123 lines
5.4 KiB
TypeScript
123 lines
5.4 KiB
TypeScript
/*
|
|
* 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:<token>` 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:<chat_id>` 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:<token> → JSON SessionClaim record
|
|
* chat:<chat_id> → 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<string | null>;
|
|
put(key: string, value: string, options: { expirationTtl: number }): Promise<void>;
|
|
delete(key: string): Promise<void>;
|
|
}
|
|
|
|
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<string> {
|
|
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<string | null> {
|
|
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<SessionClaim | null> {
|
|
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;
|
|
}
|
|
}
|