- CO1 top-3 >= corpus threshold (D-14), measured in-process via the runner's measureCorpus - CO2 zero negative leaks (honest refusal, D-13) - CO3 tautology floor: >= 15 non-negative queries not string-equal to any alias/name - CO4/CO5 every expectId exists in the gazetteer + start_param-safe charset - CO6 size >= 60 + per-category floors (landmark 12 / abbreviation 15 / glued 6 / typo 12 / alias 12 / negative 6) - CO7 per-category rates printed so suite and runner output agree - CO8 refusal margin pinned: every negative's best two-level fuzzy sim < 0.72, one near-miss in [0.55, 0.72) All 8 cases verified failing (ENOENT corpus fixture) before GREEN.
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.
Overview
arah² is an npm-workspaces monorepo with four 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, four JSON API routes, keeps TTL-only chat sessions in Workers KV, and 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, 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. |
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 — chat_sessions, TTL 900s")]
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 -->|"POST /api/session, /api/location-fallback"| TG
APP -->|"POST /api/session — initData HMAC"| W
APP -->|"GET /api/location — Bearer token"| W
W --> KV
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 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). 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 in this phase. |
/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). |
HTTP surface
The Worker's fetch handler routes exactly four 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. |
# 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 |
Run each package's zero-dependency test suite |
npm run bot:typecheck / npm run web:typecheck / npm run search: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 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. - The persisted claim is exactly four fields — user id, chat id, issued-at, language code — under a 900-second TTL. Minting rotates any prior token for the chat.
- 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.
Repository layout
theme/ @araharah/theme — tokens.css, components.css, theme.css, fonts/, basemap/, scripts/
bot/ @araharah/bot — src/webhook.ts entry, bot/, kv/, security/, i18n/, scripts/, wrangler.jsonc
web/ @araharah/web — src/main.ts entry, location/, map/, session/, telegram/, ui/, mock/
search/ @araharah/search — src/types.ts, src/normalize.ts, src/score.ts, src/gazetteer.ts, src/data/kl-gazetteer.json
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.
