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:
parent
e0882c7c68
commit
638c3a8cbc
|
|
@ -6,7 +6,7 @@
|
|||
>
|
||||
> 两者冲突时:**项目事实与安全不变量以 `docs/charter.md` 为准,质量规则以本文件为准。**
|
||||
|
||||
> **版本基线** `b70d188`(代码:所有行号锚点)|`526a73b`(文档:所有 `§x.y` 引用)
|
||||
> **版本基线** 填写本仓库的基线 commit:<`<代码行号锚点基线>`(例如 `b70d188`)|`<文档章节引用基线>`(例如 `e0882c7`)>,由持有门禁结果的提交者在合并该批改动时更新。
|
||||
|
||||
> 校验命令(`GATE-DOCS` / `GATE-P0`)的具体可复制版本写在 [`docs/charter.md`](docs/charter.md) §5。
|
||||
|
||||
|
|
@ -36,9 +36,9 @@
|
|||
### 2.1 文件规模
|
||||
|
||||
- 单文件规模控制在 **500 行**以内(软上限)。
|
||||
- 接近 400 行必须评估拆分。确需超过 500 行时,在文件头部写一句拆分评估结论与当前不拆的理由
|
||||
(例:内聚性优先——A 与 B 共享 C,拆分会引入 N 处重复)。
|
||||
- **未写明理由的超限文件视为待办项。** 超限不自动合规。
|
||||
- 接近 400 行必须评估拆分。确需超过 500 行时,文件头部必须有一行以 `SPLIT-EVAL:` 开头的注释,
|
||||
写明拆分评估结论与当前不拆的理由(例:内聚性优先——A 与 B 共享 C,拆分会引入 N 处重复)。
|
||||
- **未带 `SPLIT-EVAL:` 标记的超限文件视为待办项。** 超限不自动合规。
|
||||
- 不得为凑合规而机械拆分,也不得为压低行数把逻辑塞进不可读的长函数。
|
||||
|
||||
### 2.2 注释与代码一致
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ docker run --env-file .env -p 8080:8080 messageweave:latest
|
|||
| `PORT` | 否 | `8080` |
|
||||
| `RUN_MODE` | 否 | `webhook` |
|
||||
|
||||
`RUN_MODE` 只接受 `webhook` 或 `reconcile`。`.env.example` 只提供 2 个必需变量;其余是
|
||||
`RUN_MODE` 只接受 `webhook` 或 `reconcile`,但两种取值当前行为完全相同(共享同一套路由表),仅作前向占位,保持默认 `webhook` 即可。`.env.example` 只提供 2 个必需变量;其余是
|
||||
文档化默认值。
|
||||
|
||||
**业务配置 —— 存于 Redis,由浏览器写入。**
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
统一 HTTPS 入口 + 多后端 origin 故障转移(HA/LB 子项目)。
|
||||
**透传模型**:Worker 不感知业务,原样转发请求到多个 https 后端 origin;仅「超时 / 5xx」做有界故障转移。
|
||||
|
||||
> 本组件是 **safelist 受限的边缘负载均衡器(edge load balancer)**:路由固定 14 条白名单、
|
||||
> 本组件是 **safelist 受限的边缘负载均衡器(edge load balancer)**:路由固定 15 条白名单、
|
||||
> 后端 origin 在部署期固定且仅允许 https、未知路径一律 404。它只对固定后端做请求转发与
|
||||
> 故障转移,不接受任意目标主机,也不提供任何形式的流量中转或访问隐藏能力。
|
||||
|
||||
|
|
@ -31,21 +31,21 @@
|
|||
### 透传(A,SAF-LB-PASSTHRU)
|
||||
- 原样转发 method / headers(含鉴权头)/ body;不鉴权改写。
|
||||
- 所有后端实例共享同一组 secret(`C-LB-SHARED-SECRETS`):同一 `TELEGRAM_WEBHOOK_SECRET`、
|
||||
`JMAP_PUSH_VERIFICATION_CODE`、`API_TOKEN`(`API_TOKEN` 在 §10 仅用于多实例互斥,非鉴权)
|
||||
+ 相同 App Password(各后端实例的 `JMAP` 可不同,§10.2)。
|
||||
`JMAP_PUSH_VERIFICATION_CODE`、`API_TOKEN`(`API_TOKEN` 在 `docs/deployment.md` §10 仅用于多实例互斥,非鉴权)
|
||||
+ 相同 App Password(各后端实例的 `JMAP` 可不同,`docs/deployment.md` §10.2)。
|
||||
|
||||
### 路由 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`。
|
||||
- 其它路径 → **404**(不透传,避免 Worker 沦为后端任意路径的跳板)。
|
||||
- method 不符 → **405**。
|
||||
|
||||
### 有界故障转移(§10.4,deployment.md)
|
||||
### 有界故障转移(`docs/deployment.md` §10.4)
|
||||
- 每次请求只试 `min(LB_MAX_ATTEMPTS, origins.length)` 次;默认 2(首次 + 1 次换实例)。
|
||||
- **仅** 超时(AbortError)或 5xx 触发换下一个实例;4xx / 2xx / 3xx 直接返回。
|
||||
- 起点按 `rng` 随机化(默认 `Math.random`),实现双活分摊;单 origin 配置时退化为确定性。
|
||||
- 全失败 → **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` 绝不故障转移。其余快路径保持全局默认。
|
||||
|
||||
### 健康聚合(MOD-HEALTH-AGG;C-NO-DB / C-REDIS-ONLY-STATE)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export const SAFE_ROUTES = Object.freeze([
|
|||
"/api/business-config",
|
||||
"/api/admin/session",
|
||||
"/api/admin/session/revoke",
|
||||
"/api/enabled",
|
||||
"/webhook/tg",
|
||||
"/push/jmap",
|
||||
"/api/push/register",
|
||||
|
|
@ -33,7 +34,6 @@ export class BackendConfigError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
const HTTP_STATUS = 80;
|
||||
const HTTPS_STATUS = 443;
|
||||
|
||||
/**
|
||||
|
|
@ -118,4 +118,4 @@ export function parseBackendOrigins(raw) {
|
|||
return out;
|
||||
}
|
||||
|
||||
export { HTTP_STATUS, HTTPS_STATUS };
|
||||
export { HTTPS_STATUS };
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ const ROUTE_METHODS = Object.freeze({
|
|||
"/api/business-config": ["PUT"],
|
||||
"/api/admin/session": ["POST"],
|
||||
"/api/admin/session/revoke": ["POST"],
|
||||
"/api/enabled": ["GET", "PUT"],
|
||||
"/webhook/tg": ["POST"],
|
||||
"/push/jmap": ["POST"],
|
||||
"/api/push/register": ["POST"],
|
||||
|
|
@ -69,7 +70,10 @@ export async function handleFetch(request, env) {
|
|||
const path = new URL(request.url).pathname;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"/api/push/register", "/api/push/disable",
|
||||
"/api/config", "/api/business-config", "/api/admin/session", "/api/admin/session/revoke",
|
||||
"/api/enabled",
|
||||
]) {
|
||||
assert.ok(LB_ROUTES.includes(r), `forwarded: ${r}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
const res = await handleFetch(
|
||||
new Request("https://lb.example/unknown", { method: "POST", body: "{}" }),
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
>
|
||||
> 三者冲突时:**可核对事实以 `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
|
||||
```
|
||||
|
||||
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 前端测试
|
||||
|
|
@ -165,6 +168,8 @@ Push callback 公网映射。
|
|||
| `docs/reference.md` | 可核对事实的唯一权威来源:路由、Redis 键与 TTL、配置项、错误码、出站常量 | **可核对事实** |
|
||||
| `docs/roadmap.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` | 网关自身的配置与语义 | 网关 |
|
||||
|
||||
**跨文档引用规则**:
|
||||
|
|
@ -184,7 +189,7 @@ Push callback 公网映射。
|
|||
- 新增 ID 必须先登记,再出现在注释、文档或标识符中;校验器会拒绝未登记 ID。
|
||||
- 内容搬家时**只更新本表「定义文件」列**,所有引用本身零改动。
|
||||
|
||||
共 83 个 ID,按前缀分组:`C-*`(部署约束)· `NG-*`(非目标)· `MOD-*`(模块)·
|
||||
共 85 个 ID,按前缀分组:`C-*`(部署约束)· `NG-*`(非目标)· `MOD-*`(模块)·
|
||||
`FLOW-*`(数据流)· `REQ-*`(需求)· `SAF-*`(安全)· `NFR-*`(非功能)· `GATE-*`(门禁)·
|
||||
`BOUND-*`(边界)· `ARCH-*`(架构)。
|
||||
|
||||
|
|
@ -223,6 +228,7 @@ Push callback 公网映射。
|
|||
| `REQ-AI-EXTERNAL-CONSENT` | docs/design.md §12 / §3 | 仅用户明确允许后才向外部 AI 发正文 | 需求 |
|
||||
| `REQ-AI-CONSENT` | docs/design.md §12.3.1 | AI 授权期限与 Redis 短期 TTL(授权有效期、到期后重新询问) | 需求 |
|
||||
| `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)保证同一流消息不重复投递 | 需求 |
|
||||
| `NFR-NOTIFY-SLA` | docs/deployment.md §6.5 | 通知可用性 ≥99.9%,允许少量延迟 | 非功能 |
|
||||
| `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-AUTH` | src/debug.rs / docs/deployment.md §2.1 | 挂载后 `/debug/*` 须 `Authorization: Bearer DEBUG_TOKEN` 常数时间比较,失败 `401` 且无副作用 | 安全 |
|
||||
| `REQ-DEBUG-ENDPOINTS` | src/debug.rs / docs/reference.md §3 / docs/deployment.md §2.1 | 端点契约:`GET /debug/ping`、`/config`、`/redis`、`/jmap`、`/telegram`、`/worker` 均只读;`POST /debug/notify` 走真实出站链路发一条测试消息;响应体不含 secret 原文(凭据字段只出 `*_configured` 布尔,非密文的身份与预算字段仍明文返回) | 需求 |
|
||||
| `SAF-DEBUG-ORIGIN-ONLY` | docs/deployment.md §2.1 / docs/reference.md §4 | `/debug/*` 不在网关 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`;白名单未配置(空)时不拦截,故启用本面须确认业务白名单已配置 | 安全 |
|
||||
| `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-LOCAL-WRITE` | docs/deployment.md §0 / §9.1 / §3 | 禁止本地文件/目录写入(日志/数据/临时缓存/本地卷) | 约束 |
|
||||
| `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-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 对账;进程内缓存仅为性能优化,丢失须安全可重入 | 约束 |
|
||||
| `ARCH-STATE-REDIS` | docs/design.md §10.0 / Redis 为唯一状态来源 | 状态层统一走 Redis(Streams/SET NX/锁/摘要),进程不持有可恢复状态 | 架构 |
|
||||
| `C-REDIS-EXTERNAL` | docs/deployment.md §8.1 | Redis 由外部已认证实例提供,不与本服务同容器 | 部署约束 |
|
||||
|
|
|
|||
|
|
@ -62,11 +62,11 @@
|
|||
1. 进程命令行必须带 `--debug`(读取于 `src/main.rs:96`)。
|
||||
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`,路径为字面量,无常量抽取)
|
||||
|
||||
|
|
@ -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/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`)。
|
||||
- **不在 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 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。
|
||||
- **`/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**:网关的 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 管理流程单独审批。
|
||||
|
||||
|
|
@ -249,7 +249,7 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达
|
|||
#### 6.3.1 用户侧调度示例(copy-paste 可用)
|
||||
|
||||
端点:`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 是主路径,对账只是兜底。
|
||||
- 走 Worker 转发**无需特殊设置**:网关已为 `POST /reconcile` 单独覆盖超时与尝试次数——超时取
|
||||
`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`
|
||||
(默认 `2`),不受影响。
|
||||
|
||||
|
|
@ -487,7 +487,7 @@ Stalwart PushSub ────┼─▶ https://lb.<you>.workers.dev ← 唯
|
|||
|
||||
### 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),不触发业务副作用。
|
||||
- **故障转移(`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 配置时退化为确定性。
|
||||
|
|
|
|||
|
|
@ -280,7 +280,7 @@ TG /read 3 → handler 取会话里的 folder+page 游标
|
|||
|
||||
当前**没有 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`。
|
||||
|
||||
|
|
@ -347,6 +347,7 @@ message-weave/
|
|||
| `ai` | reqwest 0.13 | `LlmClient` / `summarize` | mock OpenAI 兼容端点 | 已实现 |
|
||||
| `notify` | axum;redis | HTTP 鉴权、全局开关、`/push/jmap` 入队、`Email/changes` 对账和游标提交 | webhook 校验失败路径覆盖 403 | 已覆盖真实对账路径;仍需真实 Stalwart 环境做端到端验收 |
|
||||
| `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` 落地。
|
||||
|
||||
|
|
@ -386,7 +387,7 @@ message-weave/
|
|||
| `LLM_ENABLED` | 否 | `false` | 关闭时不校验 `LLM_*`(`REQ-LLM-OPENAI-COMPAT`) |
|
||||
| `LLM_ALLOW_NET` | 否 | `false` | AI 出网开关(`REQ-AI-EXTERNAL-CONSENT`) |
|
||||
| `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`) |
|
||||
|
||||
> 当前 `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)。
|
||||
- **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)。
|
||||
- **速率**:出站侧未建本地令牌桶;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
|
||||
- `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。
|
||||
|
|
@ -427,7 +428,7 @@ message-weave/
|
|||
"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/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 已由代码回答的早期问题(不再待确认)
|
||||
以下问题在设计阶段以 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)。
|
||||
- **附件**(Q8、Q28):完全未实现。领域模型只有 `has_attachment: bool`,出站只有 `sendMessage`,没有 `send_document` 也没有 `Blob/get`(见 §12.3)。
|
||||
- **推送范围**(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_BASE_URL` | URL | 可省略 | 仅启用时必填,且必须 `https`,否则 `AiError::InvalidEndpoint` |
|
||||
| `LLM_MODEL` | string | 可省略 | 仅启用时必填;透传给 `/chat/completions` 的 `model` |
|
||||
| `LLM_SUMMARY_TARGET_CHARS` | int | `1024` | `LlmClient::max_chars`,对返回摘要文本做字符截断(**代码侧确有截断**) |
|
||||
| `max_retries`(运行参数) | int | `3` | 由 `OutboundConfig` 下发(`RuntimeConfigProvider`),硬上限 5;LLM 与 Telegram 出站共用 |
|
||||
| `llm_timeout_ms`(运行参数) | int | `30000` | 硬下限 100ms |
|
||||
| `LLM_SUMMARY_TARGET_CHARS` | int | `300` | `LlmClient::max_chars`,对返回摘要文本做字符截断(**代码侧确有截断**) |
|
||||
| `max_retries`(运行参数) | int | 见 `docs/reference.md` §6.1 | 由 `OutboundConfig` 下发(`RuntimeConfigProvider`),硬上限见 `docs/reference.md` §6.1;LLM 与 Telegram 出站共用 |
|
||||
| `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`)。
|
||||
|
||||
|
|
@ -748,7 +749,7 @@ src/ai.rs # LlmClient(唯一实现):summarize() 打 OpenAI 兼容 /chat/
|
|||
|
||||
### 12.4 失败检测(当前实现)
|
||||
`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)立即返回错误,不重试**。
|
||||
- 重试耗尽或全部超时 → `Err(AiError::Response)`;URL 解析失败或非 https → `Err(AiError::InvalidEndpoint)`;JSON 反序列化失败或缺 `choices[0].message.content` → `Err(AiError::Response)`。
|
||||
- `AiError` 只有三个变体:`InvalidEndpoint` / `Request` / `Response`。**不存在** 429/401/Timeout 的细分类型。
|
||||
|
|
|
|||
|
|
@ -26,48 +26,52 @@ Conventions used throughout:
|
|||
|
||||
| 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: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:outbound` | No EX | `set_outbound_config` (state.rs:552) | `get_outbound_config` (state.rs:536) |
|
||||
| `config:enabled` | No EX | `set_enabled` (state.rs:657) | `is_enabled` (state.rs:648) |
|
||||
| `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: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: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:553) | `get_outbound_config` (state.rs:537) |
|
||||
| `config:enabled` | No EX | `set_enabled` (state.rs:658) | `is_enabled` (state.rs:649) |
|
||||
| `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:
|
||||
|
||||
- `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
|
||||
(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.
|
||||
- `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`
|
||||
(notify.rs:787).
|
||||
(notify.rs:788).
|
||||
- `config:enabled` is consulted as a gate; missing or `false` keeps business processing off.
|
||||
|
||||
### 1.2 AI consent
|
||||
|
||||
| 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`
|
||||
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
|
||||
enforces this itself (`worker.rs:613`):
|
||||
enforces this itself (`worker.rs:614`):
|
||||
|
||||
| Input | TTL | Label |
|
||||
|---|---|---|
|
||||
| `/ai on`, `临时一次`, `临时`, `一次` | 3600 | `1小时` |
|
||||
| `/ai on`, `/ai yes`, `开启 ai`, `同意摘要`, `允许 ai` | 3600 | `1小时` |
|
||||
| `临时`, `一次` (substring) | 3600 | `临时1小时` |
|
||||
| `今天` | 86_400 | `今天` |
|
||||
| `7天` | 604_800 | `7天` |
|
||||
| `直到我撤销`, `长期` | 31_536_000 (365 d) | `直到我撤销` |
|
||||
| `直到我撤销`, `长期` (substring) | 31_536_000 (365 d) | `直到撤销(最长365天)` |
|
||||
| `/ai off`, `关闭 ai`, `撤销授权`, `停止摘要` | — | revoke |
|
||||
|
||||
`1小时` is a **category label, not a trigger word**: typing `1小时` alone grants nothing.
|
||||
Consent never auto-renews.
|
||||
The `/ai …` slash forms are exact matches on the whole message; the Chinese phrases are
|
||||
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
|
||||
`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 |
|
||||
|---|---|---|---|
|
||||
| `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) |
|
||||
| `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: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) |
|
||||
| `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 |
|
||||
| `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`, `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: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: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: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: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
|
||||
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` /
|
||||
`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.
|
||||
|
||||
### 1.4 Idempotency and rate limits
|
||||
|
||||
| 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: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 | — |
|
||||
| `ratelimit:push-verify:{subscription}` | EX 30 | key at notify.rs:815, `claim_dedup` at notify.rs:816 (`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) |
|
||||
| `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: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:816, `claim_dedup` at notify.rs:817 (`push_verify_rate_limited` at :819) | — |
|
||||
| `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
|
||||
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 |
|
||||
|---|---|---|---|
|
||||
| `lock:reconcile` | 300 | `acquire_lock` (notify.rs:239) | `renew_lock` heartbeat 90 (notify.rs:252) |
|
||||
| `lock:push-register:{sha256(callback_url)}` | 360 | `acquire_lock` (notify.rs:906) | `release_lock` (notify.rs:922, 937, 950, 967, 984, 994) |
|
||||
| `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: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
|
||||
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
|
||||
> slow create from admitting a duplicate.
|
||||
|
|
@ -127,11 +131,11 @@ duplicate. Source comment, notify.rs:904-905:
|
|||
|
||||
| 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` | No EX | `remember_push_subscription_id` (state.rs:715) | 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: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:orphan:{subscription_id}` | EX 604_800 | `record_push_orphan` (state.rs:751) | orphan sweep |
|
||||
| `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:716) | current-subscription lookup |
|
||||
| `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: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:752) | orphan sweep |
|
||||
|
||||
**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.
|
||||
|
||||
**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
|
||||
|
||||
|
|
@ -159,28 +163,34 @@ Do not infer correctness from its presence.
|
|||
Every error response uses one envelope shape:
|
||||
|
||||
```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 |
|
||||
|---|---|---|
|
||||
| 400 | `invalid_request`, `missing` | — |
|
||||
| 401 | `unauthorized` | — |
|
||||
| 403 | `forbidden` | — |
|
||||
| 403 | `forbidden`, `chat_not_allowed` (`/debug/*` only) | — |
|
||||
| 404 | `push_subscription_not_found` | — |
|
||||
| 409 | `conflict` | — |
|
||||
| 422 | `invalid_configuration` | — |
|
||||
| 429 | `push_verify_rate_limited` | `30` |
|
||||
| 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
|
||||
string `"30"`:
|
||||
|
||||
- `error_response` (`notify.rs:320-328`, inserted at `:324`) sets it **only when its `retry`
|
||||
flag is true**. The readiness failure path (`notify.rs:161`) passes `retry = true`, so `/ready`
|
||||
- `error_response` (`notify.rs:321-329`, inserted at `:324`) sets it **only when its `retry`
|
||||
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.
|
||||
- `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
|
||||
`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`.
|
||||
|
||||
`GET /api/status` is the exception that reports setup state with a non-envelope body.
|
||||
|
|
@ -189,7 +199,10 @@ string `"30"`:
|
|||
|
||||
## 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 |
|
||||
|---|---|---|
|
||||
|
|
@ -208,14 +221,14 @@ All registered in `router_with_worker_state_runtime_bootstrap_config` (notify.rs
|
|||
| GET | `/healthz` | `healthz` |
|
||||
| GET | `/ready` | `ready` |
|
||||
| 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
|
||||
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>`,
|
||||
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 |
|
||||
|---|---|---|
|
||||
|
|
@ -236,14 +249,16 @@ Response conventions:
|
|||
completeness (`setup_missing` empty), Redis reachability, and two upstream probes —
|
||||
`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
|
||||
(`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
|
||||
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.
|
||||
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.
|
||||
- `GET /api/status` returns **503** with body `{"status":"configuration-setup","missing":[...]}`
|
||||
when a required environment variable is absent.
|
||||
- `GET /api/status` (**always 200**, `notify.rs:166-174`) returns
|
||||
`{"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
|
||||
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
|
||||
|
|
@ -258,43 +273,49 @@ Response conventions:
|
|||
## 4. Gateway vs backend route matrix
|
||||
|
||||
> **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`
|
||||
> (wrangler.toml:20); `src/lb.js` performs forwarding and bounded failover (`SAF-LB-PASSTHRU`,
|
||||
> `C-NO-LONG-CONN`).
|
||||
|
||||
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
|
||||
path with the wrong method returns **405** (index.js:80-83), and a missing or unparseable
|
||||
backend pool returns **503** rather than passing the request through (index.js:85-93).
|
||||
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:84-86), and a missing or unparseable
|
||||
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` ·
|
||||
`/api/business-config` · `/api/admin/session` · `/api/admin/session/revoke` ·
|
||||
`/api/enabled` ·
|
||||
`/webhook/tg` · `/push/jmap` · `/api/push/register` · `/api/push/disable` · `/reconcile` ·
|
||||
`/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 |
|
||||
|---|---|
|
||||
| `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 /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 |
|
||||
| `/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
|
||||
`/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.
|
||||
|
||||
`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
|
||||
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
|
||||
deployment.md §10.5.
|
||||
|
||||
|
|
@ -304,24 +325,25 @@ deployment.md §10.5.
|
|||
|
||||
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 |
|
||||
|---|---|---|---|
|
||||
| `REDIS_URL` | yes | — | read at main.rs:40 |
|
||||
| `CONFIG_ENCRYPTION_KEY` | yes | — | `encryption_key_from_env` at main.rs:50 |
|
||||
| `PORT` | no | `8080` | `unwrap_or(8080_u16)` at main.rs:34 |
|
||||
| `RUN_MODE` | no | `webhook` | `unwrap_or_else` at config.rs:338 |
|
||||
| `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: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
|
||||
(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
|
||||
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.
|
||||
|
||||
**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
|
||||
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
|
||||
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)
|
||||
|
||||
`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` ·
|
||||
`JMAP_*` · `RECONCILE_TOKEN`
|
||||
`CONFIG_ENCRYPTION_KEY` · `PORT` · `REDIS_URL` · `CHAT_ALLOWLIST` · `TELEGRAM_CHAT_ID` ·
|
||||
`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
|
||||
`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
|
||||
(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
|
||||
`POST /api/bootstrap` (notify.rs:604) and `POST /api/admin/session` (notify.rs:774). It is
|
||||
`worker_authorized` (notify.rs:446; compare at notify.rs:1136) at the top of both
|
||||
`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
|
||||
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
|
||||
it, if any, authenticates the Redis connection and is not the credential for any HTTP
|
||||
endpoint.
|
||||
|
|
@ -362,10 +390,10 @@ endpoint.
|
|||
|
||||
| Parameter | Default | Range / cap | Source |
|
||||
|---|---|---|---|
|
||||
| `jmap_timeout_ms` | 15_000 | 100..=300_000 | default state.rs:55; validated notify.rs:491 |
|
||||
| `telegram_timeout_ms` | 10_000 | 100..=300_000 | default state.rs:56; validated notify.rs:492 |
|
||||
| `llm_timeout_ms` | 30_000 | 100..=300_000 | default state.rs:57; validated notify.rs:493 |
|
||||
| `max_retries` | 3 | hard cap 5 | default state.rs:58; rejected at notify.rs:494; re-clamped at channel.rs:138 |
|
||||
| `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: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:59; rejected at notify.rs:495; re-clamped at channel.rs:138 |
|
||||
|
||||
There is **no** `LLM_MAX_RETRIES` environment variable; retry count lives in
|
||||
`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 |
|
||||
|---|---|---|
|
||||
| `MAX_BASELINE_PAGES` | 100 | worker.rs:248 |
|
||||
| `MAX_BASELINE_EMAILS` | 10_000 | worker.rs:249 |
|
||||
| `RECONCILE_BUDGET` | 20 s | worker.rs:250 |
|
||||
| `CHANGE_WINDOW_CAP` | 4_096 | worker.rs:306 |
|
||||
| Lock TTL / heartbeat | 300 s / 90 s | notify.rs:239 / 252 |
|
||||
| `MAX_BASELINE_PAGES` | 100 | worker.rs:249 |
|
||||
| `MAX_BASELINE_EMAILS` | 10_000 | worker.rs:250 |
|
||||
| `RECONCILE_BUDGET` | 20 s | worker.rs:251 |
|
||||
| `CHANGE_WINDOW_CAP` | 4_096 | worker.rs:307 |
|
||||
| Lock TTL / heartbeat | 300 s / 90 s | notify.rs:240 / 252 |
|
||||
|
||||
`max_changes` is a function parameter, not a constant.
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
floored at the legacy 300 s single-event ceiling (state.rs:65) and capped at 6 h
|
||||
(state.rs:75). The floor is also the fallback when the config cannot be read, so a
|
||||
floored at the legacy 300 s single-event ceiling (state.rs:66) and capped at 6 h
|
||||
(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
|
||||
stake, never loss.
|
||||
|
||||
|
|
@ -399,9 +427,9 @@ stake, never loss.
|
|||
|
||||
| Constant | Value | Source |
|
||||
|---|---|---|
|
||||
| `SEARCH_LIMIT` | 10 | worker.rs:618 |
|
||||
| `SEARCH_SUBJECT_MAX` | 120 chars | worker.rs:620 |
|
||||
| `SEARCH_PREVIEW_MAX` | 160 chars | worker.rs:622 |
|
||||
| `SEARCH_LIMIT` | 10 | worker.rs:619 |
|
||||
| `SEARCH_SUBJECT_MAX` | 120 chars | worker.rs:621 |
|
||||
| `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.
|
||||
|
||||
- `/search` **is** implemented (`bfe0fd8`, `worker.rs` / `jmap_service.rs`). 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
|
||||
- `/search` **is** implemented (`bfe0fd8`). Adapter: `search_emails` at `worker.rs:652` and
|
||||
at `src/domain/jmap.rs:268`, backed by `search_emails` at `src/domain/jmap/client.rs:318`.
|
||||
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
|
||||
`subject`/`preview` highlights plus a pure-ID list when snippets are unsupported. Design
|
||||
note in `docs/design.md` §5.7.
|
||||
|
|
|
|||
|
|
@ -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` 内 |
|
||||
| `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`) |
|
||||
| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:897-900`) |
|
||||
| `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:898-901`) |
|
||||
| `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/` 目录 | 无 |
|
||||
| `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 逻辑以自由函数存在 | 无 |
|
||||
| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:884`)内联处理回调 URL 与验证码回写 | 无 |
|
||||
| `PushVerification` 类型 | 虚构类型 | 未定义;`register_push`(`notify.rs:885`)内联处理回调 URL 与验证码回写 | 无 |
|
||||
| `CancellationToken` | 虚构类型 | 未使用;无优雅关闭、无信号处理(`src/` 零命中 `tokio::signal` / `ctrl_c`) | 无 |
|
||||
| `Preview` 类型 / 4000 字符长邮件保护 / `[继续查看原文]` 按钮 / `/llm-fallback` 按钮 | 虚构类型与 UI | 全部未实现;授权后把全文交给 LLM,失败即回退前 300 字符,无任何截断标注或按钮 | 无 |
|
||||
| `LlmErr`(5 变体) | 虚构枚举 | 真实是 `AiError`,仅 3 个变体(`InvalidEndpoint` / `Request` / `Response`) | 无 |
|
||||
|
|
@ -95,9 +95,9 @@
|
|||
| 熔断器 / 半开态 / 熔断后 60s 冷却 / 规则兜底(Redis 共享计数) | 未实施设计 | 只有超时 + 重试;LLM 失败静默降级为前 300 字回退,无用户侧提示、无状态记录 | 无 |
|
||||
| 定时摘要 / 每日邮件摘要推送 | 未实施 | 未实现 | 无 |
|
||||
| 附件下载(`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`,无身份层抽象 | 无 |
|
||||
| `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 表 |
|
||||
| 早期设计辩论问题(消息格式 / 长邮件阈值 / 附件策略 / Identity / 监控 / LLM 供应商 / 熔断等 17 条) | 已由代码回答 | 均已被实现的代码给出答案,不再属于待确认项 | 见 `docs/design.md` 的「已由代码回答的早期问题」一节 |
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
## 阻塞(需要真实环境,当前无法验证)
|
||||
|
||||
- **真实 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 实例。
|
||||
- **`Email/changes` 的 `newState` 语义** — 客户端已用「同 `sinceState` 翻倍 `maxChanges` 扩窗」消除按页漏批,但 `newState` 是否表示"全部待报变更之后"仍需真实 Stalwart 复验,否则停机积压边界无法判定。**阻塞原因**:无可用 Stalwart 账号/凭据。
|
||||
- **Push callback 公网映射** — 单飞锁 `lock:push-register:{sha256(callback_url)}`(360s)与映射 `push:registration:{sha256(callback_url)}`(7d)的 TTL 需真实回调时序验证。**阻塞原因**:无真实推送回调可观察。
|
||||
|
|
|
|||
|
|
@ -1,21 +1,70 @@
|
|||
#!/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
|
||||
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
|
||||
line to catch that. Keep this in mind when quoting "0 errors" as proof of
|
||||
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
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.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 = []
|
||||
checked = 0
|
||||
|
|
@ -28,20 +77,22 @@ for d in DOCS:
|
|||
continue
|
||||
if in_fence:
|
||||
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):
|
||||
sub, base, a, b = m.group(1), m.group(2), m.group(3), m.group(4)
|
||||
if sub == 'cloudflare-worker/src':
|
||||
p = ROOT / sub / f'{base}.js'
|
||||
elif sub == 'src' or sub is None:
|
||||
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}')
|
||||
prefix, base, ext, a, b = m.groups()
|
||||
p = resolve(prefix, base, ext)
|
||||
if p is None:
|
||||
errors.append(f'{d}:{i} unknown file {base}.{ext}')
|
||||
continue
|
||||
src = p.read_text().splitlines()
|
||||
hi = int(b) if b else int(a)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -15,7 +15,7 @@ import sys
|
|||
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/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")
|
||||
# §2.3, §5.7, §3 (bare), also `§2.3/§3.4` chains handled by finditer
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from pathlib import Path
|
|||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.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]*`')
|
||||
SEP = re.compile(r'^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$')
|
||||
|
|
|
|||
|
|
@ -16,9 +16,14 @@
|
|||
# with anchors on the wrong line -- someone must read them.
|
||||
# check_sec_refs every "§N.N" cross-reference resolves to a real heading in
|
||||
# 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
|
||||
# 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
|
||||
# clean clone without editing any path.
|
||||
|
|
@ -29,7 +34,7 @@ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||
rc=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)"
|
||||
code=$?
|
||||
# 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
|
||||
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
echo "GATE-DOCS: PASS (4/4 checks clean)"
|
||||
echo "GATE-DOCS: PASS (5/5 checks clean)"
|
||||
else
|
||||
echo "GATE-DOCS: FAIL ($fail check(s) reported errors)"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from pathlib import Path
|
|||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DOCS = ['AGENTS.md', 'README.md', 'README.zh-CN.md', 'docs/design.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 = []
|
||||
|
||||
def slug(t):
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
// SPLIT-EVAL: 已评估暂缓拆分——仅略超软上限约 20 行,结构体定义、Redis 配置解析与遗留 from_env() 路径相互引用,拆出只增加跨文件跳转而不消除任何重复。
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
|
||||
use crate::error::BotError;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
//! SPLIT-EVAL: 已评估暂缓拆分——jmap-client 0.4.2 的适配、请求建模与降级策略必须贴在一起才能对照 crate 文档逐字段验证,拆开会切断这条对照链。
|
||||
//! Concrete read-only adapter (MOD-JMAP-CLIENT, GATE-G1-JMAP-READONLY).
|
||||
|
||||
use super::{
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
// SPLIT-EVAL: 已评估暂缓拆分——全部 HTTP 处理器共享同一套 Bearer/admin-session 鉴权、统一错误封装与路由装配顺序,拆分会让每个子模块重复导入并复述这些前置条件。
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::State,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
//! SPLIT-EVAL: 已评估暂缓拆分——会话令牌、幂等去重、可靠队列与限流共用同一组 Redis key 约定、加密封装与错误类型,拆分会让 key 命名规范分散到多处而失去单点审阅。
|
||||
//! Redis-only reliability primitives (C-REDIS-ONLY-STATE, MOD-DEDUP, MOD-STREAMS).
|
||||
//! These APIs are deliberately short-request operations; no subscriptions or polling.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
//! SPLIT-EVAL: 已评估暂缓拆分——搜索降级链、通知编排与出站重试共用同一份配置读取、错误映射表与日志字段集合,拆分会引入重复的错误翻译与日志规约。
|
||||
//! Bounded metadata notification worker (MOD-TELEGRAM-NOTIFY).
|
||||
use crate::ai::LlmClient;
|
||||
use crate::channel::telegram::TelegramClient;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
// SPLIT-EVAL: 已评估暂缓拆分——i18n 词条字典与配置表单逻辑共享同一份 data-i18n key 命名,且 src/web.rs 只内嵌 config.js 与 styles.css,拆成多文件需先引入构建步骤。
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
|
|
@ -16,7 +17,7 @@
|
|||
"intro.eyebrow": "Service management",
|
||||
"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.",
|
||||
"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.title": "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.",
|
||||
"auth.eyebrow": "Authorization required",
|
||||
"auth.title": "Create admin session",
|
||||
"auth.bodyPre": "Enter the password of the Redis ACL user from the deployment ",
|
||||
"auth.bodyPost": ". The server issues a 15-minute admin session; the password and session exist only in the current page memory.",
|
||||
"auth.label": "Redis management credential",
|
||||
"auth.placeholder": "Enter the ACL password from the Redis URL",
|
||||
"auth.bodyPre": "Enter the value of the required startup key ",
|
||||
"auth.bodyPost": ". The server issues a 15-minute admin session; the key and session exist only in the current page memory.",
|
||||
"auth.label": "Business encryption key",
|
||||
"auth.placeholder": "Enter the CONFIG_ENCRYPTION_KEY value",
|
||||
"auth.submit": "Create session",
|
||||
"auth.submitBusy": "Authorizing…",
|
||||
"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.fail": "{message} The request result may be unconfirmed; after checking, re-submit the complete configuration.",
|
||||
"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.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.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.status": "Unable to create an admin session (HTTP {status}).",
|
||||
"login.fail": "Unable to connect to the admin service; check the network and retry.",
|
||||
|
|
@ -189,7 +190,7 @@
|
|||
"intro.eyebrow": "服务管理",
|
||||
"intro.title": "管理你的服务连接",
|
||||
"intro.copy": "配置 JMAP、Telegram、LLM、访问白名单和运行参数。业务配置保存在外部 Redis,成功保存后会立即热加载。",
|
||||
"notice.bootstrap": "使用 Redis 管理凭据创建短期管理会话。",
|
||||
"notice.bootstrap": "使用 CONFIG_ENCRYPTION_KEY 的值创建短期管理会话。",
|
||||
"setup.eyebrow": "配置引导",
|
||||
"setup.title": "服务启动配置",
|
||||
"setup.checking": "正在检查服务启动配置…",
|
||||
|
|
@ -202,10 +203,10 @@
|
|||
"setup.fetchError": "暂时无法读取服务启动状态。为保护管理凭据,授权入口保持隐藏;请确认服务可用后重新检查。",
|
||||
"auth.eyebrow": "需要授权",
|
||||
"auth.title": "创建管理会话",
|
||||
"auth.bodyPre": "输入部署时 ",
|
||||
"auth.bodyPost": " 中 Redis ACL 用户的密码。服务端会签发有效期 15 分钟的管理会话;密码和会话只在当前页面内存中使用。",
|
||||
"auth.label": "Redis 管理凭据",
|
||||
"auth.placeholder": "输入 Redis URL 中的 ACL 密码",
|
||||
"auth.bodyPre": "输入启动必填的环境变量 ",
|
||||
"auth.bodyPost": " 的值。服务端会签发有效期 15 分钟的管理会话;该值与会话只在当前页面内存中使用。",
|
||||
"auth.label": "业务加密密钥",
|
||||
"auth.placeholder": "输入 CONFIG_ENCRYPTION_KEY 的值",
|
||||
"auth.submit": "创建会话",
|
||||
"auth.submitBusy": "正在授权…",
|
||||
"auth.hint": "不会写入 localStorage、sessionStorage 或 Cookie。关闭或刷新页面后需要重新授权。",
|
||||
|
|
@ -345,11 +346,11 @@
|
|||
"berr.status": "业务配置请求失败(HTTP {status})。请稍后重试。",
|
||||
"berr.fail": "{message} 请求结果可能无法确认;检查后可重新提交完整配置。",
|
||||
"berr.failDefault": "业务配置请求失败。",
|
||||
"login.enterCred": "请输入 Redis ACL 管理凭据。输入不会写入浏览器存储。",
|
||||
"login.enterCred": "请输入 CONFIG_ENCRYPTION_KEY 的值。输入不会写入浏览器存储。",
|
||||
"login.creatingToast": "正在创建短期管理会话…",
|
||||
"login.invalidResponse": "管理接口返回了无效的会话响应。",
|
||||
"login.successToast": "管理会话已创建。运行参数正在读取;业务配置需要完整重新填写。",
|
||||
"login.401": "Redis 管理凭据无效,或服务尚未启用管理会话。请检查 Redis URL ACL 密码。",
|
||||
"login.401": "CONFIG_ENCRYPTION_KEY 与服务器配置不一致,或服务尚未启用管理会话。此处不接受 Redis ACL 密码。",
|
||||
"login.503": "Redis 不可用或初始化尚未完成,暂时无法创建管理会话。请稍后重试。",
|
||||
"login.status": "无法创建管理会话(HTTP {status})。",
|
||||
"login.fail": "无法连接管理服务,请检查网络后重试。",
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
</section>
|
||||
|
||||
<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>
|
||||
|
||||
<section id="setup-card" class="panel auth-panel setup-panel" aria-labelledby="setup-title" aria-live="polite">
|
||||
|
|
@ -52,11 +52,11 @@
|
|||
</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="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">
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue