Compare commits

..
2 Commits
10 changed files with 2015 additions and 325 deletions
+248
View File
@@ -0,0 +1,248 @@
<!-- generated-by: gsd-doc-writer -->
# Contributing to arah²
arah² (the `araharah` repository) is a privacy-respecting, Telegram-first GPS navigator
for the Kuala Lumpur metro — a grammY bot + Cloudflare Worker, a Vite/MapLibre Mini App,
a design-token package, a curated KL gazetteer, and the routing engine behind the
turn-by-turn guidance. Code is **GPL-3.0**; the map-derived data files are **ODbL-1.0**.
This file tells you what belongs in the project, how to set up, and — most importantly —
which gates a change must pass before a maintainer will look at it. The repo has no CI,
so *you* are the CI.
---
## The charter — what belongs here, and what never will
arah²'s core value: **a user finds a local destination via Telegram and gets there with
reliable turn-by-turn guidance — tracked by nothing beyond what they explicitly opted in
to.** The project charter (`.planning/PROJECT.md`, tracked in this repo) records the
scope boundaries and, crucially, *why* each exclusion was made, so they don't get
re-litigated every milestone.
**In scope (v1):** the Telegram bot command surface, destination search over the curated
KL gazetteer, the Mini App webview, route display, live turn-by-turn guidance with
hybrid chat turn-cards, KL-metro tuning, and the Telemetry Rams design system.
**Out of scope — the deferred-ideas law** (from `.planning/PROJECT.md` "Out of Scope"):
- **Incident reports** (`/accident`, `/traffic`, `/hazard` + broadcasts) — navigation
first; reports ship after the core works. v1 reports *nothing*.
- **Gamification, points, leaderboards** — depends on reports; deferred with them.
- **Regional clusters via Telegram groups** — unvalidated social hypothesis.
- **Voice-to-text reporting, offline mode, anonymized public API** — future ideas.
- **Push notifications beyond Telegram chat** (Resend email etc.) — Telegram itself is
the channel.
- **Independent mobile apps** — the Mini App webview flow *is* the product.
- **Any paid-tier infrastructure.** The $0 law is a product constraint, not an
aspiration — see [docs/BUDGETS.md](docs/BUDGETS.md). IDEAS.md's RabbitMQ,
Prometheus/Grafana, Neon, and Resend entries are aspirational; do not wire them in.
If your proposal touches any of these, the bar is high: it needs a new charter decision,
not just a PR. Architecture seams for the deferred work exist deliberately (e.g. the
`SearchSource` interface), but the seams stay unused.
---
## Development setup
Read [docs/GETTING-STARTED.md](docs/GETTING-STARTED.md) for prerequisites and first-run
instructions, and [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for the full local
development setup. The short version:
1. Clone and install — npm workspaces install all five packages at once:
```bash
git clone http://gitmirah:3000/c-fu/arah2.git
cd arah2
npm install
```
<!-- VERIFY: the clone URL is the maintainer's internal Gitea origin (per `git remote -v` and README.md), not a public URL — forks/substitutes should clone their own fork's URL. -->
2. Copy `bot/.dev.vars.example` to `bot/.dev.vars` and fill in `BOT_TOKEN`,
`WEBHOOK_SECRET`, and `WEBHOOK_URL` (gitignored; never commit it).
3. `npm run build` — theme tokens first, then the web bundle (the theme build is an
input to the web build).
4. `npm run dev` — Worker on <http://localhost:8787>.
There is no linter, formatter, or commit-hook setup to install — see
[Code standards](#code-standards) below.
---
## Code standards
- **No ESLint, Prettier, Biome, or `.editorconfig` exists.** There is no `lint` or
`format` script in any workspace and no CI to enforce one. Style is a review-time
convention; the mechanically enforced gates are listed in the next section. Match the
existing source: explicit `.ts` extensions on relative imports, `node:`-prefixed
builtins, `.mjs` for scripts/tests, heavy header comments stating what a file is and
which decision IDs it satisfies.
- **TypeScript strictness is real:** both `bot/tsconfig.json` and `web/tsconfig.json`
use `strict` + `noUncheckedIndexedAccess` + `verbatimModuleSyntax`; `search/` and
`routing/` follow the same shape. `tsc --noEmit` (`npm run *:typecheck`) is the gate.
- **Design tokens:** never hardcode a color, spacing, font, or radius that belongs to a
token. `theme/src/tokens.css` is the single source of truth; `npm run audit` flags
off-token values in rendered CSS. Never edit `stitch_minimalist_route_navigator/` —
the mockups are read-only reference.
- **All user-facing copy lives in the i18n strings files** (`bot/src/i18n/strings.ts`,
`web/src/i18n/strings.ts`) with `en`/`ms` locale parity (gate `COPY1`). No inline copy
in shell modules (gate `COPY5`).
---
## The gates — what must be green before review
There is **no CI pipeline** (no `.github/`, no `.gitea/` workflows). Run these by hand
before opening a change, and paste the output into your PR:
```bash
npm run build # FIRST — web:test's BA cases read web/dist
npm run bot:test # bot/test/runner.mjs
npm run web:test # web/test/runner.mjs
npm run search:test # search/test/runner.mjs
npm run routing:test # routing/test/runner.mjs
npm test -w theme # theme/scripts/audit.test.mjs (no root passthrough)
npm run bot:typecheck
npm run web:typecheck
npm run search:typecheck
npm run routing:typecheck
npm run audit # off-token CSS audit — only if you touched theme/ or rendered CSS
```
Order matters in one place: **`npm run build` before `npm run web:test`**, or the
build-artifact gates (BA1–BA10) fail with a "build first" message.
### The repo's law enforcement: the two audit CLIs
Two maintainer-time tools (nothing in any `src` tree imports them) enforce the project's
non-negotiable laws. **A PR that makes either one fail is a regression, full stop.**
- **`npm run privacy-audit`** ([docs/PRIVACY-AUDIT.md](docs/PRIVACY-AUDIT.md),
`scripts/privacy-audit.mjs` + `scripts/lib/`) — the privacy law:
- **TTL-only storage:** every KV `.put(` must carry `expirationTtl` on the same line,
referencing a named `SCREAMING_SNAKE` constant (the `kv-ttl` gate; PG1 is the
suite-level twin). The four KV families are the complete storage surface.
- **Category-only logs:** no log line may carry a coordinate, chat id, user id,
token, initData payload, geometry, or serialized payload (the `log-vocabulary`
gate).
- **Zero telemetry:** no analytics, beacons, tracking pixels, third-party CDN, or
error-reporting host — in source *and* in the shipped `web/dist` bundle
(`storage-api`, `runtime-hosts`, `bundle-telemetry` gates). v1 has nothing to opt
into.
- **`npm run budget-audit`** ([docs/BUDGETS.md](docs/BUDGETS.md),
`scripts/budget-audit.mjs` + `scripts/lib/budget-gates.mjs`) — the $0 law: bundle
sizes, KV write classes (the cron tick writes **zero** — source-proven), subrequest
inventories, and the golden corpora, all re-derived against the Cloudflare free-plan
ceilings. If you add a route class or a KV write path, its inventory row must follow
the code.
Both audits are GPL-3.0 and reusable by forks; rerun them over your own tree before your
first driver. The `--live` legs (KV expiry, cache headers) need Cloudflare credentials
and are maintainer-time.
### The capture harness
For any Mini App UI change, `npm run web:capture` (Playwright, against the harness build
in `web/dist-harness/`) is the verification surface: screenshot legs over every
boot/route/guidance state, a DSGN-02 side-by-side against the committed mockup, and the
GEO-03 runtime storage probe that fails if any app-controlled storage surface is
non-empty after boot-to-live. Include the relevant captures in your PR. Outputs are
gitignored; regenerate, never commit.
---
## Branch and commit conventions
- The active branch is **`main`**. Work lands there via atomic commits; if you prefer a
branch, `feat/`, `fix/`, and `docs/` prefixes match the commit vocabulary. No
branch-protection or required-reviewer config exists.
- Commit messages are **conventional-commit style with a phase-scoped type**:
`type(phase): subject` — types `feat`, `fix`, `docs`, `chore`, `test`; the scope is a
phase identifier (e.g. `07-06`). Examples from `git log`:
```
fix(07-06): launch-gate decisions — KV cron retune to 720/day (the $0 law holds)
test(07-06): RED — SA6 doc-pointer static-text class vector (URL literals only)
```
Subject is lowercase, imperative, and carries an em-dash clause explaining the *why*
or the observable failure. Reference decision IDs (`D-09`, `PRIV-02`, `GEO-03`) in
parentheses when relevant. One concern per commit.
---
## Pull requests
There is no PR template and no configured review process (this is a private Gitea
repo — `git remote -v` points at `http://gitmirah:3000/c-fu/arah2.git`). If you open a
PR there, reviewers look for:
- **All gates green, pasted.** The full list from [the gates](#the-gates--what-must-be-green-before-review)
above, plus `npm run privacy-audit` and `npm run budget-audit` if you touched any
storage, logging, network, bundle, or KV surface — i.e. if in doubt, run both audits.
- **The observable before/after**, stated like a commit subject: what was broken, what
it does now.
- **Gate-relevant surface called out explicitly.** Touched a KV write? Say which
`expirationTtl` and which gate re-verified it. Touched `web/src`? `GEO3`/`COPY1`/`COPY5`
re-run. Touched the command surface? `CS4`/`CS6` re-run.
- **Screenshots** for Mini App changes (from `npm run web:build:harness &&
npm run web:capture`).
- **No generated artifacts committed.** `theme/dist`, `web/dist`, `web/dist-harness`,
and all `screenshots/` directories are gitignored by design — always rebuild. In
particular, committed `theme/dist` would go stale because the web build consumes it
at build time without vendoring.
---
## Licensing your contribution
- **Code is GPL-3.0.** The full text is in [LICENSE](./LICENSE), and GPL-3.0 is a
deliberate project law (decision D-04, per [LICENSES.md](./LICENSES.md)): a fork must
be able to read, change, and redistribute the whole working system. There is **no CLA
and no DCO configured** — no `Signed-off-by` lines exist in the commit history, and no
sign-off mechanism is set up. By submitting a PR you agree your code contribution is
licensed under GPL-3.0 alongside the rest of the project. If you add a new source
file, a copyright header is welcome but not required.
- **Map-derived data is ODbL-1.0, not GPL.** The curated gazetteer
(`search/src/data/kl-gazetteer.json`), the route fixtures
(`routing/src/data/route-fixtures.json`), and the derived road graph are all curated
or derived from OpenStreetMap data — **© OpenStreetMap contributors, ODbL 1.0**
(<https://opendatacommons.org/licenses/odbl/1-0/>). The attribution clause travels
with derived databases, so:
- Never remove the `_license` / `_attribution` / `_notice` envelope fields from a
data file.
- Any new data file derived from OSM must carry the same envelope.
- Any restructured/derived index of the gazetteer carries the same notice (ODbL §4.4
share-alike for Derivative Databases). Code and data are separately licensed — ODbL
§2.3(a) excludes computer programs.
- See [docs/ROAD-GRAPH.md](docs/ROAD-GRAPH.md) for the road-graph licensing surface.
---
## Reporting issues and proposing ideas
There are no issue templates (no `.github/ISSUE_TEMPLATE/`). The repository lives on a
private Gitea instance at `http://gitmirah:3000/c-fu/arah2` — use its issue tracker if
you have access. <!-- VERIFY: Gitea issue-tracker availability and URL path — the remote is confirmed via `git remote -v`, but the tracker is on the maintainer's internal instance and cannot be verified from the repository contents. -->
For a bug report, include:
- Steps to reproduce, expected vs actual behavior, and environment (which Telegram
client surface — `?mock=` value if you used the harness — and OS).
- The failing gate output, verbatim: the runners and audits print evidence strings that
are meant to be quoted, never paraphrased.
For a feature proposal, first check the charter above: if it's in the deferred list,
argue why the deferral no longer holds, and expect that discussion to happen at the
charter level (`.planning/PROJECT.md`), not in the PR.
**Where decisions live.** Project-level decisions are recorded in `.planning/PROJECT.md`
(charter, key decisions), `.planning/ROADMAP.md`, and per-phase documents under
`.planning/phases/` — all tracked in the repo. The checked-in evidence records are the
`docs/` files: [PRIVACY-AUDIT.md](docs/PRIVACY-AUDIT.md) (the signed privacy audit),
[BUDGETS.md](docs/BUDGETS.md) (the measured budget record),
[SEARCH-QUALITY.md](docs/SEARCH-QUALITY.md) and [ROAD-GRAPH.md](docs/ROAD-GRAPH.md)
(validation records), and [KNOWN-LIMITATIONS.md](docs/KNOWN-LIMITATIONS.md). If a
reviewer asks "where was this decided?", the answer is one of those files.
+39 -16
View File
@@ -8,20 +8,21 @@
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. A privacy-respecting, Telegram-first GPS navigator — a waze-like route navigator delivered through a Telegram bot and Mini App instead of an app-store install, built on OpenStreetMap data, and designed to Dieter Rams' "less, but better." First users are in the Kuala Lumpur metro.
The Telegram bot is **arah2bot**. The Telegram bot is **arah2bot**. The product is live — a private beta with real drivers in KL: open [@arah2bot](https://t.me/arah2bot), send `/cari KLCC`, open the venue card's Mini App button, and follow live turn-by-turn guidance. The app is served from <https://araharah-bot.araharah-bot.workers.dev>.
--- ---
## Overview ## Overview
arah² is an npm-workspaces monorepo with four packages: arah² is an npm-workspaces monorepo with five packages:
| Package | Name | What it does | | 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. | | `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. | | `bot/` | `@araharah/bot` | A [grammY](https://grammy.dev) Telegram bot running on a Cloudflare Worker. It serves the `/telegram/webhook` route, five JSON API routes, keeps TTL-only chat sessions in Workers KV, loads the road graph from an R2 bucket, and pushes turn cards to chat from a scheduled cron tick. It also serves the Mini App's static assets through a wrangler assets binding. |
| `web/` | `@araharah/web` | The Telegram Mini App: a MapLibre GL navigation shell built with Vite. Owns the three-source location acquisition chain, an in-memory session store, and the Telegram WebApp integrations (theme slots, BackButton, safe-area insets). | | `web/` | `@araharah/web` | The Telegram Mini App: a MapLibre GL navigation shell built with Vite. Owns the three-source location acquisition chain, the in-app destination search UI, route rendering, turn-card guidance replay, an in-memory session store, and the Telegram WebApp integrations (theme slots, BackButton, safe-area insets). |
| `search/` | `@araharah/search` | The shared, dependency-free destination-search module: the JUPEM-derived Malay abbreviation table as editable data, a symmetric normalizer, a Damerau (OSA) residual scorer with disjoint tiers, and the curated KL gazetteer with locals' aliases — imported by the bot, the Mini App, and the search-quality corpus runner. | | `search/` | `@araharah/search` | The shared, dependency-free destination-search module: the JUPEM-derived Malay abbreviation table as editable data, a symmetric normalizer, a Damerau (OSA) residual scorer with disjoint tiers, and the curated KL gazetteer with locals' aliases — imported by the bot, the Mini App, and the search-quality corpus runner. |
| `routing/` | `@araharah/routing` | The shared, dependency-free routing data plane: a CSR road graph with snap-to-graph, A* search, and polyline6 codecs. Engine A runs it Worker-side over the R2-hosted graph (`/api/route`); the fixture source (15 committed KL golden pairs) and the route-quality corpus runner live alongside it. |
The Worker is the single origin. It answers `/api/*` and `/telegram/*` itself (`run_worker_first`) and serves everything else out of `web/dist`. The Worker is the single origin. It answers `/api/*` and `/telegram/*` itself (`run_worker_first`) and serves everything else out of `web/dist`.
@@ -29,7 +30,8 @@ The Worker is the single origin. It answers `/api/*` and `/telegram/*` itself (`
graph TD graph TD
U["Telegram client — arah2bot chat + Mini App webview"] U["Telegram client — arah2bot chat + Mini App webview"]
W["Cloudflare Worker — araharah-bot"] W["Cloudflare Worker — araharah-bot"]
KV[("Workers KV — chat_sessions, TTL 900s")] KV[("Workers KV — TTL-only records: sessions 900s, pins 300s, guidance 7200s")]
R2[("R2 — road-graph binary, one GET per cold isolate")]
TG["Telegram Bot API"] TG["Telegram Bot API"]
APP["Mini App assets — web/dist via ASSETS binding"] APP["Mini App assets — web/dist via ASSETS binding"]
MAP["MapLibre GL — Rams basemap, OpenFreeMap tiles"] MAP["MapLibre GL — Rams basemap, OpenFreeMap tiles"]
@@ -39,10 +41,12 @@ graph TD
TG --> W TG --> W
U -->|"GET Mini App"| APP U -->|"GET Mini App"| APP
APP --> W APP --> W
W -->|"POST /api/session, /api/location-fallback"| TG W -->|"venue card, pin keyboard, turn cards"| TG
APP -->|"POST /api/session — initData HMAC"| W APP -->|"POST /api/session — initData HMAC"| W
APP -->|"GET /api/location — Bearer token"| W APP -->|"GET /api/location — Bearer token"| W
APP -->|"POST /api/route — Bearer token"| R2
W --> KV W --> KV
W -->|"cron */2 → turn cards"| TG
APP --> MAP APP --> MAP
THEME -.->|"inlined at build time"| MAP THEME -.->|"inlined at build time"| MAP
THEME -.->|"dist/rams.css"| APP THEME -.->|"dist/rams.css"| APP
@@ -53,7 +57,7 @@ graph TD
classDef web fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1E1B14 classDef web fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1E1B14
class U,TG telegram class U,TG telegram
class W worker class W worker
class KV storage class KV,R2 storage
class APP,MAP,THEME web class APP,MAP,THEME web
``` ```
@@ -69,7 +73,7 @@ cd arah2
npm install 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`. Requires Node.js and npm (no version is pinned in the repo — use a current LTS; the corpus runners rely on Node's native TypeScript type-stripping). Wrangler handles Cloudflare authentication on first use via `wrangler dev` / `wrangler deploy`.
<!-- VERIFY: Node.js minimum version — no `engines` field or `.nvmrc` exists in the repository; confirm the supported floor with the maintainers. --> <!-- VERIFY: Node.js minimum version — no `engines` field or `.nvmrc` exists in the repository; confirm the supported floor with the maintainers. -->
@@ -114,12 +118,21 @@ The bot answers in English and Bahasa Melayu. Commands are registered into Teleg
| Command | Alias | Behaviour | | Command | Alias | Behaviour |
|---|---|---| |---|---|---|
| `/start` | `/mula` | One-sentence introduction to arah² and its privacy posture. | | `/start` | `/mula` | One-sentence introduction to arah² and its privacy posture. |
| `/help` | — | Lists the commands that exist in this phase. | | `/help` | — | Lists the commands that exist and points at the known-limits record. |
| `/privacy` | `/privasi` | Names exactly what is stored (user id, chat id, language, ~15 min) and exactly what is never stored (location, routes, trip history, profile). | | `/privacy` | `/privasi` | Names exactly what is stored (user id, chat id, language, ~15 min) and exactly what is never stored (location, routes, trip history, profile). |
| `/cari` | `/search` | Searches the curated KL gazetteer (`/cari KLCC`) and replies with **one** native venue card (`sendVenue`) carrying an OPEN IN ARAHARAH Mini App button plus 2–4 alternates as callback buttons. Out-of-area queries get an honest refusal that names the v1 area, never the query text. |
### The live flow, end to end
1. **Search.** `/cari <place>` normalizes and scores the query against the KL gazetteer (`@araharah/search`); the best hit ships as a venue card with deep link `MINI_APP_URL/?dest=<gazetteer-id>`.
2. **Open the Mini App.** The webview reads `?dest=` from `location.search`, preloads the destination, and mints a session by posting the verbatim `Telegram.WebApp.initData` string to `/api/session`.
3. **Acquire a starting point.** The three-link location chain (`web/src/location/chain.ts`) tries **Telegram → WebView → chat pin**, in that locked order.
4. **Route.** The app posts the origin and destination to `/api/route`; the Worker's router (`bot/src/routing/worker-router.ts`) loads the R2-hosted CSR graph once per cold isolate, snaps, and runs A* (`@araharah/routing`). Every miss class answers `200 null` — router-pending, never a wrong line.
5. **Guidance.** Accepting a route mints a guidance envelope (`POST /api/guidance`, 7200 s TTL); a scheduled cron (`*/2 * * * *`) projects progress along the frozen route snapshot and pushes due turn cards — a maneuver glyph photo with a live caption — into the chat, with a button back into the Mini App at `?g=<envelope-id>`.
### HTTP surface ### 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. The Worker's `fetch` handler routes exactly six paths and returns `404` for anything else. All rejections carry a category only — never a payload, a user id, or a timestamp.
| Method | Path | Purpose | | Method | Path | Purpose |
|---|---|---| |---|---|---|
@@ -127,6 +140,8 @@ The Worker's `fetch` handler routes exactly four paths and returns `404` for any
| `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. | | `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`. | | `GET` | `/api/location` | Polled by the Mini App with its in-memory `Bearer` token. The KV key derives from the resolved claim's `chat_id`, never from a client-supplied id, so one user's token can never read another user's pin. Returns honest nulls; a missing or expired token is `401`. |
| `POST` | `/api/location-fallback` | The "ask via chat" path when geolocation is denied. The bot sends a `request_location` reply keyboard into the user's own chat. Target chat id and locale come from the resolved claim — the request body is never read. A rejected Telegram send is a category-only `502`. | | `POST` | `/api/location-fallback` | The "ask via chat" path when geolocation is denied. The bot sends a `request_location` reply keyboard into the user's own chat. Target chat id and locale come from the resolved claim — the request body is never read. A rejected Telegram send is a category-only `502`. |
| `POST` `GET` `DELETE` | `/api/guidance` | Guidance envelopes. `POST` mints a 7200 s session from a validated six-field route snapshot (a body `chat_id` is structurally ignored — identity is server-owned). `GET`/`DELETE` resolve the record and compare it to the claim, so a foreign and an absent session are indistinguishable `404`s. |
| `POST` | `/api/route` | Engine A's arm. Bearer-gated exactly like `/api/location`; returns a route summary, steps, and polyline6 geometry, or `200 null` on every miss class (honest degradation). Backed by the R2 road-graph bucket; a missing or corrupt graph degrades to router-pending, never a crash. |
```bash ```bash
# Mint a session from a Mini App initData string # Mint a session from a Mini App initData string
@@ -189,11 +204,16 @@ Run from the repo root, or with `-w <workspace>`.
| `npm run build` | Build the theme, then the Mini App | | `npm run build` | Build the theme, then the Mini App |
| `npm run dev` | Build, then run the Worker locally on port `8787` | | `npm run dev` | Build, then run the Worker locally on port `8787` |
| `npm run deploy` | Build, then `wrangler deploy` + `set-webhook.mjs` | | `npm run deploy` | Build, then `wrangler deploy` + `set-webhook.mjs` |
| `npm run bot:test` / `npm run web:test` / `npm run search:test` | Run each package's zero-dependency test suite | | `npm run bot:test` / `npm run web:test` / `npm run search:test` / `npm run routing:test` | Run each package's zero-dependency test suite |
| `npm run bot:typecheck` / `npm run web:typecheck` / `npm run search:typecheck` | `tsc --noEmit` | | `npm run bot:typecheck` / `npm run web:typecheck` / `npm run search:typecheck` / `npm run routing:typecheck` | `tsc --noEmit` |
| `npm run bot:commands` | Register the persistent Telegram command menu (EN + MS) | | `npm run bot:commands` | Register the persistent Telegram command menu (EN + MS) |
| `npm run web:capture` | Capture Mini App screenshots | | `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 web:build:harness` | Dual build into `dist-harness/` with the mock installer and fetch stubs |
| `npm run privacy-audit` | Repo-wide privacy audit: storage gate, KV-TTL gate, category-only log gate, runtime-host allowlist, production-bundle telemetry scan (`--live` probes add KV expiry + cache headers) |
| `npm run budget-audit` | Free-tier budget audit: re-measures Worker CPU, bundle sizes, KV write classes, subrequests, and re-runs the golden corpora |
| `npm run search:corpus` | Search-quality gate: golden corpus against the shipped search module |
| `npm run routing:corpus` | Route-quality gate: fixture corpus (RT1–RT10) against the shipped routing module |
| `npm run routing:fixtures` | Refresh the committed route fixtures from the fixture HTTP source |
| `npm run sheet` / `gallery` / `audit` / `capture` | Design-token tooling (see above) | | `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. The two build outputs must never share an `outDir` — the harness build emits mock fetch stubs that production must not carry.
@@ -205,17 +225,20 @@ The two build outputs must never share an `outDir` — the harness build emits m
Privacy is structural, not a policy page: 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. - 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. - Three TTL-only record families, nothing else: sessions — exactly four fields (user id, chat id, issued-at, language code) under a 900-second TTL; location pins — 300 seconds; guidance envelopes — 7200 seconds. Minting rotates any prior token for the chat.
- The turn-card cron is **stateless**: it reads KV, sends cards, and writes nothing.
- The Mini App holds its session token in a module-scope closure. There is no `localStorage`, `sessionStorage`, cookie, IndexedDB, or Cache API anywhere in `web/src` — the `no-storage` test suite pins this. - 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. - Nothing durable, nothing reconstructable. A token cannot outlive the 15-minute initData freshness proof that minted it.
- Two audits back all of this with evidence: `npm run privacy-audit` (storage, TTL, category-only logs, runtime-host allowlist, shipped-bundle telemetry scan) and `npm run budget-audit` (the free-tier ceilings, measured — see `docs/BUDGETS.md`). v1 carries **zero telemetry**; there is nothing to opt into yet.
## Repository layout ## Repository layout
``` ```
theme/ @araharah/theme — tokens.css, components.css, theme.css, fonts/, basemap/, scripts/ 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 bot/ @araharah/bot — src/webhook.ts entry, bot/, kv/, security/, routing/, guidance/, i18n/, scripts/, wrangler.jsonc
web/ @araharah/web — src/main.ts entry, location/, map/, session/, telegram/, ui/, mock/ web/ @araharah/web — src/main.ts entry, location/, route/, guidance/, search/, map/, session/, telegram/, ui/, mock/
search/ @araharah/search — src/types.ts, src/normalize.ts, src/score.ts, src/gazetteer.ts, src/data/kl-gazetteer.json search/ @araharah/search — src/types.ts, normalize.ts, distance.ts, score.ts, gazetteer.ts, source.ts, data/kl-gazetteer.json
routing/ @araharah/routing — src/graph.ts, snap.ts, astar.ts, polyline.ts, geo.ts, fixture-source.ts, http-source.ts, data/kl-roadgraph.v1.bin
stitch_minimalist_route_navigator/ read-only mockup screens (class-vocabulary source for the theme build) stitch_minimalist_route_navigator/ read-only mockup screens (class-vocabulary source for the theme build)
DESIGN.md Rams philosophy — superseded for any specific value DESIGN.md Rams philosophy — superseded for any specific value
``` ```
+1 -1
View File
@@ -50,7 +50,7 @@ import {
import type { GuidanceSessionEnvelope } from "../kv/guidance-sessions.ts"; import type { GuidanceSessionEnvelope } from "../kv/guidance-sessions.ts";
import { strings } from "../i18n/strings.ts"; import { strings } from "../i18n/strings.ts";
/** The chat card's cadence (D-11/D-13): one card window every 3 minutes; /** The chat card's cadence (D-11/D-13): one card window every 4 minutes (the launch-gate retune: 2-minute ticks + CARD_CADENCE_S 240);
* the window's first tick is the due tick. */ * the window's first tick is the due tick. */
export const CARD_CADENCE_S = 240; export const CARD_CADENCE_S = 240;
+328
View File
@@ -0,0 +1,328 @@
<!-- generated-by: gsd-doc-writer -->
# API
The **arah²** API is the surface of a single Cloudflare Worker (`araharah-bot`, source of truth:
`bot/src/webhook.ts` — the `fetch` handler — plus `bot/src/routing/worker-router.ts` and the KV
services in `bot/src/kv/`). It serves three clients:
- **Telegram** — POSTs updates to `POST /telegram/webhook`
- **The Mini App** (`web/`, the Telegram webview) — calls `/api/session`, `/api/location`,
`/api/location-fallback`, `/api/guidance`, `/api/route`
- **Nobody else.** Any other path is a plain `404 Not found`.
There is no OpenAPI/Swagger spec in the repo; this document is the contract, verified against the
live source. The corresponding clients are `web/src/session/client.ts`,
`web/src/location/chat-pin.ts`, and `web/src/route/route.ts`.
---
## Authentication
Two independent mechanisms, one per transport:
### 1. Telegram updates — secret-token header (`/telegram/webhook`)
Telegram includes `X-Telegram-Bot-Api-Secret-Token: <WEBHOOK_SECRET>` on every update. The Worker
hands the secret to grammY's `webhookCallback(bot, "cloudflare-mod", …, env.WEBHOOK_SECRET)`, which
constant-time-compares it **before any bot processing or KV access**.
**Fail-closed (WR-02):** if the `WEBHOOK_SECRET` binding is missing or empty at request time, the
route rejects every delivery with `500 Server misconfigured` *before* grammY's check ever runs —
because grammY's `compareSecretToken` silently accepts **every** request when its secret is
undefined. An unarmed webhook must never half-work; it must refuse all traffic.
The secret is armed by `bot/scripts/set-webhook.mjs`, which sends it to Telegram's `setWebhook` as
`secret_token` — Telegram then echoes it back on every update.
### 2. Bearer session tokens — every `/api/*` route
The Mini App obtains a **one-time opaque session token** from `POST /api/session` (below) and sends
it on subsequent calls:
```
Authorization: Bearer <token>
```
- Tokens are `crypto.randomUUID()` strings matching `[A-Za-z0-9-]+` (the header regex in
`bot/src/webhook.ts`).
- A token resolves through `getClaimByToken` (`bot/src/kv/sessions.ts`) to a **claim** — exactly
`{ user_id, chat_id, issued_at, language_code }` (D-08). Nothing else is ever persisted.
- Every chat-scoped KV key (`loc:<chat_id>`, `chat:<chat_id>`, `guidance:<id>`) is keyed from the
**claim's** `chat_id`, never from a client-supplied id — one user's token can never read another
user's pin or another user's guidance session.
- Tokens expire after **900 s** (`SESSION_TTL_SECONDS`, D-13 — a session cannot outlive the 15-min
initData freshness window that minted it). Rotation is mint-side: each new `/api/session` mint
sweeps the chat's previous token (`bot/src/kv/sessions.ts`, D-07).
### The `/api/session` gate (initData → token)
`POST /api/session` authenticates by Telegram's own signature: the request body is the **verbatim**
`Telegram.WebApp.initData` string — not re-serialized, not re-encoded. Validation
(`bot/src/security/initdata.ts`):
1. Parse as a Telegram-style querystring (values decoded **once**; `+` stays literal — never
`URLSearchParams`).
2. Build the data-check string from all fields **except `hash` only** — `signature` *participates*
in the HMAC (excluding it broke real clients; device-matrix round 6).
3. Derive the key as `HMAC-SHA256(key = "WebAppData", message = botToken)`, then compare
`hex(HMAC-SHA256(dataCheckString, derivedKey))` against the `hash` field with a constant-time
hex compare.
4. Reject stale payloads: `auth_date` older than **900 s** is rejected.
Rejection precedence and vocabulary: `malformed` → `tampered` → `stale`.
---
## Endpoints overview
| Method | Path | Description | Auth required |
| --- | --- | --- | --- |
| POST | `/telegram/webhook` | Telegram updates → grammY bot (commands `/start`, `/help`, `/privacy`, `/cari`, plus the location-keyboard message handler). Secret-token gated, fail-closed. | Secret-token header |
| POST | `/api/session` | Exchange verbatim `initData` for a one-time opaque session token. | None (initData **is** the credential) |
| GET | `/api/location` | Relay the pin the user shared in chat (300 s TTL) to the Mini App. | Bearer token |
| POST | `/api/location-fallback` | Ask the bot to send a `request_location` reply keyboard into the caller's own chat. | Bearer token |
| POST | `/api/guidance` | Mint a guidance session envelope (7200 s TTL) for the caller's chat. | Bearer token |
| GET | `/api/guidance?id=<uuid>` | Read the caller's guidance envelope. | Bearer token |
| DELETE | `/api/guidance?id=<uuid>` | Delete the caller's guidance envelope (trip end). | Bearer token |
| POST | `/api/route` | Route engine A: snap origin → A* over the R2-hosted KL road graph → one best route. | Bearer token |
| (cron) | `scheduled()` | Every-2-minutes cron tick: projects due turn-cards and pushes them to Telegram. Not reachable over HTTP. | n/a (platform cron) |
Anything else → `404 Not found` (text body). Static Mini App assets are served by Cloudflare
Workers static assets (`../web/dist`); the Worker takes `/api/*` and `/telegram/*` first via the
`run_worker_first` glob array in `bot/wrangler.jsonc`.
---
## Request/response formats
All `/api/*` responses are `application/json` unless noted. Error bodies carry exactly one
`error` field with a category word — never payload data, ids, or timestamps (D-14).
### POST /api/session
- **Request body:** the verbatim `initData` string as `text/plain`-ish raw body — *not* JSON, *not*
re-encoded. Cap: **4096 chars** (`MAX_INITDATA_CHARS`, WR-01) — over-cap is a `413` before any
parsing (both the declared `Content-Length` and the post-read length are checked).
- **200** — `{ "token": "<opaque uuid>" }`
- **401** — `{ "error": "malformed" | "tampered" | "stale" }`
- **413** — `{ "error": "malformed" }` (over-cap body)
- **405** — non-POST (plain text `Method not allowed`)
The token is single-use in spirit — one live token per chat under rotation (D-07); the Mini App
holds it in module memory only (never `localStorage` — `web/src/session/store.ts` writes no
storage).
### GET /api/location
- **Request:** no body. Bearer header.
- **200** — `{ "pin": { "latitude": 3.1578, "longitude": 101.7118, "horizontal_accuracy": 12 } }`
or `{ "pin": null }` — honest nulls: no pin exists, or the record failed defensive validation.
The pin record is exactly Telegram's Location fields (`bot/src/kv/location-pin.ts`, 300 s TTL).
- **401** — `{ "error": "unauthorized" }` (missing/expired/unknown token)
- **405** — non-GET
### POST /api/location-fallback
- **Request:** no body is read — **the request body is never read at all.** The target chat and the
locale both derive from the resolved claim (`claim.chat_id`, `claim.language_code` →
`pickLocale`). This is deliberate (T-03-27): no client input can aim a keyboard at another chat
or choose another language.
- **200** — `{ "ok": true }` (the keyboard was sent)
- **401** — `{ "error": "unauthorized" }`
- **502** — `{ "error": "unavailable" }` — Telegram rejected the send (blocked bot, chat not found,
rate limit). Category-only: the response carries no chat id or error text.
- **405** — non-POST
### POST /api/guidance
Mints a guidance session. The envelope shape is a frozen contract
(`bot/src/kv/guidance-sessions.ts`, GE2) — exactly four fields:
```json
{
"id": "0f6f0a2e-…(lowercase uuid)",
"chat_id": 123456789,
"route_snapshot": {
"destinationId": "klcc",
"destinationName": "Kuala Lumpur Convention Centre",
"distanceM": 5230,
"durationS": 840,
"geometry": "(polyline6-encoded string)",
"startedAtSec": 1761355200
},
"created_at": 1761355800
}
```
- **Request body:** JSON with the six `route_snapshot` fields (validation in
`webhook.ts::validateSnapshot`, hand-rolled — no zod):
- `destinationId` — gazetteer slug, `^[a-z0-9-]{1,64}$`
- `destinationName` — non-empty string, ≤ 200 chars
- `distanceM`, `durationS` — finite numbers ≥ 0
- `startedAtSec` — positive safe integer (seconds-since-epoch)
- `geometry` — polyline6 string that decodes to ≥ 2 points
- Cap: **8192 chars** (`MAX_GUIDANCE_BODY_CHARS`, WR-01) → `413` over-cap.
- **A client-supplied `chat_id` is structurally ignored** (T-06-02): the validator builds a new
object field-by-field from the six snapshot fields, so anything else in the body — chat ids
included — never reaches storage. The envelope's `chat_id` is minted from the claim.
- **201** — `{ "id": "<envelope uuid>" }`
- **400** — `{ "error": "malformed" }` (bad JSON or failed snapshot validation)
- **401** — `{ "error": "unauthorized" }`
- **413** — `{ "error": "malformed" }`
- **405** — other methods
### GET /api/guidance?id=<uuid> · DELETE /api/guidance?id=<uuid>
- `id` must be a lowercase UUID (`ENVELOPE_ID_PATTERN`); anything else returns `404` **before any
KV access** — no arbitrary-key probing.
- **200 (GET)** — the full envelope (shape above), only when `envelope.chat_id === claim.chat_id`.
- **200 (DELETE)** — `{ "deleted": true }` (active removal, not just TTL expiry).
- **404** — `{ "error": "not_found" }` — **foreign and absent sessions are indistinguishable**
(T-06-02b): a wrong-but-valid id owned by another chat is exactly the same response as a
nonexistent one. No existence oracle for cross-chat probing.
- **401** — `{ "error": "unauthorized" }`
### POST /api/route
Engine A's server arm (`bot/src/routing/worker-router.ts`). Bearer-gated exactly like
`/api/location`; routing itself is chat-agnostic — nothing about the caller is stored, logged, or
echoed.
- **Request body (JSON):**
```json
{
"origin": { "lat": 3.1520, "lng": 101.6932 },
"destinationId": "klcc"
}
```
- `origin.lat`/`origin.lng` — finite numbers (WGS84); range-checked against the graph's `bbox`
**before** any snap/A* work (out-of-bbox → `400 malformed`).
- `destinationId` — gazetteer slug (`^[a-z0-9-]{1,64}$`); resolved via the gazetteer
(`getEntryById`), not taken on faith.
- Cap: **4096 chars** (WR-01) → `413`.
- **200 (hit)** — a `RouteResult` with **exactly one option** (alternatives generation is
prohibited on this path — CPU law):
```json
{
"destinationId": "klcc",
"origin": { "lat": 3.1520, "lng": 101.6932 },
"options": [
{
"geometry": {
"points": [{ "lat": 3.152, "lng": 101.6932 }],
"cumulativeDistanceM": [0, 180, 512]
},
"summary": { "distanceM": 5230, "durationS": 840 },
"steps": [
{ "name": "Jalan Ampang", "kind": "right", "geometry": "…", "distanceM": 320, "durationS": 45 }
]
}
],
"backend": "engine-a"
}
```
The origin is snapped to the road graph server-side (`snapToGraph`) before A* — the client never
sends a snapped point. Steps carry `kind` one of
`depart | straight | left | right | slight-left | slight-right | uturn | arrive` and street names
from the graph's own OSM-derived edges.
- **200 (miss) — the null contract (D-03):** every miss class answers `200` with a JSON `null`
body — the *router-pending* state, never an error:
- unknown `destinationId`
- origin or destination snap miss
- disconnected origin/destination pair
- absent or corrupt road-graph object in R2
- failed R2 read
The client renders this honestly as "the router is not ready for this trip" — never a blank map,
never a fake route.
- **401** — `{ "error": "unauthorized" }`
- **400** — `{ "error": "malformed" }` (bad JSON, bad shape, or origin outside `graph.bbox`)
- **413** — `{ "error": "malformed" }`
- **405** — non-POST
**Free-plan CPU class (c3):** the Worker runs on the 10 ms/request free-plan CPU ceiling. Short
product routes (2–8 km, the common search-and-reroute trip) measure 2–8 ms and resolve in-budget;
cross-metro routes (20–30 km) exceed it and degrade honestly to the router-pending null — the
committed upgrade lever is ALT landmark preprocessing, not a paid plan
(`docs/BUDGETS.md`, `docs/KNOWN-LIMITATIONS.md`).
### scheduled() — the guidance cron tick (not HTTP)
`bot/wrangler.jsonc` arms `triggers.crons: ["*/2 * * * *"]` — a tick every 2 minutes. Each tick:
1. **Fail-closed unconfigure:** missing `BOT_TOKEN`, `chat_sessions`, or `MINI_APP_URL` → log
`skipped: unconfigured`, return (an unarmed cron never half-runs).
2. **Load the graph once per tick** via the same memoized `loadGraph` the route arm uses (one R2
GET per cold isolate, shared between arms). A null graph degrades cards to street-less
bare-verb compositions — the send still fires (D-03).
3. **Paginated KV scan** of the `guidance:` prefix (cursor loop until `list_complete`); each key is
read through the defensively-validated `getGuidanceSession` — a corrupt record reads `null` and
is skipped honestly.
4. **Project due cards** with the pure `projectTurnCards` (`bot/src/guidance/cron.ts`):
`CARD_CADENCE_S = 240` — a card is due on the first tick of each 240 s window
(`nowSec % CARD_CADENCE_S < SCAN_TICK_S`; `SCAN_TICK_S = 60` is the code's tick constant).
Cards are street-bearing (street reconstructed from the graph's edge names) glyph + text cards.
5. **Push to `envelope.chat_id` ONLY** — no request path exists in a cron tick, so identity is the
envelope's own mint-side chat. The card is a `sendPhoto` of
`${MINI_APP_URL}/glyphs/<turn-kind>.png` with an HTML caption and an inline keyboard opening
`MINI_APP_URL/?g=<envelope id>`.
6. **STATELESS: zero KV writes.** The tick lists and reads only — the free-plan budget law (KV list
ceiling: 1,000/day; `*/2` × 1 list-op = 720/day, inside it).
Card language is EN (the envelope contract carries no language field); captions are composed only
from numbers and string-table constants — HTML-escape surface is zero.
---
## Error codes
The standard error shape is `{"error": "<category>"}` with `content-type: application/json`
(exceptions: `404`/`405`/`500` on the raw-Response paths return plain text).
| Status | `error` body | Meaning | Where |
| --- | --- | --- | --- |
| 400 | `malformed` | Bad JSON, failed shape validation, or origin outside the graph bbox | `/api/guidance` POST, `/api/route` |
| 401 | `unauthorized` | Missing/expired/unknown Bearer token | all Bearer routes |
| 401 | `malformed` \| `tampered` \| `stale` | initData HMAC gate rejection vocabulary | `/api/session` |
| 404 | `not_found` | Guidance id absent **or** owned by another chat — indistinguishable by design | `/api/guidance` GET/DELETE |
| 404 | *(plain text)* | Unknown path | fallback |
| 405 | *(plain text)* | Method not allowed for the path | all routes |
| 413 | `malformed` | Body over cap (initData 4 KB, guidance 8 KB, route 4 KB) — checked pre- and post-read | `/api/session`, `/api/guidance` POST, `/api/route` |
| 500 | *(plain text)* | `Server misconfigured` — **fail-closed webhook gate** (missing/empty `WEBHOOK_SECRET`); every Telegram delivery is refused until the secret is armed | `/telegram/webhook` |
| 502 | `unavailable` | Telegram rejected the send (blocked bot, chat not found, rate limit) | `/api/location-fallback` |
| 200 `null` | — | The honest router-pending miss class (see `/api/route`) | `/api/route` |
**Logging law (D-14/D-17):** the only log lines are categories — `[webhook] rejected:
misconfigured`, `[initdata] rejected: <reason>`, `[location] rejected: unauthorized`,
`[guidance-cron] skipped: malformed`, `[route] rejected: <category>`, etc. No raw payload, no user
ids, no chat ids, no coordinates, no timestamps, no secrets are ever logged or echoed.
---
## Rate limits
No rate-limiting middleware or library is configured in this codebase — grep-verified: there is no
`express-rate-limit`, no token-bucket, no per-token quota in `bot/src`. Two platform-level layers
apply instead, and they are the design's actual throttle:
- **Telegram→webhook:** only Telegram's own servers POST to `/telegram/webhook`, authenticated by
the secret-token channel; arbitrary third-party POSTs fail the constant-time compare.
- **Cloudflare free-plan ceilings** (documented in `docs/BUDGETS.md`): 100,000 Worker requests/day,
1,000 KV writes/day and 1,000 KV list operations/day, 10 ms CPU/request. The API's design —
TTL-only KV writes, the stateless cron, the one-route `/api/route` — exists to stay under these
ceilings at $0.
Client-side courtesy: the Mini App's `GET /api/location` is a poll, but the pin it relays exists
for only 300 s (`PIN_TTL_SECONDS`) and is written only when the user explicitly shares a location
in chat — there is no background tracking loop to rate-limit.
<!-- VERIFY: the deployed base URL (bot/wrangler.jsonc pins the workers.dev origin MINI_APP_URL as https://araharah-bot.araharah-bot.workers.dev; a fork or a later custom domain supersedes it). -->
<!-- VERIFY: BotFather's registered Mini App URL must equal the deployed origin — the runtime origin restriction (Bot API 10.2) is enforced by Telegram, not this repo. -->
+415 -80
View File
@@ -9,16 +9,26 @@ served from the same origin. The Mini App (`@araharah/web`, a Vite + MapLibre GL
application) authenticates with the Worker by posting its verbatim application) authenticates with the Worker by posting its verbatim
`Telegram.WebApp.initData` to `POST /api/session`, receives a short-lived opaque bearer `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 token, and then acquires a position through a three-link chain (Telegram
`LocationManager` → WebView `navigator.geolocation` → chat-relayed pin). All server state `LocationManager` → WebView `navigator.geolocation` → chat-relayed pin). Searching a
lives in a TTL-only Cloudflare KV namespace, so the system has no database, no persistent destination and routing to it are real today: `@araharah/search` is the in-Worker
storage, and no analytics. `@araharah/theme` is the shared design-token package: it gazetteer (search backend #1 of the D-01 ladder), and `@araharah/routing` is the
compiles Tailwind v4 CSS consumed by the web app and is the derivation source for the routing data plane — its CSR road-graph codec, snapper, and A\* power **engine A**,
Rams basemap style the map renders. the Worker-side router (`bot/src/routing/worker-router.ts`) that routes over a
ROAD_GRAPH R2-hosted KL road-graph binary through `POST /api/route`. Guidance is
end-to-end: a turn-card cron (the repo's first Cron Trigger, `*/2 * * * *`) pushes
turn-cards into the chat from a TTL-only guidance envelope, statelessly. All durable
state is TTL-only — one Cloudflare KV namespace (`chat_sessions`, plus `guidance:*`
envelopes in it) — 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 Architecturally this is a **five-workspace npm monorepo in a layered/hexagonal
arrangement**: a stateless edge worker with a KV-backed repository layer, a arrangement**: a stateless edge worker with a KV repository layer and an embedded
framework-free TypeScript client with its own repository + adapter layers, and a pure search index plus A\* router, a framework-free TypeScript client with its own
build-time token/cartography package that both consume. repository, adapter, route, and guidance layers, and two pure V8-native data-plane
packages (search, routing) that both the worker and the client import — and a pure
build-time token/cartography package underneath.
```mermaid ```mermaid
graph TD graph TD
@@ -26,16 +36,22 @@ graph TD
classDef worker fill:#D9D2C6,stroke:#736B1E,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 store fill:#ED3F1C,stroke:#8C1F0A,stroke-width:2px,color:#FFFFFF
classDef build fill:#1A1A1A,stroke:#000000,stroke-width:2px,color:#FFF8F0 classDef build fill:#1A1A1A,stroke:#000000,stroke-width:2px,color:#FFF8F0
classDef plane fill:#736B1E,stroke:#4A440F,stroke-width:2px,color:#FFF8F0
A["Telegram client<br/>chat + Mini App webview"]:::client A["Telegram client<br/>chat + Mini App webview"]:::client
B["@araharah/bot Worker<br/>src/webhook.ts"]:::worker B["@araharah/bot Worker<br/>src/webhook.ts"]:::worker
C["grammY handlers<br/>commands + location-fallback"]:::worker C["grammY handlers<br/>commands + /cari + fallbacks"]:::worker
D["Security<br/>initdata.ts HMAC"]:::worker D["Security<br/>initdata.ts HMAC"]:::worker
E["KV chat_sessions<br/>TTL-only"]:::store E[("KV chat_sessions<br/>session:* 900s · chat:*<br/>loc:* 300s · guidance:* 7200s")]:::store
R[("R2 ROAD_GRAPH<br/>kl-roadgraph.v1.bin")]:::store
Q["scheduled()<br/>*/2 turn-card cron"]:::worker
F["@araharah/web Mini App<br/>src/main.ts"]:::client F["@araharah/web Mini App<br/>src/main.ts"]:::client
G["Session client + store<br/>src/session/"]:::client G["Session client + store<br/>src/session/"]:::client
H["Location chain<br/>src/location/"]:::client H["Location chain<br/>src/location/"]:::client
I["MapLibre map + UI<br/>src/map/, src/ui/"]:::client I["MapLibre map + UI<br/>src/map/, src/ui/"]:::client
RT["Route controller + guidance<br/>src/route/, src/guidance/"]:::client
S["@araharah/search<br/>gazetteer SearchSource"]:::plane
U["@araharah/routing<br/>graph · snap · A*"]:::plane
J["@araharah/theme<br/>dist/rams.css + basemap"]:::build J["@araharah/theme<br/>dist/rams.css + basemap"]:::build
A -->|"POST /telegram/webhook"| B A -->|"POST /telegram/webhook"| B
@@ -43,15 +59,24 @@ graph TD
B --> C B --> C
B --> D B --> D
B --> E B --> E
B --> R
Q --> E
Q --> R
C --> E C --> E
C -->|"sendMessage reply keyboard"| A C --> S
C -->|"sendPhoto turn-cards"| A
F -->|"POST /api/session initData"| B F -->|"POST /api/session initData"| B
F -->|"GET /api/location Bearer"| B F -->|"GET /api/location Bearer"| B
F -->|"POST /api/location-fallback"| B F -->|"POST /api/location-fallback"| B
F -->|"POST /api/route Bearer"| B
F -->|"POST|GET|DELETE /api/guidance"| B
F -->|"build-time imports"| S
F -->|"build-time imports"| U
G --> F G --> F
H --> G H --> G
F --> H F --> H
H --> I H --> I
RT -->|"reroute"| B
J -->|"compiled CSS + inlined style JSON"| F J -->|"compiled CSS + inlined style JSON"| F
``` ```
@@ -62,6 +87,7 @@ graph LR
classDef ws fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A classDef ws fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A
classDef wsfill fill:#FFF8F0,stroke:#EB8008,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 botfill fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1A1A1A
classDef plane fill:#736B1E,stroke:#4A440F,stroke-width:2px,color:#FFF8F0
classDef store fill:#ED3F1C,stroke:#8C1F0A,stroke-width:2px,color:#FFFFFF classDef store fill:#ED3F1C,stroke:#8C1F0A,stroke-width:2px,color:#FFFFFF
classDef ext fill:#E0D9CD,stroke:#564335,stroke-width:2px,color:#1A1A1A classDef ext fill:#E0D9CD,stroke:#564335,stroke-width:2px,color:#1A1A1A
@@ -86,38 +112,68 @@ graph LR
W3["src/location/"]:::wsfill W3["src/location/"]:::wsfill
W4["src/map/"]:::wsfill W4["src/map/"]:::wsfill
W5["src/ui/ + src/telegram/"]:::wsfill W5["src/ui/ + src/telegram/"]:::wsfill
W6["src/shell.css"]:::wsfill W6["src/search/ + src/route/<br/>+ src/guidance/"]:::wsfill
W7["dist/ (served)"]:::wsfill W7["dist/ (served)"]:::wsfill
W1 --> W2 W1 --> W2
W1 --> W3 W1 --> W3
W1 --> W4 W1 --> W4
W1 --> W5 W1 --> W5
W1 --> W6
W1 --> W7 W1 --> W7
W4 --> W7 W4 --> W7
W5 --> W7 W5 --> W7
W6 --> W7
end end
subgraph BOT["bot · @araharah/bot"] subgraph BOT["bot · @araharah/bot"]
direction TB direction TB
B1["src/webhook.ts"]:::botfill B1["src/webhook.ts"]:::botfill
B2["src/security/initdata.ts"]:::botfill B2["src/security/initdata.ts"]:::botfill
B3["src/bot/"]:::botfill B3["src/bot/ + src/guidance/cron.ts"]:::botfill
B4["src/kv/"]:::botfill B4["src/kv/"]:::botfill
B5["src/config.ts Env"]:::botfill B5["src/routing/worker-router.ts"]:::botfill
B6["src/config.ts Env"]:::botfill
B1 --> B2 B1 --> B2
B1 --> B3 B1 --> B3
B1 --> B4 B1 --> B4
B1 --> B5 B1 --> B5
B1 --> B6
end end
KV[("KV namespace chat_sessions<br/>session:* 900s · chat:* · loc:* 300s")]:::store subgraph SEARCH["search · @araharah/search"]
direction TB
S1["src/gazetteer.ts<br/>lazy memoized index"]:::plane
S2["src/source.ts<br/>SearchSource #1"]:::plane
S3["src/score.ts + normalize.ts"]:::plane
S1 --> S2
S3 --> S2
end
subgraph ROUTING["routing · @araharah/routing"]
direction TB
R1["src/graph.ts<br/>CSR codec"]:::plane
R2["src/snap.ts + astar.ts"]:::plane
R3["src/fixture-source.ts<br/>#1 · http-source.ts<br/>engine-A client seam"]:::plane
R1 --> R2
R1 --> R3
R2 --> R3
end
KV[("KV chat_sessions")]:::store
R2B[("R2 ROAD_GRAPH<br/>araharah-graphs")]:::store
CRON["Cron Trigger<br/>*/2 * * * *"]:::ext
TG["Telegram Bot API"]:::ext TG["Telegram Bot API"]:::ext
OFM["OpenFreeMap tiles"]:::ext OFM["OpenFreeMap tiles"]:::ext
end end
WEB -->|"workspace dep @araharah/theme"| THEME WEB -->|"workspace deps @araharah/{theme,search,routing}"| THEME
WEB --> SEARCH
WEB --> ROUTING
BOT -->|"deps @araharah/{search,routing}"| SEARCH
BOT --> ROUTING
BOT -->|"assets binding ../web/dist"| WEB BOT -->|"assets binding ../web/dist"| WEB
B1 --> KV B1 --> KV
B4 --> KV B4 --> KV
B3 <-->|"webhookCallback / sendMessage"| TG B5 --> R2B
B3 --> KV
CRON --> B1
B1 <-->|"webhookCallback / sendMessage"| TG
W3 <-->|"chat pin"| TG W3 <-->|"chat pin"| TG
W4 --> OFM W4 --> OFM
``` ```
@@ -132,27 +188,45 @@ graph TD
ROOT["araharah (private root)"]:::out ROOT["araharah (private root)"]:::out
TH["@araharah/theme"]:::pkg TH["@araharah/theme"]:::pkg
SE["@araharah/search"]:::pkg
RO["@araharah/routing"]:::pkg
BT["@araharah/bot"]:::pkg BT["@araharah/bot"]:::pkg
WB["@araharah/web"]:::pkg WB["@araharah/web"]:::pkg
KVS[("KV chat_sessions")]:::dep KVS[("KV chat_sessions")]:::dep
R2S[("R2 ROAD_GRAPH")]:::dep
TGAPI["Telegram Bot API"]:::dep TGAPI["Telegram Bot API"]:::dep
ROOT -->|"workspaces"| TH ROOT -->|"workspaces"| TH
ROOT -->|"workspaces"| SE
ROOT -->|"workspaces"| RO
ROOT -->|"workspaces"| BT ROOT -->|"workspaces"| BT
ROOT -->|"workspaces"| WB ROOT -->|"workspaces"| WB
WB -->|"dependencies: @araharah/theme *"| TH WB -->|"dependencies: @araharah/theme *"| TH
WB -->|"dependencies: @araharah/search *"| SE
WB -->|"dependencies: @araharah/routing *"| RO
BT -->|"dependencies: @araharah/search *"| SE
BT -->|"dependencies: @araharah/routing *"| RO
BT -->|"static assets ../web/dist"| WB BT -->|"static assets ../web/dist"| WB
BT --> KVS BT --> KVS
BT --> R2S
BT --> TGAPI BT --> TGAPI
WB -->|"api routes"| BT WB -->|"api routes"| BT
``` ```
`@araharah/web` declares `"@araharah/theme": "*"` — the only workspace-to-workspace `@araharah/search` and `@araharah/routing` are the shared V8-native data planes: the
runtime import in the repo (`@araharah/theme/dist/rams.css` in `web/src/shell.css`, **bot Worker** imports them at runtime (gazetteer search, graph decode, snap, A\*),
`@araharah/theme/basemap/liberty-rams.json` in `web/src/map/map.ts`). The bot has no the **web app** imports the same sources at build time (destination resolution, the
workspace dependency; its coupling to web is a **build-order coupling only** — the `RoutingSource` ladder, polyline decoding). Neither data plane reads the environment,
root `build` script builds theme then web, and the Worker's `[assets]` binding points the network, or storage. `@araharah/theme` stays web-only (`"@araharah/theme": "*"`
at `../web/dist`. in `web/package.json` — `dist/rams.css` in `web/src/shell.css`,
`basemap/liberty-rams.json` in `web/src/map/map.ts`). The bot has no workspace
dependency on theme or web; 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`.
Both data-plane packages have **no `exports`/`main` field** — consumers import the
explicit subpath with extension, e.g. `@araharah/search/src/index.ts` and
`@araharah/routing/src/index.ts`. A bare package specifier resolves to nothing.
## Data Flow ## Data Flow
@@ -171,6 +245,15 @@ POST /telegram/webhook → webhookCallback (secret check) → createBot(env)
→ ctx.reply(plain text, link_previews disabled) → ctx.reply(plain text, link_previews disabled)
``` ```
`createBot` also builds ONE memoized gazetteer `SearchSource` (backend #1) shared
between the `/cari` + `/search` command surface (`bot/src/bot/commands/cari.ts`) and
the alternates switcher (`bot/src/bot/handlers/search-alternate.ts`): `/cari` renders
a venue card whose OPEN button deep-links `<MINI_APP_URL>/?dest=<id>` (origin from the
configured `MINI_APP_URL` var, never user input), and the alternates handler swaps the
selected entry via a callback. The venue card's inline buttons and the
location-fallback reply keyboard are the two documented exceptions to the
plain-text-only command rule.
All user-facing copy comes from `bot/src/i18n/strings.ts`; `bot/scripts/register-commands.mjs` 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, derives the persistent `/setMyCommands` menu descriptions from the same `helpMenu` lines,
so menu and reply cannot drift. so menu and reply cannot drift.
@@ -225,7 +308,7 @@ flowchart TD
START --> L1 START --> L1
L1 -->|"yes"| NUDGE L1 -->|"yes"| NUDGE
NUDGE -->|"no"| LIVE NUDGE -->|"no"| LIVE
NUDGE -->|"yes"||"discarded webview read"| L1 NUDGE -->|"yes: discarded webview read"| L1
L1 -->|"no"| L2 L1 -->|"no"| L2
L2 -->|"yes + fix"| LIVE L2 -->|"yes + fix"| LIVE
L2 -->|"no fix"| L3 L2 -->|"no fix"| L3
@@ -270,7 +353,95 @@ Both `/api/location` and `/api/location-fallback` derive the target `chat_id` fr
`claim.language_code`. Telegram has no push channel from chat to webview, so this poll `claim.language_code`. Telegram has no push channel from chat to webview, so this poll
is server-mediated by necessity. is server-mediated by necessity.
### 5. Worker routing table ### 5. Destination deep-link (search, both carriers)
The destination id is a **plain public gazetteer slug** — no crypto, no token anywhere
on this path. `web/src/search/resolve-destination.ts` reads it from TWO carriers in a
fixed order with ONE shared validation (`[A-Za-z0-9_-]{1,64}`, optional `dest_` wire
prefix):
1. **PRIMARY — the URL query string** (`?dest=<id>`): the `/cari` OPEN inline-button
path; Telegram opens the button's URL verbatim in the webview.
2. **SECONDARY — `start_param` inside the SIGNED initData string**: the documented
direct-link (`t.me/<bot>?startapp=<id>`) and home-screen-shortcut path, treated as
an untrusted exact-lookup key.
The id resolves against the webview's **own build-time copy of the shared gazetteer**
(`@araharah/search` inlined by Vite). A hit renders the destination card with a
START-route action; a miss renders the honest "destination unavailable" card and the
boot continues staged — never a blank map. The resolved entry lives in module scope
(memory only); nothing persists.
### 6. Route consult (engine A over R2)
```mermaid
sequenceDiagram
participant MAIN as main.ts (auto-consult, once per boot)
participant CTRL as route/route.ts
participant HTTP as http-source.ts (backend #2 seam)
participant W as Worker /api/route
participant G as loadGraph (R2 ROAD_GRAPH)
participant A as snapToGraph + routeAstar
MAIN->>CTRL: route { origin: fix, destinationId }
CTRL->>HTTP: POST endpoint (injected), bearer = memory token
HTTP->>W: { origin, destinationId } (≤ 4 KB)
W->>W: getClaimByToken — Bearer claim gate
W->>G: R2 GET kl-roadgraph.v1.bin (ONE per cold isolate, memoized)
G-->>W: RoadGraph | null
W->>A: bbox-check origin → snap origin → snap destination → A*
A-->>W: RouteResult | null
W-->>HTTP: 200 { result } | 200 JSON null
HTTP-->>CTRL: RouteResult | null (fail-honest)
```
`bot/src/routing/worker-router.ts` is engine A's whole server surface: the decoded
graph memoizes per isolate (`loadGraph` — the ONLY R2 loader, shared with the cron
arm), the origin is bbox-checked against `graph.bbox` before any snap/A\* work, and
**every miss class answers `200 null`** — unknown destination, snap miss, unreachable
pair, absent/corrupt graph — the honest router-pending state (never a 500, never a
fake route). Alternatives generation is prohibited on this path: exactly one best
route. The client composes the ladder at the import site —
`createFixtureRoutingSource` (backend #1, the 15-pair golden corpus) followed by
`createHttpRoutingSource` (engine A) — with the null contract end to end.
### 7. Guidance (mint → resume → cron push)
```mermaid
sequenceDiagram
participant MA as Mini App
participant W as Worker /api/guidance
participant KV as KV guidance:id (7200s)
participant C as scheduled() every 2 min
MA->>W: POST /api/guidance (Bearer) — six-field route snapshot
W->>W: getClaimByToken → chat_id minted from the CLAIM (body chat_id structurally ignored)
W->>KV: mintGuidanceSession — inline snapshot, expirationTtl 7200
KV-->>W: envelope (uuid id)
W-->>MA: 201 { id }
Note over MA: webview killed / phone locked
MA->>W: GET /api/guidance?id=<uuid> — resume carrier ?g=<uuid>
W->>KV: getGuidanceSession — foreign or absent are indistinguishable 404s
KV-->>MA: envelope → straight into the live trip
loop every 2 min (stateless — zero KV writes)
C->>KV: list prefix guidance: → getGuidanceSession per key
C->>C: projectTurnCards(envelope, nowSec, streetAt(loadGraph))
C->>MA: sendPhoto glyph + OPEN (?g=<id>) — due cards only, cadence 240 s
end
```
The envelope is the **frozen Phase 5 contract**: exactly four fields (`id`, `chat_id`,
`route_snapshot`, `created_at`); the snapshot is INLINE (one key, one TTL) with six
fields (`destinationId`, `destinationName`, `distanceM`, `durationS`, `geometry`
polyline6, `startedAtSec`). Every write carries `expirationTtl: 7200` on the same call
line — the PG1 TTL law covers this file. On the web side,
`web/src/guidance/guidance.ts` is the pure reroute state machine (`idle → on_route →
off_route → resuming/rerouting → arrived`, accuracy-aware threshold, 20 s storm guard)
and `web/src/guidance/watch.ts` is the throttled `watchPosition` stream (guidance work
runs on ≥ 10 m moved or ≥ 5 s elapsed — fixes are consumed in the caller's frame and
never transmitted).
### 8. Worker routing table
| Method | Path | Handler in `bot/src/webhook.ts` | Purpose | | Method | Path | Handler in `bot/src/webhook.ts` | Purpose |
| --- | --- | --- | --- | | --- | --- | --- | --- |
@@ -278,11 +449,14 @@ is server-mediated by necessity.
| POST | `/api/session` | `handleSession` | initData → one-time opaque session token | | POST | `/api/session` | `handleSession` | initData → one-time opaque session token |
| GET | `/api/location` | `handleLocation` | Bearer-authenticated chat-pin relay | | GET | `/api/location` | `handleLocation` | Bearer-authenticated chat-pin relay |
| POST | `/api/location-fallback` | `handleLocationFallback` | Ask the bot to send the reply keyboard | | POST | `/api/location-fallback` | `handleLocationFallback` | Ask the bot to send the reply keyboard |
| POST/GET/DELETE | `/api/guidance` | `handleGuidance` | Mint / resume / delete the 7200 s guidance envelope |
| POST | `/api/route` | `handleRoute` (in `src/routing/worker-router.ts`) | Bearer-gated engine-A routing over the R2 road graph |
| * | anything else | — | 404 | | * | anything else | — | 404 |
`run_worker_first: ["/api/*", "/telegram/*"]` guarantees the Worker's `fetch` owns these `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` paths before the static asset fallback runs; every other path resolves to the `web/dist`
bundle. bundle. The same `fetch` handler also exports `scheduled()` — the `*/2` cron that runs
the turn-card push (flow 7).
## Build & Deploy Topology ## Build & Deploy Topology
@@ -299,11 +473,14 @@ graph LR
W2["web: vite build --mode harness → dist-harness/"]:::step W2["web: vite build --mode harness → dist-harness/"]:::step
B1["bot: wrangler deploy"]:::step B1["bot: wrangler deploy"]:::step
B2["bot: scripts/set-webhook.mjs"]:::step B2["bot: scripts/set-webhook.mjs"]:::step
RG["routing: build-road-graph.mjs<br/>→ kl-roadgraph.v1.bin"]:::step
RU["routing: upload-road-graph.mjs<br/>→ R2 (fork contract)"]:::step
AR1["theme/dist/rams.css + fonts"]:::art AR1["theme/dist/rams.css + fonts"]:::art
AR2["web/dist (index.html, hashed assets,<br/>inlined liberty-rams style)"]:::art AR2["web/dist (index.html, hashed assets,<br/>inlined liberty-rams style, public/glyphs)"]:::art
AR3["web/dist-harness (never deployed)"]:::art AR3["web/dist-harness (never deployed)"]:::art
EDGE["Cloudflare Worker araharah-bot<br/>[assets] ../web/dist"]:::cloud EDGE["Cloudflare Worker araharah-bot<br/>[assets] ../web/dist + cron */2"]:::cloud
KVS[("KV chat_sessions")]:::store KVS[("KV chat_sessions")]:::store
R2B[("R2 ROAD_GRAPH")]:::store
T1 --> W1 T1 --> W1
T2 --> W1 T2 --> W1
@@ -313,21 +490,31 @@ graph LR
AR2 --> EDGE AR2 --> EDGE
B1 --> EDGE B1 --> EDGE
B1 --> B2 B1 --> B2
RG --> RU
RU --> R2B
EDGE --> KVS EDGE --> KVS
EDGE --> R2B
``` ```
The root scripts encode the ordering: `npm run build` = `build -w theme && build -w web`; 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 `npm run deploy` = `npm run build && npm run deploy -w bot` (which is
`wrangler deploy && node scripts/set-webhook.mjs`). `bot/wrangler.jsonc` binds `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. `assets.directory` to `../web/dist`, so `web/dist` must exist before a bot deploy.
The road-graph binary is a separate provisioning track (never a build prerequisite):
`routing/scripts/build-road-graph.mjs` writes `routing/data/kl-roadgraph.v1.bin`
(~15 MB, a build artifact that is NOT committed) and
`routing/scripts/upload-road-graph.mjs` puts it on R2 — the fork contract is
`docs/ROAD-GRAPH.md`.
The two web builds never share an outDir: `vite.config.ts` sets The two web builds never share an outDir: `vite.config.ts` sets
`outDir: "dist-harness"` only under `--mode harness` and defines `outDir: "dist-harness"` only under `--mode harness` and defines
`__ARAHARAH_HARNESS__` to `true` there and `false` everywhere else, so the mock `__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 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 production. `web/test/build-artifact.test.mjs` (BA1–BA10) proves the shipped bundle
references no third-party CDN, self-hosts the woff2 fonts, contains the inlined Rams references no third-party CDN, self-hosts the woff2 fonts, contains the inlined Rams
style, and carries neither `installMockWebApp` nor `__ARAHARAH_MOCK__`. style, and carries neither `installMockWebApp` nor `__ARAHARAH_MOCK__`. The turn-card
glyphs (`web/public/glyphs/*.png`, rendered by `routing/scripts/build-glyphs.mjs`) are
static assets the cron references as `${MINI_APP_URL}/glyphs/<glyph>.png`.
<!-- VERIFY: production Worker hostname and KV namespace id come from bot/wrangler.jsonc and bot/.dev.vars, both environment-specific. --> <!-- VERIFY: production Worker hostname and KV namespace id come from bot/wrangler.jsonc and bot/.dev.vars, both environment-specific. -->
@@ -337,15 +524,43 @@ style, and carries neither `installMockWebApp` nor `__ARAHARAH_MOCK__`.
| Abstraction | File | Description | | 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`. | | `Env` | `bot/src/config.ts` | Typed contract: `BOT_TOKEN`, `WEBHOOK_SECRET` (non-empty — the webhook fails closed without it), `chat_sessions: KVNamespace`, `ROAD_GRAPH: R2Bucket` (first R2 binding), `MINI_APP_URL` (public deep-link origin). |
| `default { fetch }` | `bot/src/webhook.ts` | The `ExportedHandler<Env>` — four routes, 404 otherwise. Bot is constructed per request. | | `createExportedHandler(opts)` | `bot/src/webhook.ts` | The `ExportedHandler<Env>` factory — six fetch routes plus `scheduled()`; 404 otherwise. Production passes nothing; offline tests inject a recording `baseFetch`. |
| `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`. | | `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. | | `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. | | `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. | | `GuidanceSessionEnvelope` / `GuidanceRouteSnapshot` / `mint/get/deleteGuidanceSession` / `GUIDANCE_KEY_PREFIX` | `bot/src/kv/guidance-sessions.ts` | The frozen four-field envelope (six-field inline snapshot), `GUIDANCE_SESSION_TTL_SECONDS = 7200`, key `guidance:<uuid>`. Defensive reads (garbage → null); ids UUID-guarded before any KV touch. |
| `handleRoute` / `loadGraph` | `bot/src/routing/worker-router.ts` | Engine A's server surface: Bearer claim gate, 4 KB body cap, bbox pre-check, snap → A\* over the R2-hosted graph, `200 null` for every miss. `loadGraph` is the ONLY R2 loader — one memoized GET per cold isolate, shared with the cron arm. |
| `projectTurnCards` / `makeStreetAt` | `bot/src/guidance/cron.ts` | The PURE projection over the frozen snapshot — when a card is due (`nowSec % CARD_CADENCE_S < SCAN_TICK_S`), glyph + caption composition, street reconstruction from the graph. `CARD_CADENCE_S = 240`; the cron is stateless (zero KV writes). |
| `createBot(env, opts)` | `bot/src/bot/bot.ts` | grammY factory; wires `/start` + `/mula`, `/help`, `/privacy` + `/privasi`, `/cari` + `/search`, the alternates switcher, and the location-fallback handler. Builds one shared gazetteer `SearchSource`. |
| `registerCari(bot, source, origin)` | `bot/src/bot/commands/cari.ts` | The search command surface: venue card with OPEN deep link (`?dest=<id>`) and alternates callbacks — the second documented exception to the text-only rule. |
| `requestLocationFallback(bot, chatId, locale)` | `bot/src/bot/handlers/location-fallback.ts` | Sends the `request_location` reply keyboard. Single caller: `handleLocationFallback`. | | `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. | | `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 (module-private, not exported — the derived `MintSessionKv`/`LocationPinKv` are the public surface) — lets tests pass a fake without depending on Workers types. | | `KvLike` | `bot/src/kv/*.ts` | Structural KV interface (module-private, not exported — the derived `MintSessionKv`/`LocationPinKv`/`GuidanceSessionKv` are the public surface) — lets tests pass a fake without depending on Workers types. |
### `@araharah/search`
| Abstraction | File | Description |
| --- | --- | --- |
| `SearchSource` | `search/src/types.ts` | The pluggable search seam: async `search(q)` + synchronous `byId(id)`. The documented ladder is backend #1 (gazetteer, shipped) with later rungs slotted at the import site — no env read, no registry. |
| `createGazetteerSource()` | `search/src/source.ts` | Backend #1. The refusal contract is load-bearing: an EMPTY array means "not in the v1 area" — never a fabricated pin. Ranking is score-band DESC then entry-id ASC (deterministic tie-break). |
| `search(text, limit)` | `search/src/source.ts` | The synchronous scoring path — shared by the bot, the Mini App, and the corpus runner: the measured quality IS the shipped quality. |
| `getEntries` / `getEntryById` / `getIndex` | `search/src/gazetteer.ts` | The lazy, memoized index over a static JSON import (`data/kl-gazetteer.json`, ODbL notice inside). Three laws: lazy + memoized (never module-scope work), survives three environments (Worker bundle, Vite bundle, bare Node), no logging/storage. |
| `scoreCandidate` / `TIER` / `FUZZY_ADMIT_SIMILARITY` | `search/src/score.ts` | Tiered admission — EXACT / ALIAS_EXACT / PREFIX / TOKEN_SET bands always admitted; FUZZY only at similarity ≥ 0.72. `normalize` (with `rules/abbreviations.ts`) canonicalizes both sides of every comparison. |
| `GazetteerEntry` | `search/src/types.ts` | `id` (slug), `name`, `aliases`, coordinates, category — the entry shape every carrier (bot card, webview resolver, guidance snapshot) reduces to. |
### `@araharah/routing`
| Abstraction | File | Description |
| --- | --- | --- |
| `RoutingSource` | `routing/src/types.ts` | The pluggable routing seam: `route(req) → RouteResult \| null`. Backend ladder: #1 `"fixture"` → #2 `"engine-a"` (LIVE, via `/api/route`) → engine B (client-side webview router, documented post-v1). Selection happens at the import site in code — no env read, no registry. |
| `RouteResult` / `RouteOption` / `RouteStep` / `RouteGeometry` | `routing/src/types.ts` | Day-one contract: options 1..3 (index 0 = best), `cumulativeDistanceM` parallel to points (the guidance projection contract), polyline6 `geometry`, optional `kind` maneuver tags. Fixture-world steps are empty arrays — honest "not wired". |
| `RoadGraph` / `encodeGraph` / `decodeGraph` | `routing/src/graph.ts` | The CSR road-graph binary codec (`GRAPH_MAGIC`, `GRAPH_VERSION`) — the on-disk form of `kl-roadgraph.v1.bin`. V8-native, dependency-free, no network. |
| `snapToGraph` / `SnapResult` | `routing/src/snap.ts` | Snaps a raw fix onto the graph (bbox-margined); the origin of every route computation and of the cron's street reconstruction. |
| `routeAstar` / `deriveSteps` / `classifyTurn` | `routing/src/astar.ts` | The A\* engine plus the maneuver classifier (`depart`/`left`/…/`arrive`) that feeds both the webview guidance machine and the cron's turn cards. |
| `createFixtureRoutingSource` | `routing/src/fixture-source.ts` | Backend #1: the committed 15-pair golden KL corpus (`data/route-fixtures.json` + `test/fixtures/golden-pairs.json`). Demo-grade by design; the tolerance gate is `npm run routing:corpus`. |
| `createHttpRoutingSource(options)` | `routing/src/http-source.ts` | Engine A's client seam: injected `endpoint` + `bearer` getter, defensively validates every response field, resolves null on any failure (the null contract crossing HTTP). Never holds or persists the token. |
| `decodePolyline6` / `encodePolyline6` / `haversineM` / `deriveCumulativeDistanceM` | `routing/src/polyline.ts`, `routing/src/geo.ts` | The storage geometry format and the ONE distance authority — every distance in the system flows through `geo.ts`. |
### `@araharah/web` ### `@araharah/web`
@@ -355,7 +570,13 @@ style, and carries neither `installMockWebApp` nor `__ARAHARAH_MOCK__`.
| `LocationState` | `web/src/location/types.ts` | Discriminated union: `idle` / `validating` / `acquiring` / `awaiting-pin` / `live` / `stale` / `denied`. | | `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`. | | `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. | | `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. | | `readDestinationId` / `resolveDestination` / `setDestination` | `web/src/search/resolve-destination.ts` | The two-carrier deep-link resolver (`?dest=` URL, then signed initData's `start_param`), one shared slug validation, memory-only result, honest-null on miss. |
| `buildDestinationCard` / `buildDestinationUnavailableCard` | `web/src/search/destination-card.ts` | The search surface's Rams cards (destination confirmed / honest unavailable). |
| `createRouteController` | `web/src/route/route.ts` | The consult/select/walk-back state machine and the guidance pump: auto-consults once per boot, draws the selected line (`setData` on the `route` source), starts the guidance watch, delegates every guidance decision. Touches no DOM/map directly — everything injected. |
| `installRouteLayers(m)` | `web/src/route/route-layers.ts` | Installs the `route` / `route-alt` map sources inside map.ts's ONE load handler; a drawn route is static furniture (only selection changes it). |
| `createGuidanceSession` / `stepGuidance` | `web/src/guidance/guidance.ts` | The pure reroute state machine: `idle → on_route → off_route → resuming/rerouting`, accuracy-aware threshold (`max(35, 2×accuracy)` capped 100), 3-fix + 8 s grace, 20 s storm guard. A pure reducer — replay-verifiable in Node. |
| `createGuidanceWatch(geo, onFix)` | `web/src/guidance/watch.ts` | The gated position stream: `watchPosition` high-accuracy, throttled to ≥ 10 m moved or ≥ 5 s elapsed. Fixes are consumed in the caller's frame — never stored, logged, or transmitted. |
| `TelegramWebAppLike` | `web/src/telegram/webapp.ts` | Hand-rolled ~20-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. | | `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. | | `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`. | | `bootMap(container)` | `web/src/map/map.ts` | MapLibre v6 bootstrap with the build-time-inlined Rams style. Returns `null` on `GPUInitializationError`. |
@@ -364,9 +585,10 @@ style, and carries neither `installMockWebApp` nor `__ARAHARAH_MOCK__`.
| `createStatusStrip(host)` | `web/src/ui/strip.ts` | The status band — state lamp + state word on the left, telemetry on the right. | | `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. | | `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. | | `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. | | `watchTheme(wa)` / `resolveBranch` | `web/src/telegram/theme.ts` | The theme-slot machine: Telegram light/dark (or `header_bg_color` luminance fallback) selects between two Rams token pairs — `applyTheme`'s ONLY statement is the `data-tg-theme` attribute set; MapLibre is deliberately absent (the map style is never re-set). Pinned by `web/test/theme.test.mjs`. |
| `resolveSafeInsets` / `applySafeArea` | `web/src/telegram/safe-area.ts` | Union of CSS `env()` and `WebApp.safeAreaInset`, max per edge, emitted as CSS custom properties. | | `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. | | `wireBackButton(wa, hasOverlay)` | `web/src/telegram/backbutton.ts` | Total function — the context-aware resolver (guidance → confirm; confirm → CONTINUE; ended → search-root walk; route → Phase 5 walk; root → close) and the wire's per-state `sync()`. |
| `wireHomeScreen(wa, locale)` | `web/src/telegram/homescreen.ts` | The ONBD-03 home-screen affordance behind the Bot API 8.0 capability guard — Telegram-native methods only. |
| `installMockWebApp(options)` | `web/src/mock/telegram-mock.ts` | Harness-only fake `window.Telegram.WebApp` with a dev-signed initData. Excluded from `web/dist`. | | `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 }`. | | `localeFromInitData(raw)` | `web/src/i18n/strings.ts` | Parses the locale out of the raw initData string — the session response carries only `{ token }`. |
@@ -375,7 +597,7 @@ style, and carries neither `installMockWebApp` nor `__ARAHARAH_MOCK__`.
| Abstraction | File | Description | | 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 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 seven self-hosted `@font-face` rules; defines the `--shell-bg` / `--shell-on-surface` theme slots. | | `theme/src/theme.css` | `theme/src/theme.css` | Entry stylesheet — `@import`s tailwindcss, tokens, components; declares seven self-hosted `@font-face` rules; defines the `--shell-bg` / `--shell-on-surface` theme slots (the two Rams token pairs the slot switch flips). |
| Component classes | `theme/src/components.css` | `.card`, `.btn-primary`, `.btn-secondary`, `.chip-*`, `.maneuver-*`, `.text-*`, `.toggle*`, `.input-field`, `.checkbox`. | | 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]`. | | `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). | | `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). |
@@ -384,38 +606,59 @@ style, and carries neither `installMockWebApp` nor `__ARAHARAH_MOCK__`.
``` ```
araharah/ araharah/
├── package.json # private root; workspaces + build ordering only ├── package.json # private root; workspaces + script passthroughs
├── bot/ # @araharah/bot — the Cloudflare Worker ├── bot/ # @araharah/bot — the Cloudflare Worker
│ ├── src/ │ ├── src/
│ │ ├── webhook.ts # the single fetch handler; four routes │ │ ├── webhook.ts # the single fetch handler; six routes + scheduled()
│ │ ├── config.ts # Env contract │ │ ├── config.ts # Env contract (KV, R2, MINI_APP_URL)
│ │ ├── bot/ # grammY factory, commands/, handlers/ │ │ ├── bot/ # grammY factory, commands/, handlers/ (incl. /cari)
│ │ ├── security/ # initdata.ts — pure, isolation-free crypto │ │ ├── security/ # initdata.ts — pure, isolation-free crypto
│ │ ├── kv/ # sessions.ts, location-pin.ts — TTL-only repos │ │ ├── kv/ # sessions.ts, location-pin.ts, guidance-sessions.ts — TTL-only repos
│ │ ├── routing/ # worker-router.ts — engine A's server surface
│ │ ├── guidance/ # cron.ts — the pure turn-card projection
│ │ └── i18n/strings.ts # every user-facing bot string │ │ └── i18n/strings.ts # every user-facing bot string
│ ├── scripts/ # set-webhook, register-commands, set-menu-button │ ├── scripts/ # set-webhook, register-commands, set-menu-button
│ ├── test/ # hand-rolled zero-dependency runner + suites │ ├── test/ # hand-rolled zero-dependency runner + suites
│ └── wrangler.jsonc # worker name, assets binding, run_worker_first, KV │ └── wrangler.jsonc # worker name, assets binding, run_worker_first, R2, cron
├── web/ # @araharah/web — the Mini App ├── web/ # @araharah/web — the Mini App
│ ├── src/ │ ├── src/
│ │ ├── main.ts # boot choreography (order is load-bearing) │ │ ├── main.ts # boot choreography (order is load-bearing)
│ │ ├── session/ # client.ts (network), store.ts (memory) │ │ ├── session/ # client.ts (network), store.ts (memory)
│ │ ├── location/ # chain.ts + telegram/webview/chat-pin sources │ │ ├── location/ # chain.ts + telegram/webview/chat-pin sources
│ │ ├── telegram/ # webapp, theme, backbutton, safe-area │ │ ├── search/ # resolve-destination.ts, destination-card.ts
│ │ ├── route/ # route.ts controller, card, markers, layers
│ │ ├── guidance/ # guidance.ts machine, watch.ts, banner, eta, camera, haptics
│ │ ├── telegram/ # webapp, theme, backbutton, safe-area, homescreen
│ │ ├── map/ # map.ts, marker.ts, follow.ts │ │ ├── map/ # map.ts, marker.ts, follow.ts
│ │ ├── ui/ # strip.ts, cards.ts, recenter.ts │ │ ├── ui/ # strip.ts, cards.ts, recenter.ts
│ │ ├── mock/ # harness-only Telegram fake │ │ ├── mock/ # harness-only Telegram fake
│ │ ├── i18n/strings.ts # every user-facing shell string │ │ ├── i18n/strings.ts # every user-facing shell string
│ │ └── shell.css # Rams chrome; imports @araharah/theme/dist/rams.css │ │ └── shell.css # Rams chrome; imports @araharah/theme/dist/rams.css
│ ├── test/ # runner + gate suites (copy, storage, artifact, chain) │ ├── public/glyphs/ # turn-card PNGs the cron deep-links
│ ├── test/ # runner + gate suites (copy, storage, artifact, chain, guidance)
│ ├── scripts/capture.mjs # screenshot capture │ ├── scripts/capture.mjs # screenshot capture
│ └── vite.config.ts # dual-build dist/ vs dist-harness/ │ └── vite.config.ts # dual-build dist/ vs dist-harness/
├── search/ # @araharah/search — the gazetteer data plane
│ ├── src/ # gazetteer.ts, source.ts, score.ts, normalize.ts, types.ts
│ ├── src/data/ # kl-gazetteer.json (committed, ODbL notice inside)
│ ├── scripts/ # gazetteer-corpus.mjs, json-to-sqlite.mjs, overpass-topup.mjs
│ └── test/ # runner + suites + golden corpus fixtures
├── routing/ # @araharah/routing — the routing data plane
│ ├── src/ # graph.ts, snap.ts, astar.ts, polyline.ts, geo.ts,
│ │ # fixture-source.ts, http-source.ts, types.ts, index.ts
│ ├── data/ # kl-roadgraph.v1.bin — BUILD ARTIFACT, not committed
│ ├── scripts/ # build-road-graph.mjs, upload-road-graph.mjs,
│ │ # build-glyphs.mjs, route-corpus.mjs, fetch-route-fixtures.mjs
│ └── test/ # runner + suites + mini-graph fixtures
├── theme/ # @araharah/theme — design tokens + cartography ├── theme/ # @araharah/theme — design tokens + cartography
│ ├── src/ # tokens.css, components.css, theme.css, sheet.html │ ├── src/ # tokens.css, components.css, theme.css, sheet.html
│ ├── fonts/ # self-hosted Geist + Space Grotesk woff2 │ ├── fonts/ # self-hosted Geist + Space Grotesk woff2
│ ├── basemap/ # build.mjs + generated liberty-rams.json │ ├── basemap/ # build.mjs + generated liberty-rams.json
│ ├── scripts/ # audit.mjs (token audit), screenshot.mjs │ ├── scripts/ # audit.mjs (token audit), screenshot.mjs
│ └── dist/rams.css # compiled output │ └── dist/rams.css # compiled output
├── scripts/ # root audits — privacy-audit.mjs, budget-audit.mjs,
│ │ # lib/ (source-gates, bundle-gates, budget-gates,
│ │ # cloudflare-probes), test/ (their suites)
├── stitch_minimalist_route_navigator/ # read-only design reference (mockups) ├── stitch_minimalist_route_navigator/ # read-only design reference (mockups)
└── docs/ └── docs/
``` ```
@@ -423,20 +666,30 @@ araharah/
Rationale for the boundaries: Rationale for the boundaries:
- **`bot/src/webhook.ts` owns routing; nothing else dispatches.** The Worker is the only - **`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 place that decides which path runs, so `run_worker_first`, the secret-token gate,
apply uniformly. the body caps, and the cron tick all apply uniformly.
- **`bot/src/security/` and `bot/src/kv/` are dependency-light on purpose.** - **`bot/src/security/`, `bot/src/kv/`, and the two data-plane packages are
`initdata.ts` imports nothing from grammY and runs in both Workers and bare Node; dependency-light on purpose.** `initdata.ts` imports nothing from grammY and runs in
`kv/*.ts` take a structural `KvLike` so the suites pass fakes. both Workers and bare Node; `kv/*.ts` take a structural `KvLike` so the suites pass
fakes; `@araharah/search` and `@araharah/routing` are V8-native with zero runtime
dependencies, no env reads, and no network — the same sources run inside the Worker
bundle, the Vite bundle, and the Node corpus runners.
- **The backend ladders are constructed in code, not configured.** Search and routing
each expose a seam (`SearchSource`, `RoutingSource`) whose backend selection happens
at the import site — no env read, no config read, no dynamic registry. Swapping a
backend is an import-site change.
- **`bot/src/i18n/strings.ts` is the single copy source.** The commands register - **`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` script derives its menu text from the same `helpMenu` lines, and `bot/test/commands.test.mjs`
gates that the `/privacy` copy still names the four never-stored facts (location, routes, gates that the `/privacy` copy still names the four never-stored facts (location, routes,
trip history, profile) and keeps the ~15-minute TTL wording, in both locales. That gate trip history, profile) and keeps the ~15-minute TTL wording, in both locales.
does not assert against the `SessionClaim` fields in `sessions.ts` — keeping the copy in
step with those field names is a convention, not a test.
- **`web/src/location/` separates sources from the chain.** Each source is a small module - **`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 that normalizes to `Fix` and answers `null` for denial; `chain.ts` owns ordering and
knows nothing about any single source's API. knows nothing about any single source's API.
- **`web/src/route/` + `web/src/guidance/` split wiring from decisions.**
`route.ts` only wires (consult once, draw, start the pump); `guidance.ts` is the pure
state machine that decides every guidance behavior; `watch.ts` owns the throttle; the
view modules (banner, eta, camera, haptics) each own one concern. Node-testable with
injected DOM/map/geo.
- **`web/src/session/` splits network from state.** `client.ts` owns fetch and error - **`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 vocabulary; `store.ts` owns the in-memory token and the 900 s staleness rule. Neither
ever touches storage. ever touches storage.
@@ -446,31 +699,85 @@ Rationale for the boundaries:
- **`theme/` is pure build-time.** It exports no runtime code — a CSS bundle and a JSON - **`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 style. That is why `web` can depend on it without pulling a bundler into the token
package. package.
- **`scripts/` is the audit layer, not product code.** `privacy-audit.mjs` and
`budget-audit.mjs` are maintainer-time gates that import from `scripts/lib/`; nothing
in `bot/src`, `web/src`, `routing/src`, or `search/src` imports them.
- **`routing/data/` is a build artifact by law.** The 15 MB graph binary is gitignored;
forks build and upload their own (`docs/ROAD-GRAPH.md` is the fork contract).
- **`stitch_minimalist_route_navigator/` is `@source`-scanned, never edited.** The theme - **`stitch_minimalist_route_navigator/` is `@source`-scanned, never edited.** The theme
compiles every class the four mockups use so the design vocabulary stays provable compiles every class the four mockups use so the design vocabulary stays provable
against the shipped CSS. against the shipped CSS.
## Cross-Cutting Concerns ## Cross-Cutting Concerns
### Privacy by construction ### Privacy by construction — the gate lineage
Two grep gates pin this rather than documenting it: Two layers of gates pin this rather than documenting it:
- `bot/test/privacy.test.mjs` — every `put` on `chat_sessions` must carry an explicit **The consolidated maintainer audit** — `npm run privacy-audit`
`expirationTtl` on the same call line. No write path can persist without a TTL. (`scripts/privacy-audit.mjs`), five static gates in `scripts/lib/source-gates.mjs` and
`scripts/lib/bundle-gates.mjs`, plus `--live` probes in
`scripts/lib/cloudflare-probes.mjs`:
| Gate id | Rule |
| --- | --- |
| `storage-api` | The memory-only law, repo-wide (the no-storage suite generalized). |
| `kv-ttl` | The PG1 same-line TTL law, repo-wide — every `.put(` line in `bot/src` carries `expirationTtl` as a named constant. |
| `log-vocabulary` | The D-14 category-only log law — no coordinate, payload, user id, or timestamp in any log line. |
| `runtime-hosts` | The POSITIVE fetch-host allowlist — no fetch to any host outside it. |
| `bundle-telemetry` | The PRODUCTION `web/dist` scan — no analytics, no beacon, no telemetry in the shipped artifact. |
| `kv-expiry` / `asset-headers` (with `--live`) | Live expiry metadata and cache-header verification — a skipped probe is never mistaken for a pass (exit 2 = NOT RUN). |
Exit codes: `0` clean, `1` violations, `2` NOT RUN (environment drift) — no silent
passes. The audit doubles as the fork onboarding check. `docs/PRIVACY-AUDIT.md` is the
human record.
**The in-suite gates** (the same laws asserted where the code lives):
- `bot/test/privacy.test.mjs` — the PG1/PG2 lineage: every `put` on `chat_sessions`
(now spanning `sessions.ts`, `location-pin.ts`, `guidance-sessions.ts`, and
`webhook.ts`) must carry an explicit `expirationTtl` on the same call line, as a
named `SCREAMING_SNAKE` constant. No write path can persist without a TTL.
- `web/test/no-storage.test.mjs` — comments stripped, every `web/src` file scanned; zero - `web/test/no-storage.test.mjs` — comments stripped, every `web/src` file scanned; zero
occurrences of `localStorage`, `sessionStorage`, `indexedDB`, `caches`, occurrences of `localStorage`, `sessionStorage`, `indexedDB`, `caches`,
`document.cookie`, `WebApp.DeviceStorage`, `WebApp.SecureStorage`, `WebApp.CloudStorage`. `document.cookie`, `WebApp.DeviceStorage`, `WebApp.SecureStorage`, `WebApp.CloudStorage`,
or any service-worker registration.
- `bot/test/route-privacy.test.mjs` — the route handler's structural proof: the only
persisted surface is the guidance envelope; the route request's origin never touches
storage or a log line.
The persisted surface is exactly `SessionClaim` (user_id, chat_id, issued_at, The persisted surface is exactly `SessionClaim` (user_id, chat_id, issued_at,
language_code) for 900 s and `LocationPin` (latitude, longitude, horizontal_accuracy) language_code) for 900 s, `LocationPin` (latitude, longitude, horizontal_accuracy)
for 300 s. The client holds the token and the last fix in module scope only. for 300 s, and the `GuidanceSessionEnvelope` for 7200 s — all in ONE KV namespace.
On the web side, guidance on-route fixes never leave the device; the ONLY
network-bound position is a reroute's origin (one point, on a real excursion, snapped
server-side). The client holds the token, the destination, and the last fix in module
scope only.
### TTL-only storage law
Every KV write carries an explicit TTL; nothing survives past it:
| Key family | TTL | Constant |
| --- | --- | --- |
| `session:<token>` / `chat:<chat_id>` | 900 s (~15 min) | `SESSION_TTL_SECONDS` |
| `loc:<chat_id>` | 300 s | `PIN_TTL_SECONDS` |
| `guidance:<uuid>` | 7200 s (~2 h) | `GUIDANCE_SESSION_TTL_SECONDS` |
The R2 bucket (`ROAD_GRAPH`) is a read-only binary host, not a persistence surface.
Exactly ONE `kv_namespaces` entry is the law (`GE5`) — a new KV family is a design
change, not a refactor.
### Access control ### Access control
The `chat_id` used by `/api/location` and `/api/location-fallback` is always resolved Every chat-scoped route (`/api/location`, `/api/location-fallback`, `/api/guidance`)
from the session claim, and the locale from `claim.language_code`. The request body of resolves the `chat_id` from the Bearer session claim, never from client input; locale
the fallback route is never read — no chat id or language can arrive from the client. comes from `claim.language_code`. The location-fallback request body is never read; a
guidance POST's `chat_id` field is structurally dropped (`validateSnapshot` builds the
new object field-by-field); GET/DELETE compare `envelope.chat_id === claim.chat_id` so
foreign and absent are indistinguishable 404s (no existence oracle for cross-chat
probing). `/api/route` uses the claim purely as an identity gate — routing itself is
chat-agnostic and nothing about the caller is stored, logged, or echoed.
### Honest degradation ### Honest degradation
@@ -479,26 +786,54 @@ Every degradation is a labeled state, never a silent one or a fabricated value:
`data-map="absent"`; a null heading hides the marker cone rather than guessing an `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 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 island; the poll is bounded at 60 s so `AWAITING PIN` cannot persist; every source
timeout resolves `null` so the chain always advances. timeout resolves `null` so the chain always advances; **the route null contract** —
every `/api/route` miss class answers `200 null` and the client renders the pending
card; a null road graph degrades the cron card to the bare-verb street-less
composition — the send still fires; guidance deviation becomes a labeled off-route
state with a storm-guarded reroute, never a silent camera jump.
### Logging ### Category-only logging
The Worker's only log statements are category-only — `malformed`, `tampered`, `stale`, The Worker's only log statements are categories — `[webhook] rejected: misconfigured`,
`unauthorized`, `misconfigured`, `telegram-send`. Never raw payloads, never user ids, `[initdata] rejected: malformed|tampered|stale`, `[location]`/`[location-fallback]`/
never timestamps. Rejection responses carry the category only. `[guidance]`/`[route] rejected: unauthorized|malformed`,
`[guidance-cron] skipped: unconfigured|malformed`, `[guidance-cron] send failed`,
`[location-fallback] failed: telegram-send`. Never raw payloads, never user ids,
never chat ids, never coordinates, never timestamps. Rejection responses carry the
category only. The web app's console lines are state tokens (`ARAHARAH-ROUTE`,
`ARAHARAH-GUIDE`, `RAMS-BASEMAP-READY`, `ARAHARAH-LIVE`) — never coordinates. The
audits (`log-vocabulary` gate) prove this mechanically.
### The budget law
`npm run budget-audit` (`scripts/budget-audit.mjs`) measures the free-tier posture
instead of assuming it: route CPU (the A\* ladder, child-process), bundle size, KV
write classes (the cron's ZERO-writes law), subrequest classes, and the golden
corpora; `--live` adds observed cache headers. The record is `docs/BUDGETS.md`. The
cron's cadence is tuned to it: `*/2 * * * *` × one KV list-op per tick = 720
list-ops/day, inside the free plan's 1,000/day KV-list ceiling, and each tick performs
zero KV writes (stateless projection over the 7200 s envelopes).
### Testing ### Testing
Both `bot/test` and `web/test` use the same hand-rolled zero-dependency runner Five workspaces, one test idiom: `bot/test`, `web/test`, `search/test`, and
(`runner.mjs` + `harness.mjs`), auto-discovering `*.test.mjs`. `theme` runs `routing/test` each use the same hand-rolled zero-dependency runner (`runner.mjs` +
`scripts/audit.test.mjs` in the same shape. Gates are grep/introspection assertions `harness.mjs`), auto-discovering `*.test.mjs`; `theme` runs `scripts/audit.test.mjs`
against source and build artifacts, not just behavioral assertions. in the same shape, and `scripts/test/` holds the audit tools' own suites. Gates are
grep/introspection assertions against source and build artifacts, not just behavioral
assertions. The corpus suites (`search/test/corpus.test.mjs`,
`routing/test/corpus.test.mjs`) assert the same measurements the standalone corpus
runners print — measured quality and shipped quality cannot drift.
## What Is Not Built Yet ## What Is Not Built Yet
The following appear in `IDEAS.md` as roadmap items and are **not** in the codebase: The following are documented targets, **not** in the codebase: **engine B** (the fully
routing engines (Valhalla/OSRM), Neon/Postgres, RabbitMQ, Prometheus/Grafana, Resend, client-side webview router — graph on the phone; engine A's documented post-v1
Cloudflare R2, Photon geocoding. The current system is exactly the two implemented evolution; the Valhalla-to-JS port was ruled infeasible and geojson-path-finder was
phases: the bot skeleton with initData-gated sessions and TTL-only KV, and the Mini App rejected — recorded so neither is re-proposed), Valhalla-on-VPS (the documented
shell with the three-link location chain and the Rams basemap. Routing/guidance is provision path, not shipped), Neon/Postgres, RabbitMQ, Prometheus/Grafana, Resend,
Phase 5+. opt-in telemetry (v1 carries zero telemetry — there is nothing to opt into yet), and
the Maptiler tile provider (the basemap today is the OpenFreeMap `liberty` fork).
Alternatives generation on `/api/route` is prohibited by design, not deferred.
Search backend #1 (gazetteer) and routing backends #1/#2 are the only ladder rungs
implemented; later rungs slot in at the import site without a client rewrite.
+181 -48
View File
@@ -2,9 +2,10 @@
# Configuration # Configuration
arah² is an npm-workspaces monorepo with three workspaces — `theme`, `bot`, `web` — plus a root arah² is an npm-workspaces monorepo with five workspaces — `theme`, `bot`, `web`, `search`, `routing`
`package.json` that composes their scripts. There is **no committed `.env` file**: secrets live in — plus a root `package.json` that composes their scripts. There is **no committed `.env` file**:
`bot/.dev.vars` (gitignored) for local work and in Cloudflare Workers secrets for deployment. secrets live in `bot/.dev.vars` (gitignored) for local work and in Cloudflare Workers secrets for
deployment.
Configuration surfaces, by kind: Configuration surfaces, by kind:
@@ -12,28 +13,55 @@ Configuration surfaces, by kind:
| --- | --- | --- | | --- | --- | --- |
| Workspaces + scripts | `package.json` | all | | Workspaces + scripts | `package.json` | all |
| Worker deployment + bindings | `bot/wrangler.jsonc` | bot | | Worker deployment + bindings | `bot/wrangler.jsonc` | bot |
| Secret env contract | `bot/.dev.vars` (from `.dev.vars.example`) | bot | | Secret env contract | `bot/.dev.vars` (from `.dev.vars.example`) | bot + the repo gate CLIs |
| Worker env typing | `bot/src/config.ts` | bot | | Worker env typing | `bot/src/config.ts` | bot |
| Local secrets template | `bot/.dev.vars.example` | bot | | Local secrets template | `bot/.dev.vars.example` | bot |
| Static-asset cache policy | `web/public/_headers` | web (served by the Worker's asset binding) |
| Bundler config | `web/vite.config.ts` | web | | Bundler config | `web/vite.config.ts` | web |
| TypeScript config | `bot/tsconfig.json`, `web/tsconfig.json` | bot, web | | TypeScript config | `bot/tsconfig.json`, `web/tsconfig.json`, `search/tsconfig.json`, `routing/tsconfig.json` | bot, web, search, routing |
| Design tokens (source of truth) | `theme/src/tokens.css` → `theme/src/theme.css` | theme + everything downstream | | Design tokens (source of truth) | `theme/src/tokens.css` → `theme/src/theme.css` | theme + everything downstream |
| Graph binary + gazetteer data | `routing/data/kl-roadgraph.v1.bin`, `search/src/data/kl-gazetteer.json` | routing, search |
| Ignore rules | `.gitignore`, `bot/.gitignore` | repo | | Ignore rules | `.gitignore`, `bot/.gitignore` | repo |
--- ---
## Environment variables ## Environment variables
Four variables reach the Worker (`bot/src/config.ts` declares them as a typed `Env` interface); Three variables reach the Worker (BOT_TOKEN, WEBHOOK_SECRET, MINI_APP_URL) plus two bindings; `WEBHOOK_URL` is script-only (`bot/src/config.ts` declares them as a typed `Env` interface,
a fifth (`WEBHOOK_URL`) is consumed only by the one-time registration scripts, never by the Worker. plus two *bindings* — a KV namespace and an R2 bucket); `WEBHOOK_URL` is consumed only by the
one-time registration scripts, never by the Worker.
| Variable | Required | Default | Description | | 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. | | `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_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. |
| `MINI_APP_URL` | **Required for the deep link** | none | Public origin of the deployed Mini App (e.g. `https://araharah-bot.<account>.workers.dev`) — NOT a secret, so it lives in `bot/wrangler.jsonc`'s `vars`, not in a secret. The `/cari` venue card's OPEN button is built from it: `<MINI_APP_URL>/?dest=<gazetteer id>`. It must equal the Mini App URL registered in @BotFather (Bot API 10.2 enforces the origin). The Worker never derives it from user input — an unvalidated origin would be an open redirect. | | `MINI_APP_URL` | **Required for the deep link** | none (concrete value committed) | Public origin of the deployed Mini App — NOT a secret, so it lives in `bot/wrangler.jsonc`'s `vars`, not in a secret. The `/cari` venue card's OPEN button is built from it: `<MINI_APP_URL>/?dest=<gazetteer id>`. It must equal the Mini App URL registered in @BotFather (Bot API 10.2 enforces the origin). The Worker never derives it from user input — an unvalidated origin would be an open redirect. |
| `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. | | `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`. | | `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()` across `bot/src/kv/` (`sessions.ts`, `location-pin.ts`, `guidance-sessions.ts`) writes through it with an explicit `expirationTtl`. |
| `ROAD_GRAPH` | **Required (binding)** | n/a | Not an env var but a Cloudflare **R2 bucket** binding (`r2_buckets` in `bot/wrangler.jsonc`, bucket `araharah-graphs`). `/api/route` decodes the road-graph binary through it; `loadGraph` memoizes one decoded graph per isolate. An absent or corrupt object degrades honestly (null → router-pending), never a crash. The bucket name is public configuration, never a secret — `docs/ROAD-GRAPH.md` owns the fork contract. |
### Maintainer-only credentials (never reach the Worker)
Two more variables are read **only** by the `--live` probes of the repo gate CLIs
(`scripts/lib/cloudflare-probes.mjs`), never by any `src/` tree and never by the Worker:
| Variable | Required | Default | Description |
| --- | --- | --- | --- |
| `CLOUDFLARE_API_TOKEN` | Required for `--live` probes only | none | Wrangler credential used to run `wrangler kv key list` (KV-expiry probe) and the asset-header probe. Resolved as `process.env` first, then parsed from `bot/.dev.vars`. Never printed, never logged, never written anywhere. Not present in `.dev.vars.example` — add it by hand to `.dev.vars` or export it in the shell. |
| `CLOUDFLARE_ACCOUNT_ID` | Required for `--live` probes only | none | Cloudflare account id paired with the API token above. Same resolution order and same never-echo rule. |
The probes are invoked with `npm run privacy-audit -- --live` and `npm run budget-audit -- --live`.
Without the credentials the probes report `NOT RUN` and the CLI exits `2` — a skipped probe is
never mistaken for a pass. Without `--live`, the probes are listed as `NOT RUN (pass --live)` by
the operator's explicit choice, which is excluded from the exit-2 class.
The gate CLIs also honor one non-secret override:
| Variable | Required | Default | Description |
| --- | --- | --- | --- |
| `PRIVACY_AUDIT_ROOT` | Optional | repo root | Repo-root override for the gate CLIs' scanners (used by their test suites and by fork checkouts). The gates are otherwise cwd-independent — a stray subdirectory run must not scan the wrong tree and false-pass. |
<!-- VERIFY: the actual credential VALUES (BOT_TOKEN, WEBHOOK_SECRET, CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID) and the Cloudflare account they live under cannot be read from the repository — only their names and resolution order are documented here. -->
Local secret template — `bot/.dev.vars.example`: Local secret template — `bot/.dev.vars.example`:
@@ -48,14 +76,16 @@ WEBHOOK_URL=
MINI_APP_URL= MINI_APP_URL=
``` ```
The template does NOT list `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID` — those are read
directly from `.dev.vars` (or the shell) by the audit probes when present; they are optional and
only required for the `--live` mode. Values are never echoed anywhere in this documentation.
Script-level precedence: every script in `bot/scripts/` resolves a variable as 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 `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 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`. exits `1` listing whichever are missing. `register-commands.mjs` requires only `BOT_TOKEN`.
`set-menu-button.mjs` requires `BOT_TOKEN` and `WEBHOOK_URL`. `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 ## Configuration flow
@@ -75,9 +105,11 @@ flowchart TD
subgraph Production["Deployed Worker"] subgraph Production["Deployed Worker"]
SECRETS["wrangler secrets: BOT_TOKEN, WEBHOOK_SECRET"] SECRETS["wrangler secrets: BOT_TOKEN, WEBHOOK_SECRET"]
KV["KV namespace binding: chat_sessions"] KV["KV binding: chat_sessions (TTL-only, 4 families)"]
ASSETS["Static assets: ../web/dist"] R2["R2 binding: ROAD_GRAPH (araharah-graphs)"]
WORKER["src/webhook.ts fetch handler"] ASSETS["Static assets: ../web/dist + _headers"]
CRON["Cron trigger: */2 * * * * (stateless scan)"]
WORKER["src/webhook.ts fetch + scheduled handlers"]
end end
DEVVARS --> WRANGLERDEV DEVVARS --> WRANGLERDEV
@@ -87,7 +119,9 @@ flowchart TD
SECRETS --> WORKER SECRETS --> WORKER
KV --> WORKER KV --> WORKER
R2 --> WORKER
ASSETS --> WORKER ASSETS --> WORKER
CRON --> WORKER
WRANGLERDEV --> WORKER WRANGLERDEV --> WORKER
style Local fill:#D9D2C6,stroke:#1A1A1A,color:#1A1A1A style Local fill:#D9D2C6,stroke:#1A1A1A,color:#1A1A1A
@@ -100,7 +134,17 @@ flowchart TD
The Mini App in `web/` has **no build-time or runtime configuration surface**. It calls its own 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 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 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. `../web/dist` as its own static assets and reserves `/api/*` and `/telegram/*` for itself. The
same is true of `search/` and `routing/`: neither reads any environment variable at runtime
(`loader-env.test.mjs` in both workspaces pins that the data loads with zero infrastructure —
static imports under Node, esbuild inlining under Vite, and an inlined seed under the wrangler
/esbuild Worker bundle).
The Worker has exactly one scheduled configuration surface: the `*/2 * * * *` cron trigger in
`wrangler.jsonc`. The tick is stateless — zero KV writes (the budget law) — and gates its card
sends on `CARD_CADENCE_S = 240` in code (`bot/src/guidance/cron.ts`): `*/2` is the coarsest
cadence that keeps 2-min ticks × 1 list-op = 720/day inside the free plan's 1,000/day KV-list
ceiling while honoring the 240 s card window.
--- ---
@@ -108,28 +152,48 @@ base URL is implied by wherever the bundle is served from — which is why the W
**Fail loudly (Worker):** **Fail loudly (Worker):**
- `WEBHOOK_SECRET` — the only setting with an explicit runtime guard, at `bot/src/webhook.ts:65`: - `WEBHOOK_SECRET` — the only setting with an explicit runtime guard, at `bot/src/webhook.ts`:
`if (!env.WEBHOOK_SECRET)` logs `[webhook] rejected: misconfigured` and returns `if (!env.WEBHOOK_SECRET)` logs `[webhook] rejected: misconfigured` and returns
`500 Server misconfigured`. The rationale is documented in the file: grammY's `compareSecretToken` `500 Server misconfigured`. The rationale is documented in the file: grammY's
silently accepts every request when the secret is `undefined`, so an unarmed binding must never `compareSecretToken` silently accepts every request when the secret is `undefined`, so an
reach it. unarmed binding must never reach it.
- `BOT_TOKEN` — declared required in the `Env` type but not guarded at runtime; a missing token - `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. 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 **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. print a missing-variable list to stderr and `process.exit(1)` before any network call.
**Fail loudly (gate CLIs):** `privacy-audit` and `budget-audit` never pass silently — a required
measurement that cannot run (missing graph binary, missing build, missing `--live` credentials)
yields exit `2` NOT RUN, never a green check.
**Degrade honestly (binding):** a missing or corrupt `ROAD_GRAPH` object does not crash the
Worker — `/api/route` returns the honest router-pending state. This is deliberate: the graph
bucket is fork provisioning, not a startup invariant.
**With defaults / derived:** **With defaults / derived:**
- `KV namespace id` — `bot/wrangler.jsonc` already carries a concrete id for `chat_sessions`; the - `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`. file's leading comment notes it was filled in after `npx wrangler kv namespace create chat_sessions`.
- `MINI_APP_URL` — a **public** configuration value declared in the Worker `Env` and shipped via
`bot/wrangler.jsonc`'s `vars` block with its concrete value committed
(`https://araharah-bot.araharah-bot.workers.dev`). When it is blank the `/cari` OPEN button is
omitted rather than guessed; the venue card and its alternates still send.
- `WEBHOOK_URL` path segment — auto-derived by `set-webhook.mjs` when the configured origin has no path. - `WEBHOOK_URL` path segment — auto-derived by `set-webhook.mjs` when the configured origin has no path.
- `MINI_APP_URL` — a non-secret configuration value declared in the Worker `Env` and shipped via - KV TTLs — constants in source, not configuration. One per family, all pinned by grep gates in
`bot/wrangler.jsonc`'s `vars` block (public origin, not a secret). When it is blank the `/cari` the test suite so no write path can persist without a TTL:
OPEN button is omitted rather than guessed; the venue card and its alternates still send.
- KV TTLs — constants in source, not configuration: `SESSION_TTL_SECONDS = 900` | Constant | Value | File | Key family |
(`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. | `SESSION_TTL_SECONDS` | `900` | `bot/src/kv/sessions.ts` | `session:<token>`, `chat:<chat_id>` |
| `PIN_TTL_SECONDS` | `300` | `bot/src/kv/location-pin.ts` | `loc:<chat_id>` |
| `GUIDANCE_SESSION_TTL_SECONDS` | `7200` | `bot/src/kv/guidance-sessions.ts` | `guidance:<id>` |
The live KV probe asserts every listed key's family prefix is one of the four TTL-only families
(`session:`, `chat:`, `loc:`, `guidance:`) and carries an `expiration`.
- `CARD_CADENCE_S = 240` (`bot/src/guidance/cron.ts`) and the cron expression `*/2 * * * *`
(`wrangler.jsonc`) — a code/config pair: the cron ticks every 120 s, a card is due on the first
tick of each 240 s window.
--- ---
@@ -137,11 +201,15 @@ print a missing-variable list to stderr and `process.exit(1)` before any network
```jsonc ```jsonc
{ {
// KV namespace id is filled in plan 03 task 1 after `npx wrangler kv namespace create chat_sessions` // chat_sessions namespace created via `npx wrangler kv namespace create chat_sessions` — the id below is live.
"name": "araharah-bot", "name": "araharah-bot",
"main": "src/webhook.ts", "main": "src/webhook.ts",
"compatibility_date": "2026-10-22", "compatibility_date": "2026-10-22",
// workers_dev: serve on the <name>.<account>.workers.dev URL — the WEBHOOK_URL
// target for this phase (a custom route/domain may supersede)
"workers_dev": true, "workers_dev": true,
// Workers static assets binding: serves ../web/dist. The Worker fetch still
// owns /api/* and /telegram/* first via run_worker_first (a GLOB ARRAY, not a boolean).
"assets": { "assets": {
"directory": "../web/dist", "directory": "../web/dist",
"binding": "ASSETS", "binding": "ASSETS",
@@ -150,7 +218,16 @@ print a missing-variable list to stderr and `process.exit(1)` before any network
"run_worker_first": ["/api/*", "/telegram/*"], "run_worker_first": ["/api/*", "/telegram/*"],
// Public vars (NOT secrets): the deployed Mini App ORIGIN used to build // Public vars (NOT secrets): the deployed Mini App ORIGIN used to build
// the /cari venue card's OPEN deep link (<MINI_APP_URL>/?dest=<id>). // the /cari venue card's OPEN deep link (<MINI_APP_URL>/?dest=<id>).
"vars": { "MINI_APP_URL": "https://araharah-bot.<account>.workers.dev" }, "vars": { "MINI_APP_URL": "https://araharah-bot.araharah-bot.workers.dev" },
// R2 binding: hosts the road-graph binary /api/route loads (loadGraph memoizes
// ONE GET per cold isolate). Forks create + upload their OWN bucket:
// `npx wrangler r2 bucket create <name>` + routing/scripts/upload-road-graph.mjs
// (docs/ROAD-GRAPH.md is the fork contract).
"r2_buckets": [{ "binding": "ROAD_GRAPH", "bucket_name": "araharah-graphs" }],
// Cron trigger: */2 scan — 2-min ticks × 1 list-op = 720/day, inside the free
// plan's 1,000/day KV-list ceiling; card cadence is 240 s in code (CARD_CADENCE_S).
// The tick is STATELESS (zero KV writes — the budget law).
"triggers": { "crons": ["*/2 * * * *"] },
"kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }] "kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }]
} }
``` ```
@@ -159,23 +236,45 @@ Key notes:
- `assets.directory: "../web/dist"` — the Worker serves the Mini App bundle itself, so `npm run build` - `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. (theme then web) must run before `wrangler deploy`; a stale or absent `web/dist` ships no Mini App.
Vite copies `web/public/_headers` into the bundle, so the cache policy ships with it (see below).
- `run_worker_first` is a **glob array**, not a boolean: `/api/*` and `/telegram/*` are handled by - `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. `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. - `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` - `workers_dev: true` serves on the `<name>.<account>.workers.dev` URL, which is the `WEBHOOK_URL`
target. target and the committed `MINI_APP_URL` value.
- `vars: { "MINI_APP_URL": ... }` — a **public** origin shipped as a plain var (never a secret): - `vars: { "MINI_APP_URL": ... }` — a **public** origin shipped as a plain var (never a secret).
the deployed Mini App origin the `/cari` venue card's OPEN button deep link is built from. It It must equal the Mini App URL registered in @BotFather; see the env-var table above.
must equal the Mini App URL registered in @BotFather; see the env-var table above. - `r2_buckets` / `triggers` — the R2 graph bucket and the */2 cron are both free-plan-compatible;
the cron's budget arithmetic (720 KV list-ops/day against a 1,000/day ceiling) is measured and
printed by `npm run budget-audit` (see `docs/BUDGETS.md`).
<!-- VERIFY: the Cloudflare account subdomain backing `araharah-bot.<account>.workers.dev` is not recorded in the repository. --> ### Static-asset cache policy (`web/public/_headers`)
Committed under `web/public/`, so Vite copies it into every build output (`web/dist`,
`web/dist-harness`) and the Workers asset binding serves it verbatim:
```text
/assets/*
Cache-Control: public, max-age=31556952, immutable
/
Cache-Control: public, max-age=0, must-revalidate
```
Rationale (from the file's header comment): `/assets/*` filenames are content-HASHED — a hashed
URL's bytes can never change, so they are immutable for a year. The HTML entry is NOT hashed (it
points at the current hashed assets), so it must revalidate on every visit or a stale entry would
point at deleted asset files. The `--live` asset-headers probe (`privacy-audit --live`, re-used by
`budget-audit --live`) **quotes the observed `cache-control` and `cf-cache-status` headers verbatim**
against the deployed origin — it reports, it does not assert the policy.
--- ---
## Build and run scripts ## Build and run scripts
Root `package.json` (workspaces: `theme`, `bot`, `web`; the root, `bot`, and `web` packages set Root `package.json` (workspaces: `theme`, `bot`, `web`, `search`, `routing`; the root and `bot`,
`"private": true` while `@araharah/theme` omits the field, ESM via `"type": "module"`): `web`, `search`, `routing` packages set `"private": true` while `@araharah/theme` omits the field,
ESM via `"type": "module"`):
| Command | Description | | Command | Description |
| --- | --- | | --- | --- |
@@ -184,26 +283,38 @@ Root `package.json` (workspaces: `theme`, `bot`, `web`; the root, `bot`, and `we
| `npm run dev` | `build`, then `npm run dev -w bot` (`wrangler dev --port 8787 --local`). | | `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 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:test` / `web:typecheck` / `web:dev` | Passthroughs to the `web` package. |
| `npm run search:test` / `search:typecheck` / `search:corpus` | `search` workpace passthroughs; `search:corpus` runs `node search/scripts/gazetteer-corpus.mjs` (golden corpus measurement). |
| `npm run routing:test` / `routing:typecheck` / `routing:corpus` / `routing:fixtures` | `routing` workspace passthroughs; `routing:corpus` runs `node routing/scripts/route-corpus.mjs`; `routing:fixtures` re-fetches route fixtures from the OSRM demo server (maintainer-time, fixed public coordinates only). |
| `npm run web:build` | Same as `build`; `web:build:harness` runs the harness-mode build; `web:capture` runs the screenshot harness. | | `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. | | `npm run sheet` / `gallery` / `audit` / `capture` | Passthroughs to the `theme` design-system tooling. |
| `npm run privacy-audit` | `node scripts/privacy-audit.mjs` — the five static privacy gates (+ `--live` probes). Exits `0` clean, `1` violations, `2` NOT RUN. |
| `npm run budget-audit` | `node scripts/budget-audit.mjs` — the five measured budget gates + the free-tier comparison table (+ `--live` cache-header probe). Same exit-code contract. |
Workspace-level commands worth knowing: Workspace-level commands worth knowing:
| Command | Where | Notes | | Command | Where | Notes |
| --- | --- | --- | | --- | --- | --- |
| `npm test` | `bot`, `web` | Both run `node test/runner.mjs` (a hand-rolled runner, no external test framework). | | `npm test` | `bot`, `web`, `search`, `routing` | All 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 typecheck` | `bot`, `web`, `search`, `routing` | `tsc --noEmit`, `strict` plus `noUncheckedIndexedAccess`. |
| `npm run dev` | `bot` | `wrangler dev --port 8787 --local` — reads `bot/.dev.vars`. | | `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 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 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` | `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 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 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 capture` | `web` | `node scripts/capture.mjs` — serves `web/dist-harness` and screenshots 22 boot/route/guidance legs. |
| `npm run build` | `theme` | `tailwindcss -i ./src/theme.css -o ./dist/rams.css --minify`. | | `npm run build` | `theme` | `tailwindcss -i ./src/theme.css -o ./dist/rams.css --minify`. |
| `npm run send` | `theme` | Copies `fonts/geist` + `fonts/space-grotesk` into `dist/fonts/`. |
| `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 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 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`. | | `npm run gallery` / `sheet` / `capture` | `theme` | Design tooling; `gallery` builds, captures, then serves `http://localhost:8123/gallery.html + /basemap/` via `npm run basemap:preview` (`python3 -m http.server 8123`). |
Routing maintainer scripts with no npm alias (run directly with `node` from the workspace):
`build-road-graph.mjs`, `upload-road-graph.mjs` (fills the R2 bucket), `measure-route-cpu.mjs`
(the A*-CPU ladder the budget gate re-runs), `build-glyphs.mjs` (256×256 PNG render tiles via
`sharp`). Search maintainer scripts: `json-to-sqlite.mjs`, `overpass-topup.mjs` (gazetteer top-up
from Overpass — maintainer-time, never a runtime dependency). `docs/ROAD-GRAPH.md` is the fork
contract for the graph binary and the R2 bucket.
--- ---
@@ -214,15 +325,18 @@ Vite is configured from a function of `mode`, with one switch driving a **dual-b
- `__ARAHARAH_HARNESS__` is injected via `define` as `true` only in `--mode harness` and `false` - `__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 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 mock installer and `/api/*` fetch stubs out entirely; `web/index.html` carries its mock profile
block inside the same guarded branch. block inside the same guarded branch. In plain `vite dev` the define keeps `main.ts`'s ambient
reference from throwing while the fallback mock still fires via `import.meta.env.DEV`.
- `build.outDir` is `dist-harness` in harness mode, `dist` otherwise, with `emptyOutDir: true` and - `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 `target: "es2022"`. The two outputs must never share a directory — the file's own comment calls
that out as the reason the split exists. that out as the reason the split exists (sharing one would make them clobber each other and make
the BA5 gate meaningless).
- `base: "/"`, dev server on port 5173 with `strictPort: true`. - `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 - `__ARAHARAH_HARNESS__` is declared ambient in `web/src/vite-env.d.ts` so `tsc --noEmit` passes in
both modes. both modes.
- Harness-only URL parameters (documented in `web/index.html`): `?mock=android|ios|tdesktop|macos|web`, - 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>`. `?scheme=light|dark`, `?pin=1`, `?dest=<gazetteer id>`, `?fixpair=<pairId>`, `?drive=<model>`,
`?locale=en|ms`, `?sessiondelay=<ms>`, `?fixdelay=<ms>`.
--- ---
@@ -230,23 +344,41 @@ Vite is configured from a function of `mode`, with one switch driving a **dual-b
`theme/src/tokens.css` is the single source of truth for every color, spacing, typography, and radius `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 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 self-hosted Geist and Space Grotesk `@font-face` rules from `theme/fonts/` (no Google Fonts CDN),
scopes class generation with explicit `@source` lines — the four mockup markup files under 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 `stitch_minimalist_route_navigator/` are the only allowed utility sources, with
`@source not "../scripts"` and `@source not "../basemap"` excluding prose. `@source not "../scripts"` and `@source not "../basemap"` excluding prose.
Downstream consumers: Downstream consumers:
- `web/src/shell.css` — `@import "@araharah/theme/dist/rams.css";` - `web/src/shell.css` — `@import "@araharah/theme/dist/rams.css";`
- `web/src/map/map.ts` — `import ramsStyle from "@araharah/theme/basemap/liberty-rams.json";` - `web/src/map/map.ts` — `import ramsStyle from "@araharah/theme/basemap/liberty-rams.json";` (inlined
at build time, never re-set at runtime)
- `theme/basemap/liberty-rams.json` — the generated basemap style; its source URL, sprite, and glyph - `theme/basemap/liberty-rams.json` — the generated basemap style; its source URL, sprite, and glyph
endpoints point at `https://tiles.openfreemap.org/...`. endpoints point at `https://tiles.openfreemap.org/...`.
--- ---
## Data configuration (search + routing)
`search/` and `routing/` carry their data as committed files rather than configuration:
| File | Purpose |
| --- | --- |
| `search/src/data/kl-gazetteer.json` | The KL gazetteer seed — static-imported, esbuild-inlined for the Worker bundle. Carries the ODbL provenance header. |
| `routing/data/kl-roadgraph.v1.bin` | The built road-graph binary; `/api/route` decodes it via the `ROAD_GRAPH` R2 binding in production. |
| `routing/src/data/route-fixtures.json` | Committed demo-pair routes (polyline6 geometry), precomputed from the OSRM demo server by `routing/scripts/fetch-route-fixtures.mjs` over FIXED PUBLIC coordinates only — never user data. |
Neither workspace reads an environment variable at runtime; the loader-env suites in both pin the
"zero infrastructure" property (bare Node, Vite, and the wrangler/esbuild bundle all serve the same
seed). The only environment knob in this layer is `PRIVACY_AUDIT_ROOT`, honored by the maintainer
gate CLIs, not by the workspaces themselves.
---
## Ignore rules and generated output ## Ignore rules and generated output
`.gitignore` at the repo root excludes `.planning/`, `node_modules/`, `dist/` (which covers both `.gitignore` at the repo root TRACKS `.planning/` (the ignore line is commented out), `node_modules/`, `dist/` (which covers both
`theme/dist/` and `web/dist/`), `web/dist-harness/`, `theme/screenshots/`, `web/screenshots/`, `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 `.env`, `.env.*` (with `!*.example`), and `*:Zone.Identifier`. `bot/.gitignore` adds `.dev.vars` and
`.wrangler/`. `.wrangler/`.
@@ -266,7 +398,8 @@ is structural rather than file-based:
| 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 `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`. | | 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. | | 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. | | Production | `wrangler.jsonc` + Cloudflare secrets | Secrets set with `wrangler secret put` rather than a file; assets come from the uploaded `../web/dist` (with `_headers`); KV resolves to the real namespace; `ROAD_GRAPH` resolves to the real R2 bucket; the cron trigger schedules the stateless scan. |
| Gate-CLI `--live` | `process.env` or `bot/.dev.vars` | `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` (shell wins over the file); absent credentials → exit `2` NOT RUN, never a pass. |
`@araharah/theme` and `@araharah/web` are consumed by relative path within the workspace, so there is `@araharah/theme`, `@araharah/web`, `@araharah/search`, and `@araharah/routing` are consumed by
no registry token or publish step to configure. relative path within the workspace, so there is no registry token or publish step to configure.
+284
View File
@@ -0,0 +1,284 @@
<!-- generated-by: gsd-doc-writer -->
# Deployment
arah² deploys as **one Cloudflare Worker** (`araharah-bot`, defined in `bot/wrangler.jsonc`) that
serves three things from a single origin: the Telegram webhook, the `/api/*` surface, and the Mini
App's static assets (Vite output from `web/dist`). Deployment is **manual and one-command** — there
is no CI pipeline in this repo. Everything is $0: Workers free plan, KV free tier, R2 free tier.
Companion docs: `docs/CONFIGURATION.md` (the full variable list and binding contract),
`docs/ROAD-GRAPH.md` (the fork's road-graph contract), `docs/BUDGETS.md` (the measured free-plan
budget record).
---
## Deployment targets
| Target | Config file | Notes |
| --- | --- | --- |
| Cloudflare Workers (`araharah-bot`) | `bot/wrangler.jsonc` | The only deployment target. `main: src/webhook.ts`; `workers_dev: true` (serves on `<name>.<account>.workers.dev`); static assets binding `ASSETS` → `../web/dist`; `run_worker_first: ["/api/*", "/telegram/*"]` (a **glob array**, not a boolean — the Worker fetch owns those paths before static assets answer). |
| Cloudflare KV (`chat_sessions`) | `bot/wrangler.jsonc` `kv_namespaces` | Exactly one namespace (GE5). Every write in the codebase carries an explicit `expirationTtl` — TTL-only storage. |
| Cloudflare R2 (`araharah-graphs`) | `bot/wrangler.jsonc` `r2_buckets` | Hosts the road-graph binary `kl-roadgraph.v1.bin` behind the `ROAD_GRAPH` binding. A **bucket**, not a KV namespace. Forks create and upload their own (see Fork provisioning). |
| Telegram Bot API | registered via `bot/scripts/*.mjs` + @BotFather | Webhook, command menu, menu button, Mini App origin — see Telegram registration below. |
| Cron trigger | `bot/wrangler.jsonc` `triggers.crons: ["*/2 * * * *"]` | The guidance turn-card tick, every 2 minutes. Stateless: zero KV writes per tick. |
No `vercel.json`, `netlify.toml`, `fly.toml`, `serverless.yml`, Dockerfile, or GitHub Actions
workflow exists — Cloudflare Workers is the platform, by design (the $0 law).
## Prerequisites
- **Node.js + npm** (npm workspaces monorepo; wrangler runs via `npx`/devDependencies —
`wrangler ^4.151.3`).
- **A Cloudflare account** with Workers + KV enabled. Authenticate once:
```bash
npx wrangler login
```
- **A Telegram bot token** from [@BotFather](https://t.me/BotFather).
- **R2 enabled** on the account (one-time activation in the Cloudflare dashboard: **R2 → Get
started**) — the first `r2 bucket create` fails until it is.
## The one-command deploy
From the repo root:
```bash
npm run deploy
```
which composes (`package.json` root + `bot/package.json`):
1. `npm run build` → `theme` then `web`:
- `theme`: `tailwindcss -i ./src/theme.css -o ./dist/rams.css --minify` (+ fonts copied by
`theme`'s build pipeline into `dist/fonts/`)
- `web`: `vite build` → `web/dist` (hashed assets + the `_headers` cache-policy file from
`web/public/`)
2. `npm run deploy -w bot` → `wrangler deploy && node scripts/set-webhook.mjs`
- `wrangler deploy` uploads the Worker and the `web/dist` static assets in one deployment.
- `scripts/set-webhook.mjs` re-registers the Telegram webhook (see below) — this step reads
`bot/.dev.vars` (or env), so **the deploy command requires `bot/.dev.vars` to exist** with
`BOT_TOKEN`, `WEBHOOK_URL`, `WEBHOOK_SECRET` filled in.
**The `_headers` lesson (client caching):** `web/public/_headers` ships the cache policy —
`/assets/*` are content-hashed and served `Cache-Control: public, max-age=31556952, immutable`;
the HTML entry is *not* hashed and must revalidate (`max-age=0, must-revalidate`) or a stale entry
would point at deleted hashed assets. Deploy drift (a deployed Worker serving an older bundle than
`web/dist`) surfaces as a 404 on a freshly-built hashed asset — the live audits
(`npm run privacy-audit -- --live`, `npm run budget-audit -- --live`) report that honestly as
*deployment drift, exit 2*.
### MINI_APP_URL must match the deployed origin
`bot/wrangler.jsonc` carries `vars: { "MINI_APP_URL": "https://araharah-bot.araharah-bot.workers.dev" }`.
On a fork or a custom domain, change this var to your real origin **before** deploying — it builds
the `/cari` venue card's OPEN deep link and the cron card's OPEN button. It must equal the Mini App
URL registered in @BotFather: Bot API 10.2 (auto-enabled July 20, 2026) *disallows Mini App methods
from origins different from the registered Mini App domain*, and it fails **silently** —
`Telegram.WebApp.*` calls from a mismatched origin throw with no clear error. BotFather's
registered URL is the security authority.
<!-- VERIFY: the workers.dev origin in bot/wrangler.jsonc is account-specific; a fork's deployed URL differs. -->
## Secrets
Production secrets are **Cloudflare Workers secrets**, never committed (D-15/D-17):
```bash
cd bot
npx wrangler secret put BOT_TOKEN # from @BotFather
npx wrangler secret put WEBHOOK_SECRET # any strong random value — Telegram echoes it back on every update
```
`MINI_APP_URL` is a **public var**, not a secret — it lives in `wrangler.jsonc` `vars`.
For local work and for the registration scripts, the mirror is `bot/.dev.vars` (gitignored; copy
from `bot/.dev.vars.example`):
```bash
BOT_TOKEN= # @BotFather token
WEBHOOK_SECRET= # the same value wrangler secret put received
WEBHOOK_URL= # full origin + path; set-webhook.mjs auto-appends /telegram/webhook if only an origin is set
MINI_APP_URL= # public Mini App origin (wrangler.jsonc "vars" in production)
```
**Never commit `.dev.vars`; never echo secret values** — every script prints only API `ok`/error
fields, and the Worker logs categories only (see `docs/API.md`, logging law).
## Telegram registration (one-time, per bot)
After the first successful deploy, register Telegram's three surfaces. All three scripts read
secrets from `bot/.dev.vars` or the environment (`process.env` wins) and run idempotently.
### 1. Webhook — `bot/scripts/set-webhook.mjs`
Already run by `npm run deploy`; re-run standalone with:
```bash
cd bot && node scripts/set-webhook.mjs
```
- Sends `setWebhook` with `url`, `secret_token`, `allowed_updates: ["message"]`.
- **Auto-appends the path:** if `WEBHOOK_URL` is a bare origin (no path), the script normalizes it
to `<origin>/telegram/webhook`. The hard-won lesson (2026-10-22 deploy walkthrough): a bare
origin makes Telegram answer `ok:true` while delivering updates to `/` → Worker 404 →
**silent total drop of all updates**.
- Exits 0 only when Telegram answers `ok:true`.
### 2. Command menu — `bot/scripts/register-commands.mjs`
```bash
npm run bot:commands # or: cd bot && node scripts/register-commands.mjs
```
- Arms **six calls**: `default` (en) · `default` + `language_code: "ms"` ·
`all_private_chats` (en) · `all_private_chats` (ms) · **clear** `all_group_chats` ·
**clear** `all_chat_administrators`.
- Why every scope: Telegram resolves a chat's menu from the **narrowest scope that has ANY
commands set** — a stale `all_private_chats` set shadows the default scope entirely (the
`/cari`-missing-from-menu incident). Group scopes are cleared so leftovers can't shadow
anything elsewhere.
- Copy is derived from `bot/src/i18n/strings.ts` `helpMenu` lines — the registered menu and the
`/help` reply cannot drift (D-05).
- **The silent-field-name lesson:** this script previously sent the scope under the parameter name
`bot_command_scope` — an unknown field Telegram **silently ignores**. Every call then hit the
default scope, and the two "clear" calls wiped the default set entirely. `ok:true` proved
nothing. The Bot API field is `scope` (`core.telegram.org/bots/api/methods/setmycommands`).
General law: read the response back with the matching `get*` method before trusting any
registration.
### 3. Menu button — `bot/scripts/set-menu-button.mjs`
```bash
cd bot && node scripts/set-menu-button.mjs
```
- `setChatMenuButton` with `menu_button: { type: "web_app", text: "arah²", web_app: { url: origin } }`
(default scope — all private chats). The app origin is derived by stripping
`/telegram/webhook` from `WEBHOOK_URL`.
### 4. BotFather manual steps
In the @BotFather chat (not scriptable from this repo):
1. **Register the Mini App origin** — BotFather's Mini App URL must equal the deployed origin
exactly (Bot API 10.2 origin restriction — see the MINI_APP_URL section above). Check via
`/setmenubutton` / the WebApp URL settings.
2. **`/setuserpic`** — set the bot's profile picture from the project's logo asset
(`assets/MASTER_FILE-arah2_logo.svg`).
<!-- VERIFY: BotFather's exact menu paths and the registered Mini App URL are per-account Telegram configuration, not repo facts. -->
## Fork provisioning: the road graph
The R2 bucket is **not** shared — every fork provisions its own ("your graph, YOUR bucket";
`docs/ROAD-GRAPH.md` is the contract):
```bash
# 1. Create the bucket (once). Name must agree with bot/wrangler.jsonc's ROAD_GRAPH bucket_name.
npx wrangler r2 bucket create araharah-graphs
# 2. Build the KL road-graph binary (or tune bbox/classes for your region in the script).
node routing/scripts/build-road-graph.mjs
# 3. Check the artifact (decode + stats, no network):
node routing/scripts/build-road-graph.mjs --check routing/data/kl-roadgraph.v1.bin
# 4. Upload (pre-flight gates: binary exists, 1–80 MB envelope; shells wrangler r2 object put --remote).
node routing/scripts/upload-road-graph.mjs
# 5. Round-trip verify (what the script prints as its own last step):
npx wrangler r2 object get araharah-graphs/kl-roadgraph.v1.bin --file /tmp/opencode/graph-roundtrip.bin --remote
node routing/scripts/build-road-graph.mjs --check /tmp/opencode/graph-roundtrip.bin
```
If the bucket name differs from `araharah-graphs`, change `bot/wrangler.jsonc`'s
`ROAD_GRAPH.bucket_name` to match — the Worker reads the *binding*, never the name; the two must
simply agree. Until this upload succeeds, `/api/route` answers the honest router-pending `200 null`
(absent graph is a designed miss class, not an error) and the cron's cards degrade to street-less
bare-verb compositions.
## Environment setup (production checklist)
Full variable table in `docs/CONFIGURATION.md`; the production minimum:
- **Secrets:** `BOT_TOKEN`, `WEBHOOK_SECRET` (`wrangler secret put`, values in `bot/.dev.vars` too
for the scripts).
- **Public var:** `MINI_APP_URL` = the deployed origin (`wrangler.jsonc` `vars`).
- **Bindings:** `chat_sessions` (KV namespace — the id in `wrangler.jsonc` is a live created
namespace; a fork creates its own via `npx wrangler kv namespace create chat_sessions` and
updates the id), `ROAD_GRAPH` (R2 bucket), `ASSETS` (static assets, wired by `wrangler deploy`
from `../web/dist`).
- **Cron:** `*/2 * * * *` is committed in `wrangler.jsonc` and arms with the deploy.
## Post-deploy canary verification protocol
Every deploy is verified by **reading state back** — registration calls returning `ok:true` prove
nothing by themselves (the `bot_command_scope` lesson). The launch-gate canary set:
```bash
# 1. Webhook armed: url ends /telegram/webhook, pending_update_count → 0, secret-token channel armed.
curl -s "https://api.telegram.org/bot$BOT_TOKEN/getWebhookInfo"
# 2. Menu button: reads back type web_app pointing at the deployed origin.
curl -s "https://api.telegram.org/bot$BOT_TOKEN/getChatMenuButton"
# 3. Command menu, PER SCOPE: each private-chat scope reads back help, privacy, cari.
curl -s "https://api.telegram.org/bot$BOT_TOKEN/getMyCommands" # default (en)
curl -s "https://api.telegram.org/bot$BOT_TOKEN/getMyCommands?language_code=ms" # default (ms)
# 4. Static-asset headers read-back: the deployed origin must serve the shipped policy.
curl -sSI "https://<MINI_APP_URL>/"
curl -sSI "https://<MINI_APP_URL>/assets/<a freshly-built hashed asset>.js"
```
Canary rules learned the hard way:
- **`getWebhookInfo.last_error` staleness:** `last_error` cannot clear until the next real update
arrives — record `last_error_date` and compare it against the deploy timeline before judging a
fresh deploy broken.
- **Read back per scope:** `setMyCommands` `ok:true` says nothing about which scope received the
commands — only `getMyCommands` per scope does.
- **Header probe honesty:** read the HTTP status before interpreting headers — a stale-deploy 404
is deployment drift (fix: redeploy), not a cache-policy violation.
- **Client caching:** Telegram clients cache the command menu — a device that opened the bot
before a fix may show a stale menu until the app restarts. Typing the command directly has
always worked.
## Rollback procedure
No CI/CD pipeline exists, so rollback is manual:
1. Redeploy the previous known-good commit: `git checkout <last-good-sha> && npm run deploy`.
2. Cloudflare retains recent deployment versions per Worker — a point-in-time rollback is also
available from the Cloudflare dashboard (Workers & Pages → araharah-bot → Deployments).
3. If only the Telegram surface is wrong (webhook/menu/commands), re-run the registration scripts
and the BotFather steps — they are idempotent and independent of the Worker build.
4. The road graph degrades honestly: an absent/corrupt R2 object yields router-pending `200 null`
everywhere, so a bad graph upload is recoverable by re-uploading — no Worker redeploy needed.
5. Cron cards are stateless and the envelopes TTL out at 7200 s — a bad guidance deploy ages out
of existence within ~2 hours.
<!-- VERIFY: the Cloudflare dashboard's per-Worker deployment-version retention window is platform state, not a repo fact. -->
## Monitoring
No third-party monitoring is installed — no Sentry, Datadog, New Relic, or OpenTelemetry in any
`package.json` (verified). Observability is:
- **`npx wrangler tail`** — live Worker logs. The logging law makes tail output safe: every line is
a category (`[webhook] rejected: misconfigured`, `[route] rejected: unauthorized`,
`[guidance-cron] send failed`, …) — never payloads, ids, coordinates, or secrets.
- **Workers Logs (free tier)** — 200,000 events/day, 3-day retention (documented in
`docs/BUDGETS.md`).
- **The audits as health checks:** `npm run privacy-audit` and `npm run budget-audit` (add
`-- --live` for the deployed-origin probes) re-derive the privacy and budget gates from source
and live responses.
- **Cron smoke test (local):** under `npm run dev -w bot` (`wrangler dev`), fire the scheduled arm
directly: `curl "http://localhost:8787/cdn-cgi/local/scheduled"`.
- **Budget ceiling watch:** the cron is stateless and paginated specifically so the KV list op
count (720/day at `*/2`) stays under the 1,000/day free ceiling — `docs/BUDGETS.md` holds the
measured record.
<!-- VERIFY: Workers Logs limits and the Cloudflare dashboard URLs are platform-documentation values; see developers.cloudflare.com and docs/BUDGETS.md's sourced ceiling table. -->
+249 -72
View File
@@ -9,6 +9,8 @@ not break.
For *using* the app, see the [README](../README.md). For *how the system is put 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* together*, see [ARCHITECTURE.md](./ARCHITECTURE.md). For *what to configure*
(secrets, `wrangler.jsonc`, env vars), see [CONFIGURATION.md](./CONFIGURATION.md). (secrets, `wrangler.jsonc`, env vars), see [CONFIGURATION.md](./CONFIGURATION.md).
For the road-graph fork contract, see [ROAD-GRAPH.md](./ROAD-GRAPH.md). For the
free-tier posture, see [BUDGETS.md](./BUDGETS.md).
--- ---
@@ -18,12 +20,12 @@ together*, see [ARCHITECTURE.md](./ARCHITECTURE.md). For *what to configure*
| Requirement | Notes | | 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. --> | | 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. --> The `@araharah/search` and `@araharah/routing` sources are consumed by plain Node (the corpus runners rely on Node's type-stripping for relative `.ts` imports — the source headers call out Node 24). |
| 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`. | | 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 Cloudflare account | `wrangler` authenticates on first use via an interactive browser login. Needed for `bot:dev`, `bot:deploy`, KV access, and the R2 bucket (road graph). |
| A Telegram bot token | From @BotFather. Required by `bot:dev` and by every script in `bot/scripts/`. | | 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`. | | 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. | | Network access | Fonts, tiles, the Play CDN in the mockup reference pages, and the Overpass fetch inside `build-road-graph.mjs` are all fetched over the network; the capture harnesses need it. |
### Clone and install ### Clone and install
@@ -33,9 +35,9 @@ cd arah2
npm install npm install
``` ```
`npm install` at the root installs all three workspaces (`theme`, `bot`, `web`) at `npm install` at the root installs all five workspaces (`theme`, `bot`, `web`,
once — npm hoists shared deps into the root `node_modules/`. There is no `search`, `routing`) at once — npm hoists shared deps into the root
per-workspace `npm install` step. `node_modules/`. There is no per-workspace `npm install` step.
### Configure local secrets ### Configure local secrets
@@ -44,11 +46,34 @@ There is no committed `.env`. The Worker reads secrets from `bot/.dev.vars`
```bash ```bash
cp bot/.dev.vars.example bot/.dev.vars cp bot/.dev.vars.example bot/.dev.vars
# then edit bot/.dev.vars and fill in BOT_TOKEN, WEBHOOK_SECRET, WEBHOOK_URL # then edit bot/.dev.vars and fill in BOT_TOKEN, WEBHOOK_SECRET, WEBHOOK_URL, MINI_APP_URL
``` ```
Full details, including the `process.env` vs `.dev.vars` precedence rule, are in `MINI_APP_URL` is the public Mini App origin (not a secret) the `/cari` venue
[CONFIGURATION.md](./CONFIGURATION.md#environment-variables). card's OPEN deep link is built from; it must match the URL registered in
@BotFather. Full details, including the `process.env` vs `.dev.vars` precedence
rule, are in [CONFIGURATION.md](./CONFIGURATION.md#environment-variables).
### Fork provisioning (before first run against real infra)
A fresh fork needs its own road graph and its own bindings before `bot:dev` /
`bot:deploy` route for real:
1. **Create the R2 bucket** (road-graph host — public configuration, never a
secret): `npx wrangler r2 bucket create araharah-graphs` (or your own name —
it must agree with `bot/wrangler.jsonc`'s `ROAD_GRAPH` `bucket_name`).
2. **Build the graph**: `node routing/scripts/build-road-graph.mjs` — fetches one
Overpass extract and encodes `routing/data/kl-roadgraph.v1.bin` (build
artifact, gitignored). `--check <file>` validates it offline. **Do not commit
the binary.**
3. **Upload it**: `node routing/scripts/upload-road-graph.mjs` (1 MB floor / 80 MB
ceiling pre-flight).
4. **Point the webhook**: `npm run bot:deploy` runs `wrangler deploy` +
`scripts/set-webhook.mjs` automatically; or set it standalone.
Steps 1–3 are spelled out fully in [ROAD-GRAPH.md](./ROAD-GRAPH.md) — that doc is
the fork contract. The search gazetteer needs no provisioning: its dataset
(`search/src/data/kl-gazetteer.json`) is committed.
### Pre-build before first run ### Pre-build before first run
@@ -69,10 +94,12 @@ graph TD
classDef check fill:#D9D2C6,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 classDef gate fill:#ED3F1C,stroke:#1A1A1A,stroke-width:2px,color:#FFFFFF
EDIT["Edit source<br/>bot/src, web/src, theme/src"]:::edit EDIT["Edit source<br/>bot/src, web/src, theme/src,<br/>search/src, routing/src"]:::edit
TC["npm run bot:typecheck<br/>npm run web:typecheck<br/>tsc --noEmit"]:::check TC["npm run bot:typecheck<br/>npm run web:typecheck<br/>npm run search:typecheck<br/>npm run routing:typecheck"]:::check
T1["npm run bot:test<br/>node test/runner.mjs"]:::check T1["npm run bot:test"]:::check
T2["npm run web:test<br/>node test/runner.mjs"]:::check T2["npm run web:test"]:::check
T3["npm run search:test"]:::check
T4["npm run routing:test"]:::check
AU["npm run audit<br/>off-token CSS audit"]:::check AU["npm run audit<br/>off-token CSS audit"]:::check
BUILD["npm run build<br/>theme then web"]:::check BUILD["npm run build<br/>theme then web"]:::check
DEV1["npm run bot:dev<br/>wrangler dev :8787"]:::edit DEV1["npm run bot:dev<br/>wrangler dev :8787"]:::edit
@@ -83,10 +110,14 @@ graph TD
EDIT --> TC EDIT --> TC
EDIT --> T1 EDIT --> T1
EDIT --> T2 EDIT --> T2
EDIT --> T3
EDIT --> T4
EDIT --> AU EDIT --> AU
TC --> BUILD TC --> BUILD
T1 --> BUILD T1 --> BUILD
T2 --> BUILD T2 --> BUILD
T3 --> BUILD
T4 --> BUILD
AU --> BUILD AU --> BUILD
BUILD --> DEV1 BUILD --> DEV1
BUILD --> DEV2 BUILD --> DEV2
@@ -115,6 +146,12 @@ bundle:
npm run build && npm run bot:dev npm run build && npm run bot:dev
``` ```
The cron trigger is exercisable locally under `wrangler dev`:
```bash
curl "http://localhost:8787/cdn-cgi/local/scheduled"
```
### Web workspace (`@araharah/web`) ### Web workspace (`@araharah/web`)
```bash ```bash
@@ -158,6 +195,42 @@ Harness URL parameters, documented in `web/index.html`:
| `?sessiondelay=` | milliseconds | Delay the session handshake | | `?sessiondelay=` | milliseconds | Delay the session handshake |
| `?fixdelay=` | milliseconds | Delay the location fix | | `?fixdelay=` | milliseconds | Delay the location fix |
### Search workspace (`@araharah/search`)
```bash
npm run search:typecheck # tsc --noEmit
npm run search:test # node test/runner.mjs — unit suites + corpus assertions
npm run search:corpus # the golden-corpus bake-off (search/scripts/gazetteer-corpus.mjs)
```
`search:corpus` measures the pinned golden corpus against the SAME shared search
module the bot and the Mini App import — exit 0 only when top-3 hit rate clears
the corpus threshold AND every negative is refused (zero leaks). `corpus.test.mjs`
asserts the same measurements in-process, so a corpus regression fails the suite
too. Two further maintainer scripts regenerate the dataset when needed:
`search/scripts/overpass-topup.mjs` (fetch more POIs) and
`search/scripts/json-to-sqlite.mjs` — the committed dataset has an ODbL notice
inside its envelope.
### Routing workspace (`@araharah/routing`)
```bash
npm run routing:typecheck # tsc --noEmit
npm run routing:test # node test/runner.mjs — codec, snap, A*, seams
npm run routing:corpus # the offline tolerance gate (routing/scripts/route-corpus.mjs)
npm run routing:fixtures # regenerate fixtures from the OSRM demo server (maintainer tool)
npm run glyphs -w routing # render the turn-card PNGs into web/public/glyphs/
```
`routing:corpus` is fully offline (zero network in the measurement path): it
measures the committed fixture dataset against the same module the webview
imports. `routing:fixtures` is the only step that touches the OSRM demo server —
the committed fixtures were generated there, and the committed dataset is what
ships. The graph binary track lives under `routing/data/` (see *Fork
provisioning* above and [ROAD-GRAPH.md](./ROAD-GRAPH.md)); `build-road-graph.mjs`
and `upload-road-graph.mjs` are maintainer tools, and
`scripts/measure-route-cpu.mjs` feeds the budget audit's route-CPU gate.
### Theme workspace (`@araharah/theme`) ### Theme workspace (`@araharah/theme`)
```bash ```bash
@@ -187,20 +260,21 @@ npm run bot:commands # node scripts/register-commands.mjs
`bot/scripts/register-commands.mjs` reads `BOT_TOKEN` (from `process.env`, else `bot/scripts/register-commands.mjs` reads `BOT_TOKEN` (from `process.env`, else
from `bot/.dev.vars`), derives each description from the `helpMenu` lines in 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 `bot/src/i18n/strings.ts`, and makes six `setMyCommands` calls (default en/ms, all_private_chats en/ms, and the two stale group-scope clears) — one for
the default scope (English) and one for `language_code: "ms"` (Malay). Because the default scope (English) and one for `language_code: "ms"` (Malay). Because
the descriptions are *derived* from the same strings `/help` replies with, the the descriptions are *derived* from the same strings `/help` replies with, the
menu and the reply cannot drift. menu and the reply cannot drift.
If you add a command, you must add it to the `["help", "privacy"]` array in If you add a command, you must add it to the `menuFor()` command array **and** to
`menuFor()` **and** to the corresponding `helpMenu` lines — the script throws the corresponding `helpMenu` lines — the script throws
`helpMenu for "<locale>" is missing its /<command> line (D-03 copy drift)` if the `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 two disagree. The gate `CS5 D-03: register-commands derives both scopes from
helpMenu` in `bot/test/commands.test.mjs` enforces this. helpMenu` in `bot/test/commands.test.mjs` enforces this.
Only re-run this when the command *list or its descriptions* change. Repointing 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 the webhook is a separate script (`set-webhook.mjs`), run automatically by
`npm run deploy`. `npm run deploy`. `scripts/set-menu-button.mjs` sets the BotFather menu-button
deep link (separate server state, rerun after origin changes).
--- ---
@@ -213,6 +287,8 @@ the webhook is a separate script (`set-webhook.mjs`), run automatically by
| `npm run build` | Build `theme`, then `web`. Produces the deployable artifact. | | `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 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 deploy` | `build`, then `bot:deploy` (`wrangler deploy` + `set-webhook.mjs`). |
| `npm run privacy-audit` | The consolidated privacy audit — five static gates + `--live` probes (`node scripts/privacy-audit.mjs`). |
| `npm run budget-audit` | The consolidated free-tier budget audit — six measurement gates + the comparison table (`node scripts/budget-audit.mjs`). |
| `npm run bot:test` | `npm test -w bot` — the bot's hand-rolled suite. | | `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:typecheck` | `tsc --noEmit` in `bot`. |
| `npm run bot:dev` | `npm run dev -w bot` — `wrangler dev --port 8787 --local`. | | `npm run bot:dev` | `npm run dev -w bot` — `wrangler dev --port 8787 --local`. |
@@ -224,6 +300,13 @@ the webhook is a separate script (`set-webhook.mjs`), run automatically by
| `npm run web:build` | Alias of `build` (theme then web). | | `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:build:harness` | Harness-mode build into `web/dist-harness/`. |
| `npm run web:capture` | Playwright screenshots of the Mini App states. | | `npm run web:capture` | Playwright screenshots of the Mini App states. |
| `npm run search:test` | `npm test -w search` — the gazetteer suite (incl. corpus assertions). |
| `npm run search:typecheck` | `tsc --noEmit` in `search`. |
| `npm run search:corpus` | The golden-corpus search bake-off. |
| `npm run routing:test` | `npm test -w routing` — the routing suite (codec, snap, A\*, seams). |
| `npm run routing:typecheck` | `tsc --noEmit` in `routing`. |
| `npm run routing:corpus` | The offline route-tolerance gate. |
| `npm run routing:fixtures` | Regenerate committed fixtures from the OSRM demo server (maintainer tool). |
| `npm run sheet` | Passthrough to the theme workspace. | | `npm run sheet` | Passthrough to the theme workspace. |
| `npm run gallery` | 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 audit` | Passthrough to the theme workspace — off-token CSS audit. |
@@ -233,14 +316,18 @@ the webhook is a separate script (`set-webhook.mjs`), run automatically by
| Command | Where | Description | | Command | Where | Description |
| --- | --- | --- | | --- | --- | --- |
| `npm test` | `bot`, `web` | `node test/runner.mjs` — zero-dependency, auto-discovers `*.test.mjs`. | | `npm test` | `bot`, `web`, `search`, `routing` | `node test/runner.mjs` — zero-dependency, auto-discovers `*.test.mjs`. |
| `npm run typecheck` | `bot`, `web` | `tsc --noEmit`, `strict` + `noUncheckedIndexedAccess`. | | `npm run typecheck` | `bot`, `web`, `search`, `routing` | `tsc --noEmit`, `strict` + `noUncheckedIndexedAccess`. |
| `npm run dev` | `bot` | `wrangler dev --port 8787 --local`. | | `npm run dev` | `bot` | `wrangler dev --port 8787 --local`. |
| `npm run deploy` | `bot` | `wrangler deploy && node scripts/set-webhook.mjs`. | | `npm run deploy` | `bot` | `wrangler deploy && node scripts/set-webhook.mjs`. |
| `npm run commands` | `bot` | `node scripts/register-commands.mjs`. | | `npm run commands` | `bot` | `node scripts/register-commands.mjs`. |
| `npm run build` | `web` | `vite build` → `web/dist`. | | `npm run build` | `web` | `vite build` → `web/dist`. |
| `npm run build:harness` | `web` | `vite build --mode harness` → `web/dist-harness`. | | `npm run build:harness` | `web` | `vite build --mode harness` → `web/dist-harness`. |
| `npm run capture` | `web` | `node scripts/capture.mjs`. | | `npm run capture` | `web` | `node scripts/capture.mjs`. |
| `npm run corpus` | `search` | `node scripts/gazetteer-corpus.mjs`. |
| `npm run corpus` | `routing` | `node scripts/route-corpus.mjs`. |
| `npm run fixtures` | `routing` | `node scripts/fetch-route-fixtures.mjs` (network — OSRM demo; maintainer tool). |
| `npm run glyphs` | `routing` | `node scripts/build-glyphs.mjs` — render `web/public/glyphs/*.png`. |
| `npm run build` | `theme` | `tailwindcss -i ./src/theme.css -o ./dist/rams.css --minify`. | | `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 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` | `theme` | `node basemap/build.mjs` — derive `basemap/liberty-rams.json` from tokens. |
@@ -249,21 +336,41 @@ the webhook is a separate script (`set-webhook.mjs`), run automatically by
| `npm run capture` | `theme` | `node ./scripts/screenshot.mjs`. | | `npm run capture` | `theme` | `node ./scripts/screenshot.mjs`. |
| `npm run test` | `theme` | `node ./scripts/audit.test.mjs`. | | `npm run test` | `theme` | `node ./scripts/audit.test.mjs`. |
Maintainer scripts **without** an npm alias (run with `node <path>`): the
road-graph track `routing/scripts/build-road-graph.mjs` and
`routing/scripts/upload-road-graph.mjs`, the dataset tools
`search/scripts/overpass-topup.mjs` and `search/scripts/json-to-sqlite.mjs`, and
the CPU ladder `routing/scripts/measure-route-cpu.mjs` (also invoked by
`budget-audit`'s route-CPU gate).
### Before you open a change ### Before you open a change
Run all four. They are fast and they are the project's actual quality gates: Run the four typechecks and the four test suites. They are fast and they are the
project's actual quality gates:
```bash ```bash
npm run bot:typecheck npm run bot:typecheck
npm run web:typecheck npm run web:typecheck
npm run search:typecheck
npm run routing:typecheck
npm run bot:test npm run bot:test
npm run web:test npm run web:test
npm run search:test
npm run routing:test
npm run audit # only if you touched theme/ or any rendered CSS 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`). `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 Note it has **no root passthrough script** — you must use the `-w theme` form to
run it, unlike `bot` and `web`. run it, unlike the other four workspaces.
As a release gate, run the two consolidated audits (they print evidence lines per
gate; exit 2 means NOT RUN and is never a pass):
```bash
npm run privacy-audit # add --live when credentials + a build exist
npm run budget-audit
```
--- ---
@@ -271,55 +378,64 @@ run it, unlike `bot` and `web`.
**There is no linter and no formatter configured in this repository.** No **There is no linter and no formatter configured in this repository.** No
ESLint, no Prettier, no Biome, no `.editorconfig`, no `lint` or `format` script 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 in any of the five `package.json` files, and no commit hooks (no Husky, no
`.git/hooks` wiring). If you add one, it becomes the project's standard `.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 retroactively — but today style is a review-time convention, not an automated
check. What *is* mechanically enforced is TypeScript strictness and the grep check. What *is* mechanically enforced is TypeScript strictness and the gate
gates described below. suites described below.
### TypeScript ### TypeScript
Both `bot/tsconfig.json` and `web/tsconfig.json` are strict beyond the default. All four TS workspaces (`bot`, `web`, `search`, `routing`) are strict beyond the
The settings that actually change how you write code: default. The settings that actually change how you write code:
| Setting | `bot` | `web` | Why it matters | | Setting | `bot` | `web` | `search` / `routing` | Why it matters |
| --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `strict` | yes | yes | No implicit `any`, strict null checks. | | `strict` | yes | 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. | | `noUncheckedIndexedAccess` | yes | 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`. | | `verbatimModuleSyntax` | yes | yes | yes | Type-only imports must use `import type`. |
| `allowImportingTsExtensions` | yes | yes | Import with explicit `.ts` extensions is allowed and is the house style. | | `allowImportingTsExtensions` | yes | 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. | | `noEmit` | yes | yes | yes | `typecheck` is `tsc --noEmit`; bundling is wrangler's or Vite's job. |
| `target` / `lib` | ES2023 / ES2023 | ES2023 / ES2023 + DOM | No downleveling. | | `target` / `lib` | ES2023 / ES2023 | ES2023 / ES2023 + DOM | ES2023 / ES2023 | No downleveling. The data planes are DOM-free. |
| `types` | `@cloudflare/workers-types` | `vite/client` | Determines which globals exist. `bot` has no DOM; `web` does. | | `types` | `@cloudflare/workers-types` | `vite/client` | none | Determines which globals exist. `bot` has no DOM; `web` does; the data planes are V8-native. |
| `resolveJsonModule` | no | yes | `web` imports the basemap JSON style. | | `resolveJsonModule` | no | yes | yes | `web` imports the basemap JSON style; the data planes load their committed datasets via static JSON imports. |
`moduleResolution` is `"bundler"` in both — deliberately not `node16`, because `moduleResolution` is `"bundler"` in all four — deliberately not `node16`, because
both runtimes are bundler-hosted (wrangler / Vite), not raw Node. the runtimes are bundler-hosted (wrangler / Vite) or Node type-stripping (the
corpus runners), not raw Node + CommonJS.
### House conventions observable in the source ### House conventions observable in the source
- **Explicit `.ts` extensions on relative imports.** e.g. `import { strings } from - **Explicit `.ts` extensions on relative imports.** e.g. `import { strings } from
"../src/i18n/strings.ts"`, `from "./harness.mjs"`. "../src/i18n/strings.ts"`, `from "./harness.mjs"`. Data-plane consumers import the
subpath with extension too (`@araharah/search/src/index.ts`) — a bare package
specifier resolves to nothing (no `exports`/`main` field).
- **`.mjs` for scripts and tests, `.ts` for source.** Everything in - **`.mjs` for scripts and tests, `.ts` for source.** Everything in
`bot/scripts/`, `bot/test/`, `web/test/`, `web/scripts/`, and `theme/scripts/` `bot/scripts/`, `bot/test/`, `web/test/`, `web/scripts/`, `search/scripts/`,
is `.mjs` and runs under plain Node. `search/test/`, `routing/scripts/`, `routing/test/`, `theme/scripts/`, and
root `scripts/` is `.mjs` and runs under plain Node.
- **`node:` prefixed builtins** — `import { readFileSync } from "node:fs"`, never - **`node:` prefixed builtins** — `import { readFileSync } from "node:fs"`, never
bare `"fs"`. bare `"fs"`.
- **Heavy header comments.** Every script and several source modules open with a - **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 block comment stating what it is, which decision IDs it satisfies, and its
known constraints. Match that when adding a file. known constraints. Match that when adding a file.
- **Named constants in SCREAMING_SNAKE for tunables** — `SESSION_TTL_SECONDS = 900`, - **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). `PIN_TTL_SECONDS = 300`, `GUIDANCE_SESSION_TTL_SECONDS = 7200`,
`CARD_CADENCE_S = 240`. The privacy gate greps for this exact shape (see below).
- **Zero inline user-facing copy.** All strings live in `bot/src/i18n/strings.ts` - **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 and `web/src/i18n/strings.ts`, keyed by locale, with `en` and `ms` carrying
identical key-sets. Gate: `COPY5 no shell module inlines copy`. identical key-sets. Gate: `COPY5 no shell module inlines copy`.
- **Structural interfaces for injection.** `KvLike`, `TelegramWebAppLike`, - **Structural interfaces for injection.** `KvLike`, `TelegramWebAppLike`,
`acquireFix(deps, …)` — dependencies are passed in so tests can supply fakes `acquireFix(deps, …)`, `createGuidanceWatch(geo, …)`, `HttpRoutingSourceOptions`
without touching Workers types or `window`. — dependencies are passed in so tests can supply fakes without touching
Workers types or `window`.
- **No runtime deps in the data planes.** `@araharah/search` and
`@araharah/routing` are V8-native and dependency-free; `routing/scripts/` may
use dev tools (`sharp` for glyph rendering) but the library code never does.
--- ---
## Project conventions (the grep gates) ## Project conventions (the gates)
These are the rules the codebase enforces on itself. Each is a real test that 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 reads source or build artifacts — treat them as non-negotiable, because a change
@@ -327,35 +443,85 @@ that breaks one is a regression even if the behavior looks fine.
| Gate | Suite | Rule | | 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. | | `PG1` | `bot/test/privacy.test.mjs` | Every `.put(` line in `kv/sessions.ts`, `kv/location-pin.ts`, `kv/guidance-sessions.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. The same law is asserted repo-wide by the `kv-ttl` gate in the consolidated privacy audit. |
| `PG2` | `bot/test/privacy.test.mjs` | The KV modules export nothing persistence-shaped: no `saveProfile`, `storeProfile`, `saveLocation`, `storeLocation`, `saveHistory`, `storeHistory`, `putHistory`, `setLocation`. | | `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`. | | `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`. The `storage-api` gate generalizes this repo-wide. |
| `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. | | `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. | | `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 — except the two documented exceptions (the location reply keyboard and the `/cari` venue card). |
| `CS6` | `bot/test/commands.test.mjs` | The command stack registers exactly `/start`, `/mula`, `/help`, `/privacy`, `/privasi`. | | `CS6` | `bot/test/commands.test.mjs` | The command stack registers exactly `/start`, `/mula`, `/help`, `/privacy`, `/privasi`, `/cari`, `/search`. |
| `COPY1` | `web/test/copy-gate.test.mjs` | `en` and `ms` carry identical key-sets (locale parity). | | `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. | | `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. | | `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. |
| `R12` | `web/test/guidance-replay.test.mjs` | The guidance state machine is pure and replay-verifiable: every behavior is decided in `guidance.ts`, and its structural case gates that the module contains no console, no document, no maplibre, no localStorage. |
| route-privacy | `bot/test/route-privacy.test.mjs` | The `/api/route` handler persists nothing and logs nothing beyond categories — the origin coordinate never touches storage or a log line. |
| audit exit codes | `theme/scripts/audit.mjs` | `0` clean, `1` off-token violations, `2` token-sheet drift. No silent passes. | | audit exit codes | `theme/scripts/audit.mjs` | `0` clean, `1` off-token violations, `2` token-sheet drift. No silent passes. |
### The consolidated audits (release gates)
Two root tools carry the same laws beyond the suites — both are maintainer-time
repo tools (nothing in `bot/src`, `web/src`, `routing/src`, or `search/src`
imports them), both follow the same contract: one evidence line per gate, exit
`0` clean / `1` violations / `2` NOT RUN (environment drift — a skipped probe is
NEVER mistaken for a pass), category-only output (never a coordinate, id, token,
or payload). Both honor a `PRIVACY_AUDIT_ROOT` override and are cwd-independent,
and both double as the fork onboarding check.
**`npm run privacy-audit`** (`scripts/privacy-audit.mjs`, gates in
`scripts/lib/source-gates.mjs` + `scripts/lib/bundle-gates.mjs`):
| Gate | Rule |
| --- | --- |
| `storage-api` | The memory-only law, repo-wide. |
| `kv-ttl` | The PG1 same-line TTL law, repo-wide. |
| `log-vocabulary` | The D-14 category-only log law. |
| `runtime-hosts` | The POSITIVE fetch-host allowlist — no fetch outside it. |
| `bundle-telemetry` | The production `web/dist` scan — no analytics/beacon/telemetry. |
| `kv-expiry` / `asset-headers` (`--live`) | Live KV expiry metadata + cache headers, read verbatim. |
**`npm run budget-audit`** (`scripts/budget-audit.mjs`, gates in
`scripts/lib/budget-gates.mjs`):
| Gate | Rule |
| --- | --- |
| `route-cpu` | Re-runs the A\* CPU ladder (child process) — inside its budget or a documented, decided escalation. |
| `bundle-budget` | Route-payload gzip + whole-app bundle gzip ceilings. |
| `kv-writes` | Write classes + the cron's ZERO-writes law. |
| `subrequests` | The per-request-class subrequest inventory. |
| `golden-corpus` | Re-runs `routing:corpus` + `search:corpus`. |
| `cache-headers` (`--live`) | Observed cache headers, verbatim. |
The human record for both lives in `docs/BUDGETS.md` and
`docs/PRIVACY-AUDIT.md`.
The same privacy promise is asserted from both sides: the **bot** must never 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. 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. If you add a persistence mechanism, these suites fail — which is the point.
### The two design invariants worth internalizing ### The three invariants worth internalizing
**Access control.** The `chat_id` for `/api/location` and `/api/location-fallback` **Access control.** The `chat_id` for `/api/location`, `/api/location-fallback`,
is *always* resolved from the session claim, never from client input; locale comes and `/api/guidance` is *always* resolved from the session claim, never from
from `claim.language_code`; the fallback request body is never read. If you add a client input; locale comes from `claim.language_code`; the fallback request body
route, derive identity the same way. is never read and a guidance body's `chat_id` is structurally dropped. `/api/route`
uses the claim only as an identity gate and persists nothing about the caller.
If you add a route, derive identity the same way.
**Honest degradation.** Every failure is a labeled state, never a silent one and **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 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 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 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; forever; every source timeout resolves `null` so the chain always advances;
`bootMap()` returning `null` renders the shell with `data-map="absent"`. Copy `bootMap()` returning `null` renders the shell with `data-map="absent"`; every
along this pattern: if you cannot do the thing, say so in a state, don't fake it. `/api/route` miss class answers `200 null` (the honest router-pending card); a
null road graph degrades a cron card to the street-less bare verb but still
sends. Copy along this pattern: if you cannot do the thing, say so in a state,
don't fake it.
**The backend ladders are import-site code.** Search and routing backends are
selected by constructing them at the import site — no env read, no config, no
registry. If you add a backend rung (e.g. engine B), it slots beneath the
existing seam and the ladder documentation updates in
`ARCHITECTURE.md`/`docs/SEARCH-QUALITY.md` — never a runtime switch.
### Design-token workflow ### Design-token workflow
@@ -380,11 +546,17 @@ Rules:
consider whether `theme/basemap/build.mjs` should derive it. consider whether `theme/basemap/build.mjs` should derive it.
5. **`theme/src/theme.css`** is the entry stylesheet: it `@import`s tailwindcss, 5. **`theme/src/theme.css`** is the entry stylesheet: it `@import`s tailwindcss,
`tokens.css`, and `components.css`, declares seven self-hosted `@font-face` `tokens.css`, and `components.css`, declares seven self-hosted `@font-face`
rules from `theme/fonts/` (no Google Fonts CDN), and scopes class generation rules from `theme/fonts/` (no Google Fonts CDN), scopes class generation
with explicit `@source` lines (the four mockups are the allowed utility with explicit `@source` lines (the four mockups are the allowed utility
sources; `@source not "../scripts"` and `@source not "../basemap"` exclude sources; `@source not "../scripts"` and `@source not "../basemap"` exclude
prose). prose), and defines the `--shell-bg` / `--shell-on-surface` theme slots.
6. **`theme/basemap/build.mjs`** forks OpenFreeMap `liberty` and derives every 6. **The theme-slot machine.** Light/dark is a slot switch, not a color import:
`web/src/telegram/theme.ts` reads Telegram's signal (or the `header_bg_color`
luminance fallback) and its `applyTheme`'s ONLY statement is the
`data-tg-theme` attribute set — CSS custom properties re-slot between the two
Rams token pairs and the MapLibre style is never re-set. `web/test/theme.test.mjs`
greps that invariant; don't fight it.
7. **`theme/basemap/build.mjs`** forks OpenFreeMap `liberty` and derives every
cartographic color from `tokens.css` via `desaturate(...)`, with an accent cartographic color from `tokens.css` via `desaturate(...)`, with an accent
guard that fails the build if `#EB8008`, `#ED3F1C`, or `#736B1E` leak into any guard that fails the build if `#EB8008`, `#ED3F1C`, or `#736B1E` leak into any
layer. `building-3d` is stripped (no faux 3D). layer. `building-3d` is stripped (no faux 3D).
@@ -395,12 +567,12 @@ Rules:
### Branches ### Branches
The repo's only branch is `main` (there is a stray `master` but it is not the The repo's only active branch is `main` (there is a stray `master` but it is not
active branch), and there is **no remote branch convention documented** — no the active branch; the remote is `origin/main`), and there is **no remote branch
`CONTRIBUTING.md`, no PR template, no CI config (no `.github/`, no `.gitea/`). convention documented** — no `CONTRIBUTING.md`, no PR template, no CI config (no
In practice, work lands on `main` via atomic commits rather than long-lived `.github/`, no `.gitea/`). In practice, work lands on `main` via atomic commits
feature branches. If you prefer branches, `feat/`, `fix/`, and `docs/` prefixes rather than long-lived feature branches. If you prefer branches, `feat/`,
match the commit vocabulary below. `fix/`, and `docs/` prefixes match the commit vocabulary below.
### Commit messages ### Commit messages
@@ -435,18 +607,19 @@ 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, setup, changes are typically committed straight to `main`. If you do open a PR,
match what the project actually relies on: match what the project actually relies on:
- **Run the gates** and paste the output: both typechecks, both test suites, and - **Run the gates** and paste the output: all four typechecks, all four test
`npm run audit` if any rendered CSS or theme changed. suites, `npm run audit` if any rendered CSS or theme changed, and — for anything
privacy- or budget-relevant — `npm run privacy-audit` / `npm run budget-audit`.
- **Explain the observable before/after**, following the commit-subject habit - **Explain the observable before/after**, following the commit-subject habit
(what was broken, what it does now). (what was broken, what it does now).
- **Call out gate-relevant surface**: if you touched a KV write, `web/src` - **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 persistence, the command surface, the i18n strings, or the guidance envelope,
re-verified (`PG1`, `GEO3`, `CS6`, `COPY1`…). say which gates you re-verified (`PG1`, `GEO3`, `CS6`, `COPY1`, `R12`…).
- **Include screenshots** for any Mini App change — `npm run web:build:harness - **Include screenshots** for any Mini App change — `npm run web:build:harness
&& npm run web:capture` produces the current states. && npm run web:capture` produces the current states.
- **Do not commit build output.** `theme/dist`, `web/dist`, `web/dist-harness`, - **Do not commit build output.** `theme/dist`, `web/dist`, `web/dist-harness`,
and the `screenshots/` directories are gitignored by design; they are `routing/data/*.bin`, and the `screenshots/` directories are gitignored by
regenerated at build time. design; they are regenerated at build time.
--- ---
@@ -458,5 +631,9 @@ deliberate rule, called out in `.gitignore`: the web build consumes
`theme/dist` would go stale. Always rebuild (`npm run build`) rather than `theme/dist` would go stale. Always rebuild (`npm run build`) rather than
expecting a checked-in artifact. expecting a checked-in artifact.
`.planning/` is also gitignored (local-only planning docs), as is `bot/.dev.vars` `routing/data/kl-roadgraph.v1.bin` is likewise a build artifact (`routing/data/.gitignore`
ignores `*.bin` — it is ODbL-derived and per-fork), and `web/dist-harness/`
exists only for capture runs.
`.planning/` is git-TRACKED (the ignore line is commented out; planning artifacts are part of the repo) (local-only planning docs), as is `bot/.dev.vars`
and `bot/.wrangler/`. and `bot/.wrangler/`.
+95 -34
View File
@@ -8,7 +8,8 @@ bot (**arah2bot**), its Cloudflare Worker, and the Mini App webview — in five
For what each piece does, see [ARCHITECTURE.md](ARCHITECTURE.md). For the full list of For what each piece does, see [ARCHITECTURE.md](ARCHITECTURE.md). For the full list of
environment variables, config files, and per-package scripts, see environment variables, config files, and per-package scripts, see
[CONFIGURATION.md](CONFIGURATION.md). For a package overview, see the root [CONFIGURATION.md](CONFIGURATION.md). For a package overview, see the root
[README.md](../README.md). [README.md](../README.md). For what arah² honestly does **not** do yet, see
[KNOWN-LIMITATIONS.md](KNOWN-LIMITATIONS.md).
--- ---
@@ -16,21 +17,23 @@ environment variables, config files, and per-package scripts, see
| Requirement | Why | Notes | | 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. | | **Node.js + npm** | Every build, dev, test, and deploy script in the repo is an npm script; all five workspaces are ESM (`"type": "module"`) | No `engines` field, `.nvmrc`, or `.node-version` exists anywhere in the repo. Use a current Node LTS — the repo's development and all four test runners run on Node v24, and the search/routing corpus tooling relies on Node 24's native TypeScript type-stripping. |
| A **Telegram account** | You chat with arah2bot and receive the Mini App webview | — | | 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` | | 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 | — | | **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 | | **A Cloudflare account** *(deploy and `npm run bot:commands` only)* | `wrangler deploy`, the Workers KV namespace, and the R2 road-graph bucket 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. --> <!-- VERIFY: minimum Node.js version — no `engines` field, `.nvmrc`, or `.node-version` file exists in the repository, so no supported floor is recorded. The repo is developed on Node v24.18.0; confirm the intended minimum with the maintainers. -->
Only required for specific commands, not for a normal first run: 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` - **Python 3** — `npm run gallery` serves the design gallery via `python3 -m http.server 8123`
(`theme/package.json`, script `basemap:preview`). (`theme/package.json`, script `basemap:preview`).
- **A Playwright-managed Chromium** — `npm run capture` and `npm run web:capture` both call - **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 bare `chromium.launch()` (`theme/scripts/screenshot.mjs:103`, `web/scripts/capture.mjs:201`), so
they need `npx playwright install chromium` first. No system Chrome is used. they need `npx playwright install chromium` first. No system Chrome is used.
- **`sharp`** (in `routing/`'s devDependencies) — only the `npm run glyphs -w routing` glyph-builder
needs it; nothing else in a first run imports it. Install is still one `npm install` from the root.
There is no database to install, no Docker step, and no `.env` file to author — local secrets 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 live only in `bot/.dev.vars` (created in step 3). See
@@ -53,9 +56,10 @@ live only in `bot/.dev.vars` (created in step 3). See
cd arah2 cd arah2
``` ```
3. **Install all three workspaces from the root.** npm workspaces hoist dependencies into a 3. **Install all five workspaces from the root.** npm workspaces hoist dependencies into a
single root `node_modules`, so one install covers `theme`, `bot`, and `web` — including single root `node_modules`, so one install covers `theme`, `bot`, `web`, `search`, and
`@araharah/theme`, which `web` consumes by relative path and never needs to be published. `routing` — including `@araharah/theme`, which `web` consumes by relative path and never
needs to be published.
```bash ```bash
npm install npm install
@@ -144,7 +148,7 @@ and serves three things on **one origin, port `8787`**:
Because it is the single origin, you can smoke-test the API without Telegram at all: Because it is the single origin, you can smoke-test the API without Telegram at all:
```bash ```bash
# Unknown paths are 404 — the Worker's fetch handler routes exactly four paths. # Unknown paths are 404 — the Worker's fetch handler routes exactly six paths.
curl -i http://localhost:8787/api/location curl -i http://localhost:8787/api/location
# → 401 (no bearer token) # → 401 (no bearer token)
@@ -153,6 +157,13 @@ curl -i http://localhost:8787/api/location
curl -i -X POST http://localhost:8787/telegram/webhook curl -i -X POST http://localhost:8787/telegram/webhook
``` ```
The turn-card cron can be fired by hand against the local server — wrangler's scheduled-event
endpoint, no tunnel needed:
```bash
curl "http://localhost:8787/cdn-cgi/local/scheduled"
```
### 4. Expose the webhook ### 4. Expose the webhook
`wrangler dev` binds to localhost only, so Telegram cannot reach it. Expose port `8787` with a `wrangler dev` binds to localhost only, so Telegram cannot reach it. Expose port `8787` with a
@@ -195,16 +206,18 @@ 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. --> <!-- 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` Register the persistent command menu (English + Bahasa Melayu) once, so `/help`, `/privacy`,
appear in the Telegram UI: and `/cari` appear in the Telegram UI:
```bash ```bash
npm run bot:commands npm run bot:commands
``` ```
`register-commands.mjs` registers exactly `["help", "privacy"]` in two scopes — the default scope `register-commands.mjs` registers exactly `["help", "privacy", "cari"]` in four private-chat
plus `language_code: "ms"` — and deliberately reserves no stub entries for future commands, so scopes — default (en), default+ms, all_private_chats (en), all_private_chats+ms — and clears the
`/start` is a working handler that does **not** appear in the menu (type it manually). The group scopes, because Telegram resolves a chat's menu from the *narrowest* scope that has any
commands and a stale set shadows the default one. `/start` is a working handler that does **not**
appear in the menu (type it manually); `/search` is `/cari`'s registered twin handler. The
descriptions are derived from the `helpMenu` lines in `bot/src/i18n/strings.ts`, the same text 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. `/help` replies with, so the menu and the reply cannot drift.
@@ -216,11 +229,13 @@ Open Telegram, find **arah2bot**, and send `/start`. The Worker receives the upd
| Command | Alias | Behaviour | | Command | Alias | Behaviour |
| --- | --- | --- | | --- | --- | --- |
| `/start` | `/mula` | One-sentence introduction to arah² and its privacy posture | | `/start` | `/mula` | One-sentence introduction to arah² and its privacy posture |
| `/help` | — | Lists the commands available in this phase | | `/help` | — | Lists the commands available and points at the known-limits record |
| `/privacy` | `/privasi` | States exactly what is stored (~15 min) and what is never stored | | `/privacy` | `/privasi` | States exactly what is stored (~15 min) and what is never stored |
| `/cari` | `/search` | Searches the KL gazetteer (`/cari KLCC`) and replies with one venue card carrying the OPEN IN ARAHARAH Mini App button plus 2–4 alternates |
The Mini App opens from the bot's menu button. The script that repoints it at the deployed origin The Mini App opens from the venue card's OPEN button, or from the bot's menu button. The script
(`type: web_app`) has **no npm alias** — run it directly: that repoints the menu button at the deployed origin (`type: web_app`) has **no npm alias** —
run it directly:
```bash ```bash
node bot/scripts/set-menu-button.mjs node bot/scripts/set-menu-button.mjs
@@ -243,28 +258,48 @@ There is no Worker behind it, so `web/src/main.ts` falls back to the mock Telegr
## Verify the setup ## Verify the setup
Run both suites and both typechecks — the runners are hand-rolled and zero-dependency Run the four test suites and the four typechecks — the runners are hand-rolled and
(`node bot/test/runner.mjs` and `node web/test/runner.mjs`), so no test framework needs installing: zero-dependency (`node <workspace>/test/runner.mjs` each), so no test framework needs installing:
```bash ```bash
npm run bot:test # 6 suites in bot/test/*.test.mjs npm run bot:test # 10 suites in bot/test/*.test.mjs
npm run web:test # 6 suites in web/test/*.test.mjs npm run web:test # 14 suites in web/test/*.test.mjs
npm run search:test # 8 suites in search/test/*.test.mjs
npm run routing:test # 9 suites in routing/test/*.test.mjs
npm run bot:typecheck # tsc --noEmit npm run bot:typecheck # tsc --noEmit
npm run web:typecheck # tsc --noEmit npm run web:typecheck # tsc --noEmit
npm run search:typecheck # tsc --noEmit
npm run routing:typecheck # tsc --noEmit
``` ```
Each runner prints `PASS <name>` / `FAIL <name>` per case and exits `0` only when every case 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. passed. If a suite fails to load, it is recorded as one failing case rather than crashing the run.
(The search and routing counts include their golden-corpus suites, which measure the *committed*
datasets offline — no network.)
The bot suites are `commands`, `initdata`, `location-pin`, `privacy`, `sessions`, and `webhook`; The bot suites are `cari`, `commands`, `guidance-cron`, `guidance-envelope`, `initdata`,
the web suites are `build-artifact`, `copy-gate`, `location-chain`, `no-storage`, `session`, and `location-pin`, `privacy`, `route-privacy`, `sessions`, and `webhook`; the web suites include
`theme`. Two are worth knowing about as you get started: `build-artifact`, `copy-gate`, `destination-preload`, `failure-state-matrix`, `fullscreen`,
`guidance-eta`, `guidance-replay`, `guidance-surface`, `homescreen`, `location-chain`,
`no-storage`, `route-surface`, `session`, and `theme`. Four are worth knowing about as you get
started:
- **`no-storage`** pins that nothing in `web/src` uses `localStorage`, `sessionStorage`, cookies, - **`no-storage`** pins that nothing in `web/src` uses `localStorage`, `sessionStorage`, cookies,
IndexedDB, or the Cache API. IndexedDB, or the Cache API.
- **`privacy`** greps that every `chat_sessions` write carries an explicit `expirationTtl`. - **`privacy`** greps that every `chat_sessions` write carries an explicit `expirationTtl`.
- **`route-privacy`** pins the same category-only, claim-derived access control on `/api/route`.
- **`guidance-*`** (bot and web) pin the envelope lifecycle, the cron card projection, and the
Mini App's replay/ETA surfaces.
Optionally, prove the design tokens carry no off-token values: Optionally, prove the repo-wide privacy and budget invariants — these are the maintainer-time
audits a fork must re-run before its first driver:
```bash
npm run privacy-audit # storage, KV-TTL, category-only logs, host allowlist, bundle telemetry
npm run budget-audit # Worker CPU, bundle sizes, KV write classes, golden corpora
```
And prove the design tokens carry no off-token values:
```bash ```bash
npm run audit npm run audit
@@ -284,10 +319,10 @@ typography, radius, shadow, or blur value not derived from `theme/src/tokens.css
`npm run build` first, or just use `npm run dev` — it already builds. `npm run build` first, or just use `npm run dev` — it already builds.
**`[webhook] rejected: misconfigured` → `500 Server misconfigured`.** **`[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` `WEBHOOK_SECRET` is missing or empty in `bot/.dev.vars`. The fail-closed guard sits at the top of
and it fails closed deliberately: grammY's `compareSecretToken` silently accepts *every* request the `/telegram/webhook` branch in `bot/src/webhook.ts` and it fails closed deliberately: grammY's
when the secret is `undefined`, so an unarmed secret must never reach it. Generate a new random `compareSecretToken` silently accepts *every* request when the secret is `undefined`, so an
string and put it in `WEBHOOK_SECRET`. unarmed secret must never reach it. Generate a new random string and put it in `WEBHOOK_SECRET`.
**`setWebhook ok:true` but arah2bot never responds.** **`setWebhook ok:true` but arah2bot never responds.**
Telegram accepted the URL, so it answered `ok` while delivering updates to a path that `404`s. Telegram accepted the URL, so it answered `ok` while delivering updates to a path that `404`s.
@@ -295,6 +330,14 @@ This happens when `WEBHOOK_URL` is a bare workers.dev origin. `set-webhook.mjs`
`/telegram/webhook` for you — if you registered the webhook by hand or via a different tool, `/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. re-run `node bot/scripts/set-webhook.mjs` and confirm the registered URL includes the path.
**`/cari` is in the code but missing from the Telegram command menu.**
The bot-side bug that caused this was found and fixed on 2026-10-25 (`register-commands.mjs` sent
the scope under the wrong parameter name); re-running `npm run bot:commands` now arms all
private-chat scopes correctly. What can *still* bite: the Telegram client **caches** the menu, so
a device that opened the bot before a fix may keep showing the stale menu until the app restarts.
The workaround is always available — type `/cari` in the chat; the command itself has always
worked.
**`Missing BOT_TOKEN, WEBHOOK_URL, WEBHOOK_SECRET` and exit code `1`.** **`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. `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 Confirm you ran it from a path where `bot/.dev.vars` resolves (`bot/.dev.vars` is read relative to
@@ -306,6 +349,15 @@ Re-run the tunnel, update `WEBHOOK_URL`, re-run `node bot/scripts/set-webhook.mj
`tunnel origin` in `.dev.vars` still matches the one currently printing in Terminal 2 — tunnels `tunnel origin` in `.dev.vars` still matches the one currently printing in Terminal 2 — tunnels
rotate their hostname on every restart. rotate their hostname on every restart.
**`/api/route` answers bare `null` for long trips.**
That is the honest degradation, not a bug: route computation runs inside the Cloudflare Workers
free plan's small per-request CPU allowance. Short KL trips (2–8 km) measure 2–8 ms and resolve;
cross-metro trips (20–30 km) measured p95 38–93 ms against the same graph, beyond the free plan,
so the app shows the router-pending card instead of freezing or drawing a wrong line. The committed
upgrade lever (ALT landmark preprocessing) is a next-milestone item — see
[KNOWN-LIMITATIONS.md](KNOWN-LIMITATIONS.md). Workers Paid was deliberately rejected: it breaks
the project's $0 mandate.
**`chromium.launch()` fails — `npm run capture` or `npm run web:capture` errors on a missing browser.** **`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 Both scripts use bare `chromium.launch()` with no `executablePath` and no `channel`, so they need
Playwright's own browser build: Playwright's own browser build:
@@ -333,10 +385,15 @@ chains theme then web; the web build imports the compiled stylesheet, not the to
## Next steps ## Next steps
- **[ARCHITECTURE.md](ARCHITECTURE.md)** — how the Worker, KV namespace, Mini App, and design - **[ARCHITECTURE.md](ARCHITECTURE.md)** — how the Worker, KV namespace, R2 graph, Mini App, and
system fit together; the Worker routing table; the three-link location acquisition chain. design system fit together; the Worker routing table; the three-link location acquisition chain.
- **[CONFIGURATION.md](CONFIGURATION.md)** — every environment variable, `bot/wrangler.jsonc` - **[CONFIGURATION.md](CONFIGURATION.md)** — every environment variable, `bot/wrangler.jsonc`
and `web/vite.config.ts`, and the full command reference per workspace. and `web/vite.config.ts`, and the full command reference per workspace.
- **[TESTING.md](TESTING.md)** — each suite's contract and how to write a new one.
- **[KNOWN-LIMITATIONS.md](KNOWN-LIMITATIONS.md)** — stated plainly: no traffic awareness, no lane
guidance, the cross-metro CPU degradation, the stale Telegram menu cache, and what is on the
roadmap. Beta-quality caveats included (e.g. battery drain is a *planned* measurement before the
next driver onboarding, not an estimate).
- **[../README.md](../README.md)** — package overview, HTTP surface, design-system usage. - **[../README.md](../README.md)** — package overview, HTTP surface, design-system usage.
- **[../DESIGN.md](../DESIGN.md)** — the Dieter Rams philosophy behind the token vocabulary. - **[../DESIGN.md](../DESIGN.md)** — the Dieter Rams philosophy behind the token vocabulary.
@@ -347,8 +404,10 @@ chains theme then web; the web build imports the compiled stylesheet, not the to
| `npm run dev` | Build, then `wrangler dev --port 8787 --local` | | `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 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 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:test` / `npm run web:test` / `npm run search:test` / `npm run routing:test` | Zero-dependency test runners |
| `npm run bot:typecheck` / `npm run web:typecheck` | `tsc --noEmit` | | `npm run bot:typecheck` / `npm run web:typecheck` / `npm run search:typecheck` / `npm run routing:typecheck` | `tsc --noEmit` |
| `npm run privacy-audit` | Repo-wide privacy audit (add `--` and `--live` for the live KV/cache probes) |
| `npm run budget-audit` | Free-tier budget audit, measured and printed |
| `npm run audit` | Off-token audit of the rendered CSS; non-zero on violations | | `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 bot:commands` | Register the persistent Telegram command menu (EN + MS) |
| `npm run deploy` | Build, then `wrangler deploy` + `set-webhook.mjs` | | `npm run deploy` | Build, then `wrangler deploy` + `set-webhook.mjs` |
@@ -361,6 +420,8 @@ carry. `vite.config.ts` calls this out as the reason the split exists.
`npm run deploy` publishes the Worker to Cloudflare and registers the webhook. It reads no local `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 file for secrets — production values live as Cloudflare Workers secrets. See
[CONFIGURATION.md](CONFIGURATION.md#per-environment-overrides). [CONFIGURATION.md](CONFIGURATION.md#per-environment-overrides). A fork must also provision its own
R2 road-graph bucket (`npx wrangler r2 bucket create <name>` +
`routing/scripts/upload-road-graph.mjs`) — the fork contract is [ROAD-GRAPH.md](ROAD-GRAPH.md).
<!-- 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. --> <!-- 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. -->
+161 -60
View File
@@ -2,9 +2,19 @@
# Testing # 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. 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 runner shape was written first in `theme/`, then copied into `bot/` and `web/`, and again into the newer `search/` and `routing/` workspaces — five workspaces, one idea, 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. Beyond the suites, additional gates stand in for CI (this repo has none — see [CI integration](#ci-integration)): `tsc --noEmit` typechecks, the theme's off-token audit is itself a tested CLI, and two repo-root gate CLIs (`privacy-audit`, `budget-audit`) each carry their own TDD suite.
Case totals (verified against the current tree):
| Workspace | Suites | Cases |
|---|---|---|
| `bot/` | 10 | 126 |
| `web/` | 14 | 165 |
| `search/` | 8 | 57 |
| `routing/` | 9 | 65 |
| `theme/` (single audit suite) | 1 | — |
--- ---
@@ -16,6 +26,7 @@ Beyond the suites, two additional gates stand in for CI (this repo has none —
- [Test patterns](#test-patterns) - [Test patterns](#test-patterns)
- [Coverage requirements](#coverage-requirements) - [Coverage requirements](#coverage-requirements)
- [Visual capture workflow](#visual-capture-workflow) - [Visual capture workflow](#visual-capture-workflow)
- [Repo gate CLIs](#repo-gate-clis-privacy-audit--budget-audit)
- [CI integration](#ci-integration) - [CI integration](#ci-integration)
--- ---
@@ -28,13 +39,15 @@ Beyond the suites, two additional gates stand in for CI (this repo has none —
|---|---|---|---|---| |---|---|---|---|---|
| `bot/` | `bot/test/runner.mjs` | `bot/test/harness.mjs` | `import { test } from "./harness.mjs"` | `npm run bot:test` | | `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` | | `web/` | `web/test/runner.mjs` | `web/test/harness.mjs` | `import { test } from "./harness.mjs"` | `npm run web:test` |
| `search/` | `search/test/runner.mjs` | `search/test/harness.mjs` | `import { test } from "./harness.mjs"` | `npm run search:test` |
| `routing/` | `routing/test/runner.mjs` | `routing/test/harness.mjs` | `import { test } from "./harness.mjs"` | `npm run routing:test` |
| `theme/` | inline in `theme/scripts/audit.test.mjs` | local `check()` helper | `check("name", cond, detail)` | `npm test -w theme` | | `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). There is **no jest, vitest, mocha, playwright-test, or any other runner** in any `package.json`. The test-relevant dev dependencies are `typescript` (`tsc --noEmit`, a compile-time gate) and `playwright` (used by the capture scripts, not by any suite). `routing/` additionally dev-depends on `sharp`, but only for its glyph/road-graph build tooling — never for tests.
### The harness (10 lines, duplicated per workspace) ### The harness (duplicated per workspace, two byte-identical pairs)
`bot/test/harness.mjs` and `web/test/harness.mjs` are byte-identical: `bot/test/harness.mjs` and `web/test/harness.mjs` are byte-identical; `search/test/harness.mjs` and `routing/test/harness.mjs` are a second byte-identical pair with the same five-line body:
```js ```js
const tests = []; const tests = [];
@@ -46,9 +59,9 @@ export const registry = tests;
`test(name, fn)` pushes a case. `fn` may be sync or `async` — the runner `await`s every one. `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) ### The runner (47–48 lines (byte-identical except header comments), behaviour-identical across all four suites)
`bot/test/runner.mjs` and `web/test/runner.mjs` are also byte-identical in behaviour: `bot/test/runner.mjs`, `web/test/runner.mjs`, `search/test/runner.mjs`, and `routing/test/runner.mjs` are behaviour-identical (only the header comments differ):
1. `readdirSync(import.meta.dirname)`, filter `*.test.mjs`, sort. 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. 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.
@@ -59,11 +72,18 @@ Registration order is therefore: **suite files in alphabetical order, cases in f
### TypeScript is loaded directly ### 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". --> All four `test/` directories `await import("../src/....ts")` directly against the shipped source — no build step, no transpile step, no fixtures directory (except the committed fixture files described below). 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". -->
### Committed fixtures
Unlike the rest of the suites, two test directories carry small committed fixtures (both gitignored-pattern-free, plain files):
- `search/test/fixtures/` — `golden-corpus.json` (the golden search corpus the CO cases measure), `overpass-response.json` (a recorded Overpass response the topup cases replay).
- `routing/test/fixtures/` — `mini-graph.bin` (a tiny built graph binary), `golden-pairs.json` (pinned origin/destination pairs), and `build-mini-graph.mjs` (the generator that produced the binary).
### Assertion style ### Assertion style
Every suite in `bot/` and `web/` uses **`node:assert`'s strict mode**, imported as: Every suite in `bot/`, `web/`, `search/`, and `routing/` uses **`node:assert`'s strict mode**, imported as:
```js ```js
import { strict as assert } from "node:assert"; import { strict as assert } from "node:assert";
@@ -83,24 +103,22 @@ In practice: `assert.ok(...)` for existence checks, `assert.equal` / `assert.str
# from the repo root # from the repo root
npm run bot:test # → node test/runner.mjs in bot/ npm run bot:test # → node test/runner.mjs in bot/
npm run web:test # → node test/runner.mjs in web/ npm run web:test # → node test/runner.mjs in web/
npm run search:test # → node test/runner.mjs in search/
npm run routing:test # → node test/runner.mjs in routing/
npm test -w theme # → node ./scripts/audit.test.mjs in theme/ 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. There is no root-level `npm test` aggregate — run all five, or add one to `package.json` when you need it.
### Running one suite ### 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: The runners auto-discover by filename and offer no filter flag. A single suite file invoked directly executes its module side effects but produces no pass/fail output — the runner is what drains the registry. Always go through the runner:
```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 ```bash
cd bot && node test/runner.mjs # all bot suites cd bot && node test/runner.mjs # all bot suites
cd web && node test/runner.mjs # all web suites cd web && node test/runner.mjs # all web suites
cd search && node test/runner.mjs # all search suites
cd routing && node test/runner.mjs # all routing suites
``` ```
`theme` is the exception — its suite is a standalone script and *does* report standalone: `theme` is the exception — its suite is a standalone script and *does* report standalone:
@@ -111,11 +129,13 @@ cd theme && node scripts/audit.test.mjs
### Typecheck gates ### Typecheck gates
The `bot/` and `web/` suites do not compile. Run these alongside: None of the suites compile. Run these alongside:
```bash ```bash
npm run bot:typecheck # tsc --noEmit in bot/ npm run bot:typecheck # tsc --noEmit in bot/
npm run web:typecheck # tsc --noEmit in web/ npm run web:typecheck # tsc --noEmit in web/
npm run search:typecheck # tsc --noEmit in search/
npm run routing:typecheck # tsc --noEmit in routing/
``` ```
### The theme audit as a standalone tool ### The theme audit as a standalone tool
@@ -126,21 +146,31 @@ The audit is both the thing under test and a useful CLI in its own right. Run it
npm run audit # → node ./scripts/audit.mjs ./dist/rams.css 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. 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` for the full usage block and its scope adjudications.
```mermaid ```mermaid
graph TD graph TD
ROOT["npm run bot:test / web:test / audit"] --> BR["bot/test/runner.mjs"] ROOT["bot:test / web:test / search:test / routing:test / theme test"] --> BR["bot/test/runner.mjs"]
ROOT --> WR["web/test/runner.mjs"] ROOT --> WR["web/test/runner.mjs"]
ROOT --> SR["search/test/runner.mjs"]
ROOT --> RR["routing/test/runner.mjs"]
ROOT --> TR["theme/scripts/audit.test.mjs"] ROOT --> TR["theme/scripts/audit.test.mjs"]
BR --> BDISC["readdir *.test.mjs → dynamic import"] BR --> BDISC["readdir *.test.mjs → dynamic import"]
BDISC --> BREG["bot/test/harness.mjs registry"] BDISC --> BREG["bot/test/harness.mjs registry"]
BREG --> BCASES["commands · initdata · location-pin · privacy · sessions · webhook"] BREG --> BCASES["cari · commands · guidance-cron · guidance-envelope · initdata · location-pin · privacy · route-privacy · sessions · webhook"]
WR --> WDISC["readdir *.test.mjs → dynamic import"] WR --> WDISC["readdir *.test.mjs → dynamic import"]
WDISC --> WREG["web/test/harness.mjs registry"] WDISC --> WREG["web/test/harness.mjs registry"]
WREG --> WCASES["build-artifact · copy-gate · location-chain · no-storage · session · theme"] WREG --> WCASES["build-artifact · copy-gate · destination-preload · failure-state-matrix · fullscreen · guidance-eta · guidance-replay · guidance-surface · homescreen · location-chain · no-storage · route-surface · session · theme"]
SR --> SDISC["readdir *.test.mjs → dynamic import"]
SDISC --> SREG["search/test/harness.mjs registry"]
SREG --> SCASES["converter · corpus · distance · gazetteer · loader-env · normalizer · score · topup"]
RR --> RDISC["readdir *.test.mjs → dynamic import"]
RDISC --> RREG["routing/test/harness.mjs registry"]
RREG --> RCASES["astar · contract · corpus · fixture-source · graph · http-source · loader-env · polyline · snap"]
TR --> TFIX["fixtures under /tmp/opencode/audit-test/"] TR --> TFIX["fixtures under /tmp/opencode/audit-test/"]
TFIX --> TSPAWN["spawnSync scripts/audit.mjs"] TFIX --> TSPAWN["spawnSync scripts/audit.mjs"]
@@ -153,8 +183,8 @@ graph TD
classDef theme fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1E1B14 classDef theme fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1E1B14
class ROOT entry class ROOT entry
class BR,WR,TR runner class BR,WR,SR,RR,TR runner
class BDISC,BREG,WDISC,WREG harness class BDISC,BREG,WDISC,WREG,SDISC,SREG,RDISC,RREG harness
class TFIX,TSPAWN,TCHECK,TREAL theme class TFIX,TSPAWN,TCHECK,TREAL theme
``` ```
@@ -167,11 +197,18 @@ graph TD
| Rule | Value | | Rule | Value |
|---|---| |---|---|
| File name | `<subject>.test.mjs` — the runner only discovers this suffix | | File name | `<subject>.test.mjs` — the runner only discovers this suffix |
| Directory | `bot/test/` or `web/test/`, flat, no subdirectories | | Directory | `bot/test/`, `web/test/`, `search/test/`, or `routing/test/` — flat, no subdirectories (fixtures live in a `fixtures/` dir, not alongside suites) |
| Import the harness | `import { test } from "./harness.mjs";` | | 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. | | Case name | Prefix with a stable case code: `"WK1 POST valid initData (under cap) → 200 {token: uuid}…"`, `"CR3 NO CRYPTO IN THE DEEP-LINK PATH…"`, `"RT4 null contract — …"`. 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. Case-code families currently in use (sampled from the suite headers):
- `bot/` — `WK` (webhook), `CS` (commands), `CR` (cari venue card), `GC` (guidance cron, `GC-C` cron-cases), `GE` (guidance envelope), `T` (initData), `LP` (location pin), `PG` (privacy), `RP` (route privacy), `SM`/`SR`/`SG` (sessions)
- `web/` — `BA` (build artifact), `COPY` (copy gate), `DP` (destination preload), `FM` (failure-state matrix), `FS` (fullscreen), `E` (guidance ETA), `R` (guidance replay), `GS` (guidance surface), `HS` (homescreen), `GEO` (location chain / storage), `RS` (route surface), `SESS` (session), `PX02` (theme)
- `search/` — `CV` (converter), `CO` (corpus quality), `D` (damerau), `G` (gazetteer), `L` (loader env), `T` (normalizer), `S` (score), `TP` (topup)
- `routing/` — `A` (astar), `C` (contract), `RT` (route corpus), `F` (fixture source), `G` (graph binary), `H` (http source), `L` (loader env), `P` (polyline)
A new file dropped into any `test/` directory is picked up automatically on the next run — no manifest, no import list, no registration step anywhere.
### The minimum shape ### The minimum shape
@@ -211,7 +248,7 @@ Note the theme suite uses a **bare block** (`{ … }`) rather than a named funct
### Where the source of truth lives ### 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. Every suite header comment lists its cases up front. That header is the documentation — keep it in sync when adding a case. Most cases reference decision ids or requirement tags (`D-02`, `PRIV-02`, `GEO-03`, `CS4`, `COPY5`, `WK3`, `RTNG-02`) that the case is pinning; reuse that convention.
--- ---
@@ -221,7 +258,7 @@ The repo has converged on four recognisable patterns. Follow the closest precede
### 1. Injected fakes over mocks and over the real platform ### 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`: `bot/test/sessions.test.mjs`, `bot/test/webhook.test.mjs`, and `bot/test/location-pin.test.mjs` all hand-roll a ~20-line `KVNamespace` fake rather than depend on `miniflare` or `wrangler`:
```js ```js
function makeKV() { function makeKV() {
@@ -274,16 +311,16 @@ try {
### 3. Source-introspection gates ("grep the shipped source") ### 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: 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 every workspace:
- `bot/test/commands.test.mjs` CS4 — asserts `/keyboard/i` does not appear in any command source. - `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` PG1 — every line containing `.put(` must also contain `expirationTtl` referencing a `SCREAMING_CASE` constant; PG2 — no persistence-shaped export names exist.
- `bot/test/privacy.test.mjs` PG2 — no persistence-shaped export names exist. - `bot/test/guidance-cron.test.mjs` GC6 — real `makeStreetAt` over the mini-graph; GC8 — structural: no `console` in the cron.
- `web/test/copy-gate.test.mjs` COPY5 — no shell module inlines user-facing copy. - `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/no-storage.test.mjs` GEO3 — no banned storage token appears in `web/src` code, plus no service-worker registration exists.
- `web/test/session.test.mjs` SESS4, `web/test/theme.test.mjs` PX02 case 3 — same shape. - `web/test/session.test.mjs` SESS4, `web/test/theme.test.mjs` PX02 case 3, `web/test/guidance-surface.test.mjs` GS, `web/test/route-surface.test.mjs` RS, `web/test/fullscreen.test.mjs` FS, `web/test/homescreen.test.mjs` HS — same shape at larger scale.
**The `web/` gates strip comments first; the three `bot/` gates do not.** `bot/test/commands.test.mjs` CS4 and `bot/test/privacy.test.mjs` PG1/PG2 read raw `readFileSync(file, "utf8")` and match lines directly, with no `replace(` anywhere in `bot/test`. In `web/`, the convention is two replaces, repeated verbatim across files: **Comment-stripping is now the default in both workspaces.** The two-`replace` prelude — strip block comments, then line comments without breaking `https://` URLs — is repeated verbatim in `web/test/` gates and in the newer `bot/test/` gates (`cari.test.mjs`, `route-privacy.test.mjs`, `guidance-cron.test.mjs`). 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. The original three bot gates (`commands.test.mjs` CS4, `privacy.test.mjs` PG1/PG2) still read raw `readFileSync(file, "utf8")` and match lines directly — their targets carry no self-describing prose.
```js ```js
const code = src const code = src
@@ -291,8 +328,6 @@ const code = src
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments (skips URLs) .replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments (skips URLs)
``` ```
The reason the `web/` gates strip 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) ### 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: `web/test/build-artifact.test.mjs` reads `web/dist` — the **production** build only, never `web/dist-harness/` — and proves what actually ships:
@@ -305,6 +340,10 @@ The reason the `web/` gates strip is documented in `no-storage.test.mjs`: `web/s
| BA4 | The built JS carries the inlined Rams style JSON | | BA4 | The built JS carries the inlined Rams style JSON |
| BA5 | `installMockWebApp` / `__ARAHARAH_MOCK__` appear in **no** emitted `.js` **or** `.html` | | BA5 | `installMockWebApp` / `__ARAHARAH_MOCK__` appear in **no** emitted `.js` **or** `.html` |
| BA6 | No emitted HTML carries the runner page's fetch stubs | | BA6 | No emitted HTML carries the runner page's fetch stubs |
| BA7 | The shipped bundle carries EVERY gazetteer id (the bot/web id-set parity gate) |
| BA8 | The shipped bundle carries NO deep-link crypto |
| BA9 | The route payload fits the 200 KiB gz bundle budget — measured and printed |
| BA10 | The shipped bundle carries EVERY committed pairId (BA7's fixture-parity analog) |
These cases require `web/dist` to exist. Each one starts with `assert.ok(existsSync(DIST), claimBuildFirst().message)`, which yields: These cases require `web/dist` to exist. Each one starts with `assert.ok(existsSync(DIST), claimBuildFirst().message)`, which yields:
@@ -326,13 +365,15 @@ function loadValidator() {
} }
``` ```
**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. The `search/` and `routing/` suites use the same lazy-`import` shape (`score.test.mjs`, `astar.test.mjs`, both `corpus.test.mjs` files).
**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.
### Anti-patterns to avoid ### 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. - **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. - **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 (the two committed fixture files under `search/test/fixtures/` and `routing/test/fixtures/` are the deliberate exceptions — they hold pinned public data, never runtime state).
- **Adding a test framework.** The zero-dependency property is load-bearing (decision `D-12`) and repeated across all three runners. - **Adding a test framework.** The zero-dependency property is load-bearing (decision `D-12`) and repeated across all runners.
--- ---
@@ -345,8 +386,9 @@ What substitutes for coverage measurement:
| Mechanism | Where | | 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`. | | **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. | | **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. `routing/test/fixtures/build-mini-graph.mjs` regenerates `mini-graph.bin` so the graph cases test a real decode path. |
| **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. | | **Golden-corpus rate gates** | `search/test/corpus.test.mjs` CO1–CO8 measures top-3 hit rate and refusal margin over the committed corpus; `routing/test/corpus.test.mjs` RT1–RT10 does the same for route fixtures. |
| **Typecheck as a structural gate** | `bot:typecheck` / `web:typecheck` / `search:typecheck` / `routing: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. If you need coverage numbers, you would have to add tooling — which contradicts the current design. Prefer adding a case.
@@ -375,7 +417,7 @@ Serves `theme/` as docroot, then captures:
The mockups are read-only reference (decision `D-06`) and are never edited. The mockups are read-only reference (decision `D-06`) and are never edited.
### `web/scripts/capture.mjs` ### `web/scripts/capture.mjs` — the 22-leg harness
Rebuilds all three bundles first, then serves **`web/dist-harness/`**, not `web/dist`: Rebuilds all three bundles first, then serves **`web/dist-harness/`**, not `web/dist`:
@@ -385,24 +427,72 @@ 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. 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 | Every leg races **console state tokens** (`RAMS-BASEMAP-READY`, `ARAHARAH-LIVE`, `ARAHARAH-DEST`, `ARAHARAH-ROUTE`, `ARAHARAH-GUIDE`) or waits on a DOM state (`denied` lamp, expected body text). A timeout is always a hard failure with a diagnostic naming which signals fired — never a silent skip, never a blank-but-passing shot. Wait modes: `immediate`, `settle400`, `live`, `denied`, `dest`, `route`, `guide`.
|---|---|---|
| `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/`. The 22 legs (all under `web/screenshots/`):
Two capture legs double as runtime verification: | Output | What it pins |
|---|---|
| `shell-boot-1.png` | boot stage 1 — validating-session card over live map |
| `shell-boot-2.png` | boot stage 2 — acquiring-location card |
| `shell-live-light.png` | LIVE state, Telegram light theme |
| `shell-live-dark.png` | LIVE state, Telegram dark theme (PX-02) |
| `shell-denied.png` | denied card + ASK VIA CHAT (GEO-02 entry point) |
| `shell-chat-pin.png` | LIVE via the chat-pin relay (`?pin=1`, stubbed `/api/location`) |
| `shell-dest-preload.png` | Mini App booted WITH a preloaded destination (`?dest=<id>`) |
| `shell-route-fixture.png` | route drawn from the demo pair's pinned origin (`?fixpair=`) |
| `shell-route-alt.png` | alternative selected via chip tap (ROUTE {n}/{N} re-render) |
| `shell-route-pending.png` | the honest ROUTE // ROUTER pending card (D-03) |
| `shell-route-dark.png` | route drawn, Telegram dark theme (PX-02) |
| `gallery-compare.png` | DSGN-02 side-by-side vs `navigate_map_overview/screen.png` |
| `route-gallery-compare.png` | DSGN-02 route fidelity artifact |
| `shell-guidance-active.png` | the LIVE cockpit over a replayed drive (`?drive=`) |
| `shell-guidance-list.png` | the banner tap's expanded list + NEXT row lamp |
| `shell-guidance-rerouting.png` | captured INSIDE the REROUTING window, no red |
| `shell-guidance-confirm.png` | the abandon confirm card, bottom register |
| `shell-guidance-tripend.png` | the trip-ended card, chrome cleared |
| `shell-guidance-dark.png` | the cockpit, Telegram dark theme (PX-02) |
| `guidance-gallery-compare.png` | DSGN-02 guidance fidelity artifact vs `active_route_guidance/screen.png` |
| `shell-guidance-landscape.png` | the cockpit in landscape (driving posture) |
| `shell-route-pending-landscape.png` | the designed pending card in landscape |
- **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. Success prints `CAPTURE OK — 22/22 non-blank captures under web/screenshots/`.
Three capture legs double as runtime verification:
- **The theme honesty check** — the `live`/`route`/`guide` themed legs read `document.documentElement.dataset.tgTheme` after the console 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. - **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.
- **The D-15 coordinate-hygiene scan** — the guidance legs scan the WHOLE leg's console output (an unbounded capture buffer) for leaked coordinates; a coordinate in a console line fails the run.
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. ---
## Repo gate CLIs (privacy-audit / budget-audit)
Two repo-root CLIs are maintainer-time audits, each with its own TDD suite:
| CLI | Suite | What it proves |
|---|---|---|
| `npm run privacy-audit` | `scripts/test/privacy-audit.test.mjs` | storage-api, kv-ttl, log-vocabulary, runtime-hosts, bundle-telemetry gates — plus two `--live` probes |
| `npm run budget-audit` | `scripts/test/budget-audit.test.mjs` | route-cpu, bundle-budget, kv-writes, subrequests, golden-corpus gates — plus one `--live` probe |
Both follow one exit-code contract — **no silent passes**:
| Exit | Meaning |
|---|---|
| `0` | clean — every required gate PASSed |
| `1` | violations — each printed as a per-gate evidence line |
| `2` | NOT RUN — environment/credential drift (a `--live` probe could not run because credentials are absent, or the build/graph is absent). **A NOT RUN is never a pass.** |
The `--live` protocol matters: without `--live`, the live probes print as `NOT RUN` with a `(pass --live)` hint (flag-skip, excluded from exit-2 — the operator's explicit choice). With `--live` but missing credentials, the run exits `2` with `PRIVACY AUDIT NOT RUN` — a skipped probe is never mistaken for a pass. Credentials come from `process.env` first, then `bot/.dev.vars` — see [CONFIGURATION](CONFIGURATION.md).
The gate suites themselves run directly (no npm script — they are maintainer tools):
```bash
node scripts/test/privacy-audit.test.mjs # SA/BG/CP/AU vectors → …TEST CLEAN
node scripts/test/budget-audit.test.mjs # CPU/BA/KV/SQ/CH/GC vectors → …vectors ok
```
Both are self-contained runners: they collect failures, print `FAIL` per vector, tally at the end, and `process.exit(1)` if any failed. They use the `PRIVACY_AUDIT_ROOT` env var as a repo-root override for fork checkouts.
--- ---
@@ -417,14 +507,20 @@ The full green check a contributor must run by hand before pushing:
```bash ```bash
npm run bot:test npm run bot:test
npm run web:test npm run web:test
npm run search:test
npm run routing:test
npm test -w theme npm test -w theme
npm run bot:typecheck npm run bot:typecheck
npm run web:typecheck npm run web:typecheck
npm run search:typecheck
npm run routing:typecheck
npm run audit # token-drift audit on the real dist/rams.css npm run audit # token-drift audit on the real dist/rams.css
npm run privacy-audit # the five static privacy gates
npm run budget-audit # the five measured budget gates
npm run build # required before web:test's BA cases can pass 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. 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–BA10 all fail with the build-first message. `budget-audit`'s route-cpu gate also needs the committed graph binary (`routing/data/kl-roadgraph.v1.bin`) and the corpus gates re-run `search:corpus` + `routing:corpus`, which read the committed data files.
--- ---
@@ -434,13 +530,18 @@ Order matters in one place: `web/test/build-artifact.test.mjs` reads `web/dist`,
|---|---| |---|---|
| Run every bot test | `npm run bot:test` | | Run every bot test | `npm run bot:test` |
| Run every Mini App test | `npm run web:test` | | Run every Mini App test | `npm run web:test` |
| Run every search test | `npm run search:test` |
| Run every routing test | `npm run routing:test` |
| Run the theme audit tests | `npm test -w theme` | | Run the theme audit tests | `npm test -w theme` |
| Typecheck the Worker | `npm run bot:typecheck` | | Typecheck the Worker | `npm run bot:typecheck` |
| Typecheck the Mini App | `npm run web:typecheck` | | Typecheck the Mini App | `npm run web:typecheck` |
| Typecheck search/routing | `npm run search:typecheck` / `npm run routing:typecheck` |
| Audit the real compiled CSS | `npm run audit` | | Audit the real compiled CSS | `npm run audit` |
| Add a bot test | new `bot/test/<subject>.test.mjs` — pick up automatically | | Run the privacy gate CLI | `npm run privacy-audit` (add `-- --live` for the live probes) |
| Add a Mini App test | new `web/test/<subject>.test.mjs` — pick up automatically | | Run the budget gate CLI | `npm run budget-audit` (add `-- --live` for the live cache-header probe) |
| Re-run the golden corpora | `npm run search:corpus` / `npm run routing:corpus` |
| Add a test | new `<workspace>/test/<subject>.test.mjs` — picked up automatically |
| Regenerate design screenshots | `npm run capture` | | Regenerate design screenshots | `npm run capture` |
| Regenerate Mini App screenshots | `npm run web: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`. **Related docs:** [CONFIGURATION](CONFIGURATION.md) for the secrets the `bot:` tests substitute with `12345:TESTTOKEN` · [PRIVACY-AUDIT](PRIVACY-AUDIT.md) for what each privacy gate inspects · [BUDGETS](BUDGETS.md) for the measured budget record the budget CLI prints · [SEARCH-QUALITY](SEARCH-QUALITY.md) for the corpus metrics the CO cases pin · [ARCHITECTURE](ARCHITECTURE.md) for why the Worker/Mini App split is what it is.