@agentunion/fastaun 0.3.3 → 0.3.5

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 (57) hide show
  1. package/CHANGELOG.md +108 -85
  2. package/_packed_docs/CHANGELOG.md +108 -85
  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/E2EE_V2/347/256/200/345/214/226/344/270/2721DH/345/212/240Per-AID_Wrap/346/226/271/346/241/210.md +124 -0
  8. 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
  9. 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
  10. package/_packed_docs/sdk/01-/345/277/253/351/200/237/345/274/200/345/247/213.md +5 -5
  11. package/_packed_docs/sdk/02-WebSocket/345/215/217/350/256/256.md +1 -1
  12. package/_packed_docs/sdk/03-/346/240/270/345/277/203/346/246/202/345/277/265.md +2 -2
  13. package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +46 -6
  14. package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +89 -12
  15. package/_packed_docs/sdk/07-/351/224/231/350/257/257/345/244/204/347/220/206.md +19 -1
  16. package/_packed_docs/sdk/08-/346/234/200/344/275/263/345/256/236/350/267/265.md +20 -5
  17. package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +8 -8
  18. 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
  19. package/_packed_docs/sdk/INDEX.md +22 -22
  20. package/_packed_docs/sdk/README.md +3 -3
  21. package/dist/auth.d.ts +41 -8
  22. package/dist/auth.js +380 -101
  23. package/dist/auth.js.map +1 -1
  24. package/dist/client.d.ts +64 -19
  25. package/dist/client.js +1094 -443
  26. package/dist/client.js.map +1 -1
  27. package/dist/errors.d.ts +4 -0
  28. package/dist/errors.js +7 -0
  29. package/dist/errors.js.map +1 -1
  30. package/dist/events.d.ts +9 -0
  31. package/dist/events.js +42 -12
  32. package/dist/events.js.map +1 -1
  33. package/dist/index.d.ts +2 -2
  34. package/dist/index.js +2 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/keystore/file.d.ts +20 -0
  37. package/dist/keystore/file.js +91 -1
  38. package/dist/keystore/file.js.map +1 -1
  39. package/dist/namespaces/auth.d.ts +35 -4
  40. package/dist/namespaces/auth.js +175 -65
  41. package/dist/namespaces/auth.js.map +1 -1
  42. package/dist/secret-store/file-store.d.ts +21 -2
  43. package/dist/secret-store/file-store.js +166 -11
  44. package/dist/secret-store/file-store.js.map +1 -1
  45. package/dist/tools/cross-sdk-agent.js +2 -2
  46. package/dist/tools/cross-sdk-agent.js.map +1 -1
  47. package/dist/transport.d.ts +8 -1
  48. package/dist/transport.js +151 -32
  49. package/dist/transport.js.map +1 -1
  50. package/dist/v2/e2ee/decrypt.js +1 -1
  51. package/dist/v2/e2ee/decrypt.js.map +1 -1
  52. package/dist/v2/e2ee/encrypt-p2p.js +3 -2
  53. package/dist/v2/e2ee/encrypt-p2p.js.map +1 -1
  54. package/dist/v2/session/session.d.ts +1 -0
  55. package/dist/v2/session/session.js +7 -1
  56. package/dist/v2/session/session.js.map +1 -1
  57. 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,10 +435,17 @@ 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) */
386
446
  _pendingOrderedMsgs = new Map();
447
+ /** P2P pull 进行中到达的纯通知 push 上界;pull gate 释放后需要补拉一次。 */
448
+ _pendingP2pPullUpper = new Map();
387
449
  /** 缺 sender IK 时暂存原始 V2 消息,后台补齐 IK 后重试解密。 */
388
450
  _v2SenderIKPending = new Map();
389
451
  /** sender IK 后台补齐任务去重。 */
