The hosted MCP end to end through the Worker's own fetch (index.ts): passkey accounts through the real ceremony (the software authenticator), the whole OAuth 2.1 dance (discovery, dynamic registration, the authorize page with its consent form, PKCE, the token endpoint) against the real library on an in-memory KV, then MCP calls with the bearer token it gives. Tabs are a stand-in TAB_HUB (test/tab-hubs.ts) whose tabs answer as the 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';
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});
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}
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(/=+$/, '');
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}
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');
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 });
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/);
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 });
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]);
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 });
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});