Skip to content

Latest commit

 

History

History
426 lines (340 loc) · 26.8 KB

openless_python_server_docker_v3.md

File metadata and controls

426 lines (340 loc) · 26.8 KB

OpenLess 自建同步服务器 — Python + Docker 开发规格书

0. 背景(给实现者的上下文)

OpenLess 是一个开源的跨平台听写/输入法应用(Windows/macOS/Linux 桌面端 + Android),客户端代码在 github.com/Open-Less/openless。它自带一套端到端加密(E2EE)多设备同步功能,客户端 Rust 代码位于 openless-all/app/crates/openless-core/src/cloud_sync_e2ee*。

现状:这套同步机制默认依赖官方服务器 apic.openless.top:9443,而该服务器的登录认证走的是 GitHub OAuth Device Flow——客户端必须能连上 github.com/api.github.com 才能建立同步会话。在中国大陆网络环境下,这一步经常不稳定。

本次任务目标:实现一个自托管的替代服务器,具备与官方服务器完全相同的同步数据接口(客户端的加密/合并逻辑完全不用改),但认证方式换成服务器管理员手动签发的静态 Token,彻底不依赖 GitHub。

重要前提:本服务器只需要做"存储 + 认证 + 并发控制",不需要理解、不需要解密任何业务数据——所有同步内容在到达服务器之前已经是客户端加密好的密文(E2EE),服务器只是一个带版本控制的密文 blob 存储 + token 校验网关。这是本项目能做得"轻量"的根本原因,请不要引入任何业务数据解析逻辑。

服务器的完整 HTTP 接口规格来自对客户端 Rust 源码(cloud_sync_e2ee_protocol/transport.rs、types.rs)的逆向整理,下面第 3 节的每一条都是从真实客户端代码里核对过的硬性要求,不是建议,是必须——客户端有大量 assert/validate 逻辑,字段名、字段值、HTTP 状态码但凡有一处不符,客户端会直接拒绝连接或同步失败。


1. 技术栈

项目 选型 理由
语言/框架 Python 3.12 + FastAPI 原生异步、Pydantic 做严格 JSON schema 校验(本协议大量字段要求 camelCase 命名 + 拒绝未知字段),单进程即可生产可用
ASGI server uvicorn 与 FastAPI 官方推荐搭配,单容器单进程即可
数据库 SQLite(Python 内置 sqlite3 模块,无需额外依赖) 个人/小团队多设备同步场景,并发量极低,SQLite 完全够用,且免去额外容器
容器化 Docker + docker-compose,单容器 见第 5 节
HTTPS 不在容器内处理,交给宿主机反向代理(Caddy/Nginx)做 TLS termination,容器内只监听纯 HTTP 简化容器逻辑;客户端强制要求 https:// 开头的地址,但这可以由反代提供

2. 项目目录结构建议

openless-sync-server/
├── Dockerfile
├── docker-compose.yml
├── requirements.txt
├── .env.example
├── app/
│   ├── __init__.py
│   ├── main.py              # FastAPI app 入口,挂载路由
│   ├── config.py            # 从环境变量读配置
│   ├── db.py                # SQLite 连接、建表(启动时自动建表,若不存在)
│   ├── models.py            # Pydantic 请求/响应模型(camelCase 别名)
│   ├── auth.py               # token 校验、session 签发/校验中间件
│   ├── routes/
│   │   ├── capabilities.py   # GET /v1/capabilities
│   │   ├── auth.py           # POST /v1/auth/token, DELETE /v1/auth/session
│   │   └── vault.py          # GET/PUT/DELETE /v1/me/vault*, GET /v1/me/operations/{id}
│   └── admin_cli.py           # 命令行小工具:创建账户 + 生成静态 token(不走 HTTP,本地执行)
├── data/                      # volume 挂载点:sqlite 文件 + 密文快照存这里
└── tests/
    └── test_protocol.py       # 见第 7 节验收测试,用 pytest + httpx 写

3. HTTP 接口详细规格(逐条硬性要求)