@@ -409,10 +471,9 @@ export class AUNClient {
409
471
  /** 最近一次已成功提交的 membership_snapshot;相同快照直接跳过。 */
410
472
  _v2AutoProposeLastSnapshot = new Map();
411
473
  _v2LazyProposeTriggered = new Map();
412
- _v2PullInflight = false;
413
- _v2PullPending = false;
414
474
  static V2_BOOTSTRAP_TTL_MS = 60 * 60 * 1000;
415
475
  static V2_RETRYABLE_CODES = new Set([-33011, -33012, -33050, -33052, -33054]);
476
+ static PULL_GATE_STALE_MS = 3000;
416
477
  static V2_SIG_CACHE_TTL_MS = 60 * 60 * 1000;
417
478
  static V2_SIG_CACHE_MAX = 16_384;
418
479
  _reconnectActive = false;
@@ -426,7 +487,7 @@ export class AUNClient {
426
487
  const rawConfig = { ...(config ?? {}) };
427
488
  this._configModel = configFromMap(rawConfig);
428
489
  const initAid = String(rawConfig.aid ?? '').trim() || null;
429
- this._agentMdPath = path.join(this._configModel.aunPath, 'AgentMDs');
490
+ this._agentMdPath = path.join(this._configModel.aunPath, 'AIDs');
430
491
  this.config = {
431
492
  aun_path: this._configModel.aunPath,
432
493
  root_ca_path: this._configModel.rootCaPath,
@@ -456,6 +517,19 @@ export class AUNClient {
456
517
  secretStoreLogger: this._logger.for('aun_core.secret-store'),
457
518
  });
458
519
  this._keystore = keystore;
520
+ // 启动时被动清理 registerAid 留下的孤儿临时目录(>10 分钟)
521
+ try {
522
+ const cleanup = keystore.cleanupPendingDirs;
523
+ if (typeof cleanup === 'function') {
524
+ const removed = cleanup.call(keystore, 600_000);
525
+ if (removed > 0) {
526
+ this._clientLog.info(`_pending cleanup removed=${removed}`);
527
+ }
528
+ }
529
+ }
530
+ catch (err) {
531
+ this._clientLog.warn(`_pending cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
532
+ }
459
533
  this._slotId = '';
460
534
  this._connectDeliveryMode = normalizeDeliveryModeConfig({ mode: 'fanout' });
461
535
  this._defaultConnectDeliveryMode = { ...this._connectDeliveryMode };
@@ -547,6 +621,23 @@ export class AUNClient {
547
621
  if (!target) {
548
622
  throw new ValidationError('fetchAgentMd requires aid (or local AID)');
549
623
  }
624
+ return await this._startAgentMdFetchTask(target);
625
+ }
626
+ async _startAgentMdFetchTask(target) {
627
+ const existing = this._agentMdFetchInflight.get(target);
628
+ if (existing) {
629
+ return await existing;
630
+ }
631
+ const task = this._fetchAgentMdOnce(target);
632
+ this._agentMdFetchInflight.set(target, task);
633
+ task.finally(() => {
634
+ if (this._agentMdFetchInflight.get(target) === task) {
635
+ this._agentMdFetchInflight.delete(target);
636
+ }
637
+ }).catch(() => undefined);
638
+ return await task;
639
+ }
640
+ async _fetchAgentMdOnce(target) {
550
641
  const content = await this.auth.downloadAgentMd(target);
551
642
  const signature = await this.auth.verifyAgentMd(content, { aid: target });
552
643
  const isSelf = target === (this._aid ?? '');
@@ -585,11 +676,11 @@ export class AUNClient {
585
676
  };
586
677
  }
587
678
  /**
588
- * 设置 agent.md 本地存储根目录;为空时恢复默认 {aun_path}/AgentMDs
679
+ * 设置 agent.md 本地存储根目录;为空时恢复默认 {aun_path}/AIDs
589
680
  */
590
681
  setAgentMdPath(root) {
591
682
  const raw = String(root ?? '').trim();
592
- const next = raw || path.join(this._configModel.aunPath, 'AgentMDs');
683
+ const next = raw || path.join(this._configModel.aunPath, 'AIDs');
593
684
  fs.mkdirSync(next, { recursive: true });
594
685
  this._agentMdPath = next;
595
686
  this._agentMdCache.clear();
@@ -662,15 +753,15 @@ export class AUNClient {
662
753
  return target;
663
754
  }
664
755
  _agentMdRoot() {
665
- const root = this._agentMdPath || path.join(this._configModel.aunPath, 'AgentMDs');
756
+ const root = this._agentMdPath || path.join(this._configModel.aunPath, 'AIDs');
666
757
  fs.mkdirSync(root, { recursive: true });
667
758
  return root;
668
759
  }
669
760
  _agentMdFilePath(aid) {
670
761
  return path.join(this._agentMdRoot(), this._agentMdSafeAid(aid), 'agent.md');
671
762
  }
672
- _agentMdListPath() {
673
- return path.join(this._agentMdRoot(), 'list.json');
763
+ _agentMdMetaPath(aid) {
764
+ return path.join(this._agentMdRoot(), this._agentMdSafeAid(aid), 'agentmd.json');
674
765
  }
675
766
  _atomicWriteText(filePath, content) {
676
767
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -712,8 +803,9 @@ export class AUNClient {
712
803
  _sleepSync(ms) {
713
804
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
714
805
  }
715
- _withAgentMdListLock(fn) {
716
- const lockPath = path.join(this._agentMdRoot(), 'list.json.lock');
806
+ _withAgentMdRecordLock(aid, fn) {
807
+ const lockPath = path.join(path.dirname(this._agentMdMetaPath(aid)), 'agentmd.json.lock');
808
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
717
809
  const deadline = Date.now() + 5000;
718
810
  let fd = null;
719
811
  while (fd === null) {
@@ -749,93 +841,39 @@ export class AUNClient {
749
841
  catch { /* ignore */ }
750
842
  }
751
843
  }
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
- }
844
+ _writeAgentMdRecordUnlocked(aid, record) {
845
+ const payload = {};
846
+ for (const [key, value] of Object.entries(record)) {
847
+ if (key !== 'content' && value !== undefined && value !== null)
848
+ payload[key] = value;
768
849
  }
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;
788
- }
789
- return records;
850
+ payload.aid = this._agentMdSafeAid(aid);
851
+ this._atomicWriteText(this._agentMdMetaPath(aid), `${JSON.stringify(payload, null, 2)}\n`);
790
852
  }
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
- }
853
+ _normalizeAgentMdRecord(aid, data) {
854
+ if (!isJsonObject(data))
855
+ return {};
856
+ const record = {};
857
+ for (const [key, value] of Object.entries(data)) {
858
+ if (key !== 'content')
859
+ record[key] = value;
819
860
  }
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();
861
+ record.aid = this._agentMdSafeAid(String(record.aid ?? aid));
862
+ for (const key of ['fetched_at', 'observed_at', 'checked_at', 'updated_at']) {
863
+ record[key] = Number(record[key] ?? 0) || 0;
829
864
  }
865
+ return record;
866
+ }
867
+ _readAgentMdRecordUnlocked(aid) {
868
+ const filePath = this._agentMdMetaPath(aid);
869
+ if (!fs.existsSync(filePath))
870
+ return {};
830
871
  try {
831
- const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
832
- this._agentMdLastListRebuilt = false;
833
- return this._normalizeAgentMdList(parsed);
872
+ return this._normalizeAgentMdRecord(aid, JSON.parse(fs.readFileSync(filePath, 'utf-8')));
834
873
  }
835
874
  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();
875
+ this._clientLog.warn(`agent.md metadata damaged, ignoring: aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
876
+ return {};
839
877
  }
840
878
  }
841
879
  _readAgentMdContent(aid) {
@@ -861,21 +899,25 @@ export class AUNClient {
861
899
  if (!target)
862
900
  return null;
863
901
  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 };
902
+ const loaded = this._withAgentMdRecordLock(target, () => {
903
+ const record = this._readAgentMdRecordUnlocked(target);
904
+ const next = Object.keys(record).length > 0 ? { ...record, aid: target } : { aid: target };
868
905
  try {
869
906
  const content = this._readAgentMdContent(target);
870
- loaded.content = content;
871
- loaded.local_etag = this._agentMdContentEtag(content);
907
+ next.content = content;
908
+ next.local_etag = this._agentMdContentEtag(content);
872
909
  }
873
910
  catch (err) {
874
- this._clientLog.warn(`agent.md content read failed: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
911
+ if (fs.existsSync(this._agentMdMetaPath(target))) {
912
+ this._clientLog.warn(`agent.md content read failed: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
913
+ }
875
914
  }
876
- this._agentMdCache.set(target, { ...loaded });
877
- return { ...loaded };
878
- }
915
+ return next;
916
+ });
917
+ if (Object.keys(loaded).length <= 1)
918
+ return null;
919
+ this._agentMdCache.set(target, { ...loaded });
920
+ return { ...loaded };
879
921
  }
880
922
  catch (err) {
881
923
  this._clientLog.debug(`agent.md cache load skipped: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
@@ -890,25 +932,23 @@ export class AUNClient {
890
932
  const inputFields = { ...fields };
891
933
  const hasContent = Object.prototype.hasOwnProperty.call(inputFields, 'content') && inputFields.content !== undefined && inputFields.content !== null;
892
934
  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 };
935
+ const record = this._withAgentMdRecordLock(target, () => {
936
+ if (hasContent) {
937
+ const content = String(inputFields.content ?? '');
938
+ savedTo = this._writeAgentMdContent(target, content);
939
+ if (!inputFields.local_etag)
940
+ inputFields.local_etag = this._agentMdContentEtag(content);
941
+ if (!inputFields.fetched_at)
942
+ inputFields.fetched_at = Date.now();
943
+ }
944
+ delete inputFields.content;
945
+ const next = { ...this._readAgentMdRecordUnlocked(target), aid: target };
905
946
  for (const [key, value] of Object.entries(inputFields)) {
906
947
  if (value !== undefined && value !== null)
907
948
  next[key] = value;
908
949
  }
909
950
  next.updated_at = Date.now();
910
- records[target] = { ...next };
911
- this._writeAgentMdListUnlocked(records);
951
+ this._writeAgentMdRecordUnlocked(target, next);
912
952
  return next;
913
953
  });
914
954
  const loaded = { ...record };
@@ -967,15 +1007,12 @@ export class AUNClient {
967
1007
  return;
968
1008
  if (this._agentMdFetchInflight.has(target))
969
1009
  return;
970
- this._agentMdFetchInflight.add(target);
971
1010
  void this.fetchAgentMd(target).catch((err) => {
972
1011
  this._saveAgentMdRecord(target, {
973
1012
  last_error: err instanceof Error ? err.message : String(err),
974
1013
  remote_status: 'found',
975
1014
  });
976
1015
  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
1016
  });
980
1017
  }
981
1018
  _observeAgentMdMeta(aid, etag = '', lastModified = '', source = '') {
@@ -1026,7 +1063,7 @@ export class AUNClient {
1026
1063
  }
1027
1064
  this._observeAgentMdMeta(senderAid, String(sender.etag ?? '').trim(), String(sender.last_modified ?? sender.lastModified ?? '').trim(), 'envelope');
1028
1065
  }
1029
- async checkAgentMd(aid, maxUnsyncedDays = 0) {
1066
+ async checkAgentMd(aid, maxUnsyncedDays = 1) {
1030
1067
  const target = String(aid ?? this._aid ?? '').trim();
1031
1068
  if (!target)
1032
1069
  throw new ValidationError('checkAgentMd requires aid (or local AID)');
@@ -1035,7 +1072,9 @@ export class AUNClient {
1035
1072
  const localFound = !!(Object.keys(before).length > 0 && (String(before.content ?? '') || localEtag));
1036
1073
  const remoteEtagCached = String(before.remote_etag ?? '').trim();
1037
1074
  const lastModifiedCached = String(before.last_modified ?? '').trim();
1038
- const checkedAtCached = Number(before.checked_at ?? 0);
1075
+ const checkedAt = Number(before.checked_at ?? 0);
1076
+ const fetchedAt = Number(before.fetched_at ?? 0);
1077
+ const checkedAtCached = checkedAt > 0 ? checkedAt : fetchedAt;
1039
1078
  const cachedInSync = !!(localFound && localEtag && remoteEtagCached && localEtag === remoteEtagCached);
1040
1079
  // max_unsynced_days > 0 且距上次 HEAD 在窗口内 → 直接返回缓存;否则强制 HEAD。
1041
1080
  if (cachedInSync && this._agentMdCheckedAtFresh(checkedAtCached, maxUnsyncedDays)) {
@@ -1053,6 +1092,25 @@ export class AUNClient {
1053
1092
  verify_error: String(before.verify_error ?? ''),
1054
1093
  };
1055
1094
  }
1095
+ const remoteFoundCached = !!(remoteEtagCached || String(before.remote_status ?? '') === 'found');
1096
+ if (!localFound &&
1097
+ !remoteFoundCached &&
1098
+ String(before.remote_status ?? '') === 'missing' &&
1099
+ this._agentMdCheckedAtFresh(checkedAtCached, maxUnsyncedDays)) {
1100
+ return {
1101
+ aid: target,
1102
+ local_found: false,
1103
+ remote_found: false,
1104
+ local_etag: '',
1105
+ remote_etag: '',
1106
+ in_sync: false,
1107
+ last_modified: '',
1108
+ status: 404,
1109
+ cached: true,
1110
+ verify_status: '',
1111
+ verify_error: '',
1112
+ };
1113
+ }
1056
1114
  const now = Date.now();
1057
1115
  let remote;
1058
1116
  try {
@@ -1244,11 +1302,17 @@ export class AUNClient {
1244
1302
  }
1245
1303
  }
1246
1304
  /**
1247
- * 列出本地所有已存储的身份摘要(仅返回有有效私钥的 AID)。
1305
+ * 列出本地身份摘要。
1306
+ *
1307
+ * @param opts.all=false(默认):仅返回严格校验通过的可用身份——
1308
+ * keypair 完整 + cert 公钥 == keypair 公钥 + cert 时间窗口有效
1309
+ * @param opts.all=true:返回所有 AIDs/ 子目录(不含 _pending/);
1310
+ * 每项含 valid=bool 和 reason=string 字段
1248
1311
  */
1249
- listIdentities() {
1312
+ listIdentities(opts) {
1250
1313
  const tStart = Date.now();
1251
- this._clientLog.debug(`listIdentities enter`);
1314
+ const includeAll = !!opts?.all;
1315
+ this._clientLog.debug(`listIdentities enter all=${includeAll}`);
1252
1316
  try {
1253
1317
  const listFn = this._keystore.listIdentities;
1254
1318
  if (typeof listFn !== 'function') {
@@ -1258,10 +1322,12 @@ export class AUNClient {
1258
1322
  const aids = listFn.call(this._keystore);
1259
1323
  const summaries = [];
1260
1324
  for (const aid of [...aids].sort()) {
1261
- const identity = this._keystore.loadIdentity(aid);
1262
- if (!identity || !identity.private_key_pem)
1325
+ const { valid, reason } = this._validateLocalIdentity(aid);
1326
+ if (!includeAll && !valid)
1263
1327
  continue;
1264
- const summary = { aid };
1328
+ const summary = { aid, valid };
1329
+ if (reason)
1330
+ summary.reason = reason;
1265
1331
  const loadMetadata = this._keystore.loadMetadata;
1266
1332
  if (typeof loadMetadata === 'function') {
1267
1333
  const md = loadMetadata.call(this._keystore, aid);
@@ -1270,7 +1336,7 @@ export class AUNClient {
1270
1336
  }
1271
1337
  summaries.push(summary);
1272
1338
  }
1273
- this._clientLog.debug(`listIdentities exit: elapsed=${Date.now() - tStart}ms count=${summaries.length}`);
1339
+ this._clientLog.debug(`listIdentities exit: elapsed=${Date.now() - tStart}ms all=${includeAll} count=${summaries.length}`);
1274
1340
  return summaries;
1275
1341
  }
1276
1342
  catch (err) {
@@ -1278,6 +1344,40 @@ export class AUNClient {
1278
1344
  throw err;
1279
1345
  }
1280
1346
  }
1347
+ /**
1348
+ * 严格校验本地身份的可用性。返回 {valid, reason}。
1349
+ * 4 项校验:keypair 完整 + cert 存在 + cert 公钥 == keypair 公钥 + cert 时间窗口有效。
1350
+ */
1351
+ _validateLocalIdentity(aid) {
1352
+ const identity = this._keystore.loadIdentity(aid);
1353
+ if (!identity)
1354
+ return { valid: false, reason: 'no identity record' };
1355
+ const priv = String(identity.private_key_pem ?? '');
1356
+ const pubB64 = String(identity.public_key_der_b64 ?? '');
1357
+ const certPem = String(identity.cert ?? '');
1358
+ if (!priv || !pubB64)
1359
+ return { valid: false, reason: 'missing keypair' };
1360
+ if (!certPem)
1361
+ return { valid: false, reason: 'missing certificate' };
1362
+ try {
1363
+ const crypto = require('node:crypto');
1364
+ const cert = new crypto.X509Certificate(certPem);
1365
+ const certPubDer = cert.publicKey.export({ type: 'spki', format: 'der' });
1366
+ const localPubDer = Buffer.from(pubB64, 'base64');
1367
+ if (!certPubDer.equals(localPubDer)) {
1368
+ return { valid: false, reason: 'cert public key does not match keypair' };
1369
+ }
1370
+ const now = Date.now();
1371
+ if (now < new Date(cert.validFrom).getTime())
1372
+ return { valid: false, reason: 'cert not yet valid' };
1373
+ if (now > new Date(cert.validTo).getTime())
1374
+ return { valid: false, reason: 'cert expired' };
1375
+ return { valid: true, reason: '' };
1376
+ }
1377
+ catch (e) {
1378
+ return { valid: false, reason: `cert parse error: ${e instanceof Error ? e.message : String(e)}` };
1379
+ }
1380
+ }
1281
1381
  // ── RPC ───────────────────────────────────────────────────
1282
1382
  /**
1283
1383
  * 发送 JSON-RPC 调用。
@@ -1297,6 +1397,13 @@ export class AUNClient {
1297
1397
  throw new PermissionError(`legacy E2EE method is removed in this SDK: ${method}`);
1298
1398
  }
1299
1399
  const p = { ...(params ?? {}) };
1400
+ const rpcBackground = Boolean(p._rpc_background) || this._backgroundRpcDepth > 0;
1401
+ delete p._rpc_background;
1402
+ const runWithRpcPriority = async (operation) => {
1403
+ if (!rpcBackground)
1404
+ return await operation();
1405
+ return await this._withBackgroundRpc(operation);
1406
+ };
1300
1407
  if (method === 'message.send' || method === 'group.send') {
1301
1408
  this._normalizeOutboundMessagePayload(p, method);
1302
1409
  }
@@ -1318,17 +1425,33 @@ export class AUNClient {
1318
1425
  if (method.startsWith('group.') && p.slot_id === undefined) {
1319
1426
  p.slot_id = this._slotId;
1320
1427
  }
1428
+ const pullGateLocked = Boolean(p._pull_gate_locked);
1429
+ if ('_pull_gate_locked' in p) {
1430
+ delete p._pull_gate_locked;
1431
+ }
1432
+ const pullGateKey = this._pullGateKeyForCall(method, p);
1433
+ if (pullGateKey && this._isPullResponseProcessing(pullGateKey)) {
1434
+ this._clientLog.debug(`pull skipped while processing pull response: method=${method} key=${pullGateKey}`);
1435
+ return this._emptyPullResultForCall(method);
1436
+ }
1437
+ if (pullGateKey && !pullGateLocked) {
1438
+ const lockedParams = { ...p, _pull_gate_locked: true };
1439
+ if (rpcBackground)
1440
+ lockedParams._rpc_background = true;
1441
+ const result = await this._runPullSerialized(pullGateKey, async () => this.call(method, lockedParams));
1442
+ return result;
1443
+ }
1321
1444
  // 自动加密:message.send 默认加密(encrypt 默认 true)— V2-only
1322
1445
  if (method === 'message.send') {
1323
1446
  const encrypt = p.encrypt ?? true;
1324
1447
  delete p.encrypt;
1325
1448
  if (encrypt) {
1326
- return await this.sendV2(String(p.to ?? ''), p.payload, {
1449
+ return await runWithRpcPriority(() => this.sendV2(String(p.to ?? ''), p.payload, {
1327
1450
  messageId: String(p.message_id ?? '') || undefined,
1328
1451
  timestamp: p.timestamp,
1329
1452
  protectedHeaders: this._protectedHeadersFromParams(p),
1330
1453
  context: isJsonObject(p.context) ? p.context : undefined,
1331
- });
1454
+ }));
1332
1455
  }
1333
1456
  // encrypt=false:明文走通用 RPC 路径;protected_headers/headers 是信封元数据,加密与否都保留
1334
1457
  this._maybeAppendEchoTraceSend(p);
@@ -1338,12 +1461,12 @@ export class AUNClient {
1338
1461
  const encrypt = p.encrypt ?? true;
1339
1462
  delete p.encrypt;
1340
1463
  if (encrypt) {
1341
- return await this.sendGroupV2(String(p.group_id ?? ''), p.payload, {
1464
+ return await runWithRpcPriority(() => this.sendGroupV2(String(p.group_id ?? ''), p.payload, {
1342
1465
  messageId: String(p.message_id ?? '') || undefined,
1343
1466
  timestamp: p.timestamp,
1344
1467
  protectedHeaders: this._protectedHeadersFromParams(p),
1345
1468
  context: isJsonObject(p.context) ? p.context : undefined,
1346
- });
1469
+ }));
1347
1470
  }
1348
1471
  this._maybeAppendEchoTraceSend(p);
1349
1472
  }
@@ -1355,7 +1478,7 @@ export class AUNClient {
1355
1478
  if (!this._v2Session || !String(p.group_id ?? '').trim()) {
1356
1479
  throw new StateError(v2Error);
1357
1480
  }
1358
- return await this._putGroupThoughtEncryptedV2(p);
1481
+ return await runWithRpcPriority(() => this._putGroupThoughtEncryptedV2(p));
1359
1482
  }
1360
1483
  }
1361
1484
  if (method === 'message.thought.put') {
@@ -1363,26 +1486,42 @@ export class AUNClient {
1363
1486
  delete p.encrypt;
1364
1487
  if (encrypt) {
1365
1488
  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);
1489
+ return await runWithRpcPriority(() => this._putMessageThoughtEncryptedV2(p));
1367
1490
  }
1368
1491
  }
1369
- if (method === 'message.pull' && this._clientUsesV2P2P()) {
1492
+ // V2-only:兼容入口名只作为 SDK 内部适配层存在,底层绝不能降级发 legacy RPC。
1493
+ if (method === 'message.pull' || method === 'message.v2.pull') {
1370
1494
  await this._ensureV2SessionReady('message.pull');
1371
- const messages = await this.pullV2(Number(p.after_seq ?? 0) || 0, Number(p.limit ?? 50) || 50);
1495
+ const skipAutoAck = p._skip_auto_ack === true || p.skip_auto_ack === true;
1496
+ const afterSeq = Number(p.after_seq ?? 0) || 0;
1497
+ const limit = Number(p.limit ?? 50) || 50;
1498
+ const messages = skipAutoAck
1499
+ ? await runWithRpcPriority(() => this.pullV2(afterSeq, limit, { skipAutoAck: true, gateLocked: true }))
1500
+ : await runWithRpcPriority(() => this.pullV2(afterSeq, limit, { gateLocked: true }));
1372
1501
  return { messages };
1373
1502
  }
1374
- if (method === 'message.ack' && this._clientUsesV2P2P()) {
1503
+ if (method === 'message.ack' || method === 'message.v2.ack') {
1375
1504
  await this._ensureV2SessionReady('message.ack');
1376
- return await this.ackV2(Number(p.seq ?? p.up_to_seq ?? 0) || undefined);
1505
+ return await runWithRpcPriority(() => this.ackV2(Number(p.seq ?? p.up_to_seq ?? 0) || undefined));
1377
1506
  }
1378
- if (method === 'group.pull' && this._clientUsesV2Group() && p.group_id) {
1507
+ if (method === 'group.pull' || method === 'group.v2.pull') {
1508
+ if (!String(p.group_id ?? '').trim()) {
1509
+ throw new ValidationError('group.pull requires group_id');
1510
+ }
1379
1511
  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);
1512
+ 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
1513
  return { messages };
1382
1514
  }
1383
- if (method === 'group.ack_messages' && this._clientUsesV2Group() && p.group_id) {
1515
+ if (method === 'group.ack_messages' || method === 'group.v2.ack') {
1516
+ if (!String(p.group_id ?? '').trim()) {
1517
+ throw new ValidationError('group.ack_messages requires group_id');
1518
+ }
1384
1519
  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);
1520
+ return await runWithRpcPriority(() => this.ackGroupV2(String(p.group_id), Number(p.seq ?? p.msg_seq ?? p.up_to_seq ?? 0) || undefined));
1521
+ }
1522
+ if (method === 'message.pull') {
1523
+ delete p._skip_auto_ack;
1524
+ delete p.skip_auto_ack;
1386
1525
  }
1387
1526
  // 关键操作自动附加客户端签名
1388
1527
  if (SIGNED_METHODS.has(method)) {
@@ -1399,8 +1538,12 @@ export class AUNClient {
1399
1538
  this._clientLog.debug(`thought.get transport call start: method=${method}, params=${this._debugJson(this._messageEnvelopeFieldsForDebug(p))}`);
1400
1539
  }
1401
1540
  let result = callTimeout
1402
- ? await this._transport.call(method, p, callTimeout)
1403
- : await this._transport.call(method, p);
1541
+ ? (rpcBackground
1542
+ ? await this._transport.call(method, p, callTimeout, undefined, true)
1543
+ : await this._transport.call(method, p, callTimeout))
1544
+ : (rpcBackground
1545
+ ? await this._transport.call(method, p, undefined, undefined, true)
1546
+ : await this._transport.call(method, p));
1404
1547
  if (method === 'group.thought.get' && isJsonObject(result)) {
1405
1548
  this._clientLog.debug(`group.thought.get transport result: found=${String(result.found ?? '')}, raw_count=${Array.isArray(result.thoughts) ? result.thoughts.length : 0}`);
1406
1549
  result = await this._decryptGroupThoughts(result);
@@ -1486,6 +1629,34 @@ export class AUNClient {
1486
1629
  this._clientLog.debug(`on exit: elapsed=${Date.now() - tStart}ms event=${event}`);
1487
1630
  return result;
1488
1631
  }
1632
+ async _callRawV2Rpc(method, params) {
1633
+ const p = { ...(params ?? {}) };
1634
+ const rpcBackground = Boolean(p._rpc_background) || this._backgroundRpcDepth > 0;
1635
+ delete p._rpc_background;
1636
+ delete p._pull_gate_locked;
1637
+ delete p._skip_auto_ack;
1638
+ delete p.skip_auto_ack;
1639
+ if (method.startsWith('group.') && p.group_id !== undefined && p.group_id !== null) {
1640
+ p.group_id = normalizeGroupId(String(p.group_id)) || String(p.group_id);
1641
+ }
1642
+ if (method.startsWith('group.') && p.device_id === undefined) {
1643
+ p.device_id = this._deviceId;
1644
+ }
1645
+ if (method.startsWith('group.') && p.slot_id === undefined) {
1646
+ p.slot_id = this._slotId;
1647
+ }
1648
+ if (SIGNED_METHODS.has(method)) {
1649
+ if (this._shouldSkipClientSignature(method, p)) {
1650
+ delete p.client_signature;
1651
+ }
1652
+ else {
1653
+ this._signClientOperation(method, p);
1654
+ }
1655
+ }
1656
+ return rpcBackground
1657
+ ? await this._transport.call(method, p, undefined, undefined, true)
1658
+ : await this._transport.call(method, p);
1659
+ }
1489
1660
  /** P2-13: 取消订阅事件(对齐 Python/JS off 方法) */
1490
1661
  off(event, handler) {
1491
1662
  const tStart = Date.now();
@@ -1575,7 +1746,7 @@ export class AUNClient {
1575
1746
  _decrypt_error: String(exc),
1576
1747
  };
1577
1748
  this._attachV2EnvelopeMetadataFromSource(safeEvent, data);
1578
- this._publishAppEvent('message.undecryptable', safeEvent).catch(() => { });
1749
+ Promise.resolve(this._publishAppEvent('message.undecryptable', safeEvent)).catch(() => { });
1579
1750
  }
1580
1751
  });
1581
1752
  this._clientLog.debug(`_onRawMessageReceived exit: elapsed=${Date.now() - tStart}ms (handler dispatched)`);
@@ -1595,12 +1766,15 @@ export class AUNClient {
1595
1766
  const seq = msg.seq;
1596
1767
  if (seq !== undefined && seq !== null && this._aid) {
1597
1768
  const ns = `p2p:${this._aid}`;
1598
- // Push 修上界:先更新 maxSeenSeq,让上界反映服务端状态
1769
+ // Push 只先更新 maxSeenSeq;contiguous_seq 是已交付游标,必须等应用层发布返回后再推进。
1599
1770
  if (seq > 0)
1600
1771
  this._seqTracker.updateMaxSeen(ns, seq);
1601
- const needPull = this._seqTracker.onMessageSeq(ns, seq);
1772
+ const contigBefore = this._seqTracker.getContiguousSeq(ns);
1773
+ const published = await this._publishOrderedMessage('message.received', ns, seq, msg);
1774
+ const contigAfter = this._seqTracker.getContiguousSeq(ns);
1775
+ const needPull = Number(seq) > contigAfter && !published;
1602
1776
  if (needPull) {
1603
- this._clientLog.debug(`P2P seq gap detected: ns=${ns}, seq=${seq}, contiguous=${this._seqTracker.getContiguousSeq(ns)}`);
1777
+ this._clientLog.debug(`P2P seq gap detected: ns=${ns}, seq=${seq}, contiguous=${contigAfter}`);
1604
1778
  this._fillP2pGap().catch(exc => this._clientLog.warn(`background gap fill trigger failed: ${formatCaughtError(exc)}`));
1605
1779
  }
1606
1780
  // auto-ack contiguous_seq
@@ -1609,23 +1783,16 @@ export class AUNClient {
1609
1783
  const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
1610
1784
  const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
1611
1785
  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
- })
1786
+ this._withBackgroundRpc(() => this.ackV2(ackSeq))
1617
1787
  .then(() => { this._clientLog.debug(`P2P push auto-ack ok: ns=${ns}, seq=${ackSeq}`); })
1618
1788
  .catch((e) => { this._clientLog.debug(`P2P auto-ack failed: ${formatCaughtError(e)}`); });
1619
1789
  }
1620
1790
  // 即时持久化 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);
1791
+ if (contigAfter !== contigBefore)
1792
+ this._saveSeqTrackerState();
1627
1793
  }
1628
1794
  else {
1795
+ // V2-only:普通 _raw.message.received 只承载明文;V2 密文由 peer.v2.message_received 通知触发 pull。
1629
1796
  await this._publishAppEvent('message.received', msg, 'push');
1630
1797
  }
1631
1798
  }
@@ -1652,7 +1819,7 @@ export class AUNClient {
1652
1819
  _decrypt_error: String(exc),
1653
1820
  };
1654
1821
  this._attachV2EnvelopeMetadataFromSource(safeEvent, data);
1655
- this._publishAppEvent('group.message_undecryptable', safeEvent).catch(() => { });
1822
+ Promise.resolve(this._publishAppEvent('group.message_undecryptable', safeEvent)).catch(() => { });
1656
1823
  }
1657
1824
  });
1658
1825
  this._clientLog.debug(`_onRawGroupMessageCreated exit: elapsed=${Date.now() - tStart}ms (handler dispatched)`);
@@ -1678,17 +1845,22 @@ export class AUNClient {
1678
1845
  if (payload === undefined || payload === null
1679
1846
  || (typeof payload === 'object' && Object.keys(payload).length === 0)) {
1680
1847
  // 不带 payload 的通知不能先推进 seq,否则 auto-pull 会用推进后的 cursor 跳过该消息。
1681
- await this._autoPullGroupMessages(msg);
1848
+ void this._autoPullGroupMessages(msg).catch((exc) => {
1849
+ this._clientLog.warn(`auto pull group message task failed: ${formatCaughtError(exc)}`);
1850
+ });
1682
1851
  return;
1683
1852
  }
1684
1853
  if (groupId && seq !== undefined && seq !== null) {
1685
1854
  const ns = `group:${groupId}`;
1686
- // Push 修上界:先更新 maxSeenSeq
1855
+ // Push 只先更新 maxSeenSeq;contiguous_seq 是已交付游标,必须等应用层发布返回后再推进。
1687
1856
  if (seq > 0)
1688
1857
  this._seqTracker.updateMaxSeen(ns, seq);
1689
- const needPull = this._seqTracker.onMessageSeq(ns, seq);
1858
+ const contigBefore = this._seqTracker.getContiguousSeq(ns);
1859
+ const published = await this._publishOrderedMessage('group.message_created', ns, seq, msg);
1860
+ const contigAfter = this._seqTracker.getContiguousSeq(ns);
1861
+ const needPull = Number(seq) > contigAfter && !published;
1690
1862
  if (needPull) {
1691
- this._clientLog.debug(`group message seq gap detected: group=${groupId}, seq=${seq}, contiguous=${this._seqTracker.getContiguousSeq(ns)}`);
1863
+ this._clientLog.debug(`group message seq gap detected: group=${groupId}, seq=${seq}, contiguous=${contigAfter}`);
1692
1864
  this._fillGroupGap(groupId).catch(exc => this._clientLog.warn(`background gap fill trigger failed: ${formatCaughtError(exc)}`));
1693
1865
  }
1694
1866
  const contig = this._seqTracker.getContiguousSeq(ns);
@@ -1696,118 +1868,65 @@ export class AUNClient {
1696
1868
  const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
1697
1869
  const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
1698
1870
  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
- })
1871
+ this._withBackgroundRpc(() => this.ackGroupV2(groupId, ackSeq))
1705
1872
  .then(() => { this._clientLog.debug(`group push auto-ack ok: group=${groupId}, seq=${ackSeq}`); })
1706
1873
  .catch((e) => { this._clientLog.debug(`group message auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
1707
1874
  }
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);
1875
+ if (contigAfter !== contigBefore)
1876
+ this._saveSeqTrackerState();
1714
1877
  }
