diff --git a/Dockerfile b/Dockerfile index e085b02..e397445 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,7 @@ FROM rust:1-slim-bookworm AS builder WORKDIR /build ENV CARGO_REGISTRIES_CRATES_IO_INDEX=sparse+https://mirrors.ustc.edu.cn/crates.io-index/ +COPY build.rs . COPY Cargo.toml Cargo.lock ./ COPY src ./src COPY web ./web diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..0450cc5 --- /dev/null +++ b/build.rs @@ -0,0 +1,41 @@ +//! Emits `BUILD_VERSION` into the binary at compile time. +//! +//! The SPA is baked into the binary (`src/web.rs` uses `include_str!` on `web/`), so there is +//! no separate static asset to version. A deployed operator otherwise has no way to tell +//! whether a new binary actually landed, which makes "did the deploy take effect?" +//! undecidable from the outside. `BUILD_VERSION` closes that gap: the SPA shows it in the +//! footer and `/api/status` exposes it, so one browser refresh answers the question. +//! +//! The UTC build timestamp is the load-bearing part. It changes on every build cargo +//! recompiles, and `date` is guaranteed present in the Debian-based build images. The git +//! SHA is best-effort: the production build image is `rust:slim-trixie`, which does not ship +//! git, so absence must not degrade the fingerprint — the fallback still marks which build +//! ran. +use std::process::Command; + +fn command_stdout(program: &str, args: &[&str]) -> Option { + Command::new(program) + .args(args) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) +} + +fn git_sha() -> Option { + command_stdout("git", &["rev-parse", "--short", "HEAD"]) +} + +fn build_time_utc() -> String { + command_stdout("date", &["-u", "+%Y-%m-%dT%H:%M:%SZ"]).unwrap_or_else(|| "unknown".to_owned()) +} + +fn main() { + let version = match git_sha() { + Some(sha) => format!("{sha}+{}", build_time_utc()), + None => format!("nogit+{}", build_time_utc()), + }; + println!("cargo:rustc-env=BUILD_VERSION={version}"); +} diff --git a/docs/deployment.md b/docs/deployment.md index d0461bf..8996c33 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -159,6 +159,12 @@ HostStack 使用仓库根目录 `hoststack.yaml`:`runtime: rust` 的 agent 在 或代码仓库。**本仓库的 Dockerfile 不由该路径执行**——runner 镜像是 HostStack 自带的 `debian:trixie-slim`,不是本文件的 `debian:bookworm-slim`,见 §3 与 §8.1 的说明。 +**构建指纹 `BUILD_VERSION`(`build.rs`)**:编译期用 `git rev-parse --short HEAD` + `date -u` +拼出 `+` 并写入 `BUILD_VERSION`。`git` 在构建镜像里通常不存在, +此时退回 `nogit+<时间戳>` —— 时间戳部分是真正可用的判据,不会静默退化成固定值。 +镜像里的 `cargo build` 会自动执行该 build script(Cargo 对 workspace 根 build.rs 无条件执行, +无需额外配置)。SPA 页脚与 `GET /api/status` 的 `version` 字段都取自它,用来确认部署是否真正落地。 + **配置优先级(三层,从 HostStack agent 源码实测确认)**:部署时 agent 先把控制台 services 表里已存的值填进 payload,再用 `hoststack.yaml` 声明的值覆盖,最后才用运行时 框架默认值补缺。源码判定条件是 `if (svcConfig.install?.command)` 才覆盖,且没有 @@ -284,11 +290,15 @@ TLS 必须由 Cloudflare 或受信任反向代理终结;代理到容器的链 # 平台公网 URL: https://bot.example.com → 反代 → 容器 127.0.0.1:8080 GET / 业务配置与运行参数管理 SPA GET /assets/config.js SPA 脚本;GET /assets/styles.css SPA 样式 -GET /api/status 公开启动状态;只返回 ready/mode/missing 环境变量名 +GET /api/status 公开启动状态;只返回 ready/mode/missing 环境变量名 + version 构建指纹 POST /api/admin/session Bearer CONFIG_ENCRYPTION_KEY;成功返回 900 秒 admin session POST /api/admin/session/revoke Bearer admin session;成功返回 204 GET|PUT /api/config Bearer admin session 或 WORKER_TOKEN;Redis 错误返回 503 -PUT /api/business-config Bearer admin session 或 WORKER_TOKEN;完整替换,成功返回 204 +PUT /api/business-config Bearer admin session 或 WORKER_TOKEN;校验通过后必持久化, + 客户端构建失败返回 200+warnings(不阻断保存),reload 成功才 runtime_applied=true +POST /api/business-config/preflight Bearer admin session 或 WORKER_TOKEN;同样的校验与客户端构建, + 不写入、不换 worker;返回 200 + 逐组件结论 +POST /api/bootstrap 与 PUT 同契约(失败也持久化并回 200+warnings) POST /webhook/tg TG Webhook → [鉴权 SAF-AUTH-TG-WEBHOOK: 头 X-Telegram-Bot-Api-Secret-Token] → 快速 2xx ACK → 幂等去重(MOD-DEDUP) → 命令处理 → 同步回复(快路径) POST /push/jmap Stalwart Push 回调 → [鉴权 SAF-AUTH-JMAP-PUSH: Body pushSubscriptionId + verificationCode] @@ -494,7 +504,7 @@ docker run --env-file .env -p 8080:8080 messageweave:latest |---|---|---| | `GET /healthz` | 无条件 200 | 存活探测(进程还活着) | | `GET /ready` | 配置、Redis 或上游探针失败时 503 | 就绪探测、负载均衡摘除、Uptime Kuma | -| `GET /api/status` | 恒 200;返回 `{"ready":bool,"mode":"configured"\|"configuration-setup","missing":[...]}`,只列缺少的环境变量名称 | 排查"起来了但没干活" | +| `GET /api/status` | 恒 200;返回 `{"ready":bool,"mode":"configured"\|"configuration-setup","missing":[...],"version":"<构建指纹>"}`,只列缺少的环境变量名称;`version` 为 `BUILD_VERSION`(`+`) | 排查"起来了但没干活";`version` 用于确认部署是否落地 | > **由平台 ingress 探测 `/ready`。**镜像现已内置 `curl`(见 Dockerfile `runtime` 阶段), > 所以在 Dockerfile 部署路径下容器内写 `HEALTHCHECK` 在技术上可行(HostStack 的 `runtime: rust` diff --git a/docs/design.md b/docs/design.md index dbe5ed8..fd5e704 100644 --- a/docs/design.md +++ b/docs/design.md @@ -418,7 +418,7 @@ message-weave/ ### 7.3.1 配置管理 API - `GET /` 提供嵌入 Rust 二进制的 SPA;`/assets/config.js` 与 `/assets/styles.css` 提供页面资源。服务不在运行时读取或写入本地文件(`C-NO-LOCAL-WRITE`)。 -- `GET /api/status` 公开返回 `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[] }`,只列缺少的环境变量名称。缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时,SPA 只显示配置引导状态与缺失变量;服务状态确认 ready=true 后才显示管理会话授权区。 +- `GET /api/status` 公开返回 `{ "ready": boolean, "mode": "configured" | "configuration-setup", "missing": string[], "version": string }`,只列缺少的环境变量名称。缺少 `REDIS_URL` 或 `CONFIG_ENCRYPTION_KEY` 时,SPA 只显示配置引导状态与缺失变量;服务状态确认 ready=true 后才显示管理会话授权区。`version` 是 `build.rs` 在编译期烘焙的 `BUILD_VERSION`(`+`),SPA 页脚据此确认部署是否真正落地。 - `POST /api/admin/session` 接受 `Authorization: Bearer `,返回 `{ "session": "", "expires_in": 900 }`;admin session 仅存 Redis 中的摘要并在 900 秒后过期。`POST /api/admin/session/revoke` 撤销当前 session,成功返回 `204`。 - 管理页面仅在 JavaScript 内存中保存 session。请求设置 `credentials: omit`、`cache: no-store`,不使用 Cookie、localStorage 或 sessionStorage。管理 API 接受有效 admin session;兼容路径也接受 `WORKER_TOKEN`。Worker 原样透传鉴权头(`SAF-LB-PASSTHRU`)。 - `GET /api/config` 与 `PUT /api/config` 只读取和写入非敏感运行参数: @@ -431,8 +431,10 @@ message-weave/ } ``` - 三个 timeout 单位均为毫秒;默认值、取值范围与 `max_retries` 的硬上限以 `docs/reference.md` §6.1 为唯一权威(下表与上面的示例响应体只做示意,不重复数值)。`max_retries` 表示首次请求之外的最大重试次数。Redis 中尚无配置时 GET 返回默认值对象;PUT 成功返回保存后的相同对象并持久化到 Redis(`C-REDIS-ONLY-STATE`)。 -- `PUT /api/business-config` 接受完整 `BusinessConfigWire`,完整替换加密保存的业务配置,先构建客户端再切换运行 worker;Push verification 不属于 Wire,由 Stalwart 动态生成并由后端回写。成功返回 `204 No Content`,不会返回配置或密钥。 -- `PUT /api/config` 的运行参数错误使用 `401`(未授权)、`400`(JSON 无效)、`422`(范围错误)、`503`(Redis 不可用);GET 读取失败时也以 `503` 表示 Redis 不可用。业务 PUT 使用 `401`、`400`、`422` 与 `503`(Redis 写入或依赖客户端构建失败);其余错误不回显敏感数据。Redis/会话初始化未完成时管理页面显示 `503` 状态;运行参数在重新读取成功前禁用保存(`C-REDIS-ONLY-STATE`)。LLM 启用时,业务配置要求提供 HTTPS Base URL、非空 API key 和模型名。 +- `PUT /api/business-config` 接受完整 `BusinessConfigWire`,完整替换加密保存的业务配置;Push verification 不属于 Wire,由 Stalwart 动态生成并由后端回写。**校验是唯一写入闸门**:`validate_business_wire` 先跑,拒绝时返回真实的 `422`;Wire 合法则配置**一定**被持久化,之后才构建客户端并切换运行 worker。因此依赖连不通时返回 `200` + `{ "persisted": true, "runtime_applied": false, "warnings": [{"component", "step", "detail"}] }`,而不是 `503` —— 配置已保存、故障被如实上报,而不是被一个不可达的 JMAP 静默吞掉。`runtime_applied` 仅在 reload 提交成功时为 `true`;两种情况都返回 `x-business-config-revision` 头。响应从不回显配置或密钥。 +- `POST /api/business-config/preflight` 对提交体执行完全相同的校验与客户端构建,返回逐组件结论,**不写入、不触碰运行 worker**:`{ "persisted": false, "validation": { "ok", "errors" }, "components": { "jmap": {...}, "llm": ... } }`。校验已失败时 `components` 为 `null`(被拒的 Wire 无需再探客户端);`llm_enabled`/`llm_allow_net` 为 false 时 `llm` 为 `null` 而非虚假的 `ok`。鉴权与管理会话门(`config_authorized`)一致,刻意不挂在 `DEBUG_TOKEN` 之后。 +- `POST /api/bootstrap` 与 PUT 共用上述契约:失败也持久化并回 `200` + warnings。 +- `PUT /api/config` 的运行参数错误使用 `401`(未授权)、`400`(JSON 无效)、`422`(范围错误)、`503`(Redis 不可用);GET 读取失败时也以 `503` 表示 Redis 不可用。业务 PUT 使用 `401`(未授权)、`400`(JSON 无效)、`422`(Wire 校验失败)与 `503`(Redis 写入或 revision 读取失败);依赖客户端构建失败**不**返回错误状态码,而是 `200` + warnings(见上条)。其余错误不回显敏感数据。Redis/会话初始化未完成时管理页面显示 `503` 状态;运行参数在重新读取成功前禁用保存(`C-REDIS-ONLY-STATE`)。LLM 启用时,业务配置要求提供 HTTPS Base URL、非空 API key 和模型名。 ### 7.4 TLS(代码/依赖行为) - TLS provider 由依赖 feature 决定:**jmap-client 0.4.2 默认含 `aws_lc_rs`(并引入 `rustls`)**,`default-features = true` 时并非"rustls 默认"。**本项目实际 `default-features = false, features = ["async","rustls"]`**(不启用 `aws_lc_rs`/`websockets`),最终以 `Cargo.toml` 为准。 diff --git a/docs/reference.md b/docs/reference.md index 79d08f6..7af5d92 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -218,6 +218,7 @@ three of this table — `/healthz`, `/ready` and `/api/status` — and no busine | GET, PUT | `/api/config` | `get_config` / `put_config` | | GET, PUT | `/api/enabled` | `get_enabled` / `put_enabled` | | PUT | `/api/business-config` | `put_business_config` | +| POST | `/api/business-config/preflight` | `preflight_business_config` | | POST | `/api/bootstrap` | `bootstrap` | | POST | `/api/admin/session/revoke` | `revoke_admin_session` | | POST | `/api/admin/session` | `create_admin_session` | @@ -259,10 +260,12 @@ Response conventions: `probe_telegram_get_me`) also used by the remote-debug path, so they must not be duplicated. The JMAP probe deliberately authenticates against the *normalized origin*: probing the raw session URL unauthenticated would report not-ready forever and make ingress stop routing. -- `GET /api/status` (**always 200**, `notify.rs:166-174`) returns - `{"ready": , "mode": "env"|"redis", "missing": [...]}`. `ready` is `false` and - `missing` lists the absent required keys (`REDIS_URL`, `CONFIG_ENCRYPTION_KEY`) when a - variable is absent — the route itself never errors, so it is safe to poll. +- `GET /api/status` (**always 200**, `notify.rs:214-223`) returns + `{"ready": , "mode": "configured"|"configuration-setup", "missing": [...], "version": ""}`. + `ready` is `false` and `missing` lists the absent required keys (`REDIS_URL`, + `CONFIG_ENCRYPTION_KEY`) when a variable is absent — the route itself never errors, so it is + safe to poll. `version` is the `BUILD_VERSION` string baked in by `build.rs` + (`+`) and is how the SPA footer proves a deploy landed. - `/debug/*` returns **503** in exactly one place: `POST /debug/notify` when the business config is not loaded or there is no outbound client — `service_unavailable` with `Retry-After: 30` (`src/debug.rs:56-58`). The three probe endpoints instead report failure @@ -361,6 +364,40 @@ successful write creates the configuration, later writes hot-reload it. The same also exposed as a one-shot `POST /api/bootstrap` against the backend origin for automation. The process never reads these from the environment in normal operation. +**Persist-and-report, not persist-if-connectable.** Validation is the only gate on the write: +`validate_business_wire` runs first and a rejection is a genuine `422`. Once the wire is valid +the configuration is *always* persisted, and only then are the clients built and the running +worker swapped. A failed build therefore returns **200** with `persisted: true`, +`runtime_applied: false` and a `warnings` array of `{component, step, detail}` objects, where +`component` is `jmap` or `llm` and `step` is `connect`, `account`, `config` or `build`, rather +than a 503. The configuration is saved and the outage is reported instead of hidden. +`runtime_applied` is +`true` only when the reload was committed. This is deliberate: an unreachable JMAP must not +turn a valid configuration into a silent data loss. `PUT /api/business-config` and +`POST /api/bootstrap` both follow this contract; the revision header is still returned in both +cases. + +`POST /api/business-config/preflight` (`notify.rs:808-871`) runs the identical validation and +client build against the submitted wire and returns the per-component verdict without writing +anything and without touching the running worker: + +```json +{ + "persisted": false, + "validation": { "ok": true, "errors": [] }, + "components": { + "jmap": { "ok": false, "errors": [{ "component": "jmap", "step": "connect", "detail": "..." }] }, + "llm": null + } +} +``` + +`components` is `null` when validation already failed (there is no point probing clients from a +wire that was rejected), and `llm` is `null` when `llm_enabled`/`llm_allow_net` are false +rather than vacuously `ok`. A preflight whose answer is "the configuration is bad" is a +**200**, because that is the answer the caller asked for. Authentication is the same +`config_authorized` gate as the write path; it is deliberately **not** behind `DEBUG_TOKEN`. + ### 5.3 No legacy environment path `Config::from_env()` and its helpers (`required_secret`, `required_nonblank`, `env_bool`) are @@ -401,10 +438,10 @@ endpoint. | Parameter | Default | Range / cap | Source | |---|---|---|---| -| `jmap_timeout_ms` | 15_000 | 100..=300_000 | default state.rs:56; validated notify.rs:492 | -| `telegram_timeout_ms` | 10_000 | 100..=300_000 | default state.rs:57; validated notify.rs:493 | -| `llm_timeout_ms` | 30_000 | 100..=300_000 | default state.rs:58; validated notify.rs:494 | -| `max_retries` | 3 | hard cap 5 | default state.rs:59; rejected at notify.rs:495; re-clamped at channel.rs:138 | +| `jmap_timeout_ms` | 15_000 | 100..=300_000 | default state.rs:56; validated notify.rs:541 | +| `telegram_timeout_ms` | 10_000 | 100..=300_000 | default state.rs:57; validated notify.rs:542 | +| `llm_timeout_ms` | 30_000 | 100..=300_000 | default state.rs:58; validated notify.rs:543 | +| `max_retries` | 3 | hard cap 5 | default state.rs:59; rejected at notify.rs:544; re-clamped at channel.rs:138 | There is **no** `LLM_MAX_RETRIES` environment variable; retry count lives in `config:outbound` and is bounded at 5 regardless of what is written. diff --git a/src/config.rs b/src/config.rs index 6d6a48a..e83dc3e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -103,6 +103,7 @@ pub(crate) struct BusinessConfigWire { pub jmap_session_url: String, pub jmap_username: String, pub jmap_password: String, + #[serde(default)] pub account_id: Option, pub llm_enabled: bool, pub llm_allow_net: bool, diff --git a/src/notify.rs b/src/notify.rs index b80f358..2d70125 100644 --- a/src/notify.rs +++ b/src/notify.rs @@ -3,12 +3,13 @@ use axum::{ body::Bytes, extract::State, http::{header, HeaderMap, StatusCode}, - response::{IntoResponse, Response}, + response::{IntoResponse, Json, Response}, routing::{get, post, put}, Router, }; use ring::rand::SecureRandom; use secrecy::{ExposeSecret, SecretString}; +use serde_json::{json, Value}; use std::{ collections::HashSet, sync::{ @@ -24,12 +25,59 @@ use crate::config::{ session_digest, validate_business_wire, AuthSecrets, BusinessConfig, BusinessConfigWire, }; use crate::domain::jmap::{client::JmapClientBackend, JmapService}; +use crate::error::BotError; use crate::state::MemoryState; use crate::state::{runtime_provider, OutboundConfig, ReliableState, RuntimeConfigProvider}; pub use crate::worker::{ MetadataWorker, NoopWorker, ReloadCoordinator, WorkerHandle, WorkerHandler, }; +/// Build fingerprint baked in by `build.rs` (git SHA plus UTC build time, when available). +/// +/// The SPA is `include_str!`-baked into this binary, so this is the only surface an operator +/// has to confirm that a deploy actually landed. See `build.rs` for the fallback chain. +pub(crate) const BUILD_VERSION: &str = env!("BUILD_VERSION"); + +#[derive(Clone, Debug, serde::Serialize)] +struct WorkerBuildFailure { + component: String, + step: String, + detail: String, +} + +impl WorkerBuildFailure { + fn new(component: &str, step: &str, detail: String) -> Self { + Self { + component: component.to_owned(), + step: step.to_owned(), + detail: redact_credentials(&detail), + } + } +} + +/// Removes URL credentials before a transport error reaches logs or a client. +/// +/// jmap-client aborts redirects it does not trust and echoes the host it was pointed at; +/// Stalwart additionally bakes `user:pass` into the 307 `Location`. Anything of that shape +/// must not reach the log line or a preflight response body. +fn redact_credentials(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let mut rest = value; + while let Some(marker) = rest.find("://") { + let scheme_end = marker + 3; + let Some(cred_start) = rest[scheme_end..].find('@') else { + out.push_str(rest); + return out; + }; + let absolute_start = scheme_end + cred_start; + out.push_str(&rest[..absolute_start]); + out.push_str(""); + rest = &rest[absolute_start + 1..]; + } + out.push_str(rest); + out +} + #[derive(Clone)] struct AuthState { reconcile_token: SecretString, @@ -169,6 +217,7 @@ async fn setup_status(State(app): State) -> Response { "ready": ready, "mode": if ready { "configured" } else { "configuration-setup" }, "missing": app.setup_missing.as_ref(), + "version": BUILD_VERSION, })) .into_response() } @@ -542,8 +591,22 @@ async fn business_enabled(app: &AppState) -> bool { app.state.is_enabled().await.unwrap_or(false) } -/// Replace the complete encrypted business configuration. Client construction is performed -/// before persistence and swap, so malformed/unreachable settings leave the old worker live. +/// Replace the complete encrypted business configuration. +/// +/// 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: +/// an operator must be able to save a half-migrated target, or one whose JMAP/LLM endpoint is +/// not reachable yet, and finish the wiring when the endpoint comes back. Requiring a live +/// connection for the write to succeed turns an unreachable dependency into a data-loss trap — +/// nothing could be saved while it is down, and there would be no log line saying why. +/// +/// Validation failure and a genuine persistence failure still reject, because both would +/// contradict the write that was requested. A failed rebuild is reported in the response body +/// and in a redacted log line, and the previous runtime keeps serving: `business_config` is +/// updated either way (so `/api/config` and `/debug/config` can show what was saved) while +/// `business_runtime` — the configuration the live worker actually speaks — is replaced only +/// on success. The revision is advanced in both cases so `refresh_business_config` does not +/// rebuild on every request until the dependency recovers. async fn put_business_config( State(app): State, headers: HeaderMap, @@ -577,31 +640,65 @@ async fn put_business_config( false, ); }; - let Ok(worker) = build_worker(config.clone(), app.clone()).await else { - return error_response(StatusCode::SERVICE_UNAVAILABLE, "service_unavailable", true); - }; if app.state.set_business_config(&value).await.is_err() { return error_response(StatusCode::SERVICE_UNAVAILABLE, "service_unavailable", true); } - if app.reload.commit(config.clone(), worker).is_err() { - return error_response(StatusCode::SERVICE_UNAVAILABLE, "service_unavailable", true); - } + let (warnings, runtime_applied) = match build_worker_report(config.clone(), app.clone()).await { + Ok(worker) => match app.reload.commit(config.clone(), worker) { + Ok(()) => (Vec::new(), true), + Err(()) => { + return error_response(StatusCode::SERVICE_UNAVAILABLE, "service_unavailable", true) + } + }, + Err(failures) => { + for failure in &failures { + tracing::warn!( + component = %failure.component, + step = %failure.step, + detail = %failure.detail, + "business configuration persisted but its runtime could not be rebuilt; the previous runtime keeps serving" + ); + } + (failures, false) + } + }; if let Ok(mut current) = app.business_config.write() { *current = Some(value); } - if let Ok(mut current) = app.business_runtime.write() { - *current = Some(config); - } - if let Ok(revision) = app.state.business_config_revision().await { - if let Ok(mut current) = app.business_revision.write() { - *current = revision; + if runtime_applied { + if let Ok(mut current) = app.business_runtime.write() { + *current = Some(config); } } - StatusCode::NO_CONTENT.into_response() + let revision = match app.state.business_config_revision().await { + Ok(revision) => revision, + Err(_) => { + return error_response(StatusCode::SERVICE_UNAVAILABLE, "service_unavailable", true) + } + }; + if let Ok(mut current) = app.business_revision.write() { + *current = revision; + } + ( + StatusCode::OK, + [("x-business-config-revision", revision.to_string())], + Json(json!({ + "persisted": true, + "runtime_applied": runtime_applied, + "warnings": warnings, + })), + ) + .into_response() } /// One-shot business-config bootstrap. The admin credential is only compared in constant time /// and is never included in the response; SET-NX in ReliableState closes the init race. +/// +/// Like `put_business_config`, persistence is gated by validation rather than connectivity. +/// SET-NX consumes the one-shot slot regardless of whether a live worker was built, which is +/// deliberate: the configuration is saved, the failure is reported and logged, and the same +/// body can be retried through `PUT /api/business-config` — which has no one-shot limit. +#[allow(clippy::too_many_lines)] async fn bootstrap(State(app): State, headers: HeaderMap, body: Bytes) -> Response { if !worker_authorized(&headers, app.admin_token.expose_secret()) { return error_response(StatusCode::UNAUTHORIZED, "unauthorized", false); @@ -630,43 +727,177 @@ async fn bootstrap(State(app): State, headers: HeaderMap, body: Bytes) false, ); }; - // Build and connect all clients before SET-NX: an unreachable JMAP must not consume - // the one-shot bootstrap slot or leave an unusable encrypted snapshot in Redis. - let Ok(next_worker) = build_worker(next_config.clone(), app.clone()).await else { - return error_response(StatusCode::SERVICE_UNAVAILABLE, "service_unavailable", true); - }; match app.state.initialize_business_config(&config).await { Ok(true) => { - if app.reload.commit(next_config.clone(), next_worker).is_err() { - return error_response( - StatusCode::SERVICE_UNAVAILABLE, - "service_unavailable", - true, - ); - } + let (warnings, runtime_applied) = match build_worker_report( + next_config.clone(), + app.clone(), + ) + .await + { + Ok(next_worker) => match app.reload.commit(next_config.clone(), next_worker) { + Ok(()) => (Vec::new(), true), + Err(()) => { + return error_response( + StatusCode::SERVICE_UNAVAILABLE, + "service_unavailable", + true, + ) + } + }, + Err(failures) => { + for failure in &failures { + tracing::warn!( + component = %failure.component, + step = %failure.step, + detail = %failure.detail, + "business configuration persisted but its runtime could not be rebuilt; the previous runtime keeps serving" + ); + } + (failures, false) + } + }; if let Ok(mut current) = app.business_config.write() { *current = Some(config); } - if let Ok(mut current) = app.business_runtime.write() { - *current = Some(next_config); - } - if let Ok(revision) = app.state.business_config_revision().await { - if let Ok(mut current) = app.business_revision.write() { - *current = revision; + if runtime_applied { + if let Ok(mut current) = app.business_runtime.write() { + *current = Some(next_config); } } - StatusCode::NO_CONTENT.into_response() + let revision = match app.state.business_config_revision().await { + Ok(revision) => revision, + Err(_) => { + return error_response( + StatusCode::SERVICE_UNAVAILABLE, + "service_unavailable", + true, + ) + } + }; + if let Ok(mut current) = app.business_revision.write() { + *current = revision; + } + ( + StatusCode::OK, + [("x-business-config-revision", revision.to_string())], + Json(json!({ + "persisted": true, + "runtime_applied": runtime_applied, + "warnings": warnings, + })), + ) + .into_response() } Ok(false) => error_response(StatusCode::CONFLICT, "conflict", false), Err(_) => error_response(StatusCode::SERVICE_UNAVAILABLE, "service_unavailable", true), } } -async fn build_worker( +/// Test a business configuration without saving it. +/// +/// Same admin-session gate as the write endpoint, but nothing is persisted and no worker is +/// installed. It answers "why would saving this fail?" by running the exact validation and the +/// exact client construction the write path uses, and reports the result per component so one +/// broken endpoint does not hide the other. `components` is `null` when validation already +/// failed — building clients from an invalid wire format is meaningless. +/// +/// This endpoint never fails the whole request for a configuration problem: a preflight whose +/// answer is "the configuration is bad" is a successful preflight, so the SPA can render the +/// reason instead of a generic HTTP error. +async fn preflight_business_config( + State(app): State, + headers: HeaderMap, + body: Bytes, +) -> Response { + if !config_authorized(&app, &headers).await { + return error_response(StatusCode::UNAUTHORIZED, "unauthorized", false); + } + let Ok(value) = serde_json::from_slice::(&body) else { + return Json(json!({ + "persisted": false, + "validation": { "ok": false, "errors": ["请求体不是有效的 JSON"] }, + "components": Value::Null, + })) + .into_response(); + }; + let Ok(wire) = serde_json::from_value::(value.clone()) else { + return Json(json!({ + "persisted": false, + "validation": { "ok": false, "errors": ["JSON 不是有效的业务配置对象"] }, + "components": Value::Null, + })) + .into_response(); + }; + let errors = match validate_business_wire(wire.clone()) { + Ok(()) => Vec::new(), + Err(BotError::Config(message)) => vec![message], + Err(error) => vec![error.to_string()], + }; + if !errors.is_empty() { + return Json(json!({ + "persisted": false, + "validation": { "ok": false, "errors": errors }, + "components": Value::Null, + })) + .into_response(); + } + let Ok(config): Result = wire.try_into() else { + return Json(json!({ + "persisted": false, + "validation": { "ok": false, "errors": ["配置转换失败"] }, + "components": Value::Null, + })) + .into_response(); + }; + let llm_enabled = config.llm_enabled && config.llm_allow_net; + let failures = match build_worker_report(config, app.clone()).await { + Ok(_) => Vec::new(), + Err(failures) => failures, + }; + Json(json!({ + "persisted": false, + "validation": { "ok": true, "errors": [] }, + "components": { + "jmap": component_result(&failures, "jmap"), + "llm": if llm_enabled { + Some(component_result(&failures, "llm")) + } else { + None + }, + }, + })) + .into_response() +} + +/// Projects the collected build failures onto one component. An empty projection means that +/// component built successfully, which is the only honest way to say "this one is fine". +fn component_result(failures: &[WorkerBuildFailure], component: &str) -> Value { + let own: Vec<&WorkerBuildFailure> = failures + .iter() + .filter(|failure| failure.component == component) + .collect(); + if own.is_empty() { + json!({ "ok": true }) + } else { + json!({ "ok": false, "errors": own }) + } +} + +/// Builds the runtime backend chain, reporting *which components and steps* failed. +/// +/// JMAP and LLM are probed independently rather than short-circuiting on the first failure: +/// the two clients share nothing, and an operator who saved a new JMAP endpoint wants to know +/// that the LLM endpoint was also rejected at the same time. `None` in the result therefore +/// means "no failure", and an empty vector in the error means "all components built". +/// +/// `build_worker` is the compatibility wrapper for callers that do not need the detail. +async fn build_worker_report( config: crate::config::BusinessConfig, app: AppState, -) -> Result, ()> { - let backend = JmapClientBackend::connect_with_runtime( +) -> Result, Vec> { + let mut failures = Vec::new(); + let jmap = match JmapClientBackend::connect_with_runtime( &config.jmap_session_url, &config.jmap_username, config.jmap_password.expose_secret(), @@ -674,25 +905,45 @@ async fn build_worker( app.runtime.clone(), ) .await - .map_err(|_| ())?; - let account = backend.account_id().to_owned(); - let jmap = JmapService::new(backend, account).map_err(|_| ())?; - let telegram = TelegramClient::with_runtime(config.bot_token, app.runtime.clone()); + { + Ok(backend) => { + let account = backend.account_id().to_owned(); + match JmapService::new(backend, account) { + Ok(service) => Some(service), + Err(error) => { + failures.push(WorkerBuildFailure::new( + "jmap", + "account", + format!("{error:?}"), + )); + None + } + } + } + Err(error) => { + failures.push(WorkerBuildFailure::new( + "jmap", + "connect", + format!("{error:?}"), + )); + None + } + }; let llm = if config.llm_enabled && config.llm_allow_net { - let key = config.llm_api_key.ok_or(())?; - Some(Arc::new( - LlmClient::with_runtime( - config.llm_base_url.ok_or(())?, - key, - config.llm_model.ok_or(())?, - 300, - app.runtime, - ) - .map_err(|_| ())?, - )) + match build_llm_client(config.clone(), app.runtime.clone()) { + Ok(client) => Some(client), + Err(failure) => { + failures.push(failure); + None + } + } } else { None }; + let Some(jmap) = jmap else { + return Err(failures); + }; + let telegram = TelegramClient::with_runtime(config.bot_token, app.runtime.clone()); Ok(Arc::new(MetadataWorker::new( jmap, telegram, @@ -702,6 +953,34 @@ async fn build_worker( ))) } +/// Builds the optional LLM client, separating its own configuration problems from its build +/// problems so the caller can report the exact gap. +fn build_llm_client( + config: crate::config::BusinessConfig, + runtime: RuntimeConfigProvider, +) -> Result, WorkerBuildFailure> { + let key = config + .llm_api_key + .ok_or_else(|| WorkerBuildFailure::new("llm", "config", "llm_api_key 缺失".to_owned()))?; + let base_url = config + .llm_base_url + .ok_or_else(|| WorkerBuildFailure::new("llm", "config", "llm_base_url 缺失".to_owned()))?; + let model = config + .llm_model + .ok_or_else(|| WorkerBuildFailure::new("llm", "config", "llm_model 缺失".to_owned()))?; + LlmClient::with_runtime(base_url, key, model, 300, runtime) + .map(Arc::new) + .map_err(|error| WorkerBuildFailure::new("llm", "build", format!("{error:?}"))) +} + +/// Compatibility wrapper: rebuild failures here are not surfaced to the caller. +async fn build_worker( + config: crate::config::BusinessConfig, + app: AppState, +) -> Result, ()> { + build_worker_report(config, app).await.map_err(|_| ()) +} + /// Request-boundary refresh for multi-instance deployments. A failed remote rebuild advances the /// observed revision to avoid a hot retry loop while retaining the active worker/config. /// `REQ-DEBUG-ENDPOINTS`: the single place a business-configured Telegram client is built from the @@ -1330,6 +1609,10 @@ fn router_with_worker_state_runtime_bootstrap_config .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/preflight", + post(preflight_business_config), + ) .route("/api/bootstrap", post(bootstrap)) .route("/api/admin/session/revoke", post(revoke_admin_session)) .route("/api/admin/session", post(create_admin_session)) @@ -1544,7 +1827,7 @@ mod tests { } #[tokio::test] - async fn failed_bootstrap_does_not_consume_initialization_slot() { + async fn failed_bootstrap_persists_and_reports_instead_of_silently_failing() { let app = router_with_worker_state_runtime_bootstrap( AuthSecrets { reconcile_token: SecretString::new("r".into()), @@ -1558,25 +1841,51 @@ mod tests { SecretString::new("acl-root".into()), None, ); - // 本用例验证的是"失败的 bootstrap 不消耗一次性初始化槽位",失败原因应是 - // JMAP 不可达(127.0.0.1:1 → 503),因此 allowlist 必须合法,避免被 + // 本用例验证的是"失败的 bootstrap 仍会持久化并如实上报",失败原因应是 + // JMAP 不可达(127.0.0.1:1 → warning),因此 allowlist 必须合法,避免被 // SAF-CHAT-ALLOWLIST 的非空校验提前拦成 422。 - let body = r#"{"bot_token":"bot","telegram_chat_id":1,"chat_allowlist":[1],"telegram_webhook_secret":"hook","jmap_session_url":"https://127.0.0.1:1","jmap_username":"u","jmap_password":"p","account_id":null,"llm_enabled":false,"llm_allow_net":false,"llm_api_key":null,"llm_base_url":null,"llm_model":null,"reconcile_token":"r","worker_token":"w"}"#; - for _ in 0..2 { - let response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/api/bootstrap") - .header("authorization", "Bearer acl-root") - .body(Body::from(body)) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - } + let payload = r#"{"bot_token":"bot","telegram_chat_id":1,"chat_allowlist":[1],"telegram_webhook_secret":"hook","jmap_session_url":"https://127.0.0.1:1","jmap_username":"u","jmap_password":"p","account_id":null,"llm_enabled":false,"llm_allow_net":false,"llm_api_key":null,"llm_base_url":null,"llm_model":null,"reconcile_token":"r","worker_token":"w"}"#; + let persisted = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/bootstrap") + .header("authorization", "Bearer acl-root") + .body(Body::from(payload)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(persisted.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(persisted.into_body(), usize::MAX) + .await + .unwrap(); + let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(value["persisted"], true); + assert_eq!(value["runtime_applied"], false); + let warnings = value["warnings"].as_array().expect("warnings array"); + assert!( + !warnings.is_empty(), + "an unreachable JMAP must be reported, not swallowed" + ); + assert_eq!(warnings[0]["component"], "jmap"); + assert_eq!(warnings[0]["step"], "connect"); + assert!(warnings[0]["detail"].is_string()); + // The one-shot slot is now consumed by the write itself, so a retry cannot silently + // overwrite a configuration whose runtime never came up. + let conflicting = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/bootstrap") + .header("authorization", "Bearer acl-root") + .body(Body::from(payload)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(conflicting.status(), StatusCode::CONFLICT); } #[tokio::test] @@ -1840,6 +2149,131 @@ mod tests { assert_eq!(invalid.status(), StatusCode::UNPROCESSABLE_ENTITY); } + /// Preflight is a diagnostic endpoint, not a write: it must never persist, never install a + /// worker, and must never turn a configuration problem into a transport error. "The + /// configuration is bad" is a successful preflight, because that is the answer the caller + /// asked for. + #[tokio::test] + async fn business_config_preflight_reports_validation_failure_without_writing() { + let app = test_router(); + let unauthenticated = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/business-config/preflight") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED); + // A complete wire so that deserialization succeeds and only the validator rejects + // it: that is the case preflight has to answer rather than echoing a generic 422. + let body = r#"{"bot_token":"bot","telegram_chat_id":1,"chat_allowlist":[1],"telegram_webhook_secret":"hook","jmap_session_url":"http://not-https","jmap_username":"u","jmap_password":"p","llm_enabled":false,"llm_allow_net":false,"llm_api_key":null,"llm_base_url":null,"llm_model":null,"reconcile_token":"r","worker_token":"w"}"#; + let invalid = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/business-config/preflight") + .header("authorization", "Bearer worker-secret") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(invalid.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(invalid.into_body(), usize::MAX) + .await + .unwrap(); + let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(value["persisted"], false); + assert_eq!(value["validation"]["ok"], false); + assert!(value["validation"]["errors"] + .as_array() + .unwrap() + .iter() + .any(|error| { error.as_str().unwrap_or_default().contains("HTTPS") })); + // Components are null: there is no meaningful way to probe clients from a wire + // that validation already rejected. + assert!(value["components"].is_null()); + // "Not written" is proven by the live config snapshot: it must still be the timeout-only + // default, carrying none of the submitted wire. (/api/bootstrap cannot be used as + // evidence here — test_router has no admin credential, so it answers 401 unconditionally.) + assert_preflight_wrote_nothing(app).await; + } + + #[tokio::test] + async fn business_config_preflight_reports_component_failure_and_writes_nothing() { + let app = test_router(); + // account_id is deliberately absent: it is optional and defaults to the session + // primary account, which is the behaviour the SPA now relies on. + let body = r#"{"bot_token":"bot","telegram_chat_id":1,"chat_allowlist":[1],"telegram_webhook_secret":"hook","jmap_session_url":"https://127.0.0.1:1","jmap_username":"u","jmap_password":"p","llm_enabled":false,"llm_allow_net":false,"llm_api_key":null,"llm_base_url":null,"llm_model":null,"reconcile_token":"r","worker_token":"w"}"#; + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/business-config/preflight") + .header("authorization", "Bearer worker-secret") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(value["persisted"], false); + assert_eq!(value["validation"]["ok"], true); + assert_eq!(value["components"]["jmap"]["ok"], false); + let errors = value["components"]["jmap"]["errors"] + .as_array() + .expect("jmap failure list"); + assert_eq!(errors[0]["component"], "jmap"); + assert_eq!(errors[0]["step"], "connect"); + assert!(errors[0]["detail"] + .as_str() + .map(|detail| !detail.is_empty()) + .unwrap_or(false)); + // LLM was not requested, so it is absent rather than vacuously "ok". + assert!(value["components"]["llm"].is_null()); + // "Not written" is proven by the live config snapshot, not by the preflight echo. + assert_preflight_wrote_nothing(app).await; + } + + /// Preflight must leave the live configuration untouched. `test_router` has no admin + /// credential, so `/api/bootstrap` cannot be used as a free-slot probe; the authoritative + /// evidence is the snapshot the router actually serves, which must still be the timeout-only + /// default rather than whatever the preflight body proposed. + async fn assert_preflight_wrote_nothing(app: Router) { + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/config") + .header("authorization", "Bearer worker-secret") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert!( + value.get("jmap_session_url").is_none(), + "preflight persisted a JMAP URL: {}", + value["jmap_session_url"] + ); + assert!(value.get("telegram_chat_id").is_none()); + } + // ── /api/push/register 与 /api/push/disable(handoff #51)── // // 这两条路由此前只有实现、没有 HTTP 层测试。这里用可控 Worker 覆盖 diff --git a/web/config.js b/web/config.js index 861c0d3..69477da 100644 --- a/web/config.js +++ b/web/config.js @@ -59,15 +59,14 @@ "business.title": "Business connections and auth", "business.persist": "Encrypted in Redis", "callout.title": "Business configuration is not read from the server.", - "callout.bodyPre": " To avoid secret echo, the API does not expose a business configuration GET. Every submission must re-fill the complete configuration, including all required secrets; optional account ID and LLM fields are sent as ", - "callout.bodyPost": " when left blank.", + "callout.bodyPre": " To avoid secret echo, the API does not expose a business configuration GET. Every submission must re-fill the complete configuration, including all required secrets; optional LLM fields are sent as ", + "callout.bodyPost": " when left blank. The mailbox primary account is always used.", "legend.business": "Full business configuration", "jmap.title": "JMAP mailbox", "jmap.desc": "The server must use HTTPS; the URL must not contain credentials, query parameters or fragments.", "jmap.urlHint": "HTTPS address without username, password, query parameters or #fragment.", "jmap.userLabel": "Email username", "jmap.passLabel": "App-specific password", - "jmap.accountPh": "Leave blank to use the session primary account", "secret.reenter": "Re-enter on each full save", "telegram.title": "Telegram and access auth", "telegram.desc": "Only chat IDs in the allowlist may trigger bot operations.", @@ -97,6 +96,16 @@ "business.dirty": "The form has unsubmitted content; each submission fully replaces the existing business configuration.", "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", + "business.preflightBusy": "Testing connection…", + "business.preflightRunning": "Testing the JMAP and LLM endpoints; nothing is saved.", + "business.preflightFailed": "The preflight request failed; the saved configuration is unchanged.", + "business.preflightInvalid": "Not accepted by the backend validator:", + "business.preflightOk": "All components connected successfully. The running configuration is unchanged.", + "business.preflightPartial": "These components failed to build:", + "business.preflightUnknown": "No component-level result was returned; the backend may predate the preflight endpoint.", + "business.preflightPersisted": "Unexpected: the preflight response claims it persisted a configuration.", + "business.savedWarning": "Saved, but not hot-reloaded: these components could not be built, so the running instance keeps the previous configuration until they do:", "runtime.eyebrow": "Outbound requests", "runtime.title": "Timeouts and retries", "runtime.persist": "Stored in Redis", @@ -136,6 +145,8 @@ "footer.note": "Configuration managed by your server", "common.required": "Required", "common.optional": "Optional", + "common.buildVersion": "Build version", + "common.buildVersionUnknown": "Build version unavailable", "common.atLeastOne": "At least one", "common.ms": "ms", "common.times": "times", @@ -169,7 +180,7 @@ "berr.401": "The admin session is no longer valid. The session and form contents have been cleared from the page; please re-authorize.", "berr.400": "The backend could not parse the submitted JSON (HTTP 400); check the fields and retry.", "berr.422": "Business configuration validation failed (HTTP 422). Check required fields, HTTPS URLs, LLM settings and the chat ID allowlist. The server does not return secrets or error details.", - "berr.503": "Business configuration was not confirmed saved (HTTP 503). Redis may not be ready, or the backend could not build the JMAP/LLM client; the page keeps this input for review and retry.", + "berr.503": "The write could not be confirmed (HTTP 503): Redis could not persist the configuration or the hot reload could not be committed. A JMAP/LLM connection failure does not produce this error — those save anyway and are reported as warnings. The page keeps this input for review and retry.", "berr.status": "Business configuration request failed (HTTP {status}). Retry shortly.", "berr.fail": "{message} The request result may be unconfirmed; after checking, re-submit the complete configuration.", "berr.failDefault": "Business configuration request failed.", @@ -232,15 +243,14 @@ "business.title": "业务连接与鉴权", "business.persist": "Redis 加密保存", "callout.title": "业务配置不会从服务器读取。", - "callout.bodyPre": " 为避免密钥回显,API 不提供业务配置 GET。每次提交都必须重新填写完整配置,包括所有必填密钥;可选的 account ID 与 LLM 字段留空时会发送为 ", - "callout.bodyPost": "。", + "callout.bodyPre": " 为避免密钥回显,API 不提供业务配置 GET。每次提交都必须重新填写完整配置,包括所有必填密钥;可选的 LLM 字段留空时会发送为 ", + "callout.bodyPost": "。始终使用邮箱主账户。", "legend.business": "完整业务配置", "jmap.title": "JMAP 邮箱", "jmap.desc": "服务端必须使用 HTTPS,URL 中不能包含凭据、查询参数或片段。", "jmap.urlHint": "HTTPS 地址,不含用户名、密码、查询参数或 #片段。", "jmap.userLabel": "邮箱用户名", "jmap.passLabel": "应用专用密码", - "jmap.accountPh": "留空使用 session 主账户", "secret.reenter": "每次完整保存时重新输入", "telegram.title": "Telegram 与访问鉴权", "telegram.desc": "仅允许白名单中的 chat ID 触发机器人操作。", @@ -270,6 +280,16 @@ "business.dirty": "表单包含未提交内容;每次提交会完整替换现有业务配置。", "business.unsaved": "未收到保存成功确认;请检查服务状态。", "business.submittingToast": "正在提交完整业务配置;后端会先构建客户端,再保存并切换运行实例…", + "business.preflight": "测试连接", + "business.preflightBusy": "正在测试连接…", + "business.preflightRunning": "正在测试 JMAP 与 LLM 端点;不会写入任何配置。", + "business.preflightFailed": "预检请求失败;已保存的配置未受影响。", + "business.preflightInvalid": "后端校验未通过:", + "business.preflightOk": "各组件连接成功。当前运行配置未被修改。", + "business.preflightPartial": "以下组件构建失败:", + "business.preflightUnknown": "后端未返回组件级结果;可能运行的是不含预检接口的旧版本。", + "business.preflightPersisted": "异常:预检响应声称已写入配置。", + "business.savedWarning": "已保存,但未热加载:以下组件无法构建,运行实例将继续沿用旧配置,直到它们可以构建:", "runtime.eyebrow": "出站请求", "runtime.title": "超时与重试", "runtime.persist": "Redis 保存", @@ -309,6 +329,8 @@ "footer.note": "配置由你的服务端管理", "common.required": "必填", "common.optional": "可选", + "common.buildVersion": "构建版本", + "common.buildVersionUnknown": "构建版本未知", "common.atLeastOne": "至少一项", "common.ms": "毫秒", "common.times": "次", @@ -342,7 +364,7 @@ "berr.401": "管理会话已失效。页面已清除会话和表单内容,请重新授权。", "berr.400": "后端无法解析提交的 JSON(HTTP 400);请检查字段后重试。", "berr.422": "业务配置校验失败(HTTP 422)。请检查必填项、HTTPS URL、LLM 设置和 chat ID 白名单。服务端没有返回密钥或错误详情。", - "berr.503": "业务配置未获成功确认(HTTP 503)。Redis 可能尚未就绪,或后端无法建立 JMAP/LLM 客户端;页面保留本次输入供检查和重试。", + "berr.503": "写入未能确认(HTTP 503):Redis 未能持久化配置,或热加载提交失败。JMAP/LLM 连接失败不会返回此错误——它们会照常保存并以下发告警的形式报告。页面保留本次输入供检查和重试。", "berr.status": "业务配置请求失败(HTTP {status})。请稍后重试。", "berr.fail": "{message} 请求结果可能无法确认;检查后可重新提交完整配置。", "berr.failDefault": "业务配置请求失败。", @@ -469,6 +491,10 @@ const llmBaseUrl = document.querySelector("#llm-base-url"); const llmModel = document.querySelector("#llm-model"); const notice = document.querySelector("#notice"); + const preflightButton = document.querySelector("#business-preflight-button"); + const versionFooter = document.querySelector("#build-version"); + const versionTop = document.querySelector("#build-version-top"); + const versionSetup = document.querySelector("#build-version-setup"); // C-NO-LOCAL-WRITE / C-REDIS-ONLY-STATE: credentials and configuration stay in page memory. let adminSession = ""; @@ -497,14 +523,17 @@ reloadButton.disabled = busy || !adminSession; logoutButton.disabled = busy || !adminSession; businessSaveButton.disabled = busy || !adminSession; + preflightButton.disabled = busy || !adminSession; runtimeSaveButton.disabled = busy || !runtimeLoaded || !adminSession; defaultsButton.disabled = busy || !runtimeLoaded || !adminSession; businessSaveButton.classList.toggle("is-busy", busy && busyAction === "business-save"); runtimeSaveButton.classList.toggle("is-busy", busy && busyAction === "runtime-save"); + preflightButton.classList.toggle("is-busy", busy && busyAction === "business-preflight"); connectButton.textContent = busy && busyAction === "login" ? t("auth.submitBusy") : t("auth.submit"); reloadButton.textContent = busy && busyAction === "runtime-load" ? t("reload.busy") : t("reload.button"); businessSaveButton.querySelector(".button-label").textContent = busy && busyAction === "business-save" ? t("business.saveBusy") : t("business.save"); runtimeSaveButton.querySelector(".button-label").textContent = busy && busyAction === "runtime-save" ? t("runtime.saveBusy") : t("runtime.save"); + preflightButton.textContent = busy && busyAction === "business-preflight" ? t("business.preflightBusy") : t("business.preflight"); } function setBusy(action = "") { @@ -548,12 +577,24 @@ return parseResponseText(responseText, response.status); } - async function showSetupStatus() { + // The build fingerprint comes from /api/status so the SPA never hard-codes one: the binary + // supplies it at compile time and the page just echoes it. Absent means the backend is older + // than the version endpoint, which is itself useful information for an operator. + function renderVersion(version) { + const value = typeof version === "string" && version.trim() ? version.trim() : ""; + const label = t("common.buildVersion"); + if (versionFooter) versionFooter.textContent = value ? `${label} ${value}` : t("common.buildVersionUnknown"); + if (versionTop) versionTop.textContent = value; + if (versionSetup) versionSetup.textContent = value ? `${label} ${value}` : ""; + } + + async function showSetupStatus(options = {}) { showSetupView(t("setup.checking")); statusRetryButton.disabled = true; statusRetryButton.textContent = t("setup.checkingBtn"); try { const status = await request("/api/status", "GET", undefined, ""); + renderVersion(status && status.version); if (status && status.ready === true) { setupMode = false; setupCard.hidden = true; @@ -848,7 +889,6 @@ jmap_session_url: validateHttpsUrl(jmapUrlInput, "JMAP Session URL", { noCredentials: true, noQuery: true }), jmap_username: requireText("jmap-username", t("jmap.userLabel")), jmap_password: requireText("jmap-password", t("jmap.passLabel"), true), - account_id: document.getElementById("account-id").value.trim() || null, llm_enabled: enabled, llm_allow_net: document.getElementById("llm-allow-net").checked, llm_api_key: isBlank(llmKeyValue) ? null : llmKeyValue, @@ -1007,6 +1047,65 @@ llmEnabled.addEventListener("change", updateLLMRequirements); + // "Test connection" posts to the preflight endpoint, which never writes: the previous + // configuration stays live either way. It reuses readBusinessConfig() so preflight and save + // can never disagree about whether the form is complete. + preflightButton.addEventListener("click", runBusinessPreflight); + + async function runBusinessPreflight() { + if (!adminSession || busyAction) return; + const config = readBusinessConfig(); + if (!config) return; + setBusy("business-preflight"); + businessSaveState.textContent = t("business.preflightRunning"); + businessSaveState.classList.remove("is-ok", "is-warn"); + try { + const result = await request("/api/business-config/preflight", "POST", stringifyWithBigInt(config)); + const verdict = describePreflight(result); + businessSaveState.textContent = verdict.text; + businessSaveState.classList.remove("is-dirty"); + businessSaveState.classList.add(verdict.tone === "ok" ? "is-ok" : "is-warn"); + showNotice(verdict.tone === "ok" ? "success" : "info", verdict.text); + updateButtons(); + } catch (error) { + if (error.status === 401) { + expireSession(t("session.invalidBusiness")); + return; + } + businessSaveState.textContent = t("business.preflightFailed"); + businessSaveState.classList.add("is-warn"); + showNotice("error", businessErrorMessage(error)); + } finally { + if (adminSession) setBusy(""); + } + } + + // Preflight answers three distinct questions and they must stay separate in the UI: is the + // form valid, and if so, which *component* failed to build? A single boolean would hide the + // second failure behind the first. + function describePreflight(result) { + if (result && result.persisted === true) { + return { tone: "warn", text: t("business.preflightPersisted") }; + } + const validation = result && result.validation; + if (validation && validation.ok === false) { + const details = Array.isArray(validation.errors) ? validation.errors.filter(Boolean).join(" · ") : ""; + return { tone: "warn", text: [t("business.preflightInvalid"), details].filter(Boolean).join(" ") }; + } + const components = result && result.components; + if (!components) return { tone: "warn", text: t("business.preflightUnknown") }; + const failed = Object.entries(components) + .filter(([, component]) => component && component.ok === false) + .map(([name, component]) => { + const errors = Array.isArray(component.errors) + ? component.errors.map((item) => `${item.step || "build"}: ${item.detail || ""}`.trim()) + : []; + return [name, errors.filter(Boolean).join(" · ")].filter(Boolean).join(" "); + }); + if (!failed.length) return { tone: "ok", text: t("business.preflightOk") }; + return { tone: "warn", text: [t("business.preflightPartial"), failed.join(" | ")].filter(Boolean).join(" ") }; + } + businessForm.addEventListener("submit", async (event) => { event.preventDefault(); if (!adminSession || busyAction) return; @@ -1015,11 +1114,25 @@ setBusy("business-save"); showNotice("info", t("business.submittingToast")); try { - await request("/api/business-config", "PUT", stringifyWithBigInt(config)); + const result = await request("/api/business-config", "PUT", stringifyWithBigInt(config)); clearSecretFields(); - businessSaveState.textContent = t("business.saved"); - businessSaveState.classList.remove("is-dirty"); - showNotice("success", t("business.savedToast")); + // Saving and applying are separate outcomes: the backend persists a validated + // configuration even when no client could be built, so "saved" alone would overclaim. + const warnings = result && Array.isArray(result.warnings) ? result.warnings : []; + if (result && result.runtime_applied === false && warnings.length) { + const details = warnings + .map((item) => [item.component, item.step, item.detail].filter(Boolean).join(": ")) + .filter(Boolean); + const text = [t("business.savedWarning"), details.join(" | ")].filter(Boolean).join(" "); + businessSaveState.textContent = text; + businessSaveState.classList.remove("is-dirty"); + businessSaveState.classList.add("is-warn"); + showNotice("info", text); + } else { + businessSaveState.textContent = t("business.saved"); + businessSaveState.classList.remove("is-dirty"); + showNotice("success", t("business.savedToast")); + } updateButtons(); } catch (error) { if (error.status === 401) { diff --git a/web/index.html b/web/index.html index d523bee..edb8078 100644 --- a/web/index.html +++ b/web/index.html @@ -19,6 +19,7 @@ MessageWeaveService configuration Secure configuration channel +
@@ -43,6 +44,7 @@

