@agentunion/fastaun 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/_packed_docs/CHANGELOG.md +21 -0
  3. package/_packed_docs/INDEX.md +81 -0
  4. package/_packed_docs/KITE_DOCS_GUIDE.md +55 -0
  5. package/_packed_docs/agent.md//350/277/234/347/250/213agent.md/347/274/223/345/255/230/344/270/216etag/351/200/217/344/274/240/346/226/271/346/241/210.md +328 -0
  6. package/_packed_docs/cli/AUN-CLI/350/256/276/350/256/241/346/226/207/346/241/243.md +686 -0
  7. package/_packed_docs/design//350/267/250/350/257/255/350/250/200/345/256/271/345/231/250E2E/346/265/213/350/257/225/346/226/271/346/241/210.md +665 -0
  8. package/_packed_docs/protocol//351/231/204/345/275/225N-/345/210/206/345/270/203/345/274/217Trace/345/215/217/350/256/256.md +257 -0
  9. package/_packed_docs/sdk/01-/345/277/253/351/200/237/345/274/200/345/247/213.md +5 -5
  10. package/_packed_docs/sdk/02-WebSocket/345/215/217/350/256/256.md +1 -1
  11. package/_packed_docs/sdk/03-/346/240/270/345/277/203/346/246/202/345/277/265.md +2 -2
  12. package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +454 -429
  13. package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +1410 -1398
  14. package/_packed_docs/sdk/07-/351/224/231/350/257/257/345/244/204/347/220/206.md +19 -1
  15. package/_packed_docs/sdk/08-/346/234/200/344/275/263/345/256/236/350/267/265.md +20 -5
  16. package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +8 -8
  17. package/_packed_docs/sdk/E2EE_V2/346/266/210/346/201/257/351/200/232/344/277/241/346/227/266/345/272/217/345/233/276.md +171 -0
  18. package/_packed_docs/sdk/INDEX.md +22 -22
  19. package/_packed_docs/sdk/README.md +3 -3
  20. package/dist/auth.d.ts +41 -8
  21. package/dist/auth.js +380 -101
  22. package/dist/auth.js.map +1 -1
  23. package/dist/client.d.ts +60 -19
  24. package/dist/client.js +1049 -443
  25. package/dist/client.js.map +1 -1
  26. package/dist/errors.d.ts +4 -0
  27. package/dist/errors.js +7 -0
  28. package/dist/errors.js.map +1 -1
  29. package/dist/events.d.ts +9 -0
  30. package/dist/events.js +42 -12
  31. package/dist/events.js.map +1 -1
  32. package/dist/index.d.ts +2 -2
  33. package/dist/index.js +2 -2
  34. package/dist/index.js.map +1 -1
  35. package/dist/keystore/file.d.ts +20 -0
  36. package/dist/keystore/file.js +91 -1
  37. package/dist/keystore/file.js.map +1 -1
  38. package/dist/namespaces/auth.d.ts +33 -4
  39. package/dist/namespaces/auth.js +170 -65
  40. package/dist/namespaces/auth.js.map +1 -1
  41. package/dist/secret-store/file-store.d.ts +21 -2
  42. package/dist/secret-store/file-store.js +166 -11
  43. package/dist/secret-store/file-store.js.map +1 -1
  44. package/dist/tools/cross-sdk-agent.js +2 -2
  45. package/dist/tools/cross-sdk-agent.js.map +1 -1
  46. package/dist/transport.d.ts +8 -1
  47. package/dist/transport.js +151 -32
  48. package/dist/transport.js.map +1 -1
  49. package/dist/v2/e2ee/decrypt.js +1 -1
  50. package/dist/v2/e2ee/decrypt.js.map +1 -1
  51. package/dist/v2/e2ee/encrypt-p2p.js +3 -2
  52. package/dist/v2/e2ee/encrypt-p2p.js.map +1 -1
  53. package/dist/v2/session/session.d.ts +1 -0
  54. package/dist/v2/session/session.js +7 -1
  55. package/dist/v2/session/session.js.map +1 -1
  56. package/package.json +46 -46
package/dist/client.js CHANGED
@@ -36,6 +36,9 @@ import { encryptP2PMessage, encryptGroupMessage, decryptMessage, } from './v2/e2
36
36
  import { ecdsaVerifyRaw } from './v2/crypto/ecdsa.js';
37
37
  import { computeStateCommitment } from './v2/state/index.js';
38
38
  import { isJsonObject, } from './types.js';
39
+ function isPromiseLike(value) {
40
+ return Boolean(value && typeof value.then === 'function');
41
+ }
39
42
  /**
40
43
  * 递归排序键的 JSON 序列化(Canonical JSON for AUN)
41
44
  * 等价于 Python json.dumps(sort_keys=True, separators=(",",":"), ensure_ascii=False)
@@ -197,6 +200,59 @@ const SIGNED_METHODS = new Set([
197
200
  ]);
198
201
  /** peer 证书缓存 TTL(1 小时) */
199
202
  const PEER_CERT_CACHE_TTL = 3600;
