Files
arah2/docs/DEPLOYMENT.md
T

285 lines
15 KiB
Markdown
Raw Blame History

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.
<!-- generated-by: gsd-doc-writer -->
# 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:
```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.
<!-- VERIFY: the workers.dev origin in bot/wrangler.jsonc is account-specific; a fork's deployed URL differs. -->
## 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 `<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`
```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`).
<!-- VERIFY: BotFather's exact menu paths and the registered Mini App URL are per-account Telegram configuration, not repo facts. -->
## 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://<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.
<!-- VERIFY: the Cloudflare dashboard's per-Worker deployment-version retention window is platform state, not a repo fact. -->
## 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.
<!-- VERIFY: Workers Logs limits and the Cloudflare dashboard URLs are platform-documentation values; see developers.cloudflare.com and docs/BUDGETS.md's sourced ceiling table. -->