Files
2026-10-23 22:37:55 +08:00

853 lines
45 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 03-mini-app-shell-geolocation
plan: 02
type: execute
wave: 2
depends_on: [03-01]
files_modified:
- web/src/location/types.ts
- web/src/location/telegram.ts
- web/src/location/webview.ts
- web/src/location/chat-pin.ts
- web/src/location/chain.ts
- web/src/session/client.ts
- web/src/session/store.ts
- web/src/telegram/webapp.ts
- web/src/telegram/theme.ts
- web/src/telegram/backbutton.ts
- web/src/telegram/safe-area.ts
- web/src/mock/telegram-mock.ts
- web/test/location-chain.test.mjs
- web/test/no-storage.test.mjs
- web/test/theme.test.mjs
autonomous: true
requirements: [GEO-01, GEO-02, GEO-03, PX-02]
must_haves:
decisions_implemented:
- "D-03: session token memory-only (module scope); revalidate on every fresh boot and on 900s TTL stall"
- "D-05: gated-streaming cadence — one-shot fixes in Phase 3, no hard-coded continuous loop (pluggable for Phase 6)"
- "D-07: coordinates live in memory only; zero persistence across every storage API (grep gate + runtime probe)"
- "D-06 / D-04 (chat-keyboard link): the denied card is deliberate — retry + chat 'send location' fallback, never a silent banner"
truths:
- "A Telegram LocationManager fix, a WebView fix, and a chat-shared pin all normalize to one `Fix` shape"
- "A client below Bot API 8.0 falls through to `navigator.geolocation` without throwing"
- "A missing `window.Telegram.WebApp` entirely (plain browser) falls through without a TypeError"
- "`getLocation()` returning `null` (the denial signal) advances to the next source — never an error state"
- "`getLocation()` that never calls back resolves `null` after the wrapper timeout, so boot never wedges in ACQUIRING"
- "Two byte-identical successive fixes are detected as STALE and route to the navigator nudge, not to success"
- "The chat-pin poll is bounded at 60s, then returns to the denied card — AWAITING PIN can never persist forever"
- "The session token lives in module scope only; zero storage API is referenced anywhere in web/src"
- "Telegram `colorScheme` (or `header_bg_color` luminance fallback) sets `data-tg-theme`, and `themeChanged` touches nothing else"
artifacts:
- path: "web/src/location/types.ts"
provides: "Fix type + Source enum + LocationState machine types"
- path: "web/src/location/telegram.ts"
provides: "LocationManager.init/getLocation wrapper with hard timeout + staleness detection"
- path: "web/src/location/webview.ts"
provides: "navigator.geolocation one-shot read (also the Android freshness nudge)"
- path: "web/src/location/chat-pin.ts"
provides: "bounded 60s poll of GET /api/location with Bearer token"
- path: "web/src/location/chain.ts"
provides: "the D-04 three-source state machine with freshness as a separate axis"
- path: "web/src/session/store.ts"
provides: "module-scope in-memory token holder + TTL staleness check"
- path: "web/src/telegram/webapp.ts"
provides: "typed window.Telegram.WebApp accessor + isVersionAtLeast gate"
- path: "web/src/telegram/theme.ts"
provides: "PX-02 colorScheme -> data-tg-theme switch (attribute set is its only statement)"
- path: "web/src/mock/telegram-mock.ts"
provides: "D-11 fake WebApp + dev-signed initData + sample location streams"
- path: "web/test/no-storage.test.mjs"
provides: "GEO-03 banned-token grep gate over web/src"
key_links:
- from: "web/src/location/chain.ts"
to: "web/src/location/telegram.ts"
via: "requestTelegramFix → stale check → nudgeAndRetryTelegram"
pattern: "looksStale|nudgeAndRetryTelegram"
- from: "web/src/location/chat-pin.ts"
to: "/api/location"
via: "fetch with Authorization: Bearer <module-scope token>"
pattern: "Authorization.*Bearer"
- from: "web/src/location/chat-pin.ts"
to: "/api/location-fallback"
via: "requestChatFallback POSTs with the same Bearer token — the pathname is a contract with plan 03-01's handleLocationFallback, not a guess"
pattern: "api/location-fallback"
- from: "web/src/telegram/theme.ts"
to: "document.documentElement"
via: "dataset.tgTheme = resolveBranch(wa) — the ONLY statement applyTheme runs"
pattern: "dataset\\.tgTheme"
---
<objective>
Build the client-side location acquisition core: three source adapters normalizing to one `Fix`
type, the D-04 chain state machine with freshness as a separate axis from availability, the
memory-only session client/store, the four Telegram platform wrapper modules, and the D-11
Mock-Telegram harness — with the automated gates for GEO-01, GEO-02 (client half), GEO-03, and PX-02.
Purpose: this is the phase's highest-risk work (RESEARCH Summary findings 1–3: no `getLiveData()`
exists, the Android stale-fix inverts the naive chain reading, Desktop has no geolocation at all).
Every one of those is proven here against injected fakes, so no real device is needed until the
exit gate.
**Task count note (revision):** this plan carries FOUR tasks rather than the usual 2–3. The split
is deliberate — the single alternative was one task owning 8 files (4 wrappers + the Web-Crypto
mock + 3 test files), which put the plan exactly on the 15-file scope threshold and concentrated a
tallest-risk chunk (the harness, which the capture gate in plan 03-04 depends on) into a single
unit of work. Tasks 1–2 are the location core; task 3 is the platform wrappers; task 4 is the
harness plus the four gates.
Output: `web/src/location/{types,telegram,webview,chat-pin,chain}.ts`; `web/src/session/{client,store}.ts`;
`web/src/telegram/{webapp,theme,backbutton,safe-area}.ts`; `web/src/mock/telegram-mock.ts`;
four test files.
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
**Commit discipline:** commit after EACH task (one commit per task, atomic). If task 4 overruns
its context budget, split it rather than pushing through — this plan touches 15 files across 4 tasks,
which is above the 10-file warning line and is only safe because the code is pre-specified near-verbatim.
A task that needs more than ~7 files should be decomposed, not absorbed.
</execution_context>
<context>
@AGENTS.md
@.planning/phases/03-mini-app-shell-geolocation/03-CONTEXT.md
@.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md
@.planning/phases/03-mini-app-shell-geolocation/03-PATTERNS.md
@bot/src/security/initdata.ts
@bot/src/webhook.ts
@bot/src/kv/sessions.ts
@bot/test/webhook.test.mjs
@theme/src/theme.css
@web/tsconfig.json
@web/package.json
<interfaces>
The Worker contract this plan consumes (`bot/src/webhook.ts`, built in plan 03-01):
```
POST /api/session
request body: the verbatim Telegram.WebApp.initData string (≤ 4096 chars)
200 → { "token": "<uuid>" }
401 → { "error": "malformed" | "tampered" | "stale" }
413 → { "error": "malformed" }
GET /api/location
header: Authorization: Bearer <token>
200 → { "pin": null } or { "pin": { latitude, longitude, horizontal_accuracy } }
401 → { "error": "unauthorized" }
405 → non-GET
POST /api/location-fallback
header: Authorization: Bearer <token> (no request body is read)
200 → { "ok": true }
401 → { "error": "unauthorized" }
405 → non-POST
502 → { "error": "unavailable" } (Telegram rejected the keyboard send)
```
Dev-signed initData helper (copy this shape from `bot/test/webhook.test.mjs` into
`web/src/mock/telegram-mock.ts`; do NOT reimplement the HMAC from scratch):
```javascript
function dataCheckString(fields) {
return fields
.filter(([key]) => key !== "hash" && key !== "signature")
.slice()
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
.map(([key, value]) => `${key}=${value}`)
.join("\n");
}
function telegramSecretKey(botToken) {
return createHmac("sha256", "WebAppData").update(botToken, "utf8").digest();
}
function buildInitData(fields) {
const hash = createHmac("sha256", telegramSecretKey(botToken))
.update(dataCheckString(fields), "utf8")
.digest("hex");
const wire = fields.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&");
return `${wire}&hash=${hash}`;
}
```
The Telegram `Fix` source normalization (RESEARCH §Pattern 1 — copy verbatim):
- Telegram `LocationData` → `lat: loc.latitude`, `lng: loc.longitude`,
`accuracyM: loc.horizontal_accuracy ?? null`, `headingDeg: loc.course ?? null`, `ts: Date.now()`,
`source: 'telegram'`
- WebView `GeolocationPosition` → `accuracyM: pos.coords.accuracy`, `headingDeg: pos.coords.heading ?? null`
- Bot chat pin → `accuracyM: pin.horizontal_accuracy`, `headingDeg: null`, `source: 'chat-pin'`
</interfaces>
</context>
<tasks>
<task type="auto">
<name>task 1: `Fix` type + three source adapters (telegram / webview / chat-pin)</name>
<files>web/src/location/types.ts, web/src/location/telegram.ts, web/src/location/webview.ts, web/src/location/chat-pin.ts</files>
<read_first>
- `bot/src/security/initdata.ts` (the repo's minimal-typing + injectable-options + header-comment style)
- `bot/src/webhook.ts` (the `/api/location` contract this plan consumes)
- `.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md` §Pattern 1 and §Pattern 3 (the code shapes)
</read_first>
<action>
**`web/src/location/types.ts`** — the phase's shared contract. Header comment cites D-04/D-05/D-07.
```typescript
export type FixSource = "telegram" | "webview" | "chat-pin";
export type Fix = {
lat: number;
lng: number;
accuracyM: number | null;
headingDeg: number | null;
ts: number;
source: FixSource;
};
export type LocationState =
| { kind: "idle" }
| { kind: "validating" }
| { kind: "acquiring"; since: number }
| { kind: "awaiting-pin"; since: number }
| { kind: "live"; fix: Fix }
| { kind: "stale"; fix: Fix }
| { kind: "denied"; reason: "no-permission" | "unsupported" | "timeout" };
export const KL_CENTER: [number, number] = [101.6958, 3.1513];
```
Exported as a **type-only** module (no runtime storage, no closures over Fix instances at module scope).
**`web/src/location/telegram.ts`** — copy RESEARCH lines 268-317 as the starting point, then adapt:
```typescript
import type { Fix } from "./types.ts";
import type { TelegramWebAppLike } from "../telegram/webapp.ts";
/** NULL-SAFE by contract: `getWebApp()` returns `TelegramWebAppLike | null`, so
* this must accept null and answer false — Desktop Telegram and the plain
* browser both have no WebApp object at all (issue #77), and a TypeError here
* would crash the boot before the chain can fall through to the next link. */
export function telegramLocationSupported(wa: TelegramWebAppLike | null): boolean {
if (!wa) return false;
return typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("8.0");
}
export async function requestTelegramFix(
wa: TelegramWebAppLike,
options: { timeoutMs?: number; nowMs?: () => number } = {},
): Promise<Fix | null> {
const timeoutMs = options.timeoutMs ?? 8000;
const lm = wa.LocationManager;
if (!lm) return null;
return new Promise((resolve) => {
let settled = false;
const finish = (data: unknown | null): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (!data) { resolve(null); return; }
const loc = data as { latitude: number; longitude: number; horizontal_accuracy?: number; course?: number };
resolve({
lat: loc.latitude,
lng: loc.longitude,
accuracyM: loc.horizontal_accuracy ?? null,
headingDeg: loc.course ?? null,
ts: (options.nowMs ?? Date.now)(),
source: "telegram",
});
};
const timer = setTimeout(() => finish(null), timeoutMs);
if (lm.isInited) lm.getLocation(finish);
else lm.init(() => lm.getLocation(finish));
});
}
export function looksStale(prev: Fix | null, next: Fix): boolean {
if (prev === null || prev.source !== "telegram" || next.source !== "telegram") return false;
return haversineMeters(prev, next) < 0.01;
}
export function haversineMeters(a: Fix, b: Fix): number {
const R = 6371008.8;
const toRad = (d: number): number => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLng = toRad(b.lng - a.lng);
const s =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(s)));
}
export async function nudgeAndRetryTelegram(
wa: TelegramWebAppLike,
readWebviewFix: (o?: { timeoutMs?: number }) => Promise<Fix | null>,
lastAccepted: Fix | null,
options: { timeoutMs?: number; nowMs?: () => number } = {},
): Promise<Fix | null> {
const first = await requestTelegramFix(wa, options);
if (first === null) return null;
if (!looksStale(lastAccepted, first)) return first;
await readWebviewFix({ timeoutMs: 5000 }); // the nudge — result DISCARDED (issue #56)
return requestTelegramFix(wa, options);
}
```
The nudge's return value is deliberately discarded — that is the entire point of the Android
workaround (a WebView read refreshes the native cache the NEXT telegram read then returns).
**`web/src/location/webview.ts`**:
```typescript
import type { Fix } from "./types.ts";
export function webviewLocationSupported(): boolean {
return typeof navigator !== "undefined" && typeof navigator.geolocation?.getCurrentPosition === "function";
}
export function readWebviewFix(options: { timeoutMs?: number; nowMs?: () => number } = {}): Promise<Fix | null> {
const timeoutMs = options.timeoutMs ?? 8000;
if (!webviewLocationSupported()) return Promise.resolve(null);
return new Promise((resolve) => {
let settled = false;
const done = (fix: Fix | null): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(fix);
};
const timer = setTimeout(() => done(null), timeoutMs);
navigator.geolocation.getCurrentPosition(
(pos) => done({
lat: pos.coords.latitude,
lng: pos.coords.longitude,
accuracyM: pos.coords.accuracy ?? null,
headingDeg: pos.coords.heading ?? null,
ts: (options.nowMs ?? Date.now)(),
source: "webview",
}),
() => done(null),
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 0 },
);
});
}
```
`maximumAge: 0` is load-bearing: D-05 requires the freshest fix, and a cached position would
silently defeat the freshness axis.
**`web/src/location/chat-pin.ts`**:
```typescript
import type { Fix } from "./types.ts";
export const CHAT_PIN_POLL_MS = 2000;
export const CHAT_PIN_TIMEOUT_MS = 60000;
export async function fetchChatPin(token: string): Promise<Fix | null> {
const res = await fetch("/api/location", {
method: "GET",
headers: { authorization: `Bearer ${token}` },
});
if (!res.ok) return null;
const body: unknown = await res.json();
const pin = (body as { pin?: { latitude?: number; longitude?: number; horizontal_accuracy?: number | null } }).pin;
if (!pin || typeof pin.latitude !== "number" || typeof pin.longitude !== "number") return null;
return {
lat: pin.latitude,
lng: pin.longitude,
accuracyM: pin.horizontal_accuracy ?? null,
headingDeg: null,
ts: Date.now(),
source: "chat-pin",
};
}
export async function pollChatPin(
token: string,
options: { timeoutMs?: number; pollMs?: number; sleep?: (ms: number) => Promise<void> } = {},
): Promise<Fix | null> {
const timeoutMs = options.timeoutMs ?? CHAT_PIN_TIMEOUT_MS;
const pollMs = options.pollMs ?? CHAT_PIN_POLL_MS;
const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const fix = await fetchChatPin(token);
if (fix !== null) return fix;
await sleep(pollMs);
}
return null;
}
export async function requestChatFallback(token: string): Promise<void> {
// POST /api/location-fallback — implemented in plan 03-01 task 3 Step D as
// handleLocationFallback. The pathname is a CONTRACT with 03-01, not a
// guess; if 03-01 ever renames the route, both plans change together.
const res = await fetch("/api/location-fallback", {
method: "POST",
headers: { authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error("location-fallback-request-failed");
}
```
The route is gated identically to the poll: a 401 means the session expired and the caller must revalidate (D-03), not retry blindly. `requestChatFallback` throws so `main.ts`'s ASK VIA CHAT handler can fall back to re-running `createSession` rather than leaving the user on a card whose button appears to do nothing.
Note in this file's header comment that the bounded poll (60s) is what keeps `AWAITING PIN` from wedging forever (RESEARCH §Pattern 3). </action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run typecheck -w web 2>&1 | tail -20</automated>
</verify>
<acceptance_criteria>
- `npm run typecheck -w web` exits 0
- `grep -v '^ \*\|^/\*\|^$' web/src/location/telegram.ts | grep -c 'setTimeout' | head -1` — at least 1 occurrence (the hard timeout that prevents Pitfall 3)
- `grep -q 'maximumAge: 0' web/src/location/webview.ts` exits 0
- `grep -q 'CHAT_PIN_TIMEOUT_MS = 60000' web/src/location/chat-pin.ts` exits 0
- `grep -q 'source: "chat-pin"' web/src/location/chat-pin.ts` exits 0
- `grep -v '^ \*\|^/\*\|^$' web/src/location/telegram.ts | grep -c 'await readWebviewFix' | head -1` returns 1 (nudge result discarded — not returned)
- `grep -q 'haversineMeters(prev, next) < 0.01' web/src/location/telegram.ts` exits 0
- `grep -q 'export function telegramLocationSupported(wa: TelegramWebAppLike | null)' web/src/location/telegram.ts` exits 0 AND `grep -v '^ \*\|^/\*\|^$' web/src/location/telegram.ts | grep -c 'if (!wa) return false;' | head -1` returns 1 — a null WebApp (plain browser) must not TypeError
</acceptance_criteria>
<done>Three source adapters exist, each returning the same `Fix` shape, each with a hard timeout, the telegram adapter is null-safe on a missing WebApp, and it carries the staleness detector plus the Android nudge.</done>
</task>
<task type="auto">
<name>task 2: `chain.ts` state machine + `session/` memory-only store + `/api/session` client</name>
<files>web/src/location/chain.ts, web/src/session/store.ts, web/src/session/client.ts</files>
<read_first>
- `web/src/location/types.ts`, `web/src/location/telegram.ts`, `web/src/location/webview.ts`, `web/src/location/chat-pin.ts` (your own task-1 output — match their exported signatures exactly)
- `bot/src/webhook.ts` (the `/api/session` request/response contract)
- `bot/src/kv/sessions.ts` (the 900s TTL constant you must mirror by name, not as a bare literal)
</read_first>
<action>
**`web/src/location/chain.ts`** — the D-04 state machine. Freshness is a separate axis from availability.
```typescript
import type { Fix, LocationState } from "./types.ts";
import { nudgeAndRetryTelegram, telegramLocationSupported } from "./telegram.ts";
import { readWebviewFix, webviewLocationSupported } from "./webview.ts";
import { pollChatPin } from "./chat-pin.ts";
export interface ChainDeps {
wa: TelegramWebAppLike | null; // null off-Telegram (plain browser) — telegramLocationSupported handles it
getToken: () => string | null;
readWebview?: (o?: { timeoutMs?: number }) => Promise<Fix | null>;
pollPin?: (token: string, o?: { timeoutMs?: number }) => Promise<Fix | null>;
nowMs?: () => number;
telegramTimeoutMs?: number;
}
export async function acquireFix(deps: ChainDeps, lastAccepted: Fix | null): Promise<{ fix: Fix | null; state: LocationState }> {
const now = deps.nowMs ?? Date.now;
const readWebview = deps.readWebview ?? readWebviewFix;
const pollPin = deps.pollPin ?? pollChatPin;
// LINK 1 — Telegram-native (GEO-01 primary). Version-gated FIRST, and
// staleness is routed to the nudge, never to success (RESEARCH Pitfall 2).
if (telegramLocationSupported(deps.wa)) {
const fix = await nudgeAndRetryTelegram(deps.wa, readWebview, lastAccepted, {
timeoutMs: deps.telegramTimeoutMs ?? 8000,
nowMs: now,
});
if (fix !== null) return { fix, state: { kind: "live", fix } };
}
// LINK 2 — WebView navigator.geolocation.
if (webviewLocationSupported()) {
const fix = await readWebview({ timeoutMs: 8000 });
if (fix !== null) return { fix, state: { kind: "live", fix } };
}
// LINK 3 — chat-keyboard pin relay (GEO-02). The Desktop Telegram path:
// Desktop has NO geolocation at all (issue #77), so this is not a
// degraded backup there — it is the ONLY origin path.
const token = deps.getToken();
if (token !== null) {
const fix = await pollPin(token, { timeoutMs: 60000 });
if (fix !== null) return { fix, state: { kind: "live", fix } };
return { fix: null, state: { kind: "denied", reason: "timeout" } };
}
return { fix: null, state: { kind: "denied", reason: "no-permission" } };
}
export function isFixStale(fix: Fix, nowMs: number): boolean {
return nowMs - fix.ts > 45000;
}
export function fixAgeSeconds(fix: Fix, nowMs: number): number {
return Math.max(0, Math.round((nowMs - fix.ts) / 1000));
}
```
The 45000ms threshold is the UI-SPEC's 45s signal-lost trigger. `fixAgeSeconds` feeds the
`FIX Ns AGO` strip telemetry — the honesty surface for Pitfall 2. Header comment must record
that Phase 6 chooses between bounded `getLocation()` polling and `watchPosition()`; Phase 3
exposes a `Fix`-shaped abstraction with a **pluggable cadence** and deliberately hard-codes
no continuous-mode interval (RESEARCH Open Question 2).
**`web/src/session/store.ts`** — module-scope memory only. Header comment states what is and isn't stored, cites D-03/D-07, and names the gate (`no-storage.test.mjs`).
```typescript
export const SESSION_TTL_SECONDS = 900;
let token: string | null = null;
let issuedAtSeconds: number | null = null;
export function setSession(nextToken: string, issuedAtSec: number = Math.floor(Date.now() / 1000)): void {
token = nextToken;
issuedAtSeconds = issuedAtSec;
}
export function getSession(): string | null {
return token;
}
export function isSessionStale(nowSeconds: number = Math.floor(Date.now() / 1000)): boolean {
if (token === null || issuedAtSeconds === null) return true;
return nowSeconds - issuedAtSeconds >= SESSION_TTL_SECONDS;
}
export function clearSession(): void {
token = null;
issuedAtSeconds = null;
}
```
There is NO persistence call anywhere in this file — no localStorage, no sessionStorage, no
cookie, no IndexedDB. That absence is the D-07 invariant the grep gate pins.
**`web/src/session/client.ts`**:
```typescript
import { setSession } from "./store.ts";
export type SessionResult =
| { ok: true; token: string }
| { ok: false; reason: "malformed" | "tampered" | "stale" | "network" };
export async function createSession(initData: string): Promise<SessionResult> {
try {
const res = await fetch("/api/session", { method: "POST", body: initData });
if (res.status === 401) {
const body: unknown = await res.json().catch(() => ({ error: "malformed" }));
const reason = (body as { error?: string }).error;
return { ok: false, reason: reason === "tampered" || reason === "stale" ? reason : "malformed" };
}
if (!res.ok) return { ok: false, reason: "malformed" };
const body: unknown = await res.json();
const t = (body as { token?: unknown }).token;
if (typeof t !== "string" || t.length === 0) return { ok: false, reason: "malformed" };
setSession(t);
return { ok: true, token: t };
} catch {
return { ok: false, reason: "network" };
}
}
```
The request body is the **verbatim** `initData` string — never `initDataUnsafe` (RESEARCH §Security V2). No `credentials` option, no header carrying initData.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run typecheck -w web 2>&1 | tail -20</automated>
</verify>
<acceptance_criteria>
- `npm run typecheck -w web` exits 0
- `grep -v '^ \*\|^/\*\|^$' web/src/location/chain.ts | grep -cE 'localStorage|sessionStorage|indexedDB|caches|document.cookie' | head -1` returns 0
- `grep -v '^ \*\|^/\*\|^$' web/src/session/store.ts | grep -cE 'localStorage|sessionStorage|indexedDB|caches|document.cookie' | head -1` returns 0
- `grep -q 'export const SESSION_TTL_SECONDS = 900;' web/src/session/store.ts` exits 0 (named constant, not a bare 900 literal at a call site)
- `grep -q 'initDataUnsafe' web/src/session/client.ts` must FAIL (verbatim initData only)
- `grep -q 'nowMs - fix.ts > 45000' web/src/location/chain.ts` exits 0 (the 45s signal-lost threshold)
- `grep -v '^ \*\|^/\*\|^$' web/src/location/chain.ts | grep -cE 'setInterval|watchPosition' | head -1` returns 0 (D-05 cadence stays pluggable — no hard-coded continuous loop in Phase 3)
</acceptance_criteria>
<done>The chain tries telegram → webview → chat-pin in D-04 order, routes staleness to the nudge, and the session token lives only in a module-scope variable.</done>
</task>
<task type="auto">
<name>task 3: Telegram platform wrappers (`webapp.ts`, `theme.ts`, `backbutton.ts`, `safe-area.ts`)</name>
<files>web/src/telegram/webapp.ts, web/src/telegram/theme.ts, web/src/telegram/backbutton.ts, web/src/telegram/safe-area.ts</files>
<read_first>
- `bot/src/security/initdata.ts` (minimal hand-rolled Telegram typing precedent — lines 29-51, 62-77)
- `web/src/location/telegram.ts` (the `TelegramWebAppLike | null` import you must honour — task 1 already depends on this interface)
- `theme/src/theme.css` lines 100-109 (`--shell-bg` / `--shell-on-surface` — the PX-02 switch target)
- `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` §BackButton Interaction Contract
</read_first>
<action>
**`web/src/telegram/webapp.ts`** — narrow typed accessor. Do NOT pull in the whole Telegram surface at every call site; wrap it in one file. Note (RESEARCH §Standard Stack): `@types/telegram-web-app` is deliberately NOT a dependency — plan 03-01 dropped it because this narrow interface is what every test fake and harness mock must satisfy, and the official 500-member `WebApp` type would force each fake to stub ~50 members. This hand-rolled `TelegramWebAppLike` IS the contract.
```typescript
export interface TelegramWebAppLike {
initData: 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;
onEvent?(event: string, cb: () => void): void;
offEvent?(event: string, cb: () => void): void;
BackButton?: { show(): void; hide(): void; onClick(cb: () => void): void; offClick(cb: () => void): void };
LocationManager?: {
isInited: boolean;
isLocationAvailable?: boolean;
isAccessRequested?: boolean;
isAccessGranted?: boolean;
init(cb: () => void): void;
getLocation(cb: (data: unknown | null) => void): void;
openSettings?(): void;
};
}
export function getWebApp(): TelegramWebAppLike | null {
const w = window as unknown as { Telegram?: { WebApp?: TelegramWebAppLike } };
return w.Telegram?.WebApp ?? null;
}
```
**`web/src/telegram/theme.ts`** — copy RESEARCH §Code Examples "Theme slot switching" verbatim, including the luminance fallback:
```typescript
import type { TelegramWebAppLike } from "./webapp.ts";
type ThemeBranch = "light" | "dark";
export function resolveBranch(wa: TelegramWebAppLike): ThemeBranch {
if (wa.colorScheme === "dark" || wa.colorScheme === "light") return wa.colorScheme;
const hex = wa.themeParams?.header_bg_color;
if (typeof hex === "string" && /^#[0-9a-f]{6}$/i.test(hex)) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
return lum >= 0.5 ? "light" : "dark";
}
return "light";
}
export function applyTheme(wa: TelegramWebAppLike): void {
// The ONLY statement a theme change is allowed to touch. MapLibre is
// deliberately absent from this function (D-10 / RESEARCH Pitfall 7).
document.documentElement.dataset.tgTheme = resolveBranch(wa);
}
export function watchTheme(wa: TelegramWebAppLike): void {
applyTheme(wa);
wa.onEvent?.("themeChanged", () => applyTheme(wa));
}
```
`resolveBranch` is exported (the luminance test needs it directly in `theme.test.mjs`).
**`web/src/telegram/backbutton.ts`** (D-10 / UI-SPEC §BackButton — total function, no dead-ends):
```typescript
import type { TelegramWebAppLike } from "./webapp.ts";
export function wireBackButton(wa: TelegramWebAppLike, hasOverlay: () => boolean): void {
wa.BackButton?.hide(); // Phase 3 has ONE shell state (the map) — nothing to pop out of
wa.BackButton?.onClick(() => {
if (hasOverlay()) return;
wa.close(); // Telegram semantics: back at the root closes the app
});
}
```
An empty / no-op handler is forbidden — the copy-gate test asserts this file contains `close()`.
**`web/src/telegram/safe-area.ts`** (version-gated, `env()` union with `WebApp.safeAreaInset`, per-edge max):
```typescript
import type { TelegramWebAppLike } from "./webapp.ts";
export interface SafeInsets { top: number; bottom: number; left: number; right: number }
function envInsets(): SafeInsets {
const read = (v: string): number => {
const probe = document.createElement("div");
probe.style.cssText = `position:absolute;visibility:hidden;padding-${v}:env(safe-area-inset-${v},0px)`;
document.body.appendChild(probe);
const px = parseFloat(getComputedStyle(probe).getPropertyValue(`padding-${v}`)) || 0;
probe.remove();
return px;
};
return { top: read("top"), bottom: read("bottom"), left: read("left"), right: read("right") };
}
export function resolveSafeInsets(wa: TelegramWebAppLike | null): SafeInsets {
const base = envInsets();
const tg = wa && typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("8.0") ? wa.safeAreaInset : null;
if (!tg) return base; // old-client fallback = pure env()
return {
top: Math.max(base.top, tg.top ?? 0),
bottom: Math.max(base.bottom, tg.bottom ?? 0),
left: Math.max(base.left, tg.left ?? 0),
right: Math.max(base.right, tg.right ?? 0),
};
}
export function applySafeArea(insets: SafeInsets): void {
const root = document.documentElement.style;
root.setProperty("--safe-top", `${insets.top}px`);
root.setProperty("--safe-bottom", `${insets.bottom}px`);
root.setProperty("--safe-left", `${insets.left}px`);
root.setProperty("--safe-right", `${insets.right}px`);
}
```
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run typecheck -w web 2>&1 | tail -20 && node -e "const fs=require('fs'); for (const f of ['webapp','theme','backbutton','safe-area']) { if (!fs.existsSync('web/src/telegram/'+f+'.ts')) throw new Error('missing '+f); } console.log('4 wrappers present')"</automated>
</verify>
<acceptance_criteria>
- `npm run typecheck -w web` exits 0
- `grep -q 'export interface TelegramWebAppLike' web/src/telegram/webapp.ts` exits 0 AND `grep -q 'return w.Telegram?.WebApp ?? null;' web/src/telegram/webapp.ts` exits 0 (getWebApp can return null — that is the contract the chain must handle)
- `grep -q 'close()' web/src/telegram/backbutton.ts` exits 0 (total function, no dead-ends)
- `grep -v '^ \*\|^/\*\|^$' web/src/telegram/theme.ts | grep -c 'setStyle' | head -1` returns 0
- `grep -v '^ \*\|^/\*\|^$' web/src/telegram/theme.ts | grep -c 'dataset.tgTheme' | head -1` returns 1 (the single statement a theme change may touch)
- `grep -q 'isVersionAtLeast("8.0")' web/src/telegram/safe-area.ts` exits 0
- `grep -q 'WebApp.safeAreaInset\|safeAreaInset' web/src/telegram/safe-area.ts` exits 0
- `grep -rc 'node_modules' web/src/telegram/*.ts 2>/dev/null; grep -q '@types/telegram-web-app' web/package.json; test $? -ne 0` — the dropped package must NOT be reintroduced
</acceptance_criteria>
<done>Four narrow Telegram platform wrappers exist behind one hand-rolled interface; the theme switch's only statement is the data-attribute set, and getWebApp can honestly return null.</done>
</task>
<task type="auto">
<name>task 4: Mock-Telegram harness + the four RED gates (GEO-01, GEO-02 client, GEO-03, PX-02)</name>
<files>web/src/mock/telegram-mock.ts, web/test/location-chain.test.mjs, web/test/no-storage.test.mjs, web/test/theme.test.mjs</files>
<read_first>
- `web/src/telegram/webapp.ts` (task 3 output — `installMockWebApp` must return a `TelegramWebAppLike`)
- `web/src/location/types.ts` + `web/src/location/chain.ts` (task 1–2 output — the tests import these)
- `web/src/telegram/theme.ts` (task 3 output — `theme.test.mjs` imports `resolveBranch`/`applyTheme`/`watchTheme`)
- `bot/test/webhook.test.mjs` (the signing helper + fake-KV patterns to mirror)
- `.planning/phases/03-mini-app-shell-geolocation/03-UI-SPEC.md` §BackButton Interaction Contract
</read_first>
<action>
**`web/src/mock/telegram-mock.ts`** — the keystone (D-11). Dev/build-tagged only.
```typescript
/*
* telegram-mock.ts — the D-11 Mock-Telegram harness.
*
* Injects a fake `window.Telegram.WebApp` into the SAME window the app
* runs in — never an iframe. Bot API 10.2 (enforced 2026-07-20) rejects
* Mini App method calls from any origin other than the registered Mini
* App domain, so an iframe harness would silently fail (RESEARCH Pitfall 6).
*
* Dev-signed initData uses the SAME HMAC path as bot/src/security/initdata.ts
* (`HMAC(bot_token, "WebAppData")`) so `POST /api/session` accepts it
* unmodified against `bot/.dev.vars`. Import the token from the env; never
* hardcode a real bot token, never log it.
*
* This module ships nothing to production: `no-storage.test.mjs` and the
* build-artifact gate both assert it is excluded from `web/dist`.
*/
import { createHmac } from "node:crypto"; // dev/build only — Vite tree-shakes from the browser bundle path
```
**Important build constraint:** `node:crypto` cannot be bundled for the browser. Implement `buildInitData` with the **Web Crypto** API (`crypto.subtle.importKey` + `sign` with HMAC-SHA-256, plus `TextEncoder`), which is async; expose `export async function makeDevInitData(botToken, user)` and let the runner page await it. Do NOT import `node:crypto` in this file. The test suite (`web/test/*.test.mjs`) uses its own `node:crypto` helper copied from `bot/test/webhook.test.mjs` — that runs in Node, not the browser.
Fake WebApp shape:
```typescript
export interface MockOptions {
platform?: "android" | "ios" | "tdesktop" | "macos" | "windows" | "linux" | "web";
version?: string;
colorScheme?: "light" | "dark";
telegramFix?: { lat: number; lng: number; accuracyM?: number | null; headingDeg?: number | null } | null;
webviewFix?: { lat: number; lng: number; accuracyM?: number } | null;
grantLocation?: boolean;
}
export function installMockWebApp(options: MockOptions = {}): TelegramWebAppLike { /* returns the fake and assigns (window as any).Telegram = { WebApp } */ }
export function uninstallMockWebApp(): void { /* delete window.Telegram */ }
export function emitThemeChanged(): void { /* fire the registered themeChanged listener */ }
```
The fake's `LocationManager.getLocation(cb)` invokes `cb(options.telegramFix)` on a `setTimeout(…, 0)` when `grantLocation` is true, and `cb(null)` otherwise. `isVersionAtLeast(v)` returns true when `options.version` (default `"8.0"`) is >= v by dotted-numeric compare. `platform` defaults to `"android"`.
**`web/test/location-chain.test.mjs`** — GEO-01 + GEO-02 client half, against injected fakes (never a real MapLibre instance — ESM caveat). Cases, each named with its requirement:
```
GEO1 telegram init+getLocation yields a Fix with source 'telegram'
GEO1 a client below Bot API 8.0 falls through to navigator.geolocation without throwing
GEO1 telegramLocationSupported(null) returns false without throwing, and acquireFix with wa:null
falls through to the webview link (plain-browser leg, no window.Telegram at all)
GEO1 getLocation returning null (denied) advances to the webview source, not to an error
GEO1 getLocation that never calls back resolves null after the wrapper timeout
GEO1 two byte-identical successive telegram fixes are detected as stale and route to the nudge
GEO1 the nudge reads the webview ONCE then retries telegram (readWebview called exactly once)
GEO2 pollChatPin returns the pin when the endpoint yields one
GEO2 the chat-pin poll is bounded — a never-delivering endpoint returns null (timeoutMs 50, pollMs 5)
GEO2 a Desktop Telegram fake (no LocationManager, no navigator.geolocation) reaches denied with identical copy keys
```
Inject the fake by passing a plain object literal matching `TelegramWebAppLike` directly — do NOT construct a DOM. For `webviewLocationSupported`, the tests must set `globalThis.navigator = { geolocation: { getCurrentPosition(...) {} } }` (or delete it) per case in a `beforeEach`-style helper, and restore afterwards.
**`web/test/no-storage.test.mjs`** — GEO-03 grep gate, inverted form of `bot/test/privacy.test.mjs`:
```javascript
const BANNED = [
"localStorage", "sessionStorage", "indexedDB", "caches", "CacheStorage",
"document.cookie", "WebApp.DeviceStorage", "WebApp.SecureStorage", "WebApp.CloudStorage",
];
```
Scan every file under `web/src/` matching `**/*.{ts,html}` and assert no BANNED token appears. **Important:** the header comment of `web/src/mock/telegram-mock.ts` mentions those names in prose — the gate must scan for *code* occurrences only, so strip block comments and line comments before scanning (use a small regex pass removing `/* … */` and `// …`), and skip the mock module's *documentation*. Concretely: strip comments from the source text, THEN assert none of the BANNED tokens appear. Also add a case asserting `web/src/session/store.ts` contains no assignment to any storage object. Copy the path-scaffolding pattern from `bot/test/privacy.test.mjs` lines 23-27.
**`web/test/theme.test.mjs`** — PX-02:
```
PX02 colorScheme 'dark' sets data-tg-theme="dark"; 'light' sets "light"
PX02 a missing colorScheme falls back to header_bg_color luminance >= 0.5 (light) / < 0.5 (dark)
PX02 applyTheme touches only the data-tg-theme attribute — the map style object is not re-set
PX02 watchTheme re-applies on the themeChanged event
```
For the third case, grep the shipped `web/src/telegram/theme.ts` source for `setStyle` and assert it does NOT appear (source-introspection style, `bot/test/commands.test.mjs` CS4 precedent) — that makes the Pitfall-7 invariant greppable.
**DOM stub — mandatory.** Cases 1, 2 and 4 call `applyTheme`/`watchTheme`, which touch `document.documentElement.dataset`. The Node runner has no DOM. Install a minimal document stub in a `beforeEach`-style helper and remove it in the matching teardown (same discipline as the `navigator` stub in `location-chain.test.mjs`):
```javascript
const realDocument = globalThis.document;
function installDocumentStub() {
const dataset = {};
globalThis.document = { documentElement: { dataset, style: { setProperty() {} } } };
return dataset;
}
// per case:
const ds = installDocumentStub();
try { applyTheme(wa); assert.strictEqual(ds.tgTheme, "dark"); }
finally { globalThis.document = realDocument; }
```
The stub must expose `documentElement.dataset` as a plain writable object (that IS the assertion target) and `documentElement.style.setProperty` as a no-op, because `safe-area.ts`'s `applySafeArea` may be imported transitively. Save and restore `globalThis.document` in every case — a leaked stub would silently make a later test pass or fail for the wrong reason.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -30</automated>
</verify>
<acceptance_criteria>
- `npm test -w web` exits 0 with `[runner] N passed, 0 failed` where N >= 17 (10 GEO + 2 GEO-03 + 4 PX-02 + at least 1 storage case)
- `npm run typecheck -w web` exits 0
- `grep -v '^ \*\|^/\*\|^$' web/src/mock/telegram-mock.ts | grep -c 'node:crypto' | head -1` returns 0 (Web Crypto only — node:crypto would break the browser bundle)
- `grep -q 'import { createHmac } from "node:crypto"' web/src/mock/telegram-mock.ts; test $? -ne 0` — must FAIL (no node import in a browser-bundled module)
- `grep -q 'globalThis.document' web/test/theme.test.mjs` exits 0 (the DOM stub is installed and restored)
- `grep -q 'globalThis.navigator' web/test/location-chain.test.mjs` exits 0 (the navigator stub follows the same discipline)
- `node -e "const s=require('fs').readFileSync('web/src/location/chain.ts','utf8'); if(s.includes('localStorage')) throw new Error('GEO-03 violation'); console.log('chain clean')"`
- `node -e "const s=require('fs').readFileSync('web/src/location/telegram.ts','utf8'); if(!/telegramLocationSupported\(wa: TelegramWebAppLike \| null\)/.test(s)) throw new Error('null-guard missing'); console.log('null-guard OK')"`
</acceptance_criteria>
<done>The Mock-Telegram harness exists behind a narrow interface and the four requirement gates are green against injected fakes; no real device is needed to exercise the chain, the theme switch, or the storage prohibition.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → `/api/session` and `/api/location` | Same-origin fetches carrying the memory token; the Worker validates. |
| WebView/OS → location APIs | `LocationManager` and `navigator.geolocation` both return device data treated as unverified input. |
| Telegram client → page | `themeParams`, `colorScheme`, `safeAreaInset` are read as *signals*, never as consumed values. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-07 | Information Disclosure | Coordinate or token reaching any persistent store (D-03/D-07/GEO-03) | mitigate | `web/src/session/store.ts` holds the token in a module-scope `let`; no persistence call exists in the file. `web/test/no-storage.test.mjs` scans every `web/src/**/*.{ts,html}` file (comments stripped) for `localStorage`, `sessionStorage`, `indexedDB`, `caches`, `CacheStorage`, `document.cookie`, `DeviceStorage`, `SecureStorage`, `CloudStorage` and fails the build on any hit. |
| T-03-08 | Spoofing | Client-side bypass of the session gate | mitigate | `createSession` posts the verbatim `initData` and treats a non-200 or a non-string `token` as failure — no client-side shortcut accepts an unvalidated identity. HMAC validation stays entirely server-side; `web/` contains no crypto. |
| T-03-09 | Repudiation | A stale fix displayed as `LIVE` | mitigate | `looksStale()` compares successive telegram fixes (< 0.01 m ⇒ cache never refreshed); `isFixStale(fix, nowMs)` drives the 45s signal-lost state and `fixAgeSeconds()` renders `FIX Ns AGO`. The app never claims freshness it has not measured. |
| T-03-10 | Denial of Service | `getLocation()` never invoking its callback | mitigate | `requestTelegramFix` carries an internal `setTimeout(… , 8000)` that resolves `null`; `readWebviewFix` carries `maximumAge: 0` plus its own timeout. Neither wrapper can leave the boot choreography in `ACQUIRING` indefinitely (RESEARCH Pitfall 3). |
| T-03-11 | Information Disclosure | Telegram `themeParams` values consumed as design values | accept | `resolveBranch` reads `header_bg_color` only as a 0–1 luminance signal for the light/dark branch; no Telegram hex ever reaches a CSS custom property. Residual risk is nil — worst case is a wrong branch, which the `prefers-color-scheme` default already covers. |
| T-03-12 | Elevation of Privilege | Cross-origin `Telegram.WebApp.*` calls (Bot API 10.2, enforced 2026-07-20) | mitigate | `getWebApp()` reads `window.Telegram` from the app's own document; `telegram-mock.ts` injects into the SAME window, never an iframe. No iframe, no CDN shell, no preview subdomain may host the app. |
| T-03-29 | Denial of Service | `acquireFix({ wa: null })` throwing a TypeError before the chain can fall through | mitigate | `getWebApp()` is typed `TelegramWebAppLike \| null` and `telegramLocationSupported` accepts null, returning `false` on `if (!wa)`. The plain-browser leg of the device matrix (plan 03-04) has no `window.Telegram` at all, so this guard is load-bearing, not defensive padding. Proven by a test case and by a source grep on the signature. |
</threat_model>
<verification>
- `npm test -w web` green with at least 17 cases across GEO-01, GEO-02, GEO-03, PX-02
- `npm run typecheck -w web` clean
- No storage API token appears anywhere in `web/src` (comment-stripped grep gate)
- The `themeChanged` handler's only statement is the data-attribute set (grep-gateable)
- `telegramLocationSupported(null)` is false, not a TypeError (proven by test and by a source grep)
</verification>
<success_criteria>
- GEO-01: the telegram source, the version gate, the null-WebApp guard, the null-denial branch, the timeout, and the stale-detector all proven against fakes
- GEO-02 (client half): bounded chat-pin poll proven to return null rather than wedge, and `requestChatFallback` targets the route 03-01 actually implements (`POST /api/location-fallback`)
- GEO-03: mechanical grep gate over the entire client source tree
- PX-02: colorScheme + luminance fallback + the map-untouched invariant all proven (with a document stub installed and restored)
- Phase 6 can pick poll-vs-watchPosition later without a rewrite (pluggable cadence, no hard-coded interval)
</success_criteria>
<output>
After completion, create `.planning/phases/03-mini-app-shell-geolocation/03-02-SUMMARY.md`
</output>