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
-
Build the design tokens and the Mini App.
theme/dist/rams.cssis an input to the web build, so it must exist first.npm run build -
Run everything locally. Builds the theme, then starts the Worker on port
8787with--local, serving/telegram/webhook, the/api/*routes, andweb/dist.npm run dev -
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
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
- Search.
/cari <place>normalizes and scores the query against the KL gazetteer (@araharah/search); the best hit ships as a venue card with deep linkMINI_APP_URL/?dest=<gazetteer-id>. - Open the Mini App. The webview reads
?dest=fromlocation.search, preloads the destination, and mints a session by posting the verbatimTelegram.WebApp.initDatastring to/api/session. - Acquire a starting point. The three-link location chain (
web/src/location/chain.ts) tries Telegram → WebView → chat pin, in that locked order. - 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 answers200 null— router-pending, never a wrong line. - 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_sessionsKV namespace carries an explicitexpirationTtlon 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 inweb/src— theno-storagetest 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) andnpm run budget-audit(the free-tier ceilings, measured — seedocs/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.