2
Architecture
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.

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/>arah²-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["arah² (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 arah²-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–BA6) 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 ~12-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

arah²/
├── 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.