jevstrudel.git / worker / src / hosted-mcp.test.ts
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('&#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');
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});