@agentunion/fastaun-browser 0.3.2 → 0.3.3

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 (80) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/_packed_docs/CHANGELOG.md +19 -0
  3. package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +48 -15
  4. package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +182 -28
  5. package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +7 -5
  6. package/_packed_docs/sdk/INDEX.md +17 -12
  7. package/dist/auth.d.ts.map +1 -1
  8. package/dist/auth.js +1 -4
  9. package/dist/auth.js.map +1 -1
  10. package/dist/bundle.js +2093 -602
  11. package/dist/client.d.ts +64 -7
  12. package/dist/client.d.ts.map +1 -1
  13. package/dist/client.js +1441 -476
  14. package/dist/client.js.map +1 -1
  15. package/dist/crypto.d.ts.map +1 -1
  16. package/dist/crypto.js +45 -31
  17. package/dist/crypto.js.map +1 -1
  18. package/dist/discovery.d.ts +4 -0
  19. package/dist/discovery.d.ts.map +1 -1
  20. package/dist/discovery.js +16 -11
  21. package/dist/discovery.js.map +1 -1
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/keystore/index.d.ts +22 -0
  27. package/dist/keystore/index.d.ts.map +1 -1
  28. package/dist/keystore/indexeddb.d.ts +4 -1
  29. package/dist/keystore/indexeddb.d.ts.map +1 -1
  30. package/dist/keystore/indexeddb.js +104 -1
  31. package/dist/keystore/indexeddb.js.map +1 -1
  32. package/dist/logger.d.ts +5 -1
  33. package/dist/logger.d.ts.map +1 -1
  34. package/dist/logger.js +8 -2
  35. package/dist/logger.js.map +1 -1
  36. package/dist/namespaces/auth.d.ts +1 -0
  37. package/dist/namespaces/auth.d.ts.map +1 -1
  38. package/dist/namespaces/auth.js +38 -0
  39. package/dist/namespaces/auth.js.map +1 -1
  40. package/dist/seq-tracker.d.ts +5 -3
  41. package/dist/seq-tracker.d.ts.map +1 -1
  42. package/dist/seq-tracker.js +30 -3
  43. package/dist/seq-tracker.js.map +1 -1
  44. package/dist/transport.d.ts.map +1 -1
  45. package/dist/transport.js +18 -0
  46. package/dist/transport.js.map +1 -1
  47. package/dist/v2/crypto/canonical.d.ts +1 -1
  48. package/dist/v2/crypto/canonical.d.ts.map +1 -1
  49. package/dist/v2/crypto/canonical.js +42 -13
  50. package/dist/v2/crypto/canonical.js.map +1 -1
  51. package/dist/v2/crypto/ecdh.d.ts.map +1 -1
  52. package/dist/v2/crypto/ecdh.js +18 -1
  53. package/dist/v2/crypto/ecdh.js.map +1 -1
  54. package/dist/v2/e2ee/decrypt.d.ts.map +1 -1
  55. package/dist/v2/e2ee/decrypt.js +56 -2
  56. package/dist/v2/e2ee/decrypt.js.map +1 -1
  57. package/dist/v2/e2ee/encrypt-group.d.ts.map +1 -1
  58. package/dist/v2/e2ee/encrypt-group.js +16 -6
  59. package/dist/v2/e2ee/encrypt-group.js.map +1 -1
  60. package/dist/v2/e2ee/encrypt-p2p.d.ts.map +1 -1
  61. package/dist/v2/e2ee/encrypt-p2p.js +39 -11
  62. package/dist/v2/e2ee/encrypt-p2p.js.map +1 -1
  63. package/dist/v2/e2ee/metadata-auth.d.ts +1 -0
  64. package/dist/v2/e2ee/metadata-auth.d.ts.map +1 -1
  65. package/dist/v2/e2ee/metadata-auth.js +51 -0
  66. package/dist/v2/e2ee/metadata-auth.js.map +1 -1
  67. package/dist/v2/e2ee/types.d.ts +2 -2
  68. package/dist/v2/e2ee/types.d.ts.map +1 -1
  69. package/dist/v2/session/keystore.d.ts +12 -4
  70. package/dist/v2/session/keystore.d.ts.map +1 -1
  71. package/dist/v2/session/keystore.js +177 -35
  72. package/dist/v2/session/keystore.js.map +1 -1
  73. package/dist/v2/session/session.d.ts +10 -3
  74. package/dist/v2/session/session.d.ts.map +1 -1
  75. package/dist/v2/session/session.js +91 -17
  76. package/dist/v2/session/session.js.map +1 -1
  77. package/dist/v2/state/commitment.d.ts.map +1 -1
  78. package/dist/v2/state/commitment.js +4 -1
  79. package/dist/v2/state/commitment.js.map +1 -1
  80. package/package.json +1 -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',
@@ -269,6 +288,43 @@ function _v2B64ToBytes(s) {
269
288
  out[i] = bin.charCodeAt(i);
270
289
  return out;
271
290
  }
291
+ function _v2B64ToBytesStrict(s) {
292
+ const text = String(s ?? '').trim();
293
+ if (!text || text.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(text)) {
294
+ throw new Error('invalid base64');
295
+ }
296
+ return _v2B64ToBytes(text);
297
+ }
298
+ function _v2BytesEqual(a, b) {
299
+ if (a.length !== b.length)
300
+ return false;
301
+ let diff = 0;
302
+ for (let i = 0; i < a.length; i++)
303
+ diff |= a[i] ^ b[i];
304
+ return diff === 0;
305
+ }
306
+ function _v2ConcatBytes(...parts) {
307
+ const total = parts.reduce((sum, part) => sum + part.length, 0);
308
+ const out = new Uint8Array(total);
309
+ let offset = 0;
310
+ for (const part of parts) {
311
+ out.set(part, offset);
312
+ offset += part.length;
313
+ }
314
+ return out;
315
+ }
316
+ function _v2LengthPrefixedTextKey(...parts) {
317
+ const enc = new TextEncoder();
318
+ return parts.map((part) => `${enc.encode(part).length}:${part};`).join('');
319
+ }
320
+ function _v2LengthPrefixedBytes(...parts) {
321
+ const enc = new TextEncoder();
322
+ const framed = [];
323
+ for (const part of parts) {
324
+ framed.push(enc.encode(`${part.length}:`), part, enc.encode(';'));
325
+ }
326
+ return _v2ConcatBytes(...framed);
327
+ }
272
328
  /** Base64URL → Uint8Array(兼容缺失 padding) */
