feat: read business config back and accept partial patches
GET /api/business-config returns the stored values with presence-only secrets, and PUT now merges a patch against the stored config instead of replacing it, so the SPA can show what is already set and send back only the fields that changed. The SPA marks secrets as set-but-unreadable, edits chat_allowlist as an unordered set, and omits fields it did not touch; blank required fields keep the stored value, blank optional fields clear it. The read-back is Serialize-only, so a client cannot inject secrets_present. Forward both paths at the gateway: /api/business-config accepted PUT only, so the read-back returned 405, and the preflight path was missing from the safelist entirely, so it returned 404. The SPA is served from the Worker domain, so the whole edit experience was unreachable behind the production gateway. Route methods are now asserted per path, which the safelist test never checked. Also: /debug/config reports timezone again, the timezone select is styled like the other fields, and the docs follow the new contract.
This commit is contained in:
parent
953a4db6f0
commit
0cee50b4c9
|
|
@ -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 测试要求
|
||||
|
||||
|
|
|
|||
|
|
@ -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`)。
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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$/);
|
||||
|
|
|
|||
|
|
@ -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. 文档边界
|
||||
|
||||
|
|
|
|||
|
|
@ -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`。
|
||||
|
|
|
|||
|
|
@ -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 个值已人工逐个核对无误。
|
||||
|
||||
## 后续(多账号)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):**
|
||||
|
||||
|
|
|
|||
252
src/config.rs
252
src/config.rs
|
|
@ -220,6 +220,258 @@ impl From<BusinessConfig> 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<String>,
|
||||
#[serde(default, deserialize_with = "de_i64_from_number_or_string")]
|
||||
pub telegram_chat_id: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub chat_allowlist: Option<Vec<i64>>,
|
||||
#[serde(default)]
|
||||
pub telegram_webhook_secret: Option<String>,
|
||||
#[serde(default)]
|
||||
pub timezone: Option<String>,
|
||||
#[serde(default)]
|
||||
pub jmap_session_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub jmap_username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub jmap_password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub account_id: Option<Option<String>>,
|
||||
#[serde(default)]
|
||||
pub llm_enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub llm_allow_net: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub llm_api_key: Option<Option<String>>,
|
||||
#[serde(default)]
|
||||
pub llm_base_url: Option<Option<String>>,
|
||||
#[serde(default)]
|
||||
pub llm_model: Option<Option<String>>,
|
||||
#[serde(default)]
|
||||
pub reconcile_token: Option<String>,
|
||||
#[serde(default)]
|
||||
pub worker_token: Option<String>,
|
||||
}
|
||||
|
||||
/// `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<Option<i64>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de::Error;
|
||||
use serde::Deserialize;
|
||||
match Option::<serde_json::Value>::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<String>,
|
||||
pub timezone: String,
|
||||
pub telegram_chat_id: Option<i64>,
|
||||
pub chat_allowlist: Vec<i64>,
|
||||
pub llm_enabled: bool,
|
||||
pub llm_allow_net: bool,
|
||||
pub llm_base_url: Option<String>,
|
||||
pub llm_model: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<Option<String>>` 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<BusinessConfigWire> {
|
||||
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<BusinessConfigWire> for BusinessConfig {
|
||||
type Error = BotError;
|
||||
|
||||
|
|
|
|||
130
src/debug.rs
130
src/debug.rs
|
|
@ -113,6 +113,7 @@ async fn debug_config(State(app): State<AppState>, 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<SecretString>) -> Router {
|
||||
fn bootstrap_router_with_state<S: crate::state::ReliableState + 'static>(
|
||||
debug_token: Option<SecretString>,
|
||||
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<SecretString>) -> 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 {
|
||||
|
|
|
|||
109
src/notify.rs
109
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<BusinessConfigWire>),
|
||||
/// 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<dyn ReliableState>) -> StoredBusinessConfig {
|
||||
match state.get_business_config().await {
|
||||
Ok(None) => StoredBusinessConfig::Absent,
|
||||
Ok(Some(value)) => match serde_json::from_value::<BusinessConfigWire>(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<AppState>, 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::<serde_json::Value>(&body) else {
|
||||
return error_response(StatusCode::BAD_REQUEST, "invalid_request", false);
|
||||
};
|
||||
let Ok(wire) = serde_json::from_value::<BusinessConfigWire>(value.clone()) else {
|
||||
let Ok(patch) = serde_json::from_value::<BusinessConfigPatch>(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<BusinessConfig, _> = wire.try_into() else {
|
||||
return error_response(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
|
|
@ -1609,7 +1704,10 @@ fn router_with_worker_state_runtime_bootstrap_config<S: ReliableState + 'static>
|
|||
.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"),
|
||||
|
|
|
|||
325
web/config.js
325
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 },
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -94,19 +94,19 @@
|
|||
<div class="field-grid">
|
||||
<div class="field field-wide"><label for="jmap-session-url">JMAP Session URL <span class="required-mark" data-i18n="common.required">Required</span></label><input id="jmap-session-url" name="jmap_session_url" type="url" inputmode="url" autocomplete="url" required placeholder="https://mail.example.com/.well-known/jmap" /><small data-i18n="jmap.urlHint">HTTPS address without username, password, query parameters or #fragment.</small></div>
|
||||
<div class="field"><label for="jmap-username"><span data-i18n="jmap.userLabel">Email username</span> <span class="required-mark" data-i18n="common.required">Required</span></label><input id="jmap-username" name="jmap_username" type="text" autocomplete="username" required /></div>
|
||||
<div class="field"><label for="jmap-password"><span data-i18n="jmap.passLabel">App-specific password</span> <span class="required-mark" data-i18n="common.required">Required</span></label><input id="jmap-password" name="jmap_password" type="password" autocomplete="new-password" spellcheck="false" required data-i18n-placeholder="secret.reenter" placeholder="Re-enter on each full save" /></div>
|
||||
<div class="field"><label for="jmap-password"><span data-i18n="jmap.passLabel">App-specific password</span> <span class="required-mark" data-i18n="common.required">Required</span></label><input id="jmap-password" name="jmap_password" type="password" autocomplete="new-password" spellcheck="false" required data-i18n-placeholder="secret.reenter" placeholder="Re-enter on each full save" /><small id="secret-marker-jmap_password" class="secret-marker" role="status" aria-live="polite"></small></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section" aria-labelledby="telegram-title">
|
||||
<div class="form-section-heading"><span class="section-number">02</span><div><h3 id="telegram-title" data-i18n="telegram.title">Telegram and access auth</h3><p data-i18n="telegram.desc">Only chat IDs in the allowlist may trigger bot operations.</p></div></div>
|
||||
<div class="field-grid">
|
||||
<div class="field"><label for="telegram-bot-token">Telegram Bot Token <span class="required-mark" data-i18n="common.required">Required</span></label><input id="telegram-bot-token" name="bot_token" type="password" autocomplete="new-password" spellcheck="false" required data-i18n-placeholder="secret.reenter" placeholder="Re-enter on each full save" /></div>
|
||||
<div class="field"><label for="telegram-bot-token">Telegram Bot Token <span class="required-mark" data-i18n="common.required">Required</span></label><input id="telegram-bot-token" name="bot_token" type="password" autocomplete="new-password" spellcheck="false" required data-i18n-placeholder="secret.reenter" placeholder="Re-enter on each full save" /><small id="secret-marker-bot_token" class="secret-marker" role="status" aria-live="polite"></small></div>
|
||||
<div class="field"><label for="telegram-chat-id"><span data-i18n="telegram.chatLabel">Target Chat ID</span> <span class="required-mark" data-i18n="common.required">Required</span></label><input id="telegram-chat-id" name="telegram_chat_id" type="text" inputmode="numeric" autocomplete="off" required data-i18n-placeholder="telegram.chatPh" placeholder="e.g. -1001234567890" /><small data-i18n="telegram.chatHint">Accepts a full signed 64-bit integer.</small></div>
|
||||
<div class="field field-wide"><label for="chat-allowlist">Chat allowlist <span class="required-mark" data-i18n="common.atLeastOne">At least one</span></label><textarea id="chat-allowlist" name="chat_allowlist" rows="3" required spellcheck="false" data-i18n-placeholder="telegram.allowPh" placeholder="One chat ID per line or comma-separated"></textarea><small data-i18n="telegram.allowHint">Integer list; format and duplicates are checked before saving.</small></div>
|
||||
<div class="field"><label for="telegram-webhook-secret">Telegram Webhook Secret <span class="required-mark" data-i18n="common.required">Required</span></label><input id="telegram-webhook-secret" name="telegram_webhook_secret" type="password" autocomplete="new-password" spellcheck="false" required data-i18n-placeholder="secret.reenter" placeholder="Re-enter on each full save" /></div>
|
||||
<div class="field"><label for="reconcile-token">Reconcile Bearer Token <span class="required-mark" data-i18n="common.required">Required</span></label><input id="reconcile-token" name="reconcile_token" type="password" autocomplete="new-password" spellcheck="false" required data-i18n-placeholder="secret.reenter" placeholder="Re-enter on each full save" /></div>
|
||||
<div class="field field-wide"><label for="worker-token">Worker Token <span class="required-mark" data-i18n="common.required">Required</span></label><input id="worker-token" name="worker_token" type="password" autocomplete="new-password" spellcheck="false" required data-i18n-placeholder="telegram.workerPh" placeholder="Used by /worker and compatible admin APIs; re-enter on each full save" /><small data-i18n="telegram.workerHint">This value differs from the current admin session. The page never reads or echoes an existing token.</small></div>
|
||||
<div class="field"><label for="telegram-webhook-secret">Telegram Webhook Secret <span class="required-mark" data-i18n="common.required">Required</span></label><input id="telegram-webhook-secret" name="telegram_webhook_secret" type="password" autocomplete="new-password" spellcheck="false" required data-i18n-placeholder="secret.reenter" placeholder="Re-enter on each full save" /><small id="secret-marker-telegram_webhook_secret" class="secret-marker" role="status" aria-live="polite"></small></div>
|
||||
<div class="field"><label for="reconcile-token">Reconcile Bearer Token <span class="required-mark" data-i18n="common.required">Required</span></label><input id="reconcile-token" name="reconcile_token" type="password" autocomplete="new-password" spellcheck="false" required data-i18n-placeholder="secret.reenter" placeholder="Re-enter on each full save" /><small id="secret-marker-reconcile_token" class="secret-marker" role="status" aria-live="polite"></small></div>
|
||||
<div class="field field-wide"><label for="worker-token">Worker Token <span class="required-mark" data-i18n="common.required">Required</span></label><input id="worker-token" name="worker_token" type="password" autocomplete="new-password" spellcheck="false" required data-i18n-placeholder="telegram.workerPh" placeholder="Used by /worker and compatible admin APIs; re-enter on each full save" /><small id="secret-marker-worker_token" class="secret-marker" role="status" aria-live="polite"></small><small data-i18n="telegram.workerHint">This value differs from the current admin session. The page never reads or echoes an existing token.</small></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -119,7 +119,7 @@
|
|||
<div class="field-grid llm-fields">
|
||||
<div class="field"><label for="llm-base-url">LLM Base URL <span id="llm-url-required" class="optional-mark" data-i18n="common.optional">Optional</span></label><input id="llm-base-url" name="llm_base_url" type="url" inputmode="url" autocomplete="url" placeholder="https://api.example.com/v1" /><small data-i18n="llm.urlHint">HTTPS is required when LLM is enabled.</small></div>
|
||||
<div class="field"><label for="llm-model"><span data-i18n="llm.modelLabel">Model name</span> <span id="llm-model-required" class="optional-mark" data-i18n="common.optional">Optional</span></label><input id="llm-model" name="llm_model" type="text" autocomplete="off" data-i18n-placeholder="llm.modelPh" placeholder="Leave blank for null" /></div>
|
||||
<div class="field field-wide"><label for="llm-api-key">LLM API Key <span id="llm-key-required" class="optional-mark" data-i18n="common.optional">Optional</span></label><input id="llm-api-key" name="llm_api_key" type="password" autocomplete="new-password" spellcheck="false" data-i18n-placeholder="llm.keyPh" placeholder="Required when LLM is enabled; never echoed" /></div>
|
||||
<div class="field field-wide"><label for="llm-api-key">LLM API Key <span id="llm-key-required" class="optional-mark" data-i18n="common.optional">Optional</span></label><input id="llm-api-key" name="llm_api_key" type="password" autocomplete="new-password" spellcheck="false" data-i18n-placeholder="llm.keyPh" placeholder="Required when LLM is enabled; never echoed" /><small id="secret-marker-llm_api_key" class="secret-marker" role="status" aria-live="polite"></small></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
/* `<select>` (the timezone picker in section 04) has no UA styling anywhere else
|
||||
in this sheet, so it fell back to the browser default: a different border, a
|
||||
native chevron, and a ~20px shorter box than the inputs next to it. Mirror
|
||||
.field input exactly, then draw our own chevron over a neutral padding box. */
|
||||
.field select { width: 100%; min-width: 0; min-height: 42px; padding: 10px 34px 10px 12px; border: 1px solid #dedeea; border-radius: 8px; background-color: #fff; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1.5 2.5 6 7l4.5-4.5' fill='none' stroke='%236b5fd1' stroke-width='1.7' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; background-size: 11px 8px; color: var(--ink); font-size: 13px; cursor: pointer; -webkit-appearance: none; appearance: none; transition: border-color .15s, box-shadow .15s; }
|
||||
.field select:focus { outline: none; border-color: #8f82e8; box-shadow: 0 0 0 3px rgba(123, 108, 224, .12); }
|
||||
.field-hint, .field small, .setting-card small { display: block; margin: 7px 0 0; color: #9293a4; font-size: 11px; line-height: 1.5; }
|
||||
/* Presence marker for a secret field. Populated by config.js from the
|
||||
secrets_present booleans returned by GET /api/business-config; the marker
|
||||
never shows the stored value, only whether one exists and whether the input
|
||||
currently blanks it out (keep) or types over it (replace). */
|
||||
.secret-marker { display: block; margin: 7px 0 0; font-size: 11px; font-weight: 600; line-height: 1.5; color: #2e7d4f; }
|
||||
.secret-marker.is-unset { color: #9293a4; font-weight: 500; }
|
||||
.secret-marker:empty { display: none; }
|
||||
|
||||
.button { min-height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; padding: 0 15px; border: 1px solid transparent; border-radius: 8px; font-size: 12px; font-weight: 680; transition: color .16s, background .16s, border-color .16s, box-shadow .16s, transform .16s; white-space: nowrap; }
|
||||
.button:active:not(:disabled) { transform: translateY(1px); }
|
||||
|
|
@ -109,7 +122,7 @@ code { padding: 2px 5px; border-radius: 4px; background: #f1f0fa; color: #534a9c
|
|||
.section-copy { margin: -3px 25px 18px; color: var(--muted); font-size: 12px; line-height: 1.65; }
|
||||
|
||||
fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
|
||||
fieldset:disabled .field input, fieldset:disabled .field textarea { background: #fafafd; color: #858697; }
|
||||
fieldset:disabled .field input, fieldset:disabled .field textarea, fieldset:disabled .field select { background: #fafafd; color: #858697; }
|
||||
.form-section { padding: 22px 25px 24px; border-top: 1px solid #efeff5; }
|
||||
.form-section-heading { display: flex; align-items: flex-start; gap: 13px; margin-bottom: 17px; }
|
||||
.form-section-heading > div { min-width: 0; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue