config: persist business config regardless of client build, add preflight probe
PUT /api/business-config and POST /api/bootstrap now treat validation as the only
write gate: a valid wire is always persisted, and only then are the clients built
and the running worker swapped. A failed build returns 200 with persisted=true,
runtime_applied=false and a warnings array of {component, step, detail} instead of
503, so an unreachable JMAP/LLM no longer turns a valid configuration into a silent
data loss. The previous worker keeps serving.
POST /api/business-config/preflight runs the same validation and client build
against the submitted wire without writing anything and without touching the
running worker. It answers 200 even when the configuration is bad, because that
is the answer the caller asked for; components is null after a validation
failure, and llm is null rather than vacuously ok when LLM is disabled.
Authentication is the admin-session gate, not DEBUG_TOKEN.
The SPA drops the account_id field (the backend still accepts it; omitted falls
back to the session primary account), adds a Test connection button, and shows
the build fingerprint in the footer, the topbar and the setup card. The
503 message no longer claims JMAP/LLM build failure, which is not what produces
it.
BusinessConfigWire.account_id gains #[serde(default)]; without it, omitting the
field in the wire body made deserialization fail outright.
build.rs bakes BUILD_VERSION (git sha or nogit, plus a UTC build timestamp) which
GET /api/status exposes as version.
This commit is contained in:
parent
dda8e084ae
commit
fc686abc22
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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<String> {
|
||||
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}");
|
||||
}
|
||||
|
|
@ -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`
|
||||
拼出 `<sha>+<UTC 时间戳>` 并写入 `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`(`<git-sha 或 nogit>+<UTC 构建时间>`) | 排查"起来了但没干活";`version` 用于确认部署是否落地 |
|
||||
|
||||
> **由平台 ingress 探测 `/ready`。**镜像现已内置 `curl`(见 Dockerfile `runtime` 阶段),
|
||||
> 所以在 Dockerfile 部署路径下容器内写 `HEALTHCHECK` 在技术上可行(HostStack 的 `runtime: rust`
|
||||
|
|
|
|||
|
|
@ -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`(`<git-sha 或 nogit>+<UTC 构建时间>`),SPA 页脚据此确认部署是否真正落地。
|
||||
- `POST /api/admin/session` 接受 `Authorization: Bearer <CONFIG_ENCRYPTION_KEY>`,返回 `{ "session": "<opaque>", "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` 为准。
|
||||
|
|
|
|||
|
|
@ -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": <bool>, "mode": "env"|"redis", "missing": [...]}`. `ready` is `false` and
|
||||
`missing` lists the absent required keys (`REDIS_URL`, `CONFIG_ENCRYPTION_KEY`) when a
|
||||
variable is absent — the route itself never errors, so it is safe to poll.
|
||||
- `GET /api/status` (**always 200**, `notify.rs:214-223`) returns
|
||||
`{"ready": <bool>, "mode": "configured"|"configuration-setup", "missing": [...], "version": "<build-fingerprint>"}`.
|
||||
`ready` is `false` and `missing` lists the absent required keys (`REDIS_URL`,
|
||||
`CONFIG_ENCRYPTION_KEY`) when a variable is absent — the route itself never errors, so it is
|
||||
safe to poll. `version` is the `BUILD_VERSION` string baked in by `build.rs`
|
||||
(`<git-sha-or-nogit>+<UTC build time>`) and is how the SPA footer proves a deploy landed.
|
||||
- `/debug/*` returns **503** in exactly one place: `POST /debug/notify` when the business
|
||||
config is not loaded or there is no outbound client — `service_unavailable` with
|
||||
`Retry-After: 30` (`src/debug.rs:56-58`). The three probe endpoints instead report failure
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
pub llm_enabled: bool,
|
||||
pub llm_allow_net: bool,
|
||||
|
|
|
|||
578
src/notify.rs
578
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("<credentials>");
|
||||
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<AppState>) -> 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<AppState>,
|
||||
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<AppState>, 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<AppState>, 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<AppState>,
|
||||
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::<serde_json::Value>(&body) else {
|
||||
return Json(json!({
|
||||
"persisted": false,
|
||||
"validation": { "ok": false, "errors": ["请求体不是有效的 JSON"] },
|
||||
"components": Value::Null,
|
||||
}))
|
||||
.into_response();
|
||||
};
|
||||
let Ok(wire) = serde_json::from_value::<BusinessConfigWire>(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<BusinessConfig, _> = 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<Arc<dyn WorkerHandler>, ()> {
|
||||
let backend = JmapClientBackend::connect_with_runtime(
|
||||
) -> Result<Arc<dyn WorkerHandler>, Vec<WorkerBuildFailure>> {
|
||||
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<Arc<LlmClient>, 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<Arc<dyn WorkerHandler>, ()> {
|
||||
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<S: ReliableState + 'static>
|
|||
.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 覆盖
|
||||
|
|
|
|||
141
web/config.js
141
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) {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
<span class="brand-copy"><strong>MessageWeave</strong><span data-i18n="brand.tagline">Service configuration</span></span>
|
||||
</a>
|
||||
<span class="topbar-note"><span class="secure-dot"></span><span data-i18n="topbar.note">Secure configuration channel</span></span>
|
||||
<span id="build-version-top" class="topbar-version" aria-hidden="true"></span>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
|
|
@ -43,6 +44,7 @@
|
|||
<p id="setup-missing" class="setup-missing" hidden></p>
|
||||
<p class="setup-privacy" data-i18n="setup.privacy">Startup secrets must be configured in the deployment environment. Secrets are never saved or echoed in the page.</p>
|
||||
<button id="status-retry-button" class="button button-quiet" type="button" data-i18n="setup.retry">Re-check</button>
|
||||
<p id="build-version-setup" class="build-version" aria-hidden="true"></p>
|
||||
</section>
|
||||
|
||||
<section id="auth-card" class="panel auth-panel" aria-labelledby="auth-title" hidden>
|
||||
|
|
@ -80,7 +82,7 @@
|
|||
</div>
|
||||
<div class="callout callout-warning">
|
||||
<strong data-i18n="callout.title">Business configuration is not read from the server.</strong>
|
||||
<span data-i18n="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 </span><code>null</code><span data-i18n="callout.bodyPost"> when left blank.</span>
|
||||
<span data-i18n="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 </span><code>null</code><span data-i18n="callout.bodyPost"> when left blank. The mailbox primary account is always used.</span>
|
||||
</div>
|
||||
|
||||
<form id="business-form" novalidate>
|
||||
|
|
@ -93,7 +95,6 @@
|
|||
<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="account-id">JMAP Account ID <span class="optional-mark" data-i18n="common.optional">Optional</span></label><input id="account-id" name="account_id" type="text" autocomplete="off" data-i18n-placeholder="jmap.accountPh" placeholder="Leave blank to use the session primary account" /></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
@ -125,7 +126,7 @@
|
|||
|
||||
<div class="form-footer">
|
||||
<p id="business-save-state" class="save-state" aria-live="polite" data-i18n="business.saveStateInitial">You can fill in the full configuration after the admin session is verified.</p>
|
||||
<button id="business-save-button" class="button button-primary" type="submit" disabled><span class="button-label" data-i18n="business.save">Save business config and hot-reload</span><span class="button-spinner" aria-hidden="true"></span></button>
|
||||
<div class="form-actions"><button id="business-preflight-button" class="button button-quiet" type="button" disabled data-i18n="business.preflight">Test connection</button><button id="business-save-button" class="button button-primary" type="submit" disabled><span class="button-label" data-i18n="business.save">Save business config and hot-reload</span><span class="button-spinner" aria-hidden="true"></span></button></div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
|
@ -157,6 +158,6 @@
|
|||
<aside id="privacy-note" class="privacy-note" hidden><span class="privacy-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 3 5 6v5c0 4.5 2.8 8 7 10 4.2-2 7-5.5 7-10V6l-7-3Z"/><path d="m9 12 2 2 4-4"/></svg></span><p><strong data-i18n="privacy.title">Secrets are never echoed or persisted to the browser.</strong> <span data-i18n="privacy.body">Business configuration is submitted only to the same-origin API and encrypted into Redis by the backend; this page never reads mailbox contents and uses no local files or database.</span></p></aside>
|
||||
</main>
|
||||
|
||||
<footer class="footer"><span>MessageWeave</span><span data-i18n="footer.note">Configuration managed by your server</span></footer>
|
||||
<footer class="footer"><span>MessageWeave</span><span id="build-version" class="footer-version">build version —</span><span data-i18n="footer.note">Configuration managed by your server</span></footer>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue