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>;
|
||||
|
||||
@@ -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() };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 <name>` + 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" }]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
Reference in New Issue
Block a user