853 lines
45 KiB
Markdown
853 lines
45 KiB
Markdown
---
|
||
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> |