Standard Notes 3.3.2 API 使用详解

Standard Notes 3.3.2 服务端 HTTP API 协议级实测:004 会话注册、PKCE 登录构造、同步/版本/改密/登出全链路原始回显与六个排障坑

Standard NotesStandard Notes2026/11/02

Standard Notes 3.3.2 API 使用详解

目标:把 Standard Notes 3.3.2 版线自托管 server 的 HTTP API 从注册到登出全链路走通——004 会话体系注册、PKCE 登录的正确构造、同步/版本/改密/登出的请求体与原始回显、错误约定与限流机制,并沉淀协议级实测中踩到的坑。

本篇是协议级取证实录:所有请求与回显均为 2026-11-02 对本专栏《Standard Notes Docker Compose 部署指南》所建五服务栈(API 入口 http://127.0.0.1:3000)的原始输出,未做美化。示例账号 snapi3@quzuo.net / q1w2e3r4。

1. 为什么写 API 篇而不是 CLI 篇

裁定依据(实测):server 镜像是 Node.js 微服务 monorepo,容器内 /opt/server/docker/ 只有 docker-entrypoint.sh、wait-for.sh、is-available.sh、localstack_bootstrap.sh、supervisord.conf 五个运维脚本;supervisord 拉起 9 个 node 进程(5 个 entrypoint-server.js + 3 个 entrypoint-worker.js + 1 入口),没有任何面向日常管理的 CLI(无用户管理、无备份、无状态查询命令)。管理面完全在 HTTP API 上,因此本专栏第四篇按 API 使用详解落地。

2. 鉴权体系总览:两套 token、两个 API 标识

体系触发条件token 形态会话能力
legacy JWT注册时 version 填应用版本(如 "3.3.2")data.token(eyJ 开头)GET /v1/sessions 报 unsupported-account-version,改密受限
session(004)注册时 version 填协议版本 "004"data.session.access_token,形如 1:<sessionUuid>:<token>全能力:会话列表/刷新/登出/改密吊销

源码依据:User.supportsSessions() 即 parseInt(version) >= parseInt("004")。

api 字段决定会话载体:api=20200115 为 header-based(access_token 直接作 Authorization: Bearer);api=20240226 为 cookie-based(token 形如 2:<privateIdentifier>,原始值只在 Set-Cookie 响应头里,curl 需带 cookie jar)。

所有响应统一信封:{"meta":{"auth":{...},"server":{"filesServerUrl":...}},"data":{...}},错误在 data.error{tag,message} 或顶层 error。

3. 注册:POST /v1/users(version 大坑)

POST http://127.0.0.1:3000/v1/users
{"email": "snapi3@quzuo.net", "password": "q1w2e3r4", "api": "20200115",
 "version": "004", "pw_nonce": "594cce580d24448c",
 "origination": "registration", "created": 1793603036057}

HTTP 200
{"meta":{"auth":{},"server":{"filesServerUrl":"http://localhost:3125"}},
 "data":{
  "session":{"access_token":"1:4e102b19-83ac-4ea4-bf74-8f336a7615ea:ODdkNWI5MzA2MzJh",
             "refresh_token":"1:4e102b19-...:NzMzYWEzZTRhYzNj",
             "access_expiration":1798787036000,"refresh_expiration":1825159962000,
             "readonly_access":false},
  "key_params":{"created":1793603036057,"identifier":"snapi3@quzuo.net",
                "origination":"registration","pw_nonce":"594cce580d24448c","version":"004"},
  "user":{"uuid":"48383bb0-bad1-451c-ab3b-738cf33d7314",
          "email":"snapi3@quzuo.net","protocolVersion":"004"}}}

要点:

  • version 必须是协议版本号 "004"。填 "3.3.2" 时响应变成 {"user":{...},"token":"eyJ..."}(JWT 回退),后续会话体系接口全部不可用——这是实测复现率 100% 的头号坑;
  • pw_nonce 是客户端生成的随机串(参与密钥派生参数),created 为毫秒时间戳;
  • key_params 原样回显,供客户端本地派生加密密钥——服务器不接触密码派生的密钥本身;
  • 落库核对:users.version 存 004(MySQL 实测)。

4. PKCE 登录:两步走 + 一个非标准构造

第一步,把 challenge 存到服务器并取回 KDF 参数:

POST /v2/login-params
{"email": "snapi3@quzuo.net",
 "code_challenge": "ODRjMDdkNGJhMDEwYzVjMzNlODgwY2QwM2EwN2RiMDgzODY2YTdmNWNkYzY1ZDlmOTJkMmQ2YzVjOWQ0ODcyZg"}

HTTP 200 → {"data":{"identifier":"...","pw_nonce":"594cce580d24448c","version":"004"}}

第二步,带 verifier 登录:

POST /v1/login
{"email": "snapi3@quzuo.net", "password": "q1w2e3r4",
 "code_verifier": "ff3e0e9d3117406d990a8b961ad6a114c877fddbf6e446218e59054798719e14",
 "api": "20200115"}

HTTP 200 → {"data":{"session":{...新会话...},"key_params":{...},"user":{...}}}

challenge 构造是 PKCE 标准之外的:实测正确公式是

code_challenge = base64url( sha256(code_verifier) 的十六进制字符串 )

即对 64 位 hex 摘要字符串整体做 base64url(得 86 字符),不是标准 OAuth PKCE 的 base64url(sha256(verifier) 的 32 字节原始摘要)。用标准构造提交 challenge 后登录,实测一律 401 {"message":"Invalid email or password"}(e2e_patch3 实录),换成 hex 字符串构造后 200。另外 POST /v1/login 不带 code_verifier 同样 401。

5. 会话管理三件套

列表(session token 鉴权,JWT 账号会报 unsupported-account-version):

GET /v1/sessions   → HTTP 200
{"data":[
 {"uuid":"4e102b19-...","api_version":"20200115","device_info":"Unknown Client on Unknown OS","current":false,...},
 {"uuid":"649c5cc5-...","api_version":"20200115","device_info":"Unknown Client on Unknown OS","current":true,...}]}

刷新:POST /v1/sessions/refresh,body 必须同时带 access_token 和 refresh_token(源码 BaseSessionController.js:90),只带 refresh_token 实测 400 "Please provide all required parameters."。成功返回一对全新 token(同 sessionUuid、新 token 值):

{"access_token":"1:64f10e29-...:NTNjMzZiNWI4YTdm","refresh_token":"1:64f10e29-...:YmU5YzcwMDA0ZTQy"}
→ {"data":{"session":{"access_token":"1:64f10e29-...:YjQ1YWFmNmE3MWUw","refresh_token":"1:64f10e29-...:ZTVkYTEyOTg1NDM2",...}}}

登出:POST /v1/logout,Bearer access_token + body {"refresh_token": "..."} → HTTP 204 空响应体。登出后用该会话继续同步实测 401 {"tag":"invalid-auth","message":"Invalid login credentials."}。JWT token 调 logout 返回 401 "Invalid session token."。

6. 同步:POST /v1/items 一个端点四种用法

全部走 POST /v1/items(Bearer 鉴权),语义由请求体决定:

① 快照拉取(items 空、不带 sync_token):

{"items": [], "limit": 150}
→ {"retrieved_items":[...全量密文条目...],"saved_items":[],"unsaved":[],
   "sync_token":"MjoxNzkzNjAzMDM2LjUxODgyNw=="}

② 保存新条目(客户端已加密):

{"items":[{"uuid":"79d80785-...","content_type":"Note",
  "content":"{\"encrypted\": \"U2FsdGVkX1+F3NoteV1==\"}",
  "created_at":1793603036524,"updated_at":1793603036524}]}
→ saved_items 回显原样密文 + 新 sync_token

③ 更新(同 uuid 提交新密文)→ 旧密文进版本历史,saved=1 conflicts=0;真冲突时条目进 unsaved。

④ 增量同步(items 空 + 上次返回的 sync_token)→ retrieved_items 只含该游标之后的变更,并返回新游标。sync_token 是 base64 的状态游标(Mjox... 解出 2:<时间戳>),客户端必须逐次替换。

content_type 是开放字符串(Note/Tag/SmartTag/Notebook 等),服务器不解析 content 内容——零知识架构的直接体现。

7. 单条检索与版本历史

GET /v1/items/:uuid                 → 200 {"data":{"item":{...密文条目...}}}
GET /v1/items?content_type=Note     → 404(不存在该检索端点;全量检索只能走 POST 同步)
GET /v2/items/:uuid/revisions?limit=10
→ 200 {"data":{"revisions":[{"uuid":"b8389b29-...","item_uuid":"79d80785-...","content_type":"Note",...}]}}
GET /v2/items/:uuid/revisions/:rid
→ 200 {"data":{"revision":{"content":"{\"encrypted\": \"U2FsdGVkX1+F3NoteV1==\"}",...}}}

实测细节:更新一次密文后版本列表出现 1 条(旧值 V1 被归档),单版本接口取回的正是更新前的密文;revisions_revisions 表行数与 items 更新次数一致。

8. 完整性校验:POST /v1/items/check-integrity

客户端上报"我本地的条目时间戳",服务器回差异:

{"items":[{"uuid":"7005071f-...","updated_at":"2026-11-02T05:08:02.000Z"}]}
→ {"data":{"mismatches":[{"uuid":"7005071f-...","updated_at_timestamp":1793596081899662}]}}

上报时间与库内不符即进 mismatches(微秒时间戳),客户端据此触发该条目重同步。

9. 改密:PUT /v1/users/:uuid/attributes/credentials

PUT /v1/users/48383bb0-.../attributes/credentials   (Bearer 当前会话)
{"current_password": "q1w2e3r4", "new_password": "q1w2e3r4-new",
 "pw_nonce": "c5786435dabc40b9", "api": "20200115", "version": "004"}
→ HTTP 200,返回全新 session + key_params(pw_nonce 已换)

body 必须含 current_password;漏传实测 400 "Your current password is required to change your password. Please update your application if you do not see this option."(final2 实录)。改密成功后的连锁行为(final3 实测):旧密码登录 401,新密码 PKCE 登录 200 并返回新会话,其他既有会话被吊销(登出清理后 sessions 表仅剩 1 条)。

10. files 服务面(:3125)

GET  http://127.0.0.1:3125/healthcheck → 200 "OK"
GET  http://127.0.0.1:3125/v1/files    → 401 {"error":{"tag":"invalid-auth","message":"Invalid valet token."}}

files 服务不走 Bearer,凭 auth 签发的 valet token(VALET_TOKEN_SECRET 签名的短期票据)授权,上传下载票据由主 API 的相关端点签发。网关每个响应的 meta.server.filesServerUrl 回显 files 地址(实测 http://localhost:3125),客户端据此拼接附件 URL。

11. 限流与排障实录

失败登录限流:auth 服务把失败计数写 Redis lock:<userUuid>,触发后登录持续被拒。测试脚本反复试错后的恢复手段(实测):

docker exec stdnotes-3125ba92-cache redis-cli --scan --pattern 'lock:*'
docker exec stdnotes-3125ba92-cache redis-cli del lock:877b6c6d-3bcb-4127-80d8-5ed51dfdd822
# 1

本篇取证过程中实际踩到并定位的坑汇总:

#现象根因结论
1注册返回 JWT 而非 session;GET /v1/sessions 报 unsupported-account-versionversion 填了应用版本 "3.3.2"必须填协议版本 "004"
2PKCE 登录恒 401 Invalid email or password(challenge 已存、密码正确)challenge 用了标准 PKCE 的 base64url(原始摘要)正确构造是 base64url(sha256(verifier) 的 hex 字符串)
3GET /v1/login-params?email=...&code_challenge=... 返回 404 Cannot GET /auth/pkce_params网关把 GET login-params 路由到了不存在的 auth 内部路径存 challenge 用 POST /v2/login-params
4改密 400body 缺 current_password五个字段全传
5刷新 400 "Please provide all required parameters."只传了 refresh_tokenaccess_token + refresh_token 同传
6连错密码后登录一直 401(密码已正确)Redis lock:<userUuid> 限流未过期redis-cli del lock:* 后恢复

12. 落库核对(黑盒结论的白盒证据)

SELECT email,version,updated_at FROM users WHERE email='snapi3@quzuo.net';
-- snapi3@quzuo.net  004  2026-11-02 07:03:57
SELECT COUNT(*) FROM items;                  -- 6(全部为密文原样存储)
SELECT COUNT(*) FROM revisions_revisions;    -- 6
SELECT COUNT(*) FROM sessions;               -- 1(登出/改密吊销后)

13. 专栏导航

14. 跨栏相关阅读

15. 参考来源

  • 本平台 2026-11-02 协议级取证实录:legacy 链(JWT 注册→同步→版本→check-integrity)、004 会话链(注册→PKCE→会话→同步→版本→改密→刷新→登出→落库,17 步全通过)、新 API(20240226)与 files/web 探测链
  • github.com/standardnotes/server 源码:User.supportsSessions()、BaseSessionController.js(refresh 参数校验)
  • standardnotes.com/help(加密白皮书与协议版本说明)