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:
@@ -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
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user