@agentunion/fastaun-browser 0.3.2 → 0.3.4

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 (105) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/_packed_docs/CHANGELOG.md +45 -0
  3. package/_packed_docs/INDEX.md +81 -0
  4. package/_packed_docs/KITE_DOCS_GUIDE.md +55 -0
  5. package/_packed_docs/agent.md//350/277/234/347/250/213agent.md/347/274/223/345/255/230/344/270/216etag/351/200/217/344/274/240/346/226/271/346/241/210.md +328 -0
  6. package/_packed_docs/cli/AUN-CLI/350/256/276/350/256/241/346/226/207/346/241/243.md +686 -0
  7. package/_packed_docs/design//350/267/250/350/257/255/350/250/200/345/256/271/345/231/250E2E/346/265/213/350/257/225/346/226/271/346/241/210.md +665 -0
  8. package/_packed_docs/protocol//351/231/204/345/275/225N-/345/210/206/345/270/203/345/274/217Trace/345/215/217/350/256/256.md +257 -0
  9. package/_packed_docs/sdk/01-/345/277/253/351/200/237/345/274/200/345/247/213.md +5 -5
  10. package/_packed_docs/sdk/02-WebSocket/345/215/217/350/256/256.md +1 -1
  11. package/_packed_docs/sdk/03-/346/240/270/345/277/203/346/246/202/345/277/265.md +2 -2
  12. package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +454 -396
  13. package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +1410 -1244
  14. package/_packed_docs/sdk/07-/351/224/231/350/257/257/345/244/204/347/220/206.md +19 -1
  15. package/_packed_docs/sdk/08-/346/234/200/344/275/263/345/256/236/350/267/265.md +20 -5
  16. package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +6 -4
  17. package/_packed_docs/sdk/E2EE_V2/346/266/210/346/201/257/351/200/232/344/277/241/346/227/266/345/272/217/345/233/276.md +171 -0
  18. package/_packed_docs/sdk/INDEX.md +9 -4
  19. package/_packed_docs/sdk/README.md +3 -3
  20. package/dist/auth.d.ts +10 -11
  21. package/dist/auth.d.ts.map +1 -1
  22. package/dist/auth.js +128 -95
  23. package/dist/auth.js.map +1 -1
  24. package/dist/bundle.js +2658 -816
  25. package/dist/client.d.ts +73 -7
  26. package/dist/client.d.ts.map +1 -1
  27. package/dist/client.js +1586 -494
  28. package/dist/client.js.map +1 -1
  29. package/dist/crypto.d.ts.map +1 -1
  30. package/dist/crypto.js +45 -31
  31. package/dist/crypto.js.map +1 -1
  32. package/dist/discovery.d.ts +4 -0
  33. package/dist/discovery.d.ts.map +1 -1
  34. package/dist/discovery.js +16 -11
  35. package/dist/discovery.js.map +1 -1
  36. package/dist/errors.d.ts +4 -0
  37. package/dist/errors.d.ts.map +1 -1
  38. package/dist/errors.js +7 -0
  39. package/dist/errors.js.map +1 -1
  40. package/dist/index.d.ts +3 -3
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +3 -3
  43. package/dist/index.js.map +1 -1
  44. package/dist/keystore/index.d.ts +27 -0
  45. package/dist/keystore/index.d.ts.map +1 -1
  46. package/dist/keystore/indexeddb.d.ts +16 -1
  47. package/dist/keystore/indexeddb.d.ts.map +1 -1
  48. package/dist/keystore/indexeddb.js +168 -7
  49. package/dist/keystore/indexeddb.js.map +1 -1
  50. package/dist/logger.d.ts +5 -1
  51. package/dist/logger.d.ts.map +1 -1
  52. package/dist/logger.js +8 -2
  53. package/dist/logger.js.map +1 -1
  54. package/dist/namespaces/auth.d.ts +4 -3
  55. package/dist/namespaces/auth.d.ts.map +1 -1
  56. package/dist/namespaces/auth.js +77 -20
  57. package/dist/namespaces/auth.js.map +1 -1
  58. package/dist/secret-store/indexeddb-store.js +1 -1
  59. package/dist/secret-store/indexeddb-store.js.map +1 -1
  60. package/dist/seq-tracker.d.ts +5 -3
  61. package/dist/seq-tracker.d.ts.map +1 -1
  62. package/dist/seq-tracker.js +30 -3
  63. package/dist/seq-tracker.js.map +1 -1
  64. package/dist/transport.d.ts +9 -1
  65. package/dist/transport.d.ts.map +1 -1
  66. package/dist/transport.js +176 -64
  67. package/dist/transport.js.map +1 -1
  68. package/dist/v2/crypto/canonical.d.ts +1 -1
  69. package/dist/v2/crypto/canonical.d.ts.map +1 -1
  70. package/dist/v2/crypto/canonical.js +42 -13
  71. package/dist/v2/crypto/canonical.js.map +1 -1
  72. package/dist/v2/crypto/ecdh.d.ts.map +1 -1
  73. package/dist/v2/crypto/ecdh.js +18 -1
  74. package/dist/v2/crypto/ecdh.js.map +1 -1
  75. package/dist/v2/e2ee/decrypt.d.ts.map +1 -1
  76. package/dist/v2/e2ee/decrypt.js +57 -3
  77. package/dist/v2/e2ee/decrypt.js.map +1 -1
  78. package/dist/v2/e2ee/encrypt-group.d.ts.map +1 -1
  79. package/dist/v2/e2ee/encrypt-group.js +16 -6
  80. package/dist/v2/e2ee/encrypt-group.js.map +1 -1
  81. package/dist/v2/e2ee/encrypt-p2p.d.ts.map +1 -1
  82. package/dist/v2/e2ee/encrypt-p2p.js +40 -11
  83. package/dist/v2/e2ee/encrypt-p2p.js.map +1 -1
  84. package/dist/v2/e2ee/metadata-auth.d.ts +1 -0
  85. package/dist/v2/e2ee/metadata-auth.d.ts.map +1 -1
  86. package/dist/v2/e2ee/metadata-auth.js +51 -0
  87. package/dist/v2/e2ee/metadata-auth.js.map +1 -1
  88. package/dist/v2/e2ee/types.d.ts +2 -2
  89. package/dist/v2/e2ee/types.d.ts.map +1 -1
  90. package/dist/v2/session/keystore.d.ts +12 -4
  91. package/dist/v2/session/keystore.d.ts.map +1 -1
  92. package/dist/v2/session/keystore.js +177 -35
  93. package/dist/v2/session/keystore.js.map +1 -1
  94. package/dist/v2/session/session.d.ts +11 -3
  95. package/dist/v2/session/session.d.ts.map +1 -1
  96. package/dist/v2/session/session.js +97 -17
  97. package/dist/v2/session/session.js.map +1 -1
  98. package/dist/v2/state/commitment.d.ts.map +1 -1
  99. package/dist/v2/state/commitment.js +4 -1
  100. package/dist/v2/state/commitment.js.map +1 -1
  101. package/package.json +43 -43
  102. package/dist/e2ee-group.d.ts +0 -276
  103. package/dist/e2ee-group.d.ts.map +0 -1
  104. package/dist/e2ee-group.js +0 -1653
  105. package/dist/e2ee-group.js.map +0 -1
package/dist/client.js CHANGED
@@ -18,6 +18,7 @@ import { CryptoProvider, uint8ToBase64, base64ToUint8, pemToArrayBuffer, p1363To
18
18
  import { IndexedDBKeyStore } from './keystore/indexeddb.js';
19
19
  import { V2Session, V2KeyStore } from './v2/session/index.js';
20
20
  import { encryptP2PMessage, encryptGroupMessage, decryptMessage, } from './v2/e2ee/index.js';
21
+ import { ecdsaVerifyRaw } from './v2/crypto/ecdsa.js';
21
22
  import { computeStateCommitment } from './v2/state/index.js';
22
23
  import { AUNLogger } from './logger.js';
23
24
  import { AUNError, AuthError, ConnectionError, E2EEError, PermissionError, StateError, ValidationError, } from './errors.js';
@@ -44,6 +45,15 @@ function stableStringify(obj) {
44
45
  }
45
46
  return JSON.stringify(obj);
46
47
  }
