feat(02-03): Worker entry — webhook route + /api/session security gate + deploy plumbing (D-11, D-14, D-16, D-17)

- src/webhook.ts: routes exactly /telegram/webhook (grammY cloudflare-mod
  adapter, constant-time secret-token auth against env.WEBHOOK_SECRET) and
  POST /api/session (validateInitData → 401 category-only rejections →
  mintSession one-time token; single mint path, no extra branch — T-02-01/02/06)
- D-14: the only log statement is '[initdata] rejected: <category>' — no
  payload, ids, or timestamps
- scripts/set-webhook.mjs: setWebhook with secret_token + allowed_updates
  [message], secrets read from gitignored .dev.vars, token never echoed
- bot deploy script chains wrangler deploy + set-webhook; wrangler.jsonc
  gains workers_dev (webhook URL target); KV id stays placeholder for the
  deploy checkpoint
- verified: 27/27 tests, typecheck clean, wrangler deploy --dry-run exit 0
This commit is contained in:
araharah-executor
2026-10-22 11:49:43 +08:00
parent b4393fbcf3
commit df3b7d65c1
4 changed files with 139 additions and 1 deletions
+1 -1
View File
@@ -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": {
+70
View File
@@ -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);
+65
View File
@@ -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<Response> {
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<Env>;
/** 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 });
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" } });
}
+3
View File
@@ -3,5 +3,8 @@
"name": "araharah-bot",
"main": "src/webhook.ts",
"compatibility_date": "2026-10-22",
// workers_dev: serve on the <name>.<account>.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" }]
}