22 KiB
API 契约 6.0
账户资料使用 GET/PATCH /accounts/profile;头像为 PNG/JPEG/WebP data URI,禁止远程 URL 和 SVG。不提供旧资料导入。
空间结构使用 GET /spaces/{id}/structure,包含 revision、channels、groups、folders、locations。频道与分组分别通过 /spaces/{id}/channels 和 /spaces/{id}/groups 创建;响应同时包含完整结构快照和服务端新建实体及 ID。其他字段通过 PATCH 重命名;频道归属通过 PUT /spaces/{id}/channels/{channelId}/group 修改。文件夹通过 /spaces/{id}/folders 创建及 PATCH 重命名,文件归属通过 PUT /spaces/{id}/locations 修改。
频道、分组和目录写操作需要账号会话和空间写权限;允许上传的成员可以关联和移动自己的资源,文件移动必须满足每个条目的作者或管理权限,跨空间、跨频道的错误分组/目录关联被拒绝。结构修改后通知订阅设备重新拉取。
URL 基础路径保留 /api/v1,/info.contract_version 为 8.0.0。v8 让频道强制登录:去掉未登录访客(设置 allow_guests、/guests 接口、访客终端席位),频道与空间的所有接口都要求账号会话,缺少时返回 401 account_required;便捷分享(/shares)不受影响。v7 把「设备」收束为账号的属性:成员只按用户管理,seats 系列命名改为 max_members / members_used / members_full,去掉设备名与设备清单接口,登录账号不再需要终端密钥。v6 引入账号签名根密钥、设备证书和频道对象签名事件;不兼容的旧主版本客户端会被契约预检拒绝。账号资料与空间结构由服务端管理。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;频道链接只对登录账号有效,未登录的人先登录再按加入规则加入。
GET /info 的 defaults 包含 channel、share 创建设置;省略创建请求的 settings 时服务端使用相同默认值。choices.max_members 与 limits.max_members 表示成员人数范围,不代表设备数。频道设置 JSON 字段为 max_members(旧存储里的 seats 在启动时迁移)。其他选项按部署限制收束,前端只格式化选项文案,不提供替代业务默认值。
基础路径 /api/v1。所有请求和响应的二进制数据格式见 protocol.md。前端托管在独立域名,服务端需要配置 CORS 白名单。
1. 鉴权与通用头
| 头 | 说明 |
|---|---|
X-WD-Role |
read / write / owner,指明 Authorization 里的令牌属于哪种角色 |
Authorization |
Bearer <base64url(令牌)> |
X-WD-Endpoint |
base64url(S),终端密钥。只用于便捷分享(未登录的创建者与读者);频道与空间不使用,可省略 |
X-WD-Cap |
创建频道时的 Cap 验证码 token(服务端启用验证码时必填,见 protocol §11) |
X-WD-Max-Downloads |
第 0 帧上带,条目的下载上限,0 为不限 |
X-WD-Account |
账号 session token。与频道/分享的能力令牌分开;频道、空间、创建和管理请求必填 |
X-WD-Event-* |
已登记签名设备提交频道对象 FINAL 帧时必填,绑定对象 ID、域/epoch、事件类型与完整密文帧 manifest,见 protocol §21 |
raw 模式的读请求不需要令牌,但仍需要账号会话。
WebSocket 无法设置头,令牌通过 Sec-WebSocket-Protocol 传递:wd.v1, wd.role.<role>, wd.auth.<base64url(令牌)>, wd.account.<base64url(账号 session)>,服务端回选 wd.v1。不要放进 URL,否则会被代理记录。
2. 权限矩阵
| 操作 | 需要 |
|---|---|
| 读日志、拉取条目 | read(enc)或知道 id(raw),并已是该频道的成员(账号) |
| 追加帧 | write(members_can_upload 为 true 时)或 owner |
| 删除条目 | owner;delete_policy=own 时账号等于条目作者;anyone 时任意成员 |
| 改设置、删频道、撤销成员 | owner;这些管理操作不占成员名额 |
3. 接口
账号与保险库
用户名为 3–32 位 ASCII 字母、数字、_ 或 -,大小写不敏感。客户端 PBKDF2-SHA256(600,000 次)派生口令根,再分别派生登录凭据和 AES-GCM 保险库密钥。登录凭据仅经 HTTPS 发送;服务端再用随机 salt 和 PBKDF2-SHA256(210,000 次)存储慢哈希,数据库里的哈希不能直接用于登录。服务端不接收口令根或保险库密钥。二进制字段使用标准 Base64,保险库密文最大 1.3 MB。
POST /api/v1/accounts注册{username,salt,verifier,vault,public_key,signing_public_key}。public_key是 P-256 ECDH 公钥,signing_public_key是 P-256 ECDSA 账号根公钥;对应私钥只保存在客户端加密保险库中。API v6 不兼容旧账号保险库协议,不自动迁移旧签名身份。POST /api/v1/accounts/salt请求{username},返回{salt},供客户端派生登录凭据。POST /api/v1/accounts/session请求{username,verifier,device_id?,device_name?}。成功返回{username,token};session token 随机生成,只保存 SHA-256,期限 30 天。GET /api/v1/accounts/vault使用Authorization: Bearer <token>读取{revision,vault}。PUT /api/v1/accounts/vault使用同一鉴权,发送{revision,vault}。revision 乐观并发控制;冲突返回409 vault_conflict,客户端必须先读取并合并,不能静默覆盖。DELETE /api/v1/accounts/session撤销当前 bearer session;退出登录时调用。GET /api/v1/accounts/users/{username}/key已登录用户查询 ECDH 邀请公钥和可选的账号签名根公钥。PUT /api/v1/accounts/devices/signing-key当前设备提交{device_id,public_key,signature}。服务端核对账号 session 绑定的 device_id,并验证账号根密钥对 P-256 设备公钥证书的签名后保存。GET /api/v1/accounts/users/{username}/key返回账号 ECDH 公钥与签名根公钥;签名对象携带设备公钥和根签设备证书,客户端首次 TOFU 固定根公钥后离线验设备证书。GET /api/v1/accounts/invitations返回当前账号的邀请,payload 为绑定收件用户名和频道 ID 的 ECDH + HKDF + AES-GCM 密文。
没有邮箱找回流程。忘记密码后保险库无法恢复。保险库仅同步资源凭据和草稿清单,不同步设备 S、设备名、缓存、原始文件或发送队列。浏览器存储按账号与访客分库;切换账号会中止订阅并清空内存会话。资源清单移除使用带时间戳的墓碑同步,避免旧设备将条目恢复。
签名根密钥保存在加密保险库;设备签名私钥以不可导出的 CryptoKey 保存在本机账户数据库,不随保险库同步。已登记签名设备写入 Workspace 对象时,FINAL 帧和删除请求必须带签名事件;尚未把签名要求扩展到频道结构和成员/权限变更。
GET /api/v1/info
返回服务端能力:协议版本、限制、验证码配置(captcha.provider、captcha.enabled、captcha.api_endpoint,即 Cap widget 要连接的地址)、传输能力以及创建默认值/选项。limits.max_workspace_bytes 是单个空间全部资源的上限,limits.max_workspace_channel_bytes 是单个频道 Files 域的文件容量上限;频道结构的 fileBytes 为当前已用容量。前端只展示服务端返回的用量和限制。defaults.channel_mode、defaults.channel 和 choices 是前端创建表单的初始值与可选项;服务端独立校验提交的设置。验证码的 secret 不会出现在这里。transports.http3 只是部署层(反向代理或 Kestrel 配置)声明已启用 HTTP/3,服务端进程本身不内置 QUIC。
POST /api/v1/channels
创建者必须登录。创建请求必须提供 settings.name,服务端拒绝缺失、空白或纯空格名称并返回 400 space_name_required,存储时去除首尾空格;名称长度最多 48 个字符。只有一套频道/成员/线程模型,mode=enc|raw 仅决定客户端内容是否端到端加密,不改变业务权限。创建者自动写入 owner 成员记录并占一个成员名额,同时登记当前设备终端;该账号的其他设备不增加名额。服务端启用验证码时还需要 X-WD-Cap。
{
"id": "<channel_id>",
"mode": "enc",
"settings": { "name": "项目组", "max_members": 2, "members_can_upload": true, "delete_policy": "own",
"ttl_seconds": 86400, "max_items": 1000, "file_max_downloads": 10 },
"read_hash": "<sha256(T_read)>",
"write_hash": "<sha256(T_write)>",
"owner_hash": "<sha256(T_owner)>"
}
raw 模式省略 read_hash;write_hash 仅在成员可上传时提供。创建者自动成为频道的所有者成员。返回 201,响应体与「读取频道信息」相同(ChannelInfo)。
GET /api/v1/channels/{id}
返回 ChannelInfo:设置(包含所有成员共享的频道名)、模式、成员名额占用(members_used)、oldest_seq、last_seq、时间。members_used 统计唯一账号成员,不统计账号的设备数。不占成员名额,需要账号会话,用于加入前预览和核对模式。频道名是普通设置,服务端可见。
POST /api/v1/channels/{id}/join
加入频道,必须带账号会话(没有则 401 account_required)。只登记成员关系:账号第一次加入时占一个成员名额,同一账号的更多设备不增加名额,此后仅凭账号会话鉴权。同一账号重复调用幂等。成员名额已满时返回 403 members_full。读取频道信息和 owner 管理接口不占成员名额;访问频道内容前必须已加入,否则返回 403 not_joined。响应 JoinResult:{joined_at, members_used, members_max},不再返回终端标识。
可发送 JSON {password}。join_policy=open 允许持链接的登录账号加入;join_policy=invite 要求该账号已受邀。频道主、已加入的账号成员和受邀账号无需再次输入频道密码。密码由服务端保存加盐 PBKDF2 哈希,从不通过频道信息返回。设备是账号的属性:同一账号可在任意多台设备登录,只占一个成员名额;账号成员的读写请求出示有效账号 session 即可,不再绑定到某个终端。
GET /channels/{id}/members 返回频道内账号成员及 owner|member 角色,每项附带账号级 online(该账号在本频道有活动 WebSocket/SSE 订阅)、devices(该账号的登录设备数,不小于 online_devices)与 online_devices(该账号此刻在本频道有订阅的设备数);设备是账号的属性,不单独作为成员。在线状态只来自内存里的订阅登记,不写库、不经订阅流推送,客户端按需轮询。GET /channels/{id}/invitations 不再返回已经成为成员的账号。邀请前,频道主调用 GET /channels/{id}/invitations/{username}/crypto-plan 获取服务端根据成员权限和历史设置计算的域/epoch/收件人计划。浏览器只为计划中的收件人生成并封装域密钥;随后 POST /channels/{id}/invitations 原子提交账号链接包与 domain_envelopes。服务端会重新计算计划并校验每个 epoch 和收件人,不接受前端自行增加授权或选择轮换范围。DELETE /channels/{id}/invitations/{username} 撤销邀请、成员授权与域密钥包,并阻止重新加入,直到再次邀请。不能移除频道主。移除访客用 DELETE /channels/{id}/guests/{endpoint_hash},同样阻止该终端重新加入。
频道设置、成员权限、邀请、加入密码与加密模式统一作用于同一 Channel。每次上传、发言、线程/目录控制条目写入均经服务器校验账号会话、频道成员关系与写入权限,不能靠客户端隐藏按钮绕过。移除账号成员才会释放成员名额;撤销账号的某台登录设备只使该设备的会话失效,不影响成员身份。WebSocket 账号 session 经 wd.account.<base64url(token)> 子协议传递,并在推送循环中重新检查有效性。
PATCH /api/v1/channels/{id}/settings
所有者修改设置。可通过 name 更新共享频道名,传空字符串清除名称;max_members 调整成员人数上限,不能改变已有成员身份;ttl_seconds 的修改从现在起算。
DELETE /api/v1/channels/{id}
所有者删除整个频道和全部密文。
POST /api/v1/channels/{id}/frames
追加一帧。请求体是完整封装(application/octet-stream)。当账号设备已登记签名证书且对象在 /spaces/{id}/crypto/objects 声明时,最终帧必须携带 X-WD-Event-Id/Kind/Channel/Domain/Epoch/Frames/Manifest/Signature(编辑可带 X-WD-Event-Target)。服务端验签并核对此前帧摘要与当前 FINAL 帧的清单;缺少或无效签名拒绝提交。签名随对象元数据进入 CryptoState,成员读取时仍按数据域权限过滤。
| 响应 | 含义 |
|---|---|
201 {"seq"} |
新追加 |
200 {"seq"} |
幂等重发,返回原 seq |
409 |
同一 (item_id, frame_index) 内容不同 |
403 |
无权、成员人数已满、模式或 ENC 标志不符 |
413 |
超出限制 |
429 |
限流 |
GET /api/v1/channels/{id}/log?from=<seq>&limit=&wait=<秒>
按 seq 拉取日志,返回记录的线格式拼接(application/octet-stream)。wait 大于 0 时为长轮询,最长 25 秒。满足 0 < from < oldest_seq 时返回 410 {"error":"gone","oldest_seq":N},客户端必须从 from=0 全量同步并丢弃本地已不存在的条目(不是从 oldest_seq 读起,见 protocol §8)。from=0 永远允许。
GET /api/v1/channels/{id}/ws?from=<seq>
WebSocket 订阅(降级链的第一级)。服务端向下推送:
- 二进制消息:一条日志记录。
- 文本消息(JSON 控制消息):
{"t":"hb","seq":N}心跳,{"t":"settings"}设置变更,{"t":"members"}成员变更。
上传仍走 HTTP POST,便于幂等重试。
GET /api/v1/channels/{id}/events?from=<seq>
SSE 订阅(降级链的第二级)。记录以 base64url 放在 data: 中,id: 是 seq,断线后用 Last-Event-ID 或 from 续传。由于 EventSource 不能设置头,前端用 fetch 流式读取。
降级与重连
WebSocket → SSE → 长轮询,每一级失败自动降级。心跳间隔 20 秒,45 秒收不到任何消息即判定死链。重连使用指数退避(1 秒到 30 秒)加抖动,并携带 from=last_seq+1。
GET /api/v1/channels/{id}/items/{item_id}/frames?from=<index>
拉取条目的帧(GATED 条目的取数方式)。from=0 计一次下载;from>0 只有同一下载方(按用户)此前对同一条目成功启动过 from=0 才能续传,否则返回 410 gone。条目尚未 FINAL 时,响应会随帧到达持续输出,直到 FINAL。达到下载上限后返回 410,并在 5 分钟宽限期后删除密文,宽限期内已开始的拉取可继续续传。
DELETE /api/v1/channels/{id}/items/{item_id}
按 delete_policy 授权后删除条目,并在日志中追加 tombstone。已登记签名设备还必须提交对应 chat.delete/file.delete 事件头;服务端验证其绑定的设备证书、权限和原对象帧 manifest 后保存删除签名,浏览器验签后才应用 tombstone。
已移除的接口
- v8:
GET /channels/{id}/guests、DELETE /channels/{id}/guests/{endpoint_hash}(频道不再有未登录访客;成员按用户管理:/members、/invitations)。 - v7:
PUT /channels/{id}/seats/me、PUT /channels/{id}/seats/{endpoint_hash}/label、GET/DELETE /channels/{id}/seats。设备名只在账号设备管理里存在(/accounts/devices)。
4. 便捷分享接口
便捷分享是与频道并列的另一种资源,语义见 protocol.md §15。没有 WebSocket、SSE、日志、成员登记和成员上传。接口前缀 /api/v1/shares,只支持 enc 模式,鉴权头只用 read 和 owner 两种角色(write 会被拒绝为 bad_request)。
| 接口 | 角色 | 阶段 | 说明 |
|---|---|---|---|
POST /shares |
无(需验证码) | 创建草稿。可带 X-WD-Endpoint:这个终端是创建者,不占访客名额 |
|
PATCH /shares/{id}/settings |
owner | 草稿 | 改有效期、访客上限、默认下载次数 |
POST /shares/{id}/frames |
owner | 草稿 | 追加帧。格式与频道的帧完全相同;不允许 SNAPSHOT 标志 |
DELETE /shares/{id}/items/{item_id} |
owner | 草稿 | 移除条目 |
DELETE /shares/{id} |
owner | 草稿 | 删除整个草稿 |
POST /shares/{id}/finish |
owner | 草稿 | 封存,一次性、不可撤销 |
GET /shares/{id} |
read | 已封存 | 分享信息和每个文件的剩余下载次数(owner 在草稿阶段也可读) |
GET /shares/{id}/manifest |
read | 已封存 | 清单条目的封装字节;首次读取登记访客 |
GET /shares/{id}/items/{item_id}/frames?from=<index> |
read | 已封存 | 下载文件,from=0 计一次下载;首次读取同样登记访客 |
POST /api/v1/shares
{
"id": "<share_id>",
"read_hash": "<sha256(T_read)>",
"owner_hash": "<sha256(T_owner)>",
"settings": { "ttl_seconds": 86400, "max_visitors": 0, "file_max_downloads": 10 }
}
需要验证码(X-WD-Cap,与创建频道相同的规则),并受创建限流约束。返回 201 ShareInfo。草稿的 expires_at 是创建后 24 小时(服务端配置 WebDrop:ShareDraftTtlSeconds);ttl_seconds 是封存之后的有效期,封存时才开始计时。
| 设置 | 默认 | 范围 |
|---|---|---|
ttl_seconds |
86400 | 60 ~ 604800 |
max_visitors |
0(不限) | 0 ~ 10000 |
file_max_downloads |
10 | 0(不限)或更大 |
POST /api/v1/shares/{id}/frames
请求体是完整的帧封装,响应沿用频道的 201/200 {"seq"} 结构。便捷分享没有日志游标,这里的 seq 是该帧的 frame_index,用于确认追加与幂等重发。内容冲突返回 409 conflict,超限返回 413 too_large。额外规则:SNAPSHOT 标志返回 400 bad_request;分享已封存返回 409 sealed。第 0 帧可带 X-WD-Max-Downloads 设置该文件的下载次数上限。
POST /api/v1/shares/{id}/finish
{ "manifest_item_id": "<b64url>" }
按 protocol §15.5 校验后封存。成功返回 200 ShareInfo(此时 state 为 sealed)。清单条目不存在、不是单帧非 GATED 的完整条目,或草稿里还有没传完的条目,返回 400 bad_request。已封存的分享再次调用返回 409 sealed。
GET /api/v1/shares/{id}
{
"id": "…", "state": "sealed",
"expires_at": 1700000000000, "created_at": 1699990000000, "sealed_at": 1699999999000,
"manifest_item_id": "…",
"settings": { "ttl_seconds": 86400, "max_visitors": 0, "file_max_downloads": 10 },
"items": [ { "item_id": "…", "bytes": 12345, "frames": 3, "max_downloads": 5, "downloads_left": 4 } ]
}
downloads_left 为 null 表示不限。响应永远不包含访客人数。草稿对读角色返回 404 not_found。
GET /api/v1/shares/{id}/manifest
返回清单条目的完整封装(application/octet-stream),必须带 X-WD-Endpoint。登记访客;访客名额已满且这个终端未登记时返回 403 visitors_full。
GET /api/v1/shares/{id}/items/{item_id}/frames
与频道的 GATED 拉取一致:from=0 计一次下载;from>0 只有同一 X-WD-Endpoint 此前对同一文件成功启动过 from=0 才能续传,否则返回 410 gone 且不登记访客。达到上限后返回 410 gone,5 分钟宽限期内已开始的拉取可继续续传。必须带 X-WD-Endpoint,同样会登记访客。
5. 错误
错误响应体是 JSON {"error": "<code>"},不回显任何用户提交的内容。
| code | HTTP | 含义 |
|---|---|---|
bad_request |
400 | 封装格式或参数错误 |
unauthorized |
401 | 令牌缺失或错误 |
account_required |
401 | 需要账号会话:频道与空间只接受已登录账号,账号会话缺失或已失效 |
forbidden |
403 | 角色不够 |
not_joined |
403 | 账号不是该频道成员(未加入或已被所有者移除) |
visitors_full |
403 | 便捷分享的访客名额已满,且这个终端未登记 |
sealed |
409 | 便捷分享已封存,不能再修改;封存后再用所有者令牌发请求也是这个错误 |
members_full |
403 | 成员人数已满 |
mode_mismatch |
403 | 帧的 ENC 标志与频道模式不符 |
not_found |
404 | 频道或条目不存在,或已过期 |
conflict |
409 | 幂等冲突 |
gone |
410 | 已删除、下载达上限,或 0 < from < oldest_seq(需要从 0 全量同步) |
too_large |
413 | 超出限制 |
captcha_required |
428 | 需要验证码:缺少 X-WD-Cap,或 token 无效、已用过 |
captcha_unavailable |
503 | 验证码服务暂时不可用(服务端到 Cap 的调用失败),可稍后重试 |
rate_limited |
429 | 限流 |
按账号恢复频道密钥
GET /api/v1/accounts/channels:只需X-WD-Account,返回当前账号已加入频道的{channel_id,role,payload,created_at}密钥包;不返回其他账号或未接受邀请的密钥包,不占成员名额。PUT /api/v1/channels/{id}/key-package:需要该频道 owner 能力和 owner 账号,仅更新当前 owner 自己的{payload},用于补齐已创建频道的账号密钥包。- 创建频道可携带
key_package(1–16384 字符 opaque 密文),与频道和 owner 成员记录原子保存。前端必须提供;底层 API 允许省略供纯协议客户端使用。 - 发送邀请时原子保存收件账号的空间密钥包、频道数据域 envelopes、默认权限和 epoch 更新;接受后仍可跨设备领取。撤销邀请/成员会删除服务端密钥包与访问记录,但无法收回对方此前保存的密钥,成员移除后的 epoch 轮换仍未实现。删除空间时清理全部密钥包。
账号设备与资料
GET /accounts/devices 返回本账号设备 {id,name,created_at,last_seen,current}。PATCH /accounts/devices/{id} 发送 {name};DELETE 撤销该设备的全部登录 sessions,不移除账号成员。新客户端登录时携带稳定浏览器 device_id(UUID)和设备名,各设备单独记录。个人名称/头像存储在客户端加密保险库,修改后按 modifiedAt 合并,不写入服务端明文账号表。昵称不改变固定用户名或参与密钥派生。