feat(06-03): /api/guidance POST/GET/DELETE — Bearer-claim gated, TTL-only service beneath (GREEN)
- POST: WR-01 cap (MAX_GUIDANCE_BODY_CHARS 8192, pre/post-read double check), hand-rolled snapshot validation (slug destinationId, bounded name, finite metrics, integer startedAtSec, polyline6 geometry >= 2 points via decodePolyline6) -> mintGuidanceSession with the CLAIM's chat_id — a body chat_id is structurally ignored (T-06-02 injection probe GW4) - GET/DELETE: resolve then OWN — envelope.chat_id === claim.chat_id else 404, foreign and absent indistinguishable (T-06-02b); DELETE actively removes the record (D-14's server half); non-POST/GET/DELETE -> 405 - category-only logging: [guidance] rejected: unauthorized|malformed — literal strings, no interpolation of body/claim values (D-14) - RP2 re-pin ships WITH the change (gate re-pin law): the total @araharah/routing ban becomes an explicit allowlist — exactly the polyline6 codec may enter the Worker (geometry validation); the routing stack stays web-side; RP1 unchanged - bot/package.json + lock: @araharah/routing workspace dep
This commit is contained in:
@@ -1,26 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* route-privacy.test.mjs — the D-05 Worker boundary, gated (05-05, T-05-16).
|
||||
* route-privacy.test.mjs — the D-05 Worker boundary, gated (05-05, T-05-16;
|
||||
* re-pinned by 06-03 per the gate re-pin law).
|
||||
* ------------------------------------------------------------------------------
|
||||
* D-05: "The Worker gains no /api/route this phase. Fixtures ride in the web
|
||||
* bundle." The routing stack lives ENTIRELY in the webview — @araharah/routing
|
||||
* is imported by web/src only; the Worker imports only @araharah/search.
|
||||
* The pivot's privacy posture ("your coordinates were never sent anywhere")
|
||||
* is true partly BECAUSE no route endpoint exists to send them to — so the
|
||||
* abstinence is pinned by reading the source itself (the privacy.test.mjs /
|
||||
* PG-gate pattern: comment-stripped, prefer-fail token scans):
|
||||
* bundle." The routing STACK lives in the webview — the Worker must never
|
||||
* grow a route proxy or import the routing machinery (router, graph, A*,
|
||||
* fixtures). Phase 6 plan 06-03 legitimately broke ONE pane of that glass:
|
||||
* the frozen guidance envelope's geometry field IS polyline6, and the
|
||||
* Worker must validate it server-side (a corrupt geometry can never reach
|
||||
* the cron). So RP2 re-pins from a total ban to an explicit allowlist —
|
||||
* exactly ONE routing module may enter the Worker, the pure polyline6
|
||||
* codec; everything else routing-shaped stays out. RP1 stands unchanged.
|
||||
*
|
||||
* RP1 no route proxy — zero `/api/route` tokens AND zero
|
||||
* `registerRoute` / `routeProxy` tokens anywhere in bot/src
|
||||
* (comment-stripped): the Worker gains no route endpoint, not even
|
||||
* an accidentally-named one
|
||||
* RP2 no routing import — zero `@araharah/routing` specifiers in bot/src:
|
||||
* routing workspace code cannot leak into the Worker bundle even as
|
||||
* a dead import (the bundle would grow and the boundary would blur)
|
||||
* RP2 routing allowlist — every `@araharah/routing` specifier in bot/src
|
||||
* must be EXACTLY `@araharah/routing/src/polyline.ts` (codec
|
||||
* validation only); any other subpath fails here at test time
|
||||
*
|
||||
* The scans cover EVERY TypeScript file under bot/src and grow with the
|
||||
* tree automatically — a future route proxy fails here at test time, not
|
||||
* in review.
|
||||
* tree automatically.
|
||||
*/
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
@@ -65,15 +67,19 @@ test("RP1 no route proxy — zero /api/route + registerRoute/routeProxy tokens i
|
||||
}
|
||||
});
|
||||
|
||||
/* ————— RP2 — routing workspace code never enters the Worker ————— */
|
||||
/* ————— RP2 — the routing allowlist (re-pinned 06-03: codec only) ————— */
|
||||
|
||||
test("RP2 no routing import — zero @araharah/routing specifiers in bot/src (D-05)", () => {
|
||||
test("RP2 routing allowlist — every @araharah/routing specifier in bot/src is EXACTLY the polyline6 codec (D-05 + 06-03)", () => {
|
||||
const ALLOWED = ["@araharah/routing/src/polyline.ts"];
|
||||
const files = listFiles(SRC, [".ts"]);
|
||||
for (const file of files) {
|
||||
const code = codeText(file);
|
||||
assert.ok(
|
||||
!code.includes("@araharah/routing"),
|
||||
`${path.relative(process.cwd(), file)} imports @araharah/routing — routing lives in the webview bundle only; the Worker imports only @araharah/search (D-05)`,
|
||||
);
|
||||
const specifiers = [...code.matchAll(/["'](@araharah\/routing[^"']*)["']/g)].map((m) => m[1]);
|
||||
for (const spec of specifiers) {
|
||||
assert.ok(
|
||||
ALLOWED.includes(spec),
|
||||
`${path.relative(process.cwd(), file)} imports "${spec}" — only the polyline6 codec may enter the Worker (06-03 geometry validation); the routing stack (router/graph/astar/fixtures) stays web-side (D-05)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -306,31 +306,34 @@ test("GW2 POST with a non-conformant Bearer token (charset gate) → 401", async
|
||||
|
||||
test("GW3 POST over-cap body (8192+) with a VALID claim → 413; zero writes; category-only log; lying Content-Length backstop → 413", async () => {
|
||||
const kv = makeKV();
|
||||
const token = await bearerFor(kv, 101);
|
||||
const token = await bearerFor(kv, 101); // the claim mint writes its own 2 session keys
|
||||
const writesBefore = kv.ttlLog.length;
|
||||
const overCap = JSON.stringify({ ...VALID_BODY, destinationName: "x".repeat(9000) });
|
||||
assert.ok(overCap.length > 8192, "fixture must exceed the cap");
|
||||
const { result, seen } = await captureLogs(() => handler(guidanceReq("POST", { body: overCap, token }), makeEnv(kv), {}));
|
||||
const res = await result;
|
||||
assert.equal(res.status, 413, "the pre-read cap refuses to buffer");
|
||||
assert.deepEqual(await res.json(), { error: "malformed" });
|
||||
assert.equal(kv.ttlLog.length, 0);
|
||||
assert.equal(kv.ttlLog.length, writesBefore, "an over-cap body writes nothing");
|
||||
assert.deepEqual(seen, ["[guidance] rejected: malformed"], "category ONLY");
|
||||
|
||||
// lying header: declares 100, ships 9000+ → the post-read check closes the blind spot
|
||||
const kv2 = makeKV();
|
||||
const token2 = await bearerFor(kv2, 101);
|
||||
const writesBefore2 = kv2.ttlLog.length;
|
||||
const lying = await handler(
|
||||
guidanceReq("POST", { body: overCap, token: token2, headers: { "content-length": "100" } }),
|
||||
makeEnv(kv2),
|
||||
{},
|
||||
);
|
||||
assert.equal(lying.status, 413, "the post-read length check must backstop a lying header");
|
||||
assert.equal(kv2.ttlLog.length, 0);
|
||||
assert.equal(kv2.ttlLog.length, writesBefore2);
|
||||
});
|
||||
|
||||
test("GW4 POST happy → 201 {id: uuid}; exactly one 7200s put at guidance:<id>; body chat_id 999 IGNORED (T-06-02); extra fields dropped", async () => {
|
||||
const kv = makeKV();
|
||||
const token = await bearerFor(kv, 101);
|
||||
const token = await bearerFor(kv, 101); // the claim mint writes its own 2 session keys
|
||||
const writesBefore = kv.ttlLog.length;
|
||||
const res = await handler(
|
||||
guidanceReq("POST", { body: { ...VALID_BODY, chat_id: 999, extra: "junk" }, token }),
|
||||
makeEnv(kv),
|
||||
@@ -339,9 +342,10 @@ test("GW4 POST happy → 201 {id: uuid}; exactly one 7200s put at guidance:<id>;
|
||||
assert.equal(res.status, 201, `expected 201, got ${res.status}: ${await res.clone().text()}`);
|
||||
const { id } = await res.json();
|
||||
assert.match(id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, "opaque UUID id");
|
||||
assert.equal(kv.ttlLog.length, 1, "mint is exactly one KV write");
|
||||
assert.equal(kv.ttlLog[0].key, `guidance:${id}`);
|
||||
assert.equal(kv.ttlLog[0].expirationTtl, 7200, "TTL-only (GE1)");
|
||||
assert.equal(kv.ttlLog.length, writesBefore + 1, "mint is exactly one KV write");
|
||||
const guidanceWrite = kv.ttlLog[kv.ttlLog.length - 1];
|
||||
assert.equal(guidanceWrite.key, `guidance:${id}`);
|
||||
assert.equal(guidanceWrite.expirationTtl, 7200, "TTL-only (GE1)");
|
||||
const stored = JSON.parse(kv.map.get(`guidance:${id}`));
|
||||
assert.equal(stored.chat_id, 101, "identity comes from the CLAIM — the body's chat_id 999 is structurally ignored (T-06-02)");
|
||||
assert.deepEqual(Object.keys(stored).sort(), ["chat_id", "created_at", "id", "route_snapshot"], "frozen 4-field envelope");
|
||||
@@ -368,13 +372,14 @@ test("GW5 malformed snapshot classes → 400 {error:'malformed'} + category-only
|
||||
];
|
||||
for (const [label, body] of badBodies) {
|
||||
const kv = makeKV();
|
||||
const token = await bearerFor(kv, 101);
|
||||
const token = await bearerFor(kv, 101); // the claim mint writes its own 2 session keys
|
||||
const writesBefore = kv.ttlLog.length;
|
||||
const { result, seen } = await captureLogs(() => handler(guidanceReq("POST", { body, token }), makeEnv(kv), {}));
|
||||
const res = await result;
|
||||
assert.equal(res.status, 400, `${label} must be a 400, got ${res.status}`);
|
||||
assert.deepEqual(await res.json(), { error: "malformed" }, `${label} carries the category only`);
|
||||
assert.deepEqual(seen, ["[guidance] rejected: malformed"], `${label} logs the category ONLY`);
|
||||
assert.equal(kv.ttlLog.length, 0, `${label} writes nothing`);
|
||||
assert.equal(kv.ttlLog.length, writesBefore, `${label} writes nothing`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user