docs: stop maintaining source line numbers in reference.md
`docs/reference.md` is the single source of truth and gets touched on every API change, yet 40 of its 56 `notify.rs` anchors had drifted onto a closing brace, a doc comment, or the wrong symbol - including one whole line pointing at `revoke_admin_session` where a jmap dedup key is claimed - while `scripts/docs_check/audit_anchors.py` reported every one of them clean. Its own docstring says it only checks that the target line exists and is non-blank. Remove the anchors rather than correcting them: fixing 40 numbers creates a treadmill that re-drifts on the next unrelated edit to the same file, and the file's own convention already called the line number "not an identity". Symbol names are kept everywhere, which is what the gates and the readers need. `docs/deployment.md` keeps its line numbers - it is an operator runbook read mid-incident - and the five anchors that were wrong there are corrected. docs/reference.md - Drop the false "Verified against commit b2dbe7c" header claim; the anchors never matched that commit. Replace it with the design decision and the reason, and drop the "Only this file carries line numbers" convention that `deployment.md` was already violating. - Remove all `*.rs:NNN` and bare `:NNN` anchors, keeping symbol names. - Fix a wrong fact the anchors were hiding: the session issued by `POST /api/admin/session` is a fresh CSPRNG token generated per call, not a "fixed 32-byte hex secret derived from admin_token". The anchor pointed at `bootstrap`'s body. docs/deployment.md - `retry_or_dlq` call site 481 -> 479; `lock:reconcile` acquire 289 -> 290 (twice); heartbeat `renew_lock` 302 -> 303 (twice). AGENTS.md - Record the convention: symbol names in docs, line numbers only in `docs/deployment.md`, plus the audit script's limits so nobody reads its output as "the anchors are correct". scripts/docs_check/audit_anchor_semantics.py (new) - Independent script, not wired into `run_all.sh`, always `exit 0`, prints candidates only. It flags an anchor whose target line has no relation to the symbol the doc claims. Candidates go 20 -> 5 and `reference.md` disappears from the list entirely. It cannot catch a one- or two-line off-by-one - that still needs a human - and a bare `:NNN` shorthand is not a matchable anchor. Gates: GATE-DOCS 6/6 clean (ANCHOR AUDIT 324 -> 129 refs, 0 errors). `src/` is untouched, so the test baseline stands at 86 passed / 0 failed / 2 ignored.
This commit is contained in:
parent
e3924a53c1
commit
166b77eb34
|
|
@ -72,6 +72,14 @@
|
|||
- **行号锚点必须指向对应内容**,不能只指向「文件里那一行」。文档写「某文件第 120 行是
|
||||
重试上限」,第 120 行就必须是重试上限;行号漂移而描述跟着跑,比行号写错更危险。
|
||||
- **校验器只能证明「该行号存在且非空」,不能证明该行内容符合描述。** 内容级核对必须人工读。
|
||||
- **文档引用代码一律用符号名,不写行号。** 唯一例外是 `docs/deployment.md`(运维 runbook,
|
||||
事故中按行跳转)。行号随任何无关改动重排,而符号名稳定;`docs/reference.md` 曾有 56 个
|
||||
`notify.rs` 锚点,其中约 40 个落在闭合括号、注释行或错误的符号上,而门禁报 0 错,
|
||||
2026-09-29 决定彻底不维护行号。
|
||||
- 需要定位到行时用 `grep` 符号名,不要用文档里的行号做判断依据。
|
||||
- `scripts/docs_check/audit_anchor_semantics.py` 是**独立脚本**,没接进 `run_all.sh`,
|
||||
恒 `exit 0`,只打印候选。它抓「锚点指向的行跟所声称的符号毫无关系」这类漂移,但抓不到
|
||||
一行以内的 ±1/±2 off-by-one——那仍然要靠人读。候选数下降说明文档在好转,不等于锚点全对。
|
||||
|
||||
### 2.6 分层与依赖方向
|
||||
|
||||
|
|
|
|||
|
|
@ -74,11 +74,11 @@
|
|||
`hoststack.yaml`、不用重新构建。判定为纯函数(不直接读全局 env)以便单测覆盖,测试在
|
||||
`src/main.rs:217-264`。
|
||||
|
||||
缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1625`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:115-117`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。
|
||||
缺一即不挂载:`debug_router()` 本身会构造出全部 7 条路由(`src/debug.rs:72-80`),但主入口**只在双因子成立时才合并它**(`src/notify.rs:1724`)。所以未开启时 `/debug/*` 路由根本不存在,请求走 axum 兜底返回普通 `404 not found`——**不是** 401,也不会泄露「此路径存在」。开启成功时打一条 WARN 日志标记该面已打开(`src/main.rs:115-117`,`SAF-LOG-PURITY`:只记开启状态,从不记录 token 值)。
|
||||
|
||||
**鉴权**
|
||||
|
||||
7 条路由全部要求 `Authorization: Bearer <DEBUG_TOKEN>`,统一走 `debug_authorized`(`src/debug.rs:45`),它委托生产同款 `worker_authorized`(`src/notify.rs:496`),因此令牌是**常数时间比较**(`src/notify.rs:1416`)。失败回 `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:496`),因此令牌是**常数时间比较**(`src/notify.rs:1512`)。失败回 `401 unauthorized`,且不设 `Retry-After`(`src/debug.rs:52-54`)。
|
||||
|
||||
**7 条路由与预期状态码**(注册于 `src/debug.rs:72-80`,路径为字面量,无常量抽取)
|
||||
|
||||
|
|
@ -99,7 +99,7 @@
|
|||
**三条约束,部署时务必确认**
|
||||
|
||||
- **绝不回显凭据值**:`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:1422`),判定条件是「白名单**非空**且 `chat_id` 不在其中」才回 `403 chat_not_allowed`(`src/debug.rs:233-236`)。因此已配置白名单时无法绕过业务侧发送限制;若白名单未配置(为空)则此判定不生效,`chat_id` 可任意指定——所以启用本面时应确认业务白名单已真正配置。`text` 缺省为固定联调文案,并按 1024 字符截断(`src/debug.rs:241-246`)。
|
||||
- **`/debug/notify` 受 chat 白名单约束,但空白名单不拦截**:它复用生产同一份白名单快照(`src/notify.rs:1518`),判定条件是「白名单**非空**且 `chat_id` 不在其中」才回 `403 chat_not_allowed`(`src/debug.rs:233-236`)。因此已配置白名单时无法绕过业务侧发送限制;若白名单未配置(为空)则此判定不生效,`chat_id` 可任意指定——所以启用本面时应确认业务白名单已真正配置。`text` 缺省为固定联调文案,并按 1024 字符截断(`src/debug.rs:241-246`)。
|
||||
- **不在 Worker 白名单内,只能直连 origin**:网关的 16 条安全路由(`cloudflare-worker/src/backends.js:9-26`)不含任何 `/debug/*`,Worker 对未白名单路径返回 `404 route not forwarded: /debug/...`(`cloudflare-worker/src/index.js:82-85`)。因此 `/debug/*` 只能通过直连后端 origin 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。
|
||||
|
||||
> **建议**:生产环境不开启。需要远程联调时在控制台设置 `DEBUG_ENABLED=1` 与一次性
|
||||
|
|
@ -181,13 +181,13 @@ curl -fsS -X POST "https://<你的平台URL>/api/push/register" \
|
|||
```
|
||||
|
||||
注册动作由**本服务自己**向 JMAP 发起,不是在 Stalwart 后台点。Stalwart 随后推送验证码,
|
||||
后端用 JMAP 自动回写——**验证码不由运维提供,也不会从 SPA 配置接受**(`src/notify.rs:1163`)。
|
||||
后端用 JMAP 自动回写——**验证码不由运维提供,也不会从 SPA 配置接受**(`src/notify.rs:1259`)。
|
||||
|
||||
- `callback_url` 必须是 HTTPS 且**不含用户名 / 密码**,否则 400 `invalid_request`
|
||||
(`src/notify.rs:1175-1177`)。
|
||||
(`src/notify.rs:1271-1273`)。
|
||||
- 同一个 `callback_url` 重复调用**幂等复用**已有订阅,不会重复注册。
|
||||
- 注册期间持 `lock:push-register:{sha256(callback_url)}` 360 s 单飞锁
|
||||
(`src/notify.rs:1178` / `src/notify.rs:1186`),撞锁返回 409 `conflict`。
|
||||
(`src/notify.rs:1283` / `src/notify.rs:1286`),撞锁返回 409 `conflict`。
|
||||
- 成功 → `200 {"push_subscription_id":"..."}`,状态置 `pending`,在等 Stalwart 回来验证。
|
||||
反向确认:`POST /api/push/disable` 在从未注册过该地址时回 `404 push_subscription_not_found`。
|
||||
- 订阅权限通常内置 `user` 角色已含;若返回 `forbidden`,到 Stalwart
|
||||
|
|
@ -381,12 +381,12 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达
|
|||
|
||||
### 6.2 Redis Streams / worker(MOD-STREAMS)
|
||||
- Push 回调只做:校验 → 去重 → 入队 → ACK;不阻塞
|
||||
- worker:**不是后台 task,也不是独立 worker 容器**。本服务没有常驻消费循环,两条 stream 的唯一消费入口是 `POST /worker`(处理函数 `src/notify.rs:381`,路由 `src/notify.rs:1608`)。全代码库仅两处 `tokio::spawn`——发送重试(`src/channel.rs:205`)与对账锁续租心跳(`src/notify.rs:298`)——都不消费队列;因此**排空必须由外部调度触发**(§6.3.1)。
|
||||
- 单次 `/worker` 调用语义:对 `stalwart:jmap` 与 `stalwart:telegram` 两条 stream 各取一批(上限 10,`src/notify.rs:397`;批量读取在 `src/state.rs:444`),逐条处理后确认消费(`src/state.rs:324`);处理失败转重试 / DLQ(`src/notify.rs:478`)。`Email/changes` 增量拉取与 `sinceState` 推进属于 `POST /reconcile`,不在 worker 内。
|
||||
- worker:**不是后台 task,也不是独立 worker 容器**。本服务没有常驻消费循环,两条 stream 的唯一消费入口是 `POST /worker`(处理函数 `src/notify.rs:381`,路由 `src/notify.rs:1705`)。全代码库仅两处 `tokio::spawn`——发送重试(`src/channel.rs:205`)与对账锁续租心跳(`src/notify.rs:298`)——都不消费队列;因此**排空必须由外部调度触发**(§6.3.1)。
|
||||
- 单次 `/worker` 调用语义:对 `stalwart:jmap` 与 `stalwart:telegram` 两条 stream 各取一批(上限 10,`src/notify.rs:398`;批量读取在 `src/state.rs:444`),逐条处理后确认消费(`src/state.rs:324`);处理失败转重试 / DLQ(`src/notify.rs:479`)。`Email/changes` 增量拉取与 `sinceState` 推进属于 `POST /reconcile`,不在 worker 内。
|
||||
- 消费组 at-least-once:未 ACK 消息自动重投;处理幂等(MOD-DEDUP 二次兜底)
|
||||
|
||||
### 6.3 外部 HTTPS Cron 对账(FLOW-RECONCILE)
|
||||
- **"外部 Cron"是什么**(已确认):bot **不自建定时器、不持有调度**(`C-NO-LONG-CONN`,无 `tokio-cron`)。由**外部调度器**周期性发起 `POST https://<平台URL>/reconcile`(带 `RECONCILE_TOKEN`)。端点执行鉴权、全局开关检查与 Redis 单飞锁,调用 JMAP `Email/changes` 分页并将事件幂等入 Streams;全部入队成功后才持久化 `state:jmap:since`,依赖失败返回 `503` 供调度器重试。**对账只入队、不消费**——它全程不调用 `read_batch`(全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:410`,属于 worker);排空由另一个端点 `POST /worker` 承担,两个都要排(§6.3.1)。
|
||||
- **"外部 Cron"是什么**(已确认):bot **不自建定时器、不持有调度**(`C-NO-LONG-CONN`,无 `tokio-cron`)。由**外部调度器**周期性发起 `POST https://<平台URL>/reconcile`(带 `RECONCILE_TOKEN`)。端点执行鉴权、全局开关检查与 Redis 单飞锁,调用 JMAP `Email/changes` 分页并将事件幂等入 Streams;全部入队成功后才持久化 `state:jmap:since`,依赖失败返回 `503` 供调度器重试。**对账只入队、不消费**——它全程不调用 `read_batch`(全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:411`,属于 worker);排空由另一个端点 `POST /worker` 承担,两个都要排(§6.3.1)。
|
||||
- **建议间隔 5–10 分钟**(`NFR-RECONCILE-INTERVAL`):兼顾"少延迟"与"低开销";这是可用性的兜底频率。
|
||||
- 可用调度器(任选其一,均为外部):系统 crontab+curl / k8s CronJob / GitHub Actions scheduled / 第三方 cron 服务。
|
||||
- 对账逻辑:用 `sinceState` 调 `Email/changes` 拉增量 → 与已处理 email_id 求差 → 补发通知 → 推进 `sinceState`。
|
||||
|
|
@ -394,10 +394,10 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达
|
|||
|
||||
#### 6.3.1 用户侧调度示例(copy-paste 可用)
|
||||
|
||||
端点有两个:`POST /reconcile`(路由 `src/notify.rs:1607`)与 `POST /worker`(路由
|
||||
`src/notify.rs:1608`),同容器暴露、无端口或协议差异。**两个都必须被外部调度,顺序是先对账、
|
||||
端点有两个:`POST /reconcile`(路由 `src/notify.rs:1704`)与 `POST /worker`(路由
|
||||
`src/notify.rs:1705`),同容器暴露、无端口或协议差异。**两个都必须被外部调度,顺序是先对账、
|
||||
再排空。** 两者职责不重叠:`/reconcile` 只做 `Email/changes` 增量入队并推进游标,全程不调用
|
||||
`read_batch`;`/worker` 只做消费,全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:410`。
|
||||
`read_batch`;`/worker` 只做消费,全代码库唯一的 `read_batch` 调用点在 `src/notify.rs:411`。
|
||||
**只排对账等于邮件持续进队列、通知永远发不出去**——这是照抄调度配置时最容易踩的坑。
|
||||
|
||||
最小可运行版本(推荐起点):
|
||||
|
|
@ -409,7 +409,7 @@ GET /ready 公开就绪探针;检查配置完整性 + Redis 可达
|
|||
| 鉴权 | Bearer + `reconcile_token` | Bearer + `worker_token` |
|
||||
| 可走 Worker 吗 | 可以(在 `SAFE_ROUTES` 内) | 不行,必须直连后端 origin |
|
||||
| 幂等 | `SET NX lock:reconcile 300s` 单飞锁,撞锁回 409 | 无单飞锁,靠逐条 `delivery:*` 去重声明防重投 |
|
||||
| 单次上限 | 100 条变更(`src/notify.rs:293`) | 10 条消息(`src/notify.rs:397`) |
|
||||
| 单次上限 | 100 条变更(`src/notify.rs:293`) | 10 条消息(`src/notify.rs:398`) |
|
||||
| 常态返回 | 204 | 204(**不能当成功信号**) |
|
||||
|
||||
两个 token 都是 Redis 业务配置字段(§5),不再从环境变量读取;外部 cron 从自己的 secret
|
||||
|
|
@ -452,7 +452,7 @@ curl -sS -o /dev/null -w '%{http_code}\n' \
|
|||
- 两条之间用 `;` 而不是 `&&`:对账撞锁拿 409、或临时 503,都不该阻断排空——上一次对账
|
||||
入队的消息同样需要被消费。
|
||||
- 排空那条必须是**后端 origin 的直连地址**,不是 Worker 域名。
|
||||
- 请求体可带 `{"batch":N}`;`N` 会被压回上限 10,不会因传大值而报错(`src/notify.rs:397`)。
|
||||
- 请求体可带 `{"batch":N}`;`N` 会被压回上限 10,不会因传大值而报错(`src/notify.rs:398`)。
|
||||
传非法 JSON 才是 400 `invalid_request`(`src/notify.rs:402`)。
|
||||
|
||||
Kubernetes CronJob 示例:
|
||||
|
|
@ -501,19 +501,19 @@ spec:
|
|||
| 状态码 | 含义 | 处理 |
|
||||
| --- | --- | --- |
|
||||
| `/reconcile` 204 | 对账完成(含 since 游标前进 0 条) | 正常,不需要动作 |
|
||||
| `/worker` 204 | 本次调用正常结束 | **不能当成功信号**:无消息可读、全部撞去重都回 204,`src/notify.rs:491` 无条件返回 |
|
||||
| `/worker` 204 | 本次调用正常结束 | **不能当成功信号**:无消息可读、全部撞去重都回 204,`src/notify.rs:492` 无条件返回 |
|
||||
| `/worker` 400 | 请求体不是合法 JSON(`src/notify.rs:402`) | 修 curl;空请求体合法,等价于取默认 10 条 |
|
||||
| `/reconcile` 401 | `Authorization` 头缺失或 `reconcile_token` 不匹配(`src/notify.rs:284`) | 检查 cron 的 token 值;重启不解决 |
|
||||
| `/worker` 401 | Bearer 值与 `worker_token` 不匹配(`src/notify.rs:393`) | 同上;`/worker` 无 admin-session 兜底 |
|
||||
| `/reconcile` 409 | 单飞锁 `lock:reconcile` 被占(`src/notify.rs:289` 取锁,`src/notify.rs:345` 返回) | 直接跳过本次 tick,不要重试 |
|
||||
| `/reconcile` 409 | 单飞锁 `lock:reconcile` 被占(`src/notify.rs:290` 取锁,`src/notify.rs:346` 返回) | 直接跳过本次 tick,不要重试 |
|
||||
| 503 | 业务配置此刻不可用,或 Redis / JMAP 请求失败 | 可重试;响应带 `Retry-After: 30`(`src/notify.rs:374`) |
|
||||
|
||||
重试建议:
|
||||
|
||||
- **不要**在客户端实现重试循环。**调度器每 5 分钟触发一次已经足够**,因为:
|
||||
- 单次调用**幂等**:`/reconcile` 有 `SET NX lock:reconcile 300s` 单飞锁
|
||||
(`src/notify.rs:289`,长对账中每 90s 续租 `src/notify.rs:302`,成功后释放
|
||||
`src/notify.rs:338`);`/worker` 靠逐条去重声明防重投,重复触发无害。
|
||||
(`src/notify.rs:290`,长对账中每 90s 续租 `src/notify.rs:303`,成功后释放
|
||||
`src/notify.rs:339`);`/worker` 靠逐条去重声明防重投,重复触发无害。
|
||||
- 客户端拿到 `409 conflict` 时**不要重试**,直接跳过本次 tick——锁在 TTL 或任务完成后
|
||||
自然释放,下一次调度会重试。
|
||||
- 超时设置建议 ≥ 单飞锁 TTL 的 1/3(即 ≥ 100s)以吸收长对账,但客户端超时不应超过
|
||||
|
|
@ -521,7 +521,7 @@ spec:
|
|||
- 如果你的调度器自带重试能力,把它限制为:**只对 5xx 与超时重试**,指数退避
|
||||
2s / 4s / 8s,上限 60s,总次数 ≤ 3;`204` 与 `409` **永远不重试**。调度器没有自带能力
|
||||
就不要额外实现——下一个 tick 自然会重试。
|
||||
- **积压会自然堆到下一次 tick。** `/worker` 单次最多消费 10 条(`src/notify.rs:397`);
|
||||
- **积压会自然堆到下一次 tick。** `/worker` 单次最多消费 10 条(`src/notify.rs:398`);
|
||||
积压超过 10 条时需要连续多个 tick 才能清完。想更快就调高 cron 频率,**不要**靠加长
|
||||
curl 超时——那只会抬高挂死概率,不会让更多消息被消费。
|
||||
- `204` 与 `409` 都是正常结果,不要把它们当失败重试——尤其别对 `409` 做紧密循环重试。
|
||||
|
|
@ -530,7 +530,7 @@ spec:
|
|||
- **`/reconcile` 不会返回 400**:它没有请求体解析,传了 body 会被静默忽略,所以不要以为要传 `since`。
|
||||
- 走 Worker 转发**无需特殊设置**:网关已为 `POST /reconcile` 单独覆盖超时与尝试次数——超时取
|
||||
`LB_RECONCILE_TIMEOUT_MS`(默认 `320000` ms,大于单飞锁租期 300 s + 心跳余量;锁续租逻辑见
|
||||
`src/notify.rs:302`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回
|
||||
`src/notify.rs:303`),且 `maxAttempts=1` **绝不故障转移**(故障转移只会让第二实例立刻返回
|
||||
`409`)。其余快路径仍用全局 `LB_REQUEST_TIMEOUT_MS`(默认 `10000` ms)与 `LB_MAX_ATTEMPTS`
|
||||
(默认 `2`),不受影响。`POST /worker` 走不了 Worker,不受这些参数影响。
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
上一轮真机联调已经关掉了两条阻塞:
|
||||
|
||||
- **Telegram 入站方向**:`setWebhook` 之后群里发 `/help`,`POST /worker` 排空即收到自动回复。真实 Telegram 流量已驱动过 `/webhook/tg`。
|
||||
- **Stalwart `PushSubscription`**:`stalwart:jmap` 连续数天有真实、互不重复的推送事件。该 stream 全代码库只有两个写入点——`src/notify.rs:1140`(JMAP 回调入队)与 `src/worker.rs:394`(对账增量入队)——所以有事件出现就只能是订阅活着且在投递。同一次观测也顺带验证了去重键在跨日规模下没有重复投递。
|
||||
- **Stalwart `PushSubscription`**:`stalwart:jmap` 连续数天有真实、互不重复的推送事件。该 stream 全代码库只有两个写入点——`src/notify.rs:1236`(JMAP 回调入队)与 `src/worker.rs:394`(对账增量入队)——所以有事件出现就只能是订阅活着且在投递。同一次观测也顺带验证了去重键在跨日规模下没有重复投递。
|
||||
|
||||
两条的操作步骤已移到面向使用者的 `docs/deployment.md` §4.1,本文件不再跟踪它们。
|
||||
|
||||
|
|
@ -52,14 +52,14 @@
|
|||
| --- | --- | --- | --- |
|
||||
| `lock:reconcile` | 对账锁 | 300s | `src/notify.rs:290` |
|
||||
| `lock:reconcile` | 对账锁心跳续租 | 90s | `src/notify.rs:303` |
|
||||
| `lock:push-register:{sha256(callback_url)}` | 注册单飞锁 | 360s | `src/notify.rs:1188` |
|
||||
| `ratelimit:push-verify:{subscription_id}` | 验证限流 | 30s | `src/notify.rs:1098` |
|
||||
| `lock:push-register:{sha256(callback_url)}` | 注册单飞锁 | 360s | `src/notify.rs:1283` |
|
||||
| `ratelimit:push-verify:{subscription_id}` | 验证限流 | 30s | `src/notify.rs:1192` |
|
||||
| `dedup:tg:{update_id}` | Telegram 更新去重 | 86_400s | `src/notify.rs:250` |
|
||||
| `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重(回调入队) | 86_400s | `src/notify.rs:1137` |
|
||||
| `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重(回调入队) | 86_400s | `src/notify.rs:1231` |
|
||||
| `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重(对账增量入队) | 86_400s | `src/worker.rs:394` |
|
||||
| `delivery:inflight:{stream}:{message.id}` | 投递在途守卫 | 60s | `src/notify.rs:447` |
|
||||
| `delivery:committed:{stream}:{message.id}` | 投递幂等 | 604_800s | `src/notify.rs:459` |
|
||||
| `push:subscription:{id}:status` | push 订阅 `disabled` 标记 | 86_400s | `src/notify.rs:1313` |
|
||||
| `push:subscription:{id}:status` | push 订阅 `disabled` 标记 | 86_400s | `src/notify.rs:1408` |
|
||||
|
||||
**B. 内嵌 Lua 脚本 — 1 个写入点 / 1 个值**
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@
|
|||
> **This file is the single source of truth for verifiable facts.**
|
||||
> When any other document disagrees with this one, this one wins.
|
||||
>
|
||||
> **Verified against commit `b2dbe7c`.** Line numbers in this file were read from that
|
||||
> commit with the working tree clean.
|
||||
> **This file carries no source line numbers, by design.** They are not part of the
|
||||
> verification: this file documents *which symbol* does *what*, and line numbers are
|
||||
> renumbered by every unrelated edit to the same file. `docs/deployment.md` keeps its
|
||||
> line numbers, because that file is an operator runbook read mid-incident.
|
||||
>
|
||||
> **Maintenance responsibility.** Any change to the public API of `src/config.rs`,
|
||||
> `src/state.rs`, `src/worker.rs`, or `src/notify.rs`, or to **any Redis key name or TTL**,
|
||||
|
|
@ -14,9 +16,13 @@
|
|||
Conventions used throughout:
|
||||
|
||||
- **TTL** is in seconds. "No EX" means the key never expires and lives until explicitly deleted.
|
||||
- **Writer / Reader** name the code symbol that performs the operation; the line number is
|
||||
an annotation for fast navigation, not an identity.
|
||||
- Only this file carries line numbers. All other documents reference symbols by name.
|
||||
- **Writer / Reader** name the code symbol that performs the operation. Symbol names are
|
||||
the identity; this file deliberately records no line numbers (see the note above), so
|
||||
it stays true across renumbering.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -26,35 +32,34 @@ Conventions used throughout:
|
|||
|
||||
| Key | TTL | Writer | Reader |
|
||||
|---|---|---|---|
|
||||
| `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) |
|
||||
| `config:business` | No EX | `set_business_config`; first-time write via `SET NX` in `initialize_business_config` | `get_business_config` |
|
||||
| `config:business:revision` | No EX | atomic `INCR` on every config write; `SET 1 NX` on first-time init | `business_config_revision` |
|
||||
| `config:outbound` | No EX | `set_outbound_config` | `get_outbound_config` |
|
||||
| `config:enabled` | No EX | `set_enabled` | `is_enabled` |
|
||||
| `config:admin_session` | EX 900 | `put_admin_session` | `admin_session_valid`; cleared by `revoke_admin_session` |
|
||||
|
||||
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:718), which only
|
||||
rebuilds the worker when the remote revision exceeds the cached local value
|
||||
(notify.rs:723-725) — that guard is what keeps a stale or malformed snapshot from turning
|
||||
into a rebuild loop.
|
||||
request-bearing entry point calls `refresh_business_config`, which only
|
||||
rebuilds the worker when the remote revision exceeds the cached local value — 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:788).
|
||||
on the handler side.
|
||||
- `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: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) |
|
||||
| `consent:ai:{chat_id}` | EX 3600 / 86_400 / 604_800 / 31_536_000 | `set_ai_consent` | `ai_consent_until`; cleared by `clear_ai_consent` |
|
||||
|
||||
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:509-514).
|
||||
carries the same duration, so the key removes itself.
|
||||
|
||||
Trigger words are exact Chinese literals with **no English aliases** — the in-app help text
|
||||
enforces this itself (`worker.rs:614`):
|
||||
enforces this itself (in-app help text in `worker.rs`):
|
||||
|
||||
| Input | TTL | Label |
|
||||
|---|---|---|
|
||||
|
|
@ -66,12 +71,12 @@ enforces this itself (`worker.rs:614`):
|
|||
| `/ai off`, `关闭 ai`, `撤销授权`, `停止摘要` | — | revoke |
|
||||
|
||||
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`).
|
||||
substring matches anywhere in the message (both inside `parse_intent`, `worker.rs`).
|
||||
|
||||
`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:543, `Intent` at worker.rs:525) routes `/search <关键词>`
|
||||
The same `parse_intent` (`worker.rs`, variants in `Intent`) routes `/search <关键词>`
|
||||
and the Chinese prefixes `搜索`, `查找`, `检索` (prefix-matched only, never a substring) to
|
||||
`Intent::Search`. Search grants no consent and writes no Redis key.
|
||||
|
||||
|
|
@ -79,11 +84,11 @@ and the Chinese prefixes `搜索`, `查找`, `检索` (prefix-matched only, neve
|
|||
|
||||
| Key | TTL | Writer | Reader |
|
||||
|---|---|---|---|
|
||||
| `stalwart:jmap`, `stalwart:telegram` | Stream, no EX | `State::enqueue` (state.rs:283, `XADD` at state.rs:285); call sites notify.rs:253 (TG webhook) and notify.rs:1140 (JMAP push); reconcile path via `claim_dedup_and_enqueue` (worker.rs:394) | `read_batch` over consumer group `stalwart-workers`, consumer `http-worker` (notify.rs:409-410) |
|
||||
| `delivery:inflight:{stream}:{id}` | EX 60 | key built at notify.rs:445, `claim_dedup` at notify.rs:446 | `release_dedup` on completion (notify.rs:459, 466, 475) |
|
||||
| `delivery:committed:{stream}:{id}` | EX 604_800 | key built at notify.rs:424, `claim_dedup` at notify.rs:458 | `dedup_exists` before send (notify.rs:425) |
|
||||
| `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** |
|
||||
| `stalwart:jmap`, `stalwart:telegram` | Stream, no EX | `State::enqueue` (a `XADD`), called from `telegram_webhook` and `jmap_push`; reconcile path via `claim_dedup_and_enqueue` (`worker.rs`) | `read_batch` over consumer group `stalwart-workers`, consumer `http-worker` |
|
||||
| `delivery:inflight:{stream}:{id}` | EX 60 | key built and claimed with `claim_dedup` in the delivery path | `release_dedup` on completion |
|
||||
| `delivery:committed:{stream}:{id}` | EX 604_800 | key built and claimed with `claim_dedup` in the delivery path | `dedup_exists` before send |
|
||||
| `retry:{stream}:{id}` | EX 86_400 | `retry_or_dlq`'s Lua script (`INCR` then `EXPIRE` on the same key) | reclaim path |
|
||||
| `stalwart:jmap:dlq`, `stalwart:telegram:dlq` | Stream, no EX | the same Lua script's `XADD`; the name is passed in from `retry_or_dlq`'s call site in the worker (with `max_attempts` 3) | **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.
|
||||
|
|
@ -97,17 +102,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:485-489). No code path reads the DLQ
|
||||
append and source acknowledgement are atomic, all inside that one Lua script. 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:248, `claim_dedup` at notify.rs:249, `release_dedup` at :258 | — |
|
||||
| `dedup:jmap:{account}:{email}` | EX 86_400 | key at notify.rs:1135, `claim_dedup` at notify.rs:1136, `release_dedup` at notify.rs:1145; same key rebuilt at worker.rs:390 and claimed with `claim_dedup_and_enqueue` at worker.rs:394 | — |
|
||||
| `ratelimit:push-verify:{subscription}` | EX 30 | key at notify.rs:1096, `claim_dedup` at notify.rs:1097 (`push_verify_rate_limited` at :1107) | — |
|
||||
| `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) |
|
||||
| `dedup:tg:{update_id}` | EX 86_400 | built and claimed with `claim_dedup` in `telegram_webhook`, released with `release_dedup` on a failed enqueue | — |
|
||||
| `dedup:jmap:{account}:{email}` | EX 86_400 | built and claimed with `claim_dedup` in `jmap_push`; the same key is rebuilt on the reconcile path and claimed with `claim_dedup_and_enqueue` (`worker.rs`) | — |
|
||||
| `ratelimit:push-verify:{subscription}` | EX 30 | built and claimed with `claim_dedup` in `register_push`; a `false` claim is the `429 push_verify_rate_limited` branch | — |
|
||||
| `state:jmap:since` | **No EX** | `set_reconcile_state` (a `SET` with no `EX`) | `get_reconcile_state` |
|
||||
|
||||
`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
|
||||
|
|
@ -117,12 +122,12 @@ re-baseline on the next restart.
|
|||
|
||||
| Key | TTL | Writer | Reader |
|
||||
|---|---|---|---|
|
||||
| `lock:reconcile` | 300 | `acquire_lock` (notify.rs:289) | `renew_lock` heartbeat 90 (notify.rs:302) |
|
||||
| `lock:push-register:{sha256(callback_url)}` | 360 | `acquire_lock` (notify.rs:907) | `release_lock` (notify.rs:923, 937, 950, 967, 984, 994) |
|
||||
| `lock:reconcile` | 300 | `acquire_lock` in `reconcile` | `renew_lock` from the heartbeat task (90 s) |
|
||||
| `lock:push-register:{sha256(callback_url)}` | 360 | `acquire_lock` in `register_push` | `release_lock` on every exit path of `register_push` |
|
||||
|
||||
**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:905-906:
|
||||
duplicate. Source comment:
|
||||
|
||||
> The lock must outlive the configured 300s maximum JMAP request timeout; this prevents a
|
||||
> slow create from admitting a duplicate.
|
||||
|
|
@ -131,11 +136,11 @@ duplicate. Source comment, notify.rs:905-906:
|
|||
|
||||
| Key | TTL | Writer | Reader |
|
||||
|---|---|---|---|
|
||||
| `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 |
|
||||
| `push:subscription:{id}` | EX 300 (caller-supplied, min 1) | `remember_push_subscription`, called from `register_push` | `push_subscription_verified` |
|
||||
| `push:subscription:id` | No EX | `remember_push_subscription_id` | current-subscription lookup |
|
||||
| `push:subscription:{id}:status` | 900 / 300 / 86_400 (min 1 enforced) | `set_push_subscription_status`; `pending` on verification request, `verified` on success, `disabled` on disable | **none** |
|
||||
| `push:registration:{sha256(callback_url)}` | EX 604_800 | `remember_push_subscription_for_callback`, called from `register_push` | `get_push_subscription_for_callback`; removed by `remove_push_subscription_for_callback` |
|
||||
| `push:orphan:{subscription_id}` | EX 604_800 | `record_push_orphan` | orphan sweep |
|
||||
|
||||
**Two distinct keys that are frequently confused. Read this before editing either.**
|
||||
|
||||
|
|
@ -147,7 +152,7 @@ duplicate. Source comment, notify.rs:905-906:
|
|||
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:211:
|
||||
The source carries an explicit note on `state.rs`:
|
||||
|
||||
> `push:subscription:{id}:status` is currently write-only
|
||||
|
||||
|
|
@ -185,13 +190,12 @@ field. `request_id` is always present.
|
|||
`Retry-After` has two emission sites, both in `notify.rs`, and the value is always the literal
|
||||
string `"30"`:
|
||||
|
||||
- `error_response` (`notify.rs:371-379`, inserted at `:375`) sets it **only when its `retry`
|
||||
flag is true**. The readiness failure path (`notify.rs:210`) passes `retry = true`, so `/ready`
|
||||
is covered by that rule rather than by a special case.
|
||||
- `error_response_with_id` (`notify.rs:1464-1471`, inserted at `:1467`) sets it
|
||||
**unconditionally**. It is used only for the push register/disable failures
|
||||
`push_state_unavailable` and `push_destroy_failed` (`notify.rs:1233`, `:1250`, `:1267`,
|
||||
`:1304`), which are always `503`.
|
||||
- `error_response` sets it **only when its `retry` flag is true**. The readiness failure
|
||||
path passes `retry = true`, so `/ready` is covered by that rule rather than by a
|
||||
special case.
|
||||
- `error_response_with_id` sets it **unconditionally**. It is used only for the push
|
||||
register/disable failures `push_state_unavailable` and `push_destroy_failed`, which are
|
||||
always `503`.
|
||||
|
||||
`GET /api/status` is the exception that reports setup state with a non-envelope body.
|
||||
|
||||
|
|
@ -199,13 +203,12 @@ string `"30"`:
|
|||
|
||||
## 3. Backend routes
|
||||
|
||||
All HTTP API routes are registered in `router_with_worker_state_runtime_bootstrap`
|
||||
(`notify.rs:1575`), with the routes wired at `notify.rs:1604-1622`. 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 router via `.merge(web::router())`: the setup-mode router at `src/main.rs:76`,
|
||||
the production router at `src/main.rs:195-205`. The setup-mode router at `src/main.rs:76` mounts
|
||||
only three of this table — `/healthz`, `/ready` and `/api/status` — and no business or admin route;
|
||||
§5.1 explains why that is deliberate.
|
||||
All HTTP API routes are registered in `router_with_worker_state_runtime_bootstrap`.
|
||||
The three static SPA routes in the last table row are the exception: they live in
|
||||
`web::router()` (`src/web.rs`) and are merged into the router via `.merge(web::router())` —
|
||||
in the setup-mode router and again in the production router. The setup-mode router mounts
|
||||
only three of this table — `/healthz`, `/ready` and `/api/status` — and no business or
|
||||
admin route; §5.1 explains why that is deliberate.
|
||||
|
||||
| Method | Path | Handler |
|
||||
|---|---|---|
|
||||
|
|
@ -227,13 +230,13 @@ only three of this table — `/healthz`, `/ready` and `/api/status` — and no b
|
|||
| GET | `/api/status` | `setup_status` |
|
||||
| GET | `/`, `/assets/config.js`, `/assets/styles.css` | `index` / `script` / `styles` |
|
||||
|
||||
The remote-debug surface (`src/debug.rs:72-80`) is merged **only when the debug surface is
|
||||
requested — `--debug` on the command line or a truthy `DEBUG_ENABLED` env var — and `DEBUG_TOKEN`
|
||||
is non-empty** (`SAF-DEBUG-GATE`, gate logic `src/main.rs:15-25`, wired at `src/main.rs:102-114`,
|
||||
`src/notify.rs:1625`); 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:1416`).
|
||||
The remote-debug surface (`debug_router`, `src/debug.rs`) is merged **only when the
|
||||
debug surface is requested — `--debug` on the command line or a truthy `DEBUG_ENABLED`
|
||||
env var — and `DEBUG_TOKEN` is non-empty** (`SAF-DEBUG-GATE`; the gate is read in
|
||||
`src/main.rs` before either router is built). 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`, which delegates to
|
||||
the production `worker_authorized` so the comparison is constant time.
|
||||
|
||||
| Method | Path | Handler |
|
||||
|---|---|---|
|
||||
|
|
@ -254,13 +257,13 @@ 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:122`) and run **in parallel** (`tokio::join!`, `notify.rs:196`), so the worst case
|
||||
and run **in parallel** (`tokio::join!`), 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` (**always 200**, `notify.rs:215-224`) returns
|
||||
- `GET /api/status` (**always 200**) returns
|
||||
`{"ready": <bool>, "mode": "configured"|"configuration-setup", "missing": [...], "version": "<build-fingerprint>"}`.
|
||||
`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
|
||||
|
|
@ -268,27 +271,27 @@ Response conventions:
|
|||
(`<git-sha-or-nogit>+<UTC build time>`) and is how the SPA footer proves a deploy landed.
|
||||
- `/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
|
||||
`Retry-After: 30`. The three probe endpoints instead report failure
|
||||
**inside a 200 body** (`{"ok":false,"detail":...}`), so an unreachable upstream never looks
|
||||
like an outage. `POST /debug/notify` also returns `403 chat_not_allowed` when the chat is
|
||||
outside a *non-empty* allowlist (`src/debug.rs:233-236`) and `502 telegram_send_failed` on
|
||||
a send failure (`src/debug.rs:64-66`).
|
||||
outside a *non-empty* allowlist, and `502 telegram_send_failed` on
|
||||
a send failure.
|
||||
- Static assets are served with a strict CSP; see §7.
|
||||
|
||||
---
|
||||
|
||||
## 4. Gateway vs backend route matrix
|
||||
|
||||
> **Independently verified.** Source: `cloudflare-worker/src/backends.js` `SAFE_ROUTES`
|
||||
> (backends.js:9-26), 16 entries, alongside `ROUTE_METHODS` (index.js:46-63)
|
||||
> **Independently verified.** Source: `cloudflare-worker/src/backends.js` `SAFE_ROUTES`,
|
||||
> 16 entries, alongside `ROUTE_METHODS` (`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`); `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` (backends.js:9-26) returns **404** (index.js:82-85), a registered
|
||||
path with the wrong method returns **405** (index.js:86-89), and a missing or unparseable
|
||||
backend pool returns **503** rather than passing the request through (index.js:91-99).
|
||||
all: a path missing from `SAFE_ROUTES` returns **404**, a registered
|
||||
path with the wrong method returns **405**, and a missing or unparseable
|
||||
backend pool returns **503** rather than passing the request through.
|
||||
|
||||
**Forwarded by the worker (16):**
|
||||
|
||||
|
|
@ -304,13 +307,13 @@ backend pool returns **503** rather than passing the request through (index.js:9
|
|||
| Path | Why it is absent from the gateway |
|
||||
|---|---|
|
||||
| `POST /api/bootstrap` | One-shot trust bootstrap; kept off the public path |
|
||||
| `POST /worker` | Queue drain endpoint — driven by the external scheduler, not a human (Bearer-auth'd, `notify.rs:381`; the cron is spelled out in deployment.md §6.3.1); not part of the public gateway path |
|
||||
| `POST /worker` | Queue drain endpoint — driven by the external scheduler, not a human (Bearer-auth'd; the cron is spelled out in deployment.md §6.3.1); 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 |
|
||||
|
||||
`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:676`, the toggle writes it at `:1048`); both calls are
|
||||
reads it, the toggle writes it); both calls are
|
||||
admin-session Bearer-auth'd, so the exposure is identical to the already-forwarded
|
||||
`/api/admin/session` pair.
|
||||
|
||||
|
|
@ -320,10 +323,10 @@ needed in front of the backend instances. The SPA's first-boot flow still cannot
|
|||
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:875`,
|
||||
validated as a URL at :1172), 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 `state.rs:768`), so it does not matter which backend instance the worker picks. Push
|
||||
proxy: the callback URL is supplied by the client in the request body and
|
||||
validated as a URL before anything is written, 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`),
|
||||
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.
|
||||
|
||||
|
|
@ -337,11 +340,11 @@ Three distinct layers. They are not interchangeable.
|
|||
|
||||
| Variable | Required | Default | Source |
|
||||
|---|---|---|---|
|
||||
| `REDIS_URL` | yes | — | read at main.rs:40 |
|
||||
| `CONFIG_ENCRYPTION_KEY` | yes | — | `encryption_key_from_env` at main.rs:64 |
|
||||
| `PORT` | no | `8080` | `unwrap_or(8080_u16)` at main.rs:45-48. The listener binds it directly (main.rs:49-52); `Config` carries no port field |
|
||||
| `DEBUG_ENABLED` | no | — | `std::env::var` at main.rs:104; truthy values are `1`/`true`/`TRUE`/`True`/`yes`/`YES` (exact match); enables the debug surface on its own, paired with `--debug` as an alternative (`SAF-DEBUG-GATE`) |
|
||||
| `DEBUG_TOKEN` | no | — | `std::env::var` at main.rs:107, effective only once the debug surface is requested via `DEBUG_ENABLED` or `--debug` (`SAF-DEBUG-GATE` / `SAF-DEBUG-ORIGIN-ONLY`) |
|
||||
| `REDIS_URL` | yes | — | read at startup in `src/main.rs` |
|
||||
| `CONFIG_ENCRYPTION_KEY` | yes | — | `encryption_key_from_env` |
|
||||
| `PORT` | no | `8080` | `unwrap_or(8080_u16)`. The listener binds it directly; `Config` carries no port field |
|
||||
| `DEBUG_ENABLED` | no | — | truthy values are `1`/`true`/`TRUE`/`True`/`yes`/`YES` (exact match); enables the debug surface on its own, paired with `--debug` as an alternative (`SAF-DEBUG-GATE`) |
|
||||
| `DEBUG_TOKEN` | no | — | effective only once the debug surface is requested via `DEBUG_ENABLED` or `--debug` (`SAF-DEBUG-GATE` / `SAF-DEBUG-ORIGIN-ONLY`) |
|
||||
|
||||
`RUN_MODE` **no longer exists**: the identifier was removed together with the legacy
|
||||
`Config::from_env()` environment parser (registration in `docs/retired.md`). Both webhook
|
||||
|
|
@ -349,10 +352,10 @@ and reconcile traffic share one router and reconcile is a standalone `POST /reco
|
|||
endpoint, so the variable never changed any runtime behaviour — there is now nothing to set.
|
||||
|
||||
**Missing a required variable does not crash the process.** It logs a warning and serves
|
||||
`router_configuration_setup` (`notify.rs:1500-1520`), which mounts only `/api/status`,
|
||||
`router_configuration_setup`, which mounts only `/api/status`,
|
||||
`/ready` and `/healthz` on top of the static SPA. The business and admin routes from §3 are not
|
||||
registered at all: `admin_token` is empty, so `constant_time_eq` (`notify.rs:1416`) would reject
|
||||
every candidate forever, and `MemoryState` (`state.rs:875`) could not persist a bootstrap
|
||||
registered at all: `admin_token` is empty, so `constant_time_eq` would reject
|
||||
every candidate forever, and `MemoryState` could not persist a bootstrap
|
||||
write anyway. Posting to `/api/bootstrap` in this mode yields `404` (route absent), not a `401`
|
||||
that reads as "retry with a better credential". The container stays up and answers the status
|
||||
surface while doing no business work. This is a deliberate fail-closed-to-setup posture, and it
|
||||
|
|
@ -432,28 +435,28 @@ env surface and are listed only so a stale deployment script can be recognised a
|
|||
`TG_WEBHOOK_SECRET` · `WORKER_TOKEN`
|
||||
|
||||
The `JMAP_*` family is exactly three names. In production the account id arrives as the
|
||||
`accountId` request-body field (notify.rs:1122) and is held on the client as
|
||||
`account_id` (src/domain/jmap/client.rs:14); the only remaining environment reads of these
|
||||
four names are inside the `#[ignore]` real-server smoke test at
|
||||
src/domain/jmap/client.rs:517-526, which is not part of the production configuration surface.
|
||||
`accountId` request-body field on `jmap_push` and is held on the client as
|
||||
`account_id`; the only remaining environment reads of these
|
||||
four names are inside the `#[ignore]` real-server smoke test in
|
||||
`src/domain/jmap/client.rs`, which is not part of the production configuration surface.
|
||||
|
||||
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:63) and held as `admin_token` (main.rs:92), it is checked in constant time by
|
||||
`worker_authorized` (notify.rs:496; 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
|
||||
and held as `admin_token`, it is checked in constant time by
|
||||
`worker_authorized` at the top of both
|
||||
`POST /api/bootstrap` and `POST /api/admin/session`. 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:779-788), never the credential; only its digest is retained in Redis under
|
||||
(generated per call), 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.
|
||||
|
||||
### 5.4 Notification rendering
|
||||
|
||||
`send_notification` (`channel.rs`) is the only function that renders a notification into
|
||||
`send_notification` (`src/channel.rs`) is the only function that renders a notification into
|
||||
Telegram text, and it renders exactly three metadata lines — never the body, and never an
|
||||
LLM summary:
|
||||
|
||||
|
|
@ -494,10 +497,10 @@ in the SPA and applied hot, with no restart.
|
|||
|
||||
| Parameter | Default | Range / cap | Source |
|
||||
|---|---|---|---|
|
||||
| `jmap_timeout_ms` | 15_000 | 100..=300_000 | default state.rs:56; validated notify.rs:541 |
|
||||
| `telegram_timeout_ms` | 10_000 | 100..=300_000 | default state.rs:57; validated notify.rs:542 |
|
||||
| `llm_timeout_ms` | 30_000 | 100..=300_000 | default state.rs:58; validated notify.rs:543 |
|
||||
| `max_retries` | 3 | hard cap 5 | default state.rs:59; rejected at notify.rs:544; re-clamped at channel.rs:138 |
|
||||
| `jmap_timeout_ms` | 15_000 | 100..=300_000 | default in `state.rs`; validated in the write path |
|
||||
| `telegram_timeout_ms` | 10_000 | 100..=300_000 | default in `state.rs`; validated in the write path |
|
||||
| `llm_timeout_ms` | 30_000 | 100..=300_000 | default in `state.rs`; validated in the write path |
|
||||
| `max_retries` | 3 | hard cap 5 | default in `state.rs`; rejected in the write path; re-clamped in `channel.rs` |
|
||||
|
||||
There is **no** `LLM_MAX_RETRIES` environment variable; retry count lives in
|
||||
`config:outbound` and is bounded at 5 regardless of what is written.
|
||||
|
|
@ -506,24 +509,24 @@ There is **no** `LLM_MAX_RETRIES` environment variable; retry count lives in
|
|||
|
||||
| Constant | Value | Source |
|
||||
|---|---|---|
|
||||
| `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:289 / 302 |
|
||||
| `MAX_BASELINE_PAGES` | 100 | `worker.rs` |
|
||||
| `MAX_BASELINE_EMAILS` | 10_000 | `worker.rs` |
|
||||
| `RECONCILE_BUDGET` | 20 s | `worker.rs` |
|
||||
| `CHANGE_WINDOW_CAP` | 4_096 | `worker.rs` |
|
||||
| Lock TTL / heartbeat | 300 s / 90 s | `reconcile` and its heartbeat task |
|
||||
|
||||
`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:99):
|
||||
XAUTOCLAIM's idle threshold is derived from the live outbound config:
|
||||
|
||||
```
|
||||
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:66) and capped at 6 h
|
||||
(state.rs:76). The floor is also the fallback when the config cannot be read, so a
|
||||
Floored at the legacy 300 s single-event ceiling and capped at 6 h. 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.
|
||||
|
||||
|
|
@ -531,9 +534,9 @@ stake, never loss.
|
|||
|
||||
| Constant | Value | Source |
|
||||
|---|---|---|
|
||||
| `SEARCH_LIMIT` | 10 | worker.rs:619 |
|
||||
| `SEARCH_SUBJECT_MAX` | 120 chars | worker.rs:621 |
|
||||
| `SEARCH_PREVIEW_MAX` | 160 chars | worker.rs:623 |
|
||||
| `SEARCH_LIMIT` | 10 | `worker.rs` |
|
||||
| `SEARCH_SUBJECT_MAX` | 120 chars | `worker.rs` |
|
||||
| `SEARCH_PREVIEW_MAX` | 160 chars | `worker.rs` |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -541,21 +544,21 @@ stake, never loss.
|
|||
|
||||
Served from `src/web.rs`.
|
||||
|
||||
- Three routes only: `GET /`, `GET /assets/config.js`, `GET /assets/styles.css` (web.rs:21-23).
|
||||
- Three routes only: `GET /`, `GET /assets/config.js`, `GET /assets/styles.css`.
|
||||
Everything else returns 404.
|
||||
- Assets are embedded at compile time with `include_str!` (web.rs:15-17). There is no
|
||||
- Assets are embedded at compile time with `include_str!`. There is no
|
||||
filesystem access at runtime.
|
||||
- Every response carries `Content-Type`, `Cache-Control: no-store`,
|
||||
`X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer` (web.rs:43-51).
|
||||
- CSP is applied **only to the HTML document**: the constant is at web.rs:39, inserted inside
|
||||
an `if html` block (web.rs:53-58):
|
||||
`X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`.
|
||||
- CSP is applied **only to the HTML document**: inserted inside
|
||||
an `if html` block:
|
||||
|
||||
```
|
||||
default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self';
|
||||
form-action 'self'; base-uri 'none'; frame-ancestors 'none'
|
||||
```
|
||||
|
||||
- `X-Frame-Options: DENY` is set alongside the CSP, on the HTML response only (web.rs:58).
|
||||
- `X-Frame-Options: DENY` is set alongside the CSP, on the HTML response only.
|
||||
|
||||
`default-src 'none'` is deliberately stricter than `'self'`: the page is closed by default
|
||||
and only `script-src`, `style-src` and `connect-src` re-open a same-origin channel.
|
||||
|
|
@ -566,8 +569,8 @@ 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`). Adapter: `Intent::Search` at `worker.rs:553` and
|
||||
at `src/domain/jmap.rs:268`, backed by `search_emails` at `src/domain/jmap/client.rs:320`.
|
||||
- `/search` **is** implemented (`bfe0fd8`). Adapter: `Intent::Search` in `worker.rs` and
|
||||
in `src/domain/jmap.rs`, backed by `search_emails` in `src/domain/jmap/client.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
|
||||
per-part body highlight cannot be modelled through the locked crate. Search degrades to
|
||||
|
|
|
|||
|
|
@ -75,8 +75,8 @@
|
|||
|---|---|---|---|
|
||||
| 把 Telegram 接入拆成「模块入口 / 命令解析 / 会话状态 / 回复渲染」四个文件的目录方案 | 虚构目录方案 | 目标目录树里画出,从未创建 | 命令解析在 `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:718`) |
|
||||
| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:898-901`) |
|
||||
| `check_config_reload` | 虚构函数 | 未提交草稿中的名字,从未进入代码 | 热更新为 `refresh_business_config`(`src/notify.rs:1094`) |
|
||||
| `push:registration:{sha256(callback_url)}` 曾被写成「360s 注册单飞锁」 | 事实误标 | 该键是 7d 回调→订阅 ID 映射 | 真正的 360s 单飞锁是 `lock:push-register:{sha256(callback_url)}`(`src/notify.rs:1274-1278`) |
|
||||
| `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:381`),**不是后台循环**:全仓 `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/opengaps.md`(原名 `docs/roadmap.md`)「代码缺口」4 → 3,`docs/design.md`「已知边界」同句已删 |
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Audit whether a docs anchor actually sits next to the identifier it claims.
|
||||
|
||||
`audit_anchors.py` proves only that an anchor targets a real, non-blank line.
|
||||
It cannot tell you that the doc says "`refresh_business_config` at
|
||||
`notify.rs:381`" when line 381 is `async fn worker`. This script asks that
|
||||
weaker follow-up question: does the identifier the doc names next to an anchor
|
||||
appear anywhere near the anchored line?
|
||||
|
||||
If it does not, the anchor is a *candidate* for being wrong -- not a verdict.
|
||||
Several shapes are legal on purpose, so this script never fails the gate:
|
||||
it prints an INFO-level candidate list, always exits 0, and is deliberately
|
||||
not wired into `run_all.sh`.
|
||||
|
||||
Intended use: after a batch of anchor re-pointings, re-run this and compare the
|
||||
count. A shrinking list means the anchors are landing where the docs say; a
|
||||
flat list means something is still off. It is the only automated substitute for
|
||||
reading every anchor by hand, which is why it is worth existing.
|
||||
|
||||
How an identifier is matched to an anchor: docs pair them in prose order
|
||||
("key built at `foo.rs:424`, `claim_dedup` at `foo.rs:458`"), so an identifier
|
||||
claims the *next* anchor after it, never the nearest one. On a table row
|
||||
carrying several anchors the preceding anchor sits just as close on the wrong
|
||||
side, and pairing by distance reports every such row as drift.
|
||||
|
||||
Known-legal anchors -- do not "fix" these:
|
||||
- the doc anchors a call site while the definition lives elsewhere
|
||||
- the identifier is on an adjacent line the anchor does not name
|
||||
- the "identifier" is a value, e.g. an env var name such as `DEBUG_TOKEN`
|
||||
- the identifier is a data literal the doc deliberately points at (a Lua
|
||||
script body, a JSON string)
|
||||
The shape filter drops the third shape (no underscore); the LEGAL table drops
|
||||
the rest. LEGAL is keyed on the anchor target's span, not the doc line, because
|
||||
re-pointing the docs moves the doc line while the target does not.
|
||||
|
||||
Constants ROOT / DOCS / FILE / EXT / REF / resolve are copied verbatim from
|
||||
`audit_anchors.py` so this script stays standalone. `audit_anchors.py` cannot
|
||||
be imported: its module-level code runs the audit and calls `sys.exit`. Keep
|
||||
the copies in sync if the anchor grammar changes.
|
||||
"""
|
||||
|
||||
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/opengaps.md', 'docs/charter.md', 'cloudflare-worker/README.md']
|
||||
|
||||
FILE = r'((?:[A-Za-z0-9_.-]+/)?[A-Za-z0-9_-]+)'
|
||||
EXT = r'\.(rs|js):(\d+)'
|
||||
REF = re.compile(
|
||||
r'((?:src|cloudflare-worker/src|web)(?:/[A-Za-z0-9_.-]+)*/)?'
|
||||
+ FILE + EXT + r'(?:\s*[,–\-]\s*(\d+))?'
|
||||
)
|
||||
|
||||
# Lowercase snake_case only: `refresh_business_config`, `retry_or_dlq`.
|
||||
# Requiring at least one underscore is the main noise filter -- it drops bare
|
||||
# words like `worker` or `batch` that a doc line mentions incidentally, and it
|
||||
# drops UPPER_SNAKE env names like `DEBUG_TOKEN`, which are values, not Rust
|
||||
# identifiers. Length >= 4 is enforced separately.
|
||||
IDENTIFIER = re.compile(r'`([a-z][a-z0-9]*(?:_[a-z0-9]+)+)`')
|
||||
|
||||
# Generic accessors too common to be meaningful evidence either way. The first
|
||||
# four are already excluded by the shape filter; they are listed explicitly so
|
||||
# the intent stays visible if the filter is ever loosened.
|
||||
GENERIC = frozenset(['none', 'ok', 'err', 'some',
|
||||
'is_none', 'is_some', 'is_ok', 'is_err', 'to_string'])
|
||||
|
||||
WINDOW = 2 # lines above and below the anchor target
|
||||
MIN_LEN = 4 # minimum identifier length
|
||||
NEIGHBOURHOOD = 60 # max chars from an identifier to the anchor it claims
|
||||
|
||||
# Anchors that are correct as written but sit outside the +/- WINDOW purely by
|
||||
# design. Keyed on (identifier, source file) -> the span the target may fall in.
|
||||
# Not keyed on the doc line: re-pointing the docs moves the doc line while the
|
||||
# target these anchors claim does not. `None` -> main.rs:135 and
|
||||
# `DEBUG_TOKEN` -> main.rs:106 belong to the same class but are already dropped
|
||||
# by the shape filter (no underscore), so they need no entry here.
|
||||
LEGAL = {
|
||||
('refresh_business_config', 'notify.rs'): (381, 381),
|
||||
('invalid_request', 'notify.rs'): (402, 402),
|
||||
('reconcile_token', 'notify.rs'): (284, 284),
|
||||
('worker_token', 'notify.rs'): (393, 393),
|
||||
# retry_or_dlq spans state.rs:472-503, its Lua script body 484-491. A doc
|
||||
# may anchor anywhere inside and never mention the identifier.
|
||||
('retry_or_dlq', 'state.rs'): (472, 503),
|
||||
}
|
||||
|
||||
|
||||
def resolve(prefix, base, ext):
|
||||
"""Map an anchor's path fragment to a file on disk, or None."""
|
||||
if prefix is None:
|
||||
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)
|
||||
|
||||
|
||||
def window_text(src, line):
|
||||
"""The anchor's target line plus WINDOW lines each side."""
|
||||
lo = max(0, line - 1 - WINDOW)
|
||||
return '\n'.join(src[lo:line + WINDOW])
|
||||
|
||||
|
||||
def gap(left, right):
|
||||
"""Chars of text between two non-overlapping spans."""
|
||||
return max(left.start() - right.end(), right.start() - left.end(), 0)
|
||||
|
||||
|
||||
def identifiers_near(line):
|
||||
"""Backticked snake_case identifiers on the doc line worth checking."""
|
||||
return [m for m in IDENTIFIER.finditer(line)
|
||||
if len(m.group(1)) >= MIN_LEN and m.group(1) not in GENERIC]
|
||||
|
||||
|
||||
def partner_for(im, anchors):
|
||||
"""The anchor an identifier claims.
|
||||
|
||||
Prose pairs them in document order -- "key built at `foo.rs:424`,
|
||||
`claim_dedup` at `foo.rs:458`" -- so an identifier belongs to the next
|
||||
anchor after it. Nearest-anchor pairing mis-reads that line: the
|
||||
preceding anchor sits two characters closer and swallows the claim.
|
||||
Falls back to the previous anchor when the identifier trails the line.
|
||||
"""
|
||||
following = [a for a in anchors if a.start() >= im.end()]
|
||||
if following:
|
||||
return following[0]
|
||||
preceding = [a for a in anchors if a.end() < im.start()]
|
||||
return preceding[-1] if preceding else None
|
||||
|
||||
|
||||
candidates = []
|
||||
seen = set()
|
||||
for d in DOCS:
|
||||
lines = (ROOT / d).read_text().splitlines()
|
||||
in_fence = False
|
||||
for i, line in enumerate(lines, 1):
|
||||
if re.match(r'^\s*(```|~~~)', line):
|
||||
in_fence = not in_fence
|
||||
continue
|
||||
if in_fence:
|
||||
continue
|
||||
anchors = list(REF.finditer(line))
|
||||
if not anchors:
|
||||
continue
|
||||
for im in identifiers_near(line):
|
||||
best = partner_for(im, anchors)
|
||||
if best is None or gap(im, best) > NEIGHBOURHOOD:
|
||||
continue
|
||||
prefix, base, ext, a, b = best.groups()
|
||||
p = resolve(prefix, base, ext)
|
||||
if p is None:
|
||||
continue
|
||||
src = p.read_text().splitlines()
|
||||
lo, hi = int(a), int(b) if b else int(a)
|
||||
if hi > len(src):
|
||||
continue
|
||||
tok = im.group(1)
|
||||
key = (d, i, tok)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
span = LEGAL.get((tok, p.name))
|
||||
if span and span[0] <= lo <= span[1]:
|
||||
continue # correct as written; the gap is intentional
|
||||
if any(tok in window_text(src, n) for n in {lo, hi}):
|
||||
continue # identifier is on the anchored line (+/- 2)
|
||||
candidates.append(
|
||||
f'{d}:{i} `{tok}` -> {p.relative_to(ROOT)}:{lo}'
|
||||
+ (f'-{hi}' if b else '')
|
||||
+ f' | {src[lo - 1].strip()}'
|
||||
)
|
||||
|
||||
print(f'=== ANCHOR SEMANTICS ({len(candidates)} candidates) ===')
|
||||
for c in candidates:
|
||||
print(c)
|
||||
sys.exit(0)
|
||||
Loading…
Reference in New Issue