export interface P2PAccessTokenClaims { readonly roomName: string; readonly peerId?: string | undefined; /** * The account this token was minted for. Absent on anonymous/self-hosted * tokens (which must still verify — backward compat); present on managed-lane * tokens so the relay can meter a free byte allotment per account. */ readonly userId?: string | undefined; readonly expiresAt: number; /** * Token purpose. Absent/`'relay'` → a spendable relay access token the relay * accepts. `'grant'` → a per-game mint GRANT (see {@link mintRelayGrant}): * NOT spendable on the relay, only exchangeable at `/relay/session` for a * fresh relay token. The relay read path ({@link readP2PAccessToken}) rejects * `'grant'` outright, and a grant is signed with a DIFFERENT key besides, so * a grant can never be replayed as a relay token. */ readonly kind?: 'relay' | 'grant' | undefined; } /** * The grant claims baked into a deployed game's public manifest. A grant * authorizes ANONYMOUS players of that one game to mint short-lived relay * tokens for the deployer's account, scoped to the deployed room — the * authorization that lets `/relay/session` (unauthenticated) safely issue a * relay token bearing the deployer's `userId`. Abuse is bounded to the * deployer's per-account/per-room relay byte budget, exactly as the old baked * relay token was — but the grant is refreshable and, unlike a raw relay * token, is not directly spendable on the relay. */ export interface RelayGrantClaims { readonly kind: 'grant'; readonly userId: string; readonly roomName: string; readonly expiresAt: number; } /** * Domain-separated grant-signing subkey derived from the relay access secret. * A grant is signed with THIS key, never the raw secret, so the relay — which * only ever holds the raw secret — cannot verify (and therefore cannot accept) * a grant as a spendable token. This gives cryptographic key separation * between "grant" and "relay token" with a SINGLE provisioned secret; the relay * needs no new binding and never learns this subkey. */ export async function deriveRelayGrantKey(secret: string): Promise { return sign(secret, 'vgai-relay-grant-v1'); } /** * Mint a long-lived per-game relay-mint GRANT (see {@link RelayGrantClaims}). * Signed with {@link deriveRelayGrantKey}, so it is inert against the relay. * `vgai deploy` mints one of these (authenticated as the deployer) and bakes it * into the served manifest; players exchange it at `/relay/session` for a fresh * short-lived relay token at connect time — which is why a deployed game's * multiplayer never expires on the token's clock, only on the grant's much * longer one. */ export async function mintRelayGrant( secret: string, opts: { userId: string; roomName: string; ttlMs: number; nowMs: number }, ): Promise<{ grant: string; expiresAt: number }> { const grantKey = await deriveRelayGrantKey(secret); const expiresAt = opts.nowMs + opts.ttlMs; const grant = await createP2PAccessToken(grantKey, { kind: 'grant', userId: opts.userId, roomName: opts.roomName, expiresAt, }); return { grant, expiresAt }; } /** * Verify a grant and return its `{ userId, roomName }` (or null when invalid, * wrong-kind, or expired). The exchange path `/relay/session` calls this to * decide whether to mint a relay token, and for which account/room. */ export async function readRelayGrant( secret: string, grant: string, now = Date.now(), ): Promise<{ userId: string; roomName: string } | null> { const grantKey = await deriveRelayGrantKey(secret); const [payload, signature, extra] = grant.split('.'); if (!payload || !signature || extra !== undefined) return null; if (!constantTimeEqual(signature, await sign(grantKey, payload))) return null; let claims: RelayGrantClaims; try { claims = JSON.parse(decodeBase64Url(payload)) as RelayGrantClaims; } catch { return null; } if (claims.kind !== 'grant') return null; if (typeof claims.userId !== 'string' || typeof claims.roomName !== 'string') return null; if (!(Number.isFinite(claims.expiresAt) && claims.expiresAt > now)) return null; return { userId: claims.userId, roomName: claims.roomName }; } export async function createP2PAccessToken( secret: string, claims: P2PAccessTokenClaims, ): Promise { const payload = encodeBase64Url(JSON.stringify(claims)); const signature = await sign(secret, payload); return `${payload}.${signature}`; } /** * NEW (2026-08): the shared relay-token mint. A thin wrapper over * {@link createP2PAccessToken} that turns a wall-clock issuance moment plus a * TTL into the `expiresAt` the token claims carry, so every managed home that * issues relay access (the CF-native `@vgai/auth` Worker, and the legacy * account-service route in `app.ts` until it is retired) computes expiry one * way. Keeps the `createP2PAccessToken` primitive untouched — this only owns * the `expiresAt = nowMs + ttlMs` convention and returns it alongside the token * so the caller can echo it to the client. * * `expiresAt` is an epoch-MILLISECONDS instant, not seconds: the read path * ({@link readP2PAccessToken}) compares it against `Date.now()` directly, and * the account-service route it mirrors (`app.ts` `relayToken`) already mints * `Date.now() + ttlMs`. A seconds value would be ~1000x smaller than `now` and * so read as permanently expired. */ export async function mintRelayToken( secret: string, opts: { userId: string; roomName: string; ttlMs: number; nowMs: number }, ): Promise<{ token: string; expiresAt: number }> { const expiresAt = opts.nowMs + opts.ttlMs; const token = await createP2PAccessToken(secret, { roomName: opts.roomName, userId: opts.userId, expiresAt, }); return { token, expiresAt }; } /** * Verify a token AND return its decoded claims (or null when invalid). This is * the read path the relay uses to recover `userId` for per-account metering; * {@link verifyP2PAccessToken} is the boolean shorthand over the same checks. */ export async function readP2PAccessToken( secret: string, token: string, expected: { roomName: string; peerId?: string | undefined }, now = Date.now(), ): Promise { const [payload, signature, extra] = token.split('.'); if (!payload || !signature || extra !== undefined) return null; const expectedSignature = await sign(secret, payload); if (!constantTimeEqual(signature, expectedSignature)) return null; let claims: P2PAccessTokenClaims; try { claims = JSON.parse(decodeBase64Url(payload)) as P2PAccessTokenClaims; } catch { return null; } // A GRANT is never spendable on the relay — it is only exchangeable at // /relay/session. Reject it here too (belt-and-suspenders: a grant is also // signed with a different key, so it would already fail the signature check // above unless the grant key were misconfigured to equal the raw secret). if (claims.kind === 'grant') return null; if (claims.roomName !== expected.roomName) return null; if (expected.peerId && claims.peerId && claims.peerId !== expected.peerId) return null; if (!(Number.isFinite(claims.expiresAt) && claims.expiresAt > now)) return null; return claims; } export async function verifyP2PAccessToken( secret: string, token: string, expected: { roomName: string; peerId?: string | undefined }, now = Date.now(), ): Promise { return (await readP2PAccessToken(secret, token, expected, now)) !== null; } async function sign(secret: string, payload: string): Promise { const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ); const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload)); return encodeBase64Url(new Uint8Array(signature)); } function encodeBase64Url(value: string | Uint8Array): string { const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value; let binary = ''; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); } function decodeBase64Url(value: string): string { const padded = value .replaceAll('-', '+') .replaceAll('_', '/') .padEnd(Math.ceil(value.length / 4) * 4, '='); const binary = atob(padded); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return new TextDecoder().decode(bytes); } function constantTimeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; let result = 0; for (let i = 0; i < a.length; i++) result |= a.charCodeAt(i) ^ b.charCodeAt(i); return result === 0; }