285 lines
15 KiB
Markdown
285 lines
15 KiB
Markdown
<!-- 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. -->
|