diff --git a/AGENTS.md b/AGENTS.md index 0d41201..ea7fbb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ > **版本基线** 填写本仓库的基线 commit:<`<代码行号锚点基线>`(例如 `b70d188`)|`<文档章节引用基线>`(例如 `e0882c7`)>,由持有门禁结果的提交者在合并该批改动时更新。 -> 校验命令(`GATE-DOCS` / `GATE-P0`)的具体可复制版本写在 [`docs/charter.md`](docs/charter.md) §5。 +> 校验命令的具体可复制版本写在 `docs/charter.md §6.2`(`GATE-P0`)与 `docs/charter.md §6.3`(`GATE-DOCS`)。 --- @@ -155,10 +155,7 @@ ### 6.1 变更验证闭环 -任何涉及运行时行为或可核对事实的改动,必须**同一改动内**完成:实现 → 校验 → 修文档。 - -- 改动不得只存在于代码、也不得只存在于文档;不得留到「以后补」。 -- 改代码 → 改测试 → 改文档,三者一起提交。 +实现 → 校验 → 修文档 必须**同一改动内**完成,不得只改一边,也不得留到「以后补」。完整判据见 `docs/charter.md §6.1`。 ### 6.2 测试要求 diff --git a/cloudflare-worker/README.md b/cloudflare-worker/README.md index 57f2969..9dbe8a3 100644 --- a/cloudflare-worker/README.md +++ b/cloudflare-worker/README.md @@ -54,7 +54,7 @@ 有 ≥1 后端 up → 200;全部 down → 503。 - **不代理 Redis/JMAP**:Worker 不做数据库侧检查;后端端到端就绪(配置完整性 + Redis 可达 + JMAP session + Telegram getMe,对应 `ARCH-READY-BASELINE`)由 `/ready`(透传)承担。 -### 无长连接 / 无密钥日志(§4 红线) +### 无长连接 / 无密钥日志(§3/§5 红线) - 不使用 WebSocket/SSE/长轮询(`C-NO-LONG-CONN`):纯请求-响应转发,body 一次性 `arrayBuffer` 回灌。 - 日志只打「方法 / 路径 / origin / 失败类别」;**绝不**打印 header / body / 鉴权 secret / App Password(`SAF-LOG-PURITY`)。 diff --git a/cloudflare-worker/src/backends.js b/cloudflare-worker/src/backends.js index 348277c..f27321d 100644 --- a/cloudflare-worker/src/backends.js +++ b/cloudflare-worker/src/backends.js @@ -13,6 +13,7 @@ export const SAFE_ROUTES = Object.freeze([ "/api/status", "/api/config", "/api/business-config", + "/api/business-config/preflight", "/api/admin/session", "/api/admin/session/revoke", "/api/enabled", diff --git a/cloudflare-worker/src/index.js b/cloudflare-worker/src/index.js index 7f4dc40..faa5936 100644 --- a/cloudflare-worker/src/index.js +++ b/cloudflare-worker/src/index.js @@ -13,7 +13,8 @@ * 路由模型: * - GET /、/assets/config.js、/assets/styles.css → 内嵌 SPA 静态资源透传 * - GET /api/status → 后端配置引导状态;只返回缺失变量名称 - * - GET|PUT /api/config、PUT /api/business-config、POST /api/admin/session[/revoke] + * - GET|PUT /api/config、GET|PUT /api/business-config、POST /api/business-config/preflight、 + * POST /api/admin/session[/revoke] * → 透传至后端;后端校验 bootstrap 凭据或短期 admin session * - GET /healthz → LB 聚合健康(MOD-HEALTH-AGG) * - POST /webhook/tg|/push/jmap|/api/push/register|/api/push/disable|/reconcile、GET /ready → 透传 + 有界故障转移 @@ -48,7 +49,8 @@ const ROUTE_METHODS = Object.freeze({ "/assets/styles.css": ["GET"], "/api/status": ["GET"], "/api/config": ["GET", "PUT"], - "/api/business-config": ["PUT"], + "/api/business-config": ["GET", "PUT"], + "/api/business-config/preflight": ["POST"], "/api/admin/session": ["POST"], "/api/admin/session/revoke": ["POST"], "/api/enabled": ["GET", "PUT"], diff --git a/cloudflare-worker/test/index.test.js b/cloudflare-worker/test/index.test.js index e0ed816..ce707f9 100644 --- a/cloudflare-worker/test/index.test.js +++ b/cloudflare-worker/test/index.test.js @@ -89,6 +89,60 @@ test("Worker: business config PUT is forwarded with the session bearer and empty } }); +test("Worker: business config read-back GET and preflight POST are both forwarded", async () => { + const seen = []; + const restore = stubFetch(async (url, init) => { + seen.push([String(url), init.method]); + return new Response(JSON.stringify({ configured: false }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + try { + const readback = await handleFetch( + new Request("https://lb.example/api/business-config", { method: "GET" }), + makeEnv(), + ); + assert.equal(readback.status, 200); + assert.deepEqual(await readback.json(), { configured: false }); + + const preflight = await handleFetch( + new Request("https://lb.example/api/business-config/preflight", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }), + makeEnv(), + ); + assert.equal(preflight.status, 200); + + assert.equal(seen.length, 2); + assert.equal(seen[0][0], "https://a.example/api/business-config"); + assert.equal(seen[0][1], "GET"); + assert.equal(seen[1][0], "https://a.example/api/business-config/preflight"); + assert.equal(seen[1][1], "POST"); + } finally { + restore(); + } +}); + +test("Worker: only GET/PUT reach /api/business-config and only POST reaches preflight (SAF-LB-PASSTHRU)", async () => { + for (const method of ["DELETE", "PATCH"]) { + const res = await handleFetch( + new Request("https://lb.example/api/business-config", { method }), + makeEnv(), + ); + assert.equal(res.status, 405, `${method} /api/business-config must stay blocked`); + assert.equal(res.headers.get("allow"), "GET, PUT"); + } + const res = await handleFetch( + new Request("https://lb.example/api/business-config/preflight", { method: "GET" }), + makeEnv(), + ); + assert.equal(res.status, 405, "preflight is POST only"); + assert.equal(res.headers.get("allow"), "POST"); +}); + test("Worker: public setup status is forwarded without an authorization header", async () => { const restore = stubFetch(async (url, init) => { assert.match(url, /^https:\/\/(a|b)\.example\/api\/status$/); diff --git a/docs/charter.md b/docs/charter.md index 57a7610..6a69b5d 100644 --- a/docs/charter.md +++ b/docs/charter.md @@ -116,7 +116,7 @@ cd /home/okabe/Repo/messageweave && docker run --rm --user 1000:1000 \ bash -lc 'export PATH=/usr/local/cargo/bin:$PATH; cargo fmt --all -- --check && cargo check --locked && cargo clippy --locked --all-targets -- -D warnings && cargo test --locked 2>&1 | tail -12' ``` -**当前基线:69 passed / 0 failed / 1 ignored**(2026-09-29 复测;唯一 ignored:`real_server_tests::session_list_and_read_smoke`,需外部真实 JMAP 服务器)。 +**当前基线:77 passed / 0 failed / 2 ignored**(2026-09-29 复测;ignored:`real_server_tests::session_list_and_read_smoke` 与 `debug::tests::debug_config_reports_timezone_of_business_configured_app`,均需外部真实 JMAP 服务器凭据)。 **注意**:`docker run` 的 bash `-lc` 脚本必须用**单引号**包裹。用双引号会先在宿主机展开 `$PWD` / `$PATH`,容器内找不到 cargo。 @@ -160,7 +160,7 @@ Stalwart 与 Telegram 的业务凭据已在真机联调通过——出站、查 已连接。真实环境集成测试必须用 `#[ignore]` 标注,缺凭据时静默跳过(不泄露凭据、不判失败)。 **环境阻塞项**(未验证,不得声称已验证)见 [`opengaps.md`](opengaps.md)「阻塞」区: -`Email/changes` 的 `newState` 语义、五个键的 TTL 实测。 +`Email/changes` 的 `newState` 语义、TTL 数值实测(12 个写入点 / 17 个值)。 ## 7. 文档边界 diff --git a/docs/deployment.md b/docs/deployment.md index 2bc908d..bd92d07 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -92,15 +92,15 @@ | `GET /debug/worker` | `200`(`revision`、`reconcile_cursor`、`outbound` 预算) | 401 | | `POST /debug/notify` | `200 {"ok":true,"result":{…}}` | 401;业务配置未加载或无出站客户端 → `503 service_unavailable` + `Retry-After: 30`(`src/debug.rs:56-58`);`chat_id` 不在白名单 → `403 chat_not_allowed`(`src/debug.rs:60-62`);Telegram 发送失败 → `502 telegram_send_failed`(`src/debug.rs:64-66`) | -`/debug/config` 的响应字段(`src/debug.rs:110-148`):恒有 `revision`、`setup_missing`、`business_configured`、`allowlist_size`;业务配置存在时再加 `jmap.{session_url,username,account_id}`、`telegram.{chat_id,webhook_secret_configured}`、`worker.{worker_token_configured,reconcile_token_configured}`、`llm.{enabled,allow_net,api_key_configured,base_url,model}`、`outbound.{jmap_timeout_ms,telegram_timeout_ms,llm_timeout_ms,max_retries}`;配置缺失时只回 `business_configured:false` + `allowlist_size`。 +`/debug/config` 的响应字段(`src/debug.rs:110-148`):恒有 `revision`、`setup_missing`、`business_configured`、`allowlist_size`;业务配置存在时再加 `timezone`(IANA 时区名字符串,如 `Asia/Tokyo`)、`jmap.{session_url,username,account_id}`、`telegram.{chat_id,webhook_secret_configured}`、`worker.{worker_token_configured,reconcile_token_configured}`、`llm.{enabled,allow_net,api_key_configured,base_url,model}`、`outbound.{jmap_timeout_ms,telegram_timeout_ms,llm_timeout_ms,max_retries}`;配置缺失时只回 `business_configured:false` + `allowlist_size`。 方法不匹配先于鉴权判定(例如 `GET /debug/notify` 返回 `405`)。 **三条约束,部署时务必确认** - **绝不回显凭据值**:`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: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 访问;若必须经代理,请自行在代理层加鉴权,不要让公网可达。 +- **`/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`)。 +- **不在 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` 与一次性 > `DEBUG_TOKEN`,重新部署后联调;结束立即把 `DEBUG_ENABLED` 置空并轮换 `DEBUG_TOKEN`。 diff --git a/docs/opengaps.md b/docs/opengaps.md index f6eb99e..043bc7d 100644 --- a/docs/opengaps.md +++ b/docs/opengaps.md @@ -4,20 +4,20 @@ ## 你要做的事 -代码侧**没有**待你实现的项。剩下的一件事需要你腾一个停机窗口,另一件由我跑: +代码侧**没有**待你实现的项。剩下的一件事需要你腾一个停机窗口;第二件(TTL 实测)不能只靠我跑——它需要一个可连的测试 Redis 实例: | # | 事项 | 谁做 | 你要动手吗 | | --- | --- | --- | --- | | 1 | `Email/changes` 的 `newState` 语义 | 你 + 我 | 腾一次停机窗口 | -| 2 | 单飞锁 TTL 实测 | 我 | 不需要,我自己跑 | +| 2 | TTL 实测 | 我 | 需要一个可连的测试 Redis 实例 | ## 阶段目标 -剩下一条需要停机窗口的阻塞:**在真实环境里确认 `Email/changes` 的 `newState` 语义**;另有一条由我补测的 TTL 实测(第 2 项),不依赖你。 +剩下一条需要停机窗口的阻塞:**在真实环境里确认 `Email/changes` 的 `newState` 语义**;另一条 TTL 实测(第 2 项)也不能靠「我补测」收掉——它需要一个可连的测试 Redis 实例才能跑起 `#[ignore]` 门控的集成测试,在那之前只有人工核对。 上一轮真机联调已经关掉了两条阻塞: -- **Telegram 入站方向**:`setWebhook` 之后群里发 `/help`,`POST /worker` 排空即收到自动回复。此前 `/webhook/tg` 收到的全部是伪造 update,Telegram 侧从未收到过 `setWebhook`,所以这个方向一直没有被真实流量验证过。 +- **Telegram 入站方向**:`setWebhook` 之后群里发 `/help`,`POST /worker` 排空即收到自动回复。真实 Telegram 流量已驱动过 `/webhook/tg`。 - **Stalwart `PushSubscription`**:`stalwart:jmap` 连续数天有真实、互不重复的推送事件。该 stream 全代码库只有两个写入点——`src/notify.rs:1140`(JMAP 回调入队)与 `src/worker.rs:394`(对账增量入队)——所以有事件出现就只能是订阅活着且在投递。同一次观测也顺带验证了去重键在跨日规模下没有重复投递。 两条的操作步骤已移到面向使用者的 `docs/deployment.md` §4.1,本文件不再跟踪它们。 @@ -40,19 +40,49 @@ 我来做的:恢复后跑 `POST /reconcile`,再看 `/debug/worker` 的 `reconcile_cursor`。预期是重新变成一个**新的 `baseline:` 游标**(重新基线化成功),而不是报错或停在旧游标。实测语义回填本节。 -### 2. 单飞锁 TTL 实测 +### 2. TTL 实测 -TTL 参数已在代码里写死、单测也已覆盖,但真实场景下的实际过期行为从未观测过。前置依赖(订阅注册成功、真实回调在投递)已解除,这一项不再依赖任何人的配合。 +表里 **12 个 TTL 写入点、17 个 TTL 值**,值已人工逐个核对无误。它们分三类,可测性各不相同,不能混为一谈:A 类是调用点的字面量参数(不是模块级常量);B 类**内嵌在 Lua 脚本字符串里**,在调用点提常量够不到;C 类的值产生于 `worker.rs` 的意图构造处、只是被当作参数透传给写入点。 -| 键 | 用途 | TTL | 位置 | +共同点是:**仓库里没有任何测试断言过任何一个 TTL 数值**——测试替身 `MemoryState::claim_dedup` 直接忽略 TTL 参数(`src/state.rs:911` 的 `_ttl_seconds`),全仓 `PTTL` / `.ttl()` 断言数为 0,也没有 Redis 测试服务器 gating(只有 JMAP 有 `#[ignore]` 门控,见 `src/domain/jmap/client.rs:522`)。被测试覆盖的只是调用路径(`claim_dedup` 先 true 后 false、锁的 claim/renew/release 流程),不是过期时长本身。真实落到 Redis 的是三处原子命令——`set_nx_ex` 的 `SET … NX EX`(`src/state.rs:856`,`ttl_seconds.max(1)` 兜 0)、`retry_or_dlq` 内嵌 Lua 里的 `EXPIRE KEYS[1] 86400`(`src/state.rs:486`)、`set_ai_consent` 的 `SET … EX`(`src/state.rs:512-513`,同样 `max(1)` 兜底);真实 Redis 上这些命令的过期行为是 Redis 自身的既定语义,本仓库无法验证,也不依赖验证。 + +**A. 调用点字面量参数 — 10 个写入点 / 10 个值** + +| 键 | 用途 | TTL | 写入点 | | --- | --- | --- | --- | -| `lock:push-register:{sha256(callback_url)}` | 注册单飞锁 | 360s | `src/notify.rs:1178` | -| `lock:reconcile` | 对账锁(长对账中每 90s 续租) | 300s | `src/notify.rs:289` / `:302` | -| `ratelimit:push-verify:{subscription_id}` | 验证限流 | 30s | `src/notify.rs:1096` | -| `dedup:tg:{update_id}` | Telegram 更新去重 | 86400s | `src/notify.rs:248` | -| `dedup:jmap:{account_id}:{email_id}` | JMAP 更新去重 | 86400s | `src/notify.rs:1135` | +| `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` | +| `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/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` | -我来做的:触发一次真实回调并记录这五个键的实际过期行为,把实测值回填本节。在这之前本节保持阻塞。 +**B. 内嵌 Lua 脚本 — 1 个写入点 / 1 个值** + +| 键 | 用途 | TTL | 写入点 | +| --- | --- | --- | --- | +| `retry:{stream}:{message.id}` | 投递重试计数(键构造 `src/state.rs:481`) | 86400 | `src/state.rs:486` | + +这格写在 `retry_or_dlq` 的 Lua 脚本字符串里(`src/state.rs:484-490`),不是在调用点传参——「把 TTL 提成模块级常量」这类改造对它够不到,得改脚本本身。 + +**C. `consent:ai:{chat_id}` — 1 个写入点 / 6 个值** + +| 值 | 触发 | 说明 | 产生处 | +| --- | --- | --- | --- | +| `3600` | 「临时 / 一次」 | 1 小时 | `src/worker.rs:568` | +| `86_400` | 「今天」 | 1 天 | `src/worker.rs:574` | +| `7 * 86_400` | 「7天」 | 7 天 | `src/worker.rs:580` | +| `365 * 86_400` | 「直到撤销 / 长期」 | 最长 365 天 | `src/worker.rs:586` | +| `3600` | `/ai on` 等显式开启 | 1 小时 | `src/worker.rs:597` | +| `0` | `/ai off` 撤销 | 落到 `max(1)` = 1s,且已过期 | `src/worker.rs:607` | + +写入点是 `src/worker.rs:432` 的 `set_ai_consent(chat.id, ttl)`,落 Redis 在 `src/state.rs:505-516`。这组的值由用户在 Telegram 里说的话决定,不是写死在 state 层。 + +我来做的:**触发一次真实回调读不到 Redis 键的 TTL**——`/debug/*` 六条只读端点里没有任何一条暴露键的 TTL(全仓 `PTTL` 断言数也为 0),所以光触发回调记不到实测值。要真正走通,需要在仓库里新增一个 `#[ignore]`-gated 的 Redis 集成测试(照 `src/domain/jmap/client.rs:522` 的模式),并配置一个真实的测试 Redis URL 才能真正执行。在那之前本节保持阻塞,但残留的风险只有「TTL 数值没被测试断言」,且表中 17 个值已人工逐个核对无误。 ## 后续(多账号) diff --git a/docs/reference.md b/docs/reference.md index 1c08161..e2f15b4 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -271,7 +271,7 @@ Response conventions: `Retry-After: 30` (`src/debug.rs:56-58`). 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:232-235`) and `502 telegram_send_failed` on + outside a *non-empty* allowlist (`src/debug.rs:233-236`) and `502 telegram_send_failed` on a send failure (`src/debug.rs:64-66`). - Static assets are served with a strict CSP; see §7. @@ -286,9 +286,9 @@ Response conventions: > `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-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). +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). **Forwarded by the worker (15):** diff --git a/src/config.rs b/src/config.rs index 16ae40f..1d7d558 100644 --- a/src/config.rs +++ b/src/config.rs @@ -220,6 +220,258 @@ impl From for BusinessConfigWire { } } +/// Partial business configuration submitted by `PUT /api/business-config`: a field the client +/// does not name keeps the stored value, so an operator can change one thing without retyping +/// the whole configuration. +/// +/// The field list mirrors `BusinessConfigWire` field for field — `apply` builds a wire from these, +/// so adding a field to the wire without adding it here fails to compile instead of silently +/// freezing the new setting at its stored value. Secrets are replace-only: `validate_business_wire` +/// rejects a blank secret, so there is deliberately no way to clear one and no way for the SPA to +/// learn what it is holding. +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct BusinessConfigPatch { + #[serde(default)] + pub bot_token: Option, + #[serde(default, deserialize_with = "de_i64_from_number_or_string")] + pub telegram_chat_id: Option, + #[serde(default)] + pub chat_allowlist: Option>, + #[serde(default)] + pub telegram_webhook_secret: Option, + #[serde(default)] + pub timezone: Option, + #[serde(default)] + pub jmap_session_url: Option, + #[serde(default)] + pub jmap_username: Option, + #[serde(default)] + pub jmap_password: Option, + #[serde(default)] + pub account_id: Option>, + #[serde(default)] + pub llm_enabled: Option, + #[serde(default)] + pub llm_allow_net: Option, + #[serde(default)] + pub llm_api_key: Option>, + #[serde(default)] + pub llm_base_url: Option>, + #[serde(default)] + pub llm_model: Option>, + #[serde(default)] + pub reconcile_token: Option, + #[serde(default)] + pub worker_token: Option, +} + +/// `telegram_chat_id` arrives as a JSON number on a full submit but as a numeric string on a +/// partial one (`web/config.js` stringifies the `BigInt` to keep the value exact). Both shapes are +/// accepted; anything else is rejected rather than coerced. +fn de_i64_from_number_or_string<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de::Error; + use serde::Deserialize; + match Option::::deserialize(deserializer)? { + None => Ok(None), + Some(serde_json::Value::Number(number)) => number + .as_i64() + .map(Some) + .ok_or_else(|| D::Error::invalid_value(serde::de::Unexpected::Other("number"), &"i64")), + Some(serde_json::Value::String(text)) => text + .trim() + .parse() + .map(Some) + .map_err(|_| D::Error::invalid_value(serde::de::Unexpected::Str(&text), &"i64")), + Some(_) => Err(D::Error::invalid_type( + serde::de::Unexpected::Other("not a chat id"), + &"i64", + )), + } +} + +/// Everything the SPA may need to display as already configured: only the plaintext fields. +#[derive(Debug, serde::Serialize)] +pub(crate) struct BusinessConfigValues { + pub jmap_session_url: String, + pub jmap_username: String, + pub account_id: Option, + pub timezone: String, + pub telegram_chat_id: Option, + pub chat_allowlist: Vec, + pub llm_enabled: bool, + pub llm_allow_net: bool, + pub llm_base_url: Option, + pub llm_model: Option, +} + +/// One presence flag per secret: `true` means the SPA can hide the field and show "saved, click to +/// replace" without ever receiving the value (SAF-NO-SECRET-ECHO). This type is response-only — +/// it is deliberately not deserializable, so a client-supplied `secrets_present` block can never +/// influence what gets stored. +#[derive(Debug, Default, serde::Serialize)] +pub(crate) struct BusinessConfigSecrets { + pub bot_token: bool, + pub jmap_password: bool, + pub telegram_webhook_secret: bool, + pub reconcile_token: bool, + pub worker_token: bool, + pub llm_api_key: bool, +} + +/// Read-back shape for `GET /api/business-config`. The response is built from the private wire one +/// field at a time, so no `SecretString` is ever in reach of the JSON encoder — the same pattern the +/// debug surface uses for its `*_configured` flags. +#[derive(Debug, serde::Serialize)] +pub(crate) struct BusinessConfigReadback { + pub configured: bool, + pub revision: u64, + pub values: BusinessConfigValues, + pub secrets_present: BusinessConfigSecrets, +} + +impl BusinessConfigValues { + /// Empty values reported when nothing has been saved yet. `chat_allowlist` is `[]` and the + /// optional strings are `null` so the SPA can treat both the same; `timezone` carries the + /// default because that is the zone the backend would actually render. + fn empty() -> Self { + Self { + jmap_session_url: String::new(), + jmap_username: String::new(), + account_id: None, + timezone: default_timezone(), + telegram_chat_id: None, + chat_allowlist: Vec::new(), + llm_enabled: false, + llm_allow_net: false, + llm_base_url: None, + llm_model: None, + } + } +} + +impl BusinessConfigReadback { + /// Read-back for a stored configuration. + pub(crate) fn from_wire(revision: u64, wire: &BusinessConfigWire) -> Self { + Self { + configured: true, + revision, + values: BusinessConfigValues { + jmap_session_url: wire.jmap_session_url.clone(), + jmap_username: wire.jmap_username.clone(), + account_id: wire.account_id.clone(), + timezone: wire.timezone.clone(), + telegram_chat_id: Some(wire.telegram_chat_id), + chat_allowlist: wire.chat_allowlist.clone(), + llm_enabled: wire.llm_enabled, + llm_allow_net: wire.llm_allow_net, + llm_base_url: wire.llm_base_url.clone(), + llm_model: wire.llm_model.clone(), + }, + secrets_present: BusinessConfigSecrets { + bot_token: !wire.bot_token.trim().is_empty(), + jmap_password: !wire.jmap_password.trim().is_empty(), + telegram_webhook_secret: !wire.telegram_webhook_secret.trim().is_empty(), + reconcile_token: !wire.reconcile_token.trim().is_empty(), + worker_token: !wire.worker_token.trim().is_empty(), + llm_api_key: wire + .llm_api_key + .as_deref() + .is_some_and(|key| !key.trim().is_empty()), + }, + } + } + + /// Read-back when nothing has been saved yet. Still 200, so the SPA has one code path. + pub(crate) fn absent() -> Self { + Self { + configured: false, + revision: 0, + values: BusinessConfigValues::empty(), + secrets_present: BusinessConfigSecrets::default(), + } + } +} + +impl BusinessConfigPatch { + /// A patch that is a complete configuration on its own, so it can be the very first one. + /// + /// The four `Option>` fields are exempt: `None` is a legal stored value for + /// them. Everything else must be named on a first save, because `BusinessConfigWire` would + /// either fill one in silently (`timezone`) or fail after the merge is assembled — both of + /// which would hide that a fragment was submitted where a full configuration was required. + fn is_complete(&self) -> bool { + self.bot_token.is_some() + && self.telegram_chat_id.is_some() + && self.chat_allowlist.is_some() + && self.telegram_webhook_secret.is_some() + && self.timezone.is_some() + && self.jmap_session_url.is_some() + && self.jmap_username.is_some() + && self.jmap_password.is_some() + && self.llm_enabled.is_some() + && self.llm_allow_net.is_some() + && self.reconcile_token.is_some() + && self.worker_token.is_some() + } + + /// Merge the patch over the stored configuration. + /// + /// Only fields the patch actually names move; everything else keeps its stored value. Secrets + /// are replace-only, so this never turns a stored secret into a blank one. Returns `None` only + /// for a fragment submitted before anything is stored — in that case there is nothing to fall + /// back to, so the handler reports it rather than persisting a partial configuration. + pub(crate) fn apply(self, stored: Option<&BusinessConfigWire>) -> Option { + if stored.is_none() && !self.is_complete() { + return None; + } + // `stored` is `None` only with a complete patch, where every field below is present, so the + // fallback is only ever read when it exists. + let fallback = stored.expect("an omitted field implies a stored configuration exists"); + Some(BusinessConfigWire { + bot_token: self.bot_token.unwrap_or_else(|| fallback.bot_token.clone()), + telegram_chat_id: self.telegram_chat_id.unwrap_or(fallback.telegram_chat_id), + chat_allowlist: self + .chat_allowlist + .unwrap_or_else(|| fallback.chat_allowlist.clone()), + telegram_webhook_secret: self + .telegram_webhook_secret + .unwrap_or_else(|| fallback.telegram_webhook_secret.clone()), + timezone: self.timezone.unwrap_or_else(|| fallback.timezone.clone()), + jmap_session_url: self + .jmap_session_url + .unwrap_or_else(|| fallback.jmap_session_url.clone()), + jmap_username: self + .jmap_username + .unwrap_or_else(|| fallback.jmap_username.clone()), + jmap_password: self + .jmap_password + .unwrap_or_else(|| fallback.jmap_password.clone()), + account_id: self + .account_id + .unwrap_or_else(|| fallback.account_id.clone()), + llm_enabled: self.llm_enabled.unwrap_or(fallback.llm_enabled), + llm_allow_net: self.llm_allow_net.unwrap_or(fallback.llm_allow_net), + llm_api_key: self + .llm_api_key + .unwrap_or_else(|| fallback.llm_api_key.clone()), + llm_base_url: self + .llm_base_url + .unwrap_or_else(|| fallback.llm_base_url.clone()), + llm_model: self.llm_model.unwrap_or_else(|| fallback.llm_model.clone()), + reconcile_token: self + .reconcile_token + .unwrap_or_else(|| fallback.reconcile_token.clone()), + worker_token: self + .worker_token + .unwrap_or_else(|| fallback.worker_token.clone()), + }) + } +} + impl TryFrom for BusinessConfig { type Error = BotError; diff --git a/src/debug.rs b/src/debug.rs index 9b5b7d1..e1e40ea 100644 --- a/src/debug.rs +++ b/src/debug.rs @@ -113,6 +113,7 @@ async fn debug_config(State(app): State, headers: HeaderMap) -> Respon "setup_missing": setup_missing, "business_configured": true, "allowlist_size": allowlist_size, + "timezone": config.timezone, "jmap": { "session_url": config.jmap_session_url, "username": config.jmap_username, @@ -260,7 +261,7 @@ mod tests { use super::*; use crate::config::AuthSecrets; use crate::notify::router_with_worker_state_runtime_bootstrap; - use crate::state::{runtime_provider, MemoryState, OutboundConfig}; + use crate::state::{runtime_provider, MemoryState, OutboundConfig, ReliableState}; use crate::worker::NoopWorker; use axum::{ body::Body, @@ -275,14 +276,17 @@ mod tests { /// Router built through the real dual-factor wiring: debug routes are merged only when the /// caller passes a `debug_token` (SAF-DEBUG-GATE). - fn bootstrap_router(debug_token: Option) -> Router { + fn bootstrap_router_with_state( + debug_token: Option, + state: S, + ) -> Router { router_with_worker_state_runtime_bootstrap( AuthSecrets { reconcile_token: SecretString::new("reconcile-secret".into()), telegram_webhook_secret: SecretString::new("telegram-secret".into()), }, SecretString::new("worker-secret".into()), - MemoryState::enabled_for_tests(), + state, HashSet::new(), Arc::new(NoopWorker), runtime_provider(OutboundConfig::default()), @@ -291,6 +295,106 @@ mod tests { ) } + fn bootstrap_router(debug_token: Option) -> Router { + bootstrap_router_with_state(debug_token, MemoryState::enabled_for_tests()) + } + + /// SAF-DEBUG-AUTH regression guard for the populated branch of `debug_config`: the timezone + /// is a plain value the SPA writes, so it is echoed back for verification, while every + /// credential stays a presence flag. It drives the whole chain — persisted wire → + /// `refresh_business_config` → `build_worker` → `business_runtime` → `/debug/config` — so a + /// silently dropped timezone fails here instead of in production. + /// + /// Opt-in like the JMAP smoke test: `business_runtime` is only populated after `build_worker` + /// succeeds, and both `validate_business_wire` and `normalize_session_url` refuse a + /// non-HTTPS session URL, so no localhost mock can stand in for a real session. Run with + /// `JMAP_SESSION_URL`, `JMAP_USERNAME` and `JMAP_PASSWORD` set (and `ACCOUNT_ID` if the + /// account is not the session's primary one). + #[tokio::test] + #[ignore = "requires an explicitly configured JMAP test server"] + async fn debug_config_reports_timezone_of_business_configured_app() { + let Some(session_url) = std::env::var("JMAP_SESSION_URL").ok() else { + eprintln!("skipped: JMAP_SESSION_URL is not configured"); + return; + }; + let Some(username) = std::env::var("JMAP_USERNAME").ok() else { + eprintln!("skipped: JMAP_USERNAME is not configured"); + return; + }; + let Some(password) = std::env::var("JMAP_PASSWORD").ok() else { + eprintln!("skipped: JMAP_PASSWORD is not configured"); + return; + }; + let account_id: serde_json::Value = match std::env::var("ACCOUNT_ID") { + Ok(value) => serde_json::Value::String(value), + Err(_) => serde_json::Value::Null, + }; + + // A negative supergroup chat id: Telegram ids are i64 and this one overflows an i32. + let chat_id = -5_260_770_881_i64; + let wire = serde_json::json!({ + "bot_token": "bot-secret-value", + "telegram_chat_id": serde_json::Number::from(chat_id), + "chat_allowlist": [42], + "telegram_webhook_secret": "hook-secret-value", + "timezone": "Asia/Tokyo", + "jmap_session_url": session_url.clone(), + "jmap_username": username.clone(), + "jmap_password": password.clone(), + "account_id": account_id, + "llm_enabled": false, + "llm_allow_net": false, + "llm_api_key": null, + "llm_base_url": null, + "llm_model": null, + "reconcile_token": "reconcile-secret-value", + "worker_token": "worker-secret-value", + }); + + let state = MemoryState::enabled_for_tests(); + state.set_business_config(&wire).await.unwrap(); + let router = + bootstrap_router_with_state(Some(SecretString::new(DEBUG_TOKEN.to_string())), state); + let (status, value) = debug_get_json(router).await; + let body = value.to_string(); + + assert_eq!(status, StatusCode::OK); + assert_eq!(value["business_configured"], serde_json::json!(true)); + // The plaintext read-back is exactly what makes the timezone verifiable in production. + assert_eq!(value["timezone"], serde_json::json!("Asia/Tokyo")); + assert!(body.contains(&format!("\"session_url\":\"{session_url}\""))); + assert!(body.contains(&format!("\"chat_id\":{chat_id}"))); + assert!(body.contains(&format!("\"username\":\"{username}\""))); + assert!(value["jmap"].get("account_id").is_some()); + assert_eq!(value["allowlist_size"], serde_json::json!(1)); + assert_eq!( + value["worker"]["reconcile_token_configured"], + serde_json::json!(true) + ); + assert_eq!( + value["worker"]["worker_token_configured"], + serde_json::json!(true) + ); + assert_eq!( + value["telegram"]["webhook_secret_configured"], + serde_json::json!(true) + ); + assert_eq!(value["llm"]["api_key_configured"], serde_json::json!(false)); + + // SAF-DEBUG-AUTH: the fields exist, but no credential value may appear in the body. + for secret in [ + "bot-secret-value", + "hook-secret-value", + &password, + "reconcile-secret-value", + "worker-secret-value", + "reconcile-secret", + "telegram-secret", + ] { + assert!(!body.contains(secret), "credential value leaked: {secret}"); + } + } + async fn get(uri: &str, authorization: Option<&str>) -> (StatusCode, String) { let router = bootstrap_router(Some(SecretString::new(DEBUG_TOKEN.to_string()))); let mut builder = Request::builder().uri(uri); @@ -308,6 +412,26 @@ mod tests { (status, String::from_utf8_lossy(&body).to_string()) } + /// `/debug/config` against an already-built router, decoded as JSON so callers can assert on + /// individual fields rather than substrings. + async fn debug_get_json(router: Router) -> (StatusCode, serde_json::Value) { + let response = router + .oneshot( + Request::builder() + .uri("/debug/config") + .header("authorization", format!("Bearer {DEBUG_TOKEN}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + (status, serde_json::from_slice(&body).unwrap()) + } + /// POST variant: the notification route is `POST`-only, so a GET would be a 405 rather than /// ever reaching the authentication check. async fn post(uri: &str, authorization: Option<&str>) -> StatusCode { diff --git a/src/notify.rs b/src/notify.rs index b229534..69faf8c 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -4,7 +4,7 @@ use axum::{ extract::State, http::{header, HeaderMap, StatusCode}, response::{IntoResponse, Json, Response}, - routing::{get, post, put}, + routing::{get, post}, Router, }; use ring::rand::SecureRandom; @@ -22,7 +22,8 @@ use subtle::ConstantTimeEq; use crate::ai::LlmClient; use crate::channel::telegram::TelegramClient; use crate::config::{ - session_digest, validate_business_wire, AuthSecrets, BusinessConfig, BusinessConfigWire, + session_digest, validate_business_wire, AuthSecrets, BusinessConfig, BusinessConfigPatch, + BusinessConfigReadback, BusinessConfigWire, }; use crate::domain::jmap::{client::JmapClientBackend, JmapService}; use crate::error::BotError; @@ -591,7 +592,72 @@ async fn business_enabled(app: &AppState) -> bool { app.state.is_enabled().await.unwrap_or(false) } -/// Replace the complete encrypted business configuration. +/// What the next business-config write needs to know about what is stored. +/// +/// `Unreachable` and `Invalid` are kept distinct from `Absent`: both read as "nothing there", +/// but a patch merged over `Absent` would overwrite a configuration that is only unreachable or +/// unreadable, so either one refuses the write instead of silently truncating it. +enum StoredBusinessConfig { + /// Nothing has ever been saved, so a patch has nothing to fall back onto. + Absent, + /// A stored configuration a patch can merge over. + Loaded(Box), + /// The store could not be read: a partial write would destroy what is there. + Unreachable, + /// Something is stored but no longer parses as a wire: report it, never overwrite it as empty. + Invalid, +} + +async fn read_stored_business_config(state: &Arc) -> StoredBusinessConfig { + match state.get_business_config().await { + Ok(None) => StoredBusinessConfig::Absent, + Ok(Some(value)) => match serde_json::from_value::(value) { + Ok(wire) => StoredBusinessConfig::Loaded(Box::new(wire)), + Err(_) => StoredBusinessConfig::Invalid, + }, + Err(_) => StoredBusinessConfig::Unreachable, + } +} + +/// Read the stored business configuration back to the SPA: the plaintext fields it needs to +/// change one thing without retyping the rest, plus a presence flag per secret. Never 404 — an +/// operator with nothing saved still gets 200 with `configured: false`, so the SPA needs one code +/// path instead of two. +async fn get_business_config(State(app): State, headers: HeaderMap) -> Response { + refresh_business_config(&app).await; + if !config_authorized(&app, &headers).await { + return error_response(StatusCode::UNAUTHORIZED, "unauthorized", false); + } + let revision = match app.state.business_config_revision().await { + Ok(revision) => revision, + Err(_) => { + return error_response(StatusCode::SERVICE_UNAVAILABLE, "service_unavailable", true) + } + }; + match read_stored_business_config(&app.state).await { + StoredBusinessConfig::Unreachable => { + error_response(StatusCode::SERVICE_UNAVAILABLE, "service_unavailable", true) + } + StoredBusinessConfig::Invalid => error_response( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid_configuration", + false, + ), + StoredBusinessConfig::Absent => { + (StatusCode::OK, Json(BusinessConfigReadback::absent())).into_response() + } + StoredBusinessConfig::Loaded(wire) => ( + StatusCode::OK, + Json(BusinessConfigReadback::from_wire(revision, &wire)), + ) + .into_response(), + } +} + +/// Submit the business configuration, either in full or as a partial patch: the fields the client +/// names replace the stored ones and everything else is kept, so an operator can change one value +/// without retyping the whole configuration. Secrets are replace-only, which is also what lets the +/// SPA keep the stored value by sending nothing at all. /// /// Persistence is gated by *validation*, not by connectivity. A configuration that is /// syntactically valid is always written, even if no client can currently be built for it: @@ -619,7 +685,30 @@ async fn put_business_config( let Ok(value) = serde_json::from_slice::(&body) else { return error_response(StatusCode::BAD_REQUEST, "invalid_request", false); }; - let Ok(wire) = serde_json::from_value::(value.clone()) else { + let Ok(patch) = serde_json::from_value::(value) else { + return error_response( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid_configuration", + false, + ); + }; + // Merge first, then validate the result. A patch is never validated on its own: a + // one-field edit must be rejected when the configuration it becomes is invalid. + let merged = match read_stored_business_config(&app.state).await { + StoredBusinessConfig::Unreachable => { + return error_response(StatusCode::SERVICE_UNAVAILABLE, "service_unavailable", true) + } + StoredBusinessConfig::Invalid => { + return error_response( + StatusCode::UNPROCESSABLE_ENTITY, + "invalid_configuration", + false, + ) + } + StoredBusinessConfig::Loaded(stored) => patch.apply(Some(&stored)), + StoredBusinessConfig::Absent => patch.apply(None), + }; + let Some(wire) = merged else { return error_response( StatusCode::UNPROCESSABLE_ENTITY, "invalid_configuration", @@ -633,6 +722,12 @@ async fn put_business_config( false, ); } + // Persist the merged wire, not the request body: a patch carries only the fields it changed, + // so writing the body back would silently drop the rest of the configuration. `try_into` + // moves `wire`, so the value is taken first. + let Ok(value) = serde_json::to_value(&wire) else { + return error_response(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", true); + }; let Ok(config): Result = wire.try_into() else { return error_response( StatusCode::UNPROCESSABLE_ENTITY, @@ -1609,7 +1704,10 @@ fn router_with_worker_state_runtime_bootstrap_config .route("/worker", post(worker)) .route("/api/config", get(get_config).put(put_config)) .route("/api/enabled", get(get_enabled).put(put_enabled)) - .route("/api/business-config", put(put_business_config)) + .route( + "/api/business-config", + get(get_business_config).put(put_business_config), + ) .route( "/api/business-config/preflight", post(preflight_business_config), @@ -1809,6 +1907,7 @@ mod tests { ("GET", "/api/config"), ("GET", "/api/enabled"), ("POST", "/api/business-config"), + ("GET", "/api/business-config"), ("POST", "/api/bootstrap"), ("POST", "/api/admin/session"), ("POST", "/api/admin/session/revoke"), diff --git a/web/config.js b/web/config.js index cb0a12b..528fd0c 100644 --- a/web/config.js +++ b/web/config.js @@ -91,12 +91,12 @@ "timezone.desc": "Received times in Telegram notifications are rendered in this time zone.", "timezone.hint": "Only fixed-offset zones are supported; DST is not tracked.", "business.saveStateInitial": "You can fill in the full configuration after the admin session is verified.", - "business.saveStateReady": "Fill in the complete BusinessConfigWire to submit. Secrets are never echoed.", + "business.saveStateReady": "Only changed fields are submitted; untouched fields keep their saved values. Secrets are never echoed.", "business.save": "Save business config and hot-reload", "business.saveBusy": "Saving and hot-reloading…", "business.saved": "Business configuration saved and new configuration hot-reloaded.", "business.savedToast": "Business configuration saved and hot-reloaded. Secret inputs cleared; re-fill on the next full replacement.", - "business.dirty": "The form has unsubmitted content; each submission fully replaces the existing business configuration.", + "business.dirty": "The form has unsubmitted content; only the fields you changed will be submitted.", "business.unsaved": "No save-success confirmation received; check the service status.", "business.submittingToast": "Submitting the full business configuration; the backend builds clients first, then saves and swaps the running instance…", "business.preflight": "Test connection", @@ -177,6 +177,19 @@ "val.noCreds": "{label} must not contain a username or password.", "val.noQuery": "{label} must not contain query parameters or a #fragment.", "val.empty": "{label} cannot be empty.", + "field.set": "Set", + "field.unset": "Not set", + "secret.marked": "• {state}. {hint}", + "secret.keepEmpty": "Leave empty to keep the saved value", + "secret.keepSet": "Empty; the saved value stays", + "secret.mustEnter": "Enter a value to create the first configuration", + "secret.changed": "New value will replace the saved one", + "session.readbackToast": "Saved configuration loaded. Change only what you need.", + "session.firstConfigToast": "No saved configuration yet. Fill in every field to create one.", + "session.readbackFailed": "The saved configuration could not be loaded, so the form is empty. Nothing was changed.", + "session.readback503": "The saved configuration could not be loaded from Redis. Try again later; your session is still valid.", + "business.savedRevision": "Saved. Revision {revision}", + "business.noChange": "Nothing to submit — no field differs from the saved configuration.", "val.llmUrl": "Base URL is required when LLM is enabled.", "val.llmModel": "Model name is required when LLM is enabled.", "val.llmKey": "API key is required when LLM is enabled.", @@ -190,7 +203,7 @@ "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.successToast": "Admin session created. The saved configuration is being loaded; only change the fields you need.", "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}).", @@ -275,12 +288,12 @@ "llm.modelPh": "留空表示 null", "llm.keyPh": "启用 LLM 时必填;不会回显", "business.saveStateInitial": "完成管理会话验证后可填写完整配置。", - "business.saveStateReady": "完整填写 BusinessConfigWire 后可提交。密钥不会回显。", + "business.saveStateReady": "只提交被修改的字段,未改动的字段保持已保存的值。密钥不会回显。", "business.save": "保存业务配置并热加载", "business.saveBusy": "正在保存并热加载…", "business.saved": "业务配置已保存,新配置已热加载。", "business.savedToast": "业务配置已保存并热加载。密钥输入已清空;下次完整替换时需要重新填写。", - "business.dirty": "表单包含未提交内容;每次提交会完整替换现有业务配置。", + "business.dirty": "表单包含未提交内容;只提交你修改过的字段。", "business.unsaved": "未收到保存成功确认;请检查服务状态。", "business.submittingToast": "正在提交完整业务配置;后端会先构建客户端,再保存并切换运行实例…", "business.preflight": "测试连接", @@ -361,6 +374,19 @@ "val.noCreds": "{label} 不能包含用户名或密码。", "val.noQuery": "{label} 不能包含查询参数或 #片段。", "val.empty": "{label} 不能为空。", + "field.set": "已配置", + "field.unset": "未配置", + "secret.marked": "• {state}。{hint}", + "secret.keepEmpty": "留空即保持已保存的值", + "secret.keepSet": "留空;已保存的值不变", + "secret.mustEnter": "请填写,以创建首次配置", + "secret.changed": "将用新值替换已保存的值", + "session.readbackToast": "已载入保存的配置。只需修改要改的字段。", + "session.firstConfigToast": "尚无可保存的配置。请填写全部字段以创建首次配置。", + "session.readbackFailed": "保存的配置未能载入,表单为空。未做任何更改。", + "session.readback503": "Redis 中的保存配置暂不可读,请稍后重试;会话仍然有效。", + "business.savedRevision": "已保存。版本 {revision}", + "business.noChange": "没有内容需要提交——所有字段都与已保存的配置一致。", "val.llmUrl": "启用 LLM 时必须填写 Base URL。", "val.llmModel": "启用 LLM 时必须填写模型名称。", "val.llmKey": "启用 LLM 时必须填写 API key。", @@ -374,7 +400,7 @@ "login.enterCred": "请输入 CONFIG_ENCRYPTION_KEY 的值。输入不会写入浏览器存储。", "login.creatingToast": "正在创建短期管理会话…", "login.invalidResponse": "管理接口返回了无效的会话响应。", - "login.successToast": "管理会话已创建。运行参数正在读取;业务配置需要完整重新填写。", + "login.successToast": "管理会话已创建。正在载入已保存的配置,只需修改要改的字段。", "login.401": "CONFIG_ENCRYPTION_KEY 与服务器配置不一致,或服务尚未启用管理会话。此处不接受 Redis ACL 密码。", "login.503": "Redis 不可用或初始化尚未完成,暂时无法创建管理会话。请稍后重试。", "login.status": "无法创建管理会话(HTTP {status})。", @@ -470,20 +496,6 @@ } } - // Expose a tiny debug surface for runtime inspection and tests. - if (typeof window !== "undefined") { - window.__mw = { - t, - detectLocale, - getLocale: () => locale, - messages: MESSAGES, - joinList, - timezones: TIMEZONES, - defaultTimezone: DEFAULT_TIMEZONE, - readBusinessConfig, - }; - } - applyI18n(); populateTimezones(); // ───────────────────────────────────────────────────────────────────────── @@ -509,6 +521,38 @@ const I64_MIN = -(2n ** 63n); const I64_MAX = (2n ** 63n) - 1n; + /* + * Business config read-back + partial submission. + * + * Secrets are never echoed by the backend: the server returns only a presence + * boolean per secret field (SAF-NO-SECRET-ECHO). A blank secret input therefore + * means "keep the saved value" and the field is omitted from the request body; + * a non-blank value replaces it. There is no way to clear a secret through the UI. + * + * The first save (no saved configuration yet) is still a full submission. + */ + const SECRET_FIELDS = Object.freeze([ + Object.freeze({ key: "bot_token", id: "telegram-bot-token" }), + Object.freeze({ key: "jmap_password", id: "jmap-password" }), + Object.freeze({ key: "telegram_webhook_secret", id: "telegram-webhook-secret" }), + Object.freeze({ key: "reconcile_token", id: "reconcile-token" }), + Object.freeze({ key: "worker_token", id: "worker-token" }), + Object.freeze({ key: "llm_api_key", id: "llm-api-key" }), + ]); + + const PLAIN_TEXT_FIELDS = Object.freeze([ + Object.freeze({ key: "jmap_session_url", id: "jmap-session-url", https: "JMAP Session URL" }), + Object.freeze({ key: "jmap_username", id: "jmap-username" }), + Object.freeze({ key: "timezone", id: "timezone" }), + Object.freeze({ key: "llm_base_url", id: "llm-base-url", optional: true, https: "LLM Base URL" }), + Object.freeze({ key: "llm_model", id: "llm-model", optional: true }), + ]); + + const PLAIN_BOOL_FIELDS = Object.freeze([ + Object.freeze({ key: "llm_enabled", id: "llm-enabled" }), + Object.freeze({ key: "llm_allow_net", id: "llm-allow-net" }), + ]); + const bootstrapInput = document.querySelector("#bootstrap-input"); const authForm = document.querySelector("#auth-form"); const intro = document.querySelector("#intro"); @@ -550,6 +594,13 @@ let busyAction = ""; let runtimeLoaded = false; let setupMode = true; + // Business-config readback from GET /api/business-config. Both null means no + // saved configuration exists yet, so submission falls back to the original + // full-payload path. `businessBaseline` is the non-secret `values` object + // (including nulls); `businessSecretPresence` is the `secrets_present` object. + // Secrets themselves are never stored here — only their presence booleans. + let businessBaseline = null; + let businessSecretPresence = null; class ApiError extends Error { constructor(status, message) { @@ -721,6 +772,7 @@ businessFields.disabled = true; runtimeSaveState.textContent = t("runtime.saveStateInitial"); businessSaveState.textContent = t("business.saveStateInitial"); + resetBusinessReadback(); updateLLMRequirements(); } @@ -998,6 +1050,189 @@ document.querySelector("#llm-model-required").textContent = mark; } + // --- Business-config readback (GET /api/business-config) ---------------- + // + // The server stores a BusinessConfig in Redis. Re-logging in must not force + // the operator to re-enter everything, so the SPA reads the saved config back + // after a session is created, shows which secrets are already stored, pre-fills + // the non-secret fields, and submits only the fields the operator changed. + // + // Secret values are never echoed by the backend (charter SAF-NO-SECRET-ECHO), + // so a secret field is always blank. A blank secret therefore means "keep the + // stored value" and is omitted from the patch; a non-blank one replaces it. + + function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); + } + + function resetBusinessReadback() { + businessBaseline = null; + businessSecretPresence = null; + renderSecretMarkers(); + } + + function renderSecretMarkers() { + for (const field of SECRET_FIELDS) { + const marker = document.getElementById(`secret-marker-${field.key}`); + if (!marker) continue; + const input = document.getElementById(field.id); + const presence = Boolean(businessSecretPresence && businessSecretPresence[field.key]); + const blank = !input || isBlank(input.value); + const state = presence ? t("field.set") : t("field.unset"); + const hint = !presence + ? t("secret.mustEnter") + : (blank ? t("secret.keepSet") : t("secret.changed")); + marker.textContent = t("secret.marked", { state, hint }); + marker.classList.toggle("is-unset", !presence); + } + } + + function applyBusinessReadback(readback) { + businessBaseline = isRecord(readback && readback.values) ? readback.values : {}; + businessSecretPresence = isRecord(readback && readback.secrets_present) + ? readback.secrets_present + : {}; + for (const field of PLAIN_TEXT_FIELDS) { + const input = document.getElementById(field.id); + if (!input) continue; + const value = businessBaseline[field.key]; + input.value = typeof value === "string" ? value : ""; + } + // telegram_chat_id is a single numeric value, not a list, so it gets its own + // treatment rather than living in PLAIN_TEXT_FIELDS. + const chatIdInput = document.getElementById("telegram-chat-id"); + if (chatIdInput) { + const value = businessBaseline.telegram_chat_id; + chatIdInput.value = typeof value === "string" ? value : (value === undefined ? "" : String(value)); + } + const allowlistValue = businessBaseline.chat_allowlist; + const allowlistInput = document.getElementById("chat-allowlist"); + if (allowlistInput) { + // Comma-separated, not `joinList`: `parseAllowlist` only splits on commas + // and whitespace, so a locale separator would parse as one invalid entry. + allowlistInput.value = Array.isArray(allowlistValue) ? allowlistValue.join(", ") : ""; + } + for (const field of PLAIN_BOOL_FIELDS) { + const input = document.getElementById(field.id); + if (input) input.checked = businessBaseline[field.key] === true; + } + updateLLMRequirements(); + // A stored configuration makes every secret optional: blank keeps the stored + // value, so native `required` would wrongly block a partial save. Same for the + // LLM text fields, which are already stored server-side. Apply this after the + // toggle above so a change-event handler cannot re-enable it. + for (const field of SECRET_FIELDS) { + const input = document.getElementById(field.id); + if (!input) continue; + input.value = ""; + input.required = false; + // The original placeholder says "re-enter on each full save"; with a stored + // configuration the common action is to leave the field blank, so swap it. + input.placeholder = t("secret.keepEmpty"); + } + llmApiKey.required = false; + llmBaseUrl.required = false; + llmModel.required = false; + renderSecretMarkers(); + } + + // Build the changed-fields patch from the pre-filled form. Blank secrets are + // omitted (keep the stored value); text fields are omitted when they still + // match the baseline. Returns null after marking a field invalid. + function businessPatchFromForm() { + clearCustomValidity(businessForm); + const baseline = isRecord(businessBaseline) ? businessBaseline : {}; + const patch = {}; + try { + for (const field of SECRET_FIELDS) { + const input = document.getElementById(field.id); + if (!input) continue; + const value = input.value.trim(); + if (value !== "") patch[field.key] = value; + } + + // LLM text fields (base URL, model) and the LLM API key are handled by the + // loops below like any other field: they are compared to the stored values, + // and the key is submitted only when the operator typed a new one. Turning + // LLM off needs no text change — the flag alone is enough, and the stored + // URL and model simply stop being used. + if (llmEnabled.checked !== Boolean(baseline.llm_enabled)) { + patch.llm_enabled = llmEnabled.checked; + } + const allowNetInput = document.getElementById("llm-allow-net"); + if (allowNetInput && allowNetInput.checked !== Boolean(baseline.llm_allow_net)) { + patch.llm_allow_net = allowNetInput.checked; + } + + for (const field of PLAIN_TEXT_FIELDS) { + const input = document.getElementById(field.id); + if (!input) continue; + const value = input.value.trim(); + const stored = baseline[field.key]; + // A required text field blank means "leave the stored value alone", so a + // partial save can never accidentally delete one. + if (value === "" && !field.optional) continue; + // Clearing an optional field only matters when there was a value to clear; + // an empty-and-absent field is already null on the server. + if (value === "" && (stored === undefined || stored === null || stored === "")) continue; + if (value === stored) continue; + if (field.https && !validateHttpsUrl(input, field.https)) return null; + patch[field.key] = value === "" ? null : value; + } + + const allowlistInput = document.getElementById("chat-allowlist"); + if (!isBlank(allowlistInput.value)) { + const allowlist = parseAllowlist(allowlistInput); + const savedList = Array.isArray(baseline.chat_allowlist) + ? baseline.chat_allowlist.map((item) => String(item)) + : []; + const nextList = allowlist.map((item) => item.toString()); + const fingerprint = (values) => values.slice().sort().join(","); + if (fingerprint(nextList) !== fingerprint(savedList)) { + patch.chat_allowlist = allowlist; + } + } + + const chatIdInput = document.getElementById("telegram-chat-id"); + if (!isBlank(chatIdInput.value)) { + const chatId = parseI64(chatIdInput, t("telegram.chatLabel")).toString(); + if (chatId !== String(baseline.telegram_chat_id ?? "")) patch.telegram_chat_id = chatId; + } + + return patch; + } catch (error) { + if (error.message === "invalid-field") return null; + throw error; + } + } + + async function loadBusinessConfig(options = {}) { + const { toast = true } = options; + try { + const readback = await request("/api/business-config", "GET"); + if (readback && readback.configured === true) { + applyBusinessReadback(readback); + if (toast) showNotice("info", t("session.readbackToast")); + } else { + resetBusinessReadback(); + updateLLMRequirements(); + if (toast) showNotice("info", t("session.firstConfigToast")); + } + return true; + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + expireSession(t("session.invalidBusiness")); + return false; + } + if (error instanceof ApiError && error.status === 503) { + showNotice("error", t("session.readback503")); + return false; + } + showNotice("error", t("session.readbackFailed")); + return false; + } + } + authForm.addEventListener("submit", async (event) => { event.preventDefault(); if (busyAction) return; @@ -1027,6 +1262,7 @@ showNotice("success", t("login.successToast")); await loadRuntimeConfig(); await loadEnabled(); + await loadBusinessConfig(); } catch (error) { bootstrapCredential = ""; setBusy(""); @@ -1082,6 +1318,8 @@ businessForm.addEventListener("input", (event) => { event.target.setCustomValidity(""); + // A typed secret value replaces the stored one; a cleared one restores it. + if (businessSecretPresence) renderSecretMarkers(); if (!adminSession) return; businessSaveState.textContent = t("business.dirty"); businessSaveState.classList.add("is-dirty"); @@ -1158,12 +1396,19 @@ businessForm.addEventListener("submit", async (event) => { event.preventDefault(); if (!adminSession || busyAction) return; - const config = readBusinessConfig(); - if (!config) return; + // With a stored configuration the form is pre-filled, so only the changed + // fields are sent. Without one, every field is required as before. + const patch = businessBaseline === null ? readBusinessConfig() : businessPatchFromForm(); + if (!patch) return; + if (businessBaseline !== null && Object.keys(patch).length === 0) { + businessSaveState.textContent = t("business.noChange"); + businessSaveState.classList.remove("is-dirty"); + return; + } setBusy("business-save"); showNotice("info", t("business.submittingToast")); try { - const result = await request("/api/business-config", "PUT", stringifyWithBigInt(config)); + const result = await request("/api/business-config", "PUT", stringifyWithBigInt(patch)); clearSecretFields(); // Saving and applying are separate outcomes: the backend persists a validated // configuration even when no client could be built, so "saved" alone would overclaim. @@ -1178,11 +1423,18 @@ businessSaveState.classList.add("is-warn"); showNotice("info", text); } else { - businessSaveState.textContent = t("business.saved"); + const revision = typeof result && typeof result.revision === "number" + ? t("business.savedRevision", { revision: result.revision }) + : ""; + const text = [t("business.saved"), revision].filter(Boolean).join(" "); + businessSaveState.textContent = text; businessSaveState.classList.remove("is-dirty"); - showNotice("success", t("business.savedToast")); + showNotice("success", text); } updateButtons(); + // Refresh the authoritative baseline so the markers and pre-filled values + // reflect what was just stored. No toast: the save confirmation is enough. + await loadBusinessConfig({ toast: false }); } catch (error) { if (error.status === 401) { expireSession(t("session.invalidBusiness")); @@ -1238,4 +1490,27 @@ showSetupStatus(); updateLLMRequirements(); updateButtons(); + + // Expose a tiny debug surface for runtime inspection and tests. Declared last + // so it can close over every state variable, including the business-config + // readback baseline. + if (typeof window !== "undefined") { + window.__mw = { + t, + detectLocale, + getLocale: () => locale, + messages: MESSAGES, + joinList, + timezones: TIMEZONES, + defaultTimezone: DEFAULT_TIMEZONE, + readBusinessConfig, + applyBusinessReadback, + businessPatchFromForm, + renderSecretMarkers, + resetBusinessReadback, + get businessBaseline() { return businessBaseline; }, + get businessSecretPresence() { return businessSecretPresence; }, + fields: { secrets: SECRET_FIELDS, text: PLAIN_TEXT_FIELDS, bool: PLAIN_BOOL_FIELDS }, + }; + } })(); diff --git a/web/config.test.mjs b/web/config.test.mjs index 6d67fac..67e38a7 100644 --- a/web/config.test.mjs +++ b/web/config.test.mjs @@ -17,10 +17,15 @@ class FakeElement { this.textContent = ""; this.listeners = new Map(); this.attributes = new Map(); + const classes = new Set(); this.classList = { - toggle() {}, - add() {}, - remove() {}, + toggle(name, force) { + const on = force === true || (force === undefined && !classes.has(name)); + if (on) classes.add(name); else classes.delete(name); + }, + add(name) { classes.add(name); }, + remove(name) { classes.delete(name); }, + contains(name) { return classes.has(name); }, }; this.children = new Map(); this.options = []; @@ -91,6 +96,25 @@ async function startPage(statusResponse) { return { elements, fetchCalls, window }; } +// Objects built inside vm.runInNewContext carry that realm's Object.prototype, +// so assert.deepEqual — which compares prototypes — rejects them even when they +// are structurally identical. Re-shape them into host objects, sorted, and then +// compare. +function canonical(value) { + if (typeof value === "bigint") return value.toString(); + if (Array.isArray(value)) return Array.from(value, (item) => canonical(item)); + if (value !== null && typeof value === "object") { + const out = {}; + for (const key of Object.keys(value).sort()) out[key] = canonical(value[key]); + return out; + } + return value; +} + +function assertShape(actual, expected, message) { + assert.deepEqual(canonical(actual), canonical(expected), message); +} + test("configuration-setup hides all authorization UI and lists only missing variable names", async () => { const { elements, fetchCalls } = await startPage({ body: { @@ -277,3 +301,225 @@ test("readBusinessConfig carries the selected time zone", async () => { assert.equal(typeof payload.bot_token, "string"); assert.equal(typeof payload.reconcile_token, "string"); }); + +test("readback pre-fills stored values, blanks secrets, and marks which secrets exist", async () => { + const { elements, window: pageWindow } = await startPage({ body: { ready: true, mode: "configured", missing: [] } }); + const mw = pageWindow.__mw; + + mw.applyBusinessReadback({ + values: { + jmap_session_url: "https://mail.example.com/jmap", + jmap_username: "bot@example.com", + timezone: "Asia/Tokyo", + llm_base_url: "https://llm.example.com/v1", + llm_model: "gpt-4o", + chat_allowlist: ["123456789", "987654321"], + telegram_chat_id: "123456789", + llm_enabled: true, + llm_allow_net: true, + }, + secrets_present: { + bot_token: true, + jmap_password: true, + telegram_webhook_secret: true, + reconcile_token: true, + worker_token: false, + llm_api_key: true, + }, + }); + + // Non-secret values are filled in so the operator can change only what matters. + assert.equal(elements.get("jmap-session-url").value, "https://mail.example.com/jmap"); + assert.equal(elements.get("jmap-username").value, "bot@example.com"); + assert.equal(elements.get("timezone").value, "Asia/Tokyo"); + assert.equal(elements.get("llm-base-url").value, "https://llm.example.com/v1"); + assert.equal(elements.get("llm-model").value, "gpt-4o"); + assert.equal(elements.get("llm-enabled").checked, true); + assert.equal(elements.get("llm-allow-net").checked, true); + // The allowlist is re-serialised comma-separated so parseAllowlist round-trips it. + assert.equal(elements.get("chat-allowlist").value, "123456789, 987654321"); + assert.equal(elements.get("telegram-chat-id").value, "123456789"); + + // Secrets are never echoed: every secret input is blank and no longer required, + // because a blank secret now means "keep the stored value". + const secrets = mw.fields.secrets; + for (const field of secrets) { + assert.equal(elements.get(field.id).value, "", `secret ${field.key} must stay blank`); + assert.equal(elements.get(field.id).required, false, `secret ${field.key} must not be required`); + } + + // Presence markers are driven by secrets_present, never by the stored value. + const marker = (key) => elements.get(`secret-marker-${key}`); + const marked = (key) => marker(key).textContent; + assert.equal(marker("jmap_password").classList.contains("is-unset"), false); + assert.equal(marker("worker_token").classList.contains("is-unset"), true); + assert.notEqual(marked("jmap_password"), marked("worker_token")); + assert.equal(marked("jmap_password").includes("Set"), true); + assert.equal(marked("worker_token").includes("Not set"), true); + + assert.equal(mw.businessBaseline.jmap_session_url, "https://mail.example.com/jmap"); + assert.equal(mw.businessSecretPresence.worker_token, false); +}); + +test("no saved configuration leaves the baseline null so full submission is used", async () => { + const { window: pageWindow } = await startPage({ body: { ready: true, mode: "configured", missing: [] } }); + assert.equal(pageWindow.__mw.businessBaseline, null); + assert.equal(pageWindow.__mw.businessSecretPresence, null); +}); + +test("an untouched form produces an empty patch", async () => { + const { elements, window: pageWindow } = await startPage({ body: { ready: true, mode: "configured", missing: [] } }); + const mw = pageWindow.__mw; + mw.applyBusinessReadback({ + values: { + jmap_session_url: "https://mail.example.com/jmap", + jmap_username: "bot@example.com", + timezone: "Asia/Tokyo", + chat_allowlist: ["123456789"], + telegram_chat_id: "123456789", + llm_enabled: false, + llm_allow_net: false, + }, + secrets_present: { bot_token: true, jmap_password: true, telegram_webhook_secret: true, reconcile_token: true, worker_token: true, llm_api_key: false }, + }); + + const patch = mw.businessPatchFromForm(); + assertShape(patch, {}, "an unchanged form must not resubmit anything"); +}); + +test("only changed text fields enter the patch", async () => { + const { elements, window: pageWindow } = await startPage({ body: { ready: true, mode: "configured", missing: [] } }); + const mw = pageWindow.__mw; + mw.applyBusinessReadback({ + values: { + jmap_session_url: "https://mail.example.com/jmap", + jmap_username: "bot@example.com", + timezone: "Asia/Tokyo", + chat_allowlist: ["123456789"], + telegram_chat_id: "123456789", + llm_enabled: false, + llm_allow_net: false, + }, + secrets_present: { bot_token: true, jmap_password: true, telegram_webhook_secret: true, reconcile_token: true, worker_token: true, llm_api_key: false }, + }); + + elements.get("timezone").value = "America/New_York"; + const patch = mw.businessPatchFromForm(); + assertShape(patch, { timezone: "America/New_York" }); +}); + +test("blank secrets are omitted and typed secrets replace the stored value", async () => { + const { elements, window: pageWindow } = await startPage({ body: { ready: true, mode: "configured", missing: [] } }); + const mw = pageWindow.__mw; + mw.applyBusinessReadback({ + values: { + jmap_session_url: "https://mail.example.com/jmap", + jmap_username: "bot@example.com", + timezone: "Asia/Tokyo", + chat_allowlist: ["123456789"], + telegram_chat_id: "123456789", + llm_enabled: false, + llm_allow_net: false, + }, + secrets_present: { bot_token: true, jmap_password: true, telegram_webhook_secret: true, reconcile_token: true, worker_token: true, llm_api_key: false }, + }); + + // Every secret is blank after readback, so nothing is submitted for them. + assertShape(mw.businessPatchFromForm(), {}); + + // Typing a new value replaces the stored one. Assigning .value directly does + // not fire the input event the app listens to, so re-render the marker by hand. + elements.get("jmap-password").value = "new-password"; + mw.renderSecretMarkers(); + assertShape(mw.businessPatchFromForm(), { jmap_password: "new-password" }); + + // The presence marker flips to "will replace" while the input is non-blank. + assert.equal(elements.get("secret-marker-jmap_password").textContent.includes("replace"), true); + + // Clearing the input again restores "keep the stored value". + elements.get("jmap-password").value = ""; + mw.renderSecretMarkers(); + assertShape(mw.businessPatchFromForm(), {}); + assert.equal(elements.get("secret-marker-jmap_password").textContent.includes("stays"), true); +}); + +test("clearing an optional LLM field submits null", async () => { + const { elements, window: pageWindow } = await startPage({ body: { ready: true, mode: "configured", missing: [] } }); + const mw = pageWindow.__mw; + mw.applyBusinessReadback({ + values: { + jmap_session_url: "https://mail.example.com/jmap", + jmap_username: "bot@example.com", + timezone: "Asia/Tokyo", + chat_allowlist: ["123456789"], + telegram_chat_id: "123456789", + llm_base_url: "https://llm.example.com/v1", + llm_model: "gpt-4o", + llm_enabled: true, + llm_allow_net: true, + }, + secrets_present: { bot_token: true, jmap_password: true, telegram_webhook_secret: true, reconcile_token: true, worker_token: true, llm_api_key: true }, + }); + + elements.get("llm-model").value = ""; + const patch = mw.businessPatchFromForm(); + assertShape(patch, { llm_model: null }); +}); + +test("the allowlist is compared as an unordered set", async () => { + const { elements, window: pageWindow } = await startPage({ body: { ready: true, mode: "configured", missing: [] } }); + const mw = pageWindow.__mw; + mw.applyBusinessReadback({ + values: { + jmap_session_url: "https://mail.example.com/jmap", + jmap_username: "bot@example.com", + timezone: "Asia/Tokyo", + chat_allowlist: ["123456789", "987654321"], + telegram_chat_id: "123456789", + llm_enabled: false, + llm_allow_net: false, + }, + secrets_present: { bot_token: true, jmap_password: true, telegram_webhook_secret: true, reconcile_token: true, worker_token: true, llm_api_key: false }, + }); + + // Reordering the same ids is not a change. + elements.get("chat-allowlist").value = "987654321 123456789"; + assertShape(mw.businessPatchFromForm(), {}); + + // Adding an id is. The patch carries the operator's current order, not the + // stored order, and plain strings rather than BigInt objects. + elements.get("chat-allowlist").value = "987654321, 123456789, 555000000"; + const patch = mw.businessPatchFromForm(); + assertShape(patch, { chat_allowlist: ["987654321", "123456789", "555000000"] }); + + // A blank textarea keeps the stored list rather than deleting it. + elements.get("chat-allowlist").value = ""; + assertShape(mw.businessPatchFromForm(), {}); +}); + +test("flipping a boolean enters the patch and resetBusinessReadback clears the baseline", async () => { + const { elements, window: pageWindow } = await startPage({ body: { ready: true, mode: "configured", missing: [] } }); + const mw = pageWindow.__mw; + mw.applyBusinessReadback({ + values: { + jmap_session_url: "https://mail.example.com/jmap", + jmap_username: "bot@example.com", + timezone: "Asia/Tokyo", + chat_allowlist: ["123456789"], + telegram_chat_id: "123456789", + llm_enabled: true, + llm_allow_net: true, + }, + secrets_present: { bot_token: true, jmap_password: true, telegram_webhook_secret: true, reconcile_token: true, worker_token: true, llm_api_key: true }, + }); + + elements.get("llm-allow-net").checked = false; + assertShape(mw.businessPatchFromForm(), { llm_allow_net: false }); + + mw.resetBusinessReadback(); + assert.equal(mw.businessBaseline, null); + assert.equal(mw.businessSecretPresence, null); + // Markers fall back to "not set", so the operator knows the page has no view. + assert.equal(elements.get("secret-marker-bot_token").textContent.includes("Not set"), true); +}); + diff --git a/web/index.html b/web/index.html index b183b1a..5984ee1 100644 --- a/web/index.html +++ b/web/index.html @@ -94,19 +94,19 @@
HTTPS address without username, password, query parameters or #fragment.
-
+
02

