feat(06-04): /api/route engine-A arm + http-source backend #2 — Bearer-gated, honest-null, RP re-pinned (GREEN)

- bot/src/routing/worker-router.ts: module-scope per-env graph memo (1 R2 GET per
  cold isolate, shared with the 06-07 cron arm via loadGraph), snap+A*+deriveSteps,
  claim-gated 401 / 4096-cap 413 / V5 bbox 400, 200-null on every miss class,
  ONE best route (no alternatives), category-only logs
- webhook.ts: /api/route arm delegating to handleRoute (405 non-POST)
- config.ts: ROAD_GRAPH: R2Bucket — first R2 binding (never a KV namespace, GE5)
- wrangler.jsonc: r2_buckets ROAD_GRAPH -> araharah-graphs with the fork law comment
- routing/src/http-source.ts: createHttpRoutingSource — slug-guard-first, fail-honest
  (throw/non-200/malformed/null-body -> null), defensive RouteResult validation;
  exported from index.ts; ladder note: backend #2 LIVE
- A7 re-pinned (06-01 named 06-04 as the owner of createHttpRoutingSource)
- test-side fix during GREEN: the R2 fake hands decodeGraph an exact ArrayBuffer
  (Node Buffers are views; encodeGraph output is already an ArrayBuffer)
- bot 103 passed / routing 65 passed; both typechecks clean
This commit is contained in:
araharah-executor
2026-10-24 13:27:02 +08:00
parent 0abe64e734
commit 020eb92058
8 changed files with 376 additions and 16 deletions
+7
View File
@@ -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
+200
View File
@@ -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: <category>" — 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<object, Promise<RoadGraph | null>>();
/** 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<RoadGraph | null> {
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<string, unknown>;
const origin = b.origin;
if (origin === null || typeof origin !== "object" || Array.isArray(origin)) return null;
const o = origin as Record<string, unknown>;
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<Response> {
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() });
}
+9
View File
@@ -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<Env>;