dsh-lan-peers 0.3.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/DESIGN.md +248 -0
  2. package/HANDOVER.md +431 -0
  3. package/client/cli-group.test.mjs +327 -0
  4. package/client/cli-unpair.test.mjs +148 -0
  5. package/client/cli.mjs +783 -201
  6. package/client/cli.test.mjs +51 -0
  7. package/client/http.test.mjs +316 -0
  8. package/client/pairing.test.mjs +219 -0
  9. package/client/peer-call.mjs +336 -13
  10. package/client/protocol.mjs +20 -0
  11. package/client/sse.mjs +195 -0
  12. package/client/sse.test.mjs +231 -0
  13. package/cordis.patch.yml +5 -0
  14. package/host/server.mjs +3287 -94
  15. package/host/test/auth.test.mjs +543 -540
  16. package/host/test/encryption.test.mjs +100 -0
  17. package/host/test/group-auth.test.mjs +423 -0
  18. package/host/test/group-cluster.mjs +179 -0
  19. package/host/test/group-commit.test.mjs +281 -0
  20. package/host/test/group-election.test.mjs +342 -0
  21. package/host/test/group-replica.test.mjs +453 -0
  22. package/host/test/group.test.mjs +775 -0
  23. package/host/test/helpers.mjs +212 -192
  24. package/host/test/pairing-ecdh.test.mjs +288 -0
  25. package/host/test/stream.test.mjs +293 -0
  26. package/host/test/unpair.test.mjs +237 -0
  27. package/lib/admin-actions.test.mjs +90 -0
  28. package/lib/admin-group.test.mjs +919 -0
  29. package/lib/admin-unpair.test.mjs +158 -0
  30. package/lib/admin.mjs +1482 -53
  31. package/lib/client-render.test.mjs +225 -0
  32. package/lib/client.js +347 -5
  33. package/lib/group-client.mjs +1015 -0
  34. package/lib/index.js +37 -5
  35. package/lib/pairing-ecdh.mjs +139 -0
  36. package/lib/panel-drag.test.mjs +208 -0
  37. package/lib/panel-group.test.mjs +628 -0
  38. package/lib/panel-unpair.test.mjs +183 -0
  39. package/lib/tools.mjs +235 -170
  40. package/overlays/desktop.patch.yml +3 -0
  41. package/overlays/web.patch.yml +3 -0
  42. package/package.json +2 -1
package/DESIGN.md CHANGED
@@ -148,3 +148,251 @@ X-Peer-Sign: base64url(HMAC-SHA256(secret, canonical))
148
148
  - 响应:{enc:'v1', data:'<base64url>'},同一方向密钥加密
149
149
  - requireEncryption 默认 true:缺 X-Peer-Enc 的认证请求 → 400 encryption-required
150
150
  - 未加密:/v1/health(探活)与 /v1/pair(尚无共享密钥,只应在可信局域网配对)
