ambek .planning

This commit is contained in:
araharah-executor
2026-10-23 22:37:55 +08:00
parent e512104d5e
commit 275879a18b
54 changed files with 9921 additions and 0 deletions
@@ -0,0 +1,288 @@
---
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>
@@ -0,0 +1,236 @@
---
phase: 02-bot-skeleton-initdata-security
plan: 02
type: execute
wave: 2
depends_on: ["02-01"]
files_modified:
- bot/src/security/initdata.ts
- bot/src/kv/sessions.ts
- bot/test/sessions.test.mjs
- bot/test/privacy.test.mjs
autonomous: true
requirements: [PRIV-02]
must_haves:
truths:
- "D-13: tampered, stale (auth_date older than ~15 min), and malformed initData are rejected with typed rejection classes; known-good HMAC passes (ROADMAP SC3 — the security gate)"
- "D-06: a validated identity mints exactly one live opaque token; D-07: re-mint rotates and deletes the previous token"
- "D-08/PRIV-02: every KV write carries an expirationTtl — no write path can persist without TTL; the claim is exactly user_id + chat_id + issued_at + language_code, never more"
- "D-11: rejection classes vocabulary (tampered/stale + expired-on-read) matches D-14's category-only log contract downstream"
artifacts:
- path: "bot/src/security/initdata.ts"
provides: "validateInitData + WebAppInitUser hand-rolled types (grammy does not ship them — research-verified)"
exports: ["validateInitData", "InitDataRejection"]
- path: "bot/src/kv/sessions.ts"
provides: "TTL-only KV access layer: mint/rotate/claim getters"
exports: ["SESSION_TTL_SECONDS", "mintSession", "getClaimByToken", "getTokenForChat"]
- path: "bot/test/sessions.test.mjs"
provides: "Rotate-on-new invariant + D-08 key-set-equality + TTL assertions with mock KV"
- path: "bot/test/privacy.test.mjs"
provides: "PRIV-02 architecture gate: every .put( line carries expirationTtl"
key_links:
- from: "bot/src/security/initdata.ts"
to: "npm test -w bot initdata suite"
via: "validateInitData consumes the plan-01 T1–T7 suite (GREEN here)"
pattern: "validateInitData"
- from: "bot/src/kv/sessions.ts"
to: "bot/src/security/initdata.ts"
via: "languageCode flows from validated initData into SessionClaim (D-08)"
pattern: "language_code"
- from: "bot/src/kv/sessions.ts"
to: "KVNamespace.put"
via: "every put call line includes expirationTtl: SESSION_TTL_SECONDS"
pattern: "expirationTtl"
---
<objective>
Implement the phase's security exit criterion as pure, testable code: HMAC-SHA256 initData validation with rejection classes, and the TTL-only KV session layer with rotate-on-new — making the RED vector suite from plan 01 GREEN and adding the PRIV-02 storage gates.
Purpose: "The initData validator is the first backend artifact" (ROADMAP goal) — provability comes from the vector suite, privacy-by-construction from the type-enforced TTL layer.
Output: `src/security/initdata.ts` + `src/kv/sessions.ts`, both GREEN under `npm test -w bot` (`sessions` and `privacy` suites included).
</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
<interfaces>
<!-- Contracts created in plan 01 that this plan consumes; contracts this plan creates for plan 03. -->
From bot/src/config.ts (plan 01):
```typescript
export interface Env {
BOT_TOKEN: string;
WEBHOOK_SECRET: string;
chat_sessions: KVNamespace;
}
```
Test harness (plan 01): `import { test } from "./harness.mjs"` inside bot/test/*.test.mjs; runner auto-discovers every `*.test.mjs`.
API this plan creates for plan 03 (bot/src/security/initdata.ts):
```typescript
export type InitDataRejection = "malformed" | "tampered" | "stale";
export interface ParsedInitData {
user: { id: number; first_name?: string; username?: string; language_code?: string; [k: string]: unknown };
chat?: { id: number; type: string; [k: string]: unknown };
authDate: number; // seconds-since-epoch (Pitfall 3: never ms)
rawFields: Map<string, string>; // decoded field values, `hash`/`signature` excluded
}
export interface WebAppInitUser { id: number; first_name?: string; last_name?: string; username?: string; language_code?: string; photo_url?: string; is_premium?: boolean; added_to_attachment_menu?: boolean; allows_write_to_pm?: boolean; } // hand-rolled — NOT shipped by grammy 1.46.0 (research-verified gap)
export async function validateInitData(
raw: string,
botToken: string,
opts?: { nowSeconds?: number; maxAgeSeconds?: number },
): Promise<
| { ok: true; user: WebAppInitUser; chat?: { id: number; type: string }; languageCode: string | null; authDate: number }
| { ok: false; reason: InitDataRejection }
>;
// defaults: maxAgeSeconds = 900 (D-13), nowSeconds = Math.floor(Date.now() / 1000)
```
API this plan creates for plan 03 (bot/src/kv/sessions.ts):
```typescript
export const SESSION_TTL_SECONDS = 900; // D-13 pairing — "minutes" envelope
export interface SessionClaim {
user_id: number; // D-08: the entire claim surface — nothing else ever stored
chat_id: number;
issued_at: number; // seconds
language_code: string | null;
}
export async function mintSession(kv: KVNamespace, claim: SessionClaim): Promise<string>;
// mints crypto.randomUUID() token; rotates (D-07): reads chat:<chat_id> pointer,
// deletes the previous `session:<oldToken>` key, then puts session:<newToken> and chat:<chat_id> —
// both puts ALWAYS carry { expirationTtl: SESSION_TTL_SECONDS } (PRIV-02, type-enforced by usage).
export async function getTokenForChat(kv: KVNamespace, chatId: number): Promise<string | null>;
export async function getClaimByToken(kv: KVNamespace, token: string): Promise<SessionClaim | null>;
// null = expired/unknown — callers treat as "expired" rejection class (D-14 vocabulary).
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>task 1: validateInitData — HMAC core with rejection classes (D-13, D-14; GREEN the RED suite)</name>
<files>bot/src/security/initdata.ts</files>
<read_first>
- .planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md § Code Examples + § Common Pitfalls 1–3 (algorithm, key derivation direction, sorting)
- bot/test/initdata.test.mjs (the exact contract being GREENed — read before implementing)
- bot/src/config.ts (existing Env; initdata.ts must not import Env — it is pure (raw, botToken) by D-11)
</read_first>
<behavior>
Green conditions come from the plan 01 suite (T1–T7): known-good → ok + parsed user/languageCode; tampered hash → "tampered"; age 3600s → "stale"; 899s ok / 901s stale; out-of-order field insertion still OK; missing hash / NaN auth_date / user without id → "malformed"; RFC-4231 pin cross-check in-suite.
</behavior>
<action>
Implement exactly per the RESEARCH.md-verified algorithm (no deviations; no third-party packages):
1. **Parse:** split `raw` on `&` into `key=value` pairs; values URL-decode once (use `decodeURIComponent` on each value after split — `+` in initData is literal per Telegram's querystring encoding; do NOT use URLSearchParams because it converts `+` to space). Exclude both `hash` and `signature` from the check-string set but parse `hash`/`auth_date`/`user` normally when present. Keep insertion map (`rawFields`).
2. **Data-check-string:** iterate field entries sorted **by field name** (default JS string sort — byte/case-sensitive), format `${key}=${value}`, join with `"\n"` (0x0A).
3. **Key derivation (Pitfall 2 — direction):**
- secretKey bytes = HMAC-SHA256(message = UTF-8(botToken), key = UTF-8("WebAppData"))
- checkHex = hex(HMAC-SHA256(message = UTF-8(dataCheckString), key = secretKey))
Both via `crypto.subtle.importKey("raw", …, { name: "HMAC", hash: "SHA-256" }, false, ["sign"])` + `crypto.subtle.sign` (identical API in Workers + Node 24 — research-verified).
4. **Rejection precedence and classes (D-14 vocabulary):** raw without `hash` → `malformed`; `user` absent/unparseable JSON or missing numeric `id` → `malformed`; `auth_date` absent/non-numeric → `malformed`; checkHex mismatch (timing-safe hex compare — compare via constant-time byte loop on UTF-8 bytes, mirroring grammY's shipped approach) → `tampered`; `nowSeconds - authDate > maxAgeSeconds` (default 900) → `stale`; else `{ ok: true, user, chat, languageCode: user.language_code ?? null, authDate }`.
5. **Types:** hand-roll `WebAppInitUser`/`WebAppInitChat` in this file from Telegram's WebAppInitData spec (research gap resolution — grammy does NOT ship these, verified). Export `InitDataRejection`/`ParsedInitData` per `<interfaces>` so plan 03's endpoint has a typed surface.
6. Erasable-only TS (plan 01 constraint). Module imports use explicit `.ts` extensions (`allowImportingTsExtensions` is on; no runtime imports needed here besides types — keep the file dependency-free of grammY so it stays testable without provider deps (D-11)).
</action>
<verify>
<automated>npm test -w bot && npm run bot:typecheck</automated>
</verify>
<acceptance_criteria>
- `npm test -w bot` exits 0 with the initdata suite fully PASS (T1–T7 listed PASS) — RED suite is GREEN
- `bot/src/security/initdata.ts` contains `export async function validateInitData`, `InitDataRejection`, and the string `"WebAppData"` exactly once (the key derivation constant)
- No `import` of `grammy` inside `bot/src/security/initdata.ts` (D-11 isolation — grep returns 0)
- `npm run bot:typecheck` exits 0
- Sorting is by field name: suite T5 (out-of-order insertion) passes
</acceptance_criteria>
<done>The validator passes the pinned vector suite including tamper/stale/boundary/order/well-formed classes under SubtleCrypto (D-13 window wired via maxAgeSeconds default 900).</done>
</task>
<task type="auto">
<name>task 2: TTL-only KV sessions with rotate-on-new + PRIV-02 storage gates (D-06, D-07, D-08, D-14)</name>
<files>bot/src/kv/sessions.ts, bot/test/sessions.test.mjs, bot/test/privacy.test.mjs</files>
<read_first>
- bot/src/security/initdata.ts (just written — languageCode flows into the claim; SessionClaim contract above)
- bot/test/harness.mjs + bot/test/runner.mjs (auto-discovery + `test()` API from plan 01)
- .planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md § Pattern 3 (TTL-only KV access layer) + Open Questions resolution (rotate mechanics: delete-then-mint via chat pointer)
</read_first>
<action>
1. **bot/src/kv/sessions.ts** per the `<interfaces>` contract:
- `SESSION_TTL_SECONDS = 900` (D-13 pairing — a session cannot outlive the freshness proof that minted it; "minutes" envelope per CONTEXT).
- Key scheme: claim record under `session:<token>`; per-chat pointer under `chat:<chat_id>` holding the current token string. Both keys ALWAYS written with `{ expirationTtl: SESSION_TTL_SECONDS }`.
- `mintSession(kv, claim)`: `token = crypto.randomUUID()` (opaque by construction, D-06); read `chat:<claim.chat_id>` → oldToken; `kv.delete("session:" + oldToken)` if found (D-07 rotate-on-new — the structural close of the recent-stale-replay mint window); `kv.put("session:" + token, JSON.stringify(claim), { expirationTtl: SESSION_TTL_SECONDS })`; `kv.put("chat:" + claim.chat_id, token, { expirationTtl: SESSION_TTL_SECONDS })`; return token.
- `getTokenForChat(kv, chatId)`: read pointer; `getClaimByToken(kv, token)`: read record, JSON.parse, return or null. Reads never write.
2. **bot/test/sessions.test.mjs** — in-memory 20-line KV fake (Map-backed KVNamespace subset: `get`, `put(key, value, opts)` asserting `opts.expirationTtl` is present and finite, `delete`, plus a `ttlLog` of writes):
- mints return a token whose record contains EXACTLY the D-08 keys `{user_id, chat_id, issued_at, language_code}` (assert key-set equality — no drift toward profiles)
- every `put` recorded in ttlLog carries expirationTtl === 900 (PRIV-02 by construction)
- rotate: mint twice for same chat → older `session:<tok1>` key is gone, pointer target is tok2, exactly one live session record (D-07 invariant)
- `getClaimByToken` round-trips; token unknown → null (expired-path class, D-14)
- claim survives with `language_code: "ms"` and null both
3. **bot/test/privacy.test.mjs** — PRIV-02 architecture gate, zero-KV:
- Read `src/kv/sessions.ts` + `src/webhook.ts` (latter may not exist yet — filter existence) and assert every `.put(` occurrence on any line also contains `expirationTtl` on the same line (grep gate: no TTL-free write path can ship)
- Assert `bot/src/kv/sessions.ts` exports nothing named like persistence (e.g., `saveProfile`, `storeLocation` do not exist)
</action>
<verify>
<automated>npm test -w bot && npm run bot:typecheck && grep -cn "expirationTtl" bot/src/kv/sessions.ts | head -1</automated>
</verify>
<acceptance_criteria>
- `npm test -w bot` exits 0 — three suites PASS (initdata, sessions, privacy)
- `sessions.test.mjs` contains a case named like `rotate` / D-07 that asserts the old `session:<token>` key no longer exists after re-mint
- Every KV write in `src/kv/sessions.ts` occurs with `{ expirationTtl: SESSION_TTL_SECONDS }` visible on the call line (privacy suite enforces)
- `SessionClaim` interface contains only `user_id`, `chat_id`, `issued_at`, `language_code` (D-08 — grep the file for a 5th property: none)
- `npm run bot:typecheck` exits 0
</acceptance_criteria>
<done>Opaque tokens mint only alongside TTL-expiring claims, rotate-on-new invalidates the previous token, PRIV-02 gates are in the automated suite (this closes the plan-01→02 dependency: full GREEN).</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → /api/session entry (built in plan 03) | initData arrives untrusted; this plan defines the ONLY accepted path through validation |
| KV writes → durable storage | any schema-less KV write could persist PII indefinitely |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-01 | Tampering / Spoofing (forged initData) | validateInitData | mitigate | HMAC-SHA256 over data-check-string with derived secret key (Telegram-verified algorithm); constant-time hex compare |
| T-02-02 | Replay of stale-but-valid hash | sessions.ts + validateInitData(900s) | mitigate | auth_date freshness 900s (D-13) + rotate-on-new invalidates any token minted from a replay (D-07); suite pins both |
| T-02-05 | Spoofing (token prediction) | mintSession | mitigate | crypto.randomUUID() — 122-bit RNG, not Math.random |
| T-02-06 | Information Disclosure (persistent claim drift) | sessions.ts | mitigate | D-08 key-set-equality test + same-line expirationTtl grep gate in privacy.test.mjs |
</threat_model>
<verification>
- `npm test -w bot` exit 0 (three suites green: initdata + sessions + privacy)
- `npm run bot:typecheck` exit 0
- grep gate: every `.put(` in src/kv/sessions.ts carries `expirationTtl` on its line
- No grammy import inside src/security/initdata.ts or src/kv/sessions.ts (isolation per D-11)
</verification>
<success_criteria>
- Security exit criterion (ROADMAP SC3 logic): tampered/stale/malformed initData vectors all rejected; known-good HMAC passes — in automated CI-style suite
- Privacy by construction (ROADMAP SC4 logic): TTL-only namespace written without exception; claim is exactly D-08-minimal; rotate-on-new holds the one-live-token-per-chat invariant
- All `bots` suites pass without network, account, or Provider dependencies (pure Node 24)
</success_criteria>
<output>
After completion, create `.planning/phases/02-bot-skeleton-initdata-security/02-02-SUMMARY.md`
</output>
@@ -0,0 +1,299 @@
---
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>
@@ -0,0 +1,111 @@
# Phase 2: Bot Skeleton + initData Security - Context
**Gathered:** 2026-10-22
**Status:** Ready for planning
<domain>
## Phase Boundary
Users can reach araharah through Telegram with a working command surface (/start, /help, /privacy, plus Malay aliases) served by a grammY bot on one Cloudflare Worker, and the backend's identity + privacy architecture is provably sound from the first deploy: HMAC-validated Telegram WebApp initData with CI test vectors, one-time opaque session tokens in TTL-only KV (`chat_sessions`). Mini App shell, search, and the `guidance_sessions` envelope design are later phases.
</domain>
<decisions>
## Implementation Decisions
### Bot copy + interaction style
- **D-01:** Tone is clinical + honest (Rams-aligned): what araharah is in one sentence, no emoji clutter, no marketing flourish. Privacy statement states what is NOT stored as plainly as what is.
- **D-02:** /start is a plain text-only message — no reply keyboard, no inline buttons. Telegram commands are the interface (the Mini App launch path is Phase 4's decision).
- **D-03:** Permanent command menu contains only commands that exist in this phase (/help, /privacy) registered via setMyCommands — no stub entries for future commands.
- **D-04:** Malay command aliases are first-class citizens of the surface from this phase: `/mula` runs the same flow as `/start`, `/privasi` as `/privacy`. Later flow aliases are named now and implemented by their phases: `/pergi_ke` (~ goto), `/cari` (~ search, Phase 4). setMyCommands registers localized command descriptions via a `ms` language_code scope in addition to the default.
### Copy localization
- **D-05:** All bot copy lives in a strings module (`src/i18n/strings.ts`): English default + Bahasa Melayu `ms` locale. Telegram's per-user `language_code` picks the locale; unhandled codes fall back to English. Command aliases are handlers on the same command stack, not separate flows.
### Session token mechanics
- **D-06:** Session tokens are opaque random token ids stored in KV `chat_sessions` alongside a minimal bootstrap claim. Not JWTs — revocable and audit-friendly. Handing out the token is the DSGN-equivalent security gate: only successful initData validation mints one.
- **D-07:** Rotate-on-new: when a validated identity arrives for a chat that already holds a live session token, the old token is invalidated (overwritten). One live token per chat. The recent-stale-replay risk that a replicated validation could mint extra tokens is thereby closed structurally.
- **D-08:** The stored claim is deliberately minimal: Telegram `user.id`, `chat.id`, `issued_at`, and the user's `language_code` (needed to route the bot's future chat cards in the user's locale). No location, no routes, no profile.
### Backend repo layout
- **D-09:** Backend lives in a `bot/` workspace sibling to `theme/` (npm workspaces, root package.json gets thin delegate scripts, mirroring the Phase 1 pattern).
- **D-10:** Language is TypeScript for everything backend (grammY typings first-class, `@cloudflare/workers-types` for the runtime).
- **D-11:** bot/ layout is an explicit `src/` tree: `src/webhook.ts` (worker entry), `src/security/initdata.ts` (HMAC validation — isolated, testable without provider dependencies), `src/kv/sessions.ts` (TTL-only access layer), `src/i18n/strings.ts`, `src/bot/commands/{start,help,privacy}.ts`, wrangler config colocated.
### Validation + test harness
- **D-12:** Test runner is a hand-rolled node runner in the same shape as `theme/scripts/audit.test.mjs` (Phase 1's pattern) — no extra test dependency for this pure-Crypto HMAC vector suite.
- **D-13:** initData freshness policy: reject payloads whose `auth_date` is older than ~15 minutes; the Mini App re-validates with fresh initData on launch. This pairs with the `chat_sessions` minutes-TTL envelope.
- **D-14:** Rejection logging is category-only: tampered / stale / replayed / expired — never the raw payload, never coordinates, no user-marking timestamps. Satisfies the "logs carry no coordinate data" criterion from day one.
### Secrets + deploy setup
- **D-15:** Secrets (bot token, HMAC secret) live in Cloudflare Wrangler secrets. Local dev mirrors via a gitignored `.dev.vars` (only `.dev.vars.example` ships in the repo).
- **D-16:** Phase 2 deploy is local `npm run deploy` (wrangler deploy + setWebhook). No CI workflow yet — it arrives in Phase 3 with real Mini App users.
- **D-17:** Strict secret hygiene: no secret value ever committed, webhook URL in wrangler config, and the /privacy copy stays verifiable by reading the code's storage calls — the architecture can't hold more than it says it does.
### OpenCode's Discretion
- Exact setMyCommands scope/locale structuring details
- Webhook secret token mechanics (Telegram's `secret_token` param) at the researcher's discretion
- Exact KV TTL values inside the "minutes" envelope
- How local wrangler dev simulates KV for testing the product loop
- How the researcher verifies exact grammY initData typings (flagged research gap)
</decisions>
<specifics>
## Specific Ideas
- Command menu and bot surface should feel like the Rams product inside Telegram: spare, honest, functional. /privacy is a promise visibly kept, not a legal disclaimer.
- Malay aliases are the user's explicit addition — locals should be able to drive in their own language from day one.
- Single-file source of truth predilection carries over: the strings module is the copy's tokens.css — no copy pasted inline in handlers.
</specifics>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Phase scope + decisions
- `.planning/ROADMAP.md` §Phase 2 — goal, 4 success criteria, notes (KV namespace, grammY research gap)
- `.planning/REQUIREMENTS.md` — ONBD-01, PRIV-01, PRIV-02 (this phase's requirements)
- `.planning/phases/01-design-tokens-rams-foundation/01-CONTEXT.md` — Phase 1 pattern precedent (workspace structure, hand-rolled test runner, TDD discipline established)
### Codebase
- `.planning/codebase/CONCERNS.md`, `STACK.md`, `ARCHITECTURE.md` — pre-existing repo caveats (secret handling from day one)
- `AGENTS.md` — coding conventions
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `theme/` npm workspace pattern (root delegates + per-workspace package.json + scripts) — the repo's established structure for every subsequent workspace
- `theme/scripts/audit.test.mjs` — hand-rolled test runner pattern: node:assert, minimal, no deps (D-12 builds on this shape)
- Package-layout precedent: isolated concerns in their own module files (e.g. tokens/components/audit separation)
### Established Patterns
- TDD discipline from Phase 1 (RED→GREEN commits where sensible)
- Type-only imports, zero-radius kebab-case vocabulary, strict gitignored `.planning/`
- npm workspaces with self-hosted assets — same mental model applies to backend secrets
### Integration Points
- `theme/dist/rams.css` + basemap style become Mini App static assets served by the same Worker in Phase 3 — Phase 2's worker must implicitly plan where static asset binding lands (researcher's structural hint, not Phase 2 implementation)
- `chat_sessions` KV namespace is the first durable namespace — its TTL/shape is what Phase 5's envelope design extends
</code_context>
<deferred>
## Deferred Ideas
- Webhook dispatch via static-asset router to inline Telegram Mini App serving (Phase 3 shapes the mixed static+api worker, not this phase)
- Metrics/diagnostics beyond category logs (Phase 7 audit scope decides)
None — discussion stayed within phase scope.
</deferred>
---
*Phase: 02-bot-skeleton-initdata-security*
*Context gathered: 2026-10-22*
@@ -0,0 +1,58 @@
# Phase 2: Bot Skeleton + initData Security - Discussion Log
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
> Decisions captured in CONTEXT.md — this log preserves the analysis.
**Date:** 2026-10-22
**Phase:** 02-bot-skeleton-initdata-security
**Mode:** default (interactive)
## Areas Discussed
### Bot copy + interaction style
| Question | Options presented | Selection |
|---|---|---|
| Tone | Clinical + honest / Friendly + light / Formal | Clinical + honest |
| /start form | Text-only / reply keyboard / inline button | Text-only minimal |
| Command menu | Phase-2 commands only / v1 stubbed / you decide | Phase-2 only + user freeform added Malay aliases (/mula, /pergi_ke, /cari) |
| Localization | Strings module + ms locale / defer / aliases only | Strings module + ms locale |
### Session token mechanics
| Question | Options presented | Selection |
|---|---|---|
| Token shape | Opaque KV id / signed JWT / you decide | Opaque token id in KV |
| Second /start | Rotate-on-new / multi-session / you decide | Rotate-on-new, revocable |
| Claim fields | Minimal / wider / you decide | Minimal bootstrap claim |
### Backend repo layout
| Question | Options presented | Selection |
|---|---|---|
| Workspace layout | bot/ / worker/ / flat src | bot/ workspace |
| Language | TypeScript / JS ESM / you decide | TypeScript |
| Internal structure | Explicit src/ tree / flat folder / you decide | Explicit src/ tree |
### Validation + test harness
| Question | Options presented | Selection |
|---|---|---|
| Test runner | Hand-rolled / vitest / workers-pool / you decide | Hand-rolled runner |
| Stale policy | ~15 min / 5 min / you decide | ~15 min freshness |
| Rejection logs | Category-only / + payload hash / you decide | Category-only |
### Secrets + deploy setup
| Question | Options presented | Selection |
|---|---|---|
| Secret storage | Wrangler secrets / CI-injected / you decide | Wrangler secrets |
| Deploy | Local deploy / CI day one | Local deploy only |
| Hygiene | Secrets separation / dry-run vault / you decide | Secrets separation |
## Corrections Made
No corrections — user accepted recommended options throughout; one freeform addition (Malay command aliases D-03/D-04/D-05).
## Auto-Resolved
Not applicable.
## External Research
Not applicable (research phase follows planning; the grammY initData typings verification was already flagged in ROADMAP.md).
@@ -0,0 +1,489 @@
# Phase 2: Bot Skeleton + initData Security - Research
**Researched:** 2026-10-22
**Domain:** grammY bot on Cloudflare Workers + Telegram WebApp initData (HMAC) validation + KV TTL-only session storage
**Confidence:** HIGH (core algorithm + webhook adapter + typing gap verified this session; typing/package versions verified against npm registry)
## Summary
Phase 2 stands up the first backend artifact: a single Cloudflare Worker running a grammY bot with commands (/start, /help, /privacy + Malay aliases) and an HMAC-SHA256 initData validator backed by CI test vectors. All critical claims were verified against primary sources this session:
1. **The flagged typing gap is real and confirmed.** `grammY@1.46.0` and `@grammyjs/types@5.0.0` ship **no `WebAppInitData` typings anywhere** (verified by extracting both tarballs and grepping). The plan must hand-roll a small WebAppInitData interface inside `src/security/initdata.ts` — which fits D-11/D-12 (isolated testable module, zero extra test deps) exactly. No third-party initData package is needed.
2. **The validation algorithm is fully specified by Telegram [VERIFIED: core.telegram.org/bots/webapps]**: `secret_key = HMAC_SHA256(bot_token, key="WebAppData")`, then `hex(HMAC_SHA256(data_check_string, secret_key)) == hash`, with data-check-string = all fields except `hash` (and `signature`), sorted alphabetically, `key=value` joined by `\n`. Staleness via `auth_date` (D-13: ~15 minutes).
3. **Cross-runtime crypto works zero-dep.** Node 24's global `crypto.subtle` produced the exact RFC-4231 HMAC-SHA256 vector, identical to a reference implementation, on this machine today. The same API is the only crypto in Cloudflare Workers. So `src/security/initdata.ts` runs unchanged in Workers AND in a bare `node` test runner — D-12's hand-rolled runner needs no polyfills and no test framework [VERIFIED: local run].
4. **grammY ships a first-class Workers adapter [VERIFIED: shipped source]**: `webhookCallback(bot, "cloudflare-mod", onTimeout, timeoutMilliseconds, secretToken)` accepts the module-format `fetch(request)` handler itself, reads the update from `request.json()`, and performs a **constant-time comparison of `X-Telegram-Bot-Api-Secret-Token`** against the configured secret token, rejecting with 401 on miss.
5. **`setMyCommands` supports localized scopes [VERIFIED: shipped source]**: `bot.api.setMyCommands(commands, { bot_command_scope: { type: "default" }, language_code: "ms" })` — the `language_code` param is typed in the methods surface (`LanguageCode`).
**Primary recommendation:** Hand-roll initData validation + WebAppInitData types on `crypto.subtle` (zero deps, same code in Workers and Node tests); wire the bot into the Worker with `webhookCallback(bot, "cloudflare-mod", undefined, undefined, WEBHOOK_SECRET)`; store one opaque token per chat in KV `chat_sessions` with `expirationTtl` on every put.
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
- **D-01:** Tone is clinical + honest (Rams-aligned): what araharah is in one sentence, no emoji clutter, no marketing flourish. Privacy statement states what is NOT stored as plainly as what is.
- **D-02:** /start is a plain text-only message — no reply keyboard, no inline buttons. Telegram commands are the interface (the Mini App launch path is Phase 4's decision).
- **D-03:** Permanent command menu contains only commands that exist in this phase (/help, /privacy) registered via setMyCommands — no stub entries for future commands.
- **D-04:** Malay command aliases are first-class citizens of the surface from this phase: `/mula` runs the same flow as `/start`, `/privasi` as `/privacy`. Later flow aliases are named now and implemented by their phases: `/pergi_ke` (~ goto), `/cari` (~ search, Phase 4). setMyCommands registers localized command descriptions via a `ms` language_code scope in addition to the default.
- **D-05:** All bot copy lives in a strings module (`src/i18n/strings.ts`): English default + Bahasa Melayu `ms` locale. Telegram's per-user `language_code` picks the locale; unhandled codes fall back to English. Command aliases are handlers on the same command stack, not separate flows.
- **D-06:** Session tokens are opaque random token ids stored in KV `chat_sessions` alongside a minimal bootstrap claim. Not JWTs — revocable and audit-friendly. Handing out the token is the security gate: only successful initData validation mints one.
- **D-07:** Rotate-on-new: when a validated identity arrives for a chat that already holds a live session token, the old token is invalidated (overwritten). One live token per chat.
- **D-08:** The stored claim is deliberately minimal: Telegram `user.id`, `chat.id`, `issued_at`, and the user's `language_code`. No location, no routes, no profile.
- **D-09:** Backend lives in a `bot/` workspace sibling to `theme/` (npm workspaces, root package.json gets thin delegate scripts, mirroring the Phase 1 pattern).
- **D-10:** Language is TypeScript for everything backend (grammY typings first-class, `@cloudflare/workers-types` for the runtime).
- **D-11:** bot/ layout is an explicit `src/` tree: `src/webhook.ts` (worker entry), `src/security/initdata.ts` (HMAC validation — isolated, testable without provider dependencies), `src/kv/sessions.ts` (TTL-only access layer), `src/i18n/strings.ts`, `src/bot/commands/{start,help,privacy}.ts`, wrangler config colocated.
- **D-12:** Test runner is a hand-rolled node runner in the same shape as `theme/scripts/audit.test.mjs` (Phase 1's pattern) — no extra test dependency for this pure-Crypto HMAC vector suite.
- **D-13:** initData freshness policy: reject payloads whose `auth_date` is older than ~15 minutes; the Mini App re-validates with fresh initData on launch.
- **D-14:** Rejection logging is category-only: tampered / stale / replayed / expired — never the raw payload, never coordinates, no user-marking timestamps.
- **D-15:** Secrets (bot token, HMAC secret) live in Cloudflare Wrangler secrets. Local dev mirrors via a gitignored `.dev.vars` (only `.dev.vars.example` ships in the repo).
- **D-16:** Phase 2 deploy is local `npm run deploy` (wrangler deploy + setWebhook). No CI workflow yet.
- **D-17:** Strict secret hygiene: no secret value ever committed, webhook URL in wrangler config, and the /privacy copy stays verifiable by reading the code's storage calls.
### OpenCode's Discretion
- Exact setMyCommands scope/locale structuring details
- Webhook secret token mechanics (Telegram's `secret_token` param) at the researcher's discretion
- Exact KV TTL values inside the "minutes" envelope
- How local wrangler dev simulates KV for testing the product loop
- How the researcher verifies exact grammY initData typings (flagged research gap — RESOLVED, see below)
### Deferred Ideas (OUT OF SCOPE)
- Webhook dispatch via static-asset router to inline Telegram Mini App serving (Phase 3 shapes the mixed static+api worker, not this phase)
- Metrics/diagnostics beyond category logs (Phase 7 audit scope decides)
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|------------------|
| ONBD-01 | User opens the bot, /start gives concise explanation + always-available command menu (/help, /privacy) | grammY command handlers + `setMyCommands` localized scopes (default + `ms`), verified against shipped API surface |
| PRIV-01 | Privacy promise visible in-product: /privacy command (Phase 2 half: the command + copy; in-app messaging completes Phase 6) | Strings module (`src/i18n/strings.ts`) with en/`ms` locales; copy verifiable by reading code's storage calls (D-17) |
| PRIV-02 | Backend holds no persistent per-user location history or profile — TTL-expiring ephemeral state only | KV `chat_sessions` access layer always passes `expirationTtl`; claim restricted to D-08 fields; rejection logs category-only (D-14) |
</phase_requirements>
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| initData HMAC validation + staleness check | API / Backend (Worker) | — | Secret key derived from bot token — never touches a client tier |
| Session token minting (opaque id) | API / Backend (Worker) | — | Only validated identities mint tokens; crypto.randomUUID in Workers/Node |
| Session storage (TTL-only KV) | Database / Storage (KV) | API tier wraps access | Access layer in `src/kv/sessions.ts` is the only KV consumer |
| Bot command surface (/start /help /privacy + ms aliases) | API / Backend (Worker) | — | grammY runs inside the Worker; no SSR tier |
| Bot copy (en + ms) | API / Backend (strings module) | — | Single source of truth per D-05 |
| User sends command / receives message | Client (Telegram app) | API tier processes | Verification of this truth is manual-only (real Telegram client) |
**No security-sensitive capability is assigned outside the API/backend tier.**
## Standard Stack
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| grammy | 1.46.0 | Bot framework (commands, handlers) | First-class Cloudflare Workers support via `wh`/`webhookCallback`; typed Bot API surface. [VERIFIED: npm registry + shipped tarball] |
| @grammyjs/types | 5.0.0 | grammY's Bot API typings (transitive dep) | Ships with grammy; provides `LanguageCode` and API method Param types. Contains **no** WebAppInitData — see gap note. [VERIFIED: tarball grep] |
| @cloudflare/workers-types | 5.20261022.1 | Runtime types (KVNamespace, ScheduledController, env bindings) | The blessed runtime-types package for Workers TS. [VERIFIED: npm registry] |
| wrangler | 4.151.3 | Dev server, generate types (`wrangler types`), deploy, secrets | Current Cloudflare CLI major. [VERIFIED: npm registry, modified 2026-10-20] |
| typescript | 5.9.3 | Language | Latest stable 5.x line; TS 7.0.2 exists but wrangler tooling ecosystem is proven on 5.x — stay conservative (MEDIUM confidence choice, low risk). [VERIFIED: npm registry] |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| @types/node | 26.6.5 | Node types for the hand-rolled test runner only (test scope) | The test runner imports `node:assert`; production code uses no Node APIs. [VERIFIED: npm registry] |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Hand-rolled initData types | `@telegram-apps/init-data-node` | Same algorithm hand-implemented for zero deps keeps D-11/D-12 intact; community pkg adds a dep and a second way to do the same crypto. Rejected for this phase of one. |
| Hand-rolled test runner (D-12) | `@cloudflare/vitest-pool-workers` | The security-critical validator runs in bare Node via global `crypto.subtle` — no pool needed. Pool adds a heavy dep for tests that don't need Worker runtime emulation. Keep pool off the path; Workers-runtime-only behaviors (KV real binding) can't be unit-tested either way and are verified by deploy spot-check per D-16. |
| `webhookCallback(bot, "cloudflare-mod")` | Manual Request parsing | Manual parsing loses grammY's constant-time secret-token check (verified implementation) — don't rebuild it. |
**Installation:**
```bash
npm install -w bot grammy
npm install -w bot -D wrangler typescript @cloudflare/workers-types @types/node
```
**Version verification:** All versions probed against the npm registry today (2026-10-22). Registry publish dates confirmed fresh for every entry (wrangler modified 2026-10-20, workers-types 2026-10-22, grammy 2026-08-26).
**Typing gap resolution [research gap from ROADMAP notes — OPEN QUESTION RESOLVED]:**
`WebAppInitData` is not exported by grammy@1.46.0 or @grammyjs/types@5.0.0 (verified by tarball inspection). `src/security/initdata.ts` therefore defines its own minimal interface from Telegram's official WebAppInitData spec (`query_id?`, `user?`, `chat?`, `start_param?`, `chat_type?`, `chat_instance?`, `auth_date`, `signature?`, `chat_join_request_query_id?`) plus the raw string fields (`hash`, `auth_date`, `user`, ...). This is ~20 lines of types, tested by the same vector suite.
## Architecture Patterns
### System Architecture Diagram
```
Telegram client
│ POST https://<worker>/telegram/webhook
│ (header: X-Telegram-Bot-Api-Secret-Token)
▼
┌─────────────────────────────────────────────────────┐
│ Cloudflare Worker (src/webhook.ts) │
│ └ route /telegram/webhook only │
│ └ webhookCallback(bot, "cloudflare-mod", │
│ secretToken=WEBHOOK_SECRET) │
│ ├ 401 if secret header mismatches │
│ └ bot command handlers: │
│ /start /mula /help /privacy /privasi │
│ └ copy from src/i18n/strings.ts │
│ (en default, ms by user.language) │
│ │
│ POST /api/session (Phase 5 will be Mini App caller; │
│ body: initData string — validated HERE) │
│ ├ validateInitData(initData, BOT_TOKEN) │
│ │ ├ parse querystring fields │
│ │ ├ derive secretKey = HMAC(bot,"WebApp")│
│ │ ├ hex compare → tamper / stale checks │
│ │ └ rejection classes: tampered/stale/ │
│ │ replayed/expired (category logs only│
│ │ per D-14 — no payload, no coords)│
│ ├ mint opaque token (crypto.randomUUID) │
│ └ KV put(key=token, claim, │
│ { expirationTtl: <minutes envelope> })│
│ rotate-on-new: prior token invalidated│
└─────────────────────────────────────────────────────┘
│ ▲
▼ │ read claim by token
KV namespace chat_sessions │ (phase-local verification)
({expirationTtl} on every put — TTL-only, PRIV-02)
```
Note on `/api/session`: the Mini App UI calling it arrives in Phase 4, but the endpoint is the vehicle that makes initData validation and D-06/D-07/KV-TTL observable in this phase (validator is "the first backend artifact" and must be reachable). The Phase 3 route addition (static assets binding) is additive and does not conflict.
### Recommended Project Structure
```
bot/
├── package.json # name @araharah/bot, type module, test/deploy/dev scripts
├── wrangler.jsonc # KV binding chat_sessions, webhook URL vars in config (not secrets)
├── .dev.vars.example # shape only: BOT_TOKEN=, INITDATA_WEBHOOK_SECRET=, WEBHOOK_SECRET=
├── .dev.vars # gitignored — real local secrets (user creates from example)
├── .gitignore # .dev.vars, .wrangler/
├── src/
│ ├── webhook.ts # export default { fetch } — routes /telegram/webhook and /api/session
│ ├── config.ts # typed Env interface (BOT_TOKEN, WEBHOOK_SECRET, chat_sessions binding)
│ ├── security/
│ │ ├── initdata.ts # PURE: validateInitData(raw, botToken, now?) + WebAppInitData types
│ │ └── initdata.test.mjs? # ← runner lives in /test (see below)
│ ├── kv/
│ │ └── sessions.ts # TTL-only access layer: put()pong required expirationTtl; no get-or-store persistent paths
│ ├── i18n/
│ │ └── strings.ts # en + ms copy, locale picker (D-05)
│ └── bot/
│ ├── bot.ts # Bot instance + middleware wiring (no handlers inline)
│ └── commands/
│ ├── start.ts # handler + alias (/start, /mula)
│ ├── help.ts # handler only (no alias in this phase per D-04)
│ └── privacy.ts # handler + alias (/privacy, /privasi)
└── test/
├── runner.mjs # minimal hand-rolled runner (theme/scripts/audit.test.mjs shape)
└── initdata.test.mjs # HMAC vector suite (tamper/stale/replay/known-good)
```
### Pattern 1: Workers module Worker with grammY webhook adapter
**What:** grammY's built-in `webhookCallback` with the `"cloudflare-mod"` adapter converts the Workers `fetch(request)` handler into a grammY request-response cycle, including constant-time secret-token verification.
**When to use:** Always in this project (D-webhook = every phase).
**Example:**
```typescript
// Source: grammy@1.46.0 out/convenience/frameworks.js + webhook.js (shipped code, verified 2026-10-22)
import { Bot, webhookCallback } from "grammy";
import type { Env } from "./config.js";
const bot = new Bot(() => `t${""}`); // NOTE: do NOT copy this literal — see real signature in tasks; Bot(token) is resolved once in createBot(env.BOT_TOKEN)
```
Real shape (verified): grammY's adapter registration — `adapters["cloudflare-mod"]` takes `request` (with `.json()`, `.headers`), exposes constant-time `unauthorized()` responses. Compose as:
```typescript
export default {
fetch(req: Request, env: Env): Promise<Response> {
const url = new URL(req.url);
if (url.pathname === "/telegram/webhook") {
return webhookCallback(botHandler(env), "cloudflare-mod")(
req, undefined, undefined, env.WEBHOOK_SECRET,
);
}
if (url.pathname === "/api/session") { /* Phase 2: validate + mint */ }
return new Response("Not found", { status: 404 });
},
} satisfies ExportedHandler<Env>;
```
**Wait — signature check:** `webhookCallback(bot, adapter, onTimeout, timeoutMilliseconds, secretToken)` — verified from the shipped implementation: `(bot, adapter = defaultAdapter, onTimeout, timeoutMilliseconds, secretToken)` where `secretToken=undefined` disables the header check. Because the bot needs `env.BOT_TOKEN` (bound at deploy), create the Bot inside the fetch handler (Workers have no server startup hook):
```typescript
// src/webhook.ts — real shape
import { Bot, webhookCallback } from "grammy";
import { createBot } from "./bot/bot.js";
import { handleSession } from "./security/session.js";
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const bot = createBot(env); // cheap per-request instantiation; grammY Bot is stateless without polling
const url = new URL(req.url);
if (url.pathname === "/telegram/webhook") {
const cb = webhookCallback(bot, "cloudflare-mod", undefined, undefined, env.WEBHOOK_SECRET);
return cb(req);
}
if (url.pathname === "/api/session") return handleSession(req, env);
return new Response("Not found", { status: 404 });
},
} satisfies ExportedHandler<Env>;
```
(Per-request Bot instantiation is the standard pattern for Workers with env-bound tokens — token can't be resolved at module scope since it arrives via `env`.)
### Pattern 2: initData validation (SubtleCrypto, pure function)
**What:** Verified algorithm from Telegram's official docs, implemented as a pure async function testable in Node AND Workers.
**Example:**
```typescript
// Source: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app (verified 2026-10-22)
export type InitDataRejection = "malformed" | "tampered" | "stale" | "replayed" | "expired";
interface ParseResult { fields: Map<string, string>; hash: string | undefined; }
export async function validateInitData(
raw: string,
botToken: string,
opts: { now?: number; maxAgeSeconds?: number; existingToken?: string } = {},
): Promise<{ ok: true; user: WebAppInitUser; chat?: WebAppInitChat; languageCode?: string | null; }
| { ok: false; reason: InitDataRejection }> {
// 1. parse raw querystring → fields (do NOT pre-decode values for the check string;
// Telegram compares against the data-check-string built from the parsed fields)
// 2. secretKey = HMAC_SHA256(botToken bytes, key="WebAppData")
// 3. dataCheckString = fields minus hash, minus signature, sorted by key, "key=value\n..." (LF join)
// 4. hexCheck = HMAC_SHA256(dataCheckString UTF-8, key=secretKey)
// 5. mismatch → { ok:false, reason:"tampered" }
// 6. auth_date parse fail → "malformed"; auth_date < now - 900s → "stale" (D-13: ~15 minutes)
// 7. no user payload / user lacks id → "malformed"
return {};
}
```
(Full implementation belongs in the plan's task — algorithm + rejection classes above are the verified contract.)
### Pattern 3: TTL-only KV access layer (PRIV-02 by construction)
**What:** The KV accessor's `put` REQUIRES an `expirationTtl` argument (no default path to infinity). Type signature enforces it:
```typescript
// src/kv/sessions.ts — TTL-only by type
export interface SessionClaim {
token_id: string; // opaque token id itself (key)
user_id: number; // D-08 minimal claim
chat_id: number;
issued_at: number;
language_code: string | null;
}
export async function mintSession(kv: KVNamespace, claim: Omit<SessionClaim, "token_id">, ttlSeconds: number): Promise<string> {
const token = crypto.randomUUID();
await kv.put(`session:${token}`, JSON.stringify(claim), { expirationTtl: ttlSeconds }); // required — no overload without TTL
return token;
}
```
**Verify in CI (PRIV-02 spot-check):** the runner asserts `mintSession` forwards `expirationTtl` (mock KV) and that no other code path in `src/` calls `.put(` without `expirationTtl` (grep gate in the runner).
### Anti-Patterns to Avoid
- **Bot token as HMAC key directly:** Telegram uses `secret_key = HMAC(bot_token, key="WebAppData")` first. Validating with `HMAC(dataCheckString, key=botToken)` silently fails everywhere except in your broken tests that encode the same mistake.
- **Including `hash` (or `signature`) in data-check-string:** must exclude before sorting.
- **Re-sorting after URL-decoding values or re-encoding:** build the check string from parsed field values as Telegram sent them (decoded), sorted by key, LF-joined. Sorting must be case-sensitive byte order (JS default `Array.sort()`).
- **`parseInt` edge handling on auth_date:** Telegram sends seconds-since-epoch; parsing that yields `NaN` (e.g., `Date.now()` ms confusion) creates a stale/valid inversion bug. Normalize to seconds.
- **Storing coordinates/profile in KV "temporarily":** D-08's claim is the exhaustive list. PR-14's category-only logs: never `console.log` the raw initData string.
- **`bot.start()` anywhere in the Worker path:** grammY throws if you call `.start()` in webhook mode; handlers must not invoke polling.
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Webhook request parsing + auth | Manual `request.json()` + header check | `webhookCallback(bot, "cloudflare-mod", …, secretToken)` | grammY's constant-time token comparison is already shipped and verified; manual parsing would reintroduce a timing side-channel |
| HMAC-SHA256 | `npm i crypto-js` / custom table-coded blocks | `crypto.subtle.importKey("raw", …, { name: "HMAC", hash: "SHA-256" })` | Native in Workers and Node ≥15; FCA-grade correct; RFC-4231 verified this session |
| Bot command parsing | Custom prefix routers | grammY commands + `bot.command("mula", handler)` for aliases on same composer | grammY grammar handles `/command @botname` edge cases |
| Random opaque tokens | `Math.random().toString(36)` | `crypto.randomUUID()` | Math.random is predictable (rejection class "replay" hard to distinguish); crypto.randomUUID is available in both Workers and Node with zero deps |
**Key insight:** every cryptographic and webhook primitive this phase needs is native to the runtime — third-party crypto or webhook plumbing would only add diff surface to audit (D-17's "verifiable by reading the code's storage calls").
## Common Pitfalls
### Pitfall 1: Sorting alphabetically ≠ lexicographic on the raw VALUE
**What goes wrong:** `data-check-string` fields are sorted **by key**, but implementers sort key-value pair strings or decode-dependent value strings. Telegram sorts by field name only.
**Why it happens:** the spec phrase "sorted alphabetically" is ambiguous out of context.
**How to avoid:** sort a list of `key=value` strings generated after splitting, restricting the sort key to the field name. Test vector suite should include an out-of-alphabetical-arrival-order payload (`user=` before `auth_date=`) that still passes.
**Warning signs:** validation passes for canonical-order payloads but fails for real Telegram packets (Telegram sends `user` first in practice).
### Pitfall 2: HMAC key derivation direction confusion
**What goes wrong:** using `HMAC(bot_token, key=WebAppData)`'s result as the *message* somewhere, or re-deriving the key as `HMAC("WebAppData", key=bot_token)` (reversed).
**Why it happens:** the Telegram pseudocode line `secret_key = HMAC_SHA256(<bot_token>, "WebAppData")` reads left-to-right as message/key but is documented above in prose as **key = "WebAppData", message = bot_token**.
**How to avoid:** encode the derivation as `HMAC(message = botToken ← Bot API token string, key = utf8("WebAppData"))` in both code AND test vectors; one known-good vector pins the convention.
**Warning signs:** every initData with a given hash fails validation despite matching an official worked example.
### Pitfall 3: `auth_date` staleness window in ms-vs-seconds
**What goes wrong:** comparing `auth_date` (seconds) against `Date.now()` (ms) → everything looks 1000× too old; or hard-failing the whole check when the payload is exactly at boundary — off-by-one D-13 semantics.
**How to avoid:** compute age in seconds: `nowSeconds - authDate > maxAgeSeconds` → stale, with `maxAgeSeconds` parameterized (D-13 "$~15 min", plan fixes 900).
**Warning signs:** fresh /start after deploy logs "stale" (or stale logs nothing).
### Pitfall 4: Replay handling vs rotate-on-new (D-07)
**What goes wrong:** naive mintSession lets a stale-but-replayed initData mint *another* token (tests pass, D-07 broken structurally).
**Why it happens:** minting is unordered in the naive flow.
**How to avoid:** the session key is the token id; rotate-on-new means the KV put is keyed and unconditional — old token's key still lives until TTL; D-07's "old token invalidated" is realized by making the KV key a per-chat identity (one session key per chat) or by deleting the old token id. Plan must pick one explicitly (see Open Questions resolution below).
**Warning signs:** two different session keys exist for the same chat id.
### Pitfall 5: `env` shape mismatch in `wrangler types` vs `config.ts`
**What goes wrong:** declaring the Env interface by hand drifts from `wrangler generate-types` output.
**How to avoid:** commit a `bot/src/config.d.ts` generated from `wrangler types` output (run once at phase setup), or choose import-vs-inline explicitly and check in CI by `wrangler deploy --dry-run` (no secret needed for typing check).
**Warning signs:** `KVNamespace` NotFound / env binding missing only at deploy time.
### Pitfall 6: `.dev.vars` accidentally committed
**What goes wrong:** real local secrets via `wrangler dev` get into git (D-17 violation on the first day they exist).
**How to avoid:** `bot/.gitignore` ships in the same commit as `.dev.vars.example`; `.dev.vars` is created for the user, not by them.
**Warning signs:** repo grep for `BOT_TOKEN=` returns a real value.
## Code Examples
### initData validation core (verified shape; full impl in plan)
```typescript
// Source: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
// + Node 24 / Workers globalThis.crypto (RFC-4231 vector verified 2026-10-22)
async function hmacHex(message: string, key: Uint8Array): Promise<string> {
const cryptoKey = await crypto.subtle.importKey(
"raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
);
const sig = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(message));
return [...new Uint8Array(sig)].map(b => b.toString(16).padStart(2, "0")).join("");
}
export async function validateInitData(raw: string, botToken: string, maxAgeSeconds = 900, nowSeconds = Math.floor(Date.now() / 1000)) {
const parsed = querystringParse(raw); // all `key=value` fields
const { hash, ...rest } = parsed; // drop `hash` (+ `signature` if present)
const dataCheckString = Object.entries(rest)
.sort((a, b) => a[0].localeCompare(b[0])) // sort by FIELD NAME, case-sensitive
.map(([k, v]) => `${k}=${v}`)
.join("\n");
const secretKey = utf8Bytes("WebAppData");
const derived = await hmacHex(botToken, secretKey); // key = "WebAppData", message = botToken
const check = await hmacHex(dataCheckString, utf8Bytes(derived)); // key = secretKey (hex → bytes)
if (check !== hash) return { ok: false, reason: "tampered" } as const;
// ... stale / malformed / success per rejection classes
}
```
### Command registration with localized scopes
```typescript
// Source: grammy@1.46.0 api.d.ts surface (setMyCommands(commands, { bot_command_scope, language_code }), verified)
const EN = [{ command: "help", description: "..." }, { command: "privacy", description: "..." }];
const MS = [...]; // en + ms
await bot.api.setMyCommands(EN, { bot_command_scope: { type: "default" } });
await bot.api.setMyCommands(MS, { bot_command_scope: { type: "default" }, language_code: "ms" });
```
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Cloudflare Pages for static+API | Workers with static assets binding (project already committed — PROJECT.md) | CF deprecation wave | This phase's Worker is Workers-native; no Pages compat guarantee needed |
| grammY needed polyfills on Workers | grammY 1.x auto-platform-detects; "cloudflare-mod" is shipped as a named adapter | long-standing stable | `import { Bot, webhookCallback } from "grammy"` just works |
| telegram-web-app `WebAppInitData` via community packages | Official `signature` (Ed25519 third-party) channel exists alongside `hash` (HMAC first-party) | Bot API 7.x–10.x additions | Not needed for this phase (first-party bot validation uses `hash`); noted for Phase 5 playbook |
**Deprecated/outdated:**
- Nothing in the standard stack here is deprecated; `wrangler.toml` vs `wrangler.jsonc` — wrangler 4 supports both, jsonc preferred for comments.
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | `webhookCallback` bot instantiation per-request is accepted Workers practice (token arrives via env) | Architecture Patterns | LOW — bot migrations would document the binding pattern if not; cost of being wrong is minor refactor in webhook.ts |
| A2 | TypeScript 5.9.3 (not TS 7) is the safer pin for wrangler tooling compatibility | Standard Stack | LOW — swap is trivial; nothing in phase depends on TS version specifics |
| A3 | `maxAgeSeconds = 900` satisfies D-13's "~15 minutes" envelope | Common Pitfalls | LOW — one-line change; D-13 wrote the envelope as approximate |
| A4 | KV key strategy for D-07 (`session:<token>` vs one-key-per-chat) — see Open Questions | Pitfall 4 | LOW-MED — affects rotate-on-new semantics; resolved in Open Questions and implemented accordingly |
## Open Questions (RESOLVED)
1. **grammY initData typings — RESOLVED:** Not shipped in grammy@1.46.0 / @grammyjs/types@5.0.0 (tarball-verified). Hand-roll a ~20-line `WebAppInitData` + rejections interface in `src/security/initdata.ts` per Telegram's official spec; no third-party package.
2. **D-07 rotate-on-new mechanics — RESOLVED:** KV key = `session:<token>` with claim containing `chat_id`; on fresh validated identity for a chat, mint new token and **delete the old token key explicitly** (`ctx`-aware: read prior token claim first). Alternative (single key per chat) was rejected because the token is opaque-by-value (D-06) and clients hold the token, not the chat id. Delete-then-mint in one exchange keeps "one live token per chat" and is testable in the runner.
3. **TTL value inside the minutes envelope — RESOLVED:** `chat_sessions` TTL = 900 seconds (15 min), matching D-13's freshness window; the two eine envelope so a session cannot outlive the proof-of-freshness that minted it.
4. **Local KV simulation — RESOLVED:** `wrangler dev` provides local KV emulation out of the box (dev bindings); the session endpoint against `wrangler dev` exercises mint/get/rotate manually. Unit tests fake KVNamespace with a 20-line in-memory mock (the runner, D-12).
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node.js | hand-rolled runner (D-12), crypto.subtle | ✓ | 24.18.0 | — |
| npm | workspace installs, scripts | ✓ | 11.16.0 | — |
| wrangler | dev/deploy/types (D-15/D-16) | ✗ (installs via `npm i -w bot -D wrangler`) | 4.151.3 pinned for install | none needed |
| Cloudflare account + bot token + KV binding | deploy step (D-15/D-16) | user Setup | — | user provides `.dev.vars` values; deploy checkpoint |
| git | repo hygiene (D-17) | ✓ | — | — |
**Missing dependencies with no fallback:**
- None blocking implementation; deploy checkpoint requires user's Cloudflare/bot credentials (user_setup, standard pattern).
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework | Hand-rolled node runner (D-12) — `node:assert`, zero extra deps, `theme/scripts/audit.test.mjs` shape |
| Config file | none — plain `.mjs`, matches Phase 1 pattern |
| Quick run command | `npm test -w bot` |
| Full suite command | `npm test -w bot` (single runner, all vector + privacy gates inside) |
### Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| ONBD-01 | /start returns concise intro + /help,/privacy enrolled | unit (strings + commands module-level) + **manual Telegram walkthrough** (real client —cannot auto-verify Bot UX) | `npm test -w bot` (partial cases: strings module exports; handlers registered w/o keyboards) + manual | ✅ created this phase (Wave 0) |
| PRIV-01 | /privacy copy states stored/not-stored truthfully | unit (strings snapshot classes: states no-location, TTL-only, what IS stored) | `npm test -w bot` | ✅ created this phase (Wave 0) |
| PRIV-02 | TTL-only storage, zero persistent coordinates/profiles | unit (mock KV asserts expirationTtl on every put; grep gate for bare `.put(`) | `npm test -w bot` | ✅ created this phase (Wave 0) |
| Security | initData vectors: known-good HMAC ✓; tampered → fail; stale → fail; replay double-mint → blocked by rotate | unit (crypto vectors + fake KV) | `npm test -w bot` | ✅ created this phase (Wave 0) |
| Webhook auth | secret-token mismatch → 401 | **manual-only + deploy spot-check** — grammY's own verified constant-time check; not re-tested | manual (wrangler dev curl w/ bad header) | n/a — relies on verified library behavior |
### Sampling Rate
- **Per task commit:** `npm test -w bot`
- **Per wave merge:** `npm test -w bot` + `tsc -w bot --noEmit`
- **Phase gate:** full suite green + real-deploy smoke (manual checkpoint: send /start from real Telegram client)
### Wave 0 Gaps
- [ ] `bot/test/runner.mjs` — minimal harness (describe/it substitute, exit codes) — created in this phase's Wave 0 plan
- [ ] `bot/test/initdata.test.mjs` — vector suite (known-good + tampered + stale + replay classes) — Wave 0
- [ ] Install step: `npm i -w bot` — workspace packages introduced with the plan
*(Runner exists nowhere yet — every phase-2 test is a Wave 0 flat, created before the feature tasks land.)*
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | yes | initData HMAC validation IS the authentication; only validated identities mint tokens (D-06) |
| V3 Session Management | yes | Opaque tokens in TTL-only KV (D-06), rotate-on-new (D-07), one-live-token-per-chat invariant |
| V4 Access Control | yes | /api/session refuses requests without valid initData; webhook 401s on secret-token mismatch |
| V5 Input Validation | yes | reject classes malformed/tampered/stale; category-only logging (D-14) |
| V6 Cryptography | yes | HMAC-SHA256 via native SubtleCrypto (never hand-rolled); constant-time header compare from grammY [VERIFIED] |
| V9/V14 Communications/Config | yes | Secrets via wrangler secrets / .dev.vars (D-15); URL in config (D-17) |
### Known Threat Patterns for Cloudflare Workers + Telegram
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Forged initData / replay attack | Tampering, Spoofing | HMAC validation + auth_date freshness (D-13) + D-07 rotate-on-new |
| Webhook flooding with garbage | DoS | grammY 401 fast-path on secret-token mismatch; no KV hit before token check |
| Token theft/brute force | Spoofing, Elevation | Opaque random UUID (128-bit entropy), TTL ≤ session freshness window |
| Secret leakage via logs/repo | Information Disclosure | D-14 category-only logs; D-17 no committed secrets, `.gitignore` for `.dev.vars` |
| KV data毅力 after TTL | Information Disclosure | KV TTL-only namespaces — no KV path without `expirationTtl` (type-enforced) |
## Sources
### Primary (HIGH confidence)
- npm registry — grammy@1.46.0, @grammyjs/types@5.0.0, wrangler@4.151.3, @cloudflare/workers-types@5.20261022.1, typescript@5.9.3, @types/node@26.6.5 (all probed 2026-10-22)
- grammy 1.46.0 shipped source (`out/convenience/frameworks.js`, `out/convenience/webhook.js`) — cloudflare-mod adapter + constant-time secret-token compare + `webhookCallback` signature
- grammy 1.46.0 shipped api.d.ts — `setMyCommands(commands, other?: { bot_command_scope?, language_code? })`
- @grammyjs/types 5.0.0 tarball — confirmed absence of WebAppInitData typings
- https://core.telegram.org/bots/webapps — field chart, data-check-string/secret-key algorithm, auth_date staleness, `secret_token` webhook param, Ed25519 third-party note, setMyCommands localization support
- Local runtime probe: Node 24.18.0 global `crypto.subtle` == RFC-4231 HMAC-SHA256 vector; npm 11.16; wrangler NOT preinstalled (installs as dev dep)
### Secondary (MEDIUM confidence)
- Cloudflare Workers KV `expirationTtl` semantics + wrangler secrets/.dev.vars — standard documented behavior; will verify live during deploy checkpoint
- TS 5.9.3 vs 7.0.2 pin — Assumption A2
### Tertiary (LOW confidence)
- None beyond A1/A2 (everything load-bearing was verified above)
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — versions verified against registry; adapter verified from shipped source
- Architecture: HIGH — algorithm verified from official Telegram docs; crypto verified locally
- Pitfalls: HIGH — pitfalls correspond to verified algorithm details (sorting, key derivation, ms/seconds, replay)
**Research date:** 2026-10-22
**Valid until:** 2026-11-21 (stable stack; wrangler major bumps warrant re-check)
@@ -0,0 +1,29 @@
---
status: all_fixed
phase: 02-bot-skeleton-initdata-security
source: [02-REVIEW.md]
fixed: 2026-10-22
---
## Fix Summary
| Finding | Fix | Commit | Verification |
|---|---|---|---|
| WR-01 unbounded body read on `/api/session` | `bot/src/webhook.ts`: 4 KB pre-read Content-Length rejection + post-read backstop → 413, category-only logs | `8995498` | WK1 valid mint 200; WK2 over-cap signed body → 413, zero KV writes, log `"[initdata] rejected: malformed"`; WK2b lying Content-Length → 413; WK3 broken stream resolves 413 |
| WR-02 fail-open webhook when secret missing | `webhook.ts`: missing/empty `WEBHOOK_SECRET` binding → constant 500 + `[webhook] rejected: misconfigured` before grammY accept-all path; `config.ts` REQUIRED comment | `3ea1fdd` | WK5 unarmed → two consecutive constant 500s, no bot wiring, no KV touch; RED evidence recorded pre-fix; armed+unsigned covered by live 02-03 smokes (401) |
| WR-03 rotate-on-new atomicity over-claim | `sessions.ts`: honest invariant documented (intended single live token; reconciliation at mint/next use; orphans TTL-bounded; Phase 4 must not assume more) + best-effort post-mint sweep | `af691ae` | SR2 re-worded sequential; SR3 stale-replica emulation asserts honest outcome; PG1/PG2 unchanged |
Adjacent INFO fixed in same commits: IN-01(b) dead branch removed, IN-01(c) Phase-4 don't-delete notes added.
Out of scope (Phase 7 audit): IN-01(a), IN-02, IN-03, IN-04, IN-05.
## Verification
- `npm test -w bot` → **34 passed, 0 failed** (was 27)
- `npm run bot:typecheck` → exit 0
- All 4 `console.*` in bot/src are category-only (no payload/uid/timestamp/coords)
- Redeployed after fixes: version `b6ba6e58`; live `/api/session` garbage → 401; webhook pending 0; only stale (pre-fix) last_error
## Review status
02-REVIEW.md status `issues_found` → all Critical+Warning findings fixed; 3 INFO deferred deliberately.
@@ -0,0 +1,153 @@
---
phase: 02-bot-skeleton-initdata-security
reviewed: 2026-10-22T19:59:00Z
depth: standard
files_reviewed: 14
files_reviewed_list:
- bot/package.json
- bot/wrangler.jsonc
- bot/tsconfig.json
- bot/.gitignore
- bot/.dev.vars.example
- bot/src/config.ts
- bot/src/webhook.ts
- bot/src/security/initdata.ts
- bot/src/kv/sessions.ts
- 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/runner.mjs
- bot/test/harness.mjs
- bot/test/initdata.test.mjs
- bot/test/commands.test.mjs
- bot/test/privacy.test.mjs
- bot/test/sessions.test.mjs
- package.json
findings:
critical: 0
warning: 3
info: 5
total: 8
status: issues_found
---
# Phase 2: Code Review Report
**Reviewed:** 2026-10-22T19:59:00Z
**Depth:** standard
**Files Reviewed:** 22 (14 entry list + hidden config/test files)
**Status:** issues_found
## Summary
The security centerpiece holds up under scrutiny. The HMAC initData validator implements the official algorithm correctly (key = `HMAC-SHA256(key="WebAppData", msg=botToken)`, message = key-sorted data-check-string, comparison in constant time, freshness check after signature — D-13 precedence as specified). Re-verified against grammY's shipped typings: the `webhookCallback(bot, "cloudflare-mod", onTimeout, timeoutMs, secretToken)` positional form used in webhook.ts matches the 5-arg overload in `grammy/out/convenience/webhook.d.ts`. KV session layer honors TTL-only on every write path; `.dev.vars` is correctly gitignored via `bot/.gitignore` and untracked in git (verified with `git check-ignore` / `git ls-files`). Full suite green (27/27) and `tsc --noEmit` clean at review time.
Three warnings remain, none of them in the validator's crypto path itself: (1) the only public unauthenticated endpoint accepts an unbounded request body straight into memory, (2) a one-secret-missing misconfiguration silently *disables* the webhook secret check instead of failing closed (confirmed in grammY's runtime source, `webhook.js:24-27`), and (3) the D-07 rotate-on-new invariant is only best-effort under KV's non-atomic read-delete-put, while the code comments call it "structurally closed".
Not flagged (locked decisions per D-01..D-17): opaque UUID tokens instead of JWTs, no replay-nonce store (D-07 rotation is the accepted replay answer), 900 s TTL envelope, per-request bot instantiation.
## Warnings
### WR-01: `/api/session` reads an unbounded request body into memory (DoS surface)
**File:** `bot/src/webhook.ts:49`
**Issue:** `const raw = await req.text()` has no length cap before `parseFields` splits it. This is the one *unauthenticated, public* endpoint on the Worker: an attacker can POST up to Cloudflare's ingress body limit (tens of MB) of `&` or long values. `raw.split("&")` on a multi-MB body allocates millions of array elements plus Map entries — enough to blow the Worker isolate's 128 MB memory or CPU budget → 500s and isolate churn on a free-tier Worker. Telegram initData is a few hundred bytes; anything more is abuse.
**Fix:**
```ts
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();
if (raw.length > 4096) {
// register in D-14 vocabulary: still category-only (no payload echo)
console.log("[initdata] rejected: malformed");
return new Response(JSON.stringify({ error: "malformed" }), { status: 413, headers: { "content-type": "application/json" } });
}
const result = await validateInitData(raw, env.BOT_TOKEN);
...
```
(4 KB is generous — known-good real initData fixtures are ≪ 1 KB.)
### WR-02: Missing `WEBHOOK_SECRET` silently disables webhook authentication (fail-open)
**File:** `bot/src/webhook.ts:33-36`
**Issue:** `webhookCallback(bot, "cloudflare-mod", undefined, undefined, env.WEBHOOK_SECRET)` passes the secret through, but grammY's check is opt-in per call: `compareSecretToken` in `node_modules/grammy/out/convenience/webhook.js:24-27` returns `true` for **every** request when `secretToken === undefined`. If `BOT_TOKEN` is armed as a wrangler secret but `WEBHOOK_SECRET` is forgotten (a plausible prod path — no deploy script runs `wrangler secret put`), Telegram still sends the header against a `setWebhook`-registered secret, yet the Worker accepts *any* unsigned POST to `/telegram/webhook` as auth: anyone can inject fabricated updates/spam, and this is the identity-carrying path Phase 4's Mini App flow will build on. Today's phase fails-anything-but-500 for missing secrets elsewhere; the one auth gate must not be the exception.
**Fix:**
```ts
if (url.pathname === "/telegram/webhook") {
if (!env.WEBHOOK_SECRET) {
// fail closed: an unarmed secret must not silently arm the endpoint
return new Response("Server misconfigured", { status: 500 });
}
const bot = createBot(env);
...
}
```
### WR-03: D-07 rotate-on-new is not atomic — interleaved mints leave an orphan live token
**File:** `bot/src/kv/sessions.ts:49-57`
**Issue:** `mintSession` is get-pointer → delete-old-session → put-new-session → put-pointer. Cloudflare KV has no transactions and is eventually consistent, so two `POST `/api/session`` replays of the same valid initData can interleave: both read the same pointer, both mint, the loser's `session:<token>` record is never deleted, and KV replication may serve a stale pointer across colos — both tokens are live for up to 900 s. The module comments claim the invariant is "structurally closed" (line 48: "one live token per chat") and `SR2` asserts "exactly one live record" — true only sequentially. Impact is bounded (any party able to mint within the 15-min freshness window already holds a valid identity by D-13's design), so this is not a privilege escalation, but the invariant the tests/docs pin is stronger than what KV delivers.
**Fix (pick one and make the choice explicit in docs/tests):**
- Reconcile after write: re-read `chat:<chat_id>` after both `put`s and `kv.delete` the pointer target if it is neither the new token nor already deleted.
- Or downgrade the stated invariant truthfully: "rotation is best-effort under KV eventual consistency; every token dies within 900 s regardless", and note it in `sessions.ts`/`02-CONTEXT.md` so Phase 4 does not build a nonce-store assumption on top.
## Info
### IN-01: Dead code — unused `ParsedInitData` export, unreachable branch condition, unused read APIs
**File:** `bot/src/security/initdata.ts:53-60` / `bot/src/kv/sessions.ts:52` / `bot/src/kv/sessions.ts:61-86`
**Issue:** (a) `ParsedInitData` is exported and never imported anywhere — `validateInitData` never emits it (the file's own header comment doesn't use the shape either). (b) `previousToken !== token` at sessions.ts:52 can never be false — `token` is a fresh `crypto.randomUUID()` minted two lines above, so the guard is unreachable code dressed as a check. (c) `getTokenForChat` and `getClaimByToken` have no production caller in this phase; acceptable as the Phase 4 access-layer API, but only if that intent is stated.
**Fix:** Delete `ParsedInitData` and the dead `previousToken !== token` half-branch (keep `if (previousToken !== null) { ... }`). Add a one-line comment over the two read helpers: "consumed by the Phase 4 Mini App flow" — or their first real consumer from that phase.
### IN-02: Stale wrangler.jsonc comment contradicts the shipped namespace id
**File:** `bot/wrangler.jsonc:2` (vs `:9`)
**Issue:** Line 2 still says the KV id is "filled in plan 03 task 1 after `npx wrangler kv namespace create`", but line 9 already carries the real id. A future reader trusts the comment and tries to re-create/replace an existing namespace — the exact setup churn the comment was guarding against.
**Fix:** Replace the comment with the post-fact record: `// chat_sessions namespace created via wrangler kv namespace create (id below is live).`
### IN-03: Claimed 900 s freshness boundary is not actually tested
**File:** `bot/test/initdata.test.mjs:133-150` / `bot/src/security/initdata.ts:185`
**Issue:** Both the implementation comment ("899 ok / 900 ok / 901 stale") and the T4 plan-header claim the exact-900 case, but the suite only signs 899 s and 901 s fixtures. For a security-critical boundary (D-13), the middle point is the one that pins the comparator direction (`>` vs `>=`); the code's `>` matches the stated claim today, but nothing would catch a `>=` regression.
**Fix:**
```js
test("T4 freshness boundary: age 900 s → ok:true (boundary is inclusive)", async () => {
const validateInitData = await loadValidator();
const now = Math.floor(Date.now() / 1000);
const res = await validateInitData(signedUser(now - 900), TEST_TOKEN);
assert.equal(res.ok, true, `expected ok:true at exactly 900 s, got ${JSON.stringify(res)}`);
});
```
### IN-04: `.dev.vars` parser diverges from wrangler's dotenv semantics — and is duplicated in both scripts
**File:** `bot/scripts/register-commands.mjs:29-46` / `bot/scripts/set-webhook.mjs:24-41`
**Issue:** The hand-rolled parser keeps quotes and inline comments as part of the value (`BOT_TOKEN="123:ABC"` sends a token wrapped in literal quotes; `WEBHOOK_URL=... # comment)` mangles the URL), whereas wrangler itself parses `.dev.vars` dotenv-style with quotes stripped. A user following dotenv habits gets opaque 401s from `npm run bot:commands` while local `wrangler dev` works — the two runtimes disagree about the same file. Additionally the identical 18-line `readDevVars` exists in both scripts — the repo's first copy-paste of a secrets-handling helper, the worst place for implementations to drift.
**Fix:** Strip surrounding quotes and trailing inline comments in one shared `bot/scripts/lib/dev-vars.mjs`, imported by both scripts; or better, call out the assumed format in `.dev.vars.example` (`unquoted KEY=value`, no inline comments).
### IN-05: `handleSession` has no error handling around the mint — KV failure surfaces as an opaque 500 with no category log
**File:** `bot/src/webhook.ts:47-64`
**Issue:** `validateInitData` itself never throws (all parse paths are caught), but `mintSession` unhooks that guarantee: a KV write failure during `/api/session` propagates as an unhandled Worker exception — generic 500 page, no D-14 category entry, nothing distinguishing "storage down" from a crash in review/Tail logs. Not a correctness hazard (fail-closed), just an observability gap in the phase that defined the category vocabulary.
**Fix:**
```ts
try {
const token = await mintSession(env.chat_sessions, { /* claim */ });
return new Response(JSON.stringify({ token }), { status: 200, ... });
} catch {
console.log("[session] mint failed: storage"); // category-only (D-14)
return new Response(JSON.stringify({ error: "storage" }), { status: 503, headers: { "content-type": "application/json" } });
}
```
---
_Verified during review (not findings): validation vectors T1–T7 green; `tsc --noEmit` clean; `bot/.dev.vars` ignored and untracked; HMAC key derivation matches Telegram's official algorithm and the RFC-4231-pinned independent node:crypto path; every `kv.put` carries `expirationTtl`; the single `console.log` is category-only (D-14); no secrets or payload fields echoed in scripts or responses._
_Reviewed: 2026-10-22T19:59:00Z_
_Reviewer: OpenCode (gsd-code-reviewer)_
_Depth: standard_
@@ -0,0 +1,81 @@
---
phase: 2
slug: bot-skeleton-initdata-security
status: ready
nyquist_compliant: true
wave_0_complete: false
created: 2026-10-22
---
# Phase 2 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
---
## Test Infrastructure
| Property | Value |
|----------|-------|
| **Framework** | Hand-rolled node runner (D-12) — `node:assert`, zero extra deps, `theme/scripts/audit.test.mjs` shape |
| **Config file** | none — plain `.mjs` (Wave 0 creates) |
| **Quick run command** | `npm test -w bot` |
| **Full suite command** | `npm test -w bot` (single runner covers vectors + privacy gates) |
| **Estimated runtime** | ~2 seconds (pure crypto + mocks, no network) |
---
## Sampling Rate
- **After every task commit:** Run `npm test -w bot`
- **After every plan wave:** Run `npm test -w bot` + `tsc --noEmit` (bot workspace)
- **Before `/gsd-verify-work`:** Full suite must be green
- **Max feedback latency:** 5 seconds
---
## Per-task Verification Map
| task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| {N}-01-01 | 01 | 1 | PRIV-02, D-08/D-11/D-12/D-15/D-17 | T-02-03 / T-02-04 | no committed secrets; TTL-only type surface scaffold | unit (harness + vector stubs live) | `npm test -w bot` | ❌ W0 | ⬜ pending |
| {N}-01-02 | 01 | 1 | Security (SC3), ONBD-03-adjacent | T-02-01 / T-02-02 | tampered/stale/replayed/malformed rejected; known-good HMAC passes | unit (vectors) | `npm test -w bot` | ❌ W0 (stubbed in 01-01, filled here) | ⬜ pending |
| {N}-02-01 | 02 | 2 | PRIV-01/D-05 | T-02-04 | strings module has en + ms, no violation classes; TTL-only log categories (D-14) | unit | `npm test -w bot` | ❌ W0 | ⬜ pending |
| {N}-02-02 | 02 | 2 | ONBD-01, D-01..D-05 | — | handlers lean, no keyboards (D-02); alias co-routing (D-04); setMyCommands default+ms (D-03/D-04) | unit | `npm test -w bot` | ✅ (from 02-01 forward) | ⬜ pending |
| {N}-02-02-manual | 02 | 2 | ONBD-01 | — | real Telegram client: /start, /help, /privacy, aliases | manual checkpoint | — | — | ⬜ pending |
| {N}-03-01 | 03 | 3 | D-06/D-07/D-08, PRIV-02 | T-02-05 / T-02-06 | mint requires validated initData; rotate-on-new; claim is D-08-minimal; expirationTtl on every put | unit (mock KV) | `npm test -w bot` | ✅ | ⬜ pending |
| {N}-03-02 | 03 | 3 | D-16 deploy gate | T-02-03 | wrangler dry-run clean; .dev.vars ignored; secrets not in git | integration (dry-run) + manual checklist | `npx wrangler deploy --dry-run && npm test -w bot` | ✅ | ⬜ pending |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
- [ ] `bot/test/runner.mjs` — minimal hand-rolled harness (theme/scripts/audit.test.mjs shape, node:assert)
- [ ] `bot/test/initdata.test.mjs` — vector suite skeleton (known-good + tampered + stale + replay cases)
- [ ] `bot/.gitignore` (+ `.dev.vars.example`) — ships in the same commit as the workspace init (D-17)
- [ ] `npm i -w bot` — workspace package install (grammy, wrangler, typescript, workers-types, @types/node)
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| /start /help /privacy + /mula /privasi reply correctly in real Telegram | ONBD-01, PRIV-01 | Requires a live Telegram client + deployed bot token; Bot UI cannot be driven from the runner | 1) `npm run deploy` (D-16) 2) /start the real bot → intro + menu 3) /help → menu matches D-03 4) /privacy → truthful copy 5) /mula /privasi → same flows 6) non-en client → ms menu (covers setMyCommands scope) |
| Webhook 401 on bad secret-token header | V4/V2 security gate | grammY's verified constant-time check is library behavior, not our code | `curl -X POST $URL/telegram/webhook -d '{}'` (no header) → 401; with wrong value → 401 |
| Deploy smoke + KV spot-check | PRIV-02, D-16 | Live storage inspection | after deploy: `npx wrangler kv key list --namespace chat_sessions` (via dev session) — only TTL-bounded keys; no coordinate values; keys expire after 900s |
---
## Validation Sign-Off
- [x] All tasks have `<automated>` verify or Wave 0 dependencies
- [x] Sampling continuity: no 3 consecutive tasks without automated verify
- [x] Wave 0 covers all MISSING references
- [x] No watch-mode flags
- [x] Feedback latency < 5s
- [x] `nyquist_compliant: true` set in frontmatter
**Approval:** pending
@@ -0,0 +1,135 @@
---
phase: 02-bot-skeleton-initdata-security
verified: 2026-10-22T21:05:00Z
status: passed
score: 4/4 must-haves verified
overrides_applied: 0
re_verification:
previous_status: none
previous_score: n/a
gaps_closed: []
gaps_remaining: []
regressions: []
---
# Phase 2: Bot Skeleton + initData Security Verification Report
**Phase Goal:** Users can reach araharah through Telegram with a working command surface, and the backend's identity + privacy architecture is provably sound from the first deploy — the initData validator is the first backend artifact.
**Verified:** 2026-10-22T21:05Z
**Status:** passed
**Re-verification:** No — initial verification (codebase + live deployment)
## Goal Achievement
### Observable Truths
| # | Truth (ROADMAP SC) | Status | Evidence |
|---|--------------------|--------|----------|
| 1 | SC1/ONBD-01: /start gives a concise explanation plus an always-available command menu (/help, /privacy) that answers correctly | ✓ VERIFIED | Handlers `bot.command("start"/"mula", …)` reply `strings.startIntro` (one clinical sentence, text-only, no keyboard — CS4 grep gate green); live Telegram read-backs: `getMyCommands` (default) and `getMyCommands?language_code=ms` both return **exactly** `help` + `privacy`, `getChatMenuButton` → `commands`; user real-client walkthrough a–e all green and APPROVED (`02-03-APPROVAL.md`) |
| 2 | SC2/PRIV-01: /privacy gives an honest visible statement of what araharah stores / never stores | ✓ VERIFIED | `privacy.ts` builds `privacyStored` + `privacyNotStored` from `strings.ts`; copy names exactly user id, chat id, language, ~15-min TTL — matching `sessions.ts` `SessionClaim {user_id, chat_id, issued_at, language_code}` and `SESSION_TTL_SECONDS=900`; no 5th claim field in code; user observed block as written (approval check c) |
| 3 | SC3: tampered, stale, and replayed initData rejected with CI test vectors; known-good HMAC passes; only validated identities get a one-time token | ✓ VERIFIED | `npm test -w bot` → **34 passed, 0 failed** (T1–T7 security vectors incl. tamper/stale/899/901 boundary/order/malformed + RFC-4231 pin; WK1–WK5; sessions SM1/SM2/SR1–SR3; privacy PG1/PG2). Mint path is *only* after `ok:true` in `webhook.ts` (single `mintSession` call). Replay closes via D-07 rotate-on-new (CONTEXT decision; re-mint cannot yield an extra token — accepted locked decision, WR-03 invariant documented honestly) |
| 4 | SC4/PRIV-02: storage spot-check shows TTL-only KV, zero persistent coordinates or profiles | ✓ VERIFIED | Live `wrangler kv key list --namespace-id 59f58a54…` → `[]` (empty = TTL-expired privacy-by-construction); every `kv.put` in `sessions.ts` carries `expirationTtl: SESSION_TTL_SECONDS` on the call line (PG1 grep test green); `wrangler.jsonc` has exactly ONE kv binding (`chat_sessions`), no D1/R2/DO anywhere; claim key-set equals D-08 enforced by SM1 |
**Score:** 4/4 truths verified
### Post-Review Hardening (WR-01/02/03) — Live Verification
| Check | Status | Evidence |
|-------|--------|----------|
| Deployed version includes all 3 fix commits | ✓ | Commits `8995498`/`3ea1fdd`/`af691ae` at 12:17–12:21 UTC; HEAD = `af691ae`; latest active deployment `b6ba6e58-37dd-4aa1…` created 12:22:03 UTC (after final commit — `wrangler deployments list`) |
| WR-01 live (4 KB body cap) | ✓ | `POST /api/session` with 5 KB body → **413**; tiny garbage → 401 `{"error":"malformed"}` |
| WR-02 fail-closed secret guard | ✓ | Code verified at `webhook.ts:50-53` (constant 500 before grammY accept-all path); WK5 test green; not live-testable read-only (would require rotating secrets — timing evidence + 413 smoke confirm post-fix bundle deployed) |
| WR-03 honest invariant | ✓ | `sessions.ts` header documents best-effort rotate + reconciliation sweep; SR3 asserts the honest KV-limits outcome; IN-01b dead branch removed |
### Deferred Items (Step 9b filter of review findings)
| # | Item | Addressed In | Evidence |
|---|------|-------------|----------|
| 1 | Stale wrangler.jsonc comment (line 2: "filled in plan 03") contradicting the live id on line 9 (REVIEW IN-02, INFO) | Phase 7 | `02-REVIEW-FIX.md`: "Out of scope (Phase 7 audit): IN-01(a), IN-02, IN-03, IN-04, IN-05" — doc-comment hygiene, zero functional impact |
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `bot/src/security/initdata.ts` | validateInitData + rejection classes, no grammy dep | ✓ VERIFIED | 215 lines; SubtleCrypto HMAC, constant-time hex compare, "WebAppData" salt; consumed by `webhook.ts` |
| `bot/src/kv/sessions.ts` | TTL-only KV layer, rotate-on-new | ✓ VERIFIED | 122 lines; 2 puts, both with `expirationTtl`; read helpers noted as Phase 4 API (IN-01c) |
| `bot/src/webhook.ts` | Worker entry: webhook route + /api/session gate | ✓ VERIFIED | Two routes only + 404; WR-01 cap; WR-02 fail-closed; D-14 category-only logs (4 console lines, all category-only) |
| `bot/src/i18n/strings.ts` + `bot/src/bot/{bot.ts,commands/*}` | Copy source of truth, en+ms, text-only handlers | ✓ VERIFIED | WIRED: commands import strings; bot.ts registers 5 commands; live menu read-back matches helpMenu |
| `bot/src/config.ts` | Env contract | ✓ VERIFIED | BOT_TOKEN/WEBHOOK_SECRET/chat_sessions; REQUIRED comment added in WR-02 fix |
| `bot/test/{harness,runner}.mjs` | Zero-dep D-12 harness | ✓ VERIFIED | `test: "node test/runner.mjs"` — ran clean, 34/0, `devDependencies` carry zero test libs |
| `bot/test/{initdata,sessions,privacy,commands,webhook}.test.mjs` | Vector suites | ✓ VERIFIED | 34 cases green incl. T1–T7, WK1–WK5 (webhook suite added by review fix `8995498`) |
| `bot/scripts/{register-commands,set-webhook}.mjs` | Menu registration + setWebhook w/ secret_token | ✓ VERIFIED | Descriptions derived from helpMenu (no drift); secrets read from `.dev.vars`, never echoed |
| `bot/wrangler.jsonc` | main src/webhook.ts + chat_sessions binding (live id) | ✓ VERIFIED | id `59f58a54…` live (matches APPROVAL record + deployment) |
| `.dev.vars` hygiene | Gitignored, untracked | ✓ VERIFIED | `git check-ignore` ok; `git ls-files` shows only `.dev.vars.example` |
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|----|--------|---------|
| webhook.ts | initdata.ts | `validateInitData` call → 401/413 on rejection, mint on ok | ✓ WIRED | Lines 99–113; live 401 smoke confirms |
| webhook.ts | sessions.ts | `mintSession` only after ok:true | ✓ WIRED | Single mint path; WK1 asserts exactly two 900s KV writes |
| webhook.ts | bot.ts / grammy | `webhookCallback(bot,"cloudflare-mod",undefined,undefined,env.WEBHOOK_SECRET)` | ✓ WIRED | Live unsigned/wrong-secret webhook → 401 both |
| commands/*.ts | strings.ts | copy imported, no inline copy | ✓ WIRED | start/help/privacy all import `strings`; reply builders pure |
| register-commands.mjs | strings.ts | menu descriptions derived from helpMenu | ✓ WIRED | Live getMyCommands text matches strings exactly |
| sessions.ts | KVNamespace.put | every put carries expirationTtl | ✓ WIRED | PG1/PG2/SM2 green; live KV list empty post-TTL |
### Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
|----------|--------------|--------|-------------------|--------|
| webhook.ts handleSession | initData string | HTTP request body | Yes — validated against real bot-token HMAC | ✓ FLOWING |
| privacy command | locale/strings | ctx.from.language_code → pickLocale | Yes (live user confirmed) | ✓ FLOWING |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| Full test suite | `npm test -w bot` | `[runner] 34 passed, 0 failed`, exit 0 | ✓ PASS |
| Typecheck | `npm run bot:typecheck` | exit 0 | ✓ PASS |
| Webhook auth (unsigned / wrong header) | live POST /telegram/webhook | 401 / 401 | ✓ PASS |
| /api/session garbage | live POST | 401 `{"error":"malformed"}` | ✓ PASS |
| /api/session 5 KB body (WR-01) | live POST | 413 | ✓ PASS |
| GET /api/session | live | 405 | ✓ PASS |
| Unknown route | live | 404 | ✓ PASS |
| Command menu (default + ms) | live getMyCommands ×2 | exactly /help + /privacy in both languages | ✓ PASS |
| Menu button | live getChatMenuButton | type `commands` | ✓ PASS |
| Webhook armed | live getWebhookInfo | url `/telegram/webhook`, pending_update_count 0, `allowed_updates:["message"]` | ✓ PASS |
| KV TTL-only spot-check | live `wrangler kv key list` | `[]` (empty = all sessions expired ≤900s) | ✓ PASS |
| Deployed = HEAD (post-fix bundle) | `wrangler deployments list` + git log | b6ba6e58 (12:22:03Z) created after af691ae (12:21:11Z); current deployments list shows b6ba6e58 active at 100% | ✓ PASS |
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|------------|-------------|--------|----------|
| ONBD-01 | 02-01, 02-03 | Open bot, /start gives concise explanation + always-available command menu | ✓ SATISFIED | Handlers + strings verified; live menu lists exactly /help /privacy both scopes; user-approved walkthrough (SC1) |
| PRIV-01 | 02-01, 02-03 | Privacy promise visible in-product: /privacy command | ✓ SATISFIED (phase half) | /privacy + /privasi live-observed; noted: "trip ended, data deleted" messaging half completes in Phase 6 per REQUIREMENTS.md:127 |
| PRIV-02 | 02-01, 02-02 | No persistent per-user history/profile — TTL-only ephemeral state | ✓ SATISFIED | Live KV `[]`; TTL-only on all write paths (PG1/PG2/SM tests); one binding in wrangler.jsonc; no other storage code in bot/src |
All phase requirement IDs (ONBD-01, PRIV-01, PRIV-02) appear in plan frontmatter and are mapped to Phase 2 in REQUIREMENTS.md:97-99 — no orphaned requirements. Roadmap Phase 2 defines exactly 4 success criteria (device-matrix criterion belongs to Phase 3's SC5) — all 4 covered above.
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| bot/wrangler.jsonc | 2 | Stale "KV_ID_PLACEHOLDER" comment vs live id on line 9 | ℹ️ Info | Doc-only, deferred to Phase 7 (REVIEW IN-02) |
| bot/src/security/initdata.ts | 53-60 | `ParsedInitData` exported but unused | ℹ️ Info | Type-only export, deliberate; documented in REVIEW IN-01(a) |
| — (everything else) | — | TODO/FIXME/placeholder/console-echo of secrets/empty implementations: full grep of src/, scripts/, test/ returned **zero** | — | — |
The `getWebhookInfo` `last_error` timestamp (11:18:20 UTC) predates the final BOT_TOKEN secret re-upload (11:18:25 UTC deployment from the walkthrough remediation) and the user's subsequent live `/start` approval; `pending_update_count` is 0. Known resolved history, not a finding.
### Human Verification Required
**None outstanding.** The human-only gates of this phase were already resolved and are recorded as evidence:
- **Real-client walkthrough a–e** (D-16 blocking gate): user performed 2026-10-22 and said *"it works! Menu or / has /help and /privacy! approved!"* — `02-03-APPROVAL.md` (cross-checked live via Telegram API read-backs performed during this verification).
- Secret provisioning (BotFather token, wrangler secrets): resolved during deploy chain; runtime behavior live-verified by smokes.
### Gaps Summary
No gaps. All four roadmap success criteria verified against code, tests, and the live deployment; the code-review's 3 warnings are fixed and the fix bundle is what is live (timing + WR-01 live smoke). The one un-replicable human gate (real Telegram client) is user-approved with recorded evidence.
---
_Verified: 2026-10-22T21:05Z_
_Verifier: OpenCode (gsd-verifier)_
_Verification basis: local code at af691ae + live deployment b6ba6e58; all Cloudflare/Telegram state touched read-only._