/* * webhook.ts — the Worker entry (D-11, D-14, D-16, D-17). * -------------------------------------------------------- * 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 * by a constant-time compare BEFORE any bot processing or KV touch * (T-02-03). FAIL-CLOSED (WR-02): if the WEBHOOK_SECRET binding is * missing/empty at request time, the route rejects outright — grammY's * compareSecretToken silently accepts EVERY request when its secret is * undefined, so an unarmed binding must never reach it. The bot is * instantiated per request because the token only arrives with `env` * (research-verified Workers pattern). * POST /api/session → the phase's security gate (T-02-01): the caller * posts a verbatim Telegram.WebApp.initData string — capped at 4 KB * (WR-01: over-cap bodies are rejected 413 before any parsing; real * initData is ≪ 1 KB, so this touches no legitimate client); only an * HMAC-validated, fresh identity obtains a one-time opaque session * token (D-06). Rotation of any prior token for the chat is mintSession's * own invariant (D-07 — best-effort under KV eventual consistency, see * sessions.ts) — this endpoint adds no extra branch and no extra write * path (T-02-06). * GET /api/location → GEO-02 relay (T-03-01): the Mini App polls * here with its memory-held Bearer token; the token resolves through * getClaimByToken and the KV key loc: derives from the * CLAIM — never from a client-supplied id — so one user's token can * never read another user's pin. No pin or expired token → honest * nulls / 401, nothing fabricated. * POST /api/location-fallback → GEO-02 keyboard trigger (T-03-26): the * app's denied card asks the BOT to send the request_location reply * keyboard into the user's own chat. The target chat_id AND the * 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. * 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. * * scheduled() — the repo's FIRST Cron Trigger (06-07, GUID-05, D-11): every * minute it lists the KV prefix guidance:, reads each envelope through * getGuidanceSession (a corrupt record reads null and is skipped honestly), * projects progress along the frozen snapshot via the PURE projectTurnCards, * and pushes due cards to envelope.chat_id ONLY (T-06-02 — no request path * exists in a cron tick). Street reconstruction (D-12 default) awaits the * worker-router's loadGraph memo once per tick; a null graph degrades the * card to the bare-verb street-less composition — the send still fires. * STATELESS: the cron performs ZERO KV writes (the free-plan budget law). * * The handler object is built by createExportedHandler(opts) — production * passes nothing (the default export); offline tests inject a recording * baseFetch through the same factory (the createBot({baseFetch}) seam). * * D-14: the ONLY log statements are category-only — malformed/tampered/stale * on the initData gate, misconfigured on the fail-closed webhook gate — * never the raw payload, never user ids, never timestamps. Rejection * responses carry the category only. * D-17: no secret is ever logged or echoed; the bot token / webhook secret * exist only inside `env` at runtime. */ import { webhookCallback } from "grammy"; import type { Env } from "./config.ts"; 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, GUIDANCE_KEY_PREFIX, type GuidanceRouteSnapshot, type GuidanceSessionEnvelope, } from "./kv/guidance-sessions.ts"; import { handleRoute, loadGraph } from "./routing/worker-router.ts"; import { decodePolyline6 } from "@araharah/routing/src/polyline.ts"; import { projectTurnCards, makeStreetAt } from "./guidance/cron.ts"; import { validateInitData } from "./security/initdata.ts"; import { pickLocale, strings } from "./i18n/strings.ts"; export function createExportedHandler(opts: { baseFetch?: typeof fetch } = {}): ExportedHandler { return { async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise { const url = new URL(req.url); if (url.pathname === "/telegram/webhook") { // WR-02 fail-closed: grammY's compareSecretToken accepts EVERY request // when its secret is undefined (research-confirmed runtime behavior, // convenience/webhook.js). A WEBHOOK_SECRET binding left unarmed after // deploy would therefore silently disable webhook authentication and // admit unsigned POSTs that inject fabricated updates. This guard runs // BEFORE createBot/webhookCallback; the rejection is a constant 500 // and the log carries the category only. if (!env.WEBHOOK_SECRET) { console.log("[webhook] rejected: misconfigured"); return new Response("Server misconfigured", { status: 500 }); } const bot = createBot(env); // env-bound token → per-request instantiation const cb = webhookCallback(bot, "cloudflare-mod", undefined, undefined, env.WEBHOOK_SECRET); return cb(req); } if (url.pathname === "/api/session") { return handleSession(req, env); } if (url.pathname === "/api/location") { return handleLocation(req, env); } if (url.pathname === "/api/location-fallback") { return handleLocationFallback(req, env); } 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 }); }, async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise { // waitUntil wraps the whole tick: the runtime keeps the isolate alive // past the handler's return, so a slow Telegram send still lands. ctx.waitUntil(runGuidanceCronTick(controller, env, opts)); }, } satisfies ExportedHandler; } export default createExportedHandler(); /** The envelope key scheme's prefix, sliced into a session id. */ function envelopeIdOf(keyName: string): string { return keyName.slice(GUIDANCE_KEY_PREFIX.length); } /** * scheduled()'s body: scan → validate → project → push. * * - FAIL-CLOSED posture (the webhook guard's shape): a missing BOT_TOKEN, * namespace, or Mini App origin logs `skipped: unconfigured` and returns — * an unarmed cron must never half-run. * - The graph memo is awaited ONCE per tick (worker-router's loadGraph — * the ONLY R2 loader; a second one is forbidden). A null graph is the * street-less bare-verb degradation, not a tick failure. * - Per key: getGuidanceSession (defensive — garbage reads null and the key * is skipped honestly), projectTurnCards, then sendPhoto inside try/catch. * One bad key cannot kill the tick (delivery variance is best-effort, D-13). * - EVERY log line is a category — never a chat id, coordinate, or payload. */ async function runGuidanceCronTick( controller: ScheduledController, env: Env, opts: { baseFetch?: typeof fetch }, ): Promise { if (!env.BOT_TOKEN || !env.chat_sessions || !env.MINI_APP_URL) { console.log("[guidance-cron] skipped: unconfigured"); return; } const nowSec = Math.floor(controller.scheduledTime / 1000); const graph = await loadGraph(env); let cursor: string | undefined; do { const page = await env.chat_sessions.list({ prefix: GUIDANCE_KEY_PREFIX, cursor }); for (const key of page.keys) { let envelope: GuidanceSessionEnvelope | null = null; try { envelope = await getGuidanceSession(env.chat_sessions, envelopeIdOf(key.name)); } catch { envelope = null; // a throwing read is the same honest skip class (one bad key ≠ a dead tick) } if (envelope === null) { console.log("[guidance-cron] skipped: malformed"); continue; } const streetAt = graph === null ? undefined // street-less bare-verb degradation (D-03) — the send still fires : makeStreetAt(graph, decodePolyline6(envelope.route_snapshot.geometry)); const card = projectTurnCards(envelope, nowSec, streetAt); if (!card.due || card.glyph === null || card.caption === null) continue; try { await createBot(env, opts).api.sendPhoto(envelope.chat_id, `${env.MINI_APP_URL}/glyphs/${card.glyph}.png`, { caption: card.caption, parse_mode: "HTML", reply_markup: { inline_keyboard: [ [{ text: strings.en.cardOpenButton, web_app: { url: `${env.MINI_APP_URL}/?g=${envelope.id}` } }], ], }, }); } catch { // Telegram rejected the push (blocked bot, rate limit, delivery // variance). Category only — never the chat id or error text (D-13). console.log("[guidance-cron] send failed"); } } cursor = page.list_complete ? undefined : page.cursor; } while (cursor !== undefined); } /** initData is Telegram's verbatim WebApp string — a few hundred bytes on * the wire (research: known-good fixtures ≪ 1 KB). 4 KB is generous * headroom; anything beyond is abuse aimed at the isolate's memory/CPU * budget (WR-01) — unbounded, `raw.split("&")` would allocate millions of * entries per multi-MB request. */ const MAX_INITDATA_CHARS = 4096; /** Category-only 413 for over-cap bodies (WR-01) — the error carries the * D-14 category, nothing of the request payload. */ function oversizeBody(): Response { return new Response(JSON.stringify({ error: "malformed" }), { status: 413, headers: { "content-type": "application/json" }, }); } /** Phase-2 security gate: initData in, one-time opaque session token out — * nothing else; unvalidated identities get a category-only 401 (T-02-01). */ async function handleSession(req: Request, env: Env): Promise { if (req.method !== "POST") return new Response("Method not allowed", { status: 405 }); // WR-01: reject over-cap bodies before any parse work. A declared // Content-Length lets us say no without buffering the stream; lying or // absent headers are covered by the post-read length check. The character // bound is what governs the `split("&")` allocation below — the DoS // surface this cap exists to close. if (Number(req.headers.get("content-length") ?? "0") > MAX_INITDATA_CHARS) { console.log("[initdata] rejected: malformed"); // category ONLY — no payload, no ids, no timestamps return oversizeBody(); } const raw = await req.text(); // the verbatim Telegram.WebApp.initData string if (raw.length > MAX_INITDATA_CHARS) { console.log("[initdata] rejected: malformed"); // category ONLY — no payload, no ids, no timestamps return oversizeBody(); } const result = await validateInitData(raw, env.BOT_TOKEN); if (!result.ok) { console.log(`[initdata] rejected: ${result.reason}`); // D-14: category ONLY — no payload, no ids, no timestamps return new Response(JSON.stringify({ error: result.reason }), { status: 401, headers: { "content-type": "application/json" }, }); } const token = await mintSession(env.chat_sessions, { user_id: result.user.id, chat_id: result.chat?.id ?? result.user.id, // private chat: Telegram conflates; claim surface unchanged (D-08) issued_at: Math.floor(Date.now() / 1000), language_code: result.languageCode, }); return new Response(JSON.stringify({ token }), { status: 200, headers: { "content-type": "application/json" } }); } /** GEO-02 relay (T-03-01): the Mini App polls here with its memory-held * Bearer token to pick up a pin the user shared in chat. V4 Access * Control (RESEARCH §Security Domain): the bearer token is resolved via * getClaimByToken; a missing/expired token is a 401. The KV key is * derived from the CLAIM's chat_id — NEVER from a client-supplied chat * id, so one user's token can never read another user's pin. */ async function handleLocation(req: Request, env: Env): Promise { if (req.method !== "GET") 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) { console.log("[location] rejected: unauthorized"); // category ONLY (D-14) return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401, headers: { "content-type": "application/json" }, }); } const pin = await getPin(env.chat_sessions, claim.chat_id); return new Response(JSON.stringify({ pin }), { status: 200, headers: { "content-type": "application/json" }, }); } /** GEO-02 trigger (T-03-26): the Mini App's denied card taps ASK VIA CHAT * and POSTs here; the Worker asks the BOT to send the request_location * reply keyboard in the user's own chat. Same V4 access control as * handleLocation — the target chat_id comes from the resolved CLAIM, * never from the request body, so one user's token can never push a * keyboard into another user's chat. * * The locale comes from `claim.language_code`, which mintSession already * recorded from the validated initData — the Worker does NOT re-derive it * from client input, and no language is accepted from the request body * (T-03-27). */ async function handleLocationFallback(req: Request, env: Env): Promise { if (req.method !== "POST") 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) { console.log("[location-fallback] rejected: unauthorized"); // category ONLY (D-14) return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401, headers: { "content-type": "application/json" }, }); } try { await requestLocationFallback(createBot(env), claim.chat_id, pickLocale(claim.language_code)); return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json" }, }); } catch { // Telegram rejected the send (user blocked the bot, chat not found, rate // limit). Category only — never the chat id, never the error text (D-14, T-03-28). console.log("[location-fallback] failed: telegram-send"); return new Response(JSON.stringify({ error: "unavailable" }), { status: 502, headers: { "content-type": "application/json" }, }); } } /** 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" }, }); }