--- phase: 06-turn-by-turn-guidance-live-driving plan: 03 type: execute wave: 1 depends_on: [] files_modified: - bot/src/kv/guidance-sessions.ts - bot/src/webhook.ts - bot/package.json - bot/test/guidance-envelope.test.mjs - bot/test/privacy.test.mjs - bot/test/webhook.test.mjs autonomous: true requirements: [GUID-05] must_haves: truths: - "A guidance envelope is minted server-side with chat_id from the Bearer claim — NEVER from the request body (T-06-02)" - "Every envelope write carries expirationTtl: GUIDANCE_SESSION_TTL_SECONDS (7200) — the TTL-only law holds (GE1)" - "A malformed or foreign-shaped envelope reads as null — the cron can never push from garbage (fail-honest)" - "GET and DELETE resolve the envelope only for the OWNING chat — cross-chat read/delete is 404" - "Trip-end DELETE removes the KV record actively — D-14's server half" artifacts: - path: "bot/src/kv/guidance-sessions.ts" provides: "mint/get/delete service over the FROZEN 4-field envelope (shape unchanged — GE2 holds)" exports: ["mintGuidanceSession", "getGuidanceSession", "deleteGuidanceSession", "GUIDANCE_SESSION_TTL_SECONDS"] - path: "bot/src/webhook.ts" provides: "POST / GET / DELETE /api/guidance (Bearer-claim gated)" key_links: - from: "bot/src/webhook.ts" to: "bot/src/kv/guidance-sessions.ts" via: "handlers call the service; chat_id comes from claim only" pattern: "mintGuidanceSession|getGuidanceSession|deleteGuidanceSession" - from: "bot/src/kv/guidance-sessions.ts" to: "chat_sessions KV" via: "put with expirationTtl GUIDANCE_SESSION_TTL_SECONDS" pattern: "expirationTtl: GUIDANCE_SESSION_TTL_SECONDS" - from: "bot/test/privacy.test.mjs" to: "bot/src/kv/guidance-sessions.ts" via: "PG1 storageModules includes the new write path" pattern: "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. @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md @.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 ## 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: `); 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. | - `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 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. After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-03-SUMMARY.md`