- 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
17 KiB
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.tscreateBot(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 otherwisebot/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+ chainednpm 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: '"
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.tsis the copy's tokens.css: en + ms with identical key-sets (CS1), clinical Rams-tone/startintro and/privacystored/never-stored copy whose wording names exactly the D-08 claim fields (user id, chat id, language) and the 15-minute TTL (PRIV-01, matchessrc/kv/sessions.tsreads)- 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.mjsregisters exactly twosetMyCommandscalls — default scope +language_code:"ms"scope — descriptions derived fromhelpMenulines, 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.tsroutes exactly/telegram/webhook(grammYwebhookCallback(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) andPOST /api/session(validateInitData→ 401{error:<category>}/mintSessionone-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.mjspostssetWebhook { url, secret_token, allowed_updates:["message"] }; secrets read from gitignored.dev.vars; token never echoed (T-02-04)npm run deploy -w botnow chainswrangler deploy && node scripts/set-webhook.mjs;wrangler.jsoncmarksworkers_dev: trueas the webhook URL target; KV id intentionally stays a placeholder for the checkpoint'swrangler 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:typecheckexit 0; both scripts passnode --check;wrangler deploy --dry-run --outdir /tmp/opencode/worker-dryrunexit 0 bundling in 167.97 KiB with the chat_sessions binding
task Commits
- task 1: strings module + command surface with aliases (D-01..D-05) —
b4393fb(feat) - task 2: Worker entry — webhook route, /api/session security gate, deploy plumbing (D-14, D-16, D-17) —
df3b7d6(feat) - 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)
- KV namespace created + id wired:
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.devaraharah-botworkers.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→ id59f58a54bc44483e89872b20a005a5eb— created viawrangler kv namespace create, id committed inwrangler.jsonc(492a97b; the id is account-scoped config, not a secret) - Runtime secrets:
BOT_TOKEN+WEBHOOK_SECRETuploaded viawrangler secret put(set-once;.dev.varsis local-only forwrangler 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)
- webhook without secret header → 401
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.tsand slices the/cmd — textlines, 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 tailshowedError: Empty token!atnew Bot—.dev.varsis local-only, soenv.BOT_TOKEN/env.WEBHOOK_SECRETwere 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:deploydeploys 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
setWebhookarmed the bare workers.dev origin; Telegram answered ok but updates landed on/→ 404, silent drop. Remedy: re-registered with/telegram/webhookappended; then hardened in-repo:set-webhook.mjsnow auto-appends/telegram/webhookwhen the configured URL has no path (and fails fast on unparseable URLs, never echoing values — T-02-04). Commitd600d6e. - [Stale runtime secret] production
BOT_TOKENwas revoked — Worker-runtime secret held a dead token, so webhook updates 401'd inside the Worker;wrangler tailsurfaced the failure. Remedy:npx wrangler secret put BOT_TOKENre-run frombot/with the value from.dev.vars; verified viawrangler secret list+ live/startdelivery. - [Empty webhook + pending update queue] re-armed via scripts — after the URL/secret fixes, re-ran
set-webhook.mjsand confirmedgetWebhookInfo(url correct, pending_update_count → 0, last_error freed). - [Menu cache] menu/
/-autocomplete initially empty — Telegram client-side caching ofsetMyCommands; resolved by explicitly callingsetChatMenuButton(typecommands) and registering theall_private_chatsscope in addition to the default + ms scopes. User then confirmed both surfaces listed exactly/helpand/privacyand 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/webhookand/api/sessionkeep their paths); the chat_sessions binding and TTL conventions are already enforced by the green PG1/PG2 gates /api/sessionis live the moment the Worker deploys — the Phase 4 Mini App caller only needs to POST its verbatimTelegram.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:typecheckexit 0wrangler deploy --dry-runexit 0;node --checkclean on both scripts- task 3: commits
492a97b(KV id) andd600d6e(set-webhook hardening) verified in history; deployed Worker URL reachable (404 fallback), secrets inwrangler 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 (see02-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)