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,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)