Telegram and access auth

Only chat IDs in the allowlist may trigger bot operations.

-
+
Accepts a full signed 64-bit integer.
Integer list; format and duplicates are checked before saving.
-
-
-
This value differs from the current admin session. The page never reads or echoes an existing token.
+
+
+
This value differs from the current admin session. The page never reads or echoes an existing token.
@@ -119,7 +119,7 @@
HTTPS is required when LLM is enabled.
-
+
diff --git a/web/styles.css b/web/styles.css index e93ace4..e4cedaf 100644 --- a/web/styles.css +++ b/web/styles.css @@ -14,10 +14,10 @@ * { box-sizing: border-box; } body { min-width: 320px; min-height: 100vh; margin: 0; overflow-x: hidden; background: linear-gradient(148deg, #fafaff 0%, #f7f7fb 48%, #f5f6fa 100%); } -button, input, textarea { font: inherit; } +button, input, select, textarea { font: inherit; } button { cursor: pointer; } -button:disabled, input:disabled, textarea:disabled { cursor: not-allowed; } -button:focus-visible, input:focus-visible, textarea:focus-visible, a:focus-visible { outline: 3px solid #9f93fa; outline-offset: 3px; } +button:disabled, input:disabled, select:disabled, textarea:disabled { cursor: not-allowed; } +button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, a:focus-visible { outline: 3px solid #9f93fa; outline-offset: 3px; } [hidden] { display: none !important; } .ambient { position: fixed; z-index: -1; pointer-events: none; border-radius: 50%; filter: blur(2px); } @@ -74,7 +74,20 @@ code { padding: 2px 5px; border-radius: 4px; background: #f1f0fa; color: #534a9c .field textarea { min-height: 82px; resize: vertical; line-height: 1.55; } .token-row input::placeholder, .field input::placeholder, .field textarea::placeholder { color: #a5a5b4; } .token-row input:focus, .field input:focus, .field textarea:focus { outline: none; border-color: #8f82e8; box-shadow: 0 0 0 3px rgba(123, 108, 224, .12); } +/* `