ambek .planning
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user