This commit is contained in:
araharah-executor
2026-10-23 19:18:43 +08:00
parent 1890b5b1ca
commit af3edefbc6
9 changed files with 3027 additions and 2 deletions
+216 -2
View File
@@ -1,7 +1,221 @@
# araharah <!-- generated-by: gsd-doc-writer -->
A privacy-respecting, Telegram-first GPS navigator — Rams-inspired instrument-panel design, OpenStreetMap-based routing, KL metro first. # 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 three 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](https://grammy.dev) 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). |
The Worker is the single origin. It answers `/api/*` and `/telegram/*` itself (`run_worker_first`) and serves everything else out of `web/dist`.
```mermaid
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:
```bash
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`.
<!-- VERIFY: Node.js minimum version — no `engines` field or `.nvmrc` exists in the repository; confirm the supported floor with the maintainers. -->
For the bot's local secrets, copy the example file and fill it in:
```bash
cp bot/.dev.vars.example bot/.dev.vars
```
`bot/.dev.vars` is gitignored and must never be committed. It holds `BOT_TOKEN` (from [@BotFather](https://t.me/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.
```bash
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`.
```bash
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:
```bash
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`. |
```bash
# 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 the `@theme static` block, declared `static` so Tailwind v4's tree-shaking cannot drop tokens the MapLibre basemap derivation and plain-CSS consumers need.
```css
/* 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);
}
```
```ts
// build-time inlining of the Rams basemap style (never re-set at runtime)
import ramsStyle from "@araharah/theme/basemap/liberty-rams.json";
```
---
## Design system ## 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. 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.
```bash
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` | Run each package's zero-dependency test suite |
| `npm run bot:typecheck` / `npm run web: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_sessions` KV namespace carries an explicit `expirationTtl` on 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 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.
## 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/
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
Private repository. No license file is present; all rights reserved by the project owner.
<!-- VERIFY: repository licensing — no `LICENSE` file exists in the working tree, and the project is marked `private: true` in `package.json`. -->
+268
View File
@@ -0,0 +1,268 @@
<!-- generated-by: gsd-doc-writer -->
# @araharah/bot
The arah² Telegram bot — **arah2bot**. A [grammY](https://grammy.dev) bot running on a single Cloudflare Worker that receives Telegram updates, keeps TTL-only chat sessions in Workers KV, gates the Mini App's session handshake, and serves the Mini App's static assets from the same origin.
Part of the [arah²](../README.md) monorepo. For the design system see [`theme/`](../theme/), for the Mini App see [`web/`](../web/), and for cross-package architecture see [`docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md).
---
## What it does
Three jobs, all inside one Worker fetch handler (`src/webhook.ts`):
- **Chat commands** — `/start` (`/mula`), `/help`, `/privacy` (`/privasi`) reply with plain text in English or Bahasa Melayu.
- **Session gate** — validates a Mini App's verbatim `Telegram.WebApp.initData` string by HMAC and mints an opaque, 15-minute session token.
- **Location relay** — when the Mini App can't get a fix, the bot sends a `request_location` reply keyboard into the user's own chat; the shared pin lands in KV and the app polls for it.
Privacy is structural rather than promised: every write to KV carries an explicit `expirationTtl`, and the persisted record is exactly the four claim fields plus exactly the three location fields — nothing else can be serialized. A test (`PG1`) greps the storage sources to hold that line.
```mermaid
graph TD
TG["Telegram Bot API"]
W["Cloudflare Worker — araharah-bot"]
BOT["grammY bot — commands + location keyboard"]
SEC["initData HMAC gate"]
KV[("KV chat_sessions — session TTL 900s, pin TTL 300s")]
APP["Mini App — web/dist via ASSETS binding"]
TG -->|"POST /telegram/webhook + secret header"| W
W --> BOT
W --> SEC
SEC -->|"mints opaque token"| KV
BOT -->|"request_location keyboard"| TG
TG -->|"shared pin message"| BOT
BOT -->|"writePin loc:chat_id"| KV
APP -->|"POST /api/session — initData"| W
APP -->|"GET /api/location — Bearer"| W
APP -->|"POST /api/location-fallback — Bearer"| W
W --> KV
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 TG telegram
class W,BOT,SEC worker
class KV storage
class APP web
```
---
## Installation
`@araharah/bot` is private and consumed through the workspace, not installed on its own. From the repo root:
```bash
git clone http://gitmirah:3000/c-fu/arah2.git
cd arah2
npm install
```
Then create the local secrets file (gitignored — never commit it):
```bash
cp bot/.dev.vars.example bot/.dev.vars
```
`bot/.dev.vars` holds three values:
| Variable | Purpose |
|---|---|
| `BOT_TOKEN` | Telegram Bot API token from [@BotFather](https://t.me/BotFather). |
| `WEBHOOK_SECRET` | Secret token Telegram echoes in `X-Telegram-Bot-Api-Secret-Token`. **Must be non-empty** — the webhook fails closed with `500` otherwise. |
| `WEBHOOK_URL` | Full origin for the Worker; `set-webhook.mjs` appends `/telegram/webhook` when only an origin is set. |
<!-- VERIFY: Wrangler authenticates to your Cloudflare account on first `wrangler dev` / `wrangler deploy` — confirm which login flow your team uses (OAuth vs. `CLOUDFLARE_API_TOKEN`). -->
---
## Quick start
1. **Build the workspace** — `web/dist` is served through the Worker's assets binding, so the Mini App must exist first.
```bash
npm run build
```
2. **Run the Worker locally** on port `8787` with local KV emulation.
```bash
npm run dev
```
3. **Check the surface.**
```bash
curl -i http://localhost:8787/api/location
# → 401 {"error":"unauthorized"} (no bearer token)
curl -i -X GET http://localhost:8787/api/session
# → 405 Method not allowed (route exists, method is wrong)
curl -i http://localhost:8787/nope
# → 404 Not found
```
4. **Register the Telegram-side wiring** after deploying: the webhook, the command menu, and the menu button.
```bash
npm run deploy # wrangler deploy + set-webhook.mjs
npm run bot:commands
node scripts/set-menu-button.mjs # points the menu button at the Mini App origin
```
---
## HTTP surface
The `fetch` handler in `src/webhook.ts` routes exactly four paths and returns `404` for everything else. Every log line and every rejection body is **category only** — never a payload, a user id, a chat id, or a timestamp.
| Method | Path | Behaviour |
|---|---|---|
| `POST` | `/telegram/webhook` | grammY's `cloudflare-mod` webhook adapter. Fails closed with `500` when `WEBHOOK_SECRET` is missing or empty — grammY's own secret check silently accepts everything when its secret is `undefined`. Otherwise a constant-time compare of `X-Telegram-Bot-Api-Secret-Token` runs **before** any bot processing or KV touch. The bot is instantiated per request because the token only arrives with `env`. |
| `POST` | `/api/session` | The security gate. The client posts its verbatim initData string, capped at 4096 characters (a lying or absent `Content-Length` is caught by a post-read length check, both reject `413` **before** parsing). HMAC-validated and fresh → a one-time opaque token; rejection precedence is `malformed` → `tampered` → `stale`, each a `401` carrying only the category. Minting rotates any prior token for that chat. |
| `GET` | `/api/location` | Polled by the Mini App with its in-memory `Authorization: Bearer <token>`. The KV key derives from the **claim's** `chat_id`, never from a client-supplied id, so one user's token can never read another's pin. Honest `{"pin":null}` when nothing is stored; `401` for a missing or expired token; `405` for a non-GET. |
| `POST` | `/api/location-fallback` | The "ask via chat" path. The worker asks the bot to send the `request_location` reply keyboard into the chat named by the resolved claim. The request body is never read — neither chat id nor locale is accepted from the client. A rejected Telegram send is a category-only `502`. |
```bash
# Mint a session from a Mini App initData string
curl -X POST http://localhost:8787/api/session --data-binary @initdata.txt
# → {"token":"<opaque uuid>"}
# Poll the relay with that token held in memory
curl -H "Authorization: Bearer <opaque>" http://localhost:8787/api/location
# → {"pin":null}
```
---
## Commands
| Command | Description |
|---|---|
| `/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 what is never stored (location, routes, trip history, profile). |
| *shared pin message* | Not a command — a `message` handler records `ctx.message.location` into KV and confirms. |
Replies are plain text with link previews disabled. There are no inline buttons, callback routes, or reply menus in the command tree — the location reply keyboard in `handlers/location-fallback.ts` is the one deliberate exception, and a test (`CS4`) holds that boundary.
---
## Package scripts
Run from the repo root (`npm run bot:<script>`) or inside `bot/`.
| Script | Description |
|---|---|
| `npm test` | Runs `test/runner.mjs`, a hand-rolled zero-dependency harness. |
| `npm run typecheck` | `tsc --noEmit` against `src/` with Workers types. |
| `npm run dev` | `wrangler dev --port 8787 --local`. |
| `npm run deploy` | `wrangler deploy` followed by `scripts/set-webhook.mjs`. |
| `npm run commands` | `scripts/register-commands.mjs` — one-time `setMyCommands` in the default and `ms` scopes. |
`scripts/set-menu-button.mjs` is run directly (`node scripts/set-menu-button.mjs`) rather than through a package script; it points the bot's menu button at a `web_app` URL derived from `WEBHOOK_URL`'s origin.
The command-menu descriptions are **derived from `src/i18n/strings.ts`**'s `helpMenu` lines, not written inline in the script, so Telegram's registered menu and the `/help` reply cannot drift apart. A missing `/help` or `/privacy` line in `helpMenu` throws rather than silently registering a blank description.
---
## Configuration
The typed binding contract is `Env` in `src/config.ts`:
```ts
export interface Env {
BOT_TOKEN: string; // wrangler secret — from @BotFather
WEBHOOK_SECRET: string; // wrangler secret — must be non-empty; the route fails closed otherwise
chat_sessions: KVNamespace; // TTL-only namespace
}
```
Non-secret settings live in `wrangler.jsonc`:
| Key | Value | Note |
|---|---|---|
| `name` | `araharah-bot` | Worker name. |
| `main` | `src/webhook.ts` | Worker entry point. |
| `compatibility_date` | `2026-10-22` | |
| `workers_dev` | `true` | Serves on the `*.workers.dev` origin — the `WEBHOOK_URL` target for this phase. |
| `assets.directory` | `../web/dist` | Served through the `ASSETS` binding. |
| `assets.not_found_handling` | `none` | Unmatched paths fall through to the Worker, which answers `404`. |
| `run_worker_first` | `["/api/*", "/telegram/*"]` | A **glob array**, not a boolean — the Worker owns these prefixes; everything else is served as a static asset. |
| `kv_namespaces` | binding `chat_sessions` | |
The KV namespace id is committed in `wrangler.jsonc`; it is an identifier, not a secret.
<!-- VERIFY: The deployed `workers.dev` origin that `WEBHOOK_URL` should carry is account-specific and not recorded in the repository — take it from `wrangler deploy` output. -->
---
## Module map
| Path | Responsibility |
|---|---|
| `src/webhook.ts` | Worker entry and the only fetch handler: four routes, the 4 KB initData cap, bearer-token resolution. |
| `src/config.ts` | The `Env` contract — nothing else declares bindings. |
| `src/bot/bot.ts` | `createBot(env)` — the grammY factory; registers every command handler. Per-request instantiation, because Workers have no startup hook and the token arrives with `env`. |
| `src/bot/commands/{start,help,privacy}.ts` | One command surface each, with pure `*ReplyText(locale)` builders so tests don't need a bot instance. |
| `src/bot/handlers/location-fallback.ts` | Sends the `request_location` reply keyboard; records a shared pin on the `message` handler. |
| `src/security/initdata.ts` | Pure HMAC validation of initData. Depends only on the runtime's global `crypto.subtle`, so it runs unchanged in the Worker and in bare-node tests. |
| `src/kv/sessions.ts` | Mint / rotate / resolve session claims. `SESSION_TTL_SECONDS = 900`. |
| `src/kv/location-pin.ts` | Write / read / clear the chat-scoped pin. `PIN_TTL_SECONDS = 300`. |
| `src/i18n/strings.ts` | Every user-facing string, in `en` and `ms`. Nothing inline anywhere else. |
| `scripts/` | One-time Telegram registration: webhook, command menu, menu button. |
| `test/` | Zero-dependency suites plus the runner and registry. |
### KV key scheme
| Key | Value | TTL |
|---|---|---|
| `session:<token>` | JSON `SessionClaim` — `user_id`, `chat_id`, `issued_at`, `language_code` | 900 s |
| `chat:<chat_id>` | The chat's current token (per-chat pointer) | 900 s |
| `loc:<chat_id>` | JSON `LocationPin` — `latitude`, `longitude`, `horizontal_accuracy` | 300 s |
Session TTL is deliberately tied to the 900 s initData freshness window: a session cannot outlive the proof that minted it. Minting rotates — the previous `session:<token>` is deleted before the new record is written, then a best-effort post-mint reconciliation re-reads the pointer and sweeps a racing mint's record. Under KV's lack of transactions, a record orphaned by a lost race is invisible to both sweeps and simply dies with its TTL; nothing persists longer.
### initData validation
`validateInitData(raw, botToken, opts?)` parses the Telegram-style querystring manually — values are decoded once and `+` stays literal, so `URLSearchParams` is deliberately not used. The data-check string excludes **only** `hash` (the `signature` field participates; excluding it broke real webviews), sorts by field name, and joins with newlines. The key is derived with `HMAC-SHA256(key="WebAppData", message=botToken)` and compared in constant time. `auth_date` older than `maxAgeSeconds` (default 900) is `stale`; `nowSeconds` is injectable so freshness boundaries are testable at 899/900/901 s.
---
## Testing
The suite is a hand-rolled harness — no test framework dependency. `test/harness.mjs` exports a `test(name, fn)` registry; `test/runner.mjs` auto-discovers every `*.test.mjs` in `test/`, imports them, and prints `PASS`/`FAIL` per case with a summary line. A suite that fails to load is recorded as one failing case rather than crashing the run. Exit code is `0` only when every case passed.
```bash
npm run bot:test # from the repo root
npm test # from bot/
```
| Suite | Cases | Covers |
|---|---|---|
| `test/initdata.test.mjs` | 12 | HMAC vectors (RFC-4231 case 1), field ordering, the `signature` erratum, freshness boundaries, malformed shapes. |
| `test/sessions.test.mjs` | 7 | Exact claim key-set, TTL on every `put`, rotation, interleaved-mint behaviour, claim round-trip. |
| `test/location-pin.test.mjs` | 14 | TTL-carrying key, out-of-range coordinate rejection, malformed-JSON reads, plus the full `/api/location` and `/api/location-fallback` route surface including own-chat-only access and locale honouring. |
| `test/webhook.test.mjs` | 6 | Valid initData → token, the 4 KB cap (declared, lying, and unreadable bodies), `405` on `GET /api/session`, fail-closed `500` on an unarmed `WEBHOOK_SECRET`. |
| `test/commands.test.mjs` | 9 | Locale key-set parity, `pickLocale` fallback, privacy copy honesty, the text-only surface gate, `helpMenu`/menu derivation, and the exact command stack. |
| `test/privacy.test.mjs` | 2 | Grep gates: every `.put(` in a storage source carries `expirationTtl` on the same line; storage modules export nothing persistence-shaped. |
Typecheck separately:
```bash
npm run bot:typecheck
```
---
## Notes
- **Secrets never appear in code or logs.** `BOT_TOKEN` and `WEBHOOK_SECRET` are wrangler secrets locally and in production; `bot/.dev.vars` is gitignored. The scripts read it but only ever print Telegram's `ok` / `error_code` / `description`.
- **Logging is category-only by construction.** `"[initdata] rejected: tampered"`, `"[location] rejected: unauthorized"` — never the payload, the chat id, or a timestamp.
- **`not_found_handling: "none"` matters.** With the default, unmatched paths would be served as a 404 *asset* and the Worker's own `404` branch would be unreachable.
- **`run_worker_first` is a glob array.** Passing a boolean silently breaks the route split.
- **`requestLocationFallback` has exactly one caller** (`handleLocationFallback` in `src/webhook.ts`). A second caller would be a design change, not a refactor.
+502
View File
@@ -0,0 +1,502 @@
<!-- generated-by: gsd-doc-writer -->
# 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). All server state
lives in a TTL-only Cloudflare KV namespace, 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 **three-workspace npm monorepo in a layered/hexagonal
arrangement**: a stateless edge worker with a KV-backed repository layer, a
framework-free TypeScript client with its own repository + adapter layers, and a pure
build-time token/cartography package that both consume.
```mermaid
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
A["Telegram client<br/>chat + Mini App webview"]:::client
B["@araharah/bot Worker<br/>src/webhook.ts"]:::worker
C["grammY handlers<br/>commands + location-fallback"]:::worker
D["Security<br/>initdata.ts HMAC"]:::worker
E["KV chat_sessions<br/>TTL-only"]:::store
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
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
C --> E
C -->|"sendMessage reply keyboard"| A
F -->|"POST /api/session initData"| B
F -->|"GET /api/location Bearer"| B
F -->|"POST /api/location-fallback"| B
G --> F
H --> G
F --> H
H --> I
J -->|"compiled CSS + inlined style JSON"| F
```
## Component Diagram
```mermaid
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 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/shell.css"]:::wsfill
W7["dist/ (served)"]:::wsfill
W1 --> W2
W1 --> W3
W1 --> W4
W1 --> W5
W1 --> W7
W4 --> W7
W5 --> W7
end
subgraph BOT["bot · @araharah/bot"]
direction TB
B1["src/webhook.ts"]:::botfill
B2["src/security/initdata.ts"]:::botfill
B3["src/bot/"]:::botfill
B4["src/kv/"]:::botfill
B5["src/config.ts Env"]:::botfill
B1 --> B2
B1 --> B3
B1 --> B4
B1 --> B5
end
KV[("KV namespace chat_sessions<br/>session:* 900s · chat:* · loc:* 300s")]:::store
TG["Telegram Bot API"]:::ext
OFM["OpenFreeMap tiles"]:::ext
end
WEB -->|"workspace dep @araharah/theme"| THEME
BOT -->|"assets binding ../web/dist"| WEB
B1 --> KV
B4 --> KV
B3 <-->|"webhookCallback / sendMessage"| TG
W3 <-->|"chat pin"| TG
W4 --> OFM
```
## Workspaces Dependency Graph
```mermaid
graph TD
classDef pkg fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A
classDef dep fill:#736B1E,stroke:#4A440F,stroke-width:2px,color:#FFF8F0
classDef out fill:#D9D2C6,stroke:#736B1E,stroke-width:2px,color:#1A1A1A
ROOT["araharah (private root)"]:::out
TH["@araharah/theme"]:::pkg
BT["@araharah/bot"]:::pkg
WB["@araharah/web"]:::pkg
KVS[("KV chat_sessions")]:::dep
TGAPI["Telegram Bot API"]:::dep
ROOT -->|"workspaces"| TH
ROOT -->|"workspaces"| BT
ROOT -->|"workspaces"| WB
WB -->|"dependencies: @araharah/theme *"| TH
BT -->|"static assets ../web/dist"| WB
BT --> KVS
BT --> TGAPI
WB -->|"api routes"| BT
```
`@araharah/web` declares `"@araharah/theme": "*"` — the only workspace-to-workspace
runtime import in the repo (`@araharah/theme/dist/rams.css` in `web/src/shell.css`,
`@araharah/theme/basemap/liberty-rams.json` in `web/src/map/map.ts`). The bot has no
workspace dependency; 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`.
## 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)
```
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)
```mermaid
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
```mermaid
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)
```mermaid
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.
### 5. 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 |
| * | 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.
## Build & Deploy Topology
```mermaid
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
AR1["theme/dist/rams.css + fonts"]:::art
AR2["web/dist (index.html, hashed assets,<br/>inlined liberty-rams style)"]:::art
AR3["web/dist-harness (never deployed)"]:::art
EDGE["Cloudflare Worker araharah-bot<br/>[assets] ../web/dist"]:::cloud
KVS[("KV chat_sessions")]:::store
T1 --> W1
T2 --> W1
T1 --> AR1
W1 --> AR2
W2 --> AR3
AR2 --> EDGE
B1 --> EDGE
B1 --> B2
EDGE --> KVS
```
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 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__`.
<!-- VERIFY: production Worker hostname and KV namespace id come from bot/wrangler.jsonc and bot/.dev.vars, both environment-specific. -->
## Key Abstractions
### `@araharah/bot`
| Abstraction | File | Description |
| --- | --- | --- |
| `Env` | `bot/src/config.ts` | Typed contract: `BOT_TOKEN`, `WEBHOOK_SECRET` (must be non-empty — the webhook fails closed without it), `chat_sessions: KVNamespace`. |
| `default { fetch }` | `bot/src/webhook.ts` | The `ExportedHandler<Env>` — four routes, 404 otherwise. Bot is constructed per request. |
| `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. |
| `createBot(env, opts)` | `bot/src/bot/bot.ts` | grammY factory; wires `/start` + `/mula`, `/help`, `/privacy` + `/privasi`, and the location-fallback message handler. |
| `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 — lets tests pass a fake without depending on Workers types. |
### `@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. |
| `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` | Telegram light/dark signal → `data-tg-theme` slot switch. MapLibre is deliberately absent. |
| `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 — at the root state a click closes the app; no no-op handler. |
| `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 — `@import`s tailwindcss, tokens, components; declares eight self-hosted `@font-face` rules; defines the `--shell-bg` / `--shell-on-surface` theme slots. |
| Component classes | `theme/src/components.css` | `.card`, `.btn-primary`, `.btn-secondary`, `.chip-*`, `.maneuver-*`, `.text-*`, `.toggle*`, `.input-field`, `.checkbox`. |
| `build.mjs` | `theme/basemap/build.mjs` | Forks OpenFreeMap `liberty`, deriving every cartographic color from `tokens.css` via `desaturate(tokenHex, satMultiplier, lightnessPerRole)` with saturation clamped to `[0.06, 0.12]`. |
| `liberty-rams.json` | `theme/basemap/liberty-rams.json` | The generated style. Accent guard fails the build if `#EB8008` / `#ED3F1C` / `#736B1E` leak into any layer. `building-3d` is removed (faux 3D). |
## Directory Structure Rationale
```
araharah/
├── package.json # private root; workspaces + build ordering only
├── bot/ # @araharah/bot — the Cloudflare Worker
│ ├── src/
│ │ ├── webhook.ts # the single fetch handler; four routes
│ │ ├── config.ts # Env contract
│ │ ├── bot/ # grammY factory, commands/, handlers/
│ │ ├── security/ # initdata.ts — pure, isolation-free crypto
│ │ ├── kv/ # sessions.ts, location-pin.ts — TTL-only repos
│ │ └── 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, KV
├── 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
│ │ ├── telegram/ # webapp, theme, backbutton, safe-area
│ │ ├── 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
│ ├── test/ # runner + gate suites (copy, storage, artifact, chain)
│ ├── scripts/capture.mjs # screenshot capture
│ └── vite.config.ts # dual-build dist/ vs dist-harness/
├── 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
├── 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` and the secret-token gate
apply uniformly.
- **`bot/src/security/` and `bot/src/kv/` 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.
- **`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 matches the `SessionClaim` fields in
`sessions.ts`.
- **`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/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.
- **`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
Two grep gates pin this rather than documenting it:
- `bot/test/privacy.test.mjs` — every `put` on `chat_sessions` must carry an explicit
`expirationTtl` on the same call line. 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`.
The persisted surface is exactly `SessionClaim` (user_id, chat_id, issued_at,
language_code) for 900 s and `LocationPin` (latitude, longitude, horizontal_accuracy)
for 300 s. The client holds the token and the last fix in module scope only.
### Access control
The `chat_id` used by `/api/location` and `/api/location-fallback` is always resolved
from the session claim, and the locale from `claim.language_code`. The request body of
the fallback route is never read — no chat id or language can arrive from the client.
### 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.
### Logging
The Worker's only log statements are category-only — `malformed`, `tampered`, `stale`,
`unauthorized`, `misconfigured`, `telegram-send`. Never raw payloads, never user ids,
never timestamps. Rejection responses carry the category only.
### Testing
Both `bot/test` and `web/test` 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. Gates are grep/introspection assertions
against source and build artifacts, not just behavioral assertions.
## What Is Not Built Yet
The following appear in `IDEAS.md` as roadmap items and are **not** in the codebase:
routing engines (Valhalla/OSRM), Neon/Postgres, RabbitMQ, Prometheus/Grafana, Resend,
Cloudflare R2, Photon geocoding. The current system is exactly the two implemented
phases: the bot skeleton with initData-gated sessions and TTL-only KV, and the Mini App
shell with the three-link location chain and the Rams basemap. Routing/guidance is
Phase 5+.
+259
View File
@@ -0,0 +1,259 @@
<!-- generated-by: gsd-doc-writer -->
# Configuration
arah² is an npm-workspaces monorepo with three workspaces — `theme`, `bot`, `web` — plus a root
`package.json` that composes their scripts. There is **no committed `.env` file**: secrets live in
`bot/.dev.vars` (gitignored) for local work and in Cloudflare Workers secrets for deployment.
Configuration surfaces, by kind:
| Surface | File | Applies to |
| --- | --- | --- |
| Workspaces + scripts | `package.json` | all |
| Worker deployment + bindings | `bot/wrangler.jsonc` | bot |
| Secret env contract | `bot/.dev.vars` (from `.dev.vars.example`) | bot |
| Worker env typing | `bot/src/config.ts` | bot |
| Local secrets template | `bot/.dev.vars.example` | bot |
| Bundler config | `web/vite.config.ts` | web |
| TypeScript config | `bot/tsconfig.json`, `web/tsconfig.json` | bot, web |
| Design tokens (source of truth) | `theme/src/tokens.css` → `theme/src/theme.css` | theme + everything downstream |
| Ignore rules | `.gitignore`, `bot/.gitignore` | repo |
---
## Environment variables
Three variables reach the Worker (`bot/src/config.ts` declares them as a typed `Env` interface);
a fourth (`WEBHOOK_URL`) is consumed only by the one-time registration scripts, never by the Worker.
| Variable | Required | Default | Description |
| --- | --- | --- | --- |
| `BOT_TOKEN` | **Required** | none | Telegram Bot API token from @BotFather. Used by `createBot()` in `bot/src/bot/bot.ts` to construct the grammY `Bot`, and by `validateInitData()` in `bot/src/security/initdata.ts` as the HMAC key for Mini App `initData` verification. |
| `WEBHOOK_SECRET` | **Required** | none | Value Telegram echoes in the `X-Telegram-Bot-Api-Secret-Token` header on every update. `/telegram/webhook` constant-time-compares it **before** any bot processing or KV access. If missing or empty at request time the route fails closed with `500 Server misconfigured` — grammY's secret check would otherwise accept every request. |
| `WEBHOOK_URL` | **Required for the one-time scripts** | none | Full origin + path of the deployed Worker, used by `bot/scripts/set-webhook.mjs` and `bot/scripts/set-menu-button.mjs`. If the value points at the workers.dev root (no path), `set-webhook.mjs` appends `/telegram/webhook` automatically. The Worker never reads this variable. |
| `chat_sessions` | **Required (binding)** | n/a | Not an env var but a Cloudflare KV namespace binding. Wired in `bot/wrangler.jsonc` under `kv_namespaces`; every `kv.put()` in `bot/src/kv/sessions.ts` and `bot/src/kv/location-pin.ts` writes through it with an explicit `expirationTtl`. |
Local secret template — `bot/.dev.vars.example`:
```bash
# Copy to .dev.vars and fill for local wrangler dev. NEVER commit .dev.vars.
BOT_TOKEN=
WEBHOOK_SECRET=
# Full origin + path; set-webhook.mjs auto-appends /telegram/webhook if only an origin is set.
WEBHOOK_URL=
```
Script-level precedence: every script in `bot/scripts/` resolves a variable as
`process.env.<NAME> ?? devVars.<NAME>` — shell environment wins over the file, and the file is the
fallback. `set-webhook.mjs` requires all three of `BOT_TOKEN`, `WEBHOOK_URL`, `WEBHOOK_SECRET` and
exits `1` listing whichever are missing. `register-commands.mjs` requires only `BOT_TOKEN`.
`set-menu-button.mjs` requires `BOT_TOKEN` and `WEBHOOK_URL`.
<!-- VERIFY: the production secret names registered via `wrangler secret put` and the Cloudflare account they live under cannot be read from the repository. -->
---
## Configuration flow
```mermaid
flowchart TD
subgraph Local["Local development"]
DEVVARS["bot/.dev.vars (gitignored)"]
WRANGLERDEV["wrangler dev --port 8787 --local"]
end
subgraph Scripts["One-time Telegram registration"]
SC1["scripts/set-webhook.mjs"]
SC2["scripts/register-commands.mjs"]
SC3["scripts/set-menu-button.mjs"]
end
subgraph Production["Deployed Worker"]
SECRETS["wrangler secrets: BOT_TOKEN, WEBHOOK_SECRET"]
KV["KV namespace binding: chat_sessions"]
ASSETS["Static assets: ../web/dist"]
WORKER["src/webhook.ts fetch handler"]
end
DEVVARS --> WRANGLERDEV
DEVVARS --> SC1
DEVVARS --> SC2
DEVVARS --> SC3
SECRETS --> WORKER
KV --> WORKER
ASSETS --> WORKER
WRANGLERDEV --> WORKER
style Local fill:#D9D2C6,stroke:#1A1A1A,color:#1A1A1A
style Scripts fill:#D9D2C6,stroke:#1A1A1A,color:#1A1A1A
style Production fill:#D9D2C6,stroke:#1A1A1A,color:#1A1A1A
style DEVVARS fill:#EB8008,stroke:#1A1A1A,color:#1A1A1A
style SECRETS fill:#ED3F1C,stroke:#1A1A1A,color:#FFFFFF
```
The Mini App in `web/` has **no build-time or runtime configuration surface**. It calls its own
origin with relative paths (`/api/session`, `/api/location`, `/api/location-fallback`), so the API
base URL is implied by wherever the bundle is served from — which is why the Worker serves
`../web/dist` as its own static assets and reserves `/api/*` and `/telegram/*` for itself.
---
## Required vs optional settings
**Fail loudly (Worker):**
- `WEBHOOK_SECRET` — the only setting with an explicit runtime guard, at `bot/src/webhook.ts:65`:
`if (!env.WEBHOOK_SECRET)` logs `[webhook] rejected: misconfigured` and returns
`500 Server misconfigured`. The rationale is documented in the file: grammY's `compareSecretToken`
silently accepts every request when the secret is `undefined`, so an unarmed binding must never
reach it.
- `BOT_TOKEN` — declared required in the `Env` type but not guarded at runtime; a missing token
surfaces as a grammY authentication failure on the first request that instantiates the bot.
**Fail loudly (scripts):** `set-webhook.mjs`, `register-commands.mjs`, and `set-menu-button.mjs` each
print a missing-variable list to stderr and `process.exit(1)` before any network call.
**With defaults / derived:**
- `KV namespace id` — `bot/wrangler.jsonc` already carries a concrete id for `chat_sessions`; the
file's leading comment notes it was filled in after `npx wrangler kv namespace create chat_sessions`.
- `WEBHOOK_URL` path segment — auto-derived by `set-webhook.mjs` when the configured origin has no path.
- KV TTLs — constants in source, not configuration: `SESSION_TTL_SECONDS = 900`
(`bot/src/kv/sessions.ts`) and `PIN_TTL_SECONDS = 300` (`bot/src/kv/location-pin.ts`). Both are
pinned by a grep gate in the test suite so no write path can persist without a TTL.
---
## Worker configuration file (`bot/wrangler.jsonc`)
```jsonc
{
// KV namespace id is filled in plan 03 task 1 after `npx wrangler kv namespace create chat_sessions`
"name": "araharah-bot",
"main": "src/webhook.ts",
"compatibility_date": "2026-10-22",
"workers_dev": true,
"assets": {
"directory": "../web/dist",
"binding": "ASSETS",
"not_found_handling": "none"
},
"run_worker_first": ["/api/*", "/telegram/*"],
"kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }]
}
```
Key notes:
- `assets.directory: "../web/dist"` — the Worker serves the Mini App bundle itself, so `npm run build`
(theme then web) must run before `wrangler deploy`; a stale or absent `web/dist` ships no Mini App.
- `run_worker_first` is a **glob array**, not a boolean: `/api/*` and `/telegram/*` are handled by
`src/webhook.ts` first; everything else falls through to the static asset binding.
- `not_found_handling: "none"` — unmatched paths are not served the SPA `index.html` fallback.
- `workers_dev: true` serves on the `<name>.<account>.workers.dev` URL, which is the `WEBHOOK_URL`
target.
<!-- VERIFY: the Cloudflare account subdomain backing `araharah-bot.<account>.workers.dev` is not recorded in the repository. -->
---
## Build and run scripts
Root `package.json` (workspaces: `theme`, `bot`, `web`; all packages are `"private": true`, ESM via
`"type": "module"`):
| Command | Description |
| --- | --- |
| `npm run build` | Builds the theme then the web bundle (`theme` → `web`). |
| `npm run deploy` | `build`, then `npm run deploy -w bot` (`wrangler deploy` + `set-webhook.mjs`). |
| `npm run dev` | `build`, then `npm run dev -w bot` (`wrangler dev --port 8787 --local`). |
| `npm run bot:test` / `bot:typecheck` / `bot:dev` / `bot:deploy` / `bot:commands` | Per-workspace passthroughs to the `bot` package. |
| `npm run web:test` / `web:typecheck` / `web:dev` | Passthroughs to the `web` package. |
| `npm run web:build` | Same as `build`; `web:build:harness` runs the harness-mode build; `web:capture` runs the screenshot harness. |
| `npm run sheet` / `gallery` / `audit` / `capture` | Passthroughs to the `theme` design-system tooling. |
Workspace-level commands worth knowing:
| Command | Where | Notes |
| --- | --- | --- |
| `npm test` | `bot`, `web` | Both run `node test/runner.mjs` (a hand-rolled runner, no external test framework). |
| `npm run typecheck` | `bot`, `web` | `tsc --noEmit`, `strict` plus `noUncheckedIndexedAccess`. |
| `npm run dev` | `bot` | `wrangler dev --port 8787 --local` — reads `bot/.dev.vars`. |
| `npm run deploy` | `bot` | `wrangler deploy && node scripts/set-webhook.mjs`. |
| `npm run commands` | `bot` | `node scripts/register-commands.mjs` — `setMyCommands` in default scope and in `language_code: "ms"`; descriptions are derived from `src/i18n/strings.ts` `helpMenu` lines so the menu and `/help` reply cannot drift. |
| `npm run build` | `web` | `vite build` → `web/dist`. |
| `npm run build:harness` | `web` | `vite build --mode harness` → `web/dist-harness`, with mock installer and fetch stubs. |
| `npm run dev` | `web` | `vite` dev server on port 5173 (`strictPort: true`). |
| `npm run capture` | `web` | `node scripts/capture.mjs` — serves `web/dist-harness` and screenshots the boot/live/denied states. |
| `npm run build` | `theme` | `tailwindcss -i ./src/theme.css -o ./dist/rams.css --minify`. |
| `npm run audit` | `theme` | `node ./scripts/audit.mjs ./dist/rams.css` — flags any rendered color/spacing/typography/radius/shadow/blur value not derived from `src/tokens.css`. Exits `0` clean, `1` violations, `2` drift. |
| `npm run basemap` | `theme` | `node basemap/build.mjs` — forks OpenFreeMap `liberty` into `theme/basemap/liberty-rams.json`, deriving every cartographic value from the token file. |
| `npm run gallery` / `sheet` / `capture` | `theme` | Design tooling; `gallery` serves on `http://localhost:8123` via `python3 -m http.server 8123`. |
---
## Bundler configuration (`web/vite.config.ts`)
Vite is configured from a function of `mode`, with one switch driving a **dual-build contract**:
- `__ARAHARAH_HARNESS__` is injected via `define` as `true` only in `--mode harness` and `false`
everywhere else. Vite replaces the identifier textually, so the production bundle tree-shakes the
mock installer and `/api/*` fetch stubs out entirely; `web/index.html` carries its mock profile
block inside the same guarded branch.
- `build.outDir` is `dist-harness` in harness mode, `dist` otherwise, with `emptyOutDir: true` and
`target: "es2022"`. The two outputs must never share a directory — the file's own comment calls
that out as the reason the split exists.
- `base: "/"`, dev server on port 5173 with `strictPort: true`.
- `__ARAHARAH_HARNESS__` is declared ambient in `web/src/vite-env.d.ts` so `tsc --noEmit` passes in
both modes.
- Harness-only URL parameters (documented in `web/index.html`): `?mock=android|ios|tdesktop|macos|web`,
`?scheme=light|dark`, `?pin=1`, `?locale=en|ms`, `?sessiondelay=<ms>`, `?fixdelay=<ms>`.
---
## Design token configuration
`theme/src/tokens.css` is the single source of truth for every color, spacing, typography, and radius
value. `theme/src/theme.css` imports Tailwind v4 plus `tokens.css` and `components.css`, declares
self-hosted Geist and Space Grotesk `@font-face` rules from `theme/fonts/` (no Google Fonts CDN), and
scopes class generation with explicit `@source` lines — the four mockup markup files under
`stitch_minimalist_route_navigator/` are the only allowed utility sources, with
`@source not "../scripts"` and `@source not "../basemap"` excluding prose.
Downstream consumers:
- `web/src/shell.css` — `@import "@araharah/theme/dist/rams.css";`
- `web/src/map/map.ts` — `import ramsStyle from "@araharah/theme/basemap/liberty-rams.json";`
- `theme/basemap/liberty-rams.json` — the generated basemap style; its source URL, sprite, and glyph
endpoints point at `https://tiles.openfreemap.org/...`.
---
## Ignore rules and generated output
`.gitignore` at the repo root excludes `.planning/`, `node_modules/`, `dist/` (which covers both
`theme/dist/` and `web/dist/`), `web/dist-harness/`, `theme/screenshots/`, `web/screenshots/`,
`.env`, `.env.*` (with `!*.example`), and `*:Zone.Identifier`. `bot/.gitignore` adds `.dev.vars` and
`.wrangler/`.
Practical consequence: **`web/dist` and `theme/dist` are never committed.** Both must be rebuilt from
source before a deploy, which is why `npm run deploy` and `npm run dev` both run `build` first.
---
## Per-environment overrides
There are no committed `.env.development`, `.env.production`, or `.env.test` files. The separation
is structural rather than file-based:
| Environment | Configuration source | How it differs |
| --- | --- | --- |
| Local `wrangler dev` | `bot/.dev.vars` | Secrets read from the gitignored file; KV emulated locally under `bot/.wrangler/`; Mini App served from `web/dist`. |
| Local `vite dev` | none | Vite dev server on 5173 with no Worker behind it — `main.ts` falls back to the mock WebApp via `import.meta.env.DEV`. |
| Harness capture | `vite build --mode harness` | `web/dist-harness` plus `web/index.html`'s URL-parameter profile. Never deployed. |
| Production | `wrangler.jsonc` + Cloudflare secrets | Secrets set with `wrangler secret put` rather than a file; assets come from the uploaded `../web/dist`; KV resolves to the real namespace. |
`@araharah/theme` and `@araharah/web` are consumed by relative path within the workspace, so there is
no registry token or publish step to configure.
+462
View File
@@ -0,0 +1,462 @@
<!-- generated-by: gsd-doc-writer -->
# arah² — Development Guide
Contributor-facing reference for working on arah² day to day: local setup, the
per-workspace dev loop, the build commands, the code style the project actually
enforces, branch and commit conventions, and the project-specific gates you must
not break.
For *using* the app, see the [README](../README.md). For *how the system is put
together*, see [ARCHITECTURE.md](./ARCHITECTURE.md). For *what to configure*
(secrets, `wrangler.jsonc`, env vars), see [CONFIGURATION.md](./CONFIGURATION.md).
---
## Local setup
### Prerequisites
| Requirement | Notes |
| --- | --- |
| Node.js + npm | No `engines` field and no `.nvmrc`/`.node-version` exist in the repo. The README says to use a current Node.js LTS. <!-- VERIFY: the actual minimum Node.js version — nothing in the repository pins it; confirm with maintainers. --> |
| npm with workspace support | npm 7+ required for `workspaces`. The repo uses npm exclusively — `package-lock.json` is committed and there is no `pnpm-lock.yaml` or `yarn.lock`. |
| A Cloudflare account | `wrangler` authenticates on first use via an interactive browser login. Needed for `bot:dev`, `bot:deploy`, and KV access. |
| A Telegram bot token | From @BotFather. Required by `bot:dev` and by every script in `bot/scripts/`. |
| Python 3 | Only for `theme`'s `gallery` / `basemap:preview`, which serve on port `8123` via `python3 -m http.server 8123`. |
| Network access | Fonts, tiles, and the Play CDN in the mockup reference pages are all fetched over the network; the capture harnesses need it. |
### Clone and install
```bash
git clone http://gitmirah:3000/c-fu/arah2.git
cd arah2
npm install
```
`npm install` at the root installs all three workspaces (`theme`, `bot`, `web`) at
once — npm hoists shared deps into the root `node_modules/`. There is no
per-workspace `npm install` step.
### Configure local secrets
There is no committed `.env`. The Worker reads secrets from `bot/.dev.vars`
(gitignored via `bot/.gitignore`):
```bash
cp bot/.dev.vars.example bot/.dev.vars
# then edit bot/.dev.vars and fill in BOT_TOKEN, WEBHOOK_SECRET, WEBHOOK_URL
```
Full details, including the `process.env` vs `.dev.vars` precedence rule, are in
[CONFIGURATION.md](./CONFIGURATION.md#environment-variables).
### Pre-build before first run
Both `npm run dev` and `npm run deploy` run the build first, but building by hand
first gives you faster feedback:
```bash
npm run build # theme -> web (this is the deployable artifact)
```
---
## The dev loop
```mermaid
graph TD
classDef edit fill:#EB8008,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
classDef check fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
classDef gate fill:#ED3F1C,stroke:#1A1A1A,stroke-width:2px,color:#FFFFFF
EDIT["Edit source<br/>bot/src, web/src, theme/src"]:::edit
TC["npm run bot:typecheck<br/>npm run web:typecheck<br/>tsc --noEmit"]:::check
T1["npm run bot:test<br/>node test/runner.mjs"]:::check
T2["npm run web:test<br/>node test/runner.mjs"]:::check
AU["npm run audit<br/>off-token CSS audit"]:::check
BUILD["npm run build<br/>theme then web"]:::check
DEV1["npm run bot:dev<br/>wrangler dev :8787"]:::edit
DEV2["npm run web:dev<br/>vite :5173"]:::edit
HARN["npm run web:build:harness<br/>+ npm run web:capture"]:::check
COMMIT["git commit<br/>type(phase): subject"]:::gate
EDIT --> TC
EDIT --> T1
EDIT --> T2
EDIT --> AU
TC --> BUILD
T1 --> BUILD
T2 --> BUILD
AU --> BUILD
BUILD --> DEV1
BUILD --> DEV2
BUILD --> HARN
DEV1 --> COMMIT
DEV2 --> COMMIT
HARN --> COMMIT
COMMIT --> EDIT
```
### Bot workspace (`@araharah/bot`)
```bash
npm run bot:dev # wrangler dev --port 8787 --local
```
Runs the Worker on <http://localhost:8787>. `--local` keeps everything on
Miniflare: KV is emulated under `bot/.wrangler/` (gitignored) and nothing touches
the production namespace. Secrets come from `bot/.dev.vars`.
The Worker serves both the API and the Mini App static assets, so after changing
anything under `web/` you must rebuild for `wrangler dev` to serve the new
bundle:
```bash
npm run build && npm run bot:dev
```
### Web workspace (`@araharah/web`)
```bash
npm run web:dev # vite dev server on http://localhost:5173
```
Vite is configured with `port: 5173` and `strictPort: true`, so it will fail
rather than silently pick 5174.
Important: **there is no Worker behind the Vite dev server.** `main.ts` calls
relative paths (`/api/session`, `/api/location`), which 404 against plain Vite.
For interactive work against a real API, either run `bot:dev` and open
<http://localhost:8787> (the Worker serves the built bundle), or use the harness
below. In `import.meta.env.DEV`, `main.ts` falls back to the mock Telegram
WebApp so the shell still renders.
### Harness (Mock-Telegram) workflow
The Mini App's UI can be driven without Telegram at all. The harness build swaps
in `web/src/mock/telegram-mock.ts` plus `/api/*` fetch stubs:
```bash
npm run web:build:harness # vite build --mode harness -> web/dist-harness/
npm run web:capture # Playwright screenshots of every boot/live/denied state
```
`npm run web:capture` serves `web/dist-harness` (never `web/dist` — the harness
block is dead-code-eliminated from the production build) and writes
`web/screenshots/*.png` (gitignored). It waits on the `RAMS-BASEMAP-READY` and
`ARAHARAH-LIVE` console signals and runs the GEO-03 runtime storage probe in-page
before declaring success.
Harness URL parameters, documented in `web/index.html`:
| Parameter | Values | Effect |
| --- | --- | --- |
| `?mock=` | `android`, `ios`, `tdesktop`, `macos`, `web` | Which Telegram client surface to simulate |
| `?scheme=` | `light`, `dark` | Telegram color scheme |
| `?pin=` | `1` | Drive the chat-pin relay link (`?pin=1` stubs `/api/location`) |
| `?locale=` | `en`, `ms` | Force a locale |
| `?sessiondelay=` | milliseconds | Delay the session handshake |
| `?fixdelay=` | milliseconds | Delay the location fix |
### Theme workspace (`@araharah/theme`)
```bash
npm run build -w theme # src/theme.css -> dist/rams.css (minified)
npm run sheet # build, then open theme/src/sheet.html
npm run gallery # build + capture, then serve gallery.html + basemap on :8123
npm run audit # off-token audit of the rendered CSS
```
`npm run audit` and `npm run capture` are the two pieces of design-system
evidence tooling. `capture` renders `sheet.html` and the four read-only mockups
via Playwright and writes `theme/screenshots/` (gitignored). `gallery` blocks on
`python3 -m http.server 8123` serving the theme workspace, so stop it with
`Ctrl-C` when done.
Note that `npm run gallery` ends with a **blocking** `basemap:preview` server —
it will appear to hang. That is expected.
### Registering bot commands
The Telegram `/` menu is persistent server state, not something the Worker pushes.
Register it explicitly after adding or renaming a command:
```bash
npm run bot:commands # node scripts/register-commands.mjs
```
`bot/scripts/register-commands.mjs` reads `BOT_TOKEN` (from `process.env`, else
from `bot/.dev.vars`), derives each description from the `helpMenu` lines in
`bot/src/i18n/strings.ts`, and makes exactly two `setMyCommands` calls — one for
the default scope (English) and one for `language_code: "ms"` (Malay). Because
the descriptions are *derived* from the same strings `/help` replies with, the
menu and the reply cannot drift.
If you add a command, you must add it to the `["help", "privacy"]` array in
`menuFor()` **and** to the corresponding `helpMenu` lines — the script throws
`helpMenu for "<locale>" is missing its /<command> line (D-03 copy drift)` if the
two disagree. The gate `CS5 D-03: register-commands derives both scopes from
helpMenu` in `bot/test/commands.test.mjs` enforces this.
Only re-run this when the command *list or its descriptions* change. Repointing
the webhook is a separate script (`set-webhook.mjs`), run automatically by
`npm run deploy`.
---
## Build and check commands
### Root scripts (`package.json`)
| Command | Description |
| --- | --- |
| `npm run build` | Build `theme`, then `web`. Produces the deployable artifact. |
| `npm run dev` | `build`, then `bot:dev` — the one-command local app loop. |
| `npm run deploy` | `build`, then `bot:deploy` (`wrangler deploy` + `set-webhook.mjs`). |
| `npm run bot:test` | `npm test -w bot` — the bot's hand-rolled suite. |
| `npm run bot:typecheck` | `tsc --noEmit` in `bot`. |
| `npm run bot:dev` | `npm run dev -w bot` — `wrangler dev --port 8787 --local`. |
| `npm run bot:deploy` | `wrangler deploy && node scripts/set-webhook.mjs`. |
| `npm run bot:commands` | Register the Telegram command menu (EN + MS). |
| `npm run web:test` | `npm test -w web` — the Mini App suite. |
| `npm run web:typecheck` | `tsc --noEmit` in `web`. |
| `npm run web:dev` | `npm run dev -w web` — Vite dev server on 5173. |
| `npm run web:build` | Alias of `build` (theme then web). |
| `npm run web:build:harness` | Harness-mode build into `web/dist-harness/`. |
| `npm run web:capture` | Playwright screenshots of the Mini App states. |
| `npm run sheet` | Passthrough to the theme workspace. |
| `npm run gallery` | Passthrough to the theme workspace. |
| `npm run audit` | Passthrough to the theme workspace — off-token CSS audit. |
| `npm run capture` | Passthrough to the theme workspace — token-sheet screenshots. |
### Workspace-local scripts
| Command | Where | Description |
| --- | --- | --- |
| `npm test` | `bot`, `web` | `node test/runner.mjs` — zero-dependency, auto-discovers `*.test.mjs`. |
| `npm run typecheck` | `bot`, `web` | `tsc --noEmit`, `strict` + `noUncheckedIndexedAccess`. |
| `npm run dev` | `bot` | `wrangler dev --port 8787 --local`. |
| `npm run deploy` | `bot` | `wrangler deploy && node scripts/set-webhook.mjs`. |
| `npm run commands` | `bot` | `node scripts/register-commands.mjs`. |
| `npm run build` | `web` | `vite build` → `web/dist`. |
| `npm run build:harness` | `web` | `vite build --mode harness` → `web/dist-harness`. |
| `npm run capture` | `web` | `node scripts/capture.mjs`. |
| `npm run build` | `theme` | `tailwindcss -i ./src/theme.css -o ./dist/rams.css --minify`. |
| `npm run send` | `theme` | Copy self-hosted `woff2` fonts into `dist/fonts/`. |
| `npm run basemap` | `theme` | `node basemap/build.mjs` — derive `basemap/liberty-rams.json` from tokens. |
| `npm run basemap:preview` | `theme` | `python3 -m http.server 8123` (blocking). |
| `npm run audit` | `theme` | `node ./scripts/audit.mjs ./dist/rams.css`. |
| `npm run capture` | `theme` | `node ./scripts/screenshot.mjs`. |
| `npm run test` | `theme` | `node ./scripts/audit.test.mjs`. |
### Before you open a change
Run all four. They are fast and they are the project's actual quality gates:
```bash
npm run bot:typecheck
npm run web:typecheck
npm run bot:test
npm run web:test
npm run audit # only if you touched theme/ or any rendered CSS
```
`theme` has its own suite (`npm test -w theme` → `node ./scripts/audit.test.mjs`).
Note it has **no root passthrough script** — you must use the `-w theme` form to
run it, unlike `bot` and `web`.
---
## Code style
**There is no linter and no formatter configured in this repository.** No
ESLint, no Prettier, no Biome, no `.editorconfig`, no `lint` or `format` script
in any of the four `package.json` files, and no commit hooks (no Husky, no
`.git/hooks` wiring). If you add one, it becomes the project's standard
retroactively — but today style is a review-time convention, not an automated
check. What *is* mechanically enforced is TypeScript strictness and the grep
gates described below.
### TypeScript
Both `bot/tsconfig.json` and `web/tsconfig.json` are strict beyond the default.
The settings that actually change how you write code:
| Setting | `bot` | `web` | Why it matters |
| --- | --- | --- | --- |
| `strict` | yes | yes | No implicit `any`, strict null checks. |
| `noUncheckedIndexedAccess` | yes | yes | Indexing an array/record yields `T \| undefined` — you must narrow. This is the single most common source of compile friction in this repo. |
| `verbatimModuleSyntax` | yes | yes | Type-only imports must use `import type`. |
| `allowImportingTsExtensions` | yes | yes | Import with explicit `.ts` extensions is allowed and is the house style. |
| `noEmit` | yes | yes | `typecheck` is `tsc --noEmit`; bundling is wrangler's or Vite's job. |
| `target` / `lib` | ES2023 / ES2023 | ES2023 / ES2023 + DOM | No downleveling. |
| `types` | `@cloudflare/workers-types` | `vite/client` | Determines which globals exist. `bot` has no DOM; `web` does. |
| `resolveJsonModule` | no | yes | `web` imports the basemap JSON style. |
`moduleResolution` is `"bundler"` in both — deliberately not `node16`, because
both runtimes are bundler-hosted (wrangler / Vite), not raw Node.
### House conventions observable in the source
- **Explicit `.ts` extensions on relative imports.** e.g. `import { strings } from
"../src/i18n/strings.ts"`, `from "./harness.mjs"`.
- **`.mjs` for scripts and tests, `.ts` for source.** Everything in
`bot/scripts/`, `bot/test/`, `web/test/`, `web/scripts/`, and `theme/scripts/`
is `.mjs` and runs under plain Node.
- **`node:` prefixed builtins** — `import { readFileSync } from "node:fs"`, never
bare `"fs"`.
- **Heavy header comments.** Every script and several source modules open with a
block comment stating what it is, which decision IDs it satisfies, and its
known constraints. Match that when adding a file.
- **Named constants in SCREAMING_SNAKE for tunables** — `SESSION_TTL_SECONDS = 900`,
`PIN_TTL_SECONDS = 300`. The privacy gate greps for this exact shape (see below).
- **Zero inline user-facing copy.** All strings live in `bot/src/i18n/strings.ts`
and `web/src/i18n/strings.ts`, keyed by locale, with `en` and `ms` carrying
identical key-sets. Gate: `COPY5 no shell module inlines copy`.
- **Structural interfaces for injection.** `KvLike`, `TelegramWebAppLike`,
`acquireFix(deps, …)` — dependencies are passed in so tests can supply fakes
without touching Workers types or `window`.
---
## Project conventions (the grep gates)
These are the rules the codebase enforces on itself. Each is a real test that
reads source or build artifacts — treat them as non-negotiable, because a change
that breaks one is a regression even if the behavior looks fine.
| Gate | Suite | Rule |
| --- | --- | --- |
| `PG1` | `bot/test/privacy.test.mjs` | Every `.put(` line in `kv/sessions.ts`, `kv/location-pin.ts`, and `webhook.ts` must carry `expirationTtl` **on the same line**, and the value must be a named `SCREAMING_SNAKE` constant — never an inline literal. No TTL-free write path can ship. |
| `PG2` | `bot/test/privacy.test.mjs` | The KV modules export nothing persistence-shaped: no `saveProfile`, `storeProfile`, `saveLocation`, `storeLocation`, `saveHistory`, `storeHistory`, `putHistory`, `setLocation`. |
| `GEO3` | `web/test/no-storage.test.mjs` | After stripping comments, no `web/src` file may reference `localStorage`, `sessionStorage`, `indexedDB`, `caches`, `CacheStorage`, `document.cookie`, `WebApp.DeviceStorage`, `WebApp.SecureStorage`, or `WebApp.CloudStorage`. Also no `serviceWorker.register`, `navigator.serviceWorker`, or `workbox`. |
| `WK5` | `bot/test/webhook.test.mjs` | An empty `WEBHOOK_SECRET` must fail the webhook **closed** with a constant 500, without running the bot or touching KV. |
| `CS4` | `bot/test/commands.test.mjs` | All replies go out with link previews disabled and nothing else attached; the command tree has no reply-menu or inline-button surface. |
| `CS6` | `bot/test/commands.test.mjs` | The command stack registers exactly `/start`, `/mula`, `/help`, `/privacy`, `/privasi`. |
| `COPY1` | `web/test/copy-gate.test.mjs` | `en` and `ms` carry identical key-sets (locale parity). |
| `COPY5` | `web/test/copy-gate.test.mjs` | No module under `web/src/ui/*.ts` or `web/src/main.ts` inlines copy strings. |
| `BA1`–`BA6` | `web/test/build-artifact.test.mjs` | The production bundle references no third-party CDN, self-hosts its `woff2` fonts, contains a hashed MapLibre worker chunk, carries the inlined Rams style JSON, and contains **no** Mock-Telegram installer or runner-page fetch stub in any emitted file. |
| audit exit codes | `theme/scripts/audit.mjs` | `0` clean, `1` off-token violations, `2` token-sheet drift. No silent passes. |
The same privacy promise is asserted from both sides: the **bot** must never
write without a TTL, and the **web** client must have no storage surface at all.
If you add a persistence mechanism, these suites fail — which is the point.
### The two design invariants worth internalizing
**Access control.** The `chat_id` for `/api/location` and `/api/location-fallback`
is *always* resolved from the session claim, never from client input; locale comes
from `claim.language_code`; the fallback request body is never read. If you add a
route, derive identity the same way.
**Honest degradation.** Every failure is a labeled state, never a silent one and
never a fabricated value. A `null` heading hides the marker cone instead of
guessing; `getPin` range-validates so a corrupt record cannot fly the camera to
null island; the chat-pin poll is bounded at 60 s so `AWAITING PIN` cannot persist
forever; every source timeout resolves `null` so the chain always advances;
`bootMap()` returning `null` renders the shell with `data-map="absent"`. Copy
along this pattern: if you cannot do the thing, say so in a state, don't fake it.
### Design-token workflow
`theme/src/tokens.css` is the **single source of truth** for every color, spacing,
typography, and radius value in the project. It declares them in a Tailwind v4
`@theme static` block (55 `--color-*` tokens plus spacing and typography);
`static` forces every token into the compiled CSS so the package is consumable as
plain custom properties by anything, including the MapLibre basemap derivation.
Rules:
1. **Never hardcode a value that belongs to a token.** Not a hex, not a px
padding, not a font stack. `npm run audit` will flag it in rendered CSS.
2. **Never restate token values in documentation.** `DESIGN.md` and other docs
describe principles and point at the token package.
3. **Never edit `stitch_minimalist_route_navigator/`.** Those four mockups are
read-only reference; `theme/src/theme.css` `@source`-scans them to compile
their utility vocabulary, and `theme/scripts/screenshot.mjs` falls back to
their committed `screen.png` on capture failure.
4. **Adding a token** means editing `tokens.css`, rebuilding (`npm run build -w
theme`), and re-running `npm run audit`. If it is a cartographic color, also
consider whether `theme/basemap/build.mjs` should derive it.
5. **`theme/src/theme.css`** is the entry stylesheet: it `@import`s tailwindcss,
`tokens.css`, and `components.css`, declares seven self-hosted `@font-face`
rules from `theme/fonts/` (no Google Fonts CDN), and scopes class generation
with explicit `@source` lines (the four mockups are the allowed utility
sources; `@source not "../scripts"` and `@source not "../basemap"` exclude
prose).
6. **`theme/basemap/build.mjs`** forks OpenFreeMap `liberty` and derives every
cartographic color from `tokens.css` via `desaturate(...)`, with an accent
guard that fails the build if `#EB8008`, `#ED3F1C`, or `#736B1E` leak into any
layer. `building-3d` is stripped (no faux 3D).
---
## Branch and commit conventions
### Branches
The repo's only branch is `main` (there is a stray `master` but it is not the
active branch), and there is **no remote branch convention documented** — no
`CONTRIBUTING.md`, no PR template, no CI config (no `.github/`, no `.gitea/`).
In practice, work lands on `main` via atomic commits rather than long-lived
feature branches. If you prefer branches, `feat/`, `fix/`, and `docs/` prefixes
match the commit vocabulary below.
### Commit messages
The convention is **conventional-commit style with a phase-scoped type**:
`type(phase): subject`. The type is one of `feat`, `fix`, `docs`, `chore`, `test`.
The scope in parentheses is a phase identifier, sometimes with a sub-task
(`03-04`). Examples straight from `git log`:
```
feat(03-03): status strip, state cards, recenter control, boot choreography — map-first, D-09 staged states
fix(03-04): load telegram-web-app.js in head — production WebApp was never defined
docs(03-04): log debug round 5 — diagnostic probe armed
test(02-01): hand-rolled harness + RED initData vector suite (D-12, TDD RED)
chore(03-01): lockfile resolution for the web workspace
fix(02-02): ... (when the scope names the phase area rather than a number)
```
The subject is lowercase after the colon, imperative, and — the house habit —
carries an em-dash clause explaining the *why* or the observable failure it fixes
("production WebApp was never defined (no initData…)"). Reference decision IDs
(`D-09`, `WR-03`, `PRIV-02`) are appended in parentheses when relevant.
Keep commits atomic: the log shows one concern per commit, frequently a
`fix(...)` immediately after the `docs(...)` log entry that motivated it.
---
## Pull requests
**There is no PR process configured** — no `.github/PULL_REQUEST_TEMPLATE.md`, no
CONTRIBUTING file, no CI, no required reviewers. Given the private single-branch
setup, changes are typically committed straight to `main`. If you do open a PR,
match what the project actually relies on:
- **Run the gates** and paste the output: both typechecks, both test suites, and
`npm run audit` if any rendered CSS or theme changed.
- **Explain the observable before/after**, following the commit-subject habit
(what was broken, what it does now).
- **Call out gate-relevant surface**: if you touched a KV write, `web/src`
persistence, the command surface, or the i18n strings, say which gates you
re-verified (`PG1`, `GEO3`, `CS6`, `COPY1`…).
- **Include screenshots** for any Mini App change — `npm run web:build:harness
&& npm run web:capture` produces the current states.
- **Do not commit build output.** `theme/dist`, `web/dist`, `web/dist-harness`,
and the `screenshots/` directories are gitignored by design; they are
regenerated at build time.
---
## Committing generated artifacts
`dist/` and the screenshot directories are **never committed**. This is a
deliberate rule, called out in `.gitignore`: the web build consumes
`theme/dist/rams.css` at build time without vendoring, so a committed
`theme/dist` would go stale. Always rebuild (`npm run build`) rather than
expecting a checked-in artifact.
`.planning/` is also gitignored (local-only planning docs), as is `bot/.dev.vars`
and `bot/.wrangler/`.
+366
View File
@@ -0,0 +1,366 @@
<!-- generated-by: gsd-doc-writer -->
# Getting Started
This guide takes you from an empty machine to a locally running arah² — the Telegram
bot (**arah2bot**), its Cloudflare Worker, and the Mini App webview — in five steps.
For what each piece does, see [ARCHITECTURE.md](ARCHITECTURE.md). For the full list of
environment variables, config files, and per-package scripts, see
[CONFIGURATION.md](CONFIGURATION.md). For a package overview, see the root
[README.md](../README.md).
---
## Prerequisites
| Requirement | Why | Notes |
| --- | --- | --- |
| **Node.js + npm** | Every build, dev, test, and deploy script in the repo is an npm script; all three workspaces are ESM (`"type": "module"`) | No `engines` field, `.nvmrc`, or `.node-version` exists anywhere in the repo. Use a current Node LTS. |
| A **Telegram account** | You chat with arah2bot and receive the Mini App webview | — |
| A bot token from **[@BotFather](https://t.me/BotFather)** | `BOT_TOKEN` is required for the Worker and every registration script | Also used as the HMAC key that validates Mini App `initData` |
| **Internet access** | `wrangler dev`, the Telegram Bot API, and OpenFreeMap tile fetches all need the network | — |
| **A Cloudflare account** *(deploy and `npm run bot:commands` only)* | `wrangler deploy` and the Workers KV namespace live there | Wrangler prompts for browser-based auth on first use |
<!-- VERIFY: minimum Node.js version — no `engines` field, `.nvmrc`, or `.node-version` file exists in the repository, so no supported floor is recorded. Confirm the intended version with the maintainers. -->
Only required for specific commands, not for a normal first run:
- **Python 3** — `npm run gallery` serves the design gallery via `python3 -m http.server 8123`
(`theme/package.json`, script `basemap:preview`).
- **A Playwright-managed Chromium** — `npm run capture` and `npm run web:capture` both call
bare `chromium.launch()` (`theme/scripts/screenshot.mjs:103`, `web/scripts/capture.mjs:163`), so
they need `npx playwright install chromium` first. No system Chrome is used.
There is no database to install, no Docker step, and no `.env` file to author — local secrets
live only in `bot/.dev.vars` (created in step 3). See
[CONFIGURATION.md](CONFIGURATION.md#environment-variables) for what each variable does.
---
## Installation
1. **Clone the repository.**
```bash
git clone http://gitmirah:3000/c-fu/arah2.git
```
2. **Enter the project directory.** The clone is named `arah2` after the repo slug; the
workspace root is what `package.json` lives in.
```bash
cd arah2
```
3. **Install all three workspaces from the root.** npm workspaces hoist dependencies into a
single root `node_modules`, so one install covers `theme`, `bot`, and `web` — including
`@araharah/theme`, which `web` consumes by relative path and never needs to be published.
```bash
npm install
```
---
## First run
```mermaid
flowchart TD
CLONE["git clone"] --> INSTALL["npm install"]
INSTALL --> VARS["cp bot/.dev.vars.example bot/.dev.vars"]
VARS --> FILL["fill BOT_TOKEN, WEBHOOK_SECRET, WEBHOOK_URL"]
FILL --> BUILD["npm run build"]
BUILD --> DEV["npm run dev — wrangler dev on :8787"]
DEV --> HOOK["point the webhook at the tunnel"]
HOOK --> TALK["message arah2bot in Telegram"]
style CLONE fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
style INSTALL fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
style VARS fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
style FILL fill:#EB8008,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
style BUILD fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
style DEV fill:#1A1A1A,stroke:#EB8008,stroke-width:2px,color:#F7F0E3
style HOOK fill:#1A1A1A,stroke:#EB8008,stroke-width:2px,color:#F7F0E3
style TALK fill:#736B1E,stroke:#1A1A1A,stroke-width:2px,color:#F7F0E3
```
### 1. Create your local secrets file
`bot/.dev.vars` is gitignored (`bot/.gitignore`) and must never be committed. Copy the template:
```bash
cp bot/.dev.vars.example bot/.dev.vars
```
Then fill in all three variables:
```bash
# Copy to .dev.vars and fill for local wrangler dev (D-15). NEVER commit .dev.vars.
BOT_TOKEN=
WEBHOOK_SECRET=
# Full origin + path; set-webhook.mjs auto-appends /telegram/webhook if only an origin is set.
WEBHOOK_URL=
```
- **`BOT_TOKEN`** — from [@BotFather](https://t.me/BotFather). Required.
- **`WEBHOOK_SECRET`** — any high-entropy random string you choose. Telegram echoes it in the
`X-Telegram-Bot-Api-Secret-Token` header, and the Worker constant-time-compares it before
touching any update.
- **`WEBHOOK_URL`** — the public origin Telegram will POST updates to. Locally this is a
tunnel (see step 5); `set-webhook.mjs` appends `/telegram/webhook` automatically if you give it
a bare origin.
Every script in `bot/scripts/` resolves each variable as `process.env.<NAME> ?? devVars.<NAME>`,
so your shell environment wins over the file and the file is the fallback.
### 2. Build
```bash
npm run build
```
This runs `build -w theme` then `build -w web`. **The order matters:** `theme/dist/rams.css` is an
input to the web build, so the theme must compile first. Neither `theme/dist` nor `web/dist` is
committed (root `.gitignore` excludes `dist/`), so this step is required on every fresh clone and
before every `dev`/`deploy`.
### 3. Run the Worker locally
```bash
npm run dev
```
Root `dev` is `npm run build && npm run dev -w bot`, which is `wrangler dev --port 8787 --local`.
Wrangler reads `bot/.dev.vars`, emulates the `chat_sessions` KV namespace under `bot/.wrangler/`,
and serves three things on **one origin, port `8787`**:
| Path | Served by |
| --- | --- |
| `/telegram/webhook` | Worker `fetch` handler (`run_worker_first`) |
| `/api/*` | Worker `fetch` handler (`run_worker_first`) |
| everything else | `web/dist` static assets via the `ASSETS` binding |
Because it is the single origin, you can smoke-test the API without Telegram at all:
```bash
# Unknown paths are 404 — the Worker's fetch handler routes exactly four paths.
curl -i http://localhost:8787/api/location
# → 401 (no bearer token)
# An unauthenticated webhook POST fails closed when WEBHOOK_SECRET is unset,
# otherwise it 401s on the secret-token mismatch.
curl -i -X POST http://localhost:8787/telegram/webhook
```
### 4. Expose the webhook
`wrangler dev` binds to localhost only, so Telegram cannot reach it. Expose port `8787` with a
tunnel, then run the one-time registration script, which reads the same `bot/.dev.vars`:
```bash
# Terminal 1 — the dev server (from step 3)
npm run dev
# Terminal 2 — a tunnel that publishes port 8787 at a public HTTPS origin
cloudflared tunnel --url http://localhost:8787
```
Take the `https://…trycloudflare.com` origin it prints, put it in `WEBHOOK_URL` in
`bot/.dev.vars`, and register it:
```bash
npm run deploy --workspace bot # or, without the deploy half: see below
```
`bot`'s own `deploy` script is `wrangler deploy && node scripts/set-webhook.mjs`, which is the
wrong tool for local work. For a **local** webhook registration, run the script directly — it only
calls Telegram's `setWebhook`; it does not deploy anything:
```bash
node bot/scripts/set-webhook.mjs
```
Expected output on success:
```text
setWebhook ok:true — updates will be delivered to the configured URL with the secret-token header.
```
If `WEBHOOK_URL` has no path, the script prints a normalization line first:
```text
WEBHOOK_URL had no path — normalized to .../telegram/webhook
```
<!-- VERIFY: the recommended tunnel tooling is not pinned by the repository — no `cloudflared`, `ngrok`, or `localtunnel` dependency or config exists. Replace with whatever tunnel your team standardizes on, and record the choice here. -->
Register the persistent command menu (English + Bahasa Melayu) once, so `/help` and `/privacy`
appear in the Telegram UI:
```bash
npm run bot:commands
```
`register-commands.mjs` registers exactly `["help", "privacy"]` in two scopes — the default scope
plus `language_code: "ms"` — and deliberately reserves no stub entries for future commands, so
`/start` is a working handler that does **not** appear in the menu (type it manually). The
descriptions are derived from the `helpMenu` lines in `bot/src/i18n/strings.ts`, the same text
`/help` replies with, so the menu and the reply cannot drift.
### 5. Talk to arah2bot
Open Telegram, find **arah2bot**, and send `/start`. The Worker receives the update at
`/telegram/webhook`, verifies the secret-token header, and replies. Supported commands:
| Command | Alias | Behaviour |
| --- | --- | --- |
| `/start` | `/mula` | One-sentence introduction to arah² and its privacy posture |
| `/help` | — | Lists the commands available in this phase |
| `/privacy` | `/privasi` | States exactly what is stored (~15 min) and what is never stored |
The Mini App opens from the bot's menu button. The script that repoints it at the deployed origin
(`type: web_app`) has **no npm alias** — run it directly:
```bash
node bot/scripts/set-menu-button.mjs
```
### Working on the Mini App alone
For map and UI work that doesn't need the bot, run Vite instead of the Worker. It serves on port
**`5173`** with `strictPort: true`, so a port clash fails loudly rather than drifting:
```bash
npm run web:dev
```
There is no Worker behind it, so `web/src/main.ts` falls back to the mock Telegram WebApp via
`import.meta.env.DEV` and the `/api/*` calls stay stubbed. This is the fastest map/UI loop; run
`npm run dev` when you need the real session handshake.
---
## Verify the setup
Run both suites and both typechecks — the runners are hand-rolled and zero-dependency
(`node test/runner.mjs`), so no test framework needs installing:
```bash
npm run bot:test # 6 suites in bot/test/*.test.mjs
npm run web:test # 6 suites in web/test/*.test.mjs
npm run bot:typecheck # tsc --noEmit
npm run web:typecheck # tsc --noEmit
```
Each runner prints `PASS <name>` / `FAIL <name>` per case and exits `0` only when every case
passed. If a suite fails to load, it is recorded as one failing case rather than crashing the run.
The bot suites are `commands`, `initdata`, `location-pin`, `privacy`, `sessions`, and `webhook`;
the web suites are `build-artifact`, `copy-gate`, `location-chain`, `no-storage`, `session`, and
`theme`. Two are worth knowing about as you get started:
- **`no-storage`** pins that nothing in `web/src` uses `localStorage`, `sessionStorage`, cookies,
IndexedDB, or the Cache API.
- **`privacy`** greps that every `chat_sessions` write carries an explicit `expirationTtl`.
Optionally, prove the design tokens carry no off-token values:
```bash
npm run audit
```
`theme/scripts/audit.mjs` scans the rendered CSS and exits `1` on any colour, spacing,
typography, radius, shadow, or blur value not derived from `theme/src/tokens.css`; it prints
`AUDIT CLEAN — …` and exits `0` when the sheet is pure.
---
## Common setup issues
**`npm run dev` starts but every request returns the Mini App, or nothing.**
`web/dist` may be stale or absent. `run_worker_first` covers only `/api/*` and `/telegram/*`
(a glob **array**, not a boolean), so everything else falls through to the assets binding. Run
`npm run build` first, or just use `npm run dev` — it already builds.
**`[webhook] rejected: misconfigured` → `500 Server misconfigured`.**
`WEBHOOK_SECRET` is missing or empty in `bot/.dev.vars`. The guard is at `bot/src/webhook.ts:65`
and it fails closed deliberately: grammY's `compareSecretToken` silently accepts *every* request
when the secret is `undefined`, so an unarmed secret must never reach it. Generate a new random
string and put it in `WEBHOOK_SECRET`.
**`setWebhook ok:true` but arah2bot never responds.**
Telegram accepted the URL, so it answered `ok` while delivering updates to a path that `404`s.
This happens when `WEBHOOK_URL` is a bare workers.dev origin. `set-webhook.mjs` now appends
`/telegram/webhook` for you — if you registered the webhook by hand or via a different tool,
re-run `node bot/scripts/set-webhook.mjs` and confirm the registered URL includes the path.
**`Missing BOT_TOKEN, WEBHOOK_URL, WEBHOOK_SECRET` and exit code `1`.**
`set-webhook.mjs` checks all three before any network call and never echoes values — only names.
Confirm you ran it from a path where `bot/.dev.vars` resolves (`bot/.dev.vars` is read relative to
the script, so it works from the repo root too), and that you did not commit a truncated file.
**Nothing happens when I send `/start`.**
Almost always the tunnel. `wrangler dev --local` binds to localhost, so Telegram has no route in.
Re-run the tunnel, update `WEBHOOK_URL`, re-run `node bot/scripts/set-webhook.mjs`, and confirm the
`tunnel origin` in `.dev.vars` still matches the one currently printing in Terminal 2 — tunnels
rotate their hostname on every restart.
**`chromium.launch()` fails — `npm run capture` or `npm run web:capture` errors on a missing browser.**
Both scripts use bare `chromium.launch()` with no `executablePath` and no `channel`, so they need
Playwright's own browser build:
```bash
npx playwright install chromium
```
`npm run web:capture` additionally requires the harness build to exist — it serves
`web/dist-harness/`, not `web/dist`, and waits for `RAMS-BASEMAP-READY` then `ARAHARAH-LIVE`
signals. Run `npm run web:build:harness` first. Its 90-second denial timeout is deliberate: the
chat-pin poll must elapse before the denied card appears.
**Mini App boots but shows no map.**
Check the network tab for tile requests to `https://tiles.openfreemap.org/…` — the generated
`theme/basemap/liberty-rams.json` points there, so a blocked or offline request leaves a blank
canvas. `npm run gallery` serves the basemap locally on `:8123` if you want to inspect the style
in isolation.
**`theme/dist/rams.css` is missing when `web` builds.**
Run `npm run build` from the root rather than `npm run build -w web`. The root `build` script
chains theme then web; the web build imports the compiled stylesheet, not the token source.
---
## Next steps
- **[ARCHITECTURE.md](ARCHITECTURE.md)** — how the Worker, KV namespace, Mini App, and design
system fit together; the Worker routing table; the three-link location acquisition chain.
- **[CONFIGURATION.md](CONFIGURATION.md)** — every environment variable, `bot/wrangler.jsonc`
and `web/vite.config.ts`, and the full command reference per workspace.
- **[../README.md](../README.md)** — package overview, HTTP surface, design-system usage.
- **[../DESIGN.md](../DESIGN.md)** — the Dieter Rams philosophy behind the token vocabulary.
### Everyday commands
| Command | What it does |
| --- | --- |
| `npm run dev` | Build, then `wrangler dev --port 8787 --local` |
| `npm run build` | Build theme → Mini App (`theme/dist/rams.css` must exist first) |
| `npm run web:dev` | Vite dev server on `5173` with a mock WebApp and no Worker |
| `npm run bot:test` / `npm run web:test` | Zero-dependency test runners |
| `npm run bot:typecheck` / `npm run web:typecheck` | `tsc --noEmit` |
| `npm run audit` | Off-token audit of the rendered CSS; non-zero on violations |
| `npm run bot:commands` | Register the persistent Telegram command menu (EN + MS) |
| `npm run deploy` | Build, then `wrangler deploy` + `set-webhook.mjs` |
The two build outputs must **never** share an `outDir`: `web:build:harness` emits to
`web/dist-harness/` with the mock installer and `/api/*` fetch stubs that production must not
carry. `vite.config.ts` calls this out as the reason the split exists.
### Deploying
`npm run deploy` publishes the Worker to Cloudflare and registers the webhook. It reads no local
file for secrets — production values live as Cloudflare Workers secrets. See
[CONFIGURATION.md](CONFIGURATION.md#per-environment-overrides).
<!-- VERIFY: which environment variables are set as Cloudflare Workers secrets via `wrangler secret put`, and under which Cloudflare account, cannot be determined from the repository. -->
+446
View File
@@ -0,0 +1,446 @@
<!-- generated-by: gsd-doc-writer -->
# Testing
arah² has no third-party test framework. Every suite in this repo is a hand-rolled, zero-dependency Node script that registers cases into a small array and drains it. The runners were written first in `theme/`, then copied verbatim into `bot/` and `web/` — the three share one shape, deliberately.
Beyond the suites, two additional gates stand in for CI (this repo has none — see [CI integration](#ci-integration)): `tsc --noEmit` typechecks, and the theme's off-token audit is itself a tested CLI.
---
## Contents
- [Test framework and setup](#test-framework-and-setup)
- [Running tests](#running-tests)
- [Writing new tests](#writing-new-tests)
- [Test patterns](#test-patterns)
- [Coverage requirements](#coverage-requirements)
- [Visual capture workflow](#visual-capture-workflow)
- [CI integration](#ci-integration)
---
## Test framework and setup
### What "framework" means here
| Workspace | Runner | Harness | Registered via | Command |
|---|---|---|---|---|
| `bot/` | `bot/test/runner.mjs` | `bot/test/harness.mjs` | `import { test } from "./harness.mjs"` | `npm run bot:test` |
| `web/` | `web/test/runner.mjs` | `web/test/harness.mjs` | `import { test } from "./harness.mjs"` | `npm run web:test` |
| `theme/` | inline in `theme/scripts/audit.test.mjs` | local `check()` helper | `check("name", cond, detail)` | `npm test -w theme` |
There is **no jest, vitest, mocha, playwright-test, or any other runner** in any `package.json`. The only test-relevant dev dependency anywhere is `typescript` (`tsc --noEmit`, used as a compile-time gate) and `playwright` (used by the capture scripts, not by any suite).
### The harness (10 lines, duplicated per workspace)
`bot/test/harness.mjs` and `web/test/harness.mjs` are byte-identical:
```js
const tests = [];
export function test(name, fn) {
tests.push({ name, fn });
}
export const registry = tests;
```
`test(name, fn)` pushes a case. `fn` may be sync or `async` — the runner `await`s every one.
### The runner (47 lines, duplicated per workspace)
`bot/test/runner.mjs` and `web/test/runner.mjs` are also byte-identical in behaviour:
1. `readdirSync(import.meta.dirname)`, filter `*.test.mjs`, sort.
2. Dynamic-`import()` each suite inside a `try/catch`. A suite that throws at load time is **not** fatal — it is pushed into the registry as a single failing case named `suite load failed: <file>`. The run always completes.
3. Drain `registry` in registration order: `PASS <name>` on stdout, or `FAIL <name>` plus the error stack on stderr.
4. Print `[runner] <n> passed, <m> failed` and set `process.exitCode = 1` if anything failed.
Registration order is therefore: **suite files in alphabetical order, cases in file order**. There is no shuffling, no parallelism, no filtering, no watch mode.
### TypeScript is loaded directly
Both `bot/test/` and `web/test/` `await import("../src/....ts")` directly against the shipped source — no build step, no transpile step, no fixtures directory. This works because Node strips types natively (the code comments call this "Node 24 type stripping"). <!-- VERIFY: minimum Node.js version for native `.ts` import support — no `engines` field or `.nvmrc` is committed; the test suites themselves reference "Node 24 type stripping". -->
### Assertion style
Every suite in `bot/` and `web/` uses **`node:assert`'s strict mode**, imported as:
```js
import { strict as assert } from "node:assert";
```
In practice: `assert.ok(...)` for existence checks, `assert.equal` / `assert.strictEqual` for scalar equality, `assert.deepEqual` / `assert.deepStrictEqual` for structural equality, `assert.match` / `assert.doesNotMatch` for regex, and `assert.throws` / `assert.doesNotThrow` when a throw is the expected result. Every assertion carries a message string explaining the invariant being pinned.
`theme/scripts/audit.test.mjs` does not use `node:assert` at all — it defines a local `check(name, cond, detail = '')` that accumulates into `passes[]` / `failures[]`, reports them all at the end, and exits 1 if any failed.
---
## Running tests
### Everything, per workspace
```bash
# from the repo root
npm run bot:test # → node test/runner.mjs in bot/
npm run web:test # → node test/runner.mjs in web/
npm test -w theme # → node ./scripts/audit.test.mjs in theme/
```
There is no root-level `npm test` aggregate — run all three, or add one to `package.json` when you need it.
### Running one suite
The runners auto-discover by filename and offer no filter flag. To run a single file, invoke it directly from inside the workspace:
```bash
cd bot && node test/initdata.test.mjs
```
This works because a suite file's top-level `test(...)` calls register into `harness.mjs`, and `runner.mjs` is what drains the registry — so importing a suite alone reports nothing. **A single suite file run this way executes the module side effects (imports, fixture writes) but produces no pass/fail output.** Always go through the runner:
```bash
cd bot && node test/runner.mjs # all bot suites
cd web && node test/runner.mjs # all web suites
```
`theme` is the exception — its suite is a standalone script and *does* report standalone:
```bash
cd theme && node scripts/audit.test.mjs
```
### Typecheck gates
The `bot/` and `web/` suites do not compile. Run these alongside:
```bash
npm run bot:typecheck # tsc --noEmit in bot/
npm run web:typecheck # tsc --noEmit in web/
```
### The theme audit as a standalone tool
The audit is both the thing under test and a useful CLI in its own right. Run it on the real build:
```bash
npm run audit # → node ./scripts/audit.mjs ./dist/rams.css
```
Its exit codes are a contract: `0` clean, `1` violations (each printed as `file:line: declaration`), `2` token-sheet drift or error. See [theme/scripts/audit.mjs](theme/scripts/audit.mjs) for the full usage block and its scope adjudications.
```mermaid
graph TD
ROOT["npm run bot:test / web:test / audit"] --> BR["bot/test/runner.mjs"]
ROOT --> WR["web/test/runner.mjs"]
ROOT --> TR["theme/scripts/audit.test.mjs"]
BR --> BDISC["readdir *.test.mjs → dynamic import"]
BDISC --> BREG["bot/test/harness.mjs registry"]
BREG --> BCASES["commands · initdata · location-pin · privacy · sessions · webhook"]
WR --> WDISC["readdir *.test.mjs → dynamic import"]
WDISC --> WREG["web/test/harness.mjs registry"]
WREG --> WCASES["build-artifact · copy-gate · location-chain · no-storage · session · theme"]
TR --> TFIX["fixtures under /tmp/opencode/audit-test/"]
TFIX --> TSPAWN["spawnSync scripts/audit.mjs"]
TSPAWN --> TCHECK["check() → passes[] / failures[]"]
TCHECK --> TREAL["also audits real dist/rams.css"]
classDef entry fill:#EB8008,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
classDef runner fill:#1A1A1A,stroke:#EB8008,stroke-width:2px,color:#F7F0E3
classDef harness fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
classDef theme fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1E1B14
class ROOT entry
class BR,WR,TR runner
class BDISC,BREG,WDISC,WREG harness
class TFIX,TSPAWN,TCHECK,TREAL theme
```
---
## Writing new tests
### Naming and location
| Rule | Value |
|---|---|
| File name | `<subject>.test.mjs` — the runner only discovers this suffix |
| Directory | `bot/test/` or `web/test/`, flat, no subdirectories |
| Import the harness | `import { test } from "./harness.mjs";` |
| Case name | Prefix with a stable case code: `"WK1 POST valid initData (under cap) → 200 {token: uuid}"`, `"GEO3 no banned storage token…"`, `"CS4 D-02: the command tree source has no reply-menu…"`. The code is the identity; the prose is the explanation. |
A new file dropped into `bot/test/` or `web/test/` is picked up automatically on the next run — no manifest, no import list, no registration step anywhere.
### The minimum shape
```js
#!/usr/bin/env node
/*
* <subject>.test.mjs — what this suite pins, and under which requirement id.
* ------------------------------------------------------------------------
* <CODE> one line per case, so a reader can see the contract without
* reading the code.
*/
import { strict as assert } from "node:assert";
import { test } from "./harness.mjs";
const { someExport } = await import("../src/some/module.ts");
test("<CODE> <what is asserted> <and why>", () => {
assert.equal(someExport("input"), "expected", "why this matters");
});
test("<CODE> async path", async () => {
const res = await someAsync();
assert.ok(res.ok, `expected ok:true, got ${JSON.stringify(res)}`);
});
```
For `theme/`, add cases to `theme/scripts/audit.test.mjs` using the local helper instead:
```js
{
const r = runAudit([dirtyFixture]);
check('T2 dirty stylesheet → exit 1', r.status === 1, `status=${r.status} error=${r.error}`);
}
```
Note the theme suite uses a **bare block** (`{ … }`) rather than a named function, and relies on `passes[]`/`failures[]` instead of exceptions.
### Where the source of truth lives
Every suite header comment lists its cases up front. That header is the documentation — keep it in sync when adding a case. Several suites reference the decision ids (`D-02`, `PRIV-02`, `GEO-03`, `CS1`, `COPY5`, `WK3`) that the case is pinning; reuse that convention.
---
## Test patterns
The repo has converged on four recognisable patterns. Follow the closest precedent rather than inventing a new one.
### 1. Injected fakes over mocks and over the real platform
`bot/test/sessions.test.mjs` and `bot/test/webhook.test.mjs` both hand-roll a ~20-line `KVNamespace` fake rather than depend on `miniflare` or `wrangler`:
```js
function makeKV() {
const map = new Map();
const ttlLog = [];
return {
async get(key) { return map.has(key) ? map.get(key) : null; },
async put(key, value, opts) {
ttlLog.push({ key, expirationTtl: opts?.expirationTtl ?? null });
map.set(key, value);
},
async delete(key) { map.delete(key); },
map,
ttlLog,
};
}
```
Two variants exist and the difference matters:
- **Asserting fake** (`sessions.test.mjs`) — `put()` itself throws if `expirationTtl` is missing or non-finite, making the TTL law unbreakable from the inside.
- **Recording fake** (`webhook.test.mjs`, `location-pin.test.mjs`) — `put()` records everything into `ttlLog` and the case asserts on the log afterwards.
### 2. Global stubs: install, then restore in `finally`
Node has no DOM, no `navigator`, no `document`. Suites stub them per case and always restore:
```js
function stubNavigator(geolocationOrNull) {
const original = Object.getOwnPropertyDescriptor(globalThis, "navigator");
Object.defineProperty(globalThis, "navigator", { value: /* … */, configurable: true, writable: true });
return () => {
if (original) Object.defineProperty(globalThis, "navigator", original);
else delete globalThis.navigator;
};
}
// in the case
const restoreNav = stubNavigator({ geolocation: true });
try {
// …
} finally {
restoreNav();
}
```
`web/test/theme.test.mjs` does the same for `globalThis.document` (`installDocumentStub()` returns the `dataset` object, which *is* the assertion target). The headers are explicit about why: "a leaked stub would silently make a later pass/fail for the wrong reason."
`web/test/location-chain.test.mjs` also stubs `globalThis.fetch`. `bot/test/location-pin.test.mjs` patches a different layer entirely — grammY's node shim reads node-fetch's cached `default` export, so `createRequire` reaches `node-fetch` and swaps `.default` for the duration of the case.
### 3. Source-introspection gates ("grep the shipped source")
Structural claims — "no menu surface exists", "copy lives only in `strings.ts`", "nothing calls `setStyle`" — are pinned by *reading the file*, not by mocking behaviour. This is the most distinctive pattern in the repo and it appears in both workspaces:
- `bot/test/commands.test.mjs` CS4 — asserts `/keyboard/i` does not appear in any command source.
- `bot/test/privacy.test.mjs` PG1 — every line containing `.put(` must also contain `expirationTtl` referencing a `SCREAMING_CASE` constant.
- `bot/test/privacy.test.mjs` PG2 — no persistence-shaped export names exist.
- `web/test/copy-gate.test.mjs` COPY5 — no shell module inlines user-facing copy.
- `web/test/no-storage.test.mjs` GEO3 — no banned storage token appears in `web/src` code.
- `web/test/session.test.mjs` SESS4, `web/test/theme.test.mjs` PX02 case 3 — same shape.
**Every one of these gates strips comments first.** The convention is two replaces, repeated verbatim across files:
```js
const code = src
.replace(/\/\*[\s\S]*?\*\//g, " ") // block comments
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments (skips URLs)
```
The reason is documented in `no-storage.test.mjs`: `web/src/mock/telegram-mock.ts` documents the storage ban *in prose*, and prose is not a call site.
### 4. Build-artifact gates (assert on the shipped bundle)
`web/test/build-artifact.test.mjs` reads `web/dist` — the **production** build only, never `web/dist-harness/` — and proves what actually ships:
| Case | Assertion |
|---|---|
| BA1 | No emitted `.js`/`.css` references a third-party CDN host |
| BA2 | At least one self-hosted `.woff2` was copied through |
| BA3 | A hashed MapLibre worker chunk exists under `dist/assets` |
| BA4 | The built JS carries the inlined Rams style JSON |
| BA5 | `installMockWebApp` / `__ARAHARAH_MOCK__` appear in **no** emitted `.js` **or** `.html` |
| BA6 | No emitted HTML carries the runner page's fetch stubs |
These cases require `web/dist` to exist. Each one starts with `assert.ok(existsSync(DIST), claimBuildFirst().message)`, which yields:
```
run `npm run build -w theme && npm run build -w web` before this suite
```
The suite's header is emphatic about *why* BA5 scans HTML as well as JS: `web/index.html` is one entry shared by both builds, so a JS-only scan would miss an unguarded profile block entirely.
### Two more conventions worth copying
**Lazy module imports for RED phases.** `bot/test/initdata.test.mjs` wraps its import in `loadValidator()` so a missing module produces one `FAIL` per case rather than a lost suite:
```js
let validatorPromise = null;
function loadValidator() {
if (!validatorPromise) validatorPromise = import("../src/security/initdata.ts").then((m) => m.validateInitData);
return validatorPromise;
}
```
**Self-checks that pin the test's own primitives.** `initdata.test.mjs` T7 asserts `node:crypto`'s HMAC output equals the RFC-4231 test-case-1 digest, so a broken signing path cannot produce false-green signing tests. `theme/scripts/audit.test.mjs` T9 runs a real controlled Tailwind build to prove the assertion isn't tautological.
### Anti-patterns to avoid
- **Letting a case reach the live Bot API.** `bot/test/webhook.test.mjs` documents the constraint at length: grammY's cloudflare-mod adapter calls `bot.init()` (a live `getMe`) *before* its own secret-token compare, so no committed case may let an update reach the framework with an armed secret. Armed-secret behaviour is verified by live smokes, not by the suite.
- **Writing fixtures into the repo.** `theme/scripts/audit.test.mjs` creates everything under `/tmp/opencode/audit-test/`; the bot/web suites keep fixtures in-process entirely.
- **Adding a test framework.** The zero-dependency property is load-bearing (decision `D-12`) and repeated across all three runners.
---
## Coverage requirements
**No coverage threshold is configured.** There is no `coverageThreshold`, no `nyc`/`c8` config, and no instrumentation anywhere — because there is no framework to instrument with.
What substitutes for coverage measurement:
| Mechanism | Where |
|---|---|
| **Existence assertions on the scanned tree** | `no-storage.test.mjs` asserts `files.length >= 12` before scanning (`"scanned a real tree"`), so the gate cannot pass vacuously on an empty directory. `copy-gate.test.mjs` COPY5 asserts `files.length >= 1`. |
| **Fixture self-checks** | `theme/scripts/audit.test.mjs` aborts with `fixture setup: … not found` if `theme/src/tokens.css` loses a key the fixtures depend on. |
| **Typecheck as a structural gate** | `npm run bot:typecheck` / `web:typecheck` catch what the suites would not: a type change in a shape the runtime assertions never inspect. |
If you need coverage numbers, you would have to add tooling — which contradicts the current design. Prefer adding a case.
---
## Visual capture workflow
Two Playwright scripts generate screenshots. They are **evidence generators, not tests** — they are not part of any `npm test` command and nothing in the suites depends on them. Both share the same shape: a docroot-guarded static server on `127.0.0.1:0`, a Playwright `chromium` launch, one `MIN_BYTES = 10240` blank bar per PNG, a `FAILED`/`MISSING`/`UNDERSIZED` report, and `exit(1)` if anything is unusable.
```bash
npm run capture # → theme/scripts/screenshot.mjs → theme/screenshots/
npm run web:capture # → web/scripts/capture.mjs → web/screenshots/
```
Both output directories are gitignored — they are regenerated artifacts, never committed.
### `theme/scripts/screenshot.mjs`
Serves `theme/` as docroot, then captures:
| Output | Notes |
|---|---|
| `sheet-full.png` | Component sheet at 390px, full page, after `document.fonts.ready` |
| `mockup-<screen>.png` ×4 | The four mockup screens over `file://` — CDN Tailwind, so **network required**. On failure, the committed `screen.png` is copied in as fallback and logged as `FALLBACK` |
| `basemap-kl.png` | 1280×800, waits on the page's own `RAMS-BASEMAP-READY` console signal + 2s tile settle |
The mockups are read-only reference (decision `D-06`) and are never edited.
### `web/scripts/capture.mjs`
Rebuilds all three bundles first, then serves **`web/dist-harness/`**, not `web/dist`:
```bash
npm run build -w theme && npm run build -w web && npm run build:harness -w web
```
It then asserts in both directions before launching the browser — the harness bundle *must* contain `installMockWebApp`, and `web/dist` *must not* — and fails the run if either is wrong, because a wrong bundle means the capture would race forever.
| Output | Leg | Wait mode |
|---|---|---|
| `shell-boot-1.png` | `?mock=android&sessiondelay=1000` | `settle400` |
| `shell-boot-2.png` | `?mock=android&fixdelay=1200` | `settle400` |
| `shell-live-light.png` | `?mock=android&scheme=light` | `live` — races `ARAHARAH-LIVE` on console, 2s tile settle |
| `shell-live-dark.png` | `?mock=android&scheme=dark` | `live` |
| `shell-denied.png` | `?mock=tdesktop` | `denied` — waits `.lamp[data-state="denied"]`, 90s budget (the 60s chat-pin poll must elapse first) |
| `shell-chat-pin.png` | `?mock=tdesktop&pin=1` | `live` |
| `gallery-compare.png` | Side-by-side against `stitch_minimalist_route_navigator/navigate_map_overview/screen.png` | — |
Success prints `CAPTURE OK — 7/7 non-blank captures under web/screenshots/`.
Two capture legs double as runtime verification:
- **The theme honesty check** — the `live` legs read `document.documentElement.dataset.tgTheme` after the `ARAHARAH-LIVE` signal and throw if it does not equal the requested `colorScheme`. A themed screenshot cannot silently show the wrong theme.
- **The GEO-03 runtime probe** — after boot-to-live, `page.evaluate` reads `localStorage.length`, `sessionStorage` keys, `document.cookie`, and `indexedDB.databases()`, and fails the run on any non-empty app storage surface. One allowlisted exception: the official `telegram-web-app.js` SDK writes `__telegram__initParams`, which is the SDK's own launch bookkeeping and is not app data. Any *other* key fails.
A timeout is always a hard failure with a diagnostic naming which console signals fired — never a silent skip, never a blank-but-passing shot.
---
## CI integration
**No CI/CD pipeline is detected.** There is no `.github/workflows/`, no `.gitea/workflows/`, and no other workflow definition in the repository.
<!-- VERIFY: automated CI — no workflow directory exists in the working tree as of this writing. The repository is hosted on a private Gitea instance (`http://gitmirah:3000/c-fu/arah2.git`); if a server-side runner was configured outside the repository, this section is out of date. -->
The full green check a contributor must run by hand before pushing:
```bash
npm run bot:test
npm run web:test
npm test -w theme
npm run bot:typecheck
npm run web:typecheck
npm run audit # token-drift audit on the real dist/rams.css
npm run build # required before web:test's BA cases can pass
```
Order matters in one place: `web/test/build-artifact.test.mjs` reads `web/dist`, so **`npm run build` must run before `npm run web:test`** or BA1–BA6 all fail with the build-first message.
---
## Quick reference
| I want to… | Command |
|---|---|
| Run every bot test | `npm run bot:test` |
| Run every Mini App test | `npm run web:test` |
| Run the theme audit tests | `npm test -w theme` |
| Typecheck the Worker | `npm run bot:typecheck` |
| Typecheck the Mini App | `npm run web:typecheck` |
| Audit the real compiled CSS | `npm run audit` |
| Add a bot test | new `bot/test/<subject>.test.mjs` — pick up automatically |
| Add a Mini App test | new `web/test/<subject>.test.mjs` — pick up automatically |
| Regenerate design screenshots | `npm run capture` |
| Regenerate Mini App screenshots | `npm run web:capture` |
**Related docs:** [README](../README.md) for the command table and the HTTP surface these suites cover · [ARCHITECTURE](ARCHITECTURE.md) for why the Worker/Mini App split is what it is · [CONFIGURATION](CONFIGURATION.md) for the secrets the `bot:` tests substitute with `12345:TESTTOKEN`.
+301
View File
@@ -0,0 +1,301 @@
<!-- generated-by: gsd-doc-writer -->
# @araharah/theme
The **Telemetry Rams** design system as a Tailwind v4 CSS-first token package: every color, spacing, type, and radius value in arah² is defined once in `src/tokens.css`, compiled to `dist/rams.css`, and consumed by `@araharah/web`. The package also hosts the MapLibre basemap style fork, the self-hosted typefaces, and the audit / screenshot evidence tooling.
Part of the [arah² monorepo](../README.md) — see the root [README](../README.md) and [`docs/`](../docs/) for bot and Mini App context.
---
## Contents
| Path | What it is |
| --- | --- |
| `src/tokens.css` | Single source of truth for every numeric design value: `@theme static` color / spacing / font / radius tokens plus the `--type-*` typography scale |
| `src/theme.css` | Entry stylesheet — imports Tailwind, `tokens.css`, `components.css`; declares the self-hosted `@font-face` rules and the Telegram light/dark shell slots |
| `src/components.css` | Component layer (`@layer components`): buttons, inputs, telemetry cards, event chips, maneuver banner, checkbox, toggle, and the `.text-*` typography helpers |
| `src/sheet.html` | Component sheet — the rendered proof that every component derives from tokens |
| `gallery.html` | DSGN-02 approval gallery: mockup reference render vs. implementation capture, side by side |
| `basemap/build.mjs` | Builds `basemap/liberty-rams.json` by forking the OpenFreeMap `liberty` style and deriving every cartographic color from `tokens.css` |
| `basemap/liberty-rams.json` | The generated MapLibre style — imported directly by `web/src/map/map.ts` |
| `basemap/index.html` | MapLibre GL proof page over KL, with a placeholder route vector |
| `scripts/audit.mjs` | Off-token audit: scans rendered CSS and flags any value that does not derive from `tokens.css` |
| `scripts/audit.test.mjs` | TDD harness for the audit (12 contract tests, zero dependencies) |
| `scripts/screenshot.mjs` | Playwright capture toolchain — writes `screenshots/` |
| `fonts/geist/`, `fonts/space-grotesk/` | Self-hosted woff2 subsets (SIL Open Font License 1.1) |
```mermaid
graph TD
TOK["src/tokens.css — colors, spacing, type, radius"]
COMP["src/components.css — component classes"]
ENTRY["src/theme.css — entry stylesheet"]
CLI["@tailwindcss/cli"]
DIST["dist/rams.css — built stylesheet"]
BUILD["basemap/build.mjs"]
STYLE["basemap/liberty-rams.json"]
WEB["@araharah/web — Mini App shell"]
AUDIT["scripts/audit.mjs"]
SHOTS["scripts/screenshot.mjs"]
GAL["gallery.html + src/sheet.html + basemap/index.html"]
TOK --> COMP
TOK --> ENTRY
COMP --> ENTRY
ENTRY --> CLI
CLI --> DIST
TOK --> BUILD
BUILD --> STYLE
DIST --> WEB
STYLE --> WEB
TOK --> AUDIT
GAL --> AUDIT
GAL --> SHOTS
SHOTS --> GAL
classDef tokens fill:#EB8008,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
classDef build fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
classDef out fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1E1B14
class TOK,COMP,ENTRY tokens
class CLI,BUILD,AUDIT,SHOTS build
class DIST,STYLE,WEB,GAL out
```
---
## Installation
`@araharah/theme` is a workspace package — it is never published. Consumers depend on it by name and install from the monorepo root:
```bash
git clone http://gitmirah:3000/c-fu/arah2.git
cd arah2
npm install
```
Requires Node.js and npm. `basemap/build.mjs` needs Node >= 18 (global `fetch`), and `basemap:preview` shells out to `python3`.
<!-- VERIFY: Node.js minimum version — no `engines` field or `.nvmrc` exists in the repository; `basemap/build.mjs` documents `Node >= 18` in its header comment. -->
---
## Quick start
```bash
npm run build -w theme # src/theme.css -> dist/rams.css (minified)
npm run sheet # build, then open theme/src/sheet.html in a browser
npm run audit # audit the rendered CSS for off-token values
```
---
## Scripts
| Command | Description |
| --- | --- |
| `npm run build -w theme` | `tailwindcss -i ./src/theme.css -o ./dist/rams.css --minify` |
| `npm run send -w theme` | Copy `fonts/geist` and `fonts/space-grotesk` into `dist/fonts/` so the built stylesheet ships with its typefaces |
| `npm run sheet -w theme` | `build`, then print the path to `src/sheet.html` (open it in a browser) |
| `npm run gallery -w theme` | `build` + `capture`, then serve the workspace on `:8123` and block on `basemap:preview` |
| `npm run basemap -w theme` | `node basemap/build.mjs` — fetch OpenFreeMap `liberty`, derive the Rams cartography, write `basemap/liberty-rams.json` |
| `npm run basemap:preview -w theme` | `python3 -m http.server 8123` — serve `theme/` as docroot so `dist/rams.css`, the woff2 fonts, and `basemap/` resolve |
| `npm run audit -w theme` | `node ./scripts/audit.mjs ./dist/rams.css` — off-token audit |
| `npm run capture -w theme` | `node ./scripts/screenshot.mjs` — Playwright captures into `theme/screenshots/` |
| `npm test -w theme` | `node ./scripts/audit.test.mjs` — the audit's contract tests |
The root `package.json` delegates `sheet`, `gallery`, `audit`, and `capture` to this workspace, so `npm run audit` from the root is the same command.
### Testing
`npm test` runs a plain Node harness — no test framework, no dev-server dependency. `audit.test.mjs` spawns `audit.mjs` against fixtures under `/tmp/opencode/audit-test/` and asserts exit codes:
| Contract | Expectation |
| --- | --- |
| T1 | Stylesheet using only token values → exit `0` |
| T2 | Off-token color / non-zero radius / off-token font / shadow / blur / backdrop-filter → exit `1`, each listed as `file:line: declaration` |
| T3 | `tokens.css` drifting below 40 colors → exit `2` |
| T4 | Any `--radius-*` token other than zero → exit `2` |
| T5 | The real `dist/rams.css` build → exit `0` |
| T6 | Off-token values inside `@layer components` → flagged |
| T7 | `@layer utilities` only audits classes the implementation pages actually wear |
| T8 | Inline `style="…"` attributes are audited too |
| T9 | The deprecated bare `--radius` key drives bare `rounded` at zero |
| T10–T12 | Literal `font:` shorthands, named colors / `oklch()` and friends, and spacing + typography numerics are flagged |
`audit.mjs` exit codes: `0` clean, `1` violations (each printed as `file:line: declaration`), `2` token-sheet drift or an error.
### Coverage
No coverage threshold configured. The audit itself is the enforcement mechanism: it is a token-conformance gate, not a line-coverage gate.
---
## Usage
### Importing the built stylesheet
`@araharah/web` pulls the package in from its shell stylesheet:
```css
/* web/src/shell.css */
@import "@araharah/theme/dist/rams.css";
```
That single import brings the full token layer (`@theme static`, so every token survives compilation as a plain CSS custom property), the `@layer components` class API, the self-hosted `@font-face` rules, and the Telegram light/dark shell slots `--shell-bg` / `--shell-on-surface`.
### Consuming tokens in your own CSS
Because `@theme static` forces every token into the output, tokens are addressable as custom properties from anywhere — no Tailwind build step needed:
```css
.surface {
background: var(--color-surface);
border: 1px solid var(--color-matte-black);
padding: var(--spacing-space-md);
border-radius: var(--radius-md); /* resolves to 0 — Rams forbids roundedness */
}
.headline {
font: var(--type-headline-md);
letter-spacing: var(--type-headline-md-tracking);
}
```
Note the convention: `font:` always takes the compound `--type-*` token and letter-spacing takes the `-tracking` companion, because the CSS `font` shorthand cannot carry tracking.
### Component classes
`src/components.css` is the public component API. Every declaration in it is a token reference; the only literals are the machined border geometry (1px / 2px rules, 3px focus brackets) and the exact 18×18 checkbox square.
```html
<button class="btn-primary">Recenter</button>
<button class="btn-secondary">Layers</button>
<label class="checkbox"><input type="checkbox" checked></label>
<div class="toggle">
<button class="toggle-option" aria-pressed="true">Off</button>
<button class="toggle-option" aria-pressed="false">On</button>
</div>
<div class="card">
<div class="card-header"><span>Traffic</span><span class="card-ref">KL-01</span></div>
<div class="card-content"><span class="text-telemetry-num">2.4</span><span class="text-label-sm">KM AHEAD</span></div>
</div>
<span class="chip-traffic">Congestion</span>
<span class="chip-hazard">Crash</span>
<span class="chip-safe">Clear</span>
<div class="maneuver-banner">
<span class="maneuver-arrow">→</span>
<span class="text-label-sm">STEP</span>
<span class="text-telemetry-num">320 m</span>
</div>
```
### Importing the basemap style
```ts
// web/src/map/map.ts
import ramsStyle from "@araharah/theme/basemap/liberty-rams.json";
```
The style object is never re-tinted at runtime — MapLibre receives it as-is.
---
## How the tokens are defined
`src/tokens.css` holds every value and nothing else does. Its header records the vocabulary as copied verbatim from the frontmatter of `stitch_minimalist_route_navigator/telemetry_rams/DESIGN.md`, so mockup markup maps 1:1 onto utilities (`bg-signal-orange`, `px-margin`, `gap-space-md`).
- **`@theme static`** — Tailwind v4 tree-shakes unused theme variables by default; `static` forces every token into the compiled output so the package stays consumable as plain CSS custom properties, including by the basemap derivation script.
- **Radius at absolute zero** — every `--radius*` key is `0px`. The deprecated bare `--radius` key is set too, because that is the key v4 drives bare `rounded` from.
- **Rams-forbidden primitives disabled at the key** — `--shadow`, `--shadow-inner`, `--drop-shadow`, and `--blur` are all set to `initial`, so `shadow-*` / `blur-*` utilities cannot compile from any source.
- **Typography** — `--type-*` compound tokens follow the grammar `weight size/line-height family…`, each paired with a `-tracking` companion.
- **`--shell-bg` / `--shell-on-surface`** — declared in `src/theme.css`, mapping the OS color scheme onto shell-level surfaces only; components and cartography stay Rams-constant under both Telegram themes.
The four mockup screens under `stitch_minimalist_route_navigator/` are the read-only class vocabulary reference. `src/theme.css` lists them as explicit `@source` paths and excludes `scripts/` and `basemap/` from the auto-scan, so prose can never mint a utility class. The mockup files themselves are never edited.
---
## Audit
`scripts/audit.mjs` treats "zero off-token values" as evidence rather than an eyeball claim. It parses `src/tokens.css` into an allowlist, then scans **rendered** CSS — `dist/rams.css` plus the inline `<style>` blocks and `style="…"` attributes of every registered implementation page — and flags:
- hex / `rgb()` / `hsl()` / named-keyword / `oklch()`-family color literals that are not token values
- font families outside the sanctioned stacks, and any literal `font:` shorthand
- spacing numerics on `padding` / `margin` / `gap` that do not equal a `--spacing-*` token
- typography numerics (`font-size`, `font-weight`, `line-height`, `letter-spacing`) outside the `--type-*` scale
- any non-zero `border-radius`, any non-`none` shadow, any blur or `backdrop-filter`
Seven scope decisions are documented in the script header; the notable ones: Tailwind's own `@layer properties / theme / base` are plumbing and skipped, `@layer utilities` rules are only audited when the selector names a class a registered implementation page actually wears, and `rounded-full`'s full-circle primitive is treated as spec geometry like the 18×18 checkbox square.
The page registry lives at `scripts/audit.mjs:118` — `IMPLEMENTATION_PAGES`. Any new page that renders Rams components must be appended there, or the audit will exit `2` on a missing path.
```bash
npm run audit -w theme
node scripts/audit.mjs --tokens ./src/tokens.css --pages src/sheet.html ./dist/rams.css
```
---
## Basemap derivation
`basemap/build.mjs` fetches the OpenFreeMap `liberty` vector style and forks it into Telemetry Rams cartography. Every cartographic color is derived from `tokens.css` inside the script — there are no hand-tuned hexes in the output JSON, only the derivation parameters (saturation multipliers, per-role lightness targets).
- **Derivation core** — `desaturate(tokenHex, satMultiplier, lightness)`: hue genes come from the source token, saturation is clamped into a calibrated `[0.06, 0.12]` band, lightness snaps to the role target. Ground is `mix(soft-beige, chassis-subtle)`.
- **Accent guard** — `signal-orange`, `functional-red`, and `olive-green` are excluded by token name *and* by hex value; the build fails if any of the three (hex or `rgb()` form) appears in the output layers. Those accents are reserved for the route line and hazard semantics that later phases render.
- **Hierarchy by weight only** — zoom-interpolated color ramps flatten to role constants while width/opacity ramps are preserved.
- **Labels** — matte-black at reduced opacity with a ground-tone halo and `text-halo-blur: 0`.
- **`building-3d` removed** — fill-extrusion is faux 3D, rejected by the design system. The removal is recorded in `style.metadata.rams-removed-layers`.
- **Font probing** — the intended Rams label stacks are probed against the OpenFreeMap glyphs endpoint at build time. When they are not hosted (the Phase 1 case), the closest hosted stack is substituted and the substitution is recorded in `style.metadata.rams-font-substitutions` as honest fallback provenance.
The generated style also carries a full derivation report in `style.metadata.rams-cartography`: every role with its color and the token recipe behind it, the excluded accents, and the list of untouched layers.
```bash
npm run basemap -w theme # rebuild liberty-rams.json (needs network)
npm run basemap:preview -w theme # serve theme/ on http://localhost:8123/basemap/
```
---
## Gallery and captures
`scripts/screenshot.mjs` launches chromium, serves `theme/` on an ephemeral loopback port as docroot, and writes six PNGs to `theme/screenshots/` (gitignored):
| Capture | Source | Viewport |
| --- | --- | --- |
| `sheet-full.png` | `src/sheet.html` | 390 px, full page |
| `mockup-<screen>.png` ×4 | the four mockup screens via `file://` | 390 px |
| `basemap-kl.png` | `basemap/index.html` | 1280 × 800 |
The mockup renders need network (CDN Tailwind and Google fonts in the read-only mockups). On capture failure or an undersized / blank shot, the committed `screen.png` for that screen is copied in as a fallback — the mockups are reference material and are never edited. The basemap capture waits for the proof page's own `RAMS-BASEMAP-READY` console signal before settling on tiles. Missing, undersized (< 10 KB), or failed captures exit `1`.
```bash
npm run capture -w theme
npm run gallery -w theme
# → http://localhost:8123/gallery.html and http://localhost:8123/basemap/
```
`gallery.html` is the approval-gate mechanism template: every later UI phase adds one comparison row (mockup reference render on the left, Playwright implementation capture on the right). It is CDN-free by construction — only the built stylesheet plus local captures.
`npm run gallery` ends with a **blocking** `basemap:preview` server. Stop it with `Ctrl-C`.
---
## Notes
- `dist/` and `screenshots/` are gitignored build output. After a fresh checkout, run `npm run build -w theme` before opening `src/sheet.html` or `gallery.html`.
- Typefaces are self-hosted woff2 subsets under `fonts/` — no Google Fonts CDN. `font-display: block` is deliberate: no fallback-font flash.
- MapLibre GL v4 is loaded from a CDN on `basemap/index.html`; the product build wires its own copy.
- Windows `*:Zone.Identifier` metadata files may be present in the tree — exclude them from any tooling.
## Links
- Root [README](../README.md) — monorepo overview
- [`docs/GETTING-STARTED.md`](../docs/GETTING-STARTED.md) · [`docs/DEVELOPMENT.md`](../docs/DEVELOPMENT.md) · [`docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) · [`docs/TESTING.md`](../docs/TESTING.md)
- `stitch_minimalist_route_navigator/telemetry_rams/DESIGN.md` — the design-system document the token vocabulary is copied from
- `DESIGN.md` — Telemetry Rams philosophy
<!-- VERIFY: the private Gitea clone URL (`http://gitmirah:3000/c-fu/arah2.git`) is taken from the user-provided facts and is not verifiable from repository contents alone. -->
+207
View File
@@ -0,0 +1,207 @@
<!-- generated-by: gsd-doc-writer -->
# @araharah/web
The arah² Telegram Mini App — a MapLibre GL navigation shell built with Vite and TypeScript. It owns the three-source location acquisition chain, an in-memory-only session store, the Telegram WebApp integrations (theme slots, BackButton, safe-area insets), and the Rams chrome (status strip, boot/denied/signal-lost cards, recenter plate).
Part of the [arah²](../README.md) monorepo (workspaces: `theme`, `bot`, `web`). Private package — not published to a registry.
<!-- VERIFY: the deploy step mounts web/dist/ via the bot worker's ASSETS binding (see ../bot wrangler config) -->
---
## Install
Installed through the monorepo root (npm workspaces), not standalone:
```bash
# from the repository root
npm install
```
`@araharah/theme` is a workspace dependency (`"@araharah/theme": "*"`), so the theme package must be built before this one:
```bash
npm run build -w theme # emits theme/dist/rams.css + the basemap style
```
---
## Quick start
```bash
npm run web:dev # vite dev server on :5173 (strictPort)
```
With no Telegram client present, `src/mock/telegram-mock.ts` installs a fake `window.Telegram.WebApp` automatically in dev (`import.meta.env.DEV`), so the shell boots to `LIVE` in a plain browser with the KL default fix. Without it, the app renders its denied card — still no blank screen.
Production build (what the Worker serves):
```bash
npm run web:build # theme build + vite build -> web/dist
```
---
## Scripts
Run from the package directory, or from the root with `npm run <script> -w web`.
| Command | Description |
|---|---|
| `npm run dev` | Vite dev server on port 5173 (`strictPort: true`, `vite.config.ts`). |
| `npm run build` | Production build to `dist/` — the harness mock and fetch stubs are dead-code-eliminated. |
| `npm run build:harness` | `vite build --mode harness` to `dist-harness/` — `__ARAHARAH_HARNESS__` is `true`, so the mock installer and runner-page stubs are compiled in. Never deployed. |
| `npm test` | Zero-dependency runner (`node test/runner.mjs`) that auto-discovers `test/*.test.mjs`. |
| `npm run typecheck` | `tsc --noEmit` over `src` (strict, `noUncheckedIndexedAccess`, `verbatimModuleSyntax`). |
| `npm run capture` | Playwright harness (`scripts/capture.mjs`): serves `dist-harness/`, drives every boot state, writes `screenshots/`, and runs the runtime storage probe. |
`dist/` and `dist-harness/` must never share an outDir — the two builds would clobber each other and void the production-artifact gate.
---
## Module map
```
src/
├── main.ts boot choreography — order is load-bearing
├── shell.css the Rams chrome; imports @araharah/theme/dist/rams.css
├── vite-env.d.ts ambient __ARAHARAH_HARNESS__ declaration
├── telegram/
│ ├── webapp.ts the one typed accessor for window.Telegram.WebApp (TelegramWebAppLike)
│ ├── theme.ts Telegram theme signal -> [data-tg-theme] slot switch
│ ├── safe-area.ts env(safe-area-inset-*) ∪ WebApp.safeAreaInset -> CSS custom properties
│ └── backbutton.ts BackButton total wire (root press -> WebApp.close())
├── session/
│ ├── client.ts POST /api/session with the verbatim initData string
│ └── store.ts memory-only token holder, 900s TTL — no storage API anywhere
├── location/
│ ├── chain.ts acquireFix(): the locked telegram -> webview -> chat-pin chain
│ ├── telegram.ts LocationManager wrapper + Android stale-fix nudge
│ ├── webview.ts navigator.geolocation (maximumAge: 0)
│ ├── chat-pin.ts bounded poll of /api/location + ASK VIA CHAT trigger
│ └── types.ts Fix, FixSource, LocationState, KL_CENTER
├── map/
│ ├── map.ts MapLibre v6 bootstrap; returns null when WebGL2 is absent
│ ├── follow.ts following/free camera controller
│ └── marker.ts position marker + accuracy circle + heading cone
├── ui/
│ ├── strip.ts status strip (validating/acquiring/live/lost/denied/awaiting)
│ ├── cards.ts boot / denied / signal-lost cards + gantry blink
│ └── recenter.ts follow/recenter plate
├── i18n/strings.ts every user-facing string, en + ms (the copy gate)
└── mock/telegram-mock.ts Mock-Telegram harness — excluded from dist by BA5
```
```mermaid
graph TD
MAIN["src/main.ts — boot choreography"]
WA["src/telegram/webapp.ts"]
TH["src/telegram/theme.ts"]
SA["src/telegram/safe-area.ts"]
BB["src/telegram/backbutton.ts"]
SESS["src/session/client.ts"]
STORE["src/session/store.ts"]
CHAIN["src/location/chain.ts"]
TG["src/location/telegram.ts"]
WV["src/location/webview.ts"]
PIN["src/location/chat-pin.ts"]
TYPES["src/location/types.ts"]
MAP["src/map/map.ts"]
FOLLOW["src/map/follow.ts"]
MARK["src/map/marker.ts"]
STRIP["src/ui/strip.ts"]
CARDS["src/ui/cards.ts"]
REC["src/ui/recenter.ts"]
STR["src/i18n/strings.ts"]
MOCK["src/mock/telegram-mock.ts"]
MAIN --> WA
MAIN --> TH
MAIN --> SA
MAIN --> BB
MAIN --> SESS
MAIN --> CHAIN
MAIN --> MAP
MAIN --> STRIP
MAIN --> CARDS
MAIN --> REC
MAIN --> MOCK
SESS --> STORE
CHAIN --> TG
CHAIN --> WV
CHAIN --> PIN
CHAIN --> TYPES
TG --> TYPES
WV --> TYPES
PIN --> TYPES
MAP --> FOLLOW
MAP --> MARK
FOLLOW --> TYPES
MARK --> TYPES
STRIP --> TYPES
STRIP --> STR
CARDS --> STR
REC --> STR
```
---
## Usage examples
Acquire a fix through the chain (this is exactly what `main.ts` does):
```ts
import { acquireFix, isFixStale, fixAgeSeconds } from "./location/chain.ts";
import { createSession, } from "./session/client.ts";
import { getWebApp } from "./telegram/webapp.ts";
import { getSession } from "./session/store.ts";
const wa = getWebApp(); // null off-Telegram — the chain handles null
const session = await createSession(wa?.initData ?? "");
if (!session.ok) throw new Error(session.reason); // "malformed" | "tampered" | "stale" | "network"
const { fix, state } = await acquireFix({ wa, getToken: getSession }, null);
if (fix !== null && !isFixStale(fix, Date.now())) {
console.log(fix.source, fixAgeSeconds(fix, Date.now()), "s");
} else {
console.log(state.kind); // "denied" | "awaiting-pin" | ...
}
```
Boot the map on its own (returns `null` rather than throwing on a WebGL2-less device):
```ts
import { bootMap } from "./map/map.ts";
import { createPositionMarker } from "./map/marker.ts";
const map = bootMap(document.getElementById("map")!);
if (map !== null) {
const marker = createPositionMarker();
marker.marker.addTo(map);
marker.update(map, fix);
}
```
---
## Testing
```bash
npm run web:test # or: npm test (inside web/)
npm run web:typecheck
```
`test/runner.mjs` auto-discovers every `test/*.test.mjs` suite and drains the shared registry from `test/harness.mjs`. Suites cover the location chain (`location-chain.test.mjs`), the memory-only session store (`session.test.mjs`), theme slots (`theme.test.mjs`), the en/ms copy gates (`copy-gate.test.mjs`), the no-storage invariant (`no-storage.test.mjs`), and the production build artifact (`build-artifact.test.mjs` — CDN references, self-hosted fonts, hashed MapLibre worker, inlined Rams style, and BA5/BA6 asserting no mock installer or fetch stub ships in `dist/`).
The Playwright capture harness is a separate command (`npm run web:capture`): it serves `dist-harness/`, waits on the `RAMS-BASEMAP-READY` and `ARAHARAH-LIVE` console signals, and writes the state gallery into `screenshots/`.
---
## Related docs
- Root [README.md](../README.md) — monorepo overview and system diagram
- [docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md) — component responsibilities across `theme` / `bot` / `web`
- [docs/DEVELOPMENT.md](../docs/DEVELOPMENT.md) — local setup, build, and PR workflow
- [docs/TESTING.md](../docs/TESTING.md) — the cross-package test strategy
- [DESIGN.md](../DESIGN.md) and `theme/` — the Telemetry Rams design system this shell consumes