289 lines
16 KiB
Markdown
289 lines
16 KiB
Markdown
---
|
||
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>
|