/* * 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 — one live * token per chat, structurally closing the replay mint window. * * 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, delete the previous session record, write the new session * record, write the new pointer. 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 && previousToken !== token) { 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 }); return token; } /** Current live token for a chat (the pointer target), or null if none. */ 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. */ 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; } }