Files
arah2/docs/API.md
T

17 KiB
Raw Blame History

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:

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