/** * HMAC-signed URL helpers for the WorldMonitor Brief magazine route. * * The hosted magazine at /api/brief/{userId}/{issueSlot} is auth-less * in the traditional sense (no Clerk session, no cookie). The signed * token IS the credential: a recipient with the URL can read the * magazine; without it, no. This matches the push / email delivery * model where the token is delivered to the user through an already- * authenticated channel. * * Secret rotation is supported: set BRIEF_URL_SIGNING_SECRET_PREV to * the outgoing secret for the overlap window. `verifyBriefToken` will * accept a token signed with either, so producers can roll the primary * secret without invalidating in-flight notifications. * * Rotation runbook: * - Normal roll: set PREV = current, then replace SECRET with a * fresh value. Keep PREV set for at least the envelope TTL * (7 days) plus the push/email-delivery window so in-flight * notifications remain valid. * - Emergency kill switch (suspected secret leak): rotate SECRET * and do NOT set PREV. This invalidates every outstanding token * immediately. Accept the breakage of in-flight notifications * as the cost of containment. * * All crypto goes through Web Crypto (`crypto.subtle`) so this module * runs unchanged in Vercel Edge, Node 18+, and Tauri. */ const USER_ID_RE = /^[A-Za-z0-9_-]{1,128}$/; // YYYY-MM-DD-HHMM issue slot — hour+minute of the compose run in the // user's tz. The token binds userId + slot so each digest dispatch // gets its own frozen magazine URL. const ISSUE_DATE_RE = /^\d{4}-\d{2}-\d{2}-\d{4}$/; const TOKEN_RE = /^[A-Za-z0-9_-]{43}$/; // base64url(sha256) = 43 chars, no padding export class BriefUrlError extends Error { readonly code: 'invalid_user_id' | 'invalid_issue_date' | 'missing_secret'; constructor(code: BriefUrlError['code'], message: string) { super(message); this.code = code; this.name = 'BriefUrlError'; } } function assertShape(userId: string, issueDate: string): void { if (!USER_ID_RE.test(userId)) { throw new BriefUrlError('invalid_user_id', 'userId must match [A-Za-z0-9_-]{1,128}'); } if (!ISSUE_DATE_RE.test(issueDate)) { throw new BriefUrlError('invalid_issue_date', 'issueDate must match YYYY-MM-DD-HHMM'); } } function base64url(bytes: Uint8Array): string { let bin = ''; for (const b of bytes) bin += String.fromCharCode(b); return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } async function hmacSha256(secret: string, message: string): Promise { const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ); const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message)); return new Uint8Array(sig); } /** Constant-time byte comparison. Returns false on length mismatch. */ function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= (a[i] ?? 0) ^ (b[i] ?? 0); return diff === 0; } /** * Deterministically sign `${userId}:${issueSlot}` and return a * base64url-encoded token. The parameter is still named issueDate for * compatibility with earlier callers, but the accepted value is the * frozen issue slot. Throws BriefUrlError on malformed inputs or missing * secret. */ export async function signBriefToken( userId: string, issueDate: string, secret: string, ): Promise { assertShape(userId, issueDate); if (!secret) { throw new BriefUrlError('missing_secret', 'BRIEF_URL_SIGNING_SECRET is not configured'); } const sig = await hmacSha256(secret, `${userId}:${issueDate}`); return base64url(sig); } /** * Verify a token against userId + issueSlot. Accepts the primary * secret and (if provided) a previous secret during rotation. Returns * `true` only on a byte-for-byte match under either secret. * * The token is rejected without ever touching crypto if its shape is * invalid (wrong length, illegal chars). userId and the slot-shaped * issueDate parameter are shape-validated before any HMAC computation to * prevent probing. */ export async function verifyBriefToken( userId: string, issueDate: string, token: string, secret: string, prevSecret?: string, ): Promise { if (typeof token !== 'string' || !TOKEN_RE.test(token)) return false; try { assertShape(userId, issueDate); } catch { return false; } if (!secret) { throw new BriefUrlError('missing_secret', 'BRIEF_URL_SIGNING_SECRET is not configured'); } const tokenBytes = base64urlDecode(token); if (!tokenBytes) return false; const message = `${userId}:${issueDate}`; const primary = await hmacSha256(secret, message); if (constantTimeEqual(primary, tokenBytes)) return true; if (prevSecret) { const legacy = await hmacSha256(prevSecret, message); if (constantTimeEqual(legacy, tokenBytes)) return true; } return false; } function base64urlDecode(token: string): Uint8Array | null { try { const b64 = token.replace(/-/g, '+').replace(/_/g, '/'); const padded = b64 + '==='.slice((b64.length + 3) % 4); const bin = atob(padded); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } catch { return null; } } /** * Compose the full magazine URL with signed token. * * Producers should always go through this helper rather than string- * concatenating URLs by hand. `issueDate` is the legacy property name * for the issueSlot-shaped value (`YYYY-MM-DD-HHMM`). Example: * * const url = await signBriefUrl({ * userId: 'user_abc', * issueDate: '2026-04-17-0800', * baseUrl: 'https://worldmonitor.app', * secret: process.env.BRIEF_URL_SIGNING_SECRET!, * }); */ export async function signBriefUrl({ userId, issueDate, baseUrl, secret, }: { userId: string; /** Legacy name for the frozen issueSlot (`YYYY-MM-DD-HHMM`). */ issueDate: string; baseUrl: string; secret: string; }): Promise { const token = await signBriefToken(userId, issueDate, secret); const encodedUser = encodeURIComponent(userId); const encodedDate = encodeURIComponent(issueDate); const trimmedBase = baseUrl.replace(/\/+$/, ''); return `${trimmedBase}/api/brief/${encodedUser}/${encodedDate}?t=${token}`; }