docs: full docs-update pass — 9 GSD docs regenerated + verified (434→454 claims; verifier corrections applied)

This commit is contained in:
araharah-executor
2026-10-25 12:51:01 +08:00
parent 809864f5b6
commit 44851c70fe
9 changed files with 2014 additions and 324 deletions
+328
View File
@@ -0,0 +1,328 @@
<!-- 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. -->