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

289 lines
16 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: 01
type: execute
wave: 1
depends_on: []
files_modified:
- package.json
- bot/package.json
- bot/tsconfig.json
- bot/wrangler.jsonc
- bot/.gitignore
- bot/.dev.vars.example
- bot/src/config.ts
- bot/test/harness.mjs
- bot/test/runner.mjs
- bot/test/initdata.test.mjs
autonomous: true
requirements: [ONBD-01, PRIV-01, PRIV-02]
must_haves:
truths:
- "D-09/D-10/D-11: backend lives in a `bot/` TypeScript workspace sibling to `theme/`, with the explicit src/ tree contracted via bot/src/config.ts"
- "D-12: npm test -w bot runs a zero-dependency hand-rolled harness (audit.test.mjs shape) reporting PASS/FAIL/exit codes — substrate for all phase REQ verification"
- "D-15/D-17: the initdata vector suite RED-demonstrates the security-gate contract BEFORE implementation exists (TDD proof for RESEARCH Pitfalls 1–3), with no secret value ever in the repo"
- "D-11/D-15 contracts reach later plans: Env interface + wrangler entry points exist before implementation"
artifacts:
- path: "bot/package.json"
provides: "Workspace scripts/deps (grammy ^1.46.0, wrangler ^4.151.3, TS ^5.9.3)"
contains: "@araharah/bot"
- path: "bot/src/config.ts"
provides: "Env contract consumed by plans 02/03"
contains: "export interface Env"
- path: "bot/test/runner.mjs"
provides: "D-12 hand-rolled runner, auto-discovers *.test.mjs, suite-load failures become FAILs"
- path: "bot/test/initdata.test.mjs"
provides: "Vector suite T1–T7 (known-good/tamper/stale/boundary/order/malformed/RFC-4231 pin)"
- path: "bot/wrangler.jsonc"
provides: "Worker config: main src/webhook.ts + chat_sessions KV binding"
contains: "chat_sessions"
key_links:
- from: "bot/test/initdata.test.mjs"
to: "bot/src/security/initdata.ts"
via: "import "../src/security/initdata.ts" (RED until plan 02)"
pattern: "initdata\\.ts"
- from: "bot/wrangler.jsonc"
to: "src/webhook.ts"
via: "main entry binding"
pattern: "\"main\": \"src/webhook.ts\""
---
<objective>
Stand up the `bot/` npm workspace (D-09/D-10/D-11) with the hand-rolled test harness (D-12) and a RED initdata vector suite — the Wave-0 substrate every later verification run (`npm test -w bot`) executes on.
Purpose: Plans 02 and 03 fill in the security core and command surface against this scaffold; the initdata suite written here goes RED now and goes GREEN in plan 02 (TDD proof of the phase's security exit criterion).
Output: Installable workspace, type-checkable `src/config.ts` contract, runner + RED vector suite.
</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/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.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
@theme/package.json
@theme/scripts/audit.test.mjs
<interfaces>
<!-- Contracts this plan creates that later plans implement against. -->
From bot/src/config.ts (created here, consumed by plans 02/03):
```typescript
export interface Env {
BOT_TOKEN: string; // wrangler secret (D-15) — Telegram Bot API token from @BotFather
WEBHOOK_SECRET: string; // wrangler secret (D-15) — value Telegram echoes in X-Telegram-Bot-Api-Secret-Token
chat_sessions: KVNamespace; // TTL-only namespace — first durable KV namespace (PRIV-02)
}
```
Test harness API this plan creates (from `bot/test/harness.mjs`, consumed by every later `*.test.mjs`):
```javascript
export function test(name, fn) // register one async case; fn() throws → FAIL
```
Runner contract (from `bot/test/runner.mjs`):
- Scans `bot/test/*.test.mjs`, dynamic-imports each; a file that fails to import is recorded as one failing case (`suite load failed: <file>`), not a crash
- Prints `PASS`/`FAIL <name>` per case + summary `N passed, M failed`; exit 0 only when M = 0
</interfaces>
</context>
<tasks>
<task type="auto">
<name>task 1: bot workspace scaffold with Env contract (D-09, D-10, D-11, D-15)</name>
<files>package.json, bot/package.json, bot/tsconfig.json, bot/wrangler.jsonc, bot/.gitignore, bot/.dev.vars.example, bot/src/config.ts</files>
<read_first>
- ./package.json (current root — has workspaces ["theme"], delegate scripts pattern to extend)
- ./theme/package.json (Phase 1 workspace precedent: scripts/deps shape)
- ./theme/scripts/audit.test.mjs (runner shape D-12 builds on — read the header + structure)
- .planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md (D-09/D-10/D-11/D-15 exact wording)
- .planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md (Standard Stack versions + Recommended Project Structure)
</read_first>
<action>
1. **Root package.json (edit, don't replace):** workspaces becomes `["theme", "bot"]` (D-09). Add delegate scripts mirroring the Phase 1 pattern:
```json
"bot:test": "npm test -w bot",
"bot:typecheck": "npm run typecheck -w bot",
"bot:dev": "npm run dev -w bot",
"bot:deploy": "npm run deploy -w bot",
"bot:commands": "npm run commands -w bot"
```
Keep existing theme scripts untouched.
2. **bot/package.json (new):**
```json
{
"name": "@araharah/bot",
"private": true,
"type": "module",
"scripts": {
"test": "node test/runner.mjs",
"typecheck": "tsc --noEmit",
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"commands": "node scripts/register-commands.mjs"
},
"dependencies": { "grammy": "^1.46.0" },
"devDependencies": {
"wrangler": "^4.151.3",
"typescript": "^5.9.3",
"@cloudflare/workers-types": "^5.20261022.1",
"@types/node": "^26.6.5"
}
}
```
Versions are registry-verified in RESEARCH.md — use exactly these ranges.
3. **bot/tsconfig.json:** strict, Workers-native, noEmit (wrangler/esbuild does the real build; `allowImportingTsExtensions` pairs with noEmit and matches Node 24 type-stripping for the scripts/ D-12 runner):
```json
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["@cloudflare/workers-types"],
"strict": true,
"noUncheckedIndexedAccess": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
```
Constraint for all bot/ TS written in this phase: **erasable-only TypeScript** (no enums, no parameter properties, no namespaces — only type stripping survives). Node 24 runs `.ts` natively under this constraint [VERIFIED in RESEARCH.md].
4. **bot/wrangler.jsonc (config, not secrets — D-17):**
```jsonc
{
// KV namespace id is filled in plan 03 task 1 after `npx wrangler kv namespace create chat_sessions`
"name": "araharah-bot",
"main": "src/webhook.ts",
"compatibility_date": "2026-10-22",
"kv_namespaces": [{ "binding": "chat_sessions", "id": "KV_ID_PLACEHOLDER" }]
}
```
5. **bot/.gitignore:** exactly `.dev.vars` and `.wrangler/` (D-15/D-17 — secret hygiene from the first commit; Phase 1 precedent for self-hosted assets mirrors backend secrets).
6. **bot/.dev.vars.example (shape only, no real values):**
```
# Copy to .dev.vars and fill for local wrangler dev (D-15). NEVER commit .dev.vars.
BOT_TOKEN=
WEBHOOK_SECRET=
WEBHOOK_URL=
```
(WEBHOOK_URL is set once after the first deploy; `scripts/set-webhook.mjs` in plan 03 reads it.)
7. **bot/src/config.ts (interface contract only — no implementation beyond types):** the `Env` interface exactly as in `<interfaces>` above (D-10: grammY + workers-types first-class).
8. Run `npm i -w bot` (creates/updates root package-lock.json; verify grammy@1.46.x and wrangler@4.15x land under node_modules/bot's tree).
</action>
<verify>
<automated>npm i -w bot && npm run bot:typecheck; test -f bot/src/config.ts && test -f bot/.dev.vars.example && test -f bot/.gitignore && git check-ignore bot/.dev.vars && echo SCAFFOLD_OK</automated>
</verify>
<acceptance_criteria>
- `npm run bot:typecheck` exits 0 (src/config.ts type-checks against @cloudflare/workers-types)
- `bot/src/config.ts` contains `export interface Env` with members `BOT_TOKEN`, `WEBHOOK_SECRET`, `chat_sessions: KVNamespace`
- root package.json contains `"workspaces": ["theme", "bot"]` (order irrelevant) and script keys `bot:test`, `bot:deploy`
- `bot/package.json` contains `"name": "@araharah/bot"` and dependency `grammy` in the ^1.46.0 range
- `bot/wrangler.jsonc` contains `"main": "src/webhook.ts"` and binding `chat_sessions`
- `bot/.gitignore` contains both `.dev.vars` and `.wrangler/`
- `bot/.dev.vars.example` contains `BOT_TOKEN=` and `WEBHOOK_SECRET=` and no actual token value
- `git check-ignore bot/.dev.vars` exits 0 (secret mirror is actually ignored)
</acceptance_criteria>
<done>bot workspace installs, typechecks clean, wrangler entry points and secret-mirror files exist; nothing secret-value is committed (D-15/D-17).</done>
</task>
<task type="auto" tdd="true">
<name>task 2: Wave 0 harness + RED initdata vector suite (D-12, TDD RED phase)</name>
<files>bot/test/harness.mjs, bot/test/runner.mjs, bot/test/initdata.test.mjs</files>
<read_first>
- ./theme/scripts/audit.test.mjs (the D-12 "same shape" precedent — node:assert, exit codes, /tmp/opencode-style out-of-repo fixtures)
- {bot/.dev.vars.example path too} not needed here; read .planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md § "Validation Architecture" and § Code Examples (vector contract)
</read_first>
<behavior>
All cases begin failing (RED) because `../src/security/initdata.ts` does not exist yet — the runner must report the failed suite load as FAIL cases, not a crash. Plan 02 task 1 makes them GREEN. Vector contract (RFC-4231 cross-pinned in-suite):
- T1 known-good: initData built with test token `12345:TESTTOKEN`, fields `user={"id":42,"first_name":"Zul","language_code":"ms"}`, `auth_date=<now-S>`, `hash=<computed-in-test via node:crypto>` → validateInitData resolves `{ ok: true }` and exposes `user.id === 42`, `languageCode === "ms"`
- T2 tampered: any char of `hash` flipped → `{ ok:false, reason:"tampered" }`
- T3 stale: `auth_date` = now − 3600 with default 900s window → `reason:"stale"` (D-13)
- T4 freshness boundary: age 899s → ok; age 901s → stale
- T5 order-independence: fields inserted out of alphabetical order (`user` before `auth_date`) still validate (RESEARCH Pitfall 1)
- T6 malformed: missing `hash` field → `reason:"malformed"`; `auth_date` non-numeric → `reason:"malformed"`; `user` JSON missing `id` → `reason:"malformed"`
- T7 suite self-check: `node:crypto` HMAC(key="key", data="data") hex equals `5031fe3d989c6d1537a013fa6e739da23463fdaec3b70137d828e36ace221bd0` (independence pin — src under test uses SubtleCrypto, so primitive cross-check is real)
</behavior>
<action>
1. **bot/test/harness.mjs** (the registry):
```javascript
const tests = [];
export function test(name, fn) { tests.push({ name, fn }); }
export const registry = tests;
```
Zero dependencies (D-12).
2. **bot/test/runner.mjs** (entry; D-12 shape from audit.test.mjs):
- `import { readdirSync } from "node:fs"` + `path` from `node:path`; resolve suites as `readdirSync(import.meta.dirname).filter(f => f.endsWith(".test.mjs")).sort()`
- For each suite: `await import(`./${file}`)` inside try/catch. On load error, push `{ name: "suite load failed: " + file, fn: () => { throw loadErr; } }` into the registry (RED survives, never crashes the run)
- Run all registered cases in order: pass → print `PASS <name>`; throw → print `FAIL <name>` + `error.message`/stack, keep going
- End with /\n summary `\n[runner] N passed, M failed\n` and `process.exitCode = M > 0 ? 1 : 0`
3. **bot/test/initdata.test.mjs:** implement T1–T7 above as async cases via `import { test } from "./harness.mjs"` and `import { validateInitData } from "../src/security/initdata.ts"` (missing import = the RED state). The helper that builds a signed initData string must live IN the test file using `node:crypto` (createHmac twice: secret = HMAC(key="WebAppData", data=botToken); hash = HMAC(key=secretHexBytes, data=dataCheckString)) — independent of the SubtleCrypto path under test (T7 pins both primitives). Fixtures stay in-process; nothing written into the repo.
</action>
<verify>
<automated>npm run bot:typecheck && npm test -w bot; echo "exit=$?"</automated>
</verify>
<acceptance_criteria>
- `npm test -w bot` prints at least 7 FAIL lines matching the T1–T7 contract and exits 1 (documented RED)
- The runner summary line matches `N passed, M failed` with M > 0
- A suite-load failure prints as `FAIL suite load failed: initdata.test.mjs` (or per-case FAILs) — the runner exits with code 1 after printing all summaries, never crashes mid-run
- `node test/runner.mjs` with zero suite failures (temporarily verify by `cd bot && node test/runner.mjs` after hiding suites) prints `[runner] 0 passed, 0 failed` and exits 0
- `npm run bot:typecheck` remains exit 0 (tests are .mjs, outside tsc include — intentional; they run via Node type-stripping)
</acceptance_criteria>
<done>Runner + harness exist with zero dependencies; the initdata vector suite is written to its final contract and demonstrably RED (plan 02 goes GREEN against it); D-12 replicated in a second workspace.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| repo → git history | Secrets and local-env values cross into permanent history on any accidental `git add` |
| .dev.vars → local dev runtime | Local secrets enter the dev process |
| npm registry → workspace tree | Supply-chain surface when installing grammy/wrangler/typescript |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-04 | Information Disclosure | bot/.gitignore, bot/.dev.vars.example | mitigate | `.dev.vars` ignored in the SAME commit that introduces the secret mirror (D-15/D-17); verify with `git check-ignore` in acceptance criteria |
| T-02-07 | Tampering | bot/wrangler.jsonc, bot/tsconfig.json | mitigate | Exact registry-verified versions (^1.46.0, ^4.151.3, ^5.9.3 …) and lockfile from `npm i -w bot`; dry-run + typecheck gates in later plans catch env/shape drift |
| T-02-08 | DoS (supply chain) | bot/package.json deps | accept | Only 1 runtime dep (grammy) for a single-person project; versions pinned; lockfile committed; no paid/obscure packages |
</threat_model>
<verification>
- `npm i` at repo root succeeds with the new bot workspace
- `npm run bot:typecheck` exit 0
- `npm test -w bot` runs the full harness and exits 1 with the RED suite visible (this is the expected, documented state at end of this plan — plan 02 flips it)
- `git check-ignore bot/.dev.vars` → ignored
</verification>
<success_criteria>
- bot/ workspace scaffolded per D-09/D-10/D-11 with confirmed-clean typecheck and a working zero-dep test runner
- Wave 0 complete per 02-VALIDATION.md: harness + vector suite skeleton exist and FAIL (RED) before any implementation exists
- No secret values in the repo; `.dev.vars` ignored from the first commit
</success_criteria>
<output>
After completion, create `.planning/phases/02-bot-skeleton-initdata-security/02-01-SUMMARY.md`
</output>