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

39 KiB
Raw Blame History

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:

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.

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

// 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.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)