Table of Contents
- Configuration
- Environment variables
- Configuration flow
- Required vs optional settings
- Worker configuration file (bot/wrangler.jsonc)
- Build and run scripts
- Bundler configuration (web/vite.config.ts)
- Design token configuration
- Data configuration (search + routing)
- Ignore rules and generated output
- Per-environment overrides
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
Four variables reach the Worker (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: <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() 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 arah²-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:
# 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: <MINI_APP_URL>/?dest=<id>.
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.
Configuration flow
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 (arah²-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, atbot/src/webhook.ts:if (!env.WEBHOOK_SECRET)logs[webhook] rejected: misconfiguredand returns500 Server misconfigured. The rationale is documented in the file: grammY'scompareSecretTokensilently accepts every request when the secret isundefined, so an unarmed binding must never reach it.BOT_TOKEN— declared required in theEnvtype 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.jsoncalready carries a concrete id forchat_sessions; the file's leading comment notes it was filled in afternpx wrangler kv namespace create chat_sessions. -
MINI_APP_URL— a public configuration value declared in the WorkerEnvand shipped viabot/wrangler.jsonc'svarsblock with its concrete value committed (https://arah²-bot.arah²-bot.workers.dev). When it is blank the/cariOPEN button is omitted rather than guessed; the venue card and its alternates still send. -
WEBHOOK_URLpath segment — auto-derived byset-webhook.mjswhen 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_SECONDS900bot/src/kv/sessions.tssession:<token>,chat:<chat_id>PIN_TTL_SECONDS300bot/src/kv/location-pin.tsloc:<chat_id>GUIDANCE_SESSION_TTL_SECONDS7200bot/src/kv/guidance-sessions.tsguidance:<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 anexpiration. -
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)
{
// chat_sessions namespace created via `npx wrangler kv namespace create chat_sessions` — the id below is live.
"name": "arah²-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",
"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 (<MINI_APP_URL>/?dest=<id>).
"vars": { "MINI_APP_URL": "https://arah²-bot.arah²-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": "arah²-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, sonpm run build(theme then web) must run beforewrangler deploy; a stale or absentweb/distships no Mini App. Vite copiesweb/public/_headersinto the bundle, so the cache policy ships with it (see below).run_worker_firstis a glob array, not a boolean:/api/*and/telegram/*are handled bysrc/webhook.tsfirst; everything else falls through to the static asset binding.not_found_handling: "none"— unmatched paths are not served the SPAindex.htmlfallback.workers_dev: trueserves on the<name>.<account>.workers.devURL, which is theWEBHOOK_URLtarget and the committedMINI_APP_URLvalue.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 bynpm run budget-audit(seedocs/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:
/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 viadefineastrueonly in--mode harnessandfalseeverywhere else. Vite replaces the identifier textually, so the production bundle tree-shakes the mock installer and/api/*fetch stubs out entirely;web/index.htmlcarries its mock profile block inside the same guarded branch. In plainvite devthe define keepsmain.ts's ambient reference from throwing while the fallback mock still fires viaimport.meta.env.DEV.build.outDirisdist-harnessin harness mode,distotherwise, withemptyOutDir: trueandtarget: "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 withstrictPort: true.__ARAHARAH_HARNESS__is declared ambient inweb/src/vite-env.d.tssotsc --noEmitpasses in both modes.- Harness-only URL parameters (documented in
web/index.html):?mock=android|ios|tdesktop|macos|web,?scheme=light|dark,?pin=1,?dest=<gazetteer id>,?fixpair=<pairId>,?drive=<model>,?locale=en|ms,?sessiondelay=<ms>,?fixdelay=<ms>.
Design token configuration
theme/src/tokens.css is the single source of truth for every color, spacing, typography, and radius
value. theme/src/theme.css imports Tailwind v4 plus tokens.css and components.css, declares
self-hosted Geist and Space Grotesk @font-face rules from theme/fonts/ (no Google Fonts CDN),
and scopes class generation with explicit @source lines — the four mockup markup files under
stitch_minimalist_route_navigator/ are the only allowed utility sources, with
@source not "../scripts" and @source not "../basemap" excluding prose.
Downstream consumers:
web/src/shell.css—@import "@araharah/theme/dist/rams.css";web/src/map/map.ts—import ramsStyle from "@araharah/theme/basemap/liberty-rams.json";(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 athttps://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
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.
arah²
Packages
Repo
- Source tree
- API — API
- Deployment — Deployment
- Contributing — Contributing
- Core concepts — Core-Concepts