Files
arah2/docs/API.md
T

329 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- generated-by: gsd-doc-writer -->
# 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 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_id>`, `chat:<chat_id>`, `guidance:<id>`) 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=<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 `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": "<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:
```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": "<envelope uuid>" }`
- **400** — `{ "error": "malformed" }` (bad JSON or failed snapshot validation)
- **401** — `{ "error": "unauthorized" }`
- **413** — `{ "error": "malformed" }`
- **405** — other methods
### GET /api/guidance?id=<uuid> · DELETE /api/guidance?id=<uuid>
- `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/<turn-kind>.png` with an HTML caption and an inline keyboard opening
`MINI_APP_URL/?g=<envelope id>`.
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": "<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.
<!-- VERIFY: the deployed base URL (bot/wrangler.jsonc pins the workers.dev origin MINI_APP_URL as https://araharah-bot.araharah-bot.workers.dev; a fork or a later custom domain supersedes it). -->
<!-- VERIFY: BotFather's registered Mini App URL must equal the deployed origin — the runtime origin restriction (Bot API 10.2) is enforced by Telegram, not this repo. -->