28 KiB
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 |
|
true |
|
|
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
// …
}
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):
- Returns
falseimmediately whenwa === null(the plain-browser case). - Returns
falseimmediately whentypeof wa.requestFullscreen !== "function"(method-absent half of the guard). - Returns
falseimmediately whentypeof wa.isVersionAtLeast !== "function"OR!wa.isVersionAtLeast("8.0")(version half of the guard — Bot API 8.0 floor, the same floorhomescreen.tsandsafe-area.tsalready use). - Otherwise calls
wa.requestFullscreen()inside atry { … } catch { … }— a throw is CAUGHT and returnsfalse(a refused request is a supported degradation, never an error surface). It then callswa.lockOrientation?.()inside its own try/catch (Telegram's lock locks the CURRENT orientation only — never force landscape; never usescreen.orientation.lock). The function returnstrueonly when therequestFullscreen()call itself did not throw. - 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 callscreen.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.
export function watchSafeAreaChanges(wa: TelegramWebAppLike | null): void;
watchSafeAreaChanges(wa):
- Returns immediately when
wa === null(plain browser) ortypeof wa.onEvent !== "function". - Registers a handler for
"safeAreaChanged"and one for"contentSafeAreaChanged". Each handler callsapplySafeArea(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. - It MUST NOT call
resolveSafeInsetsmore 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.
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
requestFullscreenmethod is ABSENT:requestGuidanceFullscreen(wa)returnsfalse, nothing throws, and the method was never queried. - FS2 — an old client (
version: "7.9", soisVersionAtLeast("8.0")is false) withrequestFullscreenPRESENT: the function returnsfalseandwa.__calls.requestFullscreen === 0— the version guard ran BEFORE the call. - FS3 — Bot API 8.0+:
requestGuidanceFullscreen(wa)returnstrueandwa.__calls.requestFullscreen === 1(called exactly once). - FS4 — a THROWING
requestFullscreen(the platform refuses): the call does not throw out of the wrapper, the wrapper returnsfalse, and the guidance path continues (assertassert.doesNotThrow(...)). - FS5 —
exitGuidanceFullscreenmirrors the guard: absent method → false, old client → false, 8.0 client → the method is called exactly once. - FS6 —
watchFullscreenEvents: afullscreenChangedevent calls the callback withtrue; afullscreenFailedevent calls it withfalse; 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 —requestGuidanceFullscreenreturnsfalse,exitGuidanceFullscreenreturnsfalse,watchFullscreenEventsdoes not throw, and the client is still usable (no error surface). - FS8 — SAFE-AREA RE-RESOLUTION (PX-04.d): with a stub
wawhosesafeAreaInsetchanges between two values, callingwatchSafeAreaChanges(wa)and then firing"safeAreaChanged"re-applies the insets: assertdocument.documentElement.style.getPropertyValue("--safe-top")changes from the first value to the second. The test must install a minimalglobalThis.documentstub with adocumentElement.stylethat recordssetPropertycalls, and delete it in afinallyblock. - FS9 — the closing SOURCE-SCAN ban (the HS9 shape): comment-stripped
scan of
web/src/telegram/fullscreen.tsasserting the code contains NONE ofdocumentElement.requestFullscreen,screen.orientation.lock,webkitRequestFullscreen,mozRequestFullScreen,requestFullscreen(-on-document,orientation.lock. It MUST also assert the code containsisVersionAtLeast("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.
-
Add the import next to the existing
close-protection.tsimport:import { requestGuidanceFullscreen, exitGuidanceFullscreen } from "../telegram/fullscreen.ts"; -
In
activateGuidance()— the SYNCHRONOUS guidance-start transition, the same edge that already callsengageCloseProtection(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). -
In
teardownGuidance(reason)— the same edge that callsreleaseCloseProtection(deps.wa ?? null);— add ONE line directly after it:exitGuidanceFullscreen(deps.wa ?? null);The trip-end teardown releases fullscreen alongside the close protection. -
In
resume(trip)— the?g=resumed-trip entry, which already callsengageCloseProtection(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.) -
In
stopGuidanceArtifacts()— the walk-back teardown, which already callsreleaseCloseProtection(deps.wa ?? null);— add ONE line directly after it:exitGuidanceFullscreen(deps.wa ?? null); -
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> |
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.tsexists with the both-halves 8.0 guard, try/catch on every call, no DOM-fullscreen, no generic orientation lock, and zero logging.TelegramWebAppLikecarries the five 8.0-optional members; the harness mock satisfies them in the same change.watchSafeAreaChangesre-resolves the insets onsafeAreaChanged/contentSafeAreaChangedthrough the existing union authority.web/src/route/route.tsrequests 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.mjscarries FS1–FS9 and the wholewebsuite is green. </success_criteria>