Compare commits
2
Commits
d2e9ecfcd1
...
44851c70fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44851c70fe | ||
|
|
809864f5b6 |
+248
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
arah² is an npm-workspaces monorepo with four packages:
|
||||
arah² is an npm-workspaces monorepo with five packages:
|
||||
|
||||
| Package | Name | What it does |
|
||||
|---|---|---|
|
||||
| `theme/` | `@araharah/theme` | The Telemetry Rams design system as a Tailwind v4 CSS-first token package. Every color, spacing, type, and radius value in the project is defined once in `theme/src/tokens.css` and compiled to `theme/dist/rams.css`. Also ships the MapLibre basemap style `theme/basemap/liberty-rams.json` plus gallery/audit/screenshot tooling. |
|
||||
| `bot/` | `@araharah/bot` | A [grammY](https://grammy.dev) Telegram bot running on a Cloudflare Worker. It serves the `/telegram/webhook` route, four JSON API routes, keeps TTL-only chat sessions in Workers KV, and serves the Mini App's static assets through a wrangler assets binding. |
|
||||
| `web/` | `@araharah/web` | The Telegram Mini App: a MapLibre GL navigation shell built with Vite. Owns the three-source location acquisition chain, an in-memory session store, and the Telegram WebApp integrations (theme slots, BackButton, safe-area insets). |
|
||||
| `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, 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. |
|
||||
| `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`.
|
||||
|
||||
@@ -29,7 +30,8 @@ The Worker is the single origin. It answers `/api/*` and `/telegram/*` itself (`
|
||||
graph TD
|
||||
U["Telegram client — arah2bot chat + Mini App webview"]
|
||||
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"]
|
||||
APP["Mini App assets — web/dist via ASSETS binding"]
|
||||
MAP["MapLibre GL — Rams basemap, OpenFreeMap tiles"]
|
||||
@@ -39,10 +41,12 @@ graph TD
|
||||
TG --> W
|
||||
U -->|"GET Mini App"| APP
|
||||
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 -->|"GET /api/location — Bearer token"| W
|
||||
APP -->|"POST /api/route — Bearer token"| R2
|
||||
W --> KV
|
||||
W -->|"cron */2 → turn cards"| TG
|
||||
APP --> MAP
|
||||
THEME -.->|"inlined at build time"| MAP
|
||||
THEME -.->|"dist/rams.css"| APP
|
||||
@@ -53,7 +57,7 @@ graph TD
|
||||
classDef web fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1E1B14
|
||||
class U,TG telegram
|
||||
class W worker
|
||||
class KV storage
|
||||
class KV,R2 storage
|
||||
class APP,MAP,THEME web
|
||||
```
|
||||
|
||||
@@ -69,7 +73,7 @@ cd arah2
|
||||
npm install
|
||||
```
|
||||
|
||||
Requires Node.js and npm (no version is pinned in the repo — use a current LTS). Wrangler handles Cloudflare authentication on first use via `wrangler dev` / `wrangler deploy`.
|
||||
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. -->
|
||||
|
||||
@@ -114,12 +118,21 @@ The bot answers in English and Bahasa Melayu. Commands are registered into Teleg
|
||||
| Command | Alias | Behaviour |
|
||||
|---|---|---|
|
||||
| `/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). |
|
||||
| `/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
|
||||
|
||||
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 |
|
||||
|---|---|---|
|
||||
@@ -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. |
|
||||
| `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` `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
|
||||
# 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 dev` | Build, then run the Worker locally on port `8787` |
|
||||
| `npm run deploy` | Build, then `wrangler deploy` + `set-webhook.mjs` |
|
||||
| `npm run bot:test` / `npm run web:test` / `npm run search: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: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` / `npm run routing:typecheck` | `tsc --noEmit` |
|
||||
| `npm run bot:commands` | Register the persistent Telegram command menu (EN + MS) |
|
||||
| `npm run web:capture` | Capture Mini App screenshots |
|
||||
| `npm run web:build:harness` | Dual build into `dist-harness/` with the mock installer and fetch stubs |
|
||||
| `npm run 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) |
|
||||
|
||||
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:
|
||||
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
```
|
||||
theme/ @araharah/theme — tokens.css, components.css, theme.css, fonts/, basemap/, scripts/
|
||||
bot/ @araharah/bot — src/webhook.ts entry, bot/, kv/, security/, i18n/, scripts/, wrangler.jsonc
|
||||
web/ @araharah/web — src/main.ts entry, location/, map/, session/, telegram/, ui/, mock/
|
||||
search/ @araharah/search — src/types.ts, src/normalize.ts, src/score.ts, src/gazetteer.ts, src/data/kl-gazetteer.json
|
||||
bot/ @araharah/bot — src/webhook.ts entry, bot/, kv/, security/, routing/, guidance/, i18n/, scripts/, wrangler.jsonc
|
||||
web/ @araharah/web — src/main.ts entry, location/, route/, guidance/, search/, map/, session/, telegram/, ui/, mock/
|
||||
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)
|
||||
DESIGN.md Rams philosophy — superseded for any specific value
|
||||
```
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
import type { GuidanceSessionEnvelope } from "../kv/guidance-sessions.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. */
|
||||
export const CARD_CADENCE_S = 240;
|
||||
|
||||
|
||||
+328
@@ -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
@@ -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
|
||||
`Telegram.WebApp.initData` to `POST /api/session`, receives a short-lived opaque bearer
|
||||
token, and then acquires a position through a three-link chain (Telegram
|
||||
`LocationManager` → WebView `navigator.geolocation` → chat-relayed pin). All server state
|
||||
lives in a TTL-only Cloudflare KV namespace, so the system has no database, no persistent
|
||||
storage, and no analytics. `@araharah/theme` is the shared design-token package: it
|
||||
compiles Tailwind v4 CSS consumed by the web app and is the derivation source for the
|
||||
Rams basemap style the map renders.
|
||||
`LocationManager` → WebView `navigator.geolocation` → chat-relayed pin). Searching a
|
||||
destination and routing to it are real today: `@araharah/search` is the in-Worker
|
||||
gazetteer (search backend #1 of the D-01 ladder), and `@araharah/routing` is the
|
||||
routing data plane — its CSR road-graph codec, snapper, and A\* power **engine A**,
|
||||
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
|
||||
arrangement**: a stateless edge worker with a KV-backed repository layer, a
|
||||
framework-free TypeScript client with its own repository + adapter layers, and a pure
|
||||
build-time token/cartography package that both consume.
|
||||
Architecturally this is a **five-workspace npm monorepo in a layered/hexagonal
|
||||
arrangement**: a stateless edge worker with a KV repository layer and an embedded
|
||||
search index plus A\* router, a framework-free TypeScript client with its own
|
||||
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
|
||||
graph TD
|
||||
@@ -26,16 +36,22 @@ graph TD
|
||||
classDef worker fill:#D9D2C6,stroke:#736B1E,stroke-width:2px,color:#1A1A1A
|
||||
classDef store fill:#ED3F1C,stroke:#8C1F0A,stroke-width:2px,color:#FFFFFF
|
||||
classDef build fill:#1A1A1A,stroke:#000000,stroke-width:2px,color:#FFF8F0
|
||||
classDef plane fill:#736B1E,stroke:#4A440F,stroke-width:2px,color:#FFF8F0
|
||||
|
||||
A["Telegram client<br/>chat + Mini App webview"]:::client
|
||||
B["@araharah/bot Worker<br/>src/webhook.ts"]:::worker
|
||||
C["grammY handlers<br/>commands + location-fallback"]:::worker
|
||||
C["grammY handlers<br/>commands + /cari + fallbacks"]:::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
|
||||
G["Session client + store<br/>src/session/"]:::client
|
||||
H["Location chain<br/>src/location/"]:::client
|
||||
I["MapLibre map + UI<br/>src/map/, src/ui/"]:::client
|
||||
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
|
||||
|
||||
A -->|"POST /telegram/webhook"| B
|
||||
@@ -43,15 +59,24 @@ graph TD
|
||||
B --> C
|
||||
B --> D
|
||||
B --> E
|
||||
B --> R
|
||||
Q --> E
|
||||
Q --> R
|
||||
C --> E
|
||||
C -->|"sendMessage reply keyboard"| A
|
||||
C --> S
|
||||
C -->|"sendPhoto turn-cards"| A
|
||||
F -->|"POST /api/session initData"| B
|
||||
F -->|"GET /api/location Bearer"| 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
|
||||
H --> G
|
||||
F --> H
|
||||
H --> I
|
||||
RT -->|"reroute"| B
|
||||
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 wsfill fill:#FFF8F0,stroke:#EB8008,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 ext fill:#E0D9CD,stroke:#564335,stroke-width:2px,color:#1A1A1A
|
||||
|
||||
@@ -86,38 +112,68 @@ graph LR
|
||||
W3["src/location/"]:::wsfill
|
||||
W4["src/map/"]:::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
|
||||
W1 --> W2
|
||||
W1 --> W3
|
||||
W1 --> W4
|
||||
W1 --> W5
|
||||
W1 --> W6
|
||||
W1 --> W7
|
||||
W4 --> W7
|
||||
W5 --> W7
|
||||
W6 --> W7
|
||||
end
|
||||
subgraph BOT["bot · @araharah/bot"]
|
||||
direction TB
|
||||
B1["src/webhook.ts"]:::botfill
|
||||
B2["src/security/initdata.ts"]:::botfill
|
||||
B3["src/bot/"]:::botfill
|
||||
B3["src/bot/ + src/guidance/cron.ts"]:::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 --> B3
|
||||
B1 --> B4
|
||||
B1 --> B5
|
||||
B1 --> B6
|
||||
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
|
||||
OFM["OpenFreeMap tiles"]:::ext
|
||||
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
|
||||
B1 --> KV
|
||||
B4 --> KV
|
||||
B3 <-->|"webhookCallback / sendMessage"| TG
|
||||
B5 --> R2B
|
||||
B3 --> KV
|
||||
CRON --> B1
|
||||
B1 <-->|"webhookCallback / sendMessage"| TG
|
||||
W3 <-->|"chat pin"| TG
|
||||
W4 --> OFM
|
||||
```
|
||||
@@ -132,27 +188,45 @@ graph TD
|
||||
|
||||
ROOT["araharah (private root)"]:::out
|
||||
TH["@araharah/theme"]:::pkg
|
||||
SE["@araharah/search"]:::pkg
|
||||
RO["@araharah/routing"]:::pkg
|
||||
BT["@araharah/bot"]:::pkg
|
||||
WB["@araharah/web"]:::pkg
|
||||
KVS[("KV chat_sessions")]:::dep
|
||||
R2S[("R2 ROAD_GRAPH")]:::dep
|
||||
TGAPI["Telegram Bot API"]:::dep
|
||||
|
||||
ROOT -->|"workspaces"| TH
|
||||
ROOT -->|"workspaces"| SE
|
||||
ROOT -->|"workspaces"| RO
|
||||
ROOT -->|"workspaces"| BT
|
||||
ROOT -->|"workspaces"| WB
|
||||
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 --> KVS
|
||||
BT --> R2S
|
||||
BT --> TGAPI
|
||||
WB -->|"api routes"| BT
|
||||
```
|
||||
|
||||
`@araharah/web` declares `"@araharah/theme": "*"` — the only workspace-to-workspace
|
||||
runtime import in the repo (`@araharah/theme/dist/rams.css` in `web/src/shell.css`,
|
||||
`@araharah/theme/basemap/liberty-rams.json` in `web/src/map/map.ts`). The bot has no
|
||||
workspace dependency; its coupling to web is a **build-order coupling only** — the
|
||||
root `build` script builds theme then web, and the Worker's `[assets]` binding points
|
||||
at `../web/dist`.
|
||||
`@araharah/search` and `@araharah/routing` are the shared V8-native data planes: the
|
||||
**bot Worker** imports them at runtime (gazetteer search, graph decode, snap, A\*),
|
||||
the **web app** imports the same sources at build time (destination resolution, the
|
||||
`RoutingSource` ladder, polyline decoding). Neither data plane reads the environment,
|
||||
the network, or storage. `@araharah/theme` stays web-only (`"@araharah/theme": "*"`
|
||||
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
|
||||
|
||||
@@ -171,6 +245,15 @@ POST /telegram/webhook → webhookCallback (secret check) → createBot(env)
|
||||
→ 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`
|
||||
derives the persistent `/setMyCommands` menu descriptions from the same `helpMenu` lines,
|
||||
so menu and reply cannot drift.
|
||||
@@ -225,7 +308,7 @@ flowchart TD
|
||||
START --> L1
|
||||
L1 -->|"yes"| NUDGE
|
||||
NUDGE -->|"no"| LIVE
|
||||
NUDGE -->|"yes"||"discarded webview read"| L1
|
||||
NUDGE -->|"yes: discarded webview read"| L1
|
||||
L1 -->|"no"| L2
|
||||
L2 -->|"yes + fix"| LIVE
|
||||
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
|
||||
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 |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -278,11 +449,14 @@ is server-mediated by necessity.
|
||||
| POST | `/api/session` | `handleSession` | initData → one-time opaque session token |
|
||||
| GET | `/api/location` | `handleLocation` | Bearer-authenticated chat-pin relay |
|
||||
| POST | `/api/location-fallback` | `handleLocationFallback` | Ask the bot to send the reply keyboard |
|
||||
| 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 |
|
||||
|
||||
`run_worker_first: ["/api/*", "/telegram/*"]` guarantees the Worker's `fetch` owns these
|
||||
paths before the static asset fallback runs; every other path resolves to the `web/dist`
|
||||
bundle.
|
||||
bundle. The same `fetch` handler also exports `scheduled()` — the `*/2` cron that runs
|
||||
the turn-card push (flow 7).
|
||||
|
||||
## Build & Deploy Topology
|
||||
|
||||
@@ -299,11 +473,14 @@ graph LR
|
||||
W2["web: vite build --mode harness → dist-harness/"]:::step
|
||||
B1["bot: wrangler deploy"]:::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
|
||||
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
|
||||
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
|
||||
R2B[("R2 ROAD_GRAPH")]:::store
|
||||
|
||||
T1 --> W1
|
||||
T2 --> W1
|
||||
@@ -313,21 +490,31 @@ graph LR
|
||||
AR2 --> EDGE
|
||||
B1 --> EDGE
|
||||
B1 --> B2
|
||||
RG --> RU
|
||||
RU --> R2B
|
||||
EDGE --> KVS
|
||||
EDGE --> R2B
|
||||
```
|
||||
|
||||
The root scripts encode the ordering: `npm run build` = `build -w theme && build -w web`;
|
||||
`npm run deploy` = `npm run build && npm run deploy -w bot` (which is
|
||||
`wrangler deploy && node scripts/set-webhook.mjs`). `bot/wrangler.jsonc` binds
|
||||
`assets.directory` to `../web/dist`, so `web/dist` must exist before a bot deploy.
|
||||
The 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
|
||||
`outDir: "dist-harness"` only under `--mode harness` and defines
|
||||
`__ARAHARAH_HARNESS__` to `true` there and `false` everywhere else, so the mock
|
||||
installer and the `fetch` stubs in `web/index.html` are dead-code-eliminated from
|
||||
production. `web/test/build-artifact.test.mjs` (BA1–BA6) proves the shipped bundle
|
||||
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
|
||||
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. -->
|
||||
|
||||
@@ -337,15 +524,43 @@ style, and carries neither `installMockWebApp` nor `__ARAHARAH_MOCK__`.
|
||||
|
||||
| Abstraction | File | Description |
|
||||
| --- | --- | --- |
|
||||
| `Env` | `bot/src/config.ts` | Typed contract: `BOT_TOKEN`, `WEBHOOK_SECRET` (must be non-empty — the webhook fails closed without it), `chat_sessions: KVNamespace`. |
|
||||
| `default { fetch }` | `bot/src/webhook.ts` | The `ExportedHandler<Env>` — four routes, 404 otherwise. Bot is constructed per request. |
|
||||
| `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). |
|
||||
| `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`. |
|
||||
| `SessionClaim` / `mintSession` / `getClaimByToken` / `getTokenForChat` | `bot/src/kv/sessions.ts` | TTL-only session layer. `SESSION_TTL_SECONDS = 900`. Key scheme `session:<token>` → claim, `chat:<chat_id>` → current token. Rotate-on-new with a best-effort reconciliation sweep. |
|
||||
| `LocationPin` / `writePin` / `getPin` / `clearPin` | `bot/src/kv/location-pin.ts` | TTL-only pin layer. `PIN_TTL_SECONDS = 300`. Key `loc:<chat_id>`. `getPin` range-validates on read. |
|
||||
| `createBot(env, opts)` | `bot/src/bot/bot.ts` | grammY factory; wires `/start` + `/mula`, `/help`, `/privacy` + `/privasi`, and the location-fallback message handler. |
|
||||
| `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`. |
|
||||
| `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`
|
||||
|
||||
@@ -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`. |
|
||||
| `acquireFix(deps, lastAccepted)` | `web/src/location/chain.ts` | The three-link state machine. Pluggable cadence — no `setInterval`, no `watchPosition`. |
|
||||
| `isFixStale(fix, nowMs)` / `fixAgeSeconds` | `web/src/location/chain.ts` | The 45 s signal-lost threshold and the `FIX Ns AGO` telemetry helper. |
|
||||
| `TelegramWebAppLike` | `web/src/telegram/webapp.ts` | Hand-rolled ~12-member contract for `window.Telegram.WebApp`. `getWebApp()` may honestly return `null` off-Telegram. |
|
||||
| `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. |
|
||||
| `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`. |
|
||||
@@ -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. |
|
||||
| `buildBootCard` / `buildDeniedCard` / `buildSignalLostCard` | `web/src/ui/cards.ts` | Rams card surfaces composed from theme component classes; zero inline copy. |
|
||||
| `createRecenterControl(host, locale, onRecenter)` | `web/src/ui/recenter.ts` | 40×40 follow plate with three instant notch states. |
|
||||
| `watchTheme(wa)` / `resolveBranch` | `web/src/telegram/theme.ts` | Telegram light/dark signal → `data-tg-theme` slot switch. MapLibre is deliberately absent. |
|
||||
| `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. |
|
||||
| `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`. |
|
||||
| `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 |
|
||||
| --- | --- | --- |
|
||||
| `@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`. |
|
||||
| `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). |
|
||||
@@ -384,38 +606,59 @@ style, and carries neither `installMockWebApp` nor `__ARAHARAH_MOCK__`.
|
||||
|
||||
```
|
||||
araharah/
|
||||
├── package.json # private root; workspaces + build ordering only
|
||||
├── package.json # private root; workspaces + script passthroughs
|
||||
├── bot/ # @araharah/bot — the Cloudflare Worker
|
||||
│ ├── src/
|
||||
│ │ ├── webhook.ts # the single fetch handler; four routes
|
||||
│ │ ├── config.ts # Env contract
|
||||
│ │ ├── bot/ # grammY factory, commands/, handlers/
|
||||
│ │ ├── webhook.ts # the single fetch handler; six routes + scheduled()
|
||||
│ │ ├── config.ts # Env contract (KV, R2, MINI_APP_URL)
|
||||
│ │ ├── bot/ # grammY factory, commands/, handlers/ (incl. /cari)
|
||||
│ │ ├── 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
|
||||
│ ├── scripts/ # set-webhook, register-commands, set-menu-button
|
||||
│ ├── test/ # hand-rolled zero-dependency runner + suites
|
||||
│ └── wrangler.jsonc # worker name, assets binding, run_worker_first, KV
|
||||
│ └── wrangler.jsonc # worker name, assets binding, run_worker_first, R2, cron
|
||||
├── web/ # @araharah/web — the Mini App
|
||||
│ ├── src/
|
||||
│ │ ├── main.ts # boot choreography (order is load-bearing)
|
||||
│ │ ├── session/ # client.ts (network), store.ts (memory)
|
||||
│ │ ├── location/ # chain.ts + telegram/webview/chat-pin sources
|
||||
│ │ ├── telegram/ # webapp, theme, backbutton, safe-area
|
||||
│ │ ├── 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
|
||||
│ │ ├── ui/ # strip.ts, cards.ts, recenter.ts
|
||||
│ │ ├── mock/ # harness-only Telegram fake
|
||||
│ │ ├── i18n/strings.ts # every user-facing shell string
|
||||
│ │ └── shell.css # Rams chrome; imports @araharah/theme/dist/rams.css
|
||||
│ ├── test/ # runner + gate suites (copy, storage, artifact, chain)
|
||||
│ ├── public/glyphs/ # turn-card PNGs the cron deep-links
|
||||
│ ├── test/ # runner + gate suites (copy, storage, artifact, chain, guidance)
|
||||
│ ├── scripts/capture.mjs # screenshot capture
|
||||
│ └── 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
|
||||
│ ├── src/ # tokens.css, components.css, theme.css, sheet.html
|
||||
│ ├── fonts/ # self-hosted Geist + Space Grotesk woff2
|
||||
│ ├── basemap/ # build.mjs + generated liberty-rams.json
|
||||
│ ├── scripts/ # audit.mjs (token audit), screenshot.mjs
|
||||
│ └── dist/rams.css # compiled output
|
||||
├── 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)
|
||||
└── docs/
|
||||
```
|
||||
@@ -423,20 +666,30 @@ araharah/
|
||||
Rationale for the boundaries:
|
||||
|
||||
- **`bot/src/webhook.ts` owns routing; nothing else dispatches.** The Worker is the only
|
||||
place that decides which path runs, so `run_worker_first` and the secret-token gate
|
||||
apply uniformly.
|
||||
- **`bot/src/security/` and `bot/src/kv/` are dependency-light on purpose.**
|
||||
`initdata.ts` imports nothing from grammY and runs in both Workers and bare Node;
|
||||
`kv/*.ts` take a structural `KvLike` so the suites pass fakes.
|
||||
place that decides which path runs, so `run_worker_first`, the secret-token gate,
|
||||
the body caps, and the cron tick all apply uniformly.
|
||||
- **`bot/src/security/`, `bot/src/kv/`, and the two data-plane packages are
|
||||
dependency-light on purpose.** `initdata.ts` imports nothing from grammY and runs in
|
||||
both Workers and bare Node; `kv/*.ts` take a structural `KvLike` so the suites pass
|
||||
fakes; `@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
|
||||
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,
|
||||
trip history, profile) and keeps the ~15-minute TTL wording, in both locales. That gate
|
||||
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.
|
||||
trip history, profile) and keeps the ~15-minute TTL wording, in both locales.
|
||||
- **`web/src/location/` separates sources from the chain.** Each source is a small module
|
||||
that normalizes to `Fix` and answers `null` for denial; `chain.ts` owns ordering and
|
||||
knows nothing about any single source's API.
|
||||
- **`web/src/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
|
||||
vocabulary; `store.ts` owns the in-memory token and the 900 s staleness rule. Neither
|
||||
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
|
||||
style. That is why `web` can depend on it without pulling a bundler into the token
|
||||
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
|
||||
compiles every class the four mockups use so the design vocabulary stays provable
|
||||
against the shipped CSS.
|
||||
|
||||
## 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
|
||||
`expirationTtl` on the same call line. No write path can persist without a TTL.
|
||||
**The consolidated maintainer audit** — `npm run privacy-audit`
|
||||
(`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
|
||||
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,
|
||||
language_code) for 900 s and `LocationPin` (latitude, longitude, horizontal_accuracy)
|
||||
for 300 s. The client holds the token and the last fix in module scope only.
|
||||
language_code) for 900 s, `LocationPin` (latitude, longitude, horizontal_accuracy)
|
||||
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
|
||||
|
||||
The `chat_id` used by `/api/location` and `/api/location-fallback` is always resolved
|
||||
from the session claim, and the locale from `claim.language_code`. The request body of
|
||||
the fallback route is never read — no chat id or language can arrive from the client.
|
||||
Every chat-scoped route (`/api/location`, `/api/location-fallback`, `/api/guidance`)
|
||||
resolves the `chat_id` from the Bearer session claim, never from client input; locale
|
||||
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
|
||||
|
||||
@@ -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
|
||||
orientation; `getPin` range-validates so a corrupt record cannot fly the camera to null
|
||||
island; the poll is bounded at 60 s so `AWAITING PIN` cannot persist; every source
|
||||
timeout resolves `null` so the chain always advances.
|
||||
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`,
|
||||
`unauthorized`, `misconfigured`, `telegram-send`. Never raw payloads, never user ids,
|
||||
never timestamps. Rejection responses carry the category only.
|
||||
The Worker's only log statements are categories — `[webhook] rejected: misconfigured`,
|
||||
`[initdata] rejected: malformed|tampered|stale`, `[location]`/`[location-fallback]`/
|
||||
`[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
|
||||
|
||||
Both `bot/test` and `web/test` use the same hand-rolled zero-dependency runner
|
||||
(`runner.mjs` + `harness.mjs`), auto-discovering `*.test.mjs`. `theme` runs
|
||||
`scripts/audit.test.mjs` in the same shape. Gates are grep/introspection assertions
|
||||
against source and build artifacts, not just behavioral assertions.
|
||||
Five workspaces, one test idiom: `bot/test`, `web/test`, `search/test`, and
|
||||
`routing/test` each use the same hand-rolled zero-dependency runner (`runner.mjs` +
|
||||
`harness.mjs`), auto-discovering `*.test.mjs`; `theme` runs `scripts/audit.test.mjs`
|
||||
in the same shape, 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
|
||||
|
||||
The following appear in `IDEAS.md` as roadmap items and are **not** in the codebase:
|
||||
routing engines (Valhalla/OSRM), Neon/Postgres, RabbitMQ, Prometheus/Grafana, Resend,
|
||||
Cloudflare R2, Photon geocoding. The current system is exactly the two implemented
|
||||
phases: the bot skeleton with initData-gated sessions and TTL-only KV, and the Mini App
|
||||
shell with the three-link location chain and the Rams basemap. Routing/guidance is
|
||||
Phase 5+.
|
||||
The following are documented targets, **not** in the codebase: **engine B** (the fully
|
||||
client-side webview router — graph on the phone; engine A's documented post-v1
|
||||
evolution; the Valhalla-to-JS port was ruled infeasible and geojson-path-finder was
|
||||
rejected — recorded so neither is re-proposed), Valhalla-on-VPS (the documented
|
||||
provision path, not shipped), Neon/Postgres, RabbitMQ, Prometheus/Grafana, Resend,
|
||||
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
@@ -2,9 +2,10 @@
|
||||
|
||||
# Configuration
|
||||
|
||||
arah² is an npm-workspaces monorepo with three workspaces — `theme`, `bot`, `web` — plus a root
|
||||
`package.json` that composes their scripts. There is **no committed `.env` file**: secrets live in
|
||||
`bot/.dev.vars` (gitignored) for local work and in Cloudflare Workers secrets for deployment.
|
||||
arah² is an npm-workspaces monorepo with five workspaces — `theme`, `bot`, `web`, `search`, `routing`
|
||||
— plus a root `package.json` that composes their scripts. There is **no committed `.env` file**:
|
||||
secrets live in `bot/.dev.vars` (gitignored) for local work and in Cloudflare Workers secrets for
|
||||
deployment.
|
||||
|
||||
Configuration surfaces, by kind:
|
||||
|
||||
@@ -12,28 +13,55 @@ Configuration surfaces, by kind:
|
||||
| --- | --- | --- |
|
||||
| Workspaces + scripts | `package.json` | all |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Graph binary + gazetteer data | `routing/data/kl-roadgraph.v1.bin`, `search/src/data/kl-gazetteer.json` | routing, search |
|
||||
| Ignore rules | `.gitignore`, `bot/.gitignore` | repo |
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
Four variables reach the Worker (`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.
|
||||
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,
|
||||
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 |
|
||||
| --- | --- | --- | --- |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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`:
|
||||
|
||||
@@ -48,14 +76,16 @@ WEBHOOK_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
|
||||
`process.env.<NAME> ?? devVars.<NAME>` — shell environment wins over the file, and the file is the
|
||||
fallback. `set-webhook.mjs` requires all three of `BOT_TOKEN`, `WEBHOOK_URL`, `WEBHOOK_SECRET` and
|
||||
exits `1` listing whichever are missing. `register-commands.mjs` requires only `BOT_TOKEN`.
|
||||
`set-menu-button.mjs` requires `BOT_TOKEN` and `WEBHOOK_URL`.
|
||||
|
||||
<!-- VERIFY: the production secret names registered via `wrangler secret put` and the Cloudflare account they live under cannot be read from the repository. -->
|
||||
|
||||
---
|
||||
|
||||
## Configuration flow
|
||||
@@ -75,9 +105,11 @@ flowchart TD
|
||||
|
||||
subgraph Production["Deployed Worker"]
|
||||
SECRETS["wrangler secrets: BOT_TOKEN, WEBHOOK_SECRET"]
|
||||
KV["KV namespace binding: chat_sessions"]
|
||||
ASSETS["Static assets: ../web/dist"]
|
||||
WORKER["src/webhook.ts fetch handler"]
|
||||
KV["KV binding: chat_sessions (TTL-only, 4 families)"]
|
||||
R2["R2 binding: ROAD_GRAPH (araharah-graphs)"]
|
||||
ASSETS["Static assets: ../web/dist + _headers"]
|
||||
CRON["Cron trigger: */2 * * * * (stateless scan)"]
|
||||
WORKER["src/webhook.ts fetch + scheduled handlers"]
|
||||
end
|
||||
|
||||
DEVVARS --> WRANGLERDEV
|
||||
@@ -87,7 +119,9 @@ flowchart TD
|
||||
|
||||
SECRETS --> WORKER
|
||||
KV --> WORKER
|
||||
R2 --> WORKER
|
||||
ASSETS --> WORKER
|
||||
CRON --> WORKER
|
||||
WRANGLERDEV --> WORKER
|
||||
|
||||
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
|
||||
origin with relative paths (`/api/session`, `/api/location`, `/api/location-fallback`), so the API
|
||||
base URL is implied by wherever the bundle is served from — which is why the Worker serves
|
||||
`../web/dist` as its own static assets and reserves `/api/*` and `/telegram/*` for itself.
|
||||
`../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):**
|
||||
|
||||
- `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
|
||||
`500 Server misconfigured`. The rationale is documented in the file: grammY's `compareSecretToken`
|
||||
silently accepts every request when the secret is `undefined`, so an unarmed binding must never
|
||||
reach it.
|
||||
`500 Server misconfigured`. The rationale is documented in the file: grammY's
|
||||
`compareSecretToken` silently accepts every request when the secret is `undefined`, so an
|
||||
unarmed binding must never reach it.
|
||||
- `BOT_TOKEN` — declared required in the `Env` type but not guarded at runtime; a missing token
|
||||
surfaces as a grammY authentication failure on the first request that instantiates the bot.
|
||||
|
||||
**Fail loudly (scripts):** `set-webhook.mjs`, `register-commands.mjs`, and `set-menu-button.mjs` each
|
||||
print a missing-variable list to stderr and `process.exit(1)` before any network call.
|
||||
|
||||
**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:**
|
||||
|
||||
- `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`.
|
||||
- `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.
|
||||
- `MINI_APP_URL` — a non-secret configuration value declared in the Worker `Env` and shipped via
|
||||
`bot/wrangler.jsonc`'s `vars` block (public origin, not a secret). When it is blank the `/cari`
|
||||
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`
|
||||
(`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.
|
||||
- KV TTLs — constants in source, not configuration. One per family, all pinned by grep gates in
|
||||
the test suite so no write path can persist without a TTL:
|
||||
|
||||
| Constant | Value | File | Key family |
|
||||
| --- | --- | --- | --- |
|
||||
| `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
|
||||
{
|
||||
// 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",
|
||||
"main": "src/webhook.ts",
|
||||
"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 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": {
|
||||
"directory": "../web/dist",
|
||||
"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/*"],
|
||||
// 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>).
|
||||
"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" }]
|
||||
}
|
||||
```
|
||||
@@ -159,23 +236,45 @@ Key notes:
|
||||
|
||||
- `assets.directory: "../web/dist"` — the Worker serves the Mini App bundle itself, so `npm run build`
|
||||
(theme then web) must run before `wrangler deploy`; a stale or absent `web/dist` ships no Mini App.
|
||||
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
|
||||
`src/webhook.ts` first; everything else falls through to the static asset binding.
|
||||
- `not_found_handling: "none"` — unmatched paths are not served the SPA `index.html` fallback.
|
||||
- `workers_dev: true` serves on the `<name>.<account>.workers.dev` URL, which is the `WEBHOOK_URL`
|
||||
target.
|
||||
- `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
|
||||
must equal the Mini App URL registered in @BotFather; see the env-var table above.
|
||||
target and the committed `MINI_APP_URL` value.
|
||||
- `vars: { "MINI_APP_URL": ... }` — a **public** origin shipped as a plain var (never a secret).
|
||||
It 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
|
||||
|
||||
Root `package.json` (workspaces: `theme`, `bot`, `web`; the root, `bot`, and `web` packages set
|
||||
`"private": true` while `@araharah/theme` omits the field, ESM via `"type": "module"`):
|
||||
Root `package.json` (workspaces: `theme`, `bot`, `web`, `search`, `routing`; the root and `bot`,
|
||||
`web`, `search`, `routing` packages set `"private": true` while `@araharah/theme` omits the field,
|
||||
ESM via `"type": "module"`):
|
||||
|
||||
| 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 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 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 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:
|
||||
|
||||
| Command | Where | Notes |
|
||||
| --- | --- | --- |
|
||||
| `npm test` | `bot`, `web` | Both run `node test/runner.mjs` (a hand-rolled runner, no external test framework). |
|
||||
| `npm run typecheck` | `bot`, `web` | `tsc --noEmit`, `strict` plus `noUncheckedIndexedAccess`. |
|
||||
| `npm test` | `bot`, `web`, `search`, `routing` | All run `node test/runner.mjs` (a hand-rolled runner, no external test framework). |
|
||||
| `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 deploy` | `bot` | `wrangler deploy && node scripts/set-webhook.mjs`. |
|
||||
| `npm run commands` | `bot` | `node scripts/register-commands.mjs` — `setMyCommands` in default scope and in `language_code: "ms"`; descriptions are derived from `src/i18n/strings.ts` `helpMenu` lines so the menu and `/help` reply cannot drift. |
|
||||
| `npm run build` | `web` | `vite build` → `web/dist`. |
|
||||
| `npm run build:harness` | `web` | `vite build --mode harness` → `web/dist-harness`, with mock installer and fetch stubs. |
|
||||
| `npm run dev` | `web` | `vite` dev server on port 5173 (`strictPort: true`). |
|
||||
| `npm run capture` | `web` | `node scripts/capture.mjs` — serves `web/dist-harness` and screenshots the boot/live/denied states. |
|
||||
| `npm run 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 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 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`
|
||||
everywhere else. Vite replaces the identifier textually, so the production bundle tree-shakes the
|
||||
mock installer and `/api/*` fetch stubs out entirely; `web/index.html` carries its mock profile
|
||||
block inside the same guarded branch.
|
||||
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
|
||||
`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`.
|
||||
- `__ARAHARAH_HARNESS__` is declared ambient in `web/src/vite-env.d.ts` so `tsc --noEmit` passes in
|
||||
both modes.
|
||||
- Harness-only URL parameters (documented in `web/index.html`): `?mock=android|ios|tdesktop|macos|web`,
|
||||
`?scheme=light|dark`, `?pin=1`, `?locale=en|ms`, `?sessiondelay=<ms>`, `?fixdelay=<ms>`.
|
||||
`?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
|
||||
value. `theme/src/theme.css` imports Tailwind v4 plus `tokens.css` and `components.css`, declares
|
||||
self-hosted Geist and Space Grotesk `@font-face` rules from `theme/fonts/` (no Google Fonts CDN), and
|
||||
scopes class generation with explicit `@source` lines — the four mockup markup files under
|
||||
self-hosted Geist and Space Grotesk `@font-face` rules from `theme/fonts/` (no Google Fonts CDN),
|
||||
and scopes class generation with explicit `@source` lines — the four mockup markup files under
|
||||
`stitch_minimalist_route_navigator/` are the only allowed utility sources, with
|
||||
`@source not "../scripts"` and `@source not "../basemap"` excluding prose.
|
||||
|
||||
Downstream consumers:
|
||||
|
||||
- `web/src/shell.css` — `@import "@araharah/theme/dist/rams.css";`
|
||||
- `web/src/map/map.ts` — `import ramsStyle from "@araharah/theme/basemap/liberty-rams.json";`
|
||||
- `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
|
||||
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
|
||||
|
||||
`.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/`,
|
||||
`.env`, `.env.*` (with `!*.example`), and `*:Zone.Identifier`. `bot/.gitignore` adds `.dev.vars` and
|
||||
`.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 `vite dev` | none | Vite dev server on 5173 with no Worker behind it — `main.ts` falls back to the mock WebApp via `import.meta.env.DEV`. |
|
||||
| Harness capture | `vite build --mode harness` | `web/dist-harness` plus `web/index.html`'s URL-parameter profile. Never deployed. |
|
||||
| Production | `wrangler.jsonc` + Cloudflare secrets | Secrets set with `wrangler secret put` rather than a file; assets come from the uploaded `../web/dist`; KV resolves to the real namespace. |
|
||||
| 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
|
||||
no registry token or publish step to configure.
|
||||
`@araharah/theme`, `@araharah/web`, `@araharah/search`, and `@araharah/routing` are consumed by
|
||||
relative path within the workspace, so there is no registry token or publish step to configure.
|
||||
|
||||
@@ -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
@@ -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`
|
||||
`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/`.
|
||||
+95
-34
@@ -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
|
||||
environment variables, config files, and per-package scripts, see
|
||||
[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 |
|
||||
| --- | --- | --- |
|
||||
| **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 bot token from **[@BotFather](https://t.me/BotFather)** | `BOT_TOKEN` is required for the Worker and every registration script | Also used as the HMAC key that validates Mini App `initData` |
|
||||
| **Internet access** | `wrangler dev`, the Telegram Bot API, and OpenFreeMap tile fetches all need the network | — |
|
||||
| **A Cloudflare account** *(deploy and `npm run bot:commands` only)* | `wrangler deploy` and the Workers KV namespace live there | Wrangler prompts for browser-based auth on first use |
|
||||
| **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:
|
||||
|
||||
- **Python 3** — `npm run gallery` serves the design gallery via `python3 -m http.server 8123`
|
||||
(`theme/package.json`, script `basemap:preview`).
|
||||
- **A Playwright-managed Chromium** — `npm run capture` and `npm run web:capture` both call
|
||||
bare `chromium.launch()` (`theme/scripts/screenshot.mjs:103`, `web/scripts/capture.mjs:163`), so
|
||||
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.
|
||||
- **`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
|
||||
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
|
||||
```
|
||||
|
||||
3. **Install all three workspaces from the root.** npm workspaces hoist dependencies into a
|
||||
single root `node_modules`, so one install covers `theme`, `bot`, and `web` — including
|
||||
`@araharah/theme`, which `web` consumes by relative path and never needs to be published.
|
||||
3. **Install all five workspaces from the root.** npm workspaces hoist dependencies into a
|
||||
single root `node_modules`, so one install covers `theme`, `bot`, `web`, `search`, and
|
||||
`routing` — including `@araharah/theme`, which `web` consumes by relative path and never
|
||||
needs to be published.
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```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
|
||||
# → 401 (no bearer token)
|
||||
|
||||
@@ -153,6 +157,13 @@ curl -i http://localhost:8787/api/location
|
||||
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
|
||||
|
||||
`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. -->
|
||||
|
||||
Register the persistent command menu (English + Bahasa Melayu) once, so `/help` and `/privacy`
|
||||
appear in the Telegram UI:
|
||||
Register the persistent command menu (English + Bahasa Melayu) once, so `/help`, `/privacy`,
|
||||
and `/cari` appear in the Telegram UI:
|
||||
|
||||
```bash
|
||||
npm run bot:commands
|
||||
```
|
||||
|
||||
`register-commands.mjs` registers exactly `["help", "privacy"]` in two scopes — the default scope
|
||||
plus `language_code: "ms"` — and deliberately reserves no stub entries for future commands, so
|
||||
`/start` is a working handler that does **not** appear in the menu (type it manually). The
|
||||
`register-commands.mjs` registers exactly `["help", "privacy", "cari"]` in four private-chat
|
||||
scopes — default (en), default+ms, all_private_chats (en), all_private_chats+ms — and clears 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
|
||||
`/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 |
|
||||
| --- | --- | --- |
|
||||
| `/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 |
|
||||
| `/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
|
||||
(`type: web_app`) has **no npm alias** — run it directly:
|
||||
The Mini App opens from the venue card's OPEN button, or from the bot's menu button. The script
|
||||
that repoints the menu button at the deployed origin (`type: web_app`) has **no npm alias** —
|
||||
run it directly:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
Run both suites and both typechecks — the runners are hand-rolled and zero-dependency
|
||||
(`node bot/test/runner.mjs` and `node web/test/runner.mjs`), so no test framework needs installing:
|
||||
Run the four test suites and the four typechecks — the runners are hand-rolled and
|
||||
zero-dependency (`node <workspace>/test/runner.mjs` each), so no test framework needs installing:
|
||||
|
||||
```bash
|
||||
npm run bot:test # 6 suites in bot/test/*.test.mjs
|
||||
npm run web:test # 6 suites in web/test/*.test.mjs
|
||||
npm run bot:test # 10 suites in bot/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 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
|
||||
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 web suites are `build-artifact`, `copy-gate`, `location-chain`, `no-storage`, `session`, and
|
||||
`theme`. Two are worth knowing about as you get started:
|
||||
The bot suites are `cari`, `commands`, `guidance-cron`, `guidance-envelope`, `initdata`,
|
||||
`location-pin`, `privacy`, `route-privacy`, `sessions`, and `webhook`; the web suites include
|
||||
`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,
|
||||
IndexedDB, or the Cache API.
|
||||
- **`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
|
||||
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.
|
||||
|
||||
**`[webhook] rejected: misconfigured` → `500 Server misconfigured`.**
|
||||
`WEBHOOK_SECRET` is missing or empty in `bot/.dev.vars`. The guard is at `bot/src/webhook.ts:65`
|
||||
and it fails closed deliberately: grammY's `compareSecretToken` silently accepts *every* request
|
||||
when the secret is `undefined`, so an unarmed secret must never reach it. Generate a new random
|
||||
string and put it in `WEBHOOK_SECRET`.
|
||||
`WEBHOOK_SECRET` is missing or empty in `bot/.dev.vars`. The fail-closed guard sits at the top of
|
||||
the `/telegram/webhook` branch in `bot/src/webhook.ts` and it fails closed deliberately: grammY's
|
||||
`compareSecretToken` silently accepts *every* request when the secret is `undefined`, so an
|
||||
unarmed secret must never reach it. Generate a new random string and put it in `WEBHOOK_SECRET`.
|
||||
|
||||
**`setWebhook ok:true` but arah2bot never responds.**
|
||||
Telegram accepted the URL, so it answered `ok` while delivering updates to a path that `404`s.
|
||||
@@ -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,
|
||||
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`.**
|
||||
`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
|
||||
@@ -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
|
||||
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.**
|
||||
Both scripts use bare `chromium.launch()` with no `executablePath` and no `channel`, so they need
|
||||
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
|
||||
|
||||
- **[ARCHITECTURE.md](ARCHITECTURE.md)** — how the Worker, KV namespace, Mini App, and design
|
||||
system fit together; the Worker routing table; the three-link location acquisition chain.
|
||||
- **[ARCHITECTURE.md](ARCHITECTURE.md)** — how the Worker, KV namespace, R2 graph, Mini App, and
|
||||
design system fit together; the Worker routing table; the three-link location acquisition chain.
|
||||
- **[CONFIGURATION.md](CONFIGURATION.md)** — every environment variable, `bot/wrangler.jsonc`
|
||||
and `web/vite.config.ts`, and the full command reference per workspace.
|
||||
- **[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.
|
||||
- **[../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 build` | Build theme → Mini App (`theme/dist/rams.css` must exist first) |
|
||||
| `npm run web:dev` | Vite dev server on `5173` with a mock WebApp and no Worker |
|
||||
| `npm run bot:test` / `npm run web:test` | Zero-dependency test runners |
|
||||
| `npm run bot:typecheck` / `npm run web:typecheck` | `tsc --noEmit` |
|
||||
| `npm run 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` / `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 bot:commands` | Register the persistent Telegram command menu (EN + MS) |
|
||||
| `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
|
||||
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. -->
|
||||
+161
-60
@@ -2,9 +2,19 @@
|
||||
|
||||
# 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)
|
||||
- [Coverage requirements](#coverage-requirements)
|
||||
- [Visual capture workflow](#visual-capture-workflow)
|
||||
- [Repo gate CLIs](#repo-gate-clis-privacy-audit--budget-audit)
|
||||
- [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` |
|
||||
| `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` |
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
### 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.
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
npm run bot:test # → node test/runner.mjs in bot/
|
||||
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/
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
The runners auto-discover by filename and offer no filter flag. To run a single file, invoke it directly from inside the workspace:
|
||||
|
||||
```bash
|
||||
cd bot && node test/initdata.test.mjs
|
||||
```
|
||||
|
||||
This works because a suite file's top-level `test(...)` calls register into `harness.mjs`, and `runner.mjs` is what drains the registry — so importing a suite alone reports nothing. **A single suite file run this way executes the module side effects (imports, fixture writes) but produces no pass/fail output.** Always go through the runner:
|
||||
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/runner.mjs # all bot 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:
|
||||
@@ -111,11 +129,13 @@ cd theme && node scripts/audit.test.mjs
|
||||
|
||||
### Typecheck gates
|
||||
|
||||
The `bot/` and `web/` suites do not compile. Run these alongside:
|
||||
None of the suites compile. Run these alongside:
|
||||
|
||||
```bash
|
||||
npm run bot:typecheck # tsc --noEmit in bot/
|
||||
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
|
||||
@@ -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
|
||||
```
|
||||
|
||||
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
|
||||
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 --> SR["search/test/runner.mjs"]
|
||||
ROOT --> RR["routing/test/runner.mjs"]
|
||||
ROOT --> TR["theme/scripts/audit.test.mjs"]
|
||||
|
||||
BR --> BDISC["readdir *.test.mjs → dynamic import"]
|
||||
BDISC --> BREG["bot/test/harness.mjs registry"]
|
||||
BREG --> BCASES["commands · initdata · location-pin · privacy · sessions · webhook"]
|
||||
BREG --> BCASES["cari · commands · guidance-cron · guidance-envelope · initdata · location-pin · privacy · route-privacy · sessions · webhook"]
|
||||
|
||||
WR --> WDISC["readdir *.test.mjs → dynamic import"]
|
||||
WDISC --> WREG["web/test/harness.mjs registry"]
|
||||
WREG --> WCASES["build-artifact · copy-gate · location-chain · no-storage · session · theme"]
|
||||
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/"]
|
||||
TFIX --> TSPAWN["spawnSync scripts/audit.mjs"]
|
||||
@@ -153,8 +183,8 @@ graph TD
|
||||
classDef theme fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1E1B14
|
||||
|
||||
class ROOT entry
|
||||
class BR,WR,TR runner
|
||||
class BDISC,BREG,WDISC,WREG harness
|
||||
class BR,WR,SR,RR,TR runner
|
||||
class BDISC,BREG,WDISC,WREG,SDISC,SREG,RDISC,RREG harness
|
||||
class TFIX,TSPAWN,TCHECK,TREAL theme
|
||||
```
|
||||
|
||||
@@ -167,11 +197,18 @@ graph TD
|
||||
| Rule | Value |
|
||||
|---|---|
|
||||
| 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";` |
|
||||
| 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
|
||||
|
||||
@@ -211,7 +248,7 @@ Note the theme suite uses a **bare block** (`{ … }`) rather than a named funct
|
||||
|
||||
### 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
|
||||
|
||||
`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
|
||||
function makeKV() {
|
||||
@@ -274,16 +311,16 @@ try {
|
||||
|
||||
### 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/privacy.test.mjs` PG1 — every line containing `.put(` must also contain `expirationTtl` referencing a `SCREAMING_CASE` constant.
|
||||
- `bot/test/privacy.test.mjs` PG2 — no persistence-shaped export names exist.
|
||||
- `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/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/no-storage.test.mjs` GEO3 — no banned storage token appears in `web/src` code.
|
||||
- `web/test/session.test.mjs` SESS4, `web/test/theme.test.mjs` PX02 case 3 — same shape.
|
||||
- `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, `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
|
||||
const code = src
|
||||
@@ -291,8 +328,6 @@ const code = src
|
||||
.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)
|
||||
|
||||
`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 |
|
||||
| BA5 | `installMockWebApp` / `__ARAHARAH_MOCK__` appear in **no** emitted `.js` **or** `.html` |
|
||||
| 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:
|
||||
|
||||
@@ -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
|
||||
|
||||
- **Letting a case reach the live Bot API.** `bot/test/webhook.test.mjs` documents the constraint at length: grammY's cloudflare-mod adapter calls `bot.init()` (a live `getMe`) *before* its own secret-token compare, so no committed case may let an update reach the framework with an armed secret. Armed-secret behaviour is verified by live smokes, not by the suite.
|
||||
- **Writing fixtures into the repo.** `theme/scripts/audit.test.mjs` creates everything under `/tmp/opencode/audit-test/`; the bot/web suites keep fixtures in-process entirely.
|
||||
- **Adding a test framework.** The zero-dependency property is load-bearing (decision `D-12`) and repeated across all three runners.
|
||||
- **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 runners.
|
||||
|
||||
---
|
||||
|
||||
@@ -345,8 +386,9 @@ What substitutes for coverage measurement:
|
||||
| Mechanism | Where |
|
||||
|---|---|
|
||||
| **Existence assertions on the scanned tree** | `no-storage.test.mjs` asserts `files.length >= 12` before scanning (`"scanned a real tree"`), so the gate cannot pass vacuously on an empty directory. `copy-gate.test.mjs` COPY5 asserts `files.length >= 1`. |
|
||||
| **Fixture self-checks** | `theme/scripts/audit.test.mjs` aborts with `fixture setup: … not found` if `theme/src/tokens.css` loses a key the fixtures depend on. |
|
||||
| **Typecheck as a structural gate** | `npm run bot:typecheck` / `web:typecheck` catch what the suites would not: a type change in a shape the runtime assertions never inspect. |
|
||||
| **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. |
|
||||
| **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.
|
||||
|
||||
@@ -375,7 +417,7 @@ Serves `theme/` as docroot, then captures:
|
||||
|
||||
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`:
|
||||
|
||||
@@ -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.
|
||||
|
||||
| Output | Leg | Wait mode |
|
||||
|---|---|---|
|
||||
| `shell-boot-1.png` | `?mock=android&sessiondelay=1000` | `settle400` |
|
||||
| `shell-boot-2.png` | `?mock=android&fixdelay=1200` | `settle400` |
|
||||
| `shell-live-light.png` | `?mock=android&scheme=light` | `live` — races `ARAHARAH-LIVE` on console, 2s tile settle |
|
||||
| `shell-live-dark.png` | `?mock=android&scheme=dark` | `live` |
|
||||
| `shell-denied.png` | `?mock=tdesktop` | `denied` — waits `.lamp[data-state="denied"]`, 90s budget (the 60s chat-pin poll must elapse first) |
|
||||
| `shell-chat-pin.png` | `?mock=tdesktop&pin=1` | `live` |
|
||||
| `gallery-compare.png` | Side-by-side against `stitch_minimalist_route_navigator/navigate_map_overview/screen.png` | — |
|
||||
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`.
|
||||
|
||||
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 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
|
||||
npm run bot:test
|
||||
npm run web:test
|
||||
npm run search:test
|
||||
npm run routing:test
|
||||
npm test -w theme
|
||||
npm run bot: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 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
|
||||
```
|
||||
|
||||
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 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` |
|
||||
| Typecheck the Worker | `npm run bot: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` |
|
||||
| Add a bot test | new `bot/test/<subject>.test.mjs` — pick up automatically |
|
||||
| Add a Mini App test | new `web/test/<subject>.test.mjs` — pick up automatically |
|
||||
| Run the privacy gate CLI | `npm run privacy-audit` (add `-- --live` for the live probes) |
|
||||
| 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 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.
|
||||
|
||||
Reference in New Issue
Block a user