put with expirationTtl GUIDANCE_SESSION_TTL_SECONDS
expirationTtl: GUIDANCE_SESSION_TTL_SECONDS
from
to
via
pattern
bot/test/privacy.test.mjs
bot/src/kv/guidance-sessions.ts
PG1 storageModules includes the new write path
guidance-sessions
The Phase 5-frozen `GuidanceSessionEnvelope` goes LIVE (D-11's precondition): the types-only module becomes a TTL-only KV service (mint/get/delete), and `/api/guidance` POST/GET/DELETE land on the Worker — chat identity from the Bearer claim only, every write TTL'd at 7200 s, GE4–GE7 re-pinned in the same change.
Purpose: GUID-05's turn-cards and GUID-06's resume both read this envelope; D-14's trip-end deletion writes its absence. The envelope SHAPE stays frozen (GE1/GE2/GE3 unchanged) — this plan adds behavior, never fields.
Output: envelope service + three endpoints + re-pinned gates, all bot suites green.
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-CONTEXT.md
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-PATTERNS.md
export const GUIDANCE_SESSION_TTL_SECONDS = 7200;
export interface GuidanceRouteSnapshot {
destinationId: string; destinationName: string;
distanceM: number; durationS: number;
geometry: string; // polyline6 (the routing workspace's storage format)
startedAtSec: number; // resume-progress anchor (GUID-06)
}
export interface GuidanceSessionEnvelope {
id: string; // crypto.randomUUID() at mint (T-02-05)
chat_id: number; // server-side from the Bearer claim — never the body
route_snapshot: GuidanceRouteSnapshot;
created_at: number; // seconds-since-epoch
}
// Key scheme: guidance: → JSON envelope
const match = (req.headers.get("authorization") ?? "").match(/^Bearer ([A-Za-z0-9-]+)$/);
const claim = match ? await getClaimByToken(env.chat_sessions, match[1]) : null;
// → 401 {error:"unauthorized"} + category-only log on miss
task 1: Envelope service (mint/get/delete) + GE4–GE7 re-pins + PG1
bot/src/kv/guidance-sessions.ts, bot/test/guidance-envelope.test.mjs, bot/test/privacy.test.mjs
- bot/src/kv/guidance-sessions.ts (the frozen types — this task ADDS a service below the types; the four envelope fields and six snapshot fields stay byte-identical)
- bot/src/kv/location-pin.ts (the exact write/read/delete trio + defensive-read shape to copy)
- bot/src/kv/sessions.ts lines 53–90 (KvLike seam + crypto.randomUUID precedent)
- bot/test/guidance-envelope.test.mjs (GE1–GE7 as they stand — each gate's doc-comment names its Phase 6 re-pin)
- bot/test/privacy.test.mjs lines 25–49 (PG1 storageModules list)
- bot/test/sessions.test.mjs (the Map-backed KV fake style)
- mintGuidanceSession generates a crypto.randomUUID() id, stamps created_at = now, puts `guidance:` with expirationTtl: GUIDANCE_SESSION_TTL_SECONDS, returns the envelope
- getGuidanceSession on a well-formed record → the envelope; on missing key → null; on malformed JSON → null; on a record with a 5th envelope field / missing field / wrong-typed chat_id / non-finite distanceM → null (defensive read — a corrupt record can never reach the cron, the location-pin law)
- getGuidanceSession rejects ids that fail the UUID shape check WITHOUT touching KV (V5: no arbitrary key probing)
- deleteGuidanceSession removes the key (void resolve)
1. RED: extend `bot/test/guidance-envelope.test.mjs` — keep GE1 (TTL constant 7200), GE2 (field freeze: the envelope interface has EXACTLY id/chat_id/route_snapshot/created_at — strengthen the structural scan to fail on any 5th property), GE3 (inline snapshot) UNCHANGED in meaning; RE-PIN per the context box: GE4 now asserts the service exports mint/get/delete AND the types remain; GE5 still asserts exactly ONE kv_namespaces entry in wrangler.jsonc; GE6 still asserts the Env's chat_sessions field; GE7 now asserts webhook.ts (or any bot/src consumer) MAY import guidance-sessions service functions. Add the behavioral Map-fake tests per the behavior list.
2. GREEN: add to `bot/src/kv/guidance-sessions.ts` (below the frozen types): `interface GuidanceSessionKv { get(key: string): Promise; put(key: string, value: string, opts: { expirationTtl: number }): Promise; delete(key: string): Promise; }`, then `mintGuidanceSession(kv, chatId: number, snapshot: GuidanceRouteSnapshot, nowSec?: number): Promise`, `getGuidanceSession(kv, id: string): Promise` (UUID regex `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$` guard → null; try/catch parse; exact-field + type + finite/range validation mirroring location-pin's read), `deleteGuidanceSession(kv, id: string): Promise`. Update the module header: the FROZEN note becomes "shape frozen (GE2); the service (Phase 6, plan 06-03) owns every write; every write carries expirationTtl: GUIDANCE_SESSION_TTL_SECONDS".
3. PG1: add `"kv/guidance-sessions.ts"` to `privacy.test.mjs`'s `storageModules` list (PATTERNS caution 2 — the moment the file gains a write path). PG1 then mechanically enforces every `.put(` line carries an expirationTtl referencing an ALL-CAPS constant.
npm test -w bot
- bot/src/kv/guidance-sessions.ts exports mintGuidanceSession, getGuidanceSession, deleteGuidanceSession; GUIDANCE_SESSION_TTL_SECONDS still 7200
- grep `expirationTtl: GUIDANCE_SESSION_TTL_SECONDS` matches bot/src/kv/guidance-sessions.ts
- guidance-envelope.test.mjs: GE1/GE2/GE3 present and passing UNCHANGED in meaning; GE4–GE7 re-pinned and passing
- privacy.test.mjs storageModules includes "kv/guidance-sessions.ts"; `npm test -w bot` green (PG1 passes over the new file)
- behavioral tests prove: TTL'd put, defensive null on 4 malformed-record classes, UUID guard short-circuit
The frozen envelope has a TTL-only service beneath it; the negative-space gates re-pinned in the same change; zero shape drift (GE2 green)
task 2: /api/guidance POST + GET + DELETE (Bearer-claim gated)
bot/src/webhook.ts, bot/package.json, bot/test/webhook.test.mjs
- bot/src/webhook.ts (the route-dispatch skeleton lines 54–84, the handleLocation Bearer-claim block lines 143–161, the MAX_INITDATA_CHARS body-cap + pre/post-read double check lines 91–119, category-only logging law lines 37–43)
- bot/src/kv/guidance-sessions.ts (task 1 service)
- bot/test/webhook.test.mjs (the undici offline Request/Response pattern + claim-minting test helper)
- routing/src/polyline.ts decodePolyline6 (snapshot geometry validation — charset + round-decode)
- POST /api/guidance without/with-invalid Bearer → 401 {"error":"unauthorized"} + `[guidance] rejected: unauthorized` category log (never the token, never the body)
- POST body > MAX_GUIDANCE_BODY_CHARS (8192) → 413; body with non-numeric distanceM / negative durationS / non-integer startedAtSec / destinationId failing the slug pattern / geometry that fails decodePolyline6 → 400 `[guidance] rejected: malformed`
- POST happy path → 201 {"id": ""}; KV fake recorded the put with expirationTtl 7200; a chat_id IN THE BODY is IGNORED (injection probe: body chat_id 999 ≠ claim chat_id → stored envelope carries the CLAIM's chat_id — T-06-02)
- GET /api/guidance?id= for the owning chat → 200 envelope JSON; foreign/absent/malformed id → 404
- DELETE /api/guidance?id= for the owning chat → 200 {"deleted":true} + key gone; foreign/absent → 404 (cross-chat deletion impossible — T-06-02)
- non-POST/GET/DELETE methods → 405
1. `bot/package.json`: add `"@araharah/routing": "*"` to dependencies (beside `"@araharah/search": "*"`); run `npm install` to link the workspace.
2. RED: extend `bot/test/webhook.test.mjs` with the guidance-endpoint block using the existing offline pattern (undici Request/Response, KV Map-fake, claim-minting helper).
3. GREEN in `bot/src/webhook.ts`: add three route arms beside the existing `/api/location` arm, copying its exact posture — Bearer-claim extraction first (401 + `[guidance] rejected: unauthorized`), `MAX_GUIDANCE_BODY_CHARS = 8192` named constant with the pre/post-read double check (413), hand-rolled snapshot validation (no zod — RESEARCH V5): destinationId against the slug pattern, destinationName non-empty string ≤ 200 chars, distanceM/durationS finite ≥ 0, startedAtSec integer > 0, geometry a string that `decodePolyline6` (import `@araharah/routing/src/polyline.ts`) decodes to ≥ 2 points without throwing. POST → `mintGuidanceSession(kv, claim.chat_id, validated)` → 201 {id}. GET/DELETE → `getGuidanceSession` then `envelope.chat_id === claim.chat_id` else 404; DELETE calls `deleteGuidanceSession`. All logs category-only: `[guidance] …` — never ids, never coordinates, never payloads (D-14 law).
npm test -w bot && npm run bot:typecheck
- bot/package.json dependencies include "@araharah/routing": "*"
- webhook.test.mjs asserts all behavior-list rows: 401 class, 413 cap, 400 malformed classes, 201 with claim-owned chat_id (injection probe), GET/DELETE own-vs-foreign, 405
- grep `MAX_GUIDANCE_BODY_CHARS = 8192` matches bot/src/webhook.ts
- grep `console\.` in the guidance arms shows category-only literals — no `${…}` interpolation of body/claim values (pattern: `\[guidance\] rejected: \w+`)
- `npm run bot:typecheck` clean
Guidance sessions are live over HTTP with the claim-only chat law, honest body validation, and the TTL-only service beneath — GUID-05's and GUID-06's server surface, and D-14's delete, ready for consumers
<threat_model>
Trust Boundaries
Boundary
Description
Webview → /api/guidance
Untrusted client posts snapshots; identity and chat ownership come ONLY from the validated Bearer claim
KV record → cron (plan 07)
The cron trusts only defensively-validated envelopes
STRIDE Threat Register
Threat ID
Category
Component
Disposition
Mitigation Plan
T-06-02
Tampering / Spoofing
POST /api/guidance
mitigate
chat_id minted server-side from the Bearer claim; a client-supplied chat_id is structurally ignored (injection probe test proves it). Envelope ids are crypto.randomUUID() (unguessable).
T-06-02b
Elevation of Privilege (cross-chat)
GET/DELETE /api/guidance
mitigate
Both resolve the record and compare envelope.chat_id === claim.chat_id — foreign/absent → 404 (indistinguishable, no existence oracle).
T-06-04
Information Disclosure
handlers + service
mitigate
Category-only logging ([guidance] rejected: <category>); no ids/coordinates/payloads in any log line; TTL 7200 s on every write (GE1/PG1); active delete = D-14's server half.
—
DoS (body volume)
POST /api/guidance
mitigate
MAX_GUIDANCE_BODY_CHARS 8192 with the pre/post-read double check (the WR-01 pattern); 10 ms CPU cap is the natural rate limiter.
—
Repudiation
—
accept
No user-owned writes beyond the user's own ephemeral envelope; nothing to repudiate persists past TTL.
</threat_model>
- `npm test -w bot` — envelope + webhook suites green; GE1–GE7 + PG1 all green
- `npm run bot:typecheck` clean
- Injection probe (body chat_id ≠ claim chat_id) proves claim-only identity
<success_criteria>
The envelope is LIVE: minted server-side, claim-owned, TTL-only, defensively read, actively deletable — with GE1/GE2/GE3 still frozen and GE4–GE7 re-pinned in the same change. Plans 06 (web flows) and 07 (cron) build directly on these endpoints.
</success_criteria>
After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-03-SUMMARY.md`