docs: fold quickstart into README, rename gateway→load balancer

Two changes requested by the user:

1. Fold the hand-holding quickstart tutorial into README §3 (both
   languages). Delete docs/quickstart.md and docs/quickstart.zh-CN.md.
   Remove them from all 5 validator DOCS lists, README §6 doc index,
   and charter §7 boundary table. The tutorial now lives as
   README §3.0–§3.11: prerequisites, create Telegram bot, find chat id,
   get JMAP credentials, run backend, sign in, fill config, register
   callbacks, enable processing, verify, schedule drain, troubleshoot.

2. Replace 'gateway'/'网关' with 'load balancer'/'负载均衡器' across
   all markdown (README, docs/, AGENTS.md, cloudflare-worker/README).
   Also fix 4 markdown anchor links whose heading slugs changed.
   Also fix charter §8 '18 safe routes' → '19' (was missed in the
   earlier reference.md fix).

GATE-DOCS 6/6 PASS (71 anchors, 727 path-refs, 455 sec-refs).
This commit is contained in:
anonsaber 2026-10-08 21:35:14 +08:00
parent 7ed1a086fe
commit 29178cb093
20 changed files with 442 additions and 552 deletions

View File

@ -153,7 +153,7 @@
- 诊断 / 联调面必须默认**绝对关闭**,且关闭态必须做到「路由不存在」(落到通用 404),
而不是「路由存在但拒绝」。
- 开启必须要求**两个独立因子同时满足**(一个启动期显式开关 + 一个密钥环境变量)。
- 诊断面不得暴露在任何公共网关 / CDN 白名单内,只能直连后端 origin 访问。
- 诊断面不得暴露在任何公共负载均衡器 / CDN 白名单内,只能直连后端 origin 访问。
- 诊断面必须是只读或纯函数式探测:不得写入外部系统状态,不得修改配置。
- 若含测试性写操作,必须要求显式目标标识;**空目标必须视为拒绝而非放行**——
「空白名单 = 放行一切」是必须显式校验的坑。
@ -191,8 +191,8 @@
### 6.5 跨层改动
- 改公共 API / 状态存储键与 TTL / 配置项 / 错误码 / 用户可见文案时,
**文档、后端、前端或网关侧必须同轮同步**。
- 网关白名单改动必须同步路由文档与白名单测试。
**文档、后端、前端或负载均衡器侧必须同轮同步**。
- 负载均衡器白名单改动必须同步路由文档与白名单测试。
- **文案是产品行为的一部分。** 面向用户的提示若与后端实际行为不符,就是一个 bug——
填错的用户无法自助排障。
@ -237,7 +237,7 @@
2. 实现 + 测试。
3. 跑门禁,全部通过。
4. 跑文档校验器,全部通过。
5. 同轮同步文档(代码 ↔ 测试 ↔ 文档 ↔ 前端 / 网关)。
5. 同轮同步文档(代码 ↔ 测试 ↔ 文档 ↔ 前端 / 负载均衡器)。
6. 提交。
**不得**:

230
README.md
View File

@ -32,24 +32,24 @@ State is an architectural boundary here, not a preference.
One image, one Redis, one SPA. The only difference is what sits in front and which URL you hand
to Telegram and Stalwart.
**The Cloudflare Worker gateway is optional.** Nothing in the backend requires it, and the
**The Cloudflare Worker load balancer is optional.** Nothing in the backend requires it, and the
backend runs identically with or without it. It exists for one reason: Cloudflare's Load
Balancer product is not available on the free plan, so a Worker is the way a free-plan account
puts load sharing and failover in front of several backends.
**It is also not a security layer, and it is not meant to be one.** There is no intent to hide
the backends and no intent to harden them; the origins stay directly reachable whether the
gateway is present or not, and no access control is added at the edge. The fixed route safelist
load balancer is present or not, and no access control is added at the edge. The fixed route safelist
is what makes it a load balancer for a bounded set of paths, not a firewall.
| | Form A: backend origin only | Form B: behind the Cloudflare Worker gateway |
| | Form A: backend origin only | Form B: behind the Cloudflare Worker load balancer |
|---|---|---|
| What answers requests | one backend host | one Worker URL, N backend origins |
| Failover | none — a down origin stays down | bounded: 2 attempts by default, only timeout and `5xx` retry |
| Callback URL you register | `https://a.example` | `https://lb.example` |
| Backend access | direct from the network | equally direct — the gateway adds no access control |
| Gateway route set | n/a — there is no gateway | fixed at 19 paths; unknown paths answer `404` |
| Health to watch | `GET /ready` on the origin | `GET /ready` proxied, plus `GET /healthz-worker` for the gateway aggregate |
| Backend access | direct from the network | equally direct — the load balancer adds no access control |
| Load balancer route set | n/a — there is no load balancer | fixed at 19 paths; unknown paths answer `404` |
| Health to watch | `GET /ready` on the origin | `GET /ready` proxied, plus `GET /healthz-worker` for the load balancer aggregate |
| Extra moving parts | none | one Worker deploy, one origin list |
| Docs | `docs/deployment.md §2` | `docs/deployment.md §10`, `cloudflare-worker/README.md` |
@ -61,44 +61,213 @@ new address retires the previous subscription, so moving from Form A to Form B,
field in the SPA. Do not register both origins at once: there is a single registration slot, and
the platform entrance must stay internal (`C-LB-SINGLE-REG-URL`).
No secret moves to the gateway in Form B. It holds no business credential and checks no auth
No secret moves to the load balancer in Form B. It holds no business credential and checks no auth
header. Instead every backend must carry the same `SAF-AUTH-*` secrets and the same encrypted
business config, because a callback can land on any instance and no instance can prove it was
the one asked for (`C-LB-SHARED-SECRETS`). Mis-matched secrets show up as a random 401, not as
a routing error.
`POST /api/bootstrap` and `/debug/*` are not in the gateway's route set, so requests addressed
`POST /api/bootstrap` and `/debug/*` are not in the load balancer's route set, so requests addressed
to the Worker URL never reach them. That is the safelist working as designed for the paths that
need to be load balanced; it is not hiding anything, because the same origins are reachable at
their own addresses in both forms.
## 3. 5-minute setup
## 3. Setup, from zero
Requires a reachable Redis instance and a JMAP session plus a Telegram bot token.
If you do not have those yet, the hand-holding walkthrough in
[`docs/quickstart.md`](docs/quickstart.md) covers BotFather, chat-id lookup, app-password
creation and the full first-run from zero.
This section is a hand-holding walkthrough: create a Telegram bot, deploy one backend, fill
the configuration page, register the callbacks, get the first notification. Field semantics,
defaults, TTLs and error codes live in `docs/reference.md`; production, scaling and load
balancer operations live in `docs/deployment.md`.
**This is a local container smoke test.** Production deployment can use any platform that
can run the backend container, inject encrypted secrets, and connect to externally managed
Redis. See `docs/deployment.md` §3 and the platform-specific reference in `docs/reference.md`.
### 3.0 What you need
- A mailbox on a JMAP server you administer (this guide assumes Stalwart), HTTPS only.
- A Telegram account.
- A Redis endpoint with persistence (Upstash's free tier works; it is TLS-only, so the URL
starts with `rediss://`).
- A machine or platform that can run one container and expose one HTTP port.
- One public HTTPS origin that can receive webhooks — your own domain, a tunnel, or a
Cloudflare route. Telegram and Stalwart both require HTTPS.
- Optional: an OpenAI-compatible LLM key, a Cloudflare account if you want the load balancer.
Nothing else is required: no Postgres, no message broker, no Kubernetes.
### 3.1 Create the Telegram bot
1. In Telegram open a chat with `@BotFather` and send `/newbot`.
2. Pick a display name, then a username that ends in `bot` or `_bot`.
3. BotFather replies with a token that looks like `1234567890:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw1`.
That string is the bot token. Treat it like a password: anyone holding it can read your
webhook traffic and send messages as your bot. It is never echoed back by the API once saved.
4. Send the new bot any message (for example `/start`) from the account or group that should
receive notifications. Until you do, Telegram has no chat to deliver to.
### 3.2 Find the chat id
1. Open `https://api.telegram.org/bot<TOKEN>/getUpdates` in a browser, replacing `<TOKEN>` with
the token from step 1.
2. In the JSON, find `result[].message.chat.id`. Private chats give a positive number such as
`123456789`; groups and supergroups give a negative one such as `-1001234567890`.
3. For a group, add the bot to the group first, then send a message in the group before you call
`getUpdates`. The bot only sees messages in groups after you disable privacy mode under
`@BotFather` → `/setprivacy` → `Disable`, otherwise group messages never reach it.
You will use this id twice in step 6: as the notification target and as a member of the inbound
allowlist. The allowlist must contain at least one id — an empty allowlist is rejected as invalid
configuration rather than treated as "allow everyone".
### 3.3 Get the JMAP credentials
1. In your Stalwart admin, create an application password for the mailbox — an app password, not
the account login password. The backend authenticates with HTTP Basic over JMAP.
2. The session URL is your mail origin: `https://mail.example.com`. You may also paste
`https://mail.example.com/.well-known/jmap`; the backend collapses both to the origin, and
rejects any URL with embedded credentials, a query string or a fragment.
3. The JMAP account id is optional. Leave it empty: after the first successful save the backend
reads it back from the server and the page shows it.
### 3.4 Run the backend
The container needs exactly two environment variables:
```sh
cp .env.example .env # fill REDIS_URL and CONFIG_ENCRYPTION_KEY
docker build -t messageweave:latest .
docker run --env-file .env -p 8080:8080 messageweave:latest
# .env — never commit this file
REDIS_URL=rediss://default:<password>@<host>:6379
CONFIG_ENCRYPTION_KEY=<64 hex characters, from: openssl rand -hex 32>
```
Then open `http://localhost:8080` in a browser and enter your `CONFIG_ENCRYPTION_KEY`
value. The SPA exchanges it with `POST /api/admin/session` for a 1,800-second admin session,
then writes the business secrets to Redis with `PUT /api/business-config`. No environment
variable is read or written again for business configuration.
- `REDIS_URL` is your only state store. Back it up like a database.
- `CONFIG_ENCRYPTION_KEY` doubles as the SPA admin credential and the encryption key for stored
business secrets. If you lose it you can no longer read the configuration page or decrypt the
stored secrets; if you rotate it, previously stored secrets become unreadable.
Then build and start:
```sh
docker build -t messageweave .
docker run --rm --env-file .env -p 8080:8080 messageweave
curl localhost:8080/healthz # → ok
```
For local builds without Docker: `cargo build --locked` produces the `message-weave` binary.
If the service answers requests but does nothing useful, check the logs for a startup
warning: a missing required variable does **not** crash the process. It serves a
read-only setup router over in-memory state instead. See §4.
`GET /healthz` is a static liveness reply and never touches Redis. The readiness report is
`GET /ready`: it answers 503 until the configuration exists and every configured upstream is
reachable, and 200 with a JSON component report once they are. Point your HTTPS reverse proxy or
tunnel at this port now — steps 6 and 7 need a public HTTPS origin.
If the service answers requests but does nothing useful, check the logs for a startup warning:
a missing required variable does **not** crash the process. It serves a read-only setup router
over in-memory state instead. See §4.
### 3.5 Sign in to the configuration page
1. Open the page in a browser. If the backend started with the required variables the first card
is **Create admin session**.
2. Paste `CONFIG_ENCRYPTION_KEY` — not the Redis password. This mints an admin session that lives
only in the page's memory for 30 minutes; reloading the page logs you out.
### 3.6 Fill in the business configuration
The form is a full replacement, not a patch: whatever you submit becomes the whole stored
configuration. The sections:
- **01 JMAP mailbox** — session URL and username, plus the app password from step 3.3.
- **02 Telegram and access auth** — bot token, notification chat id, allowlist, and three secrets
you make up yourself: webhook secret, reconcile token and worker token. Generate them with
`openssl rand -hex 32` each. The webhook secret must match on the Telegram side later; the
reconcile and worker tokens protect the scheduler endpoints.
- **03 LLM service** — optional. Leave disabled unless you want AI digests; if you enable it, the
HTTPS base URL, model name and API key all become required.
- **04 Notification display** — the time zone used to render received times in notifications.
Then, in order:
1. Click **Test connection**. It preflights the credentials without saving anything.
2. Click **Save business config and hot-reload**. The first save must contain every required
field or the server answers 422 and stores nothing.
3. Read the result line under the button: `runtime_applied: true` means the new runtime was
installed on this backend. `false` means it saved but the runtime was not installed, and the
`warnings` array says which component failed — most often an unreachable `jmap_session_url`.
Fix the field and save again; `/ready` stays 503 until it goes green.
4. If the save answers **409 conflict**, someone else (another tab, a second operator) saved in
between. Reload the page to pick up their values, then re-apply your edit.
Secrets are write-only: the page never shows a stored secret again, and submitting an empty secret
field keeps the previously stored value — clear a secret deliberately in a separate edit.
### 3.7 Register the callbacks
Still on the same page, under **External callbacks**:
1. Paste the public HTTPS origin that reaches this backend, scheme and host only, no path —
for example `https://mw.example.com`. If you put the Cloudflare load balancer in front, use its
origin, not the backend's: callbacks must survive a backend restart, and the load balancer is
what stays up.
2. Click **Register callbacks**. One click registers both directions: the Telegram webhook at
`/webhook/tg` and the Stalwart push subscription at `/push/jmap`. Verification is handled
automatically; the page reports the result.
Registration is explicit and repeatable. Registering a new origin cancels the previous subscription,
so the old origin stops receiving pushes. A failed registration never removes the old one — if it
can't reach Telegram, the previous webhook stays in place. If it did reach Telegram but not
Stalwart, the page reports a partial failure and tells you to click again.
Verify from a terminal:
```sh
curl -s "https://api.telegram.org/bot<TOKEN>/getWebhookInfo"
# expect "url": "https://mw.example.com/webhook/tg" and "pending_update_count": 0
```
### 3.8 Turn on processing
Flip **Enable business processing** at the top of the page. This is the master switch: with it off,
webhooks are accepted but `/worker` and `/reconcile` refuse to drain, so nothing reaches Telegram.
With it on, incoming mail and messages are enqueued and the worker drains them.
### 3.9 Verify end to end
1. Send yourself an email. Within a few seconds the bot should post a notification with the
sender, subject and received time — never the body. That path alone proves JMAP ingest,
Redis, the queue and the Telegram send all work.
2. In Telegram try `/search 发票`. Note: the search triggers are the Chinese words `搜索`, `查找`
and `检索`, plus the `/search` command — English keywords like `search invoice` are not
recognised as triggers.
3. Try `/summary <email_id>` from the notification's email id. The first call asks for
confirmation; the summary only goes out after you confirm, and only if the LLM section is
enabled.
### 3.10 Schedule the drain
Push delivery can be delayed or missed; an external scheduler is the safety net. On any machine
that can reach your origin, every 5–10 minutes:
```sh
MW_APP_URL=https://mw.example.com \
MW_WORKER_TOKEN=<worker token> \
MW_RECONCILE_TOKEN=<reconcile token> \
scripts/cron-drain.sh
```
The script drains the queue (`/worker`) and rescans for missed pushes (`/reconcile`); both return
204 with an empty body on success. `MW_RECONCILE_TOKEN` is optional — if omitted, the reconcile
step is skipped and only the queue is drained. If you put the load balancer in front, `/worker`
must reach the backend origin directly — see `docs/deployment.md` §10 for why.
### 3.11 When something is wrong
- **Nothing arrives after saving.** Check the enable toggle (step 3.8), then
`getWebhookInfo` (step 3.7). A `last_error_message` about a 401 means the webhook secret does not
match what Telegram sends — re-save section 02 and re-register.
- **`/ready` answers 503.** That body is just an error code — it does not name the failing
component. When `/ready` returns 200, its JSON has per-field booleans (`jmap`, `telegram`,
`redis`). Common causes are an unreachable `jmap_session_url`, a wrong app password, or
`runtime_applied: false` from the last save.
- **The form says conflict.** Two sessions saved at once; reload and redo (step 3.6).
- **The bot ignores you in a group.** Privacy mode is on; disable it under `@BotFather` (step 3.2).
- **`/search` answers with a help hint.** Your query started with a Latin word; the prefix triggers
are `搜索` / `查找` / `检索`.
## 4. Configuration entry points
@ -127,7 +296,7 @@ secrets from the environment in normal operation.
returns 503 until configuration, Redis, and both upstreams (a JMAP session `GET` and
Telegram `getMe`, 3s each) are reachable. Probe `/ready`, not `/healthz`, when deciding
whether to route traffic — but `/ready` is the heavier probe (~3s worst case), so a monitor
that must stay cheap should watch the gateway aggregate `GET /healthz-worker` when the gateway
that must stay cheap should watch the load balancer aggregate `GET /healthz-worker` when the load balancer
is enabled.
**Remote debug (opt-in, off by default).**
@ -135,7 +304,7 @@ is enabled.
business config, and a single Telegram send. It sits behind a two-factor gate — the process
must be started with `--debug` **and** `DEBUG_TOKEN` must be set; miss either and the routes
do not exist at all (requests fall through to a generic 404). Nothing under `/debug/*` is on
the gateway allowlist, so it is reachable only on the backend origin itself. The only rule for
the load balancer allowlist, so it is reachable only on the backend origin itself. The only rule for
running it is to keep it off: leave `--debug` out of the start command and leave `DEBUG_TOKEN`
unset. If you do turn it on for a one-off diagnosis, configure the chat allowlist first — with
an empty allowlist the test send is not restricted to any chat. See
@ -152,7 +321,7 @@ startup. It is only compared in constant time, and is never echoed, logged, or s
Redis ACL password, if you have one, authenticates the Redis connection alone and is not
the credential for any HTTP endpoint.
The optional Cloudflare Worker gateway in front of the backends is not part of this boundary.
The optional Cloudflare Worker load balancer in front of the backends is not part of this boundary.
It holds no credential and checks no auth header, and the backends behind it are directly
reachable either way, so nothing about it changes what stands between an attacker and your
mail. See §2.
@ -161,14 +330,13 @@ mail. See §2.
| Document | What it answers |
|---|---|
| [`docs/quickstart.md`](docs/quickstart.md) | Hand-holding first run: create a Telegram bot, find the chat id, deploy one backend, fill the form, register callbacks |
| [`docs/design.md`](docs/design.md) | Why the system is shaped this way: data flow, JMAP semantics, Redis streams, AI consent rules |
| [`docs/deployment.md`](docs/deployment.md) | How to deploy: Dockerfile, secrets, Redis hosting, webhook/push/reconcile routing, multi-instance load balancing |
| [`docs/reference.md`](docs/reference.md) | **Single source of truth for verifiable facts** — Redis keys and TTLs, error codes, routes, environment layers, budgets |
| [`docs/opengaps.md`](docs/opengaps.md) | Gaps, blockers and the next phase |
| [`docs/retired.md`](docs/retired.md) | What was tried and dropped — abandoned routes, unreleased designs, and names that never existed |
| [`docs/charter.md`](docs/charter.md) | Project charter: goals, locked technology choices, security invariants, prohibitions, and the stable-ID registry |
| [`cloudflare-worker/README.md`](cloudflare-worker/README.md) | The Cloudflare Worker gateway: route safelist, timeout and retry budget, bounded failover, health aggregate, deploy |
| [`cloudflare-worker/README.md`](cloudflare-worker/README.md) | The Cloudflare Worker load balancer: route safelist, timeout and retry budget, bounded failover, health aggregate, deploy |
| [`AGENTS.md`](AGENTS.md) | Language-agnostic engineering norms: code style, config and secrets, build environment, testing gates, document governance |
Facts that matter are traceable. If two documents disagree, `docs/reference.md` wins.

