45 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 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 03-mini-app-shell-geolocation | 02 | execute | 2 |
|
|
true |
|
|
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'
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.
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.
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`);
}
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> |
<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
requestChatFallbacktargets 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>