206 lines
15 KiB
Markdown
206 lines
15 KiB
Markdown
---
|
||
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>
|