diff --git a/content/docs/configure/encryption.mdx b/content/docs/configure/encryption.mdx
index c642243..25a355e 100644
--- a/content/docs/configure/encryption.mdx
+++ b/content/docs/configure/encryption.mdx
@@ -1,92 +1,50 @@
---
title: 数据加密
-description: 加密你的 Mix Space
+description: v17 凭据加密配置、原密钥保留与 v18 准备
---
-Mix Space 提供了敏感配置的加密存储能力。默认为关。
+本文固定已发布的 Core v17.0.4。未发布的 v18 开发分支已删除关闭加密和旧格式写入,不能沿用下文的 v17 逃生口,详见 [v18 准备说明](/docs/migrate/v18-preparation)。
-为什么需要 Key 加密。
+## 三种秘密分别保管
-假设数据库被脱库。如果开启了 Key 加密,即便攻击者拿到了全部数据,也读不出被标记为加密的字段。同时,也需要谨慎开启此功能——开启后你需要记住加密密钥,否则你将永远丢失这些数据。
+| 秘密 | 用途 | 恢复时的要求 |
+| --- | --- | --- |
+| `MX_ENCRYPT_KEY` / `ENCRYPT_KEY` | 配置、Snippet、Webhook 的字段加密 | 保留产生这些密文的原密钥 |
+| `JWT_SECRET` | 登录与 Better Auth OAuth token 加密 | 保留原值;轮换需要维护窗口 |
+| 备份恢复私钥 | 打开 `.mxbackup` 归档 | 与备份分开保存;不能替代上面两种秘密 |
-开启前请务必备份数据库。忘记密钥 = 这些数据永久无法恢复。
+已有实例不要为解决解密错误而生成新密钥。新密钥无法解开旧数据。先从部署配置、秘密管理器或历史快照找回原值;丢失原秘密时,需要重新授权或重新配置相应凭据。
-## 哪些字段会被加密
+## v17 如何开启
-加密范围不是全部配置项,而是由服务端 schema 上的 `encrypt` 元数据决定,**随版本变化**。
-
-判定规则:服务端遍历配置 schema 时,会读每个字段的 `encrypt` 元数据;**父级被标记时,整棵子树都继承加密**。绝大多数密码类字段是通过 `field.password()` / `field.passwordHalfGrid()` 定义的,这两个 helper 会在构造 schema 时自动注入 `encrypt: true`,所以你在配置里看到的「密码」输入框,基本都属于加密范围。
-
-当前版本(v14.15.2)覆盖的主要字段:
-
-| 区域 | 字段 |
-| --- | --- |
-| OAuth | 各服务商的 client secret、Apple 私钥 |
-| 图床 / 对象存储 | S3、R2 的 `secretKey` |
-| 邮件 | Resend API Key、SMTP 密码 |
-| AI | 各 Provider 的 `apiKey` |
-| Webhook | 签名密钥 |
-| 搜索推送 | 百度推送 Token、Bing API key |
-| 会员 | API Key |
-| 其他 | 高德地图 Key、设备 Key、Personal Access Token 等 |
-
-
-想确认当前版本的准确清单,可以看服务端 `apps/core/src/modules/configs/` 下的 `configs.schema.ts`:凡是写成 `field.password(...)` 或 `field.passwordHalfGrid(...)` 的字段都会被加密,另加显式标注 `{ encrypt: true }` 的字段。文档这里的说明可能落后于代码,以源码为准。
-
-
-## 如何开启
-
-你可以附加 `--encrypt_enable` 来启动服务。如:
+为从未加密过的新实例生成随机字段密钥,并保存到秘密管理器:
```bash
-node out/main.mjs --encrypt_enable
+openssl rand -hex 32
```
-
-**只要设置了加密密钥而没有显式关闭,加密就会被自动开启。**
-
-判定逻辑是`ENCRYPT_ENABLE` 未设置时,回落到「是否设置了密钥」这一项。也就是说,你只填了密钥、没管开关,加密就已经生效了。如果暂时只想把密钥填进配置、暂不加密,必须显式写 `ENCRYPT_ENABLE=false`。
-
+将结果配置为 `MX_ENCRYPT_KEY`(优先)或 `ENCRYPT_KEY`,同时在 app 和 mx-migrate 的共享环境中启用 `MX_ENCRYPT_ENABLE=true`。开启时必须显式提供 **64 位十六进制**密钥;长度不足、非 hex、仅提供开启开关都会在启动时失败。不要依赖 machine-id 回退,也不要使用示例公共密钥。
-## 指定密钥
+设置密钥且未指定开关时会自动开启加密。命令行 `--encrypt_key` / `--encrypt_enable` 仍可使用;升级工具使用环境变量,不读取业务进程的 CLI/YAML 参数。
-可以通过 `--encrypt_key ` 指定,也可以用环境变量。环境变量有两名,`MX_ENCRYPT_KEY` 优先于 `ENCRYPT_KEY`:
+## V2 写入与历史数据
-```bash
-# 推荐
-export MX_ENCRYPT_KEY=<64 位密钥>
-# 等价写法
-export ENCRYPT_KEY=<64 位密钥>
-```
+`MX_SECRET_STORAGE_V2=true` 要求已开启字段加密,且 `JWT_SECRET` 至少 32 字符。它为配置、Snippet 和 Webhook 使用认证加密,为 API Key 使用哈希,为 OAuth access/refresh token 使用 Better Auth 加密。配置的密码字段由 schema 的 `encrypt` 元数据决定,数据库和 Redis 中均保留密文;空秘密 PATCH 保留旧值。
-密钥的校验分两层,容易搞混:
+V2 字段密文绑定字段路径;不能把密文直接搬到另一个字段。旧明文和 ECB 保留读取与迁移能力。`ENCRYPT_ALGORITHM` 默认 `aes-256-ecb`,供旧格式使用;V2 固定使用 AES-256-GCM。
-| 情况 | 行为 |
-|------|------|
-| 提供的密钥**不是 64 位** | 启动即报错 |
-| 提供的密钥**短于 64 位** | 不报错,服务端会用 `sha256(key + 'mx-encrypt-salt')` 派生并补齐到 64 位 |
-| 提供的密钥**长于 64 位** | 启动即报错 |
-| 提供的密钥**不是合法 hex 字符** | **启动时不校验**,直到真正加解密时才抛出 `Invalid key length` |
+未发布的 v18 开发分支已删除 `ENCRYPT_ALGORITHM`、`--encrypt_algorithm` 和 YAML `encrypt_algorithm`,新写入固定 AES-256-GCM。旧项即使填默认值也会报错,应删除;历史读取固定为 AES-256-ECB。使用其他历史算法的实例须先在旧版本用原密钥和原算法转换为 V2 并验证,再升级;详见 [v18 准备说明](/docs/migrate/v18-preparation)。
-推荐用 `openssl rand -hex 32` 生成,产出的正是 64 位小写 hex,一次通过全部校验:
-
-```bash
-openssl rand -hex 32
-```
+滚动部署时,先让全部副本具备新格式读取能力,再在全部副本启用 V2,最后回填旧数据。旧 `migrate:secrets` 完成记录仅代表该次凭据回填,不代表已完成未来 v18 的配置与指标准备。新准备工具尚未发布,见 [准备流程](/docs/migrate/v18-preparation)。
-加密算法可用 `ENCRYPT_ALGORITHM` 调整,默认 `aes-256-ecb`。
+## v17.0.4 临时明文逃生口
-
- 不指定密钥时,服务端默认取机器的
- [machine-id](https://www.npmjs.com/package/node-machine-id) 并派生。
-
+只有关闭字段加密且 `MX_SECRET_STORAGE_V2=false` 时,普通配置写入可继续保留明文。它不适用于已有密文,也不适用于明确要求加密的授权功能。关闭开关不会解密旧数据;遇到已有密文仍须开启加密并提供原密钥。
-
- 关闭加密开关**不会**把已经写入的密文还原成明文。此前加密过的字段会继续以密文形式存储和返回,密文带有 `$${mx}$$` 标识前缀。如果你打算关闭加密,需要先准备好原密钥完成解密。
-
+此逃生口仅适用于已发布的 v17.0.4。未发布的 v18 开发分支已经移除它,生产要求显式字段密钥、至少 32 字符的 JWT,并固定 V2 写入;旧关闭配置须删除。源码 dev 未配置字段密钥时可自动生成并保存在 `tmp/dev-encryption.key`,重启复用;开发库和该文件须一起保留,详见 [v18 开发环境说明](/docs/migrate/v18-preparation#源码开发环境)。
-## 疑难解答
+## 解密失败时
-如果出现 `Invalid key length`,说明你的密钥**不是合法的 hex 字符串**(长度问题在启动阶段就会被拦下,不会走到这里)。请用 `openssl rand -hex 32` 重新生成一个 64 位小写 hex 字符串。
+检查密钥来源、别名优先级、原 JWT 和密文所属路径。错误密钥、损坏密文或未知格式会失败,不会回落为明文成功。备份解封装成功也不表示归档内凭据能解密,详见 [备份与恢复](/docs/use/backup-restore)。
diff --git a/content/docs/configure/environment.mdx b/content/docs/configure/environment.mdx
index 1d2c9af..a6e583c 100644
--- a/content/docs/configure/environment.mdx
+++ b/content/docs/configure/environment.mdx
@@ -7,6 +7,8 @@ description: 所有环境变量的完整说明
如果你不确定某个变量的作用,保持默认值即可。
+以下默认行为固定已发布的 Core v17.0.4。未发布的 v18 开发分支已强制字段加密和 V2 写入,生产环境须提供显式字段密钥,源码 dev 可自动生成并持久保存本地密钥;JWT 至少 32 字符,旧关闭配置须删除,见 [v18 准备说明](/docs/migrate/v18-preparation)。
+
以下环境变量适用于 Mix Space Core 后端服务。Docker 用户在 `docker-compose.yml` 顶部的 `x-mx-env` 锚点中设置;源码用户在 `.env` 或 `ecosystem.config.cjs` 中设置。
@@ -17,8 +19,8 @@ description: 所有环境变量的完整说明
| 变量名 | 说明 | 默认值 | 示例 |
| --- | --- | --- | --- |
-| `JWT_SECRET` | JWT 签名密钥。**运行时不做长度校验**,建议 16-32 个字符。未设置时 token 会在重启后全部失效 | - | `my-secret-key` |
-| `JWTSECRET` | `JWT_SECRET` 的别名,两者都设置时以 `JWT_SECRET` 为准 | - | `my-secret-key` |
+| `JWT_SECRET` | 登录与 OAuth token 加密秘密。启用 V2 时至少 32 字符;已有实例保留原值,短 JWT 用维护轮换处理 | - | `原实例秘密或新生成的随机值` |
+| `JWTSECRET` | `JWT_SECRET` 的别名,两者都设置时以 `JWT_SECRET` 为准 | - | `原实例秘密或新生成的随机值` |
| `JWT_EXPIRE` | 登录令牌有效期(天) | `14` | `30` |
| `ALLOWED_ORIGINS` | 允许的跨域域名。**自建站必须显式设置**,否则你的域名不在白名单内 | 内置默认白名单(`innei.ren`、`localhost:*`、`*.dev`、`*.vercel.app` 等) | `example.com,www.example.com` |
| `SNOWFLAKE_WORKER_ID` | 工作节点 ID,取值 `0-1023` 的整数。**生产环境不设置会直接启动失败**,单实例填 `1` | - | `1` |
@@ -45,9 +47,9 @@ cluster 模式下可安全使用多实例:服务端会基于 `NODE_APP_INSTANC
| 变量名 | 说明 | 默认值 | 示例 |
| --- | --- | --- | --- |
-| `REDIS_URL` | 完整连接串,**优先级最高**,会覆盖下面三项。支持 `redis://` / `rediss://`(`rediss` 自动启用 TLS),path 里的 `/0` 表示 db 序号 | - | `redis://:pass@host:6379/0` |
-| `REDIS_CONNECTION` | `REDIS_URL` 的别名 | - | - |
-| `REDIS_CONNECTION_STRING` | `REDIS_URL` 的别名,优先级最高 | - | - |
+| `REDIS_URL` | 完整连接串,优先级低于 `REDIS_CONNECTION_STRING`、`REDIS_CONNECTION`。支持 `redis://` / `rediss://`(`rediss` 自动启用 TLS),path 里的 `/0` 表示 db 序号 | - | `redis://:pass@host:6379/0` |
+| `REDIS_CONNECTION` | 完整连接串,优先于 `REDIS_URL` | - | - |
+| `REDIS_CONNECTION_STRING` | 完整连接串,优先级最高 | - | - |
| `REDIS_HOST` | 地址 | `localhost` | `redis` |
| `REDIS_PORT` | 端口 | `6379` | `6379` |
| `REDIS_PASSWORD` | 密码 | - | `secret` |
@@ -57,16 +59,20 @@ cluster 模式下可安全使用多实例:服务端会基于 `NODE_APP_INSTANC
| 变量名 | 说明 | 默认值 | 示例 |
| --- | --- | --- | --- |
| `ENCRYPT_ENABLE` | 启用加密 | `false`(⚠️ 只要设置了密钥就等价于 `true`)| `true` |
-| `ENCRYPT_KEY` | 加密密钥,必须正好 64 位 | 自动取 machine-id 派生 | `openssl rand -hex 32` |
+| `ENCRYPT_KEY` | 开启时必须显式提供 64 位十六进制字段密钥 | 无可用默认密钥 | `openssl rand -hex 32` |
| `MX_ENCRYPT_KEY` | `ENCRYPT_KEY` 的别名,**优先于** `ENCRYPT_KEY` | 同上 | - |
| `MX_ENCRYPT_ENABLE` | `ENCRYPT_ENABLE` 的别名,**优先于** `ENCRYPT_ENABLE` | 同上 | - |
-| `ENCRYPT_ALGORITHM` | 加密算法 | `aes-256-ecb` | `aes-256-cbc` |
+| `ENCRYPT_ALGORITHM` | v17 旧格式算法;V2 固定 AES-256-GCM。v18 已删除此项,保留会报错 | `aes-256-ecb`(仅 v17) | `aes-256-ecb`(仅 v17) |
+| `MX_SECRET_STORAGE_V2` | V2 凭据写入;要求启用加密、JWT 至少 32 字符,全部副本兼容后才启用 | `false` | `true` |
+| `MX_MIGRATE_SECRETS` | 旧凭据回填的一次性迁移开关,不放入常驻 app 环境;不等于 v18 准备完成 | 未开启 | `1` |
| `ADMIN_UPDATE_S3_BASE_URL` | 后台更新包(`latest.json` + `admin-.zip`)的下载源 | `https://admin-r2.innei.dev` | - |
`ENCRYPT_ENABLE` 的判定逻辑是「未设置时,回落到是否设置了密钥」。**只填密钥、不管开关,加密就已经开启。** 想暂不加密必须显式写 `ENCRYPT_ENABLE=false`。详见 [数据加密](/docs/configure/encryption)。
+当前 v17.0.4 保留关闭加密的临时明文逃生口,不能读取已有密文或绕过明确要求加密的功能。未发布的 v18 开发分支已实现强制加密,新准备工具也尚未发布,见 [v18 准备说明](/docs/migrate/v18-preparation)。
+
## 缓存与限流
| 变量名 | 说明 | 默认值 | 示例 |
diff --git a/content/docs/deploy/agent.mdx b/content/docs/deploy/agent.mdx
index bfe4931..c591926 100644
--- a/content/docs/deploy/agent.mdx
+++ b/content/docs/deploy/agent.mdx
@@ -156,9 +156,9 @@ Agent 可以帮你执行命令行操作和调用 API,但涉及敏感信息(
你是 Mix Space 部署专家。请 SSH 到我的服务器(IP: ___,用户: root),
帮我完成以下操作:
1. 安装 Docker 和 Docker Compose
-2. 下载 Mix Space 的 docker-compose.yml
-3. 设置 JWT_SECRET 为随机字符串,ALLOWED_ORIGINS 为我的域名
-4. 启动所有服务
+2. 固定同一个已发布版本的 docker-compose.yml 和 app/mx-migrate 镜像,先阅读大版本迁移说明
+3. 新装生成并保存至少 32 字符的 JWT;启用加密时保存 hex64 字段密钥,ALLOWED_ORIGINS 为我的域名;已有实例保留原秘密,短 JWT 需要维护轮换
+4. 为 app/mx-migrate 配置相同环境,发布前迁移成功后再启动业务副本;v18 准备工具尚未发布,不能在 v17.0.4 上调用
5. 配置 Nginx 反向代理,将 ___ 域名指向 localhost:2333
6. 用 Certbot 申请 SSL 证书
7. 验证 https://___ 可以正常访问
diff --git a/content/docs/deploy/docker.mdx b/content/docs/deploy/docker.mdx
index f3b50b1..dda1c54 100644
--- a/content/docs/deploy/docker.mdx
+++ b/content/docs/deploy/docker.mdx
@@ -36,17 +36,19 @@ docker-compose -v
## 拉取编排文件 [step]
-自 v12 起 Core 已从 MongoDB 迁移到 PostgreSQL,编排文件中不再有 mongo 服务。当前版本(v14)的 `docker-compose.yml` 由 `app`、`mx-migrate`、`postgres`、`redis` 四个服务组成。
+自 v12 起 Core 已从 MongoDB 迁移到 PostgreSQL,编排文件中不再有 mongo 服务。当前 v17 的 `docker-compose.yml` 由 `app`、`mx-migrate`、`postgres`、`redis` 四个服务组成。
```bash
cd && mkdir -p mx-space/core && cd $_
# 拉取 docker-compose.yml 文件(全球)
-wget https://fastly.jsdelivr.net/gh/mx-space/core@master/docker-compose.yml
+wget https://fastly.jsdelivr.net/gh/mx-space/core@v17.0.4/docker-compose.yml
# 拉取 docker-compose.yml 文件(Gh-Proxy 全球)
-wget "https://gh-proxy.org/https://github.com/mx-space/core/blob/master/docker-compose.yml"
+wget "https://gh-proxy.org/https://github.com/mx-space/core/blob/v17.0.4/docker-compose.yml"
```
+本文固定已发布的 v17.0.4 编排。下载后把 app 和 mx-migrate 两处镜像都固定为 `innei/mx-server:17.0.4`,不要让一方使用 latest。未来准备工具尚未包含在该镜像,见 [v18 准备说明](/docs/migrate/v18-preparation)。
+
## 配置环境变量 [step]
我们需要正确配置环境变量,打开获取到的`docker-compose.yml`文件。
@@ -71,15 +73,15 @@ x-mx-env: &mx-env
```
-`SNOWFLAKE_WORKER_ID` 是必填项(`0-1023` 的整数),生产环境缺失会导致 mx-core 直接启动失败。官方文件里的默认值是 `'1'`。另外 `mx-migrate` 也会引用同一个 `*mx-env` 块并跑完整的 Nest 上下文,所以这里改了迁移步骤也会同步生效——**不要只给 `app` 服务单独加变量**。
+`SNOWFLAKE_WORKER_ID` 是必填项(`0-1023` 的整数),生产环境缺失会导致 mx-core 直接启动失败。官方文件里的默认值是 `'1'`。另外 `mx-migrate` 也会引用同一个 `*mx-env` 块执行独立发布前迁移,所以这里改了迁移步骤也会同步生效——**不要只给 `app` 服务单独加变量**。
### 配置生成
-在下方的组件中填入配置,点击复制后粘贴到 `docker-compose.yml` 的 `x-mx-env` 块中,覆盖 `JWT_SECRET` 和 `ALLOWED_ORIGINS` 的值,如果需要开启加密功能,请添加并将 `ENCRYPT_ENABLE` 的值改为 `true`,并填入 `ENCRYPT_KEY`。
+在下方的组件中填入配置,点击复制后粘贴到 `docker-compose.yml` 的 `x-mx-env` 块中,覆盖 `JWT_SECRET` 和 `ALLOWED_ORIGINS` 的值,如果需要开启加密功能,请添加并将 `ENCRYPT_ENABLE` 的值改为 `true`,并填入原 `ENCRYPT_KEY`;从未加密的新实例才用 `openssl rand -hex 32` 生成并持久保存。复制内容是 YAML 映射,所有值按字符串引用,美元符号按 Compose 规则转义。
-注意:只要设置了 `ENCRYPT_KEY`(或 `MX_ENCRYPT_KEY`)而没有显式设 `ENCRYPT_ENABLE=false`,加密就会被**隐式开启**。如果暂时只想把密钥填进去而暂不加密,必须显式写 `ENCRYPT_ENABLE: false`。
+注意:只要设置了 `ENCRYPT_KEY`(或 `MX_ENCRYPT_KEY`)而没有显式设 `ENCRYPT_ENABLE=false`,加密就会被**隐式开启**。如果暂时只想把密钥填进去而暂不加密,必须显式写 `ENCRYPT_ENABLE: 'false'`。
@@ -112,7 +114,7 @@ x-mx-env: &mx-env
type={{
'JWT 密钥': {
description:
- '用于签发用户 JWT,务必保存好不要泄露。运行时不做长度校验,但建议使用 16-32 个字符。未设置时 token 会在服务重启后全部失效。',
+ '用于签发用户 JWT,务必保存好不要泄露。V2 要求至少 32 字符;已有 JWT 不要直接更换,短值需要维护轮换。',
type: 'string',
},
'被允许的域名': {
@@ -127,12 +129,14 @@ x-mx-env: &mx-env
},
'加密密钥': {
description:
- '开启加密后必填,必须正好 64 位,否则启动即报错。推荐用 openssl rand -hex 32 生成(产出 64 位小写 hex)。未提供时服务端会用 machine-id 派生。此操作不可逆,请谨慎。',
+ '开启加密后必填,必须正好 64 位,否则启动即报错。推荐用 openssl rand -hex 32 生成(产出 64 位小写 hex)。必须显式配置,不依赖 machine-id。已有实例保留原密钥,新实例随机生成并单独保存。',
type: 'string',
},
}}
/>
+开启 V2 前确认全部副本支持新格式,JWT 至少 32 字符,再在共享块设置 `MX_SECRET_STORAGE_V2: 'true'`。历史实例还需要凭据回填,不能只切开关。
+
### 启动服务
```bash
diff --git a/content/docs/deploy/source.mdx b/content/docs/deploy/source.mdx
index acec278..b8f0e30 100644
--- a/content/docs/deploy/source.mdx
+++ b/content/docs/deploy/source.mdx
@@ -11,7 +11,7 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
## 要求
-- [Node.js](https://nodejs.org/zh-cn/) 22.12+(v14.7.0 起源码部署的最低要求)
+- [Node.js](https://nodejs.org/zh-cn/) 26+(v15 起要求;Node.js 22、24 不再支持)
- [PostgreSQL](https://www.postgresql.org/download/) 16+
- [Redis](https://redis.io/download)
- [Git](https://git-scm.com/downloads)、[PNPM](https://pnpm.io/installation)、[PM2](https://pm2.keymetrics.io/docs/usage/quick-start/)
@@ -22,8 +22,10 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
### 克隆并安装
```bash
-git clone https://github.com/mx-space/core.git --depth=1
+git clone https://github.com/mx-space/core.git --branch v17.0.4 --depth=1
cd core
+npm install -g corepack
+corepack enable
pnpm i
```
@@ -33,7 +35,6 @@ pnpm i
```bash
pnpm build
-pnpm bundle
```
@@ -71,8 +72,8 @@ module.exports = {
NODE_PATH: nodePath,
// 必填:允许跨域的域名,多个用逗号分隔
ALLOWED_ORIGINS: 'your-domain.com',
- // 必填:JWT 密钥。未设置时 token 会在重启后失效(建议 16-32 个字符,运行时不做长度校验)
- JWT_SECRET: 'your-jwt-secret',
+ // 必填:JWT 密钥。未设置时 token 会在重启后失效(V2 要求至少 32 字符;已有实例保留原值)
+ JWT_SECRET: 'REPLACE_WITH_YOUR_SAVED_INSTANCE_SECRET',
// 必填:Snowflake worker ID,整数 0-1023。生产环境不设置会直接启动失败。
// cluster 模式可安全使用多实例:core 会基于 NODE_APP_INSTANCE 为每个实例分配不重叠的 ID
SNOWFLAKE_WORKER_ID: '1',
@@ -87,7 +88,15 @@ module.exports = {
-### 启动
+### 先迁移,再启动
+
+先为独立迁移进程配置同样的数据库、Redis、字段密钥、JWT 等环境变量(例如 `apps/core/.env`),不要只放在 PM2 的 env 对象里。已有秘密保留,新装随机生成并持久保存。仓库根运行:
+
+```bash
+pnpm -C apps/core migrate
+```
+
+确认迁移退出成功,再进入 `apps/core`:
```bash
pm2 start ecosystem.config.cjs
diff --git a/content/docs/getting-started/quick-start.mdx b/content/docs/getting-started/quick-start.mdx
index 8aca279..6515e9f 100644
--- a/content/docs/getting-started/quick-start.mdx
+++ b/content/docs/getting-started/quick-start.mdx
@@ -5,7 +5,7 @@ title: 5 分钟快速体验
import { Callout } from 'fumadocs-ui/components/callout'
import { Step, Steps } from 'fumadocs-ui/components/steps'
-本指南的目标是在 **5 分钟** 内让你在本地运行起一个完整的 Mix Space,**无需配置域名,无需填写环境变量**(全部使用默认值)。
+本指南的目标是在 **5 分钟** 内让你在本地运行起一个完整的 Mix Space,无需公网域名。本文以 v17.0.4 为本地体验基线;公开部署前需要保存自己的 JWT 与字段密钥,见 [Docker 部署](/docs/deploy/docker)。v18 尚未发布,未来会要求显式加密配置,见 [准备说明](/docs/migrate/v18-preparation)。
@@ -22,8 +22,9 @@ import { Step, Steps } from 'fumadocs-ui/components/steps'
执行以下命令克隆后端仓库并启动服务:
```bash
- git clone https://github.com/mx-space/core.git --depth=1
+ git clone https://github.com/mx-space/core.git --branch v17.0.4 --depth=1
cd core
+ # 将 compose 中 app / mx-migrate 两处镜像固定为 innei/mx-server:17.0.4
docker compose up -d
```
diff --git a/content/docs/migrate/index.mdx b/content/docs/migrate/index.mdx
index f31f256..3a592d8 100644
--- a/content/docs/migrate/index.mdx
+++ b/content/docs/migrate/index.mdx
@@ -8,6 +8,11 @@ import { Card, Cards } from 'fumadocs-ui/components/card'
import { ArrowUp, Globe } from 'lucide-react'
+ 准备工具、凭据转换和发布边界
+ Agent 历史与 WebSocket 协议
+ BCP 47 语言标签
+ Node.js 26 运行时
+
}
href={'/docs/migrate/v13-to-v14'}
diff --git a/content/docs/migrate/meta.json b/content/docs/migrate/meta.json
index ba75587..dd7e129 100644
--- a/content/docs/migrate/meta.json
+++ b/content/docs/migrate/meta.json
@@ -4,6 +4,10 @@
"root": true,
"pages": [
"index",
+ "v18-preparation",
+ "v16-to-v17",
+ "v15-to-v16",
+ "v14-to-v15",
"v13-to-v14",
"v12-to-v13",
"v11-to-v12",
diff --git a/content/docs/migrate/v14-to-v15.mdx b/content/docs/migrate/v14-to-v15.mdx
new file mode 100644
index 0000000..02a0590
--- /dev/null
+++ b/content/docs/migrate/v14-to-v15.mdx
@@ -0,0 +1,21 @@
+---
+title: v14 → v15
+description: Node.js 26 与发布包运行时要求
+---
+
+v15 将 Core、构建工具和发布包的最低运行时统一为 Node.js 26。此变更已经发布,不属于 v18 的新增破坏。
+
+## 升级前
+
+- 保存数据库、文件与原秘密的快照。
+- 官方 Docker 镜像已经包含 Node.js 26,主机无需另装 Node。
+- 源码、自定义镜像、Railway/nixpacks 及运行 CLI 的主机必须先升级到 Node.js 26;Node.js 22、24 不再受支持。
+- Node.js 26 不再内置 Corepack,需自行安装,再按仓库 `packageManager` 选择 pnpm。`@mx-space/api-client`、CLI、MongoDB 迁移 CLI 和 Webhook 包也声明 Node.js 26 要求。
+
+固定目标版本的镜像和编排文件,运行发布前 `mx-migrate`,成功后启动 app。源码部署见 [部署步骤](/docs/deploy/source)。
+
+## 验证与回退
+
+确认 `node --version`、迁移退出状态、`/api/v3/ping` 和后台登录。需要回退时使用原版本代码和兼容的数据库;涉及数据恢复时同时恢复原文件和秘密,不能仅替换镜像。
+
+[官方 v15.0.0 发布说明](https://github.com/mx-space/core/releases/tag/v15.0.0)
diff --git a/content/docs/migrate/v15-to-v16.mdx b/content/docs/migrate/v15-to-v16.mdx
new file mode 100644
index 0000000..41225b9
--- /dev/null
+++ b/content/docs/migrate/v15-to-v16.mdx
@@ -0,0 +1,22 @@
+---
+title: v15 → v16
+description: 语言代码切换为 BCP 47 标签
+---
+
+v16 的存储、AI 设置与 API 输出统一使用 `zh-CN`、`zh-TW`、`en-US`、`ja-JP` 等 BCP 47 标签。繁体中文可以独立作为翻译目标。
+
+## 受影响的调用
+
+旧的 `zh`、`en`、`ja` 应迁为相应语言标签;`jp`、`cn`、`english` 等别名不再接受。`tw` 表示阿坎语,不能代替 `zh-TW`。客户端比较 `sourceLang`、`availableTranslations` 和 `targetLang` 时,应按语言与文字系统匹配,避免只比较旧的短代码。
+
+`@mx-space/ai` 1.x 移除了 `normalizeLanguageCode`、`normalizeTargetLang`,改用 `canonicalizeLanguageTag`、`assertLanguageTag`、`matchLanguage`。Core 与适配后的 Yohaku 网页、移动端一起部署;其他主题先确认支持这些标签。
+
+## 迁移与验证
+
+备份后让发布前迁移 `0047_language_tag_bcp47` 完成,再切换业务副本。同一文章的新旧标签冲突时保留最近写入的记录;无法映射的值保持原样,并输出 `RAISE NOTICE`,需要检查处理。
+
+验证翻译目标、语言列表、摘要、精读、搜索和朗读。`en-GB` 可匹配 `en-US`,`zh-HK` 可匹配 `zh-TW`。没有繁体摘要时不再用简体摘要替代。
+
+迁移没有 down;只回退代码不会恢复旧标签或被合并的行。需要完整回退数据时使用升级前快照和原秘密。
+
+[官方 v16.0.0 发布说明](https://github.com/mx-space/core/releases/tag/v16.0.0)
diff --git a/content/docs/migrate/v16-to-v17.mdx b/content/docs/migrate/v16-to-v17.mdx
new file mode 100644
index 0000000..b61eec0
--- /dev/null
+++ b/content/docs/migrate/v16-to-v17.mdx
@@ -0,0 +1,23 @@
+---
+title: v16 → v17
+description: 服务端 Agent 会话与新 WebSocket 协议
+---
+
+v17 将编辑器 AI Agent 的完整 transcript 放到服务端,后台与服务端改用新的 WebSocket 消息形状。
+
+
+发布迁移会重建 Agent 会话表,清空升级前的 Agent 历史对话。旧对话无法带入新协议。此项属于 v17,不能在 v18 升级说明中再次当作新破坏。
+
+
+## 操作顺序
+
+1. 备份数据库、文件与原秘密,确认可以接受旧 Agent 对话清空。
+2. 固定 v17 的镜像与匹配编排,让发布前 `mx-migrate` 完成。
+3. 一起部署 Core 和同版本内置 Admin,刷新原先打开的后台标签页;独立旧 Admin 不兼容新 Agent 协议。
+4. 验证登录、新建 Agent 对话、重新打开历史、工具调用和修改建议恢复。
+
+文章、评论等业务内容不属于该会话表清空范围。回退到旧 Agent 协议需要升级前完整快照;只回退镜像不能找回已清空的对话。
+
+v17.0.4 修复了关闭加密时普通配置写入失败的兼容问题;这是临时逃生口,见 [数据加密](/docs/configure/encryption)。下一阶段见 [v18 准备](/docs/migrate/v18-preparation)。
+
+[官方 v17.0.0 发布说明](https://github.com/mx-space/core/releases/tag/v17.0.0)
diff --git a/content/docs/migrate/v18-preparation.mdx b/content/docs/migrate/v18-preparation.mdx
new file mode 100644
index 0000000..dc079db
--- /dev/null
+++ b/content/docs/migrate/v18-preparation.mdx
@@ -0,0 +1,96 @@
+---
+title: v18 准备(未发布)
+description: 本开发分支已强制加密,部署前检查凭据、配置和 AI 指标
+---
+
+
+v18 尚未发布。本开发分支已移除非加密运行和旧格式写入,生产启动必须提供显式字段密钥与至少 32 字符的 JWT。源码 dev 可自动生成本地字段密钥,见下方说明。本文的 `upgrade:*` 工具尚未发布,已发布的 v17.0.4 不包含它们。最低准备版 tag、SDK、CLI 和主题版本会在实际发布验证后补齐;不要直接在现有镜像上运行不存在的命令。
+
+
+## 计划改变什么
+
+| 范围 | 当前 v17 | v18 计划 |
+| --- | --- | --- |
+| 凭据写入 | v17.0.4 可分阶段启用 V2,有临时明文逃生口 | **本开发分支已生效**:生产显式 hex64 字段密钥、dev 可自动生成本地密钥;JWT 至少 32 字符、固定 V2;旧关闭配置报错 |
+| 普通摘要、精读 GET | 可能隐式生成 | 只读,无结果 `data: null`;生成显式触发 |
+| 非流式执行入口 | 旧 GET 可用 | 备份、清缓存、测试邮件与速记表态用 POST;GET SSE 保留 |
+| 配置与参数 | v17 保留旧配置、排序与别名兼容 | **本分支已生效**:发布前迁移旧配置,正常服务只认新结构,PATCH 拒绝未知声明字段;数字排序和评论别名移除仍在后续阶段 |
+| AI 元数据 | 旧响应字段保留 | 统一 generationMetrics,后续才删除旧列与旧响应字段 |
+| SDK | 5.x 兼容版先转换请求 | 协调 SDK 6.x;保留 legacy 入口与 CLI V2 响应适配 |
+
+加密强制、旧配置迁移和配置严格校验已在本分支实现;其余最终破坏仍分阶段推进。当前继续支持旧 GET 和 SDK 5.x 签名,不改变普通 AI GET 的既有行为,不删除物理列。API `/api/v3`、snake_case、成功/错误 envelope 和 SSE 帧协议保持。
+
+## 准备顺序
+
+1. 更早的实例先按历史指南升级到兼容的 v17,保存数据库、文件、原字段密钥、原 JWT 和备份私钥。
+2. 取得包含工具的构建,先不要启动业务副本。独立工具读取 `PG_URL` / `PG_CONNECTION_STRING` 或 `PG_*`、字段密钥和 JWT;业务进程用 CLI/YAML 配置时,也要提供等效环境变量。先用 check 诊断现有配置。
+3. JWT 不足 32 字符时先执行下方维护轮换。不要直接改环境变量,否则现有 OAuth token 可能无法解密。
+4. 删除旧加密开关,保存原有效字段密钥与符合要求的 JWT。**先停止全部旧副本和后台写入者**,再使用本构建运行 `node migrate.mjs`(源码为 `pnpm -C apps/core migrate`)。配置转换是必跑的发布前数据迁移,无需额外开关;失败阻止新服务启动,成功才记录 `20261109-canonical-config`。这一步需要维护窗口,不能让旧版在迁移后继续写回旧结构。
+5. 同一个 migrate 还会回填 AI 指标,并必跑 `20261109-required-secret-storage`,完成 Snippet/Webhook 加密、API Key 哈希和 OAuth 加密。迁移成功后运行 check,再启动新业务副本,验证原 API Key、OAuth、配置消费与 AI 指标。Compose 中 app 与 mx-migrate 必须使用同一构建与秘密。
+
+### 启动配置与旧开关
+
+- 生产环境的 `MX_ENCRYPT_KEY` 或 `ENCRYPT_KEY` 必须是显式的 64 位十六进制密钥;不再回退到机器 ID,也不再派生短密钥。只有从未加密的新实例才用 `openssl rand -hex 32` 生成首个字段密钥并持久保存。
+- `JWT_SECRET`(别名 `JWTSECRET`)至少 32 字符;已有短 JWT 必须先按下方维护流程轮换。
+- 旧开关的 `true` 值暂接受并提示删除;`false` 或其他无效值会阻止启动,check 报告 `DEPRECATED_ENCRYPTION_FLAG`。它们不再控制运行模式。
+- 配置、Snippet 和 Webhook 新写入固定 AES-256-GCM,使用随机 96 位 nonce、128 位认证标签,并以 AAD 绑定所属字段;API Key 固定哈希,OAuth 固定加密。历史明文和默认 AES-256-ECB 仍可读取并迁移。
+- **破坏性变更**:删除 `ENCRYPT_ALGORITHM`、命令行 `--encrypt_algorithm` 和 YAML `encrypt_algorithm`。保留旧项会报错,即使填旧默认值、`aes-256-gcm` 或空环境变量也不接受;应删除配置项。环境变量检查报告 `REMOVED_ENCRYPTION_OPTION`。
+- app 与 mx-migrate 使用相同且持久保存的密钥。字段加密必填不影响 ZIP 备份和历史归档读取;备份加密仍由独立设置控制。
+
+历史字段解密固定为旧默认 AES-256-ECB。曾使用其他旧算法的实例,必须先在旧版本中使用原密钥和原算法将相关凭据转换为 V2,并验证凭据可用后再升级。删除配置项不会转换自定义算法密文。原算法和密钥仍须随旧快照单独保管,以便在隔离的旧版本环境恢复。
+
+### 源码开发环境
+
+dev 未提供字段密钥时,首次启动自动生成随机密钥,以仅所有者可读写的 `0600` 权限保存到当前工作目录的 `tmp/dev-encryption.key`;重启和多个开发进程复用同一密钥。显式 CLI、YAML 或环境变量密钥始终优先。已有本地密钥损坏或显式密钥无效时会报错,不会自动覆盖。字段仍使用 AES-256-GCM 加密,JWT 长度要求不变。
+
+本地密钥须和开发数据库一同保留;删除 `tmp` 也会删除它。生产构建不会生成或读取这个默认密钥,即使设置 `NODE_ENV=development`。独立升级检查仍读取环境配置;检查开发库或将它迁到生产前,须显式配置原本地密钥,不能重新生成。
+
+源码工作区中的命令(需要本分支代码和已完成 schema 迁移的数据库):
+
+```bash
+pnpm -C apps/core upgrade:check --json
+pnpm -C apps/core migrate
+pnpm -C apps/core upgrade:check --json
+```
+
+构建产物中用 `node migrate.mjs` 运行正式迁移,用 `node upgrade.mjs check --json` 检查结果。在包含该入口的镜像中,可使用 `docker compose run --rm --no-deps app node upgrade.mjs check --json` 启动独立检查;数据库需已运行。该检查进程不启动 HTTP、业务定时任务或 Redis。当前分支的 Compose 会先校验密钥和 JWT 非空;诊断缺失配置时使用源码命令或直接 `docker run`,避免先被 Compose 拦截。
+
+`check` 退出码:`0` 为 ready,`2` 为 needs-action,`1` 为检查失败。它只读,输出计数和路径,不输出秘密。空库可识别为新装;历史实例须完成规范配置迁移并通过实际数据检查,没有单独准备标记。
+
+所有回填都属于正式发布迁移,无需独立 prepare、dry-run 或凭据迁移开关。配置迁移持有数据库锁,与正常配置 PATCH 串行;按原路径解密,再转换配置并按新路径加密。凭据迁移哈希旧 API Key、封装 OAuth 并加密 Snippet/Webhook,原 API Key 字符串保持有效。新的 v18 凭据迁移有独立账本 ID,历史 opt-in 凭据迁移记录不会使本次回填被跳过。
+
+每条迁移成功后才记录完成,损坏密文、未知配置形状、错误字段密钥或 JWT 会使对应批次失败并回滚,阻断发布。先前成功的迁移保留账本,重试时自动跳过;重复执行不会重复哈希 API Key 或轮换已加密 token。迁移和检查完成后启动新副本,不清空所有 Redis 状态。
+
+## 配置一次性迁移
+
+发布迁移把 `githubToken` 转为 `github.token`,将 Dodo 凭据转为 `membership.apiKey` / `webhookSigningKey`,把独立图片生成和 TTS 配置并入 `ai.providers` 与功能配置,删除旧配置行。历史 SMTP 的 `smtp.options` 连接参数迁为 `smtp.host` / `port` / `secure`。凭据先按原字段路径解密,再按新路径加密,保留实际值。
+
+普通启动、读取和 PATCH 不再转换旧结构,也不再返回独立 `imageGenerationOptions` / `ttsOptions` 分区。旧分区读写返回 `CONFIG_NOT_FOUND`;未知或旧字段返回 `CONFIG_VALIDATION_FAILED`,不会静默忽略。部分更新、空秘密保留原值,以及 record / 自由 JSON 参数仍有效。
+
+数据库缺少配置迁移记录时,新服务拒绝初始化配置并提示运行发布迁移。转换器只保留在发布迁移与只读检查中,供历史数据升级;旧归档仍按下方隔离恢复流程处理。
+
+规范凭据为空时保留已有旧凭据;新旧值都非空且不同时,报告 `CONFIG_SECRET_CONFLICT` 与字段路径,配置迁移整批回滚。先在旧实例或隔离维护环境核对并明确解决冲突,再重跑迁移;不要尝试启动新服务后通过旧字段 PATCH 修复。错误密钥、损坏密文和不支持的配置形状同样阻塞迁移,不写完成记录。
+
+## 维护窗口轮换 JWT
+
+先停止全部业务副本、后台任务和其他写入者。将原 JWT 和至少 32 字符的新 JWT 放在两个仅操作者可读的文件中,不把秘密值放在命令行或聊天记录里:
+
+```bash
+pnpm -C apps/core upgrade:rotate-jwt --maintenance-confirmed --old-jwt-file /secure/old-jwt --new-jwt-file /secure/new-jwt
+```
+
+构建产物对应 `node upgrade.mjs rotate-jwt` 加同样参数;容器以只读 volume 挂载这两个文件,再传容器内路径。工具只读取文件,不改写秘密文件或业务环境。
+
+事务内用旧 JWT 解开 OAuth,再用新 JWT 重封装;保留 provider token,不联系服务商,不改字段密钥和 API Key。成功会清空登录会话,所有用户须重新登录。之后由操作者将 **全部副本** 配置为同一新 JWT,再启动。失败整批回滚;原 JWT 丢失时不得清空 token 伪装成功,应找回原秘密或重新授权。
+
+历史 Better Auth 裸 hex 密文也会验证与转换。疑似该格式但验证失败的 token 会阻塞操作,需要核对原秘密或重新授权,不会当明文继续。
+
+## 消费者与恢复边界
+
+兼容 SDK 将速记表态改为 POST,并把数值排序转换成 `asc` / `desc`;已有 token 参数转为认证头。Admin 的备份、清缓存与测试邮件提前走 POST。尚未验证的客户端不能宣称兼容 v18。
+
+回退最低版本须是经过实测的准备版,当前发布 tag 尚未确定。回退代码不会回退 schema;回到更老版本必须有旧快照和当时的秘密。
+
+当前恢复工具没有 v18 隔离预检闭环。历史 ZIP 或 v1 加密归档应先在隔离维护环境中恢复,用归档当时的字段密钥、JWT、恢复私钥完成 migrate 和 check,再验证并安排未来 v18 切换;不要把未迁移数据直接交给 v18 业务。数据库恢复和文件恢复是两个阶段,保留恢复前快照。
+
+配置字段类型已经按新结构同步到本分支的 API Client。旧 `githubToken` 和 `smtp.options` 调用须改为嵌套 GitHub 配置与平铺 SMTP 字段;这是配套大版本契约,不能将该类型删除作为 SDK 5.x patch 发布。
diff --git a/content/docs/use/backup-restore.mdx b/content/docs/use/backup-restore.mdx
index e25126a..276cc3e 100644
--- a/content/docs/use/backup-restore.mdx
+++ b/content/docs/use/backup-restore.mdx
@@ -1,64 +1,38 @@
---
title: 备份与恢复
-description: 保护你的 Mix Space 数据,学会备份与回滚
+description: ZIP、加密归档、原秘密与恢复边界
---
import { Callout } from 'fumadocs-ui/components/callout'
-## 手动备份
+## 创建与保存
-在后台后台的「备份」页面中点击「立即备份」,系统会将数据打包为 zip 文件,保存到服务器的绝对目录:
+后台「备份」页可以立即备份并下载。未启用归档加密时生成 ZIP;在「设置 → 存储 → 备份」启用备份加密后,新归档为 `.mxbackup`。定时备份、S3 上传和恢复前安全快照沿用该设置。字段加密与归档加密是两项独立设置,未来字段加密必填不等于所有备份必须加密。
-```
-~/.mx-space/backup/YYYY-MM-DD_HH:mm:ss/backup-YYYY-MM-DD_HH:mm:ss.zip
-```
+启用时浏览器生成公私钥对,只把公钥提交给服务端;私有恢复密钥只展示一次,务必复制或下载后单独保管。需要 HTTPS 或 localhost 的 Web Crypto 环境。服务器配置中的公钥不能用来恢复,旧归档仍需要创建时对应的私钥。
-
-数据目录固定为 `~/.mx-space`(开发模式下为项目下的 `./tmp`),不随部署路径变化。目录名与文件名的时间戳格式都是 `YYYY-MM-DD_HH:mm:ss`——注意其中**包含冒号**,这是 Linux/macOS 允许、Windows 不允许的。
+
+备份私钥、字段加密密钥与 JWT 分别保存。归档打开后,其中的加密凭据仍需要当时的原字段密钥和 JWT。不要把恢复私钥与备份一起放在数据目录。
-建议定期手动下载备份包到本地,以防服务器故障导致数据丢失。
-
-## 自动备份
-
-目前该功能仅支持备份到支持 S3 协议的云服务,暂不支持其他类型的云服务。当然,你也可以通过设置计划任务等方法达到和此功能一样的效果。
-
-设置入口:后台「设置 → 存储 → 备份」,填入云服务所提供的对应参数即可。
-
## 恢复
-
-该操作涉及修改数据库,请多备份几个工作日的 backup,数据无价请谨慎操作!
-
-一般情况下回滚只会导致 analyze 数据页丢失 IP & PV 的数据。
-
-
-服务端提供了两条现成的恢复路径,**都不需要手工重命名备份包**:
+上传已有 ZIP / `.mxbackup`,或选择服务器上的备份回滚,都无需手工改名。加密归档在恢复对话框粘贴私钥或导入 PEM 文件;它只随本次认证请求发送,不保存到服务器设置。旧 ZIP 不需要归档密钥,早期 v1 对称加密归档输入原 64 位十六进制密钥。
-1. **上传备份包恢复** —— 在后台后台的「备份」页面中上传你手上的 zip 备份包即可,服务端会先把它落到临时目录,再直接执行恢复。
-2. **按目录名回滚** —— 如果你想回到某次已有备份,直接指定该备份的目录名即可。
+服务端先验证整个加密归档,再解包和恢复数据库。缺少/错误私钥、损坏归档会失败;不要重新生成密钥冒充恢复。恢复需要 `pg_dump`、`pg_restore`、`psql`,官方镜像包含客户端工具。
-恢复操作开始前,服务端会**自动先做一次新备份**,所以回滚本身也是可逆的。回滚会导致 analyze 数据页丢失 IP 与 PV 数据,其余内容(文章、评论等)不受影响。操作前仍建议多备份几个工作日的包。
+恢复会覆盖数据库业务内容和数据文件。操作前服务端尝试创建安全快照,仍应额外保存可恢复的外部副本。会话、日志、搜索索引等运行时表按当前结构重建为空表。数据库替换在单个事务内,文件随后复制,二者不是同一个原子事务;文件复制失败时保留快照处理,不应报告整次恢复成功。
-替换完成后会提示「数据库有变动,将在 x 秒后重载页面」,重载后请先检查文章、评论等有没有丢失。
-
-## 命令行备份(v12+)
+恢复后核对文章、评论、登录、凭据消费及文件。跨版本恢复须遵循对应迁移指南。未来 v18 的历史恢复应走「隔离恢复 → migrate → check → 验证 → v18」路径,见 [v18 准备说明](/docs/migrate/v18-preparation)。当前没有自动隔离预检,不要恢复后直接开放未来 v18 业务。
-自 v12 起,Mix Space 使用 PostgreSQL 作为数据库。服务端自身的备份用的是 `pg_dump --format=custom`(custom 格式),你手工备份时建议保持一致:
+## 手工数据库快照
-```bash
-# 备份(custom 格式,dump 落在 mx-space/pg.dump)
-pg_dump -Fc postgresql://mx:mx@localhost:5432/mx_core > pg.dump
-```
-
-恢复时用 `pg_restore`,并加上服务端同样使用的清理参数:
+服务端使用 PostgreSQL custom 格式。手工备份可以使用:
```bash
-pg_restore --clean --if-exists -d postgresql://mx:mx@localhost:5432/mx_core pg.dump
+pg_dump -Fc "$PG_URL" > pg.dump
```
-
-`--clean --if-exists` 会在恢复前删除已存在的同名对象。如果你要的是纯 SQL 文本备份,用 `pg_dump` 不加 `-Fc`,恢复时改用 `psql < backup.sql`。
-
+该文件仅是数据库快照,不包含数据目录、秘密管理器或恢复私钥。请在隔离数据库中演练恢复;不要将生产库作为首次验证目标。
diff --git a/content/docs/use/update.mdx b/content/docs/use/update.mdx
index 2d8d9a8..86e0211 100644
--- a/content/docs/use/update.mdx
+++ b/content/docs/use/update.mdx
@@ -28,10 +28,10 @@ docker compose pull && docker compose up -d
```bash
pnpm i
pnpm build
-pnpm bundle
+pnpm -C apps/core migrate
```
-随后进入 `./apps/core`,执行以下命令启动服务:
+先确认迁移成功,并为迁移进程提供与 app 相同的数据库、字段密钥、JWT 等环境变量。随后进入 `./apps/core` 启动服务:
```bash
pnpm prod
@@ -50,6 +50,11 @@ pm2 start ecosystem.config.cjs
+ 准备工具、凭据转换和发布边界
+ Agent 历史与 WebSocket 协议
+ BCP 47 语言标签
+ Node.js 26 运行时
+
{
- const lines = variableNames.map((v, i) =>
- format === 'yaml' ? `- ${v.key}=${values[i]}` : `${v.key}=${values[i]}`,
- );
- navigator.clipboard.writeText(lines.join('\n')).then(() => {
+ navigator.clipboard.writeText(formatEnvVariables(variableNames, values, format)).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
diff --git a/src/components/env-variable-format.mts b/src/components/env-variable-format.mts
new file mode 100644
index 0000000..8a81cca
--- /dev/null
+++ b/src/components/env-variable-format.mts
@@ -0,0 +1,13 @@
+/** Compose environment mappings contain strings; dollar escaping prevents interpolation. */
+export function formatEnvVariables(
+ variables: ReadonlyArray<{ key: string }>,
+ values: readonly string[],
+ format: 'yaml' | 'env',
+): string {
+ return variables.map((variable, index) => {
+ const value = values[index] ?? '';
+ return format === 'yaml'
+ ? `${variable.key}: ${JSON.stringify(value.replaceAll('$', () => '$$'))}`
+ : `${variable.key}=${value}`;
+ }).join('\n');
+}
diff --git a/tests/env-variable-format.test.mjs b/tests/env-variable-format.test.mjs
new file mode 100644
index 0000000..cd4e67f
--- /dev/null
+++ b/tests/env-variable-format.test.mjs
@@ -0,0 +1,25 @@
+import assert from 'node:assert/strict';
+import { execFileSync, spawnSync } from 'node:child_process';
+import test from 'node:test';
+import { parse } from 'yaml';
+import { formatEnvVariables } from '../src/components/env-variable-format.mts';
+
+test('YAML output is a string-valued mapping with quoted special values', () => {
+ const values = ['false', '00123', 'a: b # value', 'quote" slash\\\nnext line'];
+ const variables = values.map((_, index) => ({ key: `KEY_${index}` }));
+ const result = parse(formatEnvVariables(variables, values, 'yaml'));
+ assert.equal(Array.isArray(result), false);
+ values.forEach((value, index) => assert.equal(result[`KEY_${index}`], value));
+});
+
+test('Compose preserves literal dollar signs in copied secrets', { skip: spawnSync('docker', ['image', 'inspect', 'alpine:latest']).status !== 0 }, () => {
+ const environment = formatEnvVariables([{ key: 'JWT_SECRET' }], ['literal$secret${UNSET}'], 'yaml');
+ const compose = `services:\n app:\n image: alpine:latest\n pull_policy: never\n network_mode: none\n environment:\n${environment.split('\n').map((line) => ` ${line}`).join('\n')}\n`;
+ const result = execFileSync('docker', ['compose', '-f', '-', 'run', '--rm', '--no-deps', 'app', 'printenv', 'JWT_SECRET'], { input: compose, encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, COMPOSE_PROJECT_NAME: `mx-docs-test-${process.pid}` } });
+ assert.equal(result.trim(), 'literal$secret${UNSET}');
+});
+
+test('existing env output and caller values remain unchanged', () => {
+ const values = Object.freeze(['test-value']);
+ assert.equal(formatEnvVariables([{ key: 'KEY' }], values, 'env'), 'KEY=test-value');
+});