feat(02-02): TTL-only KV sessions with rotate-on-new + PRIV-02 storage gates (D-06/D-07/D-08/D-14)

- sessions.ts: mintSession (crypto.randomUUID opaque token, T-02-05), rotate-on-new deletes prior session record via chat pointer (D-07), both puts carry expirationTtl: SESSION_TTL_SECONDS on the call line (PRIV-02)
- SESSION_TTL_SECONDS = 900 pairs the session envelope with the D-13 freshness window
- SessionClaim is exactly D-08: user_id, chat_id, issued_at, language_code — nothing else serializes to storage
- sessions.test.mjs: in-memory KV fake asserting finite expirationTtl on every put; D-08 key-set equality; rotate removes old token; expired-token → null (D-14 class)
- privacy.test.mjs: PG1 same-line expirationTtl source gate (auto-tightens to webhook.ts when plan 03 adds it) + PG2 no persistence-shaped exports
- 18/18 PASS across initdata + sessions + privacy suites; full plan GREEN
This commit is contained in:
araharah-executor
2026-10-22 11:43:20 +08:00
parent 54bffe728c
commit e9aec5833b
3 changed files with 279 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
/*
* 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 — one live
* token per chat, structurally closing the replay mint window.
*
* 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, 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<string> {
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<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. */
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;
}
}