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:
araharah-executor
2026-10-24 13:06:25 +08:00
parent 212aa57c14
commit 7875ed4d94
5 changed files with 201 additions and 29 deletions
+160 -1
View File
@@ -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<Env>;
@@ -200,3 +216,146 @@ async function handleLocationFallback(req: Request, env: Env): Promise<Response>
});
}
}
/** 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<string, unknown>;
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<Response> {
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" },
});
}