From 27b340fa6b3ec92bcf9b2620e427cb95588f8076 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sun, 25 Oct 2026 02:31:50 +0800 Subject: [PATCH] =?UTF-8?q?docs(07):=20plan=20phase=2007=20=E2=80=94=20har?= =?UTF-8?q?dening=20+=20private=20launch=20(6=20plans,=203=20waves)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../07-hardening-private-launch/07-01-PLAN.md | 530 +++++++++++++++++ .../07-hardening-private-launch/07-02-PLAN.md | 493 ++++++++++++++++ .../07-hardening-private-launch/07-03-PLAN.md | 532 ++++++++++++++++++ .../07-hardening-private-launch/07-04-PLAN.md | 464 +++++++++++++++ .../07-hardening-private-launch/07-05-PLAN.md | 478 ++++++++++++++++ .../07-hardening-private-launch/07-06-PLAN.md | 503 +++++++++++++++++ .../07-VALIDATION.md | 106 ++++ 7 files changed, 3106 insertions(+) create mode 100644 .planning/phases/07-hardening-private-launch/07-01-PLAN.md create mode 100644 .planning/phases/07-hardening-private-launch/07-02-PLAN.md create mode 100644 .planning/phases/07-hardening-private-launch/07-03-PLAN.md create mode 100644 .planning/phases/07-hardening-private-launch/07-04-PLAN.md create mode 100644 .planning/phases/07-hardening-private-launch/07-05-PLAN.md create mode 100644 .planning/phases/07-hardening-private-launch/07-06-PLAN.md create mode 100644 .planning/phases/07-hardening-private-launch/07-VALIDATION.md diff --git a/.planning/phases/07-hardening-private-launch/07-01-PLAN.md b/.planning/phases/07-hardening-private-launch/07-01-PLAN.md new file mode 100644 index 0000000..7b9a967 --- /dev/null +++ b/.planning/phases/07-hardening-private-launch/07-01-PLAN.md @@ -0,0 +1,530 @@ +--- +phase: 07-hardening-private-launch +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - web/src/telegram/fullscreen.ts + - web/src/telegram/webapp.ts + - web/src/telegram/safe-area.ts + - web/src/mock/telegram-mock.ts + - web/src/route/route.ts + - web/test/fullscreen.test.mjs +autonomous: true +requirements: + - PX-04 +must_haves: + truths: + - "A driver on Telegram Bot API 8.0+ gets fullscreen requested exactly once when guidance starts, and guidance continues normally if the request is refused." + - "A driver on an older Telegram client (no 8.0 methods, or version < 8.0) still gets working guidance — the fullscreen call is a silent no-op, never an error surface." + - "When the device rotates or enters fullscreen, the safe-area insets are re-resolved and re-applied, so the cockpit never sits under a notch or a Telegram control." + - "Nothing about the fullscreen path logs a coordinate, a user id, or a route payload — the path is category-only." + artifacts: + - path: "web/src/telegram/fullscreen.ts" + provides: "Capability-guarded Telegram fullscreen + orientation-lock wrapper (requestGuidanceFullscreen / exitGuidanceFullscreen / watchFullscreenEvents)" + contains: "isVersionAtLeast(\"8.0\")" + - path: "web/src/telegram/webapp.ts" + provides: "TelegramWebAppLike contract grown with the 8.0-optional fullscreen/orientation members" + contains: "requestFullscreen?" + - path: "web/src/telegram/safe-area.ts" + provides: "Re-resolution on safeAreaChanged / contentSafeAreaChanged (applySafeAreaOnChange)" + contains: "safeAreaChanged" + - path: "web/test/fullscreen.test.mjs" + provides: "FS1..FS9 + old-client fallback vectors (PX-04.a-d, SC2.e)" + contains: "FS1" + key_links: + - from: "web/src/route/route.ts" + to: "web/src/telegram/fullscreen.ts" + via: "requestGuidanceFullscreen(deps.wa ?? null) at activateGuidance() — the same edge as engageCloseProtection" + pattern: "requestGuidanceFullscreen" + - from: "web/src/route/route.ts" + to: "web/src/telegram/fullscreen.ts" + via: "exitGuidanceFullscreen(deps.wa ?? null) at teardownGuidance() — the same edge as releaseCloseProtection" + pattern: "exitGuidanceFullscreen" + - from: "web/src/telegram/safe-area.ts" + to: "web/src/telegram/webapp.ts" + via: "wa.onEvent('safeAreaChanged'|'contentSafeAreaChanged') re-resolves resolveSafeInsets(wa) and re-applies" + pattern: "contentSafeAreaChanged" + - from: "web/src/mock/telegram-mock.ts" + to: "web/src/telegram/webapp.ts" + via: "the mock satisfies every new optional member (contract + fake + test together law)" + pattern: "requestFullscreen" +--- + + +Ship the fullscreen capability layer that PX-04 requires: a Telegram-native +`requestFullscreen()` call at the guidance-start edge behind the Bot API 8.0 +capability guard, an honest non-error fallback on older clients, and a +safe-area re-resolution path so the cockpit survives rotation and fullscreen. + +Purpose: PX-04's first half — "Guidance supports fullscreen" — is the platform +capability. D-03 locks the mechanism (Telegram's native API behind the 8.0 +guard, orientation lock only where available). The telemetry-Rams tokens stay +binding; this plan adds no art direction and no new UI surface. +Output: `web/src/telegram/fullscreen.ts` (new), the grown `TelegramWebAppLike` +contract, the re-resolving safe-area module, the mock's matching members, the +route controller's guidance-start/trip-end wiring, and +`web/test/fullscreen.test.mjs` (new — PX-04.a–d + the old-client fallback). + + + +@$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/07-hardening-private-launch/07-CONTEXT.md +@.planning/phases/07-hardening-private-launch/07-RESEARCH.md +@.planning/phases/07-hardening-private-launch/07-PATTERNS.md + + +From `web/src/telegram/webapp.ts` (the contract to grow — OPTIONAL members only, +never changing an existing member's shape): + +```typescript +export interface TelegramWebAppLike { + initData: string; + startParam?: string; + colorScheme?: "light" | "dark"; + themeParams?: { header_bg_color?: string }; + version?: string; + platform?: string; + safeAreaInset?: { top: number; bottom: number; left: number; right: number }; + contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number }; + isVersionAtLeast(version: string): boolean; + close(): void; + addToHomeScreen?(): void; + checkHomeScreenStatus?(cb: (status: HomeScreenStatus) => void): void; + isActive?: boolean; + HapticFeedback?: { /* …unchanged… */ }; + disableVerticalSwipes?: () => void; + enableVerticalSwipes?: () => void; + enableClosingConfirmation?: () => void; + disableClosingConfirmation?: () => void; + onEvent?(event: string, cb: () => void): void; + offEvent?(event: string, cb: () => void): void; + BackButton?: { /* …unchanged… */ }; + LocationManager?: { /* …unchanged… */ }; +} +``` + +From `web/src/telegram/homescreen.ts` (the capability-guard shape to copy +verbatim — BOTH halves + try/catch): + +```typescript +export function wireHomeScreen(wa: TelegramWebAppLike, locale: Locale): void { + if (typeof wa.addToHomeScreen !== "function" || !wa.isVersionAtLeast("8.0")) return; + // … + wa.onEvent?.("homeScreenAdded", hide); +} +``` + +From `web/src/guidance/close-protection.ts` (the version-gated call shape): + +```typescript +export function engageCloseProtection(wa: TelegramWebAppLike | null): void { + if (wa !== null && typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("7.7")) wa?.disableVerticalSwipes?.(); + if (wa !== null && typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("6.2")) wa?.enableClosingConfirmation?.(); +} +``` + +From `web/src/telegram/safe-area.ts` (the resolve/apply pair to extend): + +```typescript +export interface SafeInsets { top: number; bottom: number; left: number; right: number; } +export function resolveSafeInsets(wa: TelegramWebAppLike | null): SafeInsets; +export function applySafeArea(insets: SafeInsets): void; +``` + +From `web/src/route/route.ts` (the TWO wiring edges — `activateGuidance()` at +line ~337 and `teardownGuidance()` at line ~474; the guidance-start edge must +be a SYNCHRONOUS user-action transition, never an async callback — RESEARCH +Pitfall 1): + +```typescript + function activateGuidance(): void { + const g = guidance; + if (g === null || g.active) return; + g.active = true; + engageCloseProtection(deps.wa ?? null); // ← fullscreen rides THIS line + if (deps.shellRoot !== undefined) deps.shellRoot.dataset.guidance = "active"; + // … + } + + function teardownGuidance(reason: "arrival" | "abandon"): void { + // … + releaseCloseProtection(deps.wa ?? null); // ← exitFullscreen rides THIS line + // … + } +``` + + + + + + task 1: grow the TelegramWebAppLike contract with the 8.0-optional fullscreen members + build the capability wrapper + web/src/telegram/webapp.ts, web/src/telegram/fullscreen.ts + + - web/src/telegram/webapp.ts (the contract; grow it, never reshape it) + - web/src/telegram/homescreen.ts (the capability-guard + try/catch shape to copy) + - web/src/guidance/close-protection.ts (the version-gated call shape) + - .planning/phases/07-hardening-private-launch/07-PATTERNS.md §`web/src/telegram/fullscreen.ts` + - .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pattern 1 + §Pitfalls 1–3 + + +Create `web/src/telegram/fullscreen.ts` — a NEW module (the whole file is new; +the header comment must name PX-04 and D-03). It exports EXACTLY these three +functions: + +```typescript +export function requestGuidanceFullscreen(wa: TelegramWebAppLike | null): boolean; +export function exitGuidanceFullscreen(wa: TelegramWebAppLike | null): boolean; +export function watchFullscreenEvents(wa: TelegramWebAppLike | null, onChange: (isFullscreen: boolean) => void): void; +``` + +`requestGuidanceFullscreen(wa)`: +1. Returns `false` immediately when `wa === null` (the plain-browser case). +2. Returns `false` immediately when `typeof wa.requestFullscreen !== "function"` + (method-absent half of the guard). +3. Returns `false` immediately when `typeof wa.isVersionAtLeast !== "function"` + OR `!wa.isVersionAtLeast("8.0")` (version half of the guard — Bot API 8.0 + floor, the same floor `homescreen.ts` and `safe-area.ts` already use). +4. Otherwise calls `wa.requestFullscreen()` inside a `try { … } catch { … }` + — a throw is CAUGHT and returns `false` (a refused request is a supported + degradation, never an error surface). It then calls + `wa.lockOrientation?.()` inside its own try/catch (Telegram's lock locks the + CURRENT orientation only — never force landscape; never use + `screen.orientation.lock`). The function returns `true` only when the + `requestFullscreen()` call itself did not throw. +5. It MUST NOT call `document.documentElement.requestFullscreen()` — the DOM + Fullscreen API is gesture-gated and limited-availability; it is NOT the + primary path. It MUST NOT call `screen.orientation.lock(...)`. + +`exitGuidanceFullscreen(wa)`: the mirror — returns `false` when `wa === null`, +when `typeof wa.exitFullscreen !== "function"`, or when the 8.0 guard fails; +otherwise calls `wa.exitFullscreen?.()` in a try/catch and `wa.unlockOrientation?.()` +in its own try/catch, returning `true` when the call did not throw. + +`watchFullscreenEvents(wa, onChange)`: when `wa` is non-null and +`typeof wa.onEvent === "function"`, register `wa.onEvent("fullscreenChanged", …)` +and `wa.onEvent("fullscreenFailed", …)`. On `fullscreenChanged`, call +`onChange(true)`; on `fullscreenFailed`, call `onChange(false)`. BOTH handlers +MUST be wrapped in try/catch so a throwing client callback can never break the +guidance pump. A `fullscreenFailed` is a CATEGORY event, never a user-facing +error. `watchFullscreenEvents` MUST NOT log anything — no coordinates, no +user ids, no payloads (the D-14 category-only law). + +In `web/src/telegram/webapp.ts`, ADD these OPTIONAL members to +`TelegramWebAppLike` (do not change, reorder, or remove any existing member; +append them after `disableClosingConfirmation?` and before `onEvent?`): + +```typescript + /** Bot API 8.0+ — request the Mini App's native fullscreen. Optional: + * absent on older clients; the 8.0 guard runs before every call. */ + requestFullscreen?(): void; + /** Bot API 8.0+ — leave the Mini App's native fullscreen. Optional. */ + exitFullscreen?(): void; + /** Bot API 8.0+ — whether the Mini App is currently fullscreen. Optional. */ + isFullscreen?: boolean; + /** Bot API 8.0+ — lock the CURRENT orientation (never forces landscape). */ + lockOrientation?(): void; + /** Bot API 8.0+ — release the orientation lock. */ + unlockOrientation?(): void; +``` + +Also extend the module header comment (the paragraph that begins +` * Phase 4 (ONBD-02/ONBD-03) grows the contract by exactly three OPTIONAL`) with +a `Phase 7 (PX-04/D-03) adds the native-fullscreen members as OPTIONAL only: +requestFullscreen/exitFullscreen/isFullscreen/lockOrientation/unlockOrientation +(8.0+) — the contract stays the contract, no existing member changes shape, +every test fake and the harness mock satisfy exactly these members.` sentence. + + + - `grep -c "requestFullscreen" web/src/telegram/webapp.ts` returns ≥ 1 and the file contains the literal `requestFullscreen?(): void;` + - `web/src/telegram/fullscreen.ts` exists and exports exactly `requestGuidanceFullscreen`, `exitGuidanceFullscreen`, `watchFullscreenEvents` + - `grep -c "isVersionAtLeast(\"8.0\")" web/src/telegram/fullscreen.ts` returns ≥ 1 (the version half of the guard) + - `grep -c "requestFullscreen" web/src/telegram/fullscreen.ts` returns ≥ 1 and `grep -c "exitFullscreen" web/src/telegram/fullscreen.ts` returns ≥ 1 + - `grep -c "documentElement.requestFullscreen\|screen.orientation.lock" web/src/telegram/fullscreen.ts` returns 0 (the DOM-fullscreen and generic-orientation-lock anti-patterns are absent) + - `grep -c "catch" web/src/telegram/fullscreen.ts` returns ≥ 3 (the request, the exit, and the event handler each catch) + - `grep -c "console\." web/src/telegram/fullscreen.ts` returns 0 (category-only law: this path logs nothing at all) + + + export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run web:typecheck 2>&1 | tail -20 + + + The contract carries the five 8.0-optional members; the wrapper exists with + the BOTH-halves guard, try/catch on every platform call, no DOM-fullscreen + and no generic orientation lock, and no logging. + + + + + task 2: re-resolve safe-area insets on safeAreaChanged / contentSafeAreaChanged + web/src/telegram/safe-area.ts + + - web/src/telegram/safe-area.ts (the resolve/apply pair to extend) + - web/src/telegram/webapp.ts (the contract with the event members) + - .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pitfall 3 (Telegram's safeAreaInset in fullscreen) + + +Extend `web/src/telegram/safe-area.ts` with ONE new exported function — do not +change `resolveSafeInsets` or `applySafeArea`'s existing behaviour: + +```typescript +export function watchSafeAreaChanges(wa: TelegramWebAppLike | null): void; +``` + +`watchSafeAreaChanges(wa)`: +1. Returns immediately when `wa === null` (plain browser) or + `typeof wa.onEvent !== "function"`. +2. Registers a handler for `"safeAreaChanged"` and one for + `"contentSafeAreaChanged"`. Each handler calls + `applySafeArea(resolveSafeInsets(wa))` — the SAME union logic, never a + duplicated inset computation (the "one authority" law). The handler is + wrapped in try/catch so a throwing client callback never breaks the boot. +3. It MUST NOT call `resolveSafeInsets` more than once per event and MUST NOT + write any storage surface. + +Also extend the module header comment with one line naming PX-04/D-03 and the +reason: the current code computes the insets once at boot; fullscreen and +orientation changes can alter them, so the insets are re-resolved on +`safeAreaChanged` / `contentSafeAreaChanged` (RESEARCH Pitfall 3). + + + - `grep -c "watchSafeAreaChanges" web/src/telegram/safe-area.ts` returns ≥ 1 + - `grep -c "safeAreaChanged" web/src/telegram/safe-area.ts` returns ≥ 1 and `grep -c "contentSafeAreaChanged" web/src/telegram/safe-area.ts` returns ≥ 1 + - `grep -c "resolveSafeInsets" web/src/telegram/safe-area.ts` returns ≥ 3 (definition + the two event handlers' call sites) + - `grep -c "localStorage\|sessionStorage\|indexedDB\|document.cookie" web/src/telegram/safe-area.ts` returns 0 + + + export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run web:typecheck 2>&1 | tail -20 + + + `watchSafeAreaChanges` exists, re-resolves through the existing union + authority on both Telegram inset events, catches handler throws, and writes + nothing to storage. + + + + + task 3: mirror the new members in the harness mock + write the FS* test suite + web/src/mock/telegram-mock.ts, web/test/fullscreen.test.mjs + + - web/src/mock/telegram-mock.ts (the MockWebApp shape + the `__events` map + `versionAtLeast`) + - web/test/homescreen.test.mjs (the WHOLE file is the template: fakeWebApp + installDocumentStub + restoreDocument + the both-halves vectors + the closing source-scan ban) + - web/src/telegram/webapp.ts (the contract as grown in task 1) + - web/src/telegram/safe-area.ts (as extended in task 2) + - .planning/phases/07-hardening-private-launch/07-PATTERNS.md §`web/test/fullscreen.test.mjs` + + +In `web/src/mock/telegram-mock.ts`, add the five OPTIONAL members to the +`fake` object literal so the mock satisfies the grown contract (the +"contract + fake + test together" law — this change ships in the SAME commit +as the contract growth): + +```typescript + requestFullscreen() { + fake.__fullscreenRequested = (fake.__fullscreenRequested ?? 0) + 1; + fake.__isFullscreen = true; + }, + exitFullscreen() { + fake.__fullscreenExited = (fake.__fullscreenExited ?? 0) + 1; + fake.__isFullscreen = false; + }, + lockOrientation() { + fake.__orientationLocked = (fake.__orientationLocked ?? 0) + 1; + }, + unlockOrientation() { + fake.__orientationUnlocked = (fake.__orientationUnlocked ?? 0) + 1; + }, +``` + +and add the three state fields to the fake's initial literal +(`__fullscreenRequested: 0, __fullscreenExited: 0, __orientationLocked: 0, +__orientationUnlocked: 0, __isFullscreen: false`). The mock's `version` comes +from `options.version` — a `?mock=` profile with `version` below 8.0 must +therefore produce the absent-behaviour path; do NOT hard-code 8.0. + +Create `web/test/fullscreen.test.mjs` (NEW — the whole file is new). Follow the +`web/test/homescreen.test.mjs` template EXACTLY: `import { test } from +"./harness.mjs";`, a `fakeWebApp(opts)` builder with the segment-wise numeric +`isVersionAtLeast` comparator (copy the shape from homescreen.test.mjs lines +87–127), a `makeElement`/`installDocumentStub`/`restoreDocument` trio only if +the module touches the DOM (it does not — `fullscreen.ts` is DOM-free, so the +DOM stub is OPTIONAL; if you add one, restore it in a `finally` block). + +The suite MUST contain these named cases (the `FS*` prefix follows the repo's +`HS*`/`GS*`/`RS*` convention): + +- **FS1** — a client whose `requestFullscreen` method is ABSENT: + `requestGuidanceFullscreen(wa)` returns `false`, nothing throws, and the + method was never queried. +- **FS2** — an old client (`version: "7.9"`, so `isVersionAtLeast("8.0")` is + false) with `requestFullscreen` PRESENT: the function returns `false` and + `wa.__calls.requestFullscreen === 0` — the version guard ran BEFORE the call. +- **FS3** — Bot API 8.0+: `requestGuidanceFullscreen(wa)` returns `true` and + `wa.__calls.requestFullscreen === 1` (called exactly once). +- **FS4** — a THROWING `requestFullscreen` (the platform refuses): the call + does not throw out of the wrapper, the wrapper returns `false`, and the + guidance path continues (assert `assert.doesNotThrow(...)`). +- **FS5** — `exitGuidanceFullscreen` mirrors the guard: absent method → false, + old client → false, 8.0 client → the method is called exactly once. +- **FS6** — `watchFullscreenEvents`: a `fullscreenChanged` event calls the + callback with `true`; a `fullscreenFailed` event calls it with `false`; + neither throws; a THROWING client callback does not escape the handler. +- **FS7** — the OLD-CLIENT FALLBACK (SC2.e): a client with `version: "7.0"` + and NO fullscreen members — `requestGuidanceFullscreen` returns `false`, + `exitGuidanceFullscreen` returns `false`, `watchFullscreenEvents` does not + throw, and the client is still usable (no error surface). +- **FS8** — SAFE-AREA RE-RESOLUTION (PX-04.d): with a stub `wa` whose + `safeAreaInset` changes between two values, calling + `watchSafeAreaChanges(wa)` and then firing `"safeAreaChanged"` re-applies the + insets: assert `document.documentElement.style.getPropertyValue("--safe-top")` + changes from the first value to the second. The test must install a minimal + `globalThis.document` stub with a `documentElement.style` that records + `setProperty` calls, and delete it in a `finally` block. +- **FS9** — the closing SOURCE-SCAN ban (the HS9 shape): comment-stripped + scan of `web/src/telegram/fullscreen.ts` asserting the code contains NONE of + `documentElement.requestFullscreen`, `screen.orientation.lock`, + `webkitRequestFullscreen`, `mozRequestFullScreen`, `requestFullscreen(`-on- + `document`, `orientation.lock`. It MUST also assert the code contains + `isVersionAtLeast("8.0")` (the guard is not optional). + The scan must use the repo's exact stripper: + `src.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:\\])\/\/[^\n]*/g, "$1")`. + +Every case must `await import("../src/telegram/fullscreen.ts")` at the top of +the file (the repo's lazy-import shape, `const { requestGuidanceFullscreen, … } = +await import("../src/telegram/fullscreen.ts");`). + + + - `web/test/fullscreen.test.mjs` exists and contains the literals `FS1`, `FS2`, `FS3`, `FS4`, `FS5`, `FS6`, `FS7`, `FS8`, `FS9` + - `grep -c "requestFullscreen" web/src/mock/telegram-mock.ts` returns ≥ 1 and `grep -c "exitFullscreen" web/src/mock/telegram-mock.ts` returns ≥ 1 and `grep -c "lockOrientation" web/src/mock/telegram-mock.ts` returns ≥ 1 + - `grep -c "screen.orientation.lock\|documentElement.requestFullscreen" web/test/fullscreen.test.mjs` returns ≥ 1 (the ban list names them) and `grep -c "isVersionAtLeast(\"8.0\")" web/test/fullscreen.test.mjs` returns ≥ 1 + - `npm test -w web` exits 0 and the runner's summary line contains `0 failed` + + + export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -25 + + + The mock satisfies the grown contract and the FS1–FS9 suite is green, + covering the both-halves guard, the throwing-platform-call path, the + old-client fallback, the fullscreen events, and the safe-area + re-resolution. + + + + + task 4: wire the fullscreen call into the guidance-start and trip-end edges + web/src/route/route.ts + + - web/src/route/route.ts (the activateGuidance / teardownGuidance edges — lines ~337 and ~474) + - web/src/telegram/fullscreen.ts (the wrapper, as built in task 1) + - .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pitfall 1 (the activation window) + + +In `web/src/route/route.ts`: + +1. Add the import next to the existing `close-protection.ts` import: + `import { requestGuidanceFullscreen, exitGuidanceFullscreen } from "../telegram/fullscreen.ts";` + +2. In `activateGuidance()` — the SYNCHRONOUS guidance-start transition, the + same edge that already calls `engageCloseProtection(deps.wa ?? null);` — + add ONE line directly after it: + `requestGuidanceFullscreen(deps.wa ?? null);` + The return value is deliberately ignored: the fullscreen request is + BEST-EFFORT and MUST NEVER block or fail the guidance start. Do NOT await + it, do NOT branch on the result, and do NOT move the call into an async + callback (RESEARCH Pitfall 1 — the transient user-activation window is + exactly here). + +3. In `teardownGuidance(reason)` — the same edge that calls + `releaseCloseProtection(deps.wa ?? null);` — add ONE line directly after it: + `exitGuidanceFullscreen(deps.wa ?? null);` + The trip-end teardown releases fullscreen alongside the close protection. + +4. In `resume(trip)` — the `?g=` resumed-trip entry, which already calls + `engageCloseProtection(deps.wa ?? null);` — add ONE line directly after it: + `requestGuidanceFullscreen(deps.wa ?? null);` + (A resumed trip is a live guidance session; it gets the same fullscreen + treatment as a fresh start.) + +5. In `stopGuidanceArtifacts()` — the walk-back teardown, which already calls + `releaseCloseProtection(deps.wa ?? null);` — add ONE line directly after + it: `exitGuidanceFullscreen(deps.wa ?? null);` + +6. Add a one-line comment above each new call naming D-03 + PX-04 and the + best-effort law, in the file's existing voice (e.g. + `// PX-04/D-03: the native fullscreen request is BEST EFFORT — a refusal is a supported fallback, never a guidance failure.`). + +Do NOT add a new console line, do NOT log the fullscreen state, and do NOT +add any coordinate-bearing output — the route module's console law +(`ARAHARAH-ROUTE state=…` / `ARAHARAH-GUIDE state=…` state tokens only) is +unchanged. + + + - `grep -c "requestGuidanceFullscreen" web/src/route/route.ts` returns exactly 2 (activateGuidance + resume) + - `grep -c "exitGuidanceFullscreen" web/src/route/route.ts` returns exactly 2 (teardownGuidance + stopGuidanceArtifacts) + - `grep -c "engageCloseProtection" web/src/route/route.ts` returns 2 (unchanged — the count did not move) + - `grep -c "releaseCloseProtection" web/src/route/route.ts` returns 2 (unchanged) + - `npm run web:typecheck` exits 0 + + + export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -15 && npm run web:typecheck 2>&1 | tail -10 + + + Fullscreen is requested at guidance start (fresh and resumed) and released + at trip end and walk-back, on the synchronous edges, best-effort, with no + new logging. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Telegram client → Mini App | The Telegram client owns the `requestFullscreen` / `lockOrientation` capability; the Mini App only asks. A client may refuse, lie about its version, or throw. | +| Mini App → DOM / CSS | The layout consumes safe-area insets reported by the client; a wrong inset clips the cockpit. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-07-06 | Denial of Service | `web/src/telegram/fullscreen.ts` — a throwing/refused platform call | mitigate | The capability guard is BOTH halves (method presence AND `isVersionAtLeast("8.0")`); every platform call is wrapped in try/catch; `requestGuidanceFullscreen` returns a boolean the caller IGNORES, so a refusal can never fail the guidance start. Pinned by FS1–FS5 and the FS9 source-scan ban. | +| T-07-06b | Denial of Service | `web/src/telegram/safe-area.ts` — a throwing inset-event handler | mitigate | `watchSafeAreaChanges` wraps each handler in try/catch; a client that throws on `safeAreaChanged` cannot break the boot or the guidance pump. Pinned by FS6/FS8. | +| T-07-09 | Information Disclosure | the fullscreen event path | mitigate | The module logs NOTHING (`grep -c "console\."` = 0); no coordinate, user id, or route payload is produced or emitted. `fullscreenFailed` is a category event, never a user-facing error. | +| T-07-10 | Tampering | the DOM-fullscreen / generic-orientation-lock escape hatches | mitigate | The FS9 source-scan ban forbids `documentElement.requestFullscreen`, `screen.orientation.lock`, `webkitRequestFullscreen`, `mozRequestFullScreen` in the module's code — the Telegram-native path is the ONLY primary path. | +| T-07-11 | Information Disclosure | `web/src/telegram/webapp.ts` contract growth | accept | The contract gains OPTIONAL members only; no existing member's shape changes, so no existing call site can be broken by the growth. Low-value target, no data path. | + + + +Run after all tasks: + +```bash +export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" +cd /home/C-Fu/araharah +npm test -w web +npm run web:typecheck +``` + +Both must exit 0. `npm test -w web` must report `0 failed`. The FS1–FS9 cases +must all appear as `PASS`. + + + +- `web/src/telegram/fullscreen.ts` exists with the both-halves 8.0 guard, try/catch on every call, no DOM-fullscreen, no generic orientation lock, and zero logging. +- `TelegramWebAppLike` carries the five 8.0-optional members; the harness mock satisfies them in the same change. +- `watchSafeAreaChanges` re-resolves the insets on `safeAreaChanged` / `contentSafeAreaChanged` through the existing union authority. +- `web/src/route/route.ts` requests fullscreen at the guidance start (fresh + resumed) and releases it at trip end + walk-back — exactly 2 request sites and 2 exit sites, both on the synchronous edges. +- `web/test/fullscreen.test.mjs` carries FS1–FS9 and the whole `web` suite is green. + + + +After completion, create `.planning/phases/07-hardening-private-launch/07-01-SUMMARY.md` + diff --git a/.planning/phases/07-hardening-private-launch/07-02-PLAN.md b/.planning/phases/07-hardening-private-launch/07-02-PLAN.md new file mode 100644 index 0000000..e5b6dff --- /dev/null +++ b/.planning/phases/07-hardening-private-launch/07-02-PLAN.md @@ -0,0 +1,493 @@ +--- +phase: 07-hardening-private-launch +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: + - web/src/shell.css + - web/test/failure-state-matrix.test.mjs + - web/scripts/capture.mjs +autonomous: true +requirements: + - PX-04 +must_haves: + truths: + - "In landscape (driving posture) the maneuver banner, the maneuver list, the guidance card and the map all stay visible and readable — nothing overlaps, nothing is clipped." + - "Every failure class — empty route, route error, route timeout, no geometry, off-road, location timeout, old client — renders a designed surface, never a blank screen." + - "A route whose geometry decodes to fewer than two points is refused before it can be half-drawn on the map." + - "The landscape rules change GEOMETRY ONLY — no new colour, no radius, no shadow, no blur; every declaration derives from a Rams token." + artifacts: + - path: "web/src/shell.css" + provides: "The @media (orientation: landscape) geometry block for the cockpit/banner/card" + contains: "@media (orientation: landscape)" + - path: "web/test/failure-state-matrix.test.mjs" + provides: "The FM1..FM7 named failure vectors with DOM state-token assertions" + contains: "FAILURE_VECTORS" + - path: "web/scripts/capture.mjs" + provides: "The landscape capture legs (PX-04.e) — a landscape cockpit shot plus the failure-state shots" + contains: "shell-guidance-landscape.png" + key_links: + - from: "web/src/shell.css" + to: "theme/src/tokens.css" + via: "every landscape declaration references var(--spacing-*)/var(--safe-*); the theme audit proves zero off-token values" + pattern: "var\\(--spacing-" + - from: "web/test/failure-state-matrix.test.mjs" + to: "web/src/route/route.ts" + via: "the route-null/throw path renders the designed pending card (asserted, not changed)" + pattern: "buildPendingCard" + - from: "web/scripts/capture.mjs" + to: "web/src/shell.css" + via: "the landscape leg opens a landscape viewport so the media query is exercised in a real browser" + pattern: "orientation: 'landscape'" +--- + + +Make the guidance cockpit usable in landscape (PX-04's second half) and prove +that every failure class renders a designed surface instead of a blank screen +(ROADMAP SC2). + +Purpose: a navigation app's empty states ARE the polish surface, and a driver +holds the phone in landscape. D-03 locks the mechanism — landscape fixes are +CSS-only media queries with the Telemetry Rams tokens binding; the map stays +Rams-constant. RESEARCH Pattern 3 (failure classes as named vectors) is the +audit-coverage form: the route-null/off-road behaviours already exist and are +NOT redesigned here — they are pinned by tests. +Output: a landscape media-query block in `web/src/shell.css`, +`web/test/failure-state-matrix.test.mjs` (new — the FM1..FM7 named vectors), +and the landscape + failure capture legs in `web/scripts/capture.mjs`. + + + +@$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/phases/07-hardening-private-launch/07-CONTEXT.md +@.planning/phases/07-hardening-private-launch/07-RESEARCH.md +@.planning/phases/07-hardening-private-launch/07-PATTERNS.md + + +From `web/src/shell.css` (the geometry the landscape block must adapt — the +sanctioned machined-geometry literals are: banner min-height 96px, arrow plate +48px + glyph 32px, compact strip 24px, row 48px + glyph 24px, next-row lamp +8px, panel max 320px/480px, bar 4px, 1px rules): + +```css +.guide-banner { + position: absolute; top: calc(var(--safe-top, 0px) + var(--spacing-space-sm)); left: 0; right: 0; + min-height: 96px; padding: var(--spacing-space-sm) var(--spacing-space-md); + /* … */ +} +[data-guidance="active"] .strip { + top: calc(var(--safe-top, 0px) + var(--spacing-space-sm) + 96px + 1px); + min-height: 24px; +} +.guide-list { + pointer-events: auto; position: relative; width: calc(100% - 2 * var(--spacing-margin)); + max-width: 320px; max-height: 480px; overflow-y: auto; + margin-top: calc(var(--safe-top, 0px) + var(--spacing-space-sm) + 96px + 1px + 24px + var(--spacing-space-sm)); +} +.route-stack[data-register="center"] { top: 50%; transform: translateY(-50%); } +.route-stack[data-register="bottom"] { top: auto; transform: none; bottom: calc(var(--safe-bottom, 0px) + 64px); } +``` + +From `web/src/route/route.ts` (the failure classes that ALREADY render — audit +coverage, not behaviour change): + +```typescript + const result: RouteResult | null = await deps.source + .route({ origin: { lat: fix.lat, lng: fix.lng }, destinationId: destination.id }) + .catch(() => null); // T-05-04: an exception renders pending — never a half-drawn line + if (result === null || result.options.length === 0) { + deps.showRouteCard(deps.buildPendingCard(deps.locale, { onSearchAgain: () => walkBack() }), "center"); + setState("pending"); + console.log("ARAHARAH-ROUTE state=pending"); + return; + } +``` + +From `web/src/guidance/guidance.ts` (the off-road state machine — the named +constants the failure matrix pins): + +```typescript +export const OFF_ROUTE_THRESHOLD_M = 50; +export const OFF_ROUTE_CONSECUTIVE_FIXES = 3; +export const OFF_ROUTE_GRACE_MS = 8000; +export const STORM_GUARD_MS = 20000; +export const MISSED_TURN_PAST_MANEUVER_M = 40; +export const MISSED_TURN_GRACE_MS = 3000; +export const ARRIVAL_THRESHOLD_M = 25; +export const GUIDANCE_START_ALONG_M = 30; +export function offRouteThresholdM(accuracyM: number | null): number; +``` + +From `web/src/guidance/banner.ts` (the DOM builders the failure matrix drives +through the minimal document fake — `buildGuidanceBanner`, `buildManeuverList`, +`buildGuidanceCard`, `buildConfirmCard`, `buildTripEndCard`, `MANEUVER_LIST_ID`). + +From `web/scripts/capture.mjs` (the leg shape to extend): + +```javascript +async function captureLeg({ name, query, colorScheme, grantGeo, waitMode, expectState, expectText, expectGuide, setup, interact }) { + const target = join(SHOTS, name); + const context = await browser.newContext({ + viewport: { width: 390, height: 844 }, + colorScheme, + deviceScaleFactor: 2, + }); + // … +} +``` + + + + + + task 1: the landscape geometry block in shell.css (CSS-only, tokens binding) + web/src/shell.css + + - web/src/shell.css (the whole file — the token-var discipline and the `[data-guidance="active"]` state-attribute pattern) + - theme/src/tokens.css (the spacing/colour token names — the ONLY legal values) + - .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pattern 2 (Landscape as CSS Geometry Adaptation) + - .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pitfall 4 (100vh is not stable) + + +APPEND a new block at the END of `web/src/shell.css` (do not edit any +existing rule — the existing portrait geometry stays byte-identical): + +```css +/* ————— Phase 7 landscape geometry (PX-04/D-03) ————— + Landscape is an ADDITIONAL CONSTRAINT, never a separate screen: portrait + remains the default and every rule below only ADAPTS the cockpit/banner/ + card geometry. The map cartography stays Rams-constant (D-10) — NO MapLibre + rule appears in this block. Every value is a token reference or a sanctioned + geometry literal; the map's cartography is untouched. */ + +@media (orientation: landscape) and (max-height: 560px) { + /* the banner band: shorter in landscape, and the horizontal safe insets + are honoured on both edges (max() with the token padding as the floor) */ + .guide-banner { + min-height: 72px; + padding-left: max(var(--spacing-space-md), var(--safe-left, 0px)); + padding-right: max(var(--spacing-space-md), var(--safe-right, 0px)); + } + .guide-banner .guide-plate { width: 40px; height: 40px; } + .guide-banner .guide-plate svg { width: 28px; height: 28px; } + + /* the compact strip rides directly under the shorter banner */ + [data-guidance="active"] .strip { + top: calc(var(--safe-top, 0px) + var(--spacing-space-sm) + 72px + 1px); + } + + /* the maneuver list: a landscape-height-bounded panel that clears the + banner and the strip instead of overlapping them */ + .guide-list { + max-height: calc(100dvh - 104px - var(--safe-top, 0px) - var(--safe-bottom, 0px)); + margin-top: calc(var(--safe-top, 0px) + var(--spacing-space-sm) + 72px + 1px + 24px + var(--spacing-space-sm)); + } + + /* the bottom register (the guidance card / the confirm card) sits inside + the landscape height instead of overflowing past it */ + .route-stack[data-register="bottom"] { + bottom: calc(var(--safe-bottom, 0px) + var(--spacing-space-sm)); + } + .route-stack[data-register="center"] { max-width: 320px; } +} +``` + +CONSTRAINTS — every one is binding: + +1. **Tokens only.** Every colour/spacing/typography value is a `var(--…)` + reference. The ONLY literals permitted in this block are the machined + geometry numbers already sanctioned by the file's header comment (72px + banner min-height, 40px plate, 28px glyph, 104px chrome offset, 1px rule) + — and you MUST extend the header comment's sanctioned literal list with + exactly: `landscape banner min-height 72px, plate 40px + glyph 28px, + landscape chrome offset 104px`. The comment lists literals so a future + reader knows they are deliberate. +2. **Zero radius, zero shadow, zero blur, zero glass.** Do NOT write + `border-radius`, `box-shadow`, `filter: blur`, `backdrop-filter`, or any + `transition`/`animation` — the house style forbids them and the theme + audit flags them. +3. **No MapLibre rule.** No `#map` selector and no MapLibre class may appear + in this block (the map is Rams-constant, D-10/PX-02). +4. **No new markup.** No `data-*` attribute is introduced; the rules hang off + the EXISTING `.guide-banner` / `.guide-list` / `.route-stack` / + `[data-guidance="active"]` selectors. +5. **`100dvh` only where a viewport-relative height is genuinely needed** — + the `.guide-list` max-height. Do NOT convert the shell's `height: 100%` / + `position: fixed` sizing model (RESEARCH Pitfall 4: keep the current model + unless a failing leg proves a change). + +After appending, run `npm run audit -w theme` (the DSGN-01 evidence command) +and confirm it still reports `AUDIT CLEAN`. If the audit flags a landscape +declaration, that declaration is off-token — replace the literal with the +matching token; never suppress the audit. + + + - `grep -c "@media (orientation: landscape)" web/src/shell.css` returns exactly 1 + - `grep -c "max-height: 560px" web/src/shell.css` returns exactly 1 + - `grep -c "border-radius\|box-shadow\|backdrop-filter\|transition:\|animation:" web/src/shell.css` returns 0 + - `grep -c "#map" web/src/shell.css` returns the SAME count as before this change (no MapLibre rule was added) + - `grep -c "landscape banner min-height 72px" web/src/shell.css` returns 1 (the header's sanctioned literal list was extended) + - `npm run audit -w theme` exits 0 and prints `AUDIT CLEAN` + + + export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run audit -w theme 2>&1 | tail -8 && npm run web:typecheck 2>&1 | tail -5 + + + The landscape block exists, is CSS-only, uses tokens plus the extended + sanctioned literal list, has zero radius/shadow/blur/transition, touches no + MapLibre rule, and the theme audit stays clean. + + + + + task 2: the FM1..FM7 failure-state matrix suite (named vectors, DOM state tokens) + web/test/failure-state-matrix.test.mjs + + - web/test/guidance-replay.test.mjs (the named-vector header + the pure-reducer replay shape — the WHOLE header is the template) + - web/test/guidance-surface.test.mjs (the minimal `makeEl` document fake + the DOM state-token assertion style) + - web/src/route/route.ts (the route-null/throw → pending card path) + - web/src/guidance/guidance.ts (the off-road state machine + its named constants) + - .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pattern 3 (Failure-State Matrix as Data) + + +Create `web/test/failure-state-matrix.test.mjs` (NEW — the whole file is new). +Follow the repo's conventions exactly: `import { strict as assert } from +"node:assert";`, `import { test } from "./harness.mjs";`, and the +`await import("../src/…")` lazy-import shape. + +The file MUST declare a `FAILURE_VECTORS` array as DATA (RESEARCH Pattern 3's +data-driven form) with EXACTLY these seven entries and their `id`/`trigger`/ +`expect` fields: + +```javascript +const FAILURE_VECTORS = [ + { id: "empty-route", trigger: "route options=[]", expect: "pending-card" }, + { id: "route-error", trigger: "route source throws", expect: "pending-card" }, + { id: "no-geometry", trigger: "polyline decodes to <2 points", expect: "pending-card" }, + { id: "off-road", trigger: "3 fixes beyond the accuracy-aware threshold", expect: "rerouting-or-no-reroute" }, + { id: "location-timeout", trigger: "LocationManager returns null after timeout", expect: "denied-or-awaiting-card" }, + { id: "old-client", trigger: "WebApp version < 8.0 / methods absent", expect: "guidance-usable-without-fullscreen" }, + { id: "route-timeout", trigger: "route source never resolves within the budget", expect: "pending-card" }, +]; +``` + +Then write the named cases (the `FM*` prefix follows the repo's `HS*`/`GS*`/ +`RS*` convention). Every case must be grep-verifiable by name: + +- **FM1** — `FAILURE_VECTORS` has EXACTLY 7 entries and their `id`s are + exactly `["empty-route","route-error","no-geometry","off-road","location-timeout","old-client","route-timeout"]`. +- **FM2** — the EMPTY ROUTE renders the designed pending card, never a blank: + build the pending card through the REAL builder + (`buildRoutePendingCard` from `web/src/route/route-card.ts`, driven through + the minimal `makeEl` document fake copied from guidance-surface.test.mjs), + and assert the rendered element carries a non-empty `textContent` and that + no element in the built card has an empty class or an undefined dataset + state. Assert the card's text is NOT empty and NOT a bare "undefined". +- **FM3** — the ROUTE ERROR and the ROUTE TIMEOUT both land on the same + pending-card surface: assert that `deps.source.route` rejecting and + `deps.source.route` resolving with `options: []` both produce the pending + card. For the timeout vector, use a `Promise` that never settles and assert + the controller's state stays `idle`/`pending` (never `route`) and that + NOTHING is drawn — assert `setRouteDrawn` was never called (use the + injected `MapLike` stub the route-surface suite already uses, or a minimal + object with a recording `setData`). The suite MUST have a timeout guard + (e.g. `Promise.race` with a 200 ms timer) so the case cannot hang the + runner. +- **FM4** — the NO-GEOMETRY route is refused, never half-drawn: assert that a + polyline6 string decoding to fewer than 2 points is rejected. Use + `decodePolyline6` from `@araharah/routing/src/index.ts` and assert the + decoded point count is `< 2` for the vector, and that the route controller + treats such a geometry as null (the pending card), NOT as a drawable route. +- **FM5** — the OFF-ROAD class renders calmly: drive the PURE reducer + (`createGuidanceSession` + `stepGuidance` from + `web/src/guidance/guidance.ts`) with three fixes beyond the accuracy-aware + threshold and assert exactly ONE `reroute-requested` effect fires, that the + storm guard holds a second immediate excursion, and that the resulting + state is `rerouting` (not `off_route` hanging, not a throw). Assert the + banner's state for `rerouting` renders the CALM slot — build it with + `buildGuidanceBanner` and assert the rendered text contains no "REROUTE" + blame phrasing and that the card's text is non-empty. +- **FM6** — the OLD-CLIENT class (SC2.e): a `TelegramWebAppLike` fake with + `version: "7.0"` and NO 8.0 members — assert the guidance chrome still + builds (the banner builder produces a non-empty element) and that no + capability call is attempted. Do NOT import `web/src/telegram/fullscreen.ts` + here — plan 07-01 owns that module's suite; this case asserts the CHROME + survives an old client, not the fullscreen wrapper. +- **FM7** — the LOCATION-TIMEOUT class: assert the state's designed outcome + is one of the two designed surfaces (`denied` / `awaiting-pin`), never a + blank. Use the location chain's own state vocabulary from + `web/src/location/types.ts` (read the file to learn the exact state names) + and assert the failure-state set contains "denied" and "awaiting" as + members. If the location chain's exported type does not carry those names + verbatim, assert instead that the chain's failure branch returns a + NON-NULL state object whose `kind` is one of the two designed failure + kinds — read the source and pin the ACTUAL names. + +NO case may assert a blank state, and no case may use a real network call, a +real DOM, or a timer longer than 200 ms. Every DOM-driving case must install +its `globalThis.document` stub and delete it in a `finally` block (the +homescreen.test.mjs law). + + + - `web/test/failure-state-matrix.test.mjs` exists and contains the literals `FAILURE_VECTORS`, `FM1`, `FM2`, `FM3`, `FM4`, `FM5`, `FM6`, `FM7` + - `grep -c "id: \"empty-route\"\|id: \"route-error\"\|id: \"no-geometry\"\|id: \"off-road\"\|id: \"location-timeout\"\|id: \"old-client\"\|id: \"route-timeout\"" web/test/failure-state-matrix.test.mjs` returns exactly 7 + - `grep -c "finally" web/test/failure-state-matrix.test.mjs` returns ≥ 1 (the DOM stub is restored) + - `npm test -w web` exits 0 and the runner's summary line contains `0 failed` + + + export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -25 + + + The failure matrix exists as DATA + named cases, covers empty/error/timeout/ + no-geometry/off-road/old-client/location-timeout, asserts designed surfaces + rather than blanks, and the whole web suite is green. + + + + + task 3: the landscape + failure-state capture legs + web/scripts/capture.mjs + + - web/scripts/capture.mjs (the `captureLeg` signature + the leg list + the guidance legs at lines ~800–1150) + - web/src/shell.css (the landscape block added in task 1) + - .planning/phases/07-hardening-private-launch/07-VALIDATION.md (PX-04.e — the landscape browser-capture requirement) + + +Extend `web/scripts/capture.mjs`: + +1. `captureLeg` MUST accept ONE new optional parameter: `viewport`, defaulting + to `{ width: 390, height: 844 }`. Change the signature line to: + `async function captureLeg({ name, query, colorScheme, grantGeo, waitMode, expectState, expectText, expectGuide, setup, interact, viewport }) {` + and inside, replace the hard-coded `viewport: { width: 390, height: 844 },` + with `viewport: viewport ?? { width: 390, height: 844 },`. + +2. Add ONE new landscape leg after the existing guidance legs, using the + `?drive=corridor-follow` replay knob (the committed fixture the 06-08 + guidance legs already use) and the guidance wait mode: + +```javascript + /* ————— 07-02 — the landscape leg (PX-04.e) ————— + The driving posture: the phone rotated. The cockpit geometry adapts via + the @media (orientation: landscape) block (07-02 task 1); the map stays + Rams-constant. A 844x390 viewport IS the landscape orientation the media + query matches (orientation: landscape + max-height: 560px). */ + await captureLeg({ + name: 'shell-guidance-landscape.png', + query: 'mock=android&scheme=light&drive=corridor-follow', + colorScheme: 'light', + grantGeo: true, + waitMode: 'guide', + expectGuide: 'on_route', + viewport: { width: 844, height: 390 }, + }); +``` + + Match the exact `expectGuide` value the existing guidance legs use for the + corridor drive — READ the existing guidance legs in this file and copy + their `expectGuide`/`waitMode` pair verbatim rather than guessing. If the + existing corridor leg uses `waitMode: 'settle400'` instead, use that. + +3. Add ONE failure-state leg that photographs the designed pending card (the + empty/error route class) so the surface is on film, not only in a Node + assertion. Use the existing pending-card leg's query/expectText values + verbatim (READ them from the file — the `shell-route-pending.png` leg + already exists) and add the landscape variant of it: + +```javascript + /* ————— 07-02 — the landscape failure-state leg (SC2) ————— + The pending card in landscape: the designed surface, never a blank map. */ + await captureLeg({ + name: 'shell-route-pending-landscape.png', + query: '', + colorScheme: 'light', + grantGeo: true, + waitMode: '', + expectState: '', + expectText: '', + viewport: { width: 844, height: 390 }, + }); +``` + +4. Update the file's header comment block (the "Outputs (web/screenshots/ —" + list) with TWO new lines, matching the existing format: + ` * shell-guidance-landscape.png the cockpit in landscape (driving posture) (07-02, PX-04)` + ` * shell-route-pending-landscape.png the designed pending card in landscape (07-02, SC2)` + +5. The capture harness's own MIN_BYTES blank bar already applies to every leg + (a landscape shot under 10240 bytes FAILS) — do NOT add a per-leg size + exemption. A landscape leg that renders a blank surface must FAIL the + capture run. + +6. Do NOT change any existing leg's query, expectText, expectState, or + screenshot name. This plan ADDS legs; it never edits the existing ones. + + + - `grep -c "viewport" web/scripts/capture.mjs` returns ≥ 3 (the signature, the default, the two legs) + - `grep -c "shell-guidance-landscape.png" web/scripts/capture.mjs` returns ≥ 1 + - `grep -c "shell-route-pending-landscape.png" web/scripts/capture.mjs` returns ≥ 1 + - `grep -c "width: 844, height: 390" web/scripts/capture.mjs` returns ≥ 2 (both new legs) + - `npm run capture -w web` exits 0 and the report lists BOTH new landscape shots as non-blank + + + export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run capture -w web 2>&1 | tail -30 + + + The capture harness accepts a per-leg viewport, and the two new landscape + legs (the cockpit + the pending card) render non-blank shots in a real + browser — the landscape media query is exercised on film. + + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Device orientation → layout | The client reports orientation; the layout must stay readable under both. | +| Routing/geometry source → client surface | Route geometry and route-source failures are untrusted inputs; a malformed polyline must not reach the map. | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-07-05 | Denial of Service | the failure classes — a blank screen is a dead app for a driver | mitigate | Every failure class is a NAMED vector in `FAILURE_VECTORS` and is asserted to render a designed surface (pending card / denied / awaiting) rather than an empty map. Pinned by FM2, FM3, FM5, FM7. | +| T-07-12 | Tampering | `web/src/shell.css` — a malformed polyline half-drawn on the map | mitigate | FM4 pins that a polyline6 decoding to `< 2` points is refused before the draw path; the pending card renders instead of a half-drawn line. | +| T-07-13 | Information Disclosure | the landscape/failure rendering path | accept | The cockpit renders route geometry and street names already on screen; no new data class is introduced and no coordinate is written to a log or storage surface by this plan. | +| T-07-14 | Tampering | `web/src/shell.css` — an off-token or blur/shadow declaration smuggled into the landscape block | mitigate | Every new declaration is a token reference or an extended sanctioned geometry literal; `npm run audit -w theme` (the DSGN-01 evidence command) must print `AUDIT CLEAN` — the zero-radius/no-shadow/no-blur house rule is enforced mechanically, not by review. | +| T-07-15 | Denial of Service | the capture harness — a landscape leg that renders blank | mitigate | The existing MIN_BYTES blank bar (10240 bytes) applies unchanged to the new legs; a blank landscape surface FAILS the capture run instead of passing silently. | + + + +Run after all tasks: + +```bash +export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH" +cd /home/C-Fu/araharah +npm test -w web +npm run audit -w theme +npm run capture -w web +``` + +All three must exit 0. The capture run must report both new landscape shots as +non-blank, and the theme audit must print `AUDIT CLEAN`. + + + +- `web/src/shell.css` carries exactly one `@media (orientation: landscape) and (max-height: 560px)` block that adapts only the cockpit/banner/list/bottom-register geometry, uses tokens plus the extended sanctioned literal list, and contains zero radius/shadow/blur/transition declarations and zero MapLibre rules. +- `web/test/failure-state-matrix.test.mjs` carries `FAILURE_VECTORS` (exactly 7 named entries) and the FM1–FM7 cases; every failure class asserts a designed surface, never a blank. +- `web/scripts/capture.mjs` accepts a per-leg `viewport` and adds the two landscape legs (`shell-guidance-landscape.png`, `shell-route-pending-landscape.png`). +- `npm test -w web`, `npm run audit -w theme` and `npm run capture -w web` all exit 0. + + + +After completion, create `.planning/phases/07-hardening-private-launch/07-02-SUMMARY.md` + diff --git a/.planning/phases/07-hardening-private-launch/07-03-PLAN.md b/.planning/phases/07-hardening-private-launch/07-03-PLAN.md new file mode 100644 index 0000000..f86da32 --- /dev/null +++ b/.planning/phases/07-hardening-private-launch/07-03-PLAN.md @@ -0,0 +1,532 @@ +--- +phase: 07-hardening-private-launch +plan: 03 +type: execute +wave: 1 +depends_on: [] +files_modified: + - scripts/privacy-audit.mjs + - scripts/lib/source-gates.mjs + - scripts/lib/bundle-gates.mjs + - scripts/lib/cloudflare-probes.mjs + - package.json + - docs/PRIVACY-AUDIT.md +autonomous: true +requirements: + - PX-04 +must_have_notes: + - "This plan re-verifies PRIV-02 and GEO-03 end-to-end (D-01). PX-04 is claimed here because SC3 is a Phase-7 success criterion under the PX-04 phase." +must_haves: + truths: + - "No production source tree in the repo contains a persistence API outside the platform allowlist — the memory-only client law and the TTL-only Worker law, extended repo-wide." + - "Every Worker KV write carries an expirationTtl that references a NAMED constant — no TTL-free write path can ship." + - "No log statement can carry a coordinate, a chat id, or a route payload — the category-only vocabulary is machine-verified." + - "The production bundle contains no analytics, no beacon, and no fetch to any host outside the positive allowlist (OpenFreeMap tiles, the Telegram API, the app's own Worker)." + - "A live KV probe shows every live key family is one of session:/chat:/loc:/guidance: and every key carries an expiration." + - "The audit's exit codes distinguish PASS (0), FAIL (1) and NOT-RUN / missing environment (2) — a skipped live probe is never mistaken for a pass." + artifacts: + - path: "scripts/privacy-audit.mjs" + provides: "The consolidated audit CLI (npm run privacy-audit) — orchestration, evidence report, exit codes" + contains: "PRIVACY AUDIT" + - path: "scripts/lib/source-gates.mjs" + provides: "Comment-stripped source scans over ALL src trees — storage, TTL, log vocabulary, runtime fetch hosts" + contains: "stripComments" + - path: "scripts/lib/bundle-gates.mjs" + provides: "Production bundle scans — analytics/beacon tokens + the positive fetch-host allowlist" + contains: "ALLOWED_FETCH_HOSTS" + - path: "scripts/lib/cloudflare-probes.mjs" + provides: "The live KV listing probe (key families + expiration metadata) and the live header probe" + contains: "list_complete" + - path: "docs/PRIVACY-AUDIT.md" + provides: "The signed written audit record citing the evidence (D-01)" + contains: "PRIVACY AUDIT RECORD" + key_links: + - from: "scripts/privacy-audit.mjs" + to: "scripts/lib/source-gates.mjs" + via: "runStorageGate / runCoordinateLogGate / runRuntimeHostGate — each returns {id, pass, evidence}" + pattern: "source-gates\\.mjs" + - from: "scripts/privacy-audit.mjs" + to: "scripts/lib/bundle-gates.mjs" + via: "runBundleTelemetryGate — scans web/dist (PRODUCTION only)" + pattern: "bundle-gates\\.mjs" + - from: "scripts/privacy-audit.mjs" + to: "scripts/lib/cloudflare-probes.mjs" + via: "runKvExpiryProbe — only under the --live flag; missing credentials => exit 2" + pattern: "cloudflare-probes\\.mjs" + - from: "package.json" + to: "scripts/privacy-audit.mjs" + via: "the root script \"privacy-audit\": \"node scripts/privacy-audit.mjs\"" + pattern: "privacy-audit" +--- + + +Ship the ONE consolidated privacy-audit tool the repo can run before every +fork's first driver, and the signed written audit record that cites its +evidence. + +Purpose: D-01 — PRIV-02 and GEO-03 are re-verified END-TO-END, not asserted: +source scans over every `src` tree, a production-bundle scan for telemetry, a +live KV listing probe that reads the expiry metadata, and a signed record a +reviewer (or a fork) can rerun. D-02 — v1 carries ZERO telemetry, so there is +nothing to opt into; the audit proves the absence rather than auditing a +consent surface that does not exist. +Output: `scripts/privacy-audit.mjs` + `scripts/lib/{source-gates,bundle-gates,cloudflare-probes}.mjs` +(new), the root `privacy-audit` npm script, and `docs/PRIVACY-AUDIT.md` (the +signed record). + + + +@$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/phases/07-hardening-private-launch/07-CONTEXT.md +@.planning/phases/07-hardening-private-launch/07-RESEARCH.md +@.planning/phases/07-hardening-private-launch/07-PATTERNS.md + + +The comment-stripped source scan — copy this EXACTLY from +`web/test/no-storage.test.mjs` lines 29–42 (the canonical twin also lives in +`bot/test/route-privacy.test.mjs` lines 42–59): + +```javascript +function listFiles(dir, exts, acc = []) { + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) listFiles(full, exts, acc); + else if (exts.some((e) => entry.endsWith(e))) acc.push(full); + } + return acc; +} + +function stripComments(src) { + return src + .replace(/\/\*[\s\S]*?\*\//g, " ") // block comments + .replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments (not URLs inside strings — benign over-stripping, gate is prefer-fail reading) +} +``` + +The banned persistence vocabulary — from `web/test/no-storage.test.mjs` lines 22–25: + +```javascript +const BANNED = [ + "localStorage", "sessionStorage", "indexedDB", "caches", "CacheStorage", + "document.cookie", "WebApp.DeviceStorage", "WebApp.SecureStorage", "WebApp.CloudStorage", +]; +``` + +The KV write-TTL law — from `bot/test/privacy.test.mjs` lines 32–49 (PG1): + +```javascript +lines.forEach((line, i) => { + if (line.includes(".put(")) { + assert.ok(line.includes("expirationTtl"), `${file}:${i + 1} — KV write without expirationTtl (PRIV-02 gate): ${line.trim()}`); + const ttl = line.match(/expirationTtl:\s*([A-Za-z0-9_]+)/)?.[1]; + assert.ok(ttl && /^[A-Z_]{2,}$/.test(ttl), `${file}:${i + 1} — expirationTtl must reference a named TTL constant, not an inline literal`); + } +}); +``` + +The CLI exit-code contract — from `theme/scripts/audit.mjs` lines 20–24 and +797–814: + +```text + * Exit codes (no silent passes — T-1-07): + * 0 clean — every checked declaration derives from tokens + * 1 violations — each printed as file:line: declaration + * 2 drift/error — token sheet degraded (see message) +``` + +The gate-orchestration shape — from 07-RESEARCH.md §Pattern 4: + +```javascript +const results = [ + await runStorageGate(), + await runCoordinateLogGate(), + await runRuntimeHostGate(), + await runBundleTelemetryGate(), + await runKvExpiryProbe(), +]; + +for (const result of results) { + console.log(`${result.id}: ${result.pass ? "PASS" : "FAIL"} — ${result.evidence}`); +} +process.exit(results.every((r) => r.pass) ? 0 : 1); +``` + +The four KV key families and their TTL constants (from `bot/src/kv/*.ts`): + +| Family | Prefix | TTL constant | TTL | +|--------|---------|--------------|-----| +| session | `session:` | `SESSION_TTL_SECONDS` | 900 | +| chat pointer | `chat:` | `SESSION_TTL_SECONDS` | 900 | +| location pin | `loc:` | `PIN_TTL_SECONDS` | 300 | +| guidance envelope | `guidance:` | `GUIDANCE_SESSION_TTL_SECONDS` | 7200 | + +The category-only log vocabulary (from `bot/src/webhook.ts` lines 63–68) — every +`console.log` in `bot/src` has the shape `[surface] verdict: category`, where +`surface` is one of `webhook`, `initdata`, `location`, `location-fallback`, +`guidance`, `guidance-cron`, `route`, `search`, `search-alternate`. + +The web-side state-token vocabulary (from `web/src/main.ts` and +`web/src/route/route.ts`) — every `console.log` in `web/src` is either a +`ARAHARAH-*` state token (`ARAHARAH-DEST state=…`, `ARAHARAH-LIVE fix=… acc=…`, +`ARAHARAH-ROUTE state=…`, `ARAHARAH-GUIDE state=…`, `ARAHARAH-DRIVE state=…`), +the `RAMS-BASEMAP-READY layers=…` signal, or a `console.error` carrying an +error object. + +The positive runtime fetch-host allowlist (D-01) — the ONLY hosts the shipped +bundle may reach: + +| Host | Why | +|------|-----| +| `tiles.openfreemap.org` | the basemap tiles / sprites / glyphs (`theme/basemap/liberty-rams.json`) | +| `telegram.org` | the official Telegram WebApp SDK script tag in `web/index.html` | +| the app's own Worker origin | `/api/session`, `/api/location`, `/api/location-fallback`, `/api/guidance` — a RELATIVE path, not a host | + + + + + + task 1: the source-gates library — storage, TTL, log vocabulary, runtime fetch hosts (ALL src trees) + scripts/lib/source-gates.mjs + + - web/test/no-storage.test.mjs (the canonical comment-stripper + the BANNED list) + - bot/test/privacy.test.mjs (the PG1 same-line expirationTtl law) + - bot/test/route-privacy.test.mjs (the host-ban + import-allowlist style) + - .planning/phases/07-hardening-private-launch/07-PATTERNS.md §`scripts/privacy-audit.mjs` + §Shared Patterns + + +Create `scripts/lib/source-gates.mjs` — the repo-local source scanner (a +MAINTAINER-TIME REPO TOOL — nothing in `bot/src`, `web/src`, `routing/src`, or +`search/src` imports it; its header comment must say so, copying the law from +`routing/scripts/measure-route-cpu.mjs` lines 3–9). + +It MUST export these named functions, each returning +`{ id, pass, evidence }` (the RESEARCH Pattern 4 shape): + +```javascript +export function runStorageGate(); // id: "storage-api" +export function runKvTtlGate(); // id: "kv-ttl" +export function runLogVocabularyGate(); // id: "log-vocabulary" +export function runRuntimeHostGate(); // id: "runtime-hosts" +``` + +**`runStorageGate()`** — scans EVERY production source tree: `web/src`, +`bot/src`, `routing/src`, `search/src`, `theme/src`. For each `.ts`/`.tsx`/ +`.js`/`.mjs`/`.html` file, strip comments with the EXACT stripper above, then +assert the file contains NONE of the `BANNED` persistence tokens. Also assert +no file contains `serviceWorker.register`, `navigator.serviceWorker`, or +`workbox`. The evidence string must name the file count scanned and every +violation as `relative-path: token`. + +**`runKvTtlGate()`** — scans EVERY file under `bot/src` (and any other tree +containing `.put(`). For each line containing `.put(`, assert the SAME LINE +contains `expirationTtl` and that the captured value matches +`/^[A-Z_]{2,}$/` (a NAMED constant, never an inline literal). The evidence +string must name each violation as `relative-path:line — KV write without a +named expirationTtl`. + +**`runLogVocabularyGate()`** — the category-only vocabulary. For every +`console.log(` call site in `bot/src` and `web/src`: +- In `bot/src`: the line (comments stripped) MUST match + `/console\.log\(\s*[`"']\[([a-z-]+)\][^`"']*`/i` — i.e. it starts with a + bracketed surface token. Additionally the line MUST NOT contain any of the + coordinate/identifier carriers: `lat`, `lng`, `chat_id`, `user_id`, `token`, + `initData`, `geometry`, `polyline`, `payload`. (Note: `chat_id`/`user_id` + must be checked as whole words so that `[location]` does not trip + `chat`.) +- In `web/src`: the line MUST match one of + `/ARAHARAH-(DEST|LIVE|ROUTE|GUIDE|DRIVE)\s+state=/`, + `/ARAHARAH-LIVE\s+fix=/`, + `/RAMS-BASEMAP-READY/`. A `console.error(` line is allowed (the basemap + error path). Any other `console.log` FAILS the gate. +- ALSO assert that NO file in `bot/src` or `web/src` contains a bare + `JSON.stringify(` inside a `console.log(` argument — a serialized payload is + a payload leak. + +The evidence string lists each violation as `relative-path:line — `. + +**`runRuntimeHostGate()`** — the POSITIVE host allowlist. Enumerate every +`fetch(` call site in `web/src` and `bot/src` and every absolute `https://` +URL in `web/src`, `bot/src`, `theme/src`, and `web/index.html`. For each +ABSOLUTE URL, its host MUST be in the allowlist +`["tiles.openfreemap.org", "telegram.org"]` (a relative path like `/api/guidance` +is NOT a host and is allowed — the app's own Worker). For each `fetch(` call +site, the first argument MUST be a relative path (starting with `/`) or a +template literal whose static prefix is relative — NEVER an absolute URL to an +unapproved host. The evidence string must print the ACTUAL host inventory it +found (this is the A4 assumption's evidence — the host list is produced, not +assumed). + +IMPORTANT — the stripper's over-stripping is deliberate ("prefer-fail +reading"). Do NOT write a smarter stripper. The header comment must say so. + + + - `scripts/lib/source-gates.mjs` exists and exports `runStorageGate`, `runKvTtlGate`, `runLogVocabularyGate`, `runRuntimeHostGate` + - `grep -c "stripComments" scripts/lib/source-gates.mjs` returns ≥ 2 (the definition + at least one use) + - `grep -c "expirationTtl" scripts/lib/source-gates.mjs` returns ≥ 1 and `grep -c "localStorage" scripts/lib/source-gates.mjs` returns ≥ 1 + - `grep -c "console.log" scripts/lib/source-gates.mjs` returns ≥ 1 (the log-vocabulary gate exists) + - `grep -c "tiles.openfreemap.org" scripts/lib/source-gates.mjs` returns ≥ 1 (the positive allowlist names the tile host) + - `grep -c "web/src\|bot/src\|routing/src\|search/src" scripts/lib/source-gates.mjs` returns ≥ 4 (the scan is repo-wide, not web-only) + + + export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && node --check scripts/lib/source-gates.mjs && echo "syntax ok" && node -e "import('./scripts/lib/source-gates.mjs').then(m=>{for(const k of ['runStorageGate','runKvTtlGate','runLogVocabularyGate','runRuntimeHostGate']){const r=m[k]();console.log(r.id+': '+(r.pass?'PASS':'FAIL')+' — '+r.evidence.slice(0,200));}})" + + + The four source gates run over every production tree, print their + evidence, and report PASS on the current tree. + + + + + task 2: the bundle-gates library + the cloudflare-probes library + scripts/lib/bundle-gates.mjs, scripts/lib/cloudflare-probes.mjs + + - web/test/build-artifact.test.mjs (the DirEnt walk at lines 79–87, the PRODUCTION-only scope law at lines 133–147, the JS-assets-only crypto rule at lines 175–187, and the BA9 gzip measurement at lines 221–234) + - .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pitfalls 5, 6, 7, 10 + §Pattern 5 + - .planning/phases/07-hardening-private-launch/07-PATTERNS.md §No Analog Found (the live-probe guidance) + + +Create `scripts/lib/bundle-gates.mjs`: + +```javascript +export function runBundleTelemetryGate(); // id: "bundle-telemetry" — reads web/dist ONLY +``` + +- It walks `web/dist` recursively (the DirEnt walk from + `web/test/build-artifact.test.mjs` lines 79–87 — copy it). +- **PRODUCTION ONLY**: `web/dist` — NEVER `web/dist-harness` (BA5's law: a + gate pointed at the harness build inverts itself; the harness is + deliberately not clean). +- If `web/dist` does not exist, the gate returns `{ pass: false, evidence: + "web/dist does not exist — run \`npm run build -w theme && npm run build -w web\` first" }` + (never a silent pass). +- It scans every emitted `.js` file for the analytics/beacon token set: + `["google-analytics.com","googletagmanager.com","analytics.google","segment.io", + "mixpanel","sentry.io","amplitude","datadog","bugsnag","posthog","plausible.io", + "hotjar","intercom","beacon","navigator.sendBeacon","gtag(","fbq("]`. + (`beacon` is checked as `navigator.sendBeacon` — a bare `beacon` substring + would false-positive on unrelated words; use the explicit form.) +- It scans every emitted `.js` AND `.html` file for ABSOLUTE `https://` URLs + and asserts every such URL's host is in the allowlist + `["tiles.openfreemap.org", "telegram.org"]`. The evidence string PRINTS the + full host inventory it found. +- It scans the emitted `.js` assets (JS ONLY, never the HTML — the official + Telegram SDK script is external and carries its own crypto, BA8's law) for + the deep-link crypto tokens `["createHmac","crypto.subtle","sha256","HMAC("]` + — the D-11 law. (Note: `crypto.subtle` may legitimately appear in the + harness mock — but the mock never ships to `web/dist`; if the scan trips on + it, that IS a finding.) +- It scans the emitted `.js` assets for the storage tokens + `["localStorage","sessionStorage","indexedDB","document.cookie"]` — but with + the platform allowlist: the OFFICIAL Telegram SDK's own + `__telegram__initParams` sessionStorage key is written by the EXTERNAL + `telegram-web-app.js`, which is loaded as a `