diff --git a/web/src/location/chain.ts b/web/src/location/chain.ts new file mode 100644 index 0000000..9edbb83 --- /dev/null +++ b/web/src/location/chain.ts @@ -0,0 +1,77 @@ +/* + * chain.ts — the D-04 three-source acquisition state machine. + * -------------------------------------------------------- + * Order is locked: telegram → webview → chat-pin. Freshness is a + * SEPARATE axis from availability (RESEARCH Pitfall 2): a telegram fix + * that is byte-identical to the last accepted one routes to the nudge + * inside nudgeAndRetryTelegram — it is never returned as success. The + * first honest fix wins; denial is a state, not an exception. + * + * Cadence contract (RESEARCH Open Question 2, resolved open-by-design): + * this module exposes a Fix-shaped abstraction with a PLUGGABLE cadence + * and hard-codes NO continuous-mode interval (no setInterval, no + * watchPosition). Phase 6 chooses between bounded getLocation() polling + * and watchPosition() without a rewrite. Post-live re-acquisition + * re-runs acquireFix; it does not stream. + * + * The 45000 ms staleness threshold is the UI-SPEC's 45s signal-lost + * trigger; fixAgeSeconds feeds the FIX Ns AGO strip telemetry — the + * honesty surface for the Android static-fix (issue #56). + */ +import type { Fix, LocationState } from "./types.ts"; +import type { TelegramWebAppLike } from "../telegram/webapp.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; + pollPin?: (token: string, o?: { timeoutMs?: number }) => Promise; + 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" } }; +} + +/** The UI-SPEC's 45s signal-lost trigger — `LIVE` past this age is a lie + * (T-03-09): the strip flips to SIGNAL LOST and the last marker dims. */ +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)); +} diff --git a/web/src/session/client.ts b/web/src/session/client.ts new file mode 100644 index 0000000..f37a492 --- /dev/null +++ b/web/src/session/client.ts @@ -0,0 +1,37 @@ +/* + * client.ts — the /api/session client (T-03-08, RESEARCH §Security V2). + * ------------------------------------------------------------------- + * The request body is the VERBATIM Telegram.WebApp.initData string — + * never a re-serialization of its parsed fields (Telegram's own docs + * warn that the rebuilt form breaks HMAC validation). No credentials + * option, no custom header carrying initData: it IS the body. Non-200 + * and a non-string token both read as failure — there is no + * client-side shortcut that accepts an unvalidated identity; HMAC + * validation stays entirely server-side. A 401 carries exactly + * bot/src/webhook.ts's rejection vocabulary (malformed | tampered | + * stale); "network" covers fetch-level failures (offline, DNS). + */ +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 { + 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); // module scope only (D-03/D-07) — store.ts writes no storage + return { ok: true, token: t }; + } catch { + return { ok: false, reason: "network" }; + } +} diff --git a/web/src/session/store.ts b/web/src/session/store.ts new file mode 100644 index 0000000..f8ec2bc --- /dev/null +++ b/web/src/session/store.ts @@ -0,0 +1,40 @@ +/* + * store.ts — the memory-only session token holder (D-03, D-07). + * ---------------------------------------------------------- + * WHAT is stored: the opaque session token and its issuance second, in + * module scope (closure over `let`). WHAT is never stored: anything + * else — no coordinates, and NO persistence call anywhere in this file + * (no localStorage, no sessionStorage, no cookie, no IndexedDB, no + * caches). That absence is the D-07 invariant the grep gate pins + * (web/test/no-storage.test.mjs — comments stripped, every web/src file). + * + * The 900 s TTL mirrors bot/src/kv/sessions.ts by NAME (D-03): a + * mid-session stall at or beyond SESSION_TTL_SECONDS is stale, so the + * caller re-runs createSession with fresh initData rather than posting + * a doomed Bearer header. Revalidation happens on every fresh boot and + * on every TTL stall — never from any persisted copy, because none + * exists. + */ +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; +}