MessageWeave/cloudflare-worker/README.md

310 lines
16 KiB
Markdown

# MessageWeave Cloudflare Worker gateway
> [中文版本 / Chinese version → README.zh-CN.md](README.zh-CN.md)
This is the canonical English version. The Chinese version is
[README.zh-CN.md](README.zh-CN.md).
One public HTTPS entry point in front of one or more MessageWeave backends, with bounded
multi-origin failover (HA/LB sub-project). **Pass-through model**: the Worker does not parse
business payloads and forwards the request as-is to the backend https origins; only *timeout /
5xx* triggers a bounded failover to another instance.
> This component is a **load balancer for the fixed set of routes MessageWeave needs behind one
> URL**: the route set is fixed (19 paths), the backend origins are fixed at deploy time and
> https-only, and every unknown path gets a `404`. It only forwards requests to those fixed
> backends and fails over between them. It does not accept an arbitrary target host and
> provides no general-purpose traffic-relay capability.
>
> **Why it exists.** Cloudflare's Load Balancer product is not available on the free plan, so
> this is the way a free-plan account gets multi-origin load sharing and failover in front of
> its MessageWeave backends. That is the whole reason for it, and the gateway is optional: the
> backend runs identically without it.
>
> **Why it is not a security feature.** There is no intent here to hide the backends and no
> intent to harden them. The origins stay directly reachable with or without the gateway and no
> access control is added at the edge, so the route safelist is not a firewall and nothing in
> this document is meant to be read as protection.
> Design basis: `docs/design.md §11.3` (`NFR-HA-MULTI-INSTANCE`) and `docs/deployment.md §10`.
> Security baseline and prohibitions: `docs/charter.md §3`, `docs/charter.md §5`; stable-ID
> registry `docs/charter.md §8`.
## 1. What it is, and what it is not
- **Forward only.** Method, headers (including auth headers) and body are forwarded as-is. The
Worker performs no authentication of its own and rewrites nothing.
- **Secretless.** No Telegram bot token, no JMAP password, no session secret. It forwards
whatever auth header it is given and checks none of them. All backends must instead share
the same set of `SAF-AUTH-*` secrets — `RECONCILE_TOKEN`, `TG_WEBHOOK_SECRET` and the
encrypted business config — because a callback can land on any instance and no instance can
prove which one was asked (`C-LB-SHARED-SECRETS`). Per-instance secrets mean a random 401.
- **Not a security layer.** It is a load balancer, not an access-control boundary. The backend
origins are directly reachable with or without it, and nothing here hides or hardens them. The
safelist's fail-closed answer on unknown paths is what makes the balancer well-defined for a
fixed set of paths, not a protection to be relied on.
- **No origin discovery.** Origins come from the deploy-time origin list only.
- **Zero runtime dependencies.** Pure ES2022 plus the platform `fetch`, `Headers`, `Request`,
`Response` and `URL`. `wrangler` is a devDependency, used only by `check`, `deploy` and
`dev`.
- **Zero state.** No Redis, no JMAP, no database (`C-NO-DB`, `C-REDIS-ONLY-STATE`). The only
mutable state is an in-isolate health probe cache.
- **No long-lived connections** (`C-NO-LONG-CONN`). Pure request-response. The request body is
read once as `arrayBuffer` and re-attached on every attempt, so streaming is never required.
- **Not a queue.** There is no retry beyond the bounded attempts below. When every origin
fails the gateway answers `503`, and Telegram / Stalwart redelivery is what makes up for it.
## 2. Directory layout
| file | role |
|---|---|
| `wrangler.toml` | `name`, `main`, compatibility flags, `[vars]` |
| `package.json` | `check` / `test` / `deploy` / `dev` scripts only, no runtime dependencies |
| `src/index.js` | entry point: HTTPS redirect, route dispatch, safelist, health aggregate |
| `src/backends.js` | origin parsing and validation, route safelist |
| `src/lb.js` | pass-through forwarding and bounded failover |
| `src/health.js` | TTL-cached health aggregate probe |
| `test/*.test.js` | 38 tests in four files, all offline |
## 3. Pass-through semantics
- Method, headers and body go out unchanged; the backend response status, body and headers are
returned as-is.
- The only header the gateway adds is best-effort observability: `x-lb-backend`, naming the
origin that answered. The write sits behind a `try/catch`. A cross-origin response that
carries no `access-control-allow-*` header comes back with an immutable header guard, and
`headers.set` throws on those. An observability write that throws must never mask a healthy
`2xx` as a `503`, so the header is dropped silently when it cannot be written. That exact
defect was the 2026-09-30 production incident.
- `GET` and `HEAD` never forward a body: a bodyless upstream response is returned as it is,
and no `Content-Length` is recomputed.
- Backend `3xx` redirects are followed by the gateway itself (`redirect: "follow"`), so a
redirecting origin never hands the browser a second hop.
- Logs and error strings carry method, path, origin and failure class only. Never headers,
body, auth secrets or App Password (`SAF-LOG-PURITY`).
## 4. Route safelist
Every inbound **HTTP** request gets a permanent **308** redirect to the same HTTPS URL before
route validation or forwarding (`C-HTTPS-INBOUND`). After that the request must hit one of 19
paths and carry a method that path allows; every other combination is refused at the edge.
| path | methods |
|---|---|
| `/` | GET |
| `/assets/config.js` | GET |
| `/assets/styles.css` | GET |
| `/api/status` | GET |
| `/api/config` | GET PUT |
| `/api/business-config` | GET PUT |
| `/api/business-config/preflight` | POST |
| `/api/admin/session` | POST |
| `/api/admin/session/revoke` | POST |
| `/api/enabled` | GET PUT |
| `/webhook/tg` | POST |
| `/push/jmap` | POST |
| `/api/push/register` | POST |
| `/api/telegram/register-webhook` | POST |
| `/api/push/disable` | POST |
| `/reconcile` | POST |
| `/worker` | POST |
| `/healthz` | GET |
| `/ready` | GET |
- Unknown path: `404`, body `route not forwarded: <path>`. The gateway can therefore never be
turned into a jump host for arbitrary backend paths.
- Method mismatch on a known path: `405`, body `method not allowed for <path>`, plus an
`Allow` header.
- Bootstrap (`POST /api/bootstrap`) and the remote diagnostics face (`/debug/*`) are
deliberately absent, so both stay origin-only.
- `/healthz-worker` is not in the safelist at all: the gateway serves it itself, on `GET`
only.
## 5. Timeout and retry budget
| variable | required | default | applies to |
|---|---|---|---|
| `BACKEND_ORIGINS_JSON` | yes | — | all routes |
| `LB_REQUEST_TIMEOUT_MS` | no | `10000` | every route except the two below, and the §7.2 probe |
| `LB_MAX_ATTEMPTS` | no | `2` | attempts per request |
| `LB_RECONCILE_TIMEOUT_MS` | no | `320000` | `POST /reconcile` only |
| `LB_WORKER_TIMEOUT_MS` | no | `300000` | `POST /worker` only |
| `LB_HEALTH_TTL_MS` | no | `30000` | `/healthz-worker` probe cache only |
| `LB_VERSION` | no | `unknown` | `/healthz-worker` `version` field |
Integer env values are parsed with `Math.floor`. A non-finite or non-positive value is ignored
and the default applies, so a typo cannot switch the budget off.
`/reconcile` and `/worker` are **single-attempt routes** with per-route timeout overrides.
Both are long synchronous backend jobs: at the 10 s global timeout the gateway would declare
them failed and fail over to a second instance, but that instance answers `/reconcile` with
`409` immediately (the cluster-wide `lock:reconcile` starts with a 300 s lease, renewed by
heartbeat) and a second `/worker` would merely drain the same batch twice. They therefore get
`LB_RECONCILE_TIMEOUT_MS` (default 320 s, headroom over the 300 s lease plus heartbeats) and
`LB_WORKER_TIMEOUT_MS` (default 300 s, equal to the backend per-event floor
`SINGLE_EVENT_CEILING_FLOOR_MS`), both with `max_attempts = 1`.
`LB_WORKER_TIMEOUT_MS` must grow if the `/worker` batch grows: the backend batch default and
its hard cap are both 10.
## 6. Bounded failover
- Each request tries `max(1, min(LB_MAX_ATTEMPTS, origins.length))` origins. The default of
2 is one first attempt plus one failover.
- Only a timeout or a `5xx` response moves on to the next origin. `2xx`, `3xx` and `4xx` are
answers and are returned immediately.
- A timeout or transport failure counts as retryable when the error name is `AbortError`, or
when the message, or the message of a `cause` further down the chain, matches one of:
`fetch failed`, `network error`, `socket hang up`, `ECONNREFUSED`, `ECONNRESET`,
`EAI_AGAIN`, `ENOTFOUND`, `ETIMEDOUT`, `EHOSTUNREACH`, `ENETUNREACH`. Anything else fails
fast.
- With more than one origin and more than one attempt, the starting origin is chosen with
`rng` (defaulting to `Math.random`) so load spreads over the healthy instances. A single
origin degrades to a fixed order.
- Every origin fails: `503` with status text `All Backends Unavailable` and body
`all backends failed: <last reason>`. Telegram and Stalwart then redeliver, which is why
nothing is lost.
The health cache feeds only the `/healthz-worker` aggregate view. Forwarding does **not**
filter by health; today, failover is the safety net.
## 7. Health endpoints
The two endpoints deliberately do not overlap, so neither can hide the other.
### 7.1 GET /healthz — passthrough
Forwarded like any other safelisted route. The response is the backend's own liveness
envelope, which is exactly why it can read healthy while a different origin is down.
### 7.2 GET /healthz-worker — gateway aggregate
Answered by the gateway itself. It never touches the forwarded route set.
- Probes each origin's `/healthz` with the same request timeout as forwarding
(`LB_REQUEST_TIMEOUT_MS`, default 10 s). Redirects are followed, so an origin that answers
with a `3xx` still counts as up.
- `up` is true when the origin answered with a status below 500. A probe failure records
`up: false` with `status: null`.
- Results are cached per origin for `LB_HEALTH_TTL_MS` (default 30 s) in an in-isolate map,
to keep probe traffic bounded. A cache miss probes and stores; a fresh entry is served
without another probe.
- Response body: `{status, version, available, total, backends: [{origin, up, status}]}`.
- at least one origin up: HTTP `200`, `status: "ok"`
- every origin down: HTTP `503`, `status: "down"`
- no origins configured: HTTP `200`, `status: "no-backends"`
- `version` is `LB_VERSION`, defaulting to the literal string `unknown`
- Any method other than `GET`: `405` with `Allow: GET`.
- The gateway does not proxy Redis or JMAP readiness. End-to-end backend readiness
(`ARCH-READY-BASELINE`: config completeness, Redis reachable, JMAP session, Telegram
`getMe`) stays on the backend's `/ready`, which is passed through.
## 8. Callback registration
The SPA registers callback URLs against **one** origin (`C-LB-SINGLE-REG-URL`). Origin only,
no path:
- Gateway enabled: the Worker URL, for example `https://lb.example`.
- Gateway disabled: a backend origin, for example `https://a.example`.
Whichever origin you register is where `/webhook/tg` and `/push/jmap` land. All three
registration endpoints are safelisted and pass through the gateway, so registration works
through either front door. Registration is idempotent, and registering a new address retires
the previous subscription — that is how you switch fronts.
## 9. Configuration
- Defaults live in `wrangler.toml` `[vars]` as a **deliberate plaintext exception**: the
current value is only publicly reachable https origins, which is not a credential. The
moment you need an internal address, a URL with embedded credentials, or a hostname that
should not be public, move it to `npx wrangler secret put BACKEND_ORIGINS_JSON` and delete
the line from `[vars]`. Git history is irreversible, so deleting it does not un-publish it.
- `BACKEND_ORIGINS_JSON` is a JSON **array of plain strings**. Object shapes such as
`{"url": "..."}` are rejected: `parseBackendOrigins` throws, and because the config is
re-parsed on **every** request the failure shows up as `503` on every request rather than
at boot. The body is `misconfigured backends`. An empty list throws the same way, before
anything is forwarded.
- Each entry must be an https **origin** only: no http, no path, no query, no fragment, no
embedded credentials (`C-HTTPS-INBOUND`).
- `wrangler.toml` also pins `name = "messageweave-lb"`, `main = "src/index.js"`,
`compatibility_date = "2025-01-01"` and
`compatibility_flags = ["nodejs_compat_v2"]`. `nodejs_compat_v2` is what lets the test
files import `node:test` and `node:assert/strict`. The runtime code imports no
node module.
- Comments in `wrangler.toml` must stay single-line `#` comments. TOML has no block comment,
so a JSDoc `/** */` block makes wrangler fail to parse the file at all.
## 10. Local and CI verification
```bash
cd cloudflare-worker
npm test # 38 tests, four files, fully offline
npm run check # node --check on sources and tests, then wrangler deploy --dry-run
```
`npm test` is `node --test`, which picks up `test/*.test.js`: `test/index.test.js` 15,
`test/lb.test.js` 12, `test/backends.test.js` 6, `test/health.test.js` 5. There is no install
step for the tests — the Worker has no runtime dependencies, and the test files import only
`node:test` and `node:assert/strict`.
Two traps:
- `node --test test/` (a directory) exits 0 after running **zero** tests on some Node
versions. Always pass the file glob.
- A default CI install step of `bun install` ignores the npm lockfile: it re-resolves and can
move wrangler across major versions. Add an explicit `npm ci` before the test step if you
need the pinned wrangler.
## 11. Deploy
```bash
cd cloudflare-worker
npm install --no-audit --no-fund
npm run deploy
curl https://<your-worker>.workers.dev/healthz-worker
```
`npm run check` includes `wrangler deploy --dry-run --outdir=.build-check`, which bundles the
four sources and reports the artifact size before anything is published. The gateway is
deployed from push, so a `curl` of `/healthz-worker` and a compare of its `version` is the
acceptance check. Bump `LB_VERSION` in the same commit as any LB logic change, so you can tell
which build answered.
Two production incidents worth keeping in mind:
- **2026-09-30**: a build wrote `x-lb-backend` onto proxied responses. Cross-origin responses
without `access-control-allow-*` headers have an immutable header guard, so `headers.set`
threw and the surrounding handler logged a healthy backend as unavailable and burned its
retries — the gateway returned `503` while `GET /` actually returned `200`.
`/healthz-worker` did not go through that code path, which is exactly what hid the outage.
Fixed by making the header write best-effort.
- **Object-shaped origin lists fail silently at parse time.** `parseBackendOrigins` throws and
you get `503 misconfigured backends` on every request. Keep `weight` and any other
per-origin tuning out of the list: nothing reads them.
## 12. Design invariants
- `SAF-LB-PASSTHRU` — no auth, header or body rewriting on the path
- `C-HTTPS-INBOUND` — https-only origins, embedded credentials and paths rejected, HTTP gets a
308
- `C-LB-SINGLE-REG-URL` — one callback origin, unknown paths 404
- `C-LB-SHARED-SECRETS` — every backend carries identical business credentials
- `MOD-HEALTH-AGG` — TTL-cached aggregate probe owned by the gateway
- `C-NO-DB`, `C-REDIS-ONLY-STATE` — no database or Redis access from the gateway
- `C-NO-LONG-CONN` — request-response only
- `ARCH-READY-BASELINE` — end-to-end readiness stays on the backend's `/ready`
- `SAF-LOG-PURITY` — method, path, origin and failure class only
- `ARCH-LB-WORKER` — the gateway is the only edge component
- `NFR-HA-MULTI-INSTANCE` — multi-origin failover is how HA is realised
## References
- `docs/deployment.md §10` — gateway deployment, configuration and rollout
- `docs/reference.md §4` — gateway versus backend route matrix
- `docs/reference.md §9.2` — backend configuration and secrets
- `docs/reference.md §9.4` — health and release checks
- `docs/design.md §11.3` — HA and multi-instance design
- `docs/charter.md §3`, `docs/charter.md §5`, `docs/charter.md §8` — security baseline,
prohibitions, stable-ID registry