diff --git a/bot/src/config.ts b/bot/src/config.ts index baa8f18..3899bc3 100644 --- a/bot/src/config.ts +++ b/bot/src/config.ts @@ -9,6 +9,13 @@ export interface Env { // accept-everything. WEBHOOK_SECRET: string; // wrangler secret (D-15) — value Telegram echoes in X-Telegram-Bot-Api-Secret-Token chat_sessions: KVNamespace; // TTL-only namespace — first durable KV namespace (PRIV-02) + // D-01 graph binary — the repo's FIRST R2 binding. A bucket, NEVER a KV + // namespace (GE5: exactly one kv_namespaces). worker-router's loadGraph + // memoizes the decoded graph per isolate; an absent/corrupt object + // degrades honest (null → router-pending, D-03), never a crash. The + // bucket itself (araharah-graphs) is fork provisioning, not a secret — + // docs/ROAD-GRAPH.md owns the fork contract. + ROAD_GRAPH: R2Bucket; // The deployed Mini App ORIGIN (public, not a secret) — used to build the // OPEN button's deep link. NOT derived from user input: the deep-link // origin is a configured value (RESEARCH §Security V4 — an unvalidated diff --git a/bot/src/routing/worker-router.ts b/bot/src/routing/worker-router.ts new file mode 100644 index 0000000..69fec32 --- /dev/null +++ b/bot/src/routing/worker-router.ts @@ -0,0 +1,200 @@ +/* + * worker-router.ts — engine A's server surface: the /api/route handler (D-01). + * ------------------------------------------------------------------------------ + * snap origin → snap destination → A* → RouteResult over the R2-hosted KL + * road graph. THIS is the guidance engine (D-03): an arbitrary current + * position routes for real, $0, no VPS. The client reaches it through + * @araharah/routing's createHttpRoutingSource (backend #2). + * + * THE GRAPH LOAD — isolate memoization is the cache that works (RESEARCH): + * `caches.default` is useless here (Bearer requests auto-bypass the Workers + * Cache; cron is never cached), so the decoded graph memoizes in a + * module-scope map keyed on `env` — the plan's `graphP ??=` law in its + * per-env-safe form. Production effect is the same: ONE R2 GET per cold + * isolate, the memo is never reset, and the cron arm (plan 06-07) imports + * THIS SAME loadGraph so either arm warming it serves the other. The map is + * keyed on env (not a bare module variable) so distinct bindings — tests, + * multi-binding tooling — can never read each other's graph. + * + * HONEST DEGRADATION (T-06-03): a missing object, a failed read, or a + * binary decodeGraph rejects resolves null → the request answers JSON null + * with 200 — the designed router-pending state (D-03), never a 500. The + * null contract crosses HTTP unchanged. + * + * SECURITY POSTURE (T-06-01 / T-06-01b / T-06-04): + * - identity is the Bearer CLAIM (getClaimByToken — the webhook.ts + * posture verbatim); no/invalid token is a category-only 401 + * - the body is capped at 4096 chars (413; the initData/guidance + * precedent) BEFORE any parse work + * - the origin is type-checked and range-checked against graph.bbox + * BEFORE any snap/A* work (V5) — untrusted coordinates never reach the + * compute-heavy path + * - an unknown destinationId short-circuits before any graph work + * - ONE best route — alternatives generation is PROHIBITED on this path + * (the RESEARCH anti-pattern: 2-3 penalty runs inside an active-drive + * storm-guard window is CPU risk with zero UX need) + * - the ONLY log lines are "[route] rejected: " — never + * coordinates, ids, or payloads (the webhook law, D-14) + * + * The routing computation is LOCAL: this module never fetches, never + * proxies — the Worker IS the routing oracle (RP1). + */ +import { + decodeGraph, + deriveCumulativeDistanceM, + deriveSteps, + routeAstar, + snapToGraph, +} from "@araharah/routing/src/index.ts"; +import type { RoadGraph, RoutePoint, RouteResult } from "@araharah/routing/src/index.ts"; +import { getEntryById } from "@araharah/search/src/gazetteer.ts"; +import type { Env } from "../config.ts"; +import { getClaimByToken } from "../kv/sessions.ts"; + +/** The R2 object key this Worker loads (upload-road-graph.mjs puts it). */ +export const GRAPH_KEY = "kl-roadgraph.v1.bin"; + +/** The /api/route body cap — an honest request is ≪ 1 KB; the WR-01 law. */ +const MAX_ROUTE_BODY_CHARS = 4096; + +/** Destination ids are gazetteer slugs — the search workspace's own law. */ +const DESTINATION_ID_PATTERN = /^[a-z0-9-]{1,64}$/; + +/* ————— the per-isolate graph memo (see header) ————— */ + +const graphMemo = new WeakMap>(); + +/** The decoded road graph, or null when absent/corrupt (honest, D-03). + * Module-scope memo: 1 R2 GET per cold isolate, shared with the cron arm. */ +export function loadGraph(env: Env): Promise { + let memoized = graphMemo.get(env); + if (memoized === undefined) { + memoized = env.ROAD_GRAPH.get(GRAPH_KEY) + .then(async (obj) => (obj === null ? null : decodeGraph(await obj.arrayBuffer()))) + .catch(() => null); // a failed read degrades exactly like a corrupt binary (T-06-03) + graphMemo.set(env, memoized); + } + return memoized; +} + +/* ————— response shapes ————— */ + +function jsonHeaders(): HeadersInit { + return { "content-type": "application/json" }; +} + +function routeUnauthorized(): Response { + console.log("[route] rejected: unauthorized"); // category ONLY (T-06-04) + return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401, headers: jsonHeaders() }); +} + +function routeMalformed(): Response { + console.log("[route] rejected: malformed"); // category ONLY — never the payload, never coordinates + return new Response(JSON.stringify({ error: "malformed" }), { status: 400, headers: jsonHeaders() }); +} + +function routeOversize(): Response { + console.log("[route] rejected: malformed"); // the cap is a malformed-class rejection (WR-01) + return new Response(JSON.stringify({ error: "malformed" }), { status: 413, headers: jsonHeaders() }); +} + +/** The null contract crossing HTTP: 200 with a JSON null body (D-03). */ +function routePending(): Response { + return new Response(JSON.stringify(null), { status: 200, headers: jsonHeaders() }); +} + +/* ————— body validation (shape only — the bbox law needs the graph) ————— */ + +interface RouteBody { + origin: RoutePoint; + destinationId: string; +} + +function validateRouteBody(parsed: unknown): RouteBody | null { + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const b = parsed as Record; + const origin = b.origin; + if (origin === null || typeof origin !== "object" || Array.isArray(origin)) return null; + const o = origin as Record; + if (typeof o.lat !== "number" || !Number.isFinite(o.lat)) return null; + if (typeof o.lng !== "number" || !Number.isFinite(o.lng)) return null; + if (typeof b.destinationId !== "string" || !DESTINATION_ID_PATTERN.test(b.destinationId)) return null; + return { origin: { lat: o.lat, lng: o.lng }, destinationId: b.destinationId }; +} + +/** + * POST /api/route — Bearer-gated engine-A routing. See the module header + * for the threat posture. Every miss class (unknown destination, snap miss, + * unreachable pair, absent/corrupt graph) answers 200 null: honest + * router-pending, never an error (D-03). + */ +export async function handleRoute(req: Request, env: Env): Promise { + if (req.method !== "POST") return new Response("Method not allowed", { status: 405 }); + + // V4 access control, the webhook.ts posture verbatim: the Bearer token + // resolves through getClaimByToken. The claim is the IDENTITY GATE — + // routing itself is chat-agnostic and nothing about the caller is stored, + // logged, or echoed. + const header = req.headers.get("authorization") ?? ""; + const match = header.match(/^Bearer ([A-Za-z0-9-]+)$/); + const token = match !== null && match[1] ? match[1] : null; + const claim = token === null ? null : await getClaimByToken(env.chat_sessions, token); + if (claim === null) return routeUnauthorized(); + + // WR-01: reject over-cap bodies before any parse work; the post-read + // check backstops a lying or absent Content-Length. + if (Number(req.headers.get("content-length") ?? "0") > MAX_ROUTE_BODY_CHARS) return routeOversize(); + const raw = await req.text(); + if (raw.length > MAX_ROUTE_BODY_CHARS) return routeOversize(); + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return routeMalformed(); + } + const body = validateRouteBody(parsed); + if (body === null) return routeMalformed(); + + const graph = await loadGraph(env); + if (graph === null) return routePending(); // absent/corrupt graph → router-pending (D-03) + + // V5 (T-06-01b): the origin is range-validated against graph.bbox BEFORE + // any snap/A* work — untrusted coordinates never reach the compute path. + const { minLat, minLng, maxLat, maxLng } = graph.bbox; + if ( + body.origin.lat < minLat || + body.origin.lat > maxLat || + body.origin.lng < minLng || + body.origin.lng > maxLng + ) { + return routeMalformed(); + } + + // Unknown destination short-circuits before ANY graph work (T-06-01b). + const dest = getEntryById(body.destinationId); + if (dest === null) return routePending(); + + const originSnap = snapToGraph(graph, body.origin); + if (originSnap === null) return routePending(); // in a nodeless corner — honest pending + const destSnap = snapToGraph(graph, { lat: dest.lat, lng: dest.lng }); + if (destSnap === null) return routePending(); + + const astar = routeAstar(graph, originSnap.node, destSnap.node); + if (astar === null) return routePending(); // disconnected pair — honest pending + + const points: RoutePoint[] = astar.nodes.map((n) => ({ lat: graph.lat[n]!, lng: graph.lng[n]! })); + const result: RouteResult = { + destinationId: body.destinationId, + origin: body.origin, + options: [ + { + geometry: { points, cumulativeDistanceM: deriveCumulativeDistanceM(points) }, + summary: { distanceM: astar.distanceM, durationS: astar.seconds }, + steps: deriveSteps(graph, astar.nodes), + }, + ], + backend: "engine-a", + }; + return new Response(JSON.stringify(result), { status: 200, headers: jsonHeaders() }); +} diff --git a/bot/src/webhook.ts b/bot/src/webhook.ts index 3563cfd..fddd03f 100644 --- a/bot/src/webhook.ts +++ b/bot/src/webhook.ts @@ -39,6 +39,11 @@ * structurally ignored (T-06-02); GET/DELETE resolve the record and * compare envelope.chat_id === claim.chat_id, so foreign and absent * are indistinguishable 404s (T-06-02b). DELETE is D-14's server half. + * POST /api/route → engine A's arm (06-04, D-01): Bearer-claim + * gated EXACTLY like /api/location (T-06-01); the handler lives in + * ./routing/worker-router.ts — the R2-hosted road graph, snap, A*, and + * the honest-null degradation (D-03) all live there. Every miss class + * answers 200 null; rejections are category-only (T-06-04). * Anything else → 404. * * D-14: the ONLY log statements are category-only — malformed/tampered/stale @@ -60,6 +65,7 @@ import { deleteGuidanceSession, type GuidanceRouteSnapshot, } from "./kv/guidance-sessions.ts"; +import { handleRoute } from "./routing/worker-router.ts"; import { decodePolyline6 } from "@araharah/routing/src/polyline.ts"; import { validateInitData } from "./security/initdata.ts"; import { pickLocale } from "./i18n/strings.ts"; @@ -95,6 +101,9 @@ export default { if (url.pathname === "/api/guidance") { return handleGuidance(req, env); } + if (url.pathname === "/api/route") { + return handleRoute(req, env); + } return new Response("Not found", { status: 404 }); }, } satisfies ExportedHandler; diff --git a/bot/test/webhook.test.mjs b/bot/test/webhook.test.mjs index 2bbaa31..44b3351 100644 --- a/bot/test/webhook.test.mjs +++ b/bot/test/webhook.test.mjs @@ -522,10 +522,18 @@ const CORRIDOR_BUF = corridorGraphBuffer(); const SPLIT_BUF = corridorGraphBuffer({ gap: true }); const SPARSE_BUF = sparseGraphBuffer(); -/** An R2 fake over one fixed binary; counts + records its gets (the memo probe). */ +/** An R2 fake over one fixed binary; counts + records its gets (the memo probe). + * Node Buffers are views over a pooled ArrayBuffer — the fake hands over an + * exact copy (the astar suite's toArrayBuffer law) so decodeGraph sees what + * a real R2 arrayBuffer() returns. */ function r2With(maybeBuf) { let gets = 0; let lastKey = null; + const exact = () => { + if (maybeBuf === null) return null; + if (maybeBuf instanceof ArrayBuffer) return maybeBuf; // encodeGraph's product + return maybeBuf.buffer.slice(maybeBuf.byteOffset, maybeBuf.byteOffset + maybeBuf.byteLength); // fs Buffer (view) + }; return { gets: () => gets, lastKey: () => lastKey, @@ -533,7 +541,7 @@ function r2With(maybeBuf) { gets += 1; lastKey = key; if (maybeBuf === null) return null; // R2 object absent - return { arrayBuffer: async () => maybeBuf }; + return { arrayBuffer: async () => exact() }; }, }; } diff --git a/bot/wrangler.jsonc b/bot/wrangler.jsonc index 4d803d7..e0e972b 100644 --- a/bot/wrangler.jsonc +++ b/bot/wrangler.jsonc @@ -20,5 +20,13 @@ // It must equal the Mini App URL registered in @BotFather (Bot API 10.2 // enforces the origin). Never derived from user input. "vars": { "MINI_APP_URL": "https://araharah-bot.araharah-bot.workers.dev" }, + // R2 binding (D-01, the repo's FIRST r2_buckets entry): hosts the road-graph + // binary /api/route loads (worker-router.ts memoizes ONE GET per cold + // isolate). A bucket is NOT a KV namespace — GE5 (exactly ONE + // kv_namespaces) still holds. Forks create + upload their OWN bucket: + // `npx wrangler r2 bucket create ` + routing/scripts/upload-road-graph.mjs + // (docs/ROAD-GRAPH.md is the fork contract). The bucket name is public + // configuration, never a secret. + "r2_buckets": [{ "binding": "ROAD_GRAPH", "bucket_name": "araharah-graphs" }], "kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }] } diff --git a/routing/src/http-source.ts b/routing/src/http-source.ts new file mode 100644 index 0000000..ec108b9 --- /dev/null +++ b/routing/src/http-source.ts @@ -0,0 +1,128 @@ +/* + * http-source.ts — RoutingSource backend #2's client seam (D-01, D-03). + * --------------------------------------------------------------------------- + * createHttpRoutingSource plugs the Worker's Bearer-gated POST /api/route + * (engine A: snap + A* over the R2-hosted CSR graph) beneath the Phase 5 + * RoutingSource seam, so the web app's route flow needs no rewrite: the + * backend ladder becomes fixture (demo corpus) → engine-a (real routing, + * THIS module) → engine B (fully client-side, documented post-v1, D-02). + * + * THE LADDER LAW HOLDS: backend selection happens at the import site, in + * code — no env read, no config read, no dynamic registry (T-05-04). The + * endpoint and the bearer getter are INJECTED by the caller (web wiring, + * plan 06-05): the module itself never reads storage and never holds a + * token — `bearer` is a getter so a token minted after construction is + * still sent (the memory-held session token's lifetime law). + * + * FAIL-HONEST (T-05-04's .catch(() => null) law, network edition): a + * thrown fetch, a non-200, a malformed body, or a shape violation resolves + * NULL — the honest "router pending" state (D-03), never a throw in the + * webview's render path and never a fabricated route. A 200 with a JSON + * null body IS the null contract crossing HTTP; it resolves null too. + * + * DEFENSIVE VALIDATION (the fixture-source isUsablePair posture): every + * field of the response is type-checked — the destinationId must answer + * THIS request, options must number 1..3, every number finite, the + * cumulativeDistanceM array must match points 1:1 (the day-one progress + * contract). A payload that fails any check is treated as no answer. + * + * The slug guard runs FIRST: a hostile destinationId never reaches the + * network (the fixture-source posture). + * + * Pure client: no logging, no storage. The origin travels to OUR Worker in + * the request body the caller asked for (the one in-drive position the + * privacy law permits transmitting — RESEARCH Pattern 4); nothing here + * persists or logs it. + */ + +import type { RoutePoint, RouteRequest, RouteResult, RoutingSource } from "./types.ts"; + +/** Destination ids are gazetteer slugs — the search workspace's own law. */ +const ID_PATTERN = /^[a-z0-9-]{1,64}$/; + +export interface HttpRoutingSourceOptions { + /** The Worker's /api/route URL (the configured Mini App origin). */ + endpoint: string; + /** Returns the memory-held session token, or null when logged out. */ + bearer: () => string | null; + /** Injectable fetch — tests pass a fake; production uses the global. */ + fetchImpl?: typeof fetch; +} + +function isRoutePoint(value: unknown): value is RoutePoint { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const p = value as Record; + return typeof p.lat === "number" && Number.isFinite(p.lat) && typeof p.lng === "number" && Number.isFinite(p.lng); +} + +/** + * Every field type-checked, every number finite, options 1..3, and the + * geometry arrays consistent (points 1:1 with cumulativeDistanceM). Returns + * the payload narrowed to RouteResult, or null — the caller's contract. + */ +function validatedResult(payload: unknown, request: RouteRequest): RouteResult | null { + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) return null; + const r = payload as Record; + if (r.destinationId !== request.destinationId) return null; // must answer THIS request + if (!isRoutePoint(r.origin)) return null; + if (typeof r.backend !== "string" || r.backend.length === 0) return null; + if (!Array.isArray(r.options) || r.options.length < 1 || r.options.length > 3) return null; + for (const option of r.options) { + if (option === null || typeof option !== "object" || Array.isArray(option)) return null; + const o = option as Record; + const geometry = o.geometry; + if (geometry === null || typeof geometry !== "object" || Array.isArray(geometry)) return null; + const g = geometry as Record; + if (!Array.isArray(g.points) || g.points.length < 1) return null; + for (const point of g.points) { + if (!isRoutePoint(point)) return null; + } + if (!Array.isArray(g.cumulativeDistanceM) || g.cumulativeDistanceM.length !== g.points.length) return null; + for (const meters of g.cumulativeDistanceM) { + if (typeof meters !== "number" || !Number.isFinite(meters) || meters < 0) return null; + } + const summary = o.summary; + if (summary === null || typeof summary !== "object" || Array.isArray(summary)) return null; + const s = summary as Record; + if (typeof s.distanceM !== "number" || !Number.isFinite(s.distanceM) || s.distanceM < 0) return null; + if (typeof s.durationS !== "number" || !Number.isFinite(s.durationS) || s.durationS < 0) return null; + if (!Array.isArray(o.steps)) return null; + for (const step of o.steps) { + if (step === null || typeof step !== "object" || Array.isArray(step)) return null; + const st = step as Record; + if (st.distanceM !== undefined && (typeof st.distanceM !== "number" || !Number.isFinite(st.distanceM))) return null; + if (st.geometry !== undefined && typeof st.geometry !== "string") return null; + if (st.name !== undefined && typeof st.name !== "string") return null; + } + } + return payload as RouteResult; +} + +/** Backend #2 of the D-01 ladder — "engine-a". See the module header. */ +export function createHttpRoutingSource(opts: HttpRoutingSourceOptions): RoutingSource { + const doFetch = opts.fetchImpl ?? fetch.bind(globalThis); + return { + name: "engine-a", + async route(req: RouteRequest): Promise { + // Slug guard FIRST — a hostile shape never reaches the network (T-05-03). + if (!ID_PATTERN.test(req.destinationId)) return null; + let payload: unknown; + try { + const token = opts.bearer(); + const headers: Record = { "content-type": "application/json" }; + if (token !== null) headers.authorization = `Bearer ${token}`; + const res = await doFetch(opts.endpoint, { + method: "POST", + headers, + body: JSON.stringify({ origin: req.origin, destinationId: req.destinationId }), + }); + if (!res.ok) return null; // 401/404/5xx — no answer is an honest null + payload = await res.json(); // throws on a malformed body → the catch below + } catch { + return null; // fail-honest (T-05-04, network edition) + } + if (payload === null) return null; // router-pending over HTTP (D-03) + return validatedResult(payload, req); + }, + }; +} diff --git a/routing/src/index.ts b/routing/src/index.ts index 8f9b88c..7471f96 100644 --- a/routing/src/index.ts +++ b/routing/src/index.ts @@ -13,18 +13,18 @@ * THE BACKEND LADDER (D-01 supersedes the phase-5 "wasm-router" note): * #1 "fixture" — createFixtureRoutingSource: the golden-pairs demo * corpus (15 committed KL pairs). - * #2 "engine-a" — the Worker-side router built on THIS plan's data - * plane: decodeGraph + snapToGraph + routeAstar over an - * R2-hosted CSR graph binary. Engine B (a fully + * #2 "engine-a" — LIVE (plan 06-04): the Worker-side router built on + * THIS package's data plane (decodeGraph + snapToGraph + + * routeAstar over an R2-hosted CSR graph), consumed + * client-side through createHttpRoutingSource (the + * Bearer-gated POST /api/route seam). Engine B (a fully * client-side webview router — graph on the phone) is * engine A's documented post-v1 evolution (D-02). The * Valhalla-to-JS port was ruled infeasible and * geojson-path-finder rejected (demo-grade: no * alternatives/one-ways) — recorded so neither is ever * re-proposed (D-02). - * Plan 04 adds `createHttpRoutingSource` (the /api/route surface that - * plugs engine A beneath the RoutingSource seam) — deliberately absent - * here. + * Engine B remains the documented future rung; nothing else is planned. * * The module reads NO environment, NO secrets, and NO network (D-05/D-12): * the whole routing stack is V8-native and dependency-free — fixtures ride @@ -44,6 +44,8 @@ export { getPairOrigin, getPairs, } from "./fixture-source.ts"; +export { createHttpRoutingSource } from "./http-source.ts"; +export type { HttpRoutingSourceOptions } from "./http-source.ts"; export type { RoadGraph, GraphEdgeInput, GraphInput } from "./graph.ts"; export { decodeGraph, diff --git a/routing/test/astar.test.mjs b/routing/test/astar.test.mjs index 511784f..fd9b58a 100644 --- a/routing/test/astar.test.mjs +++ b/routing/test/astar.test.mjs @@ -27,8 +27,9 @@ * (0,-90)->left / (0,90)->right cases; the * compass-consistent reading is pinned here) * A7 public surface — index.ts exposes the engine-A exports; - * createHttpRoutingSource is NOT here yet - * (plan 04 adds it) + * createHttpRoutingSource is LIVE (re-pinned + * by 06-04, the plan that added it — the + * original pin named this plan as the owner) * A8 structural — no `new Map(` in astar.ts (allocation-free * hot loop: typed arrays only) * @@ -251,7 +252,7 @@ test("A6 classifyTurn pins — compass convention: negative bearing delta = left assert.equal(classifyTurn(350, 80), "right", "90 deg across north is a right"); }); -test("A7 public surface — index.ts exposes engine A; createHttpRoutingSource is NOT here yet", async () => { +test("A7 public surface — index.ts exposes engine A + the engine-a HTTP source (re-pinned 06-04)", async () => { const mod = await import("../src/index.ts"); for (const name of [ "decodeGraph", @@ -266,14 +267,11 @@ test("A7 public surface — index.ts exposes engine A; createHttpRoutingSource i "TURN_STRAIGHT_MAX_DEG", "TURN_SLIGHT_MAX_DEG", "TURN_UTURN_MIN_DEG", + "createHttpRoutingSource", ]) { assert.notEqual(mod[name], undefined, `index.ts must export ${name}`); } - assert.equal( - mod.createHttpRoutingSource, - undefined, - "createHttpRoutingSource belongs to plan 04 — NOT this plan", - ); + assert.equal(typeof mod.createHttpRoutingSource, "function", "backend #2's client seam is live (D-01, 06-04)"); }); test("A8 structural — no `new Map(` in astar.ts (typed arrays only in the hot loop)", () => {