Files
arah2/.planning/phases/02-bot-skeleton-initdata-security/02-03-PLAN.md
T
2026-10-23 22:37:55 +08:00

21 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves, user_setup
phase plan type wave depends_on files_modified autonomous requirements must_haves user_setup
02-bot-skeleton-initdata-security 03 execute 3
02-01
02-02
bot/src/i18n/strings.ts
bot/src/bot/bot.ts
bot/src/bot/commands/start.ts
bot/src/bot/commands/help.ts
bot/src/bot/commands/privacy.ts
bot/src/webhook.ts
bot/scripts/register-commands.mjs
bot/scripts/set-webhook.mjs
bot/test/commands.test.mjs
bot/wrangler.jsonc
false
ONBD-01
PRIV-01
truths artifacts key_links
D-01/D-02/D-04: a real Telegram user sends /start (or /mula) and receives one honest clinical sentence with no buttons/inline keyboards
D-03/D-04: the bot's command menu lists exactly /help and /privacy via setMyCommands in default + ms scopes — nothing else, no stub future entries
D-01/D-05/D-17: /privacy states stored-facts and never-facts in en + ms that match the code's actual KV writes (PRIV-01)
D-05: unknown Telegram locales fall back to English; ms-prefixed locales get the Malay surface
D-14/D-16/D-17: webhook 401s without the secret-token header; only validated initData identities obtain a one-time token; rejection logs are category-only; deploy is local wrangler + setWebhook
path provides contains
bot/src/i18n/strings.ts Single copy source of truth (en + ms with identical key-sets) pickLocale
path provides contains
bot/src/webhook.ts Worker entry: /telegram/webhook + /api/session security gate + D-14 category-only logs cloudflare-mod
path provides contains
bot/scripts/register-commands.mjs One-time setMyCommands (default + ms scope — D-03/D-04) setMyCommands
path provides contains
bot/scripts/set-webhook.mjs setWebhook with secret_token (D-16 deploy scripting) secret_token
from to via pattern
bot/src/webhook.ts bot/src/security/initdata.ts handleSession → validateInitData → 401 on rejection / mint on success validateInitData
from to via pattern
bot/src/webhook.ts bot/src/kv/sessions.ts mintSession called ONLY after ok:true (one mint path — PRIV-02) mintSession
from to via pattern
bot/src/webhook.ts bot/src/bot/bot.ts webhookCallback(bot, 'cloudflare-mod', secretToken=env.WEBHOOK_SECRET) webhookCallback
from to via pattern
bot/src/i18n/strings.ts bot/src/bot/commands/*.ts handlers import copy from strings (no inline copy — D-05) strings
service why env_vars dashboard_config
telegram-bot Real BOT_TOKEN — webhook validation, session minting, and command-menu registration all authenticate with the bot token from @BotFather
name source
BOT_TOKEN Telegram @BotFather -> /newbot -> telegram -> copy token
task location
Keep token secret; paste into bot/.dev.vars (never commit) local file bot/.dev.vars (D-15)
service why env_vars
cloudflare Workers deploy target + KV namespace chat_sessions (D-16 deploy gate)
name source
CLOUDFLARE_API_TOKEN (or automated wrangler login) Cloudflare Dashboard -> My Profile -> API Tokens (or `npx wrangler login` browser flow)
Build the user-facing Telegram surface (commands + honest privacy copy + Malay aliases) and wire the Worker: webhook route with secret-token auth, `/api/session` security gate minting tokens, command-menu registration, deploy plumbing — then pause for the human deploy gate (D-16) that proves the whole phase in a real client.

Purpose: ROADMAP SC1/SC2 (visible command surface, honest /privacy) become real bot behavior; the tested security core becomes reachable (/api/session is the phase's security-gate entry). Output: runtime-complete bot + wired worker + one-time registration scripts; deploy checkpoint for ONBD-01.

<execution_context> @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md </execution_context>

@.planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md @.planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md @.planning/phases/02-bot-skeleton-initdata-security/02-VALIDATION.md @.planning/phases/02-bot-skeleton-initdata-security/02-01-SUMMARY.md @.planning/phases/02-bot-skeleton-initdata-security/02-02-SUMMARY.md

From bot/src/config.ts: interface Env { BOT_TOKEN: string; WEBHOOK_SECRET: string; chat_sessions: KVNamespace }

From bot/src/security/initdata.ts (plan 02):

export type InitDataRejection = "malformed" | "tampered" | "stale";
export async function validateInitData(raw: string, botToken: string,
  opts?: { nowSeconds?: number; maxAgeSeconds?: number }): Promise<{ ok: true; user: {id: number; language_code?: string}; chat?: {id: number; type: string}; languageCode: string | null; authDate: number } | { ok: false; reason: InitDataRejection }>;

From bot/src/kv/sessions.ts (plan 02):

export const SESSION_TTL_SECONDS = 900;
export interface SessionClaim { user_id: number; chat_id: number; issued_at: number; language_code: string | null; }
export async function mintSession(kv: KVNamespace, claim: SessionClaim): Promise<string>;

grammY webhook adapter (plan 02 RESEARCH verified): webhookCallback(bot, "cloudflare-mod", onTimeout, timeoutMilliseconds, secretToken) from "grammy" — constant-time X-Telegram-Bot-Api-Secret-Token check whose config is this plan's env.WEBHOOK_SECRET.

task 1: strings module + command surface with aliases (D-01, D-02, D-03, D-04, D-05) bot/src/i18n/strings.ts, bot/src/bot/commands/start.ts, bot/src/bot/commands/help.ts, bot/src/bot/commands/privacy.ts, bot/src/bot/bot.ts, bot/test/commands.test.mjs - .planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md (D-01..D-05 verbatim + "strings module is the copy's tokens.css") - .planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md § Code Examples (setMyCommands localized scopes — grammar verified) - bot/src/kv/sessions.ts (SESSION_TTL_SECONDS / claim fields — the copy's "what is stored" facts must match this code, D-17) - bot/test/harness.mjs (test API) 1. **bot/src/i18n/strings.ts** — single copy source of truth (D-05), en default + `ms` (Bahasa Melayu), clinical + honest tone (D-01, no emoji, no marketing). Keys (identical key-set in both locales — test asserts): ```typescript export interface BotStrings { startIntro: string; // D-01: what araharah is, one sentence privacyStored: string; // exactly what is stored — must name: Telegram user id, chat id, language, kept minutes (claim fields from sessions.ts) privacyNotStored: string; // exactly what is NOT stored — must name: location, routes, trip history, no profile (D-17: copy verifiable against code) helpMenu: string; // lists only /help and /privacy (D-03 — no stub entries for future commands) } export type Locale = "en" | "ms"; export const strings: Record = { en: {…}, ms: {…} }; export function pickLocale(telegramLanguageCode?: string | null): Locale; // "ms" prefix match → "ms"; every other value (incl. null/undefined) → "en" (D-05 fallback rule) ``` Concrete copy (use as written; MS kept parallel in meaning): - en.startIntro: `"araharah — navigation through Telegram for the KL metro. Everything it knows about you expires in about fifteen minutes; /privacy shows exactly what that means."` - en.privacyStored: `"What araharah keeps: your Telegram user id, this chat id, and your interface language — kept only for about 15 minutes so the bot can answer you, then gone."` - en.privacyNotStored: `"What araharah never keeps: your location, your routes, your trip history, any profile about you."` - en.helpMenu: Commands `/help …` and `/privacy …` descriptions (matches the setMyCommands registry — D-03; /help itself is a live command this phase so listing it is honest). - ms.startIntro: `"araharah — navigasi melalui Telegram untuk kawasan KL. Semua yang diketahui tentang anda luput dalam lebih kurang lima belas minit; /privacy menunjukkan dengan tepat maksudnya."` - ms.privacyStored: `"Yang araharah simpan: id pengguna Telegram anda, id chat ini, dan bahasa antara muka anda — disimpan hanya sekitar 15 minit supaya bot boleh menjawab anda, kemudian hilang."` - ms.privacyNotStored: `"Yang araharah tidak pernah simpan: lokasi anda, laluan anda, sejarah perjalanan anda, apa-apa profil tentang anda."` - ms.helpMenu: parallel to en. 2. **Commands as pure builders + thin grammar wiring (testable without a Bot instance):** each command module exports `replyText(kind, locale)` pure functions and the grammy handler that calls them. Handlers use **plain text-only replies** — `ctx.reply(text, { link_preview_options: { is_disabled: true } })` and NOTHING else (D-02: no reply keyboard, no inline buttons; the structural assertion lives in the test). - `start.ts`: exports handler for both `/start` and `/mula` (aliases on the same command stack, D-04) - `help.ts`: `/help` handler (no alias — D-04 names later-phase aliases only) - `privacy.ts`: handlers for `/privacy` and `/privasi` (D-04); reply = privacyStored + "\n\n" + privacyNotStored 3. **bot/src/bot/bot.ts:** `createBot(env: Env): Bot` — instantiates `new Bot(env.BOT_TOKEN)`, registers handlers above (alias forms via `bot.command("mula", …)` etc.), and **sets NO keyboards, NO inline buttons anywhere** (D-02). Menu registration lives in the one-time script (next item), not per-request. 4. **bot/scripts/register-commands.mjs** (one-time; D-03/D-04 — node, zero deps beyond `node:fs`): - Reads `BOT_TOKEN` from `bot/.dev.vars` (parse `KEY=VALUE` lines) - `fetch` to `https://api.telegram.org/bot/setMyCommands` twice: - default scope: `{ "commands": [ {command:"help", desc-en}, {command:"privacy", desc-en} ], "bot_command_scope": { "type": "default" } }` - `ms` scope: same two commands with Malay descriptions + `"language_code": "ms"` - Exits 0 on `ok:true`; prints the JSON response; no other commands registered (D-03 — no stub entries for future commands like /cari) 5. **bot/test/commands.test.mjs:** - strings integrity: en/ms key-sets identical (assert key arrays sort-equal); `pickLocale("ms-MY") → "ms"`, `pickLocale("en") → "en"`, `pickLocale(null/undefined/\"id\") → "en"` - PRIV-01 copy gate: `strings.en.privacyNotStored` + `ms.privacyNotStored` mention location, routes, history; `privacyStored` mentions the TTL minutes wording - D-02 structural: the bot module source contains no `"keyboard"` or `"inline_keyboard"`/`InlineKeyboard` token (grep gate on bot/src/bot/bot.ts + commands files) - helpMenu text matches the two descriptions passed to register-commands (gap-free D-03) npm test -w bot && npm run bot:typecheck && node --check bot/scripts/register-commands.mjs - `bot/src/i18n/strings.ts` exports `strings` (Record<"en"|"ms", BotStrings>) and `pickLocale`, with identical key-sets across locales (suite asserts) - commands.test.mjs asserts privacyNotStored text mentions location + routes + history (PRIV-01 gate) and privacyStored mentions 15 minutes - bot/src/bot/bot.ts + command files grep: zero occurrences of `keyboard` / `inline_keyboard` (D-02) - register-commands.mjs contains exactly two fetch calls to `setMyCommands`, one with `"language_code": "ms"`, one without; command lists contain only `help` and `privacy` (D-04) - `npm test -w bot` exit 0, `npm run bot:typecheck` exit 0 Copy lives only in strings.ts (en+ms); /start /help /privacy + /mula /privasi handlers wired text-only; menu registration script ready with honest D-03 scope. task 2: Worker entry — webhook route, /api/session security gate, startWebhook plumbing (D-14, D-16, D-17) bot/src/webhook.ts, bot/scripts/set-webhook.mjs, bot/wrangler.jsonc - bot/src/webhook.ts is new; read bot/src/bot/bot.ts, src/security/initdata.ts, src/kv/sessions.ts (contracts in ) - .planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md § Pattern 1 (real webhookCallback signature — verify before wiring) + § RESEARCH.md Architecture diagram - bot/wrangler.jsonc (created in plan 01 — KV_ID_PLACEHOLDER remains until task 3 checklist fills it) 1. **bot/src/webhook.ts** (the D-11 "worker entry"): ```typescript 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 (research-verified pattern) const cb = webhookCallback(bot, "cloudflare-mod", undefined, undefined, env.WEBHOOK_SECRET); return cb(req); } if (url.pathname === "/api/session") { // Phase 2 security gate — Mini App caller arrives Phase 4 return handleSession(req, env); } return new Response("Not found", { status: 404 }); }, } satisfies ExportedHandler;

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 raw payload, NO user 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" } }); }

(Replay: a repeated valid initData rotates through mintSession — plan 02's D-07 invariant closes it; the endpoint adds no extra branch.)
2. **bot/scripts/set-webhook.mjs:** one-time; reads BOT_TOKEN + WEBHOOK_URL + WEBHOOK_SECRET from `bot/.dev.vars`; `fetch` `https://api.telegram.org/bot<TOKEN>/setWebhook` with `{ "url": WEBHOOK_URL, "secret_token": WEBHOOK_SECRET, "allowed_updates": ["message"] }`; prints result; exit 0 only on `ok:true` (D-16).
3. **bot/package.json scripts (edit — no new file):** `"deploy": "wrangler deploy && node scripts/set-webhook.mjs"` (D-16: deploy + webhook in one command), keeping `commands`, `dev`, `test`, `typecheck` from plan 01.
4. **bot/wrangler.jsonc:** add `"workers_dev": true` comment marker only; leave `KV_ID_PLACEHOLDER` for task 3 (deployment checklist fills it).
</action>
<verify>
 <automated>npm test -w bot && npm run bot:typecheck && (cd bot && npx wrangler deploy --dry-run --outdir /tmp/opencode/worker-dryrun)</automated>
</verify>
<acceptance_criteria>
- `bot/src/webhook.ts` routes exactly `/telegram/webhook` and `/api/session` (dry-run bundle accepts `satisfies ExportedHandler<Env>`)
- The only initdata-related log statement is category-only: grep `bot/src/webhook.ts` for `console.log` statements each matching `\`[initdata] rejected: ${…`} — no payload/ids logging (D-14)
- `set-webhook.mjs` passes `secret_token` = WEBHOOK_SECRET into setWebhook payload (verified secret-token flow)
- `wrangler deploy --dry-run` exits 0 (no account credentials needed — local esbuild only)
- `npm run bot:typecheck` + `npm test -w bot` exit 0 (existing suites unbroken)
</acceptance_criteria>
<done>Worker builds for deploy; security gate reachable at /api/session; webhook auth + D-14 category logging in place; nothing secret leaves `env` reading.</done>
</task>

<task type="checkpoint:human-verify" gate="blocking">
<name>task 3: deploy gate — real-client walkthrough of the full command surface (D-16, ONBD-01)</name>
<action>Automate everything possible first (credentials aside): wrangler kv namespace create + fill id, `npm run bot:deploy` (wrangler deploy + setWebhook), `npm run bot:commands`, KV key spot-check — then walk the user through the numbered real-client verification in <how-to-verify>. Human-only steps: @BotFather token, wrangler login/browser auth, Telegram client walkthrough (a–e).</action>
<what-built>
The complete Phase 2 bot: grammY bot with /start /help /privacy (+ /mula /privasi aliases), honest RAMS-tone privacy copy (en + ms), TTL-only KV sessions behind the /api/session initData security gate, secret-token-protected webhook, all under CI test vectors (green suites). All backend code paths are automated-verified; what remains needs a real Telegram client, a real bot token, and a real Worker deploy (D-16: no CI yet — human deploy gate).
</what-built>
<how-to-verify>
 OpenCode automates everything below EXCEPT the credential steps and the real-client walkthrough; run these in order:
 1. Credentials (human-only): create bot via @BotFather → copy token into `bot/.dev.vars` as `BOT_TOKEN=...`; generate `WEBHOOK_SECRET=$(openssl rand -hex 32)` and add to `bot/.dev.vars`; authenticate Cloudflare (`npx wrangler login` or CLOUDFLARE_API_TOKEN).
 2. OpenCode runs: `npx wrangler kv namespace create chat_sessions` → copy returned id into `bot/wrangler.jsonc` replacing `KV_ID_PLACEHOLDER`; set `WEBHOOK_URL` in `.dev.vars` to the printed workers.dev URL after `npm run bot:deploy` (deploy + setWebhook via scripts).
 3. OpenCode runs `npm run bot:commands` (registers /help /privacy default + ms scopes).
 4. Real-client walkthrough (human, this checkpoint): in Telegram against your own bot —
    a. send `/start` → one sentence intro, NO buttons (D-01/D-02)
    b. open the command menu (menu button) → ONLY /help and /privacy listed (D-03)
    c. `/privacy` → stored-facts block (user id, chat id, language, ~15 min) + never-block (location, routes, history, profile)
    d. `/mula` behaves exactly like /start; `/privasi` like /privacy
    e. send gibberish `/xyz` → no crash, no menu drift
 5. Storage spot-check (PRIV-02 work-around): OpenCode runs `npx wrangler kv key list` after a manual /start — only `session:*`/`chat:*` keys, TTL-bounded, no coordinates; keys vanish after ~15 min.
</how-to-verify>
<resume-signal>Type "approved" or describe issues — deploy gate per ROADMAP SC1–SC4 (ONBD-01, PRIV-01, PRIV-02) must all be observed before phase exit.</resume-signal>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Telegram servers → webhook endpoint | updates arrive over the public internet; secret-token header is the gatekeeper |
| Mini App client → /api/session | initData arrives untrusted from the user's webview |
| Worker logs → Cloudflare log stream | anything logged becomes visible outside the app |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-03 | DoS / webhook flooding | webhook route | mitigate | grammY's verified constant-time secret-token compare 401s garbage BEFORE bot processing; no KV touched pre-check |
| T-02-01 | Spoofing (forged identity → token) | /api/session | mitigate | Only validateInitData→mintSession path mints; rejection classes log category-only, return 401s |
| T-02-02 | Replay of stale-but-valid initData | /api/session | mitigate | 900s freshness window + D-07 rotate via plan-02 mintSession |
| T-02-04 | Information Disclosure (logs, SSRF/repo) | webhook.ts, scripts/* | mitigate | D-14 category logs; secrets read from .dev.vars/ secrets at runtime only; scripts never echo the bot token (grep-gate: no `console.log` of BOT_TOKEN/secret) |
| T-02-06 | Information Disclosure (KV persistence drift) | webhook.ts | mitigate | only mintSession writes KV (TTL-enforced by plan-02 suite); endpoint adds no storage |
</threat_model>

<verification>
- `npm test -w bot` + `npm run bot:typecheck` exit 0 (all 4 suites)
- `wrangler deploy --dry-run` exit 0
- Manual checkpoint observes SC1–SC3 in a real client before phase exit (D-16 deploy gate; autonomous: false precisely for this)
- Storage spot-check shows TTL-only KV keys (SC4)
</verification>

<success_criteria>
- Real Telegram client: /start concise intro text-only; command menu lists exactly /help and /privacy; /privacy shows stored/never-stored truth; Malay aliases co-route; en fallback for unknown locales
- Deployed Worker rejects unauthorized webhook calls (secret-token) and unvalidated session requests (rejection classes), mints one-token-per-chat for validated identities
- KV contains only TTL-bounded session/pointer keys — no persistent coordinates or profiles
</success_criteria>

<output>
After completion, create `.planning/phases/02-bot-skeleton-initdata-security/02-03-SUMMARY.md`
</output>