jevstrudel.git / worker / src / hosted-mcp.test.ts

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('&#60;script&#62;x&#60;/script&#62;');
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});