diff --git a/src/backend/controllers/oidc/OIDCController.test.ts b/src/backend/controllers/oidc/OIDCController.test.ts index 2f2a25cfc..390e20737 100644 --- a/src/backend/controllers/oidc/OIDCController.test.ts +++ b/src/backend/controllers/oidc/OIDCController.test.ts @@ -401,6 +401,97 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => { } }); + // A share email lands on `/?shared=…` and its recipient often has to sign + // in first, so the link has to survive the round trip to the provider. + describe('share links in return_to', () => { + const SHARE_UUID = '11111111-2222-3333-4444-555555555555'; + const sharedPath = (name: string) => `/alice/${SHARE_UUID}/${name}`; + + const redirectUriFor = async (return_to: string) => { + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/auth/oidc/:provider/start', + makeReq({ + params: { provider: 'custom' }, + query: { return_to }, + }), + res, + ); + const state = new URL(captured.redirectUrl ?? '').searchParams.get( + 'state', + ); + return String(oidc().verifyState(state!)?.redirect_uri); + }; + + const returnToFor = (paths: string[], path = '/') => { + const params = new URLSearchParams(); + for (const p of paths) params.append('shared', p); + return `${path}?${params.toString()}`; + }; + + it('carries a share link back to the root', async () => { + const uri = await redirectUriFor( + returnToFor([sharedPath('Report.pdf')]), + ); + const url = new URL(uri); + expect(url.origin + url.pathname).toBe(`${TEST_ORIGIN}/`); + expect(url.searchParams.getAll('shared')).toEqual([ + sharedPath('Report.pdf'), + ]); + }); + + it('carries every item, deduplicated, on any whitelisted page', async () => { + const uri = await redirectUriFor( + returnToFor( + [ + sharedPath('a.txt'), + sharedPath('b.txt'), + sharedPath('a.txt'), + ], + '/desktop', + ), + ); + const url = new URL(uri); + expect(url.origin + url.pathname).toBe(`${TEST_ORIGIN}/desktop`); + expect(url.searchParams.getAll('shared')).toEqual([ + sharedPath('a.txt'), + sharedPath('b.txt'), + ]); + }); + + it('carries no more items than a share link may name', async () => { + const paths = Array.from({ length: 25 }, (_, i) => + sharedPath(`file-${i}.txt`), + ); + const uri = await redirectUriFor(returnToFor(paths)); + expect(new URL(uri).searchParams.getAll('shared')).toEqual( + paths.slice(0, 20), + ); + }); + + it('refuses a query it does not fully recognize', async () => { + const bad_values = [ + // not a masked share path: no uuid, no item after it, or a + // hand-edited absolute path + returnToFor(['/alice/Documents/Report.pdf']), + returnToFor([`/alice/${SHARE_UUID}`]), + returnToFor([`/alice/${SHARE_UUID}/`]), + returnToFor(['']), + // a parameter that isn't `shared`, alone or alongside one + '/?x=1', + `${returnToFor([sharedPath('a.txt')])}&x=1`, + // the root is only a destination when it names something + '/', + // still no origin smuggling, share link or not + `//evil.test${returnToFor([sharedPath('a.txt')])}`, + ]; + for (const return_to of bad_values) { + expect(await redirectUriFor(return_to)).toBe(TEST_ORIGIN); + } + }); + }); + it('signs revalidate-flow state with user_uuid + flow=revalidate', async () => { const userUuid = uuidv4(); const { res, captured } = makeRes(); @@ -693,6 +784,77 @@ describe('OIDCController login callback', () => { expect(captured.cookies).toHaveLength(0); }); + it('redirects back to a share link after sign-in', async () => { + const shared = '/alice/11111111-2222-3333-4444-555555555555/Report.pdf'; + const state = oidc().signState({ + provider: 'custom', + redirect_uri: `${TEST_ORIGIN}/?shared=${encodeURIComponent(shared)}`, + }); + const sub = `sub-${Math.random().toString(36).slice(2, 8)}`; + const email = `share-${Math.random().toString(36).slice(2, 8)}@test.local`; + vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({ + access_token: 'access', + id_token: 'id', + } as never); + vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({ + sub, + email, + email_verified: true, + } as never); + + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/auth/oidc/callback/login', + makeReq({ query: { code: 'c', state } }), + res, + ); + expect(captured.redirectStatus).toBe(302); + const url = new URL(captured.redirectUrl ?? ''); + expect(url.origin + url.pathname).toBe(`${TEST_ORIGIN}/`); + expect(url.searchParams.getAll('shared')).toEqual([shared]); + }); + + it('keeps a share link on the error page so a retry still lands on it', async () => { + const shared = '/alice/11111111-2222-3333-4444-555555555555/Report.pdf'; + const sub = `sub-${Math.random().toString(36).slice(2, 8)}`; + const email = `sus-${Math.random().toString(36).slice(2, 8)}@test.local`; + const created = await runWithContext({ req: makeReq({}) }, () => + oidc().createUserFromOIDC('custom', { + sub, + email, + email_verified: true, + }), + ); + await server.stores.user.update(created.user!.id, { suspended: 1 }); + + const state = oidc().signState({ + provider: 'custom', + redirect_uri: `${TEST_ORIGIN}/?shared=${encodeURIComponent(shared)}`, + }); + vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({ + access_token: 'access', + id_token: 'id', + } as never); + vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({ + sub, + email, + email_verified: true, + } as never); + + const { res, captured } = makeRes(); + await callRoute( + 'get', + '/auth/oidc/callback/login', + makeReq({ query: { code: 'c', state } }), + res, + ); + const url = new URL(captured.redirectUrl ?? ''); + expect(url.searchParams.get('auth_error')).toBe('1'); + expect(url.searchParams.get('action')).toBe('login'); + expect(url.searchParams.getAll('shared')).toEqual([shared]); + }); + it('redirects back to an /app/ landing after sign-in', async () => { const state = oidc().signState({ provider: 'custom', diff --git a/src/backend/controllers/oidc/OIDCController.ts b/src/backend/controllers/oidc/OIDCController.ts index cce1700fa..4162f53ef 100644 --- a/src/backend/controllers/oidc/OIDCController.ts +++ b/src/backend/controllers/oidc/OIDCController.ts @@ -23,6 +23,11 @@ import { HttpError } from '../../core/http/HttpError.js'; import type { PuterRouter } from '../../core/http/PuterRouter.js'; import { PuterController } from '../types.js'; import { sessionCookieFlags } from '../../util/cookieFlags.js'; +import { parseMaskedSharePath } from '../../services/fs/sharePathMask.js'; +import { + SHARE_DEEP_LINK_ITEMS_LIMIT, + SHARE_DEEP_LINK_PARAM, +} from '../../services/share/shareDeepLink.js'; const REVALIDATION_COOKIE_NAME = 'puter_revalidation'; const REVALIDATION_EXPIRY_SEC = 300; @@ -64,18 +69,84 @@ function resolutionErrorCode(code: string | undefined): string { : 'signup_blocked'; } -// GUI pages an OIDC flow may return to: /desktop, /dashboard, and direct app -// landings (/app/ and its desktop-booted twin /desktop/app/, -// mirroring APP_NAME_REGEX in AppDriver). Strict whitelist — never a -// client-supplied URL (no open redirect). +// GUI pages an OIDC flow may return to: the root (where a share email lands), +// /desktop, /dashboard, and direct app landings (/app/ and its +// desktop-booted twin /desktop/app/, mirroring APP_NAME_REGEX in +// AppDriver). Strict whitelist — never a client-supplied URL (no open +// redirect). function isWhitelistedReturnPath(path: string): boolean { return ( + path === '/' || path === '/desktop' || path === '/dashboard' || /^(\/desktop)?\/app\/[a-zA-Z0-9_-]{1,100}$/.test(path) ); } +/** + * The share items a return target's query names, or null when the query is + * anything else at all. + * + * A share email lands on `/?shared=…`, and its recipient usually has to sign in + * before they can see what was shared. An OIDC flow leaves the origin and comes + * back to a URL this server builds, so the parameter travels through the flow + * or the recipient returns to a bare Home with nothing to say what they were + * sent. + * + * `shared` is the only parameter that makes the trip, and only values shaped + * like the masked path the mail was built from — the value is user-visible + * text, so a hand-edited one is refused rather than reflected back into the + * browser. + */ +function sharedPathsFromReturnQuery(query: string): string[] | null { + const paths: string[] = []; + for (const [key, value] of new URLSearchParams(query)) { + if (key !== SHARE_DEEP_LINK_PARAM) return null; + const parsed = parseMaskedSharePath(value); + // The segment after the uuid is the shared item itself. A mask without + // one addresses the owner's parent directory, which is not the + // recipient's to open. + if (!parsed || !parsed.tail) return null; + // Same rule the link builder follows: the first items are the ones + // that travel, so what gets highlighted reads as the top of the list. + if ( + paths.length < SHARE_DEEP_LINK_ITEMS_LIMIT && + !paths.includes(value) + ) { + paths.push(value); + } + } + return paths; +} + +/** + * A client-supplied `return_to` reduced to what will actually be redirected to, + * or null when it isn't a page an OIDC flow returns to. + * + * The path is matched as given, never parsed as a URL: a protocol-relative + * value (`//evil.test/desktop`) has to fail the whitelist rather than smuggle + * an origin through as a `pathname`. The query is rebuilt from the values that + * survived, so nothing reaches the redirect verbatim. + */ +function sanitizeReturnTo(raw: string): string | null { + const separator = raw.indexOf('?'); + const path = separator === -1 ? raw : raw.slice(0, separator); + if (!isWhitelistedReturnPath(path)) return null; + + const shared = + separator === -1 + ? [] + : sharedPathsFromReturnQuery(raw.slice(separator + 1)); + if (shared === null) return null; + // The root is only a destination when it names something: on its own it is + // where the flow already lands. + if (shared.length === 0) return path === '/' ? null : path; + + const params = new URLSearchParams(); + for (const value of shared) params.append(SHARE_DEEP_LINK_PARAM, value); + return `${path}?${params.toString()}`; +} + function buildErrorRedirectUrl( origin: string, sourceFlow: string, @@ -100,11 +171,17 @@ function buildErrorRedirectUrl( // /app/ landing) so the retry — and the eventual success — keeps // the user's destination. redirect_uri comes from the signed state and // was built server-side, but re-check the path against the whitelist. + // A share link's items come back too: the retry happens on that page, and + // its success reloads it, so they have to be on it to survive. let pagePath = '/'; + let sharedPaths: string[] = []; if (typeof stateDecoded?.redirect_uri === 'string') { try { - const statePath = new URL(stateDecoded.redirect_uri).pathname; - if (isWhitelistedReturnPath(statePath)) pagePath = statePath; + const stateUrl = new URL(stateDecoded.redirect_uri); + if (isWhitelistedReturnPath(stateUrl.pathname)) { + pagePath = stateUrl.pathname; + sharedPaths = sharedPathsFromReturnQuery(stateUrl.search) ?? []; + } } catch { // unparsable redirect_uri: fall back to the root page } @@ -145,6 +222,9 @@ function buildErrorRedirectUrl( if (requestCode) { params.set('request_code', requestCode); } + for (const path of sharedPaths) { + params.append(SHARE_DEEP_LINK_PARAM, path); + } return `${base}${pagePath}?${params.toString()}`; } @@ -289,16 +369,17 @@ export class OIDCController extends PuterController { let appRedirectUri = flowRedirects[flow] ?? (origin || '/'); // Optional GUI return path so login started from /desktop, - // /dashboard, or an /app/ landing lands back there. + // /dashboard, an /app/ landing, or a share link lands + // back there. const rawReturnTo = Array.isArray(req.query.return_to) ? req.query.return_to[0] : req.query.return_to; - if ( - (flow === 'login' || flow === 'signup') && - typeof rawReturnTo === 'string' && - isWhitelistedReturnPath(rawReturnTo) - ) { - appRedirectUri = `${origin}${rawReturnTo}`; + const returnTo = + typeof rawReturnTo === 'string' + ? sanitizeReturnTo(rawReturnTo) + : null; + if ((flow === 'login' || flow === 'signup') && returnTo) { + appRedirectUri = `${origin}${returnTo}`; } // Popup support diff --git a/src/backend/controllers/peer/PeerController.test.ts b/src/backend/controllers/peer/PeerController.test.ts index abbdcace0..fc84f1d0e 100644 --- a/src/backend/controllers/peer/PeerController.test.ts +++ b/src/backend/controllers/peer/PeerController.test.ts @@ -5,6 +5,7 @@ import { PuterRouter } from '../../core/http/PuterRouter.js'; import { PuterServer } from '../../server.js'; import { setupTestServer } from '../../testUtil.js'; import { PEER_COSTS } from './costs.js'; +import { signGuestGrant, verifyGuestGrant } from './guestGrant.js'; import type { PeerController } from './PeerController.js'; let server: PuterServer; @@ -37,7 +38,9 @@ const makeReq = (init: { method?: string; }): Request => { return { - body: init.body ?? {}, + // Distinguish "no body key" from an explicit `body: undefined`, so a + // test can exercise a request that never had a parsed body at all. + body: 'body' in init ? init.body : {}, query: {}, headers: init.headers ?? {}, actor: init.actor, @@ -261,15 +264,32 @@ describe('PeerController', () => { }); describe('route registration', () => { - it('registers all three expected routes', () => { + it('registers every expected route', () => { const router = new PuterRouter(); controller.registerRoutes(router); const paths = router.routes.map((r) => r.path); expect(paths).toContain('/peer/signaller-info'); expect(paths).toContain('/peer/generate-turn'); + expect(paths).toContain('/peer/turn-grant'); + expect(paths).toContain('/peer/guest-turn'); expect(paths).toContain('/turn/ingest-usage'); }); + + it('keeps minting credentials behind auth and redeeming open', () => { + const router = new PuterRouter(); + controller.registerRoutes(router); + const optionsFor = (path: string) => + router.routes.find((r) => r.path === path)!.options; + + // The authenticated path must stay authenticated: everything that + // rides on `requireAuth` (suspended accounts, pending + // verification, access-token rejection) is attached to it. + expect(optionsFor('/peer/generate-turn').requireAuth).toBe(true); + expect(optionsFor('/peer/turn-grant').requireAuth).toBe(true); + // The guest path is open by design — the grant is the credential. + expect(optionsFor('/peer/guest-turn').requireAuth).toBeUndefined(); + }); }); }); @@ -540,3 +560,468 @@ describe('PeerController TURN', () => { } }); }); + +// -- Guest TURN access ------------------------------------------------- +// +// A host mints a grant; someone it invited redeems that grant for relay +// credentials without an account. The tests that matter here are about +// attribution — a guest's credentials must be stamped with the *host's* +// identifier, so the usage ingest above bills the host — and about refusing +// anything the host didn't sign. + +describe('PeerController guest TURN', () => { + let guestServer: PuterServer; + let generateTurn: Function; + let createTurnGrant: Function; + let guestTurn: Function; + let guestTurnKey: (req: Request) => string; + + const GRANT_SECRET = 'guest-grant-secret'; + const hostActor = { + user: { uuid: '11111111-2222-3333-4444-555555555555' }, + }; + const hostIdentifier = Buffer.from( + hostActor.user.uuid.replaceAll('-', ''), + 'hex', + ).toString('base64url'); + + beforeAll(async () => { + guestServer = await setupTestServer({ + peers: { + signaller_url: 'wss://signal.test', + turn: { + cloudflare_turn_service_id: 'svc-1', + cloudflare_turn_api_token: 'token-1', + ttl: 3600, + }, + guest_turn: { + grant_secret: GRANT_SECRET, + grant_ttl: 900, + credential_ttl: 600, + }, + }, + } as never); + const router = new PuterRouter(); + ( + guestServer.controllers.peer as unknown as PeerController + ).registerRoutes(router); + const route = (path: string) => + router.routes.find((r) => r.path === path)!; + generateTurn = route('/peer/generate-turn').handler; + createTurnGrant = route('/peer/turn-grant').handler; + guestTurn = route('/peer/guest-turn').handler; + guestTurnKey = (route('/peer/guest-turn').options.rateLimit as + { key: (req: Request) => string }).key; + }); + + afterAll(async () => { + await guestServer?.shutdown(); + }); + + const mintGrant = (actor: unknown = hostActor): string => { + const { res, captured } = makeRes(); + createTurnGrant(makeReq({ actor }), res); + return (captured.body as { grant: string }).grant; + }; + + const stubCloudflare = () => + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ iceServers: [{ urls: 'turn:cf.test' }] }), + } as never); + + /** The `{ ttl, customIdentifier }` body sent upstream on the last call. */ + const upstreamBody = (spy: ReturnType) => + JSON.parse( + (spy.mock.calls.at(-1)![1] as RequestInit).body as string, + ) as { ttl: number; customIdentifier: string }; + + describe('turn-grant', () => { + it('issues a grant carrying the caller as the paying account', () => { + const { res, captured } = makeRes(); + createTurnGrant(makeReq({ actor: hostActor }), res); + + const body = captured.body as { + grant: string; + expiresAt: number; + }; + expect(typeof body.grant).toBe('string'); + expect(verifyGuestGrant({ + grant: body.grant, + secret: GRANT_SECRET, + })).toEqual({ + status: 'ok', + customIdentifier: hostIdentifier, + expiresAt: body.expiresAt, + }); + }); + + it('honors the configured grant ttl', () => { + const { res, captured } = makeRes(); + createTurnGrant(makeReq({ actor: hostActor }), res); + + const { expiresAt } = captured.body as { expiresAt: number }; + const ttl = expiresAt - Math.floor(Date.now() / 1000); + expect(ttl).toBeGreaterThan(890); + expect(ttl).toBeLessThanOrEqual(900); + }); + + it('carries the app segment for an app-under-user host', () => { + const grant = mintGrant({ + ...hostActor, + app: { uid: 'app-66666666-7777-8888-9999-aaaaaaaaaaaa' }, + }); + + const verified = verifyGuestGrant({ + grant, + secret: GRANT_SECRET, + }); + expect(verified).toMatchObject({ status: 'ok' }); + expect( + (verified as { customIdentifier: string }).customIdentifier + .split(':'), + ).toHaveLength(2); + }); + + it('never signs two identical grants for the same host', () => { + expect(mintGrant()).not.toBe(mintGrant()); + }); + + it('does not reach the upstream credential API', () => { + const fetchSpy = stubCloudflare(); + try { + mintGrant(); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); + }); + + describe('guest-turn', () => { + it('mints credentials attributed to the host, not the guest', async () => { + const grant = mintGrant(); + const fetchSpy = stubCloudflare(); + try { + const { res, captured } = makeRes(); + await guestTurn(makeReq({ body: { grant } }), res); + + expect(captured.body).toEqual({ + ttl: 600, + iceServers: [{ urls: 'turn:cf.test' }], + }); + expect(upstreamBody(fetchSpy).customIdentifier).toBe( + hostIdentifier, + ); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('stamps the same identifier the host would get for itself', async () => { + const grant = mintGrant(); + const fetchSpy = stubCloudflare(); + try { + await guestTurn(makeReq({ body: { grant } }), makeRes().res); + const guestIdentifier = + upstreamBody(fetchSpy).customIdentifier; + + await generateTurn( + makeReq({ actor: hostActor }), + makeRes().res, + ); + expect(upstreamBody(fetchSpy).customIdentifier).toBe( + guestIdentifier, + ); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('ignores any session the guest happens to carry', async () => { + const grant = mintGrant(); + const fetchSpy = stubCloudflare(); + try { + // A signed-in caller redeeming someone else's grant is still + // billed to the grant's host — attribution comes from the + // ticket, never from the request. + await guestTurn( + makeReq({ + body: { grant }, + actor: { + user: { + uuid: '99999999-8888-7777-6666-555555555555', + }, + }, + }), + makeRes().res, + ); + expect(upstreamBody(fetchSpy).customIdentifier).toBe( + hostIdentifier, + ); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('clamps the guest credential ttl to the host ttl', async () => { + const shortServer = await setupTestServer({ + peers: { + turn: { + cloudflare_turn_service_id: 'svc-1', + cloudflare_turn_api_token: 'token-1', + ttl: 120, + }, + guest_turn: { + grant_secret: GRANT_SECRET, + credential_ttl: 99_999, + }, + }, + } as never); + const fetchSpy = stubCloudflare(); + try { + const router = new PuterRouter(); + ( + shortServer.controllers.peer as unknown as PeerController + ).registerRoutes(router); + const handler = router.routes.find( + (r) => r.path === '/peer/guest-turn', + )!.handler; + + const { res, captured } = makeRes(); + await handler(makeReq({ body: { grant: mintGrant() } }), res); + + expect(captured.body).toMatchObject({ ttl: 120 }); + expect(upstreamBody(fetchSpy).ttl).toBe(120); + } finally { + fetchSpy.mockRestore(); + await shortServer.shutdown(); + } + }); + + it('rejects a missing grant with 400 without calling upstream', async () => { + const fetchSpy = stubCloudflare(); + try { + await expect( + guestTurn(makeReq({ body: {} }), makeRes().res), + ).rejects.toMatchObject({ + statusCode: 400, + code: 'peer_grant_malformed', + }); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('rejects a forged grant with 403 without calling upstream', async () => { + const forged = signGuestGrant({ + customIdentifier: hostIdentifier, + ttlSeconds: 900, + secret: 'not-our-secret', + }).grant; + const fetchSpy = stubCloudflare(); + try { + await expect( + guestTurn(makeReq({ body: { grant: forged } }), makeRes().res), + ).rejects.toMatchObject({ + statusCode: 403, + code: 'peer_grant_invalid', + }); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('reports an expired grant distinctly so the app can ask for a new one', async () => { + const expired = signGuestGrant({ + customIdentifier: hostIdentifier, + ttlSeconds: 60, + secret: GRANT_SECRET, + now: Date.now() - 3_600_000, + }).grant; + const fetchSpy = stubCloudflare(); + try { + await expect( + guestTurn( + makeReq({ body: { grant: expired } }), + makeRes().res, + ), + ).rejects.toMatchObject({ + statusCode: 403, + code: 'peer_grant_expired', + }); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('maps an upstream failure to 500 without echoing its body', async () => { + const grant = mintGrant(); + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue({ + ok: false, + status: 403, + text: async () => 'cloudflare said no', + } as never); + const warnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => {}); + try { + await expect( + guestTurn(makeReq({ body: { grant } }), makeRes().res), + ).rejects.toMatchObject({ + statusCode: 500, + message: 'TURN credential generation failed', + }); + } finally { + fetchSpy.mockRestore(); + warnSpy.mockRestore(); + } + }); + }); + + describe('rate-limit bucketing', () => { + it('buckets a host and its guests together', () => { + const req = makeReq({ body: { grant: mintGrant() } }); + expect(guestTurnKey(req)).toBe(`host:${hostIdentifier}`); + // A second guest of the same host lands in the same bucket even + // though the grant string differs. + expect( + guestTurnKey(makeReq({ body: { grant: mintGrant() } })), + ).toBe(guestTurnKey(req)); + }); + + it('separates two hosts', () => { + const otherGrant = mintGrant({ + user: { uuid: '99999999-8888-7777-6666-555555555555' }, + }); + expect( + guestTurnKey(makeReq({ body: { grant: otherGrant } })), + ).not.toBe( + guestTurnKey(makeReq({ body: { grant: mintGrant() } })), + ); + }); + + it('falls back to the caller network when no grant parses', () => { + expect(guestTurnKey(makeReq({ body: {} }))).toMatch(/^net:/); + expect( + guestTurnKey(makeReq({ body: { grant: 'garbage' } })), + ).toMatch(/^net:/); + }); + }); + + describe('default lifetimes', () => { + // The defaults are the security-relevant knob — a deployment that sets + // only the secret still gets short-lived grants and credentials, and a + // guest credential still cannot outlive the host's own. + it('falls back to an hour for grants and guest credentials', async () => { + const defaultsServer = await setupTestServer({ + peers: { + turn: { + cloudflare_turn_service_id: 'svc-1', + cloudflare_turn_api_token: 'token-1', + ttl: 86_400, + }, + guest_turn: { grant_secret: GRANT_SECRET }, + }, + } as never); + const fetchSpy = stubCloudflare(); + try { + const router = new PuterRouter(); + ( + defaultsServer.controllers.peer as unknown as PeerController + ).registerRoutes(router); + const handlerFor = (path: string) => + router.routes.find((r) => r.path === path)!.handler; + + const grantRes = makeRes(); + handlerFor('/peer/turn-grant')( + makeReq({ actor: hostActor }), + grantRes.res, + ); + const { grant, expiresAt } = grantRes.captured.body as { + grant: string; + expiresAt: number; + }; + const grantTtl = expiresAt - Math.floor(Date.now() / 1000); + expect(grantTtl).toBeGreaterThan(3590); + expect(grantTtl).toBeLessThanOrEqual(3600); + + const turnRes = makeRes(); + await handlerFor('/peer/guest-turn')( + makeReq({ body: { grant } }), + turnRes.res, + ); + // An hour, not the host's 24 — the guest ceiling wins here. + expect(turnRes.captured.body).toMatchObject({ ttl: 3600 }); + expect(upstreamBody(fetchSpy).ttl).toBe(3600); + } finally { + fetchSpy.mockRestore(); + await defaultsServer.shutdown(); + } + }); + }); + + describe('when guest access is not configured', () => { + it('refuses to issue or redeem a grant', async () => { + const noGuestServer = await setupTestServer({ + peers: { + turn: { + cloudflare_turn_service_id: 'svc-1', + cloudflare_turn_api_token: 'token-1', + ttl: 3600, + }, + }, + } as never); + try { + const router = new PuterRouter(); + ( + noGuestServer.controllers.peer as unknown as PeerController + ).registerRoutes(router); + const handlerFor = (path: string) => + router.routes.find((r) => r.path === path)!.handler; + + expect(() => + handlerFor('/peer/turn-grant')( + makeReq({ actor: hostActor }), + makeRes().res, + ), + ).toThrow(expect.objectContaining({ statusCode: 503 })); + + await expect( + handlerFor('/peer/guest-turn')( + makeReq({ body: { grant: mintGrant() } }), + makeRes().res, + ), + ).rejects.toMatchObject({ statusCode: 503 }); + } finally { + await noGuestServer.shutdown(); + } + }); + + it('refuses to issue a grant it could not redeem', async () => { + const noTurnServer = await setupTestServer({ + peers: { guest_turn: { grant_secret: GRANT_SECRET } }, + } as never); + try { + const router = new PuterRouter(); + ( + noTurnServer.controllers.peer as unknown as PeerController + ).registerRoutes(router); + const handler = router.routes.find( + (r) => r.path === '/peer/turn-grant', + )!.handler; + + expect(() => + handler(makeReq({ actor: hostActor }), makeRes().res), + ).toThrow(expect.objectContaining({ statusCode: 503 })); + } finally { + await noTurnServer.shutdown(); + } + }); + }); +}); diff --git a/src/backend/controllers/peer/PeerController.ts b/src/backend/controllers/peer/PeerController.ts index 08f0dcf5f..29db6ad05 100644 --- a/src/backend/controllers/peer/PeerController.ts +++ b/src/backend/controllers/peer/PeerController.ts @@ -21,14 +21,31 @@ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; import type { Request, Response } from 'express'; import { makeActor, type Actor } from '../../core/actor.js'; import { HttpError } from '../../core/http/HttpError.js'; +import { computeNetworkFingerprint } from '../../core/http/middleware/rateLimit.js'; import type { PuterRouter } from '../../core/http/PuterRouter.js'; import { PuterController } from '../types.js'; import { PEER_COSTS } from './costs.js'; +import { + readClaimedGrantIdentifier, + signGuestGrant, + verifyGuestGrant, +} from './guestGrant.js'; import { DEFAULT_FREE_SUBSCRIPTION, DEFAULT_TEMP_SUBSCRIPTION, } from '../../services/metering/consts.js'; +/** Grant lifetime when config doesn't say. Long enough for a sitting. */ +const DEFAULT_GRANT_TTL = 3600; + +/** + * Guest credential lifetime when config doesn't say, and never longer than the + * host's own `turn.ttl`. Shorter than a host credential on purpose: a guest + * credential is handed to someone with no account behind it, so the window in + * which a leaked one can relay traffic on the host's tab stays small. + */ +const DEFAULT_GUEST_CREDENTIAL_TTL = 3600; + /** * Constant-time secret comparison for the internal-auth header. HMAC both sides * under a random per-process key to a fixed 32-byte digest first: this avoids @@ -88,11 +105,21 @@ const actorToTurnIdentifier = (actor: Actor): string => { /** * Peer controller — WebRTC signalling info + TURN credential generation. * - * Config shape: config.peers.signaller_url — WebRTC signaller URL - * config.peers.fallback_ice — fallback ICE server list - * config.peers.turn.cloudflare_turn_service_id - * config.peers.turn.cloudflare_turn_api_token config.peers.turn.ttl — - * credential TTL (default 86400) + * Two ways to get relay credentials: an authenticated caller mints its own + * (`/peer/generate-turn`), or a host mints a grant (`/peer/turn-grant`) that + * people it invited redeem without an account (`/peer/guest-turn`). Both paths + * end at the same upstream call and stamp the same `customIdentifier`, so relay + * usage is attributed to a real account either way — for a guest, the host's. + * + * Config shape, all under `config.peers`: + * + * - `signaller_url` — WebRTC signaller URL + * - `fallback_ice` — fallback ICE server list + * - `turn.cloudflare_turn_service_id`, `turn.cloudflare_turn_api_token` + * - `turn.ttl` — credential TTL (default 86400) + * - `guest_turn.grant_secret` — HMAC key for guest grants; absent disables the + * guest routes + * - `guest_turn.grant_ttl`, `guest_turn.credential_ttl` */ export class PeerController extends PuterController { override getReportedCosts(): Record[] { @@ -146,6 +173,58 @@ export class PeerController extends PuterController { }, this.#generateTurn, ); + router.post( + '/peer/turn-grant', + { + subdomain: 'api', + requireAuth: true, + // Issuing a grant costs nothing upstream — it's one HMAC — but + // each one lets a crowd of guests mint credentials against + // this account, so it carries the same per-account ceiling as + // minting credentials directly. One grant serves a whole + // session; a host at this limit is re-issuing in a loop. + rateLimit: { + scope: 'peer-turn-grant', + limit: 30, + window: 60_000, + key: 'user', + bySubscription: { + [DEFAULT_FREE_SUBSCRIPTION]: 10, + [DEFAULT_TEMP_SUBSCRIPTION]: 5, + }, + }, + }, + this.#createTurnGrant, + ); + router.post( + '/peer/guest-turn', + { + subdomain: 'api', + // Deliberately unauthenticated: the grant in the body is the + // credential, and it names the account that pays. Keyed on the + // host the grant claims rather than the caller, so one host's + // guests share one bucket and no host can be relayed for by + // more guests per minute than this — the only ceiling on guest + // spend we can apply before the bytes are already spent. + // A grant that doesn't parse can't name a bucket, so those + // requests fall back to the caller's own network. + rateLimit: { + scope: 'peer-guest-turn', + limit: 60, + window: 60_000, + key: (req: Request) => { + const claimed = readClaimedGrantIdentifier( + (req.body as { grant?: unknown } | undefined) + ?.grant, + ); + return claimed + ? `host:${claimed}` + : `net:${computeNetworkFingerprint(req)}`; + }, + }, + }, + this.#guestTurn, + ); router.post( '/turn/ingest-usage', { @@ -171,8 +250,12 @@ export class PeerController extends PuterController { }); }; - /** POST /peer/generate-turn — generate TURN credentials via Cloudflare. */ - #generateTurn = async (req: Request, res: Response): Promise => { + /** Upstream TURN settings, or 503 when this deployment has none configured. */ + #requireTurnConfig = (): { + serviceId: string; + apiToken: string; + ttl: number; + } => { const cfg = this.config.peers; if ( !cfg || @@ -185,11 +268,38 @@ export class PeerController extends PuterController { legacyCode: 'response_timeout', }); } - const serviceId = cfg.turn.cloudflare_turn_service_id; - const apiToken = cfg.turn.cloudflare_turn_api_token; - const ttl = cfg.turn.ttl; + return { + serviceId: cfg.turn.cloudflare_turn_service_id, + apiToken: cfg.turn.cloudflare_turn_api_token, + ttl: cfg.turn.ttl, + }; + }; - const customIdentifier = actorToTurnIdentifier(req.actor); + /** + * The signing key for guest grants, or 503 when this deployment hasn't set + * one. No key means no guest access — never a fallback to another secret, + * which would let a credential minted for one purpose be spent on another. + */ + #requireGuestGrantSecret = (): string => { + const secret = this.config.peers?.guest_turn?.grant_secret; + if (!secret) { + throw new HttpError(503, 'Guest TURN access is not configured', { + legacyCode: 'response_timeout', + }); + } + return secret; + }; + + /** + * Mint relay credentials upstream, attributed to `customIdentifier`. The + * one place that talks to the credential API, so every caller — host or + * guest — produces identically shaped, identically attributed usage. + */ + #mintIceServers = async ( + customIdentifier: string, + ttl: number, + ): Promise => { + const { serviceId, apiToken } = this.#requireTurnConfig(); const cfRes = await fetch( `https://rtc.live.cloudflare.com/v1/turn/keys/${serviceId}/credentials/generate-ice-servers`, @@ -216,7 +326,85 @@ export class PeerController extends PuterController { } const data = (await cfRes.json()) as { iceServers?: unknown }; - res.json({ ttl, iceServers: data.iceServers }); + return data.iceServers; + }; + + /** POST /peer/generate-turn — generate TURN credentials via Cloudflare. */ + #generateTurn = async (req: Request, res: Response): Promise => { + const { ttl } = this.#requireTurnConfig(); + const iceServers = await this.#mintIceServers( + actorToTurnIdentifier(req.actor), + ttl, + ); + res.json({ ttl, iceServers }); + }; + + /** + * POST /peer/turn-grant — issue a grant the caller can hand to guests. + * + * The grant names the caller as the account guest relay usage is billed to, + * so it is only as shareable as the caller wants their allowance to be: + * anyone holding it can mint guest credentials until it expires. + */ + #createTurnGrant = (req: Request, res: Response): void => { + const secret = this.#requireGuestGrantSecret(); + // Refuse to hand out a ticket this deployment couldn't redeem. + this.#requireTurnConfig(); + + const { grant, expiresAt } = signGuestGrant({ + customIdentifier: actorToTurnIdentifier(req.actor), + ttlSeconds: + this.config.peers?.guest_turn?.grant_ttl ?? DEFAULT_GRANT_TTL, + secret, + }); + + res.json({ grant, expiresAt }); + }; + + /** + * POST /peer/guest-turn — redeem a host's grant for relay credentials. + * + * Attribution comes from the grant alone; any session the caller happens to + * carry is ignored, so the account named in the grant is the account + * charged whether the guest is signed in or not. + */ + #guestTurn = async (req: Request, res: Response): Promise => { + const secret = this.#requireGuestGrantSecret(); + const { ttl: hostTtl } = this.#requireTurnConfig(); + + const verified = verifyGuestGrant({ + grant: (req.body as { grant?: unknown } | undefined)?.grant, + secret, + }); + if (verified.status !== 'ok') { + if (verified.status === 'malformed') { + throw new HttpError(400, 'Missing or malformed grant', { + code: 'peer_grant_malformed', + }); + } + // Expiry is readable from the grant the caller already holds, so + // saying so tells them nothing they didn't know and lets the app + // ask the host for a fresh one instead of retrying a dead ticket. + if (verified.status === 'expired') { + throw new HttpError(403, 'Guest grant has expired', { + code: 'peer_grant_expired', + }); + } + throw new HttpError(403, 'Guest grant is not valid', { + code: 'peer_grant_invalid', + }); + } + + const ttl = Math.min( + hostTtl, + this.config.peers?.guest_turn?.credential_ttl ?? + DEFAULT_GUEST_CREDENTIAL_TTL, + ); + const iceServers = await this.#mintIceServers( + verified.customIdentifier, + ttl, + ); + res.json({ ttl, iceServers }); }; /** diff --git a/src/backend/controllers/peer/guestGrant.test.ts b/src/backend/controllers/peer/guestGrant.test.ts new file mode 100644 index 000000000..4ba5c9d43 --- /dev/null +++ b/src/backend/controllers/peer/guestGrant.test.ts @@ -0,0 +1,301 @@ +import { createHmac } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { + readClaimedGrantIdentifier, + signGuestGrant, + verifyGuestGrant, +} from './guestGrant.js'; + +const SECRET = 'grant-secret'; +const USER_ID = 'AAAAAAAAAAAAAAAAAAAAAA'; +const APP_ID = 'BBBBBBBBBBBBBBBBBBBBBB'; + +/** + * Hand-build a grant so tests can put payloads through the real signature + * (things the issuer would never emit) and confirm verification still refuses + * them. Mirrors the wire format deliberately: if the format changes, these fail + * and get looked at. + */ +const forgeGrant = ( + payload: unknown, + { secret = SECRET, version = 'pg1' } = {}, +): string => { + const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const signature = createHmac('sha256', secret) + .update(`${version}.${encoded}`) + .digest('base64url'); + return `${version}.${encoded}.${signature}`; +}; + +describe('signGuestGrant / verifyGuestGrant', () => { + it('round-trips a user identifier and its expiry', () => { + const now = 1_700_000_000_000; + const { grant, expiresAt } = signGuestGrant({ + customIdentifier: USER_ID, + ttlSeconds: 3600, + secret: SECRET, + now, + }); + + expect(expiresAt).toBe(Math.floor(now / 1000) + 3600); + + const verified = verifyGuestGrant({ grant, secret: SECRET, now }); + expect(verified).toEqual({ + status: 'ok', + customIdentifier: USER_ID, + expiresAt, + }); + }); + + it('round-trips an app-under-user identifier', () => { + const identifier = `${USER_ID}:${APP_ID}`; + const { grant } = signGuestGrant({ + customIdentifier: identifier, + ttlSeconds: 60, + secret: SECRET, + }); + + const verified = verifyGuestGrant({ grant, secret: SECRET }); + expect(verified).toMatchObject({ + status: 'ok', + customIdentifier: identifier, + }); + }); + + it('issues distinct grants for the same identifier and second', () => { + const args = { + customIdentifier: USER_ID, + ttlSeconds: 60, + secret: SECRET, + now: 1_700_000_000_000, + }; + expect(signGuestGrant(args).grant).not.toBe(signGuestGrant(args).grant); + }); + + it('rejects a grant signed with a different secret', () => { + const { grant } = signGuestGrant({ + customIdentifier: USER_ID, + ttlSeconds: 60, + secret: 'other-secret', + }); + + expect(verifyGuestGrant({ grant, secret: SECRET })).toEqual({ + status: 'invalid', + }); + }); + + it('rejects a tampered payload', () => { + const { grant } = signGuestGrant({ + customIdentifier: USER_ID, + ttlSeconds: 60, + secret: SECRET, + }); + const [version, , signature] = grant.split('.'); + const swapped = Buffer.from( + JSON.stringify({ id: APP_ID, exp: 9_999_999_999, n: 'x' }), + ).toString('base64url'); + + expect( + verifyGuestGrant({ + grant: `${version}.${swapped}.${signature}`, + secret: SECRET, + }), + ).toEqual({ status: 'invalid' }); + }); + + it('rejects a tampered signature', () => { + const { grant } = signGuestGrant({ + customIdentifier: USER_ID, + ttlSeconds: 60, + secret: SECRET, + }); + const [version, payload, signature] = grant.split('.'); + const flipped = + signature![0] === 'A' + ? `B${signature!.slice(1)}` + : `A${signature!.slice(1)}`; + + expect( + verifyGuestGrant({ + grant: `${version}.${payload}.${flipped}`, + secret: SECRET, + }), + ).toEqual({ status: 'invalid' }); + }); + + it('rejects a signature of the wrong length', () => { + const { grant } = signGuestGrant({ + customIdentifier: USER_ID, + ttlSeconds: 60, + secret: SECRET, + }); + const [version, payload] = grant.split('.'); + + expect( + verifyGuestGrant({ + grant: `${version}.${payload}.AAAA`, + secret: SECRET, + }), + ).toEqual({ status: 'invalid' }); + }); + + it('reports an expired grant distinctly from an invalid one', () => { + const now = 1_700_000_000_000; + const { grant } = signGuestGrant({ + customIdentifier: USER_ID, + ttlSeconds: 60, + secret: SECRET, + now, + }); + + expect( + verifyGuestGrant({ grant, secret: SECRET, now: now + 61_000 }), + ).toEqual({ status: 'expired' }); + }); + + it('treats the expiry second itself as expired', () => { + const now = 1_700_000_000_000; + const { grant, expiresAt } = signGuestGrant({ + customIdentifier: USER_ID, + ttlSeconds: 60, + secret: SECRET, + now, + }); + + expect( + verifyGuestGrant({ + grant, + secret: SECRET, + now: expiresAt * 1000, + }), + ).toEqual({ status: 'expired' }); + expect( + verifyGuestGrant({ + grant, + secret: SECRET, + now: expiresAt * 1000 - 1, + }), + ).toMatchObject({ status: 'ok' }); + }); + + it.each([ + ['a non-string', 42], + ['undefined', undefined], + ['an empty string', ''], + ['too few segments', 'pg1.payload'], + ['too many segments', 'pg1.payload.sig.extra'], + ['an over-long string', `pg1.${'a'.repeat(600)}.sig`], + ])('rejects %s as malformed', (_label, grant) => { + expect(verifyGuestGrant({ grant, secret: SECRET })).toEqual({ + status: 'malformed', + }); + }); + + it('rejects an unknown version even when correctly signed', () => { + const grant = forgeGrant( + { id: USER_ID, exp: 9_999_999_999, n: 'x' }, + { version: 'pg2' }, + ); + + expect(verifyGuestGrant({ grant, secret: SECRET })).toEqual({ + status: 'malformed', + }); + }); + + it('rejects a correctly signed payload that is not JSON', () => { + const encoded = Buffer.from('not json').toString('base64url'); + const signature = createHmac('sha256', SECRET) + .update(`pg1.${encoded}`) + .digest('base64url'); + + expect( + verifyGuestGrant({ + grant: `pg1.${encoded}.${signature}`, + secret: SECRET, + }), + ).toEqual({ status: 'malformed' }); + }); + + it.each([ + [ + 'an identifier of the wrong shape', + { id: 'nope', exp: 9_999_999_999 }, + ], + ['a non-string identifier', { id: 42, exp: 9_999_999_999 }], + ['a missing identifier', { exp: 9_999_999_999 }], + ['a non-numeric expiry', { id: USER_ID, exp: 'soon' }], + ['a missing expiry', { id: USER_ID }], + ['an infinite expiry', { id: USER_ID, exp: Infinity }], + ])('rejects %s even when correctly signed', (_label, payload) => { + expect( + verifyGuestGrant({ grant: forgeGrant(payload), secret: SECRET }), + ).toEqual({ status: 'malformed' }); + }); + + it('rejects an identifier carrying a third segment', () => { + const grant = forgeGrant({ + id: `${USER_ID}:${APP_ID}:${APP_ID}`, + exp: 9_999_999_999, + }); + + expect(verifyGuestGrant({ grant, secret: SECRET })).toEqual({ + status: 'malformed', + }); + }); +}); + +describe('readClaimedGrantIdentifier', () => { + it('reads the identifier from a valid grant', () => { + const { grant } = signGuestGrant({ + customIdentifier: USER_ID, + ttlSeconds: 60, + secret: SECRET, + }); + + expect(readClaimedGrantIdentifier(grant)).toBe(USER_ID); + }); + + it('reads the claimed identifier without checking the signature', () => { + // Bucketing runs before verification, so this is expected: a forged + // grant still names a bucket, and the handler still rejects it. + const grant = forgeGrant( + { id: USER_ID, exp: 9_999_999_999, n: 'x' }, + { secret: 'wrong-secret' }, + ); + + expect(readClaimedGrantIdentifier(grant)).toBe(USER_ID); + }); + + it('reads an expired grant, which the handler then rejects', () => { + const { grant } = signGuestGrant({ + customIdentifier: USER_ID, + ttlSeconds: 60, + secret: SECRET, + now: 1_000_000_000_000, + }); + + expect(readClaimedGrantIdentifier(grant)).toBe(USER_ID); + expect(verifyGuestGrant({ grant, secret: SECRET })).toEqual({ + status: 'expired', + }); + }); + + it.each([ + ['a non-string', 42], + ['undefined', undefined], + ['a wrong-shaped string', 'not-a-grant'], + ['an unknown version', 'pg2.abc.def'], + ['an over-long string', `pg1.${'a'.repeat(600)}.sig`], + [ + 'a non-JSON payload', + `pg1.${Buffer.from('x').toString('base64url')}.sig`, + ], + ])('returns null for %s', (_label, grant) => { + expect(readClaimedGrantIdentifier(grant)).toBeNull(); + }); + + it('returns null for an identifier of the wrong shape', () => { + const grant = forgeGrant({ id: 'nope', exp: 9_999_999_999 }); + expect(readClaimedGrantIdentifier(grant)).toBeNull(); + }); +}); diff --git a/src/backend/controllers/peer/guestGrant.ts b/src/backend/controllers/peer/guestGrant.ts new file mode 100644 index 000000000..63bef8d00 --- /dev/null +++ b/src/backend/controllers/peer/guestGrant.ts @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; + +/** + * Guest TURN grants. + * + * A grant is a stateless, signed ticket an authenticated host hands to people + * it invites, letting them mint TURN credentials without an account of their + * own. It carries the identifier the resulting relay usage is attributed to — + * the host's — so a guest's egress is metered and billed exactly as if the host + * had relayed it, and an anonymous caller can never mint credentials that + * nobody pays for. + * + * Grants are verified by signature alone; nothing is stored. That keeps the + * check a single HMAC, at the cost of the ticket staying valid for its full + * lifetime once issued — so lifetimes are short and the issuing route is + * per-account rate limited. + */ + +/** + * Version prefix, included in the signed material so a future format can't be + * swapped in under a signature made for this one. + */ +const GRANT_VERSION = 'pg1'; + +/** + * Longest grant string we will do any work on. A real grant is ~150 bytes; the + * cap keeps a malicious body from turning verification into a hashing job. + */ +const MAX_GRANT_LENGTH = 512; + +/** + * Base64url of 16 raw uuid bytes is 22 characters. A grant identifier is one + * such segment for a user actor, or two joined by `:` for app-under-user — + * matching `customIdentifier` in the peer controller. + */ +const IDENTIFIER_RE = /^[A-Za-z0-9_-]{22}(:[A-Za-z0-9_-]{22})?$/; + +/** Decoded grant payload. Field names are short because they ride in a URL. */ +interface GrantPayload { + /** The `customIdentifier` relay usage is attributed to. */ + id: string; + /** Expiry, seconds since the epoch. */ + exp: number; + /** Random nonce, so two grants issued in the same second still differ. */ + n: string; +} + +/** Why a grant was rejected. Distinguished so clients can react usefully. */ +export type GrantRejection = 'malformed' | 'invalid' | 'expired'; + +/** + * Discriminated on a string rather than a boolean so it narrows under the + * project's non-strict build config too. + */ +export type GrantVerification = + | { status: 'ok'; customIdentifier: string; expiresAt: number } + | { status: GrantRejection }; + +const sign = (signedMaterial: string, secret: string): Buffer => + createHmac('sha256', secret).update(signedMaterial).digest(); + +/** + * Issue a grant for `customIdentifier`, valid for `ttlSeconds`. + * + * @returns The grant string and its expiry (seconds since the epoch). + */ +export const signGuestGrant = ({ + customIdentifier, + ttlSeconds, + secret, + now = Date.now(), +}: { + customIdentifier: string; + ttlSeconds: number; + secret: string; + now?: number; +}): { grant: string; expiresAt: number } => { + const expiresAt = Math.floor(now / 1000) + ttlSeconds; + const payload: GrantPayload = { + id: customIdentifier, + exp: expiresAt, + n: randomBytes(12).toString('base64url'), + }; + const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const signedMaterial = `${GRANT_VERSION}.${encoded}`; + const signature = sign(signedMaterial, secret).toString('base64url'); + return { grant: `${signedMaterial}.${signature}`, expiresAt }; +}; + +/** + * Verify a grant and return the identifier its usage should be attributed to. + * + * The signature is checked before the payload is decoded, so a forged grant + * never reaches the parser, and the identifier is re-validated against the + * shape the upstream credential API accepts even though it arrives signed. + */ +export const verifyGuestGrant = ({ + grant, + secret, + now = Date.now(), +}: { + grant: unknown; + secret: string; + now?: number; +}): GrantVerification => { + if ( + typeof grant !== 'string' || + grant.length === 0 || + grant.length > MAX_GRANT_LENGTH + ) { + return { status: 'malformed' }; + } + + const parts = grant.split('.'); + if (parts.length !== 3) return { status: 'malformed' }; + const [version, encoded, signature] = parts as [string, string, string]; + if (version !== GRANT_VERSION) return { status: 'malformed' }; + + const expected = sign(`${version}.${encoded}`, secret); + const provided = Buffer.from(signature, 'base64url'); + if ( + provided.length !== expected.length || + !timingSafeEqual(provided, expected) + ) { + return { status: 'invalid' }; + } + + let payload: GrantPayload; + try { + payload = JSON.parse( + Buffer.from(encoded, 'base64url').toString('utf8'), + ) as GrantPayload; + } catch { + return { status: 'malformed' }; + } + + if ( + !payload || + typeof payload.id !== 'string' || + !IDENTIFIER_RE.test(payload.id) || + typeof payload.exp !== 'number' || + !Number.isFinite(payload.exp) + ) { + return { status: 'malformed' }; + } + + if (payload.exp * 1000 <= now) return { status: 'expired' }; + + return { + status: 'ok', + customIdentifier: payload.id, + expiresAt: payload.exp, + }; +}; + +/** + * Read the claimed identifier out of a grant _without_ verifying it, for + * rate-limit bucketing only. + * + * Bucketing has to happen before the handler runs, and a forged grant is + * rejected there without reaching the upstream API — so an unverified read is + * enough to put a host's guests in one bucket, and the worst a forger achieves + * is choosing which bucket their own rejections land in. Never use this to + * decide attribution. + * + * @returns The claimed identifier, or null if the grant doesn't parse. + */ +export const readClaimedGrantIdentifier = (grant: unknown): string | null => { + if (typeof grant !== 'string' || grant.length > MAX_GRANT_LENGTH) { + return null; + } + const parts = grant.split('.'); + if (parts.length !== 3 || parts[0] !== GRANT_VERSION) return null; + try { + const payload = JSON.parse( + Buffer.from(parts[1]!, 'base64url').toString('utf8'), + ) as { id?: unknown }; + if ( + typeof payload?.id !== 'string' || + !IDENTIFIER_RE.test(payload.id) + ) { + return null; + } + return payload.id; + } catch { + return null; + } +}; diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts index 133abdd62..8975d3907 100644 --- a/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts +++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.test.ts @@ -252,6 +252,106 @@ describe('ChatCompletionDriver.complete auth and model resolution', () => { expect(passed.model).toBe('realfake'); expect(passed.provider).toBe('fake-chat'); }); + + // Catalogs are hand-written, so alias lists repeat themselves: an entry + // may list its own id, list one alias twice, or differ only by case. + // Routing must not depend on anyone having tidied that up. + it('routes correctly from a catalog whose aliases repeat the id and each other', async () => { + vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([ + { + id: 'messy', + // self-alias, a repeat, and a case variant of the id + aliases: ['messy', 'vendor/messy', 'vendor/messy', 'MESSY'], + puterId: 'puter-messy', + costs_currency: 'usd-cents', + costs: { 'input-tokens': 0, 'output-tokens': 0 }, + max_tokens: 8192, + }, + ] as never); + const d = await makeDriver(); + + const completeSpy = vi.spyOn(FakeChatProvider.prototype, 'complete'); + + // Every spelling reaches the same model, and the provider is always + // handed the canonical id. + for (const requested of [ + 'messy', + 'MESSY', + 'vendor/messy', + 'puter-messy', + ]) { + completeSpy.mockResolvedValueOnce({ + message: { + role: 'assistant', + content: [{ type: 'text', text: 'ok' }], + }, + usage: {}, + finish_reason: 'stop', + } as never); + + await withTestActor(() => + d.complete({ + model: requested, + messages: [{ role: 'user', content: 'hi' }], + }), + ); + + const call = completeSpy.mock.calls.at(-1)!; + const passed = call[0] as ICompleteArguments; + expect(passed.model, `requested '${requested}'`).toBe('messy'); + } + + // The repeats must not have split the model across buckets or + // registered a phantom extra route. + const listed = (await d.models()).filter((m) => m.id === 'messy'); + expect(listed).toHaveLength(1); + }); + + it('does not mutate the catalog objects a provider hands back', async () => { + // #buildModelMap used to normalize the id and append puterId in + // place. The catalogs are module-level constants shared by every + // driver instance, so that accumulated: build the map twice and the + // aliases array grew a duplicate puterId each time. + const catalog = [ + { + id: 'Shared-Case', + aliases: ['shared-alias'], + puterId: 'puter-shared', + costs_currency: 'usd-cents', + costs: { 'input-tokens': 0, 'output-tokens': 0 }, + max_tokens: 8192, + }, + ]; + const before = structuredClone(catalog); + + vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValue( + catalog as never, + ); + await makeDriver(); + await makeDriver(); + + expect(catalog).toEqual(before); + vi.mocked(FakeChatProvider.prototype.models).mockRestore(); + }); + + it('leaves aliases absent in models() for an entry that declares none', async () => { + // models() is serialized to the API, so the copy #buildModelMap + // stores must not sprout an `aliases: []` key the catalog entry + // never had. + vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([ + { + id: 'nameless', + costs_currency: 'usd-cents', + costs: { 'input-tokens': 0, 'output-tokens': 0 }, + max_tokens: 8192, + }, + ] as never); + const d = await makeDriver(); + + const listed = (await d.models()).find((m) => m.id === 'nameless')!; + expect(listed).toBeDefined(); + expect('aliases' in listed).toBe(false); + }); }); // ── Happy path: events + cost emission ────────────────────────────── diff --git a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts index 73b024dce..346aa2076 100644 --- a/src/backend/drivers/ai-chat/ChatCompletionDriver.ts +++ b/src/backend/drivers/ai-chat/ChatCompletionDriver.ts @@ -1316,20 +1316,40 @@ export class ChatCompletionDriver extends PuterDriver { for (const providerName in this.#providers) { const provider = this.#providers[providerName]; - for (const model of await provider.models()) { - model.id = normalizeModelKey(model.id); - if (model.puterId) { - model.aliases = model.aliases - ? [...model.aliases, model.puterId] - : [model.puterId]; - } + for (const entry of await provider.models()) { + // Catalogs are module-level constants shared by every driver + // instance, so they are read and never written: normalizing + // the id or appending puterId in place would accumulate across + // instantiations. The bucket gets its own copy instead. + const aliases = + entry.puterId && + !(entry.aliases ?? []).includes(entry.puterId) + ? [...(entry.aliases ?? []), entry.puterId] + : entry.aliases; + const model = { + ...entry, + id: normalizeModelKey(entry.id), + }; + // Assigned only when the entry has names to carry: models() + // is serialized to the API, and an entry that declared no + // aliases should not sprout an `aliases: []` key on the wire. + if (aliases) model.aliases = aliases; // Catalogs derive an alias by stripping the vendor org off the // id, which yields '' for ids that carry no org. Drop those — // an empty key would pool unrelated models together. - const keys = [model.id, ...(model.aliases ?? [])] - .map(normalizeModelKey) - .filter((key) => key.length > 0); + // + // Names may repeat: an entry is free to list its own id among + // its aliases, and normalizing can collapse two spellings onto + // one key. Deduplicate so a repeat can neither register a key + // twice nor make the bucket search consider it twice. + const keys = [ + ...new Set( + [model.id, ...(aliases ?? [])] + .map(normalizeModelKey) + .filter((key) => key.length > 0), + ), + ]; const bucket = keys diff --git a/src/backend/drivers/ai-chat/providers/alibaba/AlibabaProvider.ts b/src/backend/drivers/ai-chat/providers/alibaba/AlibabaProvider.ts index a825e36b1..0ab886afb 100644 --- a/src/backend/drivers/ai-chat/providers/alibaba/AlibabaProvider.ts +++ b/src/backend/drivers/ai-chat/providers/alibaba/AlibabaProvider.ts @@ -24,6 +24,7 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ import type { IChatProvider, ICompleteArguments } from '../../types.js'; import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { ALIBABA_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; type AlibabaConfig = { apiKey: string; @@ -54,15 +55,7 @@ export class AlibabaProvider implements IChatProvider { } async list() { - const models = this.models(); - const modelNames: string[] = []; - for (const model of models) { - modelNames.push(model.id); - if (model.aliases) { - modelNames.push(...model.aliases); - } - } - return modelNames; + return modelLookupNames(this.models()); } async complete({ diff --git a/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts b/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts index e25e5d212..811401869 100644 --- a/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts +++ b/src/backend/drivers/ai-chat/providers/azure/AzureChatProvider.ts @@ -34,6 +34,7 @@ import * as OpenAiUtil from '../../utils/OpenAIUtil.js'; import { buildCostsOverride } from '../../utils/pricing.js'; import { processPuterPathUploads } from '../openai/fileUpload.js'; import { AZURE_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; /** * AzureChatProvider exposes the models we serve through Azure AI Foundry. @@ -102,15 +103,7 @@ export class AzureChatProvider implements IChatProvider { } list() { - const models = this.models(); - const modelNames: string[] = []; - for (const model of models) { - modelNames.push(model.id); - if (model.aliases) { - modelNames.push(...model.aliases); - } - } - return modelNames; + return modelLookupNames(this.models()); } getDefaultModel() { diff --git a/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts b/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts index 8f2d5c060..d6d3c0f65 100644 --- a/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts +++ b/src/backend/drivers/ai-chat/providers/azure/AzureResponsesProvider.ts @@ -31,6 +31,7 @@ import { buildCostsOverride } from '../../utils/pricing.js'; import { processPuterPathUploads } from '../openai/fileUpload.js'; import { AZURE_MODELS } from './models.js'; import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; /** * AzureResponsesProvider serves the Responses-API-only models we expose through @@ -85,15 +86,7 @@ export class AzureResponsesProvider implements IChatProvider { } list() { - const models = this.models({ no_restrictions: false }); - const modelNames: string[] = []; - for (const model of models) { - modelNames.push(model.id); - if (model.aliases) { - modelNames.push(...model.aliases); - } - } - return modelNames; + return modelLookupNames(this.models({ no_restrictions: false })); } getDefaultModel() { diff --git a/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.ts b/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.ts index b28b64f4b..974fa8f4d 100644 --- a/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.ts +++ b/src/backend/drivers/ai-chat/providers/byteplus/BytePlusProvider.ts @@ -24,6 +24,7 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ import type { IChatProvider, ICompleteArguments } from '../../types.js'; import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { BYTEPLUS_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; type BytePlusConfig = { apiKey: string; @@ -76,14 +77,7 @@ export class BytePlusProvider implements IChatProvider { } list() { - const modelIds: string[] = []; - for (const model of this.models()) { - modelIds.push(model.id); - if (model.aliases) { - modelIds.push(...model.aliases); - } - } - return modelIds; + return modelLookupNames(this.models()); } async complete( diff --git a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts index 6e5ac5b51..285c86f76 100644 --- a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.test.ts @@ -253,6 +253,52 @@ describe('ClaudeProvider.complete request shape', () => { expect(args.max_tokens).toBe(0); }); + // With no explicit max_tokens the ceiling has to come from the entry being + // called. Deriving it from a second lookup by name-or-alias instead capped + // at 4096 every id the catalog doesn't also list among that entry's own + // aliases -- which is every dated id. + it.each(CLAUDE_MODELS.map((m) => ({ id: m.id, ceiling: m.max_tokens })))( + 'defaults max_tokens to the catalog ceiling for $id', + async ({ id, ceiling }) => { + const { provider } = makeProvider(); + messagesCreateMock.mockResolvedValueOnce(baseResponse); + + await withTestActor(() => + provider.complete({ + model: id, + messages: [{ role: 'user', content: 'hello' }], + }), + ); + + const [args] = messagesCreateMock.mock.calls[0]!; + expect(args.max_tokens).toBe(ceiling); + }, + ); + + // A name with no catalog entry is silently served by the default model, + // so the ceiling is that entry's own — not the 4096 floor the old second + // lookup fell back to. Unreachable through ChatCompletionDriver (which + // rejects unknown ids), but pinned here so the fallback's cost profile + // can't drift unnoticed for direct callers. + it('defaults max_tokens to the default model ceiling for an unknown name', async () => { + const { provider } = makeProvider(); + messagesCreateMock.mockResolvedValueOnce(baseResponse); + + await withTestActor(() => + provider.complete({ + model: 'claude-model-that-does-not-exist', + messages: [{ role: 'user', content: 'hello' }], + }), + ); + + const fallback = CLAUDE_MODELS.find( + (m) => m.id === provider.getDefaultModel(), + )!; + const [args] = messagesCreateMock.mock.calls[0]!; + expect(args.model).toBe(fallback.id); + expect(args.max_tokens).toBe(fallback.max_tokens); + }); + it('extracts system messages and forwards them as the top-level `system` field', async () => { const { provider } = makeProvider(); messagesCreateMock.mockResolvedValueOnce(baseResponse); diff --git a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts index bbba0873e..e1a2ada3b 100644 --- a/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts +++ b/src/backend/drivers/ai-chat/providers/claude/ClaudeProvider.ts @@ -47,6 +47,7 @@ import type { } from '../../utils/Streaming.js'; import { FILES_API_BETA, processPuterPathUploads } from './fileUpload.js'; import { CLAUDE_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; // Anthropic inline-compaction beta. The vendored SDK (0.68.0) doesn't type the // `compact_20260112` edit or the `compaction` content block, so the request @@ -86,15 +87,7 @@ export class ClaudeProvider implements IChatProvider { } async list() { - const models = this.models(); - const model_names: string[] = []; - for (const model of models) { - model_names.push(model.id); - if (model.aliases) { - model_names.push(...model.aliases); - } - } - return model_names; + return modelLookupNames(this.models()); } async complete({ @@ -318,16 +311,18 @@ export class ClaudeProvider implements IChatProvider { betas?: string[]; } = { model: modelUsed.id, + // The ceiling belongs to the entry actually being called, so it + // comes off `modelUsed` — already matched by id or alias — rather + // than a second lookup that repeats the matching and can disagree. + // The two 3.5 Sonnet ids predate the catalog and have no entry, so + // `modelUsed` is the default model for them and their ceiling has + // to be named outright. max_tokens: Math.floor( max_tokens ?? (model === 'claude-3-5-sonnet-20241022' || model === 'claude-3-5-sonnet-20240620' ? 8192 - : this.models().filter( - (e) => - (e as any).name === model || - e.aliases?.includes(model), - )[0]?.max_tokens || 4096), + : modelUsed.max_tokens || 4096), ), ...(resolvedTemperature !== undefined ? { temperature: resolvedTemperature } diff --git a/src/backend/drivers/ai-chat/providers/claude/models.ts b/src/backend/drivers/ai-chat/providers/claude/models.ts index c6e99bcdd..06a013c9c 100644 --- a/src/backend/drivers/ai-chat/providers/claude/models.ts +++ b/src/backend/drivers/ai-chat/providers/claude/models.ts @@ -32,7 +32,6 @@ export const CLAUDE_MODELS: IChatModel[] = [ 'claude-fable', 'claude-fable-latest', 'claude-fable-5-latest', - 'claude-fable-5', 'anthropic/claude-fable-5', ], name: 'Claude Fable 5', @@ -61,7 +60,6 @@ export const CLAUDE_MODELS: IChatModel[] = [ 'claude-sonnet', 'claude-sonnet-latest', 'claude-sonnet-5-latest', - 'claude-sonnet-5', 'anthropic/claude-sonnet-5', ], name: 'Claude Sonnet 5', @@ -91,7 +89,6 @@ export const CLAUDE_MODELS: IChatModel[] = [ 'claude-opus', 'claude-opus-latest', 'claude-opus-5-latest', - 'claude-opus-5', 'anthropic/claude-opus-5', ], name: 'Claude Opus 5', @@ -120,7 +117,6 @@ export const CLAUDE_MODELS: IChatModel[] = [ aliases: [ 'claude-opus-4-8-latest', 'claude-opus-4.8', - 'claude-opus-4-8', 'anthropic/claude-opus-4-8', ], name: 'Claude Opus 4.8', @@ -149,7 +145,6 @@ export const CLAUDE_MODELS: IChatModel[] = [ aliases: [ 'claude-opus-4-7-latest', 'claude-opus-4.7', - 'claude-opus-4-7', 'anthropic/claude-opus-4-7', ], name: 'Claude Opus 4.7', @@ -178,7 +173,6 @@ export const CLAUDE_MODELS: IChatModel[] = [ aliases: [ 'claude-sonnet-4-6-latest', 'claude-sonnet-4.6', - 'claude-sonnet-4-6', 'anthropic/claude-sonnet-4-6', ], name: 'Claude Sonnet 4.6', @@ -207,7 +201,6 @@ export const CLAUDE_MODELS: IChatModel[] = [ aliases: [ 'claude-opus-4-6-latest', 'claude-opus-4.6', - 'claude-opus-4-6', 'anthropic/claude-opus-4-6', ], name: 'Claude Opus 4.6', diff --git a/src/backend/drivers/ai-chat/providers/deepseek/DeepSeekProvider.ts b/src/backend/drivers/ai-chat/providers/deepseek/DeepSeekProvider.ts index 58b0320fe..a8faac7ae 100644 --- a/src/backend/drivers/ai-chat/providers/deepseek/DeepSeekProvider.ts +++ b/src/backend/drivers/ai-chat/providers/deepseek/DeepSeekProvider.ts @@ -25,6 +25,7 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ import type { IChatProvider, ICompleteArguments } from '../../types.js'; import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { DEEPSEEK_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; export class DeepSeekProvider implements IChatProvider { #openai: OpenAI; @@ -48,15 +49,7 @@ export class DeepSeekProvider implements IChatProvider { } async list() { - const models = this.models(); - const modelNames: string[] = []; - for (const model of models) { - modelNames.push(model.id); - if (model.aliases) { - modelNames.push(...model.aliases); - } - } - return modelNames; + return modelLookupNames(this.models()); } async complete({ diff --git a/src/backend/drivers/ai-chat/providers/deepseek/models.ts b/src/backend/drivers/ai-chat/providers/deepseek/models.ts index 307f72076..95f7cd2e6 100644 --- a/src/backend/drivers/ai-chat/providers/deepseek/models.ts +++ b/src/backend/drivers/ai-chat/providers/deepseek/models.ts @@ -31,11 +31,9 @@ export const DEEPSEEK_MODELS: IChatModel[] = [ release_date: '2026-04-24', name: 'DeepSeek Chat', aliases: [ - 'deepseek-v4-flash', 'deepseek/deepseek-v4-flash', 'deepseek-chat', 'deepseek/deepseek-chat', - 'deepseek/deepseek-v4-flash', 'deepseek/deepseek-reasoner', 'deepseek:deepseek/deepseek-reasoner', 'deepseek:deepseek/deepseek-chat', @@ -61,7 +59,7 @@ export const DEEPSEEK_MODELS: IChatModel[] = [ knowledge: '2026-04', release_date: '2026-04-24', name: 'DeepSeek Chat', - aliases: ['deepseek/deepseek-v4-pro', 'deepseek-v4-pro'], + aliases: ['deepseek/deepseek-v4-pro'], context: 1_000_000, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', diff --git a/src/backend/drivers/ai-chat/providers/gemini/GeminiChatProvider.test.ts b/src/backend/drivers/ai-chat/providers/gemini/GeminiChatProvider.test.ts index 7a06ddb62..2f8e8e829 100644 --- a/src/backend/drivers/ai-chat/providers/gemini/GeminiChatProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/gemini/GeminiChatProvider.test.ts @@ -171,6 +171,19 @@ describe('GeminiChatProvider model catalog', () => { expect(ids).toContain('gemini-2.5-flash'); expect(ids).toContain('google/gemini-2.5-flash'); }); + + // The assertion above is blind to duplicates: toContain passes just as + // happily on a doubled id, and a doubled id is not hypothetical here -- + // gemini-3.7-flash was once declared twice with two different cache + // prices. Catalog-wide uniqueness is enforced for every provider in + // providers/modelCatalogs.test.ts; this checks the other end, that the + // provider actually routes through the deduplicating helper rather than + // flattening the catalog itself. + it('list() emits every id exactly once', async () => { + const { provider } = makeProvider(); + const ids = await provider.list(); + expect(ids).toHaveLength(new Set(ids).size); + }); }); // ── Request shape ────────────────────────────────────────────────── diff --git a/src/backend/drivers/ai-chat/providers/gemini/GeminiChatProvider.ts b/src/backend/drivers/ai-chat/providers/gemini/GeminiChatProvider.ts index 069c66c09..c58a170dc 100644 --- a/src/backend/drivers/ai-chat/providers/gemini/GeminiChatProvider.ts +++ b/src/backend/drivers/ai-chat/providers/gemini/GeminiChatProvider.ts @@ -30,6 +30,7 @@ import { } from '../../utils/OpenAIUtil.js'; import { buildCostsOverride } from '../../utils/pricing.js'; import { GEMINI_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; export class GeminiChatProvider implements IChatProvider { meteringService: MeteringService; @@ -53,9 +54,7 @@ export class GeminiChatProvider implements IChatProvider { return GEMINI_MODELS; } async list() { - return (await this.models()) - .map((m) => [m.id, ...(m.aliases || [])]) - .flat(); + return modelLookupNames(await this.models()); } async complete({ diff --git a/src/backend/drivers/ai-chat/providers/gemini/models.ts b/src/backend/drivers/ai-chat/providers/gemini/models.ts index f1767e0be..259c6be63 100644 --- a/src/backend/drivers/ai-chat/providers/gemini/models.ts +++ b/src/backend/drivers/ai-chat/providers/gemini/models.ts @@ -112,7 +112,7 @@ export const GEMINI_MODELS: IChatModel[] = [ }, open_weights: false, tool_call: true, - knowledge: '2025-01', + knowledge: '2026-03', release_date: '2026-08-13', name: 'Gemini 3.7 Flash', aliases: ['google/gemini-3.7-flash'], @@ -126,7 +126,7 @@ export const GEMINI_MODELS: IChatModel[] = [ prompt_tokens: 75, completion_tokens: 375, thinking_tokens: 375, - cached_tokens: 8, + cached_tokens: 7.5, grounding_requests: 1_400_000, }, }, @@ -301,32 +301,4 @@ export const GEMINI_MODELS: IChatModel[] = [ }, max_tokens: 65536, }, - { - puterId: 'google:google/gemini-3.7-flash', - id: 'gemini-3.7-flash', - modalities: { - input: ['text', 'image', 'video', 'audio', 'pdf'], - output: ['text'], - }, - open_weights: false, - tool_call: true, - knowledge: '2026-03', - release_date: '2026-08-13', - name: 'Gemini 3.7 Flash', - aliases: ['google/gemini-3.7-flash'], - context: 1_048_576, - max_tokens: 65_536, - costs_currency: 'usd-cents', - input_cost_key: 'prompt_tokens', - output_cost_key: 'completion_tokens', - costs: { - tokens: 1_000_000, - prompt_tokens: 75, - completion_tokens: 375, - thinking_tokens: 375, - cached_tokens: 7.5, - // Gemini 3.x grounding is $14 / 1,000 requests - grounding_requests: 1_400_000, - }, - }, ]; diff --git a/src/backend/drivers/ai-chat/providers/groq/GroqAIProvider.ts b/src/backend/drivers/ai-chat/providers/groq/GroqAIProvider.ts index ffcb8ef5a..4710f6da6 100644 --- a/src/backend/drivers/ai-chat/providers/groq/GroqAIProvider.ts +++ b/src/backend/drivers/ai-chat/providers/groq/GroqAIProvider.ts @@ -25,6 +25,7 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ import type { IChatProvider, ICompleteArguments } from '../../types.js'; import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { GROQ_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; export class GroqAIProvider implements IChatProvider { #client: Groq; @@ -47,15 +48,7 @@ export class GroqAIProvider implements IChatProvider { } async list() { - const models = this.models(); - const modelNames: string[] = []; - for (const model of models) { - modelNames.push(model.id); - if (model.aliases) { - modelNames.push(...model.aliases); - } - } - return modelNames; + return modelLookupNames(this.models()); } async complete({ diff --git a/src/backend/drivers/ai-chat/providers/groq/models.ts b/src/backend/drivers/ai-chat/providers/groq/models.ts index 03b0449a2..2f06fc2bf 100644 --- a/src/backend/drivers/ai-chat/providers/groq/models.ts +++ b/src/backend/drivers/ai-chat/providers/groq/models.ts @@ -29,7 +29,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: true, release_date: '2024-07-23', name: 'Llama 3.1 8B Instant', - aliases: ['llama-3.1-8b-instant'], context: 131072, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -50,7 +49,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: true, release_date: '2024-12-06', name: 'Llama 3.3 70B Versatile', - aliases: ['llama-3.3-70b-versatile'], context: 131072, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -71,7 +69,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: true, release_date: '2025-08-05', name: 'GPT OSS 120B', - aliases: ['openai/gpt-oss-120b'], context: 131072, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -92,7 +89,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: true, release_date: '2025-08-05', name: 'GPT OSS 20B', - aliases: ['openai/gpt-oss-20b'], context: 131072, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -113,7 +109,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: true, release_date: '2025-10-29', name: 'GPT OSS Safeguard 20B', - aliases: ['openai/gpt-oss-safeguard-20b'], context: 131072, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -134,7 +129,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: false, release_date: '2025-09-04', name: 'Groq Compound', - aliases: ['groq/compound'], context: 131072, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -155,7 +149,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: false, release_date: '2025-09-04', name: 'Groq Compound Mini', - aliases: ['groq/compound-mini'], context: 131072, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -176,7 +169,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: true, release_date: '2026-04-22', name: 'Qwen3.6 27B', - aliases: ['qwen/qwen3.6-27b'], context: 131072, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -197,7 +189,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: false, release_date: '2025-05-29', name: 'Llama Prompt Guard 2 22M', - aliases: ['meta-llama/llama-prompt-guard-2-22m'], context: 512, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -218,7 +209,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: false, release_date: '2025-05-29', name: 'Llama Prompt Guard 2 86M', - aliases: ['meta-llama/llama-prompt-guard-2-86m'], context: 512, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -239,7 +229,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: false, release_date: '2025-01-23', name: 'ALLaM 2 7B', - aliases: ['allam-2-7b'], context: 4096, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', @@ -260,7 +249,6 @@ export const GROQ_MODELS: IChatModel[] = [ tool_call: false, release_date: '2025-04-05', name: 'Llama Guard 4 12B', - aliases: ['meta-llama/llama-guard-4-12b'], context: 131072, costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', diff --git a/src/backend/drivers/ai-chat/providers/meta/MetaProvider.ts b/src/backend/drivers/ai-chat/providers/meta/MetaProvider.ts index 10639311d..eef6c3625 100644 --- a/src/backend/drivers/ai-chat/providers/meta/MetaProvider.ts +++ b/src/backend/drivers/ai-chat/providers/meta/MetaProvider.ts @@ -30,6 +30,7 @@ import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { buildCostsOverride } from '../../utils/pricing.js'; import { processPuterPathUploads } from '../openai/fileUpload.js'; import { META_MODELS, MUSE_SPARK_DEFAULT_MODEL } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; const DEFAULT_API_BASE_URL = 'https://api.meta.ai/v1'; @@ -98,14 +99,7 @@ export class MetaProvider implements IChatProvider { } list() { - const modelIds: string[] = []; - for (const model of this.models()) { - modelIds.push(model.id); - if (model.aliases) { - modelIds.push(...model.aliases); - } - } - return modelIds; + return modelLookupNames(this.models()); } async complete( diff --git a/src/backend/drivers/ai-chat/providers/minimax/MiniMaxProvider.ts b/src/backend/drivers/ai-chat/providers/minimax/MiniMaxProvider.ts index 13ed5ae58..bdf0871c8 100644 --- a/src/backend/drivers/ai-chat/providers/minimax/MiniMaxProvider.ts +++ b/src/backend/drivers/ai-chat/providers/minimax/MiniMaxProvider.ts @@ -24,6 +24,7 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ import type { IChatProvider, ICompleteArguments } from '../../types.js'; import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { MINIMAX_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; type MiniMaxConfig = { apiKey: string; @@ -54,14 +55,7 @@ export class MiniMaxProvider implements IChatProvider { } list() { - const modelIds: string[] = []; - for (const model of this.models()) { - modelIds.push(model.id); - if (model.aliases) { - modelIds.push(...model.aliases); - } - } - return modelIds; + return modelLookupNames(this.models()); } async complete({ diff --git a/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts b/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts index a742ffa44..19e7fb471 100644 --- a/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts +++ b/src/backend/drivers/ai-chat/providers/mistral/MistralAiProvider.ts @@ -28,6 +28,7 @@ import type { } from '../../types.js'; import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { MISTRAL_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; export class MistralAIProvider implements IChatProvider { #client: Mistral; @@ -50,23 +51,15 @@ export class MistralAIProvider implements IChatProvider { } async list() { - const models = await this.models(); - const ids: string[] = []; - for (const model of models) { - ids.push(model.id); - if (model.aliases) { - ids.push(...model.aliases); - } - } - return ids; + return modelLookupNames(await this.models()); } /** - * Mistral's API expects `image_url` content parts to carry a plain - * string URL, not the OpenAI-style `{ url: string }` object. - * This method normalises any `{ type: 'image_url', image_url: { url } }` - * parts to `{ type: 'image_url', image_url: url }` before the request - * is sent. Messages whose `content` is a plain string are left untouched. + * Mistral's API expects `image_url` content parts to carry a plain string + * URL, not the OpenAI-style `{ url: string }` object. This method + * normalises any `{ type: 'image_url', image_url: { url } }` parts to `{ + * type: 'image_url', image_url: url }` before the request is sent. Messages + * whose `content` is a plain string are left untouched. */ #coerceImageUrls( messages: { role: string; content: unknown }[], diff --git a/src/backend/drivers/ai-chat/providers/mistral/models.ts b/src/backend/drivers/ai-chat/providers/mistral/models.ts index e8112d290..f1994b322 100644 --- a/src/backend/drivers/ai-chat/providers/mistral/models.ts +++ b/src/backend/drivers/ai-chat/providers/mistral/models.ts @@ -245,7 +245,6 @@ export const MISTRAL_MODELS: IChatModel[] = [ 'voxtral-small-latest', 'mistralai/voxtral-small-2507', 'mistralai/voxtral-small-latest', - 'voxtral-small-latest', ], context: 32_768, max_tokens: 32_768, diff --git a/src/backend/drivers/ai-chat/providers/modelCatalogs.test.ts b/src/backend/drivers/ai-chat/providers/modelCatalogs.test.ts new file mode 100644 index 000000000..6af8c1c46 --- /dev/null +++ b/src/backend/drivers/ai-chat/providers/modelCatalogs.test.ts @@ -0,0 +1,252 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Cross-provider invariants for the hardcoded model catalogs. + * + * Providers resolve a requested model with `models().find((m) => [m.id, + * ...m.aliases].includes(requested))` and build `list()` by flattening the same + * ids and aliases. Both go wrong quietly when one identifier is claimed by two + * entries: `.find()` returns whichever comes first, so the later entry is dead + * config that no request can ever reach, and `list()` advertises the model + * twice. Nothing throws, so the only symptom is wrong prices or wrong metadata + * being served from the entry that happened to win. + * + * This is easy to introduce and hard to spot in review — two branches adding + * the same model independently is enough, which is exactly how + * `gemini-3.7-flash` ended up in GEMINI_MODELS twice with two different cache + * prices. These tests are the cheap backstop for that class of mistake, so they + * live here once rather than being copy-pasted into every provider suite. + * + * Two entries claiming one identifier is a genuine defect and the first test + * below is the guard for it. The other two are hygiene: `modelLookupNames` + * deduplicates, so a repeated or self-referential alias can no longer change + * behaviour — it is just noise that reads as if it were load-bearing. Keeping + * the catalogs free of it is what lets the next reader trust that an alias + * exists because something needs it. + * + * Add new static catalogs to CATALOGS below — the last test in this file fails + * if one is missing, since a catalog nobody registered is a catalog none of + * this checks. (Its scan keys on filenames containing "model"; see the note + * on that test.) + */ + +import { readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import type { IChatModel } from '../types.js'; +import { ALIBABA_MODELS } from './alibaba/models.js'; +import { AZURE_MODELS } from './azure/models.js'; +import { BYTEPLUS_MODELS } from './byteplus/models.js'; +import { CLAUDE_MODELS } from './claude/models.js'; +import { DEEPSEEK_MODELS } from './deepseek/models.js'; +import { GEMINI_MODELS } from './gemini/models.js'; +import { GROQ_MODELS } from './groq/models.js'; +import { META_MODELS } from './meta/models.js'; +import { MINIMAX_MODELS } from './minimax/models.js'; +import { MISTRAL_MODELS } from './mistral/models.js'; +import { MOONSHOT_MODELS } from './moonshot/models.js'; +import { OPEN_AI_MODELS } from './openai/models.js'; +import { OPEN_ROUTER_MODEL_OVERRIDES } from './openrouter/modelOverrides.js'; +import { XAI_MODELS } from './xai/models.js'; +import { ZAI_MODELS } from './zai/models.js'; + +// Providers whose catalog is fetched at runtime (OpenRouter, Ollama, Together, +// Infron, Neuralwatt) have nothing static to check and are absent by design; +// OpenRouter's hardcoded *overrides* list is still covered. +const CATALOGS: [name: string, models: readonly IChatModel[]][] = [ + ['ALIBABA_MODELS', ALIBABA_MODELS], + ['AZURE_MODELS', AZURE_MODELS], + ['BYTEPLUS_MODELS', BYTEPLUS_MODELS], + ['CLAUDE_MODELS', CLAUDE_MODELS], + ['DEEPSEEK_MODELS', DEEPSEEK_MODELS], + ['GEMINI_MODELS', GEMINI_MODELS], + ['GROQ_MODELS', GROQ_MODELS], + ['META_MODELS', META_MODELS], + ['MINIMAX_MODELS', MINIMAX_MODELS], + ['MISTRAL_MODELS', MISTRAL_MODELS], + ['MOONSHOT_MODELS', MOONSHOT_MODELS], + ['OPEN_AI_MODELS', OPEN_AI_MODELS], + ['OPEN_ROUTER_MODEL_OVERRIDES', OPEN_ROUTER_MODEL_OVERRIDES], + ['XAI_MODELS', XAI_MODELS], + ['ZAI_MODELS', ZAI_MODELS], +]; + +// A label for the entry an identifier came from, good enough to grep for in a +// failure message even when the duplicated field *is* the id. +const describeEntry = (m: IChatModel, index: number) => + `#${index} (${m.name ?? m.id ?? 'unnamed'})`; + +describe.each(CATALOGS)('%s', (_name, models) => { + it('is not empty', () => { + // Guards the tests below from passing vacuously if an import breaks. + expect(models.length).toBeGreaterThan(0); + }); + + it('never lets two entries claim the same id, puterId, or alias', () => { + // Owner of each identifier seen so far, so a collision can name both + // sides rather than just saying "duplicate found". + const owners = new Map(); + const collisions: string[] = []; + + models.forEach((m, index) => { + const here = describeEntry(m, index); + const claimed: [field: string, value: string | undefined][] = [ + ['id', m.id], + ['puterId', m.puterId], + ...(m.aliases ?? []).map( + (a) => ['alias', a] as [string, string], + ), + ]; + + // Compare against *other* entries only. An entry repeating a + // name against itself is caught by the two tests below, which + // name the exact shape instead of saying "already claimed" — + // except for an id equal to its own puterId, which no test covers + // because it registers one key either way and so costs nothing. + const seenHere = new Set(); + for (const [field, value] of claimed) { + if (value === undefined) continue; + if (seenHere.has(value)) continue; + seenHere.add(value); + + const owner = owners.get(value); + if (owner !== undefined) { + collisions.push( + `'${value}' (${field}) is already claimed by entry ${owner}`, + ); + } else { + owners.set(value, here); + } + } + }); + + expect(collisions, collisions.join('\n')).toEqual([]); + }); + + it('never repeats an alias within a single entry', () => { + // A string listed twice in one aliases array is always a slip: it + // changes nothing about resolution and just doubles the model in + // list(). + const repeats: string[] = []; + + models.forEach((m, index) => { + const seen = new Set(); + for (const alias of m.aliases ?? []) { + if (seen.has(alias)) { + repeats.push( + `entry ${describeEntry(m, index)} lists '${alias}' more than once`, + ); + } + seen.add(alias); + } + }); + + expect(repeats, repeats.join('\n')).toEqual([]); + }); + + it('never re-declares its own id or puterId as an alias', () => { + // Resolution matches m.id before it ever looks at the aliases, and + // the driver appends puterId to an entry's lookup names on its own, + // so either self-alias buys nothing. It reads as though the bare name + // would stop working without it, which is the actual cost: every + // later reader has to re-derive that it is inert. + // + // flatMap rather than filter().map(): the index has to be the entry's + // position in the catalog, which a filtered array no longer knows. + const selfDeclared = models.flatMap((m, index) => { + const aliases = m.aliases ?? []; + const fields = [ + ...(aliases.includes(m.id) ? ['id'] : []), + ...(m.puterId && aliases.includes(m.puterId) + ? ['puterId'] + : []), + ]; + return fields.map( + (field) => + `${describeEntry(m, index)} aliases its own ${field}`, + ); + }); + + expect(selfDeclared, selfDeclared.join('\n')).toEqual([]); + }); +}); + +// -- Registration ---------------------------------------------------- + +describe('CATALOGS', () => { + // Everything above is opt-in: a provider added tomorrow gets none of it + // until someone remembers to list its catalog. That is the same kind of + // silent gap these tests are about, so the list is checked against what + // is actually on disk. + // + // The scan covers every non-test source file under providers/*/ with + // "model" in its name — models.ts, but also siblings like openrouter's + // modelOverrides.ts. Importing every provider file regardless of name + // would drag in SDK modules for a filename sweep, so that naming + // convention is the one assumption left unenforced here: a catalog in a + // file named without "model" would escape this net. + it('lists every static catalog under providers/', async () => { + const here = dirname(fileURLToPath(import.meta.url)); + const registered = new Map(CATALOGS); + const problems: string[] = []; + + for (const dir of readdirSync(here, { withFileTypes: true })) { + if (!dir.isDirectory()) continue; + + for (const file of readdirSync(join(here, dir.name))) { + if (!/model/i.test(file)) continue; + if (!file.endsWith('.ts') || file.endsWith('.test.ts')) { + continue; + } + + const module = await import( + pathToFileURL(join(here, dir.name, file)).href + ); + for (const [name, value] of Object.entries(module)) { + // A catalog is a non-empty array of entries carrying an + // id; these files also export default-model ids and + // helpers. + const isCatalog = + Array.isArray(value) && + value.length > 0 && + typeof value[0]?.id === 'string'; + if (!isCatalog) continue; + + if (!registered.has(name)) { + problems.push(`${dir.name}/${file} exports ${name}`); + } else if (registered.get(name) !== value) { + // The row's label names this export but its value is + // a different array — the wrong catalog would be the + // one getting checked. + problems.push( + `the CATALOGS row named ${name} does not hold ` + + `the ${name} that ${dir.name}/${file} exports`, + ); + } + } + } + } + + expect(problems, problems.join('\n')).toEqual([]); + }); +}); diff --git a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts index df886c1c0..111191db1 100644 --- a/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts +++ b/src/backend/drivers/ai-chat/providers/moonshot/MoonshotProvider.ts @@ -29,6 +29,7 @@ import type { import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { inlineHttpImageUrls } from './imageHandling.js'; import { MOONSHOT_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; export class MoonshotProvider implements IChatProvider { #openai: OpenAI; @@ -52,15 +53,7 @@ export class MoonshotProvider implements IChatProvider { } async list() { - const models = this.models(); - const modelNames: string[] = []; - for (const model of models) { - modelNames.push(model.id); - if (model.aliases) { - modelNames.push(...model.aliases); - } - } - return modelNames; + return modelLookupNames(this.models()); } async complete({ diff --git a/src/backend/drivers/ai-chat/providers/neuralwatt/NeuralwattProvider.ts b/src/backend/drivers/ai-chat/providers/neuralwatt/NeuralwattProvider.ts index 589f500be..c06c73319 100644 --- a/src/backend/drivers/ai-chat/providers/neuralwatt/NeuralwattProvider.ts +++ b/src/backend/drivers/ai-chat/providers/neuralwatt/NeuralwattProvider.ts @@ -32,6 +32,7 @@ import type { ICompleteArguments, } from '../../types.js'; import { inlineHttpImageUrls } from '../moonshot/imageHandling.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; import { mapNeuralwattApiModel, messagesHaveImageContent, @@ -84,13 +85,7 @@ export class NeuralwattProvider implements IChatProvider { } async list() { - const models = await this.models(); - const modelNames: string[] = []; - for (const model of models) { - modelNames.push(model.id); - if (model.aliases) modelNames.push(...model.aliases); - } - return modelNames; + return modelLookupNames(await this.models()); } async models(): Promise { diff --git a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts index 8f97012f4..315042e80 100644 --- a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts +++ b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatCompletionsProvider.ts @@ -35,6 +35,7 @@ import { buildCostsOverride } from '../../utils/pricing.js'; import { processPuterPathUploads } from './fileUpload.js'; import { OPEN_AI_MODELS } from './models.js'; import type { OpenAiResponsesChatProvider } from './OpenAiChatResponsesProvider.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; /** * OpenAICompletionService class provides an interface to OpenAI's chat @@ -87,15 +88,7 @@ export class OpenAiChatProvider implements IChatProvider { } list() { - const models = this.models(); - const modelNames: string[] = []; - for (const model of models) { - modelNames.push(model.id); - if (model.aliases) { - modelNames.push(...model.aliases); - } - } - return modelNames; + return modelLookupNames(this.models()); } getDefaultModel() { diff --git a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts index a353b36f7..7df7910bf 100644 --- a/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts +++ b/src/backend/drivers/ai-chat/providers/openai/OpenAiChatResponsesProvider.ts @@ -31,6 +31,7 @@ import { buildCostsOverride } from '../../utils/pricing.js'; import { processPuterPathUploads } from './fileUpload.js'; import { OPEN_AI_MODELS } from './models.js'; import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; /** * OpenAICompletionService class provides an interface to OpenAI's chat @@ -77,15 +78,7 @@ export class OpenAiResponsesChatProvider implements IChatProvider { } list() { - const models = this.models({ no_restrictions: false }); - const modelNames: string[] = []; - for (const model of models) { - modelNames.push(model.id); - if (model.aliases) { - modelNames.push(...model.aliases); - } - } - return modelNames; + return modelLookupNames(this.models({ no_restrictions: false })); } getDefaultModel() { diff --git a/src/backend/drivers/ai-chat/providers/openai/models.ts b/src/backend/drivers/ai-chat/providers/openai/models.ts index d44658763..026b5d6be 100644 --- a/src/backend/drivers/ai-chat/providers/openai/models.ts +++ b/src/backend/drivers/ai-chat/providers/openai/models.ts @@ -30,12 +30,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ open_weights: false, tool_call: true, knowledge: '2026-02-16', - aliases: [ - 'gpt-5.6', - 'gpt-5.6-sol', - 'openai/gpt-5.6', - 'openai/gpt-5.6-sol', - ], + aliases: ['gpt-5.6', 'openai/gpt-5.6', 'openai/gpt-5.6-sol'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -56,7 +51,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ open_weights: false, tool_call: true, knowledge: '2026-02-16', - aliases: ['gpt-5.6-terra', 'openai/gpt-5.6-terra'], + aliases: ['openai/gpt-5.6-terra'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -77,7 +72,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ open_weights: false, tool_call: true, knowledge: '2026-02-16', - aliases: ['gpt-5.6-luna', 'openai/gpt-5.6-luna'], + aliases: ['openai/gpt-5.6-luna'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -163,7 +158,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ tool_call: true, knowledge: '2025-08-31', release_date: '2026-03-05', - aliases: ['gpt-5.4-pro', 'openai/gpt-5.4-pro'], + aliases: ['openai/gpt-5.4-pro'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -183,7 +178,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ open_weights: false, tool_call: true, knowledge: '2025-08-31', - aliases: ['gpt-5.4-mini', 'openai/gpt-5.4-mini'], + aliases: ['openai/gpt-5.4-mini'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -205,7 +200,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ tool_call: true, knowledge: '2025-08-31', release_date: '2026-03-19', - aliases: ['gpt-5.4-nano', 'openai/gpt-5.4-nano'], + aliases: ['openai/gpt-5.4-nano'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', @@ -226,7 +221,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [ tool_call: true, knowledge: '2025-10', release_date: '2025-10-06', - aliases: ['gpt-5-pro', 'openai/gpt-5-pro'], + aliases: ['openai/gpt-5-pro'], costs_currency: 'usd-cents', input_cost_key: 'prompt_tokens', output_cost_key: 'completion_tokens', diff --git a/src/backend/drivers/ai-chat/providers/together/TogetherAIProvider.ts b/src/backend/drivers/ai-chat/providers/together/TogetherAIProvider.ts index 925029a3d..8558a08ab 100644 --- a/src/backend/drivers/ai-chat/providers/together/TogetherAIProvider.ts +++ b/src/backend/drivers/ai-chat/providers/together/TogetherAIProvider.ts @@ -23,6 +23,7 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ import { kv } from '../../../../util/kvSingleton.js'; import { IChatModel, IChatProvider, ICompleteArguments } from '../../types.js'; import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; const TOGETHER_AI_CHAT_COST_MAP = { prompt_tokens: 'input', @@ -131,15 +132,7 @@ export class TogetherAIProvider implements IChatProvider { } async list() { - const models = await this.models(); - const modelIds: string[] = []; - for (const model of models) { - modelIds.push(model.id); - if (model.aliases) { - modelIds.push(...model.aliases); - } - } - return modelIds; + return modelLookupNames(await this.models()); } async complete({ diff --git a/src/backend/drivers/ai-chat/providers/xai/XAIProvider.ts b/src/backend/drivers/ai-chat/providers/xai/XAIProvider.ts index 6c0815349..7a634fd48 100644 --- a/src/backend/drivers/ai-chat/providers/xai/XAIProvider.ts +++ b/src/backend/drivers/ai-chat/providers/xai/XAIProvider.ts @@ -28,6 +28,7 @@ import type { IChatCompleteResult, } from '../../types.js'; import { XAI_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; export class XAIProvider implements IChatProvider { #openai: OpenAI; @@ -51,15 +52,7 @@ export class XAIProvider implements IChatProvider { } async list() { - const models = this.models(); - const modelNames: string[] = []; - for (const model of models) { - modelNames.push(model.id); - if (model.aliases) { - modelNames.push(...model.aliases); - } - } - return modelNames; + return modelLookupNames(this.models()); } async complete({ diff --git a/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.ts b/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.ts index 714c38bbf..9d983d792 100644 --- a/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.ts +++ b/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.ts @@ -24,6 +24,7 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ import type { IChatProvider, ICompleteArguments } from '../../types.js'; import * as OpenAIUtil from '../../utils/OpenAIUtil.js'; import { ZAI_MODELS } from './models.js'; +import { modelLookupNames } from '../../utils/modelRouting.js'; type ZAIConfig = { apiBaseUrl?: string; @@ -72,14 +73,7 @@ export class ZAIProvider implements IChatProvider { } list() { - const modelIds: string[] = []; - for (const model of this.models()) { - modelIds.push(model.id); - if (model.aliases) { - modelIds.push(...model.aliases); - } - } - return modelIds; + return modelLookupNames(this.models()); } async complete( diff --git a/src/backend/drivers/ai-chat/utils/modelRouting.test.ts b/src/backend/drivers/ai-chat/utils/modelRouting.test.ts index 018bcc000..c6a0e6b02 100644 --- a/src/backend/drivers/ai-chat/utils/modelRouting.test.ts +++ b/src/backend/drivers/ai-chat/utils/modelRouting.test.ts @@ -24,12 +24,12 @@ import type { IChatModel } from '../types.js'; import { compareModelPreference, isIdentityKey, + modelLookupNames, normalizeModelKey, } from './modelRouting.js'; -// `#buildModelMap` mutates the catalogs providers hand back, and // `GeminiChatProvider.models()` returns the module-level `GEMINI_MODELS` by -// reference — clone so these fixtures can't be perturbed by another suite. +// reference — clone so these fixtures stay independent of it. const geminiModel = (id: string, provider = 'gemini'): IChatModel => { const found = GEMINI_MODELS.find((m) => m.id === id); if (!found) throw new Error(`no such gemini model: ${id}`); @@ -186,3 +186,45 @@ describe('isIdentityKey', () => { expect(isIdentityKey('')).toBe(false); }); }); + +describe('modelLookupNames', () => { + const m = (id: string, aliases?: string[]) => + ({ id, ...(aliases ? { aliases } : {}) }) as IChatModel; + + it('returns the id even when the entry declares no aliases', () => { + expect(modelLookupNames([m('solo')])).toEqual(['solo']); + }); + + it('keeps declaration order, id first', () => { + expect(modelLookupNames([m('a', ['vendor/a', 'a-latest'])])).toEqual([ + 'a', + 'vendor/a', + 'a-latest', + ]); + }); + + // The three shapes this helper exists to absorb, so no caller has to. + it('collapses an alias that merely repeats the entry id', () => { + expect(modelLookupNames([m('a', ['a', 'vendor/a'])])).toEqual([ + 'a', + 'vendor/a', + ]); + }); + + it('collapses an alias repeated within one entry', () => { + expect(modelLookupNames([m('a', ['x', 'x'])])).toEqual(['a', 'x']); + }); + + it('collapses a name two entries both claim', () => { + expect( + modelLookupNames([m('a', ['shared']), m('b', ['shared'])]), + ).toEqual(['a', 'shared', 'b']); + }); + + it('is unchanged by stripping self-aliases from a catalog', () => { + // The property that makes removing them from the catalogs a no-op. + const withSelf = [m('a', ['a', 'vendor/a']), m('b', ['b'])]; + const without = [m('a', ['vendor/a']), m('b')]; + expect(modelLookupNames(withSelf)).toEqual(modelLookupNames(without)); + }); +}); diff --git a/src/backend/drivers/ai-chat/utils/modelRouting.ts b/src/backend/drivers/ai-chat/utils/modelRouting.ts index a8bb849f9..990e184cb 100644 --- a/src/backend/drivers/ai-chat/utils/modelRouting.ts +++ b/src/backend/drivers/ai-chat/utils/modelRouting.ts @@ -48,6 +48,22 @@ const providerRank = (provider?: string): number => { export const normalizeModelKey = (key: string): string => key.trim().toLowerCase(); +/** + * Every name a model answers to, in declaration order and without repeats. + * + * A catalog entry's `id` is already one of its names, so an `aliases` array + * that also lists the id is redundant rather than wrong -- and catalogs do + * that, because alias lists get written as "every spelling a caller might type" + * and the id is one of those spellings. Deduplicating here means the flattened + * list stays honest no matter how the catalog is written, instead of every + * caller having to reason about it. + */ +export const modelLookupNames = ( + models: readonly Pick[], +): string[] => [ + ...new Set(models.flatMap((m) => [m.id, ...(m.aliases ?? [])])), +]; + /** * Whether a key asserts _which model this is_, rather than merely being another * way to name it. diff --git a/src/backend/drivers/ai-image/ImageGenerationDriver.test.ts b/src/backend/drivers/ai-image/ImageGenerationDriver.test.ts index bb03971e6..daa669a1f 100644 --- a/src/backend/drivers/ai-image/ImageGenerationDriver.test.ts +++ b/src/backend/drivers/ai-image/ImageGenerationDriver.test.ts @@ -44,7 +44,11 @@ import { runWithContext } from '../../core/context.js'; import { SYSTEM_ACTOR } from '../../core/actor.js'; import { PuterServer } from '../../server.js'; import { setupTestServer } from '../../testUtil.js'; +import { CLOUDFLARE_IMAGE_GENERATION_MODELS } from './providers/cloudflare/models.js'; +import { GEMINI_IMAGE_GENERATION_MODELS } from './providers/gemini/models.js'; import { OPEN_AI_IMAGE_GENERATION_MODELS } from './providers/openai/models.js'; +import { REPLICATE_IMAGE_GENERATION_MODELS } from './providers/replicate/models.js'; +import { TOGETHER_IMAGE_GENERATION_MODELS } from './providers/together/models.js'; import { XAI_IMAGE_GENERATION_MODELS } from './providers/xai/models.js'; import type { ImageGenerationDriver } from './ImageGenerationDriver.js'; @@ -220,7 +224,32 @@ describe('ImageGenerationDriver.generate argument validation', () => { // ── Catalog & list ────────────────────────────────────────────────── +// Providers hand these catalogs to the driver by module-level reference, so +// #buildModelMap must never write through to them: an in-place id +// normalization or puterId append would accumulate across map builds. Cloned +// at import time, before beforeAll boots the server that builds the map. +// (Same regression as in ChatCompletionDriver.test.ts.) +const pristineCatalogs = structuredClone({ + CLOUDFLARE_IMAGE_GENERATION_MODELS, + GEMINI_IMAGE_GENERATION_MODELS, + OPEN_AI_IMAGE_GENERATION_MODELS, + REPLICATE_IMAGE_GENERATION_MODELS, + TOGETHER_IMAGE_GENERATION_MODELS, + XAI_IMAGE_GENERATION_MODELS, +}); + describe('ImageGenerationDriver model catalog', () => { + it('does not mutate the catalog objects providers hand back', () => { + expect({ + CLOUDFLARE_IMAGE_GENERATION_MODELS, + GEMINI_IMAGE_GENERATION_MODELS, + OPEN_AI_IMAGE_GENERATION_MODELS, + REPLICATE_IMAGE_GENERATION_MODELS, + TOGETHER_IMAGE_GENERATION_MODELS, + XAI_IMAGE_GENERATION_MODELS, + }).toEqual(pristineCatalogs); + }); + it('models() returns a deduped list across providers, sorted by provider then id', async () => { const all = await driver.models(); // Every catalog id from at least one provider must be reachable. @@ -642,7 +671,3 @@ describe('ImageGenerationDriver.generate puter_output_path', () => { expect(openaiImagesGenerateMock).not.toHaveBeenCalled(); }); }); - -// Avoid coupling the 'unused' XAI export to lint. The catalog reference -// is also used implicitly by the routing tests above. -void XAI_IMAGE_GENERATION_MODELS; diff --git a/src/backend/drivers/ai-image/ImageGenerationDriver.ts b/src/backend/drivers/ai-image/ImageGenerationDriver.ts index 6628b8ba3..8ab5c1e0f 100644 --- a/src/backend/drivers/ai-image/ImageGenerationDriver.ts +++ b/src/backend/drivers/ai-image/ImageGenerationDriver.ts @@ -361,7 +361,12 @@ export class ImageGenerationDriver extends PuterDriver { async #buildModelMap() { for (const providerName in this.#providers) { const provider = this.#providers[providerName]; - for (const model of await provider.models()) { + for (const entry of await provider.models()) { + // Catalogs are module-level constants that providers hand + // back by reference, so they are read and never written: + // normalizing the id or appending puterId in place would + // accumulate across map builds. Work on a copy instead. + const model = { ...entry }; model.id = model.id.trim().toLowerCase(); if (!this.#modelIdMap[model.id]) { this.#modelIdMap[model.id] = []; diff --git a/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts b/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts index 3c723a08e..c71e1f6e9 100644 --- a/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts +++ b/src/backend/drivers/ai-video/VideoGenerationDriver.test.ts @@ -45,6 +45,9 @@ import { SYSTEM_ACTOR } from '../../core/actor.js'; import { PuterServer } from '../../server.js'; import type { MeteringService } from '../../services/metering/MeteringService.js'; import { setupTestServer } from '../../testUtil.js'; +import { GEMINI_VIDEO_GENERATION_MODELS } from './providers/gemini/models.js'; +import { OPENAI_VIDEO_MODELS } from './providers/openai/models.js'; +import { TOGETHER_VIDEO_GENERATION_MODELS } from './providers/together/models.js'; import type { VideoGenerationDriver } from './VideoGenerationDriver.js'; // ── SDK mocks ────────────────────────────────────────────────────── @@ -214,7 +217,27 @@ describe('VideoGenerationDriver.generate argument validation', () => { // ── Catalog & list ────────────────────────────────────────────────── +// Providers hand these catalogs to the driver by module-level reference +// (OpenAI's directly; Gemini's and Together's via per-call copies), so +// #buildModelMap must never write through to them: an in-place id +// normalization or puterId append would accumulate across map builds. Cloned +// at import time, before beforeAll boots the server that builds the map. +// (Same regression as in ChatCompletionDriver.test.ts.) +const pristineCatalogs = structuredClone({ + GEMINI_VIDEO_GENERATION_MODELS, + OPENAI_VIDEO_MODELS, + TOGETHER_VIDEO_GENERATION_MODELS, +}); + describe('VideoGenerationDriver catalog', () => { + it('does not mutate the catalog objects providers hand back', () => { + expect({ + GEMINI_VIDEO_GENERATION_MODELS, + OPENAI_VIDEO_MODELS, + TOGETHER_VIDEO_GENERATION_MODELS, + }).toEqual(pristineCatalogs); + }); + it('models() returns deduped entries sorted by provider then id', async () => { const all = await driver.models(); const ids = all.map((m) => m.id); diff --git a/src/backend/drivers/ai-video/VideoGenerationDriver.ts b/src/backend/drivers/ai-video/VideoGenerationDriver.ts index 58906717f..e48c2a203 100644 --- a/src/backend/drivers/ai-video/VideoGenerationDriver.ts +++ b/src/backend/drivers/ai-video/VideoGenerationDriver.ts @@ -328,7 +328,13 @@ export class VideoGenerationDriver extends PuterDriver { async #buildModelMap() { for (const providerName in this.#providers) { const provider = this.#providers[providerName]; - for (const model of await provider.models()) { + for (const entry of await provider.models()) { + // Catalogs are module-level constants that providers hand + // back by reference, so they are read and never written: + // normalizing fields or appending puterId in place would + // accumulate across map builds. Work on a copy instead — + // every alias write below lands on an array created here. + const model = { ...entry }; model.id = model.id.trim().toLowerCase(); if (model.puterId) { model.puterId = model.puterId.trim().toLowerCase(); diff --git a/src/backend/types.ts b/src/backend/types.ts index 22e10d790..41ccede91 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -343,6 +343,22 @@ export interface IPeersConfig { /** Credential TTL in seconds. Default 86400. */ ttl?: number; }; + /** + * Relay access for guests of an authenticated host, who mint credentials + * against a signed grant instead of an account of their own. + */ + guest_turn?: { + /** + * HMAC key for guest grants. Absent disables the guest routes with a + * 503 — a deployment opts into guest relay access by setting this. Must + * not be shared with any other secret. + */ + grant_secret?: string; + /** Grant lifetime in seconds. Default 3600. */ + grant_ttl?: number; + /** Guest credential TTL in seconds, clamped to `turn.ttl`. Default 3600. */ + credential_ttl?: number; + }; /** Shared secret for the internal `/turn/ingest-usage` endpoint. */ internal_auth_secret?: string; } diff --git a/src/docs/src/Peer.md b/src/docs/src/Peer.md index b852a3a72..125b3aa22 100644 --- a/src/docs/src/Peer.md +++ b/src/docs/src/Peer.md @@ -10,7 +10,7 @@ Use the Peer API to build peer-to-peer applications without the need for a serve
-Peer connections require authentication. On websites, Puter.js will prompt the user to authenticate if needed. +Hosting a session requires authentication — on websites, Puter.js will prompt the user if needed. Guests can join without an account: pass `anonToken`, plus a `turnGrant` from the host so the connection can still use Puter's relays. See [`puter.peer.createGuestGrant()`](/Peer/createGuestGrant/).
diff --git a/src/docs/src/Peer/connect.md b/src/docs/src/Peer/connect.md index 207bacaff..84e251066 100644 --- a/src/docs/src/Peer/connect.md +++ b/src/docs/src/Peer/connect.md @@ -9,7 +9,7 @@ Connects to a peer server and returns a [`PuterPeerConnection`](/Objects/puterpe
-On websites, Puter.js may prompt the user to authenticate before connecting. +On websites, Puter.js may prompt the user to authenticate before connecting. To let someone join without an account, pass `anonToken` — and a `turnGrant` from the host, so the connection can still use Puter's relays. See [`puter.peer.createGuestGrant()`](/Peer/createGuestGrant/).
@@ -32,6 +32,8 @@ A string invite code created by `puter.peer.serve()`. - `iceServers` (`RTCIceServer[]`) Custom ICE servers (STUN/TURN) to use instead of the Puter-managed relays. - `forceRelay` (`boolean`) Whether to force connections to route through a relay instead of attempting peer-to-peer (default). Metering charges may apply. +- `anonToken` (`String`) Join without a Puter session. Any uuid — it identifies this guest for the duration of the session, and no sign-in prompt is shown. The host sees the guest as `anonymous`, so anything you want to call them is yours to send over the connection. +- `turnGrant` (`String`) A grant from [`puter.peer.createGuestGrant()`](/Peer/createGuestGrant/). Lets a guest use the Puter-managed relays on the host's account. Without one, a guest connects only where a direct connection is possible; with `forceRelay`, a guest needs one. ## Return value diff --git a/src/docs/src/Peer/createGuestGrant.md b/src/docs/src/Peer/createGuestGrant.md new file mode 100644 index 000000000..bdac1b9ec --- /dev/null +++ b/src/docs/src/Peer/createGuestGrant.md @@ -0,0 +1,94 @@ +--- +title: puter.peer.createGuestGrant() +description: Let guests without a Puter account use Puter's TURN relays on your account. +platforms: [websites, apps] +--- + + +Creates a **guest grant**: a short-lived token that lets people without a Puter session use the Puter-managed TURN relays. Hand it to the people you invite alongside the invite code, and they pass it to [`puter.peer.connect()`](/Peer/connect/) as `turnGrant`. + +Without a grant, a guest can still join a session — but only over direct connections. Relay credentials are what make a connection work when one side is behind a NAT or firewall that blocks direct traffic, and minting them requires an account. The grant is how your account vouches for the guest. + +
+ +Relay traffic a guest sends is metered against **your** account, at the same rate as your own. Anyone holding the grant can mint credentials until it expires, so share it with the session you meant to host, and let it expire rather than reusing one indefinitely. + +
+ +## Syntax + +```js +const { grant, expiresAt } = await puter.peer.createGuestGrant(); +``` + +## Parameters + +None. + +## Return value + +A `Promise` that resolves to an object with: + +- `grant` (`String`) The grant to give your guests. +- `expiresAt` (`Number`) When the grant stops being accepted, in seconds since the epoch. Past this point, redeeming it fails with `peer_grant_expired` and you issue a new one. + +Rejects if the caller isn't authenticated, or if the deployment doesn't offer guest relay access. + +## Example + +```html + + + +

Host a session guests can join

+ +

+
+    
+
+
+```
diff --git a/src/docs/src/Peer/ensureTurnRelays.md b/src/docs/src/Peer/ensureTurnRelays.md
index 898ffe1a6..40688b93b 100644
--- a/src/docs/src/Peer/ensureTurnRelays.md
+++ b/src/docs/src/Peer/ensureTurnRelays.md
@@ -11,8 +11,17 @@ Fetches TURN relay credentials ahead of time so that peer connections can start
 
 ```js
 await puter.peer.ensureTurnRelays();
+await puter.peer.ensureTurnRelays(options);
 ```
 
+## Parameters
+
+#### `options` (optional)
+
+`options` is an object with the following properties:
+
+- `turnGrant` (`String`) A grant from [`puter.peer.createGuestGrant()`](/Peer/createGuestGrant/), to preload relays as a guest with no Puter session. Credentials are minted against the account that issued the grant.
+
 ## Return value
 
 A `Promise` that resolves when relay details are cached. If relays cannot be loaded, Puter.js will fall back to default ICE servers when connecting.
diff --git a/src/docs/src/Peer/serve.md b/src/docs/src/Peer/serve.md
index 6cb43e452..4e177d829 100644
--- a/src/docs/src/Peer/serve.md
+++ b/src/docs/src/Peer/serve.md
@@ -28,6 +28,9 @@ const server = await puter.peer.serve(options);
 
 - `iceServers` (`RTCIceServer[]`) Custom ICE servers (STUN/TURN) to use instead of the Puter-managed relays.
 - `forceRelay` (`boolean`) Whether to force connections to route through a relay instead of attempting peer-to-peer (default). Metering charges will increase.
+- `anonToken` (`String`) Host without a Puter session. Any uuid; no sign-in prompt is shown. An anonymous host has no account to attribute relay usage to, so it cannot issue guest grants and gets no relays of its own.
+
+To let people join your session without accounts of their own, keep hosting authenticated and give them a grant — see [`puter.peer.createGuestGrant()`](/Peer/createGuestGrant/).
 
 ## Return value
 
diff --git a/src/docs/src/rate-limits-and-quotas.md b/src/docs/src/rate-limits-and-quotas.md
index 2ae43bc2f..8cdb7b051 100644
--- a/src/docs/src/rate-limits-and-quotas.md
+++ b/src/docs/src/rate-limits-and-quotas.md
@@ -136,6 +136,17 @@ Recipients are emailed by default and opt out with the unsubscribe link the mail
 
 Over these, **the share still succeeds** — only the announcement is dropped. The recipient's notification is kept up to date either way, and folds several senders into one ("alice and bob shared 5 items with you"), so nothing is lost; it just doesn't interrupt them again. Emails are additionally batched: everything triggered for one recipient within a 90-second window goes as a single digest message. Recipients can also refuse shares outright — from one sender, or from everyone — which fails that sender's `share` call with `recipient_not_accepting_shares`. Both are managed from **Settings → Security → Blocked people**.
 
+### Peer connections
+
+| Limit | Paid | Free | Anonymous |
+| --- | --- | --- | --- |
+| Relay credentials per minute | 30 | 10 | 5 |
+| Guest grants issued per minute | 30 | 10 | 5 |
+
+Signalling details are public deployment config and bounded per network instead of per account, at 3,000 reads/min.
+
+Guests are bounded per *host*: everyone holding grants from the same account shares **60 relay-credential requests/min**. Relay traffic a guest sends is metered against the account that issued the grant, so treat a grant as something that spends your allowance — issue it for the session you meant to host, and let it expire rather than reusing one indefinitely.
+
 ### Everything at once
 
 Every driver call also passes one shared per-account budget of **8,000 calls/min** before the per-API limits above. It exists to catch a runaway loop, not to shape normal traffic — a client that sees a 429 from it is looping.
diff --git a/src/docs/src/sidebar.js b/src/docs/src/sidebar.js
index 10ab02eb7..5ae307a12 100755
--- a/src/docs/src/sidebar.js
+++ b/src/docs/src/sidebar.js
@@ -687,6 +687,14 @@ let sidebar = [
                 source: '/Peer/connect.md',
                 path: '/Peer/connect',
             },
+            {
+                title: 'createGuestGrant()',
+                page_title: 'puter.peer.createGuestGrant()',
+                title_tag: 'puter.peer.createGuestGrant()',
+                icon: '/assets/img/function.svg',
+                source: '/Peer/createGuestGrant.md',
+                path: '/Peer/createGuestGrant',
+            },
             {
                 title: 'ensureTurnRelays()',
                 page_title: 'puter.peer.ensureTurnRelays()',
diff --git a/src/gui/src/helpers/authRedirect.js b/src/gui/src/helpers/authRedirect.js
index af13730ef..b4be19664 100644
--- a/src/gui/src/helpers/authRedirect.js
+++ b/src/gui/src/helpers/authRedirect.js
@@ -17,6 +17,8 @@
  * along with this program.  If not, see .
  */
 
+import parse_shared_path, { SHARED_PATH_PARAM } from './parseSharedPath.js';
+
 /**
  * Where to send the user after a successful login/signup started from the
  * current page. Keeps the user on the page they authenticated from — most
@@ -49,18 +51,15 @@ export const get_auth_redirect_url = () => {
 };
 
 /**
- * The `return_to` path to send along when starting an OIDC flow, or null if
- * the current page isn't one the backend will return to. The backend strictly
- * whitelists these (never a client-supplied URL): `/desktop`, `/dashboard`,
- * and direct app landings (`/app/`, plus the desktop-booted
- * `/desktop/app/`), so OIDC login started from an app landing comes back
- * to the app — and to the same interface it was opened in.
+ * The pathname part of an OIDC `return_to`, or null when the current page isn't
+ * one the backend will return to. The root is in here only for the share links
+ * below — on its own it is where the flow already lands.
  *
- * @returns {string|null} whitelistable pathname, or null
+ * @returns {string|null}
  */
-export const get_oidc_return_to = () => {
+const oidc_return_path = () => {
     const pathname = window.location.pathname;
-    if ( pathname === '/desktop' || pathname === '/dashboard' ) {
+    if ( pathname === '/' || pathname === '/desktop' || pathname === '/dashboard' ) {
         return pathname;
     }
     // app landing: normalize away a trailing slash to match the backend whitelist
@@ -69,3 +68,36 @@ export const get_oidc_return_to = () => {
     }
     return null;
 };
+
+/**
+ * The `return_to` to send along when starting an OIDC flow, or null if the
+ * current page isn't one the backend will return to. The backend strictly
+ * whitelists these (never a client-supplied URL): `/desktop`, `/dashboard`,
+ * and direct app landings (`/app/`, plus the desktop-booted
+ * `/desktop/app/`), so OIDC login started from an app landing comes back
+ * to the app — and to the same interface it was opened in.
+ *
+ * A share link (`?shared=`, from an email) is carried along with the path: the
+ * recipient usually has to sign in before they can see what was shared, and an
+ * OIDC round trip leaves the origin, so the parameter has to travel through the
+ * flow or they come back to a bare Home. Only well-formed values go — the
+ * backend refuses the rest, and a hand-edited link is no one's destination.
+ *
+ * @returns {string|null} whitelistable path, with its share items, or null
+ */
+export const get_oidc_return_to = () => {
+    const path = oidc_return_path();
+    if ( path === null ) return null;
+
+    const shared = new URLSearchParams(window.location.search ?? '')
+        .getAll(SHARED_PATH_PARAM)
+        .filter(value => parse_shared_path(value) !== null);
+    if ( shared.length === 0 ) {
+        // The root is only a destination when it names something.
+        return path === '/' ? null : path;
+    }
+
+    const params = new URLSearchParams();
+    for ( const value of shared ) params.append(SHARED_PATH_PARAM, value);
+    return `${path}?${params.toString()}`;
+};
diff --git a/src/gui/src/helpers/authRedirect.test.js b/src/gui/src/helpers/authRedirect.test.js
index 033ed9f2d..8f996977a 100644
--- a/src/gui/src/helpers/authRedirect.test.js
+++ b/src/gui/src/helpers/authRedirect.test.js
@@ -20,11 +20,21 @@
 import { describe, it, expect, afterEach } from 'vitest';
 import { get_oidc_return_to } from './authRedirect.js';
 
-const at = (pathname) => {
-    globalThis.window = { location: { pathname } };
+const at = (pathname, search = '') => {
+    globalThis.window = { location: { pathname, search } };
     return get_oidc_return_to();
 };
 
+const SHARE_UUID = '11111111-2222-3333-4444-555555555555';
+const shared_path = (name) => `/alice/${SHARE_UUID}/${name}`;
+
+/** `window.location.search` for a page opened by a share link. */
+const share_search = (...paths) => {
+    const params = new URLSearchParams();
+    for ( const path of paths ) params.append('shared', path);
+    return `?${params.toString()}`;
+};
+
 afterEach(() => {
     delete globalThis.window;
 });
@@ -45,6 +55,29 @@ describe('get_oidc_return_to', () => {
         expect(at('/desktop/app/editor/')).toBe('/desktop/app/editor');
     });
 
+    it('carries a share link so the item survives the round trip', () => {
+        expect(at('/', share_search(shared_path('Report.pdf')))).toBe(
+            `/${share_search(shared_path('Report.pdf'))}`,
+        );
+        expect(at('/desktop', share_search(shared_path('Report.pdf')))).toBe(
+            `/desktop${share_search(shared_path('Report.pdf'))}`,
+        );
+        expect(
+            at('/', share_search(shared_path('a.txt'), shared_path('b.txt'))),
+        ).toBe(`/${share_search(shared_path('a.txt'), shared_path('b.txt'))}`);
+    });
+
+    it('leaves behind everything that is not a share link', () => {
+        // a hand-edited value the backend would refuse anyway
+        expect(at('/', share_search('/alice/Documents/Report.pdf'))).toBe(null);
+        expect(at('/', '?shared=')).toBe(null);
+        // other parameters are not ours to carry
+        expect(at('/desktop', '?app=editor')).toBe('/desktop');
+        expect(
+            at('/desktop', `${share_search(shared_path('a.txt'))}&app=editor`),
+        ).toBe(`/desktop${share_search(shared_path('a.txt'))}`);
+    });
+
     it('returns null for anything the backend would reject', () => {
         expect(at('/')).toBe(null);
         expect(at('/settings')).toBe(null);
diff --git a/src/puter-js/src/modules/Peer.js b/src/puter-js/src/modules/Peer.js
index 924a11c54..92cbfe30c 100644
--- a/src/puter-js/src/modules/Peer.js
+++ b/src/puter-js/src/modules/Peer.js
@@ -8,7 +8,10 @@ import { PuterModule } from '../lib/PuterModule.js';
  * @property {RTCIceServer[]} [iceServers] Custom ICE servers (STUN/TURN) to use instead of the
  * Puter-managed relays.
  * @property {boolean} [forceRelay] Route every candidate through a TURN relay.
- * @property {string} [anonToken] Connect without a Puter session, using a token the server issued.
+ * @property {string} [anonToken] Take part without a Puter session. Any uuid; it identifies this
+ * guest for the duration of the session and skips the sign-in prompt.
+ * @property {string} [turnGrant] A grant from `puter.peer.createGuestGrant()`, letting a guest with
+ * no session use the Puter-managed relays on the granting account's allowance.
  */
 
 /**
@@ -498,7 +501,12 @@ export class PuterPeerConnection extends EventTarget {
 /**
  * The `puter.peer` API. Provides WebRTC data channels with built-in signaling
  * and TURN relays for connecting clients directly without your own signaling
- * server. Peer connections require authentication.
+ * server.
+ *
+ * Hosting a session requires authentication. Guests can join one without an
+ * account by passing `anonToken`, and reach the Puter-managed relays with a
+ * `turnGrant` the host issued via `createGuestGrant()` — relay usage is
+ * charged to the host that issued it.
  */
 export class PeerModule extends PuterModule {
     #signallerUrl;
@@ -507,6 +515,36 @@ export class PeerModule extends PuterModule {
     #turnTTL;
     #turnStartedAt;
     #turnFailed;
+    #turnSource;
+
+    /**
+     * Creates a grant that lets guests without a Puter session use the
+     * Puter-managed relays. Requires authentication.
+     *
+     * Hand the grant to the people you invite — alongside the invite code —
+     * and they pass it to `connect()` as `turnGrant`. Their relay usage counts
+     * against this account, so treat the grant as something that spends your
+     * allowance: share it with the session you meant to host, and let it
+     * expire rather than reusing one indefinitely.
+     *
+     * @returns {Promise<{ grant: string, expiresAt: number }>} The grant, and
+     * when it stops being accepted (seconds since the epoch).
+     */
+    async createGuestGrant () {
+        const response = await fetchUrl(`${this.APIOrigin}/peer/turn-grant`, {
+            method: 'POST',
+            includePuterAuth: true,
+            headers: {
+                'Content-Type': 'application/json',
+            },
+        });
+
+        if ( ! response.ok ) {
+            throw new Error('Failed to create a guest grant.');
+        }
+
+        return await response.json();
+    }
 
     /**
      * Fetches TURN relay credentials ahead of time so connections start
@@ -514,19 +552,44 @@ export class PeerModule extends PuterModule {
      * it resolves either way: if relays can't be loaded, connecting falls back
      * to the default ICE servers.
      *
+     * With `turnGrant`, credentials are minted against the granting account
+     * instead of the caller's own session, which is how a guest gets relays
+     * without signing in.
+     *
+     * @param {Object} [options]
+     * @param {string} [options.turnGrant] A grant from `createGuestGrant()`.
      * @returns {Promise}
      */
-    async ensureTurnRelays () {
+    async ensureTurnRelays (options = {}) {
+        // Credentials are tied to whoever is paying for them, so a change of
+        // source invalidates both the cached servers and a previous failure —
+        // otherwise a guest who tried before holding a grant would be stuck
+        // with the fallback for the rest of the page's life.
+        const source = options.turnGrant ? `grant:${options.turnGrant}` : 'session';
+        if ( source !== this.#turnSource ) {
+            this.#turnSource = source;
+            this.#turnServers = undefined;
+            this.#turnFailed = false;
+        }
+
         if ( this.#turnFailed ) return;
         if ( this.#turnServers && Date.now() - this.#turnStartedAt < this.#turnTTL * 1000 ) return;
 
-        const response = await fetchUrl(`${this.APIOrigin}/peer/generate-turn`, {
-            method: 'POST',
-            includePuterAuth: true,
-            headers: {
-                'Content-Type': 'application/json',
-            },
-        });
+        const response = options.turnGrant
+            ? await fetchUrl(`${this.APIOrigin}/peer/guest-turn`, {
+                method: 'POST',
+                headers: {
+                    'Content-Type': 'application/json',
+                },
+                body: JSON.stringify({ grant: options.turnGrant }),
+            })
+            : await fetchUrl(`${this.APIOrigin}/peer/generate-turn`, {
+                method: 'POST',
+                includePuterAuth: true,
+                headers: {
+                    'Content-Type': 'application/json',
+                },
+            });
 
         if ( ! response.ok ) {
             this.#turnFailed = true;
@@ -565,7 +628,7 @@ export class PeerModule extends PuterModule {
         if ( options?.iceServers ) {
             iceServers = options.iceServers;
         } else {
-            await this.ensureTurnRelays();
+            await this.ensureTurnRelays(options);
             if ( this.#turnServers ) {
                 iceServers = this.#turnServers;
             } else {
@@ -583,7 +646,7 @@ export class PeerModule extends PuterModule {
     }
     /**
      * Creates a peer server and starts it, resolving to the server once it has
-     * an invite code. Requires authentication.
+     * an invite code. Requires authentication, unless `anonToken` is supplied.
      *
      * @param {PuterPeerOptions} [options]
      * @returns {Promise}
@@ -598,7 +661,9 @@ export class PeerModule extends PuterModule {
 
     /**
      * Connects to a peer server using an invite code from `serve()`, resolving
-     * once the offer has been exchanged. Requires authentication.
+     * once the offer has been exchanged. Requires authentication, unless
+     * `anonToken` is supplied to join without a session — pair it with a
+     * `turnGrant` from the host so the connection can still use relays.
      *
      * @param {string} invitecode
      * @param {PuterPeerOptions} [options]
diff --git a/src/puter-js/src/modules/Peer.turn.test.js b/src/puter-js/src/modules/Peer.turn.test.js
new file mode 100644
index 000000000..75f132e86
--- /dev/null
+++ b/src/puter-js/src/modules/Peer.turn.test.js
@@ -0,0 +1,472 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+/**
+ * Relay-credential plumbing for `puter.peer`: an authenticated caller mints
+ * its own, a guest redeems a host's grant. `fetchUrl` is the HTTP boundary, so
+ * that is what's stubbed; everything above it is the real module.
+ */
+
+const { fetchUrlMock } = vi.hoisted(() => ({ fetchUrlMock: vi.fn() }));
+vi.mock('../lib/networkUtils.js', () => ({ fetchUrl: fetchUrlMock }));
+
+const { PeerModule } = await import('./Peer.js');
+
+const API_ORIGIN = 'https://api.test';
+
+/** A `fetchUrl` response stub. */
+const respond = (body, ok = true) => ({ ok, json: async () => body });
+
+/** Routes stubbed responses by URL, so tests declare intent, not call order. */
+const routeFetch = (routes) => {
+    fetchUrlMock.mockImplementation(async (url, opts) => {
+        for (const [fragment, responder] of Object.entries(routes)) {
+            if (url.includes(fragment)) {
+                return typeof responder === 'function'
+                    ? responder(opts)
+                    : responder;
+            }
+        }
+        throw new Error(`unexpected request to ${url}`);
+    });
+};
+
+/** The options `fetchUrl` was called with for the first URL that matches. */
+const callTo = (fragment) =>
+    fetchUrlMock.mock.calls.find(([url]) => url.includes(fragment));
+
+const makePeer = ({ authToken = null, env = 'web' } = {}) => {
+    const puter = {
+        authToken,
+        APIOrigin: API_ORIGIN,
+        env,
+        ui: { authenticateWithPuter: vi.fn(async () => {}) },
+    };
+    return { peer: new PeerModule(puter), puter };
+};
+
+const HOST_SERVERS = [{ urls: 'turn:host.test' }];
+const GUEST_SERVERS = [{ urls: 'turn:guest.test' }];
+
+beforeEach(() => {
+    fetchUrlMock.mockReset();
+});
+
+describe('createGuestGrant', () => {
+    it('mints a grant against the caller session', async () => {
+        routeFetch({
+            '/peer/turn-grant': respond({
+                grant: 'pg1.payload.sig',
+                expiresAt: 1_700_000_900,
+            }),
+        });
+        const { peer } = makePeer({ authToken: 'host-token' });
+
+        await expect(peer.createGuestGrant()).resolves.toEqual({
+            grant: 'pg1.payload.sig',
+            expiresAt: 1_700_000_900,
+        });
+
+        const [url, opts] = callTo('/peer/turn-grant');
+        expect(url).toBe(`${API_ORIGIN}/peer/turn-grant`);
+        expect(opts.method).toBe('POST');
+        expect(opts.includePuterAuth).toBe(true);
+    });
+
+    it('throws when the grant is refused', async () => {
+        routeFetch({ '/peer/turn-grant': respond({}, false) });
+        const { peer } = makePeer({ authToken: 'host-token' });
+
+        await expect(peer.createGuestGrant()).rejects.toThrow(
+            'Failed to create a guest grant.',
+        );
+    });
+});
+
+describe('ensureTurnRelays', () => {
+    it('uses the authenticated endpoint when no grant is given', async () => {
+        routeFetch({
+            '/peer/generate-turn': respond({
+                iceServers: HOST_SERVERS,
+                ttl: 3600,
+            }),
+        });
+        const { peer } = makePeer({ authToken: 'host-token' });
+
+        await peer.ensureTurnRelays();
+
+        const [, opts] = callTo('/peer/generate-turn');
+        expect(opts.includePuterAuth).toBe(true);
+        expect(opts.body).toBeUndefined();
+        expect(callTo('/peer/guest-turn')).toBeUndefined();
+    });
+
+    it('redeems a grant at the guest endpoint, without sending a session', async () => {
+        routeFetch({
+            '/peer/guest-turn': respond({
+                iceServers: GUEST_SERVERS,
+                ttl: 600,
+            }),
+        });
+        const { peer } = makePeer();
+
+        await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
+
+        const [url, opts] = callTo('/peer/guest-turn');
+        expect(url).toBe(`${API_ORIGIN}/peer/guest-turn`);
+        expect(opts.method).toBe('POST');
+        expect(opts.includePuterAuth).toBeUndefined();
+        expect(JSON.parse(opts.body)).toEqual({ grant: 'grant-1' });
+        expect(callTo('/peer/generate-turn')).toBeUndefined();
+    });
+
+    it('reuses credentials within their ttl', async () => {
+        routeFetch({
+            '/peer/guest-turn': respond({
+                iceServers: GUEST_SERVERS,
+                ttl: 600,
+            }),
+        });
+        const { peer } = makePeer();
+
+        await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
+        await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
+
+        expect(fetchUrlMock).toHaveBeenCalledTimes(1);
+    });
+
+    it('re-mints once the ttl has passed', async () => {
+        routeFetch({
+            '/peer/guest-turn': respond({
+                iceServers: GUEST_SERVERS,
+                ttl: 600,
+            }),
+        });
+        const { peer } = makePeer();
+        const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000);
+        try {
+            await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
+            now.mockReturnValue(1_000_000 + 601_000);
+            await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
+        } finally {
+            now.mockRestore();
+        }
+
+        expect(fetchUrlMock).toHaveBeenCalledTimes(2);
+    });
+
+    it('does not throw when relays are unavailable', async () => {
+        routeFetch({ '/peer/guest-turn': respond({}, false) });
+        const { peer } = makePeer();
+
+        await expect(
+            peer.ensureTurnRelays({ turnGrant: 'grant-1' }),
+        ).resolves.toBeUndefined();
+    });
+
+    it('stops asking after a failure for the same source', async () => {
+        routeFetch({ '/peer/guest-turn': respond({}, false) });
+        const { peer } = makePeer();
+
+        await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
+        await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
+
+        expect(fetchUrlMock).toHaveBeenCalledTimes(1);
+    });
+
+    it('retries once a grant arrives after an unauthenticated failure', async () => {
+        // The guest case: the first attempt has no session and no grant, so it
+        // fails; holding a grant has to be a fresh start, not a cached refusal.
+        routeFetch({
+            '/peer/generate-turn': respond({}, false),
+            '/peer/guest-turn': respond({
+                iceServers: GUEST_SERVERS,
+                ttl: 600,
+            }),
+        });
+        const { peer } = makePeer();
+
+        await peer.ensureTurnRelays();
+        await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
+
+        expect(callTo('/peer/generate-turn')).toBeDefined();
+        expect(callTo('/peer/guest-turn')).toBeDefined();
+    });
+
+    it('re-mints when the grant changes', async () => {
+        routeFetch({
+            '/peer/guest-turn': respond({
+                iceServers: GUEST_SERVERS,
+                ttl: 600,
+            }),
+        });
+        const { peer } = makePeer();
+
+        await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
+        await peer.ensureTurnRelays({ turnGrant: 'grant-2' });
+
+        expect(fetchUrlMock).toHaveBeenCalledTimes(2);
+        expect(
+            JSON.parse(fetchUrlMock.mock.calls.at(-1)[1].body),
+        ).toEqual({ grant: 'grant-2' });
+    });
+});
+
+// -- Guest join, end to end through connect() --------------------------
+
+class FakeWebSocket {
+    static latest = null;
+    sent = [];
+    onopen = null;
+    onmessage = null;
+    onerror = null;
+    onclose = null;
+
+    constructor () {
+        FakeWebSocket.latest = this;
+        // Open on the next tick, the way a real socket resolves the handshake
+        // after the caller has installed its handlers.
+        queueMicrotask(() => this.onopen?.());
+    }
+
+    send (data) {
+        this.sent.push(data);
+    }
+
+    close () {}
+}
+
+class FakeRTCPeerConnection {
+    static latest = null;
+
+    constructor (config) {
+        this.config = config;
+        FakeRTCPeerConnection.latest = this;
+    }
+
+    createDataChannel () {
+        return {
+            onmessage: null,
+            onopen: null,
+            onclose: null,
+            onerror: null,
+            send () {},
+            close () {},
+        };
+    }
+
+    async createOffer () {
+        return { type: 'offer', sdp: 'v=0' };
+    }
+
+    async setLocalDescription () {}
+    async setRemoteDescription () {}
+    async addIceCandidate () {}
+    close () {}
+}
+
+/** Polls until `pred` holds, for handshakes that resolve across microtasks. */
+const waitFor = async (pred, tries = 50) => {
+    for ( let i = 0; i < tries; i++ ) {
+        if ( pred() ) return;
+        await new Promise((resolve) => setTimeout(resolve, 0));
+    }
+    throw new Error('condition never became true');
+};
+
+describe('serve as a host', () => {
+    const origWebSocket = globalThis.WebSocket;
+
+    beforeEach(() => {
+        FakeWebSocket.latest = null;
+        globalThis.WebSocket = FakeWebSocket;
+    });
+
+    afterEach(() => {
+        globalThis.WebSocket = origWebSocket;
+    });
+
+    const signaller = respond({
+        url: 'ws://signaller.test/',
+        fallbackIce: [{ urls: 'stun:fallback.test' }],
+    });
+
+    /** Drives the signaller's create handshake and resolves the invite code. */
+    const startServing = async (peer, options) => {
+        const started = peer.serve(options);
+        await waitFor(() => FakeWebSocket.latest?.onmessage);
+        await FakeWebSocket.latest.onmessage({
+            data: JSON.stringify({
+                server: { create: { success: true, invitecode: 'HOST-1234' } },
+            }),
+        });
+        return await started;
+    };
+
+    it('mints relays against the host session', async () => {
+        routeFetch({
+            '/peer/signaller-info': signaller,
+            '/peer/generate-turn': respond({
+                iceServers: HOST_SERVERS,
+                ttl: 3600,
+            }),
+        });
+        const { peer, puter } = makePeer({ authToken: 'host-token' });
+
+        const server = await startServing(peer);
+
+        expect(server.inviteCode).toBe('HOST-1234');
+        expect(puter.ui.authenticateWithPuter).not.toHaveBeenCalled();
+        expect(callTo('/peer/generate-turn')).toBeDefined();
+        expect(callTo('/peer/guest-turn')).toBeUndefined();
+
+        const sent = JSON.parse(FakeWebSocket.latest.sent[0]);
+        expect(sent.server.create.authToken).toBe('host-token');
+    });
+
+    it('prompts an unauthenticated host to sign in', async () => {
+        routeFetch({
+            '/peer/signaller-info': signaller,
+            '/peer/generate-turn': respond({
+                iceServers: HOST_SERVERS,
+                ttl: 3600,
+            }),
+        });
+        const { peer, puter } = makePeer();
+
+        await startServing(peer);
+
+        expect(puter.ui.authenticateWithPuter).toHaveBeenCalledTimes(1);
+    });
+
+    it('hosts anonymously without relays of its own', async () => {
+        // An anonymous host has no account to attribute relay usage to, so it
+        // gets the public ICE servers and no sign-in prompt.
+        routeFetch({
+            '/peer/signaller-info': signaller,
+            '/peer/generate-turn': respond({}, false),
+        });
+        const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+        const { peer, puter } = makePeer();
+        try {
+            await startServing(peer, {
+                anonToken: '11111111-2222-3333-4444-555555555555',
+            });
+        } finally {
+            warn.mockRestore();
+        }
+
+        expect(puter.ui.authenticateWithPuter).not.toHaveBeenCalled();
+        const sent = JSON.parse(FakeWebSocket.latest.sent[0]);
+        expect(sent.server.create.anonToken).toBe(
+            '11111111-2222-3333-4444-555555555555',
+        );
+    });
+});
+
+describe('connect as a guest', () => {
+    const origWebSocket = globalThis.WebSocket;
+    const origRTC = globalThis.RTCPeerConnection;
+
+    beforeEach(() => {
+        FakeWebSocket.latest = null;
+        FakeRTCPeerConnection.latest = null;
+        globalThis.WebSocket = FakeWebSocket;
+        globalThis.RTCPeerConnection = FakeRTCPeerConnection;
+    });
+
+    afterEach(() => {
+        globalThis.WebSocket = origWebSocket;
+        globalThis.RTCPeerConnection = origRTC;
+    });
+
+    const signallerInfo = respond({
+        url: 'ws://signaller.test/',
+        fallbackIce: [{ urls: 'stun:fallback.test' }],
+    });
+
+    it('joins with a grant and no session, on the granted relays', async () => {
+        routeFetch({
+            '/peer/signaller-info': signallerInfo,
+            '/peer/guest-turn': respond({
+                iceServers: GUEST_SERVERS,
+                ttl: 600,
+            }),
+        });
+        const { peer, puter } = makePeer();
+
+        await peer.connect('HOST-1234', {
+            anonToken: '11111111-2222-3333-4444-555555555555',
+            turnGrant: 'grant-1',
+        });
+
+        // No sign-in prompt, and the relays came from the host's grant.
+        expect(puter.ui.authenticateWithPuter).not.toHaveBeenCalled();
+        expect(FakeRTCPeerConnection.latest.config.iceServers).toEqual(
+            GUEST_SERVERS,
+        );
+
+        const sent = JSON.parse(FakeWebSocket.latest.sent[0]);
+        expect(sent.client.connect).toMatchObject({
+            anonToken: '11111111-2222-3333-4444-555555555555',
+            invitecode: 'HOST-1234',
+        });
+        // Nothing to authenticate with; the anon token is the identity.
+        expect(sent.client.connect.authToken ?? null).toBeNull();
+    });
+
+    it('falls back to the public ICE servers when the grant is refused', async () => {
+        routeFetch({
+            '/peer/signaller-info': signallerInfo,
+            '/peer/guest-turn': respond({}, false),
+        });
+        const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+        const { peer } = makePeer();
+        try {
+            await peer.connect('HOST-1234', {
+                anonToken: '11111111-2222-3333-4444-555555555555',
+                turnGrant: 'expired-grant',
+            });
+        } finally {
+            warn.mockRestore();
+        }
+
+        expect(FakeRTCPeerConnection.latest.config.iceServers).toEqual([
+            { urls: 'stun:fallback.test' },
+        ]);
+    });
+
+    it('honors caller-supplied ICE servers without redeeming a grant', async () => {
+        routeFetch({ '/peer/signaller-info': signallerInfo });
+        const { peer } = makePeer();
+
+        await peer.connect('HOST-1234', {
+            anonToken: '11111111-2222-3333-4444-555555555555',
+            iceServers: [{ urls: 'turn:mine.test' }],
+        });
+
+        expect(FakeRTCPeerConnection.latest.config.iceServers).toEqual([
+            { urls: 'turn:mine.test' },
+        ]);
+        expect(callTo('/peer/guest-turn')).toBeUndefined();
+    });
+
+    it('still mints against the session for an authenticated caller', async () => {
+        routeFetch({
+            '/peer/signaller-info': signallerInfo,
+            '/peer/generate-turn': respond({
+                iceServers: HOST_SERVERS,
+                ttl: 3600,
+            }),
+        });
+        const { peer } = makePeer({ authToken: 'user-token' });
+
+        await peer.connect('HOST-1234');
+
+        expect(FakeRTCPeerConnection.latest.config.iceServers).toEqual(
+            HOST_SERVERS,
+        );
+        const sent = JSON.parse(FakeWebSocket.latest.sent[0]);
+        expect(sent.client.connect.authToken).toBe('user-token');
+        expect(callTo('/peer/guest-turn')).toBeUndefined();
+    });
+});