Files
arah2/docs/ARCHITECTURE.md
T

51 KiB
Raw Blame History

arah² — Architecture

System Overview

arah² is a Telegram-first GPS navigator: a grammY bot (arah2bot) running as a single Cloudflare Worker serves both the Telegram webhook and the static Mini App bundle served from the same origin. The Mini App (@araharah/web, a Vite + MapLibre GL application) authenticates with the Worker by posting its verbatim Telegram.WebApp.initData to POST /api/session, receives a short-lived opaque bearer token, and then acquires a position through a three-link chain (Telegram LocationManager → WebView navigator.geolocation → chat-relayed pin). Searching a destination and routing to it are real today: @araharah/search is the in-Worker gazetteer (search backend #1 of the D-01 ladder), and @araharah/routing is the routing data plane — its CSR road-graph codec, snapper, and A* power engine A, the Worker-side router (bot/src/routing/worker-router.ts) that routes over a ROAD_GRAPH R2-hosted KL road-graph binary through POST /api/route. Guidance is end-to-end: a turn-card cron (the repo's first Cron Trigger, */2 * * * *) pushes turn-cards into the chat from a TTL-only guidance envelope, statelessly. All durable state is TTL-only — one Cloudflare KV namespace (chat_sessions, plus guidance:* envelopes in it) — so the system has no database, no persistent storage, and no analytics. @araharah/theme is the shared design-token package: it compiles Tailwind v4 CSS consumed by the web app and is the derivation source for the Rams basemap style the map renders.

Architecturally this is a five-workspace npm monorepo in a layered/hexagonal arrangement: a stateless edge worker with a KV repository layer and an embedded search index plus A* router, a framework-free TypeScript client with its own repository, adapter, route, and guidance layers, and two pure V8-native data-plane packages (search, routing) that both the worker and the client import — and a pure build-time token/cartography package underneath.

graph TD
    classDef client fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A
    classDef worker fill:#D9D2C6,stroke:#736B1E,stroke-width:2px,color:#1A1A1A
    classDef store fill:#ED3F1C,stroke:#8C1F0A,stroke-width:2px,color:#FFFFFF
    classDef build fill:#1A1A1A,stroke:#000000,stroke-width:2px,color:#FFF8F0
    classDef plane fill:#736B1E,stroke:#4A440F,stroke-width:2px,color:#FFF8F0

    A["Telegram client<br/>chat + Mini App webview"]:::client
    B["@araharah/bot Worker<br/>src/webhook.ts"]:::worker
    C["grammY handlers<br/>commands + /cari + fallbacks"]:::worker
    D["Security<br/>initdata.ts HMAC"]:::worker
    E[("KV chat_sessions<br/>session:* 900s · chat:*<br/>loc:* 300s · guidance:* 7200s")]:::store
    R[("R2 ROAD_GRAPH<br/>kl-roadgraph.v1.bin")]:::store
    Q["scheduled()<br/>*/2 turn-card cron"]:::worker
    F["@araharah/web Mini App<br/>src/main.ts"]:::client
    G["Session client + store<br/>src/session/"]:::client
    H["Location chain<br/>src/location/"]:::client
    I["MapLibre map + UI<br/>src/map/, src/ui/"]:::client
    RT["Route controller + guidance<br/>src/route/, src/guidance/"]:::client
    S["@araharah/search<br/>gazetteer SearchSource"]:::plane
    U["@araharah/routing<br/>graph · snap · A*"]:::plane
    J["@araharah/theme<br/>dist/rams.css + basemap"]:::build

    A -->|"POST /telegram/webhook"| B
    A -->|"loads Mini App"| B
    B --> C
    B --> D
    B --> E
    B --> R
    Q --> E
    Q --> R
    C --> E
    C --> S
    C -->|"sendPhoto turn-cards"| A
    F -->|"POST /api/session initData"| B
    F -->|"GET /api/location Bearer"| B
    F -->|"POST /api/location-fallback"| B
    F -->|"POST /api/route Bearer"| B
    F -->|"POST|GET|DELETE /api/guidance"| B
    F -->|"build-time imports"| S
    F -->|"build-time imports"| U
    G --> F
    H --> G
    F --> H
    H --> I
    RT -->|"reroute"| B
    J -->|"compiled CSS + inlined style JSON"| F

Component Diagram

graph LR
    classDef ws fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A
    classDef wsfill fill:#FFF8F0,stroke:#EB8008,stroke-width:2px,color:#1A1A1A
    classDef botfill fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1A1A1A
    classDef plane fill:#736B1E,stroke:#4A440F,stroke-width:2px,color:#FFF8F0
    classDef store fill:#ED3F1C,stroke:#8C1F0A,stroke-width:2px,color:#FFFFFF
    classDef ext fill:#E0D9CD,stroke:#564335,stroke-width:2px,color:#1A1A1A

    subgraph ROOT["root — npm workspaces"]
        direction TB
        subgraph THEME["theme · @araharah/theme"]
            direction TB
            T1["src/tokens.css<br/>@theme static, 55 colors"]:::wsfill
            T2["src/components.css"]:::wsfill
            T3["basemap/build.mjs"]:::wsfill
            T4["dist/rams.css"]:::wsfill
            T5["basemap/liberty-rams.json"]:::wsfill
            T1 --> T4
            T2 --> T4
            T1 -->|"derives roles"| T3
            T3 --> T5
        end
        subgraph WEB["web · @araharah/web"]
            direction TB
            W1["src/main.ts"]:::wsfill
            W2["src/session/"]:::wsfill
            W3["src/location/"]:::wsfill
            W4["src/map/"]:::wsfill
            W5["src/ui/ + src/telegram/"]:::wsfill
            W6["src/search/ + src/route/<br/>+ src/guidance/"]:::wsfill
            W7["dist/ (served)"]:::wsfill
            W1 --> W2
            W1 --> W3
            W1 --> W4
            W1 --> W5
            W1 --> W6
            W1 --> W7
            W4 --> W7
            W5 --> W7
            W6 --> W7
        end
        subgraph BOT["bot · @araharah/bot"]
            direction TB
            B1["src/webhook.ts"]:::botfill
            B2["src/security/initdata.ts"]:::botfill
            B3["src/bot/ + src/guidance/cron.ts"]:::botfill
            B4["src/kv/"]:::botfill
            B5["src/routing/worker-router.ts"]:::botfill
            B6["src/config.ts Env"]:::botfill
            B1 --> B2
            B1 --> B3
            B1 --> B4
            B1 --> B5
            B1 --> B6
        end
        subgraph SEARCH["search · @araharah/search"]
            direction TB
            S1["src/gazetteer.ts<br/>lazy memoized index"]:::plane
            S2["src/source.ts<br/>SearchSource #1"]:::plane
            S3["src/score.ts + normalize.ts"]:::plane
            S1 --> S2
            S3 --> S2
        end
        subgraph ROUTING["routing · @araharah/routing"]
            direction TB
            R1["src/graph.ts<br/>CSR codec"]:::plane
            R2["src/snap.ts + astar.ts"]:::plane
            R3["src/fixture-source.ts<br/>#1 · http-source.ts<br/>engine-A client seam"]:::plane
            R1 --> R2
            R1 --> R3
            R2 --> R3
        end
        KV[("KV chat_sessions")]:::store
        R2B[("R2 ROAD_GRAPH<br/>araharah-graphs")]:::store
        CRON["Cron Trigger<br/>*/2 * * * *"]:::ext
        TG["Telegram Bot API"]:::ext
        OFM["OpenFreeMap tiles"]:::ext
    end

    WEB -->|"workspace deps @araharah/{theme,search,routing}"| THEME
    WEB --> SEARCH
    WEB --> ROUTING
    BOT -->|"deps @araharah/{search,routing}"| SEARCH
    BOT --> ROUTING
    BOT -->|"assets binding ../web/dist"| WEB
    B1 --> KV
    B4 --> KV
    B5 --> R2B
    B3 --> KV
    CRON --> B1
    B1 <-->|"webhookCallback / sendMessage"| TG
    W3 <-->|"chat pin"| TG
    W4 --> OFM

Workspaces Dependency Graph

graph TD
    classDef pkg fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A
    classDef dep fill:#736B1E,stroke:#4A440F,stroke-width:2px,color:#FFF8F0
    classDef out fill:#D9D2C6,stroke:#736B1E,stroke-width:2px,color:#1A1A1A

    ROOT["araharah (private root)"]:::out
    TH["@araharah/theme"]:::pkg
    SE["@araharah/search"]:::pkg
    RO["@araharah/routing"]:::pkg
    BT["@araharah/bot"]:::pkg
    WB["@araharah/web"]:::pkg
    KVS[("KV chat_sessions")]:::dep
    R2S[("R2 ROAD_GRAPH")]:::dep
    TGAPI["Telegram Bot API"]:::dep

    ROOT -->|"workspaces"| TH
    ROOT -->|"workspaces"| SE
    ROOT -->|"workspaces"| RO
    ROOT -->|"workspaces"| BT
    ROOT -->|"workspaces"| WB
    WB -->|"dependencies: @araharah/theme *"| TH
    WB -->|"dependencies: @araharah/search *"| SE
    WB -->|"dependencies: @araharah/routing *"| RO
    BT -->|"dependencies: @araharah/search *"| SE
    BT -->|"dependencies: @araharah/routing *"| RO
    BT -->|"static assets ../web/dist"| WB
    BT --> KVS
    BT --> R2S
    BT --> TGAPI
    WB -->|"api routes"| BT

@araharah/search and @araharah/routing are the shared V8-native data planes: the bot Worker imports them at runtime (gazetteer search, graph decode, snap, A*), the web app imports the same sources at build time (destination resolution, the RoutingSource ladder, polyline decoding). Neither data plane reads the environment, the network, or storage. @araharah/theme stays web-only ("@araharah/theme": "*" in web/package.json — dist/rams.css in web/src/shell.css, basemap/liberty-rams.json in web/src/map/map.ts). The bot has no workspace dependency on theme or web; its coupling to web is a build-order coupling only — the root build script builds theme then web, and the Worker's [assets] binding points at ../web/dist.

Both data-plane packages have no exports/main field — consumers import the explicit subpath with extension, e.g. @araharah/search/src/index.ts and @araharah/routing/src/index.ts. A bare package specifier resolves to nothing.

Data Flow

1. Telegram command surface (bot → chat)

Telegram delivers an update to POST /telegram/webhook. The Worker first fails closed if WEBHOOK_SECRET is unset, then hands the request to grammY's webhookCallback(bot, "cloudflare-mod", …), which constant-time-compares the X-Telegram-Bot-Api-Secret-Token header. createBot(env) is instantiated per request (the token only arrives with env), registers the command stack, and grammY dispatches:

POST /telegram/webhook → webhookCallback (secret check) → createBot(env)
  → registerStart / registerHelp / registerPrivacy → pickLocale(language_code)
  → ctx.reply(plain text, link_previews disabled)

createBot also builds ONE memoized gazetteer SearchSource (backend #1) shared between the /cari + /search command surface (bot/src/bot/commands/cari.ts) and the alternates switcher (bot/src/bot/handlers/search-alternate.ts): /cari renders a venue card whose OPEN button deep-links <MINI_APP_URL>/?dest=<id> (origin from the configured MINI_APP_URL var, never user input), and the alternates handler swaps the selected entry via a callback. The venue card's inline buttons and the location-fallback reply keyboard are the two documented exceptions to the plain-text-only command rule.

All user-facing copy comes from bot/src/i18n/strings.ts; bot/scripts/register-commands.mjs derives the persistent /setMyCommands menu descriptions from the same helpMenu lines, so menu and reply cannot drift.

2. Mini App boot (session handshake)

sequenceDiagram
    participant WA as WebApp webview
    participant MAIN as web/src/main.ts
    participant SESS as session/client.ts
    participant WK as Worker /api/session
    participant ID as security/initdata.ts
    participant KV as KV chat_sessions

    MAIN->>MAIN: applySafeArea(resolveSafeInsets(wa))
    MAIN->>MAIN: bootMap() mounts MapLibre FIRST
    MAIN->>MAIN: register strip, cards, recenter
    MAIN->>SESS: createSession(wa.initData) [verbatim string]
    SESS->>WK: POST /api/session, body = initData (≤ 4 KB)
    WK->>ID: validateInitData(raw, BOT_TOKEN)
    ID-->>WK: ok | malformed | tampered | stale
    WK->>KV: mintSession(chat_sessions, claim)
    KV-->>WK: opaque UUID token
    WK-->>SESS: 200 { token }
    SESS->>SESS: setSession(token) [module scope only]

The HMAC path: dataCheckString = all initData fields minus hash only, sorted by field name, joined with \n; secret key = HMAC-SHA256(key="WebAppData", message=botToken); check = HMAC-SHA256(key=secretKey, message=dataCheckString) compared in constant time against hash. signature participates in the check. auth_date older than 900 s is rejected as stale.

3. Location acquisition chain

flowchart TD
    classDef s fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A
    classDef ok fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1A1A1A
    classDef no fill:#ED3F1C,stroke:#8C1F0A,stroke-width:2px,color:#FFFFFF

    START["acquireFix(deps, lastAccepted)"]:::ok
    L1{"telegramLocationSupported<br/>isVersionAtLeast 8.0"}:::ok
    NUDGE{"looksStale(prev, fix)<br/>haversine < 0.01 m"}:::ok
    L2{"webviewLocationSupported"}:::ok
    L3{"getToken() !== null"}:::ok
    LIVE["return { fix, state: live }"]:::s
    DEN1["denied / timeout"]:::no
    DEN2["denied / no-permission"]:::no

    START --> L1
    L1 -->|"yes"| NUDGE
    NUDGE -->|"no"| LIVE
    NUDGE -->|"yes: discarded webview read"| L1
    L1 -->|"no"| L2
    L2 -->|"yes + fix"| LIVE
    L2 -->|"no fix"| L3
    L3 -->|"yes: bounded 60s poll"| LIVE
    L3 -->|"poll expired"| DEN1
    L3 -->|"no token"| DEN2

Link 1 (telegram.ts) calls LocationManager.init() then getLocation(), with a hard 8 s wrapper timeout because the API documents no timeout. A byte-identical fix to the last accepted one routes to the nudge: one discarded webview read refreshes the Android native cache, then telegram is retried. Link 2 (webview.ts) calls navigator.geolocation.getCurrentPosition with maximumAge: 0. Link 3 (chat-pin.ts) polls GET /api/location every 2 s for at most 60 s.

4. Chat-pin relay (the Desktop-Telegram path)

sequenceDiagram
    participant U as User (chat)
    participant B as Bot handler
    participant KV as KV loc:chat_id
    participant MA as Mini App
    participant W as Worker

    MA->>W: POST /api/location-fallback (Bearer token)
    W->>W: getClaimByToken → claim.chat_id
    W->>B: requestLocationFallback(bot, chat_id, locale)
    B->>U: reply keyboard, request_location: true
    U->>B: taps button, native location picker
    B->>KV: writePin(kv, chat_id, { lat, lng, acc })  expirationTtl 300
    loop every 2 s, bounded 60 s
        MA->>W: GET /api/location (Bearer token)
        W->>KV: getPin(chat_sessions, claim.chat_id)
        KV-->>W: LocationPin | null
        W-->>MA: { pin }  (200) or { pin: null }
    end

Both /api/location and /api/location-fallback derive the target chat_id from the resolved session claim, never from client input, and derive locale from claim.language_code. Telegram has no push channel from chat to webview, so this poll is server-mediated by necessity.

The destination id is a plain public gazetteer slug — no crypto, no token anywhere on this path. web/src/search/resolve-destination.ts reads it from TWO carriers in a fixed order with ONE shared validation ([A-Za-z0-9_-]{1,64}, optional dest_ wire prefix):

  1. PRIMARY — the URL query string (?dest=<id>): the /cari OPEN inline-button path; Telegram opens the button's URL verbatim in the webview.
  2. SECONDARY — start_param inside the SIGNED initData string: the documented direct-link (t.me/<bot>?startapp=<id>) and home-screen-shortcut path, treated as an untrusted exact-lookup key.

The id resolves against the webview's own build-time copy of the shared gazetteer (@araharah/search inlined by Vite). A hit renders the destination card with a START-route action; a miss renders the honest "destination unavailable" card and the boot continues staged — never a blank map. The resolved entry lives in module scope (memory only); nothing persists.

6. Route consult (engine A over R2)

sequenceDiagram
    participant MAIN as main.ts (auto-consult, once per boot)
    participant CTRL as route/route.ts
    participant HTTP as http-source.ts (backend #2 seam)
    participant W as Worker /api/route
    participant G as loadGraph (R2 ROAD_GRAPH)
    participant A as snapToGraph + routeAstar

    MAIN->>CTRL: route { origin: fix, destinationId }
    CTRL->>HTTP: POST endpoint (injected), bearer = memory token
    HTTP->>W: { origin, destinationId } (≤ 4 KB)
    W->>W: getClaimByToken — Bearer claim gate
    W->>G: R2 GET kl-roadgraph.v1.bin (ONE per cold isolate, memoized)
    G-->>W: RoadGraph | null
    W->>A: bbox-check origin → snap origin → snap destination → A*
    A-->>W: RouteResult | null
    W-->>HTTP: 200 { result } | 200 JSON null
    HTTP-->>CTRL: RouteResult | null (fail-honest)

bot/src/routing/worker-router.ts is engine A's whole server surface: the decoded graph memoizes per isolate (loadGraph — the ONLY R2 loader, shared with the cron arm), the origin is bbox-checked against graph.bbox before any snap/A* work, and every miss class answers 200 null — unknown destination, snap miss, unreachable pair, absent/corrupt graph — the honest router-pending state (never a 500, never a fake route). Alternatives generation is prohibited on this path: exactly one best route. The client composes the ladder at the import site — createFixtureRoutingSource (backend #1, the 15-pair golden corpus) followed by createHttpRoutingSource (engine A) — with the null contract end to end.

7. Guidance (mint → resume → cron push)

sequenceDiagram
    participant MA as Mini App
    participant W as Worker /api/guidance
    participant KV as KV guidance:id (7200s)
    participant C as scheduled() every 2 min

    MA->>W: POST /api/guidance (Bearer) — six-field route snapshot
    W->>W: getClaimByToken → chat_id minted from the CLAIM (body chat_id structurally ignored)
    W->>KV: mintGuidanceSession — inline snapshot, expirationTtl 7200
    KV-->>W: envelope (uuid id)
    W-->>MA: 201 { id }
    Note over MA: webview killed / phone locked
    MA->>W: GET /api/guidance?id=<uuid> — resume carrier ?g=<uuid>
    W->>KV: getGuidanceSession — foreign or absent are indistinguishable 404s
    KV-->>MA: envelope → straight into the live trip
    loop every 2 min (stateless — zero KV writes)
        C->>KV: list prefix guidance: → getGuidanceSession per key
        C->>C: projectTurnCards(envelope, nowSec, streetAt(loadGraph))
        C->>MA: sendPhoto glyph + OPEN (?g=<id>) — due cards only, cadence 240 s
    end

The envelope is the frozen Phase 5 contract: exactly four fields (id, chat_id, route_snapshot, created_at); the snapshot is INLINE (one key, one TTL) with six fields (destinationId, destinationName, distanceM, durationS, geometry polyline6, startedAtSec). Every write carries expirationTtl: 7200 on the same call line — the PG1 TTL law covers this file. On the web side, web/src/guidance/guidance.ts is the pure reroute state machine (idle → on_route → off_route → resuming/rerouting → arrived, accuracy-aware threshold, 20 s storm guard) and web/src/guidance/watch.ts is the throttled watchPosition stream (guidance work runs on ≥ 10 m moved or ≥ 5 s elapsed — fixes are consumed in the caller's frame and never transmitted).

8. Worker routing table

Method Path Handler in bot/src/webhook.ts Purpose
POST /telegram/webhook inline in fetch() grammY webhook adapter + secret-token compare
POST /api/session handleSession initData → one-time opaque session token
GET /api/location handleLocation Bearer-authenticated chat-pin relay
POST /api/location-fallback handleLocationFallback Ask the bot to send the reply keyboard
POST/GET/DELETE /api/guidance handleGuidance Mint / resume / delete the 7200 s guidance envelope
POST /api/route handleRoute (in src/routing/worker-router.ts) Bearer-gated engine-A routing over the R2 road graph
* anything else — 404

run_worker_first: ["/api/*", "/telegram/*"] guarantees the Worker's fetch owns these paths before the static asset fallback runs; every other path resolves to the web/dist bundle. The same fetch handler also exports scheduled() — the */2 cron that runs the turn-card push (flow 7).

Build & Deploy Topology

graph LR
    classDef step fill:#1A1A1A,stroke:#000000,stroke-width:2px,color:#FFF8F0
    classDef art fill:#D9D2C6,stroke:#736B1E,stroke-width:2px,color:#1A1A1A
    classDef cloud fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A
    classDef store fill:#ED3F1C,stroke:#8C1F0A,stroke-width:2px,color:#FFFFFF

    T1["theme: tailwindcss -i src/theme.css -o dist/rams.css --minify"]:::step
    T2["theme: basemap/build.mjs<br/>fetch liberty → derive roles → liberty-rams.json"]:::step
    W1["web: vite build → dist/"]:::step
    W2["web: vite build --mode harness → dist-harness/"]:::step
    B1["bot: wrangler deploy"]:::step
    B2["bot: scripts/set-webhook.mjs"]:::step
    RG["routing: build-road-graph.mjs<br/>→ kl-roadgraph.v1.bin"]:::step
    RU["routing: upload-road-graph.mjs<br/>→ R2 (fork contract)"]:::step
    AR1["theme/dist/rams.css + fonts"]:::art
    AR2["web/dist (index.html, hashed assets,<br/>inlined liberty-rams style, public/glyphs)"]:::art
    AR3["web/dist-harness (never deployed)"]:::art
    EDGE["Cloudflare Worker araharah-bot<br/>[assets] ../web/dist + cron */2"]:::cloud
    KVS[("KV chat_sessions")]:::store
    R2B[("R2 ROAD_GRAPH")]:::store

    T1 --> W1
    T2 --> W1
    T1 --> AR1
    W1 --> AR2
    W2 --> AR3
    AR2 --> EDGE
    B1 --> EDGE
    B1 --> B2
    RG --> RU
    RU --> R2B
    EDGE --> KVS
    EDGE --> R2B

The root scripts encode the ordering: npm run build = build -w theme && build -w web; npm run deploy = npm run build && npm run deploy -w bot (which is wrangler deploy && node scripts/set-webhook.mjs). bot/wrangler.jsonc binds assets.directory to ../web/dist, so web/dist must exist before a bot deploy. The road-graph binary is a separate provisioning track (never a build prerequisite): routing/scripts/build-road-graph.mjs writes routing/data/kl-roadgraph.v1.bin (~15 MB, a build artifact that is NOT committed) and routing/scripts/upload-road-graph.mjs puts it on R2 — the fork contract is docs/ROAD-GRAPH.md.

The two web builds never share an outDir: vite.config.ts sets outDir: "dist-harness" only under --mode harness and defines __ARAHARAH_HARNESS__ to true there and false everywhere else, so the mock installer and the fetch stubs in web/index.html are dead-code-eliminated from production. web/test/build-artifact.test.mjs (BA1–BA10) proves the shipped bundle references no third-party CDN, self-hosts the woff2 fonts, contains the inlined Rams style, and carries neither installMockWebApp nor __ARAHARAH_MOCK__. The turn-card glyphs (web/public/glyphs/*.png, rendered by routing/scripts/build-glyphs.mjs) are static assets the cron references as ${MINI_APP_URL}/glyphs/<glyph>.png.

Key Abstractions

@araharah/bot

Abstraction File Description
Env bot/src/config.ts Typed contract: BOT_TOKEN, WEBHOOK_SECRET (non-empty — the webhook fails closed without it), chat_sessions: KVNamespace, ROAD_GRAPH: R2Bucket (first R2 binding), MINI_APP_URL (public deep-link origin).
createExportedHandler(opts) bot/src/webhook.ts The ExportedHandler<Env> factory — six fetch routes plus scheduled(); 404 otherwise. Production passes nothing; offline tests inject a recording baseFetch.
validateInitData(raw, botToken, opts) bot/src/security/initdata.ts Pure HMAC-SHA256 validation of Telegram WebApp initData. Depends only on crypto.subtle, so it runs unchanged in Workers and bare Node. Returns InitDataResult.
SessionClaim / mintSession / getClaimByToken / getTokenForChat bot/src/kv/sessions.ts TTL-only session layer. SESSION_TTL_SECONDS = 900. Key scheme session:<token> → claim, chat:<chat_id> → current token. Rotate-on-new with a best-effort reconciliation sweep.
LocationPin / writePin / getPin / clearPin bot/src/kv/location-pin.ts TTL-only pin layer. PIN_TTL_SECONDS = 300. Key loc:<chat_id>. getPin range-validates on read.
GuidanceSessionEnvelope / GuidanceRouteSnapshot / mint/get/deleteGuidanceSession / GUIDANCE_KEY_PREFIX bot/src/kv/guidance-sessions.ts The frozen four-field envelope (six-field inline snapshot), GUIDANCE_SESSION_TTL_SECONDS = 7200, key guidance:<uuid>. Defensive reads (garbage → null); ids UUID-guarded before any KV touch.
handleRoute / loadGraph bot/src/routing/worker-router.ts Engine A's server surface: Bearer claim gate, 4 KB body cap, bbox pre-check, snap → A* over the R2-hosted graph, 200 null for every miss. loadGraph is the ONLY R2 loader — one memoized GET per cold isolate, shared with the cron arm.
projectTurnCards / makeStreetAt bot/src/guidance/cron.ts The PURE projection over the frozen snapshot — when a card is due (nowSec % CARD_CADENCE_S < SCAN_TICK_S), glyph + caption composition, street reconstruction from the graph. CARD_CADENCE_S = 240; the cron is stateless (zero KV writes).
createBot(env, opts) bot/src/bot/bot.ts grammY factory; wires /start + /mula, /help, /privacy + /privasi, /cari + /search, the alternates switcher, and the location-fallback handler. Builds one shared gazetteer SearchSource.
registerCari(bot, source, origin) bot/src/bot/commands/cari.ts The search command surface: venue card with OPEN deep link (?dest=<id>) and alternates callbacks — the second documented exception to the text-only rule.
requestLocationFallback(bot, chatId, locale) bot/src/bot/handlers/location-fallback.ts Sends the request_location reply keyboard. Single caller: handleLocationFallback.
strings / pickLocale bot/src/i18n/strings.ts The bot's entire copy surface (en, ms). pickLocale maps an ms/ms-* language code to Malay, everything else to English.
KvLike bot/src/kv/*.ts Structural KV interface (module-private, not exported — the derived MintSessionKv/LocationPinKv/GuidanceSessionKv are the public surface) — lets tests pass a fake without depending on Workers types.

@araharah/search

Abstraction File Description
SearchSource search/src/types.ts The pluggable search seam: async search(q) + synchronous byId(id). The documented ladder is backend #1 (gazetteer, shipped) with later rungs slotted at the import site — no env read, no registry.
createGazetteerSource() search/src/source.ts Backend #1. The refusal contract is load-bearing: an EMPTY array means "not in the v1 area" — never a fabricated pin. Ranking is score-band DESC then entry-id ASC (deterministic tie-break).
search(text, limit) search/src/source.ts The synchronous scoring path — shared by the bot, the Mini App, and the corpus runner: the measured quality IS the shipped quality.
getEntries / getEntryById / getIndex search/src/gazetteer.ts The lazy, memoized index over a static JSON import (data/kl-gazetteer.json, ODbL notice inside). Three laws: lazy + memoized (never module-scope work), survives three environments (Worker bundle, Vite bundle, bare Node), no logging/storage.
scoreCandidate / TIER / FUZZY_ADMIT_SIMILARITY search/src/score.ts Tiered admission — EXACT / ALIAS_EXACT / PREFIX / TOKEN_SET bands always admitted; FUZZY only at similarity ≥ 0.72. normalize (with rules/abbreviations.ts) canonicalizes both sides of every comparison.
GazetteerEntry search/src/types.ts id (slug), name, aliases, coordinates, category — the entry shape every carrier (bot card, webview resolver, guidance snapshot) reduces to.

@araharah/routing

Abstraction File Description
RoutingSource routing/src/types.ts The pluggable routing seam: route(req) → RouteResult | null. Backend ladder: #1 "fixture" → #2 "engine-a" (LIVE, via /api/route) → engine B (client-side webview router, documented post-v1). Selection happens at the import site in code — no env read, no registry.
RouteResult / RouteOption / RouteStep / RouteGeometry routing/src/types.ts Day-one contract: options 1..3 (index 0 = best), cumulativeDistanceM parallel to points (the guidance projection contract), polyline6 geometry, optional kind maneuver tags. Fixture-world steps are empty arrays — honest "not wired".
RoadGraph / encodeGraph / decodeGraph routing/src/graph.ts The CSR road-graph binary codec (GRAPH_MAGIC, GRAPH_VERSION) — the on-disk form of kl-roadgraph.v1.bin. V8-native, dependency-free, no network.
snapToGraph / SnapResult routing/src/snap.ts Snaps a raw fix onto the graph (bbox-margined); the origin of every route computation and of the cron's street reconstruction.
routeAstar / deriveSteps / classifyTurn routing/src/astar.ts The A* engine plus the maneuver classifier (depart/left/…/arrive) that feeds both the webview guidance machine and the cron's turn cards.
createFixtureRoutingSource routing/src/fixture-source.ts Backend #1: the committed 15-pair golden KL corpus (data/route-fixtures.json + test/fixtures/golden-pairs.json). Demo-grade by design; the tolerance gate is npm run routing:corpus.
createHttpRoutingSource(options) routing/src/http-source.ts Engine A's client seam: injected endpoint + bearer getter, defensively validates every response field, resolves null on any failure (the null contract crossing HTTP). Never holds or persists the token.
decodePolyline6 / encodePolyline6 / haversineM / deriveCumulativeDistanceM routing/src/polyline.ts, routing/src/geo.ts The storage geometry format and the ONE distance authority — every distance in the system flows through geo.ts.

@araharah/web

Abstraction File Description
Fix web/src/location/types.ts The single normalized position shape every source reduces to: lat, lng, accuracyM, headingDeg, ts, source. Freshness lives in the fix.
LocationState web/src/location/types.ts Discriminated union: idle / validating / acquiring / awaiting-pin / live / stale / denied.
acquireFix(deps, lastAccepted) web/src/location/chain.ts The three-link state machine. Pluggable cadence — no setInterval, no watchPosition.
isFixStale(fix, nowMs) / fixAgeSeconds web/src/location/chain.ts The 45 s signal-lost threshold and the FIX Ns AGO telemetry helper.
readDestinationId / resolveDestination / setDestination web/src/search/resolve-destination.ts The two-carrier deep-link resolver (?dest= URL, then signed initData's start_param), one shared slug validation, memory-only result, honest-null on miss.
buildDestinationCard / buildDestinationUnavailableCard web/src/search/destination-card.ts The search surface's Rams cards (destination confirmed / honest unavailable).
createRouteController web/src/route/route.ts The consult/select/walk-back state machine and the guidance pump: auto-consults once per boot, draws the selected line (setData on the route source), starts the guidance watch, delegates every guidance decision. Touches no DOM/map directly — everything injected.
installRouteLayers(m) web/src/route/route-layers.ts Installs the route / route-alt map sources inside map.ts's ONE load handler; a drawn route is static furniture (only selection changes it).
createGuidanceSession / stepGuidance web/src/guidance/guidance.ts The pure reroute state machine: idle → on_route → off_route → resuming/rerouting, accuracy-aware threshold (max(35, 2×accuracy) capped 100), 3-fix + 8 s grace, 20 s storm guard. A pure reducer — replay-verifiable in Node.
createGuidanceWatch(geo, onFix) web/src/guidance/watch.ts The gated position stream: watchPosition high-accuracy, throttled to ≥ 10 m moved or ≥ 5 s elapsed. Fixes are consumed in the caller's frame — never stored, logged, or transmitted.
TelegramWebAppLike web/src/telegram/webapp.ts Hand-rolled ~20-member contract for window.Telegram.WebApp. getWebApp() may honestly return null off-Telegram.
createSession(initData) web/src/session/client.ts POSTs the verbatim initData string; returns { ok: true, token } or a malformed/tampered/stale/network reason.
setSession / getSession / isSessionStale / clearSession web/src/session/store.ts Module-scope token holder. No persistence call anywhere in the file.
bootMap(container) web/src/map/map.ts MapLibre v6 bootstrap with the build-time-inlined Rams style. Returns null on GPUInitializationError.
createPositionMarker() web/src/map/marker.ts SVG accuracy circle + 20 px dot + heading cone. Cone hidden entirely when heading is null.
createFollowController(map, onModeChange) web/src/map/follow.ts following/free camera. Only a user gesture (movestart with originalEvent) flips to free.
createStatusStrip(host) web/src/ui/strip.ts The status band — state lamp + state word on the left, telemetry on the right.
buildBootCard / buildDeniedCard / buildSignalLostCard web/src/ui/cards.ts Rams card surfaces composed from theme component classes; zero inline copy.
createRecenterControl(host, locale, onRecenter) web/src/ui/recenter.ts 40×40 follow plate with three instant notch states.
watchTheme(wa) / resolveBranch web/src/telegram/theme.ts The theme-slot machine: Telegram light/dark (or header_bg_color luminance fallback) selects between two Rams token pairs — applyTheme's ONLY statement is the data-tg-theme attribute set; MapLibre is deliberately absent (the map style is never re-set). Pinned by web/test/theme.test.mjs.
resolveSafeInsets / applySafeArea web/src/telegram/safe-area.ts Union of CSS env() and WebApp.safeAreaInset, max per edge, emitted as CSS custom properties.
wireBackButton(wa, hasOverlay) web/src/telegram/backbutton.ts Total function — the context-aware resolver (guidance → confirm; confirm → CONTINUE; ended → search-root walk; route → Phase 5 walk; root → close) and the wire's per-state sync().
wireHomeScreen(wa, locale) web/src/telegram/homescreen.ts The ONBD-03 home-screen affordance behind the Bot API 8.0 capability guard — Telegram-native methods only.
installMockWebApp(options) web/src/mock/telegram-mock.ts Harness-only fake window.Telegram.WebApp with a dev-signed initData. Excluded from web/dist.
localeFromInitData(raw) web/src/i18n/strings.ts Parses the locale out of the raw initData string — the session response carries only { token }.

@araharah/theme

Abstraction File Description
@theme static block theme/src/tokens.css 55 --color-* tokens plus spacing and typography. static forces every token into the compiled CSS so the package is consumable as plain custom properties.
theme/src/theme.css theme/src/theme.css Entry stylesheet — @imports tailwindcss, tokens, components; declares seven self-hosted @font-face rules; defines the --shell-bg / --shell-on-surface theme slots (the two Rams token pairs the slot switch flips).
Component classes theme/src/components.css .card, .btn-primary, .btn-secondary, .chip-*, .maneuver-*, .text-*, .toggle*, .input-field, .checkbox.
build.mjs theme/basemap/build.mjs Forks OpenFreeMap liberty, deriving every cartographic color from tokens.css via desaturate(tokenHex, satMultiplier, lightnessPerRole) with saturation clamped to [0.06, 0.12].
liberty-rams.json theme/basemap/liberty-rams.json The generated style. Accent guard fails the build if #EB8008 / #ED3F1C / #736B1E leak into any layer. building-3d is removed (faux 3D).

Directory Structure Rationale

araharah/
├── package.json          # private root; workspaces + script passthroughs
├── bot/                  # @araharah/bot — the Cloudflare Worker
│   ├── src/
│   │   ├── webhook.ts        # the single fetch handler; six routes + scheduled()
│   │   ├── config.ts         # Env contract (KV, R2, MINI_APP_URL)
│   │   ├── bot/              # grammY factory, commands/, handlers/ (incl. /cari)
│   │   ├── security/         # initdata.ts — pure, isolation-free crypto
│   │   ├── kv/               # sessions.ts, location-pin.ts, guidance-sessions.ts — TTL-only repos
│   │   ├── routing/          # worker-router.ts — engine A's server surface
│   │   ├── guidance/         # cron.ts — the pure turn-card projection
│   │   └── i18n/strings.ts   # every user-facing bot string
│   ├── scripts/              # set-webhook, register-commands, set-menu-button
│   ├── test/                 # hand-rolled zero-dependency runner + suites
│   └── wrangler.jsonc        # worker name, assets binding, run_worker_first, R2, cron
├── web/                  # @araharah/web — the Mini App
│   ├── src/
│   │   ├── main.ts           # boot choreography (order is load-bearing)
│   │   ├── session/          # client.ts (network), store.ts (memory)
│   │   ├── location/         # chain.ts + telegram/webview/chat-pin sources
│   │   ├── search/           # resolve-destination.ts, destination-card.ts
│   │   ├── route/            # route.ts controller, card, markers, layers
│   │   ├── guidance/         # guidance.ts machine, watch.ts, banner, eta, camera, haptics
│   │   ├── telegram/         # webapp, theme, backbutton, safe-area, homescreen
│   │   ├── map/              # map.ts, marker.ts, follow.ts
│   │   ├── ui/               # strip.ts, cards.ts, recenter.ts
│   │   ├── mock/             # harness-only Telegram fake
│   │   ├── i18n/strings.ts   # every user-facing shell string
│   │   └── shell.css         # Rams chrome; imports @araharah/theme/dist/rams.css
│   ├── public/glyphs/        # turn-card PNGs the cron deep-links
│   ├── test/                 # runner + gate suites (copy, storage, artifact, chain, guidance)
│   ├── scripts/capture.mjs   # screenshot capture
│   └── vite.config.ts        # dual-build dist/ vs dist-harness/
├── search/               # @araharah/search — the gazetteer data plane
│   ├── src/                  # gazetteer.ts, source.ts, score.ts, normalize.ts, types.ts
│   ├── src/data/             # kl-gazetteer.json (committed, ODbL notice inside)
│   ├── scripts/              # gazetteer-corpus.mjs, json-to-sqlite.mjs, overpass-topup.mjs
│   └── test/                 # runner + suites + golden corpus fixtures
├── routing/              # @araharah/routing — the routing data plane
│   ├── src/                  # graph.ts, snap.ts, astar.ts, polyline.ts, geo.ts,
│   │                         # fixture-source.ts, http-source.ts, types.ts, index.ts
│   ├── data/                 # kl-roadgraph.v1.bin — BUILD ARTIFACT, not committed
│   ├── scripts/              # build-road-graph.mjs, upload-road-graph.mjs,
│   │                         # build-glyphs.mjs, route-corpus.mjs, fetch-route-fixtures.mjs
│   └── test/                 # runner + suites + mini-graph fixtures
├── theme/                # @araharah/theme — design tokens + cartography
│   ├── src/                  # tokens.css, components.css, theme.css, sheet.html
│   ├── fonts/                # self-hosted Geist + Space Grotesk woff2
│   ├── basemap/              # build.mjs + generated liberty-rams.json
│   ├── scripts/              # audit.mjs (token audit), screenshot.mjs
│   └── dist/rams.css         # compiled output
├── scripts/              # root audits — privacy-audit.mjs, budget-audit.mjs,
│   │                         # lib/ (source-gates, bundle-gates, budget-gates,
│   │                         # cloudflare-probes), test/ (their suites)
├── stitch_minimalist_route_navigator/   # read-only design reference (mockups)
└── docs/

Rationale for the boundaries:

  • bot/src/webhook.ts owns routing; nothing else dispatches. The Worker is the only place that decides which path runs, so run_worker_first, the secret-token gate, the body caps, and the cron tick all apply uniformly.
  • bot/src/security/, bot/src/kv/, and the two data-plane packages are dependency-light on purpose. initdata.ts imports nothing from grammY and runs in both Workers and bare Node; kv/*.ts take a structural KvLike so the suites pass fakes; @araharah/search and @araharah/routing are V8-native with zero runtime dependencies, no env reads, and no network — the same sources run inside the Worker bundle, the Vite bundle, and the Node corpus runners.
  • The backend ladders are constructed in code, not configured. Search and routing each expose a seam (SearchSource, RoutingSource) whose backend selection happens at the import site — no env read, no config read, no dynamic registry. Swapping a backend is an import-site change.
  • bot/src/i18n/strings.ts is the single copy source. The commands register script derives its menu text from the same helpMenu lines, and bot/test/commands.test.mjs gates that the /privacy copy still names the four never-stored facts (location, routes, trip history, profile) and keeps the ~15-minute TTL wording, in both locales.
  • web/src/location/ separates sources from the chain. Each source is a small module that normalizes to Fix and answers null for denial; chain.ts owns ordering and knows nothing about any single source's API.
  • web/src/route/ + web/src/guidance/ split wiring from decisions. route.ts only wires (consult once, draw, start the pump); guidance.ts is the pure state machine that decides every guidance behavior; watch.ts owns the throttle; the view modules (banner, eta, camera, haptics) each own one concern. Node-testable with injected DOM/map/geo.
  • web/src/session/ splits network from state. client.ts owns fetch and error vocabulary; store.ts owns the in-memory token and the 900 s staleness rule. Neither ever touches storage.
  • web/src/telegram/ is the only place that touches Telegram.WebApp. Every other module takes the narrow TelegramWebAppLike interface, which is what lets the mock, the test fakes, and real clients satisfy the same contract.
  • theme/ is pure build-time. It exports no runtime code — a CSS bundle and a JSON style. That is why web can depend on it without pulling a bundler into the token package.
  • scripts/ is the audit layer, not product code. privacy-audit.mjs and budget-audit.mjs are maintainer-time gates that import from scripts/lib/; nothing in bot/src, web/src, routing/src, or search/src imports them.
  • routing/data/ is a build artifact by law. The 15 MB graph binary is gitignored; forks build and upload their own (docs/ROAD-GRAPH.md is the fork contract).
  • stitch_minimalist_route_navigator/ is @source-scanned, never edited. The theme compiles every class the four mockups use so the design vocabulary stays provable against the shipped CSS.

Cross-Cutting Concerns

Privacy by construction — the gate lineage

Two layers of gates pin this rather than documenting it:

The consolidated maintainer audit — npm run privacy-audit (scripts/privacy-audit.mjs), five static gates in scripts/lib/source-gates.mjs and scripts/lib/bundle-gates.mjs, plus --live probes in scripts/lib/cloudflare-probes.mjs:

Gate id Rule
storage-api The memory-only law, repo-wide (the no-storage suite generalized).
kv-ttl The PG1 same-line TTL law, repo-wide — every .put( line in bot/src carries expirationTtl as a named constant.
log-vocabulary The D-14 category-only log law — no coordinate, payload, user id, or timestamp in any log line.
runtime-hosts The POSITIVE fetch-host allowlist — no fetch to any host outside it.
bundle-telemetry The PRODUCTION web/dist scan — no analytics, no beacon, no telemetry in the shipped artifact.
kv-expiry / asset-headers (with --live) Live expiry metadata and cache-header verification — a skipped probe is never mistaken for a pass (exit 2 = NOT RUN).

Exit codes: 0 clean, 1 violations, 2 NOT RUN (environment drift) — no silent passes. The audit doubles as the fork onboarding check. docs/PRIVACY-AUDIT.md is the human record.

The in-suite gates (the same laws asserted where the code lives):

  • bot/test/privacy.test.mjs — the PG1/PG2 lineage: every put on chat_sessions (now spanning sessions.ts, location-pin.ts, guidance-sessions.ts, and webhook.ts) must carry an explicit expirationTtl on the same call line, as a named SCREAMING_SNAKE constant. No write path can persist without a TTL.
  • web/test/no-storage.test.mjs — comments stripped, every web/src file scanned; zero occurrences of localStorage, sessionStorage, indexedDB, caches, document.cookie, WebApp.DeviceStorage, WebApp.SecureStorage, WebApp.CloudStorage, or any service-worker registration.
  • bot/test/route-privacy.test.mjs — the route handler's structural proof: the only persisted surface is the guidance envelope; the route request's origin never touches storage or a log line.

The persisted surface is exactly SessionClaim (user_id, chat_id, issued_at, language_code) for 900 s, LocationPin (latitude, longitude, horizontal_accuracy) for 300 s, and the GuidanceSessionEnvelope for 7200 s — all in ONE KV namespace. On the web side, guidance on-route fixes never leave the device; the ONLY network-bound position is a reroute's origin (one point, on a real excursion, snapped server-side). The client holds the token, the destination, and the last fix in module scope only.

TTL-only storage law

Every KV write carries an explicit TTL; nothing survives past it:

Key family TTL Constant
session:<token> / chat:<chat_id> 900 s (~15 min) SESSION_TTL_SECONDS
loc:<chat_id> 300 s PIN_TTL_SECONDS
guidance:<uuid> 7200 s (~2 h) GUIDANCE_SESSION_TTL_SECONDS

The R2 bucket (ROAD_GRAPH) is a read-only binary host, not a persistence surface. Exactly ONE kv_namespaces entry is the law (GE5) — a new KV family is a design change, not a refactor.

Access control

Every chat-scoped route (/api/location, /api/location-fallback, /api/guidance) resolves the chat_id from the Bearer session claim, never from client input; locale comes from claim.language_code. The location-fallback request body is never read; a guidance POST's chat_id field is structurally dropped (validateSnapshot builds the new object field-by-field); GET/DELETE compare envelope.chat_id === claim.chat_id so foreign and absent are indistinguishable 404s (no existence oracle for cross-chat probing). /api/route uses the claim purely as an identity gate — routing itself is chat-agnostic and nothing about the caller is stored, logged, or echoed.

Honest degradation

Every degradation is a labeled state, never a silent one or a fabricated value: bootMap() returning null renders the shell over --color-surface-dim with data-map="absent"; a null heading hides the marker cone rather than guessing an orientation; getPin range-validates so a corrupt record cannot fly the camera to null island; the poll is bounded at 60 s so AWAITING PIN cannot persist; every source timeout resolves null so the chain always advances; the route null contract — every /api/route miss class answers 200 null and the client renders the pending card; a null road graph degrades the cron card to the bare-verb street-less composition — the send still fires; guidance deviation becomes a labeled off-route state with a storm-guarded reroute, never a silent camera jump.

Category-only logging

The Worker's only log statements are categories — [webhook] rejected: misconfigured, [initdata] rejected: malformed|tampered|stale, [location]/[location-fallback]/ [guidance]/[route] rejected: unauthorized|malformed, [guidance-cron] skipped: unconfigured|malformed, [guidance-cron] send failed, [location-fallback] failed: telegram-send. Never raw payloads, never user ids, never chat ids, never coordinates, never timestamps. Rejection responses carry the category only. The web app's console lines are state tokens (ARAHARAH-ROUTE, ARAHARAH-GUIDE, RAMS-BASEMAP-READY, ARAHARAH-LIVE) — never coordinates. The audits (log-vocabulary gate) prove this mechanically.

The budget law

npm run budget-audit (scripts/budget-audit.mjs) measures the free-tier posture instead of assuming it: route CPU (the A* ladder, child-process), bundle size, KV write classes (the cron's ZERO-writes law), subrequest classes, and the golden corpora; --live adds observed cache headers. The record is docs/BUDGETS.md. The cron's cadence is tuned to it: */2 * * * * × one KV list-op per tick = 720 list-ops/day, inside the free plan's 1,000/day KV-list ceiling, and each tick performs zero KV writes (stateless projection over the 7200 s envelopes).

Testing

Five workspaces, one test idiom: bot/test, web/test, search/test, and routing/test each use the same hand-rolled zero-dependency runner (runner.mjs + harness.mjs), auto-discovering *.test.mjs; theme runs scripts/audit.test.mjs in the same shape, and scripts/test/ holds the audit tools' own suites. Gates are grep/introspection assertions against source and build artifacts, not just behavioral assertions. The corpus suites (search/test/corpus.test.mjs, routing/test/corpus.test.mjs) assert the same measurements the standalone corpus runners print — measured quality and shipped quality cannot drift.

What Is Not Built Yet

The following are documented targets, not in the codebase: engine B (the fully client-side webview router — graph on the phone; engine A's documented post-v1 evolution; the Valhalla-to-JS port was ruled infeasible and geojson-path-finder was rejected — recorded so neither is re-proposed), Valhalla-on-VPS (the documented provision path, not shipped), Neon/Postgres, RabbitMQ, Prometheus/Grafana, Resend, opt-in telemetry (v1 carries zero telemetry — there is nothing to opt into yet), and the Maptiler tile provider (the basemap today is the OpenFreeMap liberty fork). Alternatives generation on /api/route is prohibited by design, not deferred. Search backend #1 (gazetteer) and routing backends #1/#2 are the only ladder rungs implemented; later rungs slot in at the import site without a client rewrite.