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

490 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 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)