39 KiB
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:
- The flagged typing gap is real and confirmed.
grammY@1.46.0and@grammyjs/types@5.0.0ship noWebAppInitDatatypings anywhere (verified by extracting both tarballs and grepping). The plan must hand-roll a small WebAppInitData interface insidesrc/security/initdata.ts— which fits D-11/D-12 (isolated testable module, zero extra test deps) exactly. No third-party initData package is needed. - The validation algorithm is fully specified by Telegram [VERIFIED: core.telegram.org/bots/webapps]:
secret_key = HMAC_SHA256(bot_token, key="WebAppData"), thenhex(HMAC_SHA256(data_check_string, secret_key)) == hash, with data-check-string = all fields excepthash(andsignature), sorted alphabetically,key=valuejoined by\n. Staleness viaauth_date(D-13: ~15 minutes). - Cross-runtime crypto works zero-dep. Node 24's global
crypto.subtleproduced 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. Sosrc/security/initdata.tsruns unchanged in Workers AND in a barenodetest runner — D-12's hand-rolled runner needs no polyfills and no test framework [VERIFIED: local run]. - grammY ships a first-class Workers adapter [VERIFIED: shipped source]:
webhookCallback(bot, "cloudflare-mod", onTimeout, timeoutMilliseconds, secretToken)accepts the module-formatfetch(request)handler itself, reads the update fromrequest.json(), and performs a constant-time comparison ofX-Telegram-Bot-Api-Secret-Tokenagainst the configured secret token, rejecting with 401 on miss. setMyCommandssupports localized scopes [VERIFIED: shipped source]:bot.api.setMyCommands(commands, { bot_command_scope: { type: "default" }, language_code: "ms" })— thelanguage_codeparam 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:
/mularuns the same flow as/start,/privasias/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 amslanguage_code scope in addition to the default. - D-05: All bot copy lives in a strings module (
src/i18n/strings.ts): English default + Bahasa Melayumslocale. Telegram's per-userlanguage_codepicks 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_sessionsalongside 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'slanguage_code. No location, no routes, no profile. - D-09: Backend lives in a
bot/workspace sibling totheme/(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-typesfor 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_dateis 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.exampleships 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_tokenparam) 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:
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:
// 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:
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):
// 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:
// 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:
// 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 withHMAC(dataCheckString, key=botToken)silently fails everywhere except in your broken tests that encode the same mistake. - Including
hash(orsignature) 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()). parseIntedge handling on auth_date: Telegram sends seconds-since-epoch; parsing that yieldsNaN(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.logthe 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)
// 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
// 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.tomlvswrangler.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)
- 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 insrc/security/initdata.tsper Telegram's official spec; no third-party package. - D-07 rotate-on-new mechanics — RESOLVED: KV key =
session:<token>with claim containingchat_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. - TTL value inside the minutes envelope — RESOLVED:
chat_sessionsTTL = 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. - Local KV simulation — RESOLVED:
wrangler devprovides local KV emulation out of the box (dev bindings); the session endpoint againstwrangler devexercises 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 planbot/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 +webhookCallbacksignature - 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_tokenwebhook 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
expirationTtlsemantics + 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)