1// The hosted MCP end to end through the Worker's own fetch (index.ts): 2// passkey accounts through the real ceremony (the software authenticator), 3// the whole OAuth 2.1 dance (discovery, dynamic registration, the authorize 4// page with its consent form, PKCE, the token endpoint) against the real 5// library on an in-memory KV, then MCP calls with the bearer token it gives. 6// Tabs are a stand-in TAB_HUB (test/tab-hubs.ts) whose tabs answer as the 7// page does; what matters here is which hub a token reaches. 8import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 9import { authenticator } from '../test/authenticator'; 10import { memoryBudgets } from '../test/budget'; 11import { testD1 } from '../test/d1'; 12import { memoryKV } from '../test/kv'; 13import { memoryTabHubs } from '../test/tab-hubs'; 14import worker from './index'; 15import { MODERN_VERSION, UNSUPPORTED_VERSION } from './mcp-rpc'; 16import { forgetSongs } from './votes'; 17 18const ORIGIN = 'https://jevstrudel.example'; 19const MCP = `${ORIGIN}/jev/mcp`; 20const REDIRECT = 'http://127.0.0.1:33418/callback'; 21const SONGS_URL = 'http://songs.invalid/jev/songs.json'; 22 23let db: D1Database; 24let kv: ReturnType<typeof memoryKV>; 25let hubs: ReturnType<typeof memoryTabHubs>; 26let mcpLimited: boolean; 27let authLimited: boolean; 28let waiting: Promise<unknown>[]; 29 30const rateLimit = (limited = () => false) => ({ limit: async () => ({ success: !limited() }) }); 31const env = () => 32 ({ 33 DB: db, 34 OAUTH_KV: kv, 35 TAB_HUB: hubs, 36 BUDGET: memoryBudgets(1000), 37 EVENTS: { writeDataPoint: () => {} }, 38 JEVSTRUDEL_TYPESAFE_API_KEY: 'test-key', 39 SONGS_URL, 40 AUTH_LIMIT: rateLimit(() => authLimited), 41 CONTENT_LIMIT: rateLimit(), 42 COVER_LIMIT: rateLimit(), 43 BROWSE_LIMIT: rateLimit(), 44 MCP_LIMIT: rateLimit(() => mcpLimited), 45 VOTE_LIMIT: rateLimit(), 46 }) as never; 47const ctx = () => ({ waitUntil: (p: Promise<unknown>) => void waiting.push(p), passThroughOnException() {}, props: {} }) as never; 48const call = (url: string, init: RequestInit = {}) => worker.fetch(new Request(url, init), env(), ctx()); 49 50beforeEach(() => { 51 db = testD1(); 52 kv = memoryKV(); 53 hubs = memoryTabHubs(); 54 mcpLimited = false; 55 authLimited = false; 56 waiting = []; 57 forgetSongs(); 58 vi.spyOn(console, 'log').mockImplementation(() => {}); 59 vi.spyOn(console, 'warn').mockImplementation(() => {}); 60 vi.spyOn(console, 'error').mockImplementation(() => {}); 61 vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { 62 const url = String(input instanceof Request ? input.url : input); 63 if (url === 'http://songs.invalid/jev/catalog.json') { 64 return Response.json({ songs: [{ id: 'jev/dial-up', title: 'Dial-Up', theme: 'jev', description: 'modems', art: 0.8, seconds: 120 }] }); 65 } 66 if (url === 'http://songs.invalid/jev/catalog/jev/dial-up.json') { 67 return Response.json({ id: 'jev/dial-up', title: 'Dial-Up', theme: 'jev', description: 'modems', spec: '# Dial-Up', code: 's("bd")' }); 68 } 69 if (url === SONGS_URL) return Response.json({ songs: ['jev/dial-up', 'jev/hey-listen'] }); 70 if (url === 'http://songs.invalid/jev/reference.json') { 71 return Response.json({ functions: [{ name: 'fast', synonyms: [], tags: ['temporal'], description: 'Speeds up a pattern.', params: [], examples: [] }] }); 72 } 73 if (url.startsWith('http://songs.invalid/')) return new Response('not found', { status: 404 }); 74 if (url === 'https://api.typesafe.ai/v1/systemone') { 75 const req = JSON.parse(String(init?.body)) as { questions: Record<string, unknown> }; 76 if ('verdict' in req.questions) return Response.json({ answers: { verdict: { choice: 'fine', confidence: 0.9 } } }); 77 return Response.json({ answers: Object.fromEntries(Object.keys(req.questions).map((k) => [k, { score: 3 }])) }); 78 } 79 throw new Error(`unexpected fetch ${url}`); 80 }); 81}); 82afterEach(() => { 83 vi.unstubAllGlobals(); 84 vi.restoreAllMocks(); 85}); 86 87// A browser on the site: it keeps its cookies and sends its page's Origin on writes. 88function browser() { 89 const cookies = new Map<string, string>(); 90 const keep = (res: Response) => { 91 for (const set of res.headers.getSetCookie()) { 92 const [pair] = set.split(';'); 93 const eq = pair.indexOf('='); 94 const [name, value] = [pair.slice(0, eq), pair.slice(eq + 1)]; 95 if (/Max-Age=0/.test(set) || !value) cookies.delete(name); 96 else cookies.set(name, value); 97 } 98 return res; 99 }; 100 const cookie = () => [...cookies].map(([k, v]) => `${k}=${v}`).join('; '); 101 return { 102 cookies, 103 async get(url: string, headers: Record<string, string> = {}) { 104 return keep(await call(url, { headers: { Cookie: cookie(), ...headers } })); 105 }, 106 async post(url: string, body: BodyInit, headers: Record<string, string> = {}) { 107 return keep(await call(url, { method: 'POST', body, headers: { Cookie: cookie(), Origin: ORIGIN, ...headers } })); 108 }, 109 async json(path: string, body: unknown) { 110 const res = await this.post(`${ORIGIN}${path}`, JSON.stringify(body), { 'Content-Type': 'application/json' }); 111 return res.json() as Promise<Record<string, unknown>>; 112 }, 113 }; 114} 115 116async function signUp(name: string) { 117 const b = browser(); 118 const passkey = await authenticator(); 119 const options = await b.json('/jev/auth/register/options', { displayName: name }); 120 const verified = await b.json('/jev/auth/register/verify', await passkey.create(options as never, ORIGIN)); 121 return { b, user: verified.user as { id: string; displayName: string } }; 122} 123 124const b64url = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); 125 126// An MCP client: registers itself, sends the browser to authorize, and swaps the code for tokens. 127async function connect(b: ReturnType<typeof browser>, { scope = 'play publish', approve = ['play', 'publish'] } = {}) { 128 const registered = await call(`${ORIGIN}/jev/oauth/register`, { 129 method: 'POST', 130 headers: { 'Content-Type': 'application/json' }, 131 body: JSON.stringify({ 132 client_name: 'Test AI', 133 redirect_uris: [REDIRECT], 134 token_endpoint_auth_method: 'none', 135 grant_types: ['authorization_code', 'refresh_token'], 136 response_types: ['code'], 137 }), 138 }); 139 expect(registered.status).toBe(201); 140 const { client_id } = (await registered.json()) as { client_id: string }; 141 const verifier = b64url(crypto.getRandomValues(new Uint8Array(32))); 142 const challenge = b64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)))); 143 const authorize = new URL(`${ORIGIN}/jev/oauth/authorize`); 144 for (const [k, v] of Object.entries({ 145 response_type: 'code', 146 client_id, 147 redirect_uri: REDIRECT, 148 scope, 149 state: 'xyz', 150 code_challenge: challenge, 151 code_challenge_method: 'S256', 152 resource: MCP, 153 })) { 154 authorize.searchParams.set(k, v); 155 } 156 const page = await b.get(authorize.href); 157 expect(page.status).toBe(200); 158 const html = await page.text(); 159 const handle = /name="handle" value="([^"]+)"/.exec(html)?.[1]; 160 expect(handle).toBeTruthy(); 161 const form = new URLSearchParams({ handle: handle!, decision: approve.length ? 'approve' : 'deny' }); 162 for (const s of approve) form.append('scope', s); 163 const answered = await b.post(authorize.href, form, { 'Content-Type': 'application/x-www-form-urlencoded' }); 164 expect(answered.status).toBe(302); 165 const back = new URL(answered.headers.get('Location')!); 166 if (!approve.length) return { denied: back }; 167 expect(`${back.origin}${back.pathname}`).toBe(REDIRECT); 168 expect(back.searchParams.get('state')).toBe('xyz'); 169 expect(back.searchParams.get('iss')).toBe(ORIGIN); 170 const token = await call(`${ORIGIN}/jev/oauth/token`, { 171 method: 'POST', 172 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 173 body: new URLSearchParams({ 174 grant_type: 'authorization_code', 175 code: back.searchParams.get('code')!, 176 redirect_uri: REDIRECT, 177 client_id, 178 code_verifier: verifier, 179 resource: MCP, 180 }), 181 }); 182 expect(token.status).toBe(200); 183 const tokens = (await token.json()) as { access_token: string; refresh_token: string; scope: string }; 184 return { tokens, client_id, html }; 185} 186 187let ids = 0; 188async function rpc(token: string | null, method: string, params: Record<string, unknown> = {}, headers: Record<string, string> = {}) { 189 const res = await call(MCP, { 190 method: 'POST', 191 headers: { 192 'Content-Type': 'application/json', 193 Accept: 'application/json, text/event-stream', 194 ...(token ? { Authorization: `Bearer ${token}` } : {}), 195 ...headers, 196 }, 197 body: JSON.stringify({ jsonrpc: '2.0', id: ++ids, method, params }), 198 }); 199 return { res, body: res.headers.get('Content-Type')?.includes('json') ? ((await res.json()) as Record<string, any>) : null }; 200} 201const tool = async (token: string, name: string, args: Record<string, unknown> = {}) => { 202 const { res, body } = await rpc(token, 'tools/call', { name, arguments: args }); 203 return { res, text: body?.result?.content?.[0]?.text as string, isError: body?.result?.isError as boolean }; 204}; 205 206describe('discovery', () => { 207 it('challenges a request without a token, and points at its metadata', async () => { 208 const { res } = await rpc(null, 'tools/list'); 209 expect(res.status).toBe(401); 210 const challenge = res.headers.get('WWW-Authenticate')!; 211 expect(challenge).toContain(`resource_metadata="${ORIGIN}/.well-known/oauth-protected-resource/jev/mcp"`); 212 const resource = (await (await call(`${ORIGIN}/.well-known/oauth-protected-resource/jev/mcp`)).json()) as Record<string, unknown>; 213 expect(resource).toMatchObject({ resource: MCP, authorization_servers: [ORIGIN], scopes_supported: ['play', 'publish'] }); 214 const server = (await (await call(`${ORIGIN}/.well-known/oauth-authorization-server`)).json()) as Record<string, unknown>; 215 expect(server).toMatchObject({ 216 issuer: ORIGIN, 217 authorization_endpoint: `${ORIGIN}/jev/oauth/authorize`, 218 token_endpoint: `${ORIGIN}/jev/oauth/token`, 219 registration_endpoint: `${ORIGIN}/jev/oauth/register`, 220 code_challenge_methods_supported: ['S256'], 221 }); 222 }); 223 224 it('holds registering, authorizing and token swaps to the per-visitor sign-in rate', async () => { 225 authLimited = true; 226 const res = await call(`${ORIGIN}/jev/oauth/register`, { 227 method: 'POST', 228 headers: { 'Content-Type': 'application/json' }, 229 body: JSON.stringify({ client_name: 'x', redirect_uris: [REDIRECT], token_endpoint_auth_method: 'none' }), 230 }); 231 expect(res.status).toBe(429); 232 expect(kv.puts).toEqual([]); 233 // discovery is not held 234 expect((await call(`${ORIGIN}/.well-known/oauth-authorization-server`)).status).toBe(200); 235 }); 236 237 it('never takes the session cookie for a token', async () => { 238 const { b } = await signUp('Link'); 239 const res = await call(MCP, { 240 method: 'POST', 241 headers: { Cookie: [...b.cookies].map(([k, v]) => `${k}=${v}`).join('; '), 'Content-Type': 'application/json' }, 242 body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), 243 }); 244 expect(res.status).toBe(401); 245 }); 246}); 247 248describe('the authorize page', () => { 249 it('asks a visitor who is not signed in to sign in with a passkey, and is never framed', async () => { 250 const b = browser(); 251 const url = new URL(`${ORIGIN}/jev/oauth/authorize`); 252 const registered = (await ( 253 await call(`${ORIGIN}/jev/oauth/register`, { 254 method: 'POST', 255 headers: { 'Content-Type': 'application/json' }, 256 body: JSON.stringify({ client_name: '<script>x</script>', redirect_uris: [REDIRECT], token_endpoint_auth_method: 'none' }), 257 }) 258 ).json()) as { client_id: string }; 259 for (const [k, v] of Object.entries({ 260 response_type: 'code', 261 client_id: registered.client_id, 262 redirect_uri: REDIRECT, 263 state: 's', 264 code_challenge: 'x'.repeat(43), 265 code_challenge_method: 'S256', 266 })) { 267 url.searchParams.set(k, v); 268 } 269 const res = await b.get(url.href); 270 expect(res.status).toBe(200); 271 const html = await res.text(); 272 expect(html).toContain('sign in with a passkey'); 273 expect(html).not.toContain('name="handle"'); 274 // the client's name is its own, escaped; and the redirect is to this computer 275 expect(html).not.toContain('<script>x</script>'); 276 expect(html).toContain('<script>x</script>'); 277 expect(html).toContain('localhost'); 278 expect(res.headers.get('X-Frame-Options')).toBe('DENY'); 279 // no-referrer would make Chrome send the consent form with Origin: null 280 expect(res.headers.get('Referrer-Policy')).toBe('same-origin'); 281 expect(res.headers.get('Content-Security-Policy')).toMatch(/frame-ancestors 'none'/); 282 expect(res.headers.get('Content-Security-Policy')).toMatch(/form-action 'self' http:\/\/127\.0\.0\.1:33418/); 283 }); 284 285 it('refuses a redirect the client did not register, without redirecting', async () => { 286 const { b } = await signUp('Link'); 287 const res = await b.get(`${ORIGIN}/jev/oauth/authorize?response_type=code&client_id=nobody&redirect_uri=https://evil.example/cb`); 288 expect(res.status).toBe(400); 289 expect(res.headers.get('Location')).toBeNull(); 290 }); 291 292 it('sends the client away empty-handed when the user denies', async () => { 293 const { b } = await signUp('Link'); 294 const { denied } = (await connect(b, { approve: [] })) as { denied: URL }; 295 expect(denied.searchParams.get('error')).toBe('access_denied'); 296 expect(denied.searchParams.get('code')).toBeNull(); 297 }); 298 299 it('takes the consent form only from this site', async () => { 300 const { b } = await signUp('Link'); 301 const res = await b.post(`${ORIGIN}/jev/oauth/authorize`, new URLSearchParams({ handle: 'x', decision: 'approve' }), { 302 'Content-Type': 'application/x-www-form-urlencoded', 303 Origin: 'https://evil.example', 304 }); 305 expect(res.status).toBe(403); 306 }); 307}); 308 309describe('the MCP, as the user its token names', () => { 310 it('speaks both eras: initialize for legacy clients, per-request versions and server/discover for modern ones', async () => { 311 const { b } = await signUp('Link'); 312 const { tokens } = await connect(b); 313 const init = await rpc(tokens.access_token, 'initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 't', version: '1' } }); 314 expect(init.body!.result).toMatchObject({ protocolVersion: '2025-06-18', serverInfo: { name: 'jevstrudel' }, capabilities: { tools: {} } }); 315 const meta = { 'io.modelcontextprotocol/protocolVersion': MODERN_VERSION }; 316 const discover = await rpc(tokens.access_token, 'server/discover', { _meta: meta }); 317 expect(discover.body!.result).toMatchObject({ resultType: 'complete', supportedVersions: expect.arrayContaining([MODERN_VERSION]) }); 318 const old = await rpc(tokens.access_token, 'tools/list', { _meta: { 'io.modelcontextprotocol/protocolVersion': '1900-01-01' } }); 319 expect(old.body!.error).toMatchObject({ code: UNSUPPORTED_VERSION, data: { requested: '1900-01-01' } }); 320 const list = await rpc(tokens.access_token, 'tools/list', { _meta: meta }); 321 expect(list.body!.result.tools.map((t: { name: string }) => t.name)).toEqual([ 322 'get_status', 323 'play_code', 324 'stop_play', 325 'get_logs', 326 'get_currently_playing_code', 327 'list_songs', 328 'get_song', 329 'publish_song', 330 'revise_song', 331 'comment', 332 'my_content', 333 'list_sounds', 334 'ask_jev_sound', 335 'api_reference', 336 'get_settings', 337 'set_settings', 338 'play_song', 339 'ask_jev_song', 340 'react', 341 'vote', 342 ]); 343 }); 344 345 it("drives only its own user's tabs, and plays in the tab's sandbox", async () => { 346 const a = await signUp('Link'); 347 const z = await signUp('Zelda'); 348 const linkToken = (await connect(a.b)).tokens.access_token; 349 const zeldaToken = (await connect(z.b)).tokens.access_token; 350 351 expect((await tool(linkToken, 'get_status')).text).toMatch(/no jevstrudel tab open/); 352 hubs.open(a.user.id, 'abc1', (c) => 353 c.type === 'play' 354 ? { ok: true } 355 : c.type === 'get-logs' 356 ? { logs: [{ at: 0.5, message: '[sampler] loaded bd', kind: '', count: 1 }] } 357 : c.type === 'status' 358 ? { status: { playing: true, cycle: 3.25, code: 'ai' } } 359 : { ok: true }, 360 ); 361 362 const played = await tool(linkToken, 'play_code', { code: 's("bd*4")' }); 363 expect(played).toMatchObject({ isError: false, text: 'playing in abc1, in its sandbox' }); 364 expect(hubs.asked.at(-1)).toEqual({ hub: a.user.id, session: 'abc1', command: { type: 'play', code: 's("bd*4")', app: 'Test AI' } }); 365 expect((await tool(linkToken, 'get_status')).text).toMatch(/abc1: playing, cycle 3\.3; an AI's code \(sandboxed\)/); 366 expect((await tool(linkToken, 'get_logs')).text).toBe('0.5s [sampler] loaded bd'); 367 368 // Zelda's token names Zelda's hub: Link's tab is not in it, by id or otherwise 369 expect((await tool(zeldaToken, 'get_status')).text).toMatch(/no jevstrudel tab open/); 370 const reached = await tool(zeldaToken, 'play_code', { code: 's("hh")', session_id: 'abc1' }); 371 expect(reached.isError).toBe(true); 372 expect(reached.text).toMatch(/you have no open tab abc1/); 373 expect(hubs.asked.filter((x) => x.command.type === 'play').map((x) => x.hub)).toEqual([a.user.id]); 374 expect(hubs.asked.every((x) => x.hub === a.user.id)).toBe(true); 375 }); 376 377 it('asks for the scope a tool needs, with the MCP scope challenge', async () => { 378 const { b } = await signUp('Link'); 379 const { tokens } = await connect(b, { approve: ['publish'] }); 380 expect(tokens.scope).toBe('publish'); 381 const { res } = await tool(tokens.access_token, 'play_code', { code: 's("bd")' }); 382 expect(res.status).toBe(403); 383 expect(res.headers.get('WWW-Authenticate')).toMatch(/error="insufficient_scope"/); 384 expect(res.headers.get('WWW-Authenticate')).toMatch(/scope="play"/); 385 // reading public songs needs no scope 386 expect((await tool(tokens.access_token, 'list_songs')).isError).toBe(false); 387 }); 388 389 it("puts every tab tool the dev hub shares behind play, and the reference and a vote behind none", async () => { 390 const { b, user } = await signUp('Link'); 391 const publishOnly = (await connect(b, { approve: ['publish'] })).tokens.access_token; 392 hubs.open(user.id, 'abc1', (c) => (c.type === 'get-settings' ? { settings: { theme: 'dracula', prebakeScript: 'x' }, themes: ['dracula'] } : { ok: true })); 393 for (const [name, args] of [ 394 ['list_sounds', {}], 395 ['ask_jev_sound', { description: 'a kick' }], 396 ['get_settings', {}], 397 ['set_settings', { changes: { theme: 'dracula' } }], 398 ['play_song', { id: 'jev/dial-up' }], 399 ['ask_jev_song', { mood: 'happy' }], 400 ['react', { kind: 'fire' }], 401 ] as const) { 402 const { res } = await tool(publishOnly, name, args); 403 expect(res.status, name).toBe(403); 404 expect(res.headers.get('WWW-Authenticate')).toMatch(/scope="play"/); 405 } 406 expect(hubs.asked).toEqual([]); 407 expect((await tool(publishOnly, 'api_reference', { query: 'fast' })).text).toMatch(/^1 of 1 functions match/); 408 expect((await tool(publishOnly, 'vote', { a: 'jev/dial-up', b: 'jev/hey-listen', pick: 'jev/dial-up' })).text).toMatch(/^counted: jev\/dial-up over jev\/hey-listen/); 409 410 // with play: through the hub of the token's user, and nothing the page may not show 411 const playToken = (await connect(b, { approve: ['play'] })).tokens.access_token; 412 expect(JSON.parse((await tool(playToken, 'get_settings')).text)).toEqual({ settings: { theme: 'dracula' }, themes: ['dracula'] }); 413 expect(hubs.asked).toEqual([{ hub: user.id, session: 'abc1', command: { type: 'get-settings' } }]); 414 // another user's tab is out of reach by id 415 const z = await signUp('Zelda'); 416 const zelda = (await connect(z.b)).tokens.access_token; 417 expect((await tool(zelda, 'list_sounds', { session_id: 'abc1' })).text).toMatch(/you have no open tab abc1/); 418 }); 419 420 it("reads the page's console tab with get_logs source console", async () => { 421 const { b, user } = await signUp('Link'); 422 const token = (await connect(b)).tokens.access_token; 423 hubs.open(user.id, 'abc1', (c) => 424 c.type === 'get-console' ? { logs: [{ at: 0, message: '[jev] drop (80%)', kind: 'highlight', count: 2 }] } : { logs: [] }, 425 ); 426 expect((await tool(token, 'get_logs', { source: 'console' })).text).toBe('[highlight] [jev] drop (80%) (×2)'); 427 expect((await tool(token, 'get_logs')).text).toBe('nothing logged'); 428 expect((await tool(token, 'get_logs', { source: 'page' })).isError).toBe(true); 429 expect(hubs.asked.map((x) => x.command.type)).toEqual(['get-console', 'get-logs']); 430 }); 431 432 it('comments under your name through the listener pipeline, with publish', async () => { 433 const { b } = await signUp('Link'); 434 const playOnly = (await connect(b, { approve: ['play'] })).tokens.access_token; 435 expect((await tool(playOnly, 'comment', { song: 'jev/dial-up', body: 'lovely' })).res.status).toBe(403); 436 const token = (await connect(b)).tokens.access_token; 437 const said = await tool(token, 'comment', { song: 'jev/dial-up', body: 'the modem solo!' }); 438 expect(said.isError).toBe(false); 439 expect(JSON.parse(said.text)).toMatchObject({ status: 'public' }); 440 expect((await tool(token, 'comment', { song: 'jev/nope', body: 'x' })).text).toMatch(/song must be a song on this site/); 441 expect((await tool(token, 'comment', { song: 'jev/dial-up', body: '' })).text).toMatch(/1 to 2000 characters/); 442 const mine = JSON.parse((await tool(token, 'my_content')).text); 443 expect(mine.comments).toEqual([expect.objectContaining({ song: 'jev/dial-up', body: 'the modem solo!', status: 'public' })]); 444 }); 445 446 it('publishes through the listener pipeline: Jev screens, then it is public', async () => { 447 const { b } = await signUp('Link'); 448 const { tokens } = await connect(b); 449 const published = await tool(tokens.access_token, 'publish_song', { 450 title: 'Hey Listen', 451 description: 'a fairy', 452 spec: '# a fairy who will not stop', 453 code: 's("bd sd")', 454 }); 455 expect(published.isError).toBe(false); 456 const result = JSON.parse(published.text); 457 expect(result).toMatchObject({ rev: 1, status: 'public' }); 458 expect(result.id).toMatch(/^listener:[A-Za-z0-9_-]{22}$/); 459 await Promise.all(waiting.splice(0)); 460 461 const listed = JSON.parse((await tool(tokens.access_token, 'list_songs')).text); 462 expect(listed.site).toEqual([{ id: 'jev/dial-up', title: 'Dial-Up', theme: 'jev', description: 'modems', art: 0.8, seconds: 120 }]); 463 expect(listed.listeners).toEqual([expect.objectContaining({ id: result.id, title: 'Hey Listen', author: 'Link' })]); 464 expect(JSON.parse((await tool(tokens.access_token, 'get_song', { id: result.id })).text)).toMatchObject({ code: 's("bd sd")' }); 465 expect(JSON.parse((await tool(tokens.access_token, 'get_song', { id: 'jev/dial-up' })).text)).toMatchObject({ code: 's("bd")' }); 466 467 const revised = JSON.parse((await tool(tokens.access_token, 'revise_song', { id: result.id, title: 'Hey Listen', code: 's("bd*2 sd")' })).text); 468 expect(revised).toMatchObject({ id: result.id, rev: 2, status: 'public' }); 469 const mine = JSON.parse((await tool(tokens.access_token, 'my_content')).text); 470 expect(mine.revisions.map((r: { rev: number }) => r.rev)).toEqual([2, 1]); 471 472 // the pipeline's own checks hold: someone else's song, and code that reaches the page 473 const other = await signUp('Zelda'); 474 const zelda = (await connect(other.b)).tokens.access_token; 475 const theirs = await tool(zelda, 'revise_song', { id: result.id, title: 'mine now', code: 's("hh")' }); 476 expect(theirs).toMatchObject({ isError: true, text: 'only its author can change a song' }); 477 const sneaky = await tool(zelda, 'publish_song', { title: 'x', code: 'fetch("/jev/auth/me")' }); 478 expect(sneaky.isError).toBe(true); 479 }); 480 481 it('bounds play code, requests, and the rate per user', async () => { 482 const { b } = await signUp('Link'); 483 const { tokens } = await connect(b); 484 const huge = await tool(tokens.access_token, 'play_code', { code: 'x'.repeat(65 * 1024) }); 485 expect(huge.isError).toBe(true); 486 const tooBig = await call(MCP, { 487 method: 'POST', 488 headers: { Authorization: `Bearer ${tokens.access_token}`, 'Content-Type': 'application/json' }, 489 body: 'x'.repeat(400 * 1024), 490 }); 491 expect(tooBig.status).toBe(413); 492 mcpLimited = true; 493 expect((await rpc(tokens.access_token, 'tools/list')).res.status).toBe(429); 494 }); 495 496 it('refuses a browser page of another origin, before anything', async () => { 497 const { b } = await signUp('Link'); 498 const { tokens } = await connect(b); 499 const { res } = await rpc(tokens.access_token, 'tools/list', {}, { Origin: 'https://evil.example' }); 500 expect(res.status).toBe(403); 501 }); 502}); 503 504describe('connected apps', () => { 505 it('lists the apps a user connected, and disconnecting one ends its token', async () => { 506 const { b, user } = await signUp('Link'); 507 const { tokens } = await connect(b); 508 const listed = (await (await b.get(`${ORIGIN}/jev/me/apps`)).json()) as { apps: Record<string, unknown>[] }; 509 expect(listed.apps).toEqual([ 510 expect.objectContaining({ app: 'Test AI', redirectHost: '127.0.0.1', scope: ['play', 'publish'] }), 511 ]); 512 // another user sees none of them, and cannot remove them 513 const other = await signUp('Zelda'); 514 expect(((await (await other.b.get(`${ORIGIN}/jev/me/apps`)).json()) as { apps: unknown[] }).apps).toEqual([]); 515 const id = listed.apps[0].id as string; 516 const del = (x: ReturnType<typeof browser>) => 517 call(`${ORIGIN}/jev/me/apps/${id}`, { method: 'DELETE', headers: { Origin: ORIGIN, Cookie: [...x.cookies].map(([k, v]) => `${k}=${v}`).join('; ') } }); 518 expect((await del(other.b)).status).toBe(404); 519 expect((await tool(tokens.access_token, 'list_songs')).isError).toBe(false); 520 expect((await del(b)).status).toBe(204); 521 expect((await rpc(tokens.access_token, 'tools/list')).res.status).toBe(401); 522 expect(user.displayName).toBe('Link'); 523 }); 524}); 525 526describe('a tab joining its hub', () => { 527 it("joins the signed-in user's own hub, only from this site's pages", async () => { 528 const { b, user } = await signUp('Link'); 529 const joined = await b.get(`${ORIGIN}/jev/me/tabs?session_id=abc1`, { Upgrade: 'websocket', Origin: ORIGIN }); 530 expect(await joined.json()).toEqual({ joined: user.id, session: 'abc1' }); 531 const elsewhere = await b.get(`${ORIGIN}/jev/me/tabs?session_id=abc1`, { Upgrade: 'websocket', Origin: 'https://evil.example' }); 532 expect(elsewhere.status).toBe(401); 533 const signedOut = await browser().get(`${ORIGIN}/jev/me/tabs?session_id=abc1`, { Upgrade: 'websocket', Origin: ORIGIN }); 534 expect(signedOut.status).toBe(401); 535 }); 536});