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
+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`.