- POST: WR-01 cap (MAX_GUIDANCE_BODY_CHARS 8192, pre/post-read double check), hand-rolled snapshot validation (slug destinationId, bounded name, finite metrics, integer startedAtSec, polyline6 geometry >= 2 points via decodePolyline6) -> mintGuidanceSession with the CLAIM's chat_id — a body chat_id is structurally ignored (T-06-02 injection probe GW4) - GET/DELETE: resolve then OWN — envelope.chat_id === claim.chat_id else 404, foreign and absent indistinguishable (T-06-02b); DELETE actively removes the record (D-14's server half); non-POST/GET/DELETE -> 405 - category-only logging: [guidance] rejected: unauthorized|malformed — literal strings, no interpolation of body/claim values (D-14) - RP2 re-pin ships WITH the change (gate re-pin law): the total @araharah/routing ban becomes an explicit allowlist — exactly the polyline6 codec may enter the Worker (geometry validation); the routing stack stays web-side; RP1 unchanged - bot/package.json + lock: @araharah/routing workspace dep
@araharah/bot
The arah² Telegram bot — arah2bot. A grammY bot running on a single Cloudflare Worker that receives Telegram updates, keeps TTL-only chat sessions in Workers KV, gates the Mini App's session handshake, and serves the Mini App's static assets from the same origin.
Part of the arah² monorepo. For the design system see theme/, for the Mini App see web/, and for cross-package architecture see docs/ARCHITECTURE.md.
What it does
Three jobs, all inside one Worker fetch handler (src/webhook.ts):
- Chat commands —
/start(/mula),/help,/privacy(/privasi) reply with plain text in English or Bahasa Melayu. - Session gate — validates a Mini App's verbatim
Telegram.WebApp.initDatastring by HMAC and mints an opaque, 15-minute session token. - Location relay — when the Mini App can't get a fix, the bot sends a
request_locationreply keyboard into the user's own chat; the shared pin lands in KV and the app polls for it.
Privacy is structural rather than promised: every write to KV carries an explicit expirationTtl, and the persisted record is exactly the four claim fields plus exactly the three location fields — nothing else can be serialized. A test (PG1) greps the storage sources to hold that line.
graph TD
TG["Telegram Bot API"]
W["Cloudflare Worker — araharah-bot"]
BOT["grammY bot — commands + location keyboard"]
SEC["initData HMAC gate"]
KV[("KV chat_sessions — session TTL 900s, pin TTL 300s")]
APP["Mini App — web/dist via ASSETS binding"]
TG -->|"POST /telegram/webhook + secret header"| W
W --> BOT
W --> SEC
SEC -->|"mints opaque token"| KV
BOT -->|"request_location keyboard"| TG
TG -->|"shared pin message"| BOT
BOT -->|"writePin loc:chat_id"| KV
APP -->|"POST /api/session — initData"| W
APP -->|"GET /api/location — Bearer"| W
APP -->|"POST /api/location-fallback — Bearer"| W
W --> KV
classDef telegram fill:#EB8008,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
classDef worker fill:#1A1A1A,stroke:#EB8008,stroke-width:2px,color:#F7F0E3
classDef storage fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
classDef web fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1E1B14
class TG telegram
class W,BOT,SEC worker
class KV storage
class APP web
Installation
@araharah/bot is private and consumed through the workspace, not installed on its own. From the repo root:
git clone http://gitmirah:3000/c-fu/arah2.git
cd arah2
npm install
Then create the local secrets file (gitignored — never commit it):
cp bot/.dev.vars.example bot/.dev.vars
bot/.dev.vars holds three values:
| Variable | Purpose |
|---|---|
BOT_TOKEN |
Telegram Bot API token from @BotFather. |
WEBHOOK_SECRET |
Secret token Telegram echoes in X-Telegram-Bot-Api-Secret-Token. Must be non-empty — the webhook fails closed with 500 otherwise. |
WEBHOOK_URL |
Full origin for the Worker; set-webhook.mjs appends /telegram/webhook when only an origin is set. |
Quick start
-
Build the workspace —
web/distis served through the Worker's assets binding, so the Mini App must exist first.npm run build -
Run the Worker locally on port
8787with local KV emulation.npm run dev -
Check the surface.
curl -i http://localhost:8787/api/location # → 401 {"error":"unauthorized"} (no bearer token) curl -i -X GET http://localhost:8787/api/session # → 405 Method not allowed (route exists, method is wrong) curl -i http://localhost:8787/nope # → 404 Not found -
Register the Telegram-side wiring after deploying: the webhook, the command menu, and the menu button.
npm run deploy # wrangler deploy + set-webhook.mjs npm run bot:commands node scripts/set-menu-button.mjs # points the menu button at the Mini App origin
HTTP surface
The fetch handler in src/webhook.ts routes exactly four paths and returns 404 for everything else. Every log line and every rejection body is category only — never a payload, a user id, a chat id, or a timestamp.
| Method | Path | Behaviour |
|---|---|---|
POST |
/telegram/webhook |
grammY's cloudflare-mod webhook adapter. Fails closed with 500 when WEBHOOK_SECRET is missing or empty — grammY's own secret check silently accepts everything when its secret is undefined. Otherwise a constant-time compare of X-Telegram-Bot-Api-Secret-Token runs before any bot processing or KV touch. The bot is instantiated per request because the token only arrives with env. |
POST |
/api/session |
The security gate. The client posts its verbatim initData string, capped at 4096 characters (a lying or absent Content-Length is caught by a post-read length check, both reject 413 before parsing). HMAC-validated and fresh → a one-time opaque token; rejection precedence is malformed → tampered → stale, each a 401 carrying only the category. Minting rotates any prior token for that chat. |
GET |
/api/location |
Polled by the Mini App with its in-memory Authorization: Bearer <token>. The KV key derives from the claim's chat_id, never from a client-supplied id, so one user's token can never read another's pin. Honest {"pin":null} when nothing is stored; 401 for a missing or expired token; 405 for a non-GET. |
POST |
/api/location-fallback |
The "ask via chat" path. The worker asks the bot to send the request_location reply keyboard into the chat named by the resolved claim. The request body is never read — neither chat id nor locale is accepted from the client. A rejected Telegram send is a category-only 502. |
# Mint a session from a Mini App initData string
curl -X POST http://localhost:8787/api/session --data-binary @initdata.txt
# → {"token":"<opaque uuid>"}
# Poll the relay with that token held in memory
curl -H "Authorization: Bearer <opaque>" http://localhost:8787/api/location
# → {"pin":null}
Commands
| Command | Description |
|---|---|
/start, /mula |
One-sentence introduction to arah² and its privacy posture. |
/help |
Lists the commands that exist in this phase. |
/privacy, /privasi |
Names exactly what is stored (user id, chat id, language, ~15 min) and what is never stored (location, routes, trip history, profile). |
| shared pin message | Not a command — a message handler records ctx.message.location into KV and confirms. |
Replies are plain text with link previews disabled. There are no inline buttons, callback routes, or reply menus in the command tree — the location reply keyboard in handlers/location-fallback.ts is the one deliberate exception, and a test (CS4) holds that boundary.
Package scripts
Run from the repo root (npm run bot:<script>) or inside bot/.
| Script | Description |
|---|---|
npm test |
Runs test/runner.mjs, a hand-rolled zero-dependency harness. |
npm run typecheck |
tsc --noEmit against src/ with Workers types. |
npm run dev |
wrangler dev --port 8787 --local. |
npm run deploy |
wrangler deploy followed by scripts/set-webhook.mjs. |
npm run commands |
scripts/register-commands.mjs — one-time setMyCommands in the default scope, plus a language_code: "ms" variant of that same default scope (both calls send bot_command_scope: { type: "default" }). |
scripts/set-menu-button.mjs is run directly (node scripts/set-menu-button.mjs) rather than through a package script; it points the bot's menu button at a web_app URL derived from WEBHOOK_URL's origin.
The command-menu descriptions are derived from src/i18n/strings.ts's helpMenu lines, not written inline in the script, so Telegram's registered menu and the /help reply cannot drift apart. A missing /help or /privacy line in helpMenu throws rather than silently registering a blank description.
Configuration
The typed binding contract is Env in src/config.ts:
export interface Env {
BOT_TOKEN: string; // wrangler secret — from @BotFather
WEBHOOK_SECRET: string; // wrangler secret — must be non-empty; the route fails closed otherwise
chat_sessions: KVNamespace; // TTL-only namespace
}
Non-secret settings live in wrangler.jsonc:
| Key | Value | Note |
|---|---|---|
name |
araharah-bot |
Worker name. |
main |
src/webhook.ts |
Worker entry point. |
compatibility_date |
2026-10-22 |
|
workers_dev |
true |
Serves on the *.workers.dev origin — the WEBHOOK_URL target for this phase. |
assets.directory |
../web/dist |
Served through the ASSETS binding. |
assets.not_found_handling |
none |
Unmatched paths fall through to the Worker, which answers 404. |
run_worker_first |
["/api/*", "/telegram/*"] |
A glob array, not a boolean — the Worker owns these prefixes; everything else is served as a static asset. |
kv_namespaces |
binding chat_sessions |
The KV namespace id is committed in wrangler.jsonc; it is an identifier, not a secret.
Module map
| Path | Responsibility |
|---|---|
src/webhook.ts |
Worker entry and the only fetch handler: four routes, the 4 KB initData cap, bearer-token resolution. |
src/config.ts |
The Env contract — nothing else declares bindings. |
src/bot/bot.ts |
createBot(env) — the grammY factory; registers every command handler. Per-request instantiation, because Workers have no startup hook and the token arrives with env. |
src/bot/commands/{start,help,privacy}.ts |
One command surface each, with pure *ReplyText(locale) builders so tests don't need a bot instance. |
src/bot/handlers/location-fallback.ts |
Sends the request_location reply keyboard; records a shared pin on the message handler. |
src/security/initdata.ts |
Pure HMAC validation of initData. Depends only on the runtime's global crypto.subtle, so it runs unchanged in the Worker and in bare-node tests. |
src/kv/sessions.ts |
Mint / rotate / resolve session claims. SESSION_TTL_SECONDS = 900. |
src/kv/location-pin.ts |
Write / read / clear the chat-scoped pin. PIN_TTL_SECONDS = 300. |
src/i18n/strings.ts |
Every user-facing string, in en and ms. Nothing inline anywhere else. |
scripts/ |
One-time Telegram registration: webhook, command menu, menu button. |
test/ |
Zero-dependency suites plus the runner and registry. |
KV key scheme
| Key | Value | TTL |
|---|---|---|
session:<token> |
JSON SessionClaim — user_id, chat_id, issued_at, language_code |
900 s |
chat:<chat_id> |
The chat's current token (per-chat pointer) | 900 s |
loc:<chat_id> |
JSON LocationPin — latitude, longitude, horizontal_accuracy |
300 s |
Session TTL is deliberately tied to the 900 s initData freshness window: a session cannot outlive the proof that minted it. Minting rotates — the previous session:<token> is deleted before the new record is written, then a best-effort post-mint reconciliation re-reads the pointer and sweeps a racing mint's record. Under KV's lack of transactions, a record orphaned by a lost race is invisible to both sweeps and simply dies with its TTL; nothing persists longer.
initData validation
validateInitData(raw, botToken, opts?) parses the Telegram-style querystring manually — values are decoded once and + stays literal, so URLSearchParams is deliberately not used. The data-check string excludes only hash (the signature field participates; excluding it broke real webviews), sorts by field name, and joins with newlines. The key is derived with HMAC-SHA256(key="WebAppData", message=botToken) and compared in constant time. auth_date older than maxAgeSeconds (default 900) is stale; nowSeconds is injectable, and the suite covers the boundary at 899 s (ok) and 901 s (stale) — the 900 s boundary itself is not asserted by any case.
Testing
The suite is a hand-rolled harness — no test framework dependency. test/harness.mjs exports a test(name, fn) registry; test/runner.mjs auto-discovers every *.test.mjs in test/, imports them, and prints PASS/FAIL per case with a summary line. A suite that fails to load is recorded as one failing case rather than crashing the run. Exit code is 0 only when every case passed.
npm run bot:test # from the repo root
npm test # from bot/
| Suite | Cases | Covers |
|---|---|---|
test/initdata.test.mjs |
12 | HMAC vectors (RFC-4231 case 1), field ordering, the signature erratum, freshness boundaries, malformed shapes. |
test/sessions.test.mjs |
7 | Exact claim key-set, TTL on every put, rotation, interleaved-mint behaviour, claim round-trip. |
test/location-pin.test.mjs |
14 | TTL-carrying key, out-of-range coordinate rejection, malformed-JSON reads, plus the full /api/location and /api/location-fallback route surface including own-chat-only access and locale honouring. |
test/webhook.test.mjs |
6 | Valid initData → token, the 4 KB cap (declared, lying, and unreadable bodies), 405 on GET /api/session, fail-closed 500 on an unarmed WEBHOOK_SECRET. |
test/commands.test.mjs |
9 | Locale key-set parity, pickLocale fallback, privacy copy honesty, the text-only surface gate, helpMenu/menu derivation, and the exact command stack. |
test/privacy.test.mjs |
2 | Grep gates: every .put( in a storage source carries expirationTtl on the same line; storage modules export nothing persistence-shaped. |
Typecheck separately:
npm run bot:typecheck
Notes
- Secrets never appear in code or logs.
BOT_TOKENandWEBHOOK_SECRETare wrangler secrets locally and in production;bot/.dev.varsis gitignored. The scripts read it but only ever print Telegram'sok/error_code/description. - Logging is category-only by construction.
"[initdata] rejected: tampered","[location] rejected: unauthorized"— never the payload, the chat id, or a timestamp. not_found_handling: "none"matters. With the default, unmatched paths would be served as a 404 asset and the Worker's own404branch would be unreachable.run_worker_firstis a glob array. Passing a boolean silently breaks the route split.requestLocationFallbackhas exactly one caller (handleLocationFallbackinsrc/webhook.ts). A second caller would be a design change, not a refactor.