Files
arah2/.planning/phases/02-bot-skeleton-initdata-security/02-03-SUMMARY.md
T
araharah-executor aedff86542 docs(02-03): record D-16 deploy-gate APPROVAL + finalize SUMMARY
- User verdict verbatim in 02-03-APPROVAL.md: "it works! Menu or / has /help and /privacy! approved!"
- Walkthrough checks a-e all green; reply-language D-05 behavior confirmed (no force-ms)
- Four live-debug remediations documented: webhook path, revoked runtime BOT_TOKEN, empty webhook re-arm, menu cache (setChatMenuButton + all_private_chats scope)
- SUMMARY finalized: 3/3 tasks, APPROVED walkthrough, self-check PASSED
2026-10-22 19:57:28 +08:00

197 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 02-bot-skeleton-initdata-security
plan: 03
subsystem: bot-surface
tags: [telegram, grammy, setmycommands, cloudflare-worker, webhook, session-gate, i18n, malay, deploy-gate]
# Dependency graph
requires:
- plan 02-01: `bot/` workspace scaffold — Env contract (BOT_TOKEN, WEBHOOK_SECRET, chat_sessions), wrangler.jsonc (KV_ID_PLACEHOLDER), delegate scripts
- plan 02-02: `validateInitData` (rejection classes malformed/tampered/stale) + `mintSession` (TTL-only, rotate-on-new) + SESSION_TTL_SECONDS
provides:
- `bot/src/i18n/strings.ts` — single copy source of truth (en + ms identical key-sets), pickLocale fallback rule; clinical/honest start + privacy copy matching the code's actual KV writes (PRIV-01)
- `bot/src/bot/bot.ts` createBot(env) + command modules start/help/privacy — text-only handlers for /start /mula /help /privacy /privasi (aliases share handlers per D-04)
- `bot/src/webhook.ts` — Worker entry: POST /telegram/webhook (grammY cloudflare-mod, constant-time secret-token auth) + POST /api/session (validateInitData → 401 category-only → mintSession); only two routes, 404 otherwise
- `bot/scripts/register-commands.mjs` — one-time setMyCommands in default + ms scopes, descriptions derived from helpMenu (no inline copy, no stub entries)
- `bot/scripts/set-webhook.mjs` + chained `npm run deploy` — setWebhook with secret_token + allowed_updates:["message"]
- `bot/test/commands.test.mjs` — CS1–CS6 command-surface gates (locale parity, pickLocale matrix, PRIV-01 copy gate, D-02 structural grep, D-03 menu gap-freedom, D-04 alias completeness)
affects: [phase 3 mini-app shell (same Worker, session endpoint already live), phase 4 Mini App caller of /api/session, phase 5 guidance envelope]
# Tech tracking
tech-stack:
added: [] # zero added deps — grammy webhookCallback + node builtins only
patterns: [per-request Bot instantiation from env-bound token, webhookCallback(bot,"cloudflare-mod",undefined,undefined,secret) with constant-time header compare, descriptions derived from helpMenu lines (copy survives in one place), pure replyText builders + thin handlers, category-only rejection logging, KV namespace id placeholder until deploy checkpoint]
key-files:
created:
- 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/scripts/register-commands.mjs
- bot/scripts/set-webhook.mjs
- bot/test/commands.test.mjs
- bot/src/webhook.ts
modified:
- bot/package.json (deploy script now chains wrangler deploy + set-webhook)
- bot/wrangler.jsonc (workers_dev: true marker; KV id still KV_ID_PLACEHOLDER)
key-decisions:
- "setMyCommands descriptions derive from strings.helpMenu's '/cmd — text' lines, so the registered menu and the /help reply cannot drift (D-03/D-05 in one mechanism)"
- "pickLocale uses exact 'ms' or 'ms-' prefix (not everything-starting-with-ms) so unrelated codes can never select Malay"
- "/api/session adds no extra branch beyond plan-02's mintSession — replay closing is purely D-07 rotate-on-new; endpoint has zero additional KV writes (T-02-06)"
- "Bot is instantiated per request inside the webhook branch only (env-bound token cannot resolve at module scope; /api/session path skips instantiation entirely)"
patterns-established:
- "Command-surface structural gates live in test/commands.test.mjs: any new keyboard/button surface or menu stub fails CS4/CS5"
- "D-14 discipline wide-scope: the only console statement in the Worker entry is '[initdata] rejected: <category>'"
requirements-completed: [] # ONBD-01/PRIV-01 code paths shipped; req completion awaits the D-16 real-client deploy gate (plan task 3)
# Metrics
duration: ~12min
completed: 2026-10-22
---
# Phase 2 Plan 3: Command Surface + Worker Wiring + Deploy Gate Summary
**The full user-facing Telegram surface (en + ms, text-only, honest privacy copy) and the Worker entry (secret-token webhook + validateInitData→mintSession /api/session gate) are built, CI-verified (27/27), LIVE on workers.dev, and APPROVED by the user in a real Telegram client (checks a–e all green, 2026-10-22); four deployment-environment issues found during the walkthrough were remediated live — plan complete**
## Performance
- **Duration:** ~12 min (tasks 1–2) + deploy chain session
- **Started:** 2026-10-22T03:46Z
- **Completed:** 2026-10-22 (deploy chain through checkpoint)
- **Tasks:** 3 of 3 — task 3 deploy chain + real-client walkthrough complete, user **approved** (`02-03-APPROVAL.md`)
- **Files created:** 9; **modified:** 3 (+ KV namespace provisioned)
## Accomplishments
- **task 1 (`b4393fb`)** — command surface with aliases:
- `src/i18n/strings.ts` is the copy's tokens.css: en + ms with **identical key-sets** (CS1), clinical Rams-tone `/start` intro and `/privacy` stored/never-stored copy whose wording names exactly the D-08 claim fields (user id, chat id, language) and the 15-minute TTL (PRIV-01, matches `src/kv/sessions.ts` reads)
- Handlers send **plain text with link previews disabled and nothing else** — D-02 is pinned as a source grep gate (CS4: zero `keyboard`-shaped tokens in bot.ts + commands)
- `pickLocale`: `ms`/`ms-…` → ms; everything else (null, unknown, `id`) → en (CS2 matrix)
- `scripts/register-commands.mjs` registers exactly two `setMyCommands` calls — default scope + `language_code:"ms"` scope — descriptions **derived from `helpMenu` lines**, no inline copy, no future-command stubs (D-03/D-05); gap-freedom asserted by CS5
- Command stack registers exactly {start, mula, help, privacy, privasi} (CS6, D-04)
- **task 2 (`df3b7d6`)** — Worker entry + deploy plumbing:
- `src/webhook.ts` routes exactly `/telegram/webhook` (grammY `webhookCallback(bot, "cloudflare-mod", undefined, undefined, env.WEBHOOK_SECRET)` — constant-time secret-token header compare rejects garbage before bot processing or any KV touch, T-02-03) and `POST /api/session` (`validateInitData` → 401 `{error:<category>}` / `mintSession` one-time opaque token; single mint path, no extra branch — T-02-01/02/06)
- D-14: the only console statement in the Worker entry is the category-only `[initdata] rejected: <reason>` line
- `scripts/set-webhook.mjs` posts `setWebhook { url, secret_token, allowed_updates:["message"] }`; secrets read from gitignored `.dev.vars`; token never echoed (T-02-04)
- `npm run deploy -w bot` now chains `wrangler deploy && node scripts/set-webhook.mjs`; `wrangler.jsonc` marks `workers_dev: true` as the webhook URL target; KV id intentionally stays a placeholder for the checkpoint's `wrangler kv namespace create`
- **Verification:** `npm test -w bot` → `[runner] 27 passed, 0 failed` (all prior suites still green: initdata T1–T7, sessions, privacy PG1/PG2); `npm run bot:typecheck` exit 0; both scripts pass `node --check`; `wrangler deploy --dry-run --outdir /tmp/opencode/worker-dryrun` exit 0 bundling in 167.97 KiB with the chat_sessions binding
## task Commits
1. **task 1: strings module + command surface with aliases (D-01..D-05)** — `b4393fb` (feat)
2. **task 2: Worker entry — webhook route, /api/session security gate, deploy plumbing (D-14, D-16, D-17)** — `df3b7d6` (feat)
3. **task 3: deploy gate (checkpoint:human-verify)** — deploy chain executed after user provisioned credentials:
- KV namespace created + id wired: `492a97b` (fix)
- Worker deployed live; secrets set; setWebhook + setMyCommands registered — no code commits required (see Deployment Record)
- **APPROVAL:** real-client walkthrough a–e observed green by the user — *"it works! Menu or / has /help and /privacy! approved!"* — recorded in `02-03-APPROVAL.md`
- Hardening from walkthrough findings: `d600d6e` (set-webhook auto-appends /telegram/webhook)
## Files Created/Modified
- Created: `bot/src/i18n/strings.ts`, `bot/src/bot/bot.ts`, `bot/src/bot/commands/{start,help,privacy}.ts`, `bot/src/webhook.ts`, `bot/scripts/{register-commands,set-webhook}.mjs`, `bot/test/commands.test.mjs`
- Modified: `bot/package.json` (deploy chain), `bot/wrangler.jsonc` (workers_dev + real KV namespace id, `492a97b`), `bot/scripts/set-webhook.mjs` + `bot/.dev.vars.example` (walkthrough hardening, `d600d6e`)
## Deployment Record (task 3, D-16)
- **Worker URL (public, not a secret):** `https://araharah-bot.araharah-bot.workers.dev`
- `araharah-bot` workers.dev subdomain was already registered on the account; DNS went live within minutes of first deploy
- Account: adri.mcmc@gmail.com, account id `7b4510d8…51e9`
- **KV namespace:** `chat_sessions` → id `59f58a54bc44483e89872b20a005a5eb` — created via `wrangler kv namespace create`, id committed in `wrangler.jsonc` (`492a97b`; the id is account-scoped config, not a secret)
- **Runtime secrets:** `BOT_TOKEN` + `WEBHOOK_SECRET` uploaded via `wrangler secret put` (set-once; `.dev.vars` is local-only for `wrangler dev` — values never printed, never committed)
- **setWebhook:** ok:true → delivered updates restricted to `allowed_updates:["message"]`, secret-token header armed (T-02-03). Verified read-back via getWebhookInfo: url/secret-header/pending=0/no errors
- **setMyCommands:** default scope + `language_code:"ms"` scope both ok:true; read-back via getMyCommands shows exactly /help + /privacy with derived descriptions in both languages (D-03/D-05)
- **Live security smokes (automated against the deployed Worker):**
- webhook without secret header → 401 `"unauthorized"`
- webhook with wrong secret header → 401 `"unauthorized"`
- POST /api/session garbage body → 401 `{"error":"malformed"}` (category-only, T-02-01/D-14)
- GET /api/session → 405; unknown route → 404 "Not found"
- KV key list pre-/start → `[]` (PRIV-02 baseline)
## Real-Client Walkthrough Outcome (APPROVED — final gate for ONBD-01/PRIV-01/PRIV-02 + SC1–SC4)
User verified in the real Telegram client (2026-10-22) and said verbatim:
*"it works! Menu or / has /help and /privacy! approved!"*
| Check | Result |
|-------|--------|
| a. `/start` → one clinical sentence, no buttons | ✓ |
| b. menu + `/` autocomplete list exactly `/help` and `/privacy` | ✓ |
| c. `/privacy` stored/never-stored blocks as written | ✓ |
| d. `/mula` ≡ `/start` (reply language follows app `language_code` per D-05; user's app is English → English reply is correct behavior; no force-ms) | ✓ |
| e. gibberish `/xyz` → silent, no crash, no menu drift | ✓ |
Four deployment-environment issues were found and remediated live during the
walkthrough (see Deviations); the user approved after all four were resolved.
Full record with verbatim response + evidence commands: `02-03-APPROVAL.md`.
KV spot-check (PRIV-02, available any time):
`cd bot && npx wrangler kv key list --namespace-id 59f58a54bc44483e89872b20a005a5eb`
— only `session:*`/`chat:*`-shaped TTL keys, gone after ~15 min.
## Decisions Made
- **Menu descriptions derive from helpMenu:** registration imports `src/i18n/strings.ts` and slices the `/cmd — text` lines, making the persisted menu and the /help reply one artifact (D-03 gap-freedom enforced by construction + test)
- **pickLocale prefix semantics:** exact `ms` / `ms-` prefix only — an unrelated code can never enter the ms surface
- **/api/session is branch-free:** replay closing is entirely plan-02's rotate-on-new invariant; the endpoint introduces zero extra KV writes so the PG1/PG2 privacy gates hold automatically
- **Per-request Bot only on the webhook path:** the token cannot resolve at module scope (Workers env), and /api/session has no need for a Bot instance
## Deviations from Plan
**Fix applied during task 3 deploy chain ([Rule 1 - Bug]: production secrets not deployed):**
- **Found during:** task 3 live smoke test — first study: burning bright no-secret webhook probe returned 500 "Worker threw exception"; `wrangler tail` showed `Error: Empty token!` at `new Bot` — `.dev.vars` is local-only, so `env.BOT_TOKEN`/`env.WEBHOOK_SECRET` were undefined in production
- **Fix:** uploaded both runtime secrets via `wrangler secret put` (set-once, values from `.dev.vars`, never echoed); re-ran smokes → all green. **Noted for future deploys:** `npm run bot:deploy` deploys code + sets the webhook, but does NOT re-upload secrets — set-once is expected behavior; recreate only if the secret is rotated. Phase 3+ deploy docs should carry this note.
- **Files modified:** none (environment-only fix); Worker redeploy not needed
- **Commit:** n/a
Also: a first `wrangler secret put` attempt ran from the wrong working directory and uploaded empty values; corrected by re-uploading from `bot/` with full values and verifying via `wrangler secret list`.
(One internal test-path fix in `commands.test.mjs` before its first green run in task 1 — the suite's own bug, not a scope change.) ` ExecutionContext` parameter in `webhook.ts` is accepted-but-unused per the standard Workers fetch signature.
**Live-debug remediations during the task 3 walkthrough (deployment-environment findings, all resolved before user approval):**
- **[Deployment misconfiguration] webhook registered without the route path** — first `setWebhook` armed the bare workers.dev origin; Telegram answered ok but updates landed on `/` → 404, silent drop. Remedy: re-registered with `/telegram/webhook` appended; then hardened in-repo: `set-webhook.mjs` now auto-appends `/telegram/webhook` when the configured URL has no path (and fails fast on unparseable URLs, never echoing values — T-02-04). Commit `d600d6e`.
- **[Stale runtime secret] production `BOT_TOKEN` was revoked** — Worker-runtime secret held a dead token, so webhook updates 401'd inside the Worker; `wrangler tail` surfaced the failure. Remedy: `npx wrangler secret put BOT_TOKEN` re-run from `bot/` with the value from `.dev.vars`; verified via `wrangler secret list` + live `/start` delivery.
- **[Empty webhook + pending update queue] re-armed via scripts** — after the URL/secret fixes, re-ran `set-webhook.mjs` and confirmed `getWebhookInfo` (url correct, pending_update_count → 0, last_error freed).
- **[Menu cache] menu/`/`-autocomplete initially empty** — Telegram client-side caching of `setMyCommands`; resolved by explicitly calling `setChatMenuButton` (type `commands`) **and** registering the `all_private_chats` scope in addition to the default + ms scopes. User then confirmed both surfaces listed exactly `/help` and `/privacy` and approved.
Note for future deploys: command-menu registration and the menu button were found to need the `all_private_chats` scope + explicit `setChatMenuButton` in addition to the default scope; `register-commands.mjs` may be extended to include these one-time calls in a later plan (out of scope here).
## Threat Model Dispositions Applied
- **T-02-03** (webhook flooding/DoS): secret-token header constant-time compare happens in grammY's adapter before bot processing; no KV touched pre-check
- **T-02-01** (spoofed identity → token): only the validateInitData→mintSession path mints; rejections return category-only 401s
- **T-02-02** (stale-but-valid replay): 900 s freshness window + rotate-on-new deletion in mintSession
- **T-02-04** (information disclosure): D-14 category-only logging; scripts print only ok/result/error_description — no token or secret values (grep-verified)
- **T-02-06** (KV persistence drift): endpoint adds no storage writes; PG1/PG2 gates automatically cover `src/webhook.ts` (no `.put(` present)
## Known Stubs
None. Every export is fully wired; no placeholder copy, no TODO/FIXME, no components without data sources. `KV_ID_PLACEHOLDER` in `wrangler.jsonc` is a deliberate deploy-checkpoint input (documented in plan task 3), not a code stub.
## User Setup Required (deploy checkpoint — plan task 3)
RESOLVED — credentials were provisioned by the user (@BotFather token + WEBHOOK_SECRET in `.dev.vars`, `wrangler login` active). Deploy chain complete — see "Deployment Record". Real-client walkthrough a–e observed and **approved** — see "Real-Client Walkthrough Outcome". Nothing remains for the user on this plan.
## Next Phase Readiness
- Phase 3's Worker inherits: mixed static-assets + existing routes are additive (`/telegram/webhook` and `/api/session` keep their paths); the chat_sessions binding and TTL conventions are already enforced by the green PG1/PG2 gates
- `/api/session` is live the moment the Worker deploys — the Phase 4 Mini App caller only needs to POST its verbatim `Telegram.WebApp.initData`
- Command-surface gates (CS1–CS6) guard all future copy/menu edits against drift from the strings module
## Self-Check: PASSED
- All 9 created files verified present on disk
- Commits verified in history: `b4393fb` (task 1), `df3b7d6` (task 2); no unintended file deletions in either commit
- `npm test -w bot` → `[runner] 27 passed, 0 failed`; `npm run bot:typecheck` exit 0
- `wrangler deploy --dry-run` exit 0; `node --check` clean on both scripts
- **task 3:** commits `492a97b` (KV id) and `d600d6e` (set-webhook hardening) verified in history; deployed Worker URL reachable (404 fallback), secrets in `wrangler secret list`, getWebhookInfo ok (pending 0), getMyCommands read-back ok (both scopes), getChatMenuButton → `commands`, live 401/401/401/405/404 smokes green, KV list baseline `[]`, user-approved walkthrough a–e (see `02-03-APPROVAL.md`)
- STATE.md / ROADMAP.md untouched (orchestrator-owned)
---
*Phase: 02-bot-skeleton-initdata-security*
*Completed: 2026-10-22 (tasks 1–3 all complete; task 3 deploy + real-client walkthrough APPROVED by user)*