1
Deployment
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.

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 create fails until it is.

The one-command deploy

From the repo root:

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

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

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).

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

# 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 → arah²-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.

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.