View File

@ -29,18 +29,18 @@ MessageWeave 位于 JMAP 邮件服务器(例如 Stalwart)与 Telegram bot
同一个镜像、同一个 Redis、同一套 SPA。唯一的区别是前面放什么,以及你把哪个 URL 交给 Telegram 和 Stalwart。
**Cloudflare Worker 网关是可选项。** 后端没有任何东西依赖它,有它和没有它跑起来完全一样。它存在的理由是单一的:Cloudflare 的 Load Balancer 产品在免费套餐上不可用,所以 Worker 是免费套餐账户在多个后端前面做负载均衡与故障转移的办法。
**Cloudflare Worker 负载均衡器是可选项。** 后端没有任何东西依赖它,有它和没有它跑起来完全一样。它存在的理由是单一的:Cloudflare 的 Load Balancer 产品在免费套餐上不可用,所以 Worker 是免费套餐账户在多个后端前面做负载均衡与故障转移的办法。
**它也不是安全层,本意不是拿来干这个的。** 没有隐藏后端的意图,也没有加固后端的意图;网关在不在,origin 都同样可以直接访问,边缘上也没有加任何访问控制。固定的路由白名单是让它在有限的一组路径上做负载均衡的手段,不是防火墙。
**它也不是安全层,本意不是拿来干这个的。** 没有隐藏后端的意图,也没有加固后端的意图;负载均衡器在不在,origin 都同样可以直接访问,边缘上也没有加任何访问控制。固定的路由白名单是让它在有限的一组路径上做负载均衡的手段,不是防火墙。
| | 形态 A:仅后端 origin | 形态 B:前置 Cloudflare Worker 网关 |
| | 形态 A:仅后端 origin | 形态 B:前置 Cloudflare Worker 负载均衡器 |
|---|---|---|
| 谁应答请求 | 一个后端主机 | 一个 Worker URL 对 N 个后端 origin |
| 故障转移 | 没有——origin 挂了就一直挂着 | 有界——默认 2 次尝试,只重试超时与 `5xx` |
| 你注册的回调 URL | `https://a.example` | `https://lb.example` |
| 后端可达性 | 网络上直连可达 | 同样直连可达——网关不加访问控制 |
| 网关路由集 | 不适用——没有网关 | 固定 19 个路径;未知路径返回 `404` |
| 要看哪个健康接口 | origin 上的 `GET /ready` | 透传的 `GET /ready`,外加网关聚合 `GET /healthz-worker` |
| 后端可达性 | 网络上直连可达 | 同样直连可达——负载均衡器不加访问控制 |
| 负载均衡器路由集 | 不适用——没有负载均衡器 | 固定 19 个路径;未知路径返回 `404` |
| 要看哪个健康接口 | origin 上的 `GET /ready` | 透传的 `GET /ready`,外加负载均衡器聚合 `GET /healthz-worker` |
| 额外组件 | 无 | 一个 Worker 部署、一份 origin 列表 |
| 文档 | `docs/deployment.md §2` | `docs/deployment.md §10`、`cloudflare-worker/README.zh-CN.md` |
@ -48,32 +48,142 @@ MessageWeave 位于 JMAP 邮件服务器(例如 Stalwart)与 Telegram bot
切换是一次重新注册,不是迁移。注册是幂等的,注册新地址会注销旧订阅,所以从形态 A 换到形态 B(或反过来)只是 SPA 里改一个字段。不要同时注册两个 origin:注册槽只有一个,后端的平台入口不对外注册(`C-LB-SINGLE-REG-URL`)。注意这里说的是「不注册」,不是「不可达」——两种形态下 origin 都同样可以直接访问。
形态 B 里没有任何 secret 搬到网关。它不持有任何业务凭据,也不校验任何认证头。真正要求一致的是每个后端都携带同一组 `SAF-AUTH-*` secret 与同一份加密业务配置——因为回调可能落在任何一个实例上,而任何实例都证明不了自己是被点名的那一个(`C-LB-SHARED-SECRETS`)。凭据不一致表现为随机 401,而不是路由错误。
形态 B 里没有任何 secret 搬到负载均衡器。它不持有任何业务凭据,也不校验任何认证头。真正要求一致的是每个后端都携带同一组 `SAF-AUTH-*` secret 与同一份加密业务配置——因为回调可能落在任何一个实例上,而任何实例都证明不了自己是被点名的那一个(`C-LB-SHARED-SECRETS`)。凭据不一致表现为随机 401,而不是路由错误。
`POST /api/bootstrap` 与 `/debug/*` 不在网关的路由集里,所以发给 Worker URL 的请求到不了它们。这是白名单在需要被负载均衡的那些路径上按设计工作,并不是在隐藏什么——两种形态下 origin 都同样可以通过自己的地址直接访问。
`POST /api/bootstrap` 与 `/debug/*` 不在负载均衡器的路由集里,所以发给 Worker URL 的请求到不了它们。这是白名单在需要被负载均衡的那些路径上按设计工作,并不是在隐藏什么——两种形态下 origin 都同样可以通过自己的地址直接访问。
## 3. 五分钟跑起来
## 3. 从零开始
需要可用的 Redis 服务、一个 JMAP session 与一个 Telegram bot token。
如果这些你还没有,[`docs/quickstart.zh-CN.md`](docs/quickstart.zh-CN.md) 有手把手教程:BotFather
申请、chat id 获取、应用密码创建与首次配置全过程。
本节是手把手教程:创建 Telegram bot、部署一个后端、填写配置页、注册回调、收到第一条通知。字段语义、默认值、TTL 与错误码在 `docs/reference.md`;生产、扩展与负载均衡器运维在 `docs/deployment.md`。
这段是**本地容器冒烟测试**。生产部署可以用任何能跑起后端容器、注入加密 secret 并连到外部托管 Redis 的平台,见 `docs/deployment.md` §3 与 `docs/reference.md` 的平台相关部分。
### 3.0 你需要准备什么
- 一个你管理的 JMAP 邮箱(本指南以 Stalwart 为例),必须 HTTPS。
- 一个 Telegram 账号。
- 一个带持久化的 Redis 实例(Upstash 免费版可用;它是纯 TLS 的,URL 以 `rediss://` 开头)。
- 一台能跑容器并暴露一个 HTTP 端口的机器或平台。
- 一个能接收 webhook 的公网 HTTPS 域名——自有域名、隧道或 Cloudflare 路由都行。Telegram 与 Stalwart 都要求 HTTPS。
- 可选:OpenAI 兼容的 LLM 密钥;想要负载均衡器的话再来一个 Cloudflare 账号。
不需要别的:没有 Postgres、没有消息队列、没有 Kubernetes。
### 3.1 创建 Telegram bot
1. 在 Telegram 中打开 `@BotFather` 的对话,发送 `/newbot`。
2. 选一个显示名,再选一个以 `bot` 或 `_bot` 结尾的用户名。
3. BotFather 会回复一串 token,形如 `1234567890:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw1`。这就是 bot token。当成密码对待:拿到它的人能读取你的 webhook 流量、以你的 bot 身份发消息。一旦保存,API 不再回显它。
4. 用应当接收通知的账号或群给新 bot 发任意一条消息(例如 `/start`)。在此之前 Telegram 没有可投递的对话。
### 3.2 找到 chat id
1. 在浏览器打开 `https://api.telegram.org/bot<TOKEN>/getUpdates`,把 `<TOKEN>` 替换为第 1 步拿到的 token。
2. 在 JSON 中找 `result[].message.chat.id`。私聊是正数,例如 `123456789`;群与超级群是负数,例如 `-1001234567890`。
3. 如果是群:先把 bot 拉进群,再在群里发一条消息,然后才调 `getUpdates`。bot 在群里只有在关闭隐私模式后才能看到非命令消息:`@BotFather` → `/setprivacy` → `Disable`。
这个 id 在第 6 步要用两次:一次当通知目标,一次放进入站白名单。白名单必须至少一个 id——空名单会被判为非法配置而拒绝,不会当成「允许所有人」。
### 3.3 拿到 JMAP 凭据
1. 在 Stalwart 管理端为邮箱创建一个应用密码——是应用密码,不是账号登录密码。后端用 HTTP Basic 走 JMAP。
2. session URL 就是你的邮箱源站:`https://mail.example.com`。也可以粘贴 `https://mail.example.com/.well-known/jmap`;后端会把两者都折叠成源站,并拒绝带凭据、查询串或片段的 URL。
3. JMAP account id 是可选的。留空:首次保存成功后后端会从服务器读回,页面会显示出来。
### 3.4 跑起后端
容器只需要两个环境变量:
```sh
cp .env.example .env # 填写 REDIS_URL 与 CONFIG_ENCRYPTION_KEY
docker build -t messageweave:latest .
docker run --env-file .env -p 8080:8080 messageweave:latest
# .env — 不要提交这个文件
REDIS_URL=rediss://default:<password>@<host>:6379
CONFIG_ENCRYPTION_KEY=<64 位十六进制,用:openssl rand -hex 32>
```
然后在浏览器打开 `http://localhost:8080`,输入你的 `CONFIG_ENCRYPTION_KEY` 值。SPA 用它
经 `POST /api/admin/session` 换取 1,800 秒(30 分钟)的管理会话,再用 `PUT /api/business-config`
把业务密钥写入 Redis。此后业务配置不再读取或写入任何环境变量。
- `REDIS_URL` 是你唯一的状态存储。像数据库一样备份它。
- `CONFIG_ENCRYPTION_KEY` 兼作 SPA 管理员凭据与已存业务密钥的加密密钥。丢了它,你既登不上配置页,也解不开已存密钥;轮换它,之前存的密钥全部不可读。
然后构建并启动:
```sh
docker build -t messageweave .
docker run --rm --env-file .env -p 8080:8080 messageweave
curl localhost:8080/healthz # → ok
```
不用 Docker 的本地构建:`cargo build --locked` 产出 `message-weave` 二进制。
如果服务有响应但什么都不做,请查看启动日志的告警:**缺少必需变量不会让进程崩溃**,它会
退化为运行在内存态上的只读配置路由。见 §4。
`GET /healthz` 是静态存活回复,不碰 Redis。就绪报告是 `GET /ready`:在配置存在且所有已配置上游可达之前回答 503,之后回答 200 并带一份 JSON 组件报告。现在把你的 HTTPS 反代或隧道指向这个端口——第 6、7 步需要一个公网 HTTPS 域名。
如果服务有响应但什么都不做,请查看启动日志的告警:**缺少必需变量不会让进程崩溃**,它会退化为运行在内存态上的只读配置路由。见 §4。
### 3.5 登录配置页
1. 在浏览器打开页面。如果后端带着所需变量启动,第一张卡是 **Create admin session**。
2. 粘贴 `CONFIG_ENCRYPTION_KEY`——不是 Redis 密码。这会签发一个管理员会话,只活在页面内存里,有效期 30 分钟;刷新页面即登出。
### 3.6 填写业务配置
表单是整存整换,不是增量补丁:你提交的就是整份存储配置。各节:
- **01 JMAP mailbox**——session URL、用户名,以及第 3.3 步的应用密码。
- **02 Telegram and access auth**——bot token、通知 chat id、白名单,加上你自己生成的三个密钥:webhook 密钥、reconcile token 与 worker token。各自用 `openssl rand -hex 32` 生成。webhook 密钥之后要在 Telegram 侧对得上;reconcile 与 worker token 保护调度端点。
- **03 LLM service**——可选。不需要 AI 摘要就别开;一旦开启,HTTPS base URL、模型名与 API key 三者都变成必填。
- **04 Notification display**——通知里收信时间的渲染时区。
然后按顺序:
1. 点 **Test connection**。它预检凭据但不保存任何东西。
2. 点 **Save business config and hot-reload**。首次保存必须包含所有必填字段,否则服务器回答 422 且什么也不存。
3. 读按钮下方那行结果:`runtime_applied: true` 表示新运行时已在本后端装配;`false` 表示存了但运行时未装配,`warnings` 数组会指出失败的组件——最常见是 `jmap_session_url` 不可达。改字段再存一次;`/ready` 会一直 503 直到它变绿。
4. 如果保存回答 **409 conflict**,说明有人(另一个标签页、第二个运维者)在中间也存了。刷新页面拿到他们的值,再把你的改动重新套上去。
密钥是只写的:页面永不再显示已存密钥,提交空的密钥字段会保留之前的值——要清密钥就在单独一次编辑里故意留空。
### 3.7 注册回调
仍在同一页,**External callbacks** 区:
1. 粘贴能到达本后端的公网 HTTPS 源站,只写 scheme + host,不带路径——例如 `https://mw.example.com`。如果你把 Cloudflare 负载均衡器放在前面,就填负载均衡器的源站,不是后端的:回调要能在后端重启后仍然可达,而负载均衡器才是那个不掉线的。
2. 点 **Register callbacks**。一次点击注册双向:Telegram webhook 注册到 `/webhook/tg`,Stalwart push 订阅注册到 `/push/jmap`。验证自动完成,页面上报结果。
注册是显式的、可重复的。注册新源站会取消上一个订阅,于是旧源站不再收到 push。失败的注册绝不会删掉旧订阅——如果连不上 Telegram,上一个 webhook 原样留着;如果连上了 Telegram 但没连上 Stalwart,页面会报部分失败并让你再点一次。
在终端验证:
```sh
curl -s "https://api.telegram.org/bot<TOKEN>/getWebhookInfo"
# 期望 "url": "https://mw.example.com/webhook/tg" 且 "pending_update_count": 0
```
### 3.8 打开处理开关
把页面顶部的 **Enable business processing** 拨开。这是总开关:关着时 webhook 照收,但 `/worker` 与 `/reconcile` 拒绝排空,所以什么也到不了 Telegram。打开后,入站邮件与消息才会进队列、由 worker 排空。
### 3.9 端到端验证
1. 给自己发一封邮件。几秒内 bot 应当贴出一条通知,含发件人、主题与收信时间——绝不包含正文。这一条路径同时证明 JMAP 入站、Redis、队列与 Telegram 发送全通。
2. 在 Telegram 里试 `/search 发票`。注意:搜索触发词是中文 `搜索`、`查找`、`检索`,外加 `/search` 命令——`search invoice` 这类英文关键词不会被识别为触发。
3. 从通知里的 email id 试 `/summary <email_id>`。首次调用会请求确认;只有你确认之后、且 LLM 节开启时,摘要才会发出。
### 3.10 排程排空
Push 投递可能延迟或丢失;外部调度器是兜底。在任意一台能访问你源站的机器上,每 5–10 分钟跑一次:
```sh
MW_APP_URL=https://mw.example.com \
MW_WORKER_TOKEN=<worker token> \
MW_RECONCILE_TOKEN=<reconcile token> \
scripts/cron-drain.sh
```
脚本会排空队列(`/worker`)并重扫漏掉的 push(`/reconcile`);两者成功都回答 204 空 body。`MW_RECONCILE_TOKEN` 是可选的——省略时跳过 reconcile 步骤,只排空队列。如果你把负载均衡器放在前面,`/worker` 必须直达后端源站——原因见 `docs/deployment.md` §10。
### 3.11 出问题时
- **保存后什么也没到。** 查处理开关(第 3.8 步),再查 `getWebhookInfo`(第 3.7 步)。`last_error_message` 里提到 401,说明 webhook 密钥与 Telegram 发来的不一致——重存 02 节并重新注册。
- **`/ready` 回 503。** 503 的 body 只是一个错误码,不会指名是哪个组件挂了。`/ready` 回 200 时,JSON 里有逐组件布尔值(`jmap`、`telegram`、`redis`)。常见原因:`jmap_session_url` 不可达、应用密码错、或上次保存 `runtime_applied: false`。
- **表单提示冲突。** 两个会话同时存了;刷新重做(第 3.6 步)。
- **bot 在群里不理你。** 隐私模式开着;去 `@BotFather` 关掉(第 3.2 步)。
- **`/search` 回的是帮助提示。** 你的查询以拉丁词开头;触发词是 `搜索` / `查找` / `检索`。
## 4. 配置入口
@ -98,12 +208,12 @@ docker run --env-file .env -p 8080:8080 messageweave:latest
`GET /healthz` 无条件返回 200——它是存活探测,不是就绪探测。`GET /ready` 在配置、Redis 与
两条上游(JMAP session `GET`、Telegram `getMe`,各 3s)可达之前返回 503。判断是否可路由
流量时,请探测 `/ready` 而不是 `/healthz`;但 `/ready` 是较重的探针(最坏约 3s),需要
保持轻量的监控在启用网关时请看网关聚合 `GET /healthz-worker`。
保持轻量的监控在启用负载均衡器时请看负载均衡器聚合 `GET /healthz-worker`。
**远程联调面(可选,默认关闭)。**
`/debug/*` 是一套可选的远程联调面:JMAP 与 Telegram 实时探针、当前业务配置,以及单条
Telegram 发送。它由双因子开关控制——进程必须带 `--debug` **且** 已设置 `DEBUG_TOKEN`;
任一缺失则这些路由完全不存在(请求落到通用 404)。`/debug/*` 不在网关白名单内,因此只能
任一缺失则这些路由完全不存在(请求落到通用 404)。`/debug/*` 不在负载均衡器白名单内,因此只能
直连后端 origin 访问。运行它唯一的规则就是不要打开:启动命令里不加 `--debug`,环境变量里不
设 `DEBUG_TOKEN`。若确实要为一次排障临时开启,请先配好 chat 白名单——白名单为空时,测试
发送不会受限到任何 chat。见 `docs/deployment.md` §2.1。
@ -114,20 +224,19 @@ Telegram 发送。它由双因子开关控制——进程必须带 `--debug` **
有一条推论与部署直接相关:SPA 管理凭据——也就是 bootstrap 的信任根——就是 `CONFIG_ENCRYPTION_KEY` 本身,即启动时提供的那个 32 字节 hex 值。它只做常数时间比较,绝不回显、记录或落盘。Redis ACL 密码(如有)只认证 Redis 连接本身,不是任何 HTTP 端点的认证凭据。
前置在后端前面的可选 Cloudflare Worker 网关不在这个边界内。它不持有任何凭据,也不校验任何认证头;网关在不在,后端都同样可以直接访问,所以它不会改变攻击者与你的邮件之间的任何东西。见 §2。
前置在后端前面的可选 Cloudflare Worker 负载均衡器不在这个边界内。它不持有任何凭据,也不校验任何认证头;负载均衡器在不在,后端都同样可以直接访问,所以它不会改变攻击者与你的邮件之间的任何东西。见 §2。
## 6. 去哪读更多
| 文档 | 回答什么 |
|---|---|
| [`docs/quickstart.zh-CN.md`](docs/quickstart.zh-CN.md) | 手把手首次运行:创建 Telegram bot、找到 chat id、部署一个后端、填表、注册回调 |
| [`docs/design.zh-CN.md`](docs/design.zh-CN.md) | 为什么这样设计:数据流、JMAP 语义、Redis streams、AI 授权规则 |
| [`docs/deployment.zh-CN.md`](docs/deployment.zh-CN.md) | 怎么部署:Dockerfile、secrets、Redis 托管、webhook/push/对账路由、多实例负载均衡 |
| [`docs/reference.zh-CN.md`](docs/reference.zh-CN.md) | **可核对事实的唯一权威来源**——Redis 键与 TTL、错误码、路由、环境变量分层、预算常量 |
| [`docs/opengaps.zh-CN.md`](docs/opengaps.zh-CN.md) | 缺口、阻塞项与下一阶段目标 |
| [`docs/retired.zh-CN.md`](docs/retired.zh-CN.md) | 试过但没用的:废弃路线、未落地的设计与从未存在的名字 |
| [`docs/charter.zh-CN.md`](docs/charter.zh-CN.md) | 项目章程:项目目标、锁定的技术选型、安全不变量、禁止事项与稳定 ID 注册表 |
| [`cloudflare-worker/README.zh-CN.md`](cloudflare-worker/README.zh-CN.md) | Cloudflare Worker 网关:路由白名单、超时与重试预算、有界故障转移、健康聚合、部署 |
| [`cloudflare-worker/README.zh-CN.md`](cloudflare-worker/README.zh-CN.md) | Cloudflare Worker 负载均衡器:路由白名单、超时与重试预算、有界故障转移、健康聚合、部署 |
| [`AGENTS.md`](AGENTS.md) | 语言无关的工程规范:代码风格、配置与密钥、构建环境、测试门禁、文档治理 |
重要的事实都可追溯。两份文档不一致时,以 `docs/reference.md` 为准。