203
+ function normalizeV2WrapPolicy(raw) {
204
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
205
+ return { explicit: false, version: '', protocol: '', scope: 'device' };
206
+ }
207
+ const obj = raw;
208
+ let protocol = String(obj.protocol ?? '').trim().toUpperCase();
209
+ if (protocol !== '1DH' && protocol !== '3DH')
210
+ protocol = '';
211
+ let scope = String(obj.scope ?? '').trim().toLowerCase();
212
+ if (scope !== 'aid' && scope !== 'device') {
213
+ scope = obj.per_aid_wrap === true ? 'aid' : 'device';
214
+ }
215
+ if (scope === 'aid')
216
+ protocol = '1DH';
217
+ return {
218
+ explicit: true,
219
+ version: String(obj.version ?? ''),
220
+ protocol,
221
+ scope: scope,
222
+ };
223
+ }
224
+ function v2WrapCapabilities() {
225
+ return {
226
+ version: 'v2.1',
227
+ protocols: ['1DH', '3DH'],
228
+ scopes: ['aid', 'device'],
229
+ per_aid_wrap: true,
230
+ per_device_wrap: true,
231
+ };
232
+ }
233
+ function applyV2WrapPolicyToTargets(targets, policy) {
234
+ if (!policy.explicit)
235
+ return targets;
236
+ const out = [];
237
+ const seen = new Set();
238
+ for (const target of targets) {
239
+ const row = { ...target };
240
+ if (policy.protocol === '1DH') {
241
+ row.keySource = 'aid_master';
242
+ row.spkPkDer = undefined;
243
+ row.spkId = '';
244
+ }
245
+ if (policy.scope === 'aid') {
246
+ const key = `${row.aid}\x1f${row.role}`;
247
+ if (seen.has(key))
248
+ continue;
249
+ seen.add(key);
250
+ row.deviceId = '';
251
+ }
252
+ out.push(row);
253
+ }
254
+ return out;
255
+ }
200
256
  function _v2LeftPad32(b) {
201
257
  if (b.length === 32)
202
258
  return b;
@@ -364,15 +420,14 @@ export class AUNClient {
364
420
  _defaultConnectDeliveryMode;
365
421
  /** peer 证书缓存 */
366
422
  _certCache = new Map();
367
- // AgentMDs 目录:{agentMdPath}/list.json 保存元数据,{agentMdPath}/{aid}/agent.md 保存正文。
423
+ // AIDs 目录:{agentMdPath}/{aid}/agentmd.json 保存元数据,{agentMdPath}/{aid}/agent.md 保存正文。
368
424
  _agentMdPath = '';
369
425
  _localAgentMdPath = '';
370
426
  _localAgentMdEtag = '';
371
427
  // gateway 在 RPC envelope._meta.agent_md_etag 注入的服务端 etag;纯观察,无下游依赖。
372
428
  _remoteAgentMdEtag = '';
373
429
  _agentMdCache = new Map();
374
- _agentMdFetchInflight = new Set();
375
- _agentMdLastListRebuilt = false;
430
+ _agentMdFetchInflight = new Map();
376
431
  /** 消息序列号跟踪器(群消息 + P2P 空洞检测) */
377
432
  _seqTracker = new SeqTracker();
378
433
  _seqTrackerContext = null;
@@ -380,6 +435,11 @@ export class AUNClient {
380
435
  _groupSynced = new Set();
381
436
  /** 补洞去重:已完成/进行中的 key -> 开始时间戳,防止重复 pull 同一区间 */
382
437
  _gapFillDone = new Map();
438
+ /** pull gate:按消费单元串行化 public pull / gap fill / push auto-pull。 */
439
+ _pullGates = new Map();
440
+ _pullResponseKeys = new Map();
441
+ /** 当前异步调用栈是否属于通知触发的后台 RPC。 */
442
+ _backgroundRpcDepth = 0;
383
443
  /** 已发布到应用层的 seq 集合(按命名空间),补洞路径 publish 前检查以避免重复分发 */
384
444
  _pushedSeqs = new Map();
385
445
  /** 已解密但因 seq 空洞暂缓发布的应用层消息(按 namespace -> seq) */
@@ -409,10 +469,9 @@ export class AUNClient {
409
469
  /** 最近一次已成功提交的 membership_snapshot;相同快照直接跳过。 */
410
470
  _v2AutoProposeLastSnapshot = new Map();
411
471
  _v2LazyProposeTriggered = new Map();
412
- _v2PullInflight = false;
413
- _v2PullPending = false;
414
472
  static V2_BOOTSTRAP_TTL_MS = 60 * 60 * 1000;
415
473
  static V2_RETRYABLE_CODES = new Set([-33011, -33012, -33050, -33052, -33054]);
474
+ static PULL_GATE_STALE_MS = 3000;
416
475
  static V2_SIG_CACHE_TTL_MS = 60 * 60 * 1000;
417
476
  static V2_SIG_CACHE_MAX = 16_384;
418
477
  _reconnectActive = false;
@@ -426,7 +485,7 @@ export class AUNClient {
426
485
  const rawConfig = { ...(config ?? {}) };
427
486
  this._configModel = configFromMap(rawConfig);
428
487
  const initAid = String(rawConfig.aid ?? '').trim() || null;
429
- this._agentMdPath = path.join(this._configModel.aunPath, 'AgentMDs');
488
+ this._agentMdPath = path.join(this._configModel.aunPath, 'AIDs');
430
489
  this.config = {
431
490
  aun_path: this._configModel.aunPath,
432
491
  root_ca_path: this._configModel.rootCaPath,
@@ -456,6 +515,19 @@ export class AUNClient {
456
515
  secretStoreLogger: this._logger.for('aun_core.secret-store'),
457
516
  });
458
517
  this._keystore = keystore;
518
+ // 启动时被动清理 registerAid 留下的孤儿临时目录(>10 分钟)
519
+ try {
520
+ const cleanup = keystore.cleanupPendingDirs;
521
+ if (typeof cleanup === 'function') {
522
+ const removed = cleanup.call(keystore, 600_000);
523
+ if (removed > 0) {
524
+ this._clientLog.info(`_pending cleanup removed=${removed}`);
525
+ }
526
+ }
527
+ }
528
+ catch (err) {
529
+ this._clientLog.warn(`_pending cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
530
+ }
459
531
  this._slotId = '';
460
532
  this._connectDeliveryMode = normalizeDeliveryModeConfig({ mode: 'fanout' });
461
533
  this._defaultConnectDeliveryMode = { ...this._connectDeliveryMode };
@@ -547,6 +619,23 @@ export class AUNClient {
547
619
  if (!target) {
548
620
  throw new ValidationError('fetchAgentMd requires aid (or local AID)');
549
621
  }
622
+ return await this._startAgentMdFetchTask(target);
623
+ }
624
+ async _startAgentMdFetchTask(target) {
625
+ const existing = this._agentMdFetchInflight.get(target);
626
+ if (existing) {
627
+ return await existing;
628
+ }
629
+ const task = this._fetchAgentMdOnce(target);
630
+ this._agentMdFetchInflight.set(target, task);
631
+ task.finally(() => {
632
+ if (this._agentMdFetchInflight.get(target) === task) {
633
+ this._agentMdFetchInflight.delete(target);
634
+ }
635
+ }).catch(() => undefined);
636
+ return await task;
637
+ }
638
+ async _fetchAgentMdOnce(target) {
550
639
  const content = await this.auth.downloadAgentMd(target);
551
640
  const signature = await this.auth.verifyAgentMd(content, { aid: target });
552
641
  const isSelf = target === (this._aid ?? '');
@@ -585,11 +674,11 @@ export class AUNClient {
585
674
  };
586
675
  }
587
676
  /**
588
- * 设置 agent.md 本地存储根目录;为空时恢复默认 {aun_path}/AgentMDs
677
+ * 设置 agent.md 本地存储根目录;为空时恢复默认 {aun_path}/AIDs
589
678
  */
590
679
  setAgentMdPath(root) {
591
680
  const raw = String(root ?? '').trim();
592
- const next = raw || path.join(this._configModel.aunPath, 'AgentMDs');
681
+ const next = raw || path.join(this._configModel.aunPath, 'AIDs');
593
682
  fs.mkdirSync(next, { recursive: true });
594
683
  this._agentMdPath = next;
595
684
  this._agentMdCache.clear();
@@ -662,15 +751,15 @@ export class AUNClient {
662
751
  return target;
663
752
  }
664
753
  _agentMdRoot() {
665
- const root = this._agentMdPath || path.join(this._configModel.aunPath, 'AgentMDs');
754
+ const root = this._agentMdPath || path.join(this._configModel.aunPath, 'AIDs');
666
755
  fs.mkdirSync(root, { recursive: true });
667
756
  return root;
668
757
  }
669
758
  _agentMdFilePath(aid) {
670
759
  return path.join(this._agentMdRoot(), this._agentMdSafeAid(aid), 'agent.md');
671
760
  }
672
- _agentMdListPath() {
673
- return path.join(this._agentMdRoot(), 'list.json');
761
+ _agentMdMetaPath(aid) {
762
+ return path.join(this._agentMdRoot(), this._agentMdSafeAid(aid), 'agentmd.json');
674
763
  }
675
764
  _atomicWriteText(filePath, content) {
676
765
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -712,8 +801,9 @@ export class AUNClient {
712
801
  _sleepSync(ms) {
713
802
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
714
803
  }
715
- _withAgentMdListLock(fn) {
716
- const lockPath = path.join(this._agentMdRoot(), 'list.json.lock');
804
+ _withAgentMdRecordLock(aid, fn) {
805
+ const lockPath = path.join(path.dirname(this._agentMdMetaPath(aid)), 'agentmd.json.lock');
806
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
717
807
  const deadline = Date.now() + 5000;
718
808
  let fd = null;
719
809
  while (fd === null) {
@@ -749,93 +839,39 @@ export class AUNClient {
749
839
  catch { /* ignore */ }
750
840
  }
751
841
  }
752
- _writeAgentMdListUnlocked(records) {
753
- const sorted = {};
754
- for (const aid of Object.keys(records).sort())
755
- sorted[aid] = records[aid];
756
- this._atomicWriteText(this._agentMdListPath(), `${JSON.stringify({ version: 1, updated_at: Date.now(), records: sorted }, null, 2)}\n`);
757
- }
758
- _normalizeAgentMdList(data) {
759
- const records = {};
760
- let iterable = [];
761
- if (isJsonObject(data)) {
762
- const rawRecords = data.records;
763
- if (Array.isArray(rawRecords))
764
- iterable = rawRecords;
765
- else if (isJsonObject(rawRecords)) {
766
- iterable = Object.values(rawRecords);
767
- }
768
- }
769
- else if (Array.isArray(data)) {
770
- iterable = data;
771
- }
772
- for (const item of iterable) {
773
- if (!isJsonObject(item))
774
- continue;
775
- const aid = String(item.aid ?? '').trim();
776
- if (!aid)
777
- continue;
778
- const record = {};
779
- for (const [key, value] of Object.entries(item)) {
780
- if (key !== 'content')
781
- record[key] = value;
782
- }
783
- record.aid = aid;
784
- for (const key of ['fetched_at', 'observed_at', 'checked_at', 'updated_at']) {
785
- record[key] = Number(record[key] ?? 0) || 0;
786
- }
787
- records[aid] = record;
842
+ _writeAgentMdRecordUnlocked(aid, record) {
843
+ const payload = {};
844
+ for (const [key, value] of Object.entries(record)) {
845
+ if (key !== 'content' && value !== undefined && value !== null)
846
+ payload[key] = value;
788
847
  }
789
- return records;
848
+ payload.aid = this._agentMdSafeAid(aid);
849
+ this._atomicWriteText(this._agentMdMetaPath(aid), `${JSON.stringify(payload, null, 2)}\n`);
790
850
  }
791
- _rebuildAgentMdListUnlocked() {
792
- const records = {};
793
- const root = this._agentMdRoot();
794
- const now = Date.now();
795
- for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
796
- if (!entry.isDirectory())
797
- continue;
798
- const aid = entry.name;
799
- const filePath = path.join(root, aid, 'agent.md');
800
- if (!fs.existsSync(filePath))
801
- continue;
802
- try {
803
- const content = fs.readFileSync(filePath, 'utf-8');
804
- let fetchedAt = now;
805
- try {
806
- fetchedAt = Math.trunc(fs.statSync(filePath).mtimeMs);
807
- }
808
- catch { /* ignore */ }
809
- records[aid] = {
810
- aid,
811
- local_etag: this._agentMdContentEtag(content),
812
- fetched_at: fetchedAt,
813
- updated_at: now,
814
- };
815
- }
816
- catch (err) {
817
- this._clientLog.warn(`agent.md rebuild skipped unreadable file aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
818
- }
851
+ _normalizeAgentMdRecord(aid, data) {
852
+ if (!isJsonObject(data))
853
+ return {};
854
+ const record = {};
855
+ for (const [key, value] of Object.entries(data)) {
856
+ if (key !== 'content')
857
+ record[key] = value;
819
858
  }
820
- this._writeAgentMdListUnlocked(records);
821
- this._agentMdCache.clear();
822
- return records;
823
- }
824
- _readAgentMdListUnlocked() {
825
- const filePath = this._agentMdListPath();
826
- if (!fs.existsSync(filePath)) {
827
- this._agentMdLastListRebuilt = true;
828
- return this._rebuildAgentMdListUnlocked();
859
+ record.aid = this._agentMdSafeAid(String(record.aid ?? aid));
860
+ for (const key of ['fetched_at', 'observed_at', 'checked_at', 'updated_at']) {
861
+ record[key] = Number(record[key] ?? 0) || 0;
829
862
  }
863
+ return record;
864
+ }
865
+ _readAgentMdRecordUnlocked(aid) {
866
+ const filePath = this._agentMdMetaPath(aid);
867
+ if (!fs.existsSync(filePath))
868
+ return {};
830
869
  try {
831
- const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
832
- this._agentMdLastListRebuilt = false;
833
- return this._normalizeAgentMdList(parsed);
870
+ return this._normalizeAgentMdRecord(aid, JSON.parse(fs.readFileSync(filePath, 'utf-8')));
834
871
  }
835
872
  catch (err) {
836
- this._clientLog.warn(`agent.md list.json damaged, rebuilding: ${err instanceof Error ? err.message : String(err)}`);
837
- this._agentMdLastListRebuilt = true;
838
- return this._rebuildAgentMdListUnlocked();
873
+ this._clientLog.warn(`agent.md metadata damaged, ignoring: aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
874
+ return {};
839
875
  }
840
876
  }
841
877
  _readAgentMdContent(aid) {
@@ -861,21 +897,25 @@ export class AUNClient {
861
897
  if (!target)
862
898
  return null;
863
899
  try {
864
- const records = this._withAgentMdListLock(() => this._readAgentMdListUnlocked());
865
- const record = records[target];
866
- if (record && typeof record === 'object') {
867
- const loaded = { ...record, aid: target };
900
+ const loaded = this._withAgentMdRecordLock(target, () => {
901
+ const record = this._readAgentMdRecordUnlocked(target);
902
+ const next = Object.keys(record).length > 0 ? { ...record, aid: target } : { aid: target };
868
903
  try {
869
904
  const content = this._readAgentMdContent(target);
870
- loaded.content = content;
871
- loaded.local_etag = this._agentMdContentEtag(content);
905
+ next.content = content;
906
+ next.local_etag = this._agentMdContentEtag(content);
872
907
  }
873
908
  catch (err) {
874
- this._clientLog.warn(`agent.md content read failed: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
909
+ if (fs.existsSync(this._agentMdMetaPath(target))) {
910
+ this._clientLog.warn(`agent.md content read failed: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
911
+ }
875
912
  }
876
- this._agentMdCache.set(target, { ...loaded });
877
- return { ...loaded };
878
- }
913
+ return next;
914
+ });
915
+ if (Object.keys(loaded).length <= 1)
916
+ return null;
917
+ this._agentMdCache.set(target, { ...loaded });
918
+ return { ...loaded };
879
919
  }
880
920
  catch (err) {
881
921
  this._clientLog.debug(`agent.md cache load skipped: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
@@ -890,25 +930,23 @@ export class AUNClient {
890
930
  const inputFields = { ...fields };
891
931
  const hasContent = Object.prototype.hasOwnProperty.call(inputFields, 'content') && inputFields.content !== undefined && inputFields.content !== null;
892
932
  let savedTo = '';
893
- if (hasContent) {
894
- const content = String(inputFields.content ?? '');
895
- savedTo = this._writeAgentMdContent(target, content);
896
- if (!inputFields.local_etag)
897
- inputFields.local_etag = this._agentMdContentEtag(content);
898
- if (!inputFields.fetched_at)
899
- inputFields.fetched_at = Date.now();
900
- }
901
- delete inputFields.content;
902
- const record = this._withAgentMdListLock(() => {
903
- const records = this._readAgentMdListUnlocked();
904
- const next = { ...(records[target] ?? {}), aid: target };
933
+ const record = this._withAgentMdRecordLock(target, () => {
934
+ if (hasContent) {
935
+ const content = String(inputFields.content ?? '');
936
+ savedTo = this._writeAgentMdContent(target, content);
937
+ if (!inputFields.local_etag)
938
+ inputFields.local_etag = this._agentMdContentEtag(content);
939
+ if (!inputFields.fetched_at)
940
+ inputFields.fetched_at = Date.now();
941
+ }
942
+ delete inputFields.content;
943
+ const next = { ...this._readAgentMdRecordUnlocked(target), aid: target };
905
944
  for (const [key, value] of Object.entries(inputFields)) {
906
945
  if (value !== undefined && value !== null)
907
946
  next[key] = value;
908
947
  }
909
948
  next.updated_at = Date.now();
910
- records[target] = { ...next };
911
- this._writeAgentMdListUnlocked(records);
949
+ this._writeAgentMdRecordUnlocked(target, next);
912
950
  return next;
913
951
  });
914
952
  const loaded = { ...record };
@@ -967,15 +1005,12 @@ export class AUNClient {
967
1005
  return;
968
1006
  if (this._agentMdFetchInflight.has(target))
969
1007
  return;
970
- this._agentMdFetchInflight.add(target);
971
1008
  void this.fetchAgentMd(target).catch((err) => {
972
1009
  this._saveAgentMdRecord(target, {
973
1010
  last_error: err instanceof Error ? err.message : String(err),
974
1011
  remote_status: 'found',
975
1012
  });
976
1013
  this._clientLog.debug(`agent.md auto fetch failed: aid=${target} source=${source || '-'} err=${err instanceof Error ? err.message : String(err)}`);
977
- }).finally(() => {
978
- this._agentMdFetchInflight.delete(target);
979
1014
  });
980
1015
  }
981
1016
  _observeAgentMdMeta(aid, etag = '', lastModified = '', source = '') {
@@ -1026,7 +1061,7 @@ export class AUNClient {
1026
1061
  }
1027
1062
  this._observeAgentMdMeta(senderAid, String(sender.etag ?? '').trim(), String(sender.last_modified ?? sender.lastModified ?? '').trim(), 'envelope');
1028
1063
  }
1029
- async checkAgentMd(aid, maxUnsyncedDays = 0) {
1064
+ async checkAgentMd(aid, maxUnsyncedDays = 1) {
1030
1065
  const target = String(aid ?? this._aid ?? '').trim();
1031
1066
  if (!target)
1032
1067
  throw new ValidationError('checkAgentMd requires aid (or local AID)');
@@ -1035,7 +1070,9 @@ export class AUNClient {
1035
1070
  const localFound = !!(Object.keys(before).length > 0 && (String(before.content ?? '') || localEtag));
1036
1071
  const remoteEtagCached = String(before.remote_etag ?? '').trim();
1037
1072
  const lastModifiedCached = String(before.last_modified ?? '').trim();
1038
- const checkedAtCached = Number(before.checked_at ?? 0);
1073
+ const checkedAt = Number(before.checked_at ?? 0);
1074
+ const fetchedAt = Number(before.fetched_at ?? 0);
1075
+ const checkedAtCached = checkedAt > 0 ? checkedAt : fetchedAt;
1039
1076
  const cachedInSync = !!(localFound && localEtag && remoteEtagCached && localEtag === remoteEtagCached);
1040
1077
  // max_unsynced_days > 0 且距上次 HEAD 在窗口内 → 直接返回缓存;否则强制 HEAD。
1041
1078
  if (cachedInSync && this._agentMdCheckedAtFresh(checkedAtCached, maxUnsyncedDays)) {
@@ -1053,6 +1090,25 @@ export class AUNClient {
1053
1090
  verify_error: String(before.verify_error ?? ''),
1054
1091
  };
1055
1092
  }
1093
+ const remoteFoundCached = !!(remoteEtagCached || String(before.remote_status ?? '') === 'found');
1094
+ if (!localFound &&
1095
+ !remoteFoundCached &&
1096
+ String(before.remote_status ?? '') === 'missing' &&
1097
+ this._agentMdCheckedAtFresh(checkedAtCached, maxUnsyncedDays)) {
1098
+ return {
1099
+ aid: target,
1100
+ local_found: false,
1101
+ remote_found: false,
1102
+ local_etag: '',
1103
+ remote_etag: '',
1104
+ in_sync: false,
1105
+ last_modified: '',
1106
+ status: 404,
1107
+ cached: true,
1108
+ verify_status: '',
1109
+ verify_error: '',
1110
+ };
1111
+ }
1056
1112
  const now = Date.now();
1057
1113
  let remote;
1058
1114
  try {
@@ -1244,11 +1300,17 @@ export class AUNClient {
1244
1300
  }
1245
1301
  }
1246
1302
  /**
1247
- * 列出本地所有已存储的身份摘要(仅返回有有效私钥的 AID)。
1303
+ * 列出本地身份摘要。
1304
+ *
1305
+ * @param opts.all=false(默认):仅返回严格校验通过的可用身份——
1306
+ * keypair 完整 + cert 公钥 == keypair 公钥 + cert 时间窗口有效
1307
+ * @param opts.all=true:返回所有 AIDs/ 子目录(不含 _pending/);
1308
+ * 每项含 valid=bool 和 reason=string 字段
1248
1309
  */
1249
- listIdentities() {
1310
+ listIdentities(opts) {
1250
1311
  const tStart = Date.now();
1251
- this._clientLog.debug(`listIdentities enter`);
1312
+ const includeAll = !!opts?.all;
1313
+ this._clientLog.debug(`listIdentities enter all=${includeAll}`);
1252
1314
  try {
1253
1315
  const listFn = this._keystore.listIdentities;
1254
1316
  if (typeof listFn !== 'function') {
@@ -1258,10 +1320,12 @@ export class AUNClient {
1258
1320
  const aids = listFn.call(this._keystore);
1259
1321
  const summaries = [];
1260
1322
  for (const aid of [...aids].sort()) {
1261
- const identity = this._keystore.loadIdentity(aid);
1262
- if (!identity || !identity.private_key_pem)
1323
+ const { valid, reason } = this._validateLocalIdentity(aid);
1324
+ if (!includeAll && !valid)
1263
1325
  continue;
1264
- const summary = { aid };
1326
+ const summary = { aid, valid };
1327
+ if (reason)
1328
+ summary.reason = reason;
1265
1329
  const loadMetadata = this._keystore.loadMetadata;
1266
1330
  if (typeof loadMetadata === 'function') {
1267
1331
  const md = loadMetadata.call(this._keystore, aid);
@@ -1270,7 +1334,7 @@ export class AUNClient {
1270
1334
  }
1271
1335
  summaries.push(summary);
1272
1336
  }
1273
- this._clientLog.debug(`listIdentities exit: elapsed=${Date.now() - tStart}ms count=${summaries.length}`);
1337
+ this._clientLog.debug(`listIdentities exit: elapsed=${Date.now() - tStart}ms all=${includeAll} count=${summaries.length}`);
1274
1338
  return summaries;
1275
1339
  }
1276
1340
  catch (err) {
@@ -1278,6 +1342,40 @@ export class AUNClient {
1278
1342
  throw err;
1279
1343
  }
1280
1344
  }
1345
+ /**
1346
+ * 严格校验本地身份的可用性。返回 {valid, reason}。
1347
+ * 4 项校验:keypair 完整 + cert 存在 + cert 公钥 == keypair 公钥 + cert 时间窗口有效。
1348
+ */
1349
+ _validateLocalIdentity(aid) {
1350
+ const identity = this._keystore.loadIdentity(aid);
1351
+ if (!identity)
1352
+ return { valid: false, reason: 'no identity record' };
1353
+ const priv = String(identity.private_key_pem ?? '');
1354
+ const pubB64 = String(identity.public_key_der_b64 ?? '');
1355
+ const certPem = String(identity.cert ?? '');
1356
+ if (!priv || !pubB64)
1357
+ return { valid: false, reason: 'missing keypair' };
1358
+ if (!certPem)
1359
+ return { valid: false, reason: 'missing certificate' };
1360
+ try {
1361
+ const crypto = require('node:crypto');
1362
+ const cert = new crypto.X509Certificate(certPem);
1363
+ const certPubDer = cert.publicKey.export({ type: 'spki', format: 'der' });
1364
+ const localPubDer = Buffer.from(pubB64, 'base64');
1365
+ if (!certPubDer.equals(localPubDer)) {
1366
+ return { valid: false, reason: 'cert public key does not match keypair' };
1367
+ }
1368
+ const now = Date.now();
1369
+ if (now < new Date(cert.validFrom).getTime())
1370
+ return { valid: false, reason: 'cert not yet valid' };
1371
+ if (now > new Date(cert.validTo).getTime())
1372
+ return { valid: false, reason: 'cert expired' };
1373
+ return { valid: true, reason: '' };
1374
+ }
1375
+ catch (e) {
1376
+ return { valid: false, reason: `cert parse error: ${e instanceof Error ? e.message : String(e)}` };
1377
+ }
1378
+ }
1281
1379
  // ── RPC ───────────────────────────────────────────────────
1282
1380
  /**
1283
1381
  * 发送 JSON-RPC 调用。
@@ -1297,6 +1395,13 @@ export class AUNClient {
1297
1395
  throw new PermissionError(`legacy E2EE method is removed in this SDK: ${method}`);
1298
1396
  }
1299
1397
  const p = { ...(params ?? {}) };
1398
+ const rpcBackground = Boolean(p._rpc_background) || this._backgroundRpcDepth > 0;
1399
+ delete p._rpc_background;
1400
+ const runWithRpcPriority = async (operation) => {
1401
+ if (!rpcBackground)
1402
+ return await operation();
1403
+ return await this._withBackgroundRpc(operation);
1404
+ };
1300
1405
  if (method === 'message.send' || method === 'group.send') {
1301
1406
  this._normalizeOutboundMessagePayload(p, method);
1302
1407
  }
@@ -1318,17 +1423,33 @@ export class AUNClient {
1318
1423
  if (method.startsWith('group.') && p.slot_id === undefined) {
1319
1424
  p.slot_id = this._slotId;
1320
1425
  }
1426
+ const pullGateLocked = Boolean(p._pull_gate_locked);
1427
+ if ('_pull_gate_locked' in p) {
1428
+ delete p._pull_gate_locked;
1429
+ }
1430
+ const pullGateKey = this._pullGateKeyForCall(method, p);
1431
+ if (pullGateKey && this._isPullResponseProcessing(pullGateKey)) {
1432
+ this._clientLog.debug(`pull skipped while processing pull response: method=${method} key=${pullGateKey}`);
1433
+ return this._emptyPullResultForCall(method);
1434
+ }
1435
+ if (pullGateKey && !pullGateLocked) {
1436
+ const lockedParams = { ...p, _pull_gate_locked: true };
1437
+ if (rpcBackground)
1438
+ lockedParams._rpc_background = true;
1439
+ const result = await this._runPullSerialized(pullGateKey, async () => this.call(method, lockedParams));
1440
+ return result;
1441
+ }
1321
1442
  // 自动加密:message.send 默认加密(encrypt 默认 true)— V2-only
1322
1443
  if (method === 'message.send') {
1323
1444
  const encrypt = p.encrypt ?? true;
1324
1445
  delete p.encrypt;
1325
1446
  if (encrypt) {
1326
- return await this.sendV2(String(p.to ?? ''), p.payload, {
1447
+ return await runWithRpcPriority(() => this.sendV2(String(p.to ?? ''), p.payload, {
1327
1448
  messageId: String(p.message_id ?? '') || undefined,
1328
1449
  timestamp: p.timestamp,
1329
1450
  protectedHeaders: this._protectedHeadersFromParams(p),
1330
1451
  context: isJsonObject(p.context) ? p.context : undefined,
1331
- });
1452
+ }));
1332
1453
  }
1333
1454
  // encrypt=false:明文走通用 RPC 路径;protected_headers/headers 是信封元数据,加密与否都保留
1334
1455
  this._maybeAppendEchoTraceSend(p);
@@ -1338,12 +1459,12 @@ export class AUNClient {
1338
1459
  const encrypt = p.encrypt ?? true;
1339
1460
  delete p.encrypt;
1340
1461
  if (encrypt) {
1341
- return await this.sendGroupV2(String(p.group_id ?? ''), p.payload, {
1462
+ return await runWithRpcPriority(() => this.sendGroupV2(String(p.group_id ?? ''), p.payload, {
1342
1463
  messageId: String(p.message_id ?? '') || undefined,
1343
1464
  timestamp: p.timestamp,
1344
1465
  protectedHeaders: this._protectedHeadersFromParams(p),
1345
1466
  context: isJsonObject(p.context) ? p.context : undefined,
1346
- });
1467
+ }));
1347
1468
  }
1348
1469
  this._maybeAppendEchoTraceSend(p);
1349
1470
  }
@@ -1355,7 +1476,7 @@ export class AUNClient {
1355
1476
  if (!this._v2Session || !String(p.group_id ?? '').trim()) {
1356
1477
  throw new StateError(v2Error);
1357
1478
  }
1358
- return await this._putGroupThoughtEncryptedV2(p);
1479
+ return await runWithRpcPriority(() => this._putGroupThoughtEncryptedV2(p));
1359
1480
  }
1360
1481
  }
1361
1482
  if (method === 'message.thought.put') {
@@ -1363,26 +1484,42 @@ export class AUNClient {
1363
1484
  delete p.encrypt;
1364
1485
  if (encrypt) {
1365
1486
  await this._ensureV2SessionReady('message.thought.put', 'V2 session not initialized; encrypted message.thought.put requires V2 (V1 E2EE removed)');
1366
- return await this._putMessageThoughtEncryptedV2(p);
1487
+ return await runWithRpcPriority(() => this._putMessageThoughtEncryptedV2(p));
1367
1488
  }
1368
1489
  }
1369
- if (method === 'message.pull' && this._clientUsesV2P2P()) {
1490
+ // V2-only:兼容入口名只作为 SDK 内部适配层存在,底层绝不能降级发 legacy RPC。
1491
+ if (method === 'message.pull' || method === 'message.v2.pull') {
1370
1492
  await this._ensureV2SessionReady('message.pull');
1371
- const messages = await this.pullV2(Number(p.after_seq ?? 0) || 0, Number(p.limit ?? 50) || 50);
1493
+ const skipAutoAck = p._skip_auto_ack === true || p.skip_auto_ack === true;
1494
+ const afterSeq = Number(p.after_seq ?? 0) || 0;
1495
+ const limit = Number(p.limit ?? 50) || 50;
1496
+ const messages = skipAutoAck
1497
+ ? await runWithRpcPriority(() => this.pullV2(afterSeq, limit, { skipAutoAck: true, gateLocked: true }))
1498
+ : await runWithRpcPriority(() => this.pullV2(afterSeq, limit, { gateLocked: true }));
1372
1499
  return { messages };
1373
1500
  }
1374
- if (method === 'message.ack' && this._clientUsesV2P2P()) {
1501
+ if (method === 'message.ack' || method === 'message.v2.ack') {
1375
1502
  await this._ensureV2SessionReady('message.ack');
1376
- return await this.ackV2(Number(p.seq ?? p.up_to_seq ?? 0) || undefined);
1503
+ return await runWithRpcPriority(() => this.ackV2(Number(p.seq ?? p.up_to_seq ?? 0) || undefined));
1377
1504
  }
1378
- if (method === 'group.pull' && this._clientUsesV2Group() && p.group_id) {
1505
+ if (method === 'group.pull' || method === 'group.v2.pull') {
1506
+ if (!String(p.group_id ?? '').trim()) {
1507
+ throw new ValidationError('group.pull requires group_id');
1508
+ }
1379
1509
  await this._ensureV2SessionReady('group.pull');
1380
- const messages = await this.pullGroupV2(String(p.group_id), Number(p.after_seq ?? p.after_message_seq ?? 0) || 0, Number(p.limit ?? 50) || 50);
1510
+ const messages = await runWithRpcPriority(() => this.pullGroupV2(String(p.group_id), Number(p.after_seq ?? p.after_message_seq ?? 0) || 0, Number(p.limit ?? 50) || 50, { gateLocked: true }));
1381
1511
  return { messages };
1382
1512
  }
1383
- if (method === 'group.ack_messages' && this._clientUsesV2Group() && p.group_id) {
1513
+ if (method === 'group.ack_messages' || method === 'group.v2.ack') {
1514
+ if (!String(p.group_id ?? '').trim()) {
1515
+ throw new ValidationError('group.ack_messages requires group_id');
1516
+ }
1384
1517
  await this._ensureV2SessionReady('group.ack_messages');
1385
- return await this.ackGroupV2(String(p.group_id), Number(p.seq ?? p.msg_seq ?? p.up_to_seq ?? 0) || undefined);
1518
+ return await runWithRpcPriority(() => this.ackGroupV2(String(p.group_id), Number(p.seq ?? p.msg_seq ?? p.up_to_seq ?? 0) || undefined));
1519
+ }
1520
+ if (method === 'message.pull') {
1521
+ delete p._skip_auto_ack;
1522
+ delete p.skip_auto_ack;
1386
1523
  }
1387
1524
  // 关键操作自动附加客户端签名
1388
1525
  if (SIGNED_METHODS.has(method)) {
@@ -1399,8 +1536,12 @@ export class AUNClient {
1399
1536
  this._clientLog.debug(`thought.get transport call start: method=${method}, params=${this._debugJson(this._messageEnvelopeFieldsForDebug(p))}`);
1400
1537
  }
1401
1538
  let result = callTimeout
1402
- ? await this._transport.call(method, p, callTimeout)
1403
- : await this._transport.call(method, p);
1539
+ ? (rpcBackground
1540
+ ? await this._transport.call(method, p, callTimeout, undefined, true)
1541
+ : await this._transport.call(method, p, callTimeout))
1542
+ : (rpcBackground
1543
+ ? await this._transport.call(method, p, undefined, undefined, true)
1544
+ : await this._transport.call(method, p));
1404
1545
  if (method === 'group.thought.get' && isJsonObject(result)) {
1405
1546
  this._clientLog.debug(`group.thought.get transport result: found=${String(result.found ?? '')}, raw_count=${Array.isArray(result.thoughts) ? result.thoughts.length : 0}`);
1406
1547
  result = await this._decryptGroupThoughts(result);
@@ -1486,6 +1627,34 @@ export class AUNClient {
1486
1627
  this._clientLog.debug(`on exit: elapsed=${Date.now() - tStart}ms event=${event}`);
1487
1628
  return result;
1488
1629
  }
1630
+ async _callRawV2Rpc(method, params) {
1631
+ const p = { ...(params ?? {}) };
1632
+ const rpcBackground = Boolean(p._rpc_background) || this._backgroundRpcDepth > 0;
1633
+ delete p._rpc_background;
1634
+ delete p._pull_gate_locked;
1635
+ delete p._skip_auto_ack;
1636
+ delete p.skip_auto_ack;
1637
+ if (method.startsWith('group.') && p.group_id !== undefined && p.group_id !== null) {
1638
+ p.group_id = normalizeGroupId(String(p.group_id)) || String(p.group_id);
1639
+ }
1640
+ if (method.startsWith('group.') && p.device_id === undefined) {
1641
+ p.device_id = this._deviceId;
1642
+ }
1643
+ if (method.startsWith('group.') && p.slot_id === undefined) {
1644
+ p.slot_id = this._slotId;
1645
+ }
1646
+ if (SIGNED_METHODS.has(method)) {
1647
+ if (this._shouldSkipClientSignature(method, p)) {
1648
+ delete p.client_signature;
1649
+ }
1650
+ else {
1651
+ this._signClientOperation(method, p);
1652
+ }
1653
+ }
1654
+ return rpcBackground
1655
+ ? await this._transport.call(method, p, undefined, undefined, true)
1656
+ : await this._transport.call(method, p);
1657
+ }
1489
1658
  /** P2-13: 取消订阅事件(对齐 Python/JS off 方法) */
1490
1659
  off(event, handler) {
1491
1660
  const tStart = Date.now();
@@ -1575,7 +1744,7 @@ export class AUNClient {
1575
1744
  _decrypt_error: String(exc),
1576
1745
  };
1577
1746
  this._attachV2EnvelopeMetadataFromSource(safeEvent, data);
1578
- this._publishAppEvent('message.undecryptable', safeEvent).catch(() => { });
1747
+ Promise.resolve(this._publishAppEvent('message.undecryptable', safeEvent)).catch(() => { });
1579
1748
  }
1580
1749
  });
1581
1750
  this._clientLog.debug(`_onRawMessageReceived exit: elapsed=${Date.now() - tStart}ms (handler dispatched)`);
@@ -1595,12 +1764,15 @@ export class AUNClient {
1595
1764
  const seq = msg.seq;
1596
1765
  if (seq !== undefined && seq !== null && this._aid) {
1597
1766
  const ns = `p2p:${this._aid}`;
1598
- // Push 修上界:先更新 maxSeenSeq,让上界反映服务端状态
1767
+ // Push 只先更新 maxSeenSeq;contiguous_seq 是已交付游标,必须等应用层发布返回后再推进。
1599
1768
  if (seq > 0)
1600
1769
  this._seqTracker.updateMaxSeen(ns, seq);
1601
- const needPull = this._seqTracker.onMessageSeq(ns, seq);
1770
+ const contigBefore = this._seqTracker.getContiguousSeq(ns);
1771
+ const published = await this._publishOrderedMessage('message.received', ns, seq, msg);
1772
+ const contigAfter = this._seqTracker.getContiguousSeq(ns);
1773
+ const needPull = Number(seq) > contigAfter && !published;
1602
1774
  if (needPull) {
1603
- this._clientLog.debug(`P2P seq gap detected: ns=${ns}, seq=${seq}, contiguous=${this._seqTracker.getContiguousSeq(ns)}`);
1775
+ this._clientLog.debug(`P2P seq gap detected: ns=${ns}, seq=${seq}, contiguous=${contigAfter}`);
1604
1776
  this._fillP2pGap().catch(exc => this._clientLog.warn(`background gap fill trigger failed: ${formatCaughtError(exc)}`));
1605
1777
  }
1606
1778
  // auto-ack contiguous_seq
@@ -1609,23 +1781,16 @@ export class AUNClient {
1609
1781
  const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
1610
1782
  const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
1611
1783
  this._clientLog.debug(`P2P push auto-ack send: ns=${ns}, seq=${ackSeq}, contiguous=${contig}, max_seen=${maxSeen}`);
1612
- this._transport.call('message.ack', {
1613
- seq: ackSeq,
1614
- device_id: this._deviceId,
1615
- slot_id: this._slotId,
1616
- })
1784
+ this._withBackgroundRpc(() => this.ackV2(ackSeq))
1617
1785
  .then(() => { this._clientLog.debug(`P2P push auto-ack ok: ns=${ns}, seq=${ackSeq}`); })
1618
1786
  .catch((e) => { this._clientLog.debug(`P2P auto-ack failed: ${formatCaughtError(e)}`); });
1619
1787
  }
1620
1788
  // 即时持久化 cursor,异常断连后不回退
1621
- this._saveSeqTrackerState();
1622
- }
1623
- // V2-only:普通 _raw.message.received 只承载明文;V2 密文由 peer.v2.message_received 通知触发 pull。
1624
- if (seq !== undefined && seq !== null && this._aid) {
1625
- const ns = `p2p:${this._aid}`;
1626
- await this._publishOrderedMessage('message.received', ns, seq, msg);
1789
+ if (contigAfter !== contigBefore)
1790
+ this._saveSeqTrackerState();
1627
1791
  }
1628
1792
  else {
1793
+ // V2-only:普通 _raw.message.received 只承载明文;V2 密文由 peer.v2.message_received 通知触发 pull。
1629
1794
  await this._publishAppEvent('message.received', msg, 'push');
1630
1795
  }
1631
1796
  }
@@ -1652,7 +1817,7 @@ export class AUNClient {
1652
1817
  _decrypt_error: String(exc),
1653
1818
  };
1654
1819
  this._attachV2EnvelopeMetadataFromSource(safeEvent, data);
1655
- this._publishAppEvent('group.message_undecryptable', safeEvent).catch(() => { });
1820
+ Promise.resolve(this._publishAppEvent('group.message_undecryptable', safeEvent)).catch(() => { });
1656
1821
  }
1657
1822
  });
1658
1823
  this._clientLog.debug(`_onRawGroupMessageCreated exit: elapsed=${Date.now() - tStart}ms (handler dispatched)`);
@@ -1678,17 +1843,22 @@ export class AUNClient {
1678
1843
  if (payload === undefined || payload === null
1679
1844
  || (typeof payload === 'object' && Object.keys(payload).length === 0)) {
1680
1845
  // 不带 payload 的通知不能先推进 seq,否则 auto-pull 会用推进后的 cursor 跳过该消息。
1681
- await this._autoPullGroupMessages(msg);
1846
+ void this._autoPullGroupMessages(msg).catch((exc) => {
1847
+ this._clientLog.warn(`auto pull group message task failed: ${formatCaughtError(exc)}`);
1848
+ });
1682
1849
  return;
1683
1850
  }
1684
1851
  if (groupId && seq !== undefined && seq !== null) {
1685
1852
  const ns = `group:${groupId}`;
1686
- // Push 修上界:先更新 maxSeenSeq
1853
+ // Push 只先更新 maxSeenSeq;contiguous_seq 是已交付游标,必须等应用层发布返回后再推进。
1687
1854
  if (seq > 0)
1688
1855
  this._seqTracker.updateMaxSeen(ns, seq);
1689
- const needPull = this._seqTracker.onMessageSeq(ns, seq);
1856
+ const contigBefore = this._seqTracker.getContiguousSeq(ns);
1857
+ const published = await this._publishOrderedMessage('group.message_created', ns, seq, msg);
1858
+ const contigAfter = this._seqTracker.getContiguousSeq(ns);
1859
+ const needPull = Number(seq) > contigAfter && !published;
1690
1860
  if (needPull) {
1691
- this._clientLog.debug(`group message seq gap detected: group=${groupId}, seq=${seq}, contiguous=${this._seqTracker.getContiguousSeq(ns)}`);
1861
+ this._clientLog.debug(`group message seq gap detected: group=${groupId}, seq=${seq}, contiguous=${contigAfter}`);
1692
1862
  this._fillGroupGap(groupId).catch(exc => this._clientLog.warn(`background gap fill trigger failed: ${formatCaughtError(exc)}`));
1693
1863
  }
1694
1864
  const contig = this._seqTracker.getContiguousSeq(ns);
@@ -1696,118 +1866,65 @@ export class AUNClient {
1696
1866
  const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
1697
1867
  const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
1698
1868
  this._clientLog.debug(`group push auto-ack send: group=${groupId}, ns=${ns}, seq=${ackSeq}, contiguous=${contig}, max_seen=${maxSeen}`);
1699
- this._transport.call('group.ack_messages', {
1700
- group_id: groupId,
1701
- msg_seq: ackSeq,
1702
- device_id: this._deviceId,
1703
- slot_id: this._slotId,
1704
- })
1869
+ this._withBackgroundRpc(() => this.ackGroupV2(groupId, ackSeq))
1705
1870
  .then(() => { this._clientLog.debug(`group push auto-ack ok: group=${groupId}, seq=${ackSeq}`); })
1706
1871
  .catch((e) => { this._clientLog.debug(`group message auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
1707
1872
  }
1708
- this._saveSeqTrackerState();
1709
- }
1710
- // V2-only:普通 group.message_created 只承载明文;V2 密文由 group.v2.message_created 通知触发 pull。
1711
- if (groupId && seq !== undefined && seq !== null) {
1712
- const nsKey = `group:${groupId}`;
1713
- await this._publishOrderedMessage('group.message_created', nsKey, seq, msg);
1873
+ if (contigAfter !== contigBefore)
1874
+ this._saveSeqTrackerState();
1714
1875
  }
1715
1876
  else {
1877
+ // V2-only:普通 group.message_created 只承载明文;V2 密文由 group.v2.message_created 通知触发 pull。
1716
1878
  await this._publishAppEvent('group.message_created', msg, 'group-push');
1717
1879
  }
1718
1880
  }
1719
1881
  /** 收到不带 payload 的 group.message_created 通知后,自动 pull 最新消息 */
1720
1882
  async _autoPullGroupMessages(notification) {
1721
- const groupId = (notification.group_id ?? '');
1883
+ let groupId = String(notification.group_id ?? '').trim();
1722
1884
  if (!groupId) {
1723
1885
  await this._publishAppEvent('group.message_created', notification);
1724
1886
  return;
1725
1887
  }
1888
+ groupId = normalizeGroupId(groupId) || groupId;
1726
1889
  const ns = `group:${groupId}`;
1727
1890
  const afterSeq = this._seqTracker.getContiguousSeq(ns);
1728
1891
  this._clientLog.debug(`auto pull group messages start: group=${groupId}, after_seq=${afterSeq}, seq=${String(notification.seq ?? '')}`);
1729
- try {
1730
- // V2-only 模式:走 group.v2.pull(合并 V1 明文 + V2 密文并自动解密)
1731
- if (this._v2Session) {
1732
- await this._pullGroupV2Internal({ group_id: groupId, after_seq: afterSeq, limit: 50 });
1733
- this._clientLog.debug(`auto pull group messages done(v2): group=${groupId}, after_seq=${afterSeq}`);
1734
- return;
1735
- }
1736
- const result = await this.call('group.pull', {
1737
- group_id: groupId,
1738
- after_message_seq: afterSeq,
1739
- device_id: this._deviceId,
1740
- limit: 50,
1741
- });
1742
- if (isJsonObject(result)) {
1743
- const messages = result.messages;
1744
- if (Array.isArray(messages)) {
1745
- // onPullResult 已在 call() 拦截器中调用,此处不再重复
1746
- const pushed = this._pushedSeqs.get(ns);
1747
- for (const msg of messages) {
1748
- if (isJsonObject(msg)) {
1749
- const s = msg.seq;
1750
- if (pushed && s !== undefined && s !== null && pushed.has(s)) {
1751
- this._clientLog.debug(`auto pull group message skipped duplicate: group=${groupId}, seq=${s}`);
1752
- continue; // 已发布到应用层,跳过
1753
- }
1754
- if (s !== undefined && s !== null) {
1755
- await this._publishPulledMessage('group.message_created', ns, s, msg);
1756
- }
1757
- else {
1758
- await this._publishAppEvent('group.message_created', msg, 'pull');
1759
- }
1760
- }
1761
- }
1762
- this._prunePushedSeqs(ns);
1763
- return;
1764
- }
1765
- }
1892
+ const started = await this._tryRunBackgroundPull(ns, async () => {
1893
+ const pullAfterSeq = this._seqTracker.getContiguousSeq(ns);
1894
+ const messages = await this.pullGroupV2(groupId, pullAfterSeq, 50, { gateLocked: true });
1895
+ this._prunePushedSeqs(ns);
1896
+ return messages.length;
1897
+ }, true);
1898
+ if (!started) {
1899
+ this._clientLog.debug(`auto pull group messages skipped: pull in-flight group=${groupId}`);
1766
1900
  }
1767
- catch (exc) {
1768
- this._clientLog.debug(`auto pull group messages failed: ${formatCaughtError(exc)}`);
1769
- }
1770
- await this._publishAppEvent('group.message_created', notification, 'group-push-fallback');
1771
1901
  }
1772
1902
  /** 后台补齐群消息空洞 */
1773
1903
  async _fillGroupGap(groupId) {
1904
+ groupId = normalizeGroupId(groupId) || String(groupId ?? '').trim();
1905
+ if (!groupId)
1906
+ return;
1774
1907
  const ns = `group:${groupId}`;
1775
1908
  const afterSeq = this._seqTracker.getContiguousSeq(ns);
1776
1909
  // 去重:同一 (group:id:after_seq) 只补一次
1777
1910
  const dedupKey = `group_msg:${groupId}:${afterSeq}`;
1778
1911
  if (this._gapFillDone.has(dedupKey))
1779
1912
  return;
1913
+ const token = this._tryAcquirePullGate(ns);
1914
+ if (token === null) {
1915
+ this._clientLog.debug(`group message gap fill skipped: pull in-flight group=${groupId}`);
1916
+ return;
1917
+ }
1780
1918
  this._gapFillDone.set(dedupKey, Date.now());
1781
1919
  this._clientLog.debug(`group message gap fill start: group=${groupId}, after_seq=${afterSeq}`);
1920
+ let filled = 0;
1782
1921
  try {
1783
- const result = await this.call('group.pull', {
1784
- group_id: groupId,
1785
- after_message_seq: afterSeq,
1786
- device_id: this._deviceId,
1787
- limit: 50,
1788
- });
1789
- let filled = 0;
1790
- if (isJsonObject(result)) {
1791
- const messages = result.messages;
1792
- if (Array.isArray(messages)) {
1793
- // onPullResult 已在 call() 拦截器中调用,此处不再重复
1794
- const pushed = this._pushedSeqs.get(ns);
1795
- for (const msg of messages) {
1796
- if (isJsonObject(msg)) {
1797
- const s = msg.seq;
1798
- if (pushed && s !== undefined && s !== null && pushed.has(s))
1799
- continue;
1800
- if (s !== undefined && s !== null) {
1801
- await this._publishPulledMessage('group.message_created', ns, s, msg);
1802
- }
1803
- else {
1804
- await this._publishAppEvent('group.message_created', msg);
1805
- }
1806
- filled += 1;
1807
- }
1808
- }
1809
- this._prunePushedSeqs(ns);
1810
- }
1922
+ const messages = await this._withBackgroundRpc(() => this.pullGroupV2(groupId, afterSeq, 50, { gateLocked: true }));
1923
+ filled = messages.length;
1924
+ this._prunePushedSeqs(ns);
1925
+ if (this._seqTracker.getContiguousSeq(ns) !== afterSeq) {
1926
+ await this._drainOrderedMessages(ns, undefined, true);
1927
+ this._saveSeqTrackerState();
1811
1928
  }
1812
1929
  this._clientLog.debug(`group message gap fill done: group=${groupId}, after_seq=${afterSeq}, filled=${filled}`);
1813
1930
  }
@@ -1816,6 +1933,10 @@ export class AUNClient {
1816
1933
  }
1817
1934
  finally {
1818
1935
  this._gapFillDone.delete(dedupKey);
1936
+ this._releasePullGate(ns, token);
1937
+ if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
1938
+ void this._fillGroupGap(groupId);
1939
+ }
1819
1940
  }
1820
1941
  }
1821
1942
  /** 后台补齐 P2P 消息空洞 */
@@ -1828,35 +1949,25 @@ export class AUNClient {
1828
1949
  const dedupKey = `p2p:${afterSeq}`;
1829
1950
  if (this._gapFillDone.has(dedupKey))
1830
1951
  return;
1952
+ const token = this._tryAcquirePullGate(ns);
1953
+ if (token === null) {
1954
+ this._clientLog.debug(`P2P message gap fill skipped: pull in-flight ns=${ns}`);
1955
+ return;
1956
+ }
1831
1957
  this._gapFillDone.set(dedupKey, Date.now());
1832
1958
  this._clientLog.debug(`P2P message gap fill start: after_seq=${afterSeq}`);
1959
+ let filled = 0;
1833
1960
  try {
1834
- const result = await this.call('message.pull', {
1835
- after_seq: afterSeq,
1836
- limit: 50,
1837
- });
1838
- let filled = 0;
1839
- if (isJsonObject(result)) {
1840
- const messages = result.messages;
1841
- if (Array.isArray(messages)) {
1842
- // onPullResult 已在 call() 拦截器中调用,此处不再重复
1843
- const pushed = this._pushedSeqs.get(ns);
1844
- for (const msg of messages) {
1845
- if (isJsonObject(msg)) {
1846
- const s = msg.seq;
1847
- if (pushed && s !== undefined && s !== null && pushed.has(s))
1848
- continue;
1849
- if (s !== undefined && s !== null) {
1850
- await this._publishPulledMessage('message.received', ns, s, msg);
1851
- }
1852
- else {
1853
- await this._publishAppEvent('message.received', msg);
1854
- }
1855
- filled += 1;
1856
- }
1857
- }
1858
- this._prunePushedSeqs(ns);
1859
- }
1961
+ const messages = await this._withBackgroundRpc(() => this.pullV2(afterSeq, 50, { skipAutoAck: true, gateLocked: true }));
1962
+ filled = messages.length;
1963
+ this._prunePushedSeqs(ns);
1964
+ if (this._seqTracker.getContiguousSeq(ns) !== afterSeq) {
1965
+ await this._drainOrderedMessages(ns, undefined, true);
1966
+ this._saveSeqTrackerState();
1967
+ }
1968
+ const contig = this._seqTracker.getContiguousSeq(ns);
1969
+ if (contig > 0 && contig !== afterSeq) {
1970
+ await this._withBackgroundRpc(() => this.ackV2(contig));
1860
1971
  }
1861
1972
  this._clientLog.debug(`P2P message gap fill done: after_seq=${afterSeq}, filled=${filled}`);
1862
1973
  }
@@ -1865,6 +1976,10 @@ export class AUNClient {
1865
1976
  }
1866
1977
  finally {
1867
1978
  this._gapFillDone.delete(dedupKey);
1979
+ this._releasePullGate(ns, token);
1980
+ if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
1981
+ void this._fillP2pGap();
1982
+ }
1868
1983
  }
1869
1984
  }
1870
1985
  /** 只按硬上限裁剪 published guard,不能按 contiguousSeq 清理。 */
@@ -1925,7 +2040,7 @@ export class AUNClient {
1925
2040
  return payload;
1926
2041
  return this._attachCurrentInstanceContext(payload);
1927
2042
  }
1928
- async _publishAppEvent(event, payload, source = 'direct') {
2043
+ _publishAppEvent(event, payload, source = 'direct') {
1929
2044
  if ((event === 'message.received' || event === 'group.message_created') && isJsonObject(payload)) {
1930
2045
  this._maybeAppendEchoTraceReceive(payload);
1931
2046
  }
@@ -1949,7 +2064,7 @@ export class AUNClient {
1949
2064
  this._clientLog.debug(`agent_md etag inject skipped: ${err instanceof Error ? err.message : String(err)}`);
1950
2065
  }
1951
2066
  }
1952
- await this._dispatcher.publish(event, this._normalizePublishedMessagePayload(event, payload));
2067
+ return this._dispatcher.publishSyncAware(event, this._normalizePublishedMessagePayload(event, payload));
1953
2068
  }
1954
2069
  _echoTimestamp() {
1955
2070
  const now = new Date();
@@ -2102,25 +2217,273 @@ export class AUNClient {
2102
2217
  }
2103
2218
  return true;
2104
2219
  }
2105
- async _drainOrderedMessages(ns, beforeSeq) {
2220
+ _tryAcquirePullGate(key) {
2221
+ if (!key)
2222
+ return 0;
2223
+ const now = Date.now();
2224
+ const gate = this._pullGates.get(key) ?? { inflight: false, startedAt: 0, token: 0 };
2225
+ if (gate.inflight && now - gate.startedAt <= AUNClient.PULL_GATE_STALE_MS) {
2226
+ return null;
2227
+ }
2228
+ if (gate.inflight) {
2229
+ this._clientLog.warn(`pull in-flight stale reset: key=${key} age=${now - gate.startedAt}ms`);
2230
+ }
2231
+ gate.token += 1;
2232
+ gate.inflight = true;
2233
+ gate.startedAt = now;
2234
+ this._pullGates.set(key, gate);
2235
+ return gate.token;
2236
+ }
2237
+ _releasePullGate(key, token) {
2238
+ if (!key || token == null)
2239
+ return;
2240
+ const gate = this._pullGates.get(key);
2241
+ if (!gate || gate.token !== token)
2242
+ return;
2243
+ gate.inflight = false;
2244
+ gate.startedAt = 0;
2245
+ }
2246
+ _pullGateKeyForCall(method, params) {
2247
+ if (method === 'message.pull' || method === 'message.v2.pull') {
2248
+ return this._aid ? `p2p:${this._aid}` : '';
2249
+ }
2250
+ if ((method === 'group.pull' || method === 'group.v2.pull') && String(params.group_id ?? '').trim()) {
2251
+ return `group:${String(params.group_id ?? '').trim()}`;
2252
+ }
2253
+ if (method === 'group.pull_events' && String(params.group_id ?? '').trim()) {
2254
+ return `group_event:${String(params.group_id ?? '').trim()}`;
2255
+ }
2256
+ return '';
2257
+ }
2258
+ _isPullResponseProcessing(key) {
2259
+ if (!key)
2260
+ return false;
2261
+ return (this._pullResponseKeys.get(key) ?? 0) > 0;
2262
+ }
2263
+ _emptyPullResultForCall(method) {
2264
+ if (method === 'group.pull_events')
2265
+ return { events: [], count: 0 };
2266
+ if (method === 'message.pull' || method === 'message.v2.pull' || method === 'group.pull' || method === 'group.v2.pull') {
2267
+ return { messages: [], count: 0 };
2268
+ }
2269
+ return {};
2270
+ }
2271
+ _withPullResponseProcessing(key, fn) {
2272
+ if (!key)
2273
+ return fn();
2274
+ this._pullResponseKeys.set(key, (this._pullResponseKeys.get(key) ?? 0) + 1);
2275
+ const release = () => {
2276
+ const next = (this._pullResponseKeys.get(key) ?? 1) - 1;
2277
+ if (next <= 0) {
2278
+ this._pullResponseKeys.delete(key);
2279
+ }
2280
+ else {
2281
+ this._pullResponseKeys.set(key, next);
2282
+ }
2283
+ };
2284
+ try {
2285
+ const result = fn();
2286
+ if (isPromiseLike(result)) {
2287
+ return Promise.resolve(result).finally(release);
2288
+ }
2289
+ release();
2290
+ return result;
2291
+ }
2292
+ catch (exc) {
2293
+ release();
2294
+ throw exc;
2295
+ }
2296
+ }
2297
+ _pullResultCount(result) {
2298
+ if (Array.isArray(result))
2299
+ return result.length;
2300
+ if (!isJsonObject(result))
2301
+ return 0;
2302
+ const obj = result;
2303
+ const rawCount = Number(obj.raw_count ?? 0);
2304
+ if (Number.isFinite(rawCount) && rawCount > 0)
2305
+ return rawCount;
2306
+ if (Array.isArray(obj.messages))
2307
+ return obj.messages.length;
2308
+ if (Array.isArray(obj.events))
2309
+ return obj.events.length;
2310
+ return 0;
2311
+ }
2312
+ _nextPullParams(method, params) {
2313
+ const next = { ...params };
2314
+ delete next._pull_gate_locked;
2315
+ if (method === 'message.pull' || method === 'message.v2.pull') {
2316
+ if (!this._aid)
2317
+ return null;
2318
+ next.after_seq = this._seqTracker.getContiguousSeq(`p2p:${this._aid}`);
2319
+ return next;
2320
+ }
2321
+ if (method === 'group.pull' || method === 'group.v2.pull') {
2322
+ const groupId = normalizeGroupId(String(next.group_id ?? '').trim()) || String(next.group_id ?? '').trim();
2323
+ if (!groupId)
2324
+ return null;
2325
+ next.group_id = groupId;
2326
+ next.after_seq = this._seqTracker.getContiguousSeq(`group:${groupId}`);
2327
+ delete next.after_message_seq;
2328
+ return next;
2329
+ }
2330
+ if (method === 'group.pull_events') {
2331
+ const groupId = normalizeGroupId(String(next.group_id ?? '').trim()) || String(next.group_id ?? '').trim();
2332
+ if (!groupId)
2333
+ return null;
2334
+ next.group_id = groupId;
2335
+ next.after_event_seq = this._seqTracker.getContiguousSeq(`group_event:${groupId}`);
2336
+ return next;
2337
+ }
2338
+ return null;
2339
+ }
2340
+ _pullRequestAfter(method, params) {
2341
+ if (method === 'message.pull' || method === 'message.v2.pull')
2342
+ return Number(params.after_seq ?? 0) || 0;
2343
+ if (method === 'group.pull' || method === 'group.v2.pull')
2344
+ return Number(params.after_seq ?? params.after_message_seq ?? 0) || 0;
2345
+ if (method === 'group.pull_events')
2346
+ return Number(params.after_event_seq ?? 0) || 0;
2347
+ return 0;
2348
+ }
2349
+ _pullRetentionFloor(result, topLevelKey, cursorKey) {
2350
+ const values = [Number(result[topLevelKey] ?? 0)];
2351
+ const cursor = isJsonObject(result.cursor) ? result.cursor : null;
2352
+ if (cursor) {
2353
+ values.push(Number(cursor[cursorKey] ?? 0));
2354
+ values.push(Number(cursor.retention_floor_seq ?? 0));
2355
+ }
2356
+ return Math.max(0, ...values.filter((value) => Number.isFinite(value)));
2357
+ }
2358
+ _schedulePullFollowup(method, params, result) {
2359
+ if (method === 'message.pull')
2360
+ method = 'message.v2.pull';
2361
+ else if (method === 'group.pull')
2362
+ method = 'group.v2.pull';
2363
+ if (this._pullResultCount(result) <= 0)
2364
+ return;
2365
+ const next = this._nextPullParams(method, params);
2366
+ if (!next)
2367
+ return;
2368
+ if (this._pullRequestAfter(method, next) <= this._pullRequestAfter(method, params))
2369
+ return;
2370
+ void (async () => {
2371
+ try {
2372
+ await this._withBackgroundRpc(async () => {
2373
+ if (method === 'message.pull' || method === 'message.v2.pull') {
2374
+ await this.pullV2(Number(next.after_seq ?? 0) || 0, Number(next.limit ?? 50) || 50);
2375
+ return;
2376
+ }
2377
+ if (method === 'group.pull' || method === 'group.v2.pull') {
2378
+ const groupId = String(next.group_id ?? '').trim();
2379
+ if (!groupId)
2380
+ return;
2381
+ await this.pullGroupV2(groupId, Number(next.after_seq ?? next.after_message_seq ?? 0) || 0, Number(next.limit ?? 50) || 50);
2382
+ return;
2383
+ }
2384
+ await this.call(method, next);
2385
+ });
2386
+ }
2387
+ catch (exc) {
2388
+ this._clientLog.debug(`pull follow-up skipped/failed: method=${method} err=${formatCaughtError(exc)}`);
2389
+ }
2390
+ })();
2391
+ }
2392
+ async _withBackgroundRpc(operation) {
2393
+ this._backgroundRpcDepth += 1;
2394
+ try {
2395
+ return await operation();
2396
+ }
2397
+ finally {
2398
+ this._backgroundRpcDepth = Math.max(0, this._backgroundRpcDepth - 1);
2399
+ }
2400
+ }
2401
+ async _runPullSerialized(key, operation) {
2402
+ if (key && this._isPullResponseProcessing(key)) {
2403
+ this._clientLog.debug(`pull skipped while processing pull response: key=${key}`);
2404
+ return [];
2405
+ }
2406
+ let token = this._tryAcquirePullGate(key);
2407
+ if (token === null) {
2408
+ // 显式 pull 可能撞上 push/gap-fill 的后台 pull。这里不并行发第二个 pull,
2409
+ // 也不把后台 in-flight 暴露成业务错误;短等待 gate 释放后再进入连接级 RPC queue。
2410
+ const deadline = Date.now() + AUNClient.PULL_GATE_STALE_MS + 100;
2411
+ while (token === null && Date.now() <= deadline) {
2412
+ await this._sleep(25);
2413
+ token = this._tryAcquirePullGate(key);
2414
+ }
2415
+ if (token === null) {
2416
+ throw new StateError(`pull already in-flight for ${key}`);
2417
+ }
2418
+ }
2419
+ try {
2420
+ return await this._withBackgroundRpc(operation);
2421
+ }
2422
+ finally {
2423
+ this._releasePullGate(key, token);
2424
+ }
2425
+ }
2426
+ async _tryRunBackgroundPull(key, operation, followupOnMessages = false) {
2427
+ if (key && this._isPullResponseProcessing(key))
2428
+ return false;
2429
+ const token = this._tryAcquirePullGate(key);
2430
+ if (token === null)
2431
+ return false;
2432
+ let count = 0;
2433
+ try {
2434
+ count = await this._withBackgroundRpc(operation);
2435
+ }
2436
+ finally {
2437
+ this._releasePullGate(key, token);
2438
+ }
2439
+ if (followupOnMessages && count > 0) {
2440
+ // 后台续拉是 fire-and-forget;关闭连接时 transport 会拒绝排队 RPC,
2441
+ // 这里必须本地收口,避免测试/宿主进程看到未处理的 Promise rejection。
2442
+ void this._tryRunBackgroundPull(key, operation, true).catch((exc) => {
2443
+ this._clientLog.debug(`background pull follow-up skipped/failed: key=${key} err=${formatCaughtError(exc)}`);
2444
+ });
2445
+ }
2446
+ return true;
2447
+ }
2448
+ async _drainOrderedMessages(ns, beforeSeq, pullResponse = false) {
2106
2449
  const queue = this._pendingOrderedMsgs.get(ns);
2107
2450
  if (!queue || queue.size === 0)
2108
2451
  return;
2109
- const contig = this._seqTracker.getContiguousSeq(ns);
2110
- const ready = [...queue.keys()]
2111
- .filter((seq) => seq <= contig && (beforeSeq === undefined || seq < beforeSeq))
2112
- .sort((a, b) => a - b);
2113
- for (const seq of ready) {
2452
+ while (true) {
2453
+ const contig = this._seqTracker.getContiguousSeq(ns);
2454
+ const ready = [...queue.keys()]
2455
+ .filter((seq) => seq <= contig && (beforeSeq === undefined || seq < beforeSeq))
2456
+ .sort((a, b) => a - b);
2457
+ let seq = ready[0];
2458
+ if (seq === undefined) {
2459
+ const nextSeq = contig + 1;
2460
+ if (beforeSeq !== undefined && nextSeq >= beforeSeq)
2461
+ break;
2462
+ if (!queue.has(nextSeq))
2463
+ break;
2464
+ seq = nextSeq;
2465
+ }
2114
2466
  const item = queue.get(seq);
2115
2467
  queue.delete(seq);
2116
2468
  if (!item)
2117
2469
  continue;
2118
2470
  if (this._pushedSeqs.get(ns)?.has(seq)) {
2119
2471
  this._clientLog.debug(`publish ordered drain skipped duplicate: ns=${ns}, seq=${seq}, event=${item.event}`);
2472
+ this._markOrderedSeqDelivered(ns, seq);
2120
2473
  continue;
2121
2474
  }
2122
- await this._publishAppEvent(item.event, item.payload, 'ordered-drain');
2475
+ if (pullResponse) {
2476
+ const published = this._withPullResponseProcessing(ns, () => this._publishAppEvent(item.event, item.payload, 'ordered-drain'));
2477
+ if (isPromiseLike(published))
2478
+ await published;
2479
+ }
2480
+ else {
2481
+ const published = this._publishAppEvent(item.event, item.payload, 'ordered-drain');
2482
+ if (isPromiseLike(published))
2483
+ await published;
2484
+ }
2123
2485
  this._markPublishedSeq(ns, seq);
2486
+ this._markOrderedSeqDelivered(ns, seq);
2124
2487
  this._clientLog.debug(`publish ordered drain delivered: ns=${ns}, seq=${seq}, event=${item.event}`);
2125
2488
  }
2126
2489
  if (queue.size === 0)
@@ -2130,7 +2493,9 @@ export class AUNClient {
2130
2493
  const seqNum = Number(seq);
2131
2494
  if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0) {
2132
2495
  this._clientLog.debug(`publish ordered direct(no-seq): event=${event}, ns=${ns || '<none>'}, seq=${String(seq)}`);
2133
- await this._publishAppEvent(event, payload, 'ordered');
2496
+ const published = this._publishAppEvent(event, payload, 'ordered');
2497
+ if (isPromiseLike(published))
2498
+ await published;
2134
2499
  return true;
2135
2500
  }
2136
2501
  if (this._pushedSeqs.get(ns)?.has(seqNum)) {
@@ -2142,7 +2507,15 @@ export class AUNClient {
2142
2507
  return false;
2143
2508
  }
2144
2509
  const contig = this._seqTracker.getContiguousSeq(ns);
2145
- if (seqNum > contig) {
2510
+ if (seqNum <= contig) {
2511
+ this._clientLog.debug(`publish ordered stale covered: event=${event}, ns=${ns}, seq=${seqNum}, contiguous=${contig}`);
2512
+ const queue = this._pendingOrderedMsgs.get(ns);
2513
+ queue?.delete(seqNum);
2514
+ if (queue && queue.size === 0)
2515
+ this._pendingOrderedMsgs.delete(ns);
2516
+ return false;
2517
+ }
2518
+ if (seqNum !== contig + 1) {
2146
2519
  this._clientLog.debug(`publish ordered enqueue(gap): event=${event}, ns=${ns}, seq=${seqNum}, contiguous=${contig}`);
2147
2520
  this._enqueueOrderedMessage(ns, event, seqNum, payload);
2148
2521
  return false;
@@ -2156,17 +2529,25 @@ export class AUNClient {
2156
2529
  queue?.delete(seqNum);
2157
2530
  if (queue && queue.size === 0)
2158
2531
  this._pendingOrderedMsgs.delete(ns);
2159
- await this._publishAppEvent(event, payload, 'ordered');
2532
+ const published = this._publishAppEvent(event, payload, 'ordered');
2533
+ if (isPromiseLike(published))
2534
+ await published;
2160
2535
  this._markPublishedSeq(ns, seqNum);
2536
+ this._markOrderedSeqDelivered(ns, seqNum);
2161
2537
  this._clientLog.debug(`publish ordered delivered: event=${event}, ns=${ns}, seq=${seqNum}`);
2162
2538
  await this._drainOrderedMessages(ns);
2163
2539
  return true;
2164
2540
  }
2165
2541
  async _publishPulledMessage(event, ns, seq, payload) {
2542
+ // Pull/gap-fill 批次是服务端对 after_seq 的可用结果集,可能跨过永久空洞。
2543
+ // 这里只能做 namespace+seq 去重并按返回顺序发布,不能套用 push 路径的
2544
+ // seq == contiguous_seq + 1 门控,否则会把空洞后的可用消息错误卡住。
2166
2545
  const seqNum = Number(seq);
2167
2546
  if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0 || !ns) {
2168
2547
  this._clientLog.debug(`publish pulled direct(no-seq): event=${event}, ns=${ns || '<none>'}, seq=${String(seq)}`);
2169
- await this._publishAppEvent(event, payload, 'pull');
2548
+ const published = this._withPullResponseProcessing(ns, () => this._publishAppEvent(event, payload, 'pull'));
2549
+ if (isPromiseLike(published))
2550
+ await published;
2170
2551
  return true;
2171
2552
  }
2172
2553
  const queue = this._pendingOrderedMsgs.get(ns);
@@ -2180,22 +2561,51 @@ export class AUNClient {
2180
2561
  queue?.delete(seqNum);
2181
2562
  if (queue && queue.size === 0)
2182
2563
  this._pendingOrderedMsgs.delete(ns);
2183
- await this._publishAppEvent(event, payload, 'pull');
2564
+ const published = this._withPullResponseProcessing(ns, () => this._publishAppEvent(event, payload, 'pull'));
2565
+ if (isPromiseLike(published))
2566
+ await published;
2184
2567
  this._markPublishedSeq(ns, seqNum);
2568
+ this._markPulledSeqDelivered(ns, seqNum);
2569
+ await this._drainOrderedMessages(ns, undefined, true);
2185
2570
  this._clientLog.debug(`publish pulled delivered: event=${event}, ns=${ns}, seq=${seqNum}`);
2186
2571
  return true;
2187
2572
  }
2573
+ _markPulledSeqDelivered(ns, seq) {
2574
+ // Pull 批次是 after_seq 之后服务端当前可用的结果集,可能跨过永久空洞。
2575
+ // 这里仅在应用层发布返回后推进已交付游标,不能改成 push 的相邻 seq 门控。
2576
+ const seqNum = Number(seq);
2577
+ if (!ns || !Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0)
2578
+ return false;
2579
+ const before = this._seqTracker.getContiguousSeq(ns);
2580
+ this._seqTracker.forceContiguousSeq(ns, seqNum);
2581
+ return this._seqTracker.getContiguousSeq(ns) !== before;
2582
+ }
2583
+ _markOrderedSeqDelivered(ns, seq) {
2584
+ if (!ns || !Number.isFinite(seq) || !Number.isInteger(seq) || seq <= 0)
2585
+ return false;
2586
+ const before = this._seqTracker.getContiguousSeq(ns);
2587
+ this._seqTracker.onMessageSeq(ns, seq);
2588
+ return this._seqTracker.getContiguousSeq(ns) !== before;
2589
+ }
2188
2590
  /** 后台补齐群事件空洞 */
2189
2591
  async _fillGroupEventGap(groupId) {
2592
+ groupId = normalizeGroupId(groupId) || String(groupId ?? '').trim();
2593
+ if (!groupId)
2594
+ return;
2190
2595
  const ns = `group_event:${groupId}`;
2191
2596
  const afterSeq = this._seqTracker.getContiguousSeq(ns);
2192
2597
  // 去重:同一 (group_evt:id:after_seq) 只补一次
2193
2598
  const dedupKey = `group_evt:${groupId}:${afterSeq}`;
2194
2599
  if (this._gapFillDone.has(dedupKey))
2195
2600
  return;
2601
+ const token = this._tryAcquirePullGate(ns);
2602
+ if (token === null) {
2603
+ this._clientLog.debug(`group event gap fill skipped: pull in-flight group=${groupId}`);
2604
+ return;
2605
+ }
2196
2606
  this._gapFillDone.set(dedupKey, Date.now());
2607
+ let filled = 0;
2197
2608
  try {
2198
- let filled = 0;
2199
2609
  let nextAfterSeq = afterSeq;
2200
2610
  const maxPages = 100;
2201
2611
  let pageCount = 0;
@@ -2207,6 +2617,7 @@ export class AUNClient {
2207
2617
  after_event_seq: nextAfterSeq,
2208
2618
  device_id: this._deviceId,
2209
2619
  limit: 50,
2620
+ _pull_gate_locked: true,
2210
2621
  });
2211
2622
  if (!isJsonObject(result))
2212
2623
  return;
@@ -2215,16 +2626,12 @@ export class AUNClient {
2215
2626
  return;
2216
2627
  const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
2217
2628
  const eventObjects = events.filter(isJsonObject);
2218
- if (eventObjects.length > 0) {
2219
- this._seqTracker.onPullResult(ns, eventObjects, nextAfterSeq);
2220
- }
2221
- const cursor = isJsonObject(result.cursor) ? result.cursor : null;
2222
- const serverAck = cursor ? Number(cursor.current_seq ?? 0) : 0;
2223
- if (serverAck > 0) {
2629
+ const retentionFloor = this._pullRetentionFloor(result, 'retention_floor_event_seq', 'retention_floor_event_seq');
2630
+ if (retentionFloor > 0) {
2224
2631
  const contigBeforeFloor = this._seqTracker.getContiguousSeq(ns);
2225
- if (contigBeforeFloor < serverAck) {
2226
- this._clientLog.info(`group.pull_events retention-floor advance: ns=${ns} contiguous=${contigBeforeFloor} -> cursor.current_seq=${serverAck}`);
2227
- this._seqTracker.forceContiguousSeq(ns, serverAck);
2632
+ if (contigBeforeFloor < retentionFloor) {
2633
+ this._clientLog.info(`group.pull_events retention-floor advance: ns=${ns} contiguous=${contigBeforeFloor} -> retention_floor=${retentionFloor}`);
2634
+ this._seqTracker.forceContiguousSeq(ns, retentionFloor);
2228
2635
  }
2229
2636
  }
2230
2637
  const eventSeqs = [];
@@ -2235,20 +2642,23 @@ export class AUNClient {
2235
2642
  evt._from_gap_fill = true;
2236
2643
  const et = String(evt.event_type ?? '');
2237
2644
  // 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
2238
- if (et === 'group.message_created')
2239
- continue;
2240
- // 验签:有 client_signature 就验(与实时事件路径对齐)
2241
- const cs = evt.client_signature;
2242
- if (cs && typeof cs === 'object') {
2243
- if (this._shouldSkipEventSignature(evt)) {
2244
- delete evt.client_signature;
2245
- }
2246
- else {
2247
- evt._verified = await this._verifyEventSignatureAsync(evt, cs);
2645
+ if (et !== 'group.message_created') {
2646
+ // 验签:有 client_signature 就验(与实时事件路径对齐)
2647
+ const cs = evt.client_signature;
2648
+ if (cs && typeof cs === 'object') {
2649
+ if (this._shouldSkipEventSignature(evt)) {
2650
+ delete evt.client_signature;
2651
+ }
2652
+ else {
2653
+ evt._verified = await this._verifyEventSignatureAsync(evt, cs);
2654
+ }
2248
2655
  }
2656
+ // group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
2657
+ await this._dispatcher.publish('group.changed', evt);
2658
+ }
2659
+ if (Number.isFinite(eventSeq) && eventSeq > 0) {
2660
+ this._markPulledSeqDelivered(ns, eventSeq);
2249
2661
  }
2250
- // group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
2251
- await this._dispatcher.publish('group.changed', evt);
2252
2662
  filled += 1;
2253
2663
  }
2254
2664
  const contig = this._seqTracker.getContiguousSeq(ns);
@@ -2263,12 +2673,11 @@ export class AUNClient {
2263
2673
  event_seq: ackSeq,
2264
2674
  device_id: this._deviceId,
2265
2675
  slot_id: this._slotId,
2266
- }).catch((e) => { this._clientLog.debug(`group event auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
2676
+ }, undefined, undefined, true).catch((e) => { this._clientLog.debug(`group event auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
2267
2677
  }
2268
- const nextAfter = Math.max(eventSeqs.length > 0 ? Math.max(...eventSeqs) : nextAfterSeq, nextAfterSeq);
2269
- if (eventObjects.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
2270
- break;
2271
- nextAfterSeq = nextAfter;
2678
+ // pull_events 与其它 pull 一样:一次后台任务只消费一个批次。
2679
+ // 非空批次返回后由 pull gate fire-and-forget follow-up 重新排队,直到空批停止。
2680
+ break;
2272
2681
  }
2273
2682
  if (pageCount >= maxPages) {
2274
2683
  this._clientLog.warn(`group event gap fill reached max_pages=${maxPages} group=${groupId} after_seq=${nextAfterSeq}`);
@@ -2280,6 +2689,10 @@ export class AUNClient {
2280
2689
  }
2281
2690
  finally {
2282
2691
  this._gapFillDone.delete(dedupKey);
2692
+ this._releasePullGate(ns, token);
2693
+ if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
2694
+ void this._fillGroupEventGap(groupId);
2695
+ }
2283
2696
  }
2284
2697
  }
2285
2698
  _extractGroupIdFromResult(result) {
@@ -2363,7 +2776,7 @@ export class AUNClient {
2363
2776
  event_seq: contig,
2364
2777
  device_id: this._deviceId,
2365
2778
  slot_id: this._slotId,
2366
- }).catch((e) => { this._clientLog.debug(`group event push auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
2779
+ }, undefined, undefined, true).catch((e) => { this._clientLog.debug(`group event push auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
2367
2780
  }
2368
2781
  }
2369
2782
  // 仅在真实 event gap 时才触发补拉(补洞回来的事件不再触发新补洞)
@@ -2569,11 +2982,85 @@ export class AUNClient {
2569
2982
  return false;
2570
2983
  }
2571
2984
  }
2572
- /**
2573
- * 获取对方证书(带缓存 + 完整 PKI 验证)。 /**
2574
- * 获取对方证书(带缓存 + 完整 PKI 验证)。
2575
- * 跨域时自动路由到 peer 所在域的 Gateway。
2576
- */
2985
+ async _validateAndCachePeerCert(opts) {
2986
+ const aid = String(opts.aid ?? '').trim();
2987
+ const certPem = String(opts.certPem ?? '').trim();
2988
+ const certFingerprint = String(opts.certFingerprint ?? '').trim() || undefined;
2989
+ if (!aid)
2990
+ throw new ValidationError('peer aid is required for cert validation');
2991
+ if (!certPem)
2992
+ throw new ValidationError(`peer cert is empty for ${aid}`);
2993
+ const gatewayUrl = this._gatewayUrl;
2994
+ if (!gatewayUrl) {
2995
+ throw new ValidationError('gateway url unavailable for e2ee cert validation');
2996
+ }
2997
+ const peerGatewayUrl = AUNClient._resolvePeerGatewayUrl(gatewayUrl, aid);
2998
+ const x509Cert = new crypto.X509Certificate(certPem);
2999
+ // H7: 严格校验指纹(DER SHA-256 或 SPKI SHA-256 任一匹配即可)
3000
+ if (certFingerprint) {
3001
+ const expectedFP = certFingerprint.toLowerCase();
3002
+ if (!expectedFP.startsWith('sha256:')) {
3003
+ throw new ValidationError(`unsupported cert_fingerprint format for ${aid}: ${expectedFP.slice(0, 24)}`);
3004
+ }
3005
+ const expectedHex = expectedFP.slice('sha256:'.length);
3006
+ const derHex = x509Cert.fingerprint256.replace(/:/g, '').toLowerCase();
3007
+ let spkiHex = '';
3008
+ try {
3009
+ const spkiDer = x509Cert.publicKey.export({ type: 'spki', format: 'der' });
3010
+ spkiHex = crypto.createHash('sha256').update(spkiDer).digest('hex');
3011
+ }
3012
+ catch {
3013
+ spkiHex = '';
3014
+ }
3015
+ if (expectedHex !== derHex && (!spkiHex || expectedHex !== spkiHex)) {
3016
+ throw new ValidationError(`peer cert fingerprint mismatch for ${aid}: expected=${expectedFP.slice(0, 24)}...`);
3017
+ }
3018
+ }
3019
+ let cachedBootstrapChain = false;
3020
+ const caChainPems = opts.caChainPems ?? [];
3021
+ if (caChainPems.length > 0) {
3022
+ try {
3023
+ this._auth.cacheGatewayCaChain(peerGatewayUrl, caChainPems, aid);
3024
+ cachedBootstrapChain = true;
3025
+ }
3026
+ catch (exc) {
3027
+ this._clientLog.debug(`bootstrap CA chain cache skipped: peer=${aid}, source=${opts.source ?? 'unknown'}, err=${formatCaughtError(exc)}`);
3028
+ }
3029
+ }
3030
+ try {
3031
+ await this._auth.verifyPeerCertificate(peerGatewayUrl, certPem, aid);
3032
+ }
3033
+ catch (exc) {
3034
+ if (cachedBootstrapChain) {
3035
+ this._auth.discardGatewayCaChain(peerGatewayUrl, aid);
3036
+ }
3037
+ throw new ValidationError(`peer cert verification failed for ${aid}: ${exc instanceof Error ? exc.message : String(exc)}`);
3038
+ }
3039
+ const nowSec = Date.now() / 1000;
3040
+ const entry = {
3041
+ certPem,
3042
+ validatedAt: nowSec,
3043
+ refreshAfter: nowSec + PEER_CERT_CACHE_TTL,
3044
+ };
3045
+ const cacheKey = AUNClient._certCacheKey(aid, certFingerprint);
3046
+ this._certCache.set(cacheKey, entry);
3047
+ const bareKey = AUNClient._certCacheKey(aid);
3048
+ if (bareKey !== cacheKey)
3049
+ this._certCache.set(bareKey, entry);
3050
+ if (!certFingerprint) {
3051
+ const actualFp = `sha256:${x509Cert.fingerprint256.replace(/:/g, '').toLowerCase()}`;
3052
+ this._certCache.set(AUNClient._certCacheKey(aid, actualFp), entry);
3053
+ }
3054
+ try {
3055
+ // peer 证书只存版本目录,不覆盖 cert.pem
3056
+ this._keystore.saveCert(aid, certPem, certFingerprint, { makeActive: false });
3057
+ }
3058
+ catch (exc) {
3059
+ this._clientLog.error(`failed to write cert to keystore (aid=${aid}, fp=${certFingerprint ?? ''}): ${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
3060
+ }
3061
+ return certPem;
3062
+ }
3063
+ /** 获取对方证书(带缓存 + 完整 PKI 验证),跨域时自动路由到 peer 所在域。 */
2577
3064
  async _fetchPeerCert(aid, certFingerprint, timeoutMs = 30_000) {
2578
3065
  const tStart = Date.now();
2579
3066
  this._clientLog.debug(`_fetchPeerCert enter: aid=${aid}, fp=${certFingerprint ?? ''}`);
@@ -2589,67 +3076,107 @@ export class AUNClient {
2589
3076
  if (!gatewayUrl) {
2590
3077
  throw new ValidationError('gateway url unavailable for e2ee cert fetch');
2591
3078
  }
2592
- // 跨域时用 peer 所在域的 Gateway URL
2593
3079
  const peerGatewayUrl = AUNClient._resolvePeerGatewayUrl(gatewayUrl, aid);
2594
3080
  let certPem;
2595
3081
  try {
2596
- const certUrl = AUNClient._buildCertUrl(peerGatewayUrl, aid, certFingerprint);
2597
- certPem = await _httpGetText(certUrl, this._configModel.verifySsl, timeoutMs);
3082
+ certPem = await _httpGetText(AUNClient._buildCertUrl(peerGatewayUrl, aid, certFingerprint), this._configModel.verifySsl, timeoutMs);
2598
3083
  }
2599
3084
  catch (exc) {
2600
- if (!certFingerprint) {
3085
+ if (!certFingerprint)
2601
3086
  throw exc;
2602
- }
2603
- const fallbackCert = await _httpGetText(AUNClient._buildCertUrl(peerGatewayUrl, aid), this._configModel.verifySsl, timeoutMs);
2604
- certPem = fallbackCert;
2605
- }
2606
- // H7: 严格校验指纹(DER SHA-256 或 SPKI SHA-256 任一匹配即可)
2607
- if (certFingerprint) {
2608
- const expectedFP = String(certFingerprint).trim().toLowerCase();
2609
- if (!expectedFP.startsWith('sha256:')) {
2610
- throw new ValidationError(`unsupported cert_fingerprint format for ${aid}: ${expectedFP.slice(0, 24)}`);
2611
- }
2612
- const expectedHex = expectedFP.slice('sha256:'.length);
2613
- const x509Cert = new crypto.X509Certificate(certPem);
2614
- const derHex = x509Cert.fingerprint256.replace(/:/g, '').toLowerCase();
2615
- let spkiHex = '';
3087
+ certPem = await _httpGetText(AUNClient._buildCertUrl(peerGatewayUrl, aid), this._configModel.verifySsl, timeoutMs);
3088
+ }
3089
+ const validated = await this._validateAndCachePeerCert({
3090
+ aid,
3091
+ certPem,
3092
+ certFingerprint,
3093
+ source: 'fetch',
3094
+ });
3095
+ this._clientLog.debug(`_fetchPeerCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} (fetched)`);
3096
+ return validated;
3097
+ }
3098
+ catch (err) {
3099
+ this._clientLog.debug(`_fetchPeerCert exit (error): elapsed=${Date.now() - tStart}ms aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
3100
+ throw err;
3101
+ }
3102
+ }
3103
+ _bootstrapCaChain(material) {
3104
+ let raw;
3105
+ for (const key of ['ca_chain', 'ca_chain_pems', 'cert_chain', 'chain']) {
3106
+ if (material[key] !== undefined && material[key] !== null) {
3107
+ raw = material[key];
3108
+ break;
3109
+ }
3110
+ }
3111
+ if (!Array.isArray(raw))
3112
+ return [];
3113
+ const result = [];
3114
+ for (const item of raw) {
3115
+ let certType = '';
3116
+ let certPem = '';
3117
+ if (isJsonObject(item)) {
3118
+ certType = String(item.cert_type ?? '').trim().toLowerCase();
3119
+ if (certType === 'agent')
3120
+ continue;
3121
+ certPem = String(item.cert_pem ?? item.cert ?? '').trim();
3122
+ }
3123
+ else {
3124
+ certPem = String(item ?? '').trim();
3125
+ }
3126
+ if (!certPem)
3127
+ continue;
3128
+ if (!certType) {
2616
3129
  try {
2617
- const spkiDer = x509Cert.publicKey.export({ type: 'spki', format: 'der' });
2618
- spkiHex = crypto.createHash('sha256').update(spkiDer).digest('hex');
3130
+ if (!new crypto.X509Certificate(certPem).ca)
3131
+ continue;
2619
3132
  }
2620
3133
  catch {
2621
- spkiHex = '';
2622
- }
2623
- if (expectedHex !== derHex && (!spkiHex || expectedHex !== spkiHex)) {
2624
- throw new ValidationError(`peer cert fingerprint mismatch for ${aid}: expected=${expectedFP.slice(0, 24)}...`);
3134
+ continue;
2625
3135
  }
2626
3136
  }
2627
- // 完整 PKI 验证
2628
- try {
2629
- await this._auth.verifyPeerCertificate(peerGatewayUrl, certPem, aid);
2630
- }
2631
- catch (exc) {
2632
- throw new ValidationError(`peer cert verification failed for ${aid}: ${exc instanceof Error ? exc.message : String(exc)}`);
2633
- }
2634
- const nowSec = Date.now() / 1000;
2635
- this._certCache.set(cacheKey, {
2636
- certPem,
2637
- validatedAt: nowSec,
2638
- refreshAfter: nowSec + PEER_CERT_CACHE_TTL,
2639
- });
3137
+ result.push(certPem);
3138
+ }
3139
+ return result;
3140
+ }
3141
+ async _primeBootstrapPeerCerts(bootstrap, peerAid) {
3142
+ const certsRaw = bootstrap.certs;
3143
+ if (!isJsonObject(certsRaw))
3144
+ return;
3145
+ const materials = certsRaw;
3146
+ const expected = new Set();
3147
+ const normalizedPeer = String(peerAid ?? '').trim();
3148
+ if (normalizedPeer)
3149
+ expected.add(normalizedPeer);
3150
+ const audit = Array.isArray(bootstrap.audit_recipients) ? bootstrap.audit_recipients : [];
3151
+ for (const dev of audit) {
3152
+ if (!isJsonObject(dev))
3153
+ continue;
3154
+ const aid = String(dev.aid ?? '').trim();
3155
+ if (aid)
3156
+ expected.add(aid);
3157
+ }
3158
+ for (const aid of expected) {
3159
+ if (aid === this._aid)
3160
+ continue;
3161
+ const material = materials[aid];
3162
+ if (!isJsonObject(material))
3163
+ continue;
3164
+ const certPem = String(material.cert_pem ?? material.cert ?? '').trim();
3165
+ if (!certPem)
3166
+ continue;
3167
+ const certFingerprint = String(material.cert_fingerprint ?? material.fingerprint ?? material.fp ?? '').trim() || undefined;
2640
3168
  try {
2641
- // peer 证书只存版本目录,不覆盖 cert.pem
2642
- this._keystore.saveCert(aid, certPem, certFingerprint, { makeActive: false });
3169
+ await this._validateAndCachePeerCert({
3170
+ aid,
3171
+ certPem,
3172
+ certFingerprint,
3173
+ caChainPems: this._bootstrapCaChain(material),
3174
+ source: 'bootstrap',
3175
+ });
2643
3176
  }
2644
3177
  catch (exc) {
2645
- this._clientLog.error(`failed to write cert to keystore (aid=${aid}, fp=${certFingerprint ?? ''}): ${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
3178
+ this._clientLog.debug(`bootstrap peer cert material ignored: peer=${aid}, err=${formatCaughtError(exc)}`);
2646
3179
  }
2647
- this._clientLog.debug(`_fetchPeerCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} (fetched)`);
2648
- return certPem;
2649
- }
2650
- catch (err) {
2651
- this._clientLog.debug(`_fetchPeerCert exit (error): elapsed=${Date.now() - tStart}ms aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
2652
- throw err;
2653
3180
  }
2654
3181
  }
2655
3182
  async _decryptGroupThoughts(result) {
@@ -3108,7 +3635,7 @@ export class AUNClient {
3108
3635
  catch (exc) {
3109
3636
  this._clientLog.warn(`V2 session init failed (non-fatal): ${formatCaughtError(exc)}`);
3110
3637
  }
3111
- // connect/reconnect 成功后自动触发一次 P2P message.pull,补齐离线期间积压
3638
+ // connect/reconnect 成功后自动触发一次 P2P message.v2.pull,补齐离线期间积压
3112
3639
  // 群消息按惰性触发,不在此处主动 pull
3113
3640
  void this._fillP2pGap().catch((exc) => {
3114
3641
  this._clientLog.warn(`schedule post-connect P2P gap fill failed: ${formatCaughtError(exc)}`);
@@ -3200,7 +3727,7 @@ export class AUNClient {
3200
3727
  this._v2Session = new V2Session(v2Store, this._deviceId, this._aid, aidPriv, aidPubDer);
3201
3728
  await this._v2Session.ensureRegistered(this._v2CallFn());
3202
3729
  this._clientLog.debug(`V2 session initialized aid=${this._aid} device=${this._deviceId}`);
3203
- this._safeAsync(this._v2AutoConfirmPendingProposals());
3730
+ // 群 state proposal 由服务端在 client.online 时定向通知。
3204
3731
  }
3205
3732
  async _v2TrustedIKPubDer(aid) {
3206
3733
  const normalizedAid = String(aid ?? '').trim();
@@ -3379,7 +3906,11 @@ export class AUNClient {
3379
3906
  const session = this._v2Session;
3380
3907
  if (session && fromAid) {
3381
3908
  try {
3382
- const bs = await this.call('message.v2.bootstrap', { peer_aid: fromAid });
3909
+ const bs = await this.call('message.v2.bootstrap', {
3910
+ peer_aid: fromAid,
3911
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
3912
+ });
3913
+ await this._primeBootstrapPeerCerts(bs, fromAid);
3383
3914
  const peers = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
3384
3915
  for (const dev of peers)
3385
3916
  this._cacheV2PeerIKFromDevice(dev, fromAid);
@@ -3389,7 +3920,10 @@ export class AUNClient {
3389
3920
  }
3390
3921
  if (groupId) {
3391
3922
  try {
3392
- const gbs = await this.call('group.v2.bootstrap', { group_id: groupId });
3923
+ const gbs = await this.call('group.v2.bootstrap', {
3924
+ group_id: groupId,
3925
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
3926
+ });
3393
3927
  const devices = (Array.isArray(gbs?.devices) ? gbs.devices : []);
3394
3928
  const audit = (Array.isArray(gbs?.audit_recipients) ? gbs.audit_recipients : []);
3395
3929
  for (const dev of devices)
@@ -3448,14 +3982,21 @@ export class AUNClient {
3448
3982
  const useCache = opts.useCache !== false;
3449
3983
  let peerDevices = [];
3450
3984
  let auditRaw = [];
3985
+ let wrapPolicy = normalizeV2WrapPolicy(undefined);
3451
3986
  const cached = useCache ? this._v2BootstrapCache.get(to) : undefined;
3452
3987
  if (cached && Date.now() - cached.cachedAt < AUNClient.V2_BOOTSTRAP_TTL_MS) {
3453
3988
  peerDevices = cached.devices;
3454
3989
  auditRaw = cached.auditRecipients;
3990
+ wrapPolicy = cached.wrapPolicy ?? wrapPolicy;
3455
3991
  this._clientLog.debug(`message.v2.bootstrap cache hit: to=${to}, devices=${peerDevices.length}, audit=${auditRaw.length}`);
3456
3992
  }
3457
3993
  else {
3458
- const bs = await this.call('message.v2.bootstrap', { peer_aid: to });
3994
+ const bs = await this.call('message.v2.bootstrap', {
3995
+ peer_aid: to,
3996
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
3997
+ });
3998
+ await this._primeBootstrapPeerCerts(bs, to);
3999
+ wrapPolicy = normalizeV2WrapPolicy(bs.e2ee_wrap_policy);
3459
4000
  peerDevices = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
3460
4001
  auditRaw = (Array.isArray(bs?.audit_recipients) ? bs.audit_recipients : []);
3461
4002
  this._clientLog.debug(`message.v2.bootstrap fetched: to=${to}, devices=${peerDevices.length}, audit=${auditRaw.length}`);
@@ -3464,6 +4005,7 @@ export class AUNClient {
3464
4005
  devices: peerDevices,
3465
4006
  auditRecipients: auditRaw,
3466
4007
  cachedAt: Date.now(),
4008
+ wrapPolicy,
3467
4009
  });
3468
4010
  }
3469
4011
  }
@@ -3504,13 +4046,19 @@ export class AUNClient {
3504
4046
  selfDevices = selfCached.devices;
3505
4047
  }
3506
4048
  else {
3507
- const selfBs = await this.call('message.v2.bootstrap', { peer_aid: this._aid });
4049
+ const selfBs = await this.call('message.v2.bootstrap', {
4050
+ peer_aid: this._aid,
4051
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
4052
+ });
4053
+ await this._primeBootstrapPeerCerts(selfBs, this._aid);
3508
4054
  selfDevices = (Array.isArray(selfBs?.peer_devices) ? selfBs.peer_devices : []);
4055
+ const selfWrapPolicy = normalizeV2WrapPolicy(selfBs.e2ee_wrap_policy);
3509
4056
  if (selfDevices.length > 0) {
3510
4057
  this._v2BootstrapCache.set(this._aid, {
3511
4058
  devices: selfDevices,
3512
4059
  auditRecipients: [],
3513
4060
  cachedAt: Date.now(),
4061
+ wrapPolicy: selfWrapPolicy,
3514
4062
  });
3515
4063
  }
3516
4064
  }
@@ -3536,7 +4084,10 @@ export class AUNClient {
3536
4084
  if (targets.length === 0) {
3537
4085
  throw new E2EEError(`V2 bootstrap: no usable devices found for ${to}`);
3538
4086
  }
3539
- const envelope = encryptP2PMessage(session.getSenderIdentity(), { targets, auditRecipients: auditTargets }, opts.payload, {
4087
+ const envelope = encryptP2PMessage(session.getSenderIdentity(), {
4088
+ targets: applyV2WrapPolicyToTargets(targets, wrapPolicy),
4089
+ auditRecipients: applyV2WrapPolicyToTargets(auditTargets, wrapPolicy),
4090
+ }, opts.payload, {
3540
4091
  messageId: opts.messageId,
3541
4092
  timestamp: opts.timestamp,
3542
4093
  protectedHeaders: opts.protectedHeaders,
@@ -3606,18 +4157,30 @@ export class AUNClient {
3606
4157
  }
3607
4158
  }
3608
4159
  /** V2 P2P 拉取并解密;直接方法返回消息数组,call("message.pull") 会包装为 {messages}. */
3609
- async pullV2(afterSeq = 0, limit = 50) {
4160
+ async pullV2(afterSeq = 0, limit = 50, opts) {
3610
4161
  await this._ensureV2SessionReady('message.pull');
3611
4162
  const ns = this._aid ? `p2p:${this._aid}` : '';
4163
+ if (ns && !opts?.gateLocked) {
4164
+ return await this._runPullSerialized(ns, async () => this.pullV2(afterSeq, limit, {
4165
+ ...(opts ?? {}),
4166
+ gateLocked: true,
4167
+ scheduleFollowup: true,
4168
+ }));
4169
+ }
3612
4170
  const decrypted = [];
4171
+ let totalRawCount = 0;
3613
4172
  let nextAfterSeq = afterSeq || (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
3614
4173
  let pageCount = 0;
3615
4174
  const maxPages = 100;
3616
4175
  while (pageCount < maxPages) {
3617
4176
  pageCount += 1;
3618
4177
  this._clientLog.debug(`message.v2.pull page request: page=${pageCount}, after_seq=${nextAfterSeq}, limit=${limit}, ns=${ns || '<none>'}`);
3619
- const result = await this.call('message.v2.pull', { after_seq: nextAfterSeq, limit });
4178
+ const result = await this._callRawV2Rpc('message.v2.pull', {
4179
+ after_seq: nextAfterSeq,
4180
+ limit,
4181
+ });
3620
4182
  const messages = (Array.isArray(result?.messages) ? result.messages : []);
4183
+ totalRawCount += messages.length;
3621
4184
  this._clientLog.debug(`message.v2.pull page response: page=${pageCount}, raw_count=${messages.length}, has_more=${String(result.has_more ?? '')}, server_ack_seq=${String(result.server_ack_seq ?? '')}`);
3622
4185
  for (const msg of messages) {
3623
4186
  this._logMessageDebug('pull-raw', 'message.v2.pull', 'message.received', msg);
@@ -3626,10 +4189,13 @@ export class AUNClient {
3626
4189
  .map((msg) => Number(msg.seq ?? 0))
3627
4190
  .filter((seq) => Number.isFinite(seq) && seq > 0);
3628
4191
  const pageContigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
3629
- const pageMaxSeq = seqs.length > 0 ? Math.max(...seqs) : nextAfterSeq;
3630
- if (ns && seqs.length > 0) {
3631
- this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
3632
- this._clientLog.debug(`message.v2.pull force contiguous: ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
4192
+ let pageMaxSeq = nextAfterSeq;
4193
+ if (seqs.length > 0) {
4194
+ pageMaxSeq = Math.max(...seqs);
4195
+ if (ns) {
4196
+ this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
4197
+ this._clientLog.debug(`message.v2.pull force contiguous: ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
4198
+ }
3633
4199
  }
3634
4200
  for (const msg of messages) {
3635
4201
  const seq = Number(msg.seq ?? 0);
@@ -3653,10 +4219,12 @@ export class AUNClient {
3653
4219
  payload: legacyPayload,
3654
4220
  encrypted: false,
3655
4221
  };
3656
- if (ns)
4222
+ if (ns) {
3657
4223
  await this._publishPulledMessage('message.received', ns, seq, v1Msg);
3658
- else
4224
+ }
4225
+ else {
3659
4226
  await this._publishAppEvent('message.received', v1Msg, 'pull');
4227
+ }
3660
4228
  decrypted.push(v1Msg);
3661
4229
  this._clientLog.debug(`message.v2.pull plaintext V1 delivered: seq=${seq}, ns=${ns || '<none>'}`);
3662
4230
  }
@@ -3678,10 +4246,12 @@ export class AUNClient {
3678
4246
  this._clientLog.debug(`message.v2.pull decrypt returned null: seq=${seq}, ns=${ns || '<none>'}`);
3679
4247
  continue;
3680
4248
  }
3681
- if (ns)
4249
+ if (ns) {
3682
4250
  await this._publishPulledMessage('message.received', ns, seq, plaintext);
3683
- else
4251
+ }
4252
+ else {
3684
4253
  await this._publishAppEvent('message.received', plaintext, 'pull');
4254
+ }
3685
4255
  decrypted.push(plaintext);
3686
4256
  this._logMessageDebug('decrypt-ok', 'message.v2.pull', 'message.received', plaintext);
3687
4257
  }
@@ -3697,10 +4267,10 @@ export class AUNClient {
3697
4267
  const ackSeq = this._seqTracker.getContiguousSeq(ns);
3698
4268
  const contigAdvanced = ackSeq !== pageContigBefore;
3699
4269
  if (contigAdvanced) {
3700
- await this._drainOrderedMessages(ns);
4270
+ await this._drainOrderedMessages(ns, undefined, true);
3701
4271
  this._saveSeqTrackerState();
3702
4272
  }
3703
- if (messages.length > 0 && contigAdvanced && ackSeq > 0) {
4273
+ if (messages.length > 0 && contigAdvanced && ackSeq > 0 && !opts?.skipAutoAck) {
3704
4274
  this._clientLog.debug(`message.v2.pull scheduling auto-ack: ns=${ns}, ack_seq=${ackSeq}, raw_count=${messages.length}`);
3705
4275
  this._safeAsync(this.ackV2(ackSeq).then(() => undefined));
3706
4276
  }
@@ -3733,7 +4303,7 @@ export class AUNClient {
3733
4303
  }
3734
4304
  }
3735
4305
  this._clientLog.debug(`message.v2.ack send: ns=${ns || '<none>'}, up_to_seq=${seq}`);
3736
- const raw = await this.call('message.v2.ack', { up_to_seq: seq });
4306
+ const raw = await this._callRawV2Rpc('message.v2.ack', { up_to_seq: seq });
3737
4307
  const result = isJsonObject(raw)
3738
4308
  ? { ...raw }
3739
4309
  : { result: raw };
@@ -3839,19 +4409,25 @@ export class AUNClient {
3839
4409
  let auditRecipientsRaw = [];
3840
4410
  let epoch = 0;
3841
4411
  let stateCommitment = { state_version: 0, state_hash: '', state_chain: '' };
4412
+ let wrapPolicy = normalizeV2WrapPolicy(undefined);
3842
4413
  const cached = useCache ? this._v2BootstrapCache.get(cacheKey) : undefined;
3843
4414
  if (cached && Date.now() - cached.cachedAt < AUNClient.V2_BOOTSTRAP_TTL_MS) {
3844
4415
  allDevices = cached.devices;
3845
4416
  auditRecipientsRaw = cached.auditRecipients;
3846
4417
  epoch = cached.epoch ?? 0;
3847
4418
  stateCommitment = cached.stateCommitment ?? stateCommitment;
4419
+ wrapPolicy = cached.wrapPolicy ?? wrapPolicy;
3848
4420
  this._clientLog.debug(`group.v2.bootstrap cache hit: group=${groupId}, devices=${allDevices.length}, audit=${auditRecipientsRaw.length}, epoch=${epoch}, state_version=${stateCommitment.state_version}`);
3849
4421
  }
3850
4422
  else {
3851
- const bs = await this.call('group.v2.bootstrap', { group_id: groupId });
4423
+ const bs = await this.call('group.v2.bootstrap', {
4424
+ group_id: groupId,
4425
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
4426
+ });
3852
4427
  allDevices = (Array.isArray(bs.devices) ? bs.devices : []);
3853
4428
  auditRecipientsRaw = (Array.isArray(bs.audit_recipients) ? bs.audit_recipients : []);
3854
4429
  epoch = Number(bs.epoch ?? 0) || 0;
4430
+ wrapPolicy = normalizeV2WrapPolicy(bs.e2ee_wrap_policy);
3855
4431
  this._clientLog.debug(`group.v2.bootstrap fetched: group=${groupId}, devices=${allDevices.length}, audit=${auditRecipientsRaw.length}, epoch=${epoch}, members=${Array.isArray(bs.member_aids) ? bs.member_aids.length : 0}`);
3856
4432
  const stateChain = String(bs.state_chain ?? '');
3857
4433
  await this._v2CheckFork(groupId, stateChain);
@@ -3869,6 +4445,7 @@ export class AUNClient {
3869
4445
  cachedAt: Date.now(),
3870
4446
  epoch,
3871
4447
  stateCommitment,
4448
+ wrapPolicy,
3872
4449
  });
3873
4450
  }
3874
4451
  // lazy sync 触发:发现 pending members 时异步发起提案
@@ -3911,7 +4488,7 @@ export class AUNClient {
3911
4488
  if (target)
3912
4489
  targets.push(target);
3913
4490
  }
3914
- const envelope = encryptGroupMessage(session.getSenderIdentity(), groupId, epoch, targets, opts.payload, {
4491
+ const envelope = encryptGroupMessage(session.getSenderIdentity(), groupId, epoch, applyV2WrapPolicyToTargets(targets, wrapPolicy), opts.payload, {
3915
4492
  messageId: opts.messageId,
3916
4493
  timestamp: opts.timestamp,
3917
4494
  protectedHeaders: opts.protectedHeaders,
@@ -3938,28 +4515,37 @@ export class AUNClient {
3938
4515
  return envelope;
3939
4516
  }
3940
4517
  async _pullGroupV2Internal(params) {
3941
- await this.pullGroupV2(params.group_id, params.after_seq, params.limit);
4518
+ await this.pullGroupV2(params.group_id, params.after_seq, params.limit, { gateLocked: true });
3942
4519
  }
3943
4520
  /** V2 Group 拉取并解密;直接方法返回消息数组,call("group.pull") 会包装为 {messages}. */
3944
- async pullGroupV2(groupId, afterSeq = 0, limit = 50) {
4521
+ async pullGroupV2(groupId, afterSeq = 0, limit = 50, opts) {
3945
4522
  await this._ensureV2SessionReady('group.pull');
3946
4523
  const gid = normalizeGroupId(groupId) || String(groupId ?? '').trim();
3947
4524
  if (!gid)
3948
4525
  throw new ValidationError('group.pull requires group_id');
3949
4526
  const ns = `group:${gid}`;
4527
+ if (!opts?.gateLocked) {
4528
+ return await this._runPullSerialized(ns, async () => this.pullGroupV2(gid, afterSeq, limit, {
4529
+ ...(opts ?? {}),
4530
+ gateLocked: true,
4531
+ scheduleFollowup: true,
4532
+ }));
4533
+ }
3950
4534
  const decrypted = [];
4535
+ let totalRawCount = 0;
3951
4536
  let nextAfterSeq = afterSeq || this._seqTracker.getContiguousSeq(ns);
3952
4537
  let pageCount = 0;
3953
4538
  const maxPages = 100;
3954
4539
  while (pageCount < maxPages) {
3955
4540
  pageCount += 1;
3956
4541
  this._clientLog.debug(`group.v2.pull page request: group=${gid}, page=${pageCount}, after_seq=${nextAfterSeq}, limit=${limit}, ns=${ns}`);
3957
- const result = await this.call('group.v2.pull', {
4542
+ const result = await this._callRawV2Rpc('group.v2.pull', {
3958
4543
  group_id: gid,
3959
4544
  after_seq: nextAfterSeq,
3960
4545
  limit,
3961
4546
  });
3962
4547
  const messages = (Array.isArray(result.messages) ? result.messages : []);
4548
+ totalRawCount += messages.length;
3963
4549
  const cursor = isJsonObject(result.cursor) ? result.cursor : null;
3964
4550
  this._clientLog.debug(`group.v2.pull page response: group=${gid}, page=${pageCount}, raw_count=${messages.length}, has_more=${String(result.has_more ?? '')}, cursor_current=${String(cursor?.current_seq ?? '')}`);
3965
4551
  for (const msg of messages) {
@@ -3969,8 +4555,9 @@ export class AUNClient {
3969
4555
  .map((msg) => Number(msg.seq ?? 0))
3970
4556
  .filter((seq) => Number.isFinite(seq) && seq > 0);
3971
4557
  const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
3972
- const pageMaxSeq = seqs.length > 0 ? Math.max(...seqs) : nextAfterSeq;
4558
+ let pageMaxSeq = nextAfterSeq;
3973
4559
  if (seqs.length > 0) {
4560
+ pageMaxSeq = Math.max(...seqs);
3974
4561
  this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
3975
4562
  this._clientLog.debug(`group.v2.pull force contiguous: group=${gid}, ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
3976
4563
  }
@@ -4034,18 +4621,18 @@ export class AUNClient {
4034
4621
  decrypted.push(plaintext);
4035
4622
  this._logMessageDebug('decrypt-ok', 'group.v2.pull', 'group.message_created', plaintext);
4036
4623
  }
4037
- const serverAckSeq = Number(cursor?.current_seq ?? 0);
4038
- if (Number.isFinite(serverAckSeq) && serverAckSeq > 0) {
4624
+ const retentionFloor = this._pullRetentionFloor(result, 'retention_floor_message_seq', 'retention_floor_message_seq');
4625
+ if (retentionFloor > 0) {
4039
4626
  const contig = this._seqTracker.getContiguousSeq(ns);
4040
- if (contig < serverAckSeq) {
4041
- this._clientLog.info(`group.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> cursor.current_seq=${serverAckSeq}`);
4042
- this._seqTracker.forceContiguousSeq(ns, serverAckSeq);
4627
+ if (contig < retentionFloor) {
4628
+ this._clientLog.info(`group.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> retention_floor=${retentionFloor}`);
4629
+ this._seqTracker.forceContiguousSeq(ns, retentionFloor);
4043
4630
  }
4044
4631
  }
4045
4632
  const ackSeq = this._seqTracker.getContiguousSeq(ns);
4046
4633
  const contigAdvanced = ackSeq !== pageContigBefore;
4047
4634
  if (contigAdvanced) {
4048
- await this._drainOrderedMessages(ns);
4635
+ await this._drainOrderedMessages(ns, undefined, true);
4049
4636
  this._saveSeqTrackerState();
4050
4637
  }
4051
4638
  if (messages.length > 0 && contigAdvanced && ackSeq > 0) {
@@ -4081,7 +4668,7 @@ export class AUNClient {
4081
4668
  seq = maxSeen;
4082
4669
  }
4083
4670
  this._clientLog.debug(`group.v2.ack send: group=${gid}, ns=${ns}, up_to_seq=${seq}`);
4084
- const result = await this.call('group.v2.ack', { group_id: gid, up_to_seq: seq });
4671
+ const result = await this._callRawV2Rpc('group.v2.ack', { group_id: gid, up_to_seq: seq });
4085
4672
  this._clientLog.debug(`group.v2.ack ok: group=${gid}, ns=${ns}, requested=${seq}, result=${this._debugJson(result)}`);
4086
4673
  return result;
4087
4674
  }
@@ -4117,7 +4704,7 @@ export class AUNClient {
4117
4704
  for (const row of recipients) {
4118
4705
  if (Array.isArray(row) && row.length >= 6
4119
4706
  && String(row[0] ?? '') === this._aid
4120
- && String(row[1] ?? '') === this._deviceId) {
4707
+ && (String(row[1] ?? '') === this._deviceId || String(row[1] ?? '') === '')) {
4121
4708
  if (!spkId)
4122
4709
  spkId = String(row[5] ?? '');
4123
4710
  if (row.length > 3)
@@ -4465,7 +5052,8 @@ export class AUNClient {
4465
5052
  for (const row of envelope.recipients) {
4466
5053
  if (!Array.isArray(row) || row.length < 6)
4467
5054
  continue;
4468
- if (String(row[0] ?? '') === this._aid && String(row[1] ?? '') === this._deviceId) {
5055
+ if (String(row[0] ?? '') === this._aid
5056
+ && (String(row[1] ?? '') === this._deviceId || String(row[1] ?? '') === '')) {
4469
5057
  spkId = String(row[5] ?? '');
4470
5058
  recipientKeySource = String(row[3] ?? '');
4471
5059
  break;
@@ -4749,7 +5337,10 @@ export class AUNClient {
4749
5337
  }
4750
5338
  if (myRole !== 'owner' && myRole !== 'admin')
4751
5339
  return false;
4752
- const bootstrapResp = await this.call('group.v2.bootstrap', { group_id: groupId });
5340
+ const bootstrapResp = await this.call('group.v2.bootstrap', {
5341
+ group_id: groupId,
5342
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
5343
+ });
4753
5344
  const devices = isJsonObject(bootstrapResp) && Array.isArray(bootstrapResp.devices)
4754
5345
  ? bootstrapResp.devices.filter(isJsonObject)
4755
5346
  : [];
@@ -4858,7 +5449,10 @@ export class AUNClient {
4858
5449
  }
4859
5450
  }
4860
5451
  }
4861
- const bootstrapResp = await this.call('group.v2.bootstrap', { group_id: groupId });
5452
+ const bootstrapResp = await this.call('group.v2.bootstrap', {
5453
+ group_id: groupId,
5454
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
5455
+ });
4862
5456
  const allDevices = isJsonObject(bootstrapResp) && Array.isArray(bootstrapResp.devices)
4863
5457
  ? bootstrapResp.devices.filter(isJsonObject)
4864
5458
  : [];
@@ -5064,11 +5658,10 @@ export class AUNClient {
5064
5658
  try {
5065
5659
  const decrypted = await this._decryptV2PushMessage(data);
5066
5660
  if (decrypted) {
5067
- // 解密成功:把 pushSeq 加入 receivedSeqs,让 _tryAdvance 自然推进
5068
- // (如果 pushSeq == contiguousSeq + 1 会自动推进到 pushSeq)
5069
- const needPull = this._seqTracker.onMessageSeq(ns, pushSeq);
5661
+ // 解密成功也不能先推进 contiguousSeq;必须等应用层发布返回后再推进和 ACK。
5070
5662
  const published = await this._publishOrderedMessage('message.received', ns, pushSeq, decrypted);
5071
5663
  const newContig = this._seqTracker.getContiguousSeq(ns);
5664
+ const needPull = pushSeq > newContig && !published;
5072
5665
  if (newContig !== contigBefore) {
5073
5666
  this._saveSeqTrackerState();
5074
5667
  }
@@ -5076,7 +5669,7 @@ export class AUNClient {
5076
5669
  // ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
5077
5670
  const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
5078
5671
  const ackSeq = maxSeen > 0 ? Math.min(newContig, maxSeen) : newContig;
5079
- this.call('message.v2.ack', { up_to_seq: ackSeq })
5672
+ this.call('message.v2.ack', { up_to_seq: ackSeq, _rpc_background: true })
5080
5673
  .catch(e => this._clientLog.debug(`V2 P2P push-ack failed: ${formatCaughtError(e)}`));
5081
5674
  }
5082
5675
  this._clientLog.debug(`_onV2PushNotification: push 带 payload 解密成功, contiguous_seq=${contigBefore}->${newContig} push_seq=${pushSeq}`);
@@ -5096,31 +5689,35 @@ export class AUNClient {
5096
5689
  if (pushSeq > 0 && ns) {
5097
5690
  this._clientLog.debug(`_onV2PushNotification: 纯通知 push_seq=${pushSeq} > contiguous_seq=${contigBefore}, 触发 pull(after_seq=${contigBefore})`);
5098
5691
  }
5099
- if (this._v2PullInflight) {
5100
- this._v2PullPending = true;
5692
+ if (!ns)
5101
5693
  return;
5102
- }
5103
- this._v2PullInflight = true;
5104
- try {
5105
- do {
5106
- this._v2PullPending = false;
5107
- await this.pullV2();
5108
- const newContig = ns ? this._seqTracker.getContiguousSeq(ns) : -1;
5694
+ void this._tryRunBackgroundPull(ns, async () => {
5695
+ const operationBefore = this._seqTracker.getContiguousSeq(ns);
5696
+ const dedupKey = `p2p_pull:${ns}`;
5697
+ if (this._gapFillDone.has(dedupKey))
5698
+ return 0;
5699
+ this._gapFillDone.set(dedupKey, Date.now());
5700
+ try {
5701
+ const pulled = await this.pullV2(0, 50, { gateLocked: true });
5702
+ const newContig = this._seqTracker.getContiguousSeq(ns);
5109
5703
  this._clientLog.debug(`_onV2PushNotification pull done: contiguous_seq=${contigBefore}->${newContig} (push_seq=${pushSeq || 'null'})`);
5110
- } while (this._v2PullPending);
5111
- }
5112
- catch (exc) {
5113
- const newContig = ns ? this._seqTracker.getContiguousSeq(ns) : -1;
5704
+ if (newContig <= operationBefore)
5705
+ return 0;
5706
+ return pulled.length;
5707
+ }
5708
+ finally {
5709
+ this._gapFillDone.delete(dedupKey);
5710
+ }
5711
+ }, true).catch((exc) => {
5712
+ const newContig = this._seqTracker.getContiguousSeq(ns);
5114
5713
  this._clientLog.warn(`V2 push auto-pull failed: contiguous_seq=${contigBefore}->${newContig} err=${formatCaughtError(exc)}`);
5115
- }
5116
- finally {
5117
- this._v2PullInflight = false;
5118
- }
5714
+ });
5119
5715
  }
5120
5716
  async _onV2StateProposed(data) {
5121
5717
  if (!isJsonObject(data) || !this._v2Session)
5122
5718
  return;
5123
- const groupId = String(data.group_id ?? '').trim();
5719
+ const rawGroupId = String(data.group_id ?? '').trim();
5720
+ const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
5124
5721
  if (!groupId)
5125
5722
  return;
5126
5723
  await this._dispatcher.publish('group.v2.state_proposed', data);
@@ -5134,7 +5731,8 @@ export class AUNClient {
5134
5731
  async _onV2StateRetryNeeded(data) {
5135
5732
  if (!isJsonObject(data) || !this._v2Session)
5136
5733
  return;
5137
- const groupId = String(data.group_id ?? '').trim();
5734
+ const rawGroupId = String(data.group_id ?? '').trim();
5735
+ const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
5138
5736
  if (!groupId)
5139
5737
  return;
5140
5738
  await this._dispatcher.publish('group.v2.state_retry_needed', data);
@@ -5148,7 +5746,8 @@ export class AUNClient {
5148
5746
  async _onV2StateConfirmed(data) {
5149
5747
  if (!isJsonObject(data))
5150
5748
  return;
5151
- const groupId = String(data.group_id ?? '').trim();
5749
+ const rawGroupId = String(data.group_id ?? '').trim();
5750
+ const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
5152
5751
  if (groupId) {
5153
5752
  this._v2BootstrapCache.delete(`group:${groupId}`);
5154
5753
  this._v2AutoProposeLastSnapshot.delete(groupId);
@@ -5161,7 +5760,8 @@ export class AUNClient {
5161
5760
  return;
5162
5761
  }
5163
5762
  this._logMessageDebug('server-push', '_raw.group.v2.message_created', 'group.message_created', data);
5164
- const groupId = String(data.group_id ?? '').trim();
5763
+ const rawGroupId = String(data.group_id ?? '').trim();
5764
+ const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
5165
5765
  const seq = Number(data.seq ?? 0);
5166
5766
  if (!groupId || !Number.isFinite(seq) || seq <= 0) {
5167
5767
  this._clientLog.debug(`_onRawGroupV2MessageCreated skipped: group=${groupId || '<empty>'}, seq=${String(data.seq ?? '')}`);
@@ -5178,22 +5778,28 @@ export class AUNClient {
5178
5778
  }
5179
5779
  const afterSeq = this._repairPushContiguousBound(ns, seq, false, '_raw.group.v2.message_created');
5180
5780
  const dedupKey = `v2_group_push:${groupId}:${afterSeq}`;
5181
- if (this._gapFillDone.has(dedupKey)) {
5182
- this._clientLog.debug(`_onRawGroupV2MessageCreated skipped duplicate in-flight pull: group=${groupId}, dedup=${dedupKey}`);
5183
- return;
5184
- }
5185
- this._gapFillDone.set(dedupKey, Date.now());
5186
- try {
5187
- this._clientLog.debug(`_onRawGroupV2MessageCreated auto-pull start: group=${groupId}, after_seq=${afterSeq}, push_seq=${seq}`);
5188
- await this.pullGroupV2(groupId, afterSeq, 50);
5189
- this._clientLog.debug(`_onRawGroupV2MessageCreated auto-pull done: group=${groupId}, after_seq=${afterSeq}, push_seq=${seq}, contiguous=${this._seqTracker.getContiguousSeq(ns)}`);
5190
- }
5191
- catch (exc) {
5781
+ void this._tryRunBackgroundPull(ns, async () => {
5782
+ const pullAfterSeq = this._seqTracker.getContiguousSeq(ns);
5783
+ if (this._gapFillDone.has(dedupKey)) {
5784
+ this._clientLog.debug(`_onRawGroupV2MessageCreated skipped duplicate in-flight pull: group=${groupId}, dedup=${dedupKey}`);
5785
+ return 0;
5786
+ }
5787
+ this._gapFillDone.set(dedupKey, Date.now());
5788
+ try {
5789
+ this._clientLog.debug(`_onRawGroupV2MessageCreated auto-pull start: group=${groupId}, after_seq=${pullAfterSeq}, push_seq=${seq}`);
5790
+ const pulled = await this.pullGroupV2(groupId, pullAfterSeq, 50, { gateLocked: true });
5791
+ const newContig = this._seqTracker.getContiguousSeq(ns);
5792
+ this._clientLog.debug(`_onRawGroupV2MessageCreated auto-pull done: group=${groupId}, after_seq=${pullAfterSeq}, push_seq=${seq}, contiguous=${newContig}`);
5793
+ if (newContig <= pullAfterSeq)
5794
+ return 0;
5795
+ return pulled.length;
5796
+ }
5797
+ finally {
5798
+ this._gapFillDone.delete(dedupKey);
5799
+ }
5800
+ }, true).catch((exc) => {
5192
5801
  this._clientLog.warn(`V2 group push auto-pull failed: group=${groupId} err=${formatCaughtError(exc)}`);
5193
- }
5194
- finally {
5195
- this._gapFillDone.delete(dedupKey);
5196
- }
5802
+ });
5197
5803
  }
5198
5804
  /** Push 通知带 payload 时的就地解密(复用 _decryptV2Message) */
5199
5805
  async _decryptV2PushMessage(data) {