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:
@@ -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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const geometry = o.geometry;
|
||||
if (geometry === null || typeof geometry !== "object" || Array.isArray(geometry)) return null;
|
||||
const g = geometry as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<RouteResult | null> {
|
||||
// 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<string, string> = { "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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user