View File

@ -1,4 +1,4 @@
# MessageWeave Cloudflare Worker gateway
# MessageWeave Cloudflare Worker load balancer
> [中文版本 / Chinese version → README.zh-CN.md](README.zh-CN.md)
@ -18,11 +18,11 @@ business payloads and forwards the request as-is to the backend https origins; o
>
> **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
> its MessageWeave backends. That is the whole reason for it, and the load balancer 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
> intent to harden them. The origins stay directly reachable with or without the load balancer 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.
@ -52,7 +52,7 @@ business payloads and forwards the request as-is to the backend https origins; o
- **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.
fails the load balancer answers `503`, and Telegram / Stalwart redelivery is what makes up for it.
## 2. Directory layout
@ -70,7 +70,7 @@ business payloads and forwards the request as-is to the backend https origins; o
- 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
- The only header the load balancer 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
@ -78,7 +78,7 @@ business payloads and forwards the request as-is to the backend https origins; o
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
- Backend `3xx` redirects are followed by the load balancer 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`).
@ -111,13 +111,13 @@ paths and carry a method that path allows; every other combination is refused at
| `/healthz` | GET |
| `/ready` | GET |
- Unknown path: `404`, body `route not forwarded: <path>`. The gateway can therefore never be
- Unknown path: `404`, body `route not forwarded: <path>`. The load balancer 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`
- `/healthz-worker` is not in the safelist at all: the load balancer serves it itself, on `GET`
only.
## 5. Timeout and retry budget
@ -136,7 +136,7 @@ Integer env values are parsed with `Math.floor`. A non-finite or non-positive va
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
Both are long synchronous backend jobs: at the 10 s global timeout the load balancer 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
@ -177,9 +177,9 @@ The two endpoints deliberately do not overlap, so neither can hide the other.
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
### 7.2 GET /healthz-worker — load balancer aggregate
Answered by the gateway itself. It never touches the forwarded route set.
Answered by the load balancer 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
@ -195,7 +195,7 @@ Answered by the gateway itself. It never touches the forwarded route set.
- 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
- The load balancer 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.
@ -204,11 +204,11 @@ Answered by the gateway itself. It never touches the forwarded route set.
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`.
- Load balancer enabled: the Worker URL, for example `https://lb.example`.
- Load balancer 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
registration endpoints are safelisted and pass through the load balancer, 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.
@ -266,7 +266,7 @@ 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
four sources and reports the artifact size before anything is published. The load balancer 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.
@ -276,7 +276,7 @@ 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`.
retries — the load balancer 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
@ -290,18 +290,18 @@ Two production incidents worth keeping in mind:
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
- `MOD-HEALTH-AGG` — TTL-cached aggregate probe owned by the load balancer
- `C-NO-DB`, `C-REDIS-ONLY-STATE` — no database or Redis access from the load balancer
- `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
- `ARCH-LB-WORKER` — the load balancer 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/deployment.md §10` — load balancer deployment, configuration and rollout
- `docs/reference.md §4` — load balancer 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

View File

@ -1,4 +1,4 @@
# MessageWeave Cloudflare Worker 网关
# MessageWeave Cloudflare Worker 负载均衡器
> [English version / 英文版 → README.md](README.md)
@ -6,9 +6,9 @@
> 本组件是**针对 MessageWeave 需要放在同一个 URL 后面的一组固定路由的负载均衡器**:路由集合固定(19 个路径)、后端 origin 在部署时固定且仅允许 https、任何未知路径一律 `404`。它只把这些固定后端之间的请求做转发与故障转移,不接受任意目标主机,也不提供通用的流量中转能力。
>
> **它的用途是负载均衡,不是防护。** Cloudflare 的 Load Balancer 产品在免费套餐上不可用,所以这是免费套餐账户在它的那些 MessageWeave 后端前面做多 origin 负载分摊与故障转移的办法。这就是它存在的唯一理由,网关是可选的:后端有它和没有它跑起来完全一样。
> **它的用途是负载均衡,不是防护。** Cloudflare 的 Load Balancer 产品在免费套餐上不可用,所以这是免费套餐账户在它的那些 MessageWeave 后端前面做多 origin 负载分摊与故障转移的办法。这就是它存在的唯一理由,负载均衡器是可选的:后端有它和没有它跑起来完全一样。
>
> **它不是安全特性。** 这里没有隐藏后端的意图,也没有加固后端的意图。网关在不在,origin 都同样可以直接访问,边缘上也没有加任何访问控制,所以路由白名单不是防火墙,本文档任何一处都不该被读成防护。
> **它不是安全特性。** 这里没有隐藏后端的意图,也没有加固后端的意图。负载均衡器在不在,origin 都同样可以直接访问,边缘上也没有加任何访问控制,所以路由白名单不是防火墙,本文档任何一处都不该被读成防护。
> 设计依据:`docs/design.md §11.3`(`NFR-HA-MULTI-INSTANCE`)与 `docs/deployment.md §10`。
> 安全基线与禁令:`docs/charter.md §3`、`docs/charter.md §5`;稳定 ID 注册表 `docs/charter.md §8`。
@ -22,7 +22,7 @@
- **零运行时依赖。** 纯 ES2022 加平台提供的 `fetch`、`Headers`、`Request`、`Response`、`URL`。`wrangler` 是 devDependency,只用于 `check`、`deploy`、`dev`。
- **零状态。** 不连 Redis、不连 JMAP、不连数据库(`C-NO-DB`、`C-REDIS-ONLY-STATE`)。唯一的可变状态是 isolate 内的健康探测缓存。
- **无长连接**(`C-NO-LONG-CONN`)。纯请求-响应。请求体只读一次为 `arrayBuffer`,每次尝试重新挂上去,因此不需要流式。
- **不是队列。** 除了下面那几轮有界尝试,不做任何重试。所有 origin 都失败时网关返回 `503`,靠 Telegram / Stalwart 的自身重投兜底。
- **不是队列。** 除了下面那几轮有界尝试,不做任何重试。所有 origin 都失败时负载均衡器返回 `503`,靠 Telegram / Stalwart 的自身重投兜底。
## 2. 目录结构
@ -39,9 +39,9 @@
## 3. 透传语义
- method、headers、body 原样发出;后端响应的 status、body、headers 原样返回。
- 网关唯一新增的 header 是尽力而为的观测头:`x-lb-backend`,标记应答的 origin。写入放在 `try/catch` 里。跨域且不带 `access-control-allow-*` 的响应,其 header guard 是 immutable,`headers.set` 会抛异常。一次会抛异常的观测写入绝不能把健康的 `2xx` 掩盖成 `503`,所以写不进去时静默放弃该 header。这个缺陷就是 2026-09-30 的生产事故根因。
- 负载均衡器唯一新增的 header 是尽力而为的观测头:`x-lb-backend`,标记应答的 origin。写入放在 `try/catch` 里。跨域且不带 `access-control-allow-*` 的响应,其 header guard 是 immutable,`headers.set` 会抛异常。一次会抛异常的观测写入绝不能把健康的 `2xx` 掩盖成 `503`,所以写不进去时静默放弃该 header。这个缺陷就是 2026-09-30 的生产事故根因。
- `GET` 和 `HEAD` 不带 body 转发:上游没有 body 的响应直接原样返回,不重算 `Content-Length`。
- 后端的 `3xx` 由网关自己跟随(`redirect: "follow"`),跳转型的 origin 不会把第二跳甩给浏览器。
- 后端的 `3xx` 由负载均衡器自己跟随(`redirect: "follow"`),跳转型的 origin 不会把第二跳甩给浏览器。
- 日志与错误串只包含 method、path、origin、失败类别,绝不含 headers、body、认证 secret 或 App Password(`SAF-LOG-PURITY`)。
## 4. 路由白名单
@ -70,10 +70,10 @@
| `/healthz` | GET |
| `/ready` | GET |
- 未知路径:`404`,body 为 `route not forwarded: <path>`。因此网关永远不可能被当作跳板去访问后端的任意路径。
- 未知路径:`404`,body 为 `route not forwarded: <path>`。因此负载均衡器永远不可能被当作跳板去访问后端的任意路径。
- 路径已知但 method 不匹配:`405`,body 为 `method not allowed for <path>`,并附带 `Allow` header。
- 引导接口(`POST /api/bootstrap`)与远程诊断面(`/debug/*`)刻意不在白名单内,两者都只保留在 origin 直连。
- `/healthz-worker` 完全不在白名单里:它由网关自己应答,且只接受 `GET`。
- `/healthz-worker` 完全不在白名单里:它由负载均衡器自己应答,且只接受 `GET`。
## 5. 超时与重试预算
@ -89,7 +89,7 @@
整数型 env 值用 `Math.floor` 解析。非有限数或非正值会被忽略并回落到默认值,所以手滑不会把预算关掉。
`/reconcile` 与 `/worker` 是**单次尝试路由**,各自有独立的超时覆盖。两者都是后端耗时的同步长任务:按全局 10 s 超时,网关会判定它们失败并故障转移到第二个实例,但第二个实例对 `/reconcile` 会立刻返回 `409`(集群级 `lock:reconcile` 初始租约 300 s,靠心跳续租),而第二个 `/worker` 只会把同一批消息再排一遍。所以这两条路由拿到 `LB_RECONCILE_TIMEOUT_MS`(默认 320 s,比 300 s 租约加心跳多留余量)与 `LB_WORKER_TIMEOUT_MS`(默认 300 s,等于后端单条事件地板 `SINGLE_EVENT_CEILING_FLOOR_MS`),并且都是 `max_attempts = 1`。
`/reconcile` 与 `/worker` 是**单次尝试路由**,各自有独立的超时覆盖。两者都是后端耗时的同步长任务:按全局 10 s 超时,负载均衡器会判定它们失败并故障转移到第二个实例,但第二个实例对 `/reconcile` 会立刻返回 `409`(集群级 `lock:reconcile` 初始租约 300 s,靠心跳续租),而第二个 `/worker` 只会把同一批消息再排一遍。所以这两条路由拿到 `LB_RECONCILE_TIMEOUT_MS`(默认 320 s,比 300 s 租约加心跳多留余量)与 `LB_WORKER_TIMEOUT_MS`(默认 300 s,等于后端单条事件地板 `SINGLE_EVENT_CEILING_FLOOR_MS`),并且都是 `max_attempts = 1`。
如果 `/worker` 的批大小上调,`LB_WORKER_TIMEOUT_MS` 必须跟着加:后端批量的默认值与硬上限都是 10。
@ -111,9 +111,9 @@
与其他白名单路由一样被转发。响应是后端自己的 liveness envelope,这也正是它在另一个 origin 已挂时依然显示健康的原因。
### 7.2 GET /healthz-worker — 网关聚合
### 7.2 GET /healthz-worker — 负载均衡器聚合
由网关自己应答,完全不接触被转发的路由集合。
由负载均衡器自己应答,完全不接触被转发的路由集合。
- 用与转发同源的请求超时(`LB_REQUEST_TIMEOUT_MS`,默认 10 s)探测每个 origin 的 `/healthz`。重定向会被跟随,所以返回 `3xx` 的 origin 同样计为 up。
- origin 应答状态码低于 500 即为 `up`。探测失败记 `up: false` 且 `status: null`。
@ -124,16 +124,16 @@
- 未配置 origin:HTTP `200`,`status: "no-backends"`
- `version` 即 `LB_VERSION`,未配置时为字面量 `unknown`
- 任何非 `GET` 的 method:`405`,并带 `Allow: GET`。
- 网关不代理 Redis 或 JMAP 的 readiness。端到端后端就绪判定(`ARCH-READY-BASELINE`:配置完整、Redis 可达、JMAP session、Telegram `getMe`)留在后端的 `/ready` 上,`/ready` 本身被透传。
- 负载均衡器不代理 Redis 或 JMAP 的 readiness。端到端后端就绪判定(`ARCH-READY-BASELINE`:配置完整、Redis 可达、JMAP session、Telegram `getMe`)留在后端的 `/ready` 上,`/ready` 本身被透传。
## 8. 回调注册
SPA 的回调 URL 只注册**一个** origin(`C-LB-SINGLE-REG-URL`)。只填 origin,不带路径:
- 启用网关:填 Worker URL,例如 `https://lb.example`。
- 不启用网关:填后端 origin,例如 `https://a.example`。
- 启用负载均衡器:填 Worker URL,例如 `https://lb.example`。
- 不启用负载均衡器:填后端 origin,例如 `https://a.example`。
你注册的是哪个 origin,`/webhook/tg` 与 `/push/jmap` 就落在哪。三个注册接口都在白名单内、都会经过网关透传,所以两种前门都能注册。注册是幂等的,且注册新地址会注销旧订阅——换前门就靠这一步。
你注册的是哪个 origin,`/webhook/tg` 与 `/push/jmap` 就落在哪。三个注册接口都在白名单内、都会经过负载均衡器透传,所以两种前门都能注册。注册是幂等的,且注册新地址会注销旧订阅——换前门就靠这一步。
## 9. 配置
@ -168,11 +168,11 @@ npm run deploy
curl https://<your-worker>.workers.dev/healthz-worker
```
`npm run check` 内含 `wrangler deploy --dry-run --outdir=.build-check`,会在发布前把四个源文件打包并报告产物体积。网关由 push 触发部署,所以对 `/healthz-worker` 发一次 `curl` 并对比其 `version` 就是验收手段。改 LB 逻辑的提交要同时 bump `LB_VERSION`,这样才能判断线上是哪一次构建在应答。
`npm run check` 内含 `wrangler deploy --dry-run --outdir=.build-check`,会在发布前把四个源文件打包并报告产物体积。负载均衡器由 push 触发部署,所以对 `/healthz-worker` 发一次 `curl` 并对比其 `version` 就是验收手段。改 LB 逻辑的提交要同时 bump `LB_VERSION`,这样才能判断线上是哪一次构建在应答。
两起值得记住的生产事故:
- **2026-09-30**:某次构建把 `x-lb-backend` 写进了代理响应。跨域且不带 `access-control-allow-*` 的响应 header guard 是 immutable,`headers.set` 抛异常,外层 catch 于是把一个健康的后端记成不可用并耗尽重试——网关返回 `503`,而 `GET /` 实际返回的是 `200`。`/healthz-worker` 不走那段代码,这正是事故被掩盖的原因。已改为把 header 写入做成尽力而为。
- **2026-09-30**:某次构建把 `x-lb-backend` 写进了代理响应。跨域且不带 `access-control-allow-*` 的响应 header guard 是 immutable,`headers.set` 抛异常,外层 catch 于是把一个健康的后端记成不可用并耗尽重试——负载均衡器返回 `503`,而 `GET /` 实际返回的是 `200`。`/healthz-worker` 不走那段代码,这正是事故被掩盖的原因。已改为把 header 写入做成尽力而为。
- **对象形式的 origin 列表在解析期静默失败。** `parseBackendOrigins` 抛异常后每个请求都是 `503 misconfigured backends`。`weight` 之类的 per-origin 调参不要写进列表,没有任何地方读它们。
## 12. 设计不变量
@ -181,18 +181,18 @@ curl https://<your-worker>.workers.dev/healthz-worker
- `C-HTTPS-INBOUND` — origin 仅允许 https,拒绝内嵌凭据与路径,HTTP 走 308
- `C-LB-SINGLE-REG-URL` — 只注册一个回调 origin,未知路径 404
- `C-LB-SHARED-SECRETS` — 所有后端携带同一组业务凭据
- `MOD-HEALTH-AGG` — 由网关持有的带 TTL 健康聚合探测
- `C-NO-DB`、`C-REDIS-ONLY-STATE` — 网关不访问数据库与 Redis
- `MOD-HEALTH-AGG` — 由负载均衡器持有的带 TTL 健康聚合探测
- `C-NO-DB`、`C-REDIS-ONLY-STATE` — 负载均衡器不访问数据库与 Redis
- `C-NO-LONG-CONN` — 仅请求-响应
- `ARCH-READY-BASELINE` — 端到端就绪判定留在后端的 `/ready`
- `SAF-LOG-PURITY` — 仅 method、path、origin、失败类别
- `ARCH-LB-WORKER` — 网关是唯一边缘组件
- `ARCH-LB-WORKER` — 负载均衡器是唯一边缘组件
- `NFR-HA-MULTI-INSTANCE` — 多 origin 故障转移即 HA 的实现方式
## References
- `docs/deployment.md §10` — 网关部署、配置与上线
- `docs/reference.md §4` — 网关与后端的路由矩阵
- `docs/deployment.md §10` — 负载均衡器部署、配置与上线
- `docs/reference.md §4` — 负载均衡器与后端的路由矩阵
- `docs/reference.md §9.2` — 后端配置与 secret
- `docs/reference.md §9.4` — 健康与发布检查
- `docs/design.md §11.3` — HA 与多实例设计

View File

@ -43,7 +43,7 @@ A JMAP email notification service deployed on Docker + externally hosted Redis:
| Log | tracing + tracing-subscriber | 0.1 / 0.3 |
| Scheduling | External cron → `POST /reconcile` | — |
| Front-end | Native HTML + single-file JS + single-file CSS, **No build tools, no frameworks** | — |
| Gateway | Cloudflare Worker (Pure JS, zero dependencies) | — |
| Load balancer | Cloudflare Worker (Pure JS, zero dependencies) | — |
## 3. Security boundary (cannot be violated)
@ -65,8 +65,8 @@ A JMAP email notification service deployed on Docker + externally hosted Redis:
14. **AI requires user-level external authorization**: Only chats that have been explicitly authorized and have not expired will trigger LLM; authorization is an external behavior and the process does not authorize on behalf of it.
15. **Business Whitelist**: Outbound Telegram chat must hit `CHAT_ALLOWLIST`.
16. **The diagnostic interface is closed by default**: `/debug/*` requires `DEBUG_ENABLED` (or `--debug`) + `DEBUG_TOKEN` to be satisfied at the same time.
17. **The diagnostic interface does not enter the gateway**: `/debug/*` is not in Worker `SAFE_ROUTES` and can only be directly connected to the backend origin.
18. **The gateway only transparently transmits and does not make decisions**: Worker does not parse the request body, does not verify the business logic, and does not issue `Retry-After`.
17. **The diagnostic interface does not enter the load balancer**: `/debug/*` is not in Worker `SAFE_ROUTES` and can only be directly connected to the backend origin.
18. **The load balancer only transparently transmits and does not make decisions**: Worker does not parse the request body, does not verify the business logic, and does not issue `Retry-After`.
19. **No retry for AI results**: Failure of LLM analysis will be regarded as "no notification", no retry, and no downgrade to no AI notification.
20. **Stateless process**: Two container instances are safe to run concurrently; no local writes.
21. **No long polling scheduling**: Scheduling is triggered by external cron `POST /reconcile`.
@ -82,7 +82,7 @@ A JMAP email notification service deployed on Docker + externally hosted Redis:
| 2 | Telegram channel self-built client (without introducing bot framework) | `ARCH-DEPS-STAGE4`, `MOD-TELEGRAM-NOTIFY` |
| 3 | AI optional access | `REQ-AI-EXTERNAL-CONSENT`, `REQ-AI-FUSE` |
| 4 | Redis implementation | `ARCH-DEPS-STAGE4` |
| 5 | Production (debug surface, gateway, reconciliation) | `MOD-DEBUG`, `SAF-DEBUG-GATE` |
| 5 | Production (debug surface, load balancer, reconciliation) | `MOD-DEBUG`, `SAF-DEBUG-GATE` |
**Phase numbers are historical order, not current status statements. ** The current status is subject to `reference.md` and code.
@ -92,7 +92,7 @@ A JMAP email notification service deployed on Docker + externally hosted Redis:
2. **Do not delete tests, skip verification, relax inspection levels, or delete access controls in order to pass the access control. **
3. **Do not reference non-existent dependencies, functions, types, Redis keys or line numbers in documentation or comments. **
4. **You are not allowed to write "plan to implement X" comments** in the code; placeholder implementations must be explicitly marked as placeholders.
5. **No build tools or frameworks may be introduced in SPA/Gateway**; `web/` maintains single-file JS + single-file CSS.
5. **No build tools or frameworks may be introduced in SPA/Load balancer**; `web/` maintains single-file JS + single-file CSS.
6. **The SPA must not be allowed to persist any credentials** (no `localStorage` / `sessionStorage` / Cookie assignments).
7. **Unverified code shall not be submitted**; the access control results must come from the actual execution of this round.
8. **Do not claim to have passed the real environment verification unless it is really connected this round. **
@ -171,15 +171,14 @@ Answer only one question per document; **tables, lists and values must not be du
|---|---|---|
| [`../AGENTS.md`](../AGENTS.md) | Universal, language-independent code writing and environment building specifications | Quality rules |
| `README.md` / `README.zh-CN.md` | User-oriented: what is it, how to run it, how to configure it | User-visible facts (information must be equivalent in Chinese and English) |
| `docs/quickstart.md` / `docs/quickstart.zh-CN.md` | Hand-holding first run: create a Telegram bot, find the chat id, deploy one backend, fill the form, register callbacks | Steps and decisions (field semantics, defaults and TTLs stay in `docs/reference.md`) |
| `docs/design.md` | Why it is designed this way: data flow, module boundaries, state machines, error handling | Architectural intent |
| `docs/deployment.md` | How to deploy: Dockerfile, secrets, gateway, multiple instances, cron | Deployment and operation and maintenance |
| `docs/deployment.md` | How to deploy: Dockerfile, secrets, load balancer, multiple instances, cron | Deployment and operation and maintenance |
| `docs/reference.md` | The single authoritative source of verifiable facts: routing, Redis keys and TTLs, configuration items, error codes, outbound constants | **verifiable facts** |
| `docs/opengaps.md` | Gaps, Blockages, Next Stage Goals | Open Items |
| `docs/retired.md` | Records and alternative links to abandoned or renamed plans | Historical decisions |
| `docs/charter.md` | This document: project constraints, technology selection, implementation stage, security invariants, stable ID registry | Project constraints |
| `web/` (no documentation, 4 files) | Manage SPA sources: static configuration pages and front-end logic, covered by `web/config.test.mjs` | Front-end behavior (authoritative facts documented in `docs/design.md` and `docs/reference.md`) |
| `cloudflare-worker/README.md` / `cloudflare-worker/README.zh-CN.md` | Configuration and semantics of the gateway itself | Gateway |
| `cloudflare-worker/README.md` / `cloudflare-worker/README.zh-CN.md` | Configuration and semantics of the load balancer itself | Load balancer |
**Cross-document citation rules**:
@ -277,7 +276,7 @@ Answer only one question per document; **tables, lists and values must not be du
| `SAF-DEBUG-GATE` | src/main.rs / src/debug.rs / docs/design.md §7.6 / docs/deployment.md §2.1 | Two-factor gate: "`DEBUG_ENABLED` is true or command line with `--debug`" **and** `DEBUG_TOKEN` Routes are mounted only if they are not empty; if any are missing, they are not mounted at all (the request fails with `404`), and it is absolutely closed by default. The start signal goes through env instead of argv, so that the startup command remains static, and the switch can be switched at a single point on the platform console | Security |
| `SAF-DEBUG-AUTH` | src/debug.rs / docs/deployment.md §2.1 | After mounting `/debug/*` requires `Authorization: Bearer DEBUG_TOKEN` constant time comparison, fails with `401` and has no side effects | Security |
| `REQ-DEBUG-ENDPOINTS` | src/debug.rs / docs/reference.md §3 / docs/deployment.md §2.1 | Endpoint contracts: `GET /debug/ping`, `/config`, `/redis`, `/jmap`, `/telegram`, `/worker` are all read-only; `POST /debug/notify` Send a test message through the real outbound link; the response body does not contain the original text of secret (the credential field only returns `*_configured` Boolean, and the non-ciphertext identity and budget fields are still returned in plain text) | Requirements |
| `SAF-DEBUG-ORIGIN-ONLY` | docs/deployment.md §2.1 / docs/reference.md §4 | `/debug/*` is not among the 18 safe routes of the gateway, and the Worker will always receive `404 route not forwarded`; it can only be directly connected to the backend origin, and the public network is unreachable | Security |
| `SAF-DEBUG-ORIGIN-ONLY` | docs/deployment.md §2.1 / docs/reference.md §4 | `/debug/*` is not among the 19 safe routes of the load balancer, and the Worker will always receive `404 route not forwarded`; it can only be directly connected to the backend origin, and the public network is unreachable | Security |
| `SAF-DEBUG-ALLOWLIST` | src/debug.rs / docs/deployment.md §2.1 | `POST /debug/notify` only verifies `chat_id` when the chat whitelist** is not empty**; it will not intercept when the whitelist is not configured (empty), so to enable this page, you must confirm that the business whitelist has been configured | Security |
| `NFR-HA-MULTI-INSTANCE` | docs/deployment.md §10.7 / §9.1 | Multi-instance high availability semantics; both active-active or active-standby; Redis single point of failure is not within the scope of the solution (user external solution) | Non-functional |
| `C-NO-DB` | docs/deployment.md §0 / §9.1 / §3 | Production does not use any database (no SQLite/Postgres/MySQL/embedded), Redis is the only state store; the application does not connect to a second database | Constraints |

View File

@ -43,7 +43,7 @@
| 日志 | tracing + tracing-subscriber | 0.1 / 0.3 |
| 调度 | 外部 cron → `POST /reconcile` | — |
| 前端 | 原生 HTML + 单文件 JS + 单文件 CSS,**无构建工具、无框架** | — |
| 网关 | Cloudflare Worker(纯 JS,零依赖) | — |
| 负载均衡器 | Cloudflare Worker(纯 JS,零依赖) | — |
## 3. 安全边界(不可违反)
@ -65,8 +65,8 @@
14. **AI 需用户级外部授权**:只有显式授权过且未过期的 chat 才触发 LLM;授权是外部行为,进程不代授权。
15. **业务白名单**:出站 Telegram chat 必须命中 `CHAT_ALLOWLIST`。
16. **诊断面默认关闭**:`/debug/*` 需要 `DEBUG_ENABLED`(或 `--debug`)+ `DEBUG_TOKEN` 双因子同时满足。
17. **诊断面不进网关**:`/debug/*` 不在 Worker `SAFE_ROUTES`,只能直连后端 origin。
18. **网关只透传、不决策**:Worker 不解析请求体、不校验业务逻辑、不下发 `Retry-After`。
17. **诊断面不进负载均衡器**:`/debug/*` 不在 Worker `SAFE_ROUTES`,只能直连后端 origin。
18. **负载均衡器只透传、不决策**:Worker 不解析请求体、不校验业务逻辑、不下发 `Retry-After`。
19. **AI 结果不重试**:LLM 分析失败即视为「不通知」,不重试、不降级为无 AI 通知。
20. **无状态进程**:两个容器实例并发安全;无本地写。
21. **无长轮询调度**:调度由外部 cron 触发 `POST /reconcile`。
@ -82,7 +82,7 @@
| 2 | Telegram 渠道自建客户端(不引入 bot 框架) | `ARCH-DEPS-STAGE4`, `MOD-TELEGRAM-NOTIFY` |
| 3 | AI 可选接入 | `REQ-AI-EXTERNAL-CONSENT`, `REQ-AI-FUSE` |
| 4 | Redis 落地 | `ARCH-DEPS-STAGE4` |
| 5 | 生产化(debug 面、网关、对账) | `MOD-DEBUG`, `SAF-DEBUG-GATE` |
| 5 | 生产化(debug 面、负载均衡器、对账) | `MOD-DEBUG`, `SAF-DEBUG-GATE` |
**阶段编号是历史顺序,不是当前状态声明。** 当前状态以 `reference.md` 与代码为准。
@ -92,7 +92,7 @@
2. **不得为通过门禁而删除测试、跳过校验、放宽检查级别、删除访问控制。**
3. **不得在文档或注释中引用不存在的依赖、函数、类型、Redis 键或行号。**
4. **不得在代码中写「计划实现 X」类注释**;占位实现必须显式标注为占位。
5. **不得在 SPA / 网关引入构建工具或框架**;`web/` 保持单文件 JS + 单文件 CSS。
5. **不得在 SPA / 负载均衡器引入构建工具或框架**;`web/` 保持单文件 JS + 单文件 CSS。
6. **不得让 SPA 持久化任何凭据**(无 `localStorage` / `sessionStorage` / Cookie 赋值)。
7. **不得提交未验证的代码**;门禁结果必须来自本轮实际执行。
8. **不得声称已通过真实环境验证,除非本轮真的连过。**
@ -171,15 +171,14 @@ Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查
|---|---|---|
| [`../AGENTS.md`](../AGENTS.md) | 通用、语言无关的代码编写与环境构建规范 | 质量规则 |
| `README.md` / `README.zh-CN.md` | 面向使用者:是什么、怎么跑、怎么配置 | 用户可见事实(中英必须信息对等) |
| `docs/quickstart.md` / `docs/quickstart.zh-CN.md` | 手把手首次运行:创建 Telegram bot、找到 chat id、部署一个后端、填表、注册回调 | 步骤与决策(字段语义、默认值、TTL 归 `docs/reference.md`) |
| `docs/design.md` | 为什么这样设计:数据流、模块边界、状态机、错误处理 | 架构意图 |
| `docs/deployment.md` | 怎么部署:Dockerfile、secrets、网关、多实例、cron | 部署与运维 |
| `docs/deployment.md` | 怎么部署:Dockerfile、secrets、负载均衡器、多实例、cron | 部署与运维 |
| `docs/reference.md` | 可核对事实的唯一权威来源:路由、Redis 键与 TTL、配置项、错误码、出站常量 | **可核对事实** |
| `docs/opengaps.md` | 缺口、阻塞、下一阶段目标 | 未决项 |
| `docs/retired.md` | 已废弃或已改名方案的记录与替代指向 | 历史决策 |
| `docs/charter.md` | 本文件:项目约束、技术选型、实现阶段、安全不变量、稳定 ID 注册表 | 项目约束 |
| `web/`(无文档,4 个文件) | 管理 SPA 源:静态配置页与前端逻辑,由 `web/config.test.mjs` 覆盖 | 前端行为(权威事实记在 `docs/design.md` 与 `docs/reference.md`) |
| `cloudflare-worker/README.md` / `cloudflare-worker/README.zh-CN.md` | 网关自身的配置与语义 | 网关 |
| `cloudflare-worker/README.md` / `cloudflare-worker/README.zh-CN.md` | 负载均衡器自身的配置与语义 | 负载均衡器 |
**跨文档引用规则**:
@ -266,7 +265,7 @@ Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查
| `SAF-JMAP-URL` | docs/design.md §7.1 / §4 | JMAP URL 约束:仅 HTTPS、禁止内嵌凭据、拒绝危险 query | 安全 |
| `REQ-JMAP-RAW-MULTIPART` | docs/design.md §3.2/§10.1 | `read_email` 多 part 原文:按 text_body 顺序拼接"有 part_id 且 bodyValue"的部分;无可用部分→明确错误 | 需求 |
| `GATE-G1-JMAP-READONLY` | docs/design.md §10.1 / §6 | G1 门禁:只读 adapter **代码已实现**(mock + `#[ignore]` 真机测试),**待真实 `cargo test -- --ignored jmap::` 验证** | 流程 |
| `ARCH-LB-WORKER` | docs/deployment.md §10 | 多实例 LB/HA:免费套餐 Cloudflare Worker 作唯一*注册*入口 + 故障转移;后端仍可直接访问,网关不加访问控制 | 架构 |
| `ARCH-LB-WORKER` | docs/deployment.md §10 | 多实例 LB/HA:免费套餐 Cloudflare Worker 作唯一*注册*入口 + 故障转移;后端仍可直接访问,负载均衡器不加访问控制 | 架构 |
| `C-LB-SINGLE-REG-URL` | docs/deployment.md §10.1 | Telegram/Push/Cron 只登记 Worker 的稳定 URL;后端平台入口不对外登记 | 约束 |
| `C-LB-SHARED-SECRETS` | docs/deployment.md §10.3 | 多实例必须共享同一组 `SAF-AUTH-*` secret,否则随机 401 | 约束 |
| `SAF-LB-PASSTHRU` | docs/deployment.md §10.3 / §3 | 信任模型=透传:Worker 不改写鉴权;后端必须继续 fail-closed 校验(后端可能被公网直连) | 安全 |
@ -277,7 +276,7 @@ Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查
| `SAF-DEBUG-GATE` | src/main.rs / src/debug.rs / docs/design.md §7.6 / docs/deployment.md §2.1 | 双因子门禁:「`DEBUG_ENABLED` 为真值或命令行带 `--debug`」**且** `DEBUG_TOKEN` 非空才挂载路由;缺任一完全不挂载(请求落通用 `404`),默认绝对关闭。开启信号走 env 而非 argv,使启动命令保持静态、开关可在平台控制台单点切换 | 安全 |
| `SAF-DEBUG-AUTH` | src/debug.rs / docs/deployment.md §2.1 | 挂载后 `/debug/*` 须 `Authorization: Bearer DEBUG_TOKEN` 常数时间比较,失败 `401` 且无副作用 | 安全 |
| `REQ-DEBUG-ENDPOINTS` | src/debug.rs / docs/reference.md §3 / docs/deployment.md §2.1 | 端点契约:`GET /debug/ping`、`/config`、`/redis`、`/jmap`、`/telegram`、`/worker` 均只读;`POST /debug/notify` 走真实出站链路发一条测试消息;响应体不含 secret 原文(凭据字段只出 `*_configured` 布尔,非密文的身份与预算字段仍明文返回) | 需求 |
| `SAF-DEBUG-ORIGIN-ONLY` | docs/deployment.md §2.1 / docs/reference.md §4 | `/debug/*` 不在网关 18 条安全路由内,Worker 一律 `404 route not forwarded`;只能直连后端 origin,公网不可达 | 安全 |
| `SAF-DEBUG-ORIGIN-ONLY` | docs/deployment.md §2.1 / docs/reference.md §4 | `/debug/*` 不在负载均衡器 19 条安全路由内,Worker 一律 `404 route not forwarded`;只能直连后端 origin,公网不可达 | 安全 |
| `SAF-DEBUG-ALLOWLIST` | src/debug.rs / docs/deployment.md §2.1 | `POST /debug/notify` 仅在 chat 白名单**非空**时校验 `chat_id`;白名单未配置(空)时不拦截,故启用本面须确认业务白名单已配置 | 安全 |
| `NFR-HA-MULTI-INSTANCE` | docs/deployment.md §10.7 / §9.1 | 多实例高可用语义;双活或主备均可;Redis 单点故障不在方案范围(用户外部解决) | 非功能 |
| `C-NO-DB` | docs/deployment.md §0 / §9.1 / §3 | 生产不使用任何数据库(无 SQLite/Postgres/MySQL/嵌入式),Redis 为唯一状态存储;应用不连接第二个数据库 | 约束 |

View File

@ -5,7 +5,7 @@
Use this guide to deploy a backend, configure mail delivery, and verify notifications.
Choose any platform that can run the backend container, provide encrypted environment
secrets, and connect to an externally managed Redis service. The optional Cloudflare Worker
gateway adds a stable URL in front of one or more backends. Platform-specific settings are
load balancer adds a stable URL in front of one or more backends. Platform-specific settings are
in the [deployment platform reference](reference.md#9-deployment-platform-details).
## 0. Before deployment
@ -42,7 +42,7 @@ only port `8080`. Do not mount a data or log volume. Full image contents are in
Open the backend's configuration page at its origin for initial setup. Use
`CONFIG_ENCRYPTION_KEY` to establish the admin session, then complete the one-time bootstrap
form. The bootstrap route is not in the gateway's route set, so it is only reachable at the
form. The bootstrap route is not in the load balancer's route set, so it is only reachable at the
backend's own address. The one-time bootstrap and
admin-session handlers are `src/notify.rs:834` and `src/notify.rs:1187`; saving business
configuration uses `src/notify.rs:694`. Later edits can use the protected page through the Worker URL.
@ -69,7 +69,7 @@ Debian slim runtime stages; see [§8.2](#82-docker-image-details) for the runtim
After saving business configuration, use the **Register Telegram and Stalwart callbacks**
section in the configuration page. Enter the public HTTPS origin that receives callbacks:
the Worker URL when the gateway is enabled, otherwise the backend origin. The page registers
the Worker URL when the load balancer is enabled, otherwise the backend origin. The page registers
`/webhook/tg` with Telegram and `/push/jmap` with Stalwart. Credentials remain in the backend;
the browser sends only the callback URLs. The protected registration handlers are
`src/notify.rs:1623` for Telegram and `src/notify.rs:1468` for Stalwart.
@ -146,7 +146,7 @@ Check the backend first:
| `GET /ready` | `200` | Configuration, Redis, JMAP, and Telegram probes are ready; dependency failures return `503`. |
| `GET /api/status` | `ready: true` | Required boot settings are present; `missing` identifies absent names. |
When the Worker gateway is enabled, also request `GET /healthz-worker`. Require
When the Worker load balancer is enabled, also request `GET /healthz-worker`. Require
`available >= 1` and check that `version` matches the deployed `LB_VERSION`; HTTP 200 alone
can mean `no-backends`. The `cloudflare-worker/` directory's tests and deploy command are
described in its README.
@ -171,23 +171,23 @@ These deployment decisions are settled; they are not per-installation prerequisi
### 9.1 Public ingress and state
The backend uses an external HTTPS ingress and external Redis. Telegram, JMAP Push, and
scheduled requests use the selected public URL. When the Worker gateway is enabled, register
that stable Worker URL with Telegram and Stalwart; do not expose credentials at the gateway.
scheduled requests use the selected public URL. When the Worker load balancer is enabled, register
that stable Worker URL with Telegram and Stalwart; do not expose credentials at the load balancer.
The stable IDs in this section point to the project charter's registry.
## 10. Optional Cloudflare Worker gateway
## 10. Optional Cloudflare Worker load balancer
The gateway is optional; nothing in the backend requires it. It provides one public HTTPS
The load balancer is optional; nothing in the backend requires it. It provides one public HTTPS
entry point for multiple backend origins. All backends must share the same Redis and business
configuration. Detailed route facts are in the
[gateway route matrix](reference.md#4-gateway-vs-backend-route-matrix).
[load balancer route matrix](reference.md#4-load-balancer-vs-backend-route-matrix).
Its purpose is load balancing, not protection. Cloudflare's Load Balancer product is not
available on the free plan, and this is how a free-plan account puts load sharing and failover
in front of several origins. There is no intent to hide the backends and no intent to harden
them: the origins are directly reachable whether the gateway is present or not, and no access
them: the origins are directly reachable whether the load balancer is present or not, and no access
control is added at the edge. The fixed route allowlist is what bounds the paths the balancer
forwards — bootstrap and `/debug/*` are not in it, so requests addressed to the gateway never
forwards — bootstrap and `/debug/*` are not in it, so requests addressed to the load balancer never
reach them — but it is not a firewall.
### 10.1 Topology
@ -245,15 +245,15 @@ HTTP requests to the Worker receive a permanent `308` redirect to the same HTTPS
route validation or forwarding. The Worker rejects unregistered routes with `404` and wrong
methods with `405`. Ordinary requests try at most `LB_MAX_ATTEMPTS` origins (default two), and
only a timeout or `5xx` triggers failover. `/reconcile` and `/worker` are single-attempt routes with longer timeout
overrides. Use the route matrix and tuning values in the [reference](reference.md#4-gateway-vs-backend-route-matrix).
overrides. Use the route matrix and tuning values in the [reference](reference.md#4-load-balancer-vs-backend-route-matrix).
### 10.5 Callback URLs
When using the gateway, set Telegram's webhook and Stalwart's push callback to the Worker
When using the load balancer, set Telegram's webhook and Stalwart's push callback to the Worker
domain. The external scheduler can use the same URL for forwarded paths. Bootstrap and
diagnostic endpoints still require the backend origin.
### 10.6 Gateway health
### 10.6 Load balancer health
`GET /healthz-worker` reports the Worker version and backend availability. Use both
`available` and `version` to verify a deployment; `status: no-backends` can be returned with

View File

@ -5,7 +5,7 @@
使用本指南部署后端、配置邮件传递并验证通知。
选择任何可以运行后端容器的平台,提供加密环境
密钥,并连接到外部管理的 Redis 服务。可选的 Cloudflare Worker
gateway 在一个或多个后端前面添加一个稳定的 URL。特定于平台的设置是
负载均衡器 在一个或多个后端前面添加一个稳定的 URL。特定于平台的设置是
在[部署平台参考](reference.zh-CN.md#9-部署平台细节)中。
## 0. 部署前准备
@ -41,7 +41,7 @@ docker run --rm --env-file .env -p 8080:8080 messageweave:latest
在原点打开后端的配置页面进行初始设置。使用
`CONFIG_ENCRYPTION_KEY` 建立管理会话,然后完成一次性引导
表单。bootstrap 路由不在网关路由集内,因此只能在后端自己的地址上访问。bootstrap 和管理会话处理器位于
表单。bootstrap 路由不在负载均衡器路由集内,因此只能在后端自己的地址上访问。bootstrap 和管理会话处理器位于
`src/notify.rs:834` 与 `src/notify.rs:1187`;业务配置保存处理器位于
`src/notify.rs:694`。后续修改可通过 Worker URL 打开受保护的配置页。
@ -126,7 +126,7 @@ Worker 仅重试超时和后端“5xx”响应;它返回“4xx”响应
|`GET /ready`|`200`|配置、Redis、JMAP 和 Telegram 探针均就绪;依赖故障返回 `503`。|
|`GET /api/status`|`{"ready":true}`|必需的启动配置已就绪;`missing` 字段列出缺失变量名。|
启用 Worker 网关后,还请求“GET /healthz-worker”。要求
启用 Worker 负载均衡器后,还请求“GET /healthz-worker”。要求
`available >= 1` 并检查 `version` 是否与部署的 `LB_VERSION` 匹配;仅 HTTP 200
可能意味着“无后端”。 `cloudflare-worker/` 目录的测试和部署命令是
在其自述文件中描述。
@ -150,22 +150,22 @@ Worker 仅重试超时和后端“5xx”响应;它返回“4xx”响应
### 9.1 公共入口与状态存储
后端使用外部 HTTPS 入口和外部 Redis。 Telegram、JMAP 推送和
计划的请求使用选定的公共 URL。当Worker网关启用后,注册
Telegram 和 Stalwart 的稳定 Worker URL;不要在网关处公开凭据。
计划的请求使用选定的公共 URL。当Worker负载均衡器启用后,注册
Telegram 和 Stalwart 的稳定 Worker URL;不要在负载均衡器处公开凭据。
本节中的稳定 ID 指向项目章程的注册表。
## 10. 可选的 Cloudflare Worker 网关
## 10. 可选的 Cloudflare Worker 负载均衡器
网关是可选项,后端没有任何东西依赖它。它为多个后端 origin
负载均衡器是可选项,后端没有任何东西依赖它。它为多个后端 origin
提供一个公共 HTTPS 入口。所有后端必须共享同一 Redis 和业务配置。
详细的路由事实在[网关路由矩阵](reference.zh-CN.md#4-网关与后端路由对照表)中。
详细的路由事实在[负载均衡器路由矩阵](reference.zh-CN.md#4-负载均衡器与后端路由对照表)中。
它的用途是负载均衡,不是防护。Cloudflare 的 Load Balancer 产品在免费
套餐上不可用,这就是免费套餐账户在多个 origin 前面做负载均衡与故障转移
的办法。没有隐藏后端的意图,也没有加固后端的意图:网关在不在,origin
的办法。没有隐藏后端的意图,也没有加固后端的意图:负载均衡器在不在,origin
都同样可以直接访问,边缘上也没有加任何访问控制。固定的路由白名单只是
限定负载均衡器转发哪些路径——bootstrap 和 `/debug/*` 不在其中,所以发给
网关的请求到不了它们——但它不是防火墙。
负载均衡器的请求到不了它们——但它不是防火墙。
### 10.1 部署拓扑
@ -217,15 +217,15 @@ HTTP 请求访问 Worker 时,会先收到永久的 `308` 重定向,前往路
HTTPS URL,之后才进行路由校验或转发。Worker 使用“404”拒绝未注册的路由,并使用“405”拒绝错误的方法。普通
请求最多尝试“LB_MAX_ATTEMPTS”来源(默认两个),并且只有超时或“5xx”
触发故障转移。 `/reconcile` 和 `/worker` 是具有较长超时时间的单次尝试路由
覆盖。使用[参考](reference.zh-CN.md#4-网关与后端路由对照表)中的路由矩阵和调整值。
覆盖。使用[参考](reference.zh-CN.md#4-负载均衡器与后端路由对照表)中的路由矩阵和调整值。
### 10.5 回调地址
使用网关时,将 Telegram 的 webhook 和 Stalwart 的 push 回调都设置为 Worker URL。
使用负载均衡器时,将 Telegram 的 webhook 和 Stalwart 的 push 回调都设置为 Worker URL。
外部调度程序(cron/`systemd`/K8s CronJob)对已转发的路径使用相同的 URL。
引导程序必须在后端源上执行;诊断端点需要后端源**和** debug token——Worker 都不转发它们。
### 10.6 网关健康状态
### 10.6 负载均衡器健康状态
`GET /healthz-worker` 报告 Worker 的版本(`version`)和健康的 origin 数量
(`available`)。两者都用于验证部署:`version` 告诉你当前是哪次部署在应答,

View File

@ -307,7 +307,7 @@ message-weave/
├── .env.example # 仅 2 个必填项的占位样例(REDIS_URL / CONFIG_ENCRYPTION_KEY),不含业务配置
├── .gitignore
├── Dockerfile # 仅本地开发用;生产不执行(C-DEBIAN-SLIM,边界见 deployment.md §3)
├── hoststack.yaml # 生产部署真源(runtime/build/start/healthCheck),非网关清单,见 §5.1
├── hoststack.yaml # 生产部署真源(runtime/build/start/healthCheck),非负载均衡器清单,见 §5.1
├── src/
│ ├── main.rs # tokio main:读 PORT/REDIS_URL/CONFIG_ENCRYPTION_KEY 后启动 webhook HTTP 入口(单入口,无 CLI 子命令)
│ ├── config.rs # Redis 业务配置反序列化(serde → BusinessConfigWire → Config);CONFIG_ENCRYPTION_KEY 解析
@ -460,7 +460,7 @@ This section summarizes the absolute red lines across code and deployment that y
### 7.6 Why is the remote joint debugging interface absolutely closed by default?
The only reason for this surface to exist is to shorten the production troubleshooting path: when you cannot log in to the container and can only rely on external requests to observe the system, someone needs to be able to detect the JMAP/Telegram connectivity and current business configuration without releasing the version. The price is that its entry softness must be higher than the three write paths - in addition to read-only detection, it also retains a **real outbound send**, and reuses the business whitelist instead of an independent whitelist. Therefore, the design choice is "closed by default" instead of "enabled by default and blocked by the gateway": the process will only mount this set of routes (`SAF-DEBUG-GATE`) when "`DEBUG_ENABLED` is true or the command line has `--debug`"** and **a non-empty `DEBUG_TOKEN` is set; when the route is missing, the route does not exist at all in the router, and the request falls to axum general `404`, not "Exists but 401" - the latter would leak the route's existence. There is no third state: there is no "open without configuration" fallback, and there is no configuration item to set it to be enabled by default. `DEBUG_ENABLED` uses environment variables instead of startup commands so that the switch can be changed once from the platform console without releasing the version - the startup command remains static. Add another layer of location constraints: it is not in the gateway's safe routing whitelist. Even if the backend is opened incorrectly, the platform entrance will be rejected by fail-closed. The only reachable path is to directly connect to the backend origin. These four things—two-factor mounting, 404 instead of 401, no default enabled fallback, and unreachable gateway—together form the architectural decision of “exposure is zero by default.” See deployment.md §2.1 for the enablement method, per-endpoint status code, and deployment confirmation checklist.
The only reason for this surface to exist is to shorten the production troubleshooting path: when you cannot log in to the container and can only rely on external requests to observe the system, someone needs to be able to detect the JMAP/Telegram connectivity and current business configuration without releasing the version. The price is that its entry softness must be higher than the three write paths - in addition to read-only detection, it also retains a **real outbound send**, and reuses the business whitelist instead of an independent whitelist. Therefore, the design choice is "closed by default" instead of "enabled by default and blocked by the load balancer": the process will only mount this set of routes (`SAF-DEBUG-GATE`) when "`DEBUG_ENABLED` is true or the command line has `--debug`"** and **a non-empty `DEBUG_TOKEN` is set; when the route is missing, the route does not exist at all in the router, and the request falls to axum general `404`, not "Exists but 401" - the latter would leak the route's existence. There is no third state: there is no "open without configuration" fallback, and there is no configuration item to set it to be enabled by default. `DEBUG_ENABLED` uses environment variables instead of startup commands so that the switch can be changed once from the platform console without releasing the version - the startup command remains static. Add another layer of location constraints: it is not in the load balancer's safe routing whitelist. Even if the backend is opened incorrectly, the platform entrance will be rejected by fail-closed. The only reachable path is to directly connect to the backend origin. These four things—two-factor mounting, 404 instead of 401, no default enabled fallback, and unreachable load balancer—together form the architectural decision of “exposure is zero by default.” See deployment.md §2.1 for the enablement method, per-endpoint status code, and deployment confirmation checklist.
---
@ -579,7 +579,7 @@ The design target form once included six additional variants: `Jmap(#[from] jmap
**Run Monitor (`GATE-UPTIME-KUMA`):**
- Use Uptime Kuma HTTP(s) Monitor to check `/healthz` (process survival) and `/ready` (configuration/Redis/upstream reachable readiness), respectively expecting HTTP 200; `/ready` takes about 3s at worst, and the probe timeout needs to be set to ≥10s; `/ready` returns `503` and standard error envelope (`service_unavailable` + `Retry-After: 30`), Uptime Kuma is still determined according to the status code and is not affected by changes in the response body.
- `/healthz` is pure liveness (unconditional `200`); `/ready` checks configuration integrity + Redis reachability + outbound read-only detection (JMAP session `GET`, TG `getMe`, each 3s, parallel, about 3s at worst), the probe is read-only, does not echo tokens or third-party responses, does not trigger business side effects, and the report body does not contain sensitive information; because `/ready` is the heaviest link (possibly 3s), the platform side should give priority to using gateway-aggregated `/healthz-worker` as a survival detection to avoid high-frequency outbound requests.
- `/healthz` is pure liveness (unconditional `200`); `/ready` checks configuration integrity + Redis reachability + outbound read-only detection (JMAP session `GET`, TG `getMe`, each 3s, parallel, about 3s at worst), the probe is read-only, does not echo tokens or third-party responses, does not trigger business side effects, and the report body does not contain sensitive information; because `/ready` is the heaviest link (possibly 3s), the platform side should give priority to using load balancer-aggregated `/healthz-worker` as a survival detection to avoid high-frequency outbound requests.
- No Prometheus, Exporter or additional indicator ports are introduced; the real Stalwart/Telegram end-to-end link still needs to be jointly debugged separately.
**Production red line (throughout all stages)**:

View File

@ -307,7 +307,7 @@ message-weave/
├── .env.example # 仅 2 个必填项的占位样例(REDIS_URL / CONFIG_ENCRYPTION_KEY),不含业务配置
├── .gitignore
├── Dockerfile # 仅本地开发用;生产不执行(C-DEBIAN-SLIM,边界见 deployment.md §3)
├── hoststack.yaml # 生产部署真源(runtime/build/start/healthCheck),非网关清单,见 §5.1
├── hoststack.yaml # 生产部署真源(runtime/build/start/healthCheck),非负载均衡器清单,见 §5.1
├── src/
│ ├── main.rs # tokio main:读 PORT/REDIS_URL/CONFIG_ENCRYPTION_KEY 后启动 webhook HTTP 入口(单入口,无 CLI 子命令)
│ ├── config.rs # Redis 业务配置反序列化(serde → BusinessConfigWire → Config);CONFIG_ENCRYPTION_KEY 解析
@ -460,7 +460,7 @@ message-weave/
### 7.6 为什么远程联调接口必须默认完全关闭?
该面存在的唯一理由是缩短生产排障路径:在无法登录容器、只能靠外部请求观察系统时,需要有人能在不发版的前提下探到 JMAP/Telegram 连通性与当前业务配置。代价是它的入口软度必然高于三条写路径——除只读探测外还保留一次**真实出站发送**,且复用业务白名单而非独立白名单。因此设计选择是「默认关闭」而不是「默认开启、靠网关挡」:进程满足「`DEBUG_ENABLED` 为真值或命令行带 `--debug`」**且**设置了非空 `DEBUG_TOKEN` 时才会挂载这组路由(`SAF-DEBUG-GATE`);缺一时路由在路由器里**根本不存在**,请求落到 axum 通用 `404`,而非「存在但 401」——后者会泄漏路由存在性。也不存在第三种状态:没有「未配置即开放」的回退,也没有任何配置项能把它设为默认开启。`DEBUG_ENABLED` 走环境变量而非启动命令,是为了让开关能在不发版的前提下从平台控制台改一次完成——启动命令保持静态。再叠一层位置约束:它不在网关的安全路由白名单内,即便后端开错,经平台入口也会被 fail-closed 拒掉,唯一可达路径是直连后端 origin。这四处——双因子挂载、404 而非 401、无默认开启回退、网关不可达——共同构成「暴露面默认为零」的架构决策。启用方式、逐端点状态码与部署确认清单见 deployment.md §2.1。
该面存在的唯一理由是缩短生产排障路径:在无法登录容器、只能靠外部请求观察系统时,需要有人能在不发版的前提下探到 JMAP/Telegram 连通性与当前业务配置。代价是它的入口软度必然高于三条写路径——除只读探测外还保留一次**真实出站发送**,且复用业务白名单而非独立白名单。因此设计选择是「默认关闭」而不是「默认开启、靠负载均衡器挡」:进程满足「`DEBUG_ENABLED` 为真值或命令行带 `--debug`」**且**设置了非空 `DEBUG_TOKEN` 时才会挂载这组路由(`SAF-DEBUG-GATE`);缺一时路由在路由器里**根本不存在**,请求落到 axum 通用 `404`,而非「存在但 401」——后者会泄漏路由存在性。也不存在第三种状态:没有「未配置即开放」的回退,也没有任何配置项能把它设为默认开启。`DEBUG_ENABLED` 走环境变量而非启动命令,是为了让开关能在不发版的前提下从平台控制台改一次完成——启动命令保持静态。再叠一层位置约束:它不在负载均衡器的安全路由白名单内,即便后端开错,经平台入口也会被 fail-closed 拒掉,唯一可达路径是直连后端 origin。这四处——双因子挂载、404 而非 401、无默认开启回退、负载均衡器不可达——共同构成「暴露面默认为零」的架构决策。启用方式、逐端点状态码与部署确认清单见 deployment.md §2.1。
---
@ -579,7 +579,7 @@ pub enum BotError {
**运行监控(`GATE-UPTIME-KUMA`):**
- 使用 Uptime Kuma HTTP(s) Monitor 检查 `/healthz`(进程存活)和 `/ready`(配置 / Redis / 上游可达就绪),分别期望 HTTP 200;`/ready` 最坏约 3s,探针超时需设 ≥10s;`/ready` 不就绪时返回 `503` 与标准错误 envelope(`service_unavailable` + `Retry-After: 30`),Uptime Kuma 仍按状态码判定,不受响应体变化影响。
- `/healthz` 是纯 liveness(无条件 `200`);`/ready` 检查配置完整性 + Redis 可达性 + 出站只读探测(JMAP session `GET`、TG `getMe`,各 3s、并行,最坏约 3s),探针只读、不回显 token 或第三方响应、不触发业务副作用,报告体不含敏感信息;由于 `/ready` 是最重的一环(可能 3s),平台侧应优先使用网关聚合的 `/healthz-worker` 作为存活探测,避免高频出站请求。
- `/healthz` 是纯 liveness(无条件 `200`);`/ready` 检查配置完整性 + Redis 可达性 + 出站只读探测(JMAP session `GET`、TG `getMe`,各 3s、并行,最坏约 3s),探针只读、不回显 token 或第三方响应、不触发业务副作用,报告体不含敏感信息;由于 `/ready` 是最重的一环(可能 3s),平台侧应优先使用负载均衡器聚合的 `/healthz-worker` 作为存活探测,避免高频出站请求。
- 不引入 Prometheus、Exporter 或额外指标端口;真实 Stalwart/Telegram 端到端链路仍需单独联调。
**生产红线(贯穿所有阶段)**:

View File

@ -1,203 +0,0 @@
# 1. Quickstart
Chinese: [quickstart.zh-CN.md](quickstart.zh-CN.md)
This page is a from-zero walkthrough for a human operator: create a Telegram bot, deploy
one backend, fill the configuration page, register the callbacks, and get the first
notification. It only contains steps and decisions. Field semantics, defaults, TTLs and
error codes live in [reference.md](reference.md); production, scaling and gateway
operations live in [deployment.md](deployment.md).
## 1.1 What you need
- A mailbox on a JMAP server you administer (this guide assumes Stalwart), HTTPS only.
- A Telegram account.
- A Redis endpoint with persistence (Upstash's free tier works; it is TLS-only, so the URL
starts with `rediss://`).
- A machine or platform that can run one container and expose one HTTP port.
- One public HTTPS origin that can receive webhooks — your own domain, a tunnel, or a
Cloudflare route. Telegram and Stalwart both require HTTPS.
- Optional: an OpenAI-compatible LLM key, a Cloudflare account if you want the gateway.
Nothing else is required: no Postgres, no message broker, no Kubernetes.
## 1.2 Create the Telegram bot
1. In Telegram open a chat with `@BotFather` and send `/newbot`.
2. Pick a display name, then a username that ends in `bot` or `_bot`.
3. BotFather replies with a token that looks like `1234567890:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw1`.
That string is the bot token. Treat it like a password: anyone holding it can read your
webhook traffic and send messages as your bot. It is never echoed back by the API once saved.
4. Send the new bot any message (for example `/start`) from the account or group that should
receive notifications. Until you do, Telegram has no chat to deliver to.
## 1.3 Find the chat id
1. Open `https://api.telegram.org/bot<TOKEN>/getUpdates` in a browser, replacing `<TOKEN>` with
the token from step 3.
2. In the JSON, find `result[].message.chat.id`. Private chats give a positive number such as
`123456789`; groups and supergroups give a negative one such as `-1001234567890`.
3. For a group, add the bot to the group first, then send a message in the group before you call
`getUpdates`. The bot only sees messages in groups after you disable privacy mode under
`@BotFather` → `/setprivacy` → `Disable`, otherwise group messages never reach it.
You will use this id twice in step 7: as the notification target and as a member of the inbound
allowlist. The allowlist must contain at least one id — an empty allowlist is rejected as invalid
configuration rather than treated as "allow everyone".
## 1.4 Get the JMAP credentials
1. In your Stalwart admin, create an application password for the mailbox — an app password, not
the account login password. The backend authenticates with HTTP Basic over JMAP.
2. The session URL is your mail origin: `https://mail.example.com`. You may also paste
`https://mail.example.com/.well-known/jmap`; the backend collapses both to the origin, and
rejects any URL with embedded credentials, a query string or a fragment.
3. The JMAP account id is optional. Leave it empty: after the first successful save the backend
reads it back from the server and the page shows it.
## 1.5 Run the backend
The container needs exactly two environment variables:
```bash
# .env — never commit this file
REDIS_URL=rediss://default:<password>@<host>:6379
CONFIG_ENCRYPTION_KEY=<64 hex characters, from: openssl rand -hex 32>
```
- `REDIS_URL` is your only state store. Back it up like a database.
- `CONFIG_ENCRYPTION_KEY` doubles as the SPA admin credential and the encryption key for stored
business secrets. If you lose it you can no longer read the configuration page or decrypt the
stored secrets; if you rotate it, previously stored secrets become unreadable.
Then build and start:
```bash
docker build -t messageweave .
docker run --rm --env-file .env -p 8080:8080 messageweave
curl localhost:8080/healthz # → ok
```
`GET /healthz` is a static liveness reply and never touches Redis. The readiness report is
`GET /ready`: it answers 503 until the configuration exists and every configured upstream is
reachable, and 200 with a JSON component report once they are. Point your HTTPS reverse proxy or
tunnel at this port now — steps 8 and 9 need a public HTTPS origin.
## 1.6 Sign in to the configuration page
1. Open the page in a browser. If the backend started with the required variables the first card
is **Create admin session**.
2. Paste `CONFIG_ENCRYPTION_KEY` — not the Redis password. This mints an admin session that lives
only in the page's memory for 30 minutes; reloading the page logs you out.
## 1.7 Fill in the business configuration
The form is a full replacement, not a patch: whatever you submit becomes the whole stored
configuration. The sections:
- **01 JMAP mailbox** — session URL and username, plus the app password from step 4.
- **02 Telegram and access auth** — bot token, notification chat id, allowlist, and three secrets
you make up yourself: webhook secret, reconcile token and worker token. Generate them with
`openssl rand -hex 32` each. The webhook secret must match on the Telegram side later; the
reconcile and worker tokens protect the scheduler endpoints.
- **03 LLM service** — optional. Leave disabled unless you want AI digests; if you enable it, the
HTTPS base URL, model name and API key all become required.
- **04 Notification display** — the time zone used to render received times in notifications.
Then, in order:
1. Click **Test connection**. It preflights the credentials without saving anything.
2. Click **Save business config and hot-reload**. The first save must contain every required
field or the server answers 422 and stores nothing.
3. Read the result line under the button: `runtime_applied: true` means the new runtime was
installed on this backend. `false` means it saved but the runtime was not installed, and the
`warnings` array says which component failed — most often an unreachable `jmap_session_url`.
Fix the field and save again; `/ready` stays 503 until it goes green.
4. If the save answers **409 conflict**, someone else (another tab, a second operator) saved in
between. Reload the page to pick up their values, then re-apply your edit.
Secrets are write-only: the page never shows a stored secret again, and submitting an empty secret
field keeps the previously stored value — clear a secret deliberately in a separate edit.
## 1.8 Register the callbacks
Still on the same page, under **External callbacks**:
1. Paste the public HTTPS origin that reaches this backend, scheme and host only, no path —
for example `https://mw.example.com`. If you put the Cloudflare gateway in front, use the
gateway's origin, not the backend's: callbacks must survive a backend restart, and the
gateway is what stays up.
2. Click **Register callbacks**. One click registers both directions: the Telegram webhook at
`/webhook/tg` and the Stalwart push subscription at `/push/jmap`. Verification is handled
automatically; the page reports the result.
Registration is explicit and repeatable. Registering a new origin cancels the previous subscription,
so the old origin stops receiving pushes. A failed registration never removes the old one — if it
can't reach Telegram, the previous webhook stays in place. If it did reach Telegram but not
Stalwart, the page reports a partial failure and tells you to click again.
Verify from a terminal:
```bash
curl -s "https://api.telegram.org/bot<TOKEN>/getWebhookInfo"
# expect "url": "https://mw.example.com/webhook/tg" and "pending_update_count": 0
```
## 1.9 Turn on processing
Flip **Enable business processing** at the top of the page. This is the master switch: with it off,
webhooks are accepted but `/worker` and `/reconcile` refuse to drain, so nothing reaches Telegram.
With it on, incoming mail and messages are enqueued and the worker drains them.
## 1.10 Verify end to end
1. Send yourself an email. Within a few seconds the bot should post a notification with the
sender, subject and received time — never the body. That path alone proves JMAP ingest,
Redis, the queue and the Telegram send all work.
2. In Telegram try `/search 发票`. Note: the search triggers are the Chinese words `搜索`, `查找`
and `检索`, plus the `/search` command — English keywords like `search invoice` are not
recognised as triggers.
3. Try `/summary <email_id>` from the notification's email id. The first call asks for
confirmation; the summary only goes out after you confirm, and only if the LLM section is
enabled.
## 1.11 Schedule the drain
Push delivery can be delayed or missed; an external scheduler is the safety net. On any machine
that can reach your origin, every 5–10 minutes:
```bash
MW_APP_URL=https://mw.example.com \
MW_WORKER_TOKEN=<worker token> \
MW_RECONCILE_TOKEN=<reconcile token> \
scripts/cron-drain.sh
```
The script drains the queue (`/worker`) and rescans for missed pushes (`/reconcile`); both return
204 with an empty body on success. `MW_RECONCILE_TOKEN` is optional — if omitted, the reconcile
step is skipped and only the queue is drained. If you put the gateway in front, `/worker` must
reach the backend origin directly — see deployment.md for why.
## 1.12 Optional: the Cloudflare gateway
Skip this unless you want a load balancer across two backends. The Worker is optional, gives
free-tier Cloudflare users a load balancer, and is not a security layer: the backend origins stay
directly reachable either way. Setup lives in `cloudflare-worker/README.md`; when you use it,
register the callback origin from step 8 against the gateway.
## 1.13 When something is wrong
- **Nothing arrives after saving.** Check the enable toggle (step 9), then
`getWebhookInfo` (step 8). A `last_error_message` about a 401 means the webhook secret does not
match what Telegram sends — re-save section 02 and re-register.
- **`/ready` answers 503.** That body is just an error code — it does not name the failing
component. When `/ready` returns 200, its JSON has per-field booleans (`jmap`, `telegram`,
`redis`). Common causes are an unreachable `jmap_session_url`, a wrong app password, or
`runtime_applied: false` from the last save.
- **The form says conflict.** Two sessions saved at once; reload and redo (step 7.3).
- **The bot ignores you in a group.** Privacy mode is on; disable it under `@BotFather` (step 3).
- **`/search` answers with a help hint.** Your query started with a Latin word; the prefix triggers
are `搜索` / `查找` / `检索`.
Field-level errors, TTLs, Redis keys and route tables: [reference.md](reference.md).
Scaling, secrets rotation, gateways and cron on the wire: [deployment.md](deployment.md).

View File

@ -1,177 +0,0 @@
# 1. 快速上手
英文:[quickstart.md](quickstart.md)
本文是为人类运维者准备的从零开始指南:创建 Telegram bot、部署一个后端、填写配置页、注册回调、
收到第一条通知。只包含步骤与决策。字段语义、默认值、TTL 与错误码在 [reference.md](reference.md);
生产、扩展、网关运维在 [deployment.md](deployment.md)。
## 1.1 你需要准备什么
- 一个你管理的 JMAP 邮箱(本指南以 Stalwart 为例),必须 HTTPS。
- 一个 Telegram 账号。
- 一个带持久化的 Redis 实例(Upstash 免费版可用;它是纯 TLS 的,URL 以 `rediss://` 开头)。
- 一台能跑容器并暴露一个 HTTP 端口的机器或平台。
- 一个能接收 webhook 的公网 HTTPS 域名——自有域名、隧道或 Cloudflare 路由都行。Telegram 与
Stalwart 都要求 HTTPS。
- 可选:OpenAI 兼容的 LLM 密钥;想要网关的话再来一个 Cloudflare 账号。
不需要别的:没有 Postgres、没有消息队列、没有 Kubernetes。
## 1.2 创建 Telegram bot
1. 在 Telegram 中打开 `@BotFather` 的对话,发送 `/newbot`。
2. 选一个显示名,再选一个以 `bot` 或 `_bot` 结尾的用户名。
3. BotFather 会回复一串 token,形如 `1234567890:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw1`。
这就是 bot token。当成密码对待:拿到它的人能读取你的 webhook 流量、以你的 bot 身份发消息。
一旦保存,API 不再回显它。
4. 用应当接收通知的账号或群给新 bot 发任意一条消息(例如 `/start`)。在此之前 Telegram 没有可投递
的对话。
## 1.3 找到 chat id
1. 在浏览器打开 `https://api.telegram.org/bot<TOKEN>/getUpdates`,把 `<TOKEN>` 替换为第 3 步拿到的
token。
2. 在 JSON 中找 `result[].message.chat.id`。私聊是正数,例如 `123456789`;群与超级群是负数,例如
`-1001234567890`。
3. 如果是群:先把 bot 拉进群,再在群里发一条消息,然后才调 `getUpdates`。bot 在群里只有在关闭隐私模式
后才能看到非命令消息:`@BotFather` → `/setprivacy` → `Disable`。
这个 id 在第 7 步要用两次:一次当通知目标,一次放进入站白名单。白名单必须至少一个 id——空名单会被
判为非法配置而拒绝,不会当成「允许所有人」。
## 1.4 拿到 JMAP 凭据
1. 在 Stalwart 管理端为邮箱创建一个应用密码——是应用密码,不是账号登录密码。后端用 HTTP Basic 走
JMAP。
2. session URL 就是你的邮箱源站:`https://mail.example.com`。也可以粘贴
`https://mail.example.com/.well-known/jmap`;后端会把两者都折叠成源站,并拒绝带凭据、查询串或片段
的 URL。
3. JMAP account id 是可选的。留空:首次保存成功后后端会从服务器读回,页面会显示出来。
## 1.5 跑起后端
容器只需要两个环境变量:
```bash
# .env — 不要提交这个文件
REDIS_URL=rediss://default:<password>@<host>:6379
CONFIG_ENCRYPTION_KEY=<64 位十六进制,用:openssl rand -hex 32>
```
- `REDIS_URL` 是你唯一的状态存储。像数据库一样备份它。
- `CONFIG_ENCRYPTION_KEY` 兼作 SPA 管理员凭据与已存业务密钥的加密密钥。丢了它,你既登不上配置页,
也解不开已存密钥;轮换它,之前存的密钥全部不可读。
然后构建并启动:
```bash
docker build -t messageweave .
docker run --rm --env-file .env -p 8080:8080 messageweave
curl localhost:8080/healthz # → ok
```
`GET /healthz` 是静态存活回复,不碰 Redis。就绪报告是 `GET /ready`:在配置存在且所有已配置上游可达
之前回答 503,之后回答 200 并带一份 JSON 组件报告。现在把你的 HTTPS 反代或隧道指向这个端口——第 8、
9 步需要一个公网 HTTPS 域名。
## 1.6 登录配置页
1. 在浏览器打开页面。如果后端带着所需变量启动,第一张卡是 **Create admin session**。
2. 粘贴 `CONFIG_ENCRYPTION_KEY`——不是 Redis 密码。这会签发一个管理员会话,只活在页面内存里,有效期
30 分钟;刷新页面即登出。
## 1.7 填写业务配置
表单是整存整换,不是增量补丁:你提交的就是整份存储配置。各节:
- **01 JMAP mailbox**——session URL、用户名,以及第 4 步的应用密码。
- **02 Telegram and access auth**——bot token、通知 chat id、白名单,加上你自己生成的三个密钥:webhook
密钥、reconcile token 与 worker token。各自用 `openssl rand -hex 32` 生成。webhook 密钥之后要在
Telegram 侧对得上;reconcile 与 worker token 保护调度端点。
- **03 LLM service**——可选。不需要 AI 摘要就别开;一旦开启,HTTPS base URL、模型名与 API key 三者都
变成必填。
- **04 Notification display**——通知里收信时间的渲染时区。
然后按顺序:
1. 点 **Test connection**。它预检凭据但不保存任何东西。
2. 点 **Save business config and hot-reload**。首次保存必须包含所有必填字段,否则服务器回答 422 且
什么也不存。
3. 读按钮下方那行结果:`runtime_applied: true` 表示新运行时已在本后端装配;`false` 表示存了但运行时未
装配,`warnings` 数组会指出失败的组件——最常见是 `jmap_session_url` 不可达。改字段再存一次;`/ready`
会一直 503 直到它变绿。
4. 如果保存回答 **409 conflict**,说明有人(另一个标签页、第二个运维者)在中间也存了。刷新页面拿到
他们的值,再把你的改动重新套上去。
密钥是只写的:页面永不再显示已存密钥,提交空的密钥字段会保留之前的值——要清密钥就在单独一次编辑里
故意留空。
## 1.8 注册回调
仍在同一页,**External callbacks** 区:
1. 粘贴能到达本后端的公网 HTTPS 源站,只写 scheme + host,不带路径——例如 `https://mw.example.com`。
如果你把 Cloudflare 网关放在前面,就填网关的源站,不是后端的:回调要能在后端重启后仍然可达,而
网关才是那个不掉线的。
2. 点 **Register callbacks**。一次点击注册双向:Telegram webhook 注册到 `/webhook/tg`,Stalwart push
订阅注册到 `/push/jmap`。验证自动完成,页面上报结果。
注册是显式的、可重复的。注册新源站会取消上一个订阅,于是旧源站不再收到 push。失败的注册绝不会删掉
旧订阅——如果连不上 Telegram,上一个 webhook 原样留着;如果连上了 Telegram 但没连上 Stalwart,页面会
报部分失败并让你再点一次。
在终端验证:
```bash
curl -s "https://api.telegram.org/bot<TOKEN>/getWebhookInfo"
# 期望 "url": "https://mw.example.com/webhook/tg" 且 "pending_update_count": 0
```
## 1.9 打开处理开关
把页面顶部的 **Enable business processing** 拨开。这是总开关:关着时 webhook 照收,但 `/worker` 与
`/reconcile` 拒绝排空,所以什么也到不了 Telegram。打开后,入站邮件与消息才会进队列、由 worker 排空。
## 1.10 端到端验证
1. 给自己发一封邮件。几秒内 bot 应当贴出一条通知,含发件人、主题与收信时间——绝不包含正文。这一条
路径同时证明 JMAP 入站、Redis、队列与 Telegram 发送全通。
2. 在 Telegram 里试 `/search 发票`。注意:搜索触发词是中文 `搜索`、`查找`、`检索`,外加 `/search`
命令——`search invoice` 这类英文关键词不会被识别为触发。
3. 从通知里的 email id 试 `/summary <email_id>`。首次调用会请求确认;只有你确认之后、且 LLM 节开启时,
摘要才会发出。
## 1.11 排程排空
Push 投递可能延迟或丢失;外部调度器是兜底。在任意一台能访问你源站的机器上,每 5–10 分钟跑一次:
```bash
MW_APP_URL=https://mw.example.com \
MW_WORKER_TOKEN=<worker token> \
MW_RECONCILE_TOKEN=<reconcile token> \
scripts/cron-drain.sh
```
脚本会排空队列(`/worker`)并重扫漏掉的 push(`/reconcile`);两者成功都回答 204 空 body。如果你把网关放
在前面,`/worker` 必须直达后端源站——原因见 deployment.md。
## 1.12 可选:Cloudflare 网关
除非你想跨两个后端做负载均衡,否则跳过。Worker 是可选的,给免费层 Cloudflare 用户提供一组负载均衡器,
不是安全层:无论用不用它,后端源站都保持可直接访问。安装见 `cloudflare-worker/README.md`;用时把第 8 步
的回调源站对准网关。
## 1.13 出问题时
- **保存后什么也没到。** 查处理开关(第 9 步),再查 `getWebhookInfo`(第 8 步)。`last_error_message`
里提到 401,说明 webhook 密钥与 Telegram 发来的不一致——重存 02 节并重新注册。
- **`/ready` 回 503。** 503 的 body 只是一个错误码,不会指名是哪个组件挂了。`/ready` 回 200 时,JSON
里有逐组件布尔值(`jmap`、`telegram`、`redis`)。常见原因:`jmap_session_url` 不可达、应用密码错、或
上次保存 `runtime_applied: false`。
- **表单提示冲突。** 两个会话同时存了;刷新重做(第 7.3 步)。
- **bot 在群里不理你。** 隐私模式开着;去 `@BotFather` 关掉(第 3 步)。
- **`/search` 回的是帮助提示。** 你的查询以拉丁词开头;触发词是 `搜索` / `查找` / `检索`。
字段级错误、TTL、Redis 键与路由表:[reference.md](reference.md)。
扩展、密钥轮换、网关与 cron 在线上的细节:[deployment.md](deployment.md)。

View File

@ -293,7 +293,7 @@ Response conventions:
---
## 4. Gateway vs backend route matrix
## 4. Load balancer vs backend route matrix
> **Independently verified.** Source: `cloudflare-worker/src/backends.js` `SAFE_ROUTES`,
> 19 entries, alongside `ROUTE_METHODS` (`index.js`)
@ -317,7 +317,7 @@ backend pool returns **503** rather than passing the request through.
**Registered on the backend but NOT forwarded (2):**
| Path | Why it is absent from the gateway |
| Path | Why it is absent from the load balancer |
|---|---|
| `POST /api/bootstrap` | One-shot trust bootstrap; not in the route set, so it is only reachable at the backend's own address |
| `/debug/*` (7 routes) | Opt-in remote-debug surface (`SAF-DEBUG-GATE`); not in `SAFE_ROUTES`, so it is reachable **only** at the backend origin's own address |
@ -334,7 +334,7 @@ what mints the session. Adding `/api/enabled` therefore opens no new credential
Consequence: neither remaining route carries external business traffic, so no second ingress is
needed in front of the backend instances. The SPA's first-boot flow still cannot drive
`/api/bootstrap` through the worker — bootstrap must be performed against the backend origin
directly, or the bootstrap path must be added to the gateway allowlist.
directly, or the bootstrap path must be added to the load balancer allowlist.
`POST /api/push/register`, `POST /api/telegram/register-webhook` and `POST /api/push/disable` **are** forwarded. These are safe to
proxy: the callback URL is supplied by the client in the request body and
@ -704,7 +704,7 @@ variable semantics and Redis business configuration fields are in [§5](#5-envir
Telegram, JMAP, allowlist, worker, reconcile, and LLM business settings are Redis-resident,
not process environment variables. The field list and validation rules in [§5.2](#52-redis-resident-business-configuration)
are authoritative. A successful initial save can use the one-shot `/api/bootstrap` endpoint at
the backend's own address; it is not in the gateway's route set, so a request addressed to the
the backend's own address; it is not in the load balancer's route set, so a request addressed to the
Worker never reaches it. Subsequent edits use the protected configuration API.
### 9.3 Callback registration and scheduled work
@ -744,7 +744,7 @@ path audit can read Git history.
### 9.5 Cloudflare Worker and Dashboard
The Worker is an optional HTTPS gateway. The backend origins must be HTTPS strings, with no
The Worker is an optional HTTPS load balancer. The backend origins must be HTTPS strings, with no
path, query, fragment, or embedded credentials. The current public origin list and
`LB_VERSION` are declared in `cloudflare-worker/wrangler.toml` under `[vars]`. Move
`BACKEND_ORIGINS_JSON` to an encrypted secret only when its values are private; never store

View File

@ -289,7 +289,7 @@ env var — 并且“DEBUG_TOKEN”非空**(“SAF-DEBUG-GATE”;门被读
---
## 4. 网关与后端路由对照表
## 4. 负载均衡器与后端路由对照表
> **独立验证。** 来源:`cloudflare-worker/src/backends.js` `SAFE_ROUTES`,
> 共 19 条,并与 `ROUTE_METHODS`(`index.js`)一起定义每条路径允许的方法。Worker 入口文件是 `src/index.js`
@ -311,7 +311,7 @@ env var — 并且“DEBUG_TOKEN”非空**(“SAF-DEBUG-GATE”;门被读
**已在后台注册但未转发(2):**
|路径|为什么网关上没有它 |
|路径|为什么负载均衡器上没有它 |
|---|---|
| `POST /api/bootstrap` | 一次性信任引导;不在路由集内,只能在后端自己的地址上访问 |
| `/debug/*`(7 条路由)| 选择加入的远程调试界面(`SAF-DEBUG-GATE`);不在 `SAFE_ROUTES` 中,因此**只能**在后端源自己的地址上访问 |
@ -326,7 +326,7 @@ env var — 并且“DEBUG_TOKEN”非空**(“SAF-DEBUG-GATE”;门被读
后果:没有任何剩余路由承载外部业务流量,所以后端实例前面不需要第二个入口。
SPA 的首次启动流程仍然无法通过 Worker 的 `/api/bootstrap` 完成——引导要么必须
直接针对后端 origin 执行,要么必须把 bootstrap 路径加入网关白名单。
直接针对后端 origin 执行,要么必须把 bootstrap 路径加入负载均衡器白名单。
`POST /api/push/register`、`POST /api/telegram/register-webhook` 和 `POST /api/push/disable` **被转发**。这些路由可以安全代理:
回调 URL 由客户端在请求正文中提供,在写入任何内容之前就被校验为 URL,并且每个推送订阅记录
@ -690,7 +690,7 @@ HostStack 部署不使用仓库的 Dockerfile;镜像构建和运行时由平
Telegram、JMAP、allowlist、worker、reconcile 和 LLM 业务设置驻留在 Redis 中,
不是进程环境变量。 [§5.2](#52-存储在-redis-中的业务配置)中的字段列表和验证规则
是权威的。成功的初始保存可以使用一次性 `/api/bootstrap` 端点,在后端
自己的地址上完成;该路由不在网关路由集内,所以发给 Worker 的请求到不了它。
自己的地址上完成;该路由不在负载均衡器路由集内,所以发给 Worker 的请求到不了它。
后续编辑使用受保护的配置 API。
### 9.3 回调注册与定时任务
@ -724,7 +724,7 @@ Telegram、JMAP、allowlist、worker、reconcile 和 LLM 业务设置驻留在 R
### 9.5 Cloudflare Worker 与控制面板
Worker 是可选的 HTTPS 网关。后端来源必须是 HTTPS 字符串,不带任何内容
Worker 是可选的 HTTPS 负载均衡器。后端来源必须是 HTTPS 字符串,不带任何内容
路径、查询、片段或嵌入凭据。目前公开的来源清单和
“LB_VERSION”在“[vars]”下的“cloudflare-worker/wrangler.toml”中声明。移动
只有来源地址需要保密时,才将 `BACKEND_ORIGINS_JSON` 作为加密变量存储;从不存储

View File

@ -50,7 +50,6 @@ DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md',
'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md',
'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md',
'docs/charter.md', 'docs/charter.zh-CN.md',
'docs/quickstart.md', 'docs/quickstart.zh-CN.md',
'cloudflare-worker/README.md',
'cloudflare-worker/README.zh-CN.md']

View File

@ -46,7 +46,6 @@ DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md',
'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md',
'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md',
'docs/charter.md', 'docs/charter.zh-CN.md',
'docs/quickstart.md', 'docs/quickstart.zh-CN.md',
'cloudflare-worker/README.md',
'cloudflare-worker/README.zh-CN.md']

View File

@ -18,7 +18,6 @@ DOCS = ["AGENTS.md", "README.md", "README.zh-CN.md", "docs/design.md",
"docs/reference.md", "docs/reference.zh-CN.md", "docs/retired.md",
"docs/retired.zh-CN.md", "docs/opengaps.md", "docs/opengaps.zh-CN.md",
"docs/charter.md", "docs/charter.zh-CN.md",
"docs/quickstart.md", "docs/quickstart.zh-CN.md",
"cloudflare-worker/README.md",
"cloudflare-worker/README.zh-CN.md"]

View File

@ -7,7 +7,6 @@ DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md',
'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md',
'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md',
'docs/charter.md', 'docs/charter.zh-CN.md',
'docs/quickstart.md', 'docs/quickstart.zh-CN.md',
'cloudflare-worker/README.md',
'cloudflare-worker/README.zh-CN.md']

View File

@ -6,7 +6,6 @@ DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md',
'docs/reference.md', 'docs/reference.zh-CN.md', 'docs/retired.md',
'docs/retired.zh-CN.md', 'docs/opengaps.md', 'docs/opengaps.zh-CN.md',
'docs/charter.md', 'docs/charter.zh-CN.md',
'docs/quickstart.md', 'docs/quickstart.zh-CN.md',
'cloudflare-worker/README.md',
'cloudflare-worker/README.zh-CN.md']
errors = []