# 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 `..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: ```bash npx wrangler login ``` - **A Telegram bot token** from [@BotFather](https://t.me/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: ```bash 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): ```bash 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`): ```bash 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: ```bash 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 `/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` ```bash 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` ```bash 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): ```bash # 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: ```bash # 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:///" curl -sSI "https:///assets/.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 && 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.