Files

206 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
<interfaces>
<!-- THE FROZEN CONTRACT (bot/src/kv/guidance-sessions.ts — DO NOT ADD FIELDS; GE2 stays): -->
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:<id> → JSON envelope
<!-- The Bearer-claim precedent to copy verbatim (bot/src/webhook.ts handleLocation, T-03-01): -->
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
<!-- Existing analogs: bot/src/kv/location-pin.ts (write/read/delete trio + defensive read),
bot/src/kv/sessions.ts (KvLike seam + crypto.randomUUID), bot/test/sessions.test.mjs (Map-fake). -->
<!-- polyline validation: bot gains "@araharah/routing": "*" in bot/package.json THIS plan
(PATTERNS workspace-import rule: import { decodePolyline6 } from "@araharah/routing/src/polyline.ts"). -->
<!-- Gate re-pin law (PATTERNS "Gate re-pins ship WITH the change that invalidates them"):
GE4 (types-only) → service exists; GE5 stays EXACTLY ONE kv_namespaces (R2 later is a
different binding class — never a second KV namespace); GE6 → chat_sessions Env field
unchanged; GE7 → bot/src may import guidance-sessions SERVICE functions.
GE1 (TTL 7200), GE2 (field freeze), GE3 (inline snapshot) stay UNCHANGED. -->
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>task 1: Envelope service (mint/get/delete) + GE4–GE7 re-pins + PG1</name>
<files>bot/src/kv/guidance-sessions.ts, bot/test/guidance-envelope.test.mjs, bot/test/privacy.test.mjs</files>
<read_first>
- 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)
</read_first>
<behavior>
- mintGuidanceSession generates a crypto.randomUUID() id, stamps created_at = now, puts `guidance:<id>` 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)
</behavior>
<action>
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<string | null>; put(key: string, value: string, opts: { expirationTtl: number }): Promise<void>; delete(key: string): Promise<void>; }`, then `mintGuidanceSession(kv, chatId: number, snapshot: GuidanceRouteSnapshot, nowSec?: number): Promise<GuidanceSessionEnvelope>`, `getGuidanceSession(kv, id: string): Promise<GuidanceSessionEnvelope | null>` (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<void>`. 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.
</action>
<verify>
<automated>npm test -w bot</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>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)</done>
</task>
<task type="auto" tdd="true">
<name>task 2: /api/guidance POST + GET + DELETE (Bearer-claim gated)</name>
<files>bot/src/webhook.ts, bot/package.json, bot/test/webhook.test.mjs</files>
<read_first>
- 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)
</read_first>
<behavior>
- 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": "<uuid>"}; 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=<uuid> for the owning chat → 200 envelope JSON; foreign/absent/malformed id → 404
- DELETE /api/guidance?id=<uuid> 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
</behavior>
<action>
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).
</action>
<verify>
<automated>npm test -w bot && npm run bot:typecheck</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>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</done>
</task>
</tasks>
<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>
<verification>
- `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
</verification>
<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>
<output>
After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-03-SUMMARY.md`
</output>