273
329
  function _v2B64uToBytes(s) {
274
330
  const std = s.replace(/-/g, '+').replace(/_/g, '/');
@@ -281,12 +337,74 @@ function formatCaughtError(error) {
281
337
  function v2E2eeMeta(envelope) {
282
338
  const suite = String(envelope.suite ?? '');
283
339
  const modeSuite = String(envelope.suite ?? 'unknown');
284
- return {
340
+ const meta = {
285
341
  version: 'v2',
286
342
  suite,
287
343
  encryption_mode: `v2_${modeSuite}`,
288
344
  forward_secrecy: true,
289
345
  };
346
+ const protectedHeaders = metadataWithoutAuth(envelope.protected_headers);
347
+ if (protectedHeaders && Object.keys(protectedHeaders).length > 0) {
348
+ meta.protected_headers = protectedHeaders;
349
+ }
350
+ const payloadType = String(envelope.payload_type ?? protectedHeaders?.payload_type ?? '').trim();
351
+ if (payloadType) {
352
+ meta.payload_type = payloadType;
353
+ }
354
+ const context = metadataWithoutAuth(envelope.context);
355
+ if (context && Object.keys(context).length > 0) {
356
+ meta.context = context;
357
+ }
358
+ const agentMd = metadataWithoutAuth(envelope.agent_md);
359
+ if (agentMd && Object.keys(agentMd).length > 0) {
360
+ meta.agent_md = agentMd;
361
+ }
362
+ return meta;
363
+ }
364
+ function attachV2EnvelopeMetadata(message, meta) {
365
+ if (!meta)
366
+ return;
367
+ const payloadType = typeof meta.payload_type === 'string' ? meta.payload_type.trim() : '';
368
+ if (payloadType)
369
+ message.payload_type = payloadType;
370
+ if (isJsonObject(meta.protected_headers)) {
371
+ message.protected_headers = { ...meta.protected_headers };
372
+ }
373
+ if (isJsonObject(meta.agent_md)) {
374
+ message.agent_md = { ...meta.agent_md };
375
+ }
376
+ }
377
+ function attachV2EnvelopeMetadataFromSource(message, source) {
378
+ const envelope = extractV2EnvelopeFromSource(source);
379
+ if (envelope)
380
+ attachV2EnvelopeMetadata(message, v2E2eeMeta(envelope));
381
+ }
382
+ function extractV2EnvelopeFromSource(source) {
383
+ if (!isJsonObject(source))
384
+ return null;
385
+ if (isJsonObject(source.payload))
386
+ return source.payload;
387
+ if (typeof source.envelope_json === 'string' && source.envelope_json) {
388
+ try {
389
+ const parsed = JSON.parse(source.envelope_json);
390
+ if (isJsonObject(parsed))
391
+ return parsed;
392
+ }
393
+ catch {
394
+ return null;
395
+ }
396
+ }
397
+ return null;
398
+ }
399
+ function metadataWithoutAuth(value) {
400
+ if (!isJsonObject(value))
401
+ return null;
402
+ const body = {};
403
+ for (const [key, item] of Object.entries(value)) {
404
+ if (key !== '_auth')
405
+ body[key] = item;
406
+ }
407
+ return body;
290
408
  }
291
409
  function normalizeDeliveryModeConfig(raw, opts = {}) {
292
410
  const defaultMode = String(opts.defaultMode ?? 'fanout').trim().toLowerCase() || 'fanout';
@@ -379,11 +497,13 @@ export class AUNClient {
379
497
  _v2Session;
380
498
  _v2KeyStore;
381
499
  _v2BootstrapCache = new Map();
500
+ _v2SenderIKPending = new Map();
501
+ _v2SenderIKFetching = new Set();
382
502
  static V2_BOOTSTRAP_TTL_MS = 60 * 60 * 1000;
383
503
  static V2_RETRYABLE_CODES = new Set([-33011, -33012, -33050, -33052, -33054]);
384
504
  /** V2 state 签名验证缓存:cacheKey(hex) → expiry_unix_ms */
385
505
  _v2SigCache = new Map();
386
- static _V2_SIG_CACHE_TTL = 600_000; // 10 min
506
+ static _V2_SIG_CACHE_TTL = 60 * 60 * 1000;
387
507
  static _V2_SIG_CACHE_MAX = 16384;
388
508
  /** V2 state chain 本地记录:group_id → [state_version, chain_hash] */
389
509
  _v2StateChains = new Map();
@@ -405,6 +525,11 @@ export class AUNClient {
405
525
  _localAgentMdEtag = '';
406
526
  /** gateway 在 RPC envelope._meta.agent_md_etag 注入的服务端 etag;纯观察,无下游依赖。 */
407
527
  _remoteAgentMdEtag = '';
528
+ /** 浏览器侧 AgentMDs 逻辑根目录,正文映射到 IndexedDB 里的 {aid}/agent.md。 */
529
+ _agentMdPath = '';
530
+ _agentMdCache = new Map();
531
+ _agentMdFetchInflight = new Set();
532
+ _agentMdListLock = Promise.resolve();
408
533
  /** 消息序列号跟踪器(群消息 + P2P 空洞检测) */
409
534
  _seqTracker = new SeqTracker();
410
535
  _seqTrackerContext = null;
@@ -444,8 +569,11 @@ export class AUNClient {
444
569
  root_ca_path: this.configModel.rootCaPem,
445
570
  seed_password: this.configModel.seedPassword,
446
571
  };
572
+ this._agentMdPath = this._agentMdDefaultRoot();
573
+ this._deviceId = getDeviceId();
447
574
  // Logger 必须最早初始化(其他子模块构造时通过 logger 输出)
448
- this._logger = new AUNLogger({ debug: _debug });
575
+ this._logger = new AUNLogger({ debug: _debug, aunPath: this.configModel.aunPath });
576
+ this._logger.bindDeviceId(this._deviceId);
449
577
  this._clientLog = this._logger.for('aun_core.client');
450
578
  this._logAuth = this._logger.for('aun_core.auth');
451
579
  this._logTransport = this._logger.for('aun_core.transport');
@@ -456,7 +584,6 @@ export class AUNClient {
456
584
  this._dispatcher = new EventDispatcher();
457
585
  this._discovery = new GatewayDiscovery();
458
586
  this._keystore = new IndexedDBKeyStore();
459
- this._deviceId = getDeviceId();
460
587
  this._slotId = '';
461
588
  this._connectDeliveryMode = normalizeDeliveryModeConfig({ mode: 'fanout' });
462
589
  this._defaultConnectDeliveryMode = { ...this._connectDeliveryMode };
@@ -475,7 +602,11 @@ export class AUNClient {
475
602
  timeout: DEFAULT_SESSION_OPTIONS.timeouts.call,
476
603
  onDisconnect: (error, closeCode) => this._handleTransportDisconnect(error, closeCode),
477
604
  });
478
- this._transport.setMetaObserver((meta) => this._observeRpcMeta(meta));
605
+ this._transport.setMetaObserver((meta) => {
606
+ void this._observeRpcMeta(meta).catch((exc) => {
607
+ this._clientLog.debug(`agent.md meta observer skipped: ${String(exc)}`);
608
+ });
609
+ });
479
610
  this.auth = new AuthNamespace(this);
480
611
  this.custody = new CustodyNamespace(this);
481
612
  this.meta = new MetaNamespace(this);
@@ -548,30 +679,64 @@ export class AUNClient {
548
679
  get aid() {
549
680
  return this._aid;
550
681
  }
682
+ setAgentMdPath(root) {
683
+ const next = String(root ?? '').trim() || this._agentMdDefaultRoot();
684
+ this._agentMdPath = next;
685
+ this._agentMdCache.clear();
686
+ return next;
687
+ }
688
+ setAgentMDPath(root) {
689
+ return this.setAgentMdPath(root);
690
+ }
691
+ SetAgentMDPath(root) {
692
+ return this.setAgentMdPath(root);
693
+ }
551
694
  /**
552
- * 浏览器版本 publishAgentMd。接收 agent.md 文本(应用层用 `<input type=file>` 等读出文本传入),
553
- * 内部签名 上传 → 更新 _localAgentMdEtag(quoted sha256,与服务端一致)。
695
+ * 浏览器版本 publishAgentMd。默认从 {agentMdPath}/{self_aid}/agent.md 的等价 IndexedDB 正文读取,
696
+ * 然后签名、上传,并刷新 list.json 元数据。
554
697
  *
555
- * @throws ValidationError content 为空
698
+ * 兼容旧浏览器调用:传入 content 时会先写入等价正文,再从该正文发布。
556
699
  */
557
700
  async publishAgentMd(content) {
558
- const text = String(content ?? '');
559
- if (text.length === 0) {
560
- throw new ValidationError('publishAgentMd requires non-empty content');
701
+ const target = this._agentMdOwnerAid();
702
+ if (!target) {
703
+ throw new ValidationError('publishAgentMd requires local AID');
704
+ }
705
+ if (content !== undefined && content !== null) {
706
+ const text = String(content ?? '');
707
+ if (text.length === 0) {
708
+ throw new ValidationError('publishAgentMd requires non-empty content');
709
+ }
710
+ await this._saveAgentMdRecord(target, {
711
+ content: text,
712
+ local_etag: await this._agentMdContentEtag(text),
713
+ fetched_at: Date.now(),
714
+ });
561
715
  }
562
- const signed = await this.auth.signAgentMd(text);
716
+ const localContent = await this._readAgentMdContent(target);
717
+ if (localContent === null || localContent.length === 0) {
718
+ throw new ValidationError('publishAgentMd requires local agent.md content');
719
+ }
720
+ const signed = await this.auth.signAgentMd(localContent);
563
721
  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}"`;
722
+ this._localAgentMdEtag = await this._agentMdContentEtag(signed);
723
+ const remoteEtag = isJsonObject(result) ? String(result.etag ?? '').trim() : '';
724
+ if (remoteEtag)
725
+ this._remoteAgentMdEtag = remoteEtag;
726
+ await this._saveAgentMdRecord(target, {
727
+ content: signed,
728
+ local_etag: this._localAgentMdEtag,
729
+ remote_etag: remoteEtag || undefined,
730
+ last_modified: isJsonObject(result) ? String(result.last_modified ?? '').trim() : '',
731
+ fetched_at: Date.now(),
732
+ remote_status: remoteEtag ? 'found' : 'unknown',
733
+ last_error: '',
734
+ });
570
735
  return result;
571
736
  }
572
737
  /**
573
- * 浏览器版本 fetchAgentMd。aid 缺省时取自身;不接受 savePath(浏览器无文件系统);
574
- * aid 是自己则同步刷新 _localAgentMdEtag in_sync。
738
+ * 浏览器版本 fetchAgentMd。aid 缺省时取自身;下载后的正文固定写入
739
+ * {agentMdPath}/{aid}/agent.md 的等价 IndexedDB 正文,list.json 只保存元数据。
575
740
  */
576
741
  async fetchAgentMd(aid) {
577
742
  const target = String(aid ?? this._aid ?? '').trim();
@@ -581,17 +746,30 @@ export class AUNClient {
581
746
  const content = await this.auth.downloadAgentMd(target);
582
747
  const signature = await this.auth.verifyAgentMd(content, { aid: target });
583
748
  const isSelf = target === (this._aid ?? '');
749
+ const localEtag = await this._agentMdContentEtag(content);
750
+ const cacheMeta = this._agentMdAuthCacheMeta(target);
751
+ const remoteEtag = String(cacheMeta.etag ?? '').trim();
752
+ const lastModified = String(cacheMeta.lastModified ?? cacheMeta.last_modified ?? '').trim();
753
+ if (isSelf) {
754
+ this._localAgentMdEtag = localEtag;
755
+ if (remoteEtag)
756
+ this._remoteAgentMdEtag = remoteEtag;
757
+ }
758
+ await this._saveAgentMdRecord(target, {
759
+ content,
760
+ local_etag: localEtag,
761
+ remote_etag: remoteEtag || undefined,
762
+ last_modified: lastModified || undefined,
763
+ fetched_at: Date.now(),
764
+ remote_status: 'found',
765
+ verify_status: isJsonObject(signature) ? String(signature.status ?? '') : '',
766
+ verify_error: isJsonObject(signature) ? String(signature.reason ?? '') : '',
767
+ last_error: '',
768
+ });
584
769
  let in_sync = null;
585
770
  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;
771
+ const remote = remoteEtag || this._remoteAgentMdEtag || '';
772
+ in_sync = localEtag && remote ? localEtag === remote : false;
595
773
  }
596
774
  return {
597
775
  aid: target,
@@ -600,13 +778,434 @@ export class AUNClient {
600
778
  in_sync,
601
779
  };
602
780
  }
781
+ getLocalAgentMdEtag() {
782
+ return this._localAgentMdEtag;
783
+ }
784
+ getRemoteAgentMdEtag() {
785
+ return this._remoteAgentMdEtag;
786
+ }
787
+ async _agentMdContentEtag(content) {
788
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(String(content ?? '')));
789
+ const hex = Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
790
+ return `"${hex}"`;
791
+ }
792
+ _agentMdOwnerAid() {
793
+ return String(this._aid ?? '').trim();
794
+ }
795
+ _agentMdDefaultRoot() {
796
+ return this._joinAgentMdPath(this.configModel.aunPath || '.', 'AgentMDs');
797
+ }
798
+ _joinAgentMdPath(base, name) {
799
+ const left = String(base ?? '').trim().replace(/[\\/]+$/g, '');
800
+ return left ? `${left}/${name}` : name;
801
+ }
802
+ _agentMdRoot() {
803
+ return this._agentMdPath || this._agentMdDefaultRoot();
804
+ }
805
+ _agentMdSafeAid(aid) {
806
+ const target = String(aid ?? '').trim();
807
+ if (!target || target.includes('/') || target.includes('\\') || target.includes('\0')) {
808
+ throw new ValidationError('agent.md aid is empty or contains path separators');
809
+ }
810
+ return target;
811
+ }
812
+ _agentMdListKey() {
813
+ return 'list.json';
814
+ }
815
+ _agentMdContentKey(aid) {
816
+ return `${this._agentMdSafeAid(aid)}/agent.md`;
817
+ }
818
+ async _readAgentMdStorage(logicalKey) {
819
+ const key = String(logicalKey ?? '').trim();
820
+ if (!key)
821
+ return null;
822
+ const load = this._keystore.loadAgentMdCache;
823
+ if (typeof load !== 'function') {
824
+ throw new Error('IndexedDB agent.md storage unavailable');
825
+ }
826
+ const record = await load.call(this._keystore, this._agentMdRoot(), key);
827
+ if (record && Object.prototype.hasOwnProperty.call(record, 'content')) {
828
+ return String(record.content ?? '');
829
+ }
830
+ return null;
831
+ }
832
+ async _writeAgentMdStorage(logicalKey, content) {
833
+ const key = String(logicalKey ?? '').trim();
834
+ if (!key)
835
+ return;
836
+ const save = this._keystore.upsertAgentMdCache;
837
+ if (typeof save !== 'function') {
838
+ throw new Error('IndexedDB agent.md storage unavailable');
839
+ }
840
+ const text = String(content ?? '');
841
+ await save.call(this._keystore, this._agentMdRoot(), key, {
842
+ content: text,
843
+ local_etag: await this._agentMdContentEtag(text),
844
+ fetched_at: Date.now(),
845
+ });
846
+ }
847
+ async _listAgentMdContentAids() {
848
+ const list = this._keystore.listAgentMdContentAids;
849
+ if (typeof list !== 'function') {
850
+ throw new Error('IndexedDB agent.md storage unavailable');
851
+ }
852
+ return await list.call(this._keystore, this._agentMdRoot());
853
+ }
854
+ async _withAgentMdListLock(fn) {
855
+ const previous = this._agentMdListLock.catch(() => undefined);
856
+ let release;
857
+ const current = new Promise((resolve) => { release = resolve; });
858
+ this._agentMdListLock = previous.then(() => current);
859
+ await previous;
860
+ try {
861
+ return await fn();
862
+ }
863
+ finally {
864
+ release();
865
+ }
866
+ }
867
+ _normalizeAgentMdList(data) {
868
+ const records = {};
869
+ let iterable = [];
870
+ if (isJsonObject(data)) {
871
+ const payload = data;
872
+ if (Array.isArray(payload.records))
873
+ iterable = payload.records;
874
+ else if (isJsonObject(payload.records))
875
+ iterable = Object.values(payload.records);
876
+ }
877
+ else if (Array.isArray(data)) {
878
+ iterable = data;
879
+ }
880
+ for (const item of iterable) {
881
+ if (!isJsonObject(item))
882
+ continue;
883
+ const raw = item;
884
+ const aid = String(raw.aid ?? '').trim();
885
+ if (!aid)
886
+ continue;
887
+ const record = {};
888
+ for (const [key, value] of Object.entries(raw)) {
889
+ if (key !== 'content')
890
+ record[key] = value;
891
+ }
892
+ record.aid = aid;
893
+ for (const key of ['fetched_at', 'observed_at', 'checked_at', 'updated_at']) {
894
+ record[key] = Number(record[key] ?? 0) || 0;
895
+ }
896
+ records[aid] = record;
897
+ }
898
+ return records;
899
+ }
900
+ async _writeAgentMdListUnlocked(records) {
901
+ const sorted = {};
902
+ for (const aid of Object.keys(records).sort())
903
+ sorted[aid] = records[aid];
904
+ await this._writeAgentMdStorage(this._agentMdListKey(), `${JSON.stringify({ version: 1, updated_at: Date.now(), records: sorted }, null, 2)}\n`);
905
+ }
906
+ async _rebuildAgentMdListUnlocked() {
907
+ const records = {};
908
+ const now = Date.now();
909
+ for (const aid of await this._listAgentMdContentAids()) {
910
+ try {
911
+ const content = await this._readAgentMdStorage(this._agentMdContentKey(aid));
912
+ if (content === null)
913
+ continue;
914
+ records[aid] = {
915
+ aid,
916
+ local_etag: await this._agentMdContentEtag(content),
917
+ fetched_at: now,
918
+ updated_at: now,
919
+ };
920
+ }
921
+ catch (err) {
922
+ this._clientLog.warn(`agent.md rebuild skipped unreadable file aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
923
+ }
924
+ }
925
+ await this._writeAgentMdListUnlocked(records);
926
+ this._agentMdCache.clear();
927
+ return records;
928
+ }
929
+ async _readAgentMdListUnlocked() {
930
+ const raw = await this._readAgentMdStorage(this._agentMdListKey());
931
+ if (raw === null) {
932
+ return await this._rebuildAgentMdListUnlocked();
933
+ }
934
+ try {
935
+ return this._normalizeAgentMdList(JSON.parse(raw));
936
+ }
937
+ catch (err) {
938
+ this._clientLog.warn(`agent.md list.json damaged, rebuilding: ${err instanceof Error ? err.message : String(err)}`);
939
+ return await this._rebuildAgentMdListUnlocked();
940
+ }
941
+ }
942
+ async _readAgentMdContent(aid) {
943
+ return await this._readAgentMdStorage(this._agentMdContentKey(aid));
944
+ }
945
+ async _writeAgentMdContent(aid, content) {
946
+ await this._writeAgentMdStorage(this._agentMdContentKey(aid), String(content ?? ''));
947
+ }
948
+ _agentMdAuthCacheMeta(aid) {
949
+ try {
950
+ const store = this.auth._agentMdCache;
951
+ const record = store?.get(String(aid ?? '').trim());
952
+ return record && typeof record === 'object' ? { ...record } : {};
953
+ }
954
+ catch {
955
+ return {};
956
+ }
957
+ }
958
+ async _loadAgentMdRecord(aid) {
959
+ const target = String(aid ?? '').trim();
960
+ if (!target)
961
+ return null;
962
+ try {
963
+ const records = await this._withAgentMdListLock(async () => await this._readAgentMdListUnlocked());
964
+ const record = records[target];
965
+ if (record && typeof record === 'object') {
966
+ const loaded = { ...record, aid: target };
967
+ const content = await this._readAgentMdContent(target);
968
+ if (content !== null) {
969
+ loaded.content = content;
970
+ loaded.local_etag = await this._agentMdContentEtag(content);
971
+ }
972
+ else {
973
+ this._clientLog.warn(`agent.md content read failed: aid=${target}`);
974
+ }
975
+ this._agentMdCache.set(target, { ...loaded });
976
+ return { ...loaded };
977
+ }
978
+ }
979
+ catch (err) {
980
+ this._clientLog.debug(`agent.md cache load skipped: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
981
+ }
982
+ return null;
983
+ }
984
+ async _saveAgentMdRecord(aid, fields) {
985
+ const target = String(aid ?? '').trim();
986
+ if (!target)
987
+ return {};
988
+ try {
989
+ const inputFields = { ...fields };
990
+ const hasContent = Object.prototype.hasOwnProperty.call(inputFields, 'content') && inputFields.content !== undefined && inputFields.content !== null;
991
+ if (hasContent) {
992
+ const text = String(inputFields.content ?? '');
993
+ await this._writeAgentMdContent(target, text);
994
+ if (!inputFields.local_etag)
995
+ inputFields.local_etag = await this._agentMdContentEtag(text);
996
+ if (!inputFields.fetched_at)
997
+ inputFields.fetched_at = Date.now();
998
+ }
999
+ delete inputFields.content;
1000
+ const record = await this._withAgentMdListLock(async () => {
1001
+ const records = await this._readAgentMdListUnlocked();
1002
+ const next = { ...(records[target] ?? {}), aid: target };
1003
+ for (const [key, value] of Object.entries(inputFields)) {
1004
+ if (value !== undefined && value !== null)
1005
+ next[key] = value;
1006
+ }
1007
+ next.updated_at = Date.now();
1008
+ records[target] = { ...next };
1009
+ await this._writeAgentMdListUnlocked(records);
1010
+ return next;
1011
+ });
1012
+ const loaded = { ...record };
1013
+ if (hasContent)
1014
+ loaded.content = String(fields.content ?? '');
1015
+ this._agentMdCache.set(target, { ...loaded });
1016
+ const owner = this._agentMdOwnerAid();
1017
+ if (target === owner) {
1018
+ const localEtag = String(loaded.local_etag ?? '').trim();
1019
+ const remoteEtag = String(loaded.remote_etag ?? '').trim();
1020
+ if (localEtag)
1021
+ this._localAgentMdEtag = localEtag;
1022
+ if (remoteEtag)
1023
+ this._remoteAgentMdEtag = remoteEtag;
1024
+ }
1025
+ return { ...loaded };
1026
+ }
1027
+ catch (err) {
1028
+ this._clientLog.debug(`agent.md cache save skipped: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
1029
+ }
1030
+ return {};
1031
+ }
1032
+ async _agentMdHasLocalContent(aid, record) {
1033
+ if (record && typeof record.content === 'string' && record.content.length > 0)
1034
+ return true;
1035
+ try {
1036
+ return (await this._readAgentMdContent(aid)) !== null;
1037
+ }
1038
+ catch {
1039
+ return false;
1040
+ }
1041
+ }
1042
+ _agentMdCheckedAtFresh(checkedAtMs, maxUnsyncedDays) {
1043
+ const days = Number(maxUnsyncedDays || 0);
1044
+ if (!Number.isFinite(days) || days <= 0)
1045
+ return false;
1046
+ if (!Number.isFinite(checkedAtMs) || checkedAtMs <= 0)
1047
+ return false;
1048
+ return Date.now() - checkedAtMs <= days * 86400000;
1049
+ }
1050
+ _agentMdLastModifiedFresh(lastModified, maxUnsyncedDays) {
1051
+ const days = Number(maxUnsyncedDays || 0);
1052
+ if (!Number.isFinite(days) || days <= 0)
1053
+ return false;
1054
+ const ts = Date.parse(String(lastModified ?? '').trim());
1055
+ if (!Number.isFinite(ts))
1056
+ return false;
1057
+ return Date.now() <= ts + days * 86400000;
1058
+ }
1059
+ async _scheduleAgentMdFetchIfMissing(aid, record, source = '') {
1060
+ const target = String(aid ?? '').trim();
1061
+ if (!target || await this._agentMdHasLocalContent(target, record))
1062
+ return;
1063
+ if (this._agentMdFetchInflight.has(target))
1064
+ return;
1065
+ this._agentMdFetchInflight.add(target);
1066
+ try {
1067
+ await this.fetchAgentMd(target);
1068
+ }
1069
+ catch (err) {
1070
+ await this._saveAgentMdRecord(target, {
1071
+ last_error: err instanceof Error ? err.message : String(err),
1072
+ remote_status: 'found',
1073
+ });
1074
+ this._clientLog.debug(`agent.md auto fetch failed: aid=${target} source=${source || '-'} err=${err instanceof Error ? err.message : String(err)}`);
1075
+ }
1076
+ finally {
1077
+ this._agentMdFetchInflight.delete(target);
1078
+ }
1079
+ }
1080
+ async _observeAgentMdMeta(aid, etag = '', lastModified = '', source = '') {
1081
+ const target = String(aid ?? '').trim();
1082
+ const remoteEtag = String(etag ?? '').trim();
1083
+ const remoteLastModified = String(lastModified ?? '').trim();
1084
+ if (!target || (!remoteEtag && !remoteLastModified))
1085
+ return;
1086
+ let before = this._agentMdCache.get(target);
1087
+ if (!before || typeof before !== 'object')
1088
+ before = await this._loadAgentMdRecord(target) ?? {};
1089
+ const same = (!remoteEtag || String(before.remote_etag ?? '').trim() === remoteEtag) &&
1090
+ (!remoteLastModified || String(before.last_modified ?? '').trim() === remoteLastModified);
1091
+ let record = { ...before };
1092
+ if (!same || Object.keys(before).length === 0) {
1093
+ const fields = {
1094
+ observed_at: Date.now(),
1095
+ remote_status: 'found',
1096
+ };
1097
+ if (remoteEtag)
1098
+ fields.remote_etag = remoteEtag;
1099
+ if (remoteLastModified)
1100
+ fields.last_modified = remoteLastModified;
1101
+ record = await this._saveAgentMdRecord(target, fields) || record;
1102
+ }
1103
+ if (target === this._agentMdOwnerAid() && remoteEtag)
1104
+ this._remoteAgentMdEtag = remoteEtag;
1105
+ await this._scheduleAgentMdFetchIfMissing(target, record, source);
1106
+ this._clientLog.debug(`agent.md meta observed: aid=${target} etag=${remoteEtag || '-'} last_modified=${remoteLastModified || '-'} source=${source || '-'}`);
1107
+ }
1108
+ async _observeAgentMdEtag(aid, etag, source = '') {
1109
+ await this._observeAgentMdMeta(aid, etag, '', source);
1110
+ }
1111
+ async _observeAgentMdFromEnvelope(envelope) {
1112
+ if (!isJsonObject(envelope))
1113
+ return;
1114
+ const env = envelope;
1115
+ if (!isJsonObject(env.agent_md))
1116
+ return;
1117
+ const agentMd = env.agent_md;
1118
+ if (!isJsonObject(agentMd.sender))
1119
+ return;
1120
+ const sender = agentMd.sender;
1121
+ let senderAid = String(sender.aid ?? '').trim();
1122
+ if (!senderAid) {
1123
+ const aad = isJsonObject(env.aad) ? env.aad : {};
1124
+ senderAid = String(aad.from ?? env.from ?? '').trim();
1125
+ }
1126
+ await this._observeAgentMdMeta(senderAid, String(sender.etag ?? '').trim(), String(sender.last_modified ?? sender.lastModified ?? '').trim(), 'envelope');
1127
+ }
1128
+ async checkAgentMd(aid, maxUnsyncedDays = 0) {
1129
+ const target = String(aid ?? this._aid ?? '').trim();
1130
+ if (!target)
1131
+ throw new ValidationError('checkAgentMd requires aid (or local AID)');
1132
+ const before = await this._loadAgentMdRecord(target) ?? {};
1133
+ const localEtag = String(before.local_etag ?? '').trim();
1134
+ const localFound = !!(Object.keys(before).length > 0 && (String(before.content ?? '') || localEtag));
1135
+ const remoteEtagCached = String(before.remote_etag ?? '').trim();
1136
+ const lastModifiedCached = String(before.last_modified ?? '').trim();
1137
+ const checkedAtCached = Number(before.checked_at ?? 0);
1138
+ const cachedInSync = !!(localFound && localEtag && remoteEtagCached && localEtag === remoteEtagCached);
1139
+ // max_unsynced_days > 0 且距上次 HEAD 在窗口内 → 直接返回缓存;否则强制 HEAD。
1140
+ if (cachedInSync && this._agentMdCheckedAtFresh(checkedAtCached, maxUnsyncedDays)) {
1141
+ return {
1142
+ aid: target,
1143
+ local_found: true,
1144
+ remote_found: true,
1145
+ local_etag: localEtag,
1146
+ remote_etag: remoteEtagCached,
1147
+ in_sync: true,
1148
+ last_modified: lastModifiedCached,
1149
+ status: 200,
1150
+ cached: true,
1151
+ verify_status: String(before.verify_status ?? ''),
1152
+ verify_error: String(before.verify_error ?? ''),
1153
+ };
1154
+ }
1155
+ const now = Date.now();
1156
+ let remote;
1157
+ try {
1158
+ remote = await this.auth.headAgentMd(target);
1159
+ }
1160
+ catch (err) {
1161
+ await this._saveAgentMdRecord(target, { checked_at: now, remote_status: 'error', last_error: err instanceof Error ? err.message : String(err) });
1162
+ throw err;
1163
+ }
1164
+ const remoteFound = !!remote.found;
1165
+ const remoteEtag = String(remote.etag ?? '').trim();
1166
+ const lastModified = String(remote.last_modified ?? remote.lastModified ?? '').trim();
1167
+ const saved = await this._saveAgentMdRecord(target, {
1168
+ remote_etag: remoteFound ? remoteEtag : '',
1169
+ last_modified: lastModified,
1170
+ checked_at: now,
1171
+ remote_status: remoteFound ? 'found' : 'missing',
1172
+ last_error: '',
1173
+ });
1174
+ if (target === this._agentMdOwnerAid() && remoteEtag)
1175
+ this._remoteAgentMdEtag = remoteEtag;
1176
+ const inSync = !!(localFound && remoteFound && localEtag && remoteEtag && localEtag === remoteEtag);
1177
+ return {
1178
+ aid: target,
1179
+ local_found: localFound,
1180
+ remote_found: remoteFound,
1181
+ local_etag: localEtag,
1182
+ remote_etag: remoteEtag,
1183
+ in_sync: inSync,
1184
+ last_modified: lastModified,
1185
+ status: Number(remote.status ?? (remoteFound ? 200 : 404)),
1186
+ cached: false,
1187
+ verify_status: String(saved.verify_status ?? before.verify_status ?? ''),
1188
+ verify_error: String(saved.verify_error ?? before.verify_error ?? ''),
1189
+ };
1190
+ }
603
1191
  /** transport 的 meta observer:吸收 gateway 注入的 _meta 字段。失败不影响业务。 */
604
- _observeRpcMeta(meta) {
1192
+ async _observeRpcMeta(meta) {
605
1193
  if (!isJsonObject(meta))
606
1194
  return;
607
1195
  const etag = String(meta.agent_md_etag ?? '').trim();
608
1196
  if (etag) {
609
1197
  this._remoteAgentMdEtag = etag;
1198
+ await this._observeAgentMdMeta(this._aid ?? '', etag, '', 'rpc.self');
1199
+ }
1200
+ const etags = meta.agent_md_etags;
1201
+ if (isJsonObject(etags)) {
1202
+ // role key 优先级:requester / peer 是新规范,其余是兼容旧 SDK 的别名。
1203
+ for (const key of ['requester', 'peer', 'receiver', 'target', 'to', 'sender', 'from']) {
1204
+ const item = etags[key];
1205
+ if (!isJsonObject(item))
1206
+ continue;
1207
+ await this._observeAgentMdMeta(String(item.aid ?? ''), String(item.etag ?? ''), String(item.last_modified ?? item.lastModified ?? ''), `rpc.${key}`);
1208
+ }
610
1209
  }
611
1210
  }
612
1211
  get state() {
@@ -660,18 +1259,30 @@ export class AUNClient {
660
1259
  this._sessionOptions = this._buildSessionOptions(normalized);
661
1260
  this._transport.setTimeout(this._sessionOptions.timeouts.call);
662
1261
  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';
1262
+ const gateways = this._resolveGateways(normalized);
1263
+ let lastErr = null;
1264
+ for (const gw of gateways) {
1265
+ try {
1266
+ const gwParams = { ...normalized, gateway: gw };
1267
+ await this._connectOnce(gwParams, false);
1268
+ this._clientLog.debug(`connect exit: elapsed=${Date.now() - tStart}ms state=${this._state}`);
1269
+ return;
671
1270
  }
672
- this._clientLog.debug(`connect exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
673
- throw err;
1271
+ catch (err) {
1272
+ lastErr = err;
1273
+ if (gateways.length > 1) {
1274
+ this._clientLog.warn(`connect: gateway ${gw} failed, trying next: ${err instanceof Error ? err.message : String(err)}`);
1275
+ }
1276
+ if (this._state === 'connecting' || this._state === 'authenticating') {
1277
+ this._state = 'connecting';
1278
+ }
1279
+ }
1280
+ }
1281
+ if (this._state === 'connecting' || this._state === 'authenticating') {
1282
+ this._state = 'disconnected';
674
1283
  }
1284
+ this._clientLog.debug(`connect exit (error): elapsed=${Date.now() - tStart}ms err=${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
1285
+ throw lastErr;
675
1286
  }
676
1287
  /** 断开连接但保留本地状态,可再次 connect */
677
1288
  async disconnect() {
@@ -803,6 +1414,9 @@ export class AUNClient {
803
1414
  throw new PermissionError(`legacy E2EE method is removed in this SDK: ${method}`);
804
1415
  }
805
1416
  const p = { ...(params ?? {}) };
1417
+ if (method === 'message.send' || method === 'group.send') {
1418
+ this._normalizeOutboundMessagePayload(p, method);
1419
+ }
806
1420
  this._validateOutboundCall(method, p);
807
1421
  this._injectMessageCursorContext(method, p);
808
1422
  // group.* 方法的 group_id 归一化为 canonical 格式(兼容老/污染数据)
@@ -815,7 +1429,7 @@ export class AUNClient {
815
1429
  p.group_id = normalizedGroupId;
816
1430
  }
817
1431
  // group.* 方法注入 device_id(服务端用于多设备消息路由)
818
- if (method.startsWith('group.') && this._deviceId && p.device_id === undefined) {
1432
+ if (method.startsWith('group.') && p.device_id === undefined) {
819
1433
  p.device_id = this._deviceId;
820
1434
  }
821
1435
  if (method.startsWith('group.') && p.slot_id === undefined) {
@@ -904,7 +1518,12 @@ export class AUNClient {
904
1518
  }
905
1519
  // 关键操作自动附加客户端签名
906
1520
  if (SIGNED_METHODS.has(method)) {
907
- await this._signClientOperation(method, p);
1521
+ if (this._shouldSkipClientSignature(method, p)) {
1522
+ delete p.client_signature;
1523
+ }
1524
+ else {
1525
+ await this._signClientOperation(method, p);
1526
+ }
908
1527
  }
909
1528
  // P1-23: 非幂等方法使用更长超时
910
1529
  const callTimeout = NON_IDEMPOTENT_METHODS.has(method) ? NON_IDEMPOTENT_TIMEOUT : undefined;
@@ -1042,6 +1661,9 @@ export class AUNClient {
1042
1661
  const seq = msg.seq;
1043
1662
  if (seq !== undefined && seq !== null && this._aid) {
1044
1663
  const ns = `p2p:${this._aid}`;
1664
+ // Push 修上界:先更新 maxSeenSeq
1665
+ if (seq > 0)
1666
+ this._seqTracker.updateMaxSeen(ns, seq);
1045
1667
  const needPull = this._seqTracker.onMessageSeq(ns, seq);
1046
1668
  if (needPull) {
1047
1669
  this._safeAsync(this._fillP2pGap());
@@ -1049,8 +1671,10 @@ export class AUNClient {
1049
1671
  // auto-ack contiguous_seq
1050
1672
  const contig = this._seqTracker.getContiguousSeq(ns);
1051
1673
  if (contig > 0) {
1674
+ const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
1675
+ const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
1052
1676
  this._transport.call('message.ack', {
1053
- seq: contig,
1677
+ seq: ackSeq,
1054
1678
  device_id: this._deviceId,
1055
1679
  slot_id: this._slotId,
1056
1680
  }).catch((e) => { this._clientLog.warn(`P2P auto-ack failed:${String(e)}`); });
@@ -1079,6 +1703,7 @@ export class AUNClient {
1079
1703
  timestamp: (src.timestamp ?? null),
1080
1704
  _decrypt_error: String(exc),
1081
1705
  };
1706
+ attachV2EnvelopeMetadataFromSource(safeEvent, data);
1082
1707
  await this._publishAppEvent('message.undecryptable', safeEvent);
1083
1708
  }
1084
1709
  }
@@ -1111,6 +1736,14 @@ export class AUNClient {
1111
1736
  }
1112
1737
  try {
1113
1738
  const ns = `group:${groupId}`;
1739
+ // Push 修上界:先更新 maxSeenSeq
1740
+ this._seqTracker.updateMaxSeen(ns, seq);
1741
+ const contigBefore = this._seqTracker.getContiguousSeq(ns);
1742
+ if (contigBefore === seq) {
1743
+ this._clientLog.debug(`_onRawGroupV2MessageCreated duplicate push already covered: group=${groupId} seq=${seq}`);
1744
+ return;
1745
+ }
1746
+ const afterSeq = this._repairPushContiguousBound(ns, seq, false, '_raw.group.v2.message_created');
1114
1747
  // per-namespace 去重:同一 group namespace 只允许 1 个 in-flight pull
1115
1748
  const dedupKey = `group_pull:${ns}`;
1116
1749
  if (this._gapFillDone.has(dedupKey)) {
@@ -1119,7 +1752,6 @@ export class AUNClient {
1119
1752
  }
1120
1753
  this._gapFillDone.add(dedupKey);
1121
1754
  try {
1122
- const afterSeq = Math.max(0, this._seqTracker.getContiguousSeq(ns));
1123
1755
  this._clientLog.debug(`_onRawGroupV2MessageCreated -> group.v2.pull group=${groupId} after_seq=${afterSeq}`);
1124
1756
  const messages = await this.pullGroupV2(groupId, afterSeq, 50);
1125
1757
  this._clientLog.debug(`_onRawGroupV2MessageCreated pulled ${messages.length} msgs for group=${groupId}`);
@@ -1164,15 +1796,20 @@ export class AUNClient {
1164
1796
  // seq 跟踪 + auto-ack
1165
1797
  if (groupId && seq !== undefined && seq !== null) {
1166
1798
  const ns = `group:${groupId}`;
1799
+ // Push 修上界:先更新 maxSeenSeq
1800
+ if (seq > 0)
1801
+ this._seqTracker.updateMaxSeen(ns, seq);
1167
1802
  const needPull = this._seqTracker.onMessageSeq(ns, seq);
1168
1803
  if (needPull) {
1169
1804
  this._safeAsync(this._fillGroupGap(groupId));
1170
1805
  }
1171
1806
  const contig = this._seqTracker.getContiguousSeq(ns);
1172
1807
  if (contig > 0) {
1808
+ const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
1809
+ const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
1173
1810
  this._transport.call('group.ack_messages', {
1174
1811
  group_id: groupId,
1175
- msg_seq: contig,
1812
+ msg_seq: ackSeq,
1176
1813
  device_id: this._deviceId,
1177
1814
  slot_id: this._slotId,
1178
1815
  }).catch((e) => { this._clientLog.warn('group message auto-ack failed: group=' + groupId, e); });
@@ -1200,6 +1837,7 @@ export class AUNClient {
1200
1837
  timestamp: (src.timestamp ?? null),
1201
1838
  _decrypt_error: String(exc),
1202
1839
  };
1840
+ attachV2EnvelopeMetadataFromSource(safeEvent, data);
1203
1841
  await this._publishAppEvent('group.message_undecryptable', safeEvent);
1204
1842
  }
1205
1843
  }
@@ -1331,53 +1969,80 @@ export class AUNClient {
1331
1969
  this._gapFillDone.add(dedupKey);
1332
1970
  this._gapFillActive = true;
1333
1971
  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)) {
1972
+ let nextAfterSeq = afterSeq;
1973
+ const maxPages = 100;
1974
+ let pageCount = 0;
1975
+ while (pageCount < maxPages) {
1976
+ pageCount += 1;
1977
+ const result = await this.call('group.pull_events', {
1978
+ group_id: groupId,
1979
+ after_event_seq: nextAfterSeq,
1980
+ device_id: this._deviceId,
1981
+ limit: 50,
1982
+ });
1983
+ if (!isJsonObject(result))
1984
+ return;
1341
1985
  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); });
1986
+ if (!Array.isArray(events))
1987
+ return;
1988
+ const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
1989
+ const eventObjects = events.filter(isJsonObject);
1990
+ if (eventObjects.length > 0) {
1991
+ this._seqTracker.onPullResult(ns, eventObjects, nextAfterSeq);
1992
+ }
1993
+ const cursor = isJsonObject(result.cursor) ? result.cursor : null;
1994
+ const serverAck = cursor ? Number(cursor.current_seq ?? 0) : 0;
1995
+ if (serverAck > 0) {
1996
+ const contigBeforeFloor = this._seqTracker.getContiguousSeq(ns);
1997
+ if (contigBeforeFloor < serverAck) {
1998
+ this._clientLog.info('group.pull_events retention-floor advance: ns=' + ns + ' contiguous=' + contigBeforeFloor + ' -> cursor.current_seq=' + serverAck);
1999
+ this._seqTracker.forceContiguousSeq(ns, serverAck);
1363
2000
  }
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);
2001
+ }
2002
+ const eventSeqs = [];
2003
+ for (const evt of eventObjects) {
2004
+ const eventSeq = Number(evt.event_seq ?? 0);
2005
+ if (Number.isFinite(eventSeq) && eventSeq > 0)
2006
+ eventSeqs.push(eventSeq);
2007
+ evt._from_gap_fill = true;
2008
+ const et = String(evt.event_type ?? '');
2009
+ // 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
2010
+ if (et === 'group.message_created')
2011
+ continue;
2012
+ // 验签:有 client_signature 就验(与实时事件路径对齐)
2013
+ const cs = evt.client_signature;
2014
+ if (cs && typeof cs === 'object') {
2015
+ if (this._shouldSkipEventSignature(evt)) {
2016
+ delete evt.client_signature;
2017
+ }
2018
+ else {
2019
+ evt._verified = await this._verifyEventSignature(evt, cs);
1378
2020
  }
1379
2021
  }
2022
+ // group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
2023
+ await this._dispatcher.publish('group.changed', evt);
2024
+ }
2025
+ const contig = this._seqTracker.getContiguousSeq(ns);
2026
+ if (contig !== pageContigBefore) {
2027
+ this._saveSeqTrackerState();
1380
2028
  }
2029
+ if (eventObjects.length > 0 && contig > 0 && contig !== pageContigBefore) {
2030
+ const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
2031
+ const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
2032
+ this._transport.call('group.ack_events', {
2033
+ group_id: groupId,
2034
+ event_seq: ackSeq,
2035
+ device_id: this._deviceId,
2036
+ slot_id: this._slotId,
2037
+ }).catch((e) => { this._clientLog.warn('group event auto-ack failed: group=' + groupId, e); });
2038
+ }
2039
+ const nextAfter = Math.max(eventSeqs.length > 0 ? Math.max(...eventSeqs) : nextAfterSeq, nextAfterSeq);
2040
+ if (eventObjects.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
2041
+ break;
2042
+ nextAfterSeq = nextAfter;
2043
+ }
2044
+ if (pageCount >= maxPages) {
2045
+ this._clientLog.warn(`group event gap fill reached max_pages=${maxPages} group=${groupId} after_seq=${nextAfterSeq}`);
1381
2046
  }
1382
2047
  }
1383
2048
  catch (exc) {
@@ -1495,10 +2160,10 @@ export class AUNClient {
1495
2160
  if (!isJsonObject(payload))
1496
2161
  return payload;
1497
2162
  const result = { ...payload };
1498
- if (this._deviceId && !String(result.device_id ?? '').trim()) {
2163
+ if (!('device_id' in result)) {
1499
2164
  result.device_id = this._deviceId;
1500
2165
  }
1501
- if (this._slotId && !String(result.slot_id ?? '').trim()) {
2166
+ if (!('slot_id' in result)) {
1502
2167
  result.slot_id = this._slotId;
1503
2168
  }
1504
2169
  return result;
@@ -1554,6 +2219,18 @@ export class AUNClient {
1554
2219
  const trace = `${this._echoTimestamp()} [AUN-SDK.send] aid=${this._aid ?? '-'} conn_uptime=${uptime}s`;
1555
2220
  params.payload = { ...payload, text: payload.text + '\n' + trace };
1556
2221
  }
2222
+ _shouldSkipClientSignature(method, params) {
2223
+ if (method !== 'message.send' && method !== 'group.send')
2224
+ return false;
2225
+ if (params.encrypted || params.encrypt)
2226
+ return false;
2227
+ return this._isEchoPayload(params.payload);
2228
+ }
2229
+ _shouldSkipEventSignature(event) {
2230
+ if (event.encrypted || event.encrypt)
2231
+ return false;
2232
+ return this._isEchoPayload(event.payload);
2233
+ }
1557
2234
  _maybeAppendEchoTraceReceive(msg) {
1558
2235
  if (msg.encrypted)
1559
2236
  return;
@@ -1567,13 +2244,17 @@ export class AUNClient {
1567
2244
  _messageTargetsCurrentInstance(message) {
1568
2245
  if (!isJsonObject(message))
1569
2246
  return true;
1570
- const targetDeviceId = String(message.device_id ?? '').trim();
1571
- if (targetDeviceId && this._deviceId && targetDeviceId !== this._deviceId) {
1572
- return false;
2247
+ if ('device_id' in message) {
2248
+ const targetDeviceId = String(message.device_id ?? '').trim();
2249
+ if (targetDeviceId !== this._deviceId) {
2250
+ return false;
2251
+ }
1573
2252
  }
1574
- const targetSlotId = String(message.slot_id ?? '').trim();
1575
- if (targetSlotId && this._slotId && targetSlotId !== this._slotId) {
1576
- return false;
2253
+ if ('slot_id' in message) {
2254
+ const targetSlotId = String(message.slot_id ?? '').trim();
2255
+ if (targetSlotId !== this._slotId) {
2256
+ return false;
2257
+ }
1577
2258
  }
1578
2259
  return true;
1579
2260
  }
@@ -1668,7 +2349,12 @@ export class AUNClient {
1668
2349
  // 验签:有 client_signature 就验,没有默认安全
1669
2350
  const cs = d.client_signature;
1670
2351
  if (cs && isJsonObject(cs)) {
1671
- d._verified = await this._verifyEventSignature(d, cs);
2352
+ if (this._shouldSkipEventSignature(d)) {
2353
+ delete d.client_signature;
2354
+ }
2355
+ else {
2356
+ d._verified = await this._verifyEventSignature(d, cs);
2357
+ }
1672
2358
  }
1673
2359
  await this._dispatcher.publish('group.changed', d);
1674
2360
  const groupId = (d.group_id ?? '');
@@ -1677,14 +2363,20 @@ export class AUNClient {
1677
2363
  this._v2BootstrapCache.delete(`group:${groupId}`);
1678
2364
  }
1679
2365
  // Group SPK 编排:成员变更触发注册/轮换
2366
+ const membershipActions = new Set([
2367
+ 'member_added', 'member_left', 'member_removed', 'role_changed',
2368
+ 'owner_transferred', 'joined', 'join_approved', 'invite_code_used',
2369
+ ]);
1680
2370
  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
2371
  if (membershipActions.has(action)) {
1686
2372
  const callFn = async (method, params) => this.call(method, params);
1687
- if (action === 'joined' || action === 'join_approved') {
2373
+ const joinedAid = String(d.joined_aid ?? d.member_aid ?? d.aid ?? '').trim();
2374
+ const actorAid = String(d.actor_aid ?? '').trim();
2375
+ const selfAid = String(this._aid ?? '').trim();
2376
+ const joinActions = new Set(['member_added', 'joined', 'join_approved', 'invite_code_used']);
2377
+ const isSelfJoin = joinActions.has(action) && !!selfAid && (joinedAid === selfAid ||
2378
+ (!joinedAid && (action === 'joined' || action === 'invite_code_used') && actorAid === selfAid));
2379
+ if (isSelfJoin) {
1688
2380
  this._v2Session.ensureGroupRegistered?.(groupId, callFn)?.catch(exc => {
1689
2381
  this._clientLog.debug(`group SPK registration failed (non-fatal): group=${groupId} action=${action} err=${exc}`);
1690
2382
  });
@@ -1696,7 +2388,7 @@ export class AUNClient {
1696
2388
  }
1697
2389
  }
1698
2390
  }
1699
- if (groupId && action === 'upsert' && this._v2Session) {
2391
+ if (groupId && this._v2Session && (action === 'upsert' || membershipActions.has(action))) {
1700
2392
  this._safeAsync(this._v2AutoProposeState(groupId, { leaderDelay: true }));
1701
2393
  }
1702
2394
  // event_seq 空洞检测:持久化后的 group.changed 会携带 event_seq。
@@ -1761,12 +2453,17 @@ export class AUNClient {
1761
2453
  // 提交者签名验证
1762
2454
  const cs = d.client_signature;
1763
2455
  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;
2456
+ if (this._shouldSkipEventSignature(d)) {
2457
+ delete d.client_signature;
2458
+ }
2459
+ else {
2460
+ const verified = await this._verifyEventSignature(d, cs);
2461
+ if (verified === false) {
2462
+ this._clientLog.warn(`state_committed committer signature verify failed group=%s${String(groupId)}`);
2463
+ return;
2464
+ }
2465
+ d._verified = verified;
1768
2466
  }
1769
- d._verified = verified;
1770
2467
  }
1771
2468
  const stateVersion = Number(d.state_version ?? 0);
1772
2469
  const stateHash = String(d.state_hash ?? '').trim();
@@ -2036,6 +2733,7 @@ export class AUNClient {
2036
2733
  let e2eeMeta = null;
2037
2734
  let decryptFailed = false;
2038
2735
  if (isV2Envelope) {
2736
+ e2eeMeta = v2E2eeMeta(payload);
2039
2737
  const plaintext = await this._decryptV2EnvelopeForThought({
2040
2738
  envelope: payload,
2041
2739
  fromAid: senderAid,
@@ -2047,7 +2745,7 @@ export class AUNClient {
2047
2745
  }
2048
2746
  else {
2049
2747
  decryptedPayload = plaintext;
2050
- const e2eeObj = v2E2eeMeta(payload);
2748
+ const e2eeObj = e2eeMeta;
2051
2749
  // 暴露 protected_headers(去 _auth)
2052
2750
  const ph = payload.protected_headers;
2053
2751
  if (isJsonObject(ph)) {
@@ -2084,6 +2782,8 @@ export class AUNClient {
2084
2782
  created_at: item.created_at,
2085
2783
  e2ee: e2eeMeta,
2086
2784
  };
2785
+ if (isJsonObject(e2eeMeta))
2786
+ attachV2EnvelopeMetadata(thought, e2eeMeta);
2087
2787
  if (decryptFailed)
2088
2788
  thought.decrypt_failed = true;
2089
2789
  if ('context' in item)
@@ -2122,6 +2822,8 @@ export class AUNClient {
2122
2822
  let decryptFailed = false;
2123
2823
  // V2 P2P thought envelope:per-device wrap,本设备解密自己的 row
2124
2824
  if (payload?.type === 'e2ee.p2p_encrypted') {
2825
+ const e2eeObj = v2E2eeMeta(payload);
2826
+ message.e2ee = e2eeObj;
2125
2827
  const plaintext = await this._decryptV2EnvelopeForThought({
2126
2828
  envelope: payload,
2127
2829
  fromAid,
@@ -2133,7 +2835,6 @@ export class AUNClient {
2133
2835
  else {
2134
2836
  decrypted = { ...message };
2135
2837
  decrypted.payload = plaintext;
2136
- const e2eeObj = v2E2eeMeta(payload);
2137
2838
  // 暴露 protected_headers(去 _auth)
2138
2839
  const ph = payload.protected_headers;
2139
2840
  if (isJsonObject(ph)) {
@@ -2162,6 +2863,7 @@ export class AUNClient {
2162
2863
  else if (payload?.type === 'e2ee.encrypted') {
2163
2864
  decryptFailed = true;
2164
2865
  }
2866
+ const exposedE2ee = (decrypted ?? message).e2ee;
2165
2867
  const thought = {
2166
2868
  thought_id: thoughtId,
2167
2869
  message_id: thoughtId,
@@ -2169,8 +2871,10 @@ export class AUNClient {
2169
2871
  to: toAid,
2170
2872
  payload: (decrypted ?? message).payload,
2171
2873
  created_at: item.created_at,
2172
- e2ee: (decrypted ?? message).e2ee,
2874
+ e2ee: exposedE2ee,
2173
2875
  };
2876
+ if (isJsonObject(exposedE2ee))
2877
+ attachV2EnvelopeMetadata(thought, exposedE2ee);
2174
2878
  if (decryptFailed)
2175
2879
  thought.decrypt_failed = true;
2176
2880
  if ('context' in item)
@@ -2184,7 +2888,7 @@ export class AUNClient {
2184
2888
  * 获取对方证书(带缓存 + 完整 PKI 验证:链 + CRL + OCSP + AID 绑定)。
2185
2889
  * 跨域时自动将请求路由到 peer 所在域的 Gateway。
2186
2890
  */
2187
- async _fetchPeerCert(aid, certFingerprint) {
2891
+ async _fetchPeerCert(aid, certFingerprint, timeoutMs = 5000) {
2188
2892
  const tStart = Date.now();
2189
2893
  this._clientLog.debug(`_fetchPeerCert enter: aid=${aid} fingerprint=${certFingerprint ?? '<none>'}`);
2190
2894
  try {
@@ -2206,7 +2910,7 @@ export class AUNClient {
2206
2910
  const certUrl = buildCertUrl(peerGatewayUrl, aid, certFingerprint);
2207
2911
  // 兼容旧浏览器,不使用 AbortSignal.timeout(Chrome 103+ 才支持)
2208
2912
  const controller = new AbortController();
2209
- const timeoutId = setTimeout(() => controller.abort(), 5000);
2913
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
2210
2914
  try {
2211
2915
  const resp = await fetch(certUrl, { signal: controller.signal });
2212
2916
  if (!resp.ok)
@@ -2223,7 +2927,7 @@ export class AUNClient {
2223
2927
  }
2224
2928
  // 兼容旧浏览器,不使用 AbortSignal.timeout(Chrome 103+ 才支持)
2225
2929
  const fallbackController = new AbortController();
2226
- const fallbackTimeoutId = setTimeout(() => fallbackController.abort(), 5000);
2930
+ const fallbackTimeoutId = setTimeout(() => fallbackController.abort(), timeoutMs);
2227
2931
  try {
2228
2932
  const fallbackResp = await fetch(buildCertUrl(peerGatewayUrl, aid), { signal: fallbackController.signal });
2229
2933
  if (!fallbackResp.ok) {
@@ -2485,6 +3189,10 @@ export class AUNClient {
2485
3189
  }
2486
3190
  }
2487
3191
  _resolveGateway(params) {
3192
+ const gateways = this._resolveGateways(params);
3193
+ return gateways[0];
3194
+ }
3195
+ _resolveGateways(params) {
2488
3196
  const topology = isJsonObject(params.topology) ? params.topology : null;
2489
3197
  if (topology) {
2490
3198
  const mode = String(topology.mode ?? 'gateway');
@@ -2495,10 +3203,16 @@ export class AUNClient {
2495
3203
  throw new ValidationError('relay topology is not implemented in the Browser SDK');
2496
3204
  }
2497
3205
  }
2498
- const gateway = String(params.gateway ?? this._gatewayUrl ?? '');
3206
+ const gw = params.gateway ?? params.gateways;
3207
+ if (Array.isArray(gw)) {
3208
+ const urls = gw.map((g) => String(g ?? '')).filter((u) => u.length > 0);
3209
+ if (urls.length > 0)
3210
+ return urls;
3211
+ }
3212
+ const gateway = String(gw ?? this._gatewayUrl ?? '');
2499
3213
  if (!gateway)
2500
3214
  throw new StateError('missing gateway in connect params');
2501
- return gateway;
3215
+ return [gateway];
2502
3216
  }
2503
3217
  async _syncIdentityAfterConnect(accessToken) {
2504
3218
  let identity = null;
@@ -2787,6 +3501,16 @@ export class AUNClient {
2787
3501
  };
2788
3502
  scheduleRefresh(0);
2789
3503
  }
3504
+ _normalizeOutboundMessagePayload(params, method = '') {
3505
+ if (!Object.prototype.hasOwnProperty.call(params, 'payload') && Object.prototype.hasOwnProperty.call(params, 'content')) {
3506
+ params.payload = params.content;
3507
+ delete params.content;
3508
+ }
3509
+ const payload = params.payload;
3510
+ if (isJsonObject(payload) && !Object.prototype.hasOwnProperty.call(payload, 'type') && typeof payload.text === 'string') {
3511
+ params.payload = { type: 'text', ...payload };
3512
+ }
3513
+ }
2790
3514
  _validateMessageRecipient(toAid) {
2791
3515
  if (isGroupServiceAid(toAid)) {
2792
3516
  throw new ValidationError('message.send receiver cannot be group.{issuer}; use group.send instead');
@@ -3210,6 +3934,8 @@ export class AUNClient {
3210
3934
  this._gapFillDone.clear();
3211
3935
  this._pushedSeqs.clear();
3212
3936
  this._pendingOrderedMsgs.clear();
3937
+ this._v2SenderIKPending.clear();
3938
+ this._v2SenderIKFetching.clear();
3213
3939
  this._groupSynced.clear();
3214
3940
  }
3215
3941
  _refreshSeqTrackerContext() {
@@ -3220,6 +3946,8 @@ export class AUNClient {
3220
3946
  this._gapFillDone.clear();
3221
3947
  this._pushedSeqs.clear();
3222
3948
  this._pendingOrderedMsgs.clear();
3949
+ this._v2SenderIKPending.clear();
3950
+ this._v2SenderIKFetching.clear();
3223
3951
  this._groupSynced.clear();
3224
3952
  this._seqTrackerContext = nextContext;
3225
3953
  }
@@ -3273,6 +4001,47 @@ export class AUNClient {
3273
4001
  }).catch(() => { });
3274
4002
  }
3275
4003
  }
4004
+ _persistRepairedSeq(ns) {
4005
+ if (!this._aid || !ns)
4006
+ return;
4007
+ const seq = this._seqTracker.getContiguousSeq(ns);
4008
+ try {
4009
+ if (seq > 0 && typeof this._keystore.saveSeq === 'function') {
4010
+ this._keystore.saveSeq(this._aid, this._deviceId, this._slotId, ns, seq).catch((exc) => {
4011
+ this._clientLog.debug(`persist repaired seq failed: ns=${ns} err=${formatCaughtError(exc)}`);
4012
+ });
4013
+ return;
4014
+ }
4015
+ const deleteSeq = this._keystore.deleteSeq;
4016
+ if (seq <= 0 && typeof deleteSeq === 'function') {
4017
+ deleteSeq.call(this._keystore, this._aid, this._deviceId, this._slotId, ns).catch((exc) => {
4018
+ this._clientLog.debug(`delete repaired seq failed: ns=${ns} err=${formatCaughtError(exc)}`);
4019
+ });
4020
+ return;
4021
+ }
4022
+ if (seq > 0) {
4023
+ this._saveSeqTrackerState();
4024
+ }
4025
+ }
4026
+ catch (exc) {
4027
+ this._clientLog.debug(`persist repaired seq failed: ns=${ns} err=${formatCaughtError(exc)}`);
4028
+ }
4029
+ }
4030
+ _repairPushContiguousBound(ns, pushSeq, hasPayload, label) {
4031
+ if (!ns || !Number.isFinite(pushSeq) || pushSeq <= 0) {
4032
+ return ns ? this._seqTracker.getContiguousSeq(ns) : 0;
4033
+ }
4034
+ const contig = this._seqTracker.getContiguousSeq(ns);
4035
+ const shouldRepair = contig > pushSeq;
4036
+ if (!shouldRepair)
4037
+ return contig;
4038
+ const repairedTo = Math.max(0, pushSeq - 1);
4039
+ this._seqTracker.repairContiguousSeq(ns, repairedTo);
4040
+ const repaired = this._seqTracker.getContiguousSeq(ns);
4041
+ this._persistRepairedSeq(ns);
4042
+ this._clientLog.warn(`${label} push repaired contiguous_seq: ns=${ns} payload=${hasPayload} push_seq=${pushSeq} contiguous=${contig}->${repaired}`);
4043
+ return repaired;
4044
+ }
3276
4045
  // ── V2 E2EE API(async,与 Python `client.py` `_init_v2_session` / `send_v2` / `pull_v2` / `ack_v2` 对齐) ──
3277
4046
  /**
3278
4047
  * 初始化 V2 session:从 AID PEM 私钥提取 raw scalar + DER 公钥,
@@ -3323,6 +4092,104 @@ export class AUNClient {
3323
4092
  // 上线时自动确认 pending state proposals
3324
4093
  this._safeAsync(this._v2AutoConfirmPendingProposals());
3325
4094
  }
4095
+ async _v2TrustedIKPubDer(aid) {
4096
+ const normalizedAid = String(aid ?? '').trim();
4097
+ if (!normalizedAid)
4098
+ throw new E2EEError('spk_aid_missing');
4099
+ if (this._aid && normalizedAid === this._aid) {
4100
+ if (!this._v2Session)
4101
+ throw new E2EEError('V2 session not initialized');
4102
+ return this._v2Session.currentIkPubDer;
4103
+ }
4104
+ const certPem = await this._fetchPeerCert(normalizedAid);
4105
+ const pubKey = await importCertPublicKeyEcdsa(certPem);
4106
+ return new Uint8Array(await crypto.subtle.exportKey('spki', pubKey));
4107
+ }
4108
+ _v2SPKTimestampText(value, aid, deviceId, spkId) {
4109
+ if (value === null || value === undefined || value === '') {
4110
+ throw new E2EEError(`spk_timestamp_missing: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
4111
+ }
4112
+ if (typeof value === 'boolean') {
4113
+ throw new E2EEError(`spk_timestamp_invalid: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
4114
+ }
4115
+ if (typeof value === 'number') {
4116
+ if (!Number.isSafeInteger(value)) {
4117
+ throw new E2EEError(`spk_timestamp_invalid: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
4118
+ }
4119
+ return String(value);
4120
+ }
4121
+ const text = String(value).trim();
4122
+ if (!/^\d+$/.test(text)) {
4123
+ throw new E2EEError(`spk_timestamp_invalid: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
4124
+ }
4125
+ return BigInt(text).toString();
4126
+ }
4127
+ async _v2VerifySPKDevice(args) {
4128
+ if (!this._v2Session)
4129
+ throw new E2EEError('V2 session not initialized');
4130
+ const spkId = String(args.dev.spk_id ?? '').trim();
4131
+ if (!spkId)
4132
+ return;
4133
+ if (args.keySource !== 'peer_device_prekey' && args.keySource !== 'group_device_prekey') {
4134
+ throw new E2EEError(`spk_key_source_invalid: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId} key_source=${args.keySource}`);
4135
+ }
4136
+ if (!args.spkPkDer || args.spkPkDer.length === 0) {
4137
+ throw new E2EEError(`spk_public_key_missing: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
4138
+ }
4139
+ const spkHash = bytesToHex(new Uint8Array(await crypto.subtle.digest('SHA-256', args.spkPkDer.slice().buffer)));
4140
+ const expectedSpkId = `sha256:${spkHash.substring(0, 16)}`;
4141
+ if (spkId !== expectedSpkId) {
4142
+ throw new E2EEError(`spk_id_mismatch: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId} expected=${expectedSpkId}`);
4143
+ }
4144
+ const trustedIK = await this._v2TrustedIKPubDer(args.aid);
4145
+ if (!_v2BytesEqual(trustedIK, args.ikPkDer)) {
4146
+ throw new E2EEError(`spk_ik_mismatch: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
4147
+ }
4148
+ if (_v2BytesEqual(args.spkPkDer, trustedIK)) {
4149
+ this._v2Session.markPeerSPKVerified(args.aid, args.deviceId, spkId);
4150
+ return;
4151
+ }
4152
+ const sigB64 = String(args.dev.spk_signature ?? '').trim();
4153
+ if (!sigB64) {
4154
+ throw new E2EEError(`spk_signature_missing: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
4155
+ }
4156
+ let signature;
4157
+ try {
4158
+ signature = _v2B64ToBytesStrict(sigB64);
4159
+ }
4160
+ catch {
4161
+ throw new E2EEError(`spk_signature_invalid_base64: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
4162
+ }
4163
+ const encoder = new TextEncoder();
4164
+ const tsText = this._v2SPKTimestampText(args.dev.spk_timestamp, args.aid, args.deviceId, spkId);
4165
+ const signData = _v2ConcatBytes(args.spkPkDer, encoder.encode(spkId), encoder.encode(tsText));
4166
+ if (!(await ecdsaVerifyRaw(trustedIK, signature, signData))) {
4167
+ throw new E2EEError(`spk_signature_invalid: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
4168
+ }
4169
+ this._v2Session.markPeerSPKVerified(args.aid, args.deviceId, spkId);
4170
+ }
4171
+ async _v2BuildTargetFromDevice(args) {
4172
+ const aid = String(args.aid ?? '').trim();
4173
+ const devId = getV2DeviceId(args.dev);
4174
+ const deviceId = devId.present ? devId.value : String(args.deviceId ?? '').trim();
4175
+ const ikPk = String(args.dev.ik_pk ?? '').trim();
4176
+ if (!aid || !devId.present || !ikPk)
4177
+ return null;
4178
+ const ikPkDer = _v2B64ToBytes(ikPk);
4179
+ const spkPkDer = args.dev.spk_pk ? _v2B64ToBytes(String(args.dev.spk_pk)) : undefined;
4180
+ const keySource = String(args.dev.key_source ?? args.defaultKeySource).trim() || args.defaultKeySource;
4181
+ await this._v2VerifySPKDevice({ dev: args.dev, aid, deviceId, ikPkDer, spkPkDer, keySource });
4182
+ this._v2Session?.cachePeerIK(aid, deviceId, ikPkDer);
4183
+ return {
4184
+ aid,
4185
+ deviceId,
4186
+ role: args.role,
4187
+ keySource,
4188
+ ikPkDer,
4189
+ spkPkDer,
4190
+ spkId: String(args.dev.spk_id ?? '').trim(),
4191
+ };
4192
+ }
3326
4193
  async _getV2SenderPubDer(fromAid, senderDeviceId) {
3327
4194
  const session = this._v2Session;
3328
4195
  if (!session || !fromAid)
@@ -3331,37 +4198,128 @@ export class AUNClient {
3331
4198
  if (senderPubDer)
3332
4199
  return senderPubDer;
3333
4200
  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);
4201
+ const certPem = await this._fetchPeerCert(fromAid, undefined, 3000);
3352
4202
  const pubKey = await importCertPublicKeyEcdsa(certPem);
3353
4203
  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}`);
4204
+ session.cachePeerIK(fromAid, senderDeviceId, senderPubDer);
4205
+ this._clientLog.debug(`V2 decrypt: sender IK fallback from PKI cert for ${fromAid}`);
3358
4206
  return senderPubDer;
3359
4207
  }
3360
4208
  catch (exc) {
3361
- this._clientLog.warn(`V2 decrypt: CA fallback for ${fromAid} failed: ${String(formatCaughtError(exc))}`);
4209
+ this._clientLog.warn(`V2 decrypt: PKI cert sender IK fallback failed for ${fromAid}: ${String(formatCaughtError(exc))}`);
3362
4210
  return null;
3363
4211
  }
3364
4212
  }
4213
+ _v2PendingSenderIKMessageKey(msg, groupId) {
4214
+ const messageId = String(msg.message_id ?? '').trim();
4215
+ const seq = String(msg.seq ?? '').trim();
4216
+ const prefix = groupId ? `group:${groupId}` : `p2p:${this._aid ?? ''}`;
4217
+ return `${prefix}:${messageId || seq || Math.random().toString(36).slice(2)}`;
4218
+ }
4219
+ _v2PendingSenderIKFetchKey(fromAid, senderDeviceId, groupId) {
4220
+ return `${fromAid}#${senderDeviceId}#${groupId || ''}`;
4221
+ }
4222
+ _cacheV2PeerIKFromDevice(dev, fallbackAid = '') {
4223
+ const session = this._v2Session;
4224
+ if (!session || !isJsonObject(dev))
4225
+ return;
4226
+ const device = dev;
4227
+ const devId = getV2DeviceId(device);
4228
+ const aid = String(device.aid ?? fallbackAid ?? '').trim();
4229
+ const ikPk = String(device.ik_pk ?? '').trim();
4230
+ if (!devId.present || !aid || !ikPk)
4231
+ return;
4232
+ try {
4233
+ session.cachePeerIK(aid, devId.value, _v2B64ToBytes(ikPk));
4234
+ }
4235
+ catch (exc) {
4236
+ this._clientLog.debug(`V2 sender IK cache from bootstrap skipped aid=${aid} dev=${devId.value}: ${String(formatCaughtError(exc))}`);
4237
+ }
4238
+ }
4239
+ _scheduleV2SenderIKPending(args) {
4240
+ const fromAid = String(args.fromAid ?? '').trim();
4241
+ if (!fromAid)
4242
+ return;
4243
+ const senderDeviceId = String(args.senderDeviceId ?? '');
4244
+ const groupId = String(args.groupId ?? '').trim();
4245
+ const messageKey = this._v2PendingSenderIKMessageKey(args.msg, groupId);
4246
+ this._v2SenderIKPending.set(messageKey, {
4247
+ msg: { ...args.msg },
4248
+ fromAid,
4249
+ senderDeviceId,
4250
+ groupId,
4251
+ createdAt: Date.now(),
4252
+ });
4253
+ this._clientLog.debug(`V2 decrypt pending sender IK: key=${messageKey} from=${fromAid} device=${senderDeviceId || '-'} group=${groupId || '<p2p>'} pending=${this._v2SenderIKPending.size}`);
4254
+ this._scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupId);
4255
+ }
4256
+ _scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupId) {
4257
+ const fetchKey = this._v2PendingSenderIKFetchKey(fromAid, senderDeviceId, groupId);
4258
+ if (!fromAid || this._v2SenderIKFetching.has(fetchKey))
4259
+ return;
4260
+ this._v2SenderIKFetching.add(fetchKey);
4261
+ this._safeAsync(this._resolveV2SenderIKPending(fromAid, senderDeviceId, groupId, fetchKey));
4262
+ }
4263
+ async _resolveV2SenderIKPending(fromAid, senderDeviceId, groupId, fetchKey) {
4264
+ try {
4265
+ const session = this._v2Session;
4266
+ if (session && fromAid) {
4267
+ try {
4268
+ const bs = await this.call('message.v2.bootstrap', { peer_aid: fromAid });
4269
+ const peers = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
4270
+ for (const dev of peers)
4271
+ this._cacheV2PeerIKFromDevice(dev, fromAid);
4272
+ }
4273
+ catch (exc) {
4274
+ this._clientLog.warn(`V2 sender IK pending bootstrap failed peer=${fromAid}: ${String(formatCaughtError(exc))}`);
4275
+ }
4276
+ if (groupId) {
4277
+ try {
4278
+ const gbs = await this.call('group.v2.bootstrap', { group_id: groupId });
4279
+ const devices = (Array.isArray(gbs?.devices) ? gbs.devices : []);
4280
+ const audit = (Array.isArray(gbs?.audit_recipients) ? gbs.audit_recipients : []);
4281
+ for (const dev of devices)
4282
+ this._cacheV2PeerIKFromDevice(dev);
4283
+ for (const dev of audit)
4284
+ this._cacheV2PeerIKFromDevice(dev);
4285
+ }
4286
+ catch (exc) {
4287
+ this._clientLog.warn(`V2 sender IK pending group bootstrap failed group=${groupId}: ${String(formatCaughtError(exc))}`);
4288
+ }
4289
+ }
4290
+ if (!session.getPeerIK(fromAid, senderDeviceId)) {
4291
+ await this._getV2SenderPubDer(fromAid, senderDeviceId);
4292
+ }
4293
+ }
4294
+ const pendingItems = [...this._v2SenderIKPending.entries()].filter(([, entry]) => entry.fromAid === fromAid && entry.senderDeviceId === senderDeviceId && entry.groupId === groupId);
4295
+ for (const [key, entry] of pendingItems) {
4296
+ let plaintext = null;
4297
+ try {
4298
+ plaintext = await this._decryptV2Message(entry.msg, false);
4299
+ }
4300
+ catch (exc) {
4301
+ this._clientLog.warn(`V2 sender IK pending retry raised: key=${key} err=${String(formatCaughtError(exc))}`);
4302
+ }
4303
+ this._v2SenderIKPending.delete(key);
4304
+ if (plaintext === null) {
4305
+ this._clientLog.debug(`V2 sender IK pending retry failed: key=${key}`);
4306
+ continue;
4307
+ }
4308
+ const seq = Number(entry.msg.seq ?? 0);
4309
+ if (entry.groupId) {
4310
+ plaintext.group_id = entry.groupId;
4311
+ await this._publishPulledMessage('group.message_created', `group:${entry.groupId}`, seq, plaintext);
4312
+ }
4313
+ else {
4314
+ await this._publishPulledMessage('message.received', `p2p:${this._aid ?? ''}`, seq, plaintext);
4315
+ }
4316
+ this._clientLog.debug(`V2 sender IK pending retry delivered: key=${key}`);
4317
+ }
4318
+ }
4319
+ finally {
4320
+ this._v2SenderIKFetching.delete(fetchKey);
4321
+ }
4322
+ }
3365
4323
  /**
3366
4324
  * V2 P2P 加密发送(推测性:用缓存 bootstrap 直接发,失败刷新重试一次)。
3367
4325
  *
@@ -3374,108 +4332,21 @@ export class AUNClient {
3374
4332
  if (!this._v2Session) {
3375
4333
  throw new StateError('V2 session not initialized (not connected?)');
3376
4334
  }
3377
- const session = this._v2Session;
3378
4335
  const toAid = String(to ?? '').trim();
3379
4336
  if (!toAid)
3380
4337
  throw new ValidationError("message.send requires 'to'");
3381
4338
  if (!isJsonObject(payload))
3382
4339
  throw new ValidationError('message.send payload must be a dict for V2 encryption');
3383
4340
  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 ?? {});
4341
+ const envelope = await this._buildV2P2PEnvelope({
4342
+ to: toAid,
4343
+ payload,
4344
+ messageId: opts?.messageId,
4345
+ timestamp: opts?.timestamp,
4346
+ protectedHeaders: opts?.protectedHeaders,
4347
+ context: opts?.context,
4348
+ useCache,
4349
+ });
3479
4350
  return this.call('message.send', {
3480
4351
  to: toAid,
3481
4352
  payload: envelope,
@@ -3506,91 +4377,106 @@ export class AUNClient {
3506
4377
  throw new StateError('V2 session not initialized (not connected?)');
3507
4378
  }
3508
4379
  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
4380
  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);
4381
+ let nextAfterSeq = afterSeq || (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
4382
+ let pageCount = 0;
4383
+ const maxPages = 100;
4384
+ while (pageCount < maxPages) {
4385
+ pageCount += 1;
4386
+ const result = await this.call('message.v2.pull', {
4387
+ after_seq: nextAfterSeq,
4388
+ limit,
4389
+ });
4390
+ const messages = (Array.isArray(result?.messages) ? result.messages : []);
4391
+ const seqs = messages
4392
+ .map((msg) => Number(msg.seq ?? 0))
4393
+ .filter((seq) => Number.isFinite(seq) && seq > 0);
4394
+ const pageContigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
4395
+ const pageMaxSeq = seqs.length > 0 ? Math.max(...seqs) : nextAfterSeq;
4396
+ if (ns && seqs.length > 0) {
4397
+ this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
4398
+ }
4399
+ for (const msg of messages) {
4400
+ const seq = Number(msg.seq ?? 0);
4401
+ if (!Number.isFinite(seq) || seq <= 0)
4402
+ continue;
4403
+ const version = String(msg.version ?? 'v2');
4404
+ if (version === 'v1') {
4405
+ const legacy = isJsonObject(msg.legacy_v1) ? msg.legacy_v1 : {};
4406
+ const legacyPayload = legacy.payload;
4407
+ const payloadType = isJsonObject(legacyPayload)
4408
+ ? String(legacyPayload.type ?? '').trim()
4409
+ : '';
4410
+ if (legacyPayload !== undefined && legacyPayload !== null
4411
+ && payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
4412
+ const v1Msg = {
4413
+ message_id: String(msg.message_id ?? ''),
4414
+ from: String(msg.from_aid ?? ''),
4415
+ to: String(legacy.to ?? this._aid ?? ''),
4416
+ seq: msg.seq,
4417
+ type: String(msg.type ?? ''),
4418
+ timestamp: msg.t_server,
4419
+ payload: legacyPayload,
4420
+ encrypted: false,
4421
+ };
4422
+ if (ns)
4423
+ await this._publishPulledMessage('message.received', ns, seq, v1Msg);
4424
+ else
4425
+ await this._publishAppEvent('message.received', v1Msg);
4426
+ decrypted.push(v1Msg);
4427
+ }
4428
+ else {
4429
+ this._clientLog.debug(`message.v2.pull skipping V1 envelope seq=${seq} payload_type=${payloadType || '<none>'} (V1 E2EE removed)`);
4430
+ }
4431
+ continue;
4432
+ }
4433
+ if (version !== 'v2') {
4434
+ this._clientLog.debug(`message.v2.pull skipping non-V2 row seq=${seq} version=${String(msg.version ?? '')}`);
4435
+ continue;
4436
+ }
4437
+ // 跟踪每个旧 SPK 引用的最大 seq(用于消费后销毁)
4438
+ const msgSpkId = String(msg.spk_id ?? '');
4439
+ if (msgSpkId && this._v2Session && !this._v2Session.isCurrentSPK(msgSpkId)) {
4440
+ this._v2Session.trackOldSPKMaxSeq(msgSpkId, seq);
4441
+ }
4442
+ const plaintext = await this._decryptV2Message(msg);
4443
+ if (plaintext === null)
4444
+ continue;
4445
+ if (ns) {
4446
+ await this._publishPulledMessage('message.received', ns, seq, plaintext);
4447
+ decrypted.push(plaintext);
3551
4448
  }
3552
4449
  else {
3553
- this._clientLog.debug(`message.v2.pull skipping V1 envelope seq=${seq} payload_type=${payloadType || '<none>'} (V1 E2EE removed)`);
4450
+ await this._publishAppEvent('message.received', plaintext);
4451
+ decrypted.push(plaintext);
3554
4452
  }
3555
- continue;
3556
- }
3557
- if (version !== 'v2') {
3558
- this._clientLog.debug(`message.v2.pull skipping non-V2 row seq=${seq} version=${String(msg.version ?? '')}`);
3559
- continue;
3560
4453
  }
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);
4454
+ const serverAckSeq = Number(result.server_ack_seq ?? 0);
4455
+ if (ns && Number.isFinite(serverAckSeq) && serverAckSeq > 0) {
4456
+ const contig = this._seqTracker.getContiguousSeq(ns);
4457
+ if (contig < serverAckSeq) {
4458
+ this._clientLog.info(`message.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> server_ack_seq=${serverAckSeq}`);
4459
+ this._seqTracker.forceContiguousSeq(ns, serverAckSeq);
4460
+ }
3565
4461
  }
3566
- // 解密
3567
- const plaintext = await this._decryptV2Message(msg);
3568
- if (plaintext === null)
3569
- continue;
3570
- // 有序 publish + 去重(与 V1 push 路径对齐)
3571
4462
  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) {
4463
+ const ackSeq = this._seqTracker.getContiguousSeq(ns);
4464
+ const contigAdvanced = ackSeq !== pageContigBefore;
4465
+ if (contigAdvanced) {
4466
+ await this._drainOrderedMessages(ns);
4467
+ this._saveSeqTrackerState();
4468
+ }
4469
+ if (messages.length > 0 && contigAdvanced && ackSeq > 0) {
3591
4470
  this._safeAsync(this.ackV2(ackSeq).then(() => undefined));
3592
4471
  }
3593
4472
  }
4473
+ const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
4474
+ if (messages.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
4475
+ break;
4476
+ nextAfterSeq = nextAfter;
4477
+ }
4478
+ if (pageCount >= maxPages) {
4479
+ this._clientLog.warn(`message.v2.pull reached max_pages=${maxPages} after_seq=${nextAfterSeq}`);
3594
4480
  }
3595
4481
  return decrypted;
3596
4482
  }
@@ -3601,9 +4487,17 @@ export class AUNClient {
3601
4487
  */
3602
4488
  async ackV2(upToSeq) {
3603
4489
  const ns = this._aid ? `p2p:${this._aid}` : '';
3604
- const seq = upToSeq ?? (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
4490
+ let seq = upToSeq ?? (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
3605
4491
  if (seq <= 0)
3606
4492
  return { acked: 0 };
4493
+ // ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
4494
+ if (ns) {
4495
+ const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
4496
+ if (maxSeen > 0 && seq > maxSeen) {
4497
+ this._clientLog.warn(`ackV2 clamp: up_to_seq=${seq} > max_seen=${maxSeen}, clamp`);
4498
+ seq = maxSeen;
4499
+ }
4500
+ }
3607
4501
  const raw = await this.call('message.v2.ack', { up_to_seq: seq });
3608
4502
  const result = isJsonObject(raw)
3609
4503
  ? { ...raw }
@@ -3634,8 +4528,8 @@ export class AUNClient {
3634
4528
  }
3635
4529
  return result;
3636
4530
  }
3637
- /** 解密单条 V2 消息(与 Python `_decrypt_v2_message` 对齐) */
3638
- async _decryptV2Message(msg) {
4531
+ /** 解密单条 V2 消息(与 Python `_decrypt_v2_message` 对齐)。缺 sender IK 时先入 pending,后台补齐后重试。 */
4532
+ async _decryptV2Message(msg, allowPending = true) {
3639
4533
  const session = this._v2Session;
3640
4534
  if (!session)
3641
4535
  return null;
@@ -3650,6 +4544,8 @@ export class AUNClient {
3650
4544
  this._clientLog.warn(`V2 decrypt: invalid envelope_json for msg seq=${String(msg.seq)}`);
3651
4545
  return null;
3652
4546
  }
4547
+ const e2eeMeta = v2E2eeMeta(envelope);
4548
+ await this._observeAgentMdFromEnvelope(envelope);
3653
4549
  // 确定 spk_id 和 recipient_key_source
3654
4550
  let spkId = '';
3655
4551
  let recipientKeySource = '';
@@ -3679,33 +4575,73 @@ export class AUNClient {
3679
4575
  }
3680
4576
  }
3681
4577
  }
3682
- // 根据 aad.group_id 判断使用 group SPK 还是 P2P SPK
4578
+ // group_id 只表示群上下文;getGroupDecryptKeys 内部必须按 group SPK -> P2P device SPK -> IK fallback 查找。
3683
4579
  const aad = isJsonObject(envelope.aad) ? envelope.aad : {};
3684
4580
  const groupIdForKeys = String(msg.group_id ?? aad.group_id ?? envelope.group_id ?? '').trim();
4581
+ const undecryptableEvent = groupIdForKeys ? 'group.message_undecryptable' : 'message.undecryptable';
3685
4582
  let ikPriv;
3686
4583
  let spkPriv;
3687
- if (groupIdForKeys) {
3688
- const keys = await session.getGroupDecryptKeys(groupIdForKeys, spkId);
3689
- ikPriv = keys.ikPriv;
3690
- spkPriv = keys.spkPriv;
4584
+ try {
4585
+ if (groupIdForKeys) {
4586
+ const keys = await session.getGroupDecryptKeys(groupIdForKeys, spkId);
4587
+ ikPriv = keys.ikPriv;
4588
+ spkPriv = keys.spkPriv;
4589
+ }
4590
+ else {
4591
+ const keys = await session.getDecryptKeys(spkId);
4592
+ ikPriv = keys.ikPriv;
4593
+ spkPriv = keys.spkPriv;
4594
+ }
3691
4595
  }
3692
- else {
3693
- const keys = await session.getDecryptKeys(spkId);
3694
- ikPriv = keys.ikPriv;
3695
- spkPriv = keys.spkPriv;
4596
+ catch (exc) {
4597
+ this._clientLog.warn(`V2 decrypt: SPK lookup failed seq=${String(msg.seq)} spk_id=${spkId}: ${String(exc)}`);
4598
+ try {
4599
+ const event = {
4600
+ message_id: String(msg.message_id ?? ''),
4601
+ from: String(msg.from_aid ?? ''),
4602
+ to: String(msg.to ?? ''),
4603
+ seq: msg.seq,
4604
+ timestamp: (msg.t_server ?? msg.timestamp),
4605
+ device_id: String(msg.device_id ?? ''),
4606
+ slot_id: String(msg.slot_id ?? ''),
4607
+ _decrypt_error: String(exc),
4608
+ _decrypt_stage: 'spk_lookup',
4609
+ _envelope_type: String(envelope.type ?? ''),
4610
+ _suite: String(envelope.suite ?? ''),
4611
+ _spk_id: spkId,
4612
+ };
4613
+ attachV2EnvelopeMetadata(event, e2eeMeta);
4614
+ await this._dispatcher.publish(undecryptableEvent, event);
4615
+ }
4616
+ catch { /* publish 异常不影响主流程 */ }
4617
+ return null;
3696
4618
  }
3697
4619
  const fromAid = String(msg.from_aid ?? '');
3698
4620
  const senderDeviceId = String(aad.from_device ?? '');
3699
4621
  const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId);
3700
4622
  if (!senderPubDer) {
3701
- this._clientLog.warn(`V2 decrypt: no sender IK for ${fromAid}, cannot verify signature`);
4623
+ this._clientLog.warn(`V2 decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
4624
+ if (allowPending) {
4625
+ this._scheduleV2SenderIKPending({ msg, fromAid, senderDeviceId, groupId: groupIdForKeys });
4626
+ return null;
4627
+ }
3702
4628
  try {
3703
- await this._dispatcher.publish('message.undecryptable', {
4629
+ const event = {
3704
4630
  message_id: String(msg.message_id ?? ''),
3705
4631
  from: fromAid,
4632
+ to: String(msg.to ?? ''),
3706
4633
  seq: msg.seq,
4634
+ timestamp: (msg.t_server ?? msg.timestamp),
4635
+ device_id: String(msg.device_id ?? ''),
4636
+ slot_id: String(msg.slot_id ?? ''),
3707
4637
  _decrypt_error: 'sender_ik_not_found',
3708
- });
4638
+ _decrypt_stage: 'sender_ik',
4639
+ _envelope_type: String(envelope.type ?? ''),
4640
+ _suite: String(envelope.suite ?? ''),
4641
+ _sender_device_id: String(aad.from_device ?? ''),
4642
+ };
4643
+ attachV2EnvelopeMetadata(event, e2eeMeta);
4644
+ await this._dispatcher.publish(undecryptableEvent, event);
3709
4645
  }
3710
4646
  catch { /* publish 异常不影响主流程 */ }
3711
4647
  return null;
@@ -3717,12 +4653,22 @@ export class AUNClient {
3717
4653
  catch (exc) {
3718
4654
  this._clientLog.warn(`V2 decrypt failed for msg seq=${String(msg.seq)}: ${String(exc)}`);
3719
4655
  try {
3720
- await this._dispatcher.publish('message.undecryptable', {
4656
+ const event = {
3721
4657
  message_id: String(msg.message_id ?? ''),
3722
4658
  from: fromAid,
4659
+ to: String(msg.to ?? ''),
3723
4660
  seq: msg.seq,
4661
+ timestamp: (msg.t_server ?? msg.timestamp),
4662
+ device_id: String(msg.device_id ?? ''),
4663
+ slot_id: String(msg.slot_id ?? ''),
3724
4664
  _decrypt_error: String(exc),
3725
- });
4665
+ _decrypt_stage: 'decrypt',
4666
+ _envelope_type: String(envelope.type ?? ''),
4667
+ _suite: String(envelope.suite ?? ''),
4668
+ _sender_device_id: String(aad.from_device ?? ''),
4669
+ };
4670
+ attachV2EnvelopeMetadata(event, e2eeMeta);
4671
+ await this._dispatcher.publish(undecryptableEvent, event);
3726
4672
  }
3727
4673
  catch { /* publish 异常不影响主流程 */ }
3728
4674
  return null;
@@ -3748,7 +4694,8 @@ export class AUNClient {
3748
4694
  this._clientLog.debug(`V2 SPK rotation failed (non-fatal): ${exc}`);
3749
4695
  });
3750
4696
  }
3751
- return {
4697
+ const e2ee = v2E2eeMeta(envelope);
4698
+ const result = {
3752
4699
  message_id: String(msg.message_id ?? ''),
3753
4700
  from: fromAid,
3754
4701
  to: this._aid ?? '',
@@ -3756,8 +4703,10 @@ export class AUNClient {
3756
4703
  t_server: msg.t_server,
3757
4704
  payload: plaintext,
3758
4705
  encrypted: true,
3759
- e2ee: v2E2eeMeta(envelope),
4706
+ e2ee: e2ee,
3760
4707
  };
4708
+ attachV2EnvelopeMetadata(result, e2ee);
4709
+ return result;
3761
4710
  }
3762
4711
  /**
3763
4712
  * V2 Group 加密发送(推测性:用缓存 bootstrap 直接发,失败刷新重试一次)。
@@ -3843,32 +4792,53 @@ export class AUNClient {
3843
4792
  if (!gid)
3844
4793
  throw new ValidationError('group.pull requires group_id');
3845
4794
  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
4795
  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') {
4796
+ let nextAfterSeq = afterSeq || this._seqTracker.getContiguousSeq(ns);
4797
+ let pageCount = 0;
4798
+ const maxPages = 100;
4799
+ while (pageCount < maxPages) {
4800
+ pageCount += 1;
4801
+ const result = await this.call('group.v2.pull', {
4802
+ group_id: gid,
4803
+ after_seq: nextAfterSeq,
4804
+ limit,
4805
+ });
4806
+ const messages = (Array.isArray(result?.messages) ? result.messages : []);
4807
+ const seqs = messages
4808
+ .map((msg) => Number(msg.seq ?? 0))
4809
+ .filter((seq) => Number.isFinite(seq) && seq > 0);
4810
+ const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
4811
+ const pageMaxSeq = seqs.length > 0 ? Math.max(...seqs) : nextAfterSeq;
4812
+ if (seqs.length > 0) {
4813
+ this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
4814
+ }
4815
+ for (const msg of messages) {
4816
+ const seq = Number(msg.seq ?? 0);
4817
+ if (!Number.isFinite(seq) || seq <= 0)
4818
+ continue;
4819
+ const version = String(msg.version ?? 'v2');
4820
+ if (version === 'v1') {
4821
+ const payload = msg.payload;
4822
+ const payloadObj = isJsonObject(payload) ? payload : null;
4823
+ if (payloadObj) {
4824
+ const payloadType = String(payloadObj.type ?? '').trim();
4825
+ if (payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
4826
+ const v1Msg = {
4827
+ message_id: String(msg.message_id ?? ''),
4828
+ from: String(msg.from_aid ?? ''),
4829
+ group_id: gid,
4830
+ seq: msg.seq,
4831
+ type: String(msg.type ?? ''),
4832
+ timestamp: msg.t_server,
4833
+ payload,
4834
+ encrypted: false,
4835
+ };
4836
+ await this._publishPulledMessage('group.message_created', ns, seq, v1Msg);
4837
+ decrypted.push(v1Msg);
4838
+ continue;
4839
+ }
4840
+ }
4841
+ else if (payload !== undefined && payload !== null) {
3872
4842
  const v1Msg = {
3873
4843
  message_id: String(msg.message_id ?? ''),
3874
4844
  from: String(msg.from_aid ?? ''),
@@ -3883,51 +4853,45 @@ export class AUNClient {
3883
4853
  decrypted.push(v1Msg);
3884
4854
  continue;
3885
4855
  }
4856
+ this._clientLog.debug(`group.v2.pull skipping V1 envelope group=${gid} seq=${seq} payload_type=${payloadObj ? String(payloadObj.type ?? '') : '<none>'} (V1 E2EE removed)`);
4857
+ continue;
3886
4858
  }
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);
4859
+ if (version !== 'v2') {
4860
+ this._clientLog.debug(`group.v2.pull skipping non-V2 row group=${gid} seq=${seq} version=${String(msg.version ?? '')}`);
3900
4861
  continue;
3901
4862
  }
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;
4863
+ const plaintext = await this._decryptV2Message(msg);
4864
+ if (plaintext === null)
4865
+ continue;
4866
+ plaintext.group_id = gid;
4867
+ await this._publishPulledMessage('group.message_created', ns, seq, plaintext);
4868
+ decrypted.push(plaintext);
3908
4869
  }
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);
4870
+ const cursor = isJsonObject(result.cursor) ? result.cursor : null;
4871
+ const serverAckSeq = Number(cursor?.current_seq ?? 0);
4872
+ if (Number.isFinite(serverAckSeq) && serverAckSeq > 0) {
4873
+ const contig = this._seqTracker.getContiguousSeq(ns);
4874
+ if (contig < serverAckSeq) {
4875
+ this._clientLog.info(`group.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> cursor.current_seq=${serverAckSeq}`);
4876
+ this._seqTracker.forceContiguousSeq(ns, serverAckSeq);
4877
+ }
3923
4878
  }
3924
4879
  const ackSeq = this._seqTracker.getContiguousSeq(ns);
3925
- if (ackSeq !== contigBefore) {
4880
+ const contigAdvanced = ackSeq !== pageContigBefore;
4881
+ if (contigAdvanced) {
4882
+ await this._drainOrderedMessages(ns);
3926
4883
  this._saveSeqTrackerState();
3927
- if (ackSeq > 0) {
3928
- this._safeAsync(this.ackGroupV2(gid, ackSeq).then(() => undefined));
3929
- }
3930
4884
  }
4885
+ if (messages.length > 0 && contigAdvanced && ackSeq > 0) {
4886
+ this._safeAsync(this.ackGroupV2(gid, ackSeq).then(() => undefined));
4887
+ }
4888
+ const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
4889
+ if (messages.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
4890
+ break;
4891
+ nextAfterSeq = nextAfter;
4892
+ }
4893
+ if (pageCount >= maxPages) {
4894
+ this._clientLog.warn(`group.v2.pull reached max_pages=${maxPages} group=${gid} after_seq=${nextAfterSeq}`);
3931
4895
  }
3932
4896
  return decrypted;
3933
4897
  }
@@ -3942,9 +4906,15 @@ export class AUNClient {
3942
4906
  if (!gid)
3943
4907
  throw new ValidationError('group.ack_messages requires group_id');
3944
4908
  const ns = `group:${gid}`;
3945
- const seq = upToSeq ?? this._seqTracker.getContiguousSeq(ns);
4909
+ let seq = upToSeq ?? this._seqTracker.getContiguousSeq(ns);
3946
4910
  if (seq <= 0)
3947
4911
  return { acked: 0 };
4912
+ // ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
4913
+ const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
4914
+ if (maxSeen > 0 && seq > maxSeen) {
4915
+ this._clientLog.warn(`ackGroupV2 clamp: group=${gid} up_to_seq=${seq} > max_seen=${maxSeen}, clamp`);
4916
+ seq = maxSeen;
4917
+ }
3948
4918
  return this.call('group.v2.ack', { group_id: gid, up_to_seq: seq });
3949
4919
  }
3950
4920
  // ── V2 thought(per-device wrap,服务端透传,不持久化)──────────
@@ -3984,33 +4954,27 @@ export class AUNClient {
3984
4954
  }
3985
4955
  const targets = [];
3986
4956
  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({
4957
+ const devId = getV2DeviceId(dev);
4958
+ const target = await this._v2BuildTargetFromDevice({
4959
+ dev,
3991
4960
  aid: to,
3992
- deviceId: String(dev.device_id ?? ''),
4961
+ deviceId: devId.value,
3993
4962
  role: 'peer',
3994
- keySource: String(dev.key_source ?? 'peer_device_prekey'),
3995
- ikPkDer: ikDer,
3996
- spkPkDer: spkDer,
3997
- spkId: String(dev.spk_id ?? ''),
4963
+ defaultKeySource: 'peer_device_prekey',
3998
4964
  });
4965
+ if (target)
4966
+ targets.push(target);
3999
4967
  }
4000
4968
  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({
4969
+ const target = await this._v2BuildTargetFromDevice({
4970
+ dev,
4006
4971
  aid: String(dev.aid ?? ''),
4007
4972
  deviceId: String(dev.device_id ?? ''),
4008
4973
  role: 'audit',
4009
- keySource: String(dev.key_source ?? 'peer_device_prekey'),
4010
- ikPkDer: ikDer,
4011
- spkPkDer: spkDer,
4012
- spkId: String(dev.spk_id ?? ''),
4974
+ defaultKeySource: 'peer_device_prekey',
4013
4975
  });
4976
+ if (target)
4977
+ targets.push(target);
4014
4978
  }
4015
4979
  // self-sync:自己其它设备
4016
4980
  if (this._aid && this._aid !== to) {
@@ -4032,20 +4996,18 @@ export class AUNClient {
4032
4996
  }
4033
4997
  }
4034
4998
  for (const dev of selfDevices) {
4035
- const devId = String(dev.owner_device_id ?? dev.device_id ?? '');
4036
- if (devId === this._deviceId)
4999
+ const devId = getV2DeviceId(dev);
5000
+ if (!devId.present || devId.value === this._deviceId)
4037
5001
  continue;
4038
- const ikDer = _v2B64ToBytes(String(dev.ik_pk ?? ''));
4039
- const spkDer = dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined;
4040
- targets.push({
5002
+ const target = await this._v2BuildTargetFromDevice({
5003
+ dev,
4041
5004
  aid: this._aid,
4042
- deviceId: devId,
5005
+ deviceId: devId.value,
4043
5006
  role: 'self_sync',
4044
- keySource: String(dev.key_source ?? 'peer_device_prekey'),
4045
- ikPkDer: ikDer,
4046
- spkPkDer: spkDer,
4047
- spkId: String(dev.spk_id ?? ''),
5007
+ defaultKeySource: 'peer_device_prekey',
4048
5008
  });
5009
+ if (target)
5010
+ targets.push(target);
4049
5011
  }
4050
5012
  }
4051
5013
  catch (exc) {
@@ -4139,6 +5101,9 @@ export class AUNClient {
4139
5101
  allDevices = (Array.isArray(bs?.devices) ? bs.devices : []);
4140
5102
  epoch = Number(bs?.epoch ?? 0);
4141
5103
  auditRecipientsRaw = (Array.isArray(bs?.audit_recipients) ? bs.audit_recipients : []);
5104
+ await this._v2CheckFork(groupId, String(bs?.state_chain ?? ''));
5105
+ await this._v2VerifyStateSignature(groupId, bs);
5106
+ await this._publishV2GroupSecurityLevel(groupId, bs);
4142
5107
  stateCommitment = {
4143
5108
  state_version: Number(bs?.state_version ?? 0) || 0,
4144
5109
  state_hash: String(bs?.state_hash_signed ?? bs?.state_hash ?? ''),
@@ -4153,9 +5118,6 @@ export class AUNClient {
4153
5118
  stateCommitment: stateCommitment,
4154
5119
  });
4155
5120
  }
4156
- await this._v2CheckFork(groupId, String(bs?.state_chain ?? ''));
4157
- await this._v2VerifyStateSignature(groupId, bs);
4158
- await this._publishV2GroupSecurityLevel(groupId, bs);
4159
5121
  // lazy sync 触发:发现 pending members 时异步发起提案
4160
5122
  const pendingAdds = Array.isArray(bs?.pending_adds) ? bs.pending_adds : [];
4161
5123
  if (pendingAdds.length > 0 && this._v2Session) {
@@ -4168,36 +5130,30 @@ export class AUNClient {
4168
5130
  const targets = [];
4169
5131
  for (const dev of allDevices) {
4170
5132
  const devAid = String(dev.aid ?? '');
4171
- const devId = String(dev.device_id ?? '');
4172
- if (devAid === this._aid && devId === this._deviceId)
5133
+ const devId = getV2DeviceId(dev);
5134
+ if (devAid === this._aid && devId.present && devId.value === this._deviceId)
4173
5135
  continue;
4174
- const ikDer = _v2B64ToBytes(String(dev.ik_pk ?? ''));
4175
- const spkDer = dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined;
4176
5136
  const role = devAid === this._aid ? 'self_sync' : 'member';
4177
- targets.push({
5137
+ const target = await this._v2BuildTargetFromDevice({
5138
+ dev,
4178
5139
  aid: devAid,
4179
- deviceId: devId,
5140
+ deviceId: devId.value,
4180
5141
  role,
4181
- keySource: String(dev.key_source ?? 'peer_device_prekey'),
4182
- ikPkDer: ikDer,
4183
- spkPkDer: spkDer,
4184
- spkId: String(dev.spk_id ?? ''),
5142
+ defaultKeySource: 'peer_device_prekey',
4185
5143
  });
5144
+ if (target)
5145
+ targets.push(target);
4186
5146
  }
4187
5147
  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({
5148
+ const target = await this._v2BuildTargetFromDevice({
5149
+ dev,
4193
5150
  aid: String(dev.aid ?? ''),
4194
5151
  deviceId: String(dev.device_id ?? ''),
4195
5152
  role: 'audit',
4196
- keySource: String(dev.key_source ?? 'peer_device_prekey'),
4197
- ikPkDer: ikDer,
4198
- spkPkDer: spkDer,
4199
- spkId: String(dev.spk_id ?? ''),
5153
+ defaultKeySource: 'peer_device_prekey',
4200
5154
  });
5155
+ if (target)
5156
+ targets.push(target);
4201
5157
  }
4202
5158
  if (targets.length === 0) {
4203
5159
  throw new E2EEError(`V2 group: no target devices for ${groupId}`);
@@ -4294,26 +5250,33 @@ export class AUNClient {
4294
5250
  }
4295
5251
  }
4296
5252
  }
4297
- // 根据 aad.group_id 判断使用 group SPK 还是 P2P SPK
4298
5253
  const aad = opts.envelope.aad ?? {};
4299
5254
  const groupIdForKeys = String(aad.group_id ?? opts.envelope.group_id ?? '').trim();
4300
5255
  let ikPriv;
4301
5256
  let spkPriv;
4302
- if (groupIdForKeys) {
4303
- const keys = await session.getGroupDecryptKeys(groupIdForKeys, spkId);
4304
- ikPriv = keys.ikPriv;
4305
- spkPriv = keys.spkPriv;
5257
+ // group_id 只表示群上下文;group lookup 内部按 group SPK -> P2P device SPK -> IK fallback。
5258
+ try {
5259
+ if (groupIdForKeys) {
5260
+ const keys = await session.getGroupDecryptKeys(groupIdForKeys, spkId);
5261
+ ikPriv = keys.ikPriv;
5262
+ spkPriv = keys.spkPriv;
5263
+ }
5264
+ else {
5265
+ const keys = await session.getDecryptKeys(spkId);
5266
+ ikPriv = keys.ikPriv;
5267
+ spkPriv = keys.spkPriv;
5268
+ }
4306
5269
  }
4307
- else {
4308
- const keys = await session.getDecryptKeys(spkId);
4309
- ikPriv = keys.ikPriv;
4310
- spkPriv = keys.spkPriv;
5270
+ catch (exc) {
5271
+ this._clientLog.warn(`V2 thought decrypt: SPK lookup failed from=${opts.fromAid}, group=${groupIdForKeys || '<p2p>'}, spk_id=${spkId || '<empty>'}: ${exc}`);
5272
+ return null;
4311
5273
  }
4312
5274
  const fromAid = String(opts.fromAid || aad.from || '').trim();
4313
5275
  const senderDeviceId = String(aad.from_device ?? '');
4314
5276
  const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId);
4315
5277
  if (!senderPubDer) {
4316
5278
  this._clientLog.warn(`V2 thought decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
5279
+ this._scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupIdForKeys);
4317
5280
  return null;
4318
5281
  }
4319
5282
  try {
@@ -4367,11 +5330,8 @@ export class AUNClient {
4367
5330
  const signPayloadBytes = new TextEncoder().encode(signPayload);
4368
5331
  const sigBytes = base64ToUint8(stateSignature);
4369
5332
  // 验签缓存检查
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);
5333
+ const cacheData = _v2LengthPrefixedBytes(new TextEncoder().encode(actorAid), signPayloadBytes, sigBytes);
5334
+ const cacheHashBuf = await crypto.subtle.digest('SHA-256', cacheData.slice().buffer);
4375
5335
  const cacheHashArr = new Uint8Array(cacheHashBuf);
4376
5336
  let cacheKey = '';
4377
5337
  for (let i = 0; i < cacheHashArr.length; i++)
@@ -4599,8 +5559,9 @@ export class AUNClient {
4599
5559
  const candidates = [];
4600
5560
  for (const dev of devices) {
4601
5561
  const aid = String(dev.aid ?? '').trim();
5562
+ const hasDeviceId = 'device_id' in dev;
4602
5563
  const deviceId = String(dev.device_id ?? '').trim();
4603
- if (aid && deviceId && onlineAdminAids.has(aid)) {
5564
+ if (aid && hasDeviceId && onlineAdminAids.has(aid)) {
4604
5565
  candidates.push(`${aid}\x1f${deviceId}`);
4605
5566
  }
4606
5567
  }
@@ -4616,7 +5577,7 @@ export class AUNClient {
4616
5577
  this._clientLog.debug(`V2 auto propose leader elected: group=${groupId} leader=${leader}`);
4617
5578
  return true;
4618
5579
  }
4619
- const delayMs = await this._v2LeaderDelayMs(`${groupId}\x00${myKey}`);
5580
+ const delayMs = await this._v2LeaderDelayMs(_v2LengthPrefixedTextKey(groupId, myKey));
4620
5581
  this._clientLog.debug(`V2 auto propose non-leader delay: group=${groupId} leader=${leader} self=${myKey} delay_ms=${delayMs}`);
4621
5582
  await this._sleep(delayMs);
4622
5583
  return true;
@@ -4929,30 +5890,42 @@ export class AUNClient {
4929
5890
  const pushFrom = isJsonObject(data) ? String(data.from_aid ?? '') : '';
4930
5891
  const pushMsgId = isJsonObject(data) ? String(data.message_id ?? '') : '';
4931
5892
  const envelopeJson = isJsonObject(data) ? data.envelope_json : undefined;
5893
+ const hasPayload = !!envelopeJson;
4932
5894
  const ns = this._aid ? `p2p:${this._aid}` : '';
4933
5895
  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}`);
5896
+ this._clientLog.debug(`_onV2PushNotification: push_seq=${pushSeq || 'null'} push_from=${pushFrom} push_msg_id=${pushMsgId} has_payload=${hasPayload} contiguous_seq=${contigBefore}`);
5897
+ // ── Push 修上界:只更新 maxSeenSeq,不动 contiguousSeq ──
5898
+ if (pushSeq > 0 && ns) {
5899
+ this._seqTracker.updateMaxSeen(ns, pushSeq);
5900
+ if (contigBefore === pushSeq) {
5901
+ this._clientLog.debug(`_onV2PushNotification: push seq=${pushSeq} already covered by contiguous_seq=${contigBefore}, ignore duplicate push`);
5902
+ return;
5903
+ }
5904
+ contigBefore = this._repairPushContiguousBound(ns, pushSeq, hasPayload, '_raw.peer.v2.message_received');
5905
+ }
4935
5906
  // ── 带 payload 的 push:尝试就地解密 ──
4936
- if (envelopeJson && pushSeq > 0 && ns) {
5907
+ if (hasPayload && pushSeq > 0 && ns) {
4937
5908
  try {
4938
5909
  const decrypted = await this._decryptV2Message(data);
4939
5910
  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);
5911
+ // 解密成功:把 pushSeq 加入 receivedSeqs,让 _tryAdvance 自然推进
5912
+ const needPull = this._seqTracker.onMessageSeq(ns, pushSeq);
5913
+ const published = await this._publishOrderedMessage('message.received', ns, pushSeq, decrypted);
4946
5914
  const newContig = this._seqTracker.getContiguousSeq(ns);
4947
5915
  if (newContig !== contigBefore) {
4948
5916
  this._saveSeqTrackerState();
4949
5917
  }
4950
5918
  if (newContig > 0 && newContig !== contigBefore) {
4951
- this._transport.call('message.v2.ack', { up_to_seq: newContig })
5919
+ const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
5920
+ const ackSeq = maxSeen > 0 ? Math.min(newContig, maxSeen) : newContig;
5921
+ this.call('message.v2.ack', { up_to_seq: ackSeq })
4952
5922
  .catch(e => this._clientLog.debug(`V2 P2P push-ack failed: ${e}`));
4953
5923
  }
4954
5924
  this._clientLog.debug(`_onV2PushNotification: push 带 payload 解密成功, contiguous_seq=${contigBefore}->${newContig} push_seq=${pushSeq}`);
4955
- return;
5925
+ if (!needPull && (published || newContig >= pushSeq || pushSeq <= contigBefore)) {
5926
+ return;
5927
+ }
5928
+ this._clientLog.debug(`_onV2PushNotification: payload push seq=${pushSeq} 因空洞挂起,继续 pull 补齐 after_seq=${newContig}`);
4956
5929
  }
4957
5930
  }
4958
5931
  catch (exc) {
@@ -4966,14 +5939,6 @@ export class AUNClient {
4966
5939
  // 正确做法:保持 contiguousSeq 不变,用它作为 pull 的 after_seq;
4967
5940
  // pull 成功 + 解密成功后再由 pull 路径推进 contiguousSeq。
4968
5941
  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
5942
  // 纯通知:不更新 contiguousSeq,由 pull 结果驱动推进
4978
5943
  this._clientLog.debug(`_onV2PushNotification: 纯通知 push_seq=${pushSeq} > contiguous_seq=${contigBefore}, 触发 pull(after_seq=${contigBefore})`);
4979
5944
  }