Table of Contents
- Deployment
- Deployment targets
- Prerequisites
- The one-command deploy
- Secrets
- Telegram registration (one-time, per bot)
- 1. Webhook — bot/scripts/set-webhook.mjs
- 2. Command menu — bot/scripts/register-commands.mjs
- 3. Menu button — bot/scripts/set-menu-button.mjs
- 4. BotFather manual steps
- Fork provisioning: the road graph
- Environment setup (production checklist)
- Post-deploy canary verification protocol
- Rollback procedure
- Monitoring
Deployment
arah² deploys as one Cloudflare Worker (arah²-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 (arah²-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 (arah²-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:
npx wrangler login -
A Telegram bot token from @BotFather.
-
R2 enabled on the account (one-time activation in the Cloudflare dashboard: R2 → Get started) — the first
r2 bucket createfails until it is.
The one-command deploy
From the repo root:
npm run deploy
which composes (package.json root + bot/package.json):
npm run build→themethenweb:theme:tailwindcss -i ./src/theme.css -o ./dist/rams.css --minify(+ fonts copied bytheme's build pipeline intodist/fonts/)web:vite build→web/dist(hashed assets + the_headerscache-policy file fromweb/public/)
npm run deploy -w bot→wrangler deploy && node scripts/set-webhook.mjswrangler deployuploads the Worker and theweb/diststatic assets in one deployment.scripts/set-webhook.mjsre-registers the Telegram webhook (see below) — this step readsbot/.dev.vars(or env), so the deploy command requiresbot/.dev.varsto exist withBOT_TOKEN,WEBHOOK_URL,WEBHOOK_SECRETfilled 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://arah²-bot.arah²-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.
Secrets
Production secrets are Cloudflare Workers secrets, never committed (D-15/D-17):
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):
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:
cd bot && node scripts/set-webhook.mjs
- Sends
setWebhookwithurl,secret_token,allowed_updates: ["message"]. - Auto-appends the path: if
WEBHOOK_URLis 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 answerok:truewhile 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
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) · clearall_group_chats· clearall_chat_administrators. - Why every scope: Telegram resolves a chat's menu from the narrowest scope that has ANY
commands set — a stale
all_private_chatsset 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.tshelpMenulines — the registered menu and the/helpreply 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:trueproved nothing. The Bot API field isscope(core.telegram.org/bots/api/methods/setmycommands). General law: read the response back with the matchingget*method before trusting any registration.
3. Menu button — bot/scripts/set-menu-button.mjs
cd bot && node scripts/set-menu-button.mjs
setChatMenuButtonwithmenu_button: { type: "web_app", text: "arah²", web_app: { url: origin } }(default scope — all private chats). The app origin is derived by stripping/telegram/webhookfromWEBHOOK_URL.
4. BotFather manual steps
In the @BotFather chat (not scriptable from this repo):
- 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. /setuserpic— set the bot's profile picture from the project's logo asset (assets/MASTER_FILE-arah2_logo.svg).
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):
# 1. Create the bucket (once). Name must agree with bot/wrangler.jsonc's ROAD_GRAPH bucket_name.
npx wrangler r2 bucket create arah²-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 arah²-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 arah²-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 inbot/.dev.varstoo for the scripts). - Public var:
MINI_APP_URL= the deployed origin (wrangler.jsoncvars). - Bindings:
chat_sessions(KV namespace — the id inwrangler.jsoncis a live created namespace; a fork creates its own vianpx wrangler kv namespace create chat_sessionsand updates the id),ROAD_GRAPH(R2 bucket),ASSETS(static assets, wired bywrangler deployfrom../web/dist). - Cron:
*/2 * * * *is committed inwrangler.jsoncand 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:
# 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_errorstaleness:last_errorcannot clear until the next real update arrives — recordlast_error_dateand compare it against the deploy timeline before judging a fresh deploy broken.- Read back per scope:
setMyCommandsok:truesays nothing about which scope received the commands — onlygetMyCommandsper 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:
- Redeploy the previous known-good commit:
git checkout <last-good-sha> && npm run deploy. - Cloudflare retains recent deployment versions per Worker — a point-in-time rollback is also available from the Cloudflare dashboard (Workers & Pages → arah²-bot → Deployments).
- 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.
- The road graph degrades honestly: an absent/corrupt R2 object yields router-pending
200 nulleverywhere, so a bad graph upload is recoverable by re-uploading — no Worker redeploy needed. - Cron cards are stateless and the envelopes TTL out at 7200 s — a bad guidance deploy ages out of existence within ~2 hours.
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-auditandnpm run budget-audit(add-- --livefor 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.mdholds the measured record.
arah²
Packages
Repo
- Source tree
- API — API
- Deployment — Deployment
- Contributing — Contributing
- Core concepts — Core-Concepts