From 7875ed4d94bfba2a71bd3cb18c2716ecf785c226 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 13:06:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(06-03):=20/api/guidance=20POST/GET/DELETE?= =?UTF-8?q?=20=E2=80=94=20Bearer-claim=20gated,=20TTL-only=20service=20ben?= =?UTF-8?q?eath=20(GREEN)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- bot/package.json | 3 +- bot/src/webhook.ts | 161 +++++++++++++++++++++++++++++++- bot/test/route-privacy.test.mjs | 42 +++++---- bot/test/webhook.test.mjs | 23 +++-- package-lock.json | 1 + 5 files changed, 201 insertions(+), 29 deletions(-) diff --git a/bot/package.json b/bot/package.json index 03037fd..6c3a2dc 100644 --- a/bot/package.json +++ b/bot/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "grammy": "^1.46.0", - "@araharah/search": "*" + "@araharah/search": "*", + "@araharah/routing": "*" }, "devDependencies": { "wrangler": "^4.151.3", diff --git a/bot/src/webhook.ts b/bot/src/webhook.ts index c416069..3563cfd 100644 --- a/bot/src/webhook.ts +++ b/bot/src/webhook.ts @@ -1,7 +1,7 @@ /* * webhook.ts — the Worker entry (D-11, D-14, D-16, D-17). * -------------------------------------------------------- - * Routes exactly two paths: + * Routes: * POST /telegram/webhook → grammY's webhook adapter (cloudflare-mod) with * the configured secret token — Telegram's updates must carry * X-Telegram-Bot-Api-Secret-Token = env.WEBHOOK_SECRET or they are 401'd @@ -33,6 +33,12 @@ * locale come from the resolved claim (T-03-27) — the request body * is never read, and no chat id or language is accepted from the * client. A rejected Telegram send is a category-only 502 (T-03-28). + * POST|GET|DELETE /api/guidance → the Phase 6 envelope endpoints (06-03, + * GUID-05/06, D-14): POST mints a guidance session (7200 s TTL) with + * the chat_id taken from the CLAIM — a client-supplied chat_id is + * 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. * Anything else → 404. * * D-14: the ONLY log statements are category-only — malformed/tampered/stale @@ -48,6 +54,13 @@ import { createBot } from "./bot/bot.ts"; import { requestLocationFallback } from "./bot/handlers/location-fallback.ts"; import { mintSession, getClaimByToken } from "./kv/sessions.ts"; import { getPin } from "./kv/location-pin.ts"; +import { + mintGuidanceSession, + getGuidanceSession, + deleteGuidanceSession, + type GuidanceRouteSnapshot, +} from "./kv/guidance-sessions.ts"; +import { decodePolyline6 } from "@araharah/routing/src/polyline.ts"; import { validateInitData } from "./security/initdata.ts"; import { pickLocale } from "./i18n/strings.ts"; @@ -79,6 +92,9 @@ export default { if (url.pathname === "/api/location-fallback") { return handleLocationFallback(req, env); } + if (url.pathname === "/api/guidance") { + return handleGuidance(req, env); + } return new Response("Not found", { status: 404 }); }, } satisfies ExportedHandler; @@ -200,3 +216,146 @@ async function handleLocationFallback(req: Request, env: Env): Promise }); } } + +/** The guidance envelope's POST body cap (WR-01, the initData precedent): + * an honest envelope is ≪ 2 KB (six snapshot fields); 8 KB is generous + * headroom, anything beyond is abuse aimed at the isolate's memory/CPU + * budget. The pre/post-read double check closes the lying-Content-Length + * blind spot (WK2b's lesson). */ +const MAX_GUIDANCE_BODY_CHARS = 8192; + +/** Destination ids are gazetteer slugs — the search workspace's own law + * (search/src/source.ts ENTRY_ID_PATTERN); a body carrying anything else + * never reaches storage. */ +const DESTINATION_ID_PATTERN = /^[a-z0-9-]{1,64}$/; + +/** Display names come from the gazetteer (short); 200 chars bounds the + * envelope line any cron card would ever render. */ +const MAX_DESTINATION_NAME_CHARS = 200; + +/** Hand-rolled snapshot validation (no zod — RESEARCH V5): every field is + * type-checked, range-checked, and the geometry must decode as polyline6 + * to ≥ 2 points. Returns a NEW object built field-by-field — anything the + * body carries beyond the six snapshot fields (chat_id included) is + * structurally dropped (T-06-02: identity and shape are server-owned). */ +function validateSnapshot(body: unknown): GuidanceRouteSnapshot | null { + if (body === null || typeof body !== "object" || Array.isArray(body)) return null; + const b = body as Record; + if (typeof b.destinationId !== "string" || !DESTINATION_ID_PATTERN.test(b.destinationId)) return null; + if ( + typeof b.destinationName !== "string" || + b.destinationName.length === 0 || + b.destinationName.length > MAX_DESTINATION_NAME_CHARS + ) { + return null; + } + if (typeof b.distanceM !== "number" || !Number.isFinite(b.distanceM) || b.distanceM < 0) return null; + if (typeof b.durationS !== "number" || !Number.isFinite(b.durationS) || b.durationS < 0) return null; + if (typeof b.startedAtSec !== "number" || !Number.isSafeInteger(b.startedAtSec) || b.startedAtSec <= 0) { + return null; + } + if (typeof b.geometry !== "string") return null; + try { + if (decodePolyline6(b.geometry).length < 2) return null; + } catch { + return null; // truncated/malformed polyline6 never reaches storage + } + return { + destinationId: b.destinationId, + destinationName: b.destinationName, + distanceM: b.distanceM, + durationS: b.durationS, + geometry: b.geometry, + startedAtSec: b.startedAtSec, + }; +} + +function guidanceUnauthorized(): Response { + console.log("[guidance] rejected: unauthorized"); // category ONLY (D-14) — never the token, never the body + return new Response(JSON.stringify({ error: "unauthorized" }), { + status: 401, + headers: { "content-type": "application/json" }, + }); +} + +function guidanceMalformed(): Response { + console.log("[guidance] rejected: malformed"); // category ONLY (D-14) — never the payload, never ids + return new Response(JSON.stringify({ error: "malformed" }), { + status: 400, + headers: { "content-type": "application/json" }, + }); +} + +/** Foreign and absent are INDISTINGUISHABLE (T-06-02b) — no existence + * oracle for cross-chat probing. */ +function guidanceNotFound(): Response { + return new Response(JSON.stringify({ error: "not_found" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); +} + +/** Guidance sessions over HTTP (GUID-05/06 server surface, D-14's delete). + * V4 access control, the handleLocation posture verbatim: the Bearer + * token resolves through getClaimByToken, and EVERYTHING chat-scoped + * derives from that CLAIM — the envelope's chat_id is minted server-side + * (a body chat_id is structurally ignored, T-06-02), and GET/DELETE + * resolve the record then compare envelope.chat_id === claim.chat_id, so + * one user's token can never read or delete another user's session. The + * envelope shape and its TTL (7200 s) are the service's own laws (GE1/GE2). */ +async function handleGuidance(req: Request, env: Env): Promise { + if (req.method !== "POST" && req.method !== "GET" && req.method !== "DELETE") { + return new Response("Method not allowed", { status: 405 }); + } + 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 guidanceUnauthorized(); + + if (req.method === "POST") { + // 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_GUIDANCE_BODY_CHARS) { + console.log("[guidance] rejected: malformed"); + return oversizeBody(); + } + const raw = await req.text(); + if (raw.length > MAX_GUIDANCE_BODY_CHARS) { + console.log("[guidance] rejected: malformed"); + return oversizeBody(); + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return guidanceMalformed(); + } + const snapshot = validateSnapshot(parsed); + if (snapshot === null) return guidanceMalformed(); + const envelope = await mintGuidanceSession(env.chat_sessions, claim.chat_id, snapshot); + return new Response(JSON.stringify({ id: envelope.id }), { + status: 201, + headers: { "content-type": "application/json" }, + }); + } + + // GET/DELETE: resolve, then OWN. The id is the only client input, and the + // service UUID-guards it before any KV touch (V5). + const id = new URL(req.url).searchParams.get("id") ?? ""; + const envelope = await getGuidanceSession(env.chat_sessions, id); + if (envelope === null || envelope.chat_id !== claim.chat_id) { + return guidanceNotFound(); + } + if (req.method === "DELETE") { + await deleteGuidanceSession(env.chat_sessions, id); + return new Response(JSON.stringify({ deleted: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response(JSON.stringify(envelope), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} diff --git a/bot/test/route-privacy.test.mjs b/bot/test/route-privacy.test.mjs index d9d1c51..6cc9bb4 100644 --- a/bot/test/route-privacy.test.mjs +++ b/bot/test/route-privacy.test.mjs @@ -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)`, + ); + } } }); diff --git a/bot/test/webhook.test.mjs b/bot/test/webhook.test.mjs index a60ca5c..b4419c9 100644 --- a/bot/test/webhook.test.mjs +++ b/bot/test/webhook.test.mjs @@ -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:; 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:; 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`); } }); diff --git a/package-lock.json b/package-lock.json index b82bfbe..91cf7c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "bot": { "name": "@araharah/bot", "dependencies": { + "@araharah/routing": "*", "@araharah/search": "*", "grammy": "^1.46.0" },