add sume
This commit is contained in:
@@ -1,7 +1,221 @@
|
|||||||
# araharah
|
<!-- generated-by: gsd-doc-writer -->
|
||||||
|
|
||||||
A privacy-respecting, Telegram-first GPS navigator — Rams-inspired instrument-panel design, OpenStreetMap-based routing, KL metro first.
|
# arah²
|
||||||
|
|
||||||
|
A privacy-respecting, Telegram-first GPS navigator — a waze-like route navigator delivered through a Telegram bot and Mini App instead of an app-store install, built on OpenStreetMap data, and designed to Dieter Rams' "less, but better." First users are in the Kuala Lumpur metro.
|
||||||
|
|
||||||
|
The Telegram bot is **arah2bot**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
arah² is an npm-workspaces monorepo with three packages:
|
||||||
|
|
||||||
|
| Package | Name | What it does |
|
||||||
|
|---|---|---|
|
||||||
|
| `theme/` | `@araharah/theme` | The Telemetry Rams design system as a Tailwind v4 CSS-first token package. Every color, spacing, type, and radius value in the project is defined once in `theme/src/tokens.css` and compiled to `theme/dist/rams.css`. Also ships the MapLibre basemap style `theme/basemap/liberty-rams.json` plus gallery/audit/screenshot tooling. |
|
||||||
|
| `bot/` | `@araharah/bot` | A [grammY](https://grammy.dev) Telegram bot running on a Cloudflare Worker. It serves the `/telegram/webhook` route, four JSON API routes, keeps TTL-only chat sessions in Workers KV, and serves the Mini App's static assets through a wrangler assets binding. |
|
||||||
|
| `web/` | `@araharah/web` | The Telegram Mini App: a MapLibre GL navigation shell built with Vite. Owns the three-source location acquisition chain, an in-memory session store, and the Telegram WebApp integrations (theme slots, BackButton, safe-area insets). |
|
||||||
|
|
||||||
|
The Worker is the single origin. It answers `/api/*` and `/telegram/*` itself (`run_worker_first`) and serves everything else out of `web/dist`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
U["Telegram client — arah2bot chat + Mini App webview"]
|
||||||
|
W["Cloudflare Worker — araharah-bot"]
|
||||||
|
KV[("Workers KV — chat_sessions, TTL 900s")]
|
||||||
|
TG["Telegram Bot API"]
|
||||||
|
APP["Mini App assets — web/dist via ASSETS binding"]
|
||||||
|
MAP["MapLibre GL — Rams basemap, OpenFreeMap tiles"]
|
||||||
|
THEME["@araharah/theme — tokens.css + liberty-rams.json"]
|
||||||
|
|
||||||
|
U -->|"POST /telegram/webhook (secret-token header)"| TG
|
||||||
|
TG --> W
|
||||||
|
U -->|"GET Mini App"| APP
|
||||||
|
APP --> W
|
||||||
|
W -->|"POST /api/session, /api/location-fallback"| TG
|
||||||
|
APP -->|"POST /api/session — initData HMAC"| W
|
||||||
|
APP -->|"GET /api/location — Bearer token"| W
|
||||||
|
W --> KV
|
||||||
|
APP --> MAP
|
||||||
|
THEME -.->|"inlined at build time"| MAP
|
||||||
|
THEME -.->|"dist/rams.css"| APP
|
||||||
|
|
||||||
|
classDef telegram fill:#EB8008,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
|
||||||
|
classDef worker fill:#1A1A1A,stroke:#EB8008,stroke-width:2px,color:#F7F0E3
|
||||||
|
classDef storage fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
|
||||||
|
classDef web fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1E1B14
|
||||||
|
class U,TG telegram
|
||||||
|
class W worker
|
||||||
|
class KV storage
|
||||||
|
class APP,MAP,THEME web
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Clone the repository and install workspace dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone http://gitmirah:3000/c-fu/arah2.git
|
||||||
|
cd arah2
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Node.js and npm (no version is pinned in the repo — use a current LTS). Wrangler handles Cloudflare authentication on first use via `wrangler dev` / `wrangler deploy`.
|
||||||
|
|
||||||
|
<!-- VERIFY: Node.js minimum version — no `engines` field or `.nvmrc` exists in the repository; confirm the supported floor with the maintainers. -->
|
||||||
|
|
||||||
|
For the bot's local secrets, copy the example file and fill it in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp bot/.dev.vars.example bot/.dev.vars
|
||||||
|
```
|
||||||
|
|
||||||
|
`bot/.dev.vars` is gitignored and must never be committed. It holds `BOT_TOKEN` (from [@BotFather](https://t.me/BotFather)), `WEBHOOK_SECRET`, and `WEBHOOK_URL` (full origin; `set-webhook.mjs` appends `/telegram/webhook` if only an origin is given).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
1. **Build the design tokens and the Mini App.** `theme/dist/rams.css` is an input to the web build, so it must exist first.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Run everything locally.** Builds the theme, then starts the Worker on port `8787` with `--local`, serving `/telegram/webhook`, the `/api/*` routes, and `web/dist`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Iterate on the Mini App alone** with Vite on port `5173` (`strictPort`), for map/UI work that doesn't need the bot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run web:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Talking to the bot
|
||||||
|
|
||||||
|
The bot answers in English and Bahasa Melayu. Commands are registered into Telegram's persistent menu by `npm run bot:commands`, which derives its descriptions from `bot/src/i18n/strings.ts` so the menu and the replies cannot drift.
|
||||||
|
|
||||||
|
| Command | Alias | Behaviour |
|
||||||
|
|---|---|---|
|
||||||
|
| `/start` | `/mula` | One-sentence introduction to arah² and its privacy posture. |
|
||||||
|
| `/help` | — | Lists the commands that exist in this phase. |
|
||||||
|
| `/privacy` | `/privasi` | Names exactly what is stored (user id, chat id, language, ~15 min) and exactly what is never stored (location, routes, trip history, profile). |
|
||||||
|
|
||||||
|
### HTTP surface
|
||||||
|
|
||||||
|
The Worker's `fetch` handler routes exactly four paths and returns `404` for anything else. All rejections carry a category only — never a payload, a user id, or a timestamp.
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/telegram/webhook` | grammY webhook adapter. Fails closed with `500` if `WEBHOOK_SECRET` is missing, then constant-time-compares `X-Telegram-Bot-Api-Secret-Token` before any bot processing or KV touch. |
|
||||||
|
| `POST` | `/api/session` | The security gate. The client posts its verbatim `Telegram.WebApp.initData` string (capped at 4 KB, over-cap bodies are rejected `413` before parsing). An HMAC-validated, fresh identity receives a one-time opaque session token. |
|
||||||
|
| `GET` | `/api/location` | Polled by the Mini App with its in-memory `Bearer` token. The KV key derives from the resolved claim's `chat_id`, never from a client-supplied id, so one user's token can never read another user's pin. Returns honest nulls; a missing or expired token is `401`. |
|
||||||
|
| `POST` | `/api/location-fallback` | The "ask via chat" path when geolocation is denied. The bot sends a `request_location` reply keyboard into the user's own chat. Target chat id and locale come from the resolved claim — the request body is never read. A rejected Telegram send is a category-only `502`. |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mint a session from a Mini App initData string
|
||||||
|
curl -X POST http://localhost:8787/api/session --data-binary @initdata.txt
|
||||||
|
# → {"token":"<opaque>"}
|
||||||
|
|
||||||
|
# Poll the location relay with the token in memory
|
||||||
|
curl -H "Authorization: Bearer <opaque>" http://localhost:8787/api/location
|
||||||
|
# → {"pin":null} (honest null, not a fabricated coordinate)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Location acquisition in the Mini App
|
||||||
|
|
||||||
|
`web/src/location/chain.ts` is a three-link state machine with a locked order — **Telegram → WebView → chat pin** — and freshness is a separate axis from availability. A Telegram fix byte-identical to the last accepted one routes to the nudge, never to success; denial is a state, not an exception. The chain exposes a `Fix`-shaped abstraction with a pluggable cadence and hard-codes no interval, so continuous-mode polling is a later decision, not a rewrite. Past 45 seconds the strip flips to `SIGNAL LOST` and the last marker dims.
|
||||||
|
|
||||||
|
### Consuming the design tokens
|
||||||
|
|
||||||
|
`theme/src/tokens.css` is the single source of truth — 108 custom properties in the `@theme static` block, declared `static` so Tailwind v4's tree-shaking cannot drop tokens the MapLibre basemap derivation and plain-CSS consumers need.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* raw token access — any stylesheet, no Tailwind required */
|
||||||
|
@import "@araharah/theme/dist/rams.css";
|
||||||
|
|
||||||
|
.instrument {
|
||||||
|
background: var(--color-signal-orange);
|
||||||
|
color: var(--color-matte-black);
|
||||||
|
padding: var(--spacing-space-md);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// build-time inlining of the Rams basemap style (never re-set at runtime)
|
||||||
|
import ramsStyle from "@araharah/theme/basemap/liberty-rams.json";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Design system
|
## Design system
|
||||||
|
|
||||||
The Telemetry Rams design system lives in `theme/` as a Tailwind v4 CSS-first token package — the single source of truth for every color, spacing, typography, and radius value in the project. Consume the tokens by importing `theme/src/tokens.css` (plain CSS custom properties, usable by any stylesheet or the MapLibre basemap) or the compiled stylesheet produced by `npm run build`. The token vocabulary mirrors the mockup screens verbatim, so markup derived from them maps one to one. Rams philosophy lives on in `DESIGN.md`, which is superseded for specifics — values are never restated outside the token package.
|
The Telemetry Rams design system lives in `theme/` as a Tailwind v4 CSS-first token package — the single source of truth for every color, spacing, typography, and radius value in the project. Consume the tokens by importing `theme/src/tokens.css` (plain CSS custom properties, usable by any stylesheet or the MapLibre basemap) or the compiled stylesheet produced by `npm run build`. The token vocabulary mirrors the mockup screens verbatim, so markup derived from them maps one to one. Rams philosophy lives on in `DESIGN.md`, which is superseded for specifics — values are never restated outside the token package.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build -w theme # src/theme.css → dist/rams.css (minified)
|
||||||
|
npm run sheet # build + print the sheet.html path
|
||||||
|
npm run gallery # build + screenshot → serve gallery.html and basemap on :8123
|
||||||
|
npm run audit # off-token audit of the rendered CSS — exits non-zero on violations
|
||||||
|
npm run capture # Playwright screenshots of the token sheet
|
||||||
|
```
|
||||||
|
|
||||||
|
The audit script treats "zero off-token values" as evidence rather than an eyeball claim: it scans rendered CSS and flags any color, spacing, typography, radius, shadow, or blur value that does not derive from `tokens.css`. Fonts (Geist, Space Grotesk) are self-hosted from `theme/fonts/` — both SIL Open Font License 1.1, no Google Fonts CDN.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Run from the repo root, or with `-w <workspace>`.
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `npm run build` | Build the theme, then the Mini App |
|
||||||
|
| `npm run dev` | Build, then run the Worker locally on port `8787` |
|
||||||
|
| `npm run deploy` | Build, then `wrangler deploy` + `set-webhook.mjs` |
|
||||||
|
| `npm run bot:test` / `npm run web:test` | Run each package's zero-dependency test suite |
|
||||||
|
| `npm run bot:typecheck` / `npm run web:typecheck` | `tsc --noEmit` |
|
||||||
|
| `npm run bot:commands` | Register the persistent Telegram command menu (EN + MS) |
|
||||||
|
| `npm run web:capture` | Capture Mini App screenshots |
|
||||||
|
| `npm run web:build:harness` | Dual build into `dist-harness/` with the mock installer and fetch stubs |
|
||||||
|
| `npm run sheet` / `gallery` / `audit` / `capture` | Design-token tooling (see above) |
|
||||||
|
|
||||||
|
The two build outputs must never share an `outDir` — the harness build emits mock fetch stubs that production must not carry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Privacy
|
||||||
|
|
||||||
|
Privacy is structural, not a policy page:
|
||||||
|
|
||||||
|
- Every write to the `chat_sessions` KV namespace carries an explicit `expirationTtl` on its call line; the storage test suite greps for this.
|
||||||
|
- The persisted claim is exactly four fields — user id, chat id, issued-at, language code — under a 900-second TTL. Minting rotates any prior token for the chat.
|
||||||
|
- The Mini App holds its session token in a module-scope closure. There is no `localStorage`, `sessionStorage`, cookie, IndexedDB, or Cache API anywhere in `web/src` — the `no-storage` test suite pins this.
|
||||||
|
- Nothing durable, nothing reconstructable. A token cannot outlive the 15-minute initData freshness proof that minted it.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
theme/ @araharah/theme — tokens.css, components.css, theme.css, fonts/, basemap/, scripts/
|
||||||
|
bot/ @araharah/bot — src/webhook.ts entry, bot/, kv/, security/, i18n/, scripts/, wrangler.jsonc
|
||||||
|
web/ @araharah/web — src/main.ts entry, location/, map/, session/, telegram/, ui/, mock/
|
||||||
|
stitch_minimalist_route_navigator/ read-only mockup screens (class-vocabulary source for the theme build)
|
||||||
|
DESIGN.md Rams philosophy — superseded for any specific value
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Private repository. No license file is present; all rights reserved by the project owner.
|
||||||
|
|
||||||
|
<!-- VERIFY: repository licensing — no `LICENSE` file exists in the working tree, and the project is marked `private: true` in `package.json`. -->
|
||||||
+268
@@ -0,0 +1,268 @@
|
|||||||
|
<!-- generated-by: gsd-doc-writer -->
|
||||||
|
|
||||||
|
# @araharah/bot
|
||||||
|
|
||||||
|
The arah² Telegram bot — **arah2bot**. A [grammY](https://grammy.dev) bot running on a single Cloudflare Worker that receives Telegram updates, keeps TTL-only chat sessions in Workers KV, gates the Mini App's session handshake, and serves the Mini App's static assets from the same origin.
|
||||||
|
|
||||||
|
Part of the [arah²](../README.md) monorepo. For the design system see [`theme/`](../theme/), for the Mini App see [`web/`](../web/), and for cross-package architecture see [`docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
Three jobs, all inside one Worker fetch handler (`src/webhook.ts`):
|
||||||
|
|
||||||
|
- **Chat commands** — `/start` (`/mula`), `/help`, `/privacy` (`/privasi`) reply with plain text in English or Bahasa Melayu.
|
||||||
|
- **Session gate** — validates a Mini App's verbatim `Telegram.WebApp.initData` string by HMAC and mints an opaque, 15-minute session token.
|
||||||
|
- **Location relay** — when the Mini App can't get a fix, the bot sends a `request_location` reply keyboard into the user's own chat; the shared pin lands in KV and the app polls for it.
|
||||||
|
|
||||||
|
Privacy is structural rather than promised: every write to KV carries an explicit `expirationTtl`, and the persisted record is exactly the four claim fields plus exactly the three location fields — nothing else can be serialized. A test (`PG1`) greps the storage sources to hold that line.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
TG["Telegram Bot API"]
|
||||||
|
W["Cloudflare Worker — araharah-bot"]
|
||||||
|
BOT["grammY bot — commands + location keyboard"]
|
||||||
|
SEC["initData HMAC gate"]
|
||||||
|
KV[("KV chat_sessions — session TTL 900s, pin TTL 300s")]
|
||||||
|
APP["Mini App — web/dist via ASSETS binding"]
|
||||||
|
|
||||||
|
TG -->|"POST /telegram/webhook + secret header"| W
|
||||||
|
W --> BOT
|
||||||
|
W --> SEC
|
||||||
|
SEC -->|"mints opaque token"| KV
|
||||||
|
BOT -->|"request_location keyboard"| TG
|
||||||
|
TG -->|"shared pin message"| BOT
|
||||||
|
BOT -->|"writePin loc:chat_id"| KV
|
||||||
|
APP -->|"POST /api/session — initData"| W
|
||||||
|
APP -->|"GET /api/location — Bearer"| W
|
||||||
|
APP -->|"POST /api/location-fallback — Bearer"| W
|
||||||
|
W --> KV
|
||||||
|
|
||||||
|
classDef telegram fill:#EB8008,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
|
||||||
|
classDef worker fill:#1A1A1A,stroke:#EB8008,stroke-width:2px,color:#F7F0E3
|
||||||
|
classDef storage fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
|
||||||
|
classDef web fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1E1B14
|
||||||
|
class TG telegram
|
||||||
|
class W,BOT,SEC worker
|
||||||
|
class KV storage
|
||||||
|
class APP web
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
`@araharah/bot` is private and consumed through the workspace, not installed on its own. From the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone http://gitmirah:3000/c-fu/arah2.git
|
||||||
|
cd arah2
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Then create the local secrets file (gitignored — never commit it):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp bot/.dev.vars.example bot/.dev.vars
|
||||||
|
```
|
||||||
|
|
||||||
|
`bot/.dev.vars` holds three values:
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `BOT_TOKEN` | Telegram Bot API token from [@BotFather](https://t.me/BotFather). |
|
||||||
|
| `WEBHOOK_SECRET` | Secret token Telegram echoes in `X-Telegram-Bot-Api-Secret-Token`. **Must be non-empty** — the webhook fails closed with `500` otherwise. |
|
||||||
|
| `WEBHOOK_URL` | Full origin for the Worker; `set-webhook.mjs` appends `/telegram/webhook` when only an origin is set. |
|
||||||
|
|
||||||
|
<!-- VERIFY: Wrangler authenticates to your Cloudflare account on first `wrangler dev` / `wrangler deploy` — confirm which login flow your team uses (OAuth vs. `CLOUDFLARE_API_TOKEN`). -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
1. **Build the workspace** — `web/dist` is served through the Worker's assets binding, so the Mini App must exist first.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Run the Worker locally** on port `8787` with local KV emulation.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Check the surface.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -i http://localhost:8787/api/location
|
||||||
|
# → 401 {"error":"unauthorized"} (no bearer token)
|
||||||
|
|
||||||
|
curl -i -X GET http://localhost:8787/api/session
|
||||||
|
# → 405 Method not allowed (route exists, method is wrong)
|
||||||
|
|
||||||
|
curl -i http://localhost:8787/nope
|
||||||
|
# → 404 Not found
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Register the Telegram-side wiring** after deploying: the webhook, the command menu, and the menu button.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run deploy # wrangler deploy + set-webhook.mjs
|
||||||
|
npm run bot:commands
|
||||||
|
node scripts/set-menu-button.mjs # points the menu button at the Mini App origin
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HTTP surface
|
||||||
|
|
||||||
|
The `fetch` handler in `src/webhook.ts` routes exactly four paths and returns `404` for everything else. Every log line and every rejection body is **category only** — never a payload, a user id, a chat id, or a timestamp.
|
||||||
|
|
||||||
|
| Method | Path | Behaviour |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/telegram/webhook` | grammY's `cloudflare-mod` webhook adapter. Fails closed with `500` when `WEBHOOK_SECRET` is missing or empty — grammY's own secret check silently accepts everything when its secret is `undefined`. Otherwise a constant-time compare of `X-Telegram-Bot-Api-Secret-Token` runs **before** any bot processing or KV touch. The bot is instantiated per request because the token only arrives with `env`. |
|
||||||
|
| `POST` | `/api/session` | The security gate. The client posts its verbatim initData string, capped at 4096 characters (a lying or absent `Content-Length` is caught by a post-read length check, both reject `413` **before** parsing). HMAC-validated and fresh → a one-time opaque token; rejection precedence is `malformed` → `tampered` → `stale`, each a `401` carrying only the category. Minting rotates any prior token for that chat. |
|
||||||
|
| `GET` | `/api/location` | Polled by the Mini App with its in-memory `Authorization: Bearer <token>`. The KV key derives from the **claim's** `chat_id`, never from a client-supplied id, so one user's token can never read another's pin. Honest `{"pin":null}` when nothing is stored; `401` for a missing or expired token; `405` for a non-GET. |
|
||||||
|
| `POST` | `/api/location-fallback` | The "ask via chat" path. The worker asks the bot to send the `request_location` reply keyboard into the chat named by the resolved claim. The request body is never read — neither chat id nor locale is accepted from the client. A rejected Telegram send is a category-only `502`. |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mint a session from a Mini App initData string
|
||||||
|
curl -X POST http://localhost:8787/api/session --data-binary @initdata.txt
|
||||||
|
# → {"token":"<opaque uuid>"}
|
||||||
|
|
||||||
|
# Poll the relay with that token held in memory
|
||||||
|
curl -H "Authorization: Bearer <opaque>" http://localhost:8787/api/location
|
||||||
|
# → {"pin":null}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `/start`, `/mula` | One-sentence introduction to arah² and its privacy posture. |
|
||||||
|
| `/help` | Lists the commands that exist in this phase. |
|
||||||
|
| `/privacy`, `/privasi` | Names exactly what is stored (user id, chat id, language, ~15 min) and what is never stored (location, routes, trip history, profile). |
|
||||||
|
| *shared pin message* | Not a command — a `message` handler records `ctx.message.location` into KV and confirms. |
|
||||||
|
|
||||||
|
Replies are plain text with link previews disabled. There are no inline buttons, callback routes, or reply menus in the command tree — the location reply keyboard in `handlers/location-fallback.ts` is the one deliberate exception, and a test (`CS4`) holds that boundary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Package scripts
|
||||||
|
|
||||||
|
Run from the repo root (`npm run bot:<script>`) or inside `bot/`.
|
||||||
|
|
||||||
|
| Script | Description |
|
||||||
|
|---|---|
|
||||||
|
| `npm test` | Runs `test/runner.mjs`, a hand-rolled zero-dependency harness. |
|
||||||
|
| `npm run typecheck` | `tsc --noEmit` against `src/` with Workers types. |
|
||||||
|
| `npm run dev` | `wrangler dev --port 8787 --local`. |
|
||||||
|
| `npm run deploy` | `wrangler deploy` followed by `scripts/set-webhook.mjs`. |
|
||||||
|
| `npm run commands` | `scripts/register-commands.mjs` — one-time `setMyCommands` in the default and `ms` scopes. |
|
||||||
|
|
||||||
|
`scripts/set-menu-button.mjs` is run directly (`node scripts/set-menu-button.mjs`) rather than through a package script; it points the bot's menu button at a `web_app` URL derived from `WEBHOOK_URL`'s origin.
|
||||||
|
|
||||||
|
The command-menu descriptions are **derived from `src/i18n/strings.ts`**'s `helpMenu` lines, not written inline in the script, so Telegram's registered menu and the `/help` reply cannot drift apart. A missing `/help` or `/privacy` line in `helpMenu` throws rather than silently registering a blank description.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The typed binding contract is `Env` in `src/config.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface Env {
|
||||||
|
BOT_TOKEN: string; // wrangler secret — from @BotFather
|
||||||
|
WEBHOOK_SECRET: string; // wrangler secret — must be non-empty; the route fails closed otherwise
|
||||||
|
chat_sessions: KVNamespace; // TTL-only namespace
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Non-secret settings live in `wrangler.jsonc`:
|
||||||
|
|
||||||
|
| Key | Value | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| `name` | `araharah-bot` | Worker name. |
|
||||||
|
| `main` | `src/webhook.ts` | Worker entry point. |
|
||||||
|
| `compatibility_date` | `2026-10-22` | |
|
||||||
|
| `workers_dev` | `true` | Serves on the `*.workers.dev` origin — the `WEBHOOK_URL` target for this phase. |
|
||||||
|
| `assets.directory` | `../web/dist` | Served through the `ASSETS` binding. |
|
||||||
|
| `assets.not_found_handling` | `none` | Unmatched paths fall through to the Worker, which answers `404`. |
|
||||||
|
| `run_worker_first` | `["/api/*", "/telegram/*"]` | A **glob array**, not a boolean — the Worker owns these prefixes; everything else is served as a static asset. |
|
||||||
|
| `kv_namespaces` | binding `chat_sessions` | |
|
||||||
|
|
||||||
|
The KV namespace id is committed in `wrangler.jsonc`; it is an identifier, not a secret.
|
||||||
|
|
||||||
|
<!-- VERIFY: The deployed `workers.dev` origin that `WEBHOOK_URL` should carry is account-specific and not recorded in the repository — take it from `wrangler deploy` output. -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module map
|
||||||
|
|
||||||
|
| Path | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `src/webhook.ts` | Worker entry and the only fetch handler: four routes, the 4 KB initData cap, bearer-token resolution. |
|
||||||
|
| `src/config.ts` | The `Env` contract — nothing else declares bindings. |
|
||||||
|
| `src/bot/bot.ts` | `createBot(env)` — the grammY factory; registers every command handler. Per-request instantiation, because Workers have no startup hook and the token arrives with `env`. |
|
||||||
|
| `src/bot/commands/{start,help,privacy}.ts` | One command surface each, with pure `*ReplyText(locale)` builders so tests don't need a bot instance. |
|
||||||
|
| `src/bot/handlers/location-fallback.ts` | Sends the `request_location` reply keyboard; records a shared pin on the `message` handler. |
|
||||||
|
| `src/security/initdata.ts` | Pure HMAC validation of initData. Depends only on the runtime's global `crypto.subtle`, so it runs unchanged in the Worker and in bare-node tests. |
|
||||||
|
| `src/kv/sessions.ts` | Mint / rotate / resolve session claims. `SESSION_TTL_SECONDS = 900`. |
|
||||||
|
| `src/kv/location-pin.ts` | Write / read / clear the chat-scoped pin. `PIN_TTL_SECONDS = 300`. |
|
||||||
|
| `src/i18n/strings.ts` | Every user-facing string, in `en` and `ms`. Nothing inline anywhere else. |
|
||||||
|
| `scripts/` | One-time Telegram registration: webhook, command menu, menu button. |
|
||||||
|
| `test/` | Zero-dependency suites plus the runner and registry. |
|
||||||
|
|
||||||
|
### KV key scheme
|
||||||
|
|
||||||
|
| Key | Value | TTL |
|
||||||
|
|---|---|---|
|
||||||
|
| `session:<token>` | JSON `SessionClaim` — `user_id`, `chat_id`, `issued_at`, `language_code` | 900 s |
|
||||||
|
| `chat:<chat_id>` | The chat's current token (per-chat pointer) | 900 s |
|
||||||
|
| `loc:<chat_id>` | JSON `LocationPin` — `latitude`, `longitude`, `horizontal_accuracy` | 300 s |
|
||||||
|
|
||||||
|
Session TTL is deliberately tied to the 900 s initData freshness window: a session cannot outlive the proof that minted it. Minting rotates — the previous `session:<token>` is deleted before the new record is written, then a best-effort post-mint reconciliation re-reads the pointer and sweeps a racing mint's record. Under KV's lack of transactions, a record orphaned by a lost race is invisible to both sweeps and simply dies with its TTL; nothing persists longer.
|
||||||
|
|
||||||
|
### initData validation
|
||||||
|
|
||||||
|
`validateInitData(raw, botToken, opts?)` parses the Telegram-style querystring manually — values are decoded once and `+` stays literal, so `URLSearchParams` is deliberately not used. The data-check string excludes **only** `hash` (the `signature` field participates; excluding it broke real webviews), sorts by field name, and joins with newlines. The key is derived with `HMAC-SHA256(key="WebAppData", message=botToken)` and compared in constant time. `auth_date` older than `maxAgeSeconds` (default 900) is `stale`; `nowSeconds` is injectable so freshness boundaries are testable at 899/900/901 s.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The suite is a hand-rolled harness — no test framework dependency. `test/harness.mjs` exports a `test(name, fn)` registry; `test/runner.mjs` auto-discovers every `*.test.mjs` in `test/`, imports them, and prints `PASS`/`FAIL` per case with a summary line. A suite that fails to load is recorded as one failing case rather than crashing the run. Exit code is `0` only when every case passed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run bot:test # from the repo root
|
||||||
|
npm test # from bot/
|
||||||
|
```
|
||||||
|
|
||||||
|
| Suite | Cases | Covers |
|
||||||
|
|---|---|---|
|
||||||
|
| `test/initdata.test.mjs` | 12 | HMAC vectors (RFC-4231 case 1), field ordering, the `signature` erratum, freshness boundaries, malformed shapes. |
|
||||||
|
| `test/sessions.test.mjs` | 7 | Exact claim key-set, TTL on every `put`, rotation, interleaved-mint behaviour, claim round-trip. |
|
||||||
|
| `test/location-pin.test.mjs` | 14 | TTL-carrying key, out-of-range coordinate rejection, malformed-JSON reads, plus the full `/api/location` and `/api/location-fallback` route surface including own-chat-only access and locale honouring. |
|
||||||
|
| `test/webhook.test.mjs` | 6 | Valid initData → token, the 4 KB cap (declared, lying, and unreadable bodies), `405` on `GET /api/session`, fail-closed `500` on an unarmed `WEBHOOK_SECRET`. |
|
||||||
|
| `test/commands.test.mjs` | 9 | Locale key-set parity, `pickLocale` fallback, privacy copy honesty, the text-only surface gate, `helpMenu`/menu derivation, and the exact command stack. |
|
||||||
|
| `test/privacy.test.mjs` | 2 | Grep gates: every `.put(` in a storage source carries `expirationTtl` on the same line; storage modules export nothing persistence-shaped. |
|
||||||
|
|
||||||
|
Typecheck separately:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run bot:typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Secrets never appear in code or logs.** `BOT_TOKEN` and `WEBHOOK_SECRET` are wrangler secrets locally and in production; `bot/.dev.vars` is gitignored. The scripts read it but only ever print Telegram's `ok` / `error_code` / `description`.
|
||||||
|
- **Logging is category-only by construction.** `"[initdata] rejected: tampered"`, `"[location] rejected: unauthorized"` — never the payload, the chat id, or a timestamp.
|
||||||
|
- **`not_found_handling: "none"` matters.** With the default, unmatched paths would be served as a 404 *asset* and the Worker's own `404` branch would be unreachable.
|
||||||
|
- **`run_worker_first` is a glob array.** Passing a boolean silently breaks the route split.
|
||||||
|
- **`requestLocationFallback` has exactly one caller** (`handleLocationFallback` in `src/webhook.ts`). A second caller would be a design change, not a refactor.
|
||||||
@@ -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+.
|
||||||
@@ -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.
|
||||||
@@ -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/`.
|
||||||
@@ -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
@@ -0,0 +1,446 @@
|
|||||||
|
<!-- generated-by: gsd-doc-writer -->
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
|
||||||
|
arah² has no third-party test framework. Every suite in this repo is a hand-rolled, zero-dependency Node script that registers cases into a small array and drains it. The runners were written first in `theme/`, then copied verbatim into `bot/` and `web/` — the three share one shape, deliberately.
|
||||||
|
|
||||||
|
Beyond the suites, two additional gates stand in for CI (this repo has none — see [CI integration](#ci-integration)): `tsc --noEmit` typechecks, and the theme's off-token audit is itself a tested CLI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [Test framework and setup](#test-framework-and-setup)
|
||||||
|
- [Running tests](#running-tests)
|
||||||
|
- [Writing new tests](#writing-new-tests)
|
||||||
|
- [Test patterns](#test-patterns)
|
||||||
|
- [Coverage requirements](#coverage-requirements)
|
||||||
|
- [Visual capture workflow](#visual-capture-workflow)
|
||||||
|
- [CI integration](#ci-integration)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test framework and setup
|
||||||
|
|
||||||
|
### What "framework" means here
|
||||||
|
|
||||||
|
| Workspace | Runner | Harness | Registered via | Command |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `bot/` | `bot/test/runner.mjs` | `bot/test/harness.mjs` | `import { test } from "./harness.mjs"` | `npm run bot:test` |
|
||||||
|
| `web/` | `web/test/runner.mjs` | `web/test/harness.mjs` | `import { test } from "./harness.mjs"` | `npm run web:test` |
|
||||||
|
| `theme/` | inline in `theme/scripts/audit.test.mjs` | local `check()` helper | `check("name", cond, detail)` | `npm test -w theme` |
|
||||||
|
|
||||||
|
There is **no jest, vitest, mocha, playwright-test, or any other runner** in any `package.json`. The only test-relevant dev dependency anywhere is `typescript` (`tsc --noEmit`, used as a compile-time gate) and `playwright` (used by the capture scripts, not by any suite).
|
||||||
|
|
||||||
|
### The harness (10 lines, duplicated per workspace)
|
||||||
|
|
||||||
|
`bot/test/harness.mjs` and `web/test/harness.mjs` are byte-identical:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const tests = [];
|
||||||
|
export function test(name, fn) {
|
||||||
|
tests.push({ name, fn });
|
||||||
|
}
|
||||||
|
export const registry = tests;
|
||||||
|
```
|
||||||
|
|
||||||
|
`test(name, fn)` pushes a case. `fn` may be sync or `async` — the runner `await`s every one.
|
||||||
|
|
||||||
|
### The runner (47 lines, duplicated per workspace)
|
||||||
|
|
||||||
|
`bot/test/runner.mjs` and `web/test/runner.mjs` are also byte-identical in behaviour:
|
||||||
|
|
||||||
|
1. `readdirSync(import.meta.dirname)`, filter `*.test.mjs`, sort.
|
||||||
|
2. Dynamic-`import()` each suite inside a `try/catch`. A suite that throws at load time is **not** fatal — it is pushed into the registry as a single failing case named `suite load failed: <file>`. The run always completes.
|
||||||
|
3. Drain `registry` in registration order: `PASS <name>` on stdout, or `FAIL <name>` plus the error stack on stderr.
|
||||||
|
4. Print `[runner] <n> passed, <m> failed` and set `process.exitCode = 1` if anything failed.
|
||||||
|
|
||||||
|
Registration order is therefore: **suite files in alphabetical order, cases in file order**. There is no shuffling, no parallelism, no filtering, no watch mode.
|
||||||
|
|
||||||
|
### TypeScript is loaded directly
|
||||||
|
|
||||||
|
Both `bot/test/` and `web/test/` `await import("../src/....ts")` directly against the shipped source — no build step, no transpile step, no fixtures directory. This works because Node strips types natively (the code comments call this "Node 24 type stripping"). <!-- VERIFY: minimum Node.js version for native `.ts` import support — no `engines` field or `.nvmrc` is committed; the test suites themselves reference "Node 24 type stripping". -->
|
||||||
|
|
||||||
|
### Assertion style
|
||||||
|
|
||||||
|
Every suite in `bot/` and `web/` uses **`node:assert`'s strict mode**, imported as:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
```
|
||||||
|
|
||||||
|
In practice: `assert.ok(...)` for existence checks, `assert.equal` / `assert.strictEqual` for scalar equality, `assert.deepEqual` / `assert.deepStrictEqual` for structural equality, `assert.match` / `assert.doesNotMatch` for regex, and `assert.throws` / `assert.doesNotThrow` when a throw is the expected result. Every assertion carries a message string explaining the invariant being pinned.
|
||||||
|
|
||||||
|
`theme/scripts/audit.test.mjs` does not use `node:assert` at all — it defines a local `check(name, cond, detail = '')` that accumulates into `passes[]` / `failures[]`, reports them all at the end, and exits 1 if any failed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
### Everything, per workspace
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from the repo root
|
||||||
|
npm run bot:test # → node test/runner.mjs in bot/
|
||||||
|
npm run web:test # → node test/runner.mjs in web/
|
||||||
|
npm test -w theme # → node ./scripts/audit.test.mjs in theme/
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no root-level `npm test` aggregate — run all three, or add one to `package.json` when you need it.
|
||||||
|
|
||||||
|
### Running one suite
|
||||||
|
|
||||||
|
The runners auto-discover by filename and offer no filter flag. To run a single file, invoke it directly from inside the workspace:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd bot && node test/initdata.test.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
This works because a suite file's top-level `test(...)` calls register into `harness.mjs`, and `runner.mjs` is what drains the registry — so importing a suite alone reports nothing. **A single suite file run this way executes the module side effects (imports, fixture writes) but produces no pass/fail output.** Always go through the runner:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd bot && node test/runner.mjs # all bot suites
|
||||||
|
cd web && node test/runner.mjs # all web suites
|
||||||
|
```
|
||||||
|
|
||||||
|
`theme` is the exception — its suite is a standalone script and *does* report standalone:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd theme && node scripts/audit.test.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typecheck gates
|
||||||
|
|
||||||
|
The `bot/` and `web/` suites do not compile. Run these alongside:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run bot:typecheck # tsc --noEmit in bot/
|
||||||
|
npm run web:typecheck # tsc --noEmit in web/
|
||||||
|
```
|
||||||
|
|
||||||
|
### The theme audit as a standalone tool
|
||||||
|
|
||||||
|
The audit is both the thing under test and a useful CLI in its own right. Run it on the real build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run audit # → node ./scripts/audit.mjs ./dist/rams.css
|
||||||
|
```
|
||||||
|
|
||||||
|
Its exit codes are a contract: `0` clean, `1` violations (each printed as `file:line: declaration`), `2` token-sheet drift or error. See [theme/scripts/audit.mjs](theme/scripts/audit.mjs) for the full usage block and its scope adjudications.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
ROOT["npm run bot:test / web:test / audit"] --> BR["bot/test/runner.mjs"]
|
||||||
|
ROOT --> WR["web/test/runner.mjs"]
|
||||||
|
ROOT --> TR["theme/scripts/audit.test.mjs"]
|
||||||
|
|
||||||
|
BR --> BDISC["readdir *.test.mjs → dynamic import"]
|
||||||
|
BDISC --> BREG["bot/test/harness.mjs registry"]
|
||||||
|
BREG --> BCASES["commands · initdata · location-pin · privacy · sessions · webhook"]
|
||||||
|
|
||||||
|
WR --> WDISC["readdir *.test.mjs → dynamic import"]
|
||||||
|
WDISC --> WREG["web/test/harness.mjs registry"]
|
||||||
|
WREG --> WCASES["build-artifact · copy-gate · location-chain · no-storage · session · theme"]
|
||||||
|
|
||||||
|
TR --> TFIX["fixtures under /tmp/opencode/audit-test/"]
|
||||||
|
TFIX --> TSPAWN["spawnSync scripts/audit.mjs"]
|
||||||
|
TSPAWN --> TCHECK["check() → passes[] / failures[]"]
|
||||||
|
TCHECK --> TREAL["also audits real dist/rams.css"]
|
||||||
|
|
||||||
|
classDef entry fill:#EB8008,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
|
||||||
|
classDef runner fill:#1A1A1A,stroke:#EB8008,stroke-width:2px,color:#F7F0E3
|
||||||
|
classDef harness fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
|
||||||
|
classDef theme fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1E1B14
|
||||||
|
|
||||||
|
class ROOT entry
|
||||||
|
class BR,WR,TR runner
|
||||||
|
class BDISC,BREG,WDISC,WREG harness
|
||||||
|
class TFIX,TSPAWN,TCHECK,TREAL theme
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Writing new tests
|
||||||
|
|
||||||
|
### Naming and location
|
||||||
|
|
||||||
|
| Rule | Value |
|
||||||
|
|---|---|
|
||||||
|
| File name | `<subject>.test.mjs` — the runner only discovers this suffix |
|
||||||
|
| Directory | `bot/test/` or `web/test/`, flat, no subdirectories |
|
||||||
|
| Import the harness | `import { test } from "./harness.mjs";` |
|
||||||
|
| Case name | Prefix with a stable case code: `"WK1 POST valid initData (under cap) → 200 {token: uuid}"`, `"GEO3 no banned storage token…"`, `"CS4 D-02: the command tree source has no reply-menu…"`. The code is the identity; the prose is the explanation. |
|
||||||
|
|
||||||
|
A new file dropped into `bot/test/` or `web/test/` is picked up automatically on the next run — no manifest, no import list, no registration step anywhere.
|
||||||
|
|
||||||
|
### The minimum shape
|
||||||
|
|
||||||
|
```js
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/*
|
||||||
|
* <subject>.test.mjs — what this suite pins, and under which requirement id.
|
||||||
|
* ------------------------------------------------------------------------
|
||||||
|
* <CODE> one line per case, so a reader can see the contract without
|
||||||
|
* reading the code.
|
||||||
|
*/
|
||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import { test } from "./harness.mjs";
|
||||||
|
|
||||||
|
const { someExport } = await import("../src/some/module.ts");
|
||||||
|
|
||||||
|
test("<CODE> <what is asserted> <and why>", () => {
|
||||||
|
assert.equal(someExport("input"), "expected", "why this matters");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("<CODE> async path", async () => {
|
||||||
|
const res = await someAsync();
|
||||||
|
assert.ok(res.ok, `expected ok:true, got ${JSON.stringify(res)}`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
For `theme/`, add cases to `theme/scripts/audit.test.mjs` using the local helper instead:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
const r = runAudit([dirtyFixture]);
|
||||||
|
check('T2 dirty stylesheet → exit 1', r.status === 1, `status=${r.status} error=${r.error}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the theme suite uses a **bare block** (`{ … }`) rather than a named function, and relies on `passes[]`/`failures[]` instead of exceptions.
|
||||||
|
|
||||||
|
### Where the source of truth lives
|
||||||
|
|
||||||
|
Every suite header comment lists its cases up front. That header is the documentation — keep it in sync when adding a case. Several suites reference the decision ids (`D-02`, `PRIV-02`, `GEO-03`, `CS1`, `COPY5`, `WK3`) that the case is pinning; reuse that convention.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test patterns
|
||||||
|
|
||||||
|
The repo has converged on four recognisable patterns. Follow the closest precedent rather than inventing a new one.
|
||||||
|
|
||||||
|
### 1. Injected fakes over mocks and over the real platform
|
||||||
|
|
||||||
|
`bot/test/sessions.test.mjs` and `bot/test/webhook.test.mjs` both hand-roll a ~20-line `KVNamespace` fake rather than depend on `miniflare` or `wrangler`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function makeKV() {
|
||||||
|
const map = new Map();
|
||||||
|
const ttlLog = [];
|
||||||
|
return {
|
||||||
|
async get(key) { return map.has(key) ? map.get(key) : null; },
|
||||||
|
async put(key, value, opts) {
|
||||||
|
ttlLog.push({ key, expirationTtl: opts?.expirationTtl ?? null });
|
||||||
|
map.set(key, value);
|
||||||
|
},
|
||||||
|
async delete(key) { map.delete(key); },
|
||||||
|
map,
|
||||||
|
ttlLog,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Two variants exist and the difference matters:
|
||||||
|
|
||||||
|
- **Asserting fake** (`sessions.test.mjs`) — `put()` itself throws if `expirationTtl` is missing or non-finite, making the TTL law unbreakable from the inside.
|
||||||
|
- **Recording fake** (`webhook.test.mjs`, `location-pin.test.mjs`) — `put()` records everything into `ttlLog` and the case asserts on the log afterwards.
|
||||||
|
|
||||||
|
### 2. Global stubs: install, then restore in `finally`
|
||||||
|
|
||||||
|
Node has no DOM, no `navigator`, no `document`. Suites stub them per case and always restore:
|
||||||
|
|
||||||
|
```js
|
||||||
|
function stubNavigator(geolocationOrNull) {
|
||||||
|
const original = Object.getOwnPropertyDescriptor(globalThis, "navigator");
|
||||||
|
Object.defineProperty(globalThis, "navigator", { value: /* … */, configurable: true, writable: true });
|
||||||
|
return () => {
|
||||||
|
if (original) Object.defineProperty(globalThis, "navigator", original);
|
||||||
|
else delete globalThis.navigator;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// in the case
|
||||||
|
const restoreNav = stubNavigator({ geolocation: true });
|
||||||
|
try {
|
||||||
|
// …
|
||||||
|
} finally {
|
||||||
|
restoreNav();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`web/test/theme.test.mjs` does the same for `globalThis.document` (`installDocumentStub()` returns the `dataset` object, which *is* the assertion target). The headers are explicit about why: "a leaked stub would silently make a later pass/fail for the wrong reason."
|
||||||
|
|
||||||
|
`web/test/location-chain.test.mjs` also stubs `globalThis.fetch`. `bot/test/location-pin.test.mjs` patches a different layer entirely — grammY's node shim reads node-fetch's cached `default` export, so `createRequire` reaches `node-fetch` and swaps `.default` for the duration of the case.
|
||||||
|
|
||||||
|
### 3. Source-introspection gates ("grep the shipped source")
|
||||||
|
|
||||||
|
Structural claims — "no menu surface exists", "copy lives only in `strings.ts`", "nothing calls `setStyle`" — are pinned by *reading the file*, not by mocking behaviour. This is the most distinctive pattern in the repo and it appears in both workspaces:
|
||||||
|
|
||||||
|
- `bot/test/commands.test.mjs` CS4 — asserts `/keyboard/i` does not appear in any command source.
|
||||||
|
- `bot/test/privacy.test.mjs` PG1 — every line containing `.put(` must also contain `expirationTtl` referencing a `SCREAMING_CASE` constant.
|
||||||
|
- `bot/test/privacy.test.mjs` PG2 — no persistence-shaped export names exist.
|
||||||
|
- `web/test/copy-gate.test.mjs` COPY5 — no shell module inlines user-facing copy.
|
||||||
|
- `web/test/no-storage.test.mjs` GEO3 — no banned storage token appears in `web/src` code.
|
||||||
|
- `web/test/session.test.mjs` SESS4, `web/test/theme.test.mjs` PX02 case 3 — same shape.
|
||||||
|
|
||||||
|
**Every one of these gates strips comments first.** The convention is two replaces, repeated verbatim across files:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const code = src
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, " ") // block comments
|
||||||
|
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments (skips URLs)
|
||||||
|
```
|
||||||
|
|
||||||
|
The reason is documented in `no-storage.test.mjs`: `web/src/mock/telegram-mock.ts` documents the storage ban *in prose*, and prose is not a call site.
|
||||||
|
|
||||||
|
### 4. Build-artifact gates (assert on the shipped bundle)
|
||||||
|
|
||||||
|
`web/test/build-artifact.test.mjs` reads `web/dist` — the **production** build only, never `web/dist-harness/` — and proves what actually ships:
|
||||||
|
|
||||||
|
| Case | Assertion |
|
||||||
|
|---|---|
|
||||||
|
| BA1 | No emitted `.js`/`.css` references a third-party CDN host |
|
||||||
|
| BA2 | At least one self-hosted `.woff2` was copied through |
|
||||||
|
| BA3 | A hashed MapLibre worker chunk exists under `dist/assets` |
|
||||||
|
| BA4 | The built JS carries the inlined Rams style JSON |
|
||||||
|
| BA5 | `installMockWebApp` / `__ARAHARAH_MOCK__` appear in **no** emitted `.js` **or** `.html` |
|
||||||
|
| BA6 | No emitted HTML carries the runner page's fetch stubs |
|
||||||
|
|
||||||
|
These cases require `web/dist` to exist. Each one starts with `assert.ok(existsSync(DIST), claimBuildFirst().message)`, which yields:
|
||||||
|
|
||||||
|
```
|
||||||
|
run `npm run build -w theme && npm run build -w web` before this suite
|
||||||
|
```
|
||||||
|
|
||||||
|
The suite's header is emphatic about *why* BA5 scans HTML as well as JS: `web/index.html` is one entry shared by both builds, so a JS-only scan would miss an unguarded profile block entirely.
|
||||||
|
|
||||||
|
### Two more conventions worth copying
|
||||||
|
|
||||||
|
**Lazy module imports for RED phases.** `bot/test/initdata.test.mjs` wraps its import in `loadValidator()` so a missing module produces one `FAIL` per case rather than a lost suite:
|
||||||
|
|
||||||
|
```js
|
||||||
|
let validatorPromise = null;
|
||||||
|
function loadValidator() {
|
||||||
|
if (!validatorPromise) validatorPromise = import("../src/security/initdata.ts").then((m) => m.validateInitData);
|
||||||
|
return validatorPromise;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Self-checks that pin the test's own primitives.** `initdata.test.mjs` T7 asserts `node:crypto`'s HMAC output equals the RFC-4231 test-case-1 digest, so a broken signing path cannot produce false-green signing tests. `theme/scripts/audit.test.mjs` T9 runs a real controlled Tailwind build to prove the assertion isn't tautological.
|
||||||
|
|
||||||
|
### Anti-patterns to avoid
|
||||||
|
|
||||||
|
- **Letting a case reach the live Bot API.** `bot/test/webhook.test.mjs` documents the constraint at length: grammY's cloudflare-mod adapter calls `bot.init()` (a live `getMe`) *before* its own secret-token compare, so no committed case may let an update reach the framework with an armed secret. Armed-secret behaviour is verified by live smokes, not by the suite.
|
||||||
|
- **Writing fixtures into the repo.** `theme/scripts/audit.test.mjs` creates everything under `/tmp/opencode/audit-test/`; the bot/web suites keep fixtures in-process entirely.
|
||||||
|
- **Adding a test framework.** The zero-dependency property is load-bearing (decision `D-12`) and repeated across all three runners.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coverage requirements
|
||||||
|
|
||||||
|
**No coverage threshold is configured.** There is no `coverageThreshold`, no `nyc`/`c8` config, and no instrumentation anywhere — because there is no framework to instrument with.
|
||||||
|
|
||||||
|
What substitutes for coverage measurement:
|
||||||
|
|
||||||
|
| Mechanism | Where |
|
||||||
|
|---|---|
|
||||||
|
| **Existence assertions on the scanned tree** | `no-storage.test.mjs` asserts `files.length >= 12` before scanning (`"scanned a real tree"`), so the gate cannot pass vacuously on an empty directory. `copy-gate.test.mjs` COPY5 asserts `files.length >= 1`. |
|
||||||
|
| **Fixture self-checks** | `theme/scripts/audit.test.mjs` aborts with `fixture setup: … not found` if `theme/src/tokens.css` loses a key the fixtures depend on. |
|
||||||
|
| **Typecheck as a structural gate** | `npm run bot:typecheck` / `web:typecheck` catch what the suites would not: a type change in a shape the runtime assertions never inspect. |
|
||||||
|
|
||||||
|
If you need coverage numbers, you would have to add tooling — which contradicts the current design. Prefer adding a case.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visual capture workflow
|
||||||
|
|
||||||
|
Two Playwright scripts generate screenshots. They are **evidence generators, not tests** — they are not part of any `npm test` command and nothing in the suites depends on them. Both share the same shape: a docroot-guarded static server on `127.0.0.1:0`, a Playwright `chromium` launch, one `MIN_BYTES = 10240` blank bar per PNG, a `FAILED`/`MISSING`/`UNDERSIZED` report, and `exit(1)` if anything is unusable.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run capture # → theme/scripts/screenshot.mjs → theme/screenshots/
|
||||||
|
npm run web:capture # → web/scripts/capture.mjs → web/screenshots/
|
||||||
|
```
|
||||||
|
|
||||||
|
Both output directories are gitignored — they are regenerated artifacts, never committed.
|
||||||
|
|
||||||
|
### `theme/scripts/screenshot.mjs`
|
||||||
|
|
||||||
|
Serves `theme/` as docroot, then captures:
|
||||||
|
|
||||||
|
| Output | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `sheet-full.png` | Component sheet at 390px, full page, after `document.fonts.ready` |
|
||||||
|
| `mockup-<screen>.png` ×4 | The four mockup screens over `file://` — CDN Tailwind, so **network required**. On failure, the committed `screen.png` is copied in as fallback and logged as `FALLBACK` |
|
||||||
|
| `basemap-kl.png` | 1280×800, waits on the page's own `RAMS-BASEMAP-READY` console signal + 2s tile settle |
|
||||||
|
|
||||||
|
The mockups are read-only reference (decision `D-06`) and are never edited.
|
||||||
|
|
||||||
|
### `web/scripts/capture.mjs`
|
||||||
|
|
||||||
|
Rebuilds all three bundles first, then serves **`web/dist-harness/`**, not `web/dist`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build -w theme && npm run build -w web && npm run build:harness -w web
|
||||||
|
```
|
||||||
|
|
||||||
|
It then asserts in both directions before launching the browser — the harness bundle *must* contain `installMockWebApp`, and `web/dist` *must not* — and fails the run if either is wrong, because a wrong bundle means the capture would race forever.
|
||||||
|
|
||||||
|
| Output | Leg | Wait mode |
|
||||||
|
|---|---|---|
|
||||||
|
| `shell-boot-1.png` | `?mock=android&sessiondelay=1000` | `settle400` |
|
||||||
|
| `shell-boot-2.png` | `?mock=android&fixdelay=1200` | `settle400` |
|
||||||
|
| `shell-live-light.png` | `?mock=android&scheme=light` | `live` — races `ARAHARAH-LIVE` on console, 2s tile settle |
|
||||||
|
| `shell-live-dark.png` | `?mock=android&scheme=dark` | `live` |
|
||||||
|
| `shell-denied.png` | `?mock=tdesktop` | `denied` — waits `.lamp[data-state="denied"]`, 90s budget (the 60s chat-pin poll must elapse first) |
|
||||||
|
| `shell-chat-pin.png` | `?mock=tdesktop&pin=1` | `live` |
|
||||||
|
| `gallery-compare.png` | Side-by-side against `stitch_minimalist_route_navigator/navigate_map_overview/screen.png` | — |
|
||||||
|
|
||||||
|
Success prints `CAPTURE OK — 7/7 non-blank captures under web/screenshots/`.
|
||||||
|
|
||||||
|
Two capture legs double as runtime verification:
|
||||||
|
|
||||||
|
- **The theme honesty check** — the `live` legs read `document.documentElement.dataset.tgTheme` after the `ARAHARAH-LIVE` signal and throw if it does not equal the requested `colorScheme`. A themed screenshot cannot silently show the wrong theme.
|
||||||
|
- **The GEO-03 runtime probe** — after boot-to-live, `page.evaluate` reads `localStorage.length`, `sessionStorage` keys, `document.cookie`, and `indexedDB.databases()`, and fails the run on any non-empty app storage surface. One allowlisted exception: the official `telegram-web-app.js` SDK writes `__telegram__initParams`, which is the SDK's own launch bookkeeping and is not app data. Any *other* key fails.
|
||||||
|
|
||||||
|
A timeout is always a hard failure with a diagnostic naming which console signals fired — never a silent skip, never a blank-but-passing shot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI integration
|
||||||
|
|
||||||
|
**No CI/CD pipeline is detected.** There is no `.github/workflows/`, no `.gitea/workflows/`, and no other workflow definition in the repository.
|
||||||
|
|
||||||
|
<!-- VERIFY: automated CI — no workflow directory exists in the working tree as of this writing. The repository is hosted on a private Gitea instance (`http://gitmirah:3000/c-fu/arah2.git`); if a server-side runner was configured outside the repository, this section is out of date. -->
|
||||||
|
|
||||||
|
The full green check a contributor must run by hand before pushing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run bot:test
|
||||||
|
npm run web:test
|
||||||
|
npm test -w theme
|
||||||
|
npm run bot:typecheck
|
||||||
|
npm run web:typecheck
|
||||||
|
npm run audit # token-drift audit on the real dist/rams.css
|
||||||
|
npm run build # required before web:test's BA cases can pass
|
||||||
|
```
|
||||||
|
|
||||||
|
Order matters in one place: `web/test/build-artifact.test.mjs` reads `web/dist`, so **`npm run build` must run before `npm run web:test`** or BA1–BA6 all fail with the build-first message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
| I want to… | Command |
|
||||||
|
|---|---|
|
||||||
|
| Run every bot test | `npm run bot:test` |
|
||||||
|
| Run every Mini App test | `npm run web:test` |
|
||||||
|
| Run the theme audit tests | `npm test -w theme` |
|
||||||
|
| Typecheck the Worker | `npm run bot:typecheck` |
|
||||||
|
| Typecheck the Mini App | `npm run web:typecheck` |
|
||||||
|
| Audit the real compiled CSS | `npm run audit` |
|
||||||
|
| Add a bot test | new `bot/test/<subject>.test.mjs` — pick up automatically |
|
||||||
|
| Add a Mini App test | new `web/test/<subject>.test.mjs` — pick up automatically |
|
||||||
|
| Regenerate design screenshots | `npm run capture` |
|
||||||
|
| Regenerate Mini App screenshots | `npm run web:capture` |
|
||||||
|
|
||||||
|
**Related docs:** [README](../README.md) for the command table and the HTTP surface these suites cover · [ARCHITECTURE](ARCHITECTURE.md) for why the Worker/Mini App split is what it is · [CONFIGURATION](CONFIGURATION.md) for the secrets the `bot:` tests substitute with `12345:TESTTOKEN`.
|
||||||
+301
@@ -0,0 +1,301 @@
|
|||||||
|
<!-- generated-by: gsd-doc-writer -->
|
||||||
|
|
||||||
|
# @araharah/theme
|
||||||
|
|
||||||
|
The **Telemetry Rams** design system as a Tailwind v4 CSS-first token package: every color, spacing, type, and radius value in arah² is defined once in `src/tokens.css`, compiled to `dist/rams.css`, and consumed by `@araharah/web`. The package also hosts the MapLibre basemap style fork, the self-hosted typefaces, and the audit / screenshot evidence tooling.
|
||||||
|
|
||||||
|
Part of the [arah² monorepo](../README.md) — see the root [README](../README.md) and [`docs/`](../docs/) for bot and Mini App context.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
| Path | What it is |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/tokens.css` | Single source of truth for every numeric design value: `@theme static` color / spacing / font / radius tokens plus the `--type-*` typography scale |
|
||||||
|
| `src/theme.css` | Entry stylesheet — imports Tailwind, `tokens.css`, `components.css`; declares the self-hosted `@font-face` rules and the Telegram light/dark shell slots |
|
||||||
|
| `src/components.css` | Component layer (`@layer components`): buttons, inputs, telemetry cards, event chips, maneuver banner, checkbox, toggle, and the `.text-*` typography helpers |
|
||||||
|
| `src/sheet.html` | Component sheet — the rendered proof that every component derives from tokens |
|
||||||
|
| `gallery.html` | DSGN-02 approval gallery: mockup reference render vs. implementation capture, side by side |
|
||||||
|
| `basemap/build.mjs` | Builds `basemap/liberty-rams.json` by forking the OpenFreeMap `liberty` style and deriving every cartographic color from `tokens.css` |
|
||||||
|
| `basemap/liberty-rams.json` | The generated MapLibre style — imported directly by `web/src/map/map.ts` |
|
||||||
|
| `basemap/index.html` | MapLibre GL proof page over KL, with a placeholder route vector |
|
||||||
|
| `scripts/audit.mjs` | Off-token audit: scans rendered CSS and flags any value that does not derive from `tokens.css` |
|
||||||
|
| `scripts/audit.test.mjs` | TDD harness for the audit (12 contract tests, zero dependencies) |
|
||||||
|
| `scripts/screenshot.mjs` | Playwright capture toolchain — writes `screenshots/` |
|
||||||
|
| `fonts/geist/`, `fonts/space-grotesk/` | Self-hosted woff2 subsets (SIL Open Font License 1.1) |
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
TOK["src/tokens.css — colors, spacing, type, radius"]
|
||||||
|
COMP["src/components.css — component classes"]
|
||||||
|
ENTRY["src/theme.css — entry stylesheet"]
|
||||||
|
CLI["@tailwindcss/cli"]
|
||||||
|
DIST["dist/rams.css — built stylesheet"]
|
||||||
|
BUILD["basemap/build.mjs"]
|
||||||
|
STYLE["basemap/liberty-rams.json"]
|
||||||
|
WEB["@araharah/web — Mini App shell"]
|
||||||
|
AUDIT["scripts/audit.mjs"]
|
||||||
|
SHOTS["scripts/screenshot.mjs"]
|
||||||
|
GAL["gallery.html + src/sheet.html + basemap/index.html"]
|
||||||
|
|
||||||
|
TOK --> COMP
|
||||||
|
TOK --> ENTRY
|
||||||
|
COMP --> ENTRY
|
||||||
|
ENTRY --> CLI
|
||||||
|
CLI --> DIST
|
||||||
|
TOK --> BUILD
|
||||||
|
BUILD --> STYLE
|
||||||
|
DIST --> WEB
|
||||||
|
STYLE --> WEB
|
||||||
|
TOK --> AUDIT
|
||||||
|
GAL --> AUDIT
|
||||||
|
GAL --> SHOTS
|
||||||
|
SHOTS --> GAL
|
||||||
|
|
||||||
|
classDef tokens fill:#EB8008,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
|
||||||
|
classDef build fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
|
||||||
|
classDef out fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1E1B14
|
||||||
|
class TOK,COMP,ENTRY tokens
|
||||||
|
class CLI,BUILD,AUDIT,SHOTS build
|
||||||
|
class DIST,STYLE,WEB,GAL out
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
`@araharah/theme` is a workspace package — it is never published. Consumers depend on it by name and install from the monorepo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone http://gitmirah:3000/c-fu/arah2.git
|
||||||
|
cd arah2
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Node.js and npm. `basemap/build.mjs` needs Node >= 18 (global `fetch`), and `basemap:preview` shells out to `python3`.
|
||||||
|
|
||||||
|
<!-- VERIFY: Node.js minimum version — no `engines` field or `.nvmrc` exists in the repository; `basemap/build.mjs` documents `Node >= 18` in its header comment. -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build -w theme # src/theme.css -> dist/rams.css (minified)
|
||||||
|
npm run sheet # build, then open theme/src/sheet.html in a browser
|
||||||
|
npm run audit # audit the rendered CSS for off-token values
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `npm run build -w theme` | `tailwindcss -i ./src/theme.css -o ./dist/rams.css --minify` |
|
||||||
|
| `npm run send -w theme` | Copy `fonts/geist` and `fonts/space-grotesk` into `dist/fonts/` so the built stylesheet ships with its typefaces |
|
||||||
|
| `npm run sheet -w theme` | `build`, then print the path to `src/sheet.html` (open it in a browser) |
|
||||||
|
| `npm run gallery -w theme` | `build` + `capture`, then serve the workspace on `:8123` and block on `basemap:preview` |
|
||||||
|
| `npm run basemap -w theme` | `node basemap/build.mjs` — fetch OpenFreeMap `liberty`, derive the Rams cartography, write `basemap/liberty-rams.json` |
|
||||||
|
| `npm run basemap:preview -w theme` | `python3 -m http.server 8123` — serve `theme/` as docroot so `dist/rams.css`, the woff2 fonts, and `basemap/` resolve |
|
||||||
|
| `npm run audit -w theme` | `node ./scripts/audit.mjs ./dist/rams.css` — off-token audit |
|
||||||
|
| `npm run capture -w theme` | `node ./scripts/screenshot.mjs` — Playwright captures into `theme/screenshots/` |
|
||||||
|
| `npm test -w theme` | `node ./scripts/audit.test.mjs` — the audit's contract tests |
|
||||||
|
|
||||||
|
The root `package.json` delegates `sheet`, `gallery`, `audit`, and `capture` to this workspace, so `npm run audit` from the root is the same command.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
`npm test` runs a plain Node harness — no test framework, no dev-server dependency. `audit.test.mjs` spawns `audit.mjs` against fixtures under `/tmp/opencode/audit-test/` and asserts exit codes:
|
||||||
|
|
||||||
|
| Contract | Expectation |
|
||||||
|
| --- | --- |
|
||||||
|
| T1 | Stylesheet using only token values → exit `0` |
|
||||||
|
| T2 | Off-token color / non-zero radius / off-token font / shadow / blur / backdrop-filter → exit `1`, each listed as `file:line: declaration` |
|
||||||
|
| T3 | `tokens.css` drifting below 40 colors → exit `2` |
|
||||||
|
| T4 | Any `--radius-*` token other than zero → exit `2` |
|
||||||
|
| T5 | The real `dist/rams.css` build → exit `0` |
|
||||||
|
| T6 | Off-token values inside `@layer components` → flagged |
|
||||||
|
| T7 | `@layer utilities` only audits classes the implementation pages actually wear |
|
||||||
|
| T8 | Inline `style="…"` attributes are audited too |
|
||||||
|
| T9 | The deprecated bare `--radius` key drives bare `rounded` at zero |
|
||||||
|
| T10–T12 | Literal `font:` shorthands, named colors / `oklch()` and friends, and spacing + typography numerics are flagged |
|
||||||
|
|
||||||
|
`audit.mjs` exit codes: `0` clean, `1` violations (each printed as `file:line: declaration`), `2` token-sheet drift or an error.
|
||||||
|
|
||||||
|
### Coverage
|
||||||
|
|
||||||
|
No coverage threshold configured. The audit itself is the enforcement mechanism: it is a token-conformance gate, not a line-coverage gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Importing the built stylesheet
|
||||||
|
|
||||||
|
`@araharah/web` pulls the package in from its shell stylesheet:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* web/src/shell.css */
|
||||||
|
@import "@araharah/theme/dist/rams.css";
|
||||||
|
```
|
||||||
|
|
||||||
|
That single import brings the full token layer (`@theme static`, so every token survives compilation as a plain CSS custom property), the `@layer components` class API, the self-hosted `@font-face` rules, and the Telegram light/dark shell slots `--shell-bg` / `--shell-on-surface`.
|
||||||
|
|
||||||
|
### Consuming tokens in your own CSS
|
||||||
|
|
||||||
|
Because `@theme static` forces every token into the output, tokens are addressable as custom properties from anywhere — no Tailwind build step needed:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.surface {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-matte-black);
|
||||||
|
padding: var(--spacing-space-md);
|
||||||
|
border-radius: var(--radius-md); /* resolves to 0 — Rams forbids roundedness */
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline {
|
||||||
|
font: var(--type-headline-md);
|
||||||
|
letter-spacing: var(--type-headline-md-tracking);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the convention: `font:` always takes the compound `--type-*` token and letter-spacing takes the `-tracking` companion, because the CSS `font` shorthand cannot carry tracking.
|
||||||
|
|
||||||
|
### Component classes
|
||||||
|
|
||||||
|
`src/components.css` is the public component API. Every declaration in it is a token reference; the only literals are the machined border geometry (1px / 2px rules, 3px focus brackets) and the exact 18×18 checkbox square.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<button class="btn-primary">Recenter</button>
|
||||||
|
<button class="btn-secondary">Layers</button>
|
||||||
|
|
||||||
|
<label class="checkbox"><input type="checkbox" checked></label>
|
||||||
|
|
||||||
|
<div class="toggle">
|
||||||
|
<button class="toggle-option" aria-pressed="true">Off</button>
|
||||||
|
<button class="toggle-option" aria-pressed="false">On</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span>Traffic</span><span class="card-ref">KL-01</span></div>
|
||||||
|
<div class="card-content"><span class="text-telemetry-num">2.4</span><span class="text-label-sm">KM AHEAD</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="chip-traffic">Congestion</span>
|
||||||
|
<span class="chip-hazard">Crash</span>
|
||||||
|
<span class="chip-safe">Clear</span>
|
||||||
|
|
||||||
|
<div class="maneuver-banner">
|
||||||
|
<span class="maneuver-arrow">→</span>
|
||||||
|
<span class="text-label-sm">STEP</span>
|
||||||
|
<span class="text-telemetry-num">320 m</span>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Importing the basemap style
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// web/src/map/map.ts
|
||||||
|
import ramsStyle from "@araharah/theme/basemap/liberty-rams.json";
|
||||||
|
```
|
||||||
|
|
||||||
|
The style object is never re-tinted at runtime — MapLibre receives it as-is.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the tokens are defined
|
||||||
|
|
||||||
|
`src/tokens.css` holds every value and nothing else does. Its header records the vocabulary as copied verbatim from the frontmatter of `stitch_minimalist_route_navigator/telemetry_rams/DESIGN.md`, so mockup markup maps 1:1 onto utilities (`bg-signal-orange`, `px-margin`, `gap-space-md`).
|
||||||
|
|
||||||
|
- **`@theme static`** — Tailwind v4 tree-shakes unused theme variables by default; `static` forces every token into the compiled output so the package stays consumable as plain CSS custom properties, including by the basemap derivation script.
|
||||||
|
- **Radius at absolute zero** — every `--radius*` key is `0px`. The deprecated bare `--radius` key is set too, because that is the key v4 drives bare `rounded` from.
|
||||||
|
- **Rams-forbidden primitives disabled at the key** — `--shadow`, `--shadow-inner`, `--drop-shadow`, and `--blur` are all set to `initial`, so `shadow-*` / `blur-*` utilities cannot compile from any source.
|
||||||
|
- **Typography** — `--type-*` compound tokens follow the grammar `weight size/line-height family…`, each paired with a `-tracking` companion.
|
||||||
|
- **`--shell-bg` / `--shell-on-surface`** — declared in `src/theme.css`, mapping the OS color scheme onto shell-level surfaces only; components and cartography stay Rams-constant under both Telegram themes.
|
||||||
|
|
||||||
|
The four mockup screens under `stitch_minimalist_route_navigator/` are the read-only class vocabulary reference. `src/theme.css` lists them as explicit `@source` paths and excludes `scripts/` and `basemap/` from the auto-scan, so prose can never mint a utility class. The mockup files themselves are never edited.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audit
|
||||||
|
|
||||||
|
`scripts/audit.mjs` treats "zero off-token values" as evidence rather than an eyeball claim. It parses `src/tokens.css` into an allowlist, then scans **rendered** CSS — `dist/rams.css` plus the inline `<style>` blocks and `style="…"` attributes of every registered implementation page — and flags:
|
||||||
|
|
||||||
|
- hex / `rgb()` / `hsl()` / named-keyword / `oklch()`-family color literals that are not token values
|
||||||
|
- font families outside the sanctioned stacks, and any literal `font:` shorthand
|
||||||
|
- spacing numerics on `padding` / `margin` / `gap` that do not equal a `--spacing-*` token
|
||||||
|
- typography numerics (`font-size`, `font-weight`, `line-height`, `letter-spacing`) outside the `--type-*` scale
|
||||||
|
- any non-zero `border-radius`, any non-`none` shadow, any blur or `backdrop-filter`
|
||||||
|
|
||||||
|
Seven scope decisions are documented in the script header; the notable ones: Tailwind's own `@layer properties / theme / base` are plumbing and skipped, `@layer utilities` rules are only audited when the selector names a class a registered implementation page actually wears, and `rounded-full`'s full-circle primitive is treated as spec geometry like the 18×18 checkbox square.
|
||||||
|
|
||||||
|
The page registry lives at `scripts/audit.mjs:118` — `IMPLEMENTATION_PAGES`. Any new page that renders Rams components must be appended there, or the audit will exit `2` on a missing path.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run audit -w theme
|
||||||
|
node scripts/audit.mjs --tokens ./src/tokens.css --pages src/sheet.html ./dist/rams.css
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Basemap derivation
|
||||||
|
|
||||||
|
`basemap/build.mjs` fetches the OpenFreeMap `liberty` vector style and forks it into Telemetry Rams cartography. Every cartographic color is derived from `tokens.css` inside the script — there are no hand-tuned hexes in the output JSON, only the derivation parameters (saturation multipliers, per-role lightness targets).
|
||||||
|
|
||||||
|
- **Derivation core** — `desaturate(tokenHex, satMultiplier, lightness)`: hue genes come from the source token, saturation is clamped into a calibrated `[0.06, 0.12]` band, lightness snaps to the role target. Ground is `mix(soft-beige, chassis-subtle)`.
|
||||||
|
- **Accent guard** — `signal-orange`, `functional-red`, and `olive-green` are excluded by token name *and* by hex value; the build fails if any of the three (hex or `rgb()` form) appears in the output layers. Those accents are reserved for the route line and hazard semantics that later phases render.
|
||||||
|
- **Hierarchy by weight only** — zoom-interpolated color ramps flatten to role constants while width/opacity ramps are preserved.
|
||||||
|
- **Labels** — matte-black at reduced opacity with a ground-tone halo and `text-halo-blur: 0`.
|
||||||
|
- **`building-3d` removed** — fill-extrusion is faux 3D, rejected by the design system. The removal is recorded in `style.metadata.rams-removed-layers`.
|
||||||
|
- **Font probing** — the intended Rams label stacks are probed against the OpenFreeMap glyphs endpoint at build time. When they are not hosted (the Phase 1 case), the closest hosted stack is substituted and the substitution is recorded in `style.metadata.rams-font-substitutions` as honest fallback provenance.
|
||||||
|
|
||||||
|
The generated style also carries a full derivation report in `style.metadata.rams-cartography`: every role with its color and the token recipe behind it, the excluded accents, and the list of untouched layers.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run basemap -w theme # rebuild liberty-rams.json (needs network)
|
||||||
|
npm run basemap:preview -w theme # serve theme/ on http://localhost:8123/basemap/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gallery and captures
|
||||||
|
|
||||||
|
`scripts/screenshot.mjs` launches chromium, serves `theme/` on an ephemeral loopback port as docroot, and writes six PNGs to `theme/screenshots/` (gitignored):
|
||||||
|
|
||||||
|
| Capture | Source | Viewport |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `sheet-full.png` | `src/sheet.html` | 390 px, full page |
|
||||||
|
| `mockup-<screen>.png` ×4 | the four mockup screens via `file://` | 390 px |
|
||||||
|
| `basemap-kl.png` | `basemap/index.html` | 1280 × 800 |
|
||||||
|
|
||||||
|
The mockup renders need network (CDN Tailwind and Google fonts in the read-only mockups). On capture failure or an undersized / blank shot, the committed `screen.png` for that screen is copied in as a fallback — the mockups are reference material and are never edited. The basemap capture waits for the proof page's own `RAMS-BASEMAP-READY` console signal before settling on tiles. Missing, undersized (< 10 KB), or failed captures exit `1`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run capture -w theme
|
||||||
|
npm run gallery -w theme
|
||||||
|
# → http://localhost:8123/gallery.html and http://localhost:8123/basemap/
|
||||||
|
```
|
||||||
|
|
||||||
|
`gallery.html` is the approval-gate mechanism template: every later UI phase adds one comparison row (mockup reference render on the left, Playwright implementation capture on the right). It is CDN-free by construction — only the built stylesheet plus local captures.
|
||||||
|
|
||||||
|
`npm run gallery` ends with a **blocking** `basemap:preview` server. Stop it with `Ctrl-C`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `dist/` and `screenshots/` are gitignored build output. After a fresh checkout, run `npm run build -w theme` before opening `src/sheet.html` or `gallery.html`.
|
||||||
|
- Typefaces are self-hosted woff2 subsets under `fonts/` — no Google Fonts CDN. `font-display: block` is deliberate: no fallback-font flash.
|
||||||
|
- MapLibre GL v4 is loaded from a CDN on `basemap/index.html`; the product build wires its own copy.
|
||||||
|
- Windows `*:Zone.Identifier` metadata files may be present in the tree — exclude them from any tooling.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Root [README](../README.md) — monorepo overview
|
||||||
|
- [`docs/GETTING-STARTED.md`](../docs/GETTING-STARTED.md) · [`docs/DEVELOPMENT.md`](../docs/DEVELOPMENT.md) · [`docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) · [`docs/TESTING.md`](../docs/TESTING.md)
|
||||||
|
- `stitch_minimalist_route_navigator/telemetry_rams/DESIGN.md` — the design-system document the token vocabulary is copied from
|
||||||
|
- `DESIGN.md` — Telemetry Rams philosophy
|
||||||
|
|
||||||
|
<!-- VERIFY: the private Gitea clone URL (`http://gitmirah:3000/c-fu/arah2.git`) is taken from the user-provided facts and is not verifiable from repository contents alone. -->
|
||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
<!-- generated-by: gsd-doc-writer -->
|
||||||
|
|
||||||
|
# @araharah/web
|
||||||
|
|
||||||
|
The arah² Telegram Mini App — a MapLibre GL navigation shell built with Vite and TypeScript. It owns the three-source location acquisition chain, an in-memory-only session store, the Telegram WebApp integrations (theme slots, BackButton, safe-area insets), and the Rams chrome (status strip, boot/denied/signal-lost cards, recenter plate).
|
||||||
|
|
||||||
|
Part of the [arah²](../README.md) monorepo (workspaces: `theme`, `bot`, `web`). Private package — not published to a registry.
|
||||||
|
|
||||||
|
<!-- VERIFY: the deploy step mounts web/dist/ via the bot worker's ASSETS binding (see ../bot wrangler config) -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Installed through the monorepo root (npm workspaces), not standalone:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from the repository root
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
`@araharah/theme` is a workspace dependency (`"@araharah/theme": "*"`), so the theme package must be built before this one:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build -w theme # emits theme/dist/rams.css + the basemap style
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run web:dev # vite dev server on :5173 (strictPort)
|
||||||
|
```
|
||||||
|
|
||||||
|
With no Telegram client present, `src/mock/telegram-mock.ts` installs a fake `window.Telegram.WebApp` automatically in dev (`import.meta.env.DEV`), so the shell boots to `LIVE` in a plain browser with the KL default fix. Without it, the app renders its denied card — still no blank screen.
|
||||||
|
|
||||||
|
Production build (what the Worker serves):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run web:build # theme build + vite build -> web/dist
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
Run from the package directory, or from the root with `npm run <script> -w web`.
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---|---|
|
||||||
|
| `npm run dev` | Vite dev server on port 5173 (`strictPort: true`, `vite.config.ts`). |
|
||||||
|
| `npm run build` | Production build to `dist/` — the harness mock and fetch stubs are dead-code-eliminated. |
|
||||||
|
| `npm run build:harness` | `vite build --mode harness` to `dist-harness/` — `__ARAHARAH_HARNESS__` is `true`, so the mock installer and runner-page stubs are compiled in. Never deployed. |
|
||||||
|
| `npm test` | Zero-dependency runner (`node test/runner.mjs`) that auto-discovers `test/*.test.mjs`. |
|
||||||
|
| `npm run typecheck` | `tsc --noEmit` over `src` (strict, `noUncheckedIndexedAccess`, `verbatimModuleSyntax`). |
|
||||||
|
| `npm run capture` | Playwright harness (`scripts/capture.mjs`): serves `dist-harness/`, drives every boot state, writes `screenshots/`, and runs the runtime storage probe. |
|
||||||
|
|
||||||
|
`dist/` and `dist-harness/` must never share an outDir — the two builds would clobber each other and void the production-artifact gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Module map
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.ts boot choreography — order is load-bearing
|
||||||
|
├── shell.css the Rams chrome; imports @araharah/theme/dist/rams.css
|
||||||
|
├── vite-env.d.ts ambient __ARAHARAH_HARNESS__ declaration
|
||||||
|
├── telegram/
|
||||||
|
│ ├── webapp.ts the one typed accessor for window.Telegram.WebApp (TelegramWebAppLike)
|
||||||
|
│ ├── theme.ts Telegram theme signal -> [data-tg-theme] slot switch
|
||||||
|
│ ├── safe-area.ts env(safe-area-inset-*) ∪ WebApp.safeAreaInset -> CSS custom properties
|
||||||
|
│ └── backbutton.ts BackButton total wire (root press -> WebApp.close())
|
||||||
|
├── session/
|
||||||
|
│ ├── client.ts POST /api/session with the verbatim initData string
|
||||||
|
│ └── store.ts memory-only token holder, 900s TTL — no storage API anywhere
|
||||||
|
├── location/
|
||||||
|
│ ├── chain.ts acquireFix(): the locked telegram -> webview -> chat-pin chain
|
||||||
|
│ ├── telegram.ts LocationManager wrapper + Android stale-fix nudge
|
||||||
|
│ ├── webview.ts navigator.geolocation (maximumAge: 0)
|
||||||
|
│ ├── chat-pin.ts bounded poll of /api/location + ASK VIA CHAT trigger
|
||||||
|
│ └── types.ts Fix, FixSource, LocationState, KL_CENTER
|
||||||
|
├── map/
|
||||||
|
│ ├── map.ts MapLibre v6 bootstrap; returns null when WebGL2 is absent
|
||||||
|
│ ├── follow.ts following/free camera controller
|
||||||
|
│ └── marker.ts position marker + accuracy circle + heading cone
|
||||||
|
├── ui/
|
||||||
|
│ ├── strip.ts status strip (validating/acquiring/live/lost/denied/awaiting)
|
||||||
|
│ ├── cards.ts boot / denied / signal-lost cards + gantry blink
|
||||||
|
│ └── recenter.ts follow/recenter plate
|
||||||
|
├── i18n/strings.ts every user-facing string, en + ms (the copy gate)
|
||||||
|
└── mock/telegram-mock.ts Mock-Telegram harness — excluded from dist by BA5
|
||||||
|
```
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
MAIN["src/main.ts — boot choreography"]
|
||||||
|
WA["src/telegram/webapp.ts"]
|
||||||
|
TH["src/telegram/theme.ts"]
|
||||||
|
SA["src/telegram/safe-area.ts"]
|
||||||
|
BB["src/telegram/backbutton.ts"]
|
||||||
|
SESS["src/session/client.ts"]
|
||||||
|
STORE["src/session/store.ts"]
|
||||||
|
CHAIN["src/location/chain.ts"]
|
||||||
|
TG["src/location/telegram.ts"]
|
||||||
|
WV["src/location/webview.ts"]
|
||||||
|
PIN["src/location/chat-pin.ts"]
|
||||||
|
TYPES["src/location/types.ts"]
|
||||||
|
MAP["src/map/map.ts"]
|
||||||
|
FOLLOW["src/map/follow.ts"]
|
||||||
|
MARK["src/map/marker.ts"]
|
||||||
|
STRIP["src/ui/strip.ts"]
|
||||||
|
CARDS["src/ui/cards.ts"]
|
||||||
|
REC["src/ui/recenter.ts"]
|
||||||
|
STR["src/i18n/strings.ts"]
|
||||||
|
MOCK["src/mock/telegram-mock.ts"]
|
||||||
|
|
||||||
|
MAIN --> WA
|
||||||
|
MAIN --> TH
|
||||||
|
MAIN --> SA
|
||||||
|
MAIN --> BB
|
||||||
|
MAIN --> SESS
|
||||||
|
MAIN --> CHAIN
|
||||||
|
MAIN --> MAP
|
||||||
|
MAIN --> STRIP
|
||||||
|
MAIN --> CARDS
|
||||||
|
MAIN --> REC
|
||||||
|
MAIN --> MOCK
|
||||||
|
SESS --> STORE
|
||||||
|
CHAIN --> TG
|
||||||
|
CHAIN --> WV
|
||||||
|
CHAIN --> PIN
|
||||||
|
CHAIN --> TYPES
|
||||||
|
TG --> TYPES
|
||||||
|
WV --> TYPES
|
||||||
|
PIN --> TYPES
|
||||||
|
MAP --> FOLLOW
|
||||||
|
MAP --> MARK
|
||||||
|
FOLLOW --> TYPES
|
||||||
|
MARK --> TYPES
|
||||||
|
STRIP --> TYPES
|
||||||
|
STRIP --> STR
|
||||||
|
CARDS --> STR
|
||||||
|
REC --> STR
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage examples
|
||||||
|
|
||||||
|
Acquire a fix through the chain (this is exactly what `main.ts` does):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { acquireFix, isFixStale, fixAgeSeconds } from "./location/chain.ts";
|
||||||
|
import { createSession, } from "./session/client.ts";
|
||||||
|
import { getWebApp } from "./telegram/webapp.ts";
|
||||||
|
import { getSession } from "./session/store.ts";
|
||||||
|
|
||||||
|
const wa = getWebApp(); // null off-Telegram — the chain handles null
|
||||||
|
const session = await createSession(wa?.initData ?? "");
|
||||||
|
if (!session.ok) throw new Error(session.reason); // "malformed" | "tampered" | "stale" | "network"
|
||||||
|
|
||||||
|
const { fix, state } = await acquireFix({ wa, getToken: getSession }, null);
|
||||||
|
if (fix !== null && !isFixStale(fix, Date.now())) {
|
||||||
|
console.log(fix.source, fixAgeSeconds(fix, Date.now()), "s");
|
||||||
|
} else {
|
||||||
|
console.log(state.kind); // "denied" | "awaiting-pin" | ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Boot the map on its own (returns `null` rather than throwing on a WebGL2-less device):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { bootMap } from "./map/map.ts";
|
||||||
|
import { createPositionMarker } from "./map/marker.ts";
|
||||||
|
|
||||||
|
const map = bootMap(document.getElementById("map")!);
|
||||||
|
if (map !== null) {
|
||||||
|
const marker = createPositionMarker();
|
||||||
|
marker.marker.addTo(map);
|
||||||
|
marker.update(map, fix);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run web:test # or: npm test (inside web/)
|
||||||
|
npm run web:typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
`test/runner.mjs` auto-discovers every `test/*.test.mjs` suite and drains the shared registry from `test/harness.mjs`. Suites cover the location chain (`location-chain.test.mjs`), the memory-only session store (`session.test.mjs`), theme slots (`theme.test.mjs`), the en/ms copy gates (`copy-gate.test.mjs`), the no-storage invariant (`no-storage.test.mjs`), and the production build artifact (`build-artifact.test.mjs` — CDN references, self-hosted fonts, hashed MapLibre worker, inlined Rams style, and BA5/BA6 asserting no mock installer or fetch stub ships in `dist/`).
|
||||||
|
|
||||||
|
The Playwright capture harness is a separate command (`npm run web:capture`): it serves `dist-harness/`, waits on the `RAMS-BASEMAP-READY` and `ARAHARAH-LIVE` console signals, and writes the state gallery into `screenshots/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
|
||||||
|
- Root [README.md](../README.md) — monorepo overview and system diagram
|
||||||
|
- [docs/ARCHITECTURE.md](../docs/ARCHITECTURE.md) — component responsibilities across `theme` / `bot` / `web`
|
||||||
|
- [docs/DEVELOPMENT.md](../docs/DEVELOPMENT.md) — local setup, build, and PR workflow
|
||||||
|
- [docs/TESTING.md](../docs/TESTING.md) — the cross-package test strategy
|
||||||
|
- [DESIGN.md](../DESIGN.md) and `theme/` — the Telemetry Rams design system this shell consumes
|
||||||
Reference in New Issue
Block a user