1715
1878
  else {
1879
+ // V2-only:普通 group.message_created 只承载明文;V2 密文由 group.v2.message_created 通知触发 pull。
1716
1880
  await this._publishAppEvent('group.message_created', msg, 'group-push');
1717
1881
  }
1718
1882
  }
1719
1883
  /** 收到不带 payload 的 group.message_created 通知后,自动 pull 最新消息 */
1720
1884
  async _autoPullGroupMessages(notification) {
1721
- const groupId = (notification.group_id ?? '');
1885
+ let groupId = String(notification.group_id ?? '').trim();
1722
1886
  if (!groupId) {
1723
1887
  await this._publishAppEvent('group.message_created', notification);
1724
1888
  return;
1725
1889
  }
1890
+ groupId = normalizeGroupId(groupId) || groupId;
1726
1891
  const ns = `group:${groupId}`;
1727
1892
  const afterSeq = this._seqTracker.getContiguousSeq(ns);
1728
1893
  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
- }
1766
- }
1767
- catch (exc) {
1768
- this._clientLog.debug(`auto pull group messages failed: ${formatCaughtError(exc)}`);
1894
+ const started = await this._tryRunBackgroundPull(ns, async () => {
1895
+ const pullAfterSeq = this._seqTracker.getContiguousSeq(ns);
1896
+ const messages = await this.pullGroupV2(groupId, pullAfterSeq, 50, { gateLocked: true });
1897
+ this._prunePushedSeqs(ns);
1898
+ return messages.length;
1899
+ }, true);
1900
+ if (!started) {
1901
+ this._clientLog.debug(`auto pull group messages skipped: pull in-flight group=${groupId}`);
1769
1902
  }
1770
- await this._publishAppEvent('group.message_created', notification, 'group-push-fallback');
1771
1903
  }
1772
1904
  /** 后台补齐群消息空洞 */
1773
1905
  async _fillGroupGap(groupId) {
1906
+ groupId = normalizeGroupId(groupId) || String(groupId ?? '').trim();
1907
+ if (!groupId)
1908
+ return;
1774
1909
  const ns = `group:${groupId}`;
1775
1910
  const afterSeq = this._seqTracker.getContiguousSeq(ns);
1776
1911
  // 去重:同一 (group:id:after_seq) 只补一次
1777
1912
  const dedupKey = `group_msg:${groupId}:${afterSeq}`;
1778
1913
  if (this._gapFillDone.has(dedupKey))
1779
1914
  return;
1915
+ const token = this._tryAcquirePullGate(ns);
1916
+ if (token === null) {
1917
+ this._clientLog.debug(`group message gap fill skipped: pull in-flight group=${groupId}`);
1918
+ return;
1919
+ }
1780
1920
  this._gapFillDone.set(dedupKey, Date.now());
1781
1921
  this._clientLog.debug(`group message gap fill start: group=${groupId}, after_seq=${afterSeq}`);
1922
+ let filled = 0;
1782
1923
  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
- }
1924
+ const messages = await this._withBackgroundRpc(() => this.pullGroupV2(groupId, afterSeq, 50, { gateLocked: true }));
1925
+ filled = messages.length;
1926
+ this._prunePushedSeqs(ns);
1927
+ if (this._seqTracker.getContiguousSeq(ns) !== afterSeq) {
1928
+ await this._drainOrderedMessages(ns, undefined, true);
1929
+ this._saveSeqTrackerState();
1811
1930
  }
1812
1931
  this._clientLog.debug(`group message gap fill done: group=${groupId}, after_seq=${afterSeq}, filled=${filled}`);
1813
1932
  }
@@ -1816,6 +1935,10 @@ export class AUNClient {
1816
1935
  }
1817
1936
  finally {
1818
1937
  this._gapFillDone.delete(dedupKey);
1938
+ this._releasePullGate(ns, token);
1939
+ if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
1940
+ void this._fillGroupGap(groupId);
1941
+ }
1819
1942
  }
