Commit Graph

86 Commits

Author SHA1 Message Date
anonsaber 7ed1a086fe docs: add hand-holding quickstart tutorial (EN + ZH)
New docs/quickstart.md and docs/quickstart.zh-CN.md walk a human operator from
zero to the first notification: create a Telegram bot via BotFather, find the
chat id, get a Stalwart app password, provision Redis, deploy the container,
sign in with CONFIG_ENCRYPTION_KEY, fill the business config, register
callbacks, enable processing, verify end-to-end, and schedule the cron drain.

Every claim was verified against the code:
- healthz is static 200 'ok'; /ready returns 503 until all upstreams
  reachable, 200 with per-component booleans
- admin session TTL = 1800s (30 min) in state.rs
- first save must be complete -> 422 (is_complete in config.rs)
- 409 conflict on revision mismatch
- secrets are write-only (Option<SecretString>, never echoed)
- /worker and /reconcile return 204 on success, 503 when disabled
- webhook secret mismatch returns 401 (not 403)
- search triggers: 搜索/查找/检索 + /search command only
- cron-drain.sh uses MW_APP_URL (req), MW_WORKER_TOKEN (req),
  MW_RECONCILE_TOKEN (opt)

Registered both files in all 5 validator DOCS lists (audit_paths globs
automatically). Added rows to README §6 and charter §7 in both languages.
Added discoverable links from README §3 to the quickstart.

GATE-DOCS 6/6 PASS (71 anchors, 735 path-refs).
2026-10-08 17:35:35 +08:00
anonsaber 74d53ae542 docs: restore indentation the ZWSP cleanup destroyed in design.md
The U+200B sweep from 5f182f9 also collapsed leading whitespace to a
single space on 140 lines. That silently wrecked the fenced ASCII
architecture diagram, the tree listings and the indented JSON/Rust code
blocks in design.md.

This commit restores HEAD^'s indentation and re-applies only the intended
content edits from 5f182f9 (11 lines in section 5.6/5.7, plus the 14
ZWSP characters). Verified: the working tree differs from 5f182f9 on
exactly 140 whitespace-only lines and on nothing else.

GATE-DOCS 6/6 PASS (anchors 71).
2026-10-01 19:04:55 +08:00
anonsaber 5f182f97b0 docs: fix facts that the code contradicts, in both languages
SAFE_ROUTES has 19 entries (backends.js), not 18. reference.md and
reference.zh-CN.md both said '18 entries'; both now say 19.

reference §4 said the /api/enabled and /api/admin/session calls are
'admin-session Bearer-auth'd'. The code says otherwise: config_authorized
(notify.rs:423) accepts the shared worker token OR a valid admin session,
and create_admin_session (notify.rs:1187) is gated by worker_authorized
against the admin credential CONFIG_ENCRYPTION_KEY — it is what mints the
session, so it cannot be session-auth'd. Both languages now state the two
distinct credentials and why adding /api/enabled opens no new credential
path.

