1
API
araharah-executor edited this page 2026-10-25 12:36:12 +08:00
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.

API

The arah² API is the surface of a single Cloudflare Worker (arah²-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:

{
  "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= · 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):

    {
      "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):

    {
      "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.