docs: full docs-update pass — 9 GSD docs regenerated + verified (434→454 claims; verifier corrections applied)

This commit is contained in:
araharah-executor
2026-10-25 12:51:01 +08:00
parent 809864f5b6
commit 44851c70fe
9 changed files with 2014 additions and 324 deletions
+250 -73
View File
@@ -9,6 +9,8 @@ not break.
For *using* the app, see the [README](../README.md). For *how the system is put
together*, see [ARCHITECTURE.md](./ARCHITECTURE.md). For *what to configure*
(secrets, `wrangler.jsonc`, env vars), see [CONFIGURATION.md](./CONFIGURATION.md).
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 |
| --- | --- |
| 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`. |
| 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/`. |
| 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
@@ -33,9 +35,9 @@ cd arah2
npm install
```
`npm install` at the root installs all three workspaces (`theme`, `bot`, `web`) at
once — npm hoists shared deps into the root `node_modules/`. There is no
per-workspace `npm install` step.
`npm install` at the root installs all five workspaces (`theme`, `bot`, `web`,
`search`, `routing`) at once — npm hoists shared deps into the root
`node_modules/`. There is no per-workspace `npm install` step.
### Configure local secrets
@@ -44,11 +46,34 @@ There is no committed `.env`. The Worker reads secrets from `bot/.dev.vars`
```bash
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
[CONFIGURATION.md](./CONFIGURATION.md#environment-variables).
`MINI_APP_URL` is the public Mini App origin (not a secret) the `/cari` venue
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
@@ -69,10 +94,12 @@ graph TD
classDef check fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A
classDef gate fill:#ED3F1C,stroke:#1A1A1A,stroke-width:2px,color:#FFFFFF
EDIT["Edit source<br/>bot/src, web/src, theme/src"]:::edit
TC["npm run bot:typecheck<br/>npm run web:typecheck<br/>tsc --noEmit"]:::check
T1["npm run bot:test<br/>node test/runner.mjs"]:::check
T2["npm run web:test<br/>node test/runner.mjs"]:::check
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/>npm run search:typecheck<br/>npm run routing:typecheck"]:::check
T1["npm run bot:test"]:::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
BUILD["npm run build<br/>theme then web"]:::check
DEV1["npm run bot:dev<br/>wrangler dev :8787"]:::edit
@@ -83,10 +110,14 @@ graph TD
EDIT --> TC
EDIT --> T1
EDIT --> T2
EDIT --> T3
EDIT --> T4
EDIT --> AU
TC --> BUILD
T1 --> BUILD
T2 --> BUILD
T3 --> BUILD
T4 --> BUILD
AU --> BUILD
BUILD --> DEV1
BUILD --> DEV2
@@ -115,6 +146,12 @@ bundle:
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`)
```bash
@@ -158,6 +195,42 @@ Harness URL parameters, documented in `web/index.html`:
| `?sessiondelay=` | milliseconds | Delay the session handshake |
| `?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`)
```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
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 descriptions are *derived* from the same strings `/help` replies with, the
menu and the reply cannot drift.
If you add a command, you must add it to the `["help", "privacy"]` array in
`menuFor()` **and** to the corresponding `helpMenu` lines — the script throws
If you add a command, you must add it to the `menuFor()` command array **and** to
the corresponding `helpMenu` lines — the script throws
`helpMenu for "<locale>" is missing its /<command> line (D-03 copy drift)` if the
two disagree. The gate `CS5 D-03: register-commands derives both scopes from
helpMenu` in `bot/test/commands.test.mjs` enforces this.
Only re-run this when the command *list or its descriptions* change. Repointing
the webhook is a separate script (`set-webhook.mjs`), run automatically by
`npm run deploy`.
`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 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 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:typecheck` | `tsc --noEmit` in `bot`. |
| `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:harness` | Harness-mode build into `web/dist-harness/`. |
| `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 gallery` | Passthrough to the theme workspace. |
| `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 |
| --- | --- | --- |
| `npm test` | `bot`, `web` | `node test/runner.mjs` — zero-dependency, auto-discovers `*.test.mjs`. |
| `npm run typecheck` | `bot`, `web` | `tsc --noEmit`, `strict` + `noUncheckedIndexedAccess`. |
| `npm test` | `bot`, `web`, `search`, `routing` | `node test/runner.mjs` — zero-dependency, auto-discovers `*.test.mjs`. |
| `npm run typecheck` | `bot`, `web`, `search`, `routing` | `tsc --noEmit`, `strict` + `noUncheckedIndexedAccess`. |
| `npm run dev` | `bot` | `wrangler dev --port 8787 --local`. |
| `npm run deploy` | `bot` | `wrangler deploy && node scripts/set-webhook.mjs`. |
| `npm run commands` | `bot` | `node scripts/register-commands.mjs`. |
| `npm run build` | `web` | `vite build` → `web/dist`. |
| `npm run build:harness` | `web` | `vite build --mode harness` → `web/dist-harness`. |
| `npm run capture` | `web` | `node scripts/capture.mjs`. |
| `npm run 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 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. |
@@ -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 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
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
npm run bot:typecheck
npm run web:typecheck
npm run search:typecheck
npm run routing:typecheck
npm run bot: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
```
`theme` has its own suite (`npm test -w theme` → `node ./scripts/audit.test.mjs`).
Note it has **no root passthrough script** — you must use the `-w theme` form to
run it, unlike `bot` and `web`.
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
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
retroactively — but today style is a review-time convention, not an automated
check. What *is* mechanically enforced is TypeScript strictness and the grep
gates described below.
check. What *is* mechanically enforced is TypeScript strictness and the gate
suites described below.
### TypeScript
Both `bot/tsconfig.json` and `web/tsconfig.json` are strict beyond the default.
The settings that actually change how you write code:
All four TS workspaces (`bot`, `web`, `search`, `routing`) are strict beyond the
default. The settings that actually change how you write code:
| Setting | `bot` | `web` | Why it matters |
| --- | --- | --- | --- |
| `strict` | yes | yes | No implicit `any`, strict null checks. |
| `noUncheckedIndexedAccess` | yes | yes | Indexing an array/record yields `T \| undefined` — you must narrow. This is the single most common source of compile friction in this repo. |
| `verbatimModuleSyntax` | yes | yes | Type-only imports must use `import type`. |
| `allowImportingTsExtensions` | yes | yes | Import with explicit `.ts` extensions is allowed and is the house style. |
| `noEmit` | yes | yes | `typecheck` is `tsc --noEmit`; bundling is wrangler's or Vite's job. |
| `target` / `lib` | ES2023 / ES2023 | ES2023 / ES2023 + DOM | No downleveling. |
| `types` | `@cloudflare/workers-types` | `vite/client` | Determines which globals exist. `bot` has no DOM; `web` does. |
| `resolveJsonModule` | no | yes | `web` imports the basemap JSON style. |
| Setting | `bot` | `web` | `search` / `routing` | Why it matters |
| --- | --- | --- | --- | --- |
| `strict` | yes | yes | yes | No implicit `any`, strict null checks. |
| `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 | yes | Type-only imports must use `import type`. |
| `allowImportingTsExtensions` | yes | yes | yes | Import with explicit `.ts` extensions is allowed and is the house style. |
| `noEmit` | yes | yes | yes | `typecheck` is `tsc --noEmit`; bundling is wrangler's or Vite's job. |
| `target` / `lib` | ES2023 / ES2023 | ES2023 / ES2023 + DOM | ES2023 / ES2023 | No downleveling. The data planes are DOM-free. |
| `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 | 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
both runtimes are bundler-hosted (wrangler / Vite), not raw Node.
`moduleResolution` is `"bundler"` in all four — deliberately not `node16`, because
the runtimes are bundler-hosted (wrangler / Vite) or Node type-stripping (the
corpus runners), not raw Node + CommonJS.
### House conventions observable in the source
- **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
`bot/scripts/`, `bot/test/`, `web/test/`, `web/scripts/`, and `theme/scripts/`
is `.mjs` and runs under plain Node.
`bot/scripts/`, `bot/test/`, `web/test/`, `web/scripts/`, `search/scripts/`,
`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
bare `"fs"`.
- **Heavy header comments.** Every script and several source modules open with a
block comment stating what it is, which decision IDs it satisfies, and its
known constraints. Match that when adding a file.
- **Named constants in SCREAMING_SNAKE for tunables** — `SESSION_TTL_SECONDS = 900`,
`PIN_TTL_SECONDS = 300`. The privacy gate greps for this exact shape (see below).
`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`
and `web/src/i18n/strings.ts`, keyed by locale, with `en` and `ms` carrying
identical key-sets. Gate: `COPY5 no shell module inlines copy`.
- **Structural interfaces for injection.** `KvLike`, `TelegramWebAppLike`,
`acquireFix(deps, …)` — dependencies are passed in so tests can supply fakes
without touching Workers types or `window`.
`acquireFix(deps, …)`, `createGuidanceWatch(geo, …)`, `HttpRoutingSourceOptions`
— 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
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 |
| --- | --- | --- |
| `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`. |
| `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. |
| `CS4` | `bot/test/commands.test.mjs` | All replies go out with link previews disabled and nothing else attached; the command tree has no reply-menu or inline-button surface. |
| `CS6` | `bot/test/commands.test.mjs` | The command stack registers exactly `/start`, `/mula`, `/help`, `/privacy`, `/privasi`. |
| `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`, `/cari`, `/search`. |
| `COPY1` | `web/test/copy-gate.test.mjs` | `en` and `ms` carry identical key-sets (locale parity). |
| `COPY5` | `web/test/copy-gate.test.mjs` | No module under `web/src/ui/*.ts` or `web/src/main.ts` inlines copy strings. |
| `BA1`–`BA6` | `web/test/build-artifact.test.mjs` | The production bundle references no third-party CDN, self-hosts its `woff2` fonts, contains a hashed MapLibre worker chunk, carries the inlined Rams style JSON, and contains **no** Mock-Telegram installer or runner-page fetch stub in any emitted file. |
| `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. |
### 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
write without a TTL, and the **web** client must have no storage surface at all.
If you add a persistence mechanism, these suites fail — which is the point.
### The two design invariants worth internalizing
### The three invariants worth internalizing
**Access control.** The `chat_id` for `/api/location` and `/api/location-fallback`
is *always* resolved from the session claim, never from client input; locale comes
from `claim.language_code`; the fallback request body is never read. If you add a
route, derive identity the same way.
**Access control.** The `chat_id` for `/api/location`, `/api/location-fallback`,
and `/api/guidance` is *always* resolved from the session claim, never from
client input; locale comes from `claim.language_code`; the fallback request body
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
never a fabricated value. A `null` heading hides the marker cone instead of
guessing; `getPin` range-validates so a corrupt record cannot fly the camera to
null island; the chat-pin poll is bounded at 60 s so `AWAITING PIN` cannot persist
forever; every source timeout resolves `null` so the chain always advances;
`bootMap()` returning `null` renders the shell with `data-map="absent"`. Copy
along this pattern: if you cannot do the thing, say so in a state, don't fake it.
`bootMap()` returning `null` renders the shell with `data-map="absent"`; every
`/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
@@ -380,11 +546,17 @@ Rules:
consider whether `theme/basemap/build.mjs` should derive it.
5. **`theme/src/theme.css`** is the entry stylesheet: it `@import`s tailwindcss,
`tokens.css`, and `components.css`, declares seven self-hosted `@font-face`
rules from `theme/fonts/` (no Google Fonts CDN), and scopes class generation
rules from `theme/fonts/` (no Google Fonts CDN), scopes class generation
with explicit `@source` lines (the four mockups are the allowed utility
sources; `@source not "../scripts"` and `@source not "../basemap"` exclude
prose).
6. **`theme/basemap/build.mjs`** forks OpenFreeMap `liberty` and derives every
prose), and defines the `--shell-bg` / `--shell-on-surface` theme slots.
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
guard that fails the build if `#EB8008`, `#ED3F1C`, or `#736B1E` leak into any
layer. `building-3d` is stripped (no faux 3D).
@@ -395,12 +567,12 @@ Rules:
### Branches
The repo's only branch is `main` (there is a stray `master` but it is not the
active branch), and there is **no remote branch convention documented** — no
`CONTRIBUTING.md`, no PR template, no CI config (no `.github/`, no `.gitea/`).
In practice, work lands on `main` via atomic commits rather than long-lived
feature branches. If you prefer branches, `feat/`, `fix/`, and `docs/` prefixes
match the commit vocabulary below.
The repo's only active branch is `main` (there is a stray `master` but it is not
the active branch; the remote is `origin/main`), and there is **no remote branch
convention documented** — no `CONTRIBUTING.md`, no PR template, no CI config (no
`.github/`, no `.gitea/`). In practice, work lands on `main` via atomic commits
rather than long-lived feature branches. If you prefer branches, `feat/`,
`fix/`, and `docs/` prefixes match the commit vocabulary below.
### Commit messages
@@ -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,
match what the project actually relies on:
- **Run the gates** and paste the output: both typechecks, both test suites, and
`npm run audit` if any rendered CSS or theme changed.
- **Run the gates** and paste the output: all four typechecks, all four test
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
(what was broken, what it does now).
- **Call out gate-relevant surface**: if you touched a KV write, `web/src`
persistence, the command surface, or the i18n strings, say which gates you
re-verified (`PG1`, `GEO3`, `CS6`, `COPY1`…).
persistence, the command surface, the i18n strings, or the guidance envelope,
say which gates you re-verified (`PG1`, `GEO3`, `CS6`, `COPY1`, `R12`…).
- **Include screenshots** for any Mini App change — `npm run web:build:harness
&& npm run web:capture` produces the current states.
- **Do not commit build output.** `theme/dist`, `web/dist`, `web/dist-harness`,
and the `screenshots/` directories are gitignored by design; they are
regenerated at build time.
`routing/data/*.bin`, and the `screenshots/` directories are gitignored by
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
expecting a checked-in artifact.
`.planning/` is also gitignored (local-only planning docs), as is `bot/.dev.vars`
and `bot/.wrangler/`.
`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/`.