--- 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/src/main.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 (watchSafeAreaChanges — the exported name, never a second alias)" contains: "safeAreaChanged" - path: "web/src/main.ts" provides: "The boot-site call site for watchSafeAreaChanges — the insets re-resolve at runtime, not only once at boot" contains: "watchSafeAreaChanges" - 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" - from: "web/src/main.ts" to: "web/src/telegram/safe-area.ts" via: "watchSafeAreaChanges(deps.wa ?? null) is CALLED at the boot site, directly after applySafeArea(resolveSafeInsets(getWebApp())) — the insets re-resolve at runtime, not only once at boot" pattern: "watchSafeAreaChanges" - from: "web/src/route/route.ts" to: "web/src/telegram/fullscreen.ts" via: "watchFullscreenEvents(deps.wa ?? null, …) is REGISTERED at the guidance-start edge (the same edge as engageCloseProtection) — the events are observability, the callback is a no-op-safe category handler" pattern: "watchFullscreenEvents" --- 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, the `web/src/main.ts` boot wiring for the safe-area watch, 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 // … } ``` From `web/src/main.ts` (the BOOT site — the ONLY place the safe-area insets are resolved today; `watchSafeAreaChanges` must be called immediately after this line, or the insets never re-resolve on a real device): ```typescript async function boot(): Promise { // 1 — safe-area vars are set before any layout reads them (PX-03 groundwork). applySafeArea(resolveSafeInsets(getWebApp())); // ← watchSafeAreaChanges(wa ?? null) belongs HERE (task 4, step 5) // … const wa = getWebApp(); // … } ``` NOTE the ORDER: `applySafeArea(resolveSafeInsets(getWebApp()))` is the FIRST statement of `boot()` and `const wa = getWebApp();` appears LATER in the same function. The watch call MUST use the same one-shot `getWebApp()` value as the apply call (`getWebApp()` is idempotent — it returns the same WebApp instance or null), so it does NOT introduce a second `getWebApp()` call and does NOT depend on the later `const wa` binding's position. 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 + the safe-area watch at the boot site web/src/route/route.ts, web/src/main.ts - web/src/route/route.ts (the activateGuidance / teardownGuidance edges — lines ~337 and ~474) - web/src/main.ts (the boot site — the `applySafeArea(resolveSafeInsets(getWebApp()))` line at the TOP of `boot()`, around line 74) - web/src/telegram/fullscreen.ts (the wrapper, as built in task 1) - web/src/telegram/safe-area.ts (the watch, as built in task 2) - .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, watchFullscreenEvents } 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. **REGISTER the fullscreen EVENT watcher at the guidance-start edge.** In `activateGuidance()` — the SAME edge as step 2 — add ONE line directly after the `requestGuidanceFullscreen(...)` line: `watchFullscreenEvents(deps.wa ?? null, () => { /* observability only — no state, no logging */ });` The callback is a deliberate NO-OP: per RESEARCH Open Question 1 the fullscreen events are OBSERVABILITY, never correctness — the UI must be fully usable without receiving either event. The registration MUST NOT log anything (the D-14 category-only law), MUST NOT change any state, and MUST NOT write any storage surface. `watchFullscreenEvents` is internally guarded (null WebApp / missing `onEvent` → returns immediately), so this call is safe in a plain browser and on an old client. Do NOT register the watcher in `resume(trip)` — `activateGuidance()` is the single guidance-start edge and the watcher must be registered exactly once per guidance session. 7. 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.`). In `web/src/main.ts`: 8. **WIRE the safe-area watch at the BOOT site.** The ONLY place the insets are resolved today is the FIRST statement of `boot()`: `applySafeArea(resolveSafeInsets(getWebApp()));` Add ONE line DIRECTLY after it (before the `// 3 (declared ahead…)` comment block): `watchSafeAreaChanges(getWebApp());` and extend the existing import to include it: `import { resolveSafeInsets, applySafeArea, watchSafeAreaChanges } from "./telegram/safe-area.ts";` WHY THIS SITE: without this call the insets are resolved ONCE at boot and never again — a device rotation or a fullscreen transition would leave the cockpit under a notch or a Telegram control while every gate stays green. `watchSafeAreaChanges` re-resolves through the SAME union authority (`resolveSafeInsets`) on `safeAreaChanged` / `contentSafeAreaChanged`, so the runtime value and the boot value can never disagree. Use `getWebApp()` — the SAME one-shot call the adjacent apply line uses — rather than the later `const wa = getWebApp();` binding (which is declared further down in `boot()` and would be a forward reference at this point). `getWebApp()` is idempotent, so this adds no second client lookup. `watchSafeAreaChanges` is internally guarded (null WebApp / missing `onEvent` → returns immediately), so the plain-browser boot path is unaffected. 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 — the CALL sites; the import line does not match because it carries no `(`) - `grep -c "exitGuidanceFullscreen(" web/src/route/route.ts` returns exactly 2 (teardownGuidance + stopGuidanceArtifacts) - `grep -c "watchFullscreenEvents(" web/src/route/route.ts` returns exactly 1 (registered ONCE, at the guidance-start edge) - `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) - `grep -c "watchSafeAreaChanges(" web/src/main.ts` returns exactly 1 (the boot-site CALL site) - `grep -c "watchSafeAreaChanges" web/src/telegram/safe-area.ts` returns ≥ 1 (the export still exists) - `grep -c "watchFullscreenEvents" web/src/telegram/fullscreen.ts` returns ≥ 1 (the export still exists) - `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 && echo "--- production call sites ---" && grep -c "watchSafeAreaChanges" web/src/main.ts && grep -c "watchFullscreenEvents" web/src/route/route.ts 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; the fullscreen event watcher is registered exactly once at the guidance-start edge; and the safe-area watch is CALLED at the boot site in `web/src/main.ts` so the insets re-resolve on rotation and fullscreen at runtime. ## 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. The watch is ALSO CALLED at the production boot site in `web/src/main.ts` (grep: `watchSafeAreaChanges` in `web/src/main.ts` ≥ 1), so the insets re-resolve at runtime — an exported-but-uncalled watch would leave the cockpit under a notch after a rotation while every gate stayed green. | | 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, AND it is CALLED at the boot site in `web/src/main.ts` (directly after the `applySafeArea(resolveSafeInsets(getWebApp()))` line) — the insets re-resolve at runtime, not only once at boot. - `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 — and registers `watchFullscreenEvents` exactly once at the guidance-start edge. - `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`