docs: consume all four self-checks; make the file-size rule enforceable

Every member's audit is now read and each real finding fixed, not just reported.

AGENTS.md §2.1
- The 500-line soft cap now has a machine-checkable exception: an over-limit
  file must carry a one-line `SPLIT-EVAL:` marker stating the split decision.
- Six files were over the cap with no justification: notify.rs 2036, state.rs
  1586, worker.rs 1045, config.js 1079, client.rs 601, config.rs 521. All now
  carry the marker. client.rs was missed by my own earlier line count, which
  did not descend into src/domain/jmap/.
- New validator scripts/docs_check/check_file_size.py; GATE-DOCS 4/4 -> 5/5.
  It proves the marker exists, not that the written reason is sound.
- Re-based 144 line anchors across six docs for the +1 line each file gained;
  every shifted anchor verified against the code, not just for non-blankness.

audit-arch.md
- design.md §6.1 module matrix omitted the `debug` module entirely; added.
- Seven numeric duplications in design.md now defer to reference.md §6.1 as
  the single authority instead of restating the values.
- reference.md §5.1 was missing DEBUG_TOKEN; §5.3 listed six recognised names
  and guessed `JMAP_*`. It now lists all 20 exactly as config.rs reads them.
- Corrected two wrong claims in the audit itself: debug.rs is compiled
  unconditionally, not under #[cfg(debug_assertions)] (there are no cargo
  features at all), and /debug/* exposes 7 routes, not the invented session
  list / resend / rebuild surface.

audit-fileplanner.md
- reference.md cited a nonexistent `jmap_service.rs`; replaced with the real
  adapter chain worker.rs -> domain/jmap.rs -> domain/jmap/client.rs.
- cloudflare-worker/README.md was outside every validator's scope; all four
  doc validators now include it (208 section refs, 250 line refs).
- README.zh-CN.md stated only half of what README.md says about RUN_MODE; the
  forward-placeholder sentence is now bilingual.
- charter.md §7 boundary table was missing rows for itself and for web/.

audit-rust.md / audit-frontend.md
- Error envelope corrected: `error` is a bare machine code, no `message`.
- Status table, AI-consent labels and ~15 stale or phantom facts fixed against
  notify.rs; `TelegramClient::SendMessage` and `web_config` phantoms removed.
- GET|PUT /api/enabled forwarded by the worker so the SPA kill switch works.
- Ten UI strings that called the admin credential the Redis ACL password now
  say CONFIG_ENCRYPTION_KEY.
- POST /healthz returns 405; dead HTTP_STATUS removed.
- audit_anchors.py now flags identifiers that do not exist in the file they
  are anchored to.

charter.md
- `SAF-ENABLE-FLAG` registered (85 IDs); `SAF-ADMIN-SESSION` row corrected;
- `REQ-PUSH-TYPES` registered; the stale `admin_session_digest` removed.

Gates: GATE-DOCS PASS 5/5 (250 line-refs, 208 section refs, 0 errors);
GATE-P0 62 passed / 0 failed / 1 ignored, baseline unchanged; worker and SPA
36 tests, 0 failures.

Real Stalwart / Telegram / managed Redis verification stays blocked on missing
credentials and remains labelled as unverified.
This commit is contained in:
anonsaber 2026-09-29 01:16:11 +08:00
parent e0882c7c68
commit 638c3a8cbc
26 changed files with 345 additions and 172 deletions

View File

@ -6,7 +6,7 @@
> >
> 两者冲突时:**项目事实与安全不变量以 `docs/charter.md` 为准,质量规则以本文件为准。** > 两者冲突时:**项目事实与安全不变量以 `docs/charter.md` 为准,质量规则以本文件为准。**
> **版本基线** `b70d188`(代码:所有行号锚点)|`526a73b`(文档:所有 `§x.y` 引用) > **版本基线** 填写本仓库的基线 commit:<`<代码行号锚点基线>`(例如 `b70d188`)|`<文档章节引用基线>`(例如 `e0882c7`)>,由持有门禁结果的提交者在合并该批改动时更新。
> 校验命令(`GATE-DOCS` / `GATE-P0`)的具体可复制版本写在 [`docs/charter.md`](docs/charter.md) §5。 > 校验命令(`GATE-DOCS` / `GATE-P0`)的具体可复制版本写在 [`docs/charter.md`](docs/charter.md) §5。
@ -36,9 +36,9 @@
### 2.1 文件规模 ### 2.1 文件规模
- 单文件规模控制在 **500 行**以内(软上限)。 - 单文件规模控制在 **500 行**以内(软上限)。
- 接近 400 行必须评估拆分。确需超过 500 行时,在文件头部写一句拆分评估结论与当前不拆的理由 - 接近 400 行必须评估拆分。确需超过 500 行时,文件头部必须有一行以 `SPLIT-EVAL:` 开头的注释,
(例:内聚性优先——A 与 B 共享 C,拆分会引入 N 处重复)。 写明拆分评估结论与当前不拆的理由(例:内聚性优先——A 与 B 共享 C,拆分会引入 N 处重复)。
- **未写明理由的超限文件视为待办项。** 超限不自动合规。 - **未带 `SPLIT-EVAL:` 标记的超限文件视为待办项。** 超限不自动合规。
- 不得为凑合规而机械拆分,也不得为压低行数把逻辑塞进不可读的长函数。 - 不得为凑合规而机械拆分,也不得为压低行数把逻辑塞进不可读的长函数。
### 2.2 注释与代码一致 ### 2.2 注释与代码一致

View File

@ -57,7 +57,7 @@ docker run --env-file .env -p 8080:8080 messageweave:latest
| `PORT` | 否 | `8080` | | `PORT` | 否 | `8080` |
| `RUN_MODE` | 否 | `webhook` | | `RUN_MODE` | 否 | `webhook` |
`RUN_MODE` 只接受 `webhook` 或 `reconcile`。`.env.example` 只提供 2 个必需变量;其余是 `RUN_MODE` 只接受 `webhook` 或 `reconcile`,但两种取值当前行为完全相同(共享同一套路由表),仅作前向占位,保持默认 `webhook` 即可。`.env.example` 只提供 2 个必需变量;其余是
文档化默认值。 文档化默认值。
**业务配置 —— 存于 Redis,由浏览器写入。** **业务配置 —— 存于 Redis,由浏览器写入。**

View File

@ -3,7 +3,7 @@
统一 HTTPS 入口 + 多后端 origin 故障转移(HA/LB 子项目)。 统一 HTTPS 入口 + 多后端 origin 故障转移(HA/LB 子项目)。
**透传模型**:Worker 不感知业务,原样转发请求到多个 https 后端 origin;仅「超时 / 5xx」做有界故障转移。 **透传模型**:Worker 不感知业务,原样转发请求到多个 https 后端 origin;仅「超时 / 5xx」做有界故障转移。
> 本组件是 **safelist 受限的边缘负载均衡器(edge load balancer)**:路由固定 14 条白名单、 > 本组件是 **safelist 受限的边缘负载均衡器(edge load balancer)**:路由固定 15 条白名单、
> 后端 origin 在部署期固定且仅允许 https、未知路径一律 404。它只对固定后端做请求转发与 > 后端 origin 在部署期固定且仅允许 https、未知路径一律 404。它只对固定后端做请求转发与
> 故障转移,不接受任意目标主机,也不提供任何形式的流量中转或访问隐藏能力。 > 故障转移,不接受任意目标主机,也不提供任何形式的流量中转或访问隐藏能力。
@ -31,21 +31,21 @@
### 透传(A,SAF-LB-PASSTHRU) ### 透传(A,SAF-LB-PASSTHRU)
- 原样转发 method / headers(含鉴权头)/ body;不鉴权改写。 - 原样转发 method / headers(含鉴权头)/ body;不鉴权改写。
- 所有后端实例共享同一组 secret(`C-LB-SHARED-SECRETS`):同一 `TELEGRAM_WEBHOOK_SECRET`、 - 所有后端实例共享同一组 secret(`C-LB-SHARED-SECRETS`):同一 `TELEGRAM_WEBHOOK_SECRET`、
`JMAP_PUSH_VERIFICATION_CODE`、`API_TOKEN`(`API_TOKEN` 在 §10 仅用于多实例互斥,非鉴权) `JMAP_PUSH_VERIFICATION_CODE`、`API_TOKEN`(`API_TOKEN` 在 `docs/deployment.md` §10 仅用于多实例互斥,非鉴权)
+ 相同 App Password(各后端实例的 `JMAP` 可不同,§10.2)。 + 相同 App Password(各后端实例的 `JMAP` 可不同,`docs/deployment.md` §10.2)。
### 路由 safelist(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL) ### 路由 safelist(ARCH-LB-WORKER / C-LB-SINGLE-REG-URL)
- 透传公开 `GET /api/status` 启动状态,以及管理 SPA API:`POST /api/admin/session[/revoke]`、`GET|PUT /api/config`、`PUT /api/business-config`。启动状态只包含缺少的环境变量名称;管理 API 的鉴权仍由后端执行。 - 透传公开 `GET /api/status` 启动状态,以及管理 SPA API:`POST /api/admin/session[/revoke]`、`GET|PUT /api/enabled`(业务总开关,SPA 服务卡片读写)、`GET|PUT /api/config`、`PUT /api/business-config`。启动状态只包含缺少的环境变量名称;管理 API 的鉴权仍由后端执行。
- 只透传 `POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`。 - 只透传 `POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`。
- 其它路径 → **404**(不透传,避免 Worker 沦为后端任意路径的跳板)。 - 其它路径 → **404**(不透传,避免 Worker 沦为后端任意路径的跳板)。
- method 不符 → **405**。 - method 不符 → **405**。
### 有界故障转移(§10.4,deployment.md) ### 有界故障转移(`docs/deployment.md` §10.4)
- 每次请求只试 `min(LB_MAX_ATTEMPTS, origins.length)` 次;默认 2(首次 + 1 次换实例)。 - 每次请求只试 `min(LB_MAX_ATTEMPTS, origins.length)` 次;默认 2(首次 + 1 次换实例)。
- **仅** 超时(AbortError)或 5xx 触发换下一个实例;4xx / 2xx / 3xx 直接返回。 - **仅** 超时(AbortError)或 5xx 触发换下一个实例;4xx / 2xx / 3xx 直接返回。
- 起点按 `rng` 随机化(默认 `Math.random`),实现双活分摊;单 origin 配置时退化为确定性。 - 起点按 `rng` 随机化(默认 `Math.random`),实现双活分摊;单 origin 配置时退化为确定性。
- 全失败 → **503 All Backends Unavailable**(交由 Telegram / Stalwart 自动重投兜底,不丢消息)。 - 全失败 → **503 All Backends Unavailable**(交由 Telegram / Stalwart 自动重投兜底,不丢消息)。
- 健康缓存(`aggregateHealth` / `LB_HEALTH_TTL_MS`)仅服务于 `/healthz` 聚合视图;转发目前**不做健康路由过滤**(§10.4「只向健康实例转发」为后续增强,当前靠故障转移兜底)。 - 健康缓存(`aggregateHealth` / `LB_HEALTH_TTL_MS`)仅服务于 `/healthz` 聚合视图;转发目前**不做健康路由过滤**(`docs/deployment.md` §10.4「只向健康实例转发」为后续增强,当前靠故障转移兜底)。
- **例外:`POST /reconcile` 走 per-route 覆盖。** 它是后端同步长任务——后端持集群级锁 `lock:reconcile`(初租 300s,期间由心跳续期),全局 10s 超时会把它误判为失败并故障转移到第二实例,而第二实例必然立刻返回 409。所以该路由改为:超时取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000`,留足锁初租 + 心跳余量),且 `maxAttempts=1` 绝不故障转移。其余快路径保持全局默认。 - **例外:`POST /reconcile` 走 per-route 覆盖。** 它是后端同步长任务——后端持集群级锁 `lock:reconcile`(初租 300s,期间由心跳续期),全局 10s 超时会把它误判为失败并故障转移到第二实例,而第二实例必然立刻返回 409。所以该路由改为:超时取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000`,留足锁初租 + 心跳余量),且 `maxAttempts=1` 绝不故障转移。其余快路径保持全局默认。
### 健康聚合(MOD-HEALTH-AGG;C-NO-DB / C-REDIS-ONLY-STATE) ### 健康聚合(MOD-HEALTH-AGG;C-NO-DB / C-REDIS-ONLY-STATE)

View File

@ -15,6 +15,7 @@ export const SAFE_ROUTES = Object.freeze([
"/api/business-config", "/api/business-config",
"/api/admin/session", "/api/admin/session",
"/api/admin/session/revoke", "/api/admin/session/revoke",
"/api/enabled",
"/webhook/tg", "/webhook/tg",
"/push/jmap", "/push/jmap",
"/api/push/register", "/api/push/register",
@ -33,7 +34,6 @@ export class BackendConfigError extends Error {
} }
} }
const HTTP_STATUS = 80;
const HTTPS_STATUS = 443; const HTTPS_STATUS = 443;
/** /**
@ -118,4 +118,4 @@ export function parseBackendOrigins(raw) {
return out; return out;
} }
export { HTTP_STATUS, HTTPS_STATUS }; export { HTTPS_STATUS };

View File

@ -51,6 +51,7 @@ const ROUTE_METHODS = Object.freeze({
"/api/business-config": ["PUT"], "/api/business-config": ["PUT"],
"/api/admin/session": ["POST"], "/api/admin/session": ["POST"],
"/api/admin/session/revoke": ["POST"], "/api/admin/session/revoke": ["POST"],
"/api/enabled": ["GET", "PUT"],
"/webhook/tg": ["POST"], "/webhook/tg": ["POST"],
"/push/jmap": ["POST"], "/push/jmap": ["POST"],
"/api/push/register": ["POST"], "/api/push/register": ["POST"],
@ -69,7 +70,10 @@ export async function handleFetch(request, env) {
const path = new URL(request.url).pathname; const path = new URL(request.url).pathname;
// LB 级健康聚合探针(不含敏感信息,SAF-PROBE-PUBLIC 精神)。 // LB 级健康聚合探针(不含敏感信息,SAF-PROBE-PUBLIC 精神)。
if (path === "/healthz" && request.method === "GET") { if (path === "/healthz") {
if (request.method !== "GET") {
return text("method not allowed for /healthz", 405, { allow: "GET" });
}
return handleHealth(env); return handleHealth(env);
} }

View File

@ -47,6 +47,7 @@ test("route safelist excludes /healthz and unknown paths (C-LB-SINGLE-REG-URL)",
"/", "/assets/config.js", "/assets/styles.css", "/api/status", "/ready", "/webhook/tg", "/push/jmap", "/reconcile", "/", "/assets/config.js", "/assets/styles.css", "/api/status", "/ready", "/webhook/tg", "/push/jmap", "/reconcile",
"/api/push/register", "/api/push/disable", "/api/push/register", "/api/push/disable",
"/api/config", "/api/business-config", "/api/admin/session", "/api/admin/session/revoke", "/api/config", "/api/business-config", "/api/admin/session", "/api/admin/session/revoke",
"/api/enabled",
]) { ]) {
assert.ok(LB_ROUTES.includes(r), `forwarded: ${r}`); assert.ok(LB_ROUTES.includes(r), `forwarded: ${r}`);
} }

View File

@ -40,6 +40,15 @@ test("Worker GET /healthz aggregates backend probes without touching forwarded r
} }
}); });
test("Worker: wrong method on the local /healthz probe => 405, not 404", async () => {
const res = await handleFetch(
new Request("https://lb.example/healthz", { method: "POST", body: "{}" }),
makeEnv(),
);
assert.equal(res.status, 405);
assert.equal(res.headers.get("allow"), "GET");
});
test("Worker: unknown route => 404 (C-LB-SINGLE-REG-URL)", async () => { test("Worker: unknown route => 404 (C-LB-SINGLE-REG-URL)", async () => {
const res = await handleFetch( const res = await handleFetch(
new Request("https://lb.example/unknown", { method: "POST", body: "{}" }), new Request("https://lb.example/unknown", { method: "POST", body: "{}" }),

View File

@ -9,7 +9,7 @@
> >
> 三者冲突时:**可核对事实以 `reference.md` 为准,项目约束以本文件为准,质量规则以 `../AGENTS.md` 为准。** > 三者冲突时:**可核对事实以 `reference.md` 为准,项目约束以本文件为准,质量规则以 `../AGENTS.md` 为准。**
> **代码基线** `b70d188`(src/ 行号锚点)|`526a73b`(文档)。 > **代码基线** `b70d188`(src/ 行号锚点)|`e0882c7`(文档)。
--- ---
@ -126,9 +126,12 @@ cd /home/okabe/Repo/messageweave && docker run --rm --user 1000:1000 \
cd /home/okabe/Repo/messageweave && bash scripts/docs_check/run_all.sh cd /home/okabe/Repo/messageweave && bash scripts/docs_check/run_all.sh
``` ```
4 个校验器全过、exit 0 才算通过。只读,从不写文件。 5 个校验器全过、exit 0 才算通过。只读,从不写文件。
**`0 error` 只证明「可达」**:行号存在、引用可解析、表格列数一致。 新增 `check_file_size`:`src/`、`web/`、`cloudflare-worker/src/` 下超过 500 行的源文件必须带
`SPLIT-EVAL:` 标记(AGENTS.md §2.1)。它证明标记存在,不证明写下的拆分理由站得住脚。
**`0 error` 只证明「可达」**:行号存在、引用可解析、表格列数一致、标记就位。
**它不证明语义正确。** 报告校验结果时必须说明这个区分。 **它不证明语义正确。** 报告校验结果时必须说明这个区分。
### 6.4 前端测试 ### 6.4 前端测试
@ -165,6 +168,8 @@ Push callback 公网映射。
| `docs/reference.md` | 可核对事实的唯一权威来源:路由、Redis 键与 TTL、配置项、错误码、出站常量 | **可核对事实** | | `docs/reference.md` | 可核对事实的唯一权威来源:路由、Redis 键与 TTL、配置项、错误码、出站常量 | **可核对事实** |
| `docs/roadmap.md` | 缺口、阻塞、下一阶段目标 | 未决项 | | `docs/roadmap.md` | 缺口、阻塞、下一阶段目标 | 未决项 |
| `docs/retired.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.md` | 网关自身的配置与语义 | 网关 |
**跨文档引用规则**: **跨文档引用规则**:
@ -184,7 +189,7 @@ Push callback 公网映射。
- 新增 ID 必须先登记,再出现在注释、文档或标识符中;校验器会拒绝未登记 ID。 - 新增 ID 必须先登记,再出现在注释、文档或标识符中;校验器会拒绝未登记 ID。
- 内容搬家时**只更新本表「定义文件」列**,所有引用本身零改动。 - 内容搬家时**只更新本表「定义文件」列**,所有引用本身零改动。
共 83 个 ID,按前缀分组:`C-*`(部署约束)· `NG-*`(非目标)· `MOD-*`(模块)· 共 85 个 ID,按前缀分组:`C-*`(部署约束)· `NG-*`(非目标)· `MOD-*`(模块)·
`FLOW-*`(数据流)· `REQ-*`(需求)· `SAF-*`(安全)· `NFR-*`(非功能)· `GATE-*`(门禁)· `FLOW-*`(数据流)· `REQ-*`(需求)· `SAF-*`(安全)· `NFR-*`(非功能)· `GATE-*`(门禁)·
`BOUND-*`(边界)· `ARCH-*`(架构)。 `BOUND-*`(边界)· `ARCH-*`(架构)。
@ -223,6 +228,7 @@ Push callback 公网映射。
| `REQ-AI-EXTERNAL-CONSENT` | docs/design.md §12 / §3 | 仅用户明确允许后才向外部 AI 发正文 | 需求 | | `REQ-AI-EXTERNAL-CONSENT` | docs/design.md §12 / §3 | 仅用户明确允许后才向外部 AI 发正文 | 需求 |
| `REQ-AI-CONSENT` | docs/design.md §12.3.1 | AI 授权期限与 Redis 短期 TTL(授权有效期、到期后重新询问) | 需求 | | `REQ-AI-CONSENT` | docs/design.md §12.3.1 | AI 授权期限与 Redis 短期 TTL(授权有效期、到期后重新询问) | 需求 |
| `REQ-SINGLE-ACCOUNT` | docs/design.md §3.1/§11.3 | 单账户;多账户 = 多 bot 实例 | 需求 | | `REQ-SINGLE-ACCOUNT` | docs/design.md §3.1/§11.3 | 单账户;多账户 = 多 bot 实例 | 需求 |
| `REQ-PUSH-TYPES` | src/domain/jmap/client.rs:109 注释 | `PushSubscription/set` create 在 jmap-client 0.4.2 中没有 `types` 参数;订阅 id 对外暴露前须经 `push_subscription_update_types` 收窄为 `Email` + `EmailDelivery` | 需求 |
| `REQ-RECONCILE-IDEMPOTENCY` | src/state.rs `claim_dedup` + `get_reconcile_state` / docs/design.md §8.2 | JMAP 对账游标只有在全部分页事件成功入队(XADD)后才推进;单次对账由 Redis SET NX EX 锁 `lock:reconcile` 保证单飞(TTL 300s,owner token 续期 90s,仅持有者可续期/释放);处理端再经 `claim_dedup`(SET NX EX,86400s)保证同一流消息不重复投递 | 需求 | | `REQ-RECONCILE-IDEMPOTENCY` | src/state.rs `claim_dedup` + `get_reconcile_state` / docs/design.md §8.2 | JMAP 对账游标只有在全部分页事件成功入队(XADD)后才推进;单次对账由 Redis SET NX EX 锁 `lock:reconcile` 保证单飞(TTL 300s,owner token 续期 90s,仅持有者可续期/释放);处理端再经 `claim_dedup`(SET NX EX,86400s)保证同一流消息不重复投递 | 需求 |
| `NFR-NOTIFY-SLA` | docs/deployment.md §6.5 | 通知可用性 ≥99.9%,允许少量延迟 | 非功能 | | `NFR-NOTIFY-SLA` | docs/deployment.md §6.5 | 通知可用性 ≥99.9%,允许少量延迟 | 非功能 |
| `NFR-RECONCILE-INTERVAL` | docs/deployment.md §6.3 | 外部 Cron 对账间隔 5–10 分钟 | 非功能 | | `NFR-RECONCILE-INTERVAL` | docs/deployment.md §6.3 | 外部 Cron 对账间隔 5–10 分钟 | 非功能 |
@ -261,13 +267,14 @@ Push callback 公网映射。
| `SAF-DEBUG-GATE` | src/main.rs / src/debug.rs / docs/design.md §7.6 / docs/deployment.md §2.1 | 双因子门禁:启动带 `--debug` **且** `DEBUG_TOKEN` 非空才挂载路由;缺任一完全不挂载(请求落通用 `404`),默认绝对关闭 | 安全 | | `SAF-DEBUG-GATE` | src/main.rs / src/debug.rs / docs/design.md §7.6 / docs/deployment.md §2.1 | 双因子门禁:启动带 `--debug` **且** `DEBUG_TOKEN` 非空才挂载路由;缺任一完全不挂载(请求落通用 `404`),默认绝对关闭 | 安全 |
| `SAF-DEBUG-AUTH` | src/debug.rs / docs/deployment.md §2.1 | 挂载后 `/debug/*` 须 `Authorization: Bearer DEBUG_TOKEN` 常数时间比较,失败 `401` 且无副作用 | 安全 | | `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` 布尔,非密文的身份与预算字段仍明文返回) | 需求 | | `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/*` 不在网关 14 条安全路由内,Worker 一律 `404 route not forwarded`;只能直连后端 origin,公网不可达 | 安全 | | `SAF-DEBUG-ORIGIN-ONLY` | docs/deployment.md §2.1 / docs/reference.md §4 | `/debug/*` 不在网关 15 条安全路由内,Worker 一律 `404 route not forwarded`;只能直连后端 origin,公网不可达 | 安全 |
| `SAF-DEBUG-ALLOWLIST` | src/debug.rs / docs/deployment.md §2.1 | `POST /debug/notify` 仅在 chat 白名单**非空**时校验 `chat_id`;白名单未配置(空)时不拦截,故启用本面须确认业务白名单已配置 | 安全 | | `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 单点故障不在方案范围(用户外部解决) | 非功能 | | `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 为唯一状态存储;应用不连接第二个数据库 | 约束 | | `C-NO-DB` | docs/deployment.md §0 / §9.1 / §3 | 生产不使用任何数据库(无 SQLite/Postgres/MySQL/嵌入式),Redis 为唯一状态存储;应用不连接第二个数据库 | 约束 |
| `C-NO-LOCAL-WRITE` | docs/deployment.md §0 / §9.1 / §3 | 禁止本地文件/目录写入(日志/数据/临时缓存/本地卷) | 约束 | | `C-NO-LOCAL-WRITE` | docs/deployment.md §0 / §9.1 / §3 | 禁止本地文件/目录写入(日志/数据/临时缓存/本地卷) | 约束 |
| `C-LOG-STDOUT-ONLY` | docs/deployment.md §0 / §9.1 / §3 | 日志只写 stdout/stderr,由平台采集;禁用文件日志后端 | 约束 | | `C-LOG-STDOUT-ONLY` | docs/deployment.md §0 / §9.1 / §3 | 日志只写 stdout/stderr,由平台采集;禁用文件日志后端 | 约束 |
| `SAF-LOG-PURITY` | docs/deployment.md §0 / §9.1 / §3 | 日志与 Redis 写入内容仅限结构化事件/计数/时间戳/脱敏摘要;禁止密钥/邮件正文/AI 请求响应/附件内容 | 安全 | | `SAF-LOG-PURITY` | docs/deployment.md §0 / §9.1 / §3 | 日志与 Redis 写入内容仅限结构化事件/计数/时间戳/脱敏摘要;禁止密钥/邮件正文/AI 请求响应/附件内容 | 安全 |
| `SAF-ENABLE-FLAG` | src/notify.rs `put_enabled` / src/state.rs `config:enabled` | 全局开关是 Redis 单键 `config:enabled`;未写入即视为关闭,`business_enabled` 出错也按关闭处理(fail-closed);写入须 admin-session Bearer | 安全 |
| `C-NO-STATEFUL-RECOVERY` | docs/deployment.md §0 / §9.1 / §3 | 禁止依赖进程内状态做生产恢复;恢复一律走 Redis + JMAP 对账;进程内缓存仅为性能优化,丢失须安全可重入 | 约束 | | `C-NO-STATEFUL-RECOVERY` | docs/deployment.md §0 / §9.1 / §3 | 禁止依赖进程内状态做生产恢复;恢复一律走 Redis + JMAP 对账;进程内缓存仅为性能优化,丢失须安全可重入 | 约束 |
| `ARCH-STATE-REDIS` | docs/design.md §10.0 / Redis 为唯一状态来源 | 状态层统一走 Redis(Streams/SET NX/锁/摘要),进程不持有可恢复状态 | 架构 | | `ARCH-STATE-REDIS` | docs/design.md §10.0 / Redis 为唯一状态来源 | 状态层统一走 Redis(Streams/SET NX/锁/摘要),进程不持有可恢复状态 | 架构 |
| `C-REDIS-EXTERNAL` | docs/deployment.md §8.1 | Redis 由外部已认证实例提供,不与本服务同容器 | 部署约束 | | `C-REDIS-EXTERNAL` | docs/deployment.md §8.1 | Redis 由外部已认证实例提供,不与本服务同容器 | 部署约束 |

View File

@ -62,11 +62,11 @@
1. 进程命令行必须带 `--debug`(读取于 `src/main.rs:96`)。 1. 进程命令行必须带 `--debug`(读取于 `src/main.rs:96`)。
2. `DEBUG_TOKEN` 环境变量必须存在且非空(读取于 `src/main.rs:97-100`)。 2. `DEBUG_TOKEN` 环境变量必须存在且非空(读取于 `src/main.rs:97-100`)。
缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1320-1321`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:107-109`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。 缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1321-1322`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:107-109`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。
**鉴权** **鉴权**
7 条路由全部要求 `Authorization: Bearer <DEBUG_TOKEN>`,统一走 `debug_authorized`(`src/debug.rs:45`),它委托生产同款 `worker_authorized`,因此令牌是**常数时间比较**(`src/notify.rs:1135`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:52-54`)。 7 条路由全部要求 `Authorization: Bearer <DEBUG_TOKEN>`,统一走 `debug_authorized`(`src/debug.rs:45`),它委托生产同款 `worker_authorized`,因此令牌是**常数时间比较**(`src/notify.rs:1136`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:52-54`)。
**7 条路由与预期状态码**(注册于 `src/debug.rs:72-80`,路径为字面量,无常量抽取) **7 条路由与预期状态码**(注册于 `src/debug.rs:72-80`,路径为字面量,无常量抽取)
@ -87,8 +87,8 @@
**三条约束,部署时务必确认** **三条约束,部署时务必确认**
- **绝不回显凭据值**:`debug_config`(`src/debug.rs:94`)对每个 Secret 字段只输出 `*_configured` **布尔**——JMAP 密码、bot token、`worker_token`、`reconcile_token`、LLM `api_key` 一律不落响应体(`SAF-DEBUG-AUTH`)。注意这是「不含凭据」,不是「全脱敏」:**非密文的身份与预算字段是明文返回的**(JMAP session URL 与 username、Telegram `chat_id`、LLM `base_url`/`model`、各类超时与重试数),所以该面仍只能放在可信网络上。 - **绝不回显凭据值**:`debug_config`(`src/debug.rs:94`)对每个 Secret 字段只输出 `*_configured` **布尔**——JMAP 密码、bot token、`worker_token`、`reconcile_token`、LLM `api_key` 一律不落响应体(`SAF-DEBUG-AUTH`)。注意这是「不含凭据」,不是「全脱敏」:**非密文的身份与预算字段是明文返回的**(JMAP session URL 与 username、Telegram `chat_id`、LLM `base_url`/`model`、各类超时与重试数),所以该面仍只能放在可信网络上。
- **`/debug/notify` 受 chat 白名单约束,但空白名单不拦截**:它复用生产同一份白名单快照(`src/notify.rs:1141`),判定条件是「白名单**非空**且 `chat_id` 不在其中」才回 `403 chat_not_allowed`(`src/debug.rs:232-235`)。因此已配置白名单时无法绕过业务侧发送限制;若白名单未配置(为空)则此判定不生效,`chat_id` 可任意指定——所以启用本面时应确认业务白名单已真正配置。`text` 缺省为固定联调文案,并按 1024 字符截断(`src/debug.rs:240-245`)。 - **`/debug/notify` 受 chat 白名单约束,但空白名单不拦截**:它复用生产同一份白名单快照(`src/notify.rs:1142`),判定条件是「白名单**非空**且 `chat_id` 不在其中」才回 `403 chat_not_allowed`(`src/debug.rs:232-235`)。因此已配置白名单时无法绕过业务侧发送限制;若白名单未配置(为空)则此判定不生效,`chat_id` 可任意指定——所以启用本面时应确认业务白名单已真正配置。`text` 缺省为固定联调文案,并按 1024 字符截断(`src/debug.rs:240-245`)。
- **不在 Worker 白名单内,只能直连 origin**:网关的 14 条安全路由(`cloudflare-worker/src/backends.js:9-24`)不含任何 `/debug/*`,Worker 对未白名单路径返回 `404 route not forwarded: /debug/...`(`cloudflare-worker/src/index.js:77-78`)。因此 `/debug/*` 只能通过直连后端 origin 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。 - **不在 Worker 白名单内,只能直连 origin**:网关的 15 条安全路由(`cloudflare-worker/src/backends.js:9-25`)不含任何 `/debug/*`,Worker 对未白名单路径返回 `404 route not forwarded: /debug/...`(`cloudflare-worker/src/index.js:81-82`)。因此 `/debug/*` 只能通过直连后端 origin 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。
> **建议**:生产环境不开启。需要远程联调时临时开启、用一次性 token,联调结束立即移除 `--debug` 与 `DEBUG_TOKEN` 后重启;长期暴露面走 §5 的 Secret 管理流程单独审批。 > **建议**:生产环境不开启。需要远程联调时临时开启、用一次性 token,联调结束立即移除 `--debug` 与 `DEBUG_TOKEN` 后重启;长期暴露面走 §5 的 Secret 管理流程单独审批。
@ -249,7 +249,7 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达
#### 6.3.1 用户侧调度示例(copy-paste 可用) #### 6.3.1 用户侧调度示例(copy-paste 可用)
端点:`POST https://<你的平台URL>/reconcile`。鉴权:`Authorization: Bearer <RECONCILE_TOKEN>` 端点:`POST https://<你的平台URL>/reconcile`。鉴权:`Authorization: Bearer <RECONCILE_TOKEN>`
(`src/config.rs:356` 启动必填,服务端常数时间比较,`notify.rs:227-230`)。 (`src/config.rs:357` 启动必填,服务端常数时间比较,`notify.rs:228-231`)。
最小示例: 最小示例:
@ -317,7 +317,7 @@ spec:
- 调度间隔按 `NFR-RECONCILE-INTERVAL` 取 **5–10 分钟**即可。Push 是主路径,对账只是兜底。 - 调度间隔按 `NFR-RECONCILE-INTERVAL` 取 **5–10 分钟**即可。Push 是主路径,对账只是兜底。
- 走 Worker 转发**无需特殊设置**:网关已为 `POST /reconcile` 单独覆盖超时与尝试次数——超时取 - 走 Worker 转发**无需特殊设置**:网关已为 `POST /reconcile` 单独覆盖超时与尝试次数——超时取
`LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms,大于单飞锁租期 300 s + 心跳余量;锁续租逻辑见 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms,大于单飞锁租期 300 s + 心跳余量;锁续租逻辑见
`notify.rs:239`/`252`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回 `notify.rs:240`/`252`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回
`409`)。其余快路径仍用全局 `LB_REQUEST_TIMEOUT_MS`(默认 `10000` ms)与 `LB_MAX_ATTEMPTS` `409`)。其余快路径仍用全局 `LB_REQUEST_TIMEOUT_MS`(默认 `10000` ms)与 `LB_MAX_ATTEMPTS`
(默认 `2`),不受影响。 (默认 `2`),不受影响。
@ -487,7 +487,7 @@ Stalwart PushSub ────┼─▶ https://lb.<you>.workers.dev ← 唯
### 10.4 路由与故障转移 ### 10.4 路由与故障转移
- **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 **14 条**(`backends.js:9-24` 的 `SAFE_ROUTES` + `index.js:45-60` 的 `ROUTE_METHODS`):`GET /`、`/assets/config.js`、`/assets/styles.css`、`GET /api/status`、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。**后端另有 5 类不在 safelist**:`GET|PUT /api/enabled`(内部开关)、`POST /api/bootstrap`(一次性信任引导,**不能**走 Worker 域名)、`POST /worker`(运维手工触发的 worker 端点,Bearer 鉴权)、`GET /healthz`(网关自行聚合,不转发)、`/debug/*`(7 条远程联调面,`SAF-DEBUG-GATE`,只能直连后端 origin,见 §2.1)——**这 5 类都不承载外部业务流量,因此后端实例前不需要第二道入口**。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。 - **路由 safelist**(`C-LB-SINGLE-REG-URL`):Worker 只透传 **15 条**(`SAFE_ROUTES` at `backends.js:9-25` + `ROUTE_METHODS` at `index.js:45-61`):`GET /`、`/assets/config.js`、`/assets/styles.css`、`GET /api/status`、`GET|PUT /api/config`、`PUT /api/business-config`、`POST /api/admin/session`、`POST /api/admin/session/revoke`、`GET|PUT /api/enabled`、`POST /webhook/tg`、`POST /push/jmap`、`POST /api/push/register`、`POST /api/push/disable`、`POST /reconcile`、`GET /ready`;**未知路径 404、method 不符 405**,不透传至后端。**后端另有 4 类不在 safelist**:`POST /api/bootstrap`(一次性信任引导,**不能**走 Worker 域名)、`POST /worker`(运维手工触发的 worker 端点,Bearer 鉴权)、`GET /healthz`(网关自行聚合,不转发)、`/debug/*`(7 条远程联调面,`SAF-DEBUG-GATE`,只能直连后端 origin,见 §2.1)——**这 4 类都不承载外部业务流量,因此后端实例前不需要第二道入口**。`GET|PUT /api/enabled` 属于必须透传的例外:管理 SPA 本身只部署在 Worker 域名下,业务总开关由它读取与切换(`loadEnabled` 在 `web/config.js:586` 读、开关写入在 `:958`),缺了这条白名单,SPA 里那个开关在后端直连模式下可用、经网关部署时恒 404;该路由已强制 admin-session Bearer 鉴权,暴露面与同在白名单内的 `/api/admin/session` 完全一致。`/api/status` 只返回启动状态和缺少的环境变量名;所有管理 API 的 Bearer 鉴权由后端执行(`SAF-LB-PASSTHRU`)。
- **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 透传给后端,做配置 + Redis + 出站只读探测(JMAP session、TG getMe,各 3s、并行,最坏约 3s),不触发业务副作用。 - **健康聚合(`MOD-HEALTH-AGG`)**:Worker 自行承载 `GET /healthz`,按 TTL 缓存(默认 30s,`LB_HEALTH_TTL_MS` 可调)探测各后端 `/healthz`,返回 `{status, available, total, backends:[{origin,up,status}]}`;≥1 后端 up → 200,全 down → 503。`/ready` 透传给后端,做配置 + Redis + 出站只读探测(JMAP session、TG getMe,各 3s、并行,最坏约 3s),不触发业务副作用。
- **故障转移(`forwardWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。**例外:`POST /reconcile` 固定 `maxAttempts=1`,绝不故障转移**——它持集群级单飞锁,切实例只会立刻撞 `409`;其单请求超时取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms),其余路由不受影响。 - **故障转移(`forwardWithFailover`)**:每次请求最多 `min(LB_MAX_ATTEMPTS, origins.length)` 次尝试;**仅**超时(AbortError)或 5xx 触发换下一个 origin;4xx/2xx/3xx 直接返回;默认 `LB_MAX_ATTEMPTS=2`(首次 + 1 次故障转移)。**例外:`POST /reconcile` 固定 `maxAttempts=1`,绝不故障转移**——它持集群级单飞锁,切实例只会立刻撞 `409`;其单请求超时取 `LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms),其余路由不受影响。
- **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。 - **随机分摊**:起点 origin 按 `Math.random` 随机化,实现双活;单 origin 配置时退化为确定性。

View File

@ -280,7 +280,7 @@ TG /read 3 → handler 取会话里的 folder+page 游标
当前**没有 FSM**。命令路由由 `src/worker.rs` 的 `parse_intent` 解析为 `Intent`;AI 授权态是 Redis 里的一个布尔加过期时间(键与 TTL 见 `docs/reference.md` 的 AI 授权态一节),不是多步确认态。 当前**没有 FSM**。命令路由由 `src/worker.rs` 的 `parse_intent` 解析为 `Intent`;AI 授权态是 Redis 里的一个布尔加过期时间(键与 TTL 见 `docs/reference.md` 的 AI 授权态一节),不是多步确认态。
仍成立的不变量:AI 摘要必须先有用户显式授权,**没有授权态不得调用 LLM**。授权一旦生效,摘要会拉取邮件正文全文送 LLM(`read_email` 请求 `TextBody` + `BodyValues` 并 `fetch_text_body_values(true)`,见 `src/domain/jmap/client.rs`),但正文**不回传给 Telegram**——出站只发摘要,或失败时回退为前 300 字符(`worker.rs:150` 注释:body text never reaches Telegram)。 仍成立的不变量:AI 摘要必须先有用户显式授权,**没有授权态不得调用 LLM**。授权一旦生效,摘要会拉取邮件正文全文送 LLM(`read_email` 请求 `TextBody` + `BodyValues` 并 `fetch_text_body_values(true)`,见 `src/domain/jmap/client.rs`),但正文**不回传给 Telegram**——出站只发摘要,或失败时回退为前 300 字符(`worker.rs:151` 注释:body text never reaches Telegram)。
曾设计过的 5 态 FSM(`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`)连同状态转移表与渠道中立说明,见 `docs/retired.md`。 曾设计过的 5 态 FSM(`Idle` / `AwaitClarify` / `AwaitConfirm` / `Analyzing` / `AwaitFallback`)连同状态转移表与渠道中立说明,见 `docs/retired.md`。
@ -347,6 +347,7 @@ message-weave/
| `ai` | reqwest 0.13 | `LlmClient` / `summarize` | mock OpenAI 兼容端点 | 已实现 | | `ai` | reqwest 0.13 | `LlmClient` / `summarize` | mock OpenAI 兼容端点 | 已实现 |
| `notify` | axum;redis | HTTP 鉴权、全局开关、`/push/jmap` 入队、`Email/changes` 对账和游标提交 | webhook 校验失败路径覆盖 403 | 已覆盖真实对账路径;仍需真实 Stalwart 环境做端到端验收 | | `notify` | axum;redis | HTTP 鉴权、全局开关、`/push/jmap` 入队、`Email/changes` 对账和游标提交 | webhook 校验失败路径覆盖 403 | 已覆盖真实对账路径;仍需真实 Stalwart 环境做端到端验收 |
| `web` | axum | `/config` 静态页 + `include_str!` 嵌入 + CSP | 前端由 `web/config.test.mjs` 覆盖 | 已实现 | | `web` | axum | `/config` 静态页 + `include_str!` 嵌入 + CSP | 前端由 `web/config.test.mjs` 覆盖 | 已实现 |
| `debug` | reqwest 0.13;redis 0.27 | 远端诊断面 `/debug/*`(`src/debug.rs`):6 条只读探针 `/debug/ping`、`/debug/config`、`/debug/redis`、`/debug/jmap`、`/debug/telegram`、`/debug/worker` + `POST /debug/notify`(走生产出站路径发一条测试消息,无独立实现) | 仅 `--debug` + `DEBUG_TOKEN` 双因子齐备时挂载路由;模块本身无条件编译 | 已实现,不进 Worker 白名单(`SAF-DEBUG-ORIGIN-ONLY`) |
> 注:「现状」列是**模块级**口径(模块已落地),不代表行为完备。行为级缺口不在本表内:`docs/roadmap.md`「代码缺口」中 `/search`、Telegram 429 退避、多实例重复投递窗口均已实现,仅剩 1 条按产品决策保留不改(`SAF-DEBUG-ALLOWLIST`)。`worker` 模块的 `/search` 路径已随 `bfe0fd8` 落地。 > 注:「现状」列是**模块级**口径(模块已落地),不代表行为完备。行为级缺口不在本表内:`docs/roadmap.md`「代码缺口」中 `/search`、Telegram 429 退避、多实例重复投递窗口均已实现,仅剩 1 条按产品决策保留不改(`SAF-DEBUG-ALLOWLIST`)。`worker` 模块的 `/search` 路径已随 `bfe0fd8` 落地。
@ -386,7 +387,7 @@ message-weave/
| `LLM_ENABLED` | 否 | `false` | 关闭时不校验 `LLM_*`(`REQ-LLM-OPENAI-COMPAT`) | | `LLM_ENABLED` | 否 | `false` | 关闭时不校验 `LLM_*`(`REQ-LLM-OPENAI-COMPAT`) |
| `LLM_ALLOW_NET` | 否 | `false` | AI 出网开关(`REQ-AI-EXTERNAL-CONSENT`) | | `LLM_ALLOW_NET` | 否 | `false` | AI 出网开关(`REQ-AI-EXTERNAL-CONSENT`) |
| `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` | `LLM_ENABLED=true` 时必填 | — | OpenAI-compatible | | `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` | `LLM_ENABLED=true` 时必填 | — | OpenAI-compatible |
| `LLM_MAX_RETRIES` | 否(环境变量路径**不读取**) | `3` | 熔断阈值(`REQ-AI-FUSE`)。当前经 Redis 运行参数管理(`OutboundConfig.max_retries`,回落默认 `3`,见 `src/ai.rs`);`Config::from_env()` **不读取**此变量 | | `LLM_MAX_RETRIES` | 否(环境变量路径**不读取**) | — | 熔断阈值(`REQ-AI-FUSE`)。当前经 Redis 运行参数管理(`OutboundConfig.max_retries`,回落默认见 `docs/reference.md` §6.1);`Config::from_env()` **不读取**此变量 |
| `LLM_SUMMARY_TARGET_CHARS` | 否 | `300` | 摘要目标字数(`REQ-LONG-EMAIL`) | | `LLM_SUMMARY_TARGET_CHARS` | 否 | `300` | 摘要目标字数(`REQ-LONG-EMAIL`) |
> 当前 `config.rs` 通过环境变量和 Redis 业务配置提供上述字段(含 `TelegramConfig` / `JmapConfig` / `LlmConfig`)。未来若引入 TOML/figment 需另立决策;当前文档不假设配置文件存在。 > 当前 `config.rs` 通过环境变量和 Redis 业务配置提供上述字段(含 `TelegramConfig` / `JmapConfig` / `LlmConfig`)。未来若引入 TOML/figment 需另立决策;当前文档不假设配置文件存在。
@ -411,7 +412,7 @@ message-weave/
- `/ready` 做端到端探测:配置完整性 + Redis 可达性 + 出站只读探测(`GET {jmap_origin}/.well-known/jmap`,带配置的 Basic 认证;`GET https://api.telegram.org/bot<token>/getMe`;各 `PROBE_TIMEOUT` = 3000ms、**并行**(`tokio::join!`),最坏约 3s);四者全过 `200` 与就绪报告(`{"status":"ready","configured":...,"jmap":...,"telegram":...}`,其中 `jmap`/`telegram` 是真实探针结果),任一失败 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":<id>}` + `Retry-After: 30`)。探针只读、只读配置状态,**不**触发邮件同步等业务副作用,也**不**回显 token 或第三方响应内容;`refresh_business_config` 仅读 Redis,无状态写入。因此 `/ready` 要求到 JMAP host 与 `api.telegram.org:443` 的出站 egress 可达(若该 egress 需要代理则 `/ready` 不可用,见 deployment.md)。 - `/ready` 做端到端探测:配置完整性 + Redis 可达性 + 出站只读探测(`GET {jmap_origin}/.well-known/jmap`,带配置的 Basic 认证;`GET https://api.telegram.org/bot<token>/getMe`;各 `PROBE_TIMEOUT` = 3000ms、**并行**(`tokio::join!`),最坏约 3s);四者全过 `200` 与就绪报告(`{"status":"ready","configured":...,"jmap":...,"telegram":...}`,其中 `jmap`/`telegram` 是真实探针结果),任一失败 `503`(标准错误 envelope `{"error":"service_unavailable","request_id":<id>}` + `Retry-After: 30`)。探针只读、只读配置状态,**不**触发邮件同步等业务副作用,也**不**回显 token 或第三方响应内容;`refresh_business_config` 仅读 Redis,无状态写入。因此 `/ready` 要求到 JMAP host 与 `api.telegram.org:443` 的出站 egress 可达(若该 egress 需要代理则 `/ready` 不可用,见 deployment.md)。
- **chat 白名单(硬约束 `SAF-CHAT-ALLOWLIST`)**:`CHAT_ALLOWLIST` 是**必填**配置;任何入站事件(TG 命令 / 回调触发的动作)在**做任何 JMAP 调用、AI 调用或状态变更之前**,必须先校验 `chat.id ∈ CHAT_ALLOWLIST`,不在白名单则**直接拒绝并终止**(防止 token 泄露后被任意人调用)。阶段0 已完成 `CHAT_ALLOWLIST` 解析骨架;强制拒绝逻辑已随 Telegram 渠道接入落地(`src/notify.rs` 的 `telegram_webhook` 在任何 JMAP/AI/状态操作之前先校验白名单,拒绝即终止)。 - **chat 白名单(硬约束 `SAF-CHAT-ALLOWLIST`)**:`CHAT_ALLOWLIST` 是**必填**配置;任何入站事件(TG 命令 / 回调触发的动作)在**做任何 JMAP 调用、AI 调用或状态变更之前**,必须先校验 `chat.id ∈ CHAT_ALLOWLIST`,不在白名单则**直接拒绝并终止**(防止 token 泄露后被任意人调用)。阶段0 已完成 `CHAT_ALLOWLIST` 解析骨架;强制拒绝逻辑已随 Telegram 渠道接入落地(`src/notify.rs` 的 `telegram_webhook` 在任何 JMAP/AI/状态操作之前先校验白名单,拒绝即终止)。
- **命令最小化**:只暴露必要命令;发邮件等写操作必须二次确认(当前未实现发信,见 §10.3)。 - **命令最小化**:只暴露必要命令;发邮件等写操作必须二次确认(当前未实现发信,见 §10.3)。
- **速率**:出站侧未建本地令牌桶;Telegram 出站发送按 Redis 运行参数 `max_retries`(默认 3、上限 5)重试;当前仅对 Push 验证码写入做 Redis 限流(`ratelimit:push-verify:*`)。Telegram 服务端 30 msg/s 限制下的 429 **按 `parameters.retry_after` 秒自动退避**(`channel.rs`,`f4cae00`):`retry_after_ms` 解析后截断到 60s 预算上限,缺该字段或非数字时回退指数退避 `backoff_delay_ms`(250ms 起、封顶 4s),整体重试预算 60s。仍不做本地令牌桶限流——超出预算直接返回失败,交由上游重试。 - **速率**:出站侧未建本地令牌桶;Telegram 出站发送按 Redis 运行参数 `max_retries` 重试(默认值与硬上限见 `docs/reference.md` §6.1);当前仅对 Push 验证码写入做 Redis 限流(`ratelimit:push-verify:*`)。Telegram 服务端 30 msg/s 限制下的 429 **按 `parameters.retry_after` 秒自动退避**(`channel.rs`,`f4cae00`):`retry_after_ms` 解析后截断到 60s 预算上限,缺该字段或非数字时回退指数退避 `backoff_delay_ms`(250ms 起、封顶 4s),整体重试预算 60s。仍不做本地令牌桶限流——超出预算直接返回失败,交由上游重试。
### 7.3.1 配置管理 API ### 7.3.1 配置管理 API
- `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。 - `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。
@ -427,7 +428,7 @@ message-weave/
"max_retries": 3 "max_retries": 3
} }
``` ```
- 三个 timeout 单位均为毫秒,范围 `100..=300000`;`max_retries` 范围 `0..=5`,表示首次请求之外的最大重试次数。Redis 中尚无配置时 GET 返回默认值 `15000 / 10000 / 30000 / 3`;PUT 成功返回保存后的相同对象并持久化到 Redis(`C-REDIS-ONLY-STATE`)。 - 三个 timeout 单位均为毫秒;默认值、取值范围与 `max_retries` 的硬上限以 `docs/reference.md` §6.1 为唯一权威(下表与上面的示例响应体只做示意,不重复数值)。`max_retries` 表示首次请求之外的最大重试次数。Redis 中尚无配置时 GET 返回默认值对象;PUT 成功返回保存后的相同对象并持久化到 Redis(`C-REDIS-ONLY-STATE`)。
- `PUT /api/business-config` 接受完整 `BusinessConfigWire`,完整替换加密保存的业务配置,先构建客户端再切换运行 worker;Push verification 不属于 Wire,由 Stalwart 动态生成并由后端回写。成功返回 `204 No Content`,不会返回配置或密钥。 - `PUT /api/business-config` 接受完整 `BusinessConfigWire`,完整替换加密保存的业务配置,先构建客户端再切换运行 worker;Push verification 不属于 Wire,由 Stalwart 动态生成并由后端回写。成功返回 `204 No Content`,不会返回配置或密钥。
- `PUT /api/config` 的运行参数错误使用 `401`(未授权)、`400`(JSON 无效)、`422`(范围错误)、`503`(Redis 不可用);GET 读取失败时也以 `503` 表示 Redis 不可用。业务 PUT 使用 `401`、`400`、`422` 与 `503`(Redis 写入或依赖客户端构建失败);其余错误不回显敏感数据。Redis/会话初始化未完成时管理页面显示 `503` 状态;运行参数在重新读取成功前禁用保存(`C-REDIS-ONLY-STATE`)。LLM 启用时,业务配置要求提供 HTTPS Base URL、非空 API key 和模型名。 - `PUT /api/config` 的运行参数错误使用 `401`(未授权)、`400`(JSON 无效)、`422`(范围错误)、`503`(Redis 不可用);GET 读取失败时也以 `503` 表示 Redis 不可用。业务 PUT 使用 `401`、`400`、`422` 与 `503`(Redis 写入或依赖客户端构建失败);其余错误不回显敏感数据。Redis/会话初始化未完成时管理页面显示 `503` 状态;运行参数在重新读取成功前禁用保存(`C-REDIS-ONLY-STATE`)。LLM 启用时,业务配置要求提供 HTTPS Base URL、非空 API key 和模型名。
@ -682,7 +683,7 @@ Push 事件经 Streams 消费并投递到 Telegram,其关键路径交付语义
### 11.4 已由代码回答的早期问题(不再待确认) ### 11.4 已由代码回答的早期问题(不再待确认)
以下问题在设计阶段以 Q6–Q30 形式列出,**代码落地时已各自给出答案**,因此不再是"待确认项",此处只记结论: 以下问题在设计阶段以 Q6–Q30 形式列出,**代码落地时已各自给出答案**,因此不再是"待确认项",此处只记结论:
- **消息格式**(Q6):`TelegramClient::SendMessage` 只带 `chat_id` + `text` 两个字段,**没有 `parse_mode`**——Telegram 收到后按纯文本渲染,既不用 HTML 也不用 MarkdownV2。 - **消息格式**(Q6):出站只有 `send_text`(`src/channel.rs:134`),其载荷结构体 `SendMessage`(`src/channel.rs:80`)只有 `chat_id` + `text` 两个字段,**没有 `parse_mode`**——Telegram 收到后按纯文本渲染,既不用 HTML 也不用 MarkdownV2。
- **长邮件 / 原文直发**(Q7、Q29):不存在"正文超过 4000 字符不发全文"的策略。`read_email` 拉全文送 LLM;AI 失败时回退为前 300 字符且**无"已截断"标注**(见 §12.3/§12.5)。 - **长邮件 / 原文直发**(Q7、Q29):不存在"正文超过 4000 字符不发全文"的策略。`read_email` 拉全文送 LLM;AI 失败时回退为前 300 字符且**无"已截断"标注**(见 §12.3/§12.5)。
- **附件**(Q8、Q28):完全未实现。领域模型只有 `has_attachment: bool`,出站只有 `sendMessage`,没有 `send_document` 也没有 `Blob/get`(见 §12.3)。 - **附件**(Q8、Q28):完全未实现。领域模型只有 `has_attachment: bool`,出站只有 `sendMessage`,没有 `send_document` 也没有 `Blob/get`(见 §12.3)。
- **推送范围**(Q9):对账拉全量 `changes`,Bot 侧不做文件夹/发件人/关键词过滤,也没有 Sieve 依赖(Q11 因此无影响)。 - **推送范围**(Q9):对账拉全量 `changes`,Bot 侧不做文件夹/发件人/关键词过滤,也没有 Sieve 依赖(Q11 因此无影响)。
@ -727,9 +728,9 @@ src/ai.rs # LlmClient(唯一实现):summarize() 打 OpenAI 兼容 /chat/
| `LLM_API_KEY` | string | 可省略 | Bearer token;仅 `LLM_ENABLED=true` 时必填 | | `LLM_API_KEY` | string | 可省略 | Bearer token;仅 `LLM_ENABLED=true` 时必填 |
| `LLM_BASE_URL` | URL | 可省略 | 仅启用时必填,且必须 `https`,否则 `AiError::InvalidEndpoint` | | `LLM_BASE_URL` | URL | 可省略 | 仅启用时必填,且必须 `https`,否则 `AiError::InvalidEndpoint` |
| `LLM_MODEL` | string | 可省略 | 仅启用时必填;透传给 `/chat/completions` 的 `model` | | `LLM_MODEL` | string | 可省略 | 仅启用时必填;透传给 `/chat/completions` 的 `model` |
| `LLM_SUMMARY_TARGET_CHARS` | int | `1024` | `LlmClient::max_chars`,对返回摘要文本做字符截断(**代码侧确有截断**) | | `LLM_SUMMARY_TARGET_CHARS` | int | `300` | `LlmClient::max_chars`,对返回摘要文本做字符截断(**代码侧确有截断**) |
| `max_retries`(运行参数) | int | `3` | 由 `OutboundConfig` 下发(`RuntimeConfigProvider`),硬上限 5;LLM 与 Telegram 出站共用 | | `max_retries`(运行参数) | int | 见 `docs/reference.md` §6.1 | 由 `OutboundConfig` 下发(`RuntimeConfigProvider`),硬上限见 `docs/reference.md` §6.1;LLM 与 Telegram 出站共用 |
| `llm_timeout_ms`(运行参数) | int | `30000` | 硬下限 100ms | | `llm_timeout_ms`(运行参数) | int | 见 `docs/reference.md` §6.1 | 由 `OutboundConfig` 下发;单位毫秒,下限与取值范围见 `docs/reference.md` §6.1 |
**不存在** `LLM_TEMPERATURE` / `LLM_MAX_TOKENS` / `LLM_TIMEOUT_SECS` / `LLM_MAX_RETRIES`:请求体只有 `model` / `messages` / `max_tokens`(= `max_chars * 2`),没有 temperature,也没有独立于 `llm_timeout_ms` 的超时开关。API key 只从配置读,不入源码、不打日志(`SAF-LOG-PURITY`),运行期 secret 注入方式见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。 **不存在** `LLM_TEMPERATURE` / `LLM_MAX_TOKENS` / `LLM_TIMEOUT_SECS` / `LLM_MAX_RETRIES`:请求体只有 `model` / `messages` / `max_tokens`(= `max_chars * 2`),没有 temperature,也没有独立于 `llm_timeout_ms` 的超时开关。API key 只从配置读,不入源码、不打日志(`SAF-LOG-PURITY`),运行期 secret 注入方式见 deployment.md(`C-NO-SECRET-IN-IMAGE`)。
@ -748,7 +749,7 @@ src/ai.rs # LlmClient(唯一实现):summarize() 打 OpenAI 兼容 /chat/
### 12.4 失败检测(当前实现) ### 12.4 失败检测(当前实现)
`LlmClient::summarize`(`src/ai.rs`)**只有重试,没有熔断**: `LlmClient::summarize`(`src/ai.rs`)**只有重试,没有熔断**:
- 超时 `llm_timeout_ms`(默认 30_000ms,最小 100ms),最多重试 `max_retries` 次(默认 3,硬上限 5,与 Telegram 出站共用同一配置项)。 - 超时 `llm_timeout_ms`、重试次数 `max_retries`(单位、默认值、下限与硬上限均见 `docs/reference.md` §6.1;两项与 Telegram 出站共用同一配置)。
- 只对上一次的失败重试:429 与 5xx 会重试;**其他 4xx(含 401/403)立即返回错误,不重试**。 - 只对上一次的失败重试:429 与 5xx 会重试;**其他 4xx(含 401/403)立即返回错误,不重试**。
- 重试耗尽或全部超时 → `Err(AiError::Response)`;URL 解析失败或非 https → `Err(AiError::InvalidEndpoint)`;JSON 反序列化失败或缺 `choices[0].message.content` → `Err(AiError::Response)`。 - 重试耗尽或全部超时 → `Err(AiError::Response)`;URL 解析失败或非 https → `Err(AiError::InvalidEndpoint)`;JSON 反序列化失败或缺 `choices[0].message.content` → `Err(AiError::Response)`。
- `AiError` 只有三个变体:`InvalidEndpoint` / `Request` / `Response`。**不存在** 429/401/Timeout 的细分类型。 - `AiError` 只有三个变体:`InvalidEndpoint` / `Request` / `Response`。**不存在** 429/401/Timeout 的细分类型。

View File

@ -26,48 +26,52 @@ Conventions used throughout:
| Key | TTL | Writer | Reader | | Key | TTL | Writer | Reader |
|---|---|---|---| |---|---|---|---|
| `config:business` | No EX | `set_business_config` (state.rs:576, key at state.rs:580); first-time write via `SET NX` in `initialize_business_config` (state.rs:591) | `get_business_config` (state.rs:565) | | `config:business` | No EX | `set_business_config` (state.rs:577, key at state.rs:581); first-time write via `SET NX` in `initialize_business_config` (state.rs:592) | `get_business_config` (state.rs:566) |
| `config:business:revision` | No EX | atomic `INCR` on every config write (state.rs:584); `SET 1 NX` on first-time init (state.rs:604) | `business_config_revision` (state.rs:639) | | `config:business:revision` | No EX | atomic `INCR` on every config write (state.rs:585); `SET 1 NX` on first-time init (state.rs:605) | `business_config_revision` (state.rs:640) |
| `config:outbound` | No EX | `set_outbound_config` (state.rs:552) | `get_outbound_config` (state.rs:536) | | `config:outbound` | No EX | `set_outbound_config` (state.rs:553) | `get_outbound_config` (state.rs:537) |
| `config:enabled` | No EX | `set_enabled` (state.rs:657) | `is_enabled` (state.rs:648) | | `config:enabled` | No EX | `set_enabled` (state.rs:658) | `is_enabled` (state.rs:649) |
| `config:admin_session` | EX 900 | `put_admin_session` (state.rs:614, key at state.rs:617) | `admin_session_valid` (state.rs:626); cleared by `revoke_admin_session` (state.rs:635) | | `config:admin_session` | EX 900 | `put_admin_session` (state.rs:615, key at state.rs:618) | `admin_session_valid` (state.rs:627); cleared by `revoke_admin_session` (state.rs:636) |
Notes: Notes:
- `config:business:revision` is an **atomic Redis `INCR`**, not a read-modify-write. Every - `config:business:revision` is an **atomic Redis `INCR`**, not a read-modify-write. Every
request-bearing entry point calls `refresh_business_config` (notify.rs:717), which only request-bearing entry point calls `refresh_business_config` (notify.rs:718), which only
rebuilds the worker when the remote revision exceeds the cached local value rebuilds the worker when the remote revision exceeds the cached local value
(notify.rs:722-724) — that guard is what keeps a stale or malformed snapshot from turning (notify.rs:723-725) — that guard is what keeps a stale or malformed snapshot from turning
into a rebuild loop. into a rebuild loop.
- `config:admin_session` carries no TTL metadata beyond the literal `EX 900` passed at write - `config:admin_session` carries no TTL metadata beyond the literal `EX 900` passed at write
time; the handler reports the same window in its response body as `expires_in: 900` time; the handler reports the same window in its response body as `expires_in: 900`
(notify.rs:787). (notify.rs:788).
- `config:enabled` is consulted as a gate; missing or `false` keeps business processing off. - `config:enabled` is consulted as a gate; missing or `false` keeps business processing off.
### 1.2 AI consent ### 1.2 AI consent
| Key | TTL | Writer | Reader | | Key | TTL | Writer | Reader |
|---|---|---|---| |---|---|---|---|
| `consent:ai:{chat_id}` | EX 3600 / 86_400 / 604_800 / 31_536_000 | `set_ai_consent` (state.rs:504; key at state.rs:506, `EX` at state.rs:511) | `ai_consent_until` (state.rs:518); cleared by `clear_ai_consent` (state.rs:527) | | `consent:ai:{chat_id}` | EX 3600 / 86_400 / 604_800 / 31_536_000 | `set_ai_consent` (state.rs:505; key at state.rs:507, `EX` at state.rs:512) | `ai_consent_until` (state.rs:519); cleared by `clear_ai_consent` (state.rs:528) |
The stored value is an **absolute Unix expiry timestamp**, not a duration; the key's `EX` The stored value is an **absolute Unix expiry timestamp**, not a duration; the key's `EX`
carries the same duration, so the key removes itself (state.rs:508-513). carries the same duration, so the key removes itself (state.rs:509-514).
Trigger words are exact Chinese literals with **no English aliases** — the in-app help text Trigger words are exact Chinese literals with **no English aliases** — the in-app help text
enforces this itself (`worker.rs:613`): enforces this itself (`worker.rs:614`):
| Input | TTL | Label | | Input | TTL | Label |
|---|---|---| |---|---|---|
| `/ai on`, `临时一次`, `临时`, `一次` | 3600 | `1小时` | | `/ai on`, `/ai yes`, `开启 ai`, `同意摘要`, `允许 ai` | 3600 | `1小时` |
| `临时`, `一次` (substring) | 3600 | `临时1小时` |
| `今天` | 86_400 | `今天` | | `今天` | 86_400 | `今天` |
| `7天` | 604_800 | `7天` | | `7天` | 604_800 | `7天` |
| `直到我撤销`, `长期` | 31_536_000 (365 d) | `直到我撤销` | | `直到我撤销`, `长期` (substring) | 31_536_000 (365 d) | `直到撤销(最长365天)` |
| `/ai off`, `关闭 ai`, `撤销授权`, `停止摘要` | — | revoke | | `/ai off`, `关闭 ai`, `撤销授权`, `停止摘要` | — | revoke |
`1小时` is a **category label, not a trigger word**: typing `1小时` alone grants nothing. The `/ai …` slash forms are exact matches on the whole message; the Chinese phrases are
Consent never auto-renews. substring matches anywhere in the message (`worker.rs:546-566`, `:581-586`).
The same `parse_intent` (worker.rs:522, `Intent` at worker.rs:504) routes `/search <关键词>` `1小时` / `临时1小时` / `直到撤销(最长365天)` are **category labels, not trigger words**:
typing a label alone grants nothing. Consent never auto-renews.
The same `parse_intent` (worker.rs:523, `Intent` at worker.rs:505) routes `/search <关键词>`
and the Chinese prefixes `搜索`, `查找`, `检索` (prefix-matched only, never a substring) to and the Chinese prefixes `搜索`, `查找`, `检索` (prefix-matched only, never a substring) to
`Intent::Search`. Search grants no consent and writes no Redis key. `Intent::Search`. Search grants no consent and writes no Redis key.
@ -75,11 +79,11 @@ and the Chinese prefixes `搜索`, `查找`, `检索` (prefix-matched only, neve
| Key | TTL | Writer | Reader | | Key | TTL | Writer | Reader |
|---|---|---|---| |---|---|---|---|
| `stalwart:jmap`, `stalwart:telegram` | Stream, no EX | `State::enqueue` (state.rs:282, `XADD` at state.rs:284); call sites notify.rs:203 (TG webhook) and notify.rs:859 (JMAP push); reconcile path via `claim_dedup_and_enqueue` (worker.rs:373) | `read_batch` over consumer group `stalwart-workers`, consumer `http-worker` (notify.rs:360, :428) | | `stalwart:jmap`, `stalwart:telegram` | Stream, no EX | `State::enqueue` (state.rs:283, `XADD` at state.rs:285); call sites notify.rs:204 (TG webhook) and notify.rs:860 (JMAP push); reconcile path via `claim_dedup_and_enqueue` (worker.rs:374) | `read_batch` over consumer group `stalwart-workers`, consumer `http-worker` (notify.rs:361, :428) |
| `delivery:inflight:{stream}:{id}` | EX 60 | key built at notify.rs:395, `claim_dedup` at notify.rs:396 | `release_dedup` on completion (notify.rs:409, 416, 425) | | `delivery:inflight:{stream}:{id}` | EX 60 | key built at notify.rs:396, `claim_dedup` at notify.rs:397 | `release_dedup` on completion (notify.rs:410, 416, 425) |
| `delivery:committed:{stream}:{id}` | EX 604_800 | key built at notify.rs:374, `claim_dedup` at notify.rs:408 | `dedup_exists` before send (notify.rs:375) | | `delivery:committed:{stream}:{id}` | EX 604_800 | key built at notify.rs:375, `claim_dedup` at notify.rs:409 | `dedup_exists` before send (notify.rs:376) |
| `retry:{stream}:{id}` | EX 86_400 | `retry_or_dlq` Lua script (key state.rs:480, `INCR` state.rs:484, `EXPIRE` state.rs:485) | reclaim path | | `retry:{stream}:{id}` | EX 86_400 | `retry_or_dlq` Lua script (key state.rs:481, `INCR` state.rs:485, `EXPIRE` state.rs:486) | reclaim path |
| `stalwart:jmap:dlq`, `stalwart:telegram:dlq` | Stream, no EX | Lua `XADD` at state.rs:487; name built at notify.rs:428 (`max_attempts` 3, same call) | **nothing in code reads it** | | `stalwart:jmap:dlq`, `stalwart:telegram:dlq` | Stream, no EX | Lua `XADD` at state.rs:488; name built at notify.rs:429 (`max_attempts` 3, same call) | **nothing in code reads it** |
There is no `delivery:pending:{stream}` key — "pending" refers to the Redis Streams There is no `delivery:pending:{stream}` key — "pending" refers to the Redis Streams
pending-entries list (PEL), which Redis maintains internally. pending-entries list (PEL), which Redis maintains internally.
@ -93,17 +97,17 @@ acknowledgement.
The DLQ is **append-and-ack**: the same Lua script that appends to `stalwart:jmap:dlq` / The DLQ is **append-and-ack**: the same Lua script that appends to `stalwart:jmap:dlq` /
`stalwart:telegram:dlq` also `XACK`s the message out of the source stream, so increment, DLQ `stalwart:telegram:dlq` also `XACK`s the message out of the source stream, so increment, DLQ
append and source acknowledgement are atomic (state.rs:484-488). No code path reads the DLQ append and source acknowledgement are atomic (state.rs:485-489). No code path reads the DLQ
back — replay is an operator action, not a service feature. back — replay is an operator action, not a service feature.
### 1.4 Idempotency and rate limits ### 1.4 Idempotency and rate limits
| Key | TTL | Writer | Reader | | Key | TTL | Writer | Reader |
|---|---|---|---| |---|---|---|---|
| `dedup:tg:{update_id}` | EX 86_400 | key at notify.rs:198, `claim_dedup` at notify.rs:199, `release_dedup` at :208 | — | | `dedup:tg:{update_id}` | EX 86_400 | key at notify.rs:199, `claim_dedup` at notify.rs:200, `release_dedup` at :208 | — |
| `dedup:jmap:{account}:{email}` | EX 86_400 | key at notify.rs:854, `claim_dedup` at notify.rs:855, `release_dedup` at :864; same key rebuilt at worker.rs:360 and claimed with `claim_dedup_and_enqueue` at worker.rs:373 | — | | `dedup:jmap:{account}:{email}` | EX 86_400 | key at notify.rs:855, `claim_dedup` at notify.rs:856, `release_dedup` at notify.rs:865; same key rebuilt at worker.rs:361 and claimed with `claim_dedup_and_enqueue` at worker.rs:374 | — |
| `ratelimit:push-verify:{subscription}` | EX 30 | key at notify.rs:815, `claim_dedup` at notify.rs:816 (`push_verify_rate_limited` at :819) | — | | `ratelimit:push-verify:{subscription}` | EX 30 | key at notify.rs:816, `claim_dedup` at notify.rs:817 (`push_verify_rate_limited` at :819) | — |
| `state:jmap:since` | **No EX** | `set_reconcile_state` (state.rs:675; key at state.rs:678, `SET` without `EX`) | `get_reconcile_state` (state.rs:667) | | `state:jmap:since` | **No EX** | `set_reconcile_state` (state.rs:676; key at state.rs:679, `SET` without `EX`) | `get_reconcile_state` (state.rs:668) |
`state:jmap:since` is the only state key that survives indefinitely without an explicit `state:jmap:since` is the only state key that survives indefinitely without an explicit
delete, by design: it is the reconciliation cursor, and expiring it would force a full delete, by design: it is the reconciliation cursor, and expiring it would force a full
@ -113,12 +117,12 @@ re-baseline on the next restart.
| Key | TTL | Writer | Reader | | Key | TTL | Writer | Reader |
|---|---|---|---| |---|---|---|---|
| `lock:reconcile` | 300 | `acquire_lock` (notify.rs:239) | `renew_lock` heartbeat 90 (notify.rs:252) | | `lock:reconcile` | 300 | `acquire_lock` (notify.rs:240) | `renew_lock` heartbeat 90 (notify.rs:253) |
| `lock:push-register:{sha256(callback_url)}` | 360 | `acquire_lock` (notify.rs:906) | `release_lock` (notify.rs:922, 937, 950, 967, 984, 994) | | `lock:push-register:{sha256(callback_url)}` | 360 | `acquire_lock` (notify.rs:907) | `release_lock` (notify.rs:923, 937, 950, 967, 984, 994) |
**Why the registration lock is 360 s and not 300 s.** The lock must outlive the longest **Why the registration lock is 360 s and not 300 s.** The lock must outlive the longest
configured JMAP request timeout (300 s), otherwise a slow registration could admit a configured JMAP request timeout (300 s), otherwise a slow registration could admit a
duplicate. Source comment, notify.rs:904-905: duplicate. Source comment, notify.rs:905-906:
> The lock must outlive the configured 300s maximum JMAP request timeout; this prevents a > The lock must outlive the configured 300s maximum JMAP request timeout; this prevents a
> slow create from admitting a duplicate. > slow create from admitting a duplicate.
@ -127,11 +131,11 @@ duplicate. Source comment, notify.rs:904-905:
| Key | TTL | Writer | Reader | | Key | TTL | Writer | Reader |
|---|---|---|---| |---|---|---|---|
| `push:subscription:{id}` | EX 300 (caller-supplied, min 1) | `remember_push_subscription` (state.rs:685, called at notify.rs:833) | `push_subscription_verified` (state.rs:702) | | `push:subscription:{id}` | EX 300 (caller-supplied, min 1) | `remember_push_subscription` (state.rs:686, called at notify.rs:834) | `push_subscription_verified` (state.rs:703) |
| `push:subscription:id` | No EX | `remember_push_subscription_id` (state.rs:715) | current-subscription lookup | | `push:subscription:id` | No EX | `remember_push_subscription_id` (state.rs:716) | current-subscription lookup |
| `push:subscription:{id}:status` | 900 / 300 / 86_400 (min 1 enforced) | `set_push_subscription_status` (state.rs:725, key at state.rs:733); pending at notify.rs:960, verified at notify.rs:845, disabled at notify.rs:1031 | **none** | | `push:subscription:{id}:status` | 900 / 300 / 86_400 (min 1 enforced) | `set_push_subscription_status` (state.rs:726, key at state.rs:734); pending at notify.rs:961, verified at notify.rs:846, disabled at notify.rs:1032 | **none** |
| `push:registration:{sha256(callback_url)}` | EX 604_800 | `remember_push_subscription_for_callback` (state.rs:778, key at state.rs:785, `EX` at state.rs:787; called at notify.rs:977) | `get_push_subscription_for_callback` (state.rs:767); removed by `remove_push_subscription_for_callback` (state.rs:794) | | `push:registration:{sha256(callback_url)}` | EX 604_800 | `remember_push_subscription_for_callback` (state.rs:779, key at state.rs:786, `EX` at state.rs:788; called at notify.rs:978) | `get_push_subscription_for_callback` (state.rs:768); removed by `remove_push_subscription_for_callback` (state.rs:795) |
| `push:orphan:{subscription_id}` | EX 604_800 | `record_push_orphan` (state.rs:751) | orphan sweep | | `push:orphan:{subscription_id}` | EX 604_800 | `record_push_orphan` (state.rs:752) | orphan sweep |
**Two distinct keys that are frequently confused. Read this before editing either.** **Two distinct keys that are frequently confused. Read this before editing either.**
@ -143,7 +147,7 @@ duplicate. Source comment, notify.rs:904-905:
They use the same digest but are separate keys with unrelated lifetimes and purposes. They use the same digest but are separate keys with unrelated lifetimes and purposes.
**Annotation: `push:subscription:{id}:status` is write-only by design.** **Annotation: `push:subscription:{id}:status` is write-only by design.**
The source carries an explicit note at state.rs:210: The source carries an explicit note at state.rs:211:
> `push:subscription:{id}:status` is currently write-only > `push:subscription:{id}:status` is currently write-only
@ -159,28 +163,34 @@ Do not infer correctness from its presence.
Every error response uses one envelope shape: Every error response uses one envelope shape:
```json ```json
{ "error": { "code": "<machine_code>", "message": "<human readable>" } } { "error": "<machine_code>", "request_id": "<id>" }
``` ```
`error` is a **plain machine-readable string**, not a nested object — there is no `message`
field. `request_id` is always present.
| Status | Code | Retry-After | | Status | Code | Retry-After |
|---|---|---| |---|---|---|
| 400 | `invalid_request`, `missing` | — | | 400 | `invalid_request`, `missing` | — |
| 401 | `unauthorized` | — | | 401 | `unauthorized` | — |
| 403 | `forbidden` | — | | 403 | `forbidden`, `chat_not_allowed` (`/debug/*` only) | — |
| 404 | `push_subscription_not_found` | — |
| 409 | `conflict` | — | | 409 | `conflict` | — |
| 422 | `invalid_configuration` | — | | 422 | `invalid_configuration` | — |
| 429 | `push_verify_rate_limited` | `30` |
| 500 | `internal_error` | — | | 500 | `internal_error` | — |
| 503 | `service_unavailable`, `disabled`, `push_subscription_not_found`, `push_verify_rate_limited`, `push_state_unavailable`, `push_destroy_failed` | `30` when retryable, or always for the push register/disable failures | | 502 | `telegram_send_failed` (`/debug/*` only) | — |
| 503 | `service_unavailable`, `disabled`, `reconcile_retry`, `push_verify_failed`, `push_state_unavailable`, `push_destroy_failed` | `30` when retryable, or always for the push register/disable failures |
`Retry-After` has two emission sites, both in `notify.rs`, and the value is always the literal `Retry-After` has two emission sites, both in `notify.rs`, and the value is always the literal
string `"30"`: string `"30"`:
- `error_response` (`notify.rs:320-328`, inserted at `:324`) sets it **only when its `retry` - `error_response` (`notify.rs:321-329`, inserted at `:324`) sets it **only when its `retry`
flag is true**. The readiness failure path (`notify.rs:161`) passes `retry = true`, so `/ready` flag is true**. The readiness failure path (`notify.rs:162`) passes `retry = true`, so `/ready`
is covered by that rule rather than by a special case. is covered by that rule rather than by a special case.
- `error_response_with_id` (`notify.rs:1087-1093`, inserted at `:1090`) sets it - `error_response_with_id` (`notify.rs:1088-1094`, inserted at `:1090`) sets it
**unconditionally**. It is used only for the push register/disable failures **unconditionally**. It is used only for the push register/disable failures
`push_state_unavailable` and `push_destroy_failed` (`notify.rs:952`, `:969`, `:986`, `push_state_unavailable` and `push_destroy_failed` (`notify.rs:953`, `:969`, `:986`,
`:1023`), which are always `503`. `:1023`), which are always `503`.
`GET /api/status` is the exception that reports setup state with a non-envelope body. `GET /api/status` is the exception that reports setup state with a non-envelope body.
@ -189,7 +199,10 @@ string `"30"`:
## 3. Backend routes ## 3. Backend routes
All registered in `router_with_worker_state_runtime_bootstrap_config` (notify.rs:1275), with the routes wired at `notify.rs:1304-1318`. All HTTP API routes are registered in `router_with_worker_state_runtime_bootstrap_config`
(`notify.rs:1276`), with the routes wired at `notify.rs:1305-1319`. The three static SPA routes
in the last table row are the exception: they live in `web::router()` (`src/web.rs:19-24`) and
are merged into the production router at `src/main.rs:62` and `src/main.rs:194`.
| Method | Path | Handler | | Method | Path | Handler |
|---|---|---| |---|---|---|
@ -208,14 +221,14 @@ All registered in `router_with_worker_state_runtime_bootstrap_config` (notify.rs
| GET | `/healthz` | `healthz` | | GET | `/healthz` | `healthz` |
| GET | `/ready` | `ready` | | GET | `/ready` | `ready` |
| GET | `/api/status` | `setup_status` | | GET | `/api/status` | `setup_status` |
| GET | `/`, `/assets/config.js`, `/assets/styles.css` | `index` / `web_config` | | GET | `/`, `/assets/config.js`, `/assets/styles.css` | `index` / `script` / `styles` |
The remote-debug surface (`src/debug.rs:72-80`) is merged **only when the process is started The remote-debug surface (`src/debug.rs:72-80`) is merged **only when the process is started
with `--debug` and `DEBUG_TOKEN` is non-empty** (`SAF-DEBUG-GATE`, `src/main.rs:96-100`, with `--debug` and `DEBUG_TOKEN` is non-empty** (`SAF-DEBUG-GATE`, `src/main.rs:96-100`,
`src/notify.rs:1320-1321`); otherwise none of these routes exist and requests fall through to `src/notify.rs:1321-1322`); otherwise none of these routes exist and requests fall through to
axum's generic `404`, not a 401. All seven require `Authorization: Bearer <DEBUG_TOKEN>`, axum's generic `404`, not a 401. All seven require `Authorization: Bearer <DEBUG_TOKEN>`,
checked by `debug_authorized` (`src/debug.rs:45`), which delegates to the production checked by `debug_authorized` (`src/debug.rs:45`), which delegates to the production
`worker_authorized` so the comparison is constant time (`src/notify.rs:1135`). `worker_authorized` so the comparison is constant time (`src/notify.rs:1136`).
| Method | Path | Handler | | Method | Path | Handler |
|---|---|---| |---|---|---|
@ -236,14 +249,16 @@ Response conventions:
completeness (`setup_missing` empty), Redis reachability, and two upstream probes — completeness (`setup_missing` empty), Redis reachability, and two upstream probes —
`GET {jmap_origin}/.well-known/jmap` with the configured Basic credentials, and `GET {jmap_origin}/.well-known/jmap` with the configured Basic credentials, and
`GET https://api.telegram.org/bot<token>/getMe`. Both probes share `PROBE_TIMEOUT` = 3000ms `GET https://api.telegram.org/bot<token>/getMe`. Both probes share `PROBE_TIMEOUT` = 3000ms
(`notify.rs:73`) and run **in parallel** (`tokio::join!`, `notify.rs:147`), so the worst case (`notify.rs:74`) and run **in parallel** (`tokio::join!`, `notify.rs:148`), so the worst case
is a single timeout, about 3s. On success the body is a report whose `jmap`/`telegram` fields is a single timeout, about 3s. On success the body is a report whose `jmap`/`telegram` fields
are real probe results. The probes are plain reusable functions (`probe_jmap_session`, are real probe results. The probes are plain reusable functions (`probe_jmap_session`,
`probe_telegram_get_me`) also used by the remote-debug path, so they must not be duplicated. `probe_telegram_get_me`) also used by the remote-debug path, so they must not be duplicated.
The JMAP probe deliberately authenticates against the *normalized origin*: probing the raw The JMAP probe deliberately authenticates against the *normalized origin*: probing the raw
session URL unauthenticated would report not-ready forever and make ingress stop routing. session URL unauthenticated would report not-ready forever and make ingress stop routing.
- `GET /api/status` returns **503** with body `{"status":"configuration-setup","missing":[...]}` - `GET /api/status` (**always 200**, `notify.rs:166-174`) returns
when a required environment variable is absent. `{"ready": <bool>, "mode": "env"|"redis", "missing": [...]}`. `ready` is `false` and
`missing` lists the absent required keys (`REDIS_URL`, `CONFIG_ENCRYPTION_KEY`) when a
variable is absent — the route itself never errors, so it is safe to poll.
- `/debug/*` returns **503** in exactly one place: `POST /debug/notify` when the business - `/debug/*` returns **503** in exactly one place: `POST /debug/notify` when the business
config is not loaded or there is no outbound client — `service_unavailable` with config is not loaded or there is no outbound client — `service_unavailable` with
`Retry-After: 30` (`src/debug.rs:56-58`). The three probe endpoints instead report failure `Retry-After: 30` (`src/debug.rs:56-58`). The three probe endpoints instead report failure
@ -258,43 +273,49 @@ Response conventions:
## 4. Gateway vs backend route matrix ## 4. Gateway vs backend route matrix
> **Independently verified.** Source: `cloudflare-worker/src/backends.js` `SAFE_ROUTES` > **Independently verified.** Source: `cloudflare-worker/src/backends.js` `SAFE_ROUTES`
> (backends.js:9-24), 14 entries, alongside `ROUTE_METHODS` (index.js:45-60) > (backends.js:9-25), 15 entries, alongside `ROUTE_METHODS` (index.js:45-61)
> which fixes one method set per path. The worker entry point is `src/index.js` > which fixes one method set per path. The worker entry point is `src/index.js`
> (wrangler.toml:20); `src/lb.js` performs forwarding and bounded failover (`SAF-LB-PASSTHRU`, > (wrangler.toml:20); `src/lb.js` performs forwarding and bounded failover (`SAF-LB-PASSTHRU`,
> `C-NO-LONG-CONN`). > `C-NO-LONG-CONN`).
The gate is **unconditional and fail-closed**. The worker reads no configuration switches at The gate is **unconditional and fail-closed**. The worker reads no configuration switches at
all: a path missing from `SAFE_ROUTES` returns **404** (index.js:77-78), a registered all: a path missing from `SAFE_ROUTES` (backends.js:9-25) returns **404** (index.js:81-82), a registered
path with the wrong method returns **405** (index.js:80-83), and a missing or unparseable path with the wrong method returns **405** (index.js:84-86), and a missing or unparseable
backend pool returns **503** rather than passing the request through (index.js:85-93). backend pool returns **503** rather than passing the request through (index.js:89-96).
**Forwarded by the worker (14):** **Forwarded by the worker (15):**
`/` · `/assets/config.js` · `/assets/styles.css` · `/api/status` · `/api/config` · `/` · `/assets/config.js` · `/assets/styles.css` · `/api/status` · `/api/config` ·
`/api/business-config` · `/api/admin/session` · `/api/admin/session/revoke` · `/api/business-config` · `/api/admin/session` · `/api/admin/session/revoke` ·
`/api/enabled` ·
`/webhook/tg` · `/push/jmap` · `/api/push/register` · `/api/push/disable` · `/reconcile` · `/webhook/tg` · `/push/jmap` · `/api/push/register` · `/api/push/disable` · `/reconcile` ·
`/ready` `/ready`
**Registered on the backend but NOT forwarded (5):** **Registered on the backend but NOT forwarded (4):**
| Path | Why it is absent from the gateway | | Path | Why it is absent from the gateway |
|---|---| |---|---|
| `GET, PUT /api/enabled` | Internal operational switch, not exposed through the public gateway |
| `POST /api/bootstrap` | One-shot trust bootstrap; kept off the public path | | `POST /api/bootstrap` | One-shot trust bootstrap; kept off the public path |
| `POST /worker` | Operator-invoked worker trigger (Bearer-auth'd, `notify.rs:330`); not part of the public gateway path | | `POST /worker` | Operator-invoked worker trigger (Bearer-auth'd, `notify.rs:331`); not part of the public gateway path |
| `GET /healthz` | Liveness is aggregated by the gateway itself | | `GET /healthz` | Liveness is aggregated by the gateway itself |
| `/debug/*` (7 routes) | Opt-in remote-debug surface (`SAF-DEBUG-GATE`); absent from `SAFE_ROUTES`, so it is reachable **only** by talking to the backend origin directly | | `/debug/*` (7 routes) | Opt-in remote-debug surface (`SAF-DEBUG-GATE`); absent from `SAFE_ROUTES`, so it is reachable **only** by talking to the backend origin directly |
Consequence: none of the five carries external business traffic, so no second ingress is `GET, PUT /api/enabled` (the `SAF-ENABLE-FLAG` kill switch) **is** forwarded, because the
admin SPA serves it at the Worker URL and toggles it from the service card (`loadEnabled`
reads it at `web/config.js:586`, the toggle writes it at `:958`); both calls are
admin-session Bearer-auth'd, so the exposure is identical to the already-forwarded
`/api/admin/session` pair.
Consequence: none of the four 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 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 `/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 gateway allowlist.
`POST /api/push/register` and `POST /api/push/disable` **are** forwarded. Both are safe to `POST /api/push/register` and `POST /api/push/disable` **are** forwarded. Both are safe to
proxy: the callback URL is supplied by the client in the request body (`notify.rs:874`, proxy: the callback URL is supplied by the client in the request body (`notify.rs:875`,
validated as a URL at :891), and every push subscription record is written to and read back validated as a URL at :891), and every push subscription record is written to and read back
from the shared Redis (`lock:push-register:{sha256(url)}`, `get_push_subscription_for_callback` from the shared Redis (`lock:push-register:{sha256(url)}`, `get_push_subscription_for_callback`
at `notify.rs:917`), so it does not matter which backend instance the worker picks. Push at `notify.rs:918`), so it does not matter which backend instance the worker picks. Push
registration therefore no longer requires hitting a specific backend address — see registration therefore no longer requires hitting a specific backend address — see
deployment.md §10.5. deployment.md §10.5.
@ -304,24 +325,25 @@ deployment.md §10.5.
Three distinct layers. They are not interchangeable. Three distinct layers. They are not interchangeable.
### 5.1 Boot-time (2 required, 2 optional with defaults) ### 5.1 Boot-time (2 required, 3 optional with defaults)
| Variable | Required | Default | Source | | Variable | Required | Default | Source |
|---|---|---|---| |---|---|---|---|
| `REDIS_URL` | yes | — | read at main.rs:40 | | `REDIS_URL` | yes | — | read at main.rs:40 |
| `CONFIG_ENCRYPTION_KEY` | yes | — | `encryption_key_from_env` at main.rs:50 | | `CONFIG_ENCRYPTION_KEY` | yes | — | `encryption_key_from_env` at main.rs:50 |
| `PORT` | no | `8080` | `unwrap_or(8080_u16)` at main.rs:34 | | `PORT` | no | `8080` | `unwrap_or(8080_u16)` at main.rs:34; parsed again at config.rs:312 on the legacy `from_env()` path (validation parity only, see the note at config.rs:37) |
| `RUN_MODE` | no | `webhook` | `unwrap_or_else` at config.rs:338 | | `RUN_MODE` | no | `webhook` | `unwrap_or_else` at config.rs:345 |
| `DEBUG_TOKEN` | no | — | `std::env::var` at main.rs:97, effective only when `--debug` is also passed (`SAF-DEBUG-GATE` / `SAF-DEBUG-ORIGIN-ONLY`) |
`RUN_MODE` accepts only `webhook` or `reconcile`; anything else is rejected at boot `RUN_MODE` accepts only `webhook` or `reconcile`; anything else is rejected at boot
(main.rs:78-81). Both modes currently route to the same no-op router; the distinction is (main.rs:78-82). Both modes currently route to the same no-op router; the distinction is
load-bearing for future splits, not for behaviour today. Note the value is only read on load-bearing for future splits, not for behaviour today. Note the value is only read on
the legacy env path (`config.rs:338`), so with the current two-variable production config the legacy env path (`config.rs:345`), so with the current two-variable production config
it always resolves to the hardcoded default — setting it to `reconcile` has no effect. it always resolves to the hardcoded default — setting it to `reconcile` has no effect.
**Missing a required variable does not crash the process.** It logs a warning and serves **Missing a required variable does not crash the process.** It logs a warning and serves
`router_configuration_setup`, which builds a router over `MemoryState` with empty tokens and `router_configuration_setup`, which builds a router over `MemoryState` with empty tokens and
a `NoopWorker` (worker.rs:142-144). The container stays up and answers requests while doing a `NoopWorker` (worker.rs:143-145). The container stays up and answers requests while doing
no business work. This is a deliberate fail-closed-to-setup posture, and it is the single no business work. This is a deliberate fail-closed-to-setup posture, and it is the single
most likely cause of "the container is healthy but nothing happens". most likely cause of "the container is healthy but nothing happens".
@ -335,21 +357,27 @@ The process never reads these from the environment in normal operation.
### 5.3 Legacy compatibility (read but not required) ### 5.3 Legacy compatibility (read but not required)
`config.rs` still reads an environment-shaped business configuration for backward `config.rs` still reads an environment-shaped business configuration for backward
compatibility. Recognised names, unprefixed unless noted: compatibility. The full set of names it recognises (`from_env()`, `config.rs:311`),
unprefixed unless noted:
`BOT_TOKEN` · `CHAT_ALLOWLIST` · `TG_WEBHOOK_SECRET` · `TELEGRAM_CHAT_ID` · `CONFIG_ENCRYPTION_KEY` · `PORT` · `REDIS_URL` · `CHAT_ALLOWLIST` · `TELEGRAM_CHAT_ID` ·
`JMAP_*` · `RECONCILE_TOKEN` `LLM_ENABLED` · `LLM_ALLOW_NET` · `LLM_API_KEY` · `LLM_BASE_URL` · `LLM_MODEL` ·
`LLM_SUMMARY_TARGET_CHARS` · `RUN_MODE` · `BOT_TOKEN` · `JMAP_SESSION_URL` · `JMAP_USERNAME` ·
`JMAP_PASSWORD` · `ACCOUNT_ID` · `RECONCILE_TOKEN` · `TG_WEBHOOK_SECRET` · `WORKER_TOKEN`
The `JMAP_*` family is exactly three names; `ACCOUNT_ID` is read here too, alongside the
direct read the JMAP client performs when no `accountId` is present in the request.
The single prefixed name is `TELEGRAM_CHAT_ID`. Anything else documented as The single prefixed name is `TELEGRAM_CHAT_ID`. Anything else documented as
`TELEGRAM_BOT_TOKEN` or similar is a documentation error, not a supported variable. `TELEGRAM_BOT_TOKEN` or similar is a documentation error, not a supported variable.
**Trust root.** The SPA admin credential is `CONFIG_ENCRYPTION_KEY` itself: read at startup **Trust root.** The SPA admin credential is `CONFIG_ENCRYPTION_KEY` itself: read at startup
(main.rs:49) and held as `admin_token` (main.rs:92), it is checked in constant time by (main.rs:49) and held as `admin_token` (main.rs:92), it is checked in constant time by
`worker_authorized` (notify.rs:445; compare at notify.rs:1135) at the top of both `worker_authorized` (notify.rs:446; compare at notify.rs:1136) at the top of both
`POST /api/bootstrap` (notify.rs:604) and `POST /api/admin/session` (notify.rs:774). It is `POST /api/bootstrap` (notify.rs:605) and `POST /api/admin/session` (notify.rs:775). It is
only compared against the request bearer — never echoed, logged, or stored. The session only compared against the request bearer — never echoed, logged, or stored. The session
issued by `/api/admin/session` is a freshly generated random 32-byte hex token issued by `/api/admin/session` is a freshly generated random 32-byte hex token
(notify.rs:778-787), never the credential; only its digest is retained in Redis under (notify.rs:779-788), never the credential; only its digest is retained in Redis under
`config:admin_session`. `REDIS_URL` is the Redis connection string alone: an ACL password in `config:admin_session`. `REDIS_URL` is the Redis connection string alone: an ACL password in
it, if any, authenticates the Redis connection and is not the credential for any HTTP it, if any, authenticates the Redis connection and is not the credential for any HTTP
endpoint. endpoint.
@ -362,10 +390,10 @@ endpoint.
| Parameter | Default | Range / cap | Source | | Parameter | Default | Range / cap | Source |
|---|---|---|---| |---|---|---|---|
| `jmap_timeout_ms` | 15_000 | 100..=300_000 | default state.rs:55; validated notify.rs:491 | | `jmap_timeout_ms` | 15_000 | 100..=300_000 | default state.rs:56; validated notify.rs:492 |
| `telegram_timeout_ms` | 10_000 | 100..=300_000 | default state.rs:56; validated notify.rs:492 | | `telegram_timeout_ms` | 10_000 | 100..=300_000 | default state.rs:57; validated notify.rs:493 |
| `llm_timeout_ms` | 30_000 | 100..=300_000 | default state.rs:57; validated notify.rs:493 | | `llm_timeout_ms` | 30_000 | 100..=300_000 | default state.rs:58; validated notify.rs:494 |
| `max_retries` | 3 | hard cap 5 | default state.rs:58; rejected at notify.rs:494; re-clamped at channel.rs:138 | | `max_retries` | 3 | hard cap 5 | default state.rs:59; rejected at notify.rs:495; re-clamped at channel.rs:138 |
There is **no** `LLM_MAX_RETRIES` environment variable; retry count lives in There is **no** `LLM_MAX_RETRIES` environment variable; retry count lives in
`config:outbound` and is bounded at 5 regardless of what is written. `config:outbound` and is bounded at 5 regardless of what is written.
@ -374,24 +402,24 @@ There is **no** `LLM_MAX_RETRIES` environment variable; retry count lives in
| Constant | Value | Source | | Constant | Value | Source |
|---|---|---| |---|---|---|
| `MAX_BASELINE_PAGES` | 100 | worker.rs:248 | | `MAX_BASELINE_PAGES` | 100 | worker.rs:249 |
| `MAX_BASELINE_EMAILS` | 10_000 | worker.rs:249 | | `MAX_BASELINE_EMAILS` | 10_000 | worker.rs:250 |
| `RECONCILE_BUDGET` | 20 s | worker.rs:250 | | `RECONCILE_BUDGET` | 20 s | worker.rs:251 |
| `CHANGE_WINDOW_CAP` | 4_096 | worker.rs:306 | | `CHANGE_WINDOW_CAP` | 4_096 | worker.rs:307 |
| Lock TTL / heartbeat | 300 s / 90 s | notify.rs:239 / 252 | | Lock TTL / heartbeat | 300 s / 90 s | notify.rs:240 / 252 |
`max_changes` is a function parameter, not a constant. `max_changes` is a function parameter, not a constant.
### 6.3 Idle threshold ### 6.3 Idle threshold
XAUTOCLAIM's idle threshold is derived from the live outbound config (state.rs:98): XAUTOCLAIM's idle threshold is derived from the live outbound config (state.rs:99):
``` ```
count × (max_retries + 1) × (jmap_timeout_ms + telegram_timeout_ms + llm_timeout_ms) × 2 count × (max_retries + 1) × (jmap_timeout_ms + telegram_timeout_ms + llm_timeout_ms) × 2
``` ```
floored at the legacy 300 s single-event ceiling (state.rs:65) and capped at 6 h floored at the legacy 300 s single-event ceiling (state.rs:66) and capped at 6 h
(state.rs:75). The floor is also the fallback when the config cannot be read, so a (state.rs:76). The floor is also the fallback when the config cannot be read, so a
Redis hiccup never collapses the window. Only duplicate-on-multi-instance is at Redis hiccup never collapses the window. Only duplicate-on-multi-instance is at
stake, never loss. stake, never loss.
@ -399,9 +427,9 @@ stake, never loss.
| Constant | Value | Source | | Constant | Value | Source |
|---|---|---| |---|---|---|
| `SEARCH_LIMIT` | 10 | worker.rs:618 | | `SEARCH_LIMIT` | 10 | worker.rs:619 |
| `SEARCH_SUBJECT_MAX` | 120 chars | worker.rs:620 | | `SEARCH_SUBJECT_MAX` | 120 chars | worker.rs:621 |
| `SEARCH_PREVIEW_MAX` | 160 chars | worker.rs:622 | | `SEARCH_PREVIEW_MAX` | 160 chars | worker.rs:623 |
--- ---
@ -434,9 +462,10 @@ and only `script-src`, `style-src` and `connect-src` re-open a same-origin chann
Recorded here so that references elsewhere cannot be mistaken for shipped features. Recorded here so that references elsewhere cannot be mistaken for shipped features.
- `/search` **is** implemented (`bfe0fd8`, `worker.rs` / `jmap_service.rs`). What is *not* - `/search` **is** implemented (`bfe0fd8`). Adapter: `search_emails` at `worker.rs:652` and
possible: **body-level** snippets. jmap-client `0.4.2` only exposes `emailId`/`subject`/ at `src/domain/jmap.rs:268`, backed by `search_emails` at `src/domain/jmap/client.rs:318`.
`preview` from `SearchSnippet/get`, and its `Filter` type has no comparator syntax, so What is *not* possible: **body-level** snippets. jmap-client `0.4.2` only exposes
`emailId`/`subject`/`preview` from `SearchSnippet/get`, and its `Filter` type has no comparator syntax, so
per-part body highlight cannot be modelled through the locked crate. Search degrades to per-part body highlight cannot be modelled through the locked crate. Search degrades to
`subject`/`preview` highlights plus a pure-ID list when snippets are unsupported. Design `subject`/`preview` highlights plus a pure-ID list when snippets are unsupported. Design
note in `docs/design.md` §5.7. note in `docs/design.md` §5.7.

View File

@ -74,10 +74,10 @@
|---|---|---|---| |---|---|---|---|
| `channel/telegram/{mod,commands,session,render}.rs` | 虚构文件 | 目标目录树里画出,从未创建 | 命令解析在 `parse_intent`(`src/worker.rs`);会话/授权态走 Redis TTL(`docs/reference.md` 的 AI 授权态一节);渲染函数在 `src/channel.rs` 内 | | `channel/telegram/{mod,commands,session,render}.rs` | 虚构文件 | 目标目录树里画出,从未创建 | 命令解析在 `parse_intent`(`src/worker.rs`);会话/授权态走 Redis TTL(`docs/reference.md` 的 AI 授权态一节);渲染函数在 `src/channel.rs` 内 |
| `delivery:pending:{stream}` | 虚构键 | 与 Redis Streams 的 pending-entries list(PEL)混淆——PEL 由 Redis 内部维护,不是可写键 | 真实键:`delivery:inflight:{stream}:{id}`(EX 60)与 `delivery:committed:{stream}:{id}`(EX 604_800),见 `docs/reference.md` 的投递流水线键一节 | | `delivery:pending:{stream}` | 虚构键 | 与 Redis Streams 的 pending-entries list(PEL)混淆——PEL 由 Redis 内部维护,不是可写键 | 真实键:`delivery:inflight:{stream}:{id}`(EX 60)与 `delivery:committed:{stream}:{id}`(EX 604_800),见 `docs/reference.md` 的投递流水线键一节 |
| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:717`) | | `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:718`) |
| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:897-900`) | | `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:898-901`) |
| `MESSAGWEAVE_DOMAIN` 曾被写成「生产未配置时 Worker 白名单失效」 | 虚构断言(未提交草稿/讨论中出现,未进文档) | 该环境变量全仓零命中;Worker 白名单是**无条件 fail-closed**:未知路径 404、method 不符 405、后端缺失或解析失败 503(`cloudflare-worker/src/index.js:77-92`) | 无需配置开关,白名单恒生效 | | `MESSAGWEAVE_DOMAIN` 曾被写成「生产未配置时 Worker 白名单失效」 | 虚构断言(未提交草稿/讨论中出现,未进文档) | 该环境变量全仓零命中;Worker 白名单是**无条件 fail-closed**:未知路径 404、method 不符 405、后端缺失或解析失败 503(`cloudflare-worker/src/index.js:77-92`) | 无需配置开关,白名单恒生效 |
| `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:330`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:384`)经 `?` 把 Redis 错误原样上抛(:425),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(:394-401);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/roadmap.md`「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 | | `read_batch` / `retry_or_dlq`「Redis 出错时仍可能返回 `Ok(())`,消费循环因此不会因单次失败退出」 | 虚构断言 | 消费入口是 HTTP handler `worker`(`notify.rs:331`),**不是后台循环**:全仓 `src/` 零命中 `select!`,无信号处理、无常驻 worker 进程。`read_batch`(`state.rs:385`)经 `?` 把 Redis 错误原样上抛(:425),唯一被丢弃的结果是 XGROUP `CREATE` 的 `BUSYGROUP` 幂等保护(:394-401);`retry_or_dlq` 同样经 `?` 上抛。调用方对每一处 `Err` 都返回 `503 service_unavailable` + `retryable=true`,故「循环不因单次失败退出」这一语义前提本身不成立 | 无——不存在该缺口;`docs/roadmap.md`「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 |
--- ---
@ -86,7 +86,7 @@
| `channel/mod.rs` | 虚构文件 | 渠道层就是 `src/channel.rs`,不是 `channel/` 目录 | 无 | | `channel/mod.rs` | 虚构文件 | 渠道层就是 `src/channel.rs`,不是 `channel/` 目录 | 无 |
| `notify::push_handler` / `notify::worker` / `notify::reconcile` 作为模块路径 | 虚构模块路径 | 这些是 `src/notify.rs` 内的自由函数(`jmap_push` / `worker` / `reconcile`),不是模块路径 | 无 | | `notify::push_handler` / `notify::worker` / `notify::reconcile` 作为模块路径 | 虚构模块路径 | 这些是 `src/notify.rs` 内的自由函数(`jmap_push` / `worker` / `reconcile`),不是模块路径 | 无 |
| `mod_dedup` / `mod_streams` / `mod_sincestate` | 虚构模块名 | `state.rs` / `notify.rs` 都是平铺文件,无子模块;去重与 Streams 逻辑以自由函数存在 | 无 | | `mod_dedup` / `mod_streams` / `mod_sincestate` | 虚构模块名 | `state.rs` / `notify.rs` 都是平铺文件,无子模块;去重与 Streams 逻辑以自由函数存在 | 无 |
| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:884`)内联处理回调 URL 与验证码回写 | 无 | | `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:885`)内联处理回调 URL 与验证码回写 | 无 |
| `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 | | `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 |
| `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 | | `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 |
| `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 | | `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 |
@ -95,9 +95,9 @@
| 熔断器 / 半开态 / 熔断后 60s 冷却 / 规则兜底(Redis 共享计数) | 未实施设计 | 只有超时 + 重试;LLM 失败静默降级为前 300 字回退,无用户侧提示、无状态记录 | 无 | | 熔断器 / 半开态 / 熔断后 60s 冷却 / 规则兜底(Redis 共享计数) | 未实施设计 | 只有超时 + 重试;LLM 失败静默降级为前 300 字回退,无用户侧提示、无状态记录 | 无 |
| 定时摘要 / 每日邮件摘要推送 | 未实施 | 未实现 | 无 | | 定时摘要 / 每日邮件摘要推送 | 未实施 | 未实现 | 无 |
| 附件下载(`send_document` / `Blob/get` / 下载按钮) | 未实施 | JMAP 侧只读;邮件附件仅以 `has_attachment: bool` 形式出现 | 无 | | 附件下载(`send_document` / `Blob/get` / 下载按钮) | 未实施 | JMAP 侧只读;邮件附件仅以 `has_attachment: bool` 形式出现 | 无 |
| `/flag` / `/unseen` / 发信命令 | 未实施 | 当前识别 6 个意图(帮助 / 同意 / 摘要 / 搜索 / 普通消息 / 未识别);其中 `/search` 已实现(`worker.rs:517`) | 无 | | `/flag` / `/unseen` / 发信命令 | 未实施 | 当前识别 6 个意图(帮助 / 同意 / 摘要 / 搜索 / 普通消息 / 未识别);其中 `/search` 已实现(`worker.rs:533`) | 无 |
| `Identity` 概念 | 未实施 | 账号识别只依赖 `ACCOUNT_ID`,无身份层抽象 | 无 | | `Identity` 概念 | 未实施 | 账号识别只依赖 `ACCOUNT_ID`,无身份层抽象 | 无 |
| `run_mode.rs`(作为单独文件) | 虚构文件 | RUN_MODE 由 `config.rs:338` 读取、在 `src/main.rs:75` 校验取值;不存在 `validate_env_or_exit` 这类函数 | 无 | | `run_mode.rs`(作为单独文件) | 虚构文件 | RUN_MODE 由 `config.rs:345` 读取、在 `src/main.rs:75` 校验取值;不存在 `validate_env_or_exit` 这类函数 | 无 |
| docker-compose `message-weave health --addr` 示例 | 虚构命令 | 应用无 CLI 子命令;健康检查端点是 `GET /healthz` 与 `GET /ready` | 见 `docs/deployment.md` 的 Health-check 表 | | docker-compose `message-weave health --addr` 示例 | 虚构命令 | 应用无 CLI 子命令;健康检查端点是 `GET /healthz` 与 `GET /ready` | 见 `docs/deployment.md` 的 Health-check 表 |
| 早期设计辩论问题(消息格式 / 长邮件阈值 / 附件策略 / Identity / 监控 / LLM 供应商 / 熔断等 17 条) | 已由代码回答 | 均已被实现的代码给出答案,不再属于待确认项 | 见 `docs/design.md` 的「已由代码回答的早期问题」一节 | | 早期设计辩论问题(消息格式 / 长邮件阈值 / 附件策略 / Identity / 监控 / LLM 供应商 / 熔断等 17 条) | 已由代码回答 | 均已被实现的代码给出答案,不再属于待确认项 | 见 `docs/design.md` 的「已由代码回答的早期问题」一节 |

View File

@ -12,7 +12,7 @@
## 阻塞(需要真实环境,当前无法验证) ## 阻塞(需要真实环境,当前无法验证)
- **真实 Stalwart 邮箱联调** — 未做任何真实邮箱端到端集成。JMAP adapter 代码与配置校验已完成并通过本地 `cargo test`,但 `Email/changes`、`PushSubscription` 路径未在真实账号上验证。`REQ-JMAP-SESSION-URL` 已实现(`D-G1-1`)但待真机验证。**阻塞原因**:无可用 Stalwart 账号/凭据;禁止使用测试账号或伪造结果。 - **真实 Stalwart 邮箱联调** — 未做任何真实邮箱端到端集成。JMAP adapter 代码与配置校验已完成并通过本地 `cargo test`,但 `Email/changes`、`PushSubscription` 路径未在真实账号上验证。`REQ-JMAP-SESSION-URL` 已实现(`D-G1-1`)但待真机验证。**阻塞原因**:无可用 Stalwart 账号/凭据;禁止使用测试账号或伪造结果。
- **真实 Telegram Bot 联调** — 入站 `POST /webhook/tg` 的 secret 头校验 + chat 白名单(`notify.rs:175` 起)、出站 `sendMessage`(`channel.rs`)均已在代码内并有单元测试,但**都没有在真实 Bot 上跑过**。另:向 Telegram 注册 webhook URL 是运维步骤(调 `setWebhook`),应用内不实现;长轮询 `getUpdates` 从未实现(见 `docs/retired.md`)。**阻塞原因**:无 Bot Token;禁止向真实用户发消息。 - **真实 Telegram Bot 联调** — 入站 `POST /webhook/tg` 的 secret 头校验 + chat 白名单(`notify.rs:176` 起)、出站 `sendMessage`(`channel.rs`)均已在代码内并有单元测试,但**都没有在真实 Bot 上跑过**。另:向 Telegram 注册 webhook URL 是运维步骤(调 `setWebhook`),应用内不实现;长轮询 `getUpdates` 从未实现(见 `docs/retired.md`)。**阻塞原因**:无 Bot Token;禁止向真实用户发消息。
- **真实 Redis TLS 连接** — `rediss://` 握手、密码特殊字符 URL 编码、`CONFIG_ENCRYPTION_KEY` 热更新未实测。**阻塞原因**:无托管 Redis 实例。 - **真实 Redis TLS 连接** — `rediss://` 握手、密码特殊字符 URL 编码、`CONFIG_ENCRYPTION_KEY` 热更新未实测。**阻塞原因**:无托管 Redis 实例。
- **`Email/changes` 的 `newState` 语义** — 客户端已用「同 `sinceState` 翻倍 `maxChanges` 扩窗」消除按页漏批,但 `newState` 是否表示"全部待报变更之后"仍需真实 Stalwart 复验,否则停机积压边界无法判定。**阻塞原因**:无可用 Stalwart 账号/凭据。 - **`Email/changes` 的 `newState` 语义** — 客户端已用「同 `sinceState` 翻倍 `maxChanges` 扩窗」消除按页漏批,但 `newState` 是否表示"全部待报变更之后"仍需真实 Stalwart 复验,否则停机积压边界无法判定。**阻塞原因**:无可用 Stalwart 账号/凭据。
- **Push callback 公网映射** — 单飞锁 `lock:push-register:{sha256(callback_url)}`(360s)与映射 `push:registration:{sha256(callback_url)}`(7d)的 TTL 需真实回调时序验证。**阻塞原因**:无真实推送回调可观察。 - **Push callback 公网映射** — 单飞锁 `lock:push-register:{sha256(callback_url)}`(360s)与映射 `push:registration:{sha256(callback_url)}`(7d)的 TTL 需真实回调时序验证。**阻塞原因**:无真实推送回调可观察。

85
scripts/docs_check/audit_anchors.py Executable file → Normal file
View File

@ -1,21 +1,70 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Check every `foo.rs:N` / `foo.rs:N-M` line anchor in the docs. """Check every `foo.rs:N` / `foo.rs:N-M` / `web/config.js:N` line anchor in the docs.
Paths may be bare (`notify.rs:320`, resolved under `src/`), prefixed with a directory
(`src/domain/jmap/client.rs:108`, `cloudflare-worker/src/backends.js:9`,
`web/config.js:585`), or unprefixed JavaScript (`config.js:585`, resolved under `web/`
or the Worker).
LIMITATION: this only proves the cited line *exists and is not blank*. It does LIMITATION: this only proves the cited line *exists and is not blank*. It does
not prove the line contains what the prose claims about it. A doc can pass this not prove the line contains what the prose claims about it. A doc can pass this
audit with anchors that point at the wrong line -- a human must read the target audit with anchors that point at the wrong line -- a human must read the target
line to catch that. Keep this in mind when quoting "0 errors" as proof of line to catch that. Keep this in mind when quoting "0 errors" as proof of
accuracy: it proves reachability, not correctness. accuracy: it proves reachability, not correctness.
The one exception is a *phantom identifier*: if a backticked name sits right next
to an anchor (the common "`foo` ... `bar.rs:N`" phrasing) and that name appears
nowhere in bar.rs, the anchor is reported. Existence, not position -- the symbol
may still be on the wrong line.
""" """
import re, sys import re
import sys
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md',
'docs/deployment.md', 'docs/reference.md', 'docs/retired.md', 'docs/deployment.md', 'docs/reference.md', 'docs/retired.md',
'docs/roadmap.md', 'docs/charter.md'] 'docs/roadmap.md', 'docs/charter.md', 'cloudflare-worker/README.md']
IDENT = r'[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)?'
FILE = r'((?:[A-Za-z0-9_.-]+/)?[A-Za-z0-9_-]+)'
EXT = r'\.(rs|js):(\d+)'
# A directory part is zero or more full segments (`/seg`), so the regex can never
# split a file name like `debug.rs` into a "directory" plus a stray base.
REF = re.compile(
r'((?:src|cloudflare-worker/src|web)(?:/[A-Za-z0-9_.-]+)*/)?'
+ FILE + EXT + r'(?:\s*[,–\-]\s*(\d+))?'
)
# A backticked identifier sitting right next to an anchor is an implicit claim:
# "`foo` ... `bar.rs:N`". If `foo` never appears in bar.rs the anchor is a phantom.
# The gap may not cross more than one backtick boundary and is capped at 40
# characters on each side, so only a genuinely coupled identifier/anchor pair
# matches. An optional `()` after the identifier is tolerated, since prose often
# writes function names that way.
PAIR = re.compile(
r'`(' + IDENT + r')(?:\(\))?`'
r'[^`\n|]{0,40}?`?[^`\n|]{0,40}?'
r'(?<![A-Za-z0-9_.])`?'
+ FILE + EXT
)
def resolve(prefix, base, ext):
"""Map an anchor's path fragment to a file on disk, or None."""
if prefix is None:
# Basename only. `.rs` lives in src/; `.js` may live in the Worker or in
# the served SPA, so try both.
if ext == 'rs':
candidates = [ROOT / 'src' / f'{base}.{ext}']
else:
candidates = [ROOT / 'cloudflare-worker/src' / f'{base}.{ext}',
ROOT / 'web' / f'{base}.{ext}']
else:
candidates = [ROOT / prefix.rstrip('/') / f'{base}.{ext}']
return next((c for c in candidates if c.is_file()), None)
REF = re.compile(r'(?:(src|cloudflare-worker/src)/)?([A-Za-z0-9_]+)\.(?:rs|js):(\d+)(?:\s*[,–\-]\s*(\d+))?')
errors = [] errors = []
checked = 0 checked = 0
@ -28,20 +77,22 @@ for d in DOCS:
continue continue
if in_fence: if in_fence:
continue continue
for m in PAIR.finditer(line):
ident, raw, ext = m.group(1), m.group(2), m.group(3)
raw = raw.rsplit('/', 1)[-1]
if '.' in raw:
continue
p = resolve(None, raw, ext)
if p is None:
continue
symbol = ident.rsplit('::', 1)[-1]
if symbol not in p.read_text():
errors.append(f'{d}:{i} phantom identifier `{ident}` not in {raw}.{ext}')
for m in REF.finditer(line): for m in REF.finditer(line):
sub, base, a, b = m.group(1), m.group(2), m.group(3), m.group(4) prefix, base, ext, a, b = m.groups()
if sub == 'cloudflare-worker/src': p = resolve(prefix, base, ext)
p = ROOT / sub / f'{base}.js' if p is None:
elif sub == 'src' or sub is None: errors.append(f'{d}:{i} unknown file {base}.{ext}')
p = ROOT / 'src' / f'{base}.rs'
if not p.exists() and sub is None:
q = ROOT / 'cloudflare-worker/src' / f'{base}.js'
if q.exists():
p = q
else:
p = ROOT / sub / f'{base}.rs'
if not p.exists():
errors.append(f'{d}:{i} unknown file {p.name}')
continue continue
src = p.read_text().splitlines() src = p.read_text().splitlines()
hi = int(b) if b else int(a) hi = int(b) if b else int(a)