所有 JSON 字段名一律 camelCase。所有时间用 ISO8601 字符串。所有响应 Content-Type: application/json(除非另有说明)。

3.1 GET /v1/capabilities

  • 认证:无需认证,任何人可访问。
  • 响应:200 OK,body 必须逐字节精确匹配以下结构(客户端硬编码校验,任意一个数值错误都会导致客户端报 UnsupportedProtocol 并拒绝继续):
{
  "protocolVersion": 1,
  "cryptoProfile": "argon2id-xchacha20poly1305-v1",
  "githubClientId": "Ov23liyv3nEucG7oMHNE",
  "maxHttpBodyBytes": 25165824,
  "maxCiphertextBytes": 16777232,
  "maxPlaintextJsonBytes": 15728640,
  "idempotencyRetentionSeconds": 604800,
  "maxBackupRetentionDays": 30
}
  • ⚠️ githubClientId 不是随便填的占位值,客户端会严格校验它:客户端建立连接时会先请求这个接口,把返回的 githubClientId 跟自己编译时写死的 GitHub OAuth App Client ID 做逐字节比较,不相等就直接报 OAuth application mismatch 并拒绝继续,根本走不到后面的 /v1/auth/token。当前客户端源码(crates/openless-core/src/marketplace.rs)里写死的默认值是 "Ov23liyv3nEucG7oMHNE"(可以被编译时的 GITHUB_OAUTH_CLIENT_ID 环境变量覆盖——如果你知道自己那份客户端编译时设置了别的值,改用那个值)。这个字段名字带 "github" 只是历史遗留,跟真实 GitHub 账号无关,纯粹是客户端用来确认"这是我认识的那个 OAuth 应用"的一个固定校验值,服务器原样抄成上面这个字符串即可,不需要自己生成或校验。
  • idempotencyRetentionSeconds:客户端要求 ≥ 604800(即至少 7 天),写死 604800 即可。
  • maxBackupRetentionDays:客户端要求 ≤ 30,写死 30 即可。
  • 其余四个数值(protocolVersion/cryptoProfile/maxHttpBodyBytes/maxCiphertextBytes/maxPlaintextJsonBytes)必须与上面示例完全相等,不可修改。

3.2 POST /v1/auth/token(新增的静态 Token 认证端点,替代官方的 /v1/auth/github)

  • 认证:请求头 Authorization: Bearer <静态token>。
  • 服务器逻辑:
    1. 从 accounts 表中查找与该 token 哈希匹配的账户记录。
    2. 未找到或 token 已被吊销 → 401 Unauthorized,body:{"code": "unauthenticated", "message": "invalid token"}。
    3. 找到 → 在 sessions 表插入一条新记录:生成一个新的随机 access_token(建议 32 字节随机数做 base64url 编码),过期时间 = 当前时间 + 900 秒。
  • 响应(200 OK):
{
  "protocolVersion": 1,
  "accessToken": "<刚生成的随机字符串>",
  "tokenType": "Bearer",
  "expiresIn": 900,
  "account": { "githubId": "<account_id 字符串>", "login": "<账户显示名>" }
}
  • ⚠️ protocolVersion 和 tokenType 是必填字段,不能省略:客户端用 #[serde(deny_unknown_fields)] 严格解析这个响应,少了任何一个字段会直接反序列化失败(报 json schema 错误,在换取会话这一步就整个失败,根本走不到后面)。protocolVersion 必须恰好等于 1(整数,不是字符串),tokenType 必须恰好是字符串 "Bearer"(大小写、拼写都要精确匹配,随便写别的值客户端会拒绝)。这两个字段在 3.1 的 /v1/capabilities 里也出现过,是同一套协议版本/令牌类型约定,/v1/auth/token 的响应必须原样带上,不是 /v1/auth/github 独有的。
  • expiresIn 必须是 1 到 900 之间的整数(客户端硬性校验区间,不能超过 900)。
  • ⚠️ account.githubId(以及下面所有接口里出现的 ownerGithubId)格式有硬性限制,不是任意字符串:客户端把这个字段按 GitHub 数字账号 ID 的格式解析——必须是纯十进制数字组成的字符串,长度 1~20 位,且不能有前导零("0" 本身也不合法,必须 ≥ 1)。传一个 UUID 或用户名字符串进去,客户端会在收到响应的第一时间就解析失败,整个登录直接报错。最简单的做法:accounts 表的主键用数据库自增整数(如 SQLite 的 INTEGER PRIMARY KEY AUTOINCREMENT,天然从 1 开始、无前导零),JSON 里输出时转成字符串(str(account_id))即可,不需要额外校验格式。

3.3 DELETE /v1/auth/session(登出)

  • 认证:Authorization: Bearer <access_token>(不是静态 token,是 3.2 换回来的那个)。
  • 逻辑:从 sessions 表删除该 access_token 对应记录。
  • 响应:204 No Content(无 body)。客户端专门校验这个状态码,返回 200 会被当成异常。
  • 若 access_token 无效/已过期 → 401,客户端会将其当作"已经登出"处理,不视为错误。

3.4 认证中间件(对 3.3 之后所有 /v1/me/* 接口生效)

  • 从 Authorization: Bearer <access_token> 取值,查 sessions 表。
  • 查不到,或 expires_at 已过去 → 一律返回 401 Unauthorized,body:
    {"code": "unauthenticated", "message": "session expired or invalid"}
    (客户端会识别 code 字段里的 unauthenticated/session_expired 这两个值,做出不同的重试逻辑,两个值都可以用,实现上统一用 unauthenticated 即可。)

⚠️ 通用规则,3.5~3.9 都适用:revision/baseRevision/committedRevision 这几个字段,跟 3.2 里 githubId/ownerGithubId 是同一个底层类型(Revision,本质是数字,但在 JSON 里必须写成字符串,例如 "0"、"3",不能写成 JSON 数字 0、3;同样不能有前导零)。下面所有示例已经按这个规则写成字符串,照抄即可,不要"优化"成数字类型。

3.5 GET /v1/me/vault(拉取元数据)

  • 认证:见 3.4。
  • 条件请求支持:若请求头带 If-None-Match: <etag> 且与当前 vault_metadata.etag 相等 → 返回 304 Not Modified,无 body。
  • 否则:返回 200 OK,响应头必须带 ETag: <当前etag>,body 必须包含下面全部 11 个字段,一个都不能少(客户端用 #[serde(deny_unknown_fields)] 严格解析,多字段、少字段都会直接失败)。empty 状态(还没上传过任何快照)示例:
{
  "protocolVersion": 1,
  "state": "empty",
  "ownerGithubId": "2",
  "revision": "0",
  "vaultId": null,
  "keyId": null,
  "updatedAt": null,
  "payloadSchemaVersion": null,
  "ciphertextBytes": 0,
  "ciphertextSha256": null,
  "lastOperationId": null
}

已经上传过快照的 active 状态示例(vaultId/keyId/updatedAt/payloadSchemaVersion/ciphertextSha256/lastOperationId 这 6 个字段从 null 变成实际值,其余字段名不变):

{
  "protocolVersion": 1,
  "state": "active",
  "ownerGithubId": "2",
  "revision": "3",
  "vaultId": "be406ca5-37fa-4b26-9a9a-74c1aad48eae",
  "keyId": "2b1889ab-7278-48e7-8d2f-284195de2484",
  "updatedAt": "2026-10-01T12:00:00Z",
  "payloadSchemaVersion": 1,
  "ciphertextBytes": 123456,
  "ciphertextSha256": "aaaa...(64 位小写十六进制 sha256)",
  "lastOperationId": "f1d8c32e-d209-4e56-8735-bc66b8684c71"
}
  • protocolVersion 固定是 1(整数,不是字符串——只有 revision 系的字段才是字符串)。
  • state 取值只能是 "empty"、"active" 或 "deleted"(v1 里基本只会用到前两种,deleted 是删除流程中间态,正常不会持久停留在这个状态——见 3.8)。
  • ciphertextBytes 是普通整数(不是 Revision),empty 状态下写 0。
  • etag 的生成方式:建议直接用 revision 的字符串形式,或 sha256(account_id + ":" + revision),只要每次 revision 变化时 etag 也跟着变、revision 不变时 etag 不变即可,客户端不关心具体算法。

3.6 GET /v1/me/vault/snapshot(拉取密文快照本体)

  • 认证:见 3.4。
  • 要求请求头 If-Match: <etag>:若与当前 vault_metadata.etag 不一致 → 返回 412 Precondition Failed(客户端收到后会重新走 3.5 再重试)。
  • 若 vault 状态是 empty(还没有快照) → 返回 404 Not Found。
  • 否则 返回 200 OK,body 就是 3.7 里客户端 PUT 上来的那个 body 原封不动地返回(ciphertext 是 base64 编码的密文,服务器不需要、也不能解析其内容)——这条接口不需要自己理解或重新拼装字段,存的是什么 JSON 字节,原样吐出来就行,字段名、字段数量、类型全部跟着客户端走,不要凭空增删或改动大小写。

3.7 PUT /v1/me/vault/snapshot(上传新快照 —— 全部接口里最核心的一条)

  • 认证:见 3.4。

  • 必须携带的请求头:

    • If-Match: <etag>——必须等于当前 vault_metadata.etag,否则整个写入基于过期状态,需要拒绝。
    • Idempotency-Key: <operation_id>——同一个请求 id 的重复提交,服务器必须能识别出来。
    • Content-Type: application/json。
  • 请求 body:客户端发来的 JSON,服务器原样存下整个 body(不需要理解每个字段的业务含义),但需要单独抽取以下几个字段用于并发控制判断:revision(新版本号,字符串)、baseRevision(基于哪个旧版本号写的,字符串)、operationId、kind("create" / "snapshot" / "passwordChange")、vaultId。

  • 处理逻辑(严格按此顺序):

    1. 幂等检查:查 idempotency_log 表,(account_id, operation_id) 是否已存在。
      • 存在 → 直接返回上次记录的响应 body 和状态码,额外加响应头 Idempotency-Replayed: true。不要重新执行任何写入逻辑。
      • 不存在 → 继续下一步。
    2. If-Match 校验:请求头 If-Match 必须等于当前 vault_metadata.etag,不相等 → 412 Precondition Failed,不记入 idempotency_log(因为根本没写成功)。
    3. 乐观并发校验:body 里的 baseRevision 必须等于当前 vault_metadata.revision;如果不相等,说明有别的设备已经抢先写过了 → 返回 409 Conflict,body:{"code": "revision_conflict", "message": "..."},同样不记入 idempotency_log。
    4. 两个校验都通过:
      • vault_snapshot 表整条覆盖为新的密文 body。
      • vault_metadata.revision = baseRevision + 1(也就是客户端传来的新 revision 值,两者应该相等,可以再做一次断言)。
      • vault_metadata.state 置为 "active"。
      • vault_metadata.lastOperationId = 本次 operation_id。
      • 生成新的 etag。
      • 把本次的响应结果写入 idempotency_log。
    5. 响应:200 OK(⚠️不是 204),响应头 Idempotency-Replayed: false,body 必须是完整的 "操作回执" 结构,下面 7 个字段一个不能少(跟 3.9 轮询接口、3.8 删除接口返回的是同一种结构):
    {
      "operationId": "<本次 operation_id>",
      "status": "committed",
      "kind": "snapshot",
      "committedRevision": "<新版本号,字符串,同上面的 revision>",
      "committedAt": "2026-10-01T12:00:00Z",
      "vaultId": "<本次写入的 vault_id>",
      "ciphertextSha256": "<64 位小写十六进制,sha256(请求体里 ciphertext 字段解码后的原始字节)>"
    }
    • status 固定字符串 "committed"(v1 版本所有写操作都同步完成,不会有别的取值)。
    • kind 原样回显请求体里的 kind 字段。
    • ciphertextSha256 必须真的计算(不能瞎填):客户端会用它跟本地重新计算的哈希比对,对不上直接报错。

3.8 DELETE /v1/me/vault(删除整个 vault)

⚠️ 这不是一个简单的无 body 幂等删除接口——它跟 3.7 走的是同一套"操作回执"机制(幂等检查、If-Match、乐观并发、200 + OperationReceipt),只是 HTTP 方法是 DELETE、请求体和响应体的字段形状不同。照抄下面的规格,不要按普通 REST 删除接口的直觉去实现(普通直觉是"204 No Content",这里客户端明确要求 200 + 一个 JSON body,204 会被客户端拒绝)。

  • 认证:见 3.4。
  • 必须携带的请求头:跟 3.7 完全一样——If-Match、Idempotency-Key、Content-Type: application/json。
  • 请求 body(客户端发来的 JSON,4 个字段):
{
  "protocolVersion": 1,
  "baseRevision": "<当前 revision,字符串>",
  "operationId": "<operation_id>",
  "expectedVaultId": "<当前 vault_id>"
}
  • 处理逻辑(跟 3.7 的第 1~3 步完全一样,第 4 步不同):

    1. 幂等检查(同 3.7)。
    2. If-Match 校验(同 3.7)。
    3. 乐观并发校验:body 里的 baseRevision 必须等于当前 vault_metadata.revision,不等 → 409 Conflict(同 3.7)。
    4. 全部通过后:
      • 清空该账户的 vault_snapshot 记录。
      • vault_metadata.state 重置为 "empty"。
      • vault_metadata.revision = baseRevision + 1。
      • vault_metadata.vaultId/keyId/updatedAt/payloadSchemaVersion/ciphertextSha256 全部置回 null,ciphertextBytes 置回 0。
      • vault_metadata.lastOperationId = 本次 operation_id。
      • 生成新的 etag。
      • 把本次的响应结果写入 idempotency_log。
    5. 响应:200 OK(同样不是 204),响应头 Idempotency-Replayed: false,body 是跟 3.7 一样的 "操作回执" 结构,kind 固定 "delete",ciphertextSha256 固定 null,vaultId 是被删除前的那个 vault_id(不是 null——客户端会拿它跟删除前的 vault_id 比对):
    {
      "operationId": "<本次 operation_id>",
      "status": "committed",
      "kind": "delete",
      "committedRevision": "<新版本号,字符串>",
      "committedAt": "2026-10-01T12:00:00Z",
      "vaultId": "<被删除前的 vault_id>",
      "ciphertextSha256": null
    }

3.9 GET /v1/me/operations/{operation_id}

  • 认证:见 3.4。
  • 逻辑:查 idempotency_log 表是否有该 operation_id 的记录(跨账户查不到算未找到)。
    • 找到 → 200 OK,body 是跟 3.7/3.8 完全一样的 "操作回执" 结构(不是简化版的 {"operationId":"...","status":"committed"}——同样是那 7 个字段一个不能少)。
    • 没找到 → 404 Not Found,body:{"code": "operation_not_found", "message": "..."}。
  • 本项目 v1 版本所有写操作都是同步完成的,这个接口只是配合客户端的轮询逻辑做个简单查询(比如客户端在提交请求后掉线、没收到 3.7/3.8 的响应,重新连上后靠这个接口确认到底有没有写成功),从 idempotency_log 里把上次记录的同一份回执原样吐出来就行,不需要真正的异步任务队列。

4. 数据库表结构(SQLite DDL,启动时若表不存在则自动创建)

CREATE TABLE IF NOT EXISTS accounts (
    account_id   INTEGER PRIMARY KEY AUTOINCREMENT,  -- 必须是自增整数:JSON 里转成字符串后要满足 GithubId 的格式限制(见 3.2 的⚠️说明)
    login_name   TEXT NOT NULL,
    token_hash   TEXT NOT NULL UNIQUE,   -- sha256(静态token),不存明文
    revoked      INTEGER NOT NULL DEFAULT 0,
    created_at   TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS sessions (
    access_token TEXT PRIMARY KEY,
    account_id   INTEGER NOT NULL REFERENCES accounts(account_id),
    expires_at   TEXT NOT NULL   -- ISO8601
);

CREATE TABLE IF NOT EXISTS vault_metadata (
    account_id            INTEGER PRIMARY KEY REFERENCES accounts(account_id),
    vault_id              TEXT,               -- NULL when state='empty'
    key_id                TEXT,               -- NULL when state='empty'
    state                 TEXT NOT NULL DEFAULT 'empty',
    revision              INTEGER NOT NULL DEFAULT 0,   -- store as integer; serialize to string in JSON (see 3.5's ⚠️ note)
    payload_schema_version INTEGER,           -- NULL when state='empty'
    ciphertext_bytes      INTEGER NOT NULL DEFAULT 0,
    ciphertext_sha256     TEXT,               -- NULL when state='empty'; 64-char lowercase hex
    last_operation_id     TEXT,
    etag                  TEXT NOT NULL,
    updated_at            TEXT              -- NULL when state='empty'; ISO8601 when active
);

CREATE TABLE IF NOT EXISTS vault_snapshot (
    account_id INTEGER PRIMARY KEY REFERENCES accounts(account_id),
    body_json  TEXT NOT NULL,   -- 原样存客户端上传的整个 JSON body(含 ciphertext 字段)
    revision   INTEGER NOT NULL
);

CREATE TABLE IF NOT EXISTS idempotency_log (
    account_id      INTEGER NOT NULL,
    operation_id    TEXT NOT NULL,
    response_status INTEGER NOT NULL,
    response_body   TEXT NOT NULL,
    created_at      TEXT NOT NULL,
    PRIMARY KEY (account_id, operation_id)
);

所有接口把 account_id 写进 JSON 响应时(account.githubId、ownerGithubId 等字段),一律用 str(account_id) 转成十进制字符串 —— 数据库里是整数,线上协议里是字符串,这是两回事,不要弄混。

账户/token 的管理不走 HTTP 接口(避免暴露一个"创建账户"的公开端点带来的安全风险),而是通过 app/admin_cli.py 命令行工具在服务器上本地执行,例如:

docker compose exec sync-server python -m app.admin_cli create-account --login "我的账户"
# 输出:account_id=<自增整数,例如 1>, token=<明文token,只显示这一次,自己抄下来配到客户端里>

5. Docker 化要求

5.1 Dockerfile

FROM python:3.12-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app/ ./app/

# 非 root 用户运行
RUN useradd -m appuser
USER appuser

EXPOSE 8080
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

5.2 docker-compose.yml

services:
  sync-server:
    build: .
    ports:
      - "127.0.0.1:8080:8080"   # 只监听本机,对外暴露交给宿主机反代做 TLS
    volumes:
      - ./data:/app/data
    environment:
      - DB_PATH=/app/data/sync.db
      - STORAGE_PATH=/app/data
    restart: unless-stopped

5.3 requirements.txt

fastapi
uvicorn[standard]
pydantic

(刻意不引入 ORM/额外框架,保持依赖最小。)

5.4 部署验收标准

  • docker compose up -d --build 一条命令启动成功,无需任何手动初始化步骤(建表逻辑在应用启动时自动跑)。
  • 镜像体积 < 150MB(docker images 查看)。
  • docker compose down 后 docker compose up -d 重新启动,之前创建的账户和同步数据仍然存在(验证 volume 挂载生效)。
  • 容器内进程不是以 root 运行(docker compose exec sync-server whoami 应输出非 root 用户)。
  • 容器日志中不出现明文 token、密文内容的打印(检查所有 log/print 语句,确保不泄露敏感字段)。

6. 安全要求清单

  • 静态 token 在数据库中只存 sha256 哈希,任何日志/错误信息都不得回显明文 token。
  • 所有 /v1/me/* 接口在未通过 3.4 认证中间件时一律 401,不允许有遗漏的未鉴权路径。
  • access_token 严格执行 15 分钟过期,过期后的 token 即使值本身没变也必须被拒绝。
  • 服务器代码里不出现任何解密/解析 ciphertext 字段内容的逻辑——这个字段对服务器来说应该完全是不透明的字节流。
  • admin_cli.py 创建账户时生成的静态 token 用密码学安全随机数生成器(Python secrets 模块,不要用 random)。
  • 所有 SQL 查询使用参数化查询,禁止字符串拼接 SQL(防注入,虽然是自用工具也要养成习惯)。

7. 验收测试清单(建议用 pytest + httpx 写成自动化测试,放在 tests/test_protocol.py)

  1. GET /v1/capabilities 返回值与第 3.1 节 JSON 逐字段比对相等。
  2. 用不存在的 token 调 POST /v1/auth/token → 401。
  3. 用 admin_cli 建好的账户 token 调 POST /v1/auth/token → 200,且 expiresIn ≤ 900。
  4. 用拿到的 access_token 调 GET /v1/me/vault → 200,响应体逐字段比对 3.5 节 empty 状态的完整 11 字段示例(尤其 revision 必须是字符串 "0" 不是数字 0,protocolVersion/ciphertextBytes 等 6 个非 null 字段也必须都在),响应头有 ETag。
  5. 带上一步拿到的 etag 做 If-None-Match 再调一次 GET /v1/me/vault → 304。
  6. 用错误/过期的 access_token 调任意 /v1/me/* 接口 → 401。
  7. 首次 PUT /v1/me/vault/snapshot(baseRevision="0")→ 200(不是 204),响应体是 3.7 节完整的 7 字段操作回执,committedRevision 变成字符串 "1"。
  8. 用同一个 Idempotency-Key 把上一步的请求原样再发一次 → 返回跟上次完全相同的 body,且响应头 Idempotency-Replayed: true;数据库里 revision 不能变成 2。
  9. 换一个新的 Idempotency-Key,但故意把 baseRevision 填成过时的值(比如还是 "0",而实际已经是 "1")→ 409 冲突,revision 不变。
  10. 正确递增 baseRevision 后再次上传 → 成功,committedRevision 变成 "2"。
  11. GET /v1/me/vault/snapshot 带正确 If-Match → 200,拿到的 body 与上传时的请求 body 逐字节比对完全一致(验证服务器只是原样存取,没有改动任何字段,包括 ciphertext)。
  12. GET /v1/me/vault/snapshot 带过时的 If-Match → 412。
  13. DELETE /v1/auth/session → 204,之后用同一个 access_token 再请求任意接口 → 401。
  14. DELETE /v1/me/vault(带正确的 If-Match/Idempotency-Key/3.8 节的 4 字段请求 body)→ 200(不是 204!),响应体是 3.8 节的操作回执,kind == "delete",ciphertextSha256 == null;之后 GET /v1/me/vault 的 state 变回 "empty",revision 变回 "0",其余可空字段全部变回 null。
  15. 重新走一遍第 7 条(对同一账户再次 PUT /v1/me/vault/snapshot,baseRevision 用第 14 条之后的新 revision)→ 验证删除后依然可以重新创建,不会被之前的 vault_id/key_id 卡住。
  16. GET /v1/me/operations/{operation_id} 传一个第 7~10 条里真实提交过的 operation_id → 200,body 跟当时 PUT 返回的操作回执完全一致;传一个随便编的 UUID → 404,body 是 {"code": "operation_not_found", ...}。

全部 16 条通过,即视为服务器端开发完成,可以对接客户端联调。


8. 明确不在本期范围内(不要做,避免过度设计)

  • 不做多快照历史版本(v1 只保留最新一份密文,旧版本不需要留档)。
  • 不做真正的异步任务队列(所有写操作都是同步完成的)。
  • 不做用户自助注册/Web 管理界面(账户管理走命令行 admin_cli.py)。
  • 不做 TLS/证书处理(交给外层反向代理)。
  • 不做除本文档列出的 8 个接口之外的任何额外接口。