forked from c-fu/arah2
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:
@@ -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));
|
||||||
|
}
|
||||||
@@ -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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user