17 KiB
API
The arah² API is the surface of a single Cloudflare Worker (araharah-bot, source of truth:
bot/src/webhook.ts — the fetch handler — plus bot/src/routing/worker-router.ts and the KV
services in bot/src/kv/). It serves three clients:
- Telegram — POSTs updates to
POST /telegram/webhook - The Mini App (
web/, the Telegram webview) — calls/api/session,/api/location,/api/location-fallback,/api/guidance,/api/route - Nobody else. Any other path is a plain
404 Not found.
There is no OpenAPI/Swagger spec in the repo; this document is the contract, verified against the
live source. The corresponding clients are web/src/session/client.ts,
web/src/location/chat-pin.ts, and web/src/route/route.ts.
Authentication
Two independent mechanisms, one per transport:
1. Telegram updates — secret-token header (/telegram/webhook)
Telegram includes X-Telegram-Bot-Api-Secret-Token: <WEBHOOK_SECRET> on every update. The Worker
hands the secret to grammY's webhookCallback(bot, "cloudflare-mod", …, env.WEBHOOK_SECRET), which
constant-time-compares it before any bot processing or KV access.
Fail-closed (WR-02): if the WEBHOOK_SECRET binding is missing or empty at request time, the
route rejects every delivery with 500 Server misconfigured before grammY's check ever runs —
because grammY's compareSecretToken silently accepts every request when its secret is
undefined. An unarmed webhook must never half-work; it must refuse all traffic.
The secret is armed by bot/scripts/set-webhook.mjs, which sends it to Telegram's setWebhook as
secret_token — Telegram then echoes it back on every update.
2. Bearer session tokens — every /api/* route
The Mini App obtains a one-time opaque session token from POST /api/session (below) and sends
it on subsequent calls:
Authorization: Bearer <token>
- Tokens are
crypto.randomUUID()strings matching[A-Za-z0-9-]+(the header regex inbot/src/webhook.ts). - A token resolves through
getClaimByToken(bot/src/kv/sessions.ts) to a claim — exactly{ user_id, chat_id, issued_at, language_code }(D-08). Nothing else is ever persisted. - Every chat-scoped KV key (
loc:<chat_id>,chat:<chat_id>,guidance:<id>) is keyed from the claim'schat_id, never from a client-supplied id — one user's token can never read another user's pin or another user's guidance session. - Tokens expire after 900 s (
SESSION_TTL_SECONDS, D-13 — a session cannot outlive the 15-min initData freshness window that minted it). Rotation is mint-side: each new/api/sessionmint sweeps the chat's previous token (bot/src/kv/sessions.ts, D-07).
The /api/session gate (initData → token)
POST /api/session authenticates by Telegram's own signature: the request body is the verbatim
Telegram.WebApp.initData string — not re-serialized, not re-encoded. Validation
(bot/src/security/initdata.ts):
- Parse as a Telegram-style querystring (values decoded once;
+stays literal — neverURLSearchParams). - Build the data-check string from all fields except
hashonly —signatureparticipates in the HMAC (excluding it broke real clients; device-matrix round 6). - Derive the key as
HMAC-SHA256(key = "WebAppData", message = botToken), then comparehex(HMAC-SHA256(dataCheckString, derivedKey))against thehashfield with a constant-time hex compare. - Reject stale payloads:
auth_dateolder than 900 s is rejected.
Rejection precedence and vocabulary: malformed → tampered → stale.
Endpoints overview
| Method | Path | Description | Auth required |
|---|---|---|---|
| POST | /telegram/webhook |
Telegram updates → grammY bot (commands /start, /help, /privacy, /cari, plus the location-keyboard message handler). Secret-token gated, fail-closed. |
Secret-token header |
| POST | /api/session |
Exchange verbatim initData for a one-time opaque session token. |
None (initData is the credential) |
| GET | /api/location |
Relay the pin the user shared in chat (300 s TTL) to the Mini App. | Bearer token |
| POST | /api/location-fallback |
Ask the bot to send a request_location reply keyboard into the caller's own chat. |
Bearer token |
| POST | /api/guidance |
Mint a guidance session envelope (7200 s TTL) for the caller's chat. | Bearer token |
| GET | /api/guidance?id=<uuid> |
Read the caller's guidance envelope. | Bearer token |
| DELETE | /api/guidance?id=<uuid> |
Delete the caller's guidance envelope (trip end). | Bearer token |
| POST | /api/route |
Route engine A: snap origin → A* over the R2-hosted KL road graph → one best route. | Bearer token |
| (cron) | scheduled() |
Every-2-minutes cron tick: projects due turn-cards and pushes them to Telegram. Not reachable over HTTP. | n/a (platform cron) |
Anything else → 404 Not found (text body). Static Mini App assets are served by Cloudflare
Workers static assets (../web/dist); the Worker takes /api/* and /telegram/* first via the
run_worker_first glob array in bot/wrangler.jsonc.
Request/response formats
All /api/* responses are application/json unless noted. Error bodies carry exactly one
error field with a category word — never payload data, ids, or timestamps (D-14).
POST /api/session
- Request body: the verbatim
initDatastring astext/plain-ish raw body — not JSON, not re-encoded. Cap: 4096 chars (MAX_INITDATA_CHARS, WR-01) — over-cap is a413before any parsing (both the declaredContent-Lengthand the post-read length are checked). - 200 —
{ "token": "<opaque uuid>" } - 401 —
{ "error": "malformed" | "tampered" | "stale" } - 413 —
{ "error": "malformed" }(over-cap body) - 405 — non-POST (plain text
Method not allowed)
The token is single-use in spirit — one live token per chat under rotation (D-07); the Mini App
holds it in module memory only (never localStorage — web/src/session/store.ts writes no
storage).
GET /api/location
- Request: no body. Bearer header.
- 200 —
{ "pin": { "latitude": 3.1578, "longitude": 101.7118, "horizontal_accuracy": 12 } }or{ "pin": null }— honest nulls: no pin exists, or the record failed defensive validation. The pin record is exactly Telegram's Location fields (bot/src/kv/location-pin.ts, 300 s TTL). - 401 —
{ "error": "unauthorized" }(missing/expired/unknown token) - 405 — non-GET
POST /api/location-fallback
- Request: no body is read — the request body is never read at all. The target chat and the
locale both derive from the resolved claim (
claim.chat_id,claim.language_code→pickLocale). This is deliberate (T-03-27): no client input can aim a keyboard at another chat or choose another language. - 200 —
{ "ok": true }(the keyboard was sent) - 401 —
{ "error": "unauthorized" } - 502 —
{ "error": "unavailable" }— Telegram rejected the send (blocked bot, chat not found, rate limit). Category-only: the response carries no chat id or error text. - 405 — non-POST
POST /api/guidance
Mints a guidance session. The envelope shape is a frozen contract
(bot/src/kv/guidance-sessions.ts, GE2) — exactly four fields:
{
"id": "0f6f0a2e-…(lowercase uuid)",
"chat_id": 123456789,
"route_snapshot": {
"destinationId": "klcc",
"destinationName": "Kuala Lumpur Convention Centre",
"distanceM": 5230,
"durationS": 840,
"geometry": "(polyline6-encoded string)",
"startedAtSec": 1761355200
},
"created_at": 1761355800
}
- Request body: JSON with the six
route_snapshotfields (validation inwebhook.ts::validateSnapshot, hand-rolled — no zod):destinationId— gazetteer slug,^[a-z0-9-]{1,64}$destinationName— non-empty string, ≤ 200 charsdistanceM,durationS— finite numbers ≥ 0startedAtSec— positive safe integer (seconds-since-epoch)geometry— polyline6 string that decodes to ≥ 2 points- Cap: 8192 chars (
MAX_GUIDANCE_BODY_CHARS, WR-01) →413over-cap. - A client-supplied
chat_idis structurally ignored (T-06-02): the validator builds a new object field-by-field from the six snapshot fields, so anything else in the body — chat ids included — never reaches storage. The envelope'schat_idis minted from the claim.
- 201 —
{ "id": "<envelope uuid>" } - 400 —
{ "error": "malformed" }(bad JSON or failed snapshot validation) - 401 —
{ "error": "unauthorized" } - 413 —
{ "error": "malformed" } - 405 — other methods
GET /api/guidance?id= · DELETE /api/guidance?id=
idmust be a lowercase UUID (ENVELOPE_ID_PATTERN); anything else returns404before any KV access — no arbitrary-key probing.- 200 (GET) — the full envelope (shape above), only when
envelope.chat_id === claim.chat_id. - 200 (DELETE) —
{ "deleted": true }(active removal, not just TTL expiry). - 404 —
{ "error": "not_found" }— foreign and absent sessions are indistinguishable (T-06-02b): a wrong-but-valid id owned by another chat is exactly the same response as a nonexistent one. No existence oracle for cross-chat probing. - 401 —
{ "error": "unauthorized" }
POST /api/route
Engine A's server arm (bot/src/routing/worker-router.ts). Bearer-gated exactly like
/api/location; routing itself is chat-agnostic — nothing about the caller is stored, logged, or
echoed.
-
Request body (JSON):
{ "origin": { "lat": 3.1520, "lng": 101.6932 }, "destinationId": "klcc" }origin.lat/origin.lng— finite numbers (WGS84); range-checked against the graph'sbboxbefore any snap/A* work (out-of-bbox →400 malformed).destinationId— gazetteer slug (^[a-z0-9-]{1,64}$); resolved via the gazetteer (getEntryById), not taken on faith.- Cap: 4096 chars (WR-01) →
413.
-
200 (hit) — a
RouteResultwith exactly one option (alternatives generation is prohibited on this path — CPU law):{ "destinationId": "klcc", "origin": { "lat": 3.1520, "lng": 101.6932 }, "options": [ { "geometry": { "points": [{ "lat": 3.152, "lng": 101.6932 }], "cumulativeDistanceM": [0, 180, 512] }, "summary": { "distanceM": 5230, "durationS": 840 }, "steps": [ { "name": "Jalan Ampang", "kind": "right", "geometry": "…", "distanceM": 320, "durationS": 45 } ] } ], "backend": "engine-a" }The origin is snapped to the road graph server-side (
snapToGraph) before A* — the client never sends a snapped point. Steps carrykindone ofdepart | straight | left | right | slight-left | slight-right | uturn | arriveand street names from the graph's own OSM-derived edges. -
200 (miss) — the null contract (D-03): every miss class answers
200with a JSONnullbody — the router-pending state, never an error:- unknown
destinationId - origin or destination snap miss
- disconnected origin/destination pair
- absent or corrupt road-graph object in R2
- failed R2 read
The client renders this honestly as "the router is not ready for this trip" — never a blank map, never a fake route.
- unknown
-
401 —
{ "error": "unauthorized" } -
400 —
{ "error": "malformed" }(bad JSON, bad shape, or origin outsidegraph.bbox) -
413 —
{ "error": "malformed" } -
405 — non-POST
Free-plan CPU class (c3): the Worker runs on the 10 ms/request free-plan CPU ceiling. Short
product routes (2–8 km, the common search-and-reroute trip) measure 2–8 ms and resolve in-budget;
cross-metro routes (20–30 km) exceed it and degrade honestly to the router-pending null — the
committed upgrade lever is ALT landmark preprocessing, not a paid plan
(docs/BUDGETS.md, docs/KNOWN-LIMITATIONS.md).
scheduled() — the guidance cron tick (not HTTP)
bot/wrangler.jsonc arms triggers.crons: ["*/2 * * * *"] — a tick every 2 minutes. Each tick:
- Fail-closed unconfigure: missing
BOT_TOKEN,chat_sessions, orMINI_APP_URL→ logskipped: unconfigured, return (an unarmed cron never half-runs). - Load the graph once per tick via the same memoized
loadGraphthe route arm uses (one R2 GET per cold isolate, shared between arms). A null graph degrades cards to street-less bare-verb compositions — the send still fires (D-03). - Paginated KV scan of the
guidance:prefix (cursor loop untillist_complete); each key is read through the defensively-validatedgetGuidanceSession— a corrupt record readsnulland is skipped honestly. - Project due cards with the pure
projectTurnCards(bot/src/guidance/cron.ts):CARD_CADENCE_S = 240— a card is due on the first tick of each 240 s window (nowSec % CARD_CADENCE_S < SCAN_TICK_S;SCAN_TICK_S = 60is the code's tick constant). Cards are street-bearing (street reconstructed from the graph's edge names) glyph + text cards. - Push to
envelope.chat_idONLY — no request path exists in a cron tick, so identity is the envelope's own mint-side chat. The card is asendPhotoof${MINI_APP_URL}/glyphs/<turn-kind>.pngwith an HTML caption and an inline keyboard openingMINI_APP_URL/?g=<envelope id>. - STATELESS: zero KV writes. The tick lists and reads only — the free-plan budget law (KV list
ceiling: 1,000/day;
*/2× 1 list-op = 720/day, inside it).
Card language is EN (the envelope contract carries no language field); captions are composed only from numbers and string-table constants — HTML-escape surface is zero.
Error codes
The standard error shape is {"error": "<category>"} with content-type: application/json
(exceptions: 404/405/500 on the raw-Response paths return plain text).
| Status | error body |
Meaning | Where |
|---|---|---|---|
| 400 | malformed |
Bad JSON, failed shape validation, or origin outside the graph bbox | /api/guidance POST, /api/route |
| 401 | unauthorized |
Missing/expired/unknown Bearer token | all Bearer routes |
| 401 | malformed | tampered | stale |
initData HMAC gate rejection vocabulary | /api/session |
| 404 | not_found |
Guidance id absent or owned by another chat — indistinguishable by design | /api/guidance GET/DELETE |
| 404 | (plain text) | Unknown path | fallback |
| 405 | (plain text) | Method not allowed for the path | all routes |
| 413 | malformed |
Body over cap (initData 4 KB, guidance 8 KB, route 4 KB) — checked pre- and post-read | /api/session, /api/guidance POST, /api/route |
| 500 | (plain text) | Server misconfigured — fail-closed webhook gate (missing/empty WEBHOOK_SECRET); every Telegram delivery is refused until the secret is armed |
/telegram/webhook |
| 502 | unavailable |
Telegram rejected the send (blocked bot, chat not found, rate limit) | /api/location-fallback |
200 null |
— | The honest router-pending miss class (see /api/route) |
/api/route |
Logging law (D-14/D-17): the only log lines are categories — [webhook] rejected: misconfigured, [initdata] rejected: <reason>, [location] rejected: unauthorized,
[guidance-cron] skipped: malformed, [route] rejected: <category>, etc. No raw payload, no user
ids, no chat ids, no coordinates, no timestamps, no secrets are ever logged or echoed.
Rate limits
No rate-limiting middleware or library is configured in this codebase — grep-verified: there is no
express-rate-limit, no token-bucket, no per-token quota in bot/src. Two platform-level layers
apply instead, and they are the design's actual throttle:
- Telegram→webhook: only Telegram's own servers POST to
/telegram/webhook, authenticated by the secret-token channel; arbitrary third-party POSTs fail the constant-time compare. - Cloudflare free-plan ceilings (documented in
docs/BUDGETS.md): 100,000 Worker requests/day, 1,000 KV writes/day and 1,000 KV list operations/day, 10 ms CPU/request. The API's design — TTL-only KV writes, the stateless cron, the one-route/api/route— exists to stay under these ceilings at $0.
Client-side courtesy: the Mini App's GET /api/location is a poll, but the pin it relays exists
for only 300 s (PIN_TTL_SECONDS) and is written only when the user explicitly shares a location
in chat — there is no background tracking loop to rate-limit.