Files
arah2/.planning/phases/07-hardening-private-launch/07-01-PLAN.md
T

28 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
07-hardening-private-launch 01 execute 1
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
true
PX-04
truths artifacts key_links
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.
path provides contains
web/src/telegram/fullscreen.ts Capability-guarded Telegram fullscreen + orientation-lock wrapper (requestGuidanceFullscreen / exitGuidanceFullscreen / watchFullscreenEvents) isVersionAtLeast("8.0")
path provides contains
web/src/telegram/webapp.ts TelegramWebAppLike contract grown with the 8.0-optional fullscreen/orientation members requestFullscreen?
path provides contains
web/src/telegram/safe-area.ts Re-resolution on safeAreaChanged / contentSafeAreaChanged (applySafeAreaOnChange) safeAreaChanged
path provides contains
web/test/fullscreen.test.mjs FS1..FS9 + old-client fallback vectors (PX-04.a-d, SC2.e) FS1
from to via pattern
web/src/route/route.ts web/src/telegram/fullscreen.ts requestGuidanceFullscreen(deps.wa ?? null) at activateGuidance() — the same edge as engageCloseProtection requestGuidanceFullscreen
from to via pattern
web/src/route/route.ts web/src/telegram/fullscreen.ts exitGuidanceFullscreen(deps.wa ?? null) at teardownGuidance() — the same edge as releaseCloseProtection exitGuidanceFullscreen
from to via pattern
web/src/telegram/safe-area.ts web/src/telegram/webapp.ts wa.onEvent('safeAreaChanged'|'contentSafeAreaChanged') re-resolves resolveSafeInsets(wa) and re-applies contentSafeAreaChanged
from to via pattern
web/src/mock/telegram-mock.ts web/src/telegram/webapp.ts the mock satisfies every new optional member (contract + fake + test together law) 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).

<execution_context> @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md </execution_context>

@.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):
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):

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):

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):

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):

  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:
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?):

  /** 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. <acceptance_criteria> - 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) </acceptance_criteria> 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:
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). <acceptance_criteria> - 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 </acceptance_criteria> 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):
    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");). <acceptance_criteria> - 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 </acceptance_criteria> 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. <acceptance_criteria> - 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 </acceptance_criteria> 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.

<threat_model>

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.
</threat_model>
Run after all tasks:
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.

<success_criteria>

  • 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. </success_criteria>
After completion, create `.planning/phases/07-hardening-private-launch/07-01-SUMMARY.md`