# 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 (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) ## 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) | ## 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:///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: })│ │ 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 { 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; ``` **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 { 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; ``` (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; 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, ttlSeconds: number): Promise { 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(, "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 { 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:` 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:` 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)