1820
1943
  }
1821
1944
  /** 后台补齐 P2P 消息空洞 */
@@ -1828,35 +1951,25 @@ export class AUNClient {
1828
1951
  const dedupKey = `p2p:${afterSeq}`;
1829
1952
  if (this._gapFillDone.has(dedupKey))
1830
1953
  return;
1954
+ const token = this._tryAcquirePullGate(ns);
1955
+ if (token === null) {
1956
+ this._clientLog.debug(`P2P message gap fill skipped: pull in-flight ns=${ns}`);
1957
+ return;
1958
+ }
1831
1959
  this._gapFillDone.set(dedupKey, Date.now());
1832
1960
  this._clientLog.debug(`P2P message gap fill start: after_seq=${afterSeq}`);
1961
+ let filled = 0;
1833
1962
  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
- }
1963
+ const messages = await this._withBackgroundRpc(() => this.pullV2(afterSeq, 50, { skipAutoAck: true, gateLocked: true }));
1964
+ filled = messages.length;
1965
+ this._prunePushedSeqs(ns);
1966
+ if (this._seqTracker.getContiguousSeq(ns) !== afterSeq) {
1967
+ await this._drainOrderedMessages(ns, undefined, true);
1968
+ this._saveSeqTrackerState();
1969
+ }
1970
+ const contig = this._seqTracker.getContiguousSeq(ns);
1971
+ if (contig > 0 && contig !== afterSeq) {
1972
+ await this._withBackgroundRpc(() => this.ackV2(contig));
1860
1973
  }
1861
1974
  this._clientLog.debug(`P2P message gap fill done: after_seq=${afterSeq}, filled=${filled}`);
1862
1975
  }
@@ -1865,6 +1978,10 @@ export class AUNClient {
1865
1978
  }
1866
1979
  finally {
1867
1980
  this._gapFillDone.delete(dedupKey);
1981
+ this._releasePullGate(ns, token);
1982
+ if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
1983
+ void this._fillP2pGap();
1984
+ }
1868
1985
  }
1869
1986
  }
1870
1987
  /** 只按硬上限裁剪 published guard,不能按 contiguousSeq 清理。 */
@@ -1877,6 +1994,38 @@ export class AUNClient {
1877
1994
  this._pushedSeqs.set(ns, new Set(keep));
1878
1995
  }
1879
1996
  }
