/* * webhook.ts — the Worker entry (D-11, D-14, D-16, D-17). * -------------------------------------------------------- * Routes exactly two paths: * 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). 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; 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) — * this endpoint adds no extra branch and no extra write path (T-02-06). * Anything else → 404. * * D-14: the ONLY log statement is category-only (malformed/tampered/stale) — * 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 { mintSession } from "./kv/sessions.ts"; import { validateInitData } from "./security/initdata.ts"; export default { async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise { const url = new URL(req.url); if (url.pathname === "/telegram/webhook") { 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); } return new Response("Not found", { status: 404 }); }, } satisfies ExportedHandler; /** 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 }); const raw = await req.text(); // the verbatim Telegram.WebApp.initData string 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" } }); }