forked from c-fu/arah2
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:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user