deployment §10.8 said every request-bearing entry point, listing /healthz
and /api/*, calls refresh_business_config. The actual 10 call sites cover
8 distinct paths (/ready, /worker, /reconcile, /push/jmap, /webhook/tg,
/api/config, /api/business-config, /api/telegram/register-webhook). GET
/healthz is a static 200 "ok" and never refreshes; /api/status,
/api/enabled, /api/push/register, /api/push/disable and the
/api/admin/session pair do not either. Both languages now list the real
set and name the exclusions.

design.md §5.7 listed the search trigger prefixes as English
'search'/'find'/'retrieval'; worker.rs:689 accepts the Chinese prefixes
搜索/查找/检索 and the help text states English aliases are not supported.
The two user-facing strings were also mistranslated: the empty-query
guidance prompt is 请提供搜索关键词,例如:/search 发票 (worker.rs:623)
and the no-match reply is 没有找到匹配「<query>」的邮件。
(worker.rs:792). The degraded-rendering marker is likewise a Chinese
string. SEARCH_SUBJECT_MAX=120 / SEARCH_PREVIEW_MAX=160 /
SEARCH_LIMIT=10 verified unchanged.

design.md §5.6/§5.7 were machine-translated from Chinese back into
English and were broken: 'the first 300 if it fails. characters', a
stray closing ** mid-sentence, '(none) deny_unknown_fields)' with a
mismatched paren, and 5 zero-width-space artefacts. charter.md:168 had a
broken '**Tables, lists, or values may not be copied across documents. **'
and opengaps.md had 4. All ZWSPs removed from docs/.

deployment.zh-CN.md §10.5/§10.6 were thin mistranslations: 'Worker 域'
for 'Worker URL', the debug-token requirement dropped entirely, and a
mangled '两者都使用用于验证部署的可用和版本'. Rewritten against the EN.

Also replaced a worker.rs:227 line anchor with the MetadataWorker doc
comment; that drops the repo's line-refs from 72 to 71, so new text no
longer adds to the drift surface.

No code changes; GATE-DOCS 6/6 (71 anchors, 455 section refs, 727 path
refs).
2026-10-01 18:47:05 +08:00
anonsaber 8e92c9de4e docs(zh): restore machine-damaged identifiers in reference.zh-CN.md
The zh translation was generated by a machine translator that treated
backtick-delimited literals as prose. It therefore translated Redis
keys, function names, trigger words and status labels, which made the
documentation technically wrong rather than merely stylistically bad:

  config:business            -> 配置:业务
  config:outbound            -> 配置:出站
  config:admin_session       -> 配置:admin_session
  consent:ai:{chat_id}       -> 同意:ai:{chat_id}
  delivery:inflight/committed
                             -> 交付:飞行中 / 交付:已提交
  dedup:jmap:{account}:{email}
                             -> dedup:jmap:{帐户}:{电子邮件}
  ratelimit:push-verify:*    -> 速率限制:推送验证:*
  push:subscription:*        -> 推送:订阅:*
  state:jmap:since           -> 状态:jmap:自
  lock:reconcile             -> 锁定:协调
  stalwart:*:dlq             -> 坚定:*:dlq
  set/get_business_config, set_enabled, is_enabled,
  remember_push_subscription_id -> Chinese prose

Same damage class, three more forms:
- a typo: delivery:committed -> delivery:comfilled
- a case change that breaks a machine-readable code:
  429 push_verify_rate_limited -> 429 Push_verify_rate_limited
- translated trigger words: 直到我取消 / 撤销授权 / 停止摘要 instead of
  直到我撤销 / 撤销授权 / 停止摘要

Also repaired:
- six table headers 关键|TTL|作家|读者 -> 键|TTL|写入方|读取方
- a stray TTL number duplicated into the label column (3600 1小时)
- the AI-consent label row (直到截至 -> 直到撤销(最长365天))
- a dropped aside, 因此递增, which silently removed the reason why
  increment, DLQ append and source ACK are atomic
- the 搜索/查找/检索 prefix list, where 检索 had been normalised to 搜索

Section 4 was rewritten rather than patched: the paragraphs carried a
dangling untranslated 'all:' and 'proxy', a dropped closing '**' on
**is forwarded**, 'two calls' rendered as 两个电话, and word order that
reversed the meaning of 'no second ingress is needed'.

Verification: headings and table row counts already matched the English
file exactly (6 section pairs, no structural drift), so this is a
content repair, not a re-sync.

No code changes; GATE-DOCS 6/6 (72 anchors, 455 section refs, 727 path
refs).
2026-10-01 18:23:08 +08:00
anonsaber ed0f8172eb docs: state the gateway's actual design intent — a free-tier load balancer, not a security layer
Reframe the Cloudflare Worker gateway across the entry points where a
reader forms an impression of it, in both languages:

- README §2 / §5 (en+zh): the gateway is optional and exists because
  Cloudflare's Load Balancer product is not on the free plan. Drop the
  comparison row that listed 'only the 19 safelisted paths' against
  'every route' as if the safelist were a benefit; replace it with
  backend reachability, which is identical either way, and move the
  route-count fact to its own neutral row. Add one sentence to §5 that
  the gateway is outside the secret boundary.
- deployment.md §10 (en+zh): purpose is load balancing, not protection;
  no intent to hide or harden the backends, no access control at the
  edge, the allowlist is not a firewall.
- cloudflare-worker README (en+zh): new 'why it exists' / 'why it is
  not a security feature' block, plus a 'not a security layer' bullet.
  Drop 'access-hiding capability' from the non-capabilities list, since
  claiming invisibility as something we refuse to provide implies it is
  the interesting axis.
- reference.md §4 / §9.2 (en+zh) and deployment.md zh §2: stop saying
  bootstrap and /debug/* are 'not exposed' / 'kept off the public path'.
  They are simply not in the route set, which is why a request to the
  Worker never reaches them — reachable at the backend's own address
  either way.
- charter ARCH-LB-WORKER (en+zh): 'only external entrance' -> 'only
  *registered* entrance', which is what C-LB-SINGLE-REG-URL actually
  says, and backends stay directly reachable.

SAF-LB-PASSTHRU already records the security-side invariant (no auth
or body rewriting, backend may be directly connected to the public
network), so no new stable ID is needed. No code changes; GATE-DOCS
6/6.
2026-10-01 17:47:49 +08:00
messageweave 09d4e01ec5 docs(deploy): document shared-Redis multi-origin topology and config convergence
10.2 now spells out that every Redis key is globally named with no
instance dimension, tabulates the shared state (config blob, global
revision, enable gate, cursor, streams, reconcile lock) and records
two consequences: the business configuration exists once so an SPA
edit from any origin writes the same copy (intended, no per-instance
slot exists), and CONFIG_ENCRYPTION_KEY must be byte-identical across
origins since it is the AES-256-GCM key and the SPA admin credential.
Extra origins buy failover, not throughput, because /reconcile is
serialized by lock:reconcile and the cursor is singular.

New 10.8 documents lazy convergence at the request boundary via
refresh_business_config, and the known behavior that a build_worker
failure advances the local revision so the instance does not retry
until a new revision is written. Includes detection via the live field
values in GET /debug/config, the warning that its revision field is the
global Redis value and cannot show convergence, and recovery by
bumping the revision again.

Appended as 10.8 rather than renumbering 10.3-10.7, which charter.md
NFR-HA-MULTI-INSTANCE cites. No code changes; GATE-DOCS 6/6.
2026-10-01 16:14:15 +08:00
anonsaber 42128433ed docs: add the worker bilingual README and name both deployment forms
Root README gains §2 "Two ways to run it": a side-by-side table of
Form A (backend origin only) against Form B (behind the Cloudflare Worker
gateway) — what answers, failover, the callback URL to register, the
reachable route surface, which health endpoint to watch, and the docs to
read. Late in the switch: registration is idempotent and re-adding retires
the previous subscription, so the switch is one SPA field, not a migration;
nothing secret moves to the gateway; /api/bootstrap and /debug/* stay
origin-only either way. Later sections renumbered, and the monitoring
paragraph now points at the gateway aggregate GET /healthz-worker instead
of the origin-passthrough /healthz.

cloudflare-worker/README.md rewritten as the canonical English version and
cloudflare-worker/README.zh-CN.md added as its translation, covering the
route safelist (19 paths with per-path methods), the timeout/retry budget
with per-route overrides, bounded failover, the two health endpoints,
callback registration, config, local verification, deploy, and the design
invariants.

Both fixes along the way:

- README.zh-CN said the startup env block has 2 required + 2 optional
  variables; the table has 3 rows, 2 required + 1 optional.
- docs/charter.md §8 / registry list now names the zh-CN twin next to the
  gateway README.

scripts/docs_check gains the new file in validate_docs, check_tables,
audit_anchors and audit_anchor_semantics so it is checked like every other
document. check_sec_refs resolves §-refs against the document being scanned
when a basename is shared: README.md exists both at the repo root and under
cloudflare-worker/, and the basename-keyed heading map silently dropped one
of them. The gate was passing for the wrong reason before.

GATE-DOCS 6/6 clean: 72 anchors, 454 section refs, 727 path refs.
audit_anchor_semantics still reports the same 7 pre-existing candidates,
none in the new or edited files.
2026-10-01 13:40:39 +08:00
anonsaber baa6d8a6a1 docs: reanchor the notify.rs and state.rs line refs the backend moved
The retire-on-change commit inserted ~500 lines into notify.rs and ~110
into state.rs, shifting every anchor at or after the insertion point.
The ANCHOR audit only proves the referenced line exists and is
non-empty, so all of these passed while pointing at the wrong code:

  deployment.md/.zh-CN   register_telegram_webhook 1619 -> 1623
                         register_push             1464 -> 1468 (EN)
                         register_telegram_webhook 1439 -> 1623 (zh)
                         register_push             1315 -> 1468 (zh)
  retired.md/.zh-CN      registration_lock fmt      1328-1330 -> 1481-1483
                         register_push              1315 -> 1468
                         state.rs read_batch        491 -> 506
                         state.rs query_async       531 -> 546
                         state.rs XGROUP CREATE     499-507 -> 514-522
  opengaps.md/.zh-CN     get_reconcile_state        777 -> 792
                         set_reconcile_state        785 -> 800
                         acquire_lock               1076 -> 1130

Every target was verified against the source before editing: two of the
pre-existing anchors pointed inside a different function than the one
they named, and the EN deployment.md had both register anchors off while
the zh twin still carried the original pre-move numbers.

Also documents the new push:registration:current key in the Redis key
table (writer, readers, TTL, and why its literal suffix cannot collide
with the hex session_digest suffix) and carries the zh twin the EN
version already got. Mirror the retire-on-change contract in
deployment.zh-CN.md - the zh twin has its own callback section, which
the EN update skipped.
2026-10-01 11:35:06 +08:00
anonsaber 2089a95b79 docs: drop the SPA-footer deploy proof and reanchor notify.rs refs
Four spots told the operator to read the SPA footer to confirm a
deploy landed. The footer no longer renders a version, so restating
that would send an operator looking at a control that no longer
exists. Point them at /api/status instead; the version field itself is
unchanged.

The previous commit inserted 500+ lines into notify.rs, moving
register_push and register_telegram_webhook. Reanchor the two
deployment.md line refs; the other seven refs in that file are before
the insertion point and are unchanged.

Document the new retire-on-change contract in reference.md.
2026-10-01 11:18:07 +08:00
anonsaber af89a17f7f feat(auth): extend admin session to 30 minutes 2026-10-01 09:19:49 +08:00
anonsaber 2752069eb3 docs: fix Chinese technical terminology 2026-10-01 03:33:42 +08:00
anonsaber e4a0a4b7db docs: translate Chinese document headings 2026-10-01 03:21:57 +08:00
anonsaber 1d4959a855 feat(worker): redirect HTTP to HTTPS 2026-10-01 02:52:01 +08:00
anonsaber 7a27f7ff00 docs: add bilingual guides and callback setup in SPA 2026-10-01 02:36:03 +08:00
anonsaber f9889288ba docs: 同步 /worker 进 safelist(17 -> 18 条),cron 示例收敛到单一域名
- deployment.md:§6.3.1 两条端点都可直接打 Worker 域名,手动 curl 与 crontab、
  k8s CronJob 示例去掉 WORKER_ORIGIN 只留 APP_URL,-m 分别给 320s / 300s(要略大于
  LB 侧 per-route 超时,否则 curl 先掐掉一个本来能完成的排空);§10.4 safelist
  18 条并补 /worker、不在 safelist 的从 3 类减为 2 类;LB_WORKER_TIMEOUT_MS 补进
  secret put 清单与 §10.9 变量表;测试计数 35 -> 37(index 12 -> 14)
- README.md:路由白名单 17 -> 18 条、/worker 入列、LB_WORKER_TIMEOUT_MS 补进
  变量表与配置片段、/reconcile 例外段落扩成 /reconcile + /worker 两条长超时
- charter.md / reference.md:17 -> 18 条、Safe Routes 16 -> 18 entries
- cron-drain.sh:MW_WORKER_URL 由「必须指源站直连地址」改为「可选,默认走平台域名」;
  404 route not forwarded 的提示改为 LB 版本太旧(需 >= 2026.10.3),升级或绕过 LB

顺手修正 6 处已漂移的行号锚点(此前指向空行或已迁移的文件):
backends.js:10-28 -> 10-30、index.js:47-65 -> 54-73、index.js:76-78 -> 87-91、
index.js:87-89 -> 95-96、index.js:93 -> 105、src/index.js:138 -> src/lb.js:155
(x-lb-backend 的 catch 在 656034d 修不可变响应头时已迁到 lb.js,文档一直指旧文件)。
GATE-DOCS PASS(115 line-refs / 477 path-refs / 252 section-refs,0 error)。
2026-09-30 23:08:57 +08:00
anonsaber 3917b96e71 docs: §4.1 / §6.3.1 指向 scripts/cron-drain.sh
「为什么收不到 Telegram」的答案分两层:调度没跑(外部 cron 问题),还是配置或
凭据问题。§4.1 第三步和 §6.3.1 各加一段指向新脚本,并说明 --diagnose 与
--test-notify 两个开关分别用来区分这两种情况。

顺带核对过 §6.3.1 的状态码表与 src/notify.rs 实际契约一致:/reconcile 成功是
204(401/409/503+Retry-After),/worker 成功是 204(400/401/503),RECONCILE_
INITIAL_CHANGES=100 在 notify.rs:294,/worker batch 上限 MAX=10 在 notify.rs:407,
行号锚点全部准确。

GATE-DOCS 6/6(PATH 475 -> 477)。
2026-09-30 22:24:53 +08:00
anonsaber 89dfe5cf3d feat(lb): /healthz 透传到源站,LB 聚合探针改挂 /healthz-worker
源站自己也暴露 /healthz;两者同路径时走 Worker 域名只能看到 LB 的聚合
结果,看不到源站自己的健康状态。现在拆开:

- GET /healthz        透传到源站(源站自己的健康检查)
- GET /healthz-worker LB 聚合探针(LB_VERSION + 后端汇总)

SAFE_ROUTES 增列 /healthz、ROUTE_METHODS 增列 /healthz: [GET],白名单 16 -> 17。
LB 探测源站的路径仍是 /healthz,不变。LB_VERSION 2026.10.1 -> 2026.10.2。

GATE-DOCS 6/6;node --check 全部通过。
2026-09-30 21:36:37 +08:00
anonsaber 15fa7f1e35 feat(lb): /healthz 回显版本号 LB_VERSION,确认线上是哪次部署在回答
上次生产事故里「改了代码不知道有没有生效」缺少直接证据;现在 /healthz 的
JSON 多一个 version 字段,取 env.LB_VERSION(写在 wrangler.toml 的 [vars],
当前 2026.10.1),未配置回退 unknown,探针永不因缺该字段而失败。

healthResponse(summary, version) 的第二参数可选:省略时 JSON 不含该字段
(JSON.stringify 丢弃 undefined),既有调用与用例不受影响。

放 [vars] 而不是 dashboard Add Variable 的理由:它必须跟代码同版本、可 diff,
写进 dashboard 反而容易漂。代价是改 LB 逻辑的提交要顺手 bump 它。

文档同步:§10.4 响应体形状加 version、§10.9 [vars] 示例与验证步骤、
README 环境变量表补一行;用例计数 32 → 35。

验证:node --test 35/35;wrangler deploy --dry-run 通过;docs gate 6/6。
2026-09-30 20:21:48 +08:00
anonsaber 3d6606d0cd docs: deployment.md §10/§10.9 同步 origin 列表改为 [vars] 明文例外
- §10 部署步骤:BACKEND_ORIGINS_JSON 从 secret 注入改为 wrangler.toml 的 [vars],
  secret put 降级为「出现非公开值时」的回退路径,并写明边界。
- §10.9 预览命令说明:预览环境现在能拿到 [vars] 里的 origin(能跑通转发);
  改回 secret 注入时才会退回只能验证 Worker 拉起 + 路由表生效。
- GATE-DOCS 6/6 clean(114 anchors / 252 sec-refs / 474 path-refs)。
2026-09-30 19:07:17 +08:00
anonsaber d7033066e2 docs: §10.9 构建命令改为 npm test,不再要求 npm ci
wrangler deploy 自己用 esbuild 把 4 个源文件打成一个 index.js 再上传
(实测 --outdir 产物 33,078 字节 + sourcemap),src/ 零外部依赖,所以
构建命令留空也不会失败——「构建」和「部署」在本项目是同一个动作。

填 npm test 的价值不在打包,而在两点:
- Cloudflare 默认安装跑 bun install,不读 npm 锁文件,已提交的锁文件
  被绕过,wrangler 版本由 ^4.144.0 浮动;
- npm test 是 32 个测试构成的部署前门禁,不依赖 node_modules(零外部
  依赖,空目录也能跑通 32/32,实测 2.6 秒)。

npm ci --no-audit --no-fund && npm test 仍写进文档作为锁死版本的选项,
代价是每次重新下载安装 35 个包(实测约 86 秒)。
2026-09-30 18:46:04 +08:00
anonsaber 7aa880c429 docs: §10.9 构建命令改为 npm ci + npm test
Cloudflare Git 集成的默认安装步骤实测用的是 bun install(日志
'Installing project dependencies: bun install'),bun 不读 npm 锁文件、
自己重新解析依赖('Saved lockfile'),仓库里提交的锁文件被完全绕过。
只填 no-op 构建命令意味着 CI 装的是 bun 解析出来的版本树,而不是我们
锁定的 wrangler 4.144.0。构建命令改成显式 npm ci 后由 npm ci 删掉
node_modules 再严格按锁文件重装;项目无编译步骤,顺带跑一遍 npm test
当门禁。

同时把 Node 版本说明改为实测值(CI 给的是 nodejs@24.18.0),并记录
wrangler 4 与 3 一样按 wrangler.toml 的 name 部署。
2026-09-30 18:08:57 +08:00
anonsaber e853032445 docs: 修正 §10.9 预览命令的建议——它是必填项,不是留空
上一条把预览命令写成「留空并取消勾选」,但 UI 里勾选启用预览构建后该字段为必需。
预览命令改用 npx wrangler dev --ip 0.0.0.0 --port 8787(wrangler 3.114.17 无
preview 子命令,dev 才有 --ip/--port;8787 是 dev 默认端口)。

同时澄清构建命令 ≠ 部署命令:部署命令(必需)保持 npx wrangler deploy 不动,
把 deploy 塞进可选的构建命令才是错的。补一句预览环境没有 BACKEND_ORIGINS_JSON,
所以预览只能验证 Worker 能拉起与路由表生效,后端转发只能看生产。

另补:根目录在折叠的「高级设置」里,设了之后三个命令都在 cloudflare-worker/ 下执行。

GATE-DOCS 6/6(114 行号锚点 / 252 节引用 / 469 路径引用)。
2026-09-30 17:10:35 +08:00
anonsaber 8d73b73fb5 docs: 新增 §10.9 网页版部署(Dashboard Code Editor / Git 集成)
补 Cloudflare Dashboard 的 Git 集成配置对照表——仓库根目录是 Rust 后端,
必须把根目录设成 cloudflare-worker,否则 Cloudflare 在仓库根跑 wrangler 直接失败。
另外三条:应用名要与 wrangler.toml 的 name 对齐、构建命令改成 no-op
(Cloudflare 构建后自己跑 wrangler deploy)、预览命令留空并关闭预览
(wrangler 3.114.17 无 preview 子命令,且预览环境没有 BACKEND_ORIGINS_JSON)。

GATE-DOCS 6/6(114 行号锚点 / 252 节引用 / 468 路径引用)。
2026-09-30 16:49:04 +08:00
anonsaber 048bd5d243 docs: 部署章节标出三个会让首次上线直接失败的坑
- §10.8 的 BACKEND_ORIGINS_JSON 示例用的是 {url,weight} 对象数组,但代码只
  接受字符串数组;且配置是每个请求重新解析的,症状是「每个请求 503」而不是
  「启动即 503」。补上 parseBackendOrigin 的位置与权重不存在的说明
  (lb.js 用 rng() 均匀选起始源)。
- 新增两条实测坑:wrangler.toml 的 JSDoc 注释会让 wrangler 在读配置阶段失败
  (Unknown character 47);npm test 在 Node 22.23 上 MODULE_NOT_FOUND。
- §10.9 验证第 2 条:/healthz 是 Worker 自生成、不透传后端,且 BACKEND_ORIGINS_JSON
  缺失/格式错/为空时 handleHealth 吞掉解析异常返回 HTTP 200 + no-backends,
  所以只看 200 会误判,必须看响应体的 available。补上三档判据速查。
2026-09-30 16:24:44 +08:00
anonsaber baff2dde8d docs: 关闭最后一个缺口(多账户不做),清掉由此暴露的 5 处陈旧指向 2026-09-30 15:12:53 +08:00
anonsaber 71336ea6d3 docs: record the real-Redis TTL green light and drop the credential ask
- opengaps: stage goal 2 is fully closed, the blocker list is empty, and the
  remaining user action is only the multi-account product decision.
- opengaps: record the fourth self-inflicted test bug -- the flaky 1s TTL
  window -- and what the corrected assertion actually proves, including the
  B-class Lua EXPIRE value now verified on a real instance.
- design: the stage-0 paragraph still claimed 69 passed / 1 ignored, which
  contradicted charter TEST_BASELINE; point it at the authoritative line.
- No credential values anywhere; the URL is written as a placeholder.
2026-09-30 14:52:06 +08:00
anonsaber e9c979bd1a docs: align the oldState wording with the domain doc 2026-09-30 14:14:37 +08:00
anonsaber 2447106840 docs: describe the reconcile cursor, re-anchor drifted refs, fix three stale claims
reference.md: new Section 6.5 explains the bimodal cursor, the two token
classes (Session.state is not an Email-collection token; newState is), why a
truncated walk persists as a walk, and why oldState and destroyed are dropped.
Section 6.2 budget table is renamed to the new constant names and gains
RECONCILE_INITIAL_CHANGES. state:jmap:since is listed as a pipeline key.

opengaps.md: the re-baseline fix is described with the new session_state()
name, and the position-walk note no longer claims a state argument is passed
as None - the None is folder_id, and the method has no state parameter at all.

retired.md: the dead-code removal no longer claims those were the last three
#[expect(dead_code)] attributes in src/; reload_async still has one.

deployment.md / design.md: the 54+14 anchors the code move had shifted by one
hunk are re-anchored, plus two manual corrections (the /reconcile route and the
unconditional 204 return). charter.md: baseline 89 -> 93 passed.
AGENTS.md: the two anchor counts it quotes (60, 44) were stale; they are 54
and 17.

check_sec_refs.py: two-part section refs attributed to an RFC (RFC 8620
Section 2.1) are external, not doc references - the trailing-dot test only
caught the three-part form.
2026-09-30 14:11:52 +08:00
anonsaber 655167e862 docs: close the TTL gap, re-anchor drifted code refs, add anchor tooling
opengaps.md: the TTL item is now closed on the code side. The 21-value
table is keyed by ttl constant rather than by line number, so it stops
drifting. The honest caveat is called out in the open instead of being
implied away: the two real-Redis assertions are wired but unrunnable with
the credentials on hand, so 'assertion ready' is not the same as 'verified
on real hardware'. The residual-risk note that was still sitting under the
closed item has been removed along with it.

charter.md: test baseline 88 -> 89 passed.

deployment.md, design.md, retired.md: 41 stale src/ line anchors. The
drift was real and the audit missed it every time, because
audit_anchors.py only asserts that a referenced line exists and is
non-blank. Three are off-by-one; the rest are wrong code --
state.rs:379 pointed at XREADGROUP XACK while the claim was about
read_batch, retired.md:99 pointed into Intent::Search::parse, the two
route-table anchors landed on state: Arc::new(state),, and one
register_push anchor sat 31 lines above the fn.

scripts/docs_check/reanchor.py: maps HEAD -> working-tree line numbers
straight off git diff -U0 hunks, applies the fix or reports only with
--check. scripts/docs_check/show_anchors.py: prints every doc anchor next
to the line it lands on, which is what caught the semantic errors the
audit structurally cannot.
2026-09-30 13:20:13 +08:00
anonsaber 0890eb18f0 test: assert the three Redis TTL write points on a real Redis (opt-in)
real_redis_ttl_tests in src/state.rs closes the "no test asserts any TTL
value" gap for the three atomic write points that actually reach Redis:

- set_nx_ex  SET NX EX, with .max(1) floor (Redis rejects a zero EX)
- retry_or_dlq EXPIRE KEYS[1] 86400, embedded in the Lua script
- set_ai_consent SET EX

Each is opt-in via REDIS_TEST_URL (#[ignore], mirrors the JMAP gating in
src/domain/jmap/client.rs:522); missing the env var prints 'skipped' and
returns, so CI stays green without credentials and the URL is never logged.
retry_or_dlq uses max_attempts=99 so the script returns before XADD/XACK and
no stream or group needs to be created.

opengaps.md item 2 is now blocked only on a reachable Redis URL. Two
defects in that section were fixed while touching it: the consent table
rendered 7 * 86_400 as 604_800 and 365 * 86_400 as 31_536_000 (arithmetic
right, literal wrong) and listed 300s/7200s values that do not exist in
the code - the sixth real consent TTL is 0 (revoke). 16 line anchors had
also drifted onto comments and neighbours; all are re-pointed. The line-
range audit passes 138 refs but only checks that a line is non-blank, not
that it holds the claimed content, which is how these slipped through.

GATE-P0 baseline moves to 88 passed / 0 failed / 4 ignored.
2026-09-30 12:30:57 +08:00
anonsaber bc68c6b241 docs: unblock GATE-DOCS (debug.rs anchors drifted + file over 500 lines)
debug.rs grew past 500 lines with no SPLIT-EVAL marker, and the seven
/src/debug/* anchors in deployment.md pointed at the blank line just
above each function - so GATE-DOCS was red on two unrelated pre-existing
counts (SIZE CHECK, ANCHOR AUDIT) while the other four checks were green.

Re-anchor all seven to the function bodies they describe, add the marker
with the reason we are keeping the module whole, and document the
redis_failure_detail classification that deployment.md still called
"redis_probe_failed". Bump the GATE-P0 baseline to 88.
2026-09-30 12:17:04 +08:00
anonsaber ae7b0293ab docs: close opengaps item 1 (re-baseline handled in code)
Item 1 asked for a downtime window to observe the newState semantics, but
jmap-client 0.4.2 exposes no reset/re-baseline signal field, so the only
observable outcome is a plain Err - the window would have proven nothing.

The real behavior was worse than the item described: Err propagated as
Err(()) with reconcile:state left untouched, so every cron retried the
same dead sinceState and reconcile answered 503 forever with no forward
progress. Fix landed in 269c8f6.

Only item 2 (TTL measurement) remains, and it needs nothing from the user.
2026-09-30 11:53:24 +08:00
anonsaber b764d6dc30 feat: reject stale business-config submits with a 409
revision becomes a request-body control field on BusinessConfigPatch. It is
never a setting: apply() and is_complete() ignore it, so the stored wire
never gains a revision key, and a PUT body that omits it keeps
last-write-wins for callers that never read the revision back.

The SPA records the revision it read, echoes it on every partial submit, and
on a 409 re-reads the stored values into the form and tells the operator to
recheck what they changed. Without that reload every retry would 409
forever; if the reload itself fails the SPA falls back to last-write-wins
rather than trapping the operator. The control field is excluded from the
empty-patch check so echoing it never counts as a change.

The compare-then-write is deliberately not atomic. Without a Lua script an
optimistic lock still has a residual window in which two requests racing
inside the same tick both pass and the later one wins; the window stays
small because a patch carries only the fields the operator actually changed.

notify.rs gained 18 lines before line 700, so its anchors in deployment.md,
opengaps.md and retired.md shifted by +18. Every one was checked against the
real line content, since the anchor audit only flags blank targets.

Gates: rust fmt/clippy clean, cargo test 87 passed 2 ignored, node --test
web/config.test.mjs 18 passed, docs_check/run_all.sh 6/6.
2026-09-30 02:56:15 +08:00
anonsaber 33a0a8b8ba docs: fix a register_push anchor that pointed into the worker body
docs/retired.md:90 cited notify.rs:860 for register_push, which is
'if let Ok(mut current) = app.business_runtime.write()' inside the worker -
the anchor audit passes it because that line exists and is non-blank. The
handler is at notify.rs:1261.

Found while reconciling the last open task against the tree: this was the only
one of the five audit_anchor_semantics.py candidates that was a real drift
rather than a nearest-name false positive.
2026-09-30 02:05:38 +08:00
anonsaber 166b77eb34 docs: stop maintaining source line numbers in reference.md
`docs/reference.md` is the single source of truth and gets touched on every API
change, yet 40 of its 56 `notify.rs` anchors had drifted onto a closing brace,
a doc comment, or the wrong symbol - including one whole line pointing at
`revoke_admin_session` where a jmap dedup key is claimed - while
`scripts/docs_check/audit_anchors.py` reported every one of them clean. Its own
docstring says it only checks that the target line exists and is non-blank.

Remove the anchors rather than correcting them: fixing 40 numbers creates a
treadmill that re-drifts on the next unrelated edit to the same file, and the
file's own convention already called the line number "not an identity". Symbol
names are kept everywhere, which is what the gates and the readers need.

`docs/deployment.md` keeps its line numbers - it is an operator runbook read
mid-incident - and the five anchors that were wrong there are corrected.

docs/reference.md
- Drop the false "Verified against commit b2dbe7c" header claim; the anchors
  never matched that commit. Replace it with the design decision and the
  reason, and drop the "Only this file carries line numbers" convention that
  `deployment.md` was already violating.
- Remove all `*.rs:NNN` and bare `:NNN` anchors, keeping symbol names.
- Fix a wrong fact the anchors were hiding: the session issued by
  `POST /api/admin/session` is a fresh CSPRNG token generated per call, not a
  "fixed 32-byte hex secret derived from admin_token". The anchor pointed at
  `bootstrap`'s body.

docs/deployment.md
- `retry_or_dlq` call site 481 -> 479; `lock:reconcile` acquire 289 -> 290
  (twice); heartbeat `renew_lock` 302 -> 303 (twice).

AGENTS.md
- Record the convention: symbol names in docs, line numbers only in
  `docs/deployment.md`, plus the audit script's limits so nobody reads its
  output as "the anchors are correct".

scripts/docs_check/audit_anchor_semantics.py (new)
- Independent script, not wired into `run_all.sh`, always `exit 0`, prints
  candidates only. It flags an anchor whose target line has no relation to the
  symbol the doc claims. Candidates go 20 -> 5 and `reference.md` disappears
  from the list entirely. It cannot catch a one- or two-line off-by-one - that
  still needs a human - and a bare `:NNN` shorthand is not a matchable anchor.

Gates: GATE-DOCS 6/6 clean (ANCHOR AUDIT 324 -> 129 refs, 0 errors).
`src/` is untouched, so the test baseline stands at 86 passed / 0 failed /
2 ignored.
2026-09-30 02:02:34 +08:00
anonsaber e3924a53c1 test: cover business config read-back and partial submit
Adds the missing test coverage for the GET /api/business-config read-back
and the partial-patch PUT, then syncs the docs to the behaviour the code
actually has.

src/notify.rs
- PUT response body now carries "revision" alongside the
  x-business-config-revision header, which the SPA surfaces in its status line.
- 4 test helpers (a router that returns (Router, MemoryState) so a config can be
  seeded first, a full valid wire value, a direct state writer that skips
  validation, and a wire value whose JMAP URL fails fast) plus 9 tests:
  - GET 401 leaks no business field at all (body is exactly error + request_id)
  - GET echoes the 10 non-secret values and returns secrets only as presence
    booleans; the serialised body is asserted to contain none of the 6 secret
    values (SAF-NO-SECRET-ECHO)
  - GET with nothing stored returns 200 configured:false revision:0, not 404
  - a first save with an incomplete patch is rejected and writes nothing
  - a partial submit replaces only the fields it carries, verified by reading
    the merged result back over the same path the SPA uses
  - validation runs on the merged result, so changing one field cannot bypass
    the rest, and a rejected submit neither persists nor bumps the revision
  - telegram_chat_id is accepted as a number or a numeric string and a garbage
    string is rejected without overwriting the stored value
  - a typo in a field name is rejected rather than silently dropped

src/config.rs
- Pure deserialisation test for de_i64_from_number_or_string, with no router and
  no store. This is the only place that separates "the custom deserializer
  refuses the string" from "a later validation step refused it" - a real
  distinction, since the SPA submits telegram_chat_id as a string on a partial
  submit while the read-back model returns a number.

cloudflare-worker/test/index.test.js
- The forwarded-route test asserted a fixed upstream origin, which is not stable:
  the LB rotates its start index between tests. Assert the origin is one of the
  configured backends and check the path and method exactly instead.

docs
- 17 spots bring the docs in line with the shipped contract: GET is now
  documented, PUT as a partial patch, the worker safelist at 16 routes, and the
  preflight path listed as forwarded.
- Corrections the docs had backwards: the response field is "configured"
  (revision is a u64, 0 when nothing is stored); Invalid returns 422, only
  Unreachable returns 503; there is no chat_allowlist_empty error code.
- "Blank keeps the stored value" is now stated as a client-side contract. The
  backend does not distinguish an omitted field from an empty string - apply()
  uses unwrap_or_else, so an explicitly submitted "" really does clear the
  secret (validation then rejects it). Saying it was server-side was wrong.
- POST /worker was listed as a forwarded route though it is not in SAFE_ROUTES,
  and the per-category counts did not match the lists they introduced.
- POST /api/bootstrap does not share the patch-merge contract; it deserialises a
  full BusinessConfigWire, which is the only path that can create a config from
  nothing.
- Re-anchored 9 src/notify.rs line refs that had drifted blank and raised the
  baseline to 86 passed / 0 failed / 2 ignored.

Gates: fmt, clippy -D warnings, test 86/0/2 ignored, worker 32/32, SPA 17/17,
GATE-DOCS 6/6 clean.
2026-09-30 01:24:42 +08:00
anonsaber 0cee50b4c9 feat: read business config back and accept partial patches
GET /api/business-config returns the stored values with presence-only secrets, and PUT now merges a patch against the stored config instead of replacing it, so the SPA can show what is already set and send back only the fields that changed.

The SPA marks secrets as set-but-unreadable, edits chat_allowlist as an unordered set, and omits fields it did not touch; blank required fields keep the stored value, blank optional fields clear it. The read-back is Serialize-only, so a client cannot inject secrets_present.

Forward both paths at the gateway: /api/business-config accepted PUT only, so the read-back returned 405, and the preflight path was missing from the safelist entirely, so it returned 404. The SPA is served from the Worker domain, so the whole edit experience was unreachable behind the production gateway. Route methods are now asserted per path, which the safelist test never checked.

Also: /debug/config reports timezone again, the timezone select is styled like the other fields, and the docs follow the new contract.
2026-09-30 00:53:58 +08:00
anonsaber 953a4db6f0 docs: document the notification rendering contract and sweep anchors
New reference.md 5.4 pins the user-visible contract: notifications are three
metadata lines only (From / Subject / Received), never the body and never an
LLM summary; the mailbox display form and its fallback literals; the
timezone field, its 16-zone whitelist, the 422 on anything else, and why DST
is not tracked. Registered as REQ-TIMEZONE-DISPLAY in charter 8.

deployment 5 gains the TIMEZONE row, including the SPA section and the
reason DST zones cannot be added.

Opengaps 1 drops two blockers that are verified done and moves the remaining
two to a "what you do / what I do" checklist.

Corrected doc claims that the line-number checker cannot reach:

- reference 6.2 and the Redis key table cited notify.rs 240/253 and
  252/253 for lock TTL and heartbeat; the lock is acquired at :289 and
  renewed at :302.
- The Redis key table's dedup:tg / dedup:jmap / ratelimit / stream call-site
  anchors all pointed into the ready() handler and into unrelated config
  helpers. Re-pointed at the real statement lines.
- Both Retry-After helpers were anchored at error_response 321-329 and
  error_response_with_id 1088-1094; the real definitions are 370-378 and
  1368-1375, and the readiness path passes retry at :210, not :162.
- design claimed summarize() is also called by notify.rs for new-mail
  notifications. There is exactly one call site, the /summary branch in
  worker.rs; notifications never call the LLM.
- design described list_emails as requesting an explicit property list and
  returning Vec<EmailSummary>; the code passes no properties and returns
  Vec<EmailMetadata>.
- retired listed 6 intents but only 5; worker.rs:533 for /search is :553.
- Config is 7 fields now that timezone is mirrored into it.

The anchor audit now flags any citation that lands on a bare brace, paren or
comma - the shape these drifted references had.
2026-09-29 21:48:04 +08:00
anonsaber 9d9f9d7ca2 docs: charter anchor baseline is now the latest code commit
The docs side of that line could never be self-consistent (it would always trail
HEAD by one commit), so it now points at main instead of a fixed SHA.
2026-09-29 20:56:08 +08:00
anonsaber 8b1afac430 docs: close two integration blockers and document the worker drain schedule
- opengaps: drop the Telegram inbound callback and Stalwart PushSubscription
  blockers, both verified live; 2 items remain (newState semantics, TTL
  measurement). The public-callback mapping question is also resolved -- the
  integration env has no Worker in front and Stalwart hits the backend origin
  directly, while /push/jmap is already in the Worker safelist for production
- deployment 4.1 (new): full setWebhook and /api/push/register procedure,
  expected replies, failure symptoms, and the setWebHook rotation trap (the
  deployed secret must be re-registered after any TG_WEBHOOK_SECRET change)
- deployment 6.2: the worker is neither a background task nor a separate
  container; POST /worker is the only consumer entry point
- deployment 6.3.1: add the /worker drain step to the cron and k8s CronJob
  examples, plus a two-endpoint comparison table, ordering requirements, the
  10-message batch cap, and the "204 is not a success signal" warning
- deployment 10.4: describe /worker as scheduler-driven, not manually invoked
- reference, retired: /worker anchor notify.rs:331 -> 380; the old value fell
  inside the reconcile function body, so the anchor audit passed while being
  semantically wrong
- charter 6.6: narrow the blocker list; code baseline b2dbe7c -> fc686ab
- design 11.2: PushSubscription permission now verified; the external Cron
  must call both /reconcile and /worker

GATE-P0: cargo fmt / check / clippy clean, 69 passed, 1 ignored
GATE-DOCS: 6/6 clean (ANCHOR 299, SECTION-REF 234, PATH 428)
2026-09-29 20:55:54 +08:00
anonsaber 94b6baf7bb docs: reframe SAF-DEBUG-ALLOWLIST as never a gap, not a risk acceptance
The PM's criterion: if the documented production posture already contains
the risk, it was never a code gap to begin with -- it was just a
description of current behavior. My previous wording said it was "closed
by risk acceptance", which implies an accepted open risk that someone
decided to live with. That is the wrong frame and reads worse than the
truth in a security review.

Reworded to state the actual reasoning: the exposure is contained by the
dual-factor debug gate and the Worker safelist, each sufficient alone,
so a third layer was never needed and nothing was accepted.

No behavior, code, or ID registration changed -- wording only.
GATE-DOCS 6/6: ANCHOR 270, SECTION-REF 226, PATH 394.
2026-09-29 20:02:15 +08:00
anonsaber bfd3fefd97 docs: close SAF-DEBUG-ALLOWLIST by risk acceptance
The PM accepts the residual on the strength of the two gates that are
already the documented production posture, so the item leaves the gap
file and docs/opengaps.md no longer has a 代码缺口 section at all.

Both gates verified in code before agreeing to the premise:
- closing debug is total: the dual-factor gate in src/main.rs:98-105
  feeds src/notify.rs:1340-1342, which only then merges debug_router();
  unset and the 7 routes do not exist, axum falls through to a generic
  404, so it is not a 401 and does not disclose the path exists
- the Worker refuses rather than forwards: cloudflare-worker/src/index.js
  :81-82 returns 404 route not forwarded for any path outside LB_ROUTES,
  and LB_ROUTES = SAFE_ROUTES holds exactly 15 entries with no /debug
  path. Confirmed by reading the list, not by trusting the README

So either gate alone is sufficient, which is stronger than the assumption
recorded in the gap entry, which treated the allowlist as the thing
keeping the other two apart.

The residual is already on paper and is the only real one: direct access
to the backend origin bypasses both. docs/deployment.md:103 says exactly
this, so nothing new had to be written, and design.md:349 now points
there and to docs/design.md §7.6 instead of at a section that no longer
exists.

- drop the allowlist row from the 你要做的事 table as well, or the table
  would advertise a task whose gap no longer exists
- docs/design.md:349 would have dangled on 「代码缺口」, so it now records
  the closure and its basis rather than a count of a deleted section
- docs/retired.md:81 says 「代码缺口」4 → 3. That is the recorded effect
  of the deletion it documents, so it stays as a past-tense fact rather
  than being rewritten to the current state of 0
- the behavioral fact stays where it belongs: docs/deployment.md:102 still
  documents that an empty allowlist does not block, and charter.md:279
  still registers the ID with its 启用须确认白名单已配置 condition.
  Only the gap status was removed
- GATE-DOCS 6/6 pass: ANCHOR 270 (was 272), SECTION-REF 226 (was 224,
  the two new cross-refs I added), PATH 394 (was 393, net +1 swap)
2026-09-29 19:59:33 +08:00
anonsaber e2d52d3b3b docs: make opengaps.md say what the operator must do
Every blocker now states the concrete action required, the owner, and the
expected result, instead of only explaining why it is blocked. The file
was answering 'what is broken'; nobody could tell from it what to run.

- new 你要做的事 table up front: 6 rows splitting each item between
  operator action and my action, so the reader sees at a glance which two
  items need a curl, which one needs an answer, which needs a downtime
  window, and which need nothing
- item 1 Telegram: setWebhook + getWebhookInfo commands, allowed_updates
  narrowed to message to match the only field the service parses
  (message.text), and the rotation trap spelled out -- changing the SPA
  secret without re-running setWebhook leaves Telegram holding the old
  value, so real callbacks start failing with 401 silently, no error
  and no alert
- item 2 PushSubscription: POST /api/push/register command plus the
  split of responsibility. Registration is performed by this service
  against JMAP, not in the Stalwart admin UI, and the verification code
  comes back over the callback and is verified automatically, so the
  operator never needs to obtain a code. Also documents the observable,
  the failure-code decode table, and the disable endpoint
- item 3 rewritten: the operator action collapses to a single question
  (which component does the public mapping), because no Worker is in
  front of this deployment. The five TTL keys moved into a table with
  exact keys, purposes, TTLs and line anchors, and the pending 900s /
  verified 300s statuses are documented where they are set
- item 4 spelled out as a concrete downtime window: stop the service
  while mail is still arriving, longer than Stalwart's delta retention,
  without running POST /reconcile during it, then I reconcile and check
  the cursor re-baselines instead of erroring
- corrected getWebhookInfo field: it returns last_error_date, not
  last_error_time
- anchor audit caught two of my own bad anchors on the first run -- the
  SPA-verification comment is at notify.rs:1163 and the callback URL
  validation at :1174, I had guessed :1151 and :1163, both off by 12.
  ANCHOR is now 272 refs / 0 errors, PATH 393
- GATE-DOCS 6/6 pass
2026-09-29 19:48:40 +08:00
anonsaber 4f44e92592 docs: close the Redis TLS blocker, confirmed live
- remove the 真实 Redis TLS 连接 blocker: user confirms the managed
  Redis is connected; the redis crate has tokio-rustls-comp enabled so
  rediss:// is a supported scheme, and /debug/redis + /ready both report
  reachable/true on the live deployment (checked just before this commit)
- the old item bundled three things: rediss:// handshake, password URL
  encoding, ACL password. The URL-encoding half was only ever covered by
  a parse-only unit test pointing at example.invalid, which never
  connects, so the handshake itself was the unverified part; the live
  connection covers all three
- blockers now 5 -> 4; no cross-reference lost: docs/deployment.md:231
  already documents the rediss://default:<url-encoded-password>@ form,
  and :220 already draws the startup-env vs business-config hot-reload
  line, so deleting the item drops no surviving fact
- docs/charter.md 6.6: the blocker enumeration mirrored opengaps.md and
  must move with it, and the credential preamble ("无 Stalwart /
  Telegram / 托管 Redis 凭据") was already stale since the Stalwart and
  Telegram creds both verified live, so it now states what is actually
  unverified, which is the inbound callback direction
- 代码缺口 has nothing to remove: its only remaining item is
  SAF-DEBUG-ALLOWLIST, which is deliberately unimplemented by product
  decision and src/debug.rs:233-235 still shows the empty-allowlist
  short-circuit
- GATE-DOCS 6/6 pass: ANCHOR 267 (was 269, the item carried 2 refs),
  SECTION-REF 224, PATH 390 (was 392)
2026-09-29 19:44:05 +08:00
anonsaber d4c4c1d09b docs: rename roadmap.md to opengaps.md and drop completed items
- rename docs/roadmap.md -> docs/opengaps.md: file now holds only
  still-open / still-unverified items, so the name matches its content
- removed items proven done during the 2026-09-28 live integration run:
  /search E2E, Telegram 429 backoff, multi-instance duplicate window,
  /ready end-to-end dependency probe, JMAP read-only adapter real-device
  verification, real platform log collection acceptance
- removed the C-NO-LONG-CONN long-polling item (permanent non-goal,
  already registered in docs/retired.md) and the duplicated Redis ACL
  encoding clause (merged into the Redis TLS item)
- removed the whole 验收待办 section (all 3 items resolved)
- narrowed the phase goal to setWebhook + PushSubscription callback chain
- baseline corrected 61 -> 69 passed / 0 failed / 1 ignored, re-measured
  2026-09-29 with cargo test --locked --bin message-weave
- docs/charter.md D-G1-1 marked real-device verified, closing the
  contradiction with docs/design.md:397/:600
- update the DOCS list in all 4 docs gate scripts + TARGET_BEFORE regex
- GATE-DOCS 6/6 pass: VALIDATION, TABLE, ANCHOR 269, SECTION-REF 224,
  SIZE, PATH 392
2026-09-29 19:38:38 +08:00
anonsaber b3568b63f2 docs(roadmap): drop unsupported config-key/redis-url hot reload, mark verified integration paths 2026-09-29 19:22:05 +08:00
anonsaber 9ce0c9f40c docs(roadmap): fix two stale anchors for the webhook and retry-exhaustion code 2026-09-29 19:17:15 +08:00
anonsaber fc686abc22 config: persist business config regardless of client build, add preflight probe
PUT /api/business-config and POST /api/bootstrap now treat validation as the only
write gate: a valid wire is always persisted, and only then are the clients built
and the running worker swapped. A failed build returns 200 with persisted=true,
runtime_applied=false and a warnings array of {component, step, detail} instead of
503, so an unreachable JMAP/LLM no longer turns a valid configuration into a silent
data loss. The previous worker keeps serving.

POST /api/business-config/preflight runs the same validation and client build
against the submitted wire without writing anything and without touching the
running worker. It answers 200 even when the configuration is bad, because that
is the answer the caller asked for; components is null after a validation
failure, and llm is null rather than vacuously ok when LLM is disabled.
Authentication is the admin-session gate, not DEBUG_TOKEN.

The SPA drops the account_id field (the backend still accepts it; omitted falls
back to the session primary account), adds a Test connection button, and shows
the build fingerprint in the footer, the topbar and the setup card. The
503 message no longer claims JMAP/LLM build failure, which is not what produces
it.

BusinessConfigWire.account_id gains #[serde(default)]; without it, omitting the
field in the wire body made deserialization fail outright.

build.rs bakes BUILD_VERSION (git sha or nogit, plus a UTC build timestamp) which
GET /api/status exposes as version.
2026-09-29 18:25:56 +08:00
anonsaber dda8e084ae fix(jmap): trust the configured origin host so Stalwart's session redirect is followed
jmap-client refuses to follow any redirect unless the target host is on a
trust list that defaults to empty. Stalwart 307-redirects /.well-known/jmap
to /jmap/session, so connect() aborted with "Aborting redirect request to
unknown host 'mail.motofans.club'" and PUT /api/business-config failed with
a 503 carrying no usable detail.

connect_with_runtime now passes the origin host parsed from the normalized
session URL to follow_redirects, trusting that host only -- a target host the
server invents is still refused. An unparseable base yields an empty host,
which matches no target and preserves the previous fail-closed behaviour.

Verified against a real Stalwart instance both ways: the ignored smoke test
session_list_and_read_smoke now passes (session -> folders -> email list ->
read) and fails with the redirect error when the change is reverted.

docs: record the redirect trust under SAF-JMAP-URL, mark G1/D-G1-1 verified
after the real-server run, and refresh two shifted line anchors in client.rs.
2026-09-29 16:30:55 +08:00
anonsaber 80d13c0292 fix(notify): stop exposing the business and admin routes in configuration-setup mode
The setup router delegated to the full factory, so all 12 business and
admin routes were registered even though not one could ever work:
admin_token is an empty SecretString and constant_time_eq has a
!expected.is_empty() guard, so an empty expected value rejects every
candidate forever. MemoryState could not persist a bootstrap write either.

Those routes answered 401 {"error":"unauthorized"}, which is
indistinguishable from a mistyped credential. An operator or a probe
reads that as "try a different token" when the truth is "these
endpoints do not exist in this mode".

router_configuration_setup now builds its own AppState and mounts only
/api/status, /ready and /healthz. Verified live with no env vars:
healthz 200, ready 503, /api/status 200 with the missing list, and all
12 business/admin routes 404. The SPA is unaffected: /api/status is the
only endpoint it calls at startup, and web/config.js reveals the
admin-session card only when ready === true.

Added configuration_setup_mounts_only_status_and_probes to guard the
route table, and configuration_setup_call as its helper.

Docs: reference.md section 3 and 5.1 now describe the reduced surface
and cite the empty-expected-value guard; deployment.md's probe table had
/api/status listed as 503 {"status":...} when it returns 200 with a
"mode" field; retired.md section 4.1 records the removed route
registrations. Anchors refreshed for the +19 lines in notify.rs.
2026-09-29 15:26:54 +08:00