# Configuration 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: | Surface | File | Applies to | | --- | --- | --- | | Workspaces + scripts | `package.json` | all | | Worker deployment + bindings | `bot/wrangler.jsonc` | bot | | Secret env contract | `bot/.dev.vars` (from `.dev.vars.example`) | bot + 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`, `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 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 (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: `/?dest=`. 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()` 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. | Local secret template — `bot/.dev.vars.example`: ```bash # Copy to .dev.vars and fill for local wrangler dev. NEVER commit .dev.vars. BOT_TOKEN= WEBHOOK_SECRET= # Full origin + path; set-webhook.mjs auto-appends /telegram/webhook if only an origin is set. WEBHOOK_URL= # Public Mini App origin (not a secret) — lives in bot/wrangler.jsonc "vars" in production. # The /cari venue card's OPEN button deep link is built from it: /?dest=. 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. ?? devVars.` — 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`. --- ## Configuration flow ```mermaid flowchart TD subgraph Local["Local development"] DEVVARS["bot/.dev.vars (gitignored)"] WRANGLERDEV["wrangler dev --port 8787 --local"] end subgraph Scripts["One-time Telegram registration"] SC1["scripts/set-webhook.mjs"] SC2["scripts/register-commands.mjs"] SC3["scripts/set-menu-button.mjs"] end subgraph Production["Deployed Worker"] SECRETS["wrangler secrets: BOT_TOKEN, WEBHOOK_SECRET"] KV["KV 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 DEVVARS --> SC1 DEVVARS --> SC2 DEVVARS --> SC3 SECRETS --> WORKER KV --> WORKER R2 --> WORKER ASSETS --> WORKER CRON --> WORKER WRANGLERDEV --> WORKER style Local fill:#D9D2C6,stroke:#1A1A1A,color:#1A1A1A style Scripts fill:#D9D2C6,stroke:#1A1A1A,color:#1A1A1A style Production fill:#D9D2C6,stroke:#1A1A1A,color:#1A1A1A style DEVVARS fill:#EB8008,stroke:#1A1A1A,color:#1A1A1A style SECRETS fill:#ED3F1C,stroke:#1A1A1A,color:#FFFFFF ``` The Mini App in `web/` has **no build-time or runtime configuration surface**. It calls its own origin with relative paths (`/api/session`, `/api/location`, `/api/location-fallback`), so the API base URL is implied by wherever the bundle is served from — which is why the Worker serves `../web/dist` as its own static assets and reserves `/api/*` and `/telegram/*` for itself. 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. --- ## Required vs optional settings **Fail loudly (Worker):** - `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. - `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. - 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:`, `chat:` | | `PIN_TTL_SECONDS` | `300` | `bot/src/kv/location-pin.ts` | `loc:` | | `GUIDANCE_SESSION_TTL_SECONDS` | `7200` | `bot/src/kv/guidance-sessions.ts` | `guidance:` | 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. --- ## Worker configuration file (`bot/wrangler.jsonc`) ```jsonc { // 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 ..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", "not_found_handling": "none" }, "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 (/?dest=). "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 ` + 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" }] } ``` 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 `..workers.dev` URL, which is the `WEBHOOK_URL` 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`). ### 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`, `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 | | --- | --- | | `npm run build` | Builds the theme then the web bundle (`theme` → `web`). | | `npm run deploy` | `build`, then `npm run deploy -w bot` (`wrangler deploy` + `set-webhook.mjs`). | | `npm run dev` | `build`, then `npm run dev -w bot` (`wrangler dev --port 8787 --local`). | | `npm run bot:test` / `bot:typecheck` / `bot:dev` / `bot:deploy` / `bot:commands` | Per-workspace passthroughs to the `bot` package. | | `npm run web:test` / `web:typecheck` / `web:dev` | Passthroughs to the `web` package. | | `npm run 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`, `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 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` 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. --- ## Bundler configuration (`web/vite.config.ts`) Vite is configured from a function of `mode`, with one switch driving a **dual-build contract**: - `__ARAHARAH_HARNESS__` is injected via `define` as `true` only in `--mode harness` and `false` everywhere else. Vite replaces the identifier textually, so the production bundle tree-shakes the mock installer and `/api/*` fetch stubs out entirely; `web/index.html` carries its mock profile block inside the same guarded branch. 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 (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`, `?dest=`, `?fixpair=`, `?drive=`, `?locale=en|ms`, `?sessiondelay=`, `?fixdelay=`. --- ## Design token configuration `theme/src/tokens.css` is the single source of truth for every color, spacing, typography, and radius value. `theme/src/theme.css` imports Tailwind v4 plus `tokens.css` and `components.css`, declares self-hosted Geist and Space Grotesk `@font-face` rules from `theme/fonts/` (no Google Fonts CDN), and scopes class generation with explicit `@source` lines — the four mockup markup files under `stitch_minimalist_route_navigator/` are the only allowed utility sources, with `@source not "../scripts"` and `@source not "../basemap"` excluding prose. Downstream consumers: - `web/src/shell.css` — `@import "@araharah/theme/dist/rams.css";` - `web/src/map/map.ts` — `import ramsStyle from "@araharah/theme/basemap/liberty-rams.json";` (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 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/`. Practical consequence: **`web/dist` and `theme/dist` are never committed.** Both must be rebuilt from source before a deploy, which is why `npm run deploy` and `npm run dev` both run `build` first. --- ## Per-environment overrides There are no committed `.env.development`, `.env.production`, or `.env.test` files. The separation is structural rather than file-based: | Environment | Configuration source | How it differs | | --- | --- | --- | | Local `wrangler dev` | `bot/.dev.vars` | Secrets read from the gitignored file; KV emulated locally under `bot/.wrangler/`; Mini App served from `web/dist`. | | Local `vite dev` | none | Vite dev server on 5173 with no Worker behind it — `main.ts` falls back to the mock WebApp via `import.meta.env.DEV`. | | Harness capture | `vite build --mode harness` | `web/dist-harness` plus `web/index.html`'s URL-parameter profile. Never deployed. | | Production | `wrangler.jsonc` + Cloudflare secrets | Secrets set with `wrangler secret put` rather than a file; assets come from the uploaded `../web/dist` (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`, `@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.