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));
}