From e9aec5833b067d0944131ee44a36036ff40da77e Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Thu, 22 Oct 2026 11:43:20 +0800 Subject: [PATCH] feat(02-02): TTL-only KV sessions with rotate-on-new + PRIV-02 storage gates (D-06/D-07/D-08/D-14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- bot/src/kv/sessions.ts | 86 ++++++++++++++++++++++++ bot/test/privacy.test.mjs | 61 +++++++++++++++++ bot/test/sessions.test.mjs | 132 +++++++++++++++++++++++++++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 bot/src/kv/sessions.ts create mode 100644 bot/test/privacy.test.mjs create mode 100644 bot/test/sessions.test.mjs diff --git a/bot/src/kv/sessions.ts b/bot/src/kv/sessions.ts new file mode 100644 index 0000000..e7e8fce --- /dev/null +++ b/bot/src/kv/sessions.ts @@ -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:` 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; + } +} diff --git a/bot/test/privacy.test.mjs b/bot/test/privacy.test.mjs new file mode 100644 index 0000000..2579fab --- /dev/null +++ b/bot/test/privacy.test.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +/* + * privacy.test.mjs — PRIV-02 architecture gates (zero-KV, source-introspection). + * ------------------------------------------------------------------------------ + * The storage promise is pinned by reading the source itself (D-17: + * "verifiable by reading the code's storage calls"): + * + * PG1 every `.put(` line in the TTL storage module carries + * `expirationTtl` on the same line — no TTL-free write path can ship + * PG2 the storage module exposes no persistence-shaped exports + * (no saveProfile / storeLocation / saveLocation / storeHistory) + * + * src/webhook.ts is included when it exists (plan 03 builds it) so the + * gate tightens automatically as call sites grow — future write paths + * fail this suite unless they, too, are TTL-carried. + */ +import { strict as assert } from "node:assert"; +import { readFileSync, existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { test } from "./harness.mjs"; + +const SRC = path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", "src"); + +const storageModules = ["kv/sessions.ts"].map((rel) => path.join(SRC, ...rel.split("/"))); +const webhookModule = path.join(SRC, "webhook.ts"); +if (existsSync(webhookModule)) storageModules.push(webhookModule); + +/* ————— PG1 — same-line expirationTtl gate on every write path ————— */ + +test("PG1 every .put( in storage sources carries expirationTtl on the same line", () => { + for (const file of storageModules) { + const lines = readFileSync(file, "utf8").split("\n"); + lines.forEach((line, i) => { + if (line.includes(".put(")) { + assert.ok( + line.includes("expirationTtl"), + `${file}:${i + 1} — KV write without expirationTtl (PRIV-02 gate): ${line.trim()}`, + ); + const ttl = line.match(/expirationTtl:\s*([A-Za-z0-9_]+)/)?.[1]; + assert.ok( + ttl && /^[A-Z_]{2,}$/.test(ttl), + `${file}:${i + 1} — expirationTtl must reference a named TTL constant, not an inline literal`, + ); + } + }); + } +}); + +/* ————— PG2 — no persistent-shaped surface ————— */ + +test("PG2 storage module exports nothing persistence-shaped (no profiles, no saved locations)", () => { + for (const file of storageModules) { + const source = readFileSync(file, "utf8"); + assert.doesNotMatch( + source, + /\b(?:saveProfile|storeProfile|saveLocation|storeLocation|saveHistory|storeHistory|putHistory|setLocation)\b/, + `${file} — persistence-shaped export found (D-08 claim surface violation)`, + ); + } +}); diff --git a/bot/test/sessions.test.mjs b/bot/test/sessions.test.mjs new file mode 100644 index 0000000..710c72d --- /dev/null +++ b/bot/test/sessions.test.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +/* + * sessions.test.mjs — TTL-only KV session layer (D-06, D-07, D-08). + * ----------------------------------------------------------------- + * Runs against a ~20-line in-memory KVNamespace fake (D-12: zero deps): + * - put() asserts a finite expirationTtl is present on every write + * - ttlLog records {key, expirationTtl} for every write + * Deletion/expiry semantics are modeled by key absence (real KV TTL). + * + * Cases: + * SM1 mint: claim record holds EXACTLY the D-08 key set (no drift) + * SM2 mint: every put carries expirationTtl === SESSION_TTL_SECONDS (900) + * SR1 rotate: second mint for same chat removes the old session key + * SR2 rotate: pointer targets the new token; exactly one live record + * SG1 getClaimByToken round-trips (language_code "ms" and null) + * SG2 unknown token → null (the "expired" rejection class, D-14) + */ +import { strict as assert } from "node:assert"; +import { test } from "./harness.mjs"; + +let mod; // lazy TS import (Node 24 type stripping) +let SESSION_TTL_SECONDS; + +function makeKV() { + const map = new Map(); + const ttlLog = []; + return { + async get(key) { + return map.has(key) ? map.get(key) : null; + }, + async put(key, value, opts) { + assert.ok( + opts && Number.isFinite(opts.expirationTtl) && opts.expirationTtl > 0, + `put(${key}) must carry a finite positive expirationTtl`, + ); + ttlLog.push({ key, expirationTtl: opts.expirationTtl }); + map.set(key, value); + }, + async delete(key) { + map.delete(key); + }, + map, + ttlLog, + }; +} + +function claim(chatId, languageCode, issuedAt = 1700000000) { + return { + user_id: 42, + chat_id: chatId, + issued_at: issuedAt, + language_code: languageCode, + }; +} + +/* ————— SM1/SM2 — mint shape + TTL-by-construction ————— */ + +test("SM1 mint: session record contains EXACTLY the D-08 key set {user_id, chat_id, issued_at, language_code}", async () => { + mod = await import("../src/kv/sessions.ts"); + SESSION_TTL_SECONDS = mod.SESSION_TTL_SECONDS; + const kv = makeKV(); + const token = await mod.mintSession(kv, claim(101, "ms")); + assert.match(token, /^[0-9a-f-]{36}$/, "token must be an opaque UUID"); + const record = JSON.parse(kv.map.get(`session:${token}`)); + assert.deepEqual( + Object.keys(record).sort(), + ["chat_id", "issued_at", "language_code", "user_id"], + "claim is exactly D-08 — a 5th property is storage drift", + ); + assert.equal(record.user_id, 42); + assert.equal(record.chat_id, 101); + assert.equal(record.language_code, "ms"); +}); + +test("SM2 mint: every put carries expirationTtl === 900 (PRIV-02 by construction)", async () => { + const kv = makeKV(); + await mod.mintSession(kv, claim(102, "en")); + assert.equal(kv.ttlLog.length, 2, "mintSession writes exactly two keys"); + for (const entry of kv.ttlLog) { + assert.strictEqual(entry.expirationTtl, SESSION_TTL_SECONDS); + assert.strictEqual(entry.expirationTtl, 900, "D-13 pairing — 15-min envelope"); + } +}); + +/* ————— SR1/SR2 — rotate-on-new (T-02-02 replay close) ————— */ + +test("SR1 rotate: minting again for the same chat removes the old session key", async () => { + const kv = makeKV(); + const token1 = await mod.mintSession(kv, claim(103, "ms")); + const token2 = await mod.mintSession(kv, claim(103, "ms", 1700000050)); + assert.notEqual(token1, token2); + assert.equal( + kv.map.has(`session:${token1}`), + false, + "old token record must be gone (D-07 rotate-on-new)", + ); +}); + +test("SR2 rotate: pointer targets the new token; exactly one live session record", async () => { + const kv = makeKV(); + const token1 = await mod.mintSession(kv, claim(104, "en")); + const token2 = await mod.mintSession(kv, claim(104, "en")); + const pointer = await mod.getTokenForChat(kv, 104); + assert.equal(pointer, token2, "chat pointer must point at the newest token"); + const liveSessions = [...kv.map.keys()].filter((k) => k.startsWith("session:")); + assert.equal(liveSessions.length, 1, "one live token per chat (D-07 invariant)"); + assert.equal(liveSessions[0], `session:${token2}`); +}); + +/* ————— SG1/SG2 — claim reads (expired class, D-14) ————— */ + +test("SG1 getClaimByToken round-trips language_code 'ms' and null", async () => { + const kv = makeKV(); + const tokenMs = await mod.mintSession(kv, claim(105, "ms")); + const claimMs = await mod.getClaimByToken(kv, tokenMs); + assert.ok(claimMs); + assert.equal(claimMs.language_code, "ms"); + assert.equal(claimMs.user_id, 42); + + const kv2 = makeKV(); + const tokenNull = await mod.mintSession(kv2, claim(106, null)); + const claimNull = await mod.getClaimByToken(kv2, tokenNull); + assert.ok(claimNull); + assert.strictEqual(claimNull.language_code, null); +}); + +test("SG2 unknown token → null (callers treat as the expired rejection class)", async () => { + const kv = makeKV(); + await mod.mintSession(kv, claim(107, "ms")); + assert.strictEqual(await mod.getClaimByToken(kv, "no-such-token"), null); + assert.strictEqual(await mod.getTokenForChat(kv, 999999), null); +});