araharah-executor 61e2bfe070 test(08.1-02): add failing maintainer-build CH pins (RED)
BS1-BS5 pin plan 02 task 1's behavior before it exists: the build script
must be landmark-free in stripped code, run ONE contractGraph stage into
the ch: encode input, print the full measured CH stats block (D-08/D-09),
keep the 1-80 MB D-01 envelope single and unchanged, and decode the
committed ARG3 fixture green via --check with CH facts. BS1/BS2/BS3/BS5
fail against the current landmark-era script (which cannot even load —
landmark.ts was deleted in 08.1-01).
2026-10-26 07:43:16 +08:00
2026-10-23 19:18:43 +08:00

arah² logo

arah²

A privacy-respecting, Telegram-first GPS navigator — a waze-like route navigator delivered through a Telegram bot and Mini App instead of an app-store install, built on OpenStreetMap data, and designed to Dieter Rams' "less, but better." First users are in the Kuala Lumpur metro.

The Telegram bot is arah2bot. The product is live — a private beta with real drivers in KL: open @arah2bot, send /cari KLCC, open the venue card's Mini App button, and follow live turn-by-turn guidance. The app is served from https://araharah-bot.araharah-bot.workers.dev.


Overview

arah² is an npm-workspaces monorepo with five packages:

Package Name What it does
theme/ @araharah/theme The Telemetry Rams design system as a Tailwind v4 CSS-first token package. Every color, spacing, type, and radius value in the project is defined once in theme/src/tokens.css and compiled to theme/dist/rams.css. Also ships the MapLibre basemap style theme/basemap/liberty-rams.json plus gallery/audit/screenshot tooling.
bot/ @araharah/bot A grammY Telegram bot running on a Cloudflare Worker. It serves the /telegram/webhook route, five JSON API routes, keeps TTL-only chat sessions in Workers KV, loads the road graph from an R2 bucket, and pushes turn cards to chat from a scheduled cron tick. It also serves the Mini App's static assets through a wrangler assets binding.
web/ @araharah/web The Telegram Mini App: a MapLibre GL navigation shell built with Vite. Owns the three-source location acquisition chain, the in-app destination search UI, route rendering, turn-card guidance replay, an in-memory session store, and the Telegram WebApp integrations (theme slots, BackButton, safe-area insets).
search/ @araharah/search The shared, dependency-free destination-search module: the JUPEM-derived Malay abbreviation table as editable data, a symmetric normalizer, a Damerau (OSA) residual scorer with disjoint tiers, and the curated KL gazetteer with locals' aliases — imported by the bot, the Mini App, and the search-quality corpus runner.
routing/ @araharah/routing The shared, dependency-free routing data plane: a CSR road graph with snap-to-graph, A* search, and polyline6 codecs. Engine A runs it Worker-side over the R2-hosted graph (/api/route); the fixture source (15 committed KL golden pairs) and the route-quality corpus runner live alongside it.

The Worker is the single origin. It answers /api/* and /telegram/* itself (run_worker_first) and serves everything else out of web/dist.

graph TD
    U["Telegram client — arah2bot chat + Mini App webview"]
    W["Cloudflare Worker — araharah-bot"]
    KV[("Workers KV — TTL-only records: sessions 900s, pins 300s, guidance 7200s")]
    R2[("R2 — road-graph binary, one GET per cold isolate")]
    TG["Telegram Bot API"]
    APP["Mini App assets — web/dist via ASSETS binding"]
    MAP["MapLibre GL — Rams basemap, OpenFreeMap tiles"]
    THEME["@araharah/theme — tokens.css + liberty-rams.json"]

    U -->|"POST /telegram/webhook (secret-token header)"| TG
    TG --> W
    U -->|"GET Mini App"| APP
    APP --> W
    W -->|"venue card, pin keyboard, turn cards"| TG
    APP -->|"POST /api/session — initData HMAC"| W
    APP -->|"GET /api/location — Bearer token"| W
    APP -->|"POST /api/route — Bearer token"| R2
    W --> KV
    W -->|"cron */2 → turn cards"| TG
    APP --> MAP
    THEME -.->|"inlined at build time"| MAP
    THEME -.->|"dist/rams.css"| APP

    classDef telegram fill:#EB8008,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
    classDef worker fill:#1A1A1A,stroke:#EB8008,stroke-width:2px,color:#F7F0E3
    classDef storage fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
    classDef web fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1E1B14
    class U,TG telegram
    class W worker
    class KV,R2 storage
    class APP,MAP,THEME web

Installation

Clone the repository and install workspace dependencies:

git clone http://gitmirah:3000/c-fu/arah2.git
cd arah2
npm install