Startup secrets must be configured in the deployment environment. Secrets are never saved or echoed in the page.

+ @@ -125,7 +126,7 @@ @@ -157,6 +158,6 @@
-
MessageWeaveConfiguration managed by your server
+ diff --git a/web/styles.css b/web/styles.css index d996b7f..e93ace4 100644 --- a/web/styles.css +++ b/web/styles.css @@ -24,13 +24,15 @@ button:focus-visible, input:focus-visible, textarea:focus-visible, a:focus-visib .ambient-one { top: 7rem; right: -13rem; width: 31rem; height: 31rem; background: radial-gradient(circle, rgba(185, 177, 255, .2), rgba(185, 177, 255, 0) 68%); } .ambient-two { top: 44rem; left: -17rem; width: 36rem; height: 36rem; background: radial-gradient(circle, rgba(125, 210, 196, .13), rgba(125, 210, 196, 0) 70%); } -.topbar { width: min(1100px, calc(100% - 48px)); height: 86px; margin: 0 auto; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid rgba(224, 224, 238, .72); } +.topbar { width: min(1100px, calc(100% - 48px)); height: 86px; margin: 0 auto; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid rgba(224, 224, 238, .72); } .brand { display: inline-flex; align-items: center; gap: 12px; color: inherit; text-decoration: none; } .brand-mark { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 12px; background: linear-gradient(145deg, #7466e5, #5443c5); color: white; font-size: 21px; font-weight: 760; box-shadow: 0 5px 12px rgba(93, 75, 203, .2); } .brand-copy { display: grid; gap: 1px; } .brand-copy strong { font-size: 14px; letter-spacing: -.2px; } .brand-copy > span { color: #85869a; font-size: 11px; letter-spacing: .05em; } -.topbar-note { display: inline-flex; align-items: center; gap: 8px; color: #77798e; font-size: 12px; } +.topbar-note { display: inline-flex; align-items: center; gap: 8px; margin-left: auto; color: #77798e; font-size: 12px; } +.topbar-version { display: none; color: #a1a1b0; font-size: 10px; padding: 4px 8px; border: 1px solid #e6e6ef; border-radius: 999px; background: rgba(255, 255, 255, .8); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +@media (min-width: 900px) { .topbar-version { display: inline-block; } } .secure-dot, .persist-dot { width: 7px; height: 7px; border-radius: 50%; background: #48ae86; box-shadow: 0 0 0 3px rgba(72, 174, 134, .12); } .layout { width: min(960px, calc(100% - 40px)); margin: 0 auto; padding: 54px 0 56px; } @@ -63,6 +65,7 @@ h3 { margin-bottom: 4px; color: #35364e; font-size: 15px; } .setup-panel .panel-copy { margin-bottom: 14px; } .setup-missing { margin: 0 0 17px; padding: 12px 14px; border: 1px solid #f2e9c8; border-radius: 8px; background: #fffbef; color: #796132; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; } .setup-privacy { margin: 0 0 17px; color: #858697; font-size: 12px; line-height: 1.65; } +.build-version { margin: 20px 0 0; padding-top: 15px; border-top: 1px solid #efeff5; color: #a1a1b0; font-size: 10px; line-height: 1.5; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; } code { padding: 2px 5px; border-radius: 4px; background: #f1f0fa; color: #534a9c; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; } .auth-form > label, .field > label, .setting-card > label { display: block; margin-bottom: 7px; color: #47485f; font-size: 12px; font-weight: 700; } .token-row { display: flex; gap: 10px; } @@ -144,6 +147,7 @@ fieldset:disabled .field input, fieldset:disabled .field textarea { background: .privacy-note strong { color: #64657a; font-weight: 680; } .footer { width: min(1100px, calc(100% - 48px)); display: flex; justify-content: space-between; margin: 0 auto; padding: 18px 0 23px; border-top: 1px solid rgba(224, 224, 238, .72); color: #a5a5b2; font-size: 9px; letter-spacing: .08em; } .footer span:last-child { letter-spacing: 0; } +.footer-version { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0; } .visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; clip-path: inset(50%); border: 0; } @media (max-width: 720px) { @@ -167,7 +171,7 @@ fieldset:disabled .field input, fieldset:disabled .field textarea { background: .field-grid, .toggle-grid, .settings-grid { grid-template-columns: minmax(0, 1fr); } .field-wide { grid-column: auto; } .form-footer { align-items: flex-start; flex-direction: column; padding: 14px 17px; } - .form-footer > .button, .form-actions { width: 100%; } + .form-footer > .form-actions { width: 100%; } .form-actions .button { flex: 1; } .runtime-section .settings-grid { padding: 0 14px 17px; } .section-copy { margin: -3px 17px 16px; }