feat(03-02): D-04 chain state machine + memory-only session store + /api/session client

- chain.ts: acquireFix tries telegram → webview → chat-pin in locked order; staleness
  routes to the nudge inside link 1, never to success (Pitfall 2); pluggable cadence —
  NO setInterval/watchPosition anywhere (Phase 6 picks, Open Question 2)
- isFixStale (45s = UI-SPEC signal-lost trigger) + fixAgeSeconds for FIX Ns AGO telemetry
- store.ts: module-scope token holder, zero persistence calls (the D-07 invariant);
  SESSION_TTL_SECONDS = 900 mirrors bot/src/kv/sessions.ts by name (D-03)
- client.ts: posts VERBATIM initData as the body, validates token shape, maps 401
  categories exactly (malformed/tampered/stale/network)
This commit is contained in:
araharah-executor
2026-10-22 23:39:16 +08:00
parent 9552e7e4ab
commit faab77a84f
3 changed files with 154 additions and 0 deletions
+77
View File
@@ -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<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" } };
}
/** 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));
}
+37
View File
@@ -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<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); // module scope only (D-03/D-07) — store.ts writes no storage
return { ok: true, token: t };
} catch {
return { ok: false, reason: "network" };
}
}
+40
View File
@@ -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;
}