616 lines
34 KiB
Markdown
616 lines
34 KiB
Markdown
---
|
||
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"
|
||
---
|
||
|
||
<objective>
|
||
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).
|
||
</objective>
|
||
|
||
<execution_context>
|
||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||
</execution_context>
|
||
|
||
<context>
|
||
@.planning/PROJECT.md
|
||
@.planning/ROADMAP.md
|
||
@.planning/STATE.md
|
||
@.planning/phases/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
|
||
|
||
<interfaces>
|
||
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<void> {
|
||
// 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.
|
||
</interfaces>
|
||
|
||
<tasks>
|
||
|
||
<task type="auto">
|
||
<name>task 1: grow the TelegramWebAppLike contract with the 8.0-optional fullscreen members + build the capability wrapper</name>
|
||
<files>web/src/telegram/webapp.ts, web/src/telegram/fullscreen.ts</files>
|
||
<read_first>
|
||
- 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
|
||
</read_first>
|
||
<action>
|
||
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.
|
||
</action>
|
||
<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>
|
||
<verify>
|
||
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run web:typecheck 2>&1 | tail -20</automated>
|
||
</verify>
|
||
<done>
|
||
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.
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto">
|
||
<name>task 2: re-resolve safe-area insets on safeAreaChanged / contentSafeAreaChanged</name>
|
||
<files>web/src/telegram/safe-area.ts</files>
|
||
<read_first>
|
||
- 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)
|
||
</read_first>
|
||
<action>
|
||
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).
|
||
</action>
|
||
<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>
|
||
<verify>
|
||
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run web:typecheck 2>&1 | tail -20</automated>
|
||
</verify>
|
||
<done>
|
||
`watchSafeAreaChanges` exists, re-resolves through the existing union
|
||
authority on both Telegram inset events, catches handler throws, and writes
|
||
nothing to storage.
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto">
|
||
<name>task 3: mirror the new members in the harness mock + write the FS* test suite</name>
|
||
<files>web/src/mock/telegram-mock.ts, web/test/fullscreen.test.mjs</files>
|
||
<read_first>
|
||
- 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`
|
||
</read_first>
|
||
<action>
|
||
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");`).
|
||
</action>
|
||
<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>
|
||
<verify>
|
||
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -25</automated>
|
||
</verify>
|
||
<done>
|
||
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.
|
||
</done>
|
||
</task>
|
||
|
||
<task type="auto">
|
||
<name>task 4: wire the fullscreen call into the guidance-start and trip-end edges + the safe-area watch at the boot site</name>
|
||
<files>web/src/route/route.ts, web/src/main.ts</files>
|
||
<read_first>
|
||
- 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)
|
||
</read_first>
|
||
<action>
|
||
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.
|
||
</action>
|
||
<acceptance_criteria>
|
||
- `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 exactly 2 (the CALL sites — activateGuidance + resume; the import line carries no `(` and so is not counted, which is why the paren form is used)
|
||
- `grep -c "releaseCloseProtection(" web/src/route/route.ts` returns exactly 2 (the CALL sites — teardownGuidance + stopGuidanceArtifacts; unchanged, import + 2 calls)
|
||
- `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
|
||
</acceptance_criteria>
|
||
<verify>
|
||
<automated>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</automated>
|
||
</verify>
|
||
<done>
|
||
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.
|
||
</done>
|
||
</task>
|
||
|
||
</tasks>
|
||
|
||
<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. 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. |
|
||
</threat_model>
|
||
|
||
<verification>
|
||
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`.
|
||
</verification>
|
||
|
||
<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, 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.
|
||
</success_criteria>
|
||
|
||
<output>
|
||
After completion, create `.planning/phases/07-hardening-private-launch/07-01-SUMMARY.md`
|
||
</output>
|