diff --git a/bot/package.json b/bot/package.json index 8a01519..8124490 100644 --- a/bot/package.json +++ b/bot/package.json @@ -6,7 +6,7 @@ "test": "node test/runner.mjs", "typecheck": "tsc --noEmit", "dev": "wrangler dev", - "deploy": "wrangler deploy", + "deploy": "wrangler deploy && node scripts/set-webhook.mjs", "commands": "node scripts/register-commands.mjs" }, "dependencies": { diff --git a/bot/scripts/set-webhook.mjs b/bot/scripts/set-webhook.mjs new file mode 100644 index 0000000..0ed1821 --- /dev/null +++ b/bot/scripts/set-webhook.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +/* + * set-webhook.mjs — one-time setWebhook registration (D-16, D-17). + * ----------------------------------------------------------------- + * Points Telegram at the Worker's /telegram/webhook route and arms the + * secret-token channel: the `secret_token` value sent here is the one + * Telegram echoes back in X-Telegram-Bot-Api-Secret-Token on every update, + * which the Worker's webhook adapter constant-time-compares against + * env.WEBHOOK_SECRET (T-02-03). + * + * Inputs come from bot/.dev.vars (gitignored, D-15): BOT_TOKEN, + * WEBHOOK_URL, WEBHOOK_SECRET. The bot token is never echoed — on failure + * only the API's error_code/description are printed (T-02-04). + * Exit 0 only when Telegram answers ok:true. + */ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +function readDevVars() { + const devVarsPath = path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", ".dev.vars"); + let raw = ""; + try { + raw = readFileSync(devVarsPath, "utf8"); + } catch { + return {}; + } + const vars = {}; + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + vars[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim(); + } + return vars; +} + +const devVars = readDevVars(); +const token = process.env.BOT_TOKEN ?? devVars.BOT_TOKEN; +const webhookUrl = process.env.WEBHOOK_URL ?? devVars.WEBHOOK_URL; +const secret = process.env.WEBHOOK_SECRET ?? devVars.WEBHOOK_SECRET; + +const missing = ["BOT_TOKEN", "WEBHOOK_URL", "WEBHOOK_SECRET"].filter( + (name) => !{ BOT_TOKEN: token, WEBHOOK_URL: webhookUrl, WEBHOOK_SECRET: secret }[name], +); +if (missing.length > 0) { + console.error(`Missing ${missing.join(", ")} — set them in bot/.dev.vars (see .dev.vars.example; never commit).`); + process.exit(1); +} + +const payload = { + url: webhookUrl, + secret_token: secret, + allowed_updates: ["message"], +}; + +const res = await fetch(`https://api.telegram.org/bot${token}/setWebhook`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), +}); +const body = await res.json(); + +if (!body.ok) { + console.error(`setWebhook failed: ${body.error_code ?? "?"} ${body.description ?? "unknown error"}`); + process.exit(1); +} +console.log(`setWebhook ok:true — updates will be delivered to the configured URL with the secret-token header.`); +process.exit(0); diff --git a/bot/src/webhook.ts b/bot/src/webhook.ts new file mode 100644 index 0000000..2ed1018 --- /dev/null +++ b/bot/src/webhook.ts @@ -0,0 +1,65 @@ +/* + * 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" } }); +} diff --git a/bot/wrangler.jsonc b/bot/wrangler.jsonc index fb6a811..9021db3 100644 --- a/bot/wrangler.jsonc +++ b/bot/wrangler.jsonc @@ -3,5 +3,8 @@ "name": "araharah-bot", "main": "src/webhook.ts", "compatibility_date": "2026-10-22", + // workers_dev: serve on the ..workers.dev URL — the WEBHOOK_URL + // target for this phase (Phase 3's custom route/domain may supersede) + "workers_dev": true, "kv_namespaces": [{ "binding": "chat_sessions", "id": "KV_ID_PLACEHOLDER" }] }