#!/usr/bin/env node /* * guidance-envelope.test.mjs — the guidance_sessions contract gates (D-06/D-07). * ------------------------------------------------------------------------------ * The envelope SHAPE is the Phase 5 frozen contract: GE1/GE2/GE3 stay UNCHANGED * in meaning (TTL 7200; exactly 4 envelope fields + 6 snapshot fields; inline * snapshot). Phase 6 plan 06-03 takes the service LIVE, so the boundary gates * re-pin WITH the change that invalidates them (the gate re-pin law): * * GE1 TTL constant — GUIDANCE_SESSION_TTL_SECONDS === 7200 (UNCHANGED; * every write carries it) * GE2 field freeze — GuidanceSessionEnvelope has EXACTLY * [id, chat_id, route_snapshot, created_at] and * GuidanceRouteSnapshot has EXACTLY * [destinationId, destinationName, distanceM, * durationS, geometry, startedAtSec] (UNCHANGED; the * structural scan fails on any 5th property — the * SessionClaim "a 5th property means drift" law) * GE3 inline snapshot — route_snapshot is TYPED INLINE * (GuidanceRouteSnapshot, not a reference) and * the tokens snapshotRef / snapshot_ref appear * NOWHERE in bot/src (UNCHANGED; D-07: one key, * one TTL) * GE4 the service — RE-PINNED (06-03; was the types-only behavior- * token ban): the module exports mint/get/delete * GuidanceSession + the TTL constant — the service * owns every write * GE5 no new namespace — wrangler.jsonc carries EXACTLY ONE kv namespace * (chat_sessions): the service reuses it; R2 later * is a different binding class, never a 2nd KV * GE6 no Env field — config.ts gains no guidance field: the service is * handed the EXISTING chat_sessions binding * GE7 consumer shape — RE-PINNED (06-03; was the total consumer ban): * bot/src MAY import the service, but every import * must be a NAMED import of the service surface * (mint/get/delete, the TTL constant, the two * types) — no default/namespace/side-effect * end-run. Vacuous until the first consumer lands * (this plan's webhook task); teeth grow with the * consumer tree. * * The behavioral block pins the SERVICE LAWS against a Map-fake KV * (sessions.test.mjs style): TTL'd mint, defensive null reads (the * location-pin law — corrupt never reaches the cron), the UUID key-guard * (V5: no arbitrary key probing), and active delete (D-14's server half). */ import { strict as assert } from "node:assert"; import { readdirSync, readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { test } from "./harness.mjs"; const here = path.dirname(fileURLToPath(import.meta.url)); // bot/test const srcDir = path.join(here, "..", "src"); // bot/src const MODULE = path.join(srcDir, "kv", "guidance-sessions.ts"); const CONFIG = path.join(srcDir, "config.ts"); const WRANGLER = path.join(here, "..", "wrangler.jsonc"); /* Comments stripped first — banned-token scans read CODE, not prose * (routing/test/contract.test.mjs C4 / web no-storage pattern). */ function stripComments(src) { return src .replace(/\/\*[\s\S]*?\*\//g, " ") // block comments .replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments (benign over-stripping) } function tsFilesUnder(dir) { const out = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, entry.name); if (entry.isDirectory()) out.push(...tsFilesUnder(p)); else if (entry.name.endsWith(".ts")) out.push(p); } return out; } /* ————— GE1 — the ~2h TTL constant exists as a documented value ————— */ test("GE1 guidance TTL constant — GUIDANCE_SESSION_TTL_SECONDS === 7200 (~2h, D-06)", async () => { const mod = await import("../src/kv/guidance-sessions.ts"); assert.equal( mod.GUIDANCE_SESSION_TTL_SECONDS, 7200, "the ~2h TTL must exist as the documented constant awaiting its Phase 6 consumer", ); }); /* ————— GE2 — the field freeze (SessionClaim law) ————— */ /** Field names of `interface { ... }` — lines shaped `^\s{2}\w+:`. */ function interfaceFields(source, name) { const decl = source.indexOf(`interface ${name} {`); assert.notEqual(decl, -1, `interface ${name} not found in the contract module`); const bodyStart = source.indexOf("{", decl) + 1; const bodyEnd = source.indexOf("\n}", bodyStart); assert.notEqual(bodyEnd, -1, `interface ${name} body never closes`); return source .slice(bodyStart, bodyEnd) .split("\n") .map((line) => line.match(/^\s{2}(\w+):/)) .filter(Boolean) .map((m) => m[1]); } test("GE2 field freeze — envelope has EXACTLY [id, chat_id, route_snapshot, created_at]; snapshot EXACTLY its six", () => { const source = readFileSync(MODULE, "utf8"); assert.deepEqual( interfaceFields(source, "GuidanceSessionEnvelope"), ["id", "chat_id", "route_snapshot", "created_at"], "the envelope grew/shrank a field — a 5th property means drift (the SessionClaim law)", ); assert.deepEqual( interfaceFields(source, "GuidanceRouteSnapshot"), ["destinationId", "destinationName", "distanceM", "durationS", "geometry", "startedAtSec"], "the route snapshot surface drifted (GUID-05/06 scoped fields)", ); }); /* ————— GE3 — the snapshot is INLINE (D-07: one key, one TTL) ————— */ test("GE3 inline snapshot — route_snapshot is typed inline; zero snapshot-ref tokens in bot/src", () => { const source = readFileSync(MODULE, "utf8"); assert.ok( source.includes("route_snapshot: GuidanceRouteSnapshot"), "route_snapshot must be typed INLINE (GuidanceRouteSnapshot), never a string/number reference", ); for (const file of tsFilesUnder(srcDir)) { assert.doesNotMatch( readFileSync(file, "utf8"), /snapshotRef|snapshot_ref/, `${file} — snapshot-by-reference token found (D-07: one key, one TTL, nothing reconstructable after expiry)`, ); } }); /* ————— GE4 — RE-PINNED (06-03): the service is LIVE beneath the frozen shape ————— */ test("GE4 the service — the frozen contract exports mint/get/delete + the TTL constant (re-pinned 06-03)", async () => { const mod = await import("../src/kv/guidance-sessions.ts"); assert.equal( typeof mod.mintGuidanceSession, "function", "mintGuidanceSession must exist — the envelope service is LIVE (06-03; was types-only)", ); assert.equal( typeof mod.getGuidanceSession, "function", "getGuidanceSession must exist — the cron's defensively-validated read", ); assert.equal( typeof mod.deleteGuidanceSession, "function", "deleteGuidanceSession must exist — D-14's server half (active trip-end deletion)", ); assert.equal( mod.GUIDANCE_SESSION_TTL_SECONDS, 7200, "the TTL constant survives the service — every write still carries it", ); }); /* ————— GE5 — exactly one kv namespace (chat_sessions) ————— */ test("GE5 no new namespace — wrangler.jsonc carries EXACTLY ONE kv namespace: chat_sessions", () => { const wrangler = JSON.parse(stripComments(readFileSync(WRANGLER, "utf8"))); const namespaces = wrangler.kv_namespaces; assert.equal(Array.isArray(namespaces), true, "kv_namespaces must remain an array"); assert.equal( namespaces.length, 1, "a second kv namespace appeared — the guidance service reuses chat_sessions (R2 is a different binding class)", ); assert.equal(namespaces[0].binding, "chat_sessions", "the single namespace must remain chat_sessions"); }); /* ————— GE6 — the Env contract gains nothing ————— */ test("GE6 no Env field — config.ts contains no guidance token", () => { const config = readFileSync(CONFIG, "utf8"); assert.ok( !config.includes("guidance"), "config.ts gained a guidance field — the service is handed the EXISTING chat_sessions binding; no new Env surface", ); }); /* ————— GE7 — RE-PINNED (06-03): consumer imports constrained to the service surface ————— */ test("GE7 consumer shape — every bot/src import of guidance-sessions is a NAMED import of the service surface", () => { const ALLOWED = new Set([ "mintGuidanceSession", "getGuidanceSession", "deleteGuidanceSession", "GUIDANCE_SESSION_TTL_SECONDS", "GuidanceSessionEnvelope", "GuidanceRouteSnapshot", ]); const NAMED_RE = /\bimport\s+(?:type\s+)?\{([^}]*)\}\s*from\s*["'][^"']*guidance-sessions[^"']*["']/g; for (const file of tsFilesUnder(srcDir)) { const code = stripComments(readFileSync(file, "utf8")); const named = [...code.matchAll(NAMED_RE)]; const specifiers = code.match(/["'][^"']*guidance-sessions[^"']*["']/g) ?? []; assert.equal( specifiers.length, named.length, `${file} — a default/namespace/side-effect import of guidance-sessions is banned; import NAMED service surface only`, ); for (const [, names] of named) { for (const raw of names.split(",")) { const name = raw.trim().replace(/^type\s+/, ""); if (name === "") continue; assert.ok( ALLOWED.has(name), `${file} imports "${name}" — only the service surface may cross (mint/get/delete, the TTL constant, the two types)`, ); } } } }); /* ————— behavioral — the service laws (Map-fake KV, sessions.test.mjs style) ————— */ function makeKV() { const map = new Map(); const ttlLog = []; let getCalls = 0; return { async get(key) { getCalls += 1; 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, get getCalls() { return getCalls; }, }; } const SNAPSHOT = { destinationId: "suria-klcc", destinationName: "Suria KLCC", distanceM: 8432, durationS: 900, geometry: "oqv_Egt~~`Ewj@_q@", // polyline6, two KL points (routing/src/polyline.ts codec) startedAtSec: 1792817000, }; test("GS1 mint: uuid id, created_at stamp, EXACTLY ONE put at guidance: with expirationTtl 7200, envelope exactly 4 fields", async () => { const mod = await import("../src/kv/guidance-sessions.ts"); const kv = makeKV(); const envelope = await mod.mintGuidanceSession(kv, 101, SNAPSHOT, 1792817600); assert.match(envelope.id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, "opaque UUID id (T-02-05 law)"); assert.deepEqual( Object.keys(envelope).sort(), ["chat_id", "created_at", "id", "route_snapshot"], "the minted envelope is exactly the frozen 4-field shape (GE2's runtime twin)", ); assert.equal(envelope.chat_id, 101, "chat_id comes from the caller's CLAIM, never a body"); assert.equal(envelope.created_at, 1792817600, "created_at is the injected now, seconds-since-epoch"); assert.deepEqual(envelope.route_snapshot, SNAPSHOT, "the snapshot is stored verbatim, INLINE"); assert.equal(kv.ttlLog.length, 1, "mint is exactly one KV write"); assert.equal(kv.ttlLog[0].key, `guidance:${envelope.id}`, "key scheme: guidance:"); assert.equal(kv.ttlLog[0].expirationTtl, mod.GUIDANCE_SESSION_TTL_SECONDS, "every write carries the TTL constant (GE1)"); }); test("GS2 get: a well-formed record round-trips the minted envelope", async () => { const mod = await import("../src/kv/guidance-sessions.ts"); const kv = makeKV(); const minted = await mod.mintGuidanceSession(kv, 101, SNAPSHOT, 1792817600); assert.deepEqual(await mod.getGuidanceSession(kv, minted.id), minted); }); test("GS3 defensive read: missing key, malformed JSON, a 5th envelope field, a missing field, wrong-typed chat_id, non-finite distanceM, negative durationS — ALL null (corrupt never reaches the cron)", async () => { const mod = await import("../src/kv/guidance-sessions.ts"); const kv = makeKV(); assert.equal( await mod.getGuidanceSession(kv, "3f2a2c3e-1b4d-4e5a-9c8d-000000000001"), null, "missing key → null", ); const minted = await mod.mintGuidanceSession(kv, 101, SNAPSHOT, 1792817600); const key = `guidance:${minted.id}`; const base = JSON.parse(kv.map.get(key)); const tamper = (mutate) => kv.map.set(key, JSON.stringify(mutate(base))); tamper((r) => ({ ...r, extra: 1 })); assert.equal(await mod.getGuidanceSession(kv, minted.id), null, "a 5th envelope field → null"); tamper(({ created_at: _dropped, ...rest }) => rest); assert.equal(await mod.getGuidanceSession(kv, minted.id), null, "a missing field → null"); tamper((r) => ({ ...r, created_at: 1792817600, chat_id: "101" })); assert.equal(await mod.getGuidanceSession(kv, minted.id), null, "wrong-typed chat_id → null"); tamper((r) => ({ ...r, chat_id: 101, route_snapshot: { ...r.route_snapshot, distanceM: "far" } })); assert.equal(await mod.getGuidanceSession(kv, minted.id), null, "non-finite distanceM → null"); tamper((r) => ({ ...r, route_snapshot: { ...r.route_snapshot, distanceM: 8432, durationS: -5 } })); assert.equal(await mod.getGuidanceSession(kv, minted.id), null, "negative durationS → null"); kv.map.set(key, "{not json"); assert.equal(await mod.getGuidanceSession(kv, minted.id), null, "malformed JSON → null"); }); test("GS4 key-guard: ids failing the UUID shape read as null WITHOUT touching KV (V5: no arbitrary key probing)", async () => { const mod = await import("../src/kv/guidance-sessions.ts"); const kv = makeKV(); const badIds = [ "", "../../session:abc", "3F2A2C3E-1B4D-4E5A-9C8D-000000000001", // uppercase — the guard is lowercase-exact "3f2a2c3e-1b4d-4e5a-9c8d-00000000000", // one hex short "3f2a2c3e-1b4d-4e5a-9c8d-00000000000g", // one hex over ]; for (const id of badIds) { assert.equal(await mod.getGuidanceSession(kv, id), null, `guard rejects ${JSON.stringify(id)}`); } assert.equal(kv.getCalls, 0, "KV was never consulted for a malformed id"); }); test("GS5 delete: the key is actively removed (D-14's server half) — a get after reads null", async () => { const mod = await import("../src/kv/guidance-sessions.ts"); const kv = makeKV(); const minted = await mod.mintGuidanceSession(kv, 101, SNAPSHOT, 1792817600); await mod.deleteGuidanceSession(kv, minted.id); assert.equal(kv.map.has(`guidance:${minted.id}`), false, "the record is gone, not merely expired"); assert.equal(await mod.getGuidanceSession(kv, minted.id), null); });