Files
arah2/bot/src/webhook.ts
T
araharah-executor ad612299ca feat(06-07): scheduled() cron tick wired — factory, loadGraph street memo, crons trigger (GREEN)
- webhook.ts: createExportedHandler(opts) factory (offline send capture via
  baseFetch); scheduled() wraps runGuidanceCronTick in ctx.waitUntil —
  fail-closed unconfigured gate, paginated guidance: KV list (list_complete
  respected), loadGraph awaited ONCE per tick (the shared R2 memo), per-key
  getGuidanceSession → projectTurnCards → sendPhoto to envelope.chat_id ONLY
  (T-06-02), every log a [guidance-cron] category, zero KV writes
- guidance-sessions.ts: GUIDANCE_KEY_PREFIX exported — the ONE key-scheme
  authority (list scans derive prefix + slice ids from it)
- wrangler.jsonc: triggers.crons */1 * * * * (60 s scan, cadence in code)
- guidance-cron.test.mjs GREEN completion: GC-C3/C5/C8/C9 wire the recording
  baseFetch through the factory (the header-documented seam — they defined
  the recorder but never passed it); GC-C7 uses readdirSync (require is
  undefined in ESM); GC-C1/C8/C9 drain inside the withConsole window (the
  tick's logs fire under waitUntil)
- guidance-envelope.test.mjs: GE7 allows the GUIDANCE_KEY_PREFIX named import
- 125/125 bot tests green; bot:typecheck clean
2026-10-24 19:49:00 +08:00

468 lines
22 KiB
TypeScript

/*
* 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:<chat_id> 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<Env> {
return {
async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
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<void> {
// 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<Env>;
}
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<void> {
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<Response> {
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<Response> {
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<Response> {
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<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" },
});
}