View File

@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Enforce the AGENTS.md §2.1 file-size rule.
A file over SOFT_MAX lines must carry a `SPLIT-EVAL:` marker comment in its
first WINDOW lines, stating the split decision and why it was not split.
Over limit without the marker is a hard error.
The marker only needs to contain the literal token, so the rule is stated once
and stays valid regardless of the source language used.
LIMITATION: this proves the marker exists, not that the written reason is
sound. Someone must read it.
"""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
SOFT_MAX = 500 # AGENTS.md §2.1 soft cap
WINDOW = 60 # the marker must appear this many lines from the top
MARKER = 'SPLIT-EVAL:'
# Source only: Rust, the admin SPA, the worker. Docs and tests are excluded.
GLOBS = ['src/**/*.rs', 'web/*.js', 'cloudflare-worker/src/*.js']
def scan():
files = []
for pattern in GLOBS:
files.extend(p for p in ROOT.glob(pattern) if p.is_file())
return sorted(files)
def main():
over = []
errors = []
for path in scan():
try:
lines = path.read_text(encoding='utf-8').splitlines()
except UnicodeDecodeError:
continue
n = len(lines)
if n <= SOFT_MAX:
continue
over.append(path)
if MARKER not in '\n'.join(lines[:WINDOW]):
errors.append(path)
print(f'ERROR: {path.relative_to(ROOT)}: {n} lines > {SOFT_MAX}, '
f'no "{MARKER}" marker within the first {WINDOW} lines')
for path in over:
print(f' {path.relative_to(ROOT)}: '
f'{len(path.read_text(encoding="utf-8").splitlines())} lines')
print(f'=== SIZE CHECK ({len(errors)} errors) ===')
return 1 if errors else 0
if __name__ == '__main__':
sys.exit(main())

View File

@ -15,7 +15,7 @@ import sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
DOCS = ["AGENTS.md", "README.md", "README.zh-CN.md", "docs/design.md", DOCS = ["AGENTS.md", "README.md", "README.zh-CN.md", "docs/design.md",
"docs/deployment.md", "docs/reference.md", "docs/retired.md", "docs/deployment.md", "docs/reference.md", "docs/retired.md",
"docs/roadmap.md", "docs/charter.md"] "docs/roadmap.md", "docs/charter.md", "cloudflare-worker/README.md"]
HEADING = re.compile(r"^(#{2,4})\s+(\d+(?:\.\d+)?\.?)\s") HEADING = re.compile(r"^(#{2,4})\s+(\d+(?:\.\d+)?\.?)\s")
# §2.3, §5.7, §3 (bare), also `§2.3/§3.4` chains handled by finditer # §2.3, §5.7, §3 (bare), also `§2.3/§3.4` chains handled by finditer

View File

@ -4,7 +4,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md',
'docs/deployment.md', 'docs/reference.md', 'docs/retired.md', 'docs/deployment.md', 'docs/reference.md', 'docs/retired.md',
'docs/roadmap.md', 'docs/charter.md'] 'docs/roadmap.md', 'docs/charter.md', 'cloudflare-worker/README.md']
CODE = re.compile(r'`[^`\n]*`') CODE = re.compile(r'`[^`\n]*`')
SEP = re.compile(r'^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$') SEP = re.compile(r'^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$')

View File

@ -16,9 +16,14 @@
# with anchors on the wrong line -- someone must read them. # with anchors on the wrong line -- someone must read them.
# check_sec_refs every "§N.N" cross-reference resolves to a real heading in # check_sec_refs every "§N.N" cross-reference resolves to a real heading in
# the document it is attributed to. # the document it is attributed to.
# check_file_size every source file over 500 lines carries the SPLIT-EVAL
# marker required by AGENTS.md §2.1. LIMITATION: it proves
# the marker exists, not that the written reason is sound.
# #
# audit_anchors and check_sec_refs only scan prose: lines inside fenced code # audit_anchors and check_sec_refs only scan prose: lines inside fenced code
# blocks are skipped, so examples and tables holding code do not raise noise. # blocks are skipped, so examples and tables holding code do not raise noise.
# check_file_size only scans src/, web/ and cloudflare-worker/src/ — docs and
# tests are out of scope.
# #
# Root is derived from this file's own location, so the whole suite works from a # Root is derived from this file's own location, so the whole suite works from a
# clean clone without editing any path. # clean clone without editing any path.
@ -29,7 +34,7 @@ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
rc=0 rc=0
fail=0 fail=0
for script in validate_docs check_tables audit_anchors check_sec_refs; do for script in validate_docs check_tables audit_anchors check_sec_refs check_file_size; do
out="$(python3 "$here/$script.py" 2>&1)" out="$(python3 "$here/$script.py" 2>&1)"
code=$? code=$?
# Each check prints "=== NAME (n errors) ===" as its summary, but not # Each check prints "=== NAME (n errors) ===" as its summary, but not
@ -46,7 +51,7 @@ for script in validate_docs check_tables audit_anchors check_sec_refs; do
done done
if [ "$fail" -eq 0 ]; then if [ "$fail" -eq 0 ]; then
echo "GATE-DOCS: PASS (4/4 checks clean)" echo "GATE-DOCS: PASS (5/5 checks clean)"
else else
echo "GATE-DOCS: FAIL ($fail check(s) reported errors)" echo "GATE-DOCS: FAIL ($fail check(s) reported errors)"
fi fi

View File

@ -3,7 +3,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md', DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.md',
'docs/deployment.md', 'docs/reference.md', 'docs/retired.md', 'docs/deployment.md', 'docs/reference.md', 'docs/retired.md',
'docs/roadmap.md', 'docs/charter.md'] 'docs/roadmap.md', 'docs/charter.md', 'cloudflare-worker/README.md']
errors = [] errors = []
def slug(t): def slug(t):

View File

@ -1,3 +1,4 @@
// SPLIT-EVAL: 已评估暂缓拆分——仅略超软上限约 20 行,结构体定义、Redis 配置解析与遗留 from_env() 路径相互引用,拆出只增加跨文件跳转而不消除任何重复。
use secrecy::{ExposeSecret, SecretString}; use secrecy::{ExposeSecret, SecretString};
use crate::error::BotError; use crate::error::BotError;

View File

@ -1,3 +1,4 @@
//! SPLIT-EVAL: 已评估暂缓拆分——jmap-client 0.4.2 的适配、请求建模与降级策略必须贴在一起才能对照 crate 文档逐字段验证,拆开会切断这条对照链。
//! Concrete read-only adapter (MOD-JMAP-CLIENT, GATE-G1-JMAP-READONLY). //! Concrete read-only adapter (MOD-JMAP-CLIENT, GATE-G1-JMAP-READONLY).
use super::{ use super::{

View File

@ -1,3 +1,4 @@
// SPLIT-EVAL: 已评估暂缓拆分——全部 HTTP 处理器共享同一套 Bearer/admin-session 鉴权、统一错误封装与路由装配顺序,拆分会让每个子模块重复导入并复述这些前置条件。
use axum::{ use axum::{
body::Bytes, body::Bytes,
extract::State, extract::State,

View File

@ -1,3 +1,4 @@
//! SPLIT-EVAL: 已评估暂缓拆分——会话令牌、幂等去重、可靠队列与限流共用同一组 Redis key 约定、加密封装与错误类型,拆分会让 key 命名规范分散到多处而失去单点审阅。
//! Redis-only reliability primitives (C-REDIS-ONLY-STATE, MOD-DEDUP, MOD-STREAMS). //! Redis-only reliability primitives (C-REDIS-ONLY-STATE, MOD-DEDUP, MOD-STREAMS).
//! These APIs are deliberately short-request operations; no subscriptions or polling. //! These APIs are deliberately short-request operations; no subscriptions or polling.

View File

@ -1,3 +1,4 @@
//! SPLIT-EVAL: 已评估暂缓拆分——搜索降级链、通知编排与出站重试共用同一份配置读取、错误映射表与日志字段集合,拆分会引入重复的错误翻译与日志规约。
//! Bounded metadata notification worker (MOD-TELEGRAM-NOTIFY). //! Bounded metadata notification worker (MOD-TELEGRAM-NOTIFY).
use crate::ai::LlmClient; use crate::ai::LlmClient;
use crate::channel::telegram::TelegramClient; use crate::channel::telegram::TelegramClient;

View File

@ -1,3 +1,4 @@
// SPLIT-EVAL: 已评估暂缓拆分——i18n 词条字典与配置表单逻辑共享同一份 data-i18n key 命名,且 src/web.rs 只内嵌 config.js 与 styles.css,拆成多文件需先引入构建步骤。
(() => { (() => {
"use strict"; "use strict";
@ -16,7 +17,7 @@
"intro.eyebrow": "Service management", "intro.eyebrow": "Service management",
"intro.title": "Manage your service connections", "intro.title": "Manage your service connections",
"intro.copy": "Configure JMAP, Telegram, LLM, access allowlist and runtime parameters. Business configuration is stored in external Redis and hot-reloaded immediately after a successful save.", "intro.copy": "Configure JMAP, Telegram, LLM, access allowlist and runtime parameters. Business configuration is stored in external Redis and hot-reloaded immediately after a successful save.",
"notice.bootstrap": "Create a short-lived admin session using Redis management credentials.", "notice.bootstrap": "Create a short-lived admin session using the CONFIG_ENCRYPTION_KEY value.",
"setup.eyebrow": "Setup guide", "setup.eyebrow": "Setup guide",
"setup.title": "Service startup configuration", "setup.title": "Service startup configuration",
"setup.checking": "Checking service startup configuration…", "setup.checking": "Checking service startup configuration…",
@ -29,10 +30,10 @@
"setup.fetchError": "Unable to read the service startup status right now. To protect management credentials, the authorization entry stays hidden; confirm the service is available and re-check.", "setup.fetchError": "Unable to read the service startup status right now. To protect management credentials, the authorization entry stays hidden; confirm the service is available and re-check.",
"auth.eyebrow": "Authorization required", "auth.eyebrow": "Authorization required",
"auth.title": "Create admin session", "auth.title": "Create admin session",
"auth.bodyPre": "Enter the password of the Redis ACL user from the deployment ", "auth.bodyPre": "Enter the value of the required startup key ",
"auth.bodyPost": ". The server issues a 15-minute admin session; the password and session exist only in the current page memory.", "auth.bodyPost": ". The server issues a 15-minute admin session; the key and session exist only in the current page memory.",
"auth.label": "Redis management credential", "auth.label": "Business encryption key",
"auth.placeholder": "Enter the ACL password from the Redis URL", "auth.placeholder": "Enter the CONFIG_ENCRYPTION_KEY value",
"auth.submit": "Create session", "auth.submit": "Create session",
"auth.submitBusy": "Authorizing…", "auth.submitBusy": "Authorizing…",
"auth.hint": "Never written to localStorage, sessionStorage or cookies. You must re-authorize after closing or refreshing the page.", "auth.hint": "Never written to localStorage, sessionStorage or cookies. You must re-authorize after closing or refreshing the page.",
@ -172,11 +173,11 @@
"berr.status": "Business configuration request failed (HTTP {status}). Retry shortly.", "berr.status": "Business configuration request failed (HTTP {status}). Retry shortly.",
"berr.fail": "{message} The request result may be unconfirmed; after checking, re-submit the complete configuration.", "berr.fail": "{message} The request result may be unconfirmed; after checking, re-submit the complete configuration.",
"berr.failDefault": "Business configuration request failed.", "berr.failDefault": "Business configuration request failed.",
"login.enterCred": "Enter the Redis ACL management credential. The input is never written to browser storage.", "login.enterCred": "Enter the CONFIG_ENCRYPTION_KEY value. The input is never written to browser storage.",
"login.creatingToast": "Creating a short-lived admin session…", "login.creatingToast": "Creating a short-lived admin session…",
"login.invalidResponse": "The admin endpoint returned an invalid session response.", "login.invalidResponse": "The admin endpoint returned an invalid session response.",
"login.successToast": "Admin session created. Runtime parameters are being read; business configuration must be filled in again in full.", "login.successToast": "Admin session created. Runtime parameters are being read; business configuration must be filled in again in full.",
"login.401": "The Redis management credential is invalid, or admin sessions are not enabled. Check the Redis URL ACL password.", "login.401": "The CONFIG_ENCRYPTION_KEY value does not match the server, or admin sessions are not enabled. The Redis ACL password is not accepted here.",
"login.503": "Redis unavailable or initialization incomplete; unable to create an admin session right now. Retry shortly.", "login.503": "Redis unavailable or initialization incomplete; unable to create an admin session right now. Retry shortly.",
"login.status": "Unable to create an admin session (HTTP {status}).", "login.status": "Unable to create an admin session (HTTP {status}).",
"login.fail": "Unable to connect to the admin service; check the network and retry.", "login.fail": "Unable to connect to the admin service; check the network and retry.",
@ -189,7 +190,7 @@
"intro.eyebrow": "服务管理", "intro.eyebrow": "服务管理",
"intro.title": "管理你的服务连接", "intro.title": "管理你的服务连接",
"intro.copy": "配置 JMAP、Telegram、LLM、访问白名单和运行参数。业务配置保存在外部 Redis,成功保存后会立即热加载。", "intro.copy": "配置 JMAP、Telegram、LLM、访问白名单和运行参数。业务配置保存在外部 Redis,成功保存后会立即热加载。",
"notice.bootstrap": "使用 Redis 管理凭据创建短期管理会话。", "notice.bootstrap": "使用 CONFIG_ENCRYPTION_KEY 的值创建短期管理会话。",
"setup.eyebrow": "配置引导", "setup.eyebrow": "配置引导",
"setup.title": "服务启动配置", "setup.title": "服务启动配置",
"setup.checking": "正在检查服务启动配置…", "setup.checking": "正在检查服务启动配置…",
@ -202,10 +203,10 @@
"setup.fetchError": "暂时无法读取服务启动状态。为保护管理凭据,授权入口保持隐藏;请确认服务可用后重新检查。", "setup.fetchError": "暂时无法读取服务启动状态。为保护管理凭据,授权入口保持隐藏;请确认服务可用后重新检查。",
"auth.eyebrow": "需要授权", "auth.eyebrow": "需要授权",
"auth.title": "创建管理会话", "auth.title": "创建管理会话",
"auth.bodyPre": "输入部署时 ", "auth.bodyPre": "输入启动必填的环境变量 ",
"auth.bodyPost": " 中 Redis ACL 用户的密码。服务端会签发有效期 15 分钟的管理会话;密码和会话只在当前页面内存中使用。", "auth.bodyPost": " 的值。服务端会签发有效期 15 分钟的管理会话;该值与会话只在当前页面内存中使用。",
"auth.label": "Redis 管理凭据", "auth.label": "业务加密密钥",
"auth.placeholder": "输入 Redis URL 中的 ACL 密码", "auth.placeholder": "输入 CONFIG_ENCRYPTION_KEY 的值",
"auth.submit": "创建会话", "auth.submit": "创建会话",
"auth.submitBusy": "正在授权…", "auth.submitBusy": "正在授权…",
"auth.hint": "不会写入 localStorage、sessionStorage 或 Cookie。关闭或刷新页面后需要重新授权。", "auth.hint": "不会写入 localStorage、sessionStorage 或 Cookie。关闭或刷新页面后需要重新授权。",
@ -345,11 +346,11 @@
"berr.status": "业务配置请求失败(HTTP {status})。请稍后重试。", "berr.status": "业务配置请求失败(HTTP {status})。请稍后重试。",
"berr.fail": "{message} 请求结果可能无法确认;检查后可重新提交完整配置。", "berr.fail": "{message} 请求结果可能无法确认;检查后可重新提交完整配置。",
"berr.failDefault": "业务配置请求失败。", "berr.failDefault": "业务配置请求失败。",
"login.enterCred": "请输入 Redis ACL 管理凭据。输入不会写入浏览器存储。", "login.enterCred": "请输入 CONFIG_ENCRYPTION_KEY 的值。输入不会写入浏览器存储。",
"login.creatingToast": "正在创建短期管理会话…", "login.creatingToast": "正在创建短期管理会话…",
"login.invalidResponse": "管理接口返回了无效的会话响应。", "login.invalidResponse": "管理接口返回了无效的会话响应。",
"login.successToast": "管理会话已创建。运行参数正在读取;业务配置需要完整重新填写。", "login.successToast": "管理会话已创建。运行参数正在读取;业务配置需要完整重新填写。",
"login.401": "Redis 管理凭据无效,或服务尚未启用管理会话。请检查 Redis URL ACL 密码。", "login.401": "CONFIG_ENCRYPTION_KEY 与服务器配置不一致,或服务尚未启用管理会话。此处不接受 Redis ACL 密码。",
"login.503": "Redis 不可用或初始化尚未完成,暂时无法创建管理会话。请稍后重试。", "login.503": "Redis 不可用或初始化尚未完成,暂时无法创建管理会话。请稍后重试。",
"login.status": "无法创建管理会话(HTTP {status})。", "login.status": "无法创建管理会话(HTTP {status})。",
"login.fail": "无法连接管理服务,请检查网络后重试。", "login.fail": "无法连接管理服务,请检查网络后重试。",

View File

@ -29,7 +29,7 @@
</section> </section>
<div id="notice" class="notice notice-info" role="status" aria-live="polite" hidden data-i18n="notice.bootstrap"> <div id="notice" class="notice notice-info" role="status" aria-live="polite" hidden data-i18n="notice.bootstrap">
Create a short-lived admin session using Redis management credentials. Create a short-lived admin session using the CONFIG_ENCRYPTION_KEY value.
</div> </div>
<section id="setup-card" class="panel auth-panel setup-panel" aria-labelledby="setup-title" aria-live="polite"> <section id="setup-card" class="panel auth-panel setup-panel" aria-labelledby="setup-title" aria-live="polite">
@ -52,11 +52,11 @@
</div> </div>
<div><p class="eyebrow" data-i18n="auth.eyebrow">Authorization required</p><h2 id="auth-title" data-i18n="auth.title">Create admin session</h2></div> <div><p class="eyebrow" data-i18n="auth.eyebrow">Authorization required</p><h2 id="auth-title" data-i18n="auth.title">Create admin session</h2></div>
</div> </div>
<p class="panel-copy"><span data-i18n="auth.bodyPre">Enter the password of the Redis ACL user from the deployment </span><code>REDIS_URL</code><span data-i18n="auth.bodyPost">. The server issues a 15-minute admin session; the password and session exist only in the current page memory.</span></p> <p class="panel-copy"><span data-i18n="auth.bodyPre">Enter the value of the required startup key </span><code>CONFIG_ENCRYPTION_KEY</code><span data-i18n="auth.bodyPost">. The server issues a 15-minute admin session; the key and session exist only in the current page memory.</span></p>
<form id="auth-form" class="auth-form" autocomplete="off"> <form id="auth-form" class="auth-form" autocomplete="off">
<label for="bootstrap-input" data-i18n="auth.label">Redis management credential</label> <label for="bootstrap-input" data-i18n="auth.label">Business encryption key</label>
<div class="token-row"> <div class="token-row">
<input id="bootstrap-input" name="bootstrap-credential" type="password" autocomplete="new-password" spellcheck="false" required data-i18n-placeholder="auth.placeholder" placeholder="Enter the ACL password from the Redis URL" aria-describedby="bootstrap-hint" /> <input id="bootstrap-input" name="admin-credential" type="password" autocomplete="new-password" spellcheck="false" required data-i18n-placeholder="auth.placeholder" placeholder="Enter the CONFIG_ENCRYPTION_KEY value" aria-describedby="bootstrap-hint" />
<button id="connect-button" class="button button-primary" type="submit" data-i18n="auth.submit">Create session</button> <button id="connect-button" class="button button-primary" type="submit" data-i18n="auth.submit">Create session</button>
</div> </div>
<p id="bootstrap-hint" class="field-hint" data-i18n="auth.hint">Never written to localStorage, sessionStorage or cookies. You must re-authorize after closing or refreshing the page.</p> <p id="bootstrap-hint" class="field-hint" data-i18n="auth.hint">Never written to localStorage, sessionStorage or cookies. You must re-authorize after closing or refreshing the page.</p>