/* * sessions.ts — TTL-only KV session layer (D-06, D-07, D-08, PRIV-02). * ------------------------------------------------------------------ * Storage contract (privacy by construction — D-17's "verifiable by * reading the code's storage calls"): * - EVERY write to `chat_sessions` carries an explicit `expirationTtl` * on its call line — no write path in this module can persist * without a TTL. The privacy.test.mjs grep gate pins this. * - The claim is exactly D-08: user_id + chat_id + issued_at + * language_code. Nothing else is ever serialized to storage. * - Tokens are opaque by construction: `crypto.randomUUID()` (T-02-05). * - Rotate-on-new (D-07): a fresh validated identity for a chat deletes * the previous `session:` record before minting, then runs one * best-effort post-mint reconciliation (WR-03). * * Honest invariant under KV semantics (no transactions, eventual * consistency — WR-03 corrected the earlier "structurally closed" claim): * - one *intended* live token per chat: the `chat:` pointer's * target at any sequential moment; * - interleaved mints (two requests racing the same pre-write pointer) * converge at mint time or at next use — the per-mint sweep deletes * the pointer's predecessor, the post-mint reconciliation re-reads * and sweeps when a racing mint's pointer put won, and any record * orphaned by a lost race is invisible to both sweeps and dies with * the 900 s TTL (PRIV-02), never anything longer; * - this is not a privilege escalation: minting requires an identity * that is already valid-by-HMAC (D-13) and the claim stored is that * same identity's own (D-08) — an extra concurrent token only extends * validity the holder already has, 15 minutes at most; * - Phase 4 must NOT build a nonce-store or single-session assumption * on top of the sequential shape alone: worst case is a handful of * concurrently live same-identity tokens, every one bounded by 900 s. * * Key scheme: * session: → JSON SessionClaim record * chat: → current token string (per-chat pointer) * * TTL value pairs with D-13: a session cannot outlive the freshness * proof that minted it. */ /** D-13 pairing — a session may not outlive the 15-min initData window. */ export const SESSION_TTL_SECONDS = 900; /** D-08: the entire persisted claim surface — a 5th property means drift. */ export interface SessionClaim { user_id: number; chat_id: number; issued_at: number; // seconds-since-epoch language_code: string | null; } interface KvLike { get(key: string): Promise; put(key: string, value: string, options: { expirationTtl: number }): Promise; delete(key: string): Promise; } export interface MintSessionKv extends KvLike {} /** * Mint an opaque token for `claim` and rotate (D-07): read the chat * pointer, sweep the previous session record, write the new session * record, write the new pointer — then reconcile (WR-03): re-read the * pointer once and sweep whatever it names if it is not this mint. Under * sequential operation the reconciliation is a no-op; if an interleaved * mint's pointer put landed after ours, its record is swept here (that * client re-mints on next use). Reconciliation is best-effort — it must * never fail a mint that already succeeded; any residue TTLs out at * 900 s regardless. Both writes carry `expirationTtl` (PRIV-02 — see the * call lines; anything less is a review-blocking gate). */ export async function mintSession(kv: MintSessionKv, claim: SessionClaim): Promise { const token = crypto.randomUUID(); const previousToken = await kv.get(`chat:${claim.chat_id}`); if (previousToken !== null) { await kv.delete(`session:${previousToken}`); // D-07 rotate-on-new } await kv.put(`session:${token}`, JSON.stringify(claim), { expirationTtl: SESSION_TTL_SECONDS }); await kv.put(`chat:${claim.chat_id}`, token, { expirationTtl: SESSION_TTL_SECONDS }); try { const settled = await kv.get(`chat:${claim.chat_id}`); if (settled !== null && settled !== token) { await kv.delete(`session:${settled}`); // WR-03 best-effort reconciliation sweep } } catch { // best-effort: TTL (SESSION_TTL_SECONDS) bounds any unreconciled residue } return token; } /** Current live token for a chat (the pointer target), or null if none. * Tests consume it today; the production caller is the Phase 4 Mini App * flow's claim validation (IN-01 — do not delete as dead code). */ export async function getTokenForChat(kv: MintSessionKv, chatId: number): Promise { return kv.get(`chat:${chatId}`); } /** Claim for a token, or null when unknown/expired — callers treat null * as the "expired" rejection class (D-14 vocabulary). Reads never write. * Tests consume it today; the production caller is the Phase 4 Mini App * flow (IN-01 — do not delete as dead code). */ export async function getClaimByToken(kv: MintSessionKv, token: string): Promise { const raw = await kv.get(`session:${token}`); if (raw === null) return null; try { const claim: unknown = JSON.parse(raw); if ( claim && typeof claim === "object" && "user_id" in claim && "chat_id" in claim && "issued_at" in claim && "language_code" in claim ) { return claim as SessionClaim; } return null; } catch { return null; } }