Files
2026-10-23 22:37:55 +08:00

300 lines
21 KiB
Markdown
Raw Permalink 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
type: execute
wave: 3
depends_on: ["02-01", "02-02"]
files_modified:
- 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
autonomous: false
requirements: [ONBD-01, PRIV-01]
must_haves:
truths:
- "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"
artifacts:
- path: "bot/src/i18n/strings.ts"
provides: "Single copy source of truth (en + ms with identical key-sets)"
contains: "pickLocale"
- path: "bot/src/webhook.ts"
provides: "Worker entry: /telegram/webhook + /api/session security gate + D-14 category-only logs"
contains: "cloudflare-mod"
- path: "bot/scripts/register-commands.mjs"
provides: "One-time setMyCommands (default + ms scope — D-03/D-04)"
contains: "setMyCommands"
- path: "bot/scripts/set-webhook.mjs"
provides: "setWebhook with secret_token (D-16 deploy scripting)"
contains: "secret_token"
key_links:
- from: "bot/src/webhook.ts"
to: "bot/src/security/initdata.ts"
via: "handleSession → validateInitData → 401 on rejection / mint on success"
pattern: "validateInitData"
- from: "bot/src/webhook.ts"
to: "bot/src/kv/sessions.ts"
via: "mintSession called ONLY after ok:true (one mint path — PRIV-02)"
pattern: "mintSession"
- from: "bot/src/webhook.ts"
to: "bot/src/bot/bot.ts"
via: "webhookCallback(bot, 'cloudflare-mod', secretToken=env.WEBHOOK_SECRET)"
pattern: "webhookCallback"
- from: "bot/src/i18n/strings.ts"
to: "bot/src/bot/commands/*.ts"
via: "handlers import copy from strings (no inline copy — D-05)"
pattern: "strings"
user_setup:
- service: telegram-bot
why: "Real BOT_TOKEN — webhook validation, session minting, and command-menu registration all authenticate with the bot token from @BotFather"
env_vars:
- name: BOT_TOKEN
source: "Telegram @BotFather -> /newbot -> telegram -> copy token"
dashboard_config:
- task: "Keep token secret; paste into bot/.dev.vars (never commit)"
location: "local file bot/.dev.vars (D-15)"
- service: cloudflare
why: "Workers deploy target + KV namespace chat_sessions (D-16 deploy gate)"
env_vars:
- name: CLOUDFLARE_API_TOKEN (or automated wrangler login)
source: "Cloudflare Dashboard -> My Profile -> API Tokens (or `npx wrangler login` browser flow)"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- Contracts consumed from plan 01/02 files. -->
From bot/src/config.ts: `interface Env { BOT_TOKEN: string; WEBHOOK_SECRET: string; chat_sessions: KVNamespace }`
From bot/src/security/initdata.ts (plan 02):
```typescript
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):
```typescript
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`.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>task 1: strings module + command surface with aliases (D-01, D-02, D-03, D-04, D-05)</name>
<files>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</files>
<read_first>
- .planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md (D-01..D-05 verbatim + <specifics> "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)
</read_first>
<action>
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<Locale, BotStrings> = { 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<TOKEN>/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)
</action>
<verify>
<automated>npm test -w bot && npm run bot:typecheck && node --check bot/scripts/register-commands.mjs</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto">
<name>task 2: Worker entry — webhook route, /api/session security gate, startWebhook plumbing (D-14, D-16, D-17)</name>
<files>bot/src/webhook.ts, bot/scripts/set-webhook.mjs, bot/wrangler.jsonc</files>
<read_first>
- bot/src/webhook.ts is new; read bot/src/bot/bot.ts, src/security/initdata.ts, src/kv/sessions.ts (contracts in <interfaces>)
- .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)
</read_first>
<action>
1. **bot/src/webhook.ts** (the D-11 "worker entry"):
```typescript
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 (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<Env>;
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 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>