Files
arah2/.planning/phases/03-mini-app-shell-geolocation/03-02-PLAN.md
T
2026-10-23 22:37:55 +08:00

45 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
03-mini-app-shell-geolocation 02 execute 2
03-01
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
true
GEO-01
GEO-02
GEO-03
PX-02
decisions_implemented truths artifacts key_links
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
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
path provides
web/src/location/types.ts Fix type + Source enum + LocationState machine types
path provides
web/src/location/telegram.ts LocationManager.init/getLocation wrapper with hard timeout + staleness detection
path provides
web/src/location/webview.ts navigator.geolocation one-shot read (also the Android freshness nudge)
path provides
web/src/location/chat-pin.ts bounded 60s poll of GET /api/location with Bearer token
path provides
web/src/location/chain.ts the D-04 three-source state machine with freshness as a separate axis
path provides
web/src/session/store.ts module-scope in-memory token holder + TTL staleness check
path provides
web/src/telegram/webapp.ts typed window.Telegram.WebApp accessor + isVersionAtLeast gate
path provides
web/src/telegram/theme.ts PX-02 colorScheme -> data-tg-theme switch (attribute set is its only statement)
path provides
web/src/mock/telegram-mock.ts D-11 fake WebApp + dev-signed initData + sample location streams
path provides
web/test/no-storage.test.mjs GEO-03 banned-token grep gate over web/src
from to via pattern
web/src/location/chain.ts web/src/location/telegram.ts requestTelegramFix → stale check → nudgeAndRetryTelegram looksStale|nudgeAndRetryTelegram
from to via pattern
web/src/location/chat-pin.ts /api/location fetch with Authorization: Bearer <module-scope token> Authorization.*Bearer
from to via pattern
web/src/location/chat-pin.ts /api/location-fallback requestChatFallback POSTs with the same Bearer token — the pathname is a contract with plan 03-01's handleLocationFallback, not a guess api/location-fallback
from to via pattern
web/src/telegram/theme.ts document.documentElement dataset.tgTheme = resolveBranch(wa) — the ONLY statement applyTheme runs dataset.tgTheme
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.

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

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

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'
task 1: `Fix` type + three source adapters (telegram / webview / chat-pin) web/src/location/types.ts, web/src/location/telegram.ts, web/src/location/webview.ts, web/src/location/chat-pin.ts - `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) **`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:

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:

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). cd /home/C-Fu/araharah && npm run typecheck -w web 2>&1 | tail -20 <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> 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.

task 2: `chain.ts` state machine + `session/` memory-only store + `/api/session` client web/src/location/chain.ts, web/src/session/store.ts, web/src/session/client.ts - `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) **`web/src/location/chain.ts`** — the D-04 state machine. Freshness is a separate axis from availability.
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).

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:

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. cd /home/C-Fu/araharah && npm run typecheck -w web 2>&1 | tail -20 <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> 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.

task 3: Telegram platform wrappers (`webapp.ts`, `theme.ts`, `backbutton.ts`, `safe-area.ts`) web/src/telegram/webapp.ts, web/src/telegram/theme.ts, web/src/telegram/backbutton.ts, web/src/telegram/safe-area.ts - `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 **`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):

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

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`);
}
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')" - `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 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. task 4: Mock-Telegram harness + the four RED gates (GEO-01, GEO-02 client, GEO-03, PX-02) web/src/mock/telegram-mock.ts, web/test/location-chain.test.mjs, web/test/no-storage.test.mjs, web/test/theme.test.mjs - `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 **`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:

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:

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

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. cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -30 <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> 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.

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

<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>
After completion, create `.planning/phases/03-mini-app-shell-geolocation/03-02-SUMMARY.md`