Requires Node.js and npm (no version is pinned in the repo — use a current LTS; the corpus runners rely on Node's native TypeScript type-stripping). Wrangler handles Cloudflare authentication on first use via wrangler dev / wrangler deploy.

For the bot's local secrets, copy the example file and fill it in:

cp bot/.dev.vars.example bot/.dev.vars

bot/.dev.vars is gitignored and must never be committed. It holds BOT_TOKEN (from @BotFather), WEBHOOK_SECRET, and WEBHOOK_URL (full origin; set-webhook.mjs appends /telegram/webhook if only an origin is given).


Quick start

  1. Build the design tokens and the Mini App. theme/dist/rams.css is an input to the web build, so it must exist first.

    npm run build
    
  2. Run everything locally. Builds the theme, then starts the Worker on port 8787 with --local, serving /telegram/webhook, the /api/* routes, and web/dist.

    npm run dev
    
  3. Iterate on the Mini App alone with Vite on port 5173 (strictPort), for map/UI work that doesn't need the bot:

    npm run web:dev
    

Quickstart: clone → production (your own bot, your own Cloudflare)

The concise path. Full detail lives in docs/DEPLOYMENT.md; local development in docs/GETTING-STARTED.md.

1. Create the bot — in Telegram, open @BotFather:

  • /newbot → pick a name + username → copy the bot token
  • /setuserpic → upload assets/logo_arah2.png (the bot's avatar)
  • Bot Settings → Main Mini App → set your future production URL (you'll fill the real one in step 4; BotFather's app name is what Telegram shows in the mini-app header)

2. Clone + install

git clone <your fork>
cd arah2 && npm install
cp bot/.dev.vars.example bot/.dev.vars

3. Fill bot/.dev.vars (gitignored — never commit):

BOT_TOKEN=<token from step 1>
WEBHOOK_SECRET=<any long random string — you choose it>
WEBHOOK_URL=https://<your-worker>.workers.dev
MINI_APP_URL=https://<your-worker>.workers.dev

4. Deploy to YOUR Cloudflare (free plan — $0):

npx wrangler login          # browser auth, once
npm run build               # theme tokens → web/dist
npm run deploy              # theme → web → worker + assets + webhook + commands

5. Provision the road graph (engine A's routing data — R2, also free):

npx wrangler r2 bucket create araharah-graphs
node routing/scripts/upload-road-graph.mjs   # builds + uploads the KL graph, round-trip verifies

6. Register the bot surfaces (usually already done by npm run deploy, re-run to be sure):

node bot/scripts/set-webhook.mjs          # arms the webhook (+ /telegram/webhook path)
node bot/scripts/register-commands.mjs    # /help /privacy /cari in every command scope
node bot/scripts/set-menu-button.mjs      # the menu button opens the app ("arah²")

7. Verify — the canaries (read-backs, nothing trusted blindly):

npm run privacy-audit -- --live && npm run budget-audit -- --live

Then in Telegram: restart the client → open the bot → /cari KLCC → venue card → OPEN → the map routes. Done: your own private navigator, $0/month.

Permission notes: the Cloudflare token (optional, only for the audit --live probes) needs Account → Workers KV Storage → Read only. The bot itself needs no special Telegram permissions; /setprivacy default (off) is correct — the bot never reads non-command messages.


Future enhancements & milestones

v1.x (next milestone):

  • ALT landmark preprocessing — routing backend v2: fixes the cross-metro CPU class while staying $0 (measured decision: short KL reroutes are in-budget today)
  • WASM-router track — fully client-side routing in the webview (origin+destination never touch a server; static-hosted; the fork-perfect endgame)
  • Battery measurement — the <10%/hr guidance-drain record (deferred from launch)
  • Bitmap map snapshots in minimized chat turn-cards (needs a rasterizer in the $0 stack)
  • Voice guidance · live trip share (sendLocation(live_period)) · toll toggle · speed-limit display — the REQUIREMENTS deferred table

v2 (post user-validation):

  • Incident reports + community layer, gamification, traffic-weighted ETAs — per the original vision doc (IDEAS.md)

Core concepts (the privacy law, backend ladders, session flow) are documented in docs/CORE-CONCEPTS.md.



Usage

Talking to the bot

The bot answers in English and Bahasa Melayu. Commands are registered into Telegram's persistent menu by npm run bot:commands, which derives its descriptions from bot/src/i18n/strings.ts so the menu and the replies cannot drift.

Command Alias Behaviour
/start /mula One-sentence introduction to arah² and its privacy posture.
/help — Lists the commands that exist and points at the known-limits record.
/privacy /privasi Names exactly what is stored (user id, chat id, language, ~15 min) and exactly what is never stored (location, routes, trip history, profile).
/cari /search Searches the curated KL gazetteer (/cari KLCC) and replies with one native venue card (sendVenue) carrying an OPEN IN ARAHARAH Mini App button plus 2–4 alternates as callback buttons. Out-of-area queries get an honest refusal that names the v1 area, never the query text.

The live flow, end to end

  1. Search. /cari <place> normalizes and scores the query against the KL gazetteer (@araharah/search); the best hit ships as a venue card with deep link MINI_APP_URL/?dest=<gazetteer-id>.
  2. Open the Mini App. The webview reads ?dest= from location.search, preloads the destination, and mints a session by posting the verbatim Telegram.WebApp.initData string to /api/session.
  3. Acquire a starting point. The three-link location chain (web/src/location/chain.ts) tries Telegram → WebView → chat pin, in that locked order.
  4. Route. The app posts the origin and destination to /api/route; the Worker's router (bot/src/routing/worker-router.ts) loads the R2-hosted CSR graph once per cold isolate, snaps, and runs A* (@araharah/routing). Every miss class answers 200 null — router-pending, never a wrong line.
  5. Guidance. Accepting a route mints a guidance envelope (POST /api/guidance, 7200 s TTL); a scheduled cron (*/2 * * * *) projects progress along the frozen route snapshot and pushes due turn cards — a maneuver glyph photo with a live caption — into the chat, with a button back into the Mini App at ?g=<envelope-id>.