48
+ function getV2DeviceId(dev) {
49
+ if (Object.prototype.hasOwnProperty.call(dev, 'device_id')) {
50
+ return { present: true, value: String(dev.device_id ?? '').trim() };
51
+ }
52
+ if (Object.prototype.hasOwnProperty.call(dev, 'owner_device_id')) {
53
+ return { present: true, value: String(dev.owner_device_id ?? '').trim() };
54
+ }
55
+ return { present: false, value: '' };
56
+ }
47
57
  function sortObjectKeys(obj) {
48
58
  if (obj === null || obj === undefined || typeof obj !== 'object')
49
59
  return obj;
@@ -118,7 +128,16 @@ const REMOVED_E2EE_METHODS = new Set([
118
128
  ]);
119
129
  /** 需要客户端签名的关键方法 */
120
130
  const SIGNED_METHODS = new Set([
121
- 'group.send', 'group.kick', 'group.add_member',
131
+ 'message.send',
132
+ 'message.v2.put_peer_pk', 'message.v2.bootstrap',
133
+ 'message.v2.group_bootstrap', 'message.v2.pull',
134
+ 'message.v2.ack',
135
+ 'group.send',
136
+ 'group.v2.put_group_pk', 'group.v2.bootstrap',
137
+ 'group.v2.send', 'group.v2.pull', 'group.v2.ack',
138
+ 'group.v2.propose_state', 'group.v2.confirm_state',
139
+ 'group.v2.get_proposal',
140
+ 'group.kick', 'group.add_member',
122
141
  'group.leave', 'group.remove_member', 'group.update_rules',
123
142
  'group.update', 'group.update_announcement',
124
143
  'group.update_join_requirements', 'group.set_role',
@@ -251,6 +270,63 @@ function isGroupServiceAid(value) {
251
270
  const [name, ...issuerParts] = text.split('.');
252
271
  return name === 'group' && issuerParts.join('.').length > 0;
253
272
  }
273
+ function normalizeV2WrapPolicy(raw) {
274
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
275
+ return undefined;
276
+ const obj = raw;
277
+ let protocol = String(obj.protocol ?? '').trim().toUpperCase();
278
+ let scope = String(obj.scope ?? '').trim().toLowerCase();
279
+ if (scope !== 'aid' && scope !== 'device') {
280
+ if (obj.per_aid_wrap === true)
281
+ scope = 'aid';
282
+ else if (obj.per_device_wrap === true)
283
+ scope = 'device';
284
+ else
285
+ scope = '';
286
+ }
287
+ if (protocol !== '1DH' && protocol !== '3DH')
288
+ protocol = '';
289
+ if (scope === 'aid')
290
+ protocol = '1DH';
291
+ if (!protocol && !scope)
292
+ return undefined;
293
+ return {
294
+ protocol: protocol ? protocol : undefined,
295
+ scope: scope ? scope : undefined,
296
+ };
297
+ }
298
+ function v2WrapCapabilities() {
299
+ return {
300
+ version: 'v2.1',
301
+ protocols: ['1DH', '3DH'],
302
+ scopes: ['aid', 'device'],
303
+ per_aid_wrap: true,
304
+ per_device_wrap: true,
305
+ };
306
+ }
307
+ function applyV2WrapPolicyToTargets(targets, policy) {
308
+ if (!policy)
309
+ return targets;
310
+ const normalized = targets.map((target) => {
311
+ const row = { ...target };
312
+ if (policy.protocol === '1DH') {
313
+ row.keySource = 'aid_master';
314
+ row.spkPkDer = undefined;
315
+ row.spkId = '';
316
+ }
317
+ return row;
318
+ });
319
+ if (policy.scope !== 'aid')
320
+ return normalized;
321
+ const collapsed = new Map();
322
+ for (const target of normalized) {
323
+ const key = `${target.aid}\u0000${target.role}`;
324
+ if (!collapsed.has(key)) {
325
+ collapsed.set(key, { ...target, deviceId: '' });
326
+ }
327
+ }
328
+ return Array.from(collapsed.values());
329
+ }
254
330
  /** 32 字节左侧零填充(用于 P-256 私钥 scalar 规范化) */
255
331
  function _v2LeftPad32(b) {
256
332
  if (b.length === 32)
@@ -269,6 +345,43 @@ function _v2B64ToBytes(s) {
269
345
  out[i] = bin.charCodeAt(i);
270
346
  return out;
271
347
  }
348
+ function _v2B64ToBytesStrict(s) {
349
+ const text = String(s ?? '').trim();
350
+ if (!text || text.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(text)) {
351
+ throw new Error('invalid base64');
352
+ }
353
+ return _v2B64ToBytes(text);
354
+ }
355
+ function _v2BytesEqual(a, b) {
356
+ if (a.length !== b.length)
357
+ return false;
358
+ let diff = 0;
359
+ for (let i = 0; i < a.length; i++)
360
+ diff |= a[i] ^ b[i];
361
+ return diff === 0;
362
+ }
363
+ function _v2ConcatBytes(...parts) {
364
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
365
+ const out = new Uint8Array(total);
366
+ let offset = 0;
367
+ for (const part of parts) {
368
+ out.set(part, offset);
369
+ offset += part.length;
370
+ }
371
+ return out;
372
+ }
373
+ function _v2LengthPrefixedTextKey(...parts) {
374
+ const enc = new TextEncoder();
375
+ return parts.map((part) => `${enc.encode(part).length}:${part};`).join('');
376
+ }
377
+ function _v2LengthPrefixedBytes(...parts) {
378
+ const enc = new TextEncoder();
379
+ const framed = [];
380
+ for (const part of parts) {
381
+ framed.push(enc.encode(`${part.length}:`), part, enc.encode(';'));
382
+ }
383
+ return _v2ConcatBytes(...framed);
384
+ }
272
385
  /** Base64URL → Uint8Array(兼容缺失 padding) */
273
386
  function _v2B64uToBytes(s) {
274
387
  const std = s.replace(/-/g, '+').replace(/_/g, '/');
@@ -281,12 +394,74 @@ function formatCaughtError(error) {
281
394
  function v2E2eeMeta(envelope) {
282
395
  const suite = String(envelope.suite ?? '');
283
396
  const modeSuite = String(envelope.suite ?? 'unknown');
284
- return {
397
+ const meta = {
285
398
  version: 'v2',
286
399
  suite,
287
400
  encryption_mode: `v2_${modeSuite}`,
288
401
  forward_secrecy: true,
289
402
  };
403
+ const protectedHeaders = metadataWithoutAuth(envelope.protected_headers);
404
+ if (protectedHeaders && Object.keys(protectedHeaders).length > 0) {
405
+ meta.protected_headers = protectedHeaders;
406
+ }
407
+ const payloadType = String(envelope.payload_type ?? protectedHeaders?.payload_type ?? '').trim();
408
+ if (payloadType) {
409
+ meta.payload_type = payloadType;
410
+ }
411
+ const context = metadataWithoutAuth(envelope.context);
412
+ if (context && Object.keys(context).length > 0) {
413
+ meta.context = context;
414
+ }
415
+ const agentMd = metadataWithoutAuth(envelope.agent_md);
416
+ if (agentMd && Object.keys(agentMd).length > 0) {
417
+ meta.agent_md = agentMd;
418
+ }
419
+ return meta;
420
+ }
421
+ function attachV2EnvelopeMetadata(message, meta) {
422
+ if (!meta)
423
+ return;
424
+ const payloadType = typeof meta.payload_type === 'string' ? meta.payload_type.trim() : '';
425
+ if (payloadType)
426
+ message.payload_type = payloadType;
427
+ if (isJsonObject(meta.protected_headers)) {
428
+ message.protected_headers = { ...meta.protected_headers };
429
+ }
430
+ if (isJsonObject(meta.agent_md)) {
431
+ message.agent_md = { ...meta.agent_md };
432
+ }
433
+ }
434
+ function attachV2EnvelopeMetadataFromSource(message, source) {
435
+ const envelope = extractV2EnvelopeFromSource(source);
436
+ if (envelope)
437
+ attachV2EnvelopeMetadata(message, v2E2eeMeta(envelope));
438
+ }
439
+ function extractV2EnvelopeFromSource(source) {
440
+ if (!isJsonObject(source))
441
+ return null;
442
+ if (isJsonObject(source.payload))
443
+ return source.payload;
444
+ if (typeof source.envelope_json === 'string' && source.envelope_json) {
445
+ try {
446
+ const parsed = JSON.parse(source.envelope_json);
447
+ if (isJsonObject(parsed))
448
+ return parsed;
449
+ }
450
+ catch {
451
+ return null;
452
+ }
453
+ }
454
+ return null;
455
+ }
456
+ function metadataWithoutAuth(value) {
457
+ if (!isJsonObject(value))
458
+ return null;
459
+ const body = {};
460
+ for (const [key, item] of Object.entries(value)) {
461
+ if (key !== '_auth')
462
+ body[key] = item;
463
+ }
464
+ return body;
290
465
  }
291
466
  function normalizeDeliveryModeConfig(raw, opts = {}) {
292
467
  const defaultMode = String(opts.defaultMode ?? 'fanout').trim().toLowerCase() || 'fanout';
@@ -379,11 +554,13 @@ export class AUNClient {
379
554
  _v2Session;
380
555
  _v2KeyStore;
381
556
  _v2BootstrapCache = new Map();
557
+ _v2SenderIKPending = new Map();
558
+ _v2SenderIKFetching = new Set();
382
559
  static V2_BOOTSTRAP_TTL_MS = 60 * 60 * 1000;
383
560
  static V2_RETRYABLE_CODES = new Set([-33011, -33012, -33050, -33052, -33054]);
384
561
  /** V2 state 签名验证缓存:cacheKey(hex) → expiry_unix_ms */
385
562
  _v2SigCache = new Map();
386
- static _V2_SIG_CACHE_TTL = 600_000; // 10 min
563
+ static _V2_SIG_CACHE_TTL = 60 * 60 * 1000;
387
564
  static _V2_SIG_CACHE_MAX = 16384;
388
565
  /** V2 state chain 本地记录:group_id → [state_version, chain_hash] */
389
566
  _v2StateChains = new Map();
@@ -405,6 +582,11 @@ export class AUNClient {
405
582
  _localAgentMdEtag = '';
406
583
  /** gateway 在 RPC envelope._meta.agent_md_etag 注入的服务端 etag;纯观察,无下游依赖。 */
407
584
  _remoteAgentMdEtag = '';
585
+ /** 浏览器侧 AgentMDs 逻辑根目录,正文映射到 IndexedDB 里的 {aid}/agent.md。 */
586
+ _agentMdPath = '';
587
+ _agentMdCache = new Map();
588
+ _agentMdFetchInflight = new Set();
589
+ _agentMdLock = Promise.resolve();
408
590
  /** 消息序列号跟踪器(群消息 + P2P 空洞检测) */
409
591
  _seqTracker = new SeqTracker();
410
592
  _seqTrackerContext = null;
@@ -418,6 +600,9 @@ export class AUNClient {
418
600
  _groupSynced = new Set();
419
601
  /** gap fill 来源标记:true 表示当前正在补洞(pull 触发),false 表示非补洞 */
420
602
  _gapFillActive = false;
603
+ // Pull Gate:序列化同一 key 的并发 pull 操作,防止重复拉取
604
+ _pullGates = new Map();
605
+ static _PULL_GATE_STALE_MS = 30000;
421
606
  // 重连相关
422
607
  _reconnectActive = false;
423
608
  _reconnectAbort = null;
@@ -444,8 +629,11 @@ export class AUNClient {
444
629
  root_ca_path: this.configModel.rootCaPem,
445
630
  seed_password: this.configModel.seedPassword,
446
631
  };
632
+ this._agentMdPath = this._agentMdDefaultRoot();
633
+ this._deviceId = getDeviceId();
447
634
  // Logger 必须最早初始化(其他子模块构造时通过 logger 输出)
448
- this._logger = new AUNLogger({ debug: _debug });
635
+ this._logger = new AUNLogger({ debug: _debug, aunPath: this.configModel.aunPath });
636
+ this._logger.bindDeviceId(this._deviceId);
449
637
  this._clientLog = this._logger.for('aun_core.client');
450
638
  this._logAuth = this._logger.for('aun_core.auth');
451
639
  this._logTransport = this._logger.for('aun_core.transport');
@@ -455,8 +643,7 @@ export class AUNClient {
455
643
  this._clientLog.info(`AUNClient initialized: debug=${_debug} aunPath=${this.configModel.aunPath} aid=${initAid ?? '-'}`);
456
644
  this._dispatcher = new EventDispatcher();
457
645
  this._discovery = new GatewayDiscovery();
458
- this._keystore = new IndexedDBKeyStore();
459
- this._deviceId = getDeviceId();
646
+ this._keystore = new IndexedDBKeyStore({ encryptionSeed: this.configModel.seedPassword ?? undefined });
460
647
  this._slotId = '';
461
648
  this._connectDeliveryMode = normalizeDeliveryModeConfig({ mode: 'fanout' });
462
649
  this._defaultConnectDeliveryMode = { ...this._connectDeliveryMode };
@@ -475,7 +662,11 @@ export class AUNClient {
475
662
  timeout: DEFAULT_SESSION_OPTIONS.timeouts.call,
476
663
  onDisconnect: (error, closeCode) => this._handleTransportDisconnect(error, closeCode),
477
664
  });
478
- this._transport.setMetaObserver((meta) => this._observeRpcMeta(meta));
665
+ this._transport.setMetaObserver((meta) => {
666
+ void this._observeRpcMeta(meta).catch((exc) => {
667
+ this._clientLog.debug(`agent.md meta observer skipped: ${String(exc)}`);
668
+ });
669
+ });
479
670
  this.auth = new AuthNamespace(this);
480
671
  this.custody = new CustodyNamespace(this);
481
672
  this.meta = new MetaNamespace(this);
@@ -548,30 +739,64 @@ export class AUNClient {
548
739
  get aid() {
549
740
  return this._aid;
550
741
  }
742
+ setAgentMdPath(root) {
743
+ const next = String(root ?? '').trim() || this._agentMdDefaultRoot();
744
+ this._agentMdPath = next;
745
+ this._agentMdCache.clear();
746
+ return next;
747
+ }
748
+ setAgentMDPath(root) {
749
+ return this.setAgentMdPath(root);
750
+ }
751
+ SetAgentMDPath(root) {
752
+ return this.setAgentMdPath(root);
753
+ }
551
754
  /**
552
- * 浏览器版本 publishAgentMd。接收 agent.md 文本(应用层用 `<input type=file>` 等读出文本传入),
553
- * 内部签名 上传 → 更新 _localAgentMdEtag(quoted sha256,与服务端一致)。
755
+ * 浏览器版本 publishAgentMd。默认从 {agentMdPath}/{self_aid}/agent.md 的等价 IndexedDB 正文读取,
756
+ * 然后签名、上传,并刷新 agentmd.json 元数据。
554
757
  *
555
- * @throws ValidationError content 为空
758
+ * 兼容旧浏览器调用:传入 content 时会先写入等价正文,再从该正文发布。
556
759
  */
557
760
  async publishAgentMd(content) {
558
- const text = String(content ?? '');
559
- if (text.length === 0) {
560
- throw new ValidationError('publishAgentMd requires non-empty content');
761
+ const target = this._agentMdOwnerAid();
762
+ if (!target) {
763
+ throw new ValidationError('publishAgentMd requires local AID');
764
+ }
765
+ if (content !== undefined && content !== null) {
766
+ const text = String(content ?? '');
767
+ if (text.length === 0) {
768
+ throw new ValidationError('publishAgentMd requires non-empty content');
769
+ }
770
+ await this._saveAgentMdRecord(target, {
771
+ content: text,
772
+ local_etag: await this._agentMdContentEtag(text),
773
+ fetched_at: Date.now(),
774
+ });
561
775
  }
562
- const signed = await this.auth.signAgentMd(text);
776
+ const localContent = await this._readAgentMdContent(target);
777
+ if (localContent === null || localContent.length === 0) {
778
+ throw new ValidationError('publishAgentMd requires local agent.md content');
779
+ }
780
+ const signed = await this.auth.signAgentMd(localContent);
563
781
  const result = await this.auth.uploadAgentMd(signed);
564
- const buf = new TextEncoder().encode(signed);
565
- const digest = await crypto.subtle.digest('SHA-256', buf);
566
- const hex = Array.from(new Uint8Array(digest))
567
- .map((b) => b.toString(16).padStart(2, '0'))
568
- .join('');
569
- this._localAgentMdEtag = `"${hex}"`;
782
+ this._localAgentMdEtag = await this._agentMdContentEtag(signed);
783
+ const remoteEtag = isJsonObject(result) ? String(result.etag ?? '').trim() : '';
784
+ if (remoteEtag)
785
+ this._remoteAgentMdEtag = remoteEtag;
786
+ await this._saveAgentMdRecord(target, {
787
+ content: signed,
788
+ local_etag: this._localAgentMdEtag,
789
+ remote_etag: remoteEtag || undefined,
790
+ last_modified: isJsonObject(result) ? String(result.last_modified ?? '').trim() : '',
791
+ fetched_at: Date.now(),
792
+ remote_status: remoteEtag ? 'found' : 'unknown',
793
+ last_error: '',
794
+ });
570
795
  return result;
571
796
  }
572
797
  /**
573
- * 浏览器版本 fetchAgentMd。aid 缺省时取自身;不接受 savePath(浏览器无文件系统);
574
- * aid 是自己则同步刷新 _localAgentMdEtag in_sync。
798
+ * 浏览器版本 fetchAgentMd。aid 缺省时取自身;下载后的正文固定写入
799
+ * {agentMdPath}/{aid}/agent.md 的等价 IndexedDB 正文,agentmd.json 只保存元数据。
575
800
  */
576
801
  async fetchAgentMd(aid) {
577
802
  const target = String(aid ?? this._aid ?? '').trim();
@@ -581,17 +806,30 @@ export class AUNClient {
581
806
  const content = await this.auth.downloadAgentMd(target);
582
807
  const signature = await this.auth.verifyAgentMd(content, { aid: target });
583
808
  const isSelf = target === (this._aid ?? '');
809
+ const localEtag = await this._agentMdContentEtag(content);
810
+ const cacheMeta = this._agentMdAuthCacheMeta(target);
811
+ const remoteEtag = String(cacheMeta.etag ?? '').trim();
812
+ const lastModified = String(cacheMeta.lastModified ?? cacheMeta.last_modified ?? '').trim();
813
+ if (isSelf) {
814
+ this._localAgentMdEtag = localEtag;
815
+ if (remoteEtag)
816
+ this._remoteAgentMdEtag = remoteEtag;
817
+ }
818
+ await this._saveAgentMdRecord(target, {
819
+ content,
820
+ local_etag: localEtag,
821
+ remote_etag: remoteEtag || undefined,
822
+ last_modified: lastModified || undefined,
823
+ fetched_at: Date.now(),
824
+ remote_status: 'found',
825
+ verify_status: isJsonObject(signature) ? String(signature.status ?? '') : '',
826
+ verify_error: isJsonObject(signature) ? String(signature.reason ?? '') : '',
827
+ last_error: '',
828
+ });
584
829
  let in_sync = null;
585
830
  if (isSelf) {
586
- const buf = new TextEncoder().encode(content);
587
- const digest = await crypto.subtle.digest('SHA-256', buf);
588
- const hex = Array.from(new Uint8Array(digest))
589
- .map((b) => b.toString(16).padStart(2, '0'))
590
- .join('');
591
- this._localAgentMdEtag = `"${hex}"`;
592
- const local = this._localAgentMdEtag || '';
593
- const remote = this._remoteAgentMdEtag || '';
594
- in_sync = local && remote ? local === remote : false;
831
+ const remote = remoteEtag || this._remoteAgentMdEtag || '';
832
+ in_sync = localEtag && remote ? localEtag === remote : false;
595
833
  }
596
834
  return {
597
835
  aid: target,
@@ -600,13 +838,396 @@ export class AUNClient {
600
838
  in_sync,
601
839
  };
602
840
  }
841
+ getLocalAgentMdEtag() {
842
+ return this._localAgentMdEtag;
843
+ }
844
+ getRemoteAgentMdEtag() {
845
+ return this._remoteAgentMdEtag;
846
+ }
847
+ async _agentMdContentEtag(content) {
848
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(String(content ?? '')));
849
+ const hex = Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
850
+ return `"${hex}"`;
851
+ }
852
+ _agentMdOwnerAid() {
853
+ return String(this._aid ?? '').trim();
854
+ }
855
+ _agentMdDefaultRoot() {
856
+ return this._joinAgentMdPath(this.configModel.aunPath || '.', 'AgentMDs');
857
+ }
858
+ _joinAgentMdPath(base, name) {
859
+ const left = String(base ?? '').trim().replace(/[\\/]+$/g, '');
860
+ return left ? `${left}/${name}` : name;
861
+ }
862
+ _agentMdRoot() {
863
+ return this._agentMdPath || this._agentMdDefaultRoot();
864
+ }
865
+ _agentMdSafeAid(aid) {
866
+ const target = String(aid ?? '').trim();
867
+ if (!target || target.includes('/') || target.includes('\\') || target.includes('\0')) {
868
+ throw new ValidationError('agent.md aid is empty or contains path separators');
869
+ }
870
+ return target;
871
+ }
872
+ _agentMdMetaKey(aid) {
873
+ return `${this._agentMdSafeAid(aid)}/agentmd.json`;
874
+ }
875
+ _agentMdContentKey(aid) {
876
+ return `${this._agentMdSafeAid(aid)}/agent.md`;
877
+ }
878
+ async _readAgentMdStorage(logicalKey) {
879
+ const key = String(logicalKey ?? '').trim();
880
+ if (!key)
881
+ return null;
882
+ const load = this._keystore.loadAgentMdCache;
883
+ if (typeof load !== 'function') {
884
+ throw new Error('IndexedDB agent.md storage unavailable');
885
+ }
886
+ const record = await load.call(this._keystore, this._agentMdRoot(), key);
887
+ if (record && Object.prototype.hasOwnProperty.call(record, 'content')) {
888
+ return String(record.content ?? '');
889
+ }
890
+ return null;
891
+ }
892
+ async _writeAgentMdStorage(logicalKey, content) {
893
+ const key = String(logicalKey ?? '').trim();
894
+ if (!key)
895
+ return;
896
+ const save = this._keystore.upsertAgentMdCache;
897
+ if (typeof save !== 'function') {
898
+ throw new Error('IndexedDB agent.md storage unavailable');
899
+ }
900
+ const text = String(content ?? '');
901
+ await save.call(this._keystore, this._agentMdRoot(), key, {
902
+ content: text,
903
+ local_etag: await this._agentMdContentEtag(text),
904
+ fetched_at: Date.now(),
905
+ });
906
+ }
907
+ async _withAgentMdLock(fn) {
908
+ const previous = this._agentMdLock.catch(() => undefined);
909
+ let release;
910
+ const current = new Promise((resolve) => { release = resolve; });
911
+ this._agentMdLock = previous.then(() => current);
912
+ await previous;
913
+ try {
914
+ return await fn();
915
+ }
916
+ finally {
917
+ release();
918
+ }
919
+ }
920
+ _normalizeAgentMdRecord(aid, data) {
921
+ if (!isJsonObject(data))
922
+ return {};
923
+ const record = {};
924
+ for (const [key, value] of Object.entries(data)) {
925
+ if (key !== 'content')
926
+ record[key] = value;
927
+ }
928
+ record.aid = this._agentMdSafeAid(String(record.aid ?? aid));
929
+ for (const key of ['fetched_at', 'observed_at', 'checked_at', 'updated_at']) {
930
+ record[key] = Number(record[key] ?? 0) || 0;
931
+ }
932
+ return record;
933
+ }
934
+ async _writeAgentMdRecordUnlocked(aid, record) {
935
+ const payload = {};
936
+ for (const [key, value] of Object.entries(record)) {
937
+ if (key !== 'content' && value !== undefined && value !== null)
938
+ payload[key] = value;
939
+ }
940
+ payload.aid = this._agentMdSafeAid(aid);
941
+ await this._writeAgentMdStorage(this._agentMdMetaKey(aid), `${JSON.stringify(payload, null, 2)}\n`);
942
+ }
943
+ async _readAgentMdRecordUnlocked(aid) {
944
+ const raw = await this._readAgentMdStorage(this._agentMdMetaKey(aid));
945
+ if (raw === null)
946
+ return {};
947
+ try {
948
+ return this._normalizeAgentMdRecord(aid, JSON.parse(raw));
949
+ }
950
+ catch (err) {
951
+ this._clientLog.warn(`agent.md metadata damaged, ignoring: aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
952
+ return {};
953
+ }
954
+ }
955
+ async _readAgentMdContent(aid) {
956
+ return await this._readAgentMdStorage(this._agentMdContentKey(aid));
957
+ }
958
+ async _writeAgentMdContent(aid, content) {
959
+ await this._writeAgentMdStorage(this._agentMdContentKey(aid), String(content ?? ''));
960
+ }
961
+ _agentMdAuthCacheMeta(aid) {
962
+ try {
963
+ const store = this.auth._agentMdCache;
964
+ const record = store?.get(String(aid ?? '').trim());
965
+ return record && typeof record === 'object' ? { ...record } : {};
966
+ }
967
+ catch {
968
+ return {};
969
+ }
970
+ }
971
+ async _loadAgentMdRecord(aid) {
972
+ const target = String(aid ?? '').trim();
973
+ if (!target)
974
+ return null;
975
+ try {
976
+ const loaded = await this._withAgentMdLock(async () => {
977
+ const record = await this._readAgentMdRecordUnlocked(target);
978
+ const next = Object.keys(record).length > 0 ? { ...record, aid: target } : { aid: target };
979
+ try {
980
+ const content = await this._readAgentMdContent(target);
981
+ if (content !== null) {
982
+ next.content = content;
983
+ next.local_etag = await this._agentMdContentEtag(content);
984
+ }
985
+ else {
986
+ // 元数据存在但正文缺失
987
+ const metaRaw = await this._readAgentMdStorage(this._agentMdMetaKey(target));
988
+ if (metaRaw !== null) {
989
+ this._clientLog.warn(`agent.md content read failed: aid=${target}`);
990
+ }
991
+ }
992
+ }
993
+ catch (err) {
994
+ this._clientLog.warn(`agent.md content read failed: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
995
+ }
996
+ return next;
997
+ });
998
+ if (Object.keys(loaded).length <= 1)
999
+ return null;
1000
+ this._agentMdCache.set(target, { ...loaded });
1001
+ return { ...loaded };
1002
+ }
1003
+ catch (err) {
1004
+ this._clientLog.debug(`agent.md cache load skipped: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
1005
+ }
1006
+ return null;
1007
+ }
1008
+ async _saveAgentMdRecord(aid, fields) {
1009
+ const target = String(aid ?? '').trim();
1010
+ if (!target)
1011
+ return {};
1012
+ try {
1013
+ const inputFields = { ...fields };
1014
+ const hasContent = Object.prototype.hasOwnProperty.call(inputFields, 'content') && inputFields.content !== undefined && inputFields.content !== null;
1015
+ if (hasContent) {
1016
+ const text = String(inputFields.content ?? '');
1017
+ await this._writeAgentMdContent(target, text);
1018
+ if (!inputFields.local_etag)
1019
+ inputFields.local_etag = await this._agentMdContentEtag(text);
1020
+ if (!inputFields.fetched_at)
1021
+ inputFields.fetched_at = Date.now();
1022
+ }
1023
+ delete inputFields.content;
1024
+ const record = await this._withAgentMdLock(async () => {
1025
+ const next = { ...(await this._readAgentMdRecordUnlocked(target)), aid: target };
1026
+ for (const [key, value] of Object.entries(inputFields)) {
1027
+ if (value !== undefined && value !== null)
1028
+ next[key] = value;
1029
+ }
1030
+ next.updated_at = Date.now();
1031
+ await this._writeAgentMdRecordUnlocked(target, next);
1032
+ return next;
1033
+ });
1034
+ const loaded = { ...record };
1035
+ if (hasContent)
1036
+ loaded.content = String(fields.content ?? '');
1037
+ this._agentMdCache.set(target, { ...loaded });
1038
+ const owner = this._agentMdOwnerAid();
1039
+ if (target === owner) {
1040
+ const localEtag = String(loaded.local_etag ?? '').trim();
1041
+ const remoteEtag = String(loaded.remote_etag ?? '').trim();
1042
+ if (localEtag)
1043
+ this._localAgentMdEtag = localEtag;
1044
+ if (remoteEtag)
1045
+ this._remoteAgentMdEtag = remoteEtag;
1046
+ }
1047
+ return { ...loaded };
1048
+ }
1049
+ catch (err) {
1050
+ this._clientLog.debug(`agent.md cache save skipped: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
1051
+ }
1052
+ return {};
1053
+ }
1054
+ async _agentMdHasLocalContent(aid, record) {
1055
+ if (record && typeof record.content === 'string' && record.content.length > 0)
1056
+ return true;
1057
+ try {
1058
+ return (await this._readAgentMdContent(aid)) !== null;
1059
+ }
1060
+ catch {
1061
+ return false;
1062
+ }
1063
+ }
1064
+ _agentMdCheckedAtFresh(checkedAtMs, maxUnsyncedDays) {
1065
+ const days = Number(maxUnsyncedDays || 0);
1066
+ if (!Number.isFinite(days) || days <= 0)
1067
+ return false;
1068
+ if (!Number.isFinite(checkedAtMs) || checkedAtMs <= 0)
1069
+ return false;
1070
+ return Date.now() - checkedAtMs <= days * 86400000;
1071
+ }
1072
+ _agentMdLastModifiedFresh(lastModified, maxUnsyncedDays) {
1073
+ const days = Number(maxUnsyncedDays || 0);
1074
+ if (!Number.isFinite(days) || days <= 0)
1075
+ return false;
1076
+ const ts = Date.parse(String(lastModified ?? '').trim());
1077
+ if (!Number.isFinite(ts))
1078
+ return false;
1079
+ return Date.now() <= ts + days * 86400000;
1080
+ }
1081
+ async _scheduleAgentMdFetchIfMissing(aid, record, source = '') {
1082
+ const target = String(aid ?? '').trim();
1083
+ if (!target || await this._agentMdHasLocalContent(target, record))
1084
+ return;
1085
+ if (this._agentMdFetchInflight.has(target))
1086
+ return;
1087
+ this._agentMdFetchInflight.add(target);
1088
+ try {
1089
+ await this.fetchAgentMd(target);
1090
+ }
1091
+ catch (err) {
1092
+ await this._saveAgentMdRecord(target, {
1093
+ last_error: err instanceof Error ? err.message : String(err),
1094
+ remote_status: 'found',
1095
+ });
1096
+ this._clientLog.debug(`agent.md auto fetch failed: aid=${target} source=${source || '-'} err=${err instanceof Error ? err.message : String(err)}`);
1097
+ }
1098
+ finally {
1099
+ this._agentMdFetchInflight.delete(target);
1100
+ }
1101
+ }
1102
+ async _observeAgentMdMeta(aid, etag = '', lastModified = '', source = '') {
1103
+ const target = String(aid ?? '').trim();
1104
+ const remoteEtag = String(etag ?? '').trim();
1105
+ const remoteLastModified = String(lastModified ?? '').trim();
1106
+ if (!target || (!remoteEtag && !remoteLastModified))
1107
+ return;
1108
+ let before = this._agentMdCache.get(target);
1109
+ if (!before || typeof before !== 'object')
1110
+ before = await this._loadAgentMdRecord(target) ?? {};
1111
+ const same = (!remoteEtag || String(before.remote_etag ?? '').trim() === remoteEtag) &&
1112
+ (!remoteLastModified || String(before.last_modified ?? '').trim() === remoteLastModified);
1113
+ let record = { ...before };
1114
+ if (!same || Object.keys(before).length === 0) {
1115
+ const fields = {
1116
+ observed_at: Date.now(),
1117
+ remote_status: 'found',
1118
+ };
1119
+ if (remoteEtag)
1120
+ fields.remote_etag = remoteEtag;
1121
+ if (remoteLastModified)
1122
+ fields.last_modified = remoteLastModified;
1123
+ record = await this._saveAgentMdRecord(target, fields) || record;
1124
+ }
1125
+ if (target === this._agentMdOwnerAid() && remoteEtag)
1126
+ this._remoteAgentMdEtag = remoteEtag;
1127
+ await this._scheduleAgentMdFetchIfMissing(target, record, source);
1128
+ this._clientLog.debug(`agent.md meta observed: aid=${target} etag=${remoteEtag || '-'} last_modified=${remoteLastModified || '-'} source=${source || '-'}`);
1129
+ }
1130
+ async _observeAgentMdEtag(aid, etag, source = '') {
1131
+ await this._observeAgentMdMeta(aid, etag, '', source);
1132
+ }
1133
+ async _observeAgentMdFromEnvelope(envelope) {
1134
+ if (!isJsonObject(envelope))
1135
+ return;
1136
+ const env = envelope;
1137
+ if (!isJsonObject(env.agent_md))
1138
+ return;
1139
+ const agentMd = env.agent_md;
1140
+ if (!isJsonObject(agentMd.sender))
1141
+ return;
1142
+ const sender = agentMd.sender;
1143
+ let senderAid = String(sender.aid ?? '').trim();
1144
+ if (!senderAid) {
1145
+ const aad = isJsonObject(env.aad) ? env.aad : {};
1146
+ senderAid = String(aad.from ?? env.from ?? '').trim();
1147
+ }
1148
+ await this._observeAgentMdMeta(senderAid, String(sender.etag ?? '').trim(), String(sender.last_modified ?? sender.lastModified ?? '').trim(), 'envelope');
1149
+ }
1150
+ async checkAgentMd(aid, maxUnsyncedDays = 0) {
1151
+ const target = String(aid ?? this._aid ?? '').trim();
1152
+ if (!target)
1153
+ throw new ValidationError('checkAgentMd requires aid (or local AID)');
1154
+ const before = await this._loadAgentMdRecord(target) ?? {};
1155
+ const localEtag = String(before.local_etag ?? '').trim();
1156
+ const localFound = !!(Object.keys(before).length > 0 && (String(before.content ?? '') || localEtag));
1157
+ const remoteEtagCached = String(before.remote_etag ?? '').trim();
1158
+ const lastModifiedCached = String(before.last_modified ?? '').trim();
1159
+ const checkedAtCached = Number(before.checked_at ?? 0);
1160
+ const cachedInSync = !!(localFound && localEtag && remoteEtagCached && localEtag === remoteEtagCached);
1161
+ // max_unsynced_days > 0 且距上次 HEAD 在窗口内 → 直接返回缓存;否则强制 HEAD。
1162
+ if (cachedInSync && this._agentMdCheckedAtFresh(checkedAtCached, maxUnsyncedDays)) {
1163
+ return {
1164
+ aid: target,
1165
+ local_found: true,
1166
+ remote_found: true,
1167
+ local_etag: localEtag,
1168
+ remote_etag: remoteEtagCached,
1169
+ in_sync: true,
1170
+ last_modified: lastModifiedCached,
1171
+ status: 200,
1172
+ cached: true,
1173
+ verify_status: String(before.verify_status ?? ''),
1174
+ verify_error: String(before.verify_error ?? ''),
1175
+ };
1176
+ }
1177
+ const now = Date.now();
1178
+ let remote;
1179
+ try {
1180
+ remote = await this.auth.headAgentMd(target);
1181
+ }
1182
+ catch (err) {
1183
+ await this._saveAgentMdRecord(target, { checked_at: now, remote_status: 'error', last_error: err instanceof Error ? err.message : String(err) });
1184
+ throw err;
1185
+ }
1186
+ const remoteFound = !!remote.found;
1187
+ const remoteEtag = String(remote.etag ?? '').trim();
1188
+ const lastModified = String(remote.last_modified ?? remote.lastModified ?? '').trim();
1189
+ const saved = await this._saveAgentMdRecord(target, {
1190
+ remote_etag: remoteFound ? remoteEtag : '',
1191
+ last_modified: lastModified,
1192
+ checked_at: now,
1193
+ remote_status: remoteFound ? 'found' : 'missing',
1194
+ last_error: '',
1195
+ });
1196
+ if (target === this._agentMdOwnerAid() && remoteEtag)
1197
+ this._remoteAgentMdEtag = remoteEtag;
1198
+ const inSync = !!(localFound && remoteFound && localEtag && remoteEtag && localEtag === remoteEtag);
1199
+ return {
1200
+ aid: target,
1201
+ local_found: localFound,
1202
+ remote_found: remoteFound,
1203
+ local_etag: localEtag,
1204
+ remote_etag: remoteEtag,
1205
+ in_sync: inSync,
1206
+ last_modified: lastModified,
1207
+ status: Number(remote.status ?? (remoteFound ? 200 : 404)),
1208
+ cached: false,
1209
+ verify_status: String(saved.verify_status ?? before.verify_status ?? ''),
1210
+ verify_error: String(saved.verify_error ?? before.verify_error ?? ''),
1211
+ };
1212
+ }
603
1213
  /** transport 的 meta observer:吸收 gateway 注入的 _meta 字段。失败不影响业务。 */
604
- _observeRpcMeta(meta) {
1214
+ async _observeRpcMeta(meta) {
605
1215
  if (!isJsonObject(meta))
606
1216
  return;
607
1217
  const etag = String(meta.agent_md_etag ?? '').trim();
608
1218
  if (etag) {
609
1219
  this._remoteAgentMdEtag = etag;
1220
+ await this._observeAgentMdMeta(this._aid ?? '', etag, '', 'rpc.self');
1221
+ }
1222
+ const etags = meta.agent_md_etags;
1223
+ if (isJsonObject(etags)) {
1224
+ // role key 优先级:requester / peer 是新规范,其余是兼容旧 SDK 的别名。
1225
+ for (const key of ['requester', 'peer', 'receiver', 'target', 'to', 'sender', 'from']) {
1226
+ const item = etags[key];
1227
+ if (!isJsonObject(item))
1228
+ continue;
1229
+ await this._observeAgentMdMeta(String(item.aid ?? ''), String(item.etag ?? ''), String(item.last_modified ?? item.lastModified ?? ''), `rpc.${key}`);
1230
+ }
610
1231
  }
611
1232
  }
612
1233
  get state() {
@@ -660,18 +1281,30 @@ export class AUNClient {
660
1281
  this._sessionOptions = this._buildSessionOptions(normalized);
661
1282
  this._transport.setTimeout(this._sessionOptions.timeouts.call);
662
1283
  this._closing = false;
663
- try {
664
- await this._connectOnce(normalized, false);
665
- this._clientLog.debug(`connect exit: elapsed=${Date.now() - tStart}ms state=${this._state}`);
666
- }
667
- catch (err) {
668
- // 连接失败时回退状态,允许重试
669
- if (this._state === 'connecting' || this._state === 'authenticating') {
670
- this._state = 'disconnected';
1284
+ const gateways = this._resolveGateways(normalized);
1285
+ let lastErr = null;
1286
+ for (const gw of gateways) {
1287
+ try {
1288
+ const gwParams = { ...normalized, gateway: gw };
1289
+ await this._connectOnce(gwParams, false);
1290
+ this._clientLog.debug(`connect exit: elapsed=${Date.now() - tStart}ms state=${this._state}`);
1291
+ return;
671
1292
  }
672
- this._clientLog.debug(`connect exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
673
- throw err;
1293
+ catch (err) {
1294
+ lastErr = err;
1295
+ if (gateways.length > 1) {
1296
+ this._clientLog.warn(`connect: gateway ${gw} failed, trying next: ${err instanceof Error ? err.message : String(err)}`);
1297
+ }
1298
+ if (this._state === 'connecting' || this._state === 'authenticating') {
1299
+ this._state = 'connecting';
1300
+ }
1301
+ }
1302
+ }
1303
+ if (this._state === 'connecting' || this._state === 'authenticating') {
1304
+ this._state = 'disconnected';
674
1305
  }
1306
+ this._clientLog.debug(`connect exit (error): elapsed=${Date.now() - tStart}ms err=${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
1307
+ throw lastErr;
675
1308
  }
676
1309
  /** 断开连接但保留本地状态,可再次 connect */
677
1310
  async disconnect() {
@@ -803,6 +1436,9 @@ export class AUNClient {
803
1436
  throw new PermissionError(`legacy E2EE method is removed in this SDK: ${method}`);
804
1437
  }
805
1438
  const p = { ...(params ?? {}) };
1439
+ if (method === 'message.send' || method === 'group.send') {
1440
+ this._normalizeOutboundMessagePayload(p, method);
1441
+ }
806
1442
  this._validateOutboundCall(method, p);
807
1443
  this._injectMessageCursorContext(method, p);
808
1444
  // group.* 方法的 group_id 归一化为 canonical 格式(兼容老/污染数据)
@@ -815,7 +1451,7 @@ export class AUNClient {
815
1451
  p.group_id = normalizedGroupId;
816
1452
  }
817
1453
  // group.* 方法注入 device_id(服务端用于多设备消息路由)
818
- if (method.startsWith('group.') && this._deviceId && p.device_id === undefined) {
1454
+ if (method.startsWith('group.') && p.device_id === undefined) {
819
1455
  p.device_id = this._deviceId;
820
1456
  }
821
1457
  if (method.startsWith('group.') && p.slot_id === undefined) {
@@ -880,6 +1516,20 @@ export class AUNClient {
880
1516
  return this._putMessageThoughtEncryptedV2(p);
881
1517
  }
882
1518
  }
1519
+ // Pull Gate:序列化同一 key 的 pull 操作,防止并发重复拉取
1520
+ const pullGateKey = this._pullGateKeyForCall(method, p);
1521
+ if (pullGateKey) {
1522
+ return await this._runPullSerialized(pullGateKey, async () => {
1523
+ return await this._callImplInner(method, p);
1524
+ });
1525
+ }
1526
+ return await this._callImplInner(method, p);
1527
+ }
1528
+ /**
1529
+ * _callImpl 的内层:pull gate 之后的实际 RPC 分发逻辑。
1530
+ * 拆分出来以便 pull gate 包裹整个操作。
1531
+ */
1532
+ async _callImplInner(method, p) {
883
1533
  // message.pull:V2 就绪时走 V2 pull
884
1534
  if (method === 'message.pull' && this._v2Session) {
885
1535
  this._clientLog.debug('call route: message.pull → V2 pull');
@@ -904,7 +1554,12 @@ export class AUNClient {
904
1554
  }
905
1555
  // 关键操作自动附加客户端签名
906
1556
  if (SIGNED_METHODS.has(method)) {
907
- await this._signClientOperation(method, p);
1557
+ if (this._shouldSkipClientSignature(method, p)) {
1558
+ delete p.client_signature;
1559
+ }
1560
+ else {
1561
+ await this._signClientOperation(method, p);
1562
+ }
908
1563
  }
909
1564
  // P1-23: 非幂等方法使用更长超时
910
1565
  const callTimeout = NON_IDEMPOTENT_METHODS.has(method) ? NON_IDEMPOTENT_TIMEOUT : undefined;
@@ -1042,6 +1697,9 @@ export class AUNClient {
1042
1697
  const seq = msg.seq;
1043
1698
  if (seq !== undefined && seq !== null && this._aid) {
1044
1699
  const ns = `p2p:${this._aid}`;
1700
+ // Push 修上界:先更新 maxSeenSeq
1701
+ if (seq > 0)
1702
+ this._seqTracker.updateMaxSeen(ns, seq);
1045
1703
  const needPull = this._seqTracker.onMessageSeq(ns, seq);
1046
1704
  if (needPull) {
1047
1705
  this._safeAsync(this._fillP2pGap());
@@ -1049,8 +1707,10 @@ export class AUNClient {
1049
1707
  // auto-ack contiguous_seq
1050
1708
  const contig = this._seqTracker.getContiguousSeq(ns);
1051
1709
  if (contig > 0) {
1710
+ const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
1711
+ const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
1052
1712
  this._transport.call('message.ack', {
1053
- seq: contig,
1713
+ seq: ackSeq,
1054
1714
  device_id: this._deviceId,
1055
1715
  slot_id: this._slotId,
1056
1716
  }).catch((e) => { this._clientLog.warn(`P2P auto-ack failed:${String(e)}`); });
@@ -1079,6 +1739,7 @@ export class AUNClient {
1079
1739
  timestamp: (src.timestamp ?? null),
1080
1740
  _decrypt_error: String(exc),
1081
1741
  };
1742
+ attachV2EnvelopeMetadataFromSource(safeEvent, data);
1082
1743
  await this._publishAppEvent('message.undecryptable', safeEvent);
1083
1744
  }
1084
1745
  }
@@ -1111,6 +1772,14 @@ export class AUNClient {
1111
1772
  }
1112
1773
  try {
1113
1774
  const ns = `group:${groupId}`;
1775
+ // Push 修上界:先更新 maxSeenSeq
1776
+ this._seqTracker.updateMaxSeen(ns, seq);
1777
+ const contigBefore = this._seqTracker.getContiguousSeq(ns);
1778
+ if (contigBefore === seq) {
1779
+ this._clientLog.debug(`_onRawGroupV2MessageCreated duplicate push already covered: group=${groupId} seq=${seq}`);
1780
+ return;
1781
+ }
1782
+ const afterSeq = this._repairPushContiguousBound(ns, seq, false, '_raw.group.v2.message_created');
1114
1783
  // per-namespace 去重:同一 group namespace 只允许 1 个 in-flight pull
1115
1784
  const dedupKey = `group_pull:${ns}`;
1116
1785
  if (this._gapFillDone.has(dedupKey)) {
@@ -1119,7 +1788,6 @@ export class AUNClient {
1119
1788
  }
1120
1789
  this._gapFillDone.add(dedupKey);
1121
1790
  try {
1122
- const afterSeq = Math.max(0, this._seqTracker.getContiguousSeq(ns));
1123
1791
  this._clientLog.debug(`_onRawGroupV2MessageCreated -> group.v2.pull group=${groupId} after_seq=${afterSeq}`);
1124
1792
  const messages = await this.pullGroupV2(groupId, afterSeq, 50);
1125
1793
  this._clientLog.debug(`_onRawGroupV2MessageCreated pulled ${messages.length} msgs for group=${groupId}`);
@@ -1164,15 +1832,20 @@ export class AUNClient {
1164
1832
  // seq 跟踪 + auto-ack
1165
1833
  if (groupId && seq !== undefined && seq !== null) {
1166
1834
  const ns = `group:${groupId}`;
1835
+ // Push 修上界:先更新 maxSeenSeq
1836
+ if (seq > 0)
1837
+ this._seqTracker.updateMaxSeen(ns, seq);
1167
1838
  const needPull = this._seqTracker.onMessageSeq(ns, seq);
1168
1839
  if (needPull) {
1169
1840
  this._safeAsync(this._fillGroupGap(groupId));
1170
1841
  }
1171
1842
  const contig = this._seqTracker.getContiguousSeq(ns);
1172
1843
  if (contig > 0) {
1844
+ const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
1845
+ const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
1173
1846
  this._transport.call('group.ack_messages', {
1174
1847
  group_id: groupId,
1175
- msg_seq: contig,
1848
+ msg_seq: ackSeq,
1176
1849
  device_id: this._deviceId,
1177
1850
  slot_id: this._slotId,
1178
1851
  }).catch((e) => { this._clientLog.warn('group message auto-ack failed: group=' + groupId, e); });
@@ -1200,6 +1873,7 @@ export class AUNClient {
1200
1873
  timestamp: (src.timestamp ?? null),
1201
1874
  _decrypt_error: String(exc),
1202
1875
  };
1876
+ attachV2EnvelopeMetadataFromSource(safeEvent, data);
1203
1877
  await this._publishAppEvent('group.message_undecryptable', safeEvent);
1204
1878
  }
1205
1879
  }
@@ -1331,53 +2005,80 @@ export class AUNClient {
1331
2005
  this._gapFillDone.add(dedupKey);
1332
2006
  this._gapFillActive = true;
1333
2007
  try {
1334
- const result = await this.call('group.pull_events', {
1335
- group_id: groupId,
1336
- after_event_seq: afterSeq,
1337
- device_id: this._deviceId,
1338
- limit: 50,
1339
- });
1340
- if (isJsonObject(result)) {
2008
+ let nextAfterSeq = afterSeq;
2009
+ const maxPages = 100;
2010
+ let pageCount = 0;
2011
+ while (pageCount < maxPages) {
2012
+ pageCount += 1;
2013
+ const result = await this.call('group.pull_events', {
2014
+ group_id: groupId,
2015
+ after_event_seq: nextAfterSeq,
2016
+ device_id: this._deviceId,
2017
+ limit: 50,
2018
+ });
2019
+ if (!isJsonObject(result))
2020
+ return;
1341
2021
  const events = result.events;
1342
- if (Array.isArray(events)) {
1343
- this._seqTracker.onPullResult(ns, events.filter(isJsonObject), afterSeq);
1344
- const cursor = isJsonObject(result.cursor) ? result.cursor : null;
1345
- const serverAck = cursor ? Number(cursor.current_seq ?? 0) : 0;
1346
- if (serverAck > 0) {
1347
- const contigBefore = this._seqTracker.getContiguousSeq(ns);
1348
- if (contigBefore < serverAck) {
1349
- this._clientLog.info('group.pull_events retention-floor advance: ns=' + ns + ' contiguous=' + contigBefore + ' -> cursor.current_seq=' + serverAck);
1350
- this._seqTracker.forceContiguousSeq(ns, serverAck);
1351
- }
1352
- }
1353
- // 持久化 cursor + ack_events(与 Python 对齐)
1354
- this._saveSeqTrackerState();
1355
- const contig = this._seqTracker.getContiguousSeq(ns);
1356
- if (contig > 0 && (events.length > 0 || serverAck > 0)) {
1357
- this._transport.call('group.ack_events', {
1358
- group_id: groupId,
1359
- event_seq: contig,
1360
- device_id: this._deviceId,
1361
- slot_id: this._slotId,
1362
- }).catch((e) => { this._clientLog.warn('group event auto-ack failed: group=' + groupId, e); });
2022
+ if (!Array.isArray(events))
2023
+ return;
2024
+ const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
2025
+ const eventObjects = events.filter(isJsonObject);
2026
+ if (eventObjects.length > 0) {
2027
+ this._seqTracker.onPullResult(ns, eventObjects, nextAfterSeq);
2028
+ }
2029
+ const cursor = isJsonObject(result.cursor) ? result.cursor : null;
2030
+ const serverAck = cursor ? Number(cursor.current_seq ?? 0) : 0;
2031
+ if (serverAck > 0) {
2032
+ const contigBeforeFloor = this._seqTracker.getContiguousSeq(ns);
2033
+ if (contigBeforeFloor < serverAck) {
2034
+ this._clientLog.info('group.pull_events retention-floor advance: ns=' + ns + ' contiguous=' + contigBeforeFloor + ' -> cursor.current_seq=' + serverAck);
2035
+ this._seqTracker.forceContiguousSeq(ns, serverAck);
1363
2036
  }
1364
- for (const evt of events) {
1365
- if (isJsonObject(evt)) {
1366
- evt._from_gap_fill = true;
1367
- const et = String(evt.event_type ?? '');
1368
- // 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
1369
- if (et === 'group.message_created')
1370
- continue;
1371
- // 验签:有 client_signature 就验(与实时事件路径对齐)
1372
- const cs = evt.client_signature;
1373
- if (cs && typeof cs === 'object') {
1374
- evt._verified = await this._verifyEventSignature(evt, cs);
1375
- }
1376
- // group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
1377
- await this._dispatcher.publish('group.changed', evt);
2037
+ }
2038
+ const eventSeqs = [];
2039
+ for (const evt of eventObjects) {
2040
+ const eventSeq = Number(evt.event_seq ?? 0);
2041
+ if (Number.isFinite(eventSeq) && eventSeq > 0)
2042
+ eventSeqs.push(eventSeq);
2043
+ evt._from_gap_fill = true;
2044
+ const et = String(evt.event_type ?? '');
2045
+ // 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
2046
+ if (et === 'group.message_created')
2047
+ continue;
2048
+ // 验签:有 client_signature 就验(与实时事件路径对齐)
2049
+ const cs = evt.client_signature;
2050
+ if (cs && typeof cs === 'object') {
2051
+ if (this._shouldSkipEventSignature(evt)) {
2052
+ delete evt.client_signature;
2053
+ }
2054
+ else {
2055
+ evt._verified = await this._verifyEventSignature(evt, cs);
1378
2056
  }
1379
2057
  }
2058
+ // group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
2059
+ await this._dispatcher.publish('group.changed', evt);
2060
+ }
2061
+ const contig = this._seqTracker.getContiguousSeq(ns);
2062
+ if (contig !== pageContigBefore) {
2063
+ this._saveSeqTrackerState();
2064
+ }
2065
+ if (eventObjects.length > 0 && contig > 0 && contig !== pageContigBefore) {
2066
+ const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
2067
+ const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
2068
+ this._transport.call('group.ack_events', {
2069
+ group_id: groupId,
2070
+ event_seq: ackSeq,
2071
+ device_id: this._deviceId,
2072
+ slot_id: this._slotId,
2073
+ }).catch((e) => { this._clientLog.warn('group event auto-ack failed: group=' + groupId, e); });
1380
2074
  }
2075
+ const nextAfter = Math.max(eventSeqs.length > 0 ? Math.max(...eventSeqs) : nextAfterSeq, nextAfterSeq);
2076
+ if (eventObjects.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
2077
+ break;
2078
+ nextAfterSeq = nextAfter;
2079
+ }
2080
+ if (pageCount >= maxPages) {
2081
+ this._clientLog.warn(`group event gap fill reached max_pages=${maxPages} group=${groupId} after_seq=${nextAfterSeq}`);
1381
2082
  }
1382
2083
  }
1383
2084
  catch (exc) {
@@ -1495,10 +2196,10 @@ export class AUNClient {
1495
2196
  if (!isJsonObject(payload))
1496
2197
  return payload;
1497
2198
  const result = { ...payload };
1498
- if (this._deviceId && !String(result.device_id ?? '').trim()) {
2199
+ if (!('device_id' in result)) {
1499
2200
  result.device_id = this._deviceId;
1500
2201
  }
1501
- if (this._slotId && !String(result.slot_id ?? '').trim()) {
2202
+ if (!('slot_id' in result)) {
1502
2203
  result.slot_id = this._slotId;
1503
2204
  }
1504
2205
  return result;
@@ -1554,6 +2255,18 @@ export class AUNClient {
1554
2255
  const trace = `${this._echoTimestamp()} [AUN-SDK.send] aid=${this._aid ?? '-'} conn_uptime=${uptime}s`;
1555
2256
  params.payload = { ...payload, text: payload.text + '\n' + trace };
1556
2257
  }
2258
+ _shouldSkipClientSignature(method, params) {
2259
+ if (method !== 'message.send' && method !== 'group.send')
2260
+ return false;
2261
+ if (params.encrypted || params.encrypt)
2262
+ return false;
2263
+ return this._isEchoPayload(params.payload);
2264
+ }
2265
+ _shouldSkipEventSignature(event) {
2266
+ if (event.encrypted || event.encrypt)
2267
+ return false;
2268
+ return this._isEchoPayload(event.payload);
2269
+ }
1557
2270
  _maybeAppendEchoTraceReceive(msg) {
1558
2271
  if (msg.encrypted)
1559
2272
  return;
@@ -1567,13 +2280,17 @@ export class AUNClient {
1567
2280
  _messageTargetsCurrentInstance(message) {
1568
2281
  if (!isJsonObject(message))
1569
2282
  return true;
1570
- const targetDeviceId = String(message.device_id ?? '').trim();
1571
- if (targetDeviceId && this._deviceId && targetDeviceId !== this._deviceId) {
1572
- return false;
2283
+ if ('device_id' in message) {
2284
+ const targetDeviceId = String(message.device_id ?? '').trim();
2285
+ if (targetDeviceId !== this._deviceId) {
2286
+ return false;
2287
+ }
1573
2288
  }
1574
- const targetSlotId = String(message.slot_id ?? '').trim();
1575
- if (targetSlotId && this._slotId && targetSlotId !== this._slotId) {
1576
- return false;
2289
+ if ('slot_id' in message) {
2290
+ const targetSlotId = String(message.slot_id ?? '').trim();
2291
+ if (targetSlotId !== this._slotId) {
2292
+ return false;
2293
+ }
1577
2294
  }
1578
2295
  return true;
1579
2296
  }
@@ -1668,7 +2385,12 @@ export class AUNClient {
1668
2385
  // 验签:有 client_signature 就验,没有默认安全
1669
2386
  const cs = d.client_signature;
1670
2387
  if (cs && isJsonObject(cs)) {
1671
- d._verified = await this._verifyEventSignature(d, cs);
2388
+ if (this._shouldSkipEventSignature(d)) {
2389
+ delete d.client_signature;
2390
+ }
2391
+ else {
2392
+ d._verified = await this._verifyEventSignature(d, cs);
2393
+ }
1672
2394
  }
1673
2395
  await this._dispatcher.publish('group.changed', d);
1674
2396
  const groupId = (d.group_id ?? '');
@@ -1677,14 +2399,20 @@ export class AUNClient {
1677
2399
  this._v2BootstrapCache.delete(`group:${groupId}`);
1678
2400
  }
1679
2401
  // Group SPK 编排:成员变更触发注册/轮换
2402
+ const membershipActions = new Set([
2403
+ 'member_added', 'member_left', 'member_removed', 'role_changed',
2404
+ 'owner_transferred', 'joined', 'join_approved', 'invite_code_used',
2405
+ ]);
1680
2406
  if (this._v2Session && groupId) {
1681
- const membershipActions = new Set([
1682
- 'member_added', 'member_left', 'member_removed', 'role_changed',
1683
- 'owner_transferred', 'joined', 'join_approved',
1684
- ]);
1685
2407
  if (membershipActions.has(action)) {
1686
2408
  const callFn = async (method, params) => this.call(method, params);
1687
- if (action === 'joined' || action === 'join_approved') {
2409
+ const joinedAid = String(d.joined_aid ?? d.member_aid ?? d.aid ?? '').trim();
2410
+ const actorAid = String(d.actor_aid ?? '').trim();
2411
+ const selfAid = String(this._aid ?? '').trim();
2412
+ const joinActions = new Set(['member_added', 'joined', 'join_approved', 'invite_code_used']);
2413
+ const isSelfJoin = joinActions.has(action) && !!selfAid && (joinedAid === selfAid ||
2414
+ (!joinedAid && (action === 'joined' || action === 'invite_code_used') && actorAid === selfAid));
2415
+ if (isSelfJoin) {
1688
2416
  this._v2Session.ensureGroupRegistered?.(groupId, callFn)?.catch(exc => {
1689
2417
  this._clientLog.debug(`group SPK registration failed (non-fatal): group=${groupId} action=${action} err=${exc}`);
1690
2418
  });
@@ -1696,7 +2424,7 @@ export class AUNClient {
1696
2424
  }
1697
2425
  }
1698
2426
  }
1699
- if (groupId && action === 'upsert' && this._v2Session) {
2427
+ if (groupId && this._v2Session && (action === 'upsert' || membershipActions.has(action))) {
1700
2428
  this._safeAsync(this._v2AutoProposeState(groupId, { leaderDelay: true }));
1701
2429
  }
1702
2430
  // event_seq 空洞检测:持久化后的 group.changed 会携带 event_seq。
@@ -1761,12 +2489,17 @@ export class AUNClient {
1761
2489
  // 提交者签名验证
1762
2490
  const cs = d.client_signature;
1763
2491
  if (cs && isJsonObject(cs)) {
1764
- const verified = await this._verifyEventSignature(d, cs);
1765
- if (verified === false) {
1766
- this._clientLog.warn(`state_committed committer signature verify failed group=%s${String(groupId)}`);
1767
- return;
2492
+ if (this._shouldSkipEventSignature(d)) {
2493
+ delete d.client_signature;
2494
+ }
2495
+ else {
2496
+ const verified = await this._verifyEventSignature(d, cs);
2497
+ if (verified === false) {
2498
+ this._clientLog.warn(`state_committed committer signature verify failed group=%s${String(groupId)}`);
2499
+ return;
2500
+ }
2501
+ d._verified = verified;
1768
2502
  }
1769
- d._verified = verified;
1770
2503
  }
1771
2504
  const stateVersion = Number(d.state_version ?? 0);
1772
2505
  const stateHash = String(d.state_hash ?? '').trim();
@@ -2036,6 +2769,7 @@ export class AUNClient {
2036
2769
  let e2eeMeta = null;
2037
2770
  let decryptFailed = false;
2038
2771
  if (isV2Envelope) {
2772
+ e2eeMeta = v2E2eeMeta(payload);
2039
2773
  const plaintext = await this._decryptV2EnvelopeForThought({
2040
2774
  envelope: payload,
2041
2775
  fromAid: senderAid,
@@ -2047,7 +2781,7 @@ export class AUNClient {
2047
2781
  }
2048
2782
  else {
2049
2783
  decryptedPayload = plaintext;
2050
- const e2eeObj = v2E2eeMeta(payload);
2784
+ const e2eeObj = e2eeMeta;
2051
2785
  // 暴露 protected_headers(去 _auth)
2052
2786
  const ph = payload.protected_headers;
2053
2787
  if (isJsonObject(ph)) {
@@ -2084,6 +2818,8 @@ export class AUNClient {
2084
2818
  created_at: item.created_at,
2085
2819
  e2ee: e2eeMeta,
2086
2820
  };
2821
+ if (isJsonObject(e2eeMeta))
2822
+ attachV2EnvelopeMetadata(thought, e2eeMeta);
2087
2823
  if (decryptFailed)
2088
2824
  thought.decrypt_failed = true;
2089
2825
  if ('context' in item)
@@ -2122,6 +2858,8 @@ export class AUNClient {
2122
2858
  let decryptFailed = false;
2123
2859
  // V2 P2P thought envelope:per-device wrap,本设备解密自己的 row
2124
2860
  if (payload?.type === 'e2ee.p2p_encrypted') {
2861
+ const e2eeObj = v2E2eeMeta(payload);
2862
+ message.e2ee = e2eeObj;
2125
2863
  const plaintext = await this._decryptV2EnvelopeForThought({
2126
2864
  envelope: payload,
2127
2865
  fromAid,
@@ -2133,7 +2871,6 @@ export class AUNClient {
2133
2871
  else {
2134
2872
  decrypted = { ...message };
2135
2873
  decrypted.payload = plaintext;
2136
- const e2eeObj = v2E2eeMeta(payload);
2137
2874
  // 暴露 protected_headers(去 _auth)
2138
2875
  const ph = payload.protected_headers;
2139
2876
  if (isJsonObject(ph)) {
@@ -2162,6 +2899,7 @@ export class AUNClient {
2162
2899
  else if (payload?.type === 'e2ee.encrypted') {
2163
2900
  decryptFailed = true;
2164
2901
  }
2902
+ const exposedE2ee = (decrypted ?? message).e2ee;
2165
2903
  const thought = {
2166
2904
  thought_id: thoughtId,
2167
2905
  message_id: thoughtId,
@@ -2169,8 +2907,10 @@ export class AUNClient {
2169
2907
  to: toAid,
2170
2908
  payload: (decrypted ?? message).payload,
2171
2909
  created_at: item.created_at,
2172
- e2ee: (decrypted ?? message).e2ee,
2910
+ e2ee: exposedE2ee,
2173
2911
  };
2912
+ if (isJsonObject(exposedE2ee))
2913
+ attachV2EnvelopeMetadata(thought, exposedE2ee);
2174
2914
  if (decryptFailed)
2175
2915
  thought.decrypt_failed = true;
2176
2916
  if ('context' in item)
@@ -2184,7 +2924,7 @@ export class AUNClient {
2184
2924
  * 获取对方证书(带缓存 + 完整 PKI 验证:链 + CRL + OCSP + AID 绑定)。
2185
2925
  * 跨域时自动将请求路由到 peer 所在域的 Gateway。
2186
2926
  */
2187
- async _fetchPeerCert(aid, certFingerprint) {
2927
+ async _fetchPeerCert(aid, certFingerprint, timeoutMs = 5000) {
2188
2928
  const tStart = Date.now();
2189
2929
  this._clientLog.debug(`_fetchPeerCert enter: aid=${aid} fingerprint=${certFingerprint ?? '<none>'}`);
2190
2930
  try {
@@ -2206,7 +2946,7 @@ export class AUNClient {
2206
2946
  const certUrl = buildCertUrl(peerGatewayUrl, aid, certFingerprint);
2207
2947
  // 兼容旧浏览器,不使用 AbortSignal.timeout(Chrome 103+ 才支持)
2208
2948
  const controller = new AbortController();
2209
- const timeoutId = setTimeout(() => controller.abort(), 5000);
2949
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
2210
2950
  try {
2211
2951
  const resp = await fetch(certUrl, { signal: controller.signal });
2212
2952
  if (!resp.ok)
@@ -2223,7 +2963,7 @@ export class AUNClient {
2223
2963
  }
2224
2964
  // 兼容旧浏览器,不使用 AbortSignal.timeout(Chrome 103+ 才支持)
2225
2965
  const fallbackController = new AbortController();
2226
- const fallbackTimeoutId = setTimeout(() => fallbackController.abort(), 5000);
2966
+ const fallbackTimeoutId = setTimeout(() => fallbackController.abort(), timeoutMs);
2227
2967
  try {
2228
2968
  const fallbackResp = await fetch(buildCertUrl(peerGatewayUrl, aid), { signal: fallbackController.signal });
2229
2969
  if (!fallbackResp.ok) {
@@ -2485,6 +3225,10 @@ export class AUNClient {
2485
3225
  }
2486
3226
  }
2487
3227
  _resolveGateway(params) {
3228
+ const gateways = this._resolveGateways(params);
3229
+ return gateways[0];
3230
+ }
3231
+ _resolveGateways(params) {
2488
3232
  const topology = isJsonObject(params.topology) ? params.topology : null;
2489
3233
  if (topology) {
2490
3234
  const mode = String(topology.mode ?? 'gateway');
@@ -2495,10 +3239,16 @@ export class AUNClient {
2495
3239
  throw new ValidationError('relay topology is not implemented in the Browser SDK');
2496
3240
  }
2497
3241
  }
2498
- const gateway = String(params.gateway ?? this._gatewayUrl ?? '');
3242
+ const gw = params.gateway ?? params.gateways;
3243
+ if (Array.isArray(gw)) {
3244
+ const urls = gw.map((g) => String(g ?? '')).filter((u) => u.length > 0);
3245
+ if (urls.length > 0)
3246
+ return urls;
3247
+ }
3248
+ const gateway = String(gw ?? this._gatewayUrl ?? '');
2499
3249
  if (!gateway)
2500
3250
  throw new StateError('missing gateway in connect params');
2501
- return gateway;
3251
+ return [gateway];
2502
3252
  }
2503
3253
  async _syncIdentityAfterConnect(accessToken) {
2504
3254
  let identity = null;
@@ -2787,6 +3537,16 @@ export class AUNClient {
2787
3537
  };
2788
3538
  scheduleRefresh(0);
2789
3539
  }
3540
+ _normalizeOutboundMessagePayload(params, method = '') {
3541
+ if (!Object.prototype.hasOwnProperty.call(params, 'payload') && Object.prototype.hasOwnProperty.call(params, 'content')) {
3542
+ params.payload = params.content;
3543
+ delete params.content;
3544
+ }
3545
+ const payload = params.payload;
3546
+ if (isJsonObject(payload) && !Object.prototype.hasOwnProperty.call(payload, 'type') && typeof payload.text === 'string') {
3547
+ params.payload = { type: 'text', ...payload };
3548
+ }
3549
+ }
2790
3550
  _validateMessageRecipient(toAid) {
2791
3551
  if (isGroupServiceAid(toAid)) {
2792
3552
  throw new ValidationError('message.send receiver cannot be group.{issuer}; use group.send instead');
@@ -3210,6 +3970,8 @@ export class AUNClient {
3210
3970
  this._gapFillDone.clear();
3211
3971
  this._pushedSeqs.clear();
3212
3972
  this._pendingOrderedMsgs.clear();
3973
+ this._v2SenderIKPending.clear();
3974
+ this._v2SenderIKFetching.clear();
3213
3975
  this._groupSynced.clear();
3214
3976
  }
3215
3977
  _refreshSeqTrackerContext() {
@@ -3220,6 +3982,8 @@ export class AUNClient {
3220
3982
  this._gapFillDone.clear();
3221
3983
  this._pushedSeqs.clear();
3222
3984
  this._pendingOrderedMsgs.clear();
3985
+ this._v2SenderIKPending.clear();
3986
+ this._v2SenderIKFetching.clear();
3223
3987
  this._groupSynced.clear();
3224
3988
  this._seqTrackerContext = nextContext;
3225
3989
  }
@@ -3273,13 +4037,54 @@ export class AUNClient {
3273
4037
  }).catch(() => { });
3274
4038
  }
3275
4039
  }
3276
- // ── V2 E2EE API(async,与 Python `client.py` `_init_v2_session` / `send_v2` / `pull_v2` / `ack_v2` 对齐) ──
3277
- /**
3278
- * 初始化 V2 session:从 AID PEM 私钥提取 raw scalar + DER 公钥,
3279
- * 打开 V2 KeyStore(IndexedDB),构造 V2Session 并注册当前设备 SPK。
3280
- *
3281
- * connect 成功后自动调用,可幂等手动调用。
3282
- */
4040
+ _persistRepairedSeq(ns) {
4041
+ if (!this._aid || !ns)
4042
+ return;
4043
+ const seq = this._seqTracker.getContiguousSeq(ns);
4044
+ try {
4045
+ if (seq > 0 && typeof this._keystore.saveSeq === 'function') {
4046
+ this._keystore.saveSeq(this._aid, this._deviceId, this._slotId, ns, seq).catch((exc) => {
4047
+ this._clientLog.debug(`persist repaired seq failed: ns=${ns} err=${formatCaughtError(exc)}`);
4048
+ });
4049
+ return;
4050
+ }
4051
+ const deleteSeq = this._keystore.deleteSeq;
4052
+ if (seq <= 0 && typeof deleteSeq === 'function') {
4053
+ deleteSeq.call(this._keystore, this._aid, this._deviceId, this._slotId, ns).catch((exc) => {
4054
+ this._clientLog.debug(`delete repaired seq failed: ns=${ns} err=${formatCaughtError(exc)}`);
4055
+ });
4056
+ return;
4057
+ }
4058
+ if (seq > 0) {
4059
+ this._saveSeqTrackerState();
4060
+ }
4061
+ }
4062
+ catch (exc) {
4063
+ this._clientLog.debug(`persist repaired seq failed: ns=${ns} err=${formatCaughtError(exc)}`);
4064
+ }
4065
+ }
4066
+ _repairPushContiguousBound(ns, pushSeq, hasPayload, label) {
4067
+ if (!ns || !Number.isFinite(pushSeq) || pushSeq <= 0) {
4068
+ return ns ? this._seqTracker.getContiguousSeq(ns) : 0;
4069
+ }
4070
+ const contig = this._seqTracker.getContiguousSeq(ns);
4071
+ const shouldRepair = contig > pushSeq;
4072
+ if (!shouldRepair)
4073
+ return contig;
4074
+ const repairedTo = Math.max(0, pushSeq - 1);
4075
+ this._seqTracker.repairContiguousSeq(ns, repairedTo);
4076
+ const repaired = this._seqTracker.getContiguousSeq(ns);
4077
+ this._persistRepairedSeq(ns);
4078
+ this._clientLog.warn(`${label} push repaired contiguous_seq: ns=${ns} payload=${hasPayload} push_seq=${pushSeq} contiguous=${contig}->${repaired}`);
4079
+ return repaired;
4080
+ }
4081
+ // ── V2 E2EE API(async,与 Python `client.py` `_init_v2_session` / `send_v2` / `pull_v2` / `ack_v2` 对齐) ──
4082
+ /**
4083
+ * 初始化 V2 session:从 AID PEM 私钥提取 raw scalar + DER 公钥,
4084
+ * 打开 V2 KeyStore(IndexedDB),构造 V2Session 并注册当前设备 SPK。
4085
+ *
4086
+ * connect 成功后自动调用,可幂等手动调用。
4087
+ */
3283
4088
  async initV2Session() {
3284
4089
  if (!this._aid)
3285
4090
  return;
@@ -3323,6 +4128,104 @@ export class AUNClient {
3323
4128
  // 上线时自动确认 pending state proposals
3324
4129
  this._safeAsync(this._v2AutoConfirmPendingProposals());
3325
4130
  }
4131
+ async _v2TrustedIKPubDer(aid) {
4132
+ const normalizedAid = String(aid ?? '').trim();
4133
+ if (!normalizedAid)
4134
+ throw new E2EEError('spk_aid_missing');
4135
+ if (this._aid && normalizedAid === this._aid) {
4136
+ if (!this._v2Session)
4137
+ throw new E2EEError('V2 session not initialized');
4138
+ return this._v2Session.currentIkPubDer;
4139
+ }
4140
+ const certPem = await this._fetchPeerCert(normalizedAid);
4141
+ const pubKey = await importCertPublicKeyEcdsa(certPem);
4142
+ return new Uint8Array(await crypto.subtle.exportKey('spki', pubKey));
4143
+ }
4144
+ _v2SPKTimestampText(value, aid, deviceId, spkId) {
4145
+ if (value === null || value === undefined || value === '') {
4146
+ throw new E2EEError(`spk_timestamp_missing: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
4147
+ }
4148
+ if (typeof value === 'boolean') {
4149
+ throw new E2EEError(`spk_timestamp_invalid: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
4150
+ }
4151
+ if (typeof value === 'number') {
4152
+ if (!Number.isSafeInteger(value)) {
4153
+ throw new E2EEError(`spk_timestamp_invalid: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
4154
+ }
4155
+ return String(value);
4156
+ }
4157
+ const text = String(value).trim();
4158
+ if (!/^\d+$/.test(text)) {
4159
+ throw new E2EEError(`spk_timestamp_invalid: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
4160
+ }
4161
+ return BigInt(text).toString();
4162
+ }
4163
+ async _v2VerifySPKDevice(args) {
4164
+ if (!this._v2Session)
4165
+ throw new E2EEError('V2 session not initialized');
4166
+ const spkId = String(args.dev.spk_id ?? '').trim();
4167
+ if (!spkId)
4168
+ return;
4169
+ if (args.keySource !== 'peer_device_prekey' && args.keySource !== 'group_device_prekey') {
4170
+ throw new E2EEError(`spk_key_source_invalid: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId} key_source=${args.keySource}`);
4171
+ }
4172
+ if (!args.spkPkDer || args.spkPkDer.length === 0) {
4173
+ throw new E2EEError(`spk_public_key_missing: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
4174
+ }
4175
+ const spkHash = bytesToHex(new Uint8Array(await crypto.subtle.digest('SHA-256', args.spkPkDer.slice().buffer)));
4176
+ const expectedSpkId = `sha256:${spkHash.substring(0, 16)}`;
4177
+ if (spkId !== expectedSpkId) {
4178
+ throw new E2EEError(`spk_id_mismatch: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId} expected=${expectedSpkId}`);
4179
+ }
4180
+ const trustedIK = await this._v2TrustedIKPubDer(args.aid);
4181
+ if (!_v2BytesEqual(trustedIK, args.ikPkDer)) {
4182
+ throw new E2EEError(`spk_ik_mismatch: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
4183
+ }
4184
+ if (_v2BytesEqual(args.spkPkDer, trustedIK)) {
4185
+ this._v2Session.markPeerSPKVerified(args.aid, args.deviceId, spkId);
4186
+ return;
4187
+ }
4188
+ const sigB64 = String(args.dev.spk_signature ?? '').trim();
4189
+ if (!sigB64) {
4190
+ throw new E2EEError(`spk_signature_missing: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
4191
+ }
4192
+ let signature;
4193
+ try {
4194
+ signature = _v2B64ToBytesStrict(sigB64);
4195
+ }
4196
+ catch {
4197
+ throw new E2EEError(`spk_signature_invalid_base64: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
4198
+ }
4199
+ const encoder = new TextEncoder();
4200
+ const tsText = this._v2SPKTimestampText(args.dev.spk_timestamp, args.aid, args.deviceId, spkId);
4201
+ const signData = _v2ConcatBytes(args.spkPkDer, encoder.encode(spkId), encoder.encode(tsText));
4202
+ if (!(await ecdsaVerifyRaw(trustedIK, signature, signData))) {
4203
+ throw new E2EEError(`spk_signature_invalid: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
4204
+ }
4205
+ this._v2Session.markPeerSPKVerified(args.aid, args.deviceId, spkId);
4206
+ }
4207
+ async _v2BuildTargetFromDevice(args) {
4208
+ const aid = String(args.aid ?? '').trim();
4209
+ const devId = getV2DeviceId(args.dev);
4210
+ const deviceId = devId.present ? devId.value : String(args.deviceId ?? '').trim();
4211
+ const ikPk = String(args.dev.ik_pk ?? '').trim();
4212
+ if (!aid || !devId.present || !ikPk)
4213
+ return null;
4214
+ const ikPkDer = _v2B64ToBytes(ikPk);
4215
+ const spkPkDer = args.dev.spk_pk ? _v2B64ToBytes(String(args.dev.spk_pk)) : undefined;
4216
+ const keySource = String(args.dev.key_source ?? args.defaultKeySource).trim() || args.defaultKeySource;
4217
+ await this._v2VerifySPKDevice({ dev: args.dev, aid, deviceId, ikPkDer, spkPkDer, keySource });
4218
+ this._v2Session?.cachePeerIK(aid, deviceId, ikPkDer);
4219
+ return {
4220
+ aid,
4221
+ deviceId,
4222
+ role: args.role,
4223
+ keySource,
4224
+ ikPkDer,
4225
+ spkPkDer,
4226
+ spkId: String(args.dev.spk_id ?? '').trim(),
4227
+ };
4228
+ }
3326
4229
  async _getV2SenderPubDer(fromAid, senderDeviceId) {
3327
4230
  const session = this._v2Session;
3328
4231
  if (!session || !fromAid)
@@ -3331,37 +4234,134 @@ export class AUNClient {
3331
4234
  if (senderPubDer)
3332
4235
  return senderPubDer;
3333
4236
  try {
3334
- const bs = await this.call('message.v2.bootstrap', { peer_aid: fromAid });
3335
- const peers = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
3336
- for (const dev of peers) {
3337
- const devId = String(dev.device_id ?? dev.owner_device_id ?? '');
3338
- const ikPk = String(dev.ik_pk ?? '');
3339
- if (!devId || !ikPk)
3340
- continue;
3341
- session.cachePeerIK(fromAid, devId, _v2B64ToBytes(ikPk));
3342
- }
3343
- senderPubDer = session.getPeerIK(fromAid, senderDeviceId);
3344
- if (senderPubDer)
3345
- return senderPubDer;
3346
- }
3347
- catch (exc) {
3348
- this._clientLog.warn(`V2 decrypt: bootstrap for sender ${fromAid} failed: ${String(formatCaughtError(exc))}`);
3349
- }
3350
- try {
3351
- const certPem = await this._fetchPeerCert(fromAid);
4237
+ const certPem = await this._fetchPeerCert(fromAid, undefined, 3000);
3352
4238
  const pubKey = await importCertPublicKeyEcdsa(certPem);
3353
4239
  senderPubDer = new Uint8Array(await crypto.subtle.exportKey('spki', pubKey));
3354
- if (senderDeviceId) {
3355
- session.cachePeerIK(fromAid, senderDeviceId, senderPubDer);
3356
- }
3357
- this._clientLog.debug(`V2 decrypt: sender IK fallback from CA cert for ${fromAid}`);
4240
+ session.cachePeerIK(fromAid, senderDeviceId, senderPubDer);
4241
+ this._clientLog.debug(`V2 decrypt: sender IK fallback from PKI cert for ${fromAid}`);
3358
4242
  return senderPubDer;
3359
4243
  }
3360
4244
  catch (exc) {
3361
- this._clientLog.warn(`V2 decrypt: CA fallback for ${fromAid} failed: ${String(formatCaughtError(exc))}`);
4245
+ this._clientLog.warn(`V2 decrypt: PKI cert sender IK fallback failed for ${fromAid}: ${String(formatCaughtError(exc))}`);
3362
4246
  return null;
3363
4247
  }
3364
4248
  }
4249
+ _v2PendingSenderIKMessageKey(msg, groupId) {
4250
+ const messageId = String(msg.message_id ?? '').trim();
4251
+ const seq = String(msg.seq ?? '').trim();
4252
+ const prefix = groupId ? `group:${groupId}` : `p2p:${this._aid ?? ''}`;
4253
+ return `${prefix}:${messageId || seq || Math.random().toString(36).slice(2)}`;
4254
+ }
4255
+ _v2PendingSenderIKFetchKey(fromAid, senderDeviceId, groupId) {
4256
+ return `${fromAid}#${senderDeviceId}#${groupId || ''}`;
4257
+ }
4258
+ _cacheV2PeerIKFromDevice(dev, fallbackAid = '') {
4259
+ const session = this._v2Session;
4260
+ if (!session || !isJsonObject(dev))
4261
+ return;
4262
+ const device = dev;
4263
+ const devId = getV2DeviceId(device);
4264
+ const aid = String(device.aid ?? fallbackAid ?? '').trim();
4265
+ const ikPk = String(device.ik_pk ?? '').trim();
4266
+ if (!devId.present || !aid || !ikPk)
4267
+ return;
4268
+ try {
4269
+ session.cachePeerIK(aid, devId.value, _v2B64ToBytes(ikPk));
4270
+ }
4271
+ catch (exc) {
4272
+ this._clientLog.debug(`V2 sender IK cache from bootstrap skipped aid=${aid} dev=${devId.value}: ${String(formatCaughtError(exc))}`);
4273
+ }
4274
+ }
4275
+ _scheduleV2SenderIKPending(args) {
4276
+ const fromAid = String(args.fromAid ?? '').trim();
4277
+ if (!fromAid)
4278
+ return;
4279
+ const senderDeviceId = String(args.senderDeviceId ?? '');
4280
+ const groupId = String(args.groupId ?? '').trim();
4281
+ const messageKey = this._v2PendingSenderIKMessageKey(args.msg, groupId);
4282
+ this._v2SenderIKPending.set(messageKey, {
4283
+ msg: { ...args.msg },
4284
+ fromAid,
4285
+ senderDeviceId,
4286
+ groupId,
4287
+ createdAt: Date.now(),
4288
+ });
4289
+ this._clientLog.debug(`V2 decrypt pending sender IK: key=${messageKey} from=${fromAid} device=${senderDeviceId || '-'} group=${groupId || '<p2p>'} pending=${this._v2SenderIKPending.size}`);
4290
+ this._scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupId);
4291
+ }
4292
+ _scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupId) {
4293
+ const fetchKey = this._v2PendingSenderIKFetchKey(fromAid, senderDeviceId, groupId);
4294
+ if (!fromAid || this._v2SenderIKFetching.has(fetchKey))
4295
+ return;
4296
+ this._v2SenderIKFetching.add(fetchKey);
4297
+ this._safeAsync(this._resolveV2SenderIKPending(fromAid, senderDeviceId, groupId, fetchKey));
4298
+ }
4299
+ async _resolveV2SenderIKPending(fromAid, senderDeviceId, groupId, fetchKey) {
4300
+ try {
4301
+ const session = this._v2Session;
4302
+ if (session && fromAid) {
4303
+ try {
4304
+ const bs = await this.call('message.v2.bootstrap', {
4305
+ peer_aid: fromAid,
4306
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
4307
+ });
4308
+ const peers = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
4309
+ for (const dev of peers)
4310
+ this._cacheV2PeerIKFromDevice(dev, fromAid);
4311
+ }
4312
+ catch (exc) {
4313
+ this._clientLog.warn(`V2 sender IK pending bootstrap failed peer=${fromAid}: ${String(formatCaughtError(exc))}`);
4314
+ }
4315
+ if (groupId) {
4316
+ try {
4317
+ const gbs = await this.call('group.v2.bootstrap', {
4318
+ group_id: groupId,
4319
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
4320
+ });
4321
+ const devices = (Array.isArray(gbs?.devices) ? gbs.devices : []);
4322
+ const audit = (Array.isArray(gbs?.audit_recipients) ? gbs.audit_recipients : []);
4323
+ for (const dev of devices)
4324
+ this._cacheV2PeerIKFromDevice(dev);
4325
+ for (const dev of audit)
4326
+ this._cacheV2PeerIKFromDevice(dev);
4327
+ }
4328
+ catch (exc) {
4329
+ this._clientLog.warn(`V2 sender IK pending group bootstrap failed group=${groupId}: ${String(formatCaughtError(exc))}`);
4330
+ }
4331
+ }
4332
+ if (!session.getPeerIK(fromAid, senderDeviceId)) {
4333
+ await this._getV2SenderPubDer(fromAid, senderDeviceId);
4334
+ }
4335
+ }
4336
+ const pendingItems = [...this._v2SenderIKPending.entries()].filter(([, entry]) => entry.fromAid === fromAid && entry.senderDeviceId === senderDeviceId && entry.groupId === groupId);
4337
+ for (const [key, entry] of pendingItems) {
4338
+ let plaintext = null;
4339
+ try {
4340
+ plaintext = await this._decryptV2Message(entry.msg, false);
4341
+ }
4342
+ catch (exc) {
4343
+ this._clientLog.warn(`V2 sender IK pending retry raised: key=${key} err=${String(formatCaughtError(exc))}`);
4344
+ }
4345
+ this._v2SenderIKPending.delete(key);
4346
+ if (plaintext === null) {
4347
+ this._clientLog.debug(`V2 sender IK pending retry failed: key=${key}`);
4348
+ continue;
4349
+ }
4350
+ const seq = Number(entry.msg.seq ?? 0);
4351
+ if (entry.groupId) {
4352
+ plaintext.group_id = entry.groupId;
4353
+ await this._publishPulledMessage('group.message_created', `group:${entry.groupId}`, seq, plaintext);
4354
+ }
4355
+ else {
4356
+ await this._publishPulledMessage('message.received', `p2p:${this._aid ?? ''}`, seq, plaintext);
4357
+ }
4358
+ this._clientLog.debug(`V2 sender IK pending retry delivered: key=${key}`);
4359
+ }
4360
+ }
4361
+ finally {
4362
+ this._v2SenderIKFetching.delete(fetchKey);
4363
+ }
4364
+ }
3365
4365
  /**
3366
4366
  * V2 P2P 加密发送(推测性:用缓存 bootstrap 直接发,失败刷新重试一次)。
3367
4367
  *
@@ -3374,108 +4374,21 @@ export class AUNClient {
3374
4374
  if (!this._v2Session) {
3375
4375
  throw new StateError('V2 session not initialized (not connected?)');
3376
4376
  }
3377
- const session = this._v2Session;
3378
4377
  const toAid = String(to ?? '').trim();
3379
4378
  if (!toAid)
3380
4379
  throw new ValidationError("message.send requires 'to'");
3381
4380
  if (!isJsonObject(payload))
3382
4381
  throw new ValidationError('message.send payload must be a dict for V2 encryption');
3383
4382
  const attempt = async (useCache) => {
3384
- let peerDevices = [];
3385
- let auditRaw = [];
3386
- const cached = useCache ? this._v2BootstrapCache.get(toAid) : undefined;
3387
- if (cached && (Date.now() - cached.cachedAt) < AUNClient.V2_BOOTSTRAP_TTL_MS) {
3388
- peerDevices = cached.devices;
3389
- auditRaw = cached.auditRecipients;
3390
- }
3391
- else {
3392
- const bs = await this.call('message.v2.bootstrap', { peer_aid: toAid });
3393
- peerDevices = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
3394
- auditRaw = (Array.isArray(bs?.audit_recipients) ? bs.audit_recipients : []);
3395
- if (peerDevices.length > 0) {
3396
- this._v2BootstrapCache.set(toAid, {
3397
- devices: peerDevices,
3398
- auditRecipients: auditRaw,
3399
- cachedAt: Date.now(),
3400
- });
3401
- }
3402
- }
3403
- if (peerDevices.length === 0) {
3404
- throw new E2EEError(`V2 bootstrap: no devices found for ${toAid}`);
3405
- }
3406
- const targets = [];
3407
- for (const dev of peerDevices) {
3408
- const ikDer = _v2B64ToBytes(String(dev.ik_pk ?? ''));
3409
- const spkDer = dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined;
3410
- session.cachePeerIK(toAid, String(dev.device_id ?? ''), ikDer);
3411
- targets.push({
3412
- aid: toAid,
3413
- deviceId: String(dev.device_id ?? ''),
3414
- role: 'peer',
3415
- keySource: String(dev.key_source ?? 'peer_device_prekey'),
3416
- ikPkDer: ikDer,
3417
- spkPkDer: spkDer,
3418
- spkId: String(dev.spk_id ?? ''),
3419
- });
3420
- }
3421
- const auditTargets = [];
3422
- for (const dev of auditRaw) {
3423
- if (!dev.ik_pk)
3424
- continue;
3425
- const ikDer = _v2B64ToBytes(String(dev.ik_pk));
3426
- const spkDer = dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined;
3427
- auditTargets.push({
3428
- aid: String(dev.aid ?? ''),
3429
- deviceId: String(dev.device_id ?? ''),
3430
- role: 'audit',
3431
- keySource: String(dev.key_source ?? 'peer_device_prekey'),
3432
- ikPkDer: ikDer,
3433
- spkPkDer: spkDer,
3434
- spkId: String(dev.spk_id ?? ''),
3435
- });
3436
- }
3437
- // self-sync:当前 AID 的其它设备
3438
- if (this._aid && this._aid !== toAid) {
3439
- try {
3440
- const selfCached = this._v2BootstrapCache.get(this._aid);
3441
- let selfDevices = [];
3442
- if (selfCached && (Date.now() - selfCached.cachedAt) < AUNClient.V2_BOOTSTRAP_TTL_MS) {
3443
- selfDevices = selfCached.devices;
3444
- }
3445
- else {
3446
- const selfBs = await this.call('message.v2.bootstrap', { peer_aid: this._aid });
3447
- selfDevices = (Array.isArray(selfBs?.peer_devices) ? selfBs.peer_devices : []);
3448
- if (selfDevices.length > 0) {
3449
- this._v2BootstrapCache.set(this._aid, {
3450
- devices: selfDevices,
3451
- auditRecipients: [],
3452
- cachedAt: Date.now(),
3453
- });
3454
- }
3455
- }
3456
- for (const dev of selfDevices) {
3457
- const devId = String(dev.owner_device_id ?? dev.device_id ?? '');
3458
- if (devId === this._deviceId)
3459
- continue;
3460
- const ikDer = _v2B64ToBytes(String(dev.ik_pk ?? ''));
3461
- const spkDer = dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined;
3462
- targets.push({
3463
- aid: this._aid,
3464
- deviceId: devId,
3465
- role: 'self_sync',
3466
- keySource: String(dev.key_source ?? 'peer_device_prekey'),
3467
- ikPkDer: ikDer,
3468
- spkPkDer: spkDer,
3469
- spkId: String(dev.spk_id ?? ''),
3470
- });
3471
- }
3472
- }
3473
- catch (exc) {
3474
- this._clientLog.debug(`V2 self-sync bootstrap failed (non-fatal): ${String(exc)}`);
3475
- }
3476
- }
3477
- const sender = await session.getSenderIdentity();
3478
- const envelope = await encryptP2PMessage(sender, { targets, auditRecipients: auditTargets }, payload, opts ?? {});
4383
+ const envelope = await this._buildV2P2PEnvelope({
4384
+ to: toAid,
4385
+ payload,
4386
+ messageId: opts?.messageId,
4387
+ timestamp: opts?.timestamp,
4388
+ protectedHeaders: opts?.protectedHeaders,
4389
+ context: opts?.context,
4390
+ useCache,
4391
+ });
3479
4392
  return this.call('message.send', {
3480
4393
  to: toAid,
3481
4394
  payload: envelope,
@@ -3506,91 +4419,106 @@ export class AUNClient {
3506
4419
  throw new StateError('V2 session not initialized (not connected?)');
3507
4420
  }
3508
4421
  const ns = this._aid ? `p2p:${this._aid}` : '';
3509
- const effective = afterSeq || (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
3510
- const result = await this.call('message.v2.pull', {
3511
- after_seq: effective,
3512
- limit,
3513
- });
3514
- const messages = (Array.isArray(result?.messages) ? result.messages : []);
3515
4422
  const decrypted = [];
3516
- const seqs = messages
3517
- .map((msg) => Number(msg.seq ?? 0))
3518
- .filter((seq) => Number.isFinite(seq) && seq > 0);
3519
- const contigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
3520
- if (ns && seqs.length > 0 && seqs[0] > contigBefore) {
3521
- this._seqTracker.forceContiguousSeq(ns, seqs[0]);
3522
- }
3523
- for (const msg of messages) {
3524
- const seq = Number(msg.seq ?? 0);
3525
- if (!Number.isFinite(seq) || seq <= 0)
3526
- continue;
3527
- const version = String(msg.version ?? 'v2');
3528
- if (version === 'v1') {
3529
- const legacy = isJsonObject(msg.legacy_v1) ? msg.legacy_v1 : {};
3530
- const legacyPayload = legacy.payload;
3531
- const payloadType = isJsonObject(legacyPayload)
3532
- ? String(legacyPayload.type ?? '').trim()
3533
- : '';
3534
- if (legacyPayload !== undefined && legacyPayload !== null
3535
- && payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
3536
- const v1Msg = {
3537
- message_id: String(msg.message_id ?? ''),
3538
- from: String(msg.from_aid ?? ''),
3539
- to: String(legacy.to ?? this._aid ?? ''),
3540
- seq: msg.seq,
3541
- type: String(msg.type ?? ''),
3542
- timestamp: msg.t_server,
3543
- payload: legacyPayload,
3544
- encrypted: false,
3545
- };
3546
- if (ns)
3547
- await this._publishPulledMessage('message.received', ns, seq, v1Msg);
3548
- else
3549
- await this._publishAppEvent('message.received', v1Msg);
3550
- decrypted.push(v1Msg);
4423
+ let nextAfterSeq = afterSeq || (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
4424
+ let pageCount = 0;
4425
+ const maxPages = 100;
4426
+ while (pageCount < maxPages) {
4427
+ pageCount += 1;
4428
+ const result = await this.call('message.v2.pull', {
4429
+ after_seq: nextAfterSeq,
4430
+ limit,
4431
+ });
4432
+ const messages = (Array.isArray(result?.messages) ? result.messages : []);
4433
+ const seqs = messages
4434
+ .map((msg) => Number(msg.seq ?? 0))
4435
+ .filter((seq) => Number.isFinite(seq) && seq > 0);
4436
+ const pageContigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
4437
+ const pageMaxSeq = seqs.length > 0 ? Math.max(...seqs) : nextAfterSeq;
4438
+ if (ns && seqs.length > 0) {
4439
+ this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
4440
+ }
4441
+ for (const msg of messages) {
4442
+ const seq = Number(msg.seq ?? 0);
4443
+ if (!Number.isFinite(seq) || seq <= 0)
4444
+ continue;
4445
+ const version = String(msg.version ?? 'v2');
4446
+ if (version === 'v1') {
4447
+ const legacy = isJsonObject(msg.legacy_v1) ? msg.legacy_v1 : {};
4448
+ const legacyPayload = legacy.payload;
4449
+ const payloadType = isJsonObject(legacyPayload)
4450
+ ? String(legacyPayload.type ?? '').trim()
4451
+ : '';
4452
+ if (legacyPayload !== undefined && legacyPayload !== null
4453
+ && payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
4454
+ const v1Msg = {
4455
+ message_id: String(msg.message_id ?? ''),
4456
+ from: String(msg.from_aid ?? ''),
4457
+ to: String(legacy.to ?? this._aid ?? ''),
4458
+ seq: msg.seq,
4459
+ type: String(msg.type ?? ''),
4460
+ timestamp: msg.t_server,
4461
+ payload: legacyPayload,
4462
+ encrypted: false,
4463
+ };
4464
+ if (ns)
4465
+ await this._publishPulledMessage('message.received', ns, seq, v1Msg);
4466
+ else
4467
+ await this._publishAppEvent('message.received', v1Msg);
4468
+ decrypted.push(v1Msg);
4469
+ }
4470
+ else {
4471
+ this._clientLog.debug(`message.v2.pull skipping V1 envelope seq=${seq} payload_type=${payloadType || '<none>'} (V1 E2EE removed)`);
4472
+ }
4473
+ continue;
4474
+ }
4475
+ if (version !== 'v2') {
4476
+ this._clientLog.debug(`message.v2.pull skipping non-V2 row seq=${seq} version=${String(msg.version ?? '')}`);
4477
+ continue;
4478
+ }
4479
+ // 跟踪每个旧 SPK 引用的最大 seq(用于消费后销毁)
4480
+ const msgSpkId = String(msg.spk_id ?? '');
4481
+ if (msgSpkId && this._v2Session && !this._v2Session.isCurrentSPK(msgSpkId)) {
4482
+ this._v2Session.trackOldSPKMaxSeq(msgSpkId, seq);
4483
+ }
4484
+ const plaintext = await this._decryptV2Message(msg);
4485
+ if (plaintext === null)
4486
+ continue;
4487
+ if (ns) {
4488
+ await this._publishPulledMessage('message.received', ns, seq, plaintext);
4489
+ decrypted.push(plaintext);
3551
4490
  }
3552
4491
  else {
3553
- this._clientLog.debug(`message.v2.pull skipping V1 envelope seq=${seq} payload_type=${payloadType || '<none>'} (V1 E2EE removed)`);
4492
+ await this._publishAppEvent('message.received', plaintext);
4493
+ decrypted.push(plaintext);
3554
4494
  }
3555
- continue;
3556
4495
  }
3557
- if (version !== 'v2') {
3558
- this._clientLog.debug(`message.v2.pull skipping non-V2 row seq=${seq} version=${String(msg.version ?? '')}`);
3559
- continue;
3560
- }
3561
- // 跟踪每个旧 SPK 引用的最大 seq(用于消费后销毁)
3562
- const msgSpkId = String(msg.spk_id ?? '');
3563
- if (msgSpkId && this._v2Session && !this._v2Session.isCurrentSPK(msgSpkId)) {
3564
- this._v2Session.trackOldSPKMaxSeq(msgSpkId, seq);
4496
+ const serverAckSeq = Number(result.server_ack_seq ?? 0);
4497
+ if (ns && Number.isFinite(serverAckSeq) && serverAckSeq > 0) {
4498
+ const contig = this._seqTracker.getContiguousSeq(ns);
4499
+ if (contig < serverAckSeq) {
4500
+ this._clientLog.info(`message.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> server_ack_seq=${serverAckSeq}`);
4501
+ this._seqTracker.forceContiguousSeq(ns, serverAckSeq);
4502
+ }
3565
4503
  }
3566
- // 解密
3567
- const plaintext = await this._decryptV2Message(msg);
3568
- if (plaintext === null)
3569
- continue;
3570
- // 有序 publish + 去重(与 V1 push 路径对齐)
3571
4504
  if (ns) {
3572
- await this._publishPulledMessage('message.received', ns, seq, plaintext);
3573
- decrypted.push(plaintext);
3574
- }
3575
- else {
3576
- await this._publishAppEvent('message.received', plaintext);
3577
- decrypted.push(plaintext);
3578
- }
3579
- }
3580
- if (ns && seqs.length > 0) {
3581
- const maxSeq = Math.max(...seqs);
3582
- const contig = this._seqTracker.getContiguousSeq(ns);
3583
- if (maxSeq > contig) {
3584
- this._seqTracker.forceContiguousSeq(ns, maxSeq);
3585
- await this._drainOrderedMessages(ns);
3586
- }
3587
- const ackSeq = this._seqTracker.getContiguousSeq(ns);
3588
- if (ackSeq !== contigBefore) {
3589
- this._saveSeqTrackerState();
3590
- if (ackSeq > 0) {
4505
+ const ackSeq = this._seqTracker.getContiguousSeq(ns);
4506
+ const contigAdvanced = ackSeq !== pageContigBefore;
4507
+ if (contigAdvanced) {
4508
+ await this._drainOrderedMessages(ns);
4509
+ this._saveSeqTrackerState();
4510
+ }
4511
+ if (messages.length > 0 && contigAdvanced && ackSeq > 0) {
3591
4512
  this._safeAsync(this.ackV2(ackSeq).then(() => undefined));
3592
4513
  }
3593
4514
  }
4515
+ const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
4516
+ if (messages.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
4517
+ break;
4518
+ nextAfterSeq = nextAfter;
4519
+ }
4520
+ if (pageCount >= maxPages) {
4521
+ this._clientLog.warn(`message.v2.pull reached max_pages=${maxPages} after_seq=${nextAfterSeq}`);
3594
4522
  }
3595
4523
  return decrypted;
3596
4524
  }
@@ -3601,9 +4529,17 @@ export class AUNClient {
3601
4529
  */
3602
4530
  async ackV2(upToSeq) {
3603
4531
  const ns = this._aid ? `p2p:${this._aid}` : '';
3604
- const seq = upToSeq ?? (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
4532
+ let seq = upToSeq ?? (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
3605
4533
  if (seq <= 0)
3606
4534
  return { acked: 0 };
4535
+ // ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
4536
+ if (ns) {
4537
+ const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
4538
+ if (maxSeen > 0 && seq > maxSeen) {
4539
+ this._clientLog.warn(`ackV2 clamp: up_to_seq=${seq} > max_seen=${maxSeen}, clamp`);
4540
+ seq = maxSeen;
4541
+ }
4542
+ }
3607
4543
  const raw = await this.call('message.v2.ack', { up_to_seq: seq });
3608
4544
  const result = isJsonObject(raw)
3609
4545
  ? { ...raw }
@@ -3634,8 +4570,8 @@ export class AUNClient {
3634
4570
  }
3635
4571
  return result;
3636
4572
  }
3637
- /** 解密单条 V2 消息(与 Python `_decrypt_v2_message` 对齐) */
3638
- async _decryptV2Message(msg) {
4573
+ /** 解密单条 V2 消息(与 Python `_decrypt_v2_message` 对齐)。缺 sender IK 时先入 pending,后台补齐后重试。 */
4574
+ async _decryptV2Message(msg, allowPending = true) {
3639
4575
  const session = this._v2Session;
3640
4576
  if (!session)
3641
4577
  return null;
@@ -3650,6 +4586,8 @@ export class AUNClient {
3650
4586
  this._clientLog.warn(`V2 decrypt: invalid envelope_json for msg seq=${String(msg.seq)}`);
3651
4587
  return null;
3652
4588
  }
4589
+ const e2eeMeta = v2E2eeMeta(envelope);
4590
+ await this._observeAgentMdFromEnvelope(envelope);
3653
4591
  // 确定 spk_id 和 recipient_key_source
3654
4592
  let spkId = '';
3655
4593
  let recipientKeySource = '';
@@ -3663,7 +4601,7 @@ export class AUNClient {
3663
4601
  // 从 recipients 数组中查找本设备的 row 以获取 key_source
3664
4602
  if (!spkId) {
3665
4603
  for (const row of envelope.recipients) {
3666
- if (Array.isArray(row) && row.length >= 6 && row[0] === this._aid && row[1] === this._deviceId) {
4604
+ if (Array.isArray(row) && row.length >= 6 && row[0] === this._aid && (row[1] === this._deviceId || row[1] === '')) {
3667
4605
  spkId = String(row[5] ?? '');
3668
4606
  recipientKeySource = row.length > 3 ? String(row[3] ?? '') : '';
3669
4607
  break;
@@ -3672,40 +4610,80 @@ export class AUNClient {
3672
4610
  }
3673
4611
  else {
3674
4612
  for (const row of envelope.recipients) {
3675
- if (Array.isArray(row) && row.length >= 6 && row[0] === this._aid && row[1] === this._deviceId) {
4613
+ if (Array.isArray(row) && row.length >= 6 && row[0] === this._aid && (row[1] === this._deviceId || row[1] === '')) {
3676
4614
  recipientKeySource = row.length > 3 ? String(row[3] ?? '') : '';
3677
4615
  break;
3678
4616
  }
3679
4617
  }
3680
4618
  }
3681
4619
  }
3682
- // 根据 aad.group_id 判断使用 group SPK 还是 P2P SPK
4620
+ // group_id 只表示群上下文;getGroupDecryptKeys 内部必须按 group SPK -> P2P device SPK -> IK fallback 查找。
3683
4621
  const aad = isJsonObject(envelope.aad) ? envelope.aad : {};
3684
4622
  const groupIdForKeys = String(msg.group_id ?? aad.group_id ?? envelope.group_id ?? '').trim();
4623
+ const undecryptableEvent = groupIdForKeys ? 'group.message_undecryptable' : 'message.undecryptable';
3685
4624
  let ikPriv;
3686
4625
  let spkPriv;
3687
- if (groupIdForKeys) {
3688
- const keys = await session.getGroupDecryptKeys(groupIdForKeys, spkId);
3689
- ikPriv = keys.ikPriv;
3690
- spkPriv = keys.spkPriv;
4626
+ try {
4627
+ if (groupIdForKeys) {
4628
+ const keys = await session.getGroupDecryptKeys(groupIdForKeys, spkId);
4629
+ ikPriv = keys.ikPriv;
4630
+ spkPriv = keys.spkPriv;
4631
+ }
4632
+ else {
4633
+ const keys = await session.getDecryptKeys(spkId);
4634
+ ikPriv = keys.ikPriv;
4635
+ spkPriv = keys.spkPriv;
4636
+ }
3691
4637
  }
3692
- else {
3693
- const keys = await session.getDecryptKeys(spkId);
3694
- ikPriv = keys.ikPriv;
3695
- spkPriv = keys.spkPriv;
4638
+ catch (exc) {
4639
+ this._clientLog.warn(`V2 decrypt: SPK lookup failed seq=${String(msg.seq)} spk_id=${spkId}: ${String(exc)}`);
4640
+ try {
4641
+ const event = {
4642
+ message_id: String(msg.message_id ?? ''),
4643
+ from: String(msg.from_aid ?? ''),
4644
+ to: String(msg.to ?? ''),
4645
+ seq: msg.seq,
4646
+ timestamp: (msg.t_server ?? msg.timestamp),
4647
+ device_id: String(msg.device_id ?? ''),
4648
+ slot_id: String(msg.slot_id ?? ''),
4649
+ _decrypt_error: String(exc),
4650
+ _decrypt_stage: 'spk_lookup',
4651
+ _envelope_type: String(envelope.type ?? ''),
4652
+ _suite: String(envelope.suite ?? ''),
4653
+ _spk_id: spkId,
4654
+ };
4655
+ attachV2EnvelopeMetadata(event, e2eeMeta);
4656
+ await this._dispatcher.publish(undecryptableEvent, event);
4657
+ }
4658
+ catch { /* publish 异常不影响主流程 */ }
4659
+ return null;
3696
4660
  }
3697
4661
  const fromAid = String(msg.from_aid ?? '');
3698
4662
  const senderDeviceId = String(aad.from_device ?? '');
3699
4663
  const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId);
3700
4664
  if (!senderPubDer) {
3701
- this._clientLog.warn(`V2 decrypt: no sender IK for ${fromAid}, cannot verify signature`);
4665
+ this._clientLog.warn(`V2 decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
4666
+ if (allowPending) {
4667
+ this._scheduleV2SenderIKPending({ msg, fromAid, senderDeviceId, groupId: groupIdForKeys });
4668
+ return null;
4669
+ }
3702
4670
  try {
3703
- await this._dispatcher.publish('message.undecryptable', {
4671
+ const event = {
3704
4672
  message_id: String(msg.message_id ?? ''),
3705
4673
  from: fromAid,
4674
+ to: String(msg.to ?? ''),
3706
4675
  seq: msg.seq,
4676
+ timestamp: (msg.t_server ?? msg.timestamp),
4677
+ device_id: String(msg.device_id ?? ''),
4678
+ slot_id: String(msg.slot_id ?? ''),
3707
4679
  _decrypt_error: 'sender_ik_not_found',
3708
- });
4680
+ _decrypt_stage: 'sender_ik',
4681
+ _envelope_type: String(envelope.type ?? ''),
4682
+ _suite: String(envelope.suite ?? ''),
4683
+ _sender_device_id: String(aad.from_device ?? ''),
4684
+ };
4685
+ attachV2EnvelopeMetadata(event, e2eeMeta);
4686
+ await this._dispatcher.publish(undecryptableEvent, event);
3709
4687
  }
3710
4688
  catch { /* publish 异常不影响主流程 */ }
3711
4689
  return null;
@@ -3717,12 +4695,22 @@ export class AUNClient {
3717
4695
  catch (exc) {
3718
4696
  this._clientLog.warn(`V2 decrypt failed for msg seq=${String(msg.seq)}: ${String(exc)}`);
3719
4697
  try {
3720
- await this._dispatcher.publish('message.undecryptable', {
4698
+ const event = {
3721
4699
  message_id: String(msg.message_id ?? ''),
3722
4700
  from: fromAid,
4701
+ to: String(msg.to ?? ''),
3723
4702
  seq: msg.seq,
4703
+ timestamp: (msg.t_server ?? msg.timestamp),
4704
+ device_id: String(msg.device_id ?? ''),
4705
+ slot_id: String(msg.slot_id ?? ''),
3724
4706
  _decrypt_error: String(exc),
3725
- });
4707
+ _decrypt_stage: 'decrypt',
4708
+ _envelope_type: String(envelope.type ?? ''),
4709
+ _suite: String(envelope.suite ?? ''),
4710
+ _sender_device_id: String(aad.from_device ?? ''),
4711
+ };
4712
+ attachV2EnvelopeMetadata(event, e2eeMeta);
4713
+ await this._dispatcher.publish(undecryptableEvent, event);
3726
4714
  }
3727
4715
  catch { /* publish 异常不影响主流程 */ }
3728
4716
  return null;
@@ -3748,7 +4736,8 @@ export class AUNClient {
3748
4736
  this._clientLog.debug(`V2 SPK rotation failed (non-fatal): ${exc}`);
3749
4737
  });
3750
4738
  }
3751
- return {
4739
+ const e2ee = v2E2eeMeta(envelope);
4740
+ const result = {
3752
4741
  message_id: String(msg.message_id ?? ''),
3753
4742
  from: fromAid,
3754
4743
  to: this._aid ?? '',
@@ -3756,8 +4745,10 @@ export class AUNClient {
3756
4745
  t_server: msg.t_server,
3757
4746
  payload: plaintext,
3758
4747
  encrypted: true,
3759
- e2ee: v2E2eeMeta(envelope),
4748
+ e2ee: e2ee,
3760
4749
  };
4750
+ attachV2EnvelopeMetadata(result, e2ee);
4751
+ return result;
3761
4752
  }
3762
4753
  /**
3763
4754
  * V2 Group 加密发送(推测性:用缓存 bootstrap 直接发,失败刷新重试一次)。
@@ -3843,32 +4834,53 @@ export class AUNClient {
3843
4834
  if (!gid)
3844
4835
  throw new ValidationError('group.pull requires group_id');
3845
4836
  const ns = `group:${gid}`;
3846
- const effective = afterSeq || this._seqTracker.getContiguousSeq(ns);
3847
- const result = await this.call('group.v2.pull', {
3848
- group_id: gid,
3849
- after_seq: effective,
3850
- limit,
3851
- });
3852
- const messages = (Array.isArray(result?.messages) ? result.messages : []);
3853
4837
  const decrypted = [];
3854
- const seqs = messages
3855
- .map((msg) => Number(msg.seq ?? 0))
3856
- .filter((seq) => Number.isFinite(seq) && seq > 0);
3857
- const contigBefore = this._seqTracker.getContiguousSeq(ns);
3858
- if (seqs.length > 0 && seqs[0] > contigBefore) {
3859
- this._seqTracker.forceContiguousSeq(ns, seqs[0]);
3860
- }
3861
- for (const msg of messages) {
3862
- const seq = Number(msg.seq ?? 0);
3863
- if (!Number.isFinite(seq) || seq <= 0)
3864
- continue;
3865
- const version = String(msg.version ?? 'v2');
3866
- if (version === 'v1') {
3867
- const payload = msg.payload;
3868
- const payloadObj = isJsonObject(payload) ? payload : null;
3869
- if (payloadObj) {
3870
- const payloadType = String(payloadObj.type ?? '').trim();
3871
- if (payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
4838
+ let nextAfterSeq = afterSeq || this._seqTracker.getContiguousSeq(ns);
4839
+ let pageCount = 0;
4840
+ const maxPages = 100;
4841
+ while (pageCount < maxPages) {
4842
+ pageCount += 1;
4843
+ const result = await this.call('group.v2.pull', {
4844
+ group_id: gid,
4845
+ after_seq: nextAfterSeq,
4846
+ limit,
4847
+ });
4848
+ const messages = (Array.isArray(result?.messages) ? result.messages : []);
4849
+ const seqs = messages
4850
+ .map((msg) => Number(msg.seq ?? 0))
4851
+ .filter((seq) => Number.isFinite(seq) && seq > 0);
4852
+ const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
4853
+ const pageMaxSeq = seqs.length > 0 ? Math.max(...seqs) : nextAfterSeq;
4854
+ if (seqs.length > 0) {
4855
+ this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
4856
+ }
4857
+ for (const msg of messages) {
4858
+ const seq = Number(msg.seq ?? 0);
4859
+ if (!Number.isFinite(seq) || seq <= 0)
4860
+ continue;
4861
+ const version = String(msg.version ?? 'v2');
4862
+ if (version === 'v1') {
4863
+ const payload = msg.payload;
4864
+ const payloadObj = isJsonObject(payload) ? payload : null;
4865
+ if (payloadObj) {
4866
+ const payloadType = String(payloadObj.type ?? '').trim();
4867
+ if (payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
4868
+ const v1Msg = {
4869
+ message_id: String(msg.message_id ?? ''),
4870
+ from: String(msg.from_aid ?? ''),
4871
+ group_id: gid,
4872
+ seq: msg.seq,
4873
+ type: String(msg.type ?? ''),
4874
+ timestamp: msg.t_server,
4875
+ payload,
4876
+ encrypted: false,
4877
+ };
4878
+ await this._publishPulledMessage('group.message_created', ns, seq, v1Msg);
4879
+ decrypted.push(v1Msg);
4880
+ continue;
4881
+ }
4882
+ }
4883
+ else if (payload !== undefined && payload !== null) {
3872
4884
  const v1Msg = {
3873
4885
  message_id: String(msg.message_id ?? ''),
3874
4886
  from: String(msg.from_aid ?? ''),
@@ -3883,51 +4895,45 @@ export class AUNClient {
3883
4895
  decrypted.push(v1Msg);
3884
4896
  continue;
3885
4897
  }
4898
+ this._clientLog.debug(`group.v2.pull skipping V1 envelope group=${gid} seq=${seq} payload_type=${payloadObj ? String(payloadObj.type ?? '') : '<none>'} (V1 E2EE removed)`);
4899
+ continue;
3886
4900
  }
3887
- else if (payload !== undefined && payload !== null) {
3888
- const v1Msg = {
3889
- message_id: String(msg.message_id ?? ''),
3890
- from: String(msg.from_aid ?? ''),
3891
- group_id: gid,
3892
- seq: msg.seq,
3893
- type: String(msg.type ?? ''),
3894
- timestamp: msg.t_server,
3895
- payload,
3896
- encrypted: false,
3897
- };
3898
- await this._publishPulledMessage('group.message_created', ns, seq, v1Msg);
3899
- decrypted.push(v1Msg);
4901
+ if (version !== 'v2') {
4902
+ this._clientLog.debug(`group.v2.pull skipping non-V2 row group=${gid} seq=${seq} version=${String(msg.version ?? '')}`);
3900
4903
  continue;
3901
4904
  }
3902
- this._clientLog.debug(`group.v2.pull skipping V1 envelope group=${gid} seq=${seq} payload_type=${payloadObj ? String(payloadObj.type ?? '') : '<none>'} (V1 E2EE removed)`);
3903
- continue;
3904
- }
3905
- if (version !== 'v2') {
3906
- this._clientLog.debug(`group.v2.pull skipping non-V2 row group=${gid} seq=${seq} version=${String(msg.version ?? '')}`);
3907
- continue;
4905
+ const plaintext = await this._decryptV2Message(msg);
4906
+ if (plaintext === null)
4907
+ continue;
4908
+ plaintext.group_id = gid;
4909
+ await this._publishPulledMessage('group.message_created', ns, seq, plaintext);
4910
+ decrypted.push(plaintext);
3908
4911
  }
3909
- const plaintext = await this._decryptV2Message(msg);
3910
- if (plaintext === null)
3911
- continue;
3912
- plaintext.group_id = gid;
3913
- // 有序 publish + 去重(与 V1 group push 路径对齐)
3914
- await this._publishPulledMessage('group.message_created', ns, seq, plaintext);
3915
- decrypted.push(plaintext);
3916
- }
3917
- if (seqs.length > 0) {
3918
- const maxSeq = Math.max(...seqs);
3919
- const contig = this._seqTracker.getContiguousSeq(ns);
3920
- if (maxSeq > contig) {
3921
- this._seqTracker.forceContiguousSeq(ns, maxSeq);
3922
- await this._drainOrderedMessages(ns);
4912
+ const cursor = isJsonObject(result.cursor) ? result.cursor : null;
4913
+ const serverAckSeq = Number(cursor?.current_seq ?? 0);
4914
+ if (Number.isFinite(serverAckSeq) && serverAckSeq > 0) {
4915
+ const contig = this._seqTracker.getContiguousSeq(ns);
4916
+ if (contig < serverAckSeq) {
4917
+ this._clientLog.info(`group.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> cursor.current_seq=${serverAckSeq}`);
4918
+ this._seqTracker.forceContiguousSeq(ns, serverAckSeq);
4919
+ }
3923
4920
  }
3924
4921
  const ackSeq = this._seqTracker.getContiguousSeq(ns);
3925
- if (ackSeq !== contigBefore) {
4922
+ const contigAdvanced = ackSeq !== pageContigBefore;
4923
+ if (contigAdvanced) {
4924
+ await this._drainOrderedMessages(ns);
3926
4925
  this._saveSeqTrackerState();
3927
- if (ackSeq > 0) {
3928
- this._safeAsync(this.ackGroupV2(gid, ackSeq).then(() => undefined));
3929
- }
3930
4926
  }
4927
+ if (messages.length > 0 && contigAdvanced && ackSeq > 0) {
4928
+ this._safeAsync(this.ackGroupV2(gid, ackSeq).then(() => undefined));
4929
+ }
4930
+ const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
4931
+ if (messages.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
4932
+ break;
4933
+ nextAfterSeq = nextAfter;
4934
+ }
4935
+ if (pageCount >= maxPages) {
4936
+ this._clientLog.warn(`group.v2.pull reached max_pages=${maxPages} group=${gid} after_seq=${nextAfterSeq}`);
3931
4937
  }
3932
4938
  return decrypted;
3933
4939
  }
@@ -3942,9 +4948,15 @@ export class AUNClient {
3942
4948
  if (!gid)
3943
4949
  throw new ValidationError('group.ack_messages requires group_id');
3944
4950
  const ns = `group:${gid}`;
3945
- const seq = upToSeq ?? this._seqTracker.getContiguousSeq(ns);
4951
+ let seq = upToSeq ?? this._seqTracker.getContiguousSeq(ns);
3946
4952
  if (seq <= 0)
3947
4953
  return { acked: 0 };
4954
+ // ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
4955
+ const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
4956
+ if (maxSeen > 0 && seq > maxSeen) {
4957
+ this._clientLog.warn(`ackGroupV2 clamp: group=${gid} up_to_seq=${seq} > max_seen=${maxSeen}, clamp`);
4958
+ seq = maxSeen;
4959
+ }
3948
4960
  return this.call('group.v2.ack', { group_id: gid, up_to_seq: seq });
3949
4961
  }
3950
4962
  // ── V2 thought(per-device wrap,服务端透传,不持久化)──────────
@@ -3962,20 +4974,27 @@ export class AUNClient {
3962
4974
  const useCache = opts.useCache !== false;
3963
4975
  let peerDevices = [];
3964
4976
  let auditRaw = [];
4977
+ let wrapPolicy;
3965
4978
  const cached = useCache ? this._v2BootstrapCache.get(to) : undefined;
3966
4979
  if (cached && (Date.now() - cached.cachedAt) < AUNClient.V2_BOOTSTRAP_TTL_MS) {
3967
4980
  peerDevices = cached.devices;
3968
4981
  auditRaw = cached.auditRecipients;
4982
+ wrapPolicy = cached.wrapPolicy;
3969
4983
  }
3970
4984
  else {
3971
- const bs = await this.call('message.v2.bootstrap', { peer_aid: to });
4985
+ const bs = await this.call('message.v2.bootstrap', {
4986
+ peer_aid: to,
4987
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
4988
+ });
3972
4989
  peerDevices = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
3973
4990
  auditRaw = (Array.isArray(bs?.audit_recipients) ? bs.audit_recipients : []);
4991
+ wrapPolicy = normalizeV2WrapPolicy(bs?.e2ee_wrap_policy);
3974
4992
  if (peerDevices.length > 0) {
3975
4993
  this._v2BootstrapCache.set(to, {
3976
4994
  devices: peerDevices,
3977
4995
  auditRecipients: auditRaw,
3978
4996
  cachedAt: Date.now(),
4997
+ wrapPolicy,
3979
4998
  });
3980
4999
  }
3981
5000
  }
@@ -3984,33 +5003,27 @@ export class AUNClient {
3984
5003
  }
3985
5004
  const targets = [];
3986
5005
  for (const dev of peerDevices) {
3987
- const ikDer = _v2B64ToBytes(String(dev.ik_pk ?? ''));
3988
- const spkDer = dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined;
3989
- session.cachePeerIK(to, String(dev.device_id ?? ''), ikDer);
3990
- targets.push({
5006
+ const devId = getV2DeviceId(dev);
5007
+ const target = await this._v2BuildTargetFromDevice({
5008
+ dev,
3991
5009
  aid: to,
3992
- deviceId: String(dev.device_id ?? ''),
5010
+ deviceId: devId.value,
3993
5011
  role: 'peer',
3994
- keySource: String(dev.key_source ?? 'peer_device_prekey'),
3995
- ikPkDer: ikDer,
3996
- spkPkDer: spkDer,
3997
- spkId: String(dev.spk_id ?? ''),
5012
+ defaultKeySource: 'peer_device_prekey',
3998
5013
  });
5014
+ if (target)
5015
+ targets.push(target);
3999
5016
  }
4000
5017
  for (const dev of auditRaw) {
4001
- if (!dev.ik_pk)
4002
- continue;
4003
- const ikDer = _v2B64ToBytes(String(dev.ik_pk));
4004
- const spkDer = dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined;
4005
- targets.push({
5018
+ const target = await this._v2BuildTargetFromDevice({
5019
+ dev,
4006
5020
  aid: String(dev.aid ?? ''),
4007
5021
  deviceId: String(dev.device_id ?? ''),
4008
5022
  role: 'audit',
4009
- keySource: String(dev.key_source ?? 'peer_device_prekey'),
4010
- ikPkDer: ikDer,
4011
- spkPkDer: spkDer,
4012
- spkId: String(dev.spk_id ?? ''),
5023
+ defaultKeySource: 'peer_device_prekey',
4013
5024
  });
5025
+ if (target)
5026
+ targets.push(target);
4014
5027
  }
4015
5028
  // self-sync:自己其它设备
4016
5029
  if (this._aid && this._aid !== to) {
@@ -4021,7 +5034,10 @@ export class AUNClient {
4021
5034
  selfDevices = selfCached.devices;
4022
5035
  }
4023
5036
  else {
4024
- const selfBs = await this.call('message.v2.bootstrap', { peer_aid: this._aid });
5037
+ const selfBs = await this.call('message.v2.bootstrap', {
5038
+ peer_aid: this._aid,
5039
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
5040
+ });
4025
5041
  selfDevices = (Array.isArray(selfBs?.peer_devices) ? selfBs.peer_devices : []);
4026
5042
  if (selfDevices.length > 0) {
4027
5043
  this._v2BootstrapCache.set(this._aid, {
@@ -4032,20 +5048,18 @@ export class AUNClient {
4032
5048
  }
4033
5049
  }
4034
5050
  for (const dev of selfDevices) {
4035
- const devId = String(dev.owner_device_id ?? dev.device_id ?? '');
4036
- if (devId === this._deviceId)
5051
+ const devId = getV2DeviceId(dev);
5052
+ if (!devId.present || devId.value === this._deviceId)
4037
5053
  continue;
4038
- const ikDer = _v2B64ToBytes(String(dev.ik_pk ?? ''));
4039
- const spkDer = dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined;
4040
- targets.push({
5054
+ const target = await this._v2BuildTargetFromDevice({
5055
+ dev,
4041
5056
  aid: this._aid,
4042
- deviceId: devId,
5057
+ deviceId: devId.value,
4043
5058
  role: 'self_sync',
4044
- keySource: String(dev.key_source ?? 'peer_device_prekey'),
4045
- ikPkDer: ikDer,
4046
- spkPkDer: spkDer,
4047
- spkId: String(dev.spk_id ?? ''),
5059
+ defaultKeySource: 'peer_device_prekey',
4048
5060
  });
5061
+ if (target)
5062
+ targets.push(target);
4049
5063
  }
4050
5064
  }
4051
5065
  catch (exc) {
@@ -4053,7 +5067,8 @@ export class AUNClient {
4053
5067
  }
4054
5068
  }
4055
5069
  const sender = await session.getSenderIdentity();
4056
- const envelope = await encryptP2PMessage(sender, { targets, auditRecipients: [] }, opts.payload, { messageId: opts.messageId, timestamp: opts.timestamp, protectedHeaders: opts.protectedHeaders, context: opts.context });
5070
+ const sendTargets = applyV2WrapPolicyToTargets(targets, wrapPolicy);
5071
+ const envelope = await encryptP2PMessage(sender, { targets: sendTargets, auditRecipients: [] }, opts.payload, { messageId: opts.messageId, timestamp: opts.timestamp, protectedHeaders: opts.protectedHeaders, context: opts.context });
4057
5072
  return envelope;
4058
5073
  }
4059
5074
  /**
@@ -4127,18 +5142,27 @@ export class AUNClient {
4127
5142
  let epoch = 0;
4128
5143
  let stateCommitment = { state_version: 0, state_hash: '', state_chain: '' };
4129
5144
  let auditRecipientsRaw = [];
5145
+ let wrapPolicy;
4130
5146
  const cached = useCache ? this._v2BootstrapCache.get(cacheKey) : undefined;
4131
5147
  if (cached && (Date.now() - cached.cachedAt) < AUNClient.V2_BOOTSTRAP_TTL_MS) {
4132
5148
  allDevices = cached.devices;
4133
5149
  epoch = cached.epoch ?? 0;
4134
5150
  stateCommitment = cached.stateCommitment ?? { state_version: 0, state_hash: '', state_chain: '' };
4135
5151
  auditRecipientsRaw = cached.auditRecipients;
5152
+ wrapPolicy = cached.wrapPolicy;
4136
5153
  }
4137
5154
  else {
4138
- const bs = await this.call('group.v2.bootstrap', { group_id: groupId });
5155
+ const bs = await this.call('group.v2.bootstrap', {
5156
+ group_id: groupId,
5157
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
5158
+ });
4139
5159
  allDevices = (Array.isArray(bs?.devices) ? bs.devices : []);
4140
5160
  epoch = Number(bs?.epoch ?? 0);
4141
5161
  auditRecipientsRaw = (Array.isArray(bs?.audit_recipients) ? bs.audit_recipients : []);
5162
+ wrapPolicy = normalizeV2WrapPolicy(bs?.e2ee_wrap_policy);
5163
+ await this._v2CheckFork(groupId, String(bs?.state_chain ?? ''));
5164
+ await this._v2VerifyStateSignature(groupId, bs);
5165
+ await this._publishV2GroupSecurityLevel(groupId, bs);
4142
5166
  stateCommitment = {
4143
5167
  state_version: Number(bs?.state_version ?? 0) || 0,
4144
5168
  state_hash: String(bs?.state_hash_signed ?? bs?.state_hash ?? ''),
@@ -4151,11 +5175,9 @@ export class AUNClient {
4151
5175
  cachedAt: Date.now(),
4152
5176
  epoch,
4153
5177
  stateCommitment: stateCommitment,
5178
+ wrapPolicy,
4154
5179
  });
4155
5180
  }
4156
- await this._v2CheckFork(groupId, String(bs?.state_chain ?? ''));
4157
- await this._v2VerifyStateSignature(groupId, bs);
4158
- await this._publishV2GroupSecurityLevel(groupId, bs);
4159
5181
  // lazy sync 触发:发现 pending members 时异步发起提案
4160
5182
  const pendingAdds = Array.isArray(bs?.pending_adds) ? bs.pending_adds : [];
4161
5183
  if (pendingAdds.length > 0 && this._v2Session) {
@@ -4168,42 +5190,37 @@ export class AUNClient {
4168
5190
  const targets = [];
4169
5191
  for (const dev of allDevices) {
4170
5192
  const devAid = String(dev.aid ?? '');
4171
- const devId = String(dev.device_id ?? '');
4172
- if (devAid === this._aid && devId === this._deviceId)
5193
+ const devId = getV2DeviceId(dev);
5194
+ if (devAid === this._aid && devId.present && devId.value === this._deviceId)
4173
5195
  continue;
4174
- const ikDer = _v2B64ToBytes(String(dev.ik_pk ?? ''));
4175
- const spkDer = dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined;
4176
5196
  const role = devAid === this._aid ? 'self_sync' : 'member';
4177
- targets.push({
5197
+ const target = await this._v2BuildTargetFromDevice({
5198
+ dev,
4178
5199
  aid: devAid,
4179
- deviceId: devId,
5200
+ deviceId: devId.value,
4180
5201
  role,
4181
- keySource: String(dev.key_source ?? 'peer_device_prekey'),
4182
- ikPkDer: ikDer,
4183
- spkPkDer: spkDer,
4184
- spkId: String(dev.spk_id ?? ''),
5202
+ defaultKeySource: 'peer_device_prekey',
4185
5203
  });
5204
+ if (target)
5205
+ targets.push(target);
4186
5206
  }
4187
5207
  for (const dev of auditRecipientsRaw) {
4188
- if (!dev.ik_pk)
4189
- continue;
4190
- const ikDer = _v2B64ToBytes(String(dev.ik_pk));
4191
- const spkDer = dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined;
4192
- targets.push({
5208
+ const target = await this._v2BuildTargetFromDevice({
5209
+ dev,
4193
5210
  aid: String(dev.aid ?? ''),
4194
5211
  deviceId: String(dev.device_id ?? ''),
4195
5212
  role: 'audit',
4196
- keySource: String(dev.key_source ?? 'peer_device_prekey'),
4197
- ikPkDer: ikDer,
4198
- spkPkDer: spkDer,
4199
- spkId: String(dev.spk_id ?? ''),
5213
+ defaultKeySource: 'peer_device_prekey',
4200
5214
  });
5215
+ if (target)
5216
+ targets.push(target);
4201
5217
  }
4202
5218
  if (targets.length === 0) {
4203
5219
  throw new E2EEError(`V2 group: no target devices for ${groupId}`);
4204
5220
  }
4205
5221
  const sender = await session.getSenderIdentity();
4206
- const envelope = await encryptGroupMessage(sender, groupId, epoch, targets, opts.payload, { messageId: opts.messageId, timestamp: opts.timestamp, protectedHeaders: opts.protectedHeaders, context: opts.context }, stateCommitment);
5222
+ const sendTargets = applyV2WrapPolicyToTargets(targets, wrapPolicy);
5223
+ const envelope = await encryptGroupMessage(sender, groupId, epoch, sendTargets, opts.payload, { messageId: opts.messageId, timestamp: opts.timestamp, protectedHeaders: opts.protectedHeaders, context: opts.context }, stateCommitment);
4207
5224
  return envelope;
4208
5225
  }
4209
5226
  async _publishV2GroupSecurityLevel(groupId, bootstrap) {
@@ -4286,7 +5303,7 @@ export class AUNClient {
4286
5303
  if (Array.isArray(recipients)) {
4287
5304
  for (const row of recipients) {
4288
5305
  if (Array.isArray(row) && row.length >= 6) {
4289
- if (row[0] === this._aid && row[1] === this._deviceId) {
5306
+ if (row[0] === this._aid && (row[1] === this._deviceId || row[1] === '')) {
4290
5307
  spkId = String(row[5] ?? '');
4291
5308
  recipientKeySource = row.length > 3 ? String(row[3] ?? '') : '';
4292
5309
  break;
@@ -4294,26 +5311,33 @@ export class AUNClient {
4294
5311
  }
4295
5312
  }
4296
5313
  }
4297
- // 根据 aad.group_id 判断使用 group SPK 还是 P2P SPK
4298
5314
  const aad = opts.envelope.aad ?? {};
4299
5315
  const groupIdForKeys = String(aad.group_id ?? opts.envelope.group_id ?? '').trim();
4300
5316
  let ikPriv;
4301
5317
  let spkPriv;
4302
- if (groupIdForKeys) {
4303
- const keys = await session.getGroupDecryptKeys(groupIdForKeys, spkId);
4304
- ikPriv = keys.ikPriv;
4305
- spkPriv = keys.spkPriv;
5318
+ // group_id 只表示群上下文;group lookup 内部按 group SPK -> P2P device SPK -> IK fallback。
5319
+ try {
5320
+ if (groupIdForKeys) {
5321
+ const keys = await session.getGroupDecryptKeys(groupIdForKeys, spkId);
5322
+ ikPriv = keys.ikPriv;
5323
+ spkPriv = keys.spkPriv;
5324
+ }
5325
+ else {
5326
+ const keys = await session.getDecryptKeys(spkId);
5327
+ ikPriv = keys.ikPriv;
5328
+ spkPriv = keys.spkPriv;
5329
+ }
4306
5330
  }
4307
- else {
4308
- const keys = await session.getDecryptKeys(spkId);
4309
- ikPriv = keys.ikPriv;
4310
- spkPriv = keys.spkPriv;
5331
+ catch (exc) {
5332
+ this._clientLog.warn(`V2 thought decrypt: SPK lookup failed from=${opts.fromAid}, group=${groupIdForKeys || '<p2p>'}, spk_id=${spkId || '<empty>'}: ${exc}`);
5333
+ return null;
4311
5334
  }
4312
5335
  const fromAid = String(opts.fromAid || aad.from || '').trim();
4313
5336
  const senderDeviceId = String(aad.from_device ?? '');
4314
5337
  const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId);
4315
5338
  if (!senderPubDer) {
4316
5339
  this._clientLog.warn(`V2 thought decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
5340
+ this._scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupIdForKeys);
4317
5341
  return null;
4318
5342
  }
4319
5343
  try {
@@ -4367,11 +5391,8 @@ export class AUNClient {
4367
5391
  const signPayloadBytes = new TextEncoder().encode(signPayload);
4368
5392
  const sigBytes = base64ToUint8(stateSignature);
4369
5393
  // 验签缓存检查
4370
- const cacheInput = new TextEncoder().encode(actorAid + '\x00' + signPayload + '\x00');
4371
- const cacheData = new Uint8Array(cacheInput.length + sigBytes.length);
4372
- cacheData.set(cacheInput, 0);
4373
- cacheData.set(sigBytes, cacheInput.length);
4374
- const cacheHashBuf = await crypto.subtle.digest('SHA-256', cacheData.buffer);
5394
+ const cacheData = _v2LengthPrefixedBytes(new TextEncoder().encode(actorAid), signPayloadBytes, sigBytes);
5395
+ const cacheHashBuf = await crypto.subtle.digest('SHA-256', cacheData.slice().buffer);
4375
5396
  const cacheHashArr = new Uint8Array(cacheHashBuf);
4376
5397
  let cacheKey = '';
4377
5398
  for (let i = 0; i < cacheHashArr.length; i++)
@@ -4594,13 +5615,17 @@ export class AUNClient {
4594
5615
  }
4595
5616
  if (myRole !== 'owner' && myRole !== 'admin')
4596
5617
  return false;
4597
- const bootstrapResp = await this.call('group.v2.bootstrap', { group_id: groupId });
5618
+ const bootstrapResp = await this.call('group.v2.bootstrap', {
5619
+ group_id: groupId,
5620
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
5621
+ });
4598
5622
  const devices = (Array.isArray(bootstrapResp?.devices) ? bootstrapResp.devices : []);
4599
5623
  const candidates = [];
4600
5624
  for (const dev of devices) {
4601
5625
  const aid = String(dev.aid ?? '').trim();
5626
+ const hasDeviceId = 'device_id' in dev;
4602
5627
  const deviceId = String(dev.device_id ?? '').trim();
4603
- if (aid && deviceId && onlineAdminAids.has(aid)) {
5628
+ if (aid && hasDeviceId && onlineAdminAids.has(aid)) {
4604
5629
  candidates.push(`${aid}\x1f${deviceId}`);
4605
5630
  }
4606
5631
  }
@@ -4616,7 +5641,7 @@ export class AUNClient {
4616
5641
  this._clientLog.debug(`V2 auto propose leader elected: group=${groupId} leader=${leader}`);
4617
5642
  return true;
4618
5643
  }
4619
- const delayMs = await this._v2LeaderDelayMs(`${groupId}\x00${myKey}`);
5644
+ const delayMs = await this._v2LeaderDelayMs(_v2LengthPrefixedTextKey(groupId, myKey));
4620
5645
  this._clientLog.debug(`V2 auto propose non-leader delay: group=${groupId} leader=${leader} self=${myKey} delay_ms=${delayMs}`);
4621
5646
  await this._sleep(delayMs);
4622
5647
  return true;
@@ -4695,7 +5720,10 @@ export class AUNClient {
4695
5720
  }
4696
5721
  }
4697
5722
  // 获取群所有成员的设备列表(V2 bootstrap)
4698
- const bootstrapResp = await this.call('group.v2.bootstrap', { group_id: groupId });
5723
+ const bootstrapResp = await this.call('group.v2.bootstrap', {
5724
+ group_id: groupId,
5725
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
5726
+ });
4699
5727
  const allDevices = (Array.isArray(bootstrapResp?.devices) ? bootstrapResp.devices : []);
4700
5728
  const auditRecipients = (Array.isArray(bootstrapResp?.audit_recipients) ? bootstrapResp.audit_recipients : []);
4701
5729
  const auditAidsList = [...new Set(auditRecipients.map(r => String(r.aid ?? '').trim()).filter(Boolean))].sort();
@@ -4929,30 +5957,42 @@ export class AUNClient {
4929
5957
  const pushFrom = isJsonObject(data) ? String(data.from_aid ?? '') : '';
4930
5958
  const pushMsgId = isJsonObject(data) ? String(data.message_id ?? '') : '';
4931
5959
  const envelopeJson = isJsonObject(data) ? data.envelope_json : undefined;
5960
+ const hasPayload = !!envelopeJson;
4932
5961
  const ns = this._aid ? `p2p:${this._aid}` : '';
4933
5962
  let contigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
4934
- this._clientLog.debug(`_onV2PushNotification: push_seq=${pushSeq || 'null'} push_from=${pushFrom} push_msg_id=${pushMsgId} has_payload=${!!envelopeJson} contiguous_seq=${contigBefore}`);
5963
+ this._clientLog.debug(`_onV2PushNotification: push_seq=${pushSeq || 'null'} push_from=${pushFrom} push_msg_id=${pushMsgId} has_payload=${hasPayload} contiguous_seq=${contigBefore}`);
5964
+ // ── Push 修上界:只更新 maxSeenSeq,不动 contiguousSeq ──
5965
+ if (pushSeq > 0 && ns) {
5966
+ this._seqTracker.updateMaxSeen(ns, pushSeq);
5967
+ if (contigBefore === pushSeq) {
5968
+ this._clientLog.debug(`_onV2PushNotification: push seq=${pushSeq} already covered by contiguous_seq=${contigBefore}, ignore duplicate push`);
5969
+ return;
5970
+ }
5971
+ contigBefore = this._repairPushContiguousBound(ns, pushSeq, hasPayload, '_raw.peer.v2.message_received');
5972
+ }
4935
5973
  // ── 带 payload 的 push:尝试就地解密 ──
4936
- if (envelopeJson && pushSeq > 0 && ns) {
5974
+ if (hasPayload && pushSeq > 0 && ns) {
4937
5975
  try {
4938
5976
  const decrypted = await this._decryptV2Message(data);
4939
5977
  if (decrypted) {
4940
- // 解密成功:contiguous_seq 上界 = push_seq
4941
- this._seqTracker.onMessageSeq(ns, pushSeq);
4942
- if (pushSeq === contigBefore + 1) {
4943
- this._seqTracker.forceContiguousSeq(ns, pushSeq);
4944
- }
4945
- await this._publishOrderedMessage('message.received', ns, pushSeq, decrypted);
5978
+ // 解密成功:把 pushSeq 加入 receivedSeqs,让 _tryAdvance 自然推进
5979
+ const needPull = this._seqTracker.onMessageSeq(ns, pushSeq);
5980
+ const published = await this._publishOrderedMessage('message.received', ns, pushSeq, decrypted);
4946
5981
  const newContig = this._seqTracker.getContiguousSeq(ns);
4947
5982
  if (newContig !== contigBefore) {
4948
5983
  this._saveSeqTrackerState();
4949
5984
  }
4950
5985
  if (newContig > 0 && newContig !== contigBefore) {
4951
- this._transport.call('message.v2.ack', { up_to_seq: newContig })
5986
+ const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
5987
+ const ackSeq = maxSeen > 0 ? Math.min(newContig, maxSeen) : newContig;
5988
+ this.call('message.v2.ack', { up_to_seq: ackSeq })
4952
5989
  .catch(e => this._clientLog.debug(`V2 P2P push-ack failed: ${e}`));
4953
5990
  }
4954
5991
  this._clientLog.debug(`_onV2PushNotification: push 带 payload 解密成功, contiguous_seq=${contigBefore}->${newContig} push_seq=${pushSeq}`);
4955
- return;
5992
+ if (!needPull && (published || newContig >= pushSeq || pushSeq <= contigBefore)) {
5993
+ return;
5994
+ }
5995
+ this._clientLog.debug(`_onV2PushNotification: payload push seq=${pushSeq} 因空洞挂起,继续 pull 补齐 after_seq=${newContig}`);
4956
5996
  }
4957
5997
  }
4958
5998
  catch (exc) {
@@ -4966,14 +6006,6 @@ export class AUNClient {
4966
6006
  // 正确做法:保持 contiguousSeq 不变,用它作为 pull 的 after_seq;
4967
6007
  // pull 成功 + 解密成功后再由 pull 路径推进 contiguousSeq。
4968
6008
  if (pushSeq > 0 && ns) {
4969
- // 越界修复:如果 contiguousSeq >= pushSeq(被之前的异常 push 污染,或恰好等于),
4970
- // 强制拉回到 pushSeq - 1,确保能拉到 pushSeq 这条消息
4971
- if (contigBefore >= pushSeq) {
4972
- this._clientLog.warn(`_onV2PushNotification: contiguous_seq=${contigBefore} 越界(>= push_seq=${pushSeq}),强制修复为 ${pushSeq - 1}`);
4973
- this._seqTracker.forceContiguousSeq(ns, pushSeq - 1);
4974
- this._saveSeqTrackerState(); // 持久化修复后的状态
4975
- contigBefore = pushSeq - 1;
4976
- }
4977
6009
  // 纯通知:不更新 contiguousSeq,由 pull 结果驱动推进
4978
6010
  this._clientLog.debug(`_onV2PushNotification: 纯通知 push_seq=${pushSeq} > contiguous_seq=${contigBefore}, 触发 pull(after_seq=${contigBefore})`);
4979
6011
  }
@@ -5035,6 +6067,66 @@ export class AUNClient {
5035
6067
  this._clientLog.warn(`background task exception:${String(exc)}`);
5036
6068
  });
5037
6069
  }
6070
+ // ── Pull Gate(序列化同一 key 的并发 pull)──────────────────
6071
+ _pullGateKeyForCall(method, params) {
6072
+ if (method === 'message.pull' || method === 'message.v2.pull') {
6073
+ return this._aid ? `p2p:${this._aid}` : '';
6074
+ }
6075
+ if (method === 'group.pull' || method === 'group.v2.pull') {
6076
+ const gid = String(params.group_id ?? '').trim();
6077
+ return gid ? `group:${gid}` : '';
6078
+ }
6079
+ if (method === 'group.pull_events') {
6080
+ const gid = String(params.group_id ?? '').trim();
6081
+ return gid ? `group_event:${gid}` : '';
6082
+ }
6083
+ return '';
6084
+ }
6085
+ _tryAcquirePullGate(key) {
6086
+ if (!key)
6087
+ return 0;
6088
+ const now = Date.now();
6089
+ const gate = this._pullGates.get(key) ?? { inflight: false, startedAt: 0, token: 0 };
6090
+ if (gate.inflight && now - gate.startedAt <= AUNClient._PULL_GATE_STALE_MS) {
6091
+ return null;
6092
+ }
6093
+ if (gate.inflight) {
6094
+ this._clientLog.warn(`pull in-flight stale reset: key=${key} age=${now - gate.startedAt}ms`);
6095
+ }
6096
+ gate.token += 1;
6097
+ gate.inflight = true;
6098
+ gate.startedAt = now;
6099
+ this._pullGates.set(key, gate);
6100
+ return gate.token;
6101
+ }
6102
+ _releasePullGate(key, token) {
6103
+ if (!key || token == null)
6104
+ return;
6105
+ const gate = this._pullGates.get(key);
6106
+ if (!gate || gate.token !== token)
6107
+ return;
6108
+ gate.inflight = false;
6109
+ gate.startedAt = 0;
6110
+ }
6111
+ async _runPullSerialized(key, operation) {
6112
+ let token = this._tryAcquirePullGate(key);
6113
+ if (token === null) {
6114
+ const deadline = Date.now() + AUNClient._PULL_GATE_STALE_MS + 100;
6115
+ while (token === null && Date.now() <= deadline) {
6116
+ await this._sleep(25);
6117
+ token = this._tryAcquirePullGate(key);
6118
+ }
6119
+ if (token === null) {
6120
+ throw new StateError(`pull already in-flight for ${key}`);
6121
+ }
6122
+ }
6123
+ try {
6124
+ return await operation();
6125
+ }
6126
+ finally {
6127
+ this._releasePullGate(key, token);
6128
+ }
6129
+ }
5038
6130
  /** 可取消的 sleep */
5039
6131
  _sleep(ms) {
5040
6132
  return new Promise((resolve) => {