2
Configuration
araharah-executor edited this page 2026-10-25 12:36:12 +08:00
This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, 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://arah²-bot.arah²-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:<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.


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, 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 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:

/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=<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 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 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.