HTTP surface

The Worker's fetch handler routes exactly six paths and returns 404 for anything else. All rejections carry a category only — never a payload, a user id, or a timestamp.

Method Path Purpose
POST /telegram/webhook grammY webhook adapter. Fails closed with 500 if WEBHOOK_SECRET is missing, then constant-time-compares X-Telegram-Bot-Api-Secret-Token before any bot processing or KV touch.
POST /api/session The security gate. The client posts its verbatim Telegram.WebApp.initData string (capped at 4 KB, over-cap bodies are rejected 413 before parsing). An HMAC-validated, fresh identity receives a one-time opaque session token.
GET /api/location Polled by the Mini App with its in-memory Bearer token. The KV key derives from the resolved claim's chat_id, never from a client-supplied id, so one user's token can never read another user's pin. Returns honest nulls; a missing or expired token is 401.
POST /api/location-fallback The "ask via chat" path when geolocation is denied. The bot sends a request_location reply keyboard into the user's own chat. Target chat id and locale come from the resolved claim — the request body is never read. A rejected Telegram send is a category-only 502.
POST GET DELETE /api/guidance Guidance envelopes. POST mints a 7200 s session from a validated six-field route snapshot (a body chat_id is structurally ignored — identity is server-owned). GET/DELETE resolve the record and compare it to the claim, so a foreign and an absent session are indistinguishable 404s.
POST /api/route Engine A's arm. Bearer-gated exactly like /api/location; returns a route summary, steps, and polyline6 geometry, or 200 null on every miss class (honest degradation). Backed by the R2 road-graph bucket; a missing or corrupt graph degrades to router-pending, never a crash.
# Mint a session from a Mini App initData string
curl -X POST http://localhost:8787/api/session --data-binary @initdata.txt
# → {"token":"<opaque>"}

# Poll the location relay with the token in memory
curl -H "Authorization: Bearer <opaque>" http://localhost:8787/api/location
# → {"pin":null}   (honest null, not a fabricated coordinate)

Location acquisition in the Mini App

web/src/location/chain.ts is a three-link state machine with a locked order — Telegram → WebView → chat pin — and freshness is a separate axis from availability. A Telegram fix byte-identical to the last accepted one routes to the nudge, never to success; denial is a state, not an exception. The chain exposes a Fix-shaped abstraction with a pluggable cadence and hard-codes no interval, so continuous-mode polling is a later decision, not a rewrite. Past 45 seconds the strip flips to SIGNAL LOST and the last marker dims.

Consuming the design tokens

theme/src/tokens.css is the single source of truth — 108 custom properties in all — 82 in the @theme static block, declared static so Tailwind v4's tree-shaking cannot drop tokens the MapLibre basemap derivation and plain-CSS consumers need, plus the 26 --type-* typography tokens in a separate :root { } block.

/* raw token access — any stylesheet, no Tailwind required */
@import "@araharah/theme/dist/rams.css";

.instrument {
  background: var(--color-signal-orange);
  color: var(--color-matte-black);
  padding: var(--spacing-space-md);
}
// build-time inlining of the Rams basemap style (never re-set at runtime)
import ramsStyle from "@araharah/theme/basemap/liberty-rams.json";

Design system

The Telemetry Rams design system lives in theme/ as a Tailwind v4 CSS-first token package — the single source of truth for every color, spacing, typography, and radius value in the project. Consume the tokens by importing theme/src/tokens.css (plain CSS custom properties, usable by any stylesheet or the MapLibre basemap) or the compiled stylesheet produced by npm run build. The token vocabulary mirrors the mockup screens verbatim, so markup derived from them maps one to one. Rams philosophy lives on in DESIGN.md, which is superseded for specifics — values are never restated outside the token package.

