21 KiB
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 |
|
|
false |
|
|
|
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.mdFrom 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.
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>