151
+
152
+ ## 12. v0.4.0(在途,未发布)
153
+
154
+ > 状态:实现中。本节是 v0.4.0 的权威契约;发布后本节升为正式条款,同时把 §4.1 的「/v1/pair 明文例外」改为「仅 legacy 兼容路径,默认路径为 ECDH」。
155
+
156
+ 改动两条:**配对不再明文传密钥**(ECDH P-256 两轮)+**派单不再固定 1.5s 轮询**(有界长轮询 + SSE)。总原则:新老双路径,任一端先升级都不破坏互通。
157
+
158
+ ### 12.1 兼容矩阵
159
+
160
+ | 组合 | 要求 |
161
+ |---|---|
162
+ | 旧 0.3.1 客户端 → 新服务端 | `POST /v1/pair` 无 `ecdh` 字段时明文路径必须成功,服务端日志打 `pair: legacy plaintext path used by <peerId>` |
163
+ | 新客户端 → 旧 0.3.1 服务端 | `health.capability` 不含 `pair-ecdh` → 自动回退明文配对 |
164
+ | 旧客户端 → `/v1/answer` | 不带 `waitMs` → 立即返回,语义与 0.3.1 一致 |
165
+ | 新客户端 → 旧服务端 | `capability` 不含 `stream` → 回退 1.5s 轮询 |
166
+
167
+ ### 12.2 端点
168
+
169
+ `GET /v1/health`:capability 追加 `"pair-ecdh"`、`"stream"`,旧值 `"ask"`、`"pair"` 保留。
170
+
171
+ `POST /v1/pair`(明文,第一轮,**不消费配对码**)
172
+ body `{code, peerId, peerName, ecdh:{v:1, curve:"P-256", publicKey:"<base64url, 65 字节未压缩点>"}}`
173
+ → `200 {ecdh:{v:1, curve:"P-256", publicKey}, pairingTtlMs:120000}`。服务端在内存保存 pending(服务端临时私钥 + peerId + expiresAt,120s TTL,不落盘)。无 `ecdh` 字段 → §3 旧路径,行为不变。码不可用 → 401 `invalid-code`;公钥非法 → 400 `bad-request`。
174
+
175
+ `POST /v1/pair/confirm`(明文,消费配对码)
176
+ body `{code, peerId, peerName, wrapped:"<base64url(nonce12‖ct‖tag)>"}`
177
+ 派生 `Z = ECDH(serverPriv, clientPub)`(32B X 坐标),`W = HKDF-SHA256(ikm=Z, salt="dsh-lan-peers/pair/v1", info="dsh-lan-peers/pair/wrap/v1", len=32)`。
178
+ 解密得到客户端发来的 S → 写 peer(`inboundSecret=S`)→ 生成 T,用同一把 W 包裹返回 `200 {peerId, peerName, wrapped:<AES-256-GCM(W, {"secret":T})>}`(nonce 每次新随机)。
179
+ 解密失败 400 `bad-encryption`;无 pending/过期 401 `pairing-expired`;码错 401 `invalid-code`。
180
+
181
+ `GET /v1/answer?sessionId=<id>[&waitMs=<ms>]`(签名+加密)
182
+ 无 `waitMs` → 立即返回(0.3.1 行为);有 `waitMs` → 服务端 hold 到终态或 `min(waitMs, 25000)`,内部每 150ms 调 `runner.poll`。响应 `{status, reply?, error?, elapsedMs}`。
183
+
184
+ `GET /v1/events?sessionId=<id>`(签名,`Accept: text/event-stream`)
185
+ canonical 的 body 用 `sha256("")`。**每条事件载荷独立加密**(与 /v1/answer 同算法、逐条新 nonce),data 形如 `{"enc":"v1","data":"…"}`:
186
+ `status` → `{status:"running", elapsedMs}`;`done` → `{status:"done", reply, elapsedMs}`;`error` → `{status:"error", error, elapsedMs}`;`ping` → 每 10s `{t}`。
187
+ 终态后服务端主动断流;客户端非 200 或中途断流 → 回退长轮询续跑,不重放事件。
188
+
189
+ ### 12.3 配置新增(均有默认值,不填不改变旧行为)
190
+
191
+ `pairingMode: "auto" | "ecdh" | "legacy"`(默认 `auto`,按 capability 探测)、`answerWaitMs: number`(默认 25000,上限 25000)。
192
+
193
+ ### 12.4 模块接口(服务端与客户端共用)
194
+
195
+ `lib/pairing-ecdh.mjs`:`generateKeyPair()` → `{privateKey, publicKeyB64url}`;`deriveWrapKey(privateKey, peerPublicKeyB64url)` → 32B `Buffer`;`wrapSecret(wrapKey, plainObject)` → `base64url(nonce‖ct‖tag)`;`unwrapSecret(wrapKey, wrappedB64url)` → object(失败 throw)。`lib/wire-crypto.mjs` 既有导出不变,只允许新增。
196
+
197
+ ### 12.5 验收
198
+
199
+ 原 §11 六条继续有效,新增:ECDH 两轮成功/被篡改 400/过期 401/码一次性;legacy 路径仍 200 且有弃用告警;`waitMs` 终态提前返回且带 `elapsedMs`、缺省立即返回;/v1/events 每条可解密且终态断流;新旧四类组合互通(见 §12.1)。
200
+
201
+ ## 13. v0.4.1(在途,未发布):手动取消配对
202
+
203
+ > 状态:实现并测试完成,**未发布**(用户要求暂缓)。原则同 §12:只新增端点与 capability,不动既有语义。
204
+
205
+ ### 13.1 capability
206
+
207
+ 追加 `"unpair"` → `["ask","pair","pair-ecdh","stream","unpair"]`。只增不减,旧客户端不认识即忽略;回退决策仍靠运行时探测。
208
+
209
+ ### 13.2 端点
210
+
211
+ `POST /v1/unpair`(签名 + 加密,与 `/v1/ask`、`/v1/answer`、`/v1/peers` 同一门控,缺 `X-Peer-Enc` → 400 `encryption-required`,签名为空/不符 → 401)
212
+ 语义:**请求方要求本端把请求方删掉**,故删除对象恒为 `auth.peerId`,**body 一律忽略**(否则任一已配对者可删掉别人的配对)。无 body 合法(canonical 的 body 摘要为 `sha256("")`)。
213
+ 成功 → `200 {ok:true, removed:<bool>, peers:<剩余数>}`;同时作废该 peerId 挂起的 ECDH 握手,避免删除后又被 `/v1/pair/confirm` 写回。
214
+
215
+ `StateStore.removePeer(peerId)`:先重读 `peers.json`(同机 CLI 可能刚写过)→ 存在才删 → 0600 落盘 → 返回 `true/false`;幂等;清掉该 peer 的写盘节流记录。
216
+
217
+ ### 13.3 管理接口(回环,仅 Host 本机)
218
+
219
+ `POST /api/lan-peers/unpair`,body `{peerId}`:
220
+ - 未知/缺失 peerId → 404 `{error:"unknown-peer"}`;
221
+ - **尽力通知对端**:有 `baseUrl` 且 `/v1/health` 的 capability 含 `unpair`(2s 探活、5s 调用)时用该 peer 的 `secret` 发签名 `POST /v1/unpair`;
222
+ - **对端失败绝不阻塞本地删除**(`unreachable`/`unsupported`/`no-address`/`no-secret`/错误原文都只记录);
223
+ - 响应 `{ok:true, peer:{peerId,name}, remote:{notified:<bool>, reason?:<string>}}`,不含任何 secret。
224
+
225
+ ### 13.4 兼容
226
+
227
+ 0.3.1 对端不声明 `unpair`:管理接口只发一次 `/v1/health` 探活即跳过通知(`reason:"unsupported"`),本地照删;老对端仍持有本端条目,但它再派单会被 401 `unknown-peer` 拒绝。互通矩阵 harness 六行断言在 unpair 加入后仍全 PASS。
228
+
229
+ ### 13.5 验收
230
+
231
+ `removePeer` 落盘与幂等;`/v1/unpair` 未签名 401、只删请求方、body 指定他人无效、删除后再请求 401 `unknown-peer`、作废挂起握手;管理接口未知 → 404、对端不可达仍本地删除;CLI 未知/歧义 exit 2;两处界面「解除」按钮 + 二次确认 + 请求期禁用。
232
+
233
+ ## 14. v0.5.0(在途,未发布):群聊(Phase1+2)
234
+
235
+ > 状态:实现并测试完成(全量 192/192),未发布、未部署。**只新增群相关端点与管理接口**,不改 §12/§13 的既有语义。
236
+
237
+ ### 14.1 拓扑与原则
238
+
239
+ **hub 星型中继**:成员只与 hub 配对,hub 是**唯一顺序源**(`seq` 单调递增)。原因:群聊必须让所有人看到一致的消息次序;全互联需要 N(N-1)/2 次配对 + 分布式定序。capability 预留 `group`(本轮只实现 hub 模式)。
240
+
241
+ **关键取舍:不改 1:1 会话的归属校验**(§12 的 `/v1/answer`、`/v1/events` 仍只允许发起者本人读)。共享进度靠**发起端读自己的事件流、把摘要镜像给 hub**、hub 再扇出——这样不必为了群聊放开一条安全约束。
242
+
243
+ ### 14.2 capability 与配置
244
+
245
+ capability 追加 `group` → `["ask","pair","pair-ecdh","stream","unpair","group"]`。老版本不认识即忽略。
246
+
247
+ ```yaml
248
+ group:
249
+ enabled: false # 本机启用群功能(成员侧也要 true 才会解析 hub、镜像进度)
250
+ hub: "" # 成员侧:hub 的 peer 名字或 peerId;hub 本身留空
251
+ name: "default" # 群名(hub 首次初始化时使用)
252
+ autoJoin: false # hub 侧:是否允许已配对成员自助加入
253
+ retentionDays: 7 # 历史保留天数
254
+ ```
255
+
256
+ ### 14.3 端点(仅 hub 提供;成员侧一律 404)
257
+
258
+ | 端点 | 说明 |
259
+ |---|---|
260
+ | `GET /v1/group/state` | `{groupId,name,epoch,keyVersion,members[],groupKey?}`;成员含 `groupKey`(响应已被该成员密钥加密);非成员 `403 not-a-member` |
261
+ | `POST /v1/group/join` | `autoJoin=true` 则加入;否则 `403` |
262
+ | `POST /v1/group/post` | `{kind∈chat|progress|result|system, text≤4000, meta}` → `201 {seq,ts}`;超长 `413` |
263
+ | `GET /v1/group/history?sinceSeq&limit` | `{groupId,epoch,lastSeq,messages[]}` |
264
+ | `GET /v1/group/events?sinceSeq` | SSE,`event: message` 逐帧加密(复用 `sseFrame`,逐帧新 nonce),`ping` 每 10s,断流用 `sinceSeq` 续传 |
265
+
266
+ `meta` 白名单:`peer/sessionId/phase/status/transport/elapsedMs/replyChars`,字符串值截断 200 字符。
267
+
268
+ ### 14.4 存储(仅 hub 落盘)
269
+
270
+ `<stateDir>/group.json`(0600):`{groupId,name,createdAt,keyVersion,key,lastSeq,members{peerId:{name,addedAt}},messages[{seq,ts,from{peerId,name},kind,text,meta}]}`
271
+ - 读写都 prune:删 `retentionDays` 之前的消息;5000 条硬上限(丢最旧);
272
+ - `seq` 单调递增,**删旧消息不回退、号不复用**(客户端用 seq 去重)。
273
+
274
+ ### 14.5 隐私边界(硬约束)
275
+
276
+ 进度镜像只发**状态摘要**:`progress`(无 text)+ `result`(`text` 如 `done/1227ms/142字`,只含状态、耗时与长度)。**绝不发送 prompt 原文、回复全文、文件内容**。有单测断言。
277
+
278
+ ### 14.6 管理接口(回环 `/api/lan-peers/group*`)
279
+
280
+ 面板与 CLI 只经这里,**浏览器永不直连对端**:`GET /group?sinceSeq`(未启用返回 `{enabled:false,...}` 而非报错)、`POST /group/post|member|config|join`。hub 侧直读本地 `group.json`;成员侧由插件代理到 hub。`groupKey` 在适配层剥掉,不进管理接口、不进日志。
281
+
282
+ ### 14.7 验收
283
+
284
+ 三身份真进程组群、消息 `seq` 单调、实时 `tail`、非成员 `403`、hub 重启后历史可读、`retentionDays=0` 清旧、日志无 `groupKey`/`secret`/`text` 全文。
285
+
286
+ ## 15. v0.6.0(在途,未发布):动态 hub 第一阶段(S1 成员互访 + S2 日志复制)
287
+
288
+ > 背景:§14 的群只有 hub 能服务端点,hub 一死成员彼此无法通信。本节让成员之间可认证互访、并各自保存日志副本,为 S3(选举)铺路。**凡 §14 已定义的端点语义不变**;`/v1/ask|answer|events|unpair` 与 1:1 会话归属校验**一行未动**。
289
+
290
+ ### 15.1 群级鉴权(S1)
291
+
292
+ - 请求头 `X-Peer-Auth: group`(不带则仍走 per-peer 签名,行为不变);另带 `X-Peer-Group: <groupId>`、`X-Peer-Base: <advertiseBaseUrl>`、`X-Peer-LastSeq: <n>` 用于自报。
293
+ - **canonical 为 8 行**(与冻结契约文件一致;早先口头说的"7 行"是笔误):
294
+ ```
295
+ GROUP
296
+ <METHOD>
297
+ <path 含 query>
298
+ <ts>
299
+ <nonce>
300
+ <groupId>
301
+ <senderPeerId>
302
+ <sha256(body) hex | GET 用 sha256("")>
303
+ ```
304
+ - 签名密钥 `HKDF-SHA256(groupKey, salt="dsh-lan-peers/group-auth/v1", info="dsh-lan-peers/group-sign/v1")`;响应加密密钥 `info="dsh-lan-peers/group-body/v1"`;信封布局与 wire-crypto 相同(base64url(nonce‖ct‖tag)),**没有新造原语**。
305
+ - **路径白名单只有四条**:`/v1/group/probe`、`/v1/group/leader`、`/v1/group/pull`、`/v1/group/replicate`。其它路径带群头 → `401 {error:"group-auth-scope"}`(**群密钥不能驱动 ask/unpair**)。
306
+ - 校验顺序:groupId 一致 → senderPeerId 在成员表 → ts ±60s → nonce 120s 去重(独立命名空间,与 per-peer 分开)。
307
+ - 四个端点**同时接受群级签名与 per-peer 签名**(后者仍过成员门控),因此没有 `group-key.json` 的 CLI 也能排障。
308
+
309
+ ### 15.2 成员可达地址
310
+
311
+ `group.advertiseBaseUrl` 配置优先;否则探测首选私有 IPv4(排除 loopback/docker/veth/br-/virbr/vmnet/vbox/vEthernet/hyper-v/tailscale/utun/wg 等虚拟网卡,只留 RFC1918;192.168 > 10/8 > 172.16/12;同档按网卡名稳定排序),拼上真实监听端口。地址写进群状态 `members[].baseUrl`,面板显示"别人看到的你的地址"。
312
+
313
+ ### 15.3 日志复制(S2)
314
+
315
+ - 成员本地 `<stateDir>/group-replica.json`(0600):`{groupId, epoch, leaderPeerId, lastSeq, messages[]}`,与 hub `group.json` 同保留策略(retentionDays / 5000 条 / seq 单调不回退);按 seq 去重、只增不减;更高 epoch 才换 leader;groupId 不符拒收。
316
+ - 成员密钥单独存 `<stateDir>/group-key.json`(0600,**故意不塞进 replica**,保持 §14.4 形状冻结)。
317
+ - hub `post` 成功即提交(写本地),随后**入队异步**推送 `replicate` 给所有成员;失败留队 + 2s 重试,**绝不影响 `post` 的 201**。
318
+ - 成员每 `group.replicaPullMs`(默认 10s)`pull?sinceSeq=<local>&limit=200` 补差;启动时 bootstrap(per-peer 取 state 拿 groupKey,403 且 autoJoin 则先 join 再取)。
319
+ - **hub 拒收 replicate**(`409 not-a-replica`):只有成员保存副本,避免日志出现第二作者。
320
+ - **S2 提交语义**:写本地即提交、复制 best-effort。`group.quorum`(默认 `majority`、可配 `none`)**本轮只解析不生效**——两阶段提交必须与 S3 选举同时做,否则自相矛盾。S3 之前断电仍可能丢最后几条"已复制但未落盘"的消息。
321
+
322
+ ### 15.4 成员进度(供面板显示)
323
+
324
+ hub 收到成员自报的 `X-Peer-LastSeq` 后记在**内存 Map**(不写 `group.json`,避免写放大),通过 `server.group.members()` 暴露;**从未上报的成员为 `null`**(不填 0,否则面板会把"从未同步"显示成"已同步 0 条")。
325
+
326
+ ### 15.5 配置新增
327
+
328
+ ```yaml
329
+ group:
330
+ advertiseBaseUrl: "" # 本机对外可达地址;空=自动探测
331
+ quorum: majority # majority | none(S3 生效;本轮仅解析)
332
+ replicaPullMs: 10000 # 成员拉取间隔
333
+ ```
334
+
335
+ ### 15.6 验收(已执行,2026-10-27)
336
+
337
+ 真进程 hub+成员 10/10:probe 成功;带群头 `POST /v1/ask` → 401 scope;`post` 后成员 replica 0ms 收到;把 replica 的 lastSeq 改 0 → `pull` 118ms 补齐;**杀 hub 后成员副本仍含全部消息**;日志含 `[group] probe|replicate|pull` 一行摘要且不含 groupKey/secret/正文。全量测试 232/232。
338
+
339
+ ## 16. v0.7.0(在途,未发布):动态 hub(S3 选举 + 租约 + 两阶段提交)
340
+
341
+ > 目标:每个成员都具备 leader 能力,同一时刻只有一个 leader;leader 失效自动接任;**不丢已提交消息、不错序、不分裂**。只新增端点与字段,§14/§15 语义不变,1:1 归属校验一行未动。
342
+
343
+ ### 16.1 角色与租约
344
+
345
+ - 角色状态落 `<stateDir>/group-role.json`(0600):`{role, epoch, leaderPeerId, leaseUntil, commitSeq, votedFor, groupId}`。
346
+ - hub(不配 `group.hub`)首次即 leader、epoch 取群的 keyVersion;成员首次从 follower 起;**文件存在即沿用**(重启不会把已降级的节点又抬成 leader);成员尚未 bootstrap(无 groupKey)时不落盘。
347
+ - 心跳 `POST /v1/group/leader`(与 §15 的 **GET 查询同路径**)每 `heartbeatMs`(默认 3000);租约 = `3×heartbeatMs`(默认 9000)。
348
+ - 租约检查 `roleTick` 200ms;过期后进入候选(随机抖动 0–500ms,抖动在"宣布候选"之前)。
349
+
350
+ ### 16.2 选举与冲突裁决
351
+
352
+ - 候选发 `POST /v1/group/election` `{epoch, candidate, priority}`;投票条件:候选在成员表内、epoch 更新或同 epoch 优先级更高、本 epoch 未投他人(`votedFor` 持久化)。
353
+ - 票数 ≥ quorum 才上任,上任即广播心跳。
354
+ - 冲突裁决:**epoch 高者胜**;同 epoch 按 `hubPriority`(小者优先)→ `peerId` 字典序;非法自称 leader 收 `409 leader-conflict` 并降级。
355
+ - **已知抖动**:两台幸存者租约同刻到点时会连选一次(实测 epoch 3 → 4,最终收敛、seq 连续、无丢失)。缓解已做(落败轮次撤回自投票、让位给更合法一方),彻底消除需"落败者退避到租约过期后再参选"(下一轮)。
356
+
357
+ ### 16.3 两阶段提交(`group.quorum: majority`)
358
+
359
+ - `post` → 分配 seq → 写入 `group.json` 的 `pending`(**与 messages 分开**,`state()` 暴露 `pendingCount`)→ `POST /v1/group/propose`。
360
+ - 成员把提案存本地 pending,**不对外可见**(不进 history/pull/events);ack 数达 quorum → leader 移入 messages、广播 `POST /v1/group/commit`、返回 201。
361
+ - 超时(`commitTimeoutMs` 默认 3000)或 ack 不足 → 丢弃 pending,返回 `503 quorum-unavailable`。**未提交消息永不对外可见**(有测试与真机断言)。
362
+ - `seq` 在 propose 时取 `lastSeq+1` 但**不推进 lastSeq**;提案被丢弃后该号可复用——这是"失败重试后 seq 连续无重复"的实现方式。
363
+ - `commit` 额外携带 `message`(追加字段):防"propose 丢包而 commit 到达"时把同号旧提案误转正;成员两者都按 seq 去重。
364
+ - `post` 提交串行化(postChain),并发不撞号。
365
+ - `group.quorum: none` → 跳过两阶段,leader 直接提交并返回 201(可用性优先,**明确承担分裂风险**,两节点场景适用)。
366
+
367
+ ### 16.4 **quorum 分母(重要口径与风险)**
368
+
369
+ 实现取 **`self + 在网络上自报过地址的成员`**,而非"成员表总数"。原因:从不上线的成员既收不到提案也不可能 ack,算进分母会让群**永远写不进去**(也会打破 S1/S2 里用假 peerId 的既有用例)。三成员真实群全员自报地址时,与契约"成员总数含自己"等价(实测:只剩 1 台时 quorum 仍为 2 → 拒写)。
370
+ **风险**:若某成员在成员表里但从未上报地址,分母会偏小,理论上降低分裂门槛。缓解建议(下一轮):把"未上报地址的成员数"暴露到管理接口与面板,让用户看见分母被削弱。
371
+
372
+ ### 16.5 端点(群级鉴权白名单共 **8 条**)
373
+
374
+ `probe` · `replicate` · `pull` · `leader`(**GET=查询 / POST=心跳**)· `election` · `propose` · `commit` · `lease`(只读租约快照,为 CLI 与排障补的第 8 条)。
375
+
376
+ 其它路径带 `X-Peer-Auth: group` 一律 `401 group-auth-scope`。
377
+
378
+ **post 的可用性语义**:非 leader 节点不提供 `/v1/group/post`(仍按 v0.5 返回 404);leader 拿不到多数派 → `503 quorum-unavailable`。客户端应把两者都视为"本次未被接受",重试即可。
379
+
380
+ ### 16.6 配置新增
381
+
382
+ ```yaml
383
+ group:
384
+ heartbeatMs: 3000 # 心跳间隔;租约 = 3×
385
+ commitTimeoutMs: 3000 # propose→commit 超时
386
+ hubPriority: 0 # 数值小者优先当 leader;同值按 peerId 字典序
387
+ ```
388
+
389
+ ### 16.7 管理与 CLI
390
+
391
+ - `GET /api/lan-peers/group` 追加 `role / leaseUntil / quorum / commitSeq / pendingCount / degraded`(只加不改,未启用分支形状不变)。
392
+ - `POST /api/lan-peers/group/leader {action:"claim"}` → 走真实选举(epoch = max(当前+1, 传入)),赢票即广播心跳。
393
+ - CLI:`group leader [--peer X]`(本地角色 + 可查对端 lease)、`group claim`(本机立刻发起选举)。
394
+ - `server.group.role()` → `{role, epoch, leaderPeerId, leaseUntil, commitSeq, pendingCount, votedFor, groupId, degraded, quorum}`;`server.group.claim()` → `{ok, role, epoch, leaderPeerId, reason, votes, needed}`。
395
+
396
+ ### 16.8 验收(2026-10-27 实测)
397
+
398
+ 全量 **269/269**;三成员真进程:30s 内**选举 0 次**;`claim` → epoch 2 且成员可当 leader 写;杀 leader **6963ms 接任**、epoch+1、恢复后 `seqs=[1,2,3]` 连续无重复;少数派 `post` 失败且 marker **不在任何成员 history/副本**;`quorum:none` 单活可写;`[group]` 日志 105 行、密钥与正文痕迹 0、定时器全 `unref` 且 `close()` 清理。