Files
arah2/docs/DEPLOYMENT.md

15 KiB
Raw Permalink Blame History

Deployment

arah² deploys as one Cloudflare Worker (araharah-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 (araharah-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 (araharah-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://araharah-bot.araharah-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 araharah-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 araharah-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 araharah-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 → araharah-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.