# 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: ` 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 ``` - Tokens are `crypto.randomUUID()` strings matching `[A-Za-z0-9-]+` (the header regex in `bot/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:`, `guidance:`) is keyed from the **claim's** `chat_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/session` mint 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`): 1. Parse as a Telegram-style querystring (values decoded **once**; `+` stays literal — never `URLSearchParams`). 2. Build the data-check string from all fields **except `hash` only** — `signature` *participates* in the HMAC (excluding it broke real clients; device-matrix round 6). 3. Derive the key as `HMAC-SHA256(key = "WebAppData", message = botToken)`, then compare `hex(HMAC-SHA256(dataCheckString, derivedKey))` against the `hash` field with a constant-time hex compare. 4. Reject stale payloads: `auth_date` older 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=` | Read the caller's guidance envelope. | Bearer token | | DELETE | `/api/guidance?id=` | 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 `initData` string as `text/plain`-ish raw body — *not* JSON, *not* re-encoded. Cap: **4096 chars** (`MAX_INITDATA_CHARS`, WR-01) — over-cap is a `413` before any parsing (both the declared `Content-Length` and the post-read length are checked). - **200** — `{ "token": "" }` - **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: ```json { "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_snapshot` fields (validation in `webhook.ts::validateSnapshot`, hand-rolled — no zod): - `destinationId` — gazetteer slug, `^[a-z0-9-]{1,64}$` - `destinationName` — non-empty string, ≤ 200 chars - `distanceM`, `durationS` — finite numbers ≥ 0 - `startedAtSec` — positive safe integer (seconds-since-epoch) - `geometry` — polyline6 string that decodes to ≥ 2 points - Cap: **8192 chars** (`MAX_GUIDANCE_BODY_CHARS`, WR-01) → `413` over-cap. - **A client-supplied `chat_id` is 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's `chat_id` is minted from the claim. - **201** — `{ "id": "" }` - **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= - `id` must be a lowercase UUID (`ENVELOPE_ID_PATTERN`); anything else returns `404` **before 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):** ```json { "origin": { "lat": 3.1520, "lng": 101.6932 }, "destinationId": "klcc" } ``` - `origin.lat`/`origin.lng` — finite numbers (WGS84); range-checked against the graph's `bbox` **before** 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 `RouteResult` with **exactly one option** (alternatives generation is prohibited on this path — CPU law): ```json { "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 carry `kind` one of `depart | straight | left | right | slight-left | slight-right | uturn | arrive` and street names from the graph's own OSM-derived edges. - **200 (miss) — the null contract (D-03):** every miss class answers `200` with a JSON `null` body — 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. - **401** — `{ "error": "unauthorized" }` - **400** — `{ "error": "malformed" }` (bad JSON, bad shape, or origin outside `graph.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: 1. **Fail-closed unconfigure:** missing `BOT_TOKEN`, `chat_sessions`, or `MINI_APP_URL` → log `skipped: unconfigured`, return (an unarmed cron never half-runs). 2. **Load the graph once per tick** via the same memoized `loadGraph` the 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). 3. **Paginated KV scan** of the `guidance:` prefix (cursor loop until `list_complete`); each key is read through the defensively-validated `getGuidanceSession` — a corrupt record reads `null` and is skipped honestly. 4. **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 = 60` is the code's tick constant). Cards are street-bearing (street reconstructed from the graph's edge names) glyph + text cards. 5. **Push to `envelope.chat_id` ONLY** — no request path exists in a cron tick, so identity is the envelope's own mint-side chat. The card is a `sendPhoto` of `${MINI_APP_URL}/glyphs/.png` with an HTML caption and an inline keyboard opening `MINI_APP_URL/?g=`. 6. **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": ""}` 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: `, `[location] rejected: unauthorized`, `[guidance-cron] skipped: malformed`, `[route] rejected: `, 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.