npm run build -w theme   # src/theme.css → dist/rams.css (minified)
npm run sheet            # build + print the sheet.html path
npm run gallery          # build + screenshot → serve gallery.html and basemap on :8123
npm run audit            # off-token audit of the rendered CSS — exits non-zero on violations
npm run capture          # Playwright screenshots of the token sheet

The audit script treats "zero off-token values" as evidence rather than an eyeball claim: it scans rendered CSS and flags any color, spacing, typography, radius, shadow, or blur value that does not derive from tokens.css. Fonts (Geist, Space Grotesk) are self-hosted from theme/fonts/ — both SIL Open Font License 1.1, no Google Fonts CDN.


Commands

Run from the repo root, or with -w <workspace>.

Command Description
npm run build Build the theme, then the Mini App
npm run dev Build, then run the Worker locally on port 8787
npm run deploy Build, then wrangler deploy + set-webhook.mjs
npm run bot:test / npm run web:test / npm run search:test / npm run routing:test Run each package's zero-dependency test suite
npm run bot:typecheck / npm run web:typecheck / npm run search:typecheck / npm run routing:typecheck tsc --noEmit
npm run bot:commands Register the persistent Telegram command menu (EN + MS)
npm run web:capture Capture Mini App screenshots
npm run web:build:harness Dual build into dist-harness/ with the mock installer and fetch stubs
npm run privacy-audit Repo-wide privacy audit: storage gate, KV-TTL gate, category-only log gate, runtime-host allowlist, production-bundle telemetry scan (--live probes add KV expiry + cache headers)
npm run budget-audit Free-tier budget audit: re-measures Worker CPU, bundle sizes, KV write classes, subrequests, and re-runs the golden corpora
npm run search:corpus Search-quality gate: golden corpus against the shipped search module
npm run routing:corpus Route-quality gate: fixture corpus (RT1–RT10) against the shipped routing module
npm run routing:fixtures Refresh the committed route fixtures from the fixture HTTP source
npm run sheet / gallery / audit / capture Design-token tooling (see above)

The two build outputs must never share an outDir — the harness build emits mock fetch stubs that production must not carry.


Privacy

Privacy is structural, not a policy page:

  • Every write to the chat_sessions KV namespace carries an explicit expirationTtl on its call line; the storage test suite greps for this.
  • Three TTL-only record families, nothing else: sessions — exactly four fields (user id, chat id, issued-at, language code) under a 900-second TTL; location pins — 300 seconds; guidance envelopes — 7200 seconds. Minting rotates any prior token for the chat.
  • The turn-card cron is stateless: it reads KV, sends cards, and writes nothing.
  • The Mini App holds its session token in a module-scope closure. There is no localStorage, sessionStorage, cookie, IndexedDB, or Cache API anywhere in web/src — the no-storage test suite pins this.
  • Nothing durable, nothing reconstructable. A token cannot outlive the 15-minute initData freshness proof that minted it.
  • Two audits back all of this with evidence: npm run privacy-audit (storage, TTL, category-only logs, runtime-host allowlist, shipped-bundle telemetry scan) and npm run budget-audit (the free-tier ceilings, measured — see docs/BUDGETS.md). v1 carries zero telemetry; there is nothing to opt into yet.

Repository layout

theme/   @araharah/theme — tokens.css, components.css, theme.css, fonts/, basemap/, scripts/
bot/     @araharah/bot   — src/webhook.ts entry, bot/, kv/, security/, routing/, guidance/, i18n/, scripts/, wrangler.jsonc
web/     @araharah/web   — src/main.ts entry, location/, route/, guidance/, search/, map/, session/, telegram/, ui/, mock/
search/  @araharah/search — src/types.ts, normalize.ts, distance.ts, score.ts, gazetteer.ts, source.ts, data/kl-gazetteer.json
routing/ @araharah/routing — src/graph.ts, snap.ts, astar.ts, polyline.ts, geo.ts, fixture-source.ts, http-source.ts, data/kl-roadgraph.v1.bin
stitch_minimalist_route_navigator/  read-only mockup screens (class-vocabulary source for the theme build)
DESIGN.md  Rams philosophy — superseded for any specific value

License

Code is licensed under GPL-3.0 — the full text is in LICENSE. The curated KL gazetteer (search/src/data/kl-gazetteer.json) is data, licensed separately under the Open Database License (ODbL) v1.0 with attribution to © OpenStreetMap contributors; the dual-licence note and the share-alike scope are in LICENSES.md.

S
Description
No description provided
Readme GPL-3.0
30 MiB
Languages
JavaScript 58.3%
TypeScript 20.8%
PostScript 12.7%
HTML 5.4%
CSS 2.6%
Other 0.2%