1997
+ _recordPendingP2pPull(ns, seq) {
1998
+ if (!ns || seq <= 0)
1999
+ return;
2000
+ const previous = this._pendingP2pPullUpper.get(ns) ?? 0;
2001
+ if (seq > previous) {
2002
+ this._pendingP2pPullUpper.set(ns, seq);
2003
+ }
2004
+ this._clientLog.debug(`P2P pending pull upper recorded: ns=${ns}, seq=${seq}, previous=${previous}, contiguous=${this._seqTracker.getContiguousSeq(ns)}`);
2005
+ }
2006
+ _schedulePendingP2pPullIfNeeded(ns, reason) {
2007
+ if (!ns)
2008
+ return false;
2009
+ const upperSeq = this._pendingP2pPullUpper.get(ns) ?? 0;
2010
+ if (upperSeq <= 0) {
2011
+ this._pendingP2pPullUpper.delete(ns);
2012
+ return false;
2013
+ }
2014
+ const contig = this._seqTracker.getContiguousSeq(ns);
2015
+ if (upperSeq <= contig) {
2016
+ this._pendingP2pPullUpper.delete(ns);
2017
+ this._clientLog.debug(`P2P pending pull upper already covered: ns=${ns}, upper_seq=${upperSeq}, contiguous=${contig}, reason=${reason}`);
2018
+ return false;
2019
+ }
2020
+ if (this._state !== 'connected' || this._closing) {
2021
+ this._clientLog.debug(`P2P pending pull postponed: ns=${ns}, upper_seq=${upperSeq}, contiguous=${contig}, state=${this._state}, closing=${this._closing}, reason=${reason}`);
2022
+ return false;
2023
+ }
2024
+ this._pendingP2pPullUpper.delete(ns);
2025
+ this._clientLog.info(`P2P pending push follow-up pull scheduled: ns=${ns}, upper_seq=${upperSeq}, contiguous=${contig}, reason=${reason}`);
2026
+ void this._fillP2pGap();
2027
+ return true;
2028
+ }
1880
2029
  _markPublishedSeq(ns, seq) {
1881
2030
  let pushed = this._pushedSeqs.get(ns);
1882
2031
  if (!pushed) {
@@ -1925,7 +2074,7 @@ export class AUNClient {
1925
2074
  return payload;
1926
2075
  return this._attachCurrentInstanceContext(payload);
1927
2076
  }
1928
- async _publishAppEvent(event, payload, source = 'direct') {
2077
+ _publishAppEvent(event, payload, source = 'direct') {
1929
2078
  if ((event === 'message.received' || event === 'group.message_created') && isJsonObject(payload)) {
1930
2079
  this._maybeAppendEchoTraceReceive(payload);
1931
2080
  }
@@ -1949,7 +2098,7 @@ export class AUNClient {
1949
2098
  this._clientLog.debug(`agent_md etag inject skipped: ${err instanceof Error ? err.message : String(err)}`);
1950
2099
  }
1951
2100
  }
1952
- await this._dispatcher.publish(event, this._normalizePublishedMessagePayload(event, payload));
2101
+ return this._dispatcher.publishSyncAware(event, this._normalizePublishedMessagePayload(event, payload));
1953
2102
  }
1954
2103
  _echoTimestamp() {
1955
2104
  const now = new Date();
@@ -2102,25 +2251,280 @@ export class AUNClient {
2102
2251
  }
2103
2252
  return true;
2104
2253
  }
2105
- async _drainOrderedMessages(ns, beforeSeq) {
2254
+ _tryAcquirePullGate(key) {
2255
+ if (!key)
2256
+ return 0;
2257
+ const now = Date.now();
2258
+ const gate = this._pullGates.get(key) ?? { inflight: false, startedAt: 0, token: 0 };
2259
+ if (gate.inflight && now - gate.startedAt <= AUNClient.PULL_GATE_STALE_MS) {
2260
+ return null;
2261
+ }
2262
+ if (gate.inflight) {
2263
+ this._clientLog.warn(`pull in-flight stale reset: key=${key} age=${now - gate.startedAt}ms`);
2264
+ }
2265
+ gate.token += 1;
2266
+ gate.inflight = true;
2267
+ gate.startedAt = now;
2268
+ this._pullGates.set(key, gate);
2269
+ return gate.token;
2270
+ }
2271
+ _releasePullGate(key, token) {
2272
+ if (!key || token == null)
2273
+ return;
2274
+ const gate = this._pullGates.get(key);
2275
+ if (!gate || gate.token !== token)
2276
+ return;
2277
+ gate.inflight = false;
2278
+ gate.startedAt = 0;
2279
+ if (key.startsWith('p2p:')) {
2280
+ this._schedulePendingP2pPullIfNeeded(key, 'pull-gate-release');
2281
+ }
2282
+ }
2283
+ _pullGateKeyForCall(method, params) {
2284
+ if (method === 'message.pull' || method === 'message.v2.pull') {
2285
+ return this._aid ? `p2p:${this._aid}` : '';
2286
+ }
2287
+ if ((method === 'group.pull' || method === 'group.v2.pull') && String(params.group_id ?? '').trim()) {
2288
+ return `group:${String(params.group_id ?? '').trim()}`;
2289
+ }
2290
+ if (method === 'group.pull_events' && String(params.group_id ?? '').trim()) {
2291
+ return `group_event:${String(params.group_id ?? '').trim()}`;
2292
+ }
2293
+ return '';
2294
+ }
2295
+ _isPullResponseProcessing(key) {
2296
+ if (!key)
2297
+ return false;
2298
+ return (this._pullResponseKeys.get(key) ?? 0) > 0;
2299
+ }
2300
+ _emptyPullResultForCall(method) {
2301
+ if (method === 'group.pull_events')
2302
+ return { events: [], count: 0 };
2303
+ if (method === 'message.pull' || method === 'message.v2.pull' || method === 'group.pull' || method === 'group.v2.pull') {
2304
+ return { messages: [], count: 0 };
2305
+ }
2306
+ return {};
2307
+ }
2308
+ _withPullResponseProcessing(key, fn) {
2309
+ if (!key)
2310
+ return fn();
2311
+ this._pullResponseKeys.set(key, (this._pullResponseKeys.get(key) ?? 0) + 1);
2312
+ const release = () => {
2313
+ const next = (this._pullResponseKeys.get(key) ?? 1) - 1;
2314
+ if (next <= 0) {
2315
+ this._pullResponseKeys.delete(key);
2316
+ }
2317
+ else {
2318
+ this._pullResponseKeys.set(key, next);
2319
+ }
2320
+ };
2321
+ try {
2322
+ const result = fn();
2323
+ if (isPromiseLike(result)) {
2324
+ return Promise.resolve(result).finally(release);
2325
+ }
2326
+ release();
2327
+ return result;
2328
+ }
2329
+ catch (exc) {
2330
+ release();
2331
+ throw exc;
2332
+ }
2333
+ }
2334
+ _pullResultCount(result) {
2335
+ if (Array.isArray(result))
2336
+ return result.length;
2337
+ if (!isJsonObject(result))
2338
+ return 0;
2339
+ const obj = result;
2340
+ const rawCount = Number(obj.raw_count ?? 0);
2341
+ if (Number.isFinite(rawCount) && rawCount > 0)
2342
+ return rawCount;
2343
+ if (Array.isArray(obj.messages))
2344
+ return obj.messages.length;
2345
+ if (Array.isArray(obj.events))
2346
+ return obj.events.length;
2347
+ return 0;
2348
+ }
2349
+ _nextPullParams(method, params) {
2350
+ const next = { ...params };
2351
+ delete next._pull_gate_locked;
2352
+ if (method === 'message.pull' || method === 'message.v2.pull') {
2353
+ if (!this._aid)
2354
+ return null;
2355
+ next.after_seq = this._seqTracker.getContiguousSeq(`p2p:${this._aid}`);
2356
+ return next;
2357
+ }
2358
+ if (method === 'group.pull' || method === 'group.v2.pull') {
2359
+ const groupId = normalizeGroupId(String(next.group_id ?? '').trim()) || String(next.group_id ?? '').trim();
2360
+ if (!groupId)
2361
+ return null;
2362
+ next.group_id = groupId;
2363
+ next.after_seq = this._seqTracker.getContiguousSeq(`group:${groupId}`);
2364
+ delete next.after_message_seq;
2365
+ return next;
2366
+ }
2367
+ if (method === 'group.pull_events') {
2368
+ const groupId = normalizeGroupId(String(next.group_id ?? '').trim()) || String(next.group_id ?? '').trim();
2369
+ if (!groupId)
2370
+ return null;
2371
+ next.group_id = groupId;
2372
+ next.after_event_seq = this._seqTracker.getContiguousSeq(`group_event:${groupId}`);
2373
+ return next;
2374
+ }
2375
+ return null;
2376
+ }
2377
+ _pullRequestAfter(method, params) {
2378
+ if (method === 'message.pull' || method === 'message.v2.pull')
2379
+ return Number(params.after_seq ?? 0) || 0;
2380
+ if (method === 'group.pull' || method === 'group.v2.pull')
2381
+ return Number(params.after_seq ?? params.after_message_seq ?? 0) || 0;
2382
+ if (method === 'group.pull_events')
2383
+ return Number(params.after_event_seq ?? 0) || 0;
2384
+ return 0;
2385
+ }
2386
+ _pullRetentionFloor(result, topLevelKey, cursorKey) {
2387
+ const values = [Number(result[topLevelKey] ?? 0)];
2388
+ const cursor = isJsonObject(result.cursor) ? result.cursor : null;
2389
+ if (cursor) {
2390
+ values.push(Number(cursor[cursorKey] ?? 0));
2391
+ values.push(Number(cursor.retention_floor_seq ?? 0));
2392
+ }
2393
+ return Math.max(0, ...values.filter((value) => Number.isFinite(value)));
2394
+ }
2395
+ _schedulePullFollowup(method, params, result) {
2396
+ if (method === 'message.pull')
2397
+ method = 'message.v2.pull';
2398
+ else if (method === 'group.pull')
2399
+ method = 'group.v2.pull';
2400
+ if (this._pullResultCount(result) <= 0)
2401
+ return;
2402
+ const next = this._nextPullParams(method, params);
2403
+ if (!next)
2404
+ return;
2405
+ if (this._pullRequestAfter(method, next) <= this._pullRequestAfter(method, params))
2406
+ return;
2407
+ void (async () => {
2408
+ try {
2409
+ await this._withBackgroundRpc(async () => {
2410
+ if (method === 'message.pull' || method === 'message.v2.pull') {
2411
+ await this.pullV2(Number(next.after_seq ?? 0) || 0, Number(next.limit ?? 50) || 50);
2412
+ return;
2413
+ }
2414
+ if (method === 'group.pull' || method === 'group.v2.pull') {
2415
+ const groupId = String(next.group_id ?? '').trim();
2416
+ if (!groupId)
2417
+ return;
2418
+ await this.pullGroupV2(groupId, Number(next.after_seq ?? next.after_message_seq ?? 0) || 0, Number(next.limit ?? 50) || 50);
2419
+ return;
2420
+ }
2421
+ await this.call(method, next);
2422
+ });
2423
+ }
2424
+ catch (exc) {
2425
+ this._clientLog.debug(`pull follow-up skipped/failed: method=${method} err=${formatCaughtError(exc)}`);
2426
+ }
2427
+ })();
2428
+ }
2429
+ async _withBackgroundRpc(operation) {
2430
+ this._backgroundRpcDepth += 1;
2431
+ try {
2432
+ return await operation();
2433
+ }
2434
+ finally {
2435
+ this._backgroundRpcDepth = Math.max(0, this._backgroundRpcDepth - 1);
2436
+ }
2437
+ }
2438
+ async _runPullSerialized(key, operation) {
2439
+ if (key && this._isPullResponseProcessing(key)) {
2440
+ this._clientLog.debug(`pull skipped while processing pull response: key=${key}`);
2441
+ return [];
2442
+ }
2443
+ let token = this._tryAcquirePullGate(key);
2444
+ if (token === null) {
2445
+ // 显式 pull 可能撞上 push/gap-fill 的后台 pull。这里不并行发第二个 pull,
2446
+ // 也不把后台 in-flight 暴露成业务错误;短等待 gate 释放后再进入连接级 RPC queue。
2447
+ const deadline = Date.now() + AUNClient.PULL_GATE_STALE_MS + 100;
2448
+ while (token === null && Date.now() <= deadline) {
2449
+ await this._sleep(25);
2450
+ token = this._tryAcquirePullGate(key);
2451
+ }
2452
+ if (token === null) {
2453
+ throw new StateError(`pull already in-flight for ${key}`);
2454
+ }
2455
+ }
2456
+ try {
2457
+ return await this._withBackgroundRpc(operation);
2458
+ }
2459
+ finally {
2460
+ this._releasePullGate(key, token);
2461
+ }
2462
+ }
2463
+ async _tryRunBackgroundPull(key, operation, followupOnMessages = false, onBusy) {
2464
+ if (key && this._isPullResponseProcessing(key)) {
2465
+ onBusy?.();
2466
+ return false;
2467
+ }
2468
+ const token = this._tryAcquirePullGate(key);
2469
+ if (token === null) {
2470
+ onBusy?.();
2471
+ return false;
2472
+ }
2473
+ let count = 0;
2474
+ try {
2475
+ count = await this._withBackgroundRpc(operation);
2476
+ }
2477
+ finally {
2478
+ this._releasePullGate(key, token);
2479
+ }
2480
+ if (followupOnMessages && count > 0) {
2481
+ // 后台续拉是 fire-and-forget;关闭连接时 transport 会拒绝排队 RPC,
2482
+ // 这里必须本地收口,避免测试/宿主进程看到未处理的 Promise rejection。
2483
+ void this._tryRunBackgroundPull(key, operation, true).catch((exc) => {
2484
+ this._clientLog.debug(`background pull follow-up skipped/failed: key=${key} err=${formatCaughtError(exc)}`);
2485
+ });
2486
+ }
2487
+ return true;
2488
+ }
2489
+ async _drainOrderedMessages(ns, beforeSeq, pullResponse = false) {
2106
2490
  const queue = this._pendingOrderedMsgs.get(ns);
2107
2491
  if (!queue || queue.size === 0)
2108
2492
  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) {
2493
+ while (true) {
2494
+ const contig = this._seqTracker.getContiguousSeq(ns);
2495
+ const ready = [...queue.keys()]
2496
+ .filter((seq) => seq <= contig && (beforeSeq === undefined || seq < beforeSeq))
2497
+ .sort((a, b) => a - b);
2498
+ let seq = ready[0];
2499
+ if (seq === undefined) {
2500
+ const nextSeq = contig + 1;
2501
+ if (beforeSeq !== undefined && nextSeq >= beforeSeq)
2502
+ break;
2503
+ if (!queue.has(nextSeq))
2504
+ break;
2505
+ seq = nextSeq;
2506
+ }
2114
2507
  const item = queue.get(seq);
2115
2508
  queue.delete(seq);
2116
2509
  if (!item)
2117
2510
  continue;
2118
2511
  if (this._pushedSeqs.get(ns)?.has(seq)) {
2119
2512
  this._clientLog.debug(`publish ordered drain skipped duplicate: ns=${ns}, seq=${seq}, event=${item.event}`);
2513
+ this._markOrderedSeqDelivered(ns, seq);
2120
2514
  continue;
2121
2515
  }
2122
- await this._publishAppEvent(item.event, item.payload, 'ordered-drain');
2516
+ if (pullResponse) {
2517
+ const published = this._withPullResponseProcessing(ns, () => this._publishAppEvent(item.event, item.payload, 'ordered-drain'));
2518
+ if (isPromiseLike(published))
2519
+ await published;
2520
+ }
2521
+ else {
2522
+ const published = this._publishAppEvent(item.event, item.payload, 'ordered-drain');
2523
+ if (isPromiseLike(published))
2524
+ await published;
2525
+ }
2123
2526
  this._markPublishedSeq(ns, seq);
2527
+ this._markOrderedSeqDelivered(ns, seq);
2124
2528
  this._clientLog.debug(`publish ordered drain delivered: ns=${ns}, seq=${seq}, event=${item.event}`);
2125
2529
  }
2126
2530
  if (queue.size === 0)
@@ -2130,7 +2534,9 @@ export class AUNClient {
2130
2534
  const seqNum = Number(seq);
2131
2535
  if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0) {
2132
2536
  this._clientLog.debug(`publish ordered direct(no-seq): event=${event}, ns=${ns || '<none>'}, seq=${String(seq)}`);
2133
- await this._publishAppEvent(event, payload, 'ordered');
2537
+ const published = this._publishAppEvent(event, payload, 'ordered');
2538
+ if (isPromiseLike(published))
2539
+ await published;
2134
2540
  return true;
2135
2541
  }
2136
2542
  if (this._pushedSeqs.get(ns)?.has(seqNum)) {
@@ -2142,7 +2548,15 @@ export class AUNClient {
2142
2548
  return false;
2143
2549
  }
2144
2550
  const contig = this._seqTracker.getContiguousSeq(ns);
2145
- if (seqNum > contig) {
2551
+ if (seqNum <= contig) {
2552
+ this._clientLog.debug(`publish ordered stale covered: event=${event}, ns=${ns}, seq=${seqNum}, contiguous=${contig}`);
2553
+ const queue = this._pendingOrderedMsgs.get(ns);
2554
+ queue?.delete(seqNum);
2555
+ if (queue && queue.size === 0)
2556
+ this._pendingOrderedMsgs.delete(ns);
2557
+ return false;
2558
+ }
2559
+ if (seqNum !== contig + 1) {
2146
2560
  this._clientLog.debug(`publish ordered enqueue(gap): event=${event}, ns=${ns}, seq=${seqNum}, contiguous=${contig}`);
2147
2561
  this._enqueueOrderedMessage(ns, event, seqNum, payload);
2148
2562
  return false;
@@ -2156,17 +2570,25 @@ export class AUNClient {
2156
2570
  queue?.delete(seqNum);
2157
2571
  if (queue && queue.size === 0)
2158
2572
  this._pendingOrderedMsgs.delete(ns);
2159
- await this._publishAppEvent(event, payload, 'ordered');
2573
+ const published = this._publishAppEvent(event, payload, 'ordered');
2574
+ if (isPromiseLike(published))
2575
+ await published;
2160
2576
  this._markPublishedSeq(ns, seqNum);
2577
+ this._markOrderedSeqDelivered(ns, seqNum);
2161
2578
  this._clientLog.debug(`publish ordered delivered: event=${event}, ns=${ns}, seq=${seqNum}`);
2162
2579
  await this._drainOrderedMessages(ns);
2163
2580
  return true;
2164
2581
  }
2165
2582
  async _publishPulledMessage(event, ns, seq, payload) {
2583
+ // Pull/gap-fill 批次是服务端对 after_seq 的可用结果集,可能跨过永久空洞。
2584
+ // 这里只能做 namespace+seq 去重并按返回顺序发布,不能套用 push 路径的
2585
+ // seq == contiguous_seq + 1 门控,否则会把空洞后的可用消息错误卡住。
2166
2586
  const seqNum = Number(seq);
2167
2587
  if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0 || !ns) {
2168
2588
  this._clientLog.debug(`publish pulled direct(no-seq): event=${event}, ns=${ns || '<none>'}, seq=${String(seq)}`);
2169
- await this._publishAppEvent(event, payload, 'pull');
2589
+ const published = this._withPullResponseProcessing(ns, () => this._publishAppEvent(event, payload, 'pull'));
2590
+ if (isPromiseLike(published))
2591
+ await published;
2170
2592
  return true;
2171
2593
  }
2172
2594
  const queue = this._pendingOrderedMsgs.get(ns);
@@ -2180,22 +2602,51 @@ export class AUNClient {
2180
2602
  queue?.delete(seqNum);
2181
2603
  if (queue && queue.size === 0)
2182
2604
  this._pendingOrderedMsgs.delete(ns);
2183
- await this._publishAppEvent(event, payload, 'pull');
2605
+ const published = this._withPullResponseProcessing(ns, () => this._publishAppEvent(event, payload, 'pull'));
2606
+ if (isPromiseLike(published))
2607
+ await published;
2184
2608
  this._markPublishedSeq(ns, seqNum);
2609
+ this._markPulledSeqDelivered(ns, seqNum);
2610
+ await this._drainOrderedMessages(ns, undefined, true);
2185
2611
  this._clientLog.debug(`publish pulled delivered: event=${event}, ns=${ns}, seq=${seqNum}`);
2186
2612
  return true;
2187
2613
  }
2614
+ _markPulledSeqDelivered(ns, seq) {
2615
+ // Pull 批次是 after_seq 之后服务端当前可用的结果集,可能跨过永久空洞。
2616
+ // 这里仅在应用层发布返回后推进已交付游标,不能改成 push 的相邻 seq 门控。
2617
+ const seqNum = Number(seq);
2618
+ if (!ns || !Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0)
2619
+ return false;
2620
+ const before = this._seqTracker.getContiguousSeq(ns);
2621
+ this._seqTracker.forceContiguousSeq(ns, seqNum);
2622
+ return this._seqTracker.getContiguousSeq(ns) !== before;
2623
+ }
2624
+ _markOrderedSeqDelivered(ns, seq) {
2625
+ if (!ns || !Number.isFinite(seq) || !Number.isInteger(seq) || seq <= 0)
2626
+ return false;
2627
+ const before = this._seqTracker.getContiguousSeq(ns);
2628
+ this._seqTracker.onMessageSeq(ns, seq);
2629
+ return this._seqTracker.getContiguousSeq(ns) !== before;
2630
+ }
2188
2631
  /** 后台补齐群事件空洞 */
2189
2632
  async _fillGroupEventGap(groupId) {
2633
+ groupId = normalizeGroupId(groupId) || String(groupId ?? '').trim();
2634
+ if (!groupId)
2635
+ return;
2190
2636
  const ns = `group_event:${groupId}`;
2191
2637
  const afterSeq = this._seqTracker.getContiguousSeq(ns);
2192
2638
  // 去重:同一 (group_evt:id:after_seq) 只补一次
2193
2639
  const dedupKey = `group_evt:${groupId}:${afterSeq}`;
2194
2640
  if (this._gapFillDone.has(dedupKey))
2195
2641
  return;
2642
+ const token = this._tryAcquirePullGate(ns);
2643
+ if (token === null) {
2644
+ this._clientLog.debug(`group event gap fill skipped: pull in-flight group=${groupId}`);
2645
+ return;
2646
+ }
2196
2647
  this._gapFillDone.set(dedupKey, Date.now());
2648
+ let filled = 0;
2197
2649
  try {
2198
- let filled = 0;
2199
2650
  let nextAfterSeq = afterSeq;
2200
2651
  const maxPages = 100;
2201
2652
  let pageCount = 0;
@@ -2207,6 +2658,7 @@ export class AUNClient {
2207
2658
  after_event_seq: nextAfterSeq,
2208
2659
  device_id: this._deviceId,
2209
2660
  limit: 50,
2661
+ _pull_gate_locked: true,
2210
2662
  });
2211
2663
  if (!isJsonObject(result))
2212
2664
  return;
@@ -2215,16 +2667,12 @@ export class AUNClient {
2215
2667
  return;
2216
2668
  const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
2217
2669
  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) {
2670
+ const retentionFloor = this._pullRetentionFloor(result, 'retention_floor_event_seq', 'retention_floor_event_seq');
2671
+ if (retentionFloor > 0) {
2224
2672
  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);
2673
+ if (contigBeforeFloor < retentionFloor) {
2674
+ this._clientLog.info(`group.pull_events retention-floor advance: ns=${ns} contiguous=${contigBeforeFloor} -> retention_floor=${retentionFloor}`);
2675
+ this._seqTracker.forceContiguousSeq(ns, retentionFloor);
2228
2676
  }
2229
2677
  }
2230
2678
  const eventSeqs = [];
@@ -2235,20 +2683,23 @@ export class AUNClient {
2235
2683
  evt._from_gap_fill = true;
2236
2684
  const et = String(evt.event_type ?? '');
2237
2685
  // 消息事件由 _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);
2686
+ if (et !== 'group.message_created') {
2687
+ // 验签:有 client_signature 就验(与实时事件路径对齐)
2688
+ const cs = evt.client_signature;
2689
+ if (cs && typeof cs === 'object') {
2690
+ if (this._shouldSkipEventSignature(evt)) {
2691
+ delete evt.client_signature;
2692
+ }
2693
+ else {
2694
+ evt._verified = await this._verifyEventSignatureAsync(evt, cs);
2695
+ }
2248
2696
  }
2697
+ // group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
2698
+ await this._dispatcher.publish('group.changed', evt);
2699
+ }
2700
+ if (Number.isFinite(eventSeq) && eventSeq > 0) {
2701
+ this._markPulledSeqDelivered(ns, eventSeq);
2249
2702
  }
2250
- // group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
2251
- await this._dispatcher.publish('group.changed', evt);
2252
2703
  filled += 1;
2253
2704
  }
2254
2705
  const contig = this._seqTracker.getContiguousSeq(ns);
@@ -2263,12 +2714,11 @@ export class AUNClient {
2263
2714
  event_seq: ackSeq,
2264
2715
  device_id: this._deviceId,
2265
2716
  slot_id: this._slotId,
2266
- }).catch((e) => { this._clientLog.debug(`group event auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
2717
+ }, undefined, undefined, true).catch((e) => { this._clientLog.debug(`group event auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
2267
2718
  }
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;
2719
+ // pull_events 与其它 pull 一样:一次后台任务只消费一个批次。
2720
+ // 非空批次返回后由 pull gate fire-and-forget follow-up 重新排队,直到空批停止。
2721
+ break;
2272
2722
  }
2273
2723
  if (pageCount >= maxPages) {
2274
2724
  this._clientLog.warn(`group event gap fill reached max_pages=${maxPages} group=${groupId} after_seq=${nextAfterSeq}`);
@@ -2280,6 +2730,10 @@ export class AUNClient {
2280
2730
  }
2281
2731
  finally {
2282
2732
  this._gapFillDone.delete(dedupKey);
2733
+ this._releasePullGate(ns, token);
2734
+ if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
2735
+ void this._fillGroupEventGap(groupId);
2736
+ }
2283
2737
  }
2284
2738
  }
2285
2739
  _extractGroupIdFromResult(result) {
@@ -2363,7 +2817,7 @@ export class AUNClient {
2363
2817
  event_seq: contig,
2364
2818
  device_id: this._deviceId,
2365
2819
  slot_id: this._slotId,
2366
- }).catch((e) => { this._clientLog.debug(`group event push auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
2820
+ }, undefined, undefined, true).catch((e) => { this._clientLog.debug(`group event push auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
2367
2821
  }
2368
2822
  }
2369
2823
  // 仅在真实 event gap 时才触发补拉(补洞回来的事件不再触发新补洞)
@@ -2569,11 +3023,85 @@ export class AUNClient {
2569
3023
  return false;
2570
3024
  }
2571
3025
  }
2572
- /**
2573
- * 获取对方证书(带缓存 + 完整 PKI 验证)。 /**
2574
- * 获取对方证书(带缓存 + 完整 PKI 验证)。
2575
- * 跨域时自动路由到 peer 所在域的 Gateway。
2576
- */
3026
+ async _validateAndCachePeerCert(opts) {
3027
+ const aid = String(opts.aid ?? '').trim();
3028
+ const certPem = String(opts.certPem ?? '').trim();
3029
+ const certFingerprint = String(opts.certFingerprint ?? '').trim() || undefined;
3030
+ if (!aid)
3031
+ throw new ValidationError('peer aid is required for cert validation');
3032
+ if (!certPem)
3033
+ throw new ValidationError(`peer cert is empty for ${aid}`);
3034
+ const gatewayUrl = this._gatewayUrl;
3035
+ if (!gatewayUrl) {
3036
+ throw new ValidationError('gateway url unavailable for e2ee cert validation');
3037
+ }
3038
+ const peerGatewayUrl = AUNClient._resolvePeerGatewayUrl(gatewayUrl, aid);
3039
+ const x509Cert = new crypto.X509Certificate(certPem);
3040
+ // H7: 严格校验指纹(DER SHA-256 或 SPKI SHA-256 任一匹配即可)
3041
+ if (certFingerprint) {
3042
+ const expectedFP = certFingerprint.toLowerCase();
3043
+ if (!expectedFP.startsWith('sha256:')) {
3044
+ throw new ValidationError(`unsupported cert_fingerprint format for ${aid}: ${expectedFP.slice(0, 24)}`);
3045
+ }
3046
+ const expectedHex = expectedFP.slice('sha256:'.length);
3047
+ const derHex = x509Cert.fingerprint256.replace(/:/g, '').toLowerCase();
3048
+ let spkiHex = '';
3049
+ try {
3050
+ const spkiDer = x509Cert.publicKey.export({ type: 'spki', format: 'der' });
3051
+ spkiHex = crypto.createHash('sha256').update(spkiDer).digest('hex');
3052
+ }
3053
+ catch {
3054
+ spkiHex = '';
3055
+ }
3056
+ if (expectedHex !== derHex && (!spkiHex || expectedHex !== spkiHex)) {
3057
+ throw new ValidationError(`peer cert fingerprint mismatch for ${aid}: expected=${expectedFP.slice(0, 24)}...`);
3058
+ }
3059
+ }
3060
+ let cachedBootstrapChain = false;
3061
+ const caChainPems = opts.caChainPems ?? [];
3062
+ if (caChainPems.length > 0) {
3063
+ try {
3064
+ this._auth.cacheGatewayCaChain(peerGatewayUrl, caChainPems, aid);
3065
+ cachedBootstrapChain = true;
3066
+ }
3067
+ catch (exc) {
3068
+ this._clientLog.debug(`bootstrap CA chain cache skipped: peer=${aid}, source=${opts.source ?? 'unknown'}, err=${formatCaughtError(exc)}`);
3069
+ }
3070
+ }
3071
+ try {
3072
+ await this._auth.verifyPeerCertificate(peerGatewayUrl, certPem, aid);
3073
+ }
3074
+ catch (exc) {
3075
+ if (cachedBootstrapChain) {
3076
+ this._auth.discardGatewayCaChain(peerGatewayUrl, aid);
3077
+ }
3078
+ throw new ValidationError(`peer cert verification failed for ${aid}: ${exc instanceof Error ? exc.message : String(exc)}`);
3079
+ }
3080
+ const nowSec = Date.now() / 1000;
3081
+ const entry = {
3082
+ certPem,
3083
+ validatedAt: nowSec,
3084
+ refreshAfter: nowSec + PEER_CERT_CACHE_TTL,
3085
+ };
3086
+ const cacheKey = AUNClient._certCacheKey(aid, certFingerprint);
3087
+ this._certCache.set(cacheKey, entry);
3088
+ const bareKey = AUNClient._certCacheKey(aid);
3089
+ if (bareKey !== cacheKey)
3090
+ this._certCache.set(bareKey, entry);
3091
+ if (!certFingerprint) {
3092
+ const actualFp = `sha256:${x509Cert.fingerprint256.replace(/:/g, '').toLowerCase()}`;
3093
+ this._certCache.set(AUNClient._certCacheKey(aid, actualFp), entry);
3094
+ }
3095
+ try {
3096
+ // peer 证书只存版本目录,不覆盖 cert.pem
3097
+ this._keystore.saveCert(aid, certPem, certFingerprint, { makeActive: false });
3098
+ }
3099
+ catch (exc) {
3100
+ this._clientLog.error(`failed to write cert to keystore (aid=${aid}, fp=${certFingerprint ?? ''}): ${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
3101
+ }
3102
+ return certPem;
3103
+ }
3104
+ /** 获取对方证书(带缓存 + 完整 PKI 验证),跨域时自动路由到 peer 所在域。 */
2577
3105
  async _fetchPeerCert(aid, certFingerprint, timeoutMs = 30_000) {
2578
3106
  const tStart = Date.now();
2579
3107
  this._clientLog.debug(`_fetchPeerCert enter: aid=${aid}, fp=${certFingerprint ?? ''}`);
@@ -2589,67 +3117,107 @@ export class AUNClient {
2589
3117
  if (!gatewayUrl) {
2590
3118
  throw new ValidationError('gateway url unavailable for e2ee cert fetch');
2591
3119
  }
2592
- // 跨域时用 peer 所在域的 Gateway URL
2593
3120
  const peerGatewayUrl = AUNClient._resolvePeerGatewayUrl(gatewayUrl, aid);
2594
3121
  let certPem;
2595
3122
  try {
2596
- const certUrl = AUNClient._buildCertUrl(peerGatewayUrl, aid, certFingerprint);
2597
- certPem = await _httpGetText(certUrl, this._configModel.verifySsl, timeoutMs);
3123
+ certPem = await _httpGetText(AUNClient._buildCertUrl(peerGatewayUrl, aid, certFingerprint), this._configModel.verifySsl, timeoutMs);
2598
3124
  }
2599
3125
  catch (exc) {
2600
- if (!certFingerprint) {
3126
+ if (!certFingerprint)
2601
3127
  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 = '';
3128
+ certPem = await _httpGetText(AUNClient._buildCertUrl(peerGatewayUrl, aid), this._configModel.verifySsl, timeoutMs);
3129
+ }
3130
+ const validated = await this._validateAndCachePeerCert({
3131
+ aid,
3132
+ certPem,
3133
+ certFingerprint,
3134
+ source: 'fetch',
3135
+ });
3136
+ this._clientLog.debug(`_fetchPeerCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} (fetched)`);
3137
+ return validated;
3138
+ }
3139
+ catch (err) {
3140
+ this._clientLog.debug(`_fetchPeerCert exit (error): elapsed=${Date.now() - tStart}ms aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
3141
+ throw err;
3142
+ }
3143
+ }
3144
+ _bootstrapCaChain(material) {
3145
+ let raw;
3146
+ for (const key of ['ca_chain', 'ca_chain_pems', 'cert_chain', 'chain']) {
3147
+ if (material[key] !== undefined && material[key] !== null) {
3148
+ raw = material[key];
3149
+ break;
3150
+ }
3151
+ }
3152
+ if (!Array.isArray(raw))
3153
+ return [];
3154
+ const result = [];
3155
+ for (const item of raw) {
3156
+ let certType = '';
3157
+ let certPem = '';
3158
+ if (isJsonObject(item)) {
3159
+ certType = String(item.cert_type ?? '').trim().toLowerCase();
3160
+ if (certType === 'agent')
3161
+ continue;
3162
+ certPem = String(item.cert_pem ?? item.cert ?? '').trim();
3163
+ }
3164
+ else {
3165
+ certPem = String(item ?? '').trim();
3166
+ }
3167
+ if (!certPem)
3168
+ continue;
3169
+ if (!certType) {
2616
3170
  try {
2617
- const spkiDer = x509Cert.publicKey.export({ type: 'spki', format: 'der' });
2618
- spkiHex = crypto.createHash('sha256').update(spkiDer).digest('hex');
3171
+ if (!new crypto.X509Certificate(certPem).ca)
3172
+ continue;
2619
3173
  }
2620
3174
  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)}...`);
3175
+ continue;
2625
3176
  }
2626
3177
  }
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
- });
3178
+ result.push(certPem);
3179
+ }
3180
+ return result;
3181
+ }
3182
+ async _primeBootstrapPeerCerts(bootstrap, peerAid) {
3183
+ const certsRaw = bootstrap.certs;
3184
+ if (!isJsonObject(certsRaw))
3185
+ return;
3186
+ const materials = certsRaw;
3187
+ const expected = new Set();
3188
+ const normalizedPeer = String(peerAid ?? '').trim();
3189
+ if (normalizedPeer)
3190
+ expected.add(normalizedPeer);
3191
+ const audit = Array.isArray(bootstrap.audit_recipients) ? bootstrap.audit_recipients : [];
3192
+ for (const dev of audit) {
3193
+ if (!isJsonObject(dev))
3194
+ continue;
3195
+ const aid = String(dev.aid ?? '').trim();
3196
+ if (aid)
3197
+ expected.add(aid);
3198
+ }
3199
+ for (const aid of expected) {
3200
+ if (aid === this._aid)
3201
+ continue;
3202
+ const material = materials[aid];
3203
+ if (!isJsonObject(material))
3204
+ continue;
3205
+ const certPem = String(material.cert_pem ?? material.cert ?? '').trim();
3206
+ if (!certPem)
3207
+ continue;
3208
+ const certFingerprint = String(material.cert_fingerprint ?? material.fingerprint ?? material.fp ?? '').trim() || undefined;
2640
3209
  try {
2641
- // peer 证书只存版本目录,不覆盖 cert.pem
2642
- this._keystore.saveCert(aid, certPem, certFingerprint, { makeActive: false });
3210
+ await this._validateAndCachePeerCert({
3211
+ aid,
3212
+ certPem,
3213
+ certFingerprint,
3214
+ caChainPems: this._bootstrapCaChain(material),
3215
+ source: 'bootstrap',
3216
+ });
2643
3217
  }
2644
3218
  catch (exc) {
2645
- this._clientLog.error(`failed to write cert to keystore (aid=${aid}, fp=${certFingerprint ?? ''}): ${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
3219
+ this._clientLog.debug(`bootstrap peer cert material ignored: peer=${aid}, err=${formatCaughtError(exc)}`);
2646
3220
  }
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
3221
  }
2654
3222
  }
2655
3223
  async _decryptGroupThoughts(result) {
@@ -2893,6 +3461,7 @@ export class AUNClient {
2893
3461
  this._gapFillDone.clear();
2894
3462
  this._pushedSeqs.clear();
2895
3463
  this._pendingOrderedMsgs.clear();
3464
+ this._pendingP2pPullUpper.clear();
2896
3465
  this._v2SenderIKPending.clear();
2897
3466
  this._v2SenderIKFetching.clear();
2898
3467
  this._groupSynced.clear();
@@ -2905,6 +3474,7 @@ export class AUNClient {
2905
3474
  this._gapFillDone.clear();
2906
3475
  this._pushedSeqs.clear();
2907
3476
  this._pendingOrderedMsgs.clear();
3477
+ this._pendingP2pPullUpper.clear();
2908
3478
  this._v2SenderIKPending.clear();
2909
3479
  this._v2SenderIKFetching.clear();
2910
3480
  this._groupSynced.clear();
@@ -3108,7 +3678,7 @@ export class AUNClient {
3108
3678
  catch (exc) {
3109
3679
  this._clientLog.warn(`V2 session init failed (non-fatal): ${formatCaughtError(exc)}`);
3110
3680
  }
3111
- // connect/reconnect 成功后自动触发一次 P2P message.pull,补齐离线期间积压
3681
+ // connect/reconnect 成功后自动触发一次 P2P message.v2.pull,补齐离线期间积压
3112
3682
  // 群消息按惰性触发,不在此处主动 pull
3113
3683
  void this._fillP2pGap().catch((exc) => {
3114
3684
  this._clientLog.warn(`schedule post-connect P2P gap fill failed: ${formatCaughtError(exc)}`);
@@ -3200,7 +3770,7 @@ export class AUNClient {
3200
3770
  this._v2Session = new V2Session(v2Store, this._deviceId, this._aid, aidPriv, aidPubDer);
3201
3771
  await this._v2Session.ensureRegistered(this._v2CallFn());
3202
3772
  this._clientLog.debug(`V2 session initialized aid=${this._aid} device=${this._deviceId}`);
3203
- this._safeAsync(this._v2AutoConfirmPendingProposals());
3773
+ // 群 state proposal 由服务端在 client.online 时定向通知。
3204
3774
  }
3205
3775
  async _v2TrustedIKPubDer(aid) {
3206
3776
  const normalizedAid = String(aid ?? '').trim();
@@ -3379,7 +3949,11 @@ export class AUNClient {
3379
3949
  const session = this._v2Session;
3380
3950
  if (session && fromAid) {
3381
3951
  try {
3382
- const bs = await this.call('message.v2.bootstrap', { peer_aid: fromAid });
3952
+ const bs = await this.call('message.v2.bootstrap', {
3953
+ peer_aid: fromAid,
3954
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
3955
+ });
3956
+ await this._primeBootstrapPeerCerts(bs, fromAid);
3383
3957
  const peers = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
3384
3958
  for (const dev of peers)
3385
3959
  this._cacheV2PeerIKFromDevice(dev, fromAid);
@@ -3389,7 +3963,10 @@ export class AUNClient {
3389
3963
  }
3390
3964
  if (groupId) {
3391
3965
  try {
3392
- const gbs = await this.call('group.v2.bootstrap', { group_id: groupId });
3966
+ const gbs = await this.call('group.v2.bootstrap', {
3967
+ group_id: groupId,
3968
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
3969
+ });
3393
3970
  const devices = (Array.isArray(gbs?.devices) ? gbs.devices : []);
3394
3971
  const audit = (Array.isArray(gbs?.audit_recipients) ? gbs.audit_recipients : []);
3395
3972
  for (const dev of devices)
@@ -3448,14 +4025,21 @@ export class AUNClient {
3448
4025
  const useCache = opts.useCache !== false;
3449
4026
  let peerDevices = [];
3450
4027
  let auditRaw = [];
4028
+ let wrapPolicy = normalizeV2WrapPolicy(undefined);
3451
4029
  const cached = useCache ? this._v2BootstrapCache.get(to) : undefined;
3452
4030
  if (cached && Date.now() - cached.cachedAt < AUNClient.V2_BOOTSTRAP_TTL_MS) {
3453
4031
  peerDevices = cached.devices;
3454
4032
  auditRaw = cached.auditRecipients;
4033
+ wrapPolicy = cached.wrapPolicy ?? wrapPolicy;
3455
4034
  this._clientLog.debug(`message.v2.bootstrap cache hit: to=${to}, devices=${peerDevices.length}, audit=${auditRaw.length}`);
3456
4035
  }
3457
4036
  else {
3458
- const bs = await this.call('message.v2.bootstrap', { peer_aid: to });
4037
+ const bs = await this.call('message.v2.bootstrap', {
4038
+ peer_aid: to,
4039
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
4040
+ });
4041
+ await this._primeBootstrapPeerCerts(bs, to);
4042
+ wrapPolicy = normalizeV2WrapPolicy(bs.e2ee_wrap_policy);
3459
4043
  peerDevices = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
3460
4044
  auditRaw = (Array.isArray(bs?.audit_recipients) ? bs.audit_recipients : []);
3461
4045
  this._clientLog.debug(`message.v2.bootstrap fetched: to=${to}, devices=${peerDevices.length}, audit=${auditRaw.length}`);
@@ -3464,6 +4048,7 @@ export class AUNClient {
3464
4048
  devices: peerDevices,
3465
4049
  auditRecipients: auditRaw,
3466
4050
  cachedAt: Date.now(),
4051
+ wrapPolicy,
3467
4052
  });
3468
4053
  }
3469
4054
  }
@@ -3504,13 +4089,19 @@ export class AUNClient {
3504
4089
  selfDevices = selfCached.devices;
3505
4090
  }
3506
4091
  else {
3507
- const selfBs = await this.call('message.v2.bootstrap', { peer_aid: this._aid });
4092
+ const selfBs = await this.call('message.v2.bootstrap', {
4093
+ peer_aid: this._aid,
4094
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
4095
+ });
4096
+ await this._primeBootstrapPeerCerts(selfBs, this._aid);
3508
4097
  selfDevices = (Array.isArray(selfBs?.peer_devices) ? selfBs.peer_devices : []);
4098
+ const selfWrapPolicy = normalizeV2WrapPolicy(selfBs.e2ee_wrap_policy);
3509
4099
  if (selfDevices.length > 0) {
3510
4100
  this._v2BootstrapCache.set(this._aid, {
3511
4101
  devices: selfDevices,
3512
4102
  auditRecipients: [],
3513
4103
  cachedAt: Date.now(),
4104
+ wrapPolicy: selfWrapPolicy,
3514
4105
  });
3515
4106
  }
3516
4107
  }
@@ -3536,7 +4127,10 @@ export class AUNClient {
3536
4127
  if (targets.length === 0) {
3537
4128
  throw new E2EEError(`V2 bootstrap: no usable devices found for ${to}`);
3538
4129
  }
3539
- const envelope = encryptP2PMessage(session.getSenderIdentity(), { targets, auditRecipients: auditTargets }, opts.payload, {
4130
+ const envelope = encryptP2PMessage(session.getSenderIdentity(), {
4131
+ targets: applyV2WrapPolicyToTargets(targets, wrapPolicy),
4132
+ auditRecipients: applyV2WrapPolicyToTargets(auditTargets, wrapPolicy),
4133
+ }, opts.payload, {
3540
4134
  messageId: opts.messageId,
3541
4135
  timestamp: opts.timestamp,
3542
4136
  protectedHeaders: opts.protectedHeaders,
@@ -3606,18 +4200,30 @@ export class AUNClient {
3606
4200
  }
3607
4201
  }
3608
4202
  /** V2 P2P 拉取并解密;直接方法返回消息数组,call("message.pull") 会包装为 {messages}. */
3609
- async pullV2(afterSeq = 0, limit = 50) {
4203
+ async pullV2(afterSeq = 0, limit = 50, opts) {
3610
4204
  await this._ensureV2SessionReady('message.pull');
3611
4205
  const ns = this._aid ? `p2p:${this._aid}` : '';
4206
+ if (ns && !opts?.gateLocked) {
4207
+ return await this._runPullSerialized(ns, async () => this.pullV2(afterSeq, limit, {
4208
+ ...(opts ?? {}),
4209
+ gateLocked: true,
4210
+ scheduleFollowup: true,
4211
+ }));
4212
+ }
3612
4213
  const decrypted = [];
4214
+ let totalRawCount = 0;
3613
4215
  let nextAfterSeq = afterSeq || (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
3614
4216
  let pageCount = 0;
3615
4217
  const maxPages = 100;
3616
4218
  while (pageCount < maxPages) {
3617
4219
  pageCount += 1;
3618
4220
  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 });
4221
+ const result = await this._callRawV2Rpc('message.v2.pull', {
4222
+ after_seq: nextAfterSeq,
4223
+ limit,
4224
+ });
3620
4225
  const messages = (Array.isArray(result?.messages) ? result.messages : []);
4226
+ totalRawCount += messages.length;
3621
4227
  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
4228
  for (const msg of messages) {
3623
4229
  this._logMessageDebug('pull-raw', 'message.v2.pull', 'message.received', msg);
@@ -3626,10 +4232,13 @@ export class AUNClient {
3626
4232
  .map((msg) => Number(msg.seq ?? 0))
3627
4233
  .filter((seq) => Number.isFinite(seq) && seq > 0);
3628
4234
  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}`);
4235
+ let pageMaxSeq = nextAfterSeq;
4236
+ if (seqs.length > 0) {
4237
+ pageMaxSeq = Math.max(...seqs);
4238
+ if (ns) {
4239
+ this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
4240
+ this._clientLog.debug(`message.v2.pull force contiguous: ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
4241
+ }
3633
4242
  }
3634
4243
  for (const msg of messages) {
3635
4244
  const seq = Number(msg.seq ?? 0);
@@ -3653,10 +4262,12 @@ export class AUNClient {
3653
4262
  payload: legacyPayload,
3654
4263
  encrypted: false,
3655
4264
  };
3656
- if (ns)
4265
+ if (ns) {
3657
4266
  await this._publishPulledMessage('message.received', ns, seq, v1Msg);
3658
- else
4267
+ }
4268
+ else {
3659
4269
  await this._publishAppEvent('message.received', v1Msg, 'pull');
4270
+ }
3660
4271
  decrypted.push(v1Msg);
3661
4272
  this._clientLog.debug(`message.v2.pull plaintext V1 delivered: seq=${seq}, ns=${ns || '<none>'}`);
3662
4273
  }
@@ -3678,10 +4289,12 @@ export class AUNClient {
3678
4289
  this._clientLog.debug(`message.v2.pull decrypt returned null: seq=${seq}, ns=${ns || '<none>'}`);
3679
4290
  continue;
3680
4291
  }
3681
- if (ns)
4292
+ if (ns) {
3682
4293
  await this._publishPulledMessage('message.received', ns, seq, plaintext);
3683
- else
4294
+ }
4295
+ else {
3684
4296
  await this._publishAppEvent('message.received', plaintext, 'pull');
4297
+ }
3685
4298
  decrypted.push(plaintext);
3686
4299
  this._logMessageDebug('decrypt-ok', 'message.v2.pull', 'message.received', plaintext);
3687
4300
  }
@@ -3697,10 +4310,10 @@ export class AUNClient {
3697
4310
  const ackSeq = this._seqTracker.getContiguousSeq(ns);
3698
4311
  const contigAdvanced = ackSeq !== pageContigBefore;
3699
4312
  if (contigAdvanced) {
3700
- await this._drainOrderedMessages(ns);
4313
+ await this._drainOrderedMessages(ns, undefined, true);
3701
4314
  this._saveSeqTrackerState();
3702
4315
  }
3703
- if (messages.length > 0 && contigAdvanced && ackSeq > 0) {
4316
+ if (messages.length > 0 && contigAdvanced && ackSeq > 0 && !opts?.skipAutoAck) {
3704
4317
  this._clientLog.debug(`message.v2.pull scheduling auto-ack: ns=${ns}, ack_seq=${ackSeq}, raw_count=${messages.length}`);
3705
4318
  this._safeAsync(this.ackV2(ackSeq).then(() => undefined));
3706
4319
  }
@@ -3733,7 +4346,7 @@ export class AUNClient {
3733
4346
  }
3734
4347
  }
3735
4348
  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 });
4349
+ const raw = await this._callRawV2Rpc('message.v2.ack', { up_to_seq: seq });
3737
4350
  const result = isJsonObject(raw)
3738
4351
  ? { ...raw }
3739
4352
  : { result: raw };
@@ -3839,19 +4452,25 @@ export class AUNClient {
3839
4452
  let auditRecipientsRaw = [];
3840
4453
  let epoch = 0;
3841
4454
  let stateCommitment = { state_version: 0, state_hash: '', state_chain: '' };
4455
+ let wrapPolicy = normalizeV2WrapPolicy(undefined);
3842
4456
  const cached = useCache ? this._v2BootstrapCache.get(cacheKey) : undefined;
3843
4457
  if (cached && Date.now() - cached.cachedAt < AUNClient.V2_BOOTSTRAP_TTL_MS) {
3844
4458
  allDevices = cached.devices;
3845
4459
  auditRecipientsRaw = cached.auditRecipients;
3846
4460
  epoch = cached.epoch ?? 0;
3847
4461
  stateCommitment = cached.stateCommitment ?? stateCommitment;
4462
+ wrapPolicy = cached.wrapPolicy ?? wrapPolicy;
3848
4463
  this._clientLog.debug(`group.v2.bootstrap cache hit: group=${groupId}, devices=${allDevices.length}, audit=${auditRecipientsRaw.length}, epoch=${epoch}, state_version=${stateCommitment.state_version}`);
3849
4464
  }
3850
4465
  else {
3851
- const bs = await this.call('group.v2.bootstrap', { group_id: groupId });
4466
+ const bs = await this.call('group.v2.bootstrap', {
4467
+ group_id: groupId,
4468
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
4469
+ });
3852
4470
  allDevices = (Array.isArray(bs.devices) ? bs.devices : []);
3853
4471
  auditRecipientsRaw = (Array.isArray(bs.audit_recipients) ? bs.audit_recipients : []);
3854
4472
  epoch = Number(bs.epoch ?? 0) || 0;
4473
+ wrapPolicy = normalizeV2WrapPolicy(bs.e2ee_wrap_policy);
3855
4474
  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
4475
  const stateChain = String(bs.state_chain ?? '');
3857
4476
  await this._v2CheckFork(groupId, stateChain);
@@ -3869,6 +4488,7 @@ export class AUNClient {
3869
4488
  cachedAt: Date.now(),
3870
4489
  epoch,
3871
4490
  stateCommitment,
4491
+ wrapPolicy,
3872
4492
  });
3873
4493
  }
3874
4494
  // lazy sync 触发:发现 pending members 时异步发起提案
@@ -3911,7 +4531,7 @@ export class AUNClient {
3911
4531
  if (target)
3912
4532
  targets.push(target);
3913
4533
  }
3914
- const envelope = encryptGroupMessage(session.getSenderIdentity(), groupId, epoch, targets, opts.payload, {
4534
+ const envelope = encryptGroupMessage(session.getSenderIdentity(), groupId, epoch, applyV2WrapPolicyToTargets(targets, wrapPolicy), opts.payload, {
3915
4535
  messageId: opts.messageId,
3916
4536
  timestamp: opts.timestamp,
3917
4537
  protectedHeaders: opts.protectedHeaders,
@@ -3938,28 +4558,37 @@ export class AUNClient {
3938
4558
  return envelope;
3939
4559
  }
3940
4560
  async _pullGroupV2Internal(params) {
3941
- await this.pullGroupV2(params.group_id, params.after_seq, params.limit);
4561
+ await this.pullGroupV2(params.group_id, params.after_seq, params.limit, { gateLocked: true });
3942
4562
  }
3943
4563
  /** V2 Group 拉取并解密;直接方法返回消息数组,call("group.pull") 会包装为 {messages}. */
3944
- async pullGroupV2(groupId, afterSeq = 0, limit = 50) {
4564
+ async pullGroupV2(groupId, afterSeq = 0, limit = 50, opts) {
3945
4565
  await this._ensureV2SessionReady('group.pull');
3946
4566
  const gid = normalizeGroupId(groupId) || String(groupId ?? '').trim();
3947
4567
  if (!gid)
3948
4568
  throw new ValidationError('group.pull requires group_id');
3949
4569
  const ns = `group:${gid}`;
4570
+ if (!opts?.gateLocked) {
4571
+ return await this._runPullSerialized(ns, async () => this.pullGroupV2(gid, afterSeq, limit, {
4572
+ ...(opts ?? {}),
4573
+ gateLocked: true,
4574
+ scheduleFollowup: true,
4575
+ }));
4576
+ }
3950
4577
  const decrypted = [];
4578
+ let totalRawCount = 0;
3951
4579
  let nextAfterSeq = afterSeq || this._seqTracker.getContiguousSeq(ns);
3952
4580
  let pageCount = 0;
3953
4581
  const maxPages = 100;
3954
4582
  while (pageCount < maxPages) {
3955
4583
  pageCount += 1;
3956
4584
  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', {
4585
+ const result = await this._callRawV2Rpc('group.v2.pull', {
3958
4586
  group_id: gid,
3959
4587
  after_seq: nextAfterSeq,
3960
4588
  limit,
3961
4589
  });
3962
4590
  const messages = (Array.isArray(result.messages) ? result.messages : []);
4591
+ totalRawCount += messages.length;
3963
4592
  const cursor = isJsonObject(result.cursor) ? result.cursor : null;
3964
4593
  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
4594
  for (const msg of messages) {
@@ -3969,8 +4598,9 @@ export class AUNClient {
3969
4598
  .map((msg) => Number(msg.seq ?? 0))
3970
4599
  .filter((seq) => Number.isFinite(seq) && seq > 0);
3971
4600
  const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
3972
- const pageMaxSeq = seqs.length > 0 ? Math.max(...seqs) : nextAfterSeq;
4601
+ let pageMaxSeq = nextAfterSeq;
3973
4602
  if (seqs.length > 0) {
4603
+ pageMaxSeq = Math.max(...seqs);
3974
4604
  this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
3975
4605
  this._clientLog.debug(`group.v2.pull force contiguous: group=${gid}, ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
3976
4606
  }
@@ -4034,18 +4664,18 @@ export class AUNClient {
4034
4664
  decrypted.push(plaintext);
4035
4665
  this._logMessageDebug('decrypt-ok', 'group.v2.pull', 'group.message_created', plaintext);
4036
4666
  }
4037
- const serverAckSeq = Number(cursor?.current_seq ?? 0);
4038
- if (Number.isFinite(serverAckSeq) && serverAckSeq > 0) {
4667
+ const retentionFloor = this._pullRetentionFloor(result, 'retention_floor_message_seq', 'retention_floor_message_seq');
4668
+ if (retentionFloor > 0) {
4039
4669
  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);
4670
+ if (contig < retentionFloor) {
4671
+ this._clientLog.info(`group.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> retention_floor=${retentionFloor}`);
4672
+ this._seqTracker.forceContiguousSeq(ns, retentionFloor);
4043
4673
  }
4044
4674
  }
4045
4675
  const ackSeq = this._seqTracker.getContiguousSeq(ns);
4046
4676
  const contigAdvanced = ackSeq !== pageContigBefore;
4047
4677
  if (contigAdvanced) {
4048
- await this._drainOrderedMessages(ns);
4678
+ await this._drainOrderedMessages(ns, undefined, true);
4049
4679
  this._saveSeqTrackerState();
4050
4680
  }
4051
4681
  if (messages.length > 0 && contigAdvanced && ackSeq > 0) {
@@ -4081,7 +4711,7 @@ export class AUNClient {
4081
4711
  seq = maxSeen;
4082
4712
  }
4083
4713
  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 });
4714
+ const result = await this._callRawV2Rpc('group.v2.ack', { group_id: gid, up_to_seq: seq });
4085
4715
  this._clientLog.debug(`group.v2.ack ok: group=${gid}, ns=${ns}, requested=${seq}, result=${this._debugJson(result)}`);
4086
4716
  return result;
4087
4717
  }
@@ -4117,7 +4747,7 @@ export class AUNClient {
4117
4747
  for (const row of recipients) {
4118
4748
  if (Array.isArray(row) && row.length >= 6
4119
4749
  && String(row[0] ?? '') === this._aid
4120
- && String(row[1] ?? '') === this._deviceId) {
4750
+ && (String(row[1] ?? '') === this._deviceId || String(row[1] ?? '') === '')) {
4121
4751
  if (!spkId)
4122
4752
  spkId = String(row[5] ?? '');
4123
4753
  if (row.length > 3)
@@ -4465,7 +5095,8 @@ export class AUNClient {
4465
5095
  for (const row of envelope.recipients) {
4466
5096
  if (!Array.isArray(row) || row.length < 6)
4467
5097
  continue;
4468
- if (String(row[0] ?? '') === this._aid && String(row[1] ?? '') === this._deviceId) {
5098
+ if (String(row[0] ?? '') === this._aid
5099
+ && (String(row[1] ?? '') === this._deviceId || String(row[1] ?? '') === '')) {
4469
5100
  spkId = String(row[5] ?? '');
4470
5101
  recipientKeySource = String(row[3] ?? '');
4471
5102
  break;
@@ -4749,7 +5380,10 @@ export class AUNClient {
4749
5380
  }
4750
5381
  if (myRole !== 'owner' && myRole !== 'admin')
4751
5382
  return false;
4752
- const bootstrapResp = await this.call('group.v2.bootstrap', { group_id: groupId });
5383
+ const bootstrapResp = await this.call('group.v2.bootstrap', {
5384
+ group_id: groupId,
5385
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
5386
+ });
4753
5387
  const devices = isJsonObject(bootstrapResp) && Array.isArray(bootstrapResp.devices)
4754
5388
  ? bootstrapResp.devices.filter(isJsonObject)
4755
5389
  : [];
@@ -4858,7 +5492,10 @@ export class AUNClient {
4858
5492
  }
4859
5493
  }
4860
5494
  }
4861
- const bootstrapResp = await this.call('group.v2.bootstrap', { group_id: groupId });
5495
+ const bootstrapResp = await this.call('group.v2.bootstrap', {
5496
+ group_id: groupId,
5497
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
5498
+ });
4862
5499
  const allDevices = isJsonObject(bootstrapResp) && Array.isArray(bootstrapResp.devices)
4863
5500
  ? bootstrapResp.devices.filter(isJsonObject)
4864
5501
  : [];
@@ -5064,11 +5701,10 @@ export class AUNClient {
5064
5701
  try {
5065
5702
  const decrypted = await this._decryptV2PushMessage(data);
5066
5703
  if (decrypted) {
5067
- // 解密成功:把 pushSeq 加入 receivedSeqs,让 _tryAdvance 自然推进
5068
- // (如果 pushSeq == contiguousSeq + 1 会自动推进到 pushSeq)
5069
- const needPull = this._seqTracker.onMessageSeq(ns, pushSeq);
5704
+ // 解密成功也不能先推进 contiguousSeq;必须等应用层发布返回后再推进和 ACK。
5070
5705
  const published = await this._publishOrderedMessage('message.received', ns, pushSeq, decrypted);
5071
5706
  const newContig = this._seqTracker.getContiguousSeq(ns);
5707
+ const needPull = pushSeq > newContig && !published;
5072
5708
  if (newContig !== contigBefore) {
5073
5709
  this._saveSeqTrackerState();
5074
5710
  }
@@ -5076,7 +5712,7 @@ export class AUNClient {
5076
5712
  // ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
5077
5713
  const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
5078
5714
  const ackSeq = maxSeen > 0 ? Math.min(newContig, maxSeen) : newContig;
5079
- this.call('message.v2.ack', { up_to_seq: ackSeq })
5715
+ this.call('message.v2.ack', { up_to_seq: ackSeq, _rpc_background: true })
5080
5716
  .catch(e => this._clientLog.debug(`V2 P2P push-ack failed: ${formatCaughtError(e)}`));
5081
5717
  }
5082
5718
  this._clientLog.debug(`_onV2PushNotification: push 带 payload 解密成功, contiguous_seq=${contigBefore}->${newContig} push_seq=${pushSeq}`);
@@ -5096,31 +5732,37 @@ export class AUNClient {
5096
5732
  if (pushSeq > 0 && ns) {
5097
5733
  this._clientLog.debug(`_onV2PushNotification: 纯通知 push_seq=${pushSeq} > contiguous_seq=${contigBefore}, 触发 pull(after_seq=${contigBefore})`);
5098
5734
  }
5099
- if (this._v2PullInflight) {
5100
- this._v2PullPending = true;
5735
+ if (!ns)
5101
5736
  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;
5737
+ void this._tryRunBackgroundPull(ns, async () => {
5738
+ const operationBefore = this._seqTracker.getContiguousSeq(ns);
5739
+ const dedupKey = `p2p_pull:${ns}`;
5740
+ if (this._gapFillDone.has(dedupKey)) {
5741
+ this._recordPendingP2pPull(ns, pushSeq);
5742
+ return 0;
5743
+ }
5744
+ this._gapFillDone.set(dedupKey, Date.now());
5745
+ try {
5746
+ const pulled = await this.pullV2(0, 50, { gateLocked: true });
5747
+ const newContig = this._seqTracker.getContiguousSeq(ns);
5109
5748
  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;
5749
+ if (newContig <= operationBefore)
5750
+ return 0;
5751
+ return pulled.length;
5752
+ }
5753
+ finally {
5754
+ this._gapFillDone.delete(dedupKey);
5755
+ }
5756
+ }, true, () => this._recordPendingP2pPull(ns, pushSeq)).catch((exc) => {
5757
+ const newContig = this._seqTracker.getContiguousSeq(ns);
5114
5758
  this._clientLog.warn(`V2 push auto-pull failed: contiguous_seq=${contigBefore}->${newContig} err=${formatCaughtError(exc)}`);
5115
- }
5116
- finally {
5117
- this._v2PullInflight = false;
5118
- }
5759
+ });
5119
5760
  }
5120
5761
  async _onV2StateProposed(data) {
5121
5762
  if (!isJsonObject(data) || !this._v2Session)
5122
5763
  return;
5123
- const groupId = String(data.group_id ?? '').trim();
5764
+ const rawGroupId = String(data.group_id ?? '').trim();
5765
+ const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
5124
5766
  if (!groupId)
5125
5767
  return;
5126
5768
  await this._dispatcher.publish('group.v2.state_proposed', data);
@@ -5134,7 +5776,8 @@ export class AUNClient {
5134
5776
  async _onV2StateRetryNeeded(data) {
5135
5777
  if (!isJsonObject(data) || !this._v2Session)
5136
5778
  return;
5137
- const groupId = String(data.group_id ?? '').trim();
5779
+ const rawGroupId = String(data.group_id ?? '').trim();
5780
+ const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
5138
5781
  if (!groupId)
5139
5782
  return;
5140
5783
  await this._dispatcher.publish('group.v2.state_retry_needed', data);
@@ -5148,7 +5791,8 @@ export class AUNClient {
5148
5791
  async _onV2StateConfirmed(data) {
5149
5792
  if (!isJsonObject(data))
5150
5793
  return;
5151
- const groupId = String(data.group_id ?? '').trim();
5794
+ const rawGroupId = String(data.group_id ?? '').trim();
5795
+ const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
5152
5796
  if (groupId) {
5153
5797
  this._v2BootstrapCache.delete(`group:${groupId}`);
5154
5798
  this._v2AutoProposeLastSnapshot.delete(groupId);
@@ -5161,7 +5805,8 @@ export class AUNClient {
5161
5805
  return;
5162
5806
  }
5163
5807
  this._logMessageDebug('server-push', '_raw.group.v2.message_created', 'group.message_created', data);
5164
- const groupId = String(data.group_id ?? '').trim();
5808
+ const rawGroupId = String(data.group_id ?? '').trim();
5809
+ const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
5165
5810
  const seq = Number(data.seq ?? 0);
5166
5811
  if (!groupId || !Number.isFinite(seq) || seq <= 0) {
5167
5812
  this._clientLog.debug(`_onRawGroupV2MessageCreated skipped: group=${groupId || '<empty>'}, seq=${String(data.seq ?? '')}`);
@@ -5178,22 +5823,28 @@ export class AUNClient {
5178
5823
  }
5179
5824
  const afterSeq = this._repairPushContiguousBound(ns, seq, false, '_raw.group.v2.message_created');
5180
5825
  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) {
5826
+ void this._tryRunBackgroundPull(ns, async () => {
5827
+ const pullAfterSeq = this._seqTracker.getContiguousSeq(ns);
5828
+ if (this._gapFillDone.has(dedupKey)) {
5829
+ this._clientLog.debug(`_onRawGroupV2MessageCreated skipped duplicate in-flight pull: group=${groupId}, dedup=${dedupKey}`);
5830
+ return 0;
5831
+ }
5832
+ this._gapFillDone.set(dedupKey, Date.now());
5833
+ try {
5834
+ this._clientLog.debug(`_onRawGroupV2MessageCreated auto-pull start: group=${groupId}, after_seq=${pullAfterSeq}, push_seq=${seq}`);
5835
+ const pulled = await this.pullGroupV2(groupId, pullAfterSeq, 50, { gateLocked: true });
5836
+ const newContig = this._seqTracker.getContiguousSeq(ns);
5837
+ this._clientLog.debug(`_onRawGroupV2MessageCreated auto-pull done: group=${groupId}, after_seq=${pullAfterSeq}, push_seq=${seq}, contiguous=${newContig}`);
5838
+ if (newContig <= pullAfterSeq)
5839
+ return 0;
5840
+ return pulled.length;
5841
+ }
5842
+ finally {
5843
+ this._gapFillDone.delete(dedupKey);
5844
+ }
5845
+ }, true).catch((exc) => {
5192
5846
  this._clientLog.warn(`V2 group push auto-pull failed: group=${groupId} err=${formatCaughtError(exc)}`);
5193
- }
5194
- finally {
5195
- this._gapFillDone.delete(dedupKey);
5196
- }
5847
+ });
5197
5848
  }
5198
5849
  /** Push 通知带 payload 时的就地解密(复用 _decryptV2Message) */
5199
5850
  async _decryptV2PushMessage(data) {