@agentunion/fastaun 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/_packed_docs/CHANGELOG.md +43 -0
  3. package/_packed_docs/INDEX.md +81 -0
  4. package/_packed_docs/KITE_DOCS_GUIDE.md +55 -0
  5. package/_packed_docs/agent.md//350/277/234/347/250/213agent.md/347/274/223/345/255/230/344/270/216etag/351/200/217/344/274/240/346/226/271/346/241/210.md +328 -0
  6. package/_packed_docs/cli/AUN-CLI/350/256/276/350/256/241/346/226/207/346/241/243.md +686 -0
  7. package/_packed_docs/design//350/267/250/350/257/255/350/250/200/345/256/271/345/231/250E2E/346/265/213/350/257/225/346/226/271/346/241/210.md +665 -0
  8. package/_packed_docs/protocol//351/231/204/345/275/225N-/345/210/206/345/270/203/345/274/217Trace/345/215/217/350/256/256.md +257 -0
  9. package/_packed_docs/sdk/01-/345/277/253/351/200/237/345/274/200/345/247/213.md +5 -5
  10. package/_packed_docs/sdk/02-WebSocket/345/215/217/350/256/256.md +1 -1
  11. package/_packed_docs/sdk/03-/346/240/270/345/277/203/346/246/202/345/277/265.md +2 -2
  12. package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +454 -396
  13. package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +1410 -1244
  14. package/_packed_docs/sdk/07-/351/224/231/350/257/257/345/244/204/347/220/206.md +19 -1
  15. package/_packed_docs/sdk/08-/346/234/200/344/275/263/345/256/236/350/267/265.md +20 -5
  16. package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +6 -4
  17. package/_packed_docs/sdk/E2EE_V2/346/266/210/346/201/257/351/200/232/344/277/241/346/227/266/345/272/217/345/233/276.md +171 -0
  18. package/_packed_docs/sdk/INDEX.md +9 -4
  19. package/_packed_docs/sdk/README.md +3 -3
  20. package/dist/auth.d.ts +44 -8
  21. package/dist/auth.js +398 -119
  22. package/dist/auth.js.map +1 -1
  23. package/dist/client.d.ts +123 -19
  24. package/dist/client.js +2650 -673
  25. package/dist/client.js.map +1 -1
  26. package/dist/discovery.d.ts +4 -0
  27. package/dist/discovery.js +28 -13
  28. package/dist/discovery.js.map +1 -1
  29. package/dist/errors.d.ts +4 -0
  30. package/dist/errors.js +7 -0
  31. package/dist/errors.js.map +1 -1
  32. package/dist/events.d.ts +9 -0
  33. package/dist/events.js +42 -12
  34. package/dist/events.js.map +1 -1
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.js +2 -2
  37. package/dist/index.js.map +1 -1
  38. package/dist/keystore/aid-db.d.ts +4 -0
  39. package/dist/keystore/aid-db.js +94 -0
  40. package/dist/keystore/aid-db.js.map +1 -1
  41. package/dist/keystore/file.d.ts +23 -1
  42. package/dist/keystore/file.js +109 -1
  43. package/dist/keystore/file.js.map +1 -1
  44. package/dist/keystore/index.d.ts +20 -0
  45. package/dist/logger.d.ts +2 -0
  46. package/dist/logger.js +7 -4
  47. package/dist/logger.js.map +1 -1
  48. package/dist/namespaces/auth.d.ts +34 -4
  49. package/dist/namespaces/auth.js +194 -51
  50. package/dist/namespaces/auth.js.map +1 -1
  51. package/dist/net.d.ts +43 -0
  52. package/dist/net.js +192 -0
  53. package/dist/net.js.map +1 -0
  54. package/dist/secret-store/file-store.d.ts +21 -2
  55. package/dist/secret-store/file-store.js +166 -11
  56. package/dist/secret-store/file-store.js.map +1 -1
  57. package/dist/seq-tracker.d.ts +32 -3
  58. package/dist/seq-tracker.js +60 -3
  59. package/dist/seq-tracker.js.map +1 -1
  60. package/dist/tools/cross-sdk-agent.d.ts +2 -0
  61. package/dist/tools/cross-sdk-agent.js +695 -0
  62. package/dist/tools/cross-sdk-agent.js.map +1 -0
  63. package/dist/transport.d.ts +10 -1
  64. package/dist/transport.js +196 -32
  65. package/dist/transport.js.map +1 -1
  66. package/dist/v2/crypto/canonical.d.ts +1 -1
  67. package/dist/v2/crypto/canonical.js +42 -17
  68. package/dist/v2/crypto/canonical.js.map +1 -1
  69. package/dist/v2/e2ee/decrypt.js +57 -3
  70. package/dist/v2/e2ee/decrypt.js.map +1 -1
  71. package/dist/v2/e2ee/encrypt-group.js +16 -7
  72. package/dist/v2/e2ee/encrypt-group.js.map +1 -1
  73. package/dist/v2/e2ee/encrypt-p2p.js +42 -9
  74. package/dist/v2/e2ee/encrypt-p2p.js.map +1 -1
  75. package/dist/v2/e2ee/metadata-auth.d.ts +1 -0
  76. package/dist/v2/e2ee/metadata-auth.js +37 -1
  77. package/dist/v2/e2ee/metadata-auth.js.map +1 -1
  78. package/dist/v2/e2ee/types.d.ts +2 -2
  79. package/dist/v2/session/keystore.d.ts +10 -3
  80. package/dist/v2/session/keystore.js +158 -30
  81. package/dist/v2/session/keystore.js.map +1 -1
  82. package/dist/v2/session/session.d.ts +7 -3
  83. package/dist/v2/session/session.js +64 -12
  84. package/dist/v2/session/session.js.map +1 -1
  85. package/package.json +46 -46
package/dist/client.js CHANGED
@@ -14,10 +14,12 @@ import * as crypto from 'node:crypto';
14
14
  import * as fs from 'node:fs';
15
15
  import * as http from 'node:http';
16
16
  import * as https from 'node:https';
17
+ import * as path from 'node:path';
17
18
  import { URL } from 'node:url';
18
19
  import { configFromMap, getDeviceId, normalizeInstanceId } from './config.js';
19
20
  import { CryptoProvider } from './crypto.js';
20
21
  import { GatewayDiscovery } from './discovery.js';
22
+ import { DnsResilientNet } from './net.js';
21
23
  import { AUNError, AuthError, ConnectionError, E2EEError, PermissionError, StateError, TimeoutError, ValidationError, } from './errors.js';
22
24
  import { EventDispatcher } from './events.js';
23
25
  import { FileKeyStore } from './keystore/file.js';
@@ -31,8 +33,12 @@ import { AuthFlow } from './auth.js';
31
33
  import { SeqTracker } from './seq-tracker.js';
32
34
  import { V2Session } from './v2/session/index.js';
33
35
  import { encryptP2PMessage, encryptGroupMessage, decryptMessage, } from './v2/e2ee/index.js';
36
+ import { ecdsaVerifyRaw } from './v2/crypto/ecdsa.js';
34
37
  import { computeStateCommitment } from './v2/state/index.js';
35
38
  import { isJsonObject, } from './types.js';
39
+ function isPromiseLike(value) {
40
+ return Boolean(value && typeof value.then === 'function');
41
+ }
36
42
  /**
37
43
  * 递归排序键的 JSON 序列化(Canonical JSON for AUN)
38
44
  * 等价于 Python json.dumps(sort_keys=True, separators=(",",":"), ensure_ascii=False)
@@ -58,6 +64,15 @@ export function stableStringify(obj) {
58
64
  }
59
65
  return JSON.stringify(obj);
60
66
  }
67
+ function getV2DeviceId(dev) {
68
+ if (Object.prototype.hasOwnProperty.call(dev, 'device_id')) {
69
+ return { present: true, value: String(dev.device_id ?? '').trim() };
70
+ }
71
+ if (Object.prototype.hasOwnProperty.call(dev, 'owner_device_id')) {
72
+ return { present: true, value: String(dev.owner_device_id ?? '').trim() };
73
+ }
74
+ return { present: false, value: '' };
75
+ }
61
76
  function computeStateHash(params) {
62
77
  const sortedMembers = [...params.members].sort((a, b) => a.aid.localeCompare(b.aid));
63
78
  const membershipBlock = sortedMembers.map(m => `${m.aid}:${m.role}`).join('|');
@@ -156,7 +171,16 @@ function reconnectSleepDelayMs(baseDelay, maxBaseDelay) {
156
171
  }
157
172
  /** 需要客户端签名的关键方法 */
158
173
  const SIGNED_METHODS = new Set([
159
- 'group.send', 'group.kick', 'group.add_member',
174
+ 'message.send',
175
+ 'message.v2.put_peer_pk', 'message.v2.bootstrap',
176
+ 'message.v2.group_bootstrap', 'message.v2.pull',
177
+ 'message.v2.ack',
178
+ 'group.send',
179
+ 'group.v2.put_group_pk', 'group.v2.bootstrap',
180
+ 'group.v2.send', 'group.v2.pull', 'group.v2.ack',
181
+ 'group.v2.propose_state', 'group.v2.confirm_state',
182
+ 'group.v2.get_proposal',
183
+ 'group.kick', 'group.add_member',
160
184
  'group.leave', 'group.remove_member', 'group.update_rules',
161
185
  'group.update', 'group.update_announcement',
162
186
  'group.update_join_requirements', 'group.set_role',
@@ -176,6 +200,59 @@ const SIGNED_METHODS = new Set([
176
200
  ]);
177
201
  /** peer 证书缓存 TTL(1 小时) */
178
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
+ }
179
256
  function _v2LeftPad32(b) {
180
257
  if (b.length === 32)
181
258
  return b;
@@ -189,6 +266,21 @@ function _v2B64ToBytes(s) {
189
266
  const buf = Buffer.from(String(s ?? '').trim(), 'base64');
190
267
  return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
191
268
  }
269
+ function _v2B64ToBytesStrict(s) {
270
+ const text = String(s ?? '').trim();
271
+ if (!text || text.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(text)) {
272
+ throw new Error('invalid base64');
273
+ }
274
+ return _v2B64ToBytes(text);
275
+ }
276
+ function _v2BytesEqual(a, b) {
277
+ if (a.length !== b.length)
278
+ return false;
279
+ let diff = 0;
280
+ for (let i = 0; i < a.length; i++)
281
+ diff |= a[i] ^ b[i];
282
+ return diff === 0;
283
+ }
192
284
  function _v2B64uToBytes(s) {
193
285
  const std = String(s ?? '').replace(/-/g, '+').replace(/_/g, '/');
194
286
  const pad = std.length % 4 === 0 ? '' : '='.repeat(4 - (std.length % 4));
@@ -245,11 +337,11 @@ function normalizeDeliveryModeConfig(raw, opts = {}) {
245
337
  }
246
338
  // ── HTTP 辅助 ─────────────────────────────────────────────────
247
339
  /** 发起 HTTP GET 请求,返回文本内容 */
248
- function _httpGetText(url, verifySsl) {
340
+ function _httpGetText(url, verifySsl, timeoutMs = 30_000) {
249
341
  return new Promise((resolve, reject) => {
250
342
  const parsed = new URL(url);
251
343
  const mod = parsed.protocol === 'https:' ? https : http;
252
- const options = { timeout: 30_000 };
344
+ const options = { timeout: timeoutMs };
253
345
  if (!verifySsl) {
254
346
  options.rejectUnauthorized = false;
255
347
  }
@@ -274,6 +366,17 @@ function _httpGetText(url, verifySsl) {
274
366
  /**
275
367
  * AUN Core SDK 主客户端
276
368
  */
369
+ function lengthPrefixedTextKey(...parts) {
370
+ return parts.map((part) => `${Buffer.byteLength(part, 'utf8')}:${part};`).join('');
371
+ }
372
+ function lengthPrefixedBytesKey(...parts) {
373
+ const chunks = [];
374
+ for (const part of parts) {
375
+ const bytes = Buffer.from(part.buffer, part.byteOffset, part.byteLength);
376
+ chunks.push(Buffer.from(`${bytes.length}:`, 'ascii'), bytes, Buffer.from(';', 'ascii'));
377
+ }
378
+ return Buffer.concat(chunks);
379
+ }
277
380
  export class AUNClient {
278
381
  /** 原始配置 */
279
382
  config;
@@ -317,13 +420,14 @@ export class AUNClient {
317
420
  _defaultConnectDeliveryMode;
318
421
  /** peer 证书缓存 */
319
422
  _certCache = new Map();
320
- // 本地 agent.md 文件路径与对应 etag(quoted sha256 hex,与服务端 _agent_md_etag 一致)。
321
- // setLocalAgentMdPath() 设置;用于跟服务端 RPC 注入的 _meta.agent_md_etag 比对,
322
- // 触发"本地未发布到服务端"或"服务端版本更新"的 UI 提示。
423
+ // AIDs 目录:{agentMdPath}/{aid}/agentmd.json 保存元数据,{agentMdPath}/{aid}/agent.md 保存正文。
424
+ _agentMdPath = '';
323
425
  _localAgentMdPath = '';
324
426
  _localAgentMdEtag = '';
325
427
  // gateway 在 RPC envelope._meta.agent_md_etag 注入的服务端 etag;纯观察,无下游依赖。
326
428
  _remoteAgentMdEtag = '';
429
+ _agentMdCache = new Map();
430
+ _agentMdFetchInflight = new Map();
327
431
  /** 消息序列号跟踪器(群消息 + P2P 空洞检测) */
328
432
  _seqTracker = new SeqTracker();
329
433
  _seqTrackerContext = null;
@@ -331,10 +435,19 @@ export class AUNClient {
331
435
  _groupSynced = new Set();
332
436
  /** 补洞去重:已完成/进行中的 key -> 开始时间戳,防止重复 pull 同一区间 */
333
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;
334
443
  /** 已发布到应用层的 seq 集合(按命名空间),补洞路径 publish 前检查以避免重复分发 */
335
444
  _pushedSeqs = new Map();
336
445
  /** 已解密但因 seq 空洞暂缓发布的应用层消息(按 namespace -> seq) */
337
446
  _pendingOrderedMsgs = new Map();
447
+ /** 缺 sender IK 时暂存原始 V2 消息,后台补齐 IK 后重试解密。 */
448
+ _v2SenderIKPending = new Map();
449
+ /** sender IK 后台补齐任务去重。 */
450
+ _v2SenderIKFetching = new Set();
338
451
  // ── 后台任务定时器 ──────────────────────────────────────────
339
452
  _heartbeatTimer = null;
340
453
  _tokenRefreshTimer = null;
@@ -356,11 +469,10 @@ export class AUNClient {
356
469
  /** 最近一次已成功提交的 membership_snapshot;相同快照直接跳过。 */
357
470
  _v2AutoProposeLastSnapshot = new Map();
358
471
  _v2LazyProposeTriggered = new Map();
359
- _v2PullInflight = false;
360
- _v2PullPending = false;
361
472
  static V2_BOOTSTRAP_TTL_MS = 60 * 60 * 1000;
362
473
  static V2_RETRYABLE_CODES = new Set([-33011, -33012, -33050, -33052, -33054]);
363
- static V2_SIG_CACHE_TTL_MS = 600_000;
474
+ static PULL_GATE_STALE_MS = 3000;
475
+ static V2_SIG_CACHE_TTL_MS = 60 * 60 * 1000;
364
476
  static V2_SIG_CACHE_MAX = 16_384;
365
477
  _reconnectActive = false;
366
478
  _reconnectAbort = null;
@@ -373,30 +485,49 @@ export class AUNClient {
373
485
  const rawConfig = { ...(config ?? {}) };
374
486
  this._configModel = configFromMap(rawConfig);
375
487
  const initAid = String(rawConfig.aid ?? '').trim() || null;
488
+ this._agentMdPath = path.join(this._configModel.aunPath, 'AIDs');
376
489
  this.config = {
377
490
  aun_path: this._configModel.aunPath,
378
491
  root_ca_path: this._configModel.rootCaPath,
379
492
  seed_password: this._configModel.seedPassword,
380
493
  };
494
+ this._deviceId = getDeviceId(this._configModel.aunPath);
381
495
  // 初始化 Logger(per-client 单例,必须最早创建)
382
496
  const debugFlag = this._configModel.debug || debug;
383
497
  this._logger = new AUNLogger({
384
498
  debug: debugFlag,
385
499
  aunPath: this._configModel.aunPath,
386
500
  });
501
+ this._logger.bindDeviceId(this._deviceId);
387
502
  this._clientLog = this._logger.for('aun_core.client');
388
503
  if (debugFlag) {
389
504
  this._clientLog.info(`AUNClient initialized (debug=true, aunPath=${this._configModel.aunPath})`);
390
505
  }
391
506
  this._dispatcher = new EventDispatcher(this._logger.for('aun_core.events'));
392
- this._discovery = new GatewayDiscovery({ verifySsl: this._configModel.verifySsl });
507
+ const dnsNet = new DnsResilientNet({
508
+ verifySsl: this._configModel.verifySsl,
509
+ logger: this._clientLog,
510
+ });
511
+ this._discovery = new GatewayDiscovery({ verifySsl: this._configModel.verifySsl, logger: this._clientLog, net: dnsNet });
393
512
  const keystore = new FileKeyStore(this._configModel.aunPath, {
394
513
  encryptionSeed: this._configModel.seedPassword ?? undefined,
395
514
  logger: this._logger.for('aun_core.keystore'),
396
515
  secretStoreLogger: this._logger.for('aun_core.secret-store'),
397
516
  });
398
517
  this._keystore = keystore;
399
- this._deviceId = getDeviceId(this._configModel.aunPath);
518
+ // 启动时被动清理 registerAid 留下的孤儿临时目录(>10 分钟)
519
+ try {
520
+ const cleanup = keystore.cleanupPendingDirs;
521
+ if (typeof cleanup === 'function') {
522
+ const removed = cleanup.call(keystore, 600_000);
523
+ if (removed > 0) {
524
+ this._clientLog.info(`_pending cleanup removed=${removed}`);
525
+ }
526
+ }
527
+ }
528
+ catch (err) {
529
+ this._clientLog.warn(`_pending cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
530
+ }
400
531
  this._slotId = '';
401
532
  this._connectDeliveryMode = normalizeDeliveryModeConfig({ mode: 'fanout' });
402
533
  this._defaultConnectDeliveryMode = { ...this._connectDeliveryMode };
@@ -409,6 +540,7 @@ export class AUNClient {
409
540
  rootCaPath: this._configModel.rootCaPath ?? undefined,
410
541
  verifySsl: this._configModel.verifySsl,
411
542
  logger: this._logger.for('aun_core.auth'),
543
+ net: dnsNet,
412
544
  });
413
545
  this._aid = initAid;
414
546
  this._transport = new RPCTransport({
@@ -417,6 +549,7 @@ export class AUNClient {
417
549
  onDisconnect: (err, closeCode) => this._handleTransportDisconnect(err, closeCode),
418
550
  verifySsl: this._configModel.verifySsl,
419
551
  logger: this._logger.for('aun_core.transport'),
552
+ dnsNet,
420
553
  });
421
554
  this._transport.setMetaObserver((meta) => this._observeRpcMeta(meta));
422
555
  this.auth = new AuthNamespace(this);
@@ -453,61 +586,107 @@ export class AUNClient {
453
586
  return this._aid;
454
587
  }
455
588
  /**
456
- * 读取本地 agent.md,签名后上传,并刷新本地 etag。
589
+ * 读取 {agentMdPath}/{self_aid}/agent.md,签名后上传,并把签名结果原子写回本地。
457
590
  */
458
- async publishAgentMd(path) {
459
- const rawPath = String(path ?? '').trim();
460
- if (!rawPath) {
461
- throw new ValidationError('publishAgentMd requires non-empty path');
591
+ async publishAgentMd() {
592
+ const target = this._agentMdOwnerAid();
593
+ if (!target) {
594
+ throw new ValidationError('publishAgentMd requires local AID');
462
595
  }
463
- const content = fs.readFileSync(rawPath).toString('utf-8');
596
+ const content = this._readAgentMdContent(target);
464
597
  const signed = await this.auth.signAgentMd(content);
465
598
  const result = await this.auth.uploadAgentMd(signed);
466
- const digest = crypto.createHash('sha256').update(signed, 'utf-8').digest('hex');
467
- this._localAgentMdEtag = `"${digest}"`;
468
- this._localAgentMdPath = rawPath;
599
+ this._localAgentMdEtag = this._agentMdContentEtag(signed);
600
+ const remoteEtag = isJsonObject(result) ? String(result.etag ?? '').trim() : '';
601
+ if (remoteEtag)
602
+ this._remoteAgentMdEtag = remoteEtag;
603
+ this._saveAgentMdRecord(target, {
604
+ content: signed,
605
+ local_etag: this._localAgentMdEtag,
606
+ remote_etag: remoteEtag || undefined,
607
+ last_modified: isJsonObject(result) ? String(result.last_modified ?? '').trim() : '',
608
+ fetched_at: Date.now(),
609
+ remote_status: remoteEtag ? 'found' : 'unknown',
610
+ last_error: '',
611
+ });
469
612
  return result;
470
613
  }
471
614
  /**
472
- * 下载 agent.md 并自动验签;可选写盘;目标是自身 AID 时刷新本地 etag
615
+ * 下载 agent.md 并自动验签;内容固定保存到 {agentMdPath}/{aid}/agent.md
473
616
  */
474
- async fetchAgentMd(aid, savePath) {
617
+ async fetchAgentMd(aid) {
475
618
  const target = String(aid ?? this._aid ?? '').trim();
476
619
  if (!target) {
477
620
  throw new ValidationError('fetchAgentMd requires aid (or local AID)');
478
621
  }
622
+ return await this._startAgentMdFetchTask(target);
623
+ }
624
+ async _startAgentMdFetchTask(target) {
625
+ const existing = this._agentMdFetchInflight.get(target);
626
+ if (existing) {
627
+ return await existing;
628
+ }
629
+ const task = this._fetchAgentMdOnce(target);
630
+ this._agentMdFetchInflight.set(target, task);
631
+ task.finally(() => {
632
+ if (this._agentMdFetchInflight.get(target) === task) {
633
+ this._agentMdFetchInflight.delete(target);
634
+ }
635
+ }).catch(() => undefined);
636
+ return await task;
637
+ }
638
+ async _fetchAgentMdOnce(target) {
479
639
  const content = await this.auth.downloadAgentMd(target);
480
640
  const signature = await this.auth.verifyAgentMd(content, { aid: target });
481
641
  const isSelf = target === (this._aid ?? '');
642
+ const localEtag = this._agentMdContentEtag(content);
643
+ const cacheMeta = this._agentMdAuthCacheMeta(target);
644
+ const remoteEtag = String(cacheMeta.etag ?? '').trim();
645
+ const lastModified = String(cacheMeta.lastModified ?? cacheMeta.last_modified ?? '').trim();
646
+ if (isSelf) {
647
+ this._localAgentMdEtag = localEtag;
648
+ if (remoteEtag)
649
+ this._remoteAgentMdEtag = remoteEtag;
650
+ }
651
+ const saved = this._saveAgentMdRecord(target, {
652
+ content,
653
+ local_etag: localEtag,
654
+ remote_etag: remoteEtag || undefined,
655
+ last_modified: lastModified || undefined,
656
+ fetched_at: Date.now(),
657
+ remote_status: 'found',
658
+ verify_status: isJsonObject(signature) ? String(signature.status ?? '') : '',
659
+ verify_error: isJsonObject(signature) ? String(signature.reason ?? '') : '',
660
+ last_error: '',
661
+ });
482
662
  let inSync = null;
483
663
  if (isSelf) {
484
- const digest = crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
485
- this._localAgentMdEtag = `"${digest}"`;
486
- const local = this._localAgentMdEtag || '';
487
- const remote = this._remoteAgentMdEtag || '';
488
- inSync = local && remote ? local === remote : false;
489
- }
490
- let savedTo = null;
491
- let saveError = null;
492
- const rawSavePath = String(savePath ?? '').trim();
493
- if (rawSavePath) {
494
- try {
495
- fs.writeFileSync(rawSavePath, content, 'utf-8');
496
- savedTo = rawSavePath;
497
- }
498
- catch (exc) {
499
- saveError = exc instanceof Error ? exc.message : String(exc);
500
- }
664
+ const remote = remoteEtag || this._remoteAgentMdEtag || '';
665
+ inSync = localEtag && remote ? localEtag === remote : false;
501
666
  }
502
667
  return {
503
668
  aid: target,
504
669
  content,
505
670
  signature: signature,
506
671
  in_sync: inSync,
507
- saved_to: savedTo,
508
- save_error: saveError,
672
+ saved_to: String(saved.saved_to ?? this._agentMdFilePath(target)),
673
+ save_error: null,
509
674
  };
510
675
  }
676
+ /**
677
+ * 设置 agent.md 本地存储根目录;为空时恢复默认 {aun_path}/AIDs。
678
+ */
679
+ setAgentMdPath(root) {
680
+ const raw = String(root ?? '').trim();
681
+ const next = raw || path.join(this._configModel.aunPath, 'AIDs');
682
+ fs.mkdirSync(next, { recursive: true });
683
+ this._agentMdPath = next;
684
+ this._agentMdCache.clear();
685
+ return this._agentMdPath;
686
+ }
687
+ SetAgentMDPath(root) {
688
+ return this.setAgentMdPath(root);
689
+ }
511
690
  /**
512
691
  * 记录本地 agent.md 文件路径并一次性计算 etag(quoted sha256,与服务端一致)。
513
692
  *
@@ -558,6 +737,414 @@ export class AUNClient {
558
737
  getRemoteAgentMdEtag() {
559
738
  return this._remoteAgentMdEtag;
560
739
  }
740
+ _agentMdContentEtag(content) {
741
+ return `"${crypto.createHash('sha256').update(String(content ?? ''), 'utf-8').digest('hex')}"`;
742
+ }
743
+ _agentMdOwnerAid() {
744
+ return String(this._aid ?? '').trim();
745
+ }
746
+ _agentMdSafeAid(aid) {
747
+ const target = String(aid ?? '').trim();
748
+ if (!target || target.includes('/') || target.includes('\\') || target.includes('\0')) {
749
+ throw new ValidationError('agent.md aid is empty or contains path separators');
750
+ }
751
+ return target;
752
+ }
753
+ _agentMdRoot() {
754
+ const root = this._agentMdPath || path.join(this._configModel.aunPath, 'AIDs');
755
+ fs.mkdirSync(root, { recursive: true });
756
+ return root;
757
+ }
758
+ _agentMdFilePath(aid) {
759
+ return path.join(this._agentMdRoot(), this._agentMdSafeAid(aid), 'agent.md');
760
+ }
761
+ _agentMdMetaPath(aid) {
762
+ return path.join(this._agentMdRoot(), this._agentMdSafeAid(aid), 'agentmd.json');
763
+ }
764
+ _atomicWriteText(filePath, content) {
765
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
766
+ const tmp = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
767
+ let fd = null;
768
+ try {
769
+ fd = fs.openSync(tmp, 'w');
770
+ fs.writeFileSync(fd, content, 'utf-8');
771
+ fs.fsyncSync(fd);
772
+ fs.closeSync(fd);
773
+ fd = null;
774
+ fs.renameSync(tmp, filePath);
775
+ try {
776
+ const dirFd = fs.openSync(path.dirname(filePath), 'r');
777
+ try {
778
+ fs.fsyncSync(dirFd);
779
+ }
780
+ finally {
781
+ fs.closeSync(dirFd);
782
+ }
783
+ }
784
+ catch { /* best effort */ }
785
+ }
786
+ finally {
787
+ if (fd !== null) {
788
+ try {
789
+ fs.closeSync(fd);
790
+ }
791
+ catch { /* ignore */ }
792
+ }
793
+ if (fs.existsSync(tmp)) {
794
+ try {
795
+ fs.unlinkSync(tmp);
796
+ }
797
+ catch { /* ignore */ }
798
+ }
799
+ }
800
+ }
801
+ _sleepSync(ms) {
802
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
803
+ }
804
+ _withAgentMdRecordLock(aid, fn) {
805
+ const lockPath = path.join(path.dirname(this._agentMdMetaPath(aid)), 'agentmd.json.lock');
806
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
807
+ const deadline = Date.now() + 5000;
808
+ let fd = null;
809
+ while (fd === null) {
810
+ try {
811
+ fd = fs.openSync(lockPath, 'wx');
812
+ fs.writeFileSync(fd, `${process.pid}\n`, 'utf-8');
813
+ }
814
+ catch (err) {
815
+ if (err?.code !== 'EEXIST' || Date.now() >= deadline)
816
+ throw err;
817
+ try {
818
+ const st = fs.statSync(lockPath);
819
+ if (Date.now() - st.mtimeMs > 30000)
820
+ fs.unlinkSync(lockPath);
821
+ }
822
+ catch { /* ignore */ }
823
+ this._sleepSync(25);
824
+ }
825
+ }
826
+ try {
827
+ return fn();
828
+ }
829
+ finally {
830
+ if (fd !== null) {
831
+ try {
832
+ fs.closeSync(fd);
833
+ }
834
+ catch { /* ignore */ }
835
+ }
836
+ try {
837
+ fs.unlinkSync(lockPath);
838
+ }
839
+ catch { /* ignore */ }
840
+ }
841
+ }
842
+ _writeAgentMdRecordUnlocked(aid, record) {
843
+ const payload = {};
844
+ for (const [key, value] of Object.entries(record)) {
845
+ if (key !== 'content' && value !== undefined && value !== null)
846
+ payload[key] = value;
847
+ }
848
+ payload.aid = this._agentMdSafeAid(aid);
849
+ this._atomicWriteText(this._agentMdMetaPath(aid), `${JSON.stringify(payload, null, 2)}\n`);
850
+ }
851
+ _normalizeAgentMdRecord(aid, data) {
852
+ if (!isJsonObject(data))
853
+ return {};
854
+ const record = {};
855
+ for (const [key, value] of Object.entries(data)) {
856
+ if (key !== 'content')
857
+ record[key] = value;
858
+ }
859
+ record.aid = this._agentMdSafeAid(String(record.aid ?? aid));
860
+ for (const key of ['fetched_at', 'observed_at', 'checked_at', 'updated_at']) {
861
+ record[key] = Number(record[key] ?? 0) || 0;
862
+ }
863
+ return record;
864
+ }
865
+ _readAgentMdRecordUnlocked(aid) {
866
+ const filePath = this._agentMdMetaPath(aid);
867
+ if (!fs.existsSync(filePath))
868
+ return {};
869
+ try {
870
+ return this._normalizeAgentMdRecord(aid, JSON.parse(fs.readFileSync(filePath, 'utf-8')));
871
+ }
872
+ catch (err) {
873
+ this._clientLog.warn(`agent.md metadata damaged, ignoring: aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
874
+ return {};
875
+ }
876
+ }
877
+ _readAgentMdContent(aid) {
878
+ return fs.readFileSync(this._agentMdFilePath(aid), 'utf-8');
879
+ }
880
+ _writeAgentMdContent(aid, content) {
881
+ const filePath = this._agentMdFilePath(aid);
882
+ this._atomicWriteText(filePath, String(content ?? ''));
883
+ return filePath;
884
+ }
885
+ _agentMdAuthCacheMeta(aid) {
886
+ try {
887
+ const store = this.auth._agentMdCache;
888
+ const record = store?.get(String(aid ?? '').trim());
889
+ return record && typeof record === 'object' ? { ...record } : {};
890
+ }
891
+ catch {
892
+ return {};
893
+ }
894
+ }
895
+ _loadAgentMdRecord(aid) {
896
+ const target = String(aid ?? '').trim();
897
+ if (!target)
898
+ return null;
899
+ try {
900
+ const loaded = this._withAgentMdRecordLock(target, () => {
901
+ const record = this._readAgentMdRecordUnlocked(target);
902
+ const next = Object.keys(record).length > 0 ? { ...record, aid: target } : { aid: target };
903
+ try {
904
+ const content = this._readAgentMdContent(target);
905
+ next.content = content;
906
+ next.local_etag = this._agentMdContentEtag(content);
907
+ }
908
+ catch (err) {
909
+ if (fs.existsSync(this._agentMdMetaPath(target))) {
910
+ this._clientLog.warn(`agent.md content read failed: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
911
+ }
912
+ }
913
+ return next;
914
+ });
915
+ if (Object.keys(loaded).length <= 1)
916
+ return null;
917
+ this._agentMdCache.set(target, { ...loaded });
918
+ return { ...loaded };
919
+ }
920
+ catch (err) {
921
+ this._clientLog.debug(`agent.md cache load skipped: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
922
+ }
923
+ return null;
924
+ }
925
+ _saveAgentMdRecord(aid, fields) {
926
+ const target = String(aid ?? '').trim();
927
+ if (!target)
928
+ return {};
929
+ try {
930
+ const inputFields = { ...fields };
931
+ const hasContent = Object.prototype.hasOwnProperty.call(inputFields, 'content') && inputFields.content !== undefined && inputFields.content !== null;
932
+ let savedTo = '';
933
+ const record = this._withAgentMdRecordLock(target, () => {
934
+ if (hasContent) {
935
+ const content = String(inputFields.content ?? '');
936
+ savedTo = this._writeAgentMdContent(target, content);
937
+ if (!inputFields.local_etag)
938
+ inputFields.local_etag = this._agentMdContentEtag(content);
939
+ if (!inputFields.fetched_at)
940
+ inputFields.fetched_at = Date.now();
941
+ }
942
+ delete inputFields.content;
943
+ const next = { ...this._readAgentMdRecordUnlocked(target), aid: target };
944
+ for (const [key, value] of Object.entries(inputFields)) {
945
+ if (value !== undefined && value !== null)
946
+ next[key] = value;
947
+ }
948
+ next.updated_at = Date.now();
949
+ this._writeAgentMdRecordUnlocked(target, next);
950
+ return next;
951
+ });
952
+ const loaded = { ...record };
953
+ if (hasContent) {
954
+ loaded.content = String(fields.content ?? '');
955
+ if (savedTo)
956
+ loaded.saved_to = savedTo;
957
+ }
958
+ this._agentMdCache.set(target, { ...loaded });
959
+ const owner = this._agentMdOwnerAid();
960
+ if (target === owner) {
961
+ const localEtag = String(loaded.local_etag ?? '').trim();
962
+ const remoteEtag = String(loaded.remote_etag ?? '').trim();
963
+ if (localEtag)
964
+ this._localAgentMdEtag = localEtag;
965
+ if (remoteEtag)
966
+ this._remoteAgentMdEtag = remoteEtag;
967
+ }
968
+ return { ...loaded };
969
+ }
970
+ catch (err) {
971
+ this._clientLog.debug(`agent.md cache save skipped: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
972
+ }
973
+ return {};
974
+ }
975
+ _agentMdHasLocalContent(aid, record) {
976
+ if (record && typeof record.content === 'string' && record.content.length > 0)
977
+ return true;
978
+ try {
979
+ return fs.existsSync(this._agentMdFilePath(aid));
980
+ }
981
+ catch {
982
+ return false;
983
+ }
984
+ }
985
+ _agentMdCheckedAtFresh(checkedAtMs, maxUnsyncedDays) {
986
+ const days = Number(maxUnsyncedDays || 0);
987
+ if (!Number.isFinite(days) || days <= 0)
988
+ return false;
989
+ if (!Number.isFinite(checkedAtMs) || checkedAtMs <= 0)
990
+ return false;
991
+ return Date.now() - checkedAtMs <= days * 86400000;
992
+ }
993
+ _agentMdLastModifiedFresh(lastModified, maxUnsyncedDays) {
994
+ const days = Number(maxUnsyncedDays || 0);
995
+ if (!Number.isFinite(days) || days <= 0)
996
+ return false;
997
+ const ts = Date.parse(String(lastModified ?? '').trim());
998
+ if (!Number.isFinite(ts))
999
+ return false;
1000
+ return Date.now() <= ts + days * 86400000;
1001
+ }
1002
+ _scheduleAgentMdFetchIfMissing(aid, record, source = '') {
1003
+ const target = String(aid ?? '').trim();
1004
+ if (!target || this._agentMdHasLocalContent(target, record))
1005
+ return;
1006
+ if (this._agentMdFetchInflight.has(target))
1007
+ return;
1008
+ void this.fetchAgentMd(target).catch((err) => {
1009
+ this._saveAgentMdRecord(target, {
1010
+ last_error: err instanceof Error ? err.message : String(err),
1011
+ remote_status: 'found',
1012
+ });
1013
+ this._clientLog.debug(`agent.md auto fetch failed: aid=${target} source=${source || '-'} err=${err instanceof Error ? err.message : String(err)}`);
1014
+ });
1015
+ }
1016
+ _observeAgentMdMeta(aid, etag = '', lastModified = '', source = '') {
1017
+ const target = String(aid ?? '').trim();
1018
+ const remoteEtag = String(etag ?? '').trim();
1019
+ const remoteLastModified = String(lastModified ?? '').trim();
1020
+ if (!target || (!remoteEtag && !remoteLastModified))
1021
+ return;
1022
+ let before = this._agentMdCache.get(target);
1023
+ if (!before || typeof before !== 'object')
1024
+ before = this._loadAgentMdRecord(target) ?? {};
1025
+ const same = (!remoteEtag || String(before.remote_etag ?? '').trim() === remoteEtag) &&
1026
+ (!remoteLastModified || String(before.last_modified ?? '').trim() === remoteLastModified);
1027
+ let record = { ...before };
1028
+ if (!same || Object.keys(before).length === 0) {
1029
+ const fields = {
1030
+ observed_at: Date.now(),
1031
+ remote_status: 'found',
1032
+ };
1033
+ if (remoteEtag)
1034
+ fields.remote_etag = remoteEtag;
1035
+ if (remoteLastModified)
1036
+ fields.last_modified = remoteLastModified;
1037
+ record = this._saveAgentMdRecord(target, fields) || record;
1038
+ }
1039
+ if (target === this._agentMdOwnerAid() && remoteEtag)
1040
+ this._remoteAgentMdEtag = remoteEtag;
1041
+ this._scheduleAgentMdFetchIfMissing(target, record, source);
1042
+ this._clientLog.debug(`agent.md meta observed: aid=${target} etag=${remoteEtag || '-'} last_modified=${remoteLastModified || '-'} source=${source || '-'}`);
1043
+ }
1044
+ _observeAgentMdEtag(aid, etag, source = '') {
1045
+ this._observeAgentMdMeta(aid, etag, '', source);
1046
+ }
1047
+ _observeAgentMdFromEnvelope(envelope) {
1048
+ if (!isJsonObject(envelope))
1049
+ return;
1050
+ const env = envelope;
1051
+ if (!isJsonObject(env.agent_md))
1052
+ return;
1053
+ const agentMd = env.agent_md;
1054
+ if (!isJsonObject(agentMd.sender))
1055
+ return;
1056
+ const sender = agentMd.sender;
1057
+ let senderAid = String(sender.aid ?? '').trim();
1058
+ if (!senderAid) {
1059
+ const aad = isJsonObject(env.aad) ? env.aad : {};
1060
+ senderAid = String(aad.from ?? env.from ?? '').trim();
1061
+ }
1062
+ this._observeAgentMdMeta(senderAid, String(sender.etag ?? '').trim(), String(sender.last_modified ?? sender.lastModified ?? '').trim(), 'envelope');
1063
+ }
1064
+ async checkAgentMd(aid, maxUnsyncedDays = 1) {
1065
+ const target = String(aid ?? this._aid ?? '').trim();
1066
+ if (!target)
1067
+ throw new ValidationError('checkAgentMd requires aid (or local AID)');
1068
+ const before = this._loadAgentMdRecord(target) ?? {};
1069
+ const localEtag = String(before.local_etag ?? '').trim();
1070
+ const localFound = !!(Object.keys(before).length > 0 && (String(before.content ?? '') || localEtag));
1071
+ const remoteEtagCached = String(before.remote_etag ?? '').trim();
1072
+ const lastModifiedCached = String(before.last_modified ?? '').trim();
1073
+ const checkedAt = Number(before.checked_at ?? 0);
1074
+ const fetchedAt = Number(before.fetched_at ?? 0);
1075
+ const checkedAtCached = checkedAt > 0 ? checkedAt : fetchedAt;
1076
+ const cachedInSync = !!(localFound && localEtag && remoteEtagCached && localEtag === remoteEtagCached);
1077
+ // max_unsynced_days > 0 且距上次 HEAD 在窗口内 → 直接返回缓存;否则强制 HEAD。
1078
+ if (cachedInSync && this._agentMdCheckedAtFresh(checkedAtCached, maxUnsyncedDays)) {
1079
+ return {
1080
+ aid: target,
1081
+ local_found: true,
1082
+ remote_found: true,
1083
+ local_etag: localEtag,
1084
+ remote_etag: remoteEtagCached,
1085
+ in_sync: true,
1086
+ last_modified: lastModifiedCached,
1087
+ status: 200,
1088
+ cached: true,
1089
+ verify_status: String(before.verify_status ?? ''),
1090
+ verify_error: String(before.verify_error ?? ''),
1091
+ };
1092
+ }
1093
+ const remoteFoundCached = !!(remoteEtagCached || String(before.remote_status ?? '') === 'found');
1094
+ if (!localFound &&
1095
+ !remoteFoundCached &&
1096
+ String(before.remote_status ?? '') === 'missing' &&
1097
+ this._agentMdCheckedAtFresh(checkedAtCached, maxUnsyncedDays)) {
1098
+ return {
1099
+ aid: target,
1100
+ local_found: false,
1101
+ remote_found: false,
1102
+ local_etag: '',
1103
+ remote_etag: '',
1104
+ in_sync: false,
1105
+ last_modified: '',
1106
+ status: 404,
1107
+ cached: true,
1108
+ verify_status: '',
1109
+ verify_error: '',
1110
+ };
1111
+ }
1112
+ const now = Date.now();
1113
+ let remote;
1114
+ try {
1115
+ remote = await this.auth.headAgentMd(target);
1116
+ }
1117
+ catch (err) {
1118
+ this._saveAgentMdRecord(target, { checked_at: now, remote_status: 'error', last_error: err instanceof Error ? err.message : String(err) });
1119
+ throw err;
1120
+ }
1121
+ const remoteFound = !!remote.found;
1122
+ const remoteEtag = String(remote.etag ?? '').trim();
1123
+ const lastModified = String(remote.last_modified ?? remote.lastModified ?? '').trim();
1124
+ const saved = this._saveAgentMdRecord(target, {
1125
+ remote_etag: remoteFound ? remoteEtag : '',
1126
+ last_modified: lastModified,
1127
+ checked_at: now,
1128
+ remote_status: remoteFound ? 'found' : 'missing',
1129
+ last_error: '',
1130
+ });
1131
+ if (target === this._agentMdOwnerAid() && remoteEtag)
1132
+ this._remoteAgentMdEtag = remoteEtag;
1133
+ const inSync = !!(localFound && remoteFound && localEtag && remoteEtag && localEtag === remoteEtag);
1134
+ return {
1135
+ aid: target,
1136
+ local_found: localFound,
1137
+ remote_found: remoteFound,
1138
+ local_etag: localEtag,
1139
+ remote_etag: remoteEtag,
1140
+ in_sync: inSync,
1141
+ last_modified: lastModified,
1142
+ status: Number(remote.status ?? (remoteFound ? 200 : 404)),
1143
+ cached: false,
1144
+ verify_status: String(saved.verify_status ?? before.verify_status ?? ''),
1145
+ verify_error: String(saved.verify_error ?? before.verify_error ?? ''),
1146
+ };
1147
+ }
561
1148
  /** transport 的 meta observer:吸收 gateway 注入的 _meta 字段。失败不影响业务。 */
562
1149
  _observeRpcMeta(meta) {
563
1150
  if (!meta || typeof meta !== 'object')
@@ -565,6 +1152,17 @@ export class AUNClient {
565
1152
  const etag = String(meta.agent_md_etag ?? '').trim();
566
1153
  if (etag) {
567
1154
  this._remoteAgentMdEtag = etag;
1155
+ this._observeAgentMdMeta(this._aid ?? '', etag, '', 'rpc.self');
1156
+ }
1157
+ const etags = meta.agent_md_etags;
1158
+ if (isJsonObject(etags)) {
1159
+ // role key 优先级:requester / peer 是新规范,其余是兼容旧 SDK 的别名。
1160
+ for (const key of ['requester', 'peer', 'receiver', 'target', 'to', 'sender', 'from']) {
1161
+ const item = etags[key];
1162
+ if (!isJsonObject(item))
1163
+ continue;
1164
+ this._observeAgentMdMeta(String(item.aid ?? ''), String(item.etag ?? ''), String(item.last_modified ?? item.lastModified ?? ''), `rpc.${key}`);
1165
+ }
568
1166
  }
569
1167
  }
570
1168
  /** 连接状态 */
@@ -613,19 +1211,31 @@ export class AUNClient {
613
1211
  this._transport.setTimeout(callTimeoutSec != null ? callTimeoutSec * 1000 : 35_000);
614
1212
  this._closing = false;
615
1213
  this._clientLog.debug(`connect enter: gateway=${String(normalized.gateway ?? '')}, device_id=${this._deviceId}`);
616
- try {
617
- await this._connectOnce(normalized, false);
618
- this._clientLog.debug(`connect exit: elapsed=${Date.now() - tStart}ms aid=${this._aid ?? ''}, state=${this._state}`);
619
- }
620
- catch (err) {
621
- // 连接失败时回退状态,允许重试
622
- if (this._state === 'connecting' || this._state === 'authenticating') {
623
- this._state = 'disconnected';
1214
+ const gateways = this._resolveGateways(normalized);
1215
+ let lastErr = null;
1216
+ for (const gw of gateways) {
1217
+ try {
1218
+ const gwParams = { ...normalized, gateway: gw };
1219
+ await this._connectOnce(gwParams, false);
1220
+ this._clientLog.debug(`connect exit: elapsed=${Date.now() - tStart}ms aid=${this._aid ?? ''}, state=${this._state}`);
1221
+ return;
624
1222
  }
625
- this._clientLog.error(`connect failed: ${formatCaughtError(err)}`, err instanceof Error ? err : undefined);
626
- this._clientLog.debug(`connect exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
627
- throw err;
1223
+ catch (err) {
1224
+ lastErr = err;
1225
+ if (gateways.length > 1) {
1226
+ this._clientLog.warn(`connect: gateway ${gw} failed, trying next: ${formatCaughtError(err)}`);
1227
+ }
1228
+ if (this._state === 'connecting' || this._state === 'authenticating') {
1229
+ this._state = 'connecting';
1230
+ }
1231
+ }
1232
+ }
1233
+ if (this._state === 'connecting' || this._state === 'authenticating') {
1234
+ this._state = 'disconnected';
628
1235
  }
1236
+ this._clientLog.error(`connect failed: ${formatCaughtError(lastErr)}`, lastErr instanceof Error ? lastErr : undefined);
1237
+ this._clientLog.debug(`connect exit (error): elapsed=${Date.now() - tStart}ms err=${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
1238
+ throw lastErr;
629
1239
  }
630
1240
  /** 关闭连接 */
631
1241
  async close() {
@@ -690,11 +1300,17 @@ export class AUNClient {
690
1300
  }
691
1301
  }
692
1302
  /**
693
- * 列出本地所有已存储的身份摘要(仅返回有有效私钥的 AID)。
1303
+ * 列出本地身份摘要。
1304
+ *
1305
+ * @param opts.all=false(默认):仅返回严格校验通过的可用身份——
1306
+ * keypair 完整 + cert 公钥 == keypair 公钥 + cert 时间窗口有效
1307
+ * @param opts.all=true:返回所有 AIDs/ 子目录(不含 _pending/);
1308
+ * 每项含 valid=bool 和 reason=string 字段
694
1309
  */
695
- listIdentities() {
1310
+ listIdentities(opts) {
696
1311
  const tStart = Date.now();
697
- this._clientLog.debug(`listIdentities enter`);
1312
+ const includeAll = !!opts?.all;
1313
+ this._clientLog.debug(`listIdentities enter all=${includeAll}`);
698
1314
  try {
699
1315
  const listFn = this._keystore.listIdentities;
700
1316
  if (typeof listFn !== 'function') {
@@ -704,10 +1320,12 @@ export class AUNClient {
704
1320
  const aids = listFn.call(this._keystore);
705
1321
  const summaries = [];
706
1322
  for (const aid of [...aids].sort()) {
707
- const identity = this._keystore.loadIdentity(aid);
708
- if (!identity || !identity.private_key_pem)
1323
+ const { valid, reason } = this._validateLocalIdentity(aid);
1324
+ if (!includeAll && !valid)
709
1325
  continue;
710
- const summary = { aid };
1326
+ const summary = { aid, valid };
1327
+ if (reason)
1328
+ summary.reason = reason;
711
1329
  const loadMetadata = this._keystore.loadMetadata;
712
1330
  if (typeof loadMetadata === 'function') {
713
1331
  const md = loadMetadata.call(this._keystore, aid);
@@ -716,7 +1334,7 @@ export class AUNClient {
716
1334
  }
717
1335
  summaries.push(summary);
718
1336
  }
719
- this._clientLog.debug(`listIdentities exit: elapsed=${Date.now() - tStart}ms count=${summaries.length}`);
1337
+ this._clientLog.debug(`listIdentities exit: elapsed=${Date.now() - tStart}ms all=${includeAll} count=${summaries.length}`);
720
1338
  return summaries;
721
1339
  }
722
1340
  catch (err) {
@@ -724,6 +1342,40 @@ export class AUNClient {
724
1342
  throw err;
725
1343
  }
726
1344
  }
1345
+ /**
1346
+ * 严格校验本地身份的可用性。返回 {valid, reason}。
1347
+ * 4 项校验:keypair 完整 + cert 存在 + cert 公钥 == keypair 公钥 + cert 时间窗口有效。
1348
+ */
1349
+ _validateLocalIdentity(aid) {
1350
+ const identity = this._keystore.loadIdentity(aid);
1351
+ if (!identity)
1352
+ return { valid: false, reason: 'no identity record' };
1353
+ const priv = String(identity.private_key_pem ?? '');
1354
+ const pubB64 = String(identity.public_key_der_b64 ?? '');
1355
+ const certPem = String(identity.cert ?? '');
1356
+ if (!priv || !pubB64)
1357
+ return { valid: false, reason: 'missing keypair' };
1358
+ if (!certPem)
1359
+ return { valid: false, reason: 'missing certificate' };
1360
+ try {
1361
+ const crypto = require('node:crypto');
1362
+ const cert = new crypto.X509Certificate(certPem);
1363
+ const certPubDer = cert.publicKey.export({ type: 'spki', format: 'der' });
1364
+ const localPubDer = Buffer.from(pubB64, 'base64');
1365
+ if (!certPubDer.equals(localPubDer)) {
1366
+ return { valid: false, reason: 'cert public key does not match keypair' };
1367
+ }
1368
+ const now = Date.now();
1369
+ if (now < new Date(cert.validFrom).getTime())
1370
+ return { valid: false, reason: 'cert not yet valid' };
1371
+ if (now > new Date(cert.validTo).getTime())
1372
+ return { valid: false, reason: 'cert expired' };
1373
+ return { valid: true, reason: '' };
1374
+ }
1375
+ catch (e) {
1376
+ return { valid: false, reason: `cert parse error: ${e instanceof Error ? e.message : String(e)}` };
1377
+ }
1378
+ }
727
1379
  // ── RPC ───────────────────────────────────────────────────
728
1380
  /**
729
1381
  * 发送 JSON-RPC 调用。
@@ -743,6 +1395,16 @@ export class AUNClient {
743
1395
  throw new PermissionError(`legacy E2EE method is removed in this SDK: ${method}`);
744
1396
  }
745
1397
  const p = { ...(params ?? {}) };
1398
+ const rpcBackground = Boolean(p._rpc_background) || this._backgroundRpcDepth > 0;
1399
+ delete p._rpc_background;
1400
+ const runWithRpcPriority = async (operation) => {
1401
+ if (!rpcBackground)
1402
+ return await operation();
1403
+ return await this._withBackgroundRpc(operation);
1404
+ };
1405
+ if (method === 'message.send' || method === 'group.send') {
1406
+ this._normalizeOutboundMessagePayload(p, method);
1407
+ }
746
1408
  this._validateOutboundCall(method, p);
747
1409
  this._injectMessageCursorContext(method, p);
748
1410
  // group.* 方法的 group_id 归一化为 canonical 格式(兼容老/污染数据)
@@ -755,23 +1417,39 @@ export class AUNClient {
755
1417
  p.group_id = normalizedGroupId;
756
1418
  }
757
1419
  // group.* 方法注入 device_id(服务端用于多设备消息路由)
758
- if (method.startsWith('group.') && this._deviceId && p.device_id === undefined) {
1420
+ if (method.startsWith('group.') && p.device_id === undefined) {
759
1421
  p.device_id = this._deviceId;
760
1422
  }
761
1423
  if (method.startsWith('group.') && p.slot_id === undefined) {
762
1424
  p.slot_id = this._slotId;
763
1425
  }
1426
+ const pullGateLocked = Boolean(p._pull_gate_locked);
1427
+ if ('_pull_gate_locked' in p) {
1428
+ delete p._pull_gate_locked;
1429
+ }
1430
+ const pullGateKey = this._pullGateKeyForCall(method, p);
1431
+ if (pullGateKey && this._isPullResponseProcessing(pullGateKey)) {
1432
+ this._clientLog.debug(`pull skipped while processing pull response: method=${method} key=${pullGateKey}`);
1433
+ return this._emptyPullResultForCall(method);
1434
+ }
1435
+ if (pullGateKey && !pullGateLocked) {
1436
+ const lockedParams = { ...p, _pull_gate_locked: true };
1437
+ if (rpcBackground)
1438
+ lockedParams._rpc_background = true;
1439
+ const result = await this._runPullSerialized(pullGateKey, async () => this.call(method, lockedParams));
1440
+ return result;
1441
+ }
764
1442
  // 自动加密:message.send 默认加密(encrypt 默认 true)— V2-only
765
1443
  if (method === 'message.send') {
766
1444
  const encrypt = p.encrypt ?? true;
767
1445
  delete p.encrypt;
768
1446
  if (encrypt) {
769
- return await this.sendV2(String(p.to ?? ''), p.payload, {
1447
+ return await runWithRpcPriority(() => this.sendV2(String(p.to ?? ''), p.payload, {
770
1448
  messageId: String(p.message_id ?? '') || undefined,
771
1449
  timestamp: p.timestamp,
772
1450
  protectedHeaders: this._protectedHeadersFromParams(p),
773
1451
  context: isJsonObject(p.context) ? p.context : undefined,
774
- });
1452
+ }));
775
1453
  }
776
1454
  // encrypt=false:明文走通用 RPC 路径;protected_headers/headers 是信封元数据,加密与否都保留
777
1455
  this._maybeAppendEchoTraceSend(p);
@@ -781,12 +1459,12 @@ export class AUNClient {
781
1459
  const encrypt = p.encrypt ?? true;
782
1460
  delete p.encrypt;
783
1461
  if (encrypt) {
784
- return await this.sendGroupV2(String(p.group_id ?? ''), p.payload, {
1462
+ return await runWithRpcPriority(() => this.sendGroupV2(String(p.group_id ?? ''), p.payload, {
785
1463
  messageId: String(p.message_id ?? '') || undefined,
786
1464
  timestamp: p.timestamp,
787
1465
  protectedHeaders: this._protectedHeadersFromParams(p),
788
1466
  context: isJsonObject(p.context) ? p.context : undefined,
789
- });
1467
+ }));
790
1468
  }
791
1469
  this._maybeAppendEchoTraceSend(p);
792
1470
  }
@@ -798,7 +1476,7 @@ export class AUNClient {
798
1476
  if (!this._v2Session || !String(p.group_id ?? '').trim()) {
799
1477
  throw new StateError(v2Error);
800
1478
  }
801
- return await this._putGroupThoughtEncryptedV2(p);
1479
+ return await runWithRpcPriority(() => this._putGroupThoughtEncryptedV2(p));
802
1480
  }
803
1481
  }
804
1482
  if (method === 'message.thought.put') {
@@ -806,40 +1484,70 @@ export class AUNClient {
806
1484
  delete p.encrypt;
807
1485
  if (encrypt) {
808
1486
  await this._ensureV2SessionReady('message.thought.put', 'V2 session not initialized; encrypted message.thought.put requires V2 (V1 E2EE removed)');
809
- return await this._putMessageThoughtEncryptedV2(p);
1487
+ return await runWithRpcPriority(() => this._putMessageThoughtEncryptedV2(p));
810
1488
  }
811
1489
  }
812
- if (method === 'message.pull' && this._clientUsesV2P2P()) {
1490
+ // V2-only:兼容入口名只作为 SDK 内部适配层存在,底层绝不能降级发 legacy RPC。
1491
+ if (method === 'message.pull' || method === 'message.v2.pull') {
813
1492
  await this._ensureV2SessionReady('message.pull');
814
- const messages = await this.pullV2(Number(p.after_seq ?? 0) || 0, Number(p.limit ?? 50) || 50);
1493
+ const skipAutoAck = p._skip_auto_ack === true || p.skip_auto_ack === true;
1494
+ const afterSeq = Number(p.after_seq ?? 0) || 0;
1495
+ const limit = Number(p.limit ?? 50) || 50;
1496
+ const messages = skipAutoAck
1497
+ ? await runWithRpcPriority(() => this.pullV2(afterSeq, limit, { skipAutoAck: true, gateLocked: true }))
1498
+ : await runWithRpcPriority(() => this.pullV2(afterSeq, limit, { gateLocked: true }));
815
1499
  return { messages };
816
1500
  }
817
- if (method === 'message.ack' && this._clientUsesV2P2P()) {
1501
+ if (method === 'message.ack' || method === 'message.v2.ack') {
818
1502
  await this._ensureV2SessionReady('message.ack');
819
- return await this.ackV2(Number(p.seq ?? p.up_to_seq ?? 0) || undefined);
1503
+ return await runWithRpcPriority(() => this.ackV2(Number(p.seq ?? p.up_to_seq ?? 0) || undefined));
820
1504
  }
821
- if (method === 'group.pull' && this._clientUsesV2Group() && p.group_id) {
1505
+ if (method === 'group.pull' || method === 'group.v2.pull') {
1506
+ if (!String(p.group_id ?? '').trim()) {
1507
+ throw new ValidationError('group.pull requires group_id');
1508
+ }
822
1509
  await this._ensureV2SessionReady('group.pull');
823
- const messages = await this.pullGroupV2(String(p.group_id), Number(p.after_seq ?? p.after_message_seq ?? 0) || 0, Number(p.limit ?? 50) || 50);
1510
+ const messages = await runWithRpcPriority(() => this.pullGroupV2(String(p.group_id), Number(p.after_seq ?? p.after_message_seq ?? 0) || 0, Number(p.limit ?? 50) || 50, { gateLocked: true }));
824
1511
  return { messages };
825
1512
  }
826
- if (method === 'group.ack_messages' && this._clientUsesV2Group() && p.group_id) {
1513
+ if (method === 'group.ack_messages' || method === 'group.v2.ack') {
1514
+ if (!String(p.group_id ?? '').trim()) {
1515
+ throw new ValidationError('group.ack_messages requires group_id');
1516
+ }
827
1517
  await this._ensureV2SessionReady('group.ack_messages');
828
- return await this.ackGroupV2(String(p.group_id), Number(p.seq ?? p.msg_seq ?? p.up_to_seq ?? 0) || undefined);
1518
+ return await runWithRpcPriority(() => this.ackGroupV2(String(p.group_id), Number(p.seq ?? p.msg_seq ?? p.up_to_seq ?? 0) || undefined));
1519
+ }
1520
+ if (method === 'message.pull') {
1521
+ delete p._skip_auto_ack;
1522
+ delete p.skip_auto_ack;
829
1523
  }
830
1524
  // 关键操作自动附加客户端签名
831
1525
  if (SIGNED_METHODS.has(method)) {
832
- this._signClientOperation(method, p);
1526
+ if (this._shouldSkipClientSignature(method, p)) {
1527
+ delete p.client_signature;
1528
+ }
1529
+ else {
1530
+ this._signClientOperation(method, p);
1531
+ }
833
1532
  }
834
1533
  // P1-23: 非幂等方法使用更长超时
835
1534
  const callTimeout = NON_IDEMPOTENT_METHODS.has(method) ? NON_IDEMPOTENT_TIMEOUT_MS : undefined;
1535
+ if (method === 'group.thought.get' || method === 'message.thought.get') {
1536
+ this._clientLog.debug(`thought.get transport call start: method=${method}, params=${this._debugJson(this._messageEnvelopeFieldsForDebug(p))}`);
1537
+ }
836
1538
  let result = callTimeout
837
- ? await this._transport.call(method, p, callTimeout)
838
- : await this._transport.call(method, p);
1539
+ ? (rpcBackground
1540
+ ? await this._transport.call(method, p, callTimeout, undefined, true)
1541
+ : await this._transport.call(method, p, callTimeout))
1542
+ : (rpcBackground
1543
+ ? await this._transport.call(method, p, undefined, undefined, true)
1544
+ : await this._transport.call(method, p));
839
1545
  if (method === 'group.thought.get' && isJsonObject(result)) {
1546
+ this._clientLog.debug(`group.thought.get transport result: found=${String(result.found ?? '')}, raw_count=${Array.isArray(result.thoughts) ? result.thoughts.length : 0}`);
840
1547
  result = await this._decryptGroupThoughts(result);
841
1548
  }
842
1549
  if (method === 'message.thought.get' && isJsonObject(result)) {
1550
+ this._clientLog.debug(`message.thought.get transport result: found=${String(result.found ?? '')}, raw_count=${Array.isArray(result.thoughts) ? result.thoughts.length : 0}`);
843
1551
  result = await this._decryptMessageThoughts(result);
844
1552
  }
845
1553
  // ── V2-only 群状态编排:成员变更后 propose+confirm state。
@@ -919,6 +1627,34 @@ export class AUNClient {
919
1627
  this._clientLog.debug(`on exit: elapsed=${Date.now() - tStart}ms event=${event}`);
920
1628
  return result;
921
1629
  }
1630
+ async _callRawV2Rpc(method, params) {
1631
+ const p = { ...(params ?? {}) };
1632
+ const rpcBackground = Boolean(p._rpc_background) || this._backgroundRpcDepth > 0;
1633
+ delete p._rpc_background;
1634
+ delete p._pull_gate_locked;
1635
+ delete p._skip_auto_ack;
1636
+ delete p.skip_auto_ack;
1637
+ if (method.startsWith('group.') && p.group_id !== undefined && p.group_id !== null) {
1638
+ p.group_id = normalizeGroupId(String(p.group_id)) || String(p.group_id);
1639
+ }
1640
+ if (method.startsWith('group.') && p.device_id === undefined) {
1641
+ p.device_id = this._deviceId;
1642
+ }
1643
+ if (method.startsWith('group.') && p.slot_id === undefined) {
1644
+ p.slot_id = this._slotId;
1645
+ }
1646
+ if (SIGNED_METHODS.has(method)) {
1647
+ if (this._shouldSkipClientSignature(method, p)) {
1648
+ delete p.client_signature;
1649
+ }
1650
+ else {
1651
+ this._signClientOperation(method, p);
1652
+ }
1653
+ }
1654
+ return rpcBackground
1655
+ ? await this._transport.call(method, p, undefined, undefined, true)
1656
+ : await this._transport.call(method, p);
1657
+ }
922
1658
  /** P2-13: 取消订阅事件(对齐 Python/JS off 方法) */
923
1659
  off(event, handler) {
924
1660
  const tStart = Date.now();
@@ -988,6 +1724,7 @@ export class AUNClient {
988
1724
  async _onRawMessageReceived(data) {
989
1725
  const tStart = Date.now();
990
1726
  if (isJsonObject(data)) {
1727
+ this._logMessageDebug('server-push', '_raw.message.received', 'message.received', data);
991
1728
  this._clientLog.debug(`_onRawMessageReceived enter: from=${String(data.from ?? '')}, message_id=${String(data.message_id ?? '')}, seq=${String(data.seq ?? '')}`);
992
1729
  }
993
1730
  else {
@@ -1006,7 +1743,8 @@ export class AUNClient {
1006
1743
  timestamp: data.timestamp,
1007
1744
  _decrypt_error: String(exc),
1008
1745
  };
1009
- this._publishAppEvent('message.undecryptable', safeEvent).catch(() => { });
1746
+ this._attachV2EnvelopeMetadataFromSource(safeEvent, data);
1747
+ Promise.resolve(this._publishAppEvent('message.undecryptable', safeEvent)).catch(() => { });
1010
1748
  }
1011
1749
  });
1012
1750
  this._clientLog.debug(`_onRawMessageReceived exit: elapsed=${Date.now() - tStart}ms (handler dispatched)`);
@@ -1014,47 +1752,53 @@ export class AUNClient {
1014
1752
  /** 实际处理推送消息的异步任务 */
1015
1753
  async _processAndPublishMessage(data) {
1016
1754
  if (!isJsonObject(data)) {
1017
- await this._publishAppEvent('message.received', data);
1755
+ await this._publishAppEvent('message.received', data, 'push');
1018
1756
  return;
1019
1757
  }
1020
1758
  const msg = { ...data };
1021
1759
  if (!this._messageTargetsCurrentInstance(msg)) {
1760
+ this._clientLog.debug(`P2P push filtered by instance: message_id=${String(msg.message_id ?? '')}, seq=${String(msg.seq ?? '')}, target_device=${String(msg.device_id ?? '')}, target_slot=${String(msg.slot_id ?? '')}, local_device=${this._deviceId}, local_slot=${this._slotId}`);
1022
1761
  return;
1023
1762
  }
1024
1763
  // P2P 空洞检测
1025
1764
  const seq = msg.seq;
1026
1765
  if (seq !== undefined && seq !== null && this._aid) {
1027
1766
  const ns = `p2p:${this._aid}`;
1028
- const needPull = this._seqTracker.onMessageSeq(ns, seq);
1767
+ // Push 只先更新 maxSeenSeq;contiguous_seq 是已交付游标,必须等应用层发布返回后再推进。
1768
+ if (seq > 0)
1769
+ this._seqTracker.updateMaxSeen(ns, seq);
1770
+ const contigBefore = this._seqTracker.getContiguousSeq(ns);
1771
+ const published = await this._publishOrderedMessage('message.received', ns, seq, msg);
1772
+ const contigAfter = this._seqTracker.getContiguousSeq(ns);
1773
+ const needPull = Number(seq) > contigAfter && !published;
1029
1774
  if (needPull) {
1030
- this._clientLog.debug(`P2P seq gap detected: ns=${ns}, seq=${seq}, contiguous=${this._seqTracker.getContiguousSeq(ns)}`);
1775
+ this._clientLog.debug(`P2P seq gap detected: ns=${ns}, seq=${seq}, contiguous=${contigAfter}`);
1031
1776
  this._fillP2pGap().catch(exc => this._clientLog.warn(`background gap fill trigger failed: ${formatCaughtError(exc)}`));
1032
1777
  }
1033
1778
  // auto-ack contiguous_seq
1034
1779
  const contig = this._seqTracker.getContiguousSeq(ns);
1035
1780
  if (contig > 0) {
1036
- this._transport.call('message.ack', {
1037
- seq: contig,
1038
- device_id: this._deviceId,
1039
- slot_id: this._slotId,
1040
- }).catch((e) => { this._clientLog.debug(`P2P auto-ack failed: ${formatCaughtError(e)}`); });
1781
+ const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
1782
+ const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
1783
+ this._clientLog.debug(`P2P push auto-ack send: ns=${ns}, seq=${ackSeq}, contiguous=${contig}, max_seen=${maxSeen}`);
1784
+ this._withBackgroundRpc(() => this.ackV2(ackSeq))
1785
+ .then(() => { this._clientLog.debug(`P2P push auto-ack ok: ns=${ns}, seq=${ackSeq}`); })
1786
+ .catch((e) => { this._clientLog.debug(`P2P auto-ack failed: ${formatCaughtError(e)}`); });
1041
1787
  }
1042
1788
  // 即时持久化 cursor,异常断连后不回退
1043
- this._saveSeqTrackerState();
1044
- }
1045
- // V2-only:普通 _raw.message.received 只承载明文;V2 密文由 peer.v2.message_received 通知触发 pull。
1046
- if (seq !== undefined && seq !== null && this._aid) {
1047
- const ns = `p2p:${this._aid}`;
1048
- await this._publishOrderedMessage('message.received', ns, seq, msg);
1789
+ if (contigAfter !== contigBefore)
1790
+ this._saveSeqTrackerState();
1049
1791
  }
1050
1792
  else {
1051
- await this._publishAppEvent('message.received', msg);
1793
+ // V2-only:普通 _raw.message.received 只承载明文;V2 密文由 peer.v2.message_received 通知触发 pull。
1794
+ await this._publishAppEvent('message.received', msg, 'push');
1052
1795
  }
1053
1796
  }
1054
1797
  /** 处理群组消息推送:自动解密后 re-publish */
1055
1798
  async _onRawGroupMessageCreated(data) {
1056
1799
  const tStart = Date.now();
1057
1800
  if (isJsonObject(data)) {
1801
+ this._logMessageDebug('server-push', '_raw.group.message_created', 'group.message_created', data);
1058
1802
  this._clientLog.debug(`_onRawGroupMessageCreated enter: group_id=${String(data.group_id ?? '')}, message_id=${String(data.message_id ?? '')}, seq=${String(data.seq ?? '')}`);
1059
1803
  }
1060
1804
  else {
@@ -1072,7 +1816,8 @@ export class AUNClient {
1072
1816
  timestamp: data.timestamp,
1073
1817
  _decrypt_error: String(exc),
1074
1818
  };
1075
- this._publishAppEvent('group.message_undecryptable', safeEvent).catch(() => { });
1819
+ this._attachV2EnvelopeMetadataFromSource(safeEvent, data);
1820
+ Promise.resolve(this._publishAppEvent('group.message_undecryptable', safeEvent)).catch(() => { });
1076
1821
  }
1077
1822
  });
1078
1823
  this._clientLog.debug(`_onRawGroupMessageCreated exit: elapsed=${Date.now() - tStart}ms (handler dispatched)`);
@@ -1085,7 +1830,7 @@ export class AUNClient {
1085
1830
  */
1086
1831
  async _processAndPublishGroupMessage(data) {
1087
1832
  if (!isJsonObject(data)) {
1088
- await this._publishAppEvent('group.message_created', data);
1833
+ await this._publishAppEvent('group.message_created', data, 'group-push');
1089
1834
  return;
1090
1835
  }
1091
1836
  const msg = { ...data };
@@ -1098,125 +1843,88 @@ export class AUNClient {
1098
1843
  if (payload === undefined || payload === null
1099
1844
  || (typeof payload === 'object' && Object.keys(payload).length === 0)) {
1100
1845
  // 不带 payload 的通知不能先推进 seq,否则 auto-pull 会用推进后的 cursor 跳过该消息。
1101
- await this._autoPullGroupMessages(msg);
1846
+ void this._autoPullGroupMessages(msg).catch((exc) => {
1847
+ this._clientLog.warn(`auto pull group message task failed: ${formatCaughtError(exc)}`);
1848
+ });
1102
1849
  return;
1103
1850
  }
1104
1851
  if (groupId && seq !== undefined && seq !== null) {
1105
1852
  const ns = `group:${groupId}`;
1106
- const needPull = this._seqTracker.onMessageSeq(ns, seq);
1853
+ // Push 只先更新 maxSeenSeq;contiguous_seq 是已交付游标,必须等应用层发布返回后再推进。
1854
+ if (seq > 0)
1855
+ this._seqTracker.updateMaxSeen(ns, seq);
1856
+ const contigBefore = this._seqTracker.getContiguousSeq(ns);
1857
+ const published = await this._publishOrderedMessage('group.message_created', ns, seq, msg);
1858
+ const contigAfter = this._seqTracker.getContiguousSeq(ns);
1859
+ const needPull = Number(seq) > contigAfter && !published;
1107
1860
  if (needPull) {
1108
- this._clientLog.debug(`group message seq gap detected: group=${groupId}, seq=${seq}, contiguous=${this._seqTracker.getContiguousSeq(ns)}`);
1861
+ this._clientLog.debug(`group message seq gap detected: group=${groupId}, seq=${seq}, contiguous=${contigAfter}`);
1109
1862
  this._fillGroupGap(groupId).catch(exc => this._clientLog.warn(`background gap fill trigger failed: ${formatCaughtError(exc)}`));
1110
1863
  }
1111
1864
  const contig = this._seqTracker.getContiguousSeq(ns);
1112
1865
  if (contig > 0) {
1113
- this._transport.call('group.ack_messages', {
1114
- group_id: groupId,
1115
- msg_seq: contig,
1116
- device_id: this._deviceId,
1117
- slot_id: this._slotId,
1118
- }).catch((e) => { this._clientLog.debug(`group message auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
1119
- }
1120
- this._saveSeqTrackerState();
1121
- }
1122
- // V2-only:普通 group.message_created 只承载明文;V2 密文由 group.v2.message_created 通知触发 pull。
1123
- if (groupId && seq !== undefined && seq !== null) {
1124
- const nsKey = `group:${groupId}`;
1125
- await this._publishOrderedMessage('group.message_created', nsKey, seq, msg);
1866
+ const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
1867
+ const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
1868
+ this._clientLog.debug(`group push auto-ack send: group=${groupId}, ns=${ns}, seq=${ackSeq}, contiguous=${contig}, max_seen=${maxSeen}`);
1869
+ this._withBackgroundRpc(() => this.ackGroupV2(groupId, ackSeq))
1870
+ .then(() => { this._clientLog.debug(`group push auto-ack ok: group=${groupId}, seq=${ackSeq}`); })
1871
+ .catch((e) => { this._clientLog.debug(`group message auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
1872
+ }
1873
+ if (contigAfter !== contigBefore)
1874
+ this._saveSeqTrackerState();
1126
1875
  }
1127
1876
  else {
1128
- await this._publishAppEvent('group.message_created', msg);
1877
+ // V2-only:普通 group.message_created 只承载明文;V2 密文由 group.v2.message_created 通知触发 pull。
1878
+ await this._publishAppEvent('group.message_created', msg, 'group-push');
1129
1879
  }
1130
1880
  }
1131
1881
  /** 收到不带 payload 的 group.message_created 通知后,自动 pull 最新消息 */
1132
1882
  async _autoPullGroupMessages(notification) {
1133
- const groupId = (notification.group_id ?? '');
1883
+ let groupId = String(notification.group_id ?? '').trim();
1134
1884
  if (!groupId) {
1135
1885
  await this._publishAppEvent('group.message_created', notification);
1136
1886
  return;
1137
1887
  }
1888
+ groupId = normalizeGroupId(groupId) || groupId;
1138
1889
  const ns = `group:${groupId}`;
1139
1890
  const afterSeq = this._seqTracker.getContiguousSeq(ns);
1140
- try {
1141
- // V2-only 模式:走 group.v2.pull(合并 V1 明文 + V2 密文并自动解密)
1142
- if (this._v2Session) {
1143
- await this._pullGroupV2Internal({ group_id: groupId, after_seq: afterSeq, limit: 50 });
1144
- return;
1145
- }
1146
- const result = await this.call('group.pull', {
1147
- group_id: groupId,
1148
- after_message_seq: afterSeq,
1149
- device_id: this._deviceId,
1150
- limit: 50,
1151
- });
1152
- if (isJsonObject(result)) {
1153
- const messages = result.messages;
1154
- if (Array.isArray(messages)) {
1155
- // onPullResult 已在 call() 拦截器中调用,此处不再重复
1156
- const pushed = this._pushedSeqs.get(ns);
1157
- for (const msg of messages) {
1158
- if (isJsonObject(msg)) {
1159
- const s = msg.seq;
1160
- if (pushed && s !== undefined && s !== null && pushed.has(s)) {
1161
- continue; // 已发布到应用层,跳过
1162
- }
1163
- if (s !== undefined && s !== null) {
1164
- await this._publishPulledMessage('group.message_created', ns, s, msg);
1165
- }
1166
- else {
1167
- await this._publishAppEvent('group.message_created', msg);
1168
- }
1169
- }
1170
- }
1171
- this._prunePushedSeqs(ns);
1172
- return;
1173
- }
1174
- }
1175
- }
1176
- catch (exc) {
1177
- this._clientLog.debug(`auto pull group messages failed: ${formatCaughtError(exc)}`);
1891
+ this._clientLog.debug(`auto pull group messages start: group=${groupId}, after_seq=${afterSeq}, seq=${String(notification.seq ?? '')}`);
1892
+ const started = await this._tryRunBackgroundPull(ns, async () => {
1893
+ const pullAfterSeq = this._seqTracker.getContiguousSeq(ns);
1894
+ const messages = await this.pullGroupV2(groupId, pullAfterSeq, 50, { gateLocked: true });
1895
+ this._prunePushedSeqs(ns);
1896
+ return messages.length;
1897
+ }, true);
1898
+ if (!started) {
1899
+ this._clientLog.debug(`auto pull group messages skipped: pull in-flight group=${groupId}`);
1178
1900
  }
1179
- await this._publishAppEvent('group.message_created', notification);
1180
1901
  }
1181
1902
  /** 后台补齐群消息空洞 */
1182
1903
  async _fillGroupGap(groupId) {
1904
+ groupId = normalizeGroupId(groupId) || String(groupId ?? '').trim();
1905
+ if (!groupId)
1906
+ return;
1183
1907
  const ns = `group:${groupId}`;
1184
1908
  const afterSeq = this._seqTracker.getContiguousSeq(ns);
1185
1909
  // 去重:同一 (group:id:after_seq) 只补一次
1186
1910
  const dedupKey = `group_msg:${groupId}:${afterSeq}`;
1187
1911
  if (this._gapFillDone.has(dedupKey))
1188
1912
  return;
1913
+ const token = this._tryAcquirePullGate(ns);
1914
+ if (token === null) {
1915
+ this._clientLog.debug(`group message gap fill skipped: pull in-flight group=${groupId}`);
1916
+ return;
1917
+ }
1189
1918
  this._gapFillDone.set(dedupKey, Date.now());
1190
1919
  this._clientLog.debug(`group message gap fill start: group=${groupId}, after_seq=${afterSeq}`);
1920
+ let filled = 0;
1191
1921
  try {
1192
- const result = await this.call('group.pull', {
1193
- group_id: groupId,
1194
- after_message_seq: afterSeq,
1195
- device_id: this._deviceId,
1196
- limit: 50,
1197
- });
1198
- let filled = 0;
1199
- if (isJsonObject(result)) {
1200
- const messages = result.messages;
1201
- if (Array.isArray(messages)) {
1202
- // onPullResult 已在 call() 拦截器中调用,此处不再重复
1203
- const pushed = this._pushedSeqs.get(ns);
1204
- for (const msg of messages) {
1205
- if (isJsonObject(msg)) {
1206
- const s = msg.seq;
1207
- if (pushed && s !== undefined && s !== null && pushed.has(s))
1208
- continue;
1209
- if (s !== undefined && s !== null) {
1210
- await this._publishPulledMessage('group.message_created', ns, s, msg);
1211
- }
1212
- else {
1213
- await this._publishAppEvent('group.message_created', msg);
1214
- }
1215
- filled += 1;
1216
- }
1217
- }
1218
- this._prunePushedSeqs(ns);
1219
- }
1922
+ const messages = await this._withBackgroundRpc(() => this.pullGroupV2(groupId, afterSeq, 50, { gateLocked: true }));
1923
+ filled = messages.length;
1924
+ this._prunePushedSeqs(ns);
1925
+ if (this._seqTracker.getContiguousSeq(ns) !== afterSeq) {
1926
+ await this._drainOrderedMessages(ns, undefined, true);
1927
+ this._saveSeqTrackerState();
1220
1928
  }
1221
1929
  this._clientLog.debug(`group message gap fill done: group=${groupId}, after_seq=${afterSeq}, filled=${filled}`);
1222
1930
  }
@@ -1225,6 +1933,10 @@ export class AUNClient {
1225
1933
  }
1226
1934
  finally {
1227
1935
  this._gapFillDone.delete(dedupKey);
1936
+ this._releasePullGate(ns, token);
1937
+ if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
1938
+ void this._fillGroupGap(groupId);
1939
+ }
1228
1940
  }
1229
1941
  }
1230
1942
  /** 后台补齐 P2P 消息空洞 */
@@ -1237,35 +1949,25 @@ export class AUNClient {
1237
1949
  const dedupKey = `p2p:${afterSeq}`;
1238
1950
  if (this._gapFillDone.has(dedupKey))
1239
1951
  return;
1952
+ const token = this._tryAcquirePullGate(ns);
1953
+ if (token === null) {
1954
+ this._clientLog.debug(`P2P message gap fill skipped: pull in-flight ns=${ns}`);
1955
+ return;
1956
+ }
1240
1957
  this._gapFillDone.set(dedupKey, Date.now());
1241
1958
  this._clientLog.debug(`P2P message gap fill start: after_seq=${afterSeq}`);
1959
+ let filled = 0;
1242
1960
  try {
1243
- const result = await this.call('message.pull', {
1244
- after_seq: afterSeq,
1245
- limit: 50,
1246
- });
1247
- let filled = 0;
1248
- if (isJsonObject(result)) {
1249
- const messages = result.messages;
1250
- if (Array.isArray(messages)) {
1251
- // onPullResult 已在 call() 拦截器中调用,此处不再重复
1252
- const pushed = this._pushedSeqs.get(ns);
1253
- for (const msg of messages) {
1254
- if (isJsonObject(msg)) {
1255
- const s = msg.seq;
1256
- if (pushed && s !== undefined && s !== null && pushed.has(s))
1257
- continue;
1258
- if (s !== undefined && s !== null) {
1259
- await this._publishPulledMessage('message.received', ns, s, msg);
1260
- }
1261
- else {
1262
- await this._publishAppEvent('message.received', msg);
1263
- }
1264
- filled += 1;
1265
- }
1266
- }
1267
- this._prunePushedSeqs(ns);
1268
- }
1961
+ const messages = await this._withBackgroundRpc(() => this.pullV2(afterSeq, 50, { skipAutoAck: true, gateLocked: true }));
1962
+ filled = messages.length;
1963
+ this._prunePushedSeqs(ns);
1964
+ if (this._seqTracker.getContiguousSeq(ns) !== afterSeq) {
1965
+ await this._drainOrderedMessages(ns, undefined, true);
1966
+ this._saveSeqTrackerState();
1967
+ }
1968
+ const contig = this._seqTracker.getContiguousSeq(ns);
1969
+ if (contig > 0 && contig !== afterSeq) {
1970
+ await this._withBackgroundRpc(() => this.ackV2(contig));
1269
1971
  }
1270
1972
  this._clientLog.debug(`P2P message gap fill done: after_seq=${afterSeq}, filled=${filled}`);
1271
1973
  }
@@ -1274,6 +1976,10 @@ export class AUNClient {
1274
1976
  }
1275
1977
  finally {
1276
1978
  this._gapFillDone.delete(dedupKey);
1979
+ this._releasePullGate(ns, token);
1980
+ if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
1981
+ void this._fillP2pGap();
1982
+ }
1277
1983
  }
1278
1984
  }
1279
1985
  /** 只按硬上限裁剪 published guard,不能按 contiguousSeq 清理。 */
@@ -1321,10 +2027,10 @@ export class AUNClient {
1321
2027
  if (!isJsonObject(payload))
1322
2028
  return payload;
1323
2029
  const result = { ...payload };
1324
- if (this._deviceId && !String(result.device_id ?? '').trim()) {
2030
+ if (!('device_id' in result)) {
1325
2031
  result.device_id = this._deviceId;
1326
2032
  }
1327
- if (this._slotId && !String(result.slot_id ?? '').trim()) {
2033
+ if (!('slot_id' in result)) {
1328
2034
  result.slot_id = this._slotId;
1329
2035
  }
1330
2036
  return result;
@@ -1334,10 +2040,11 @@ export class AUNClient {
1334
2040
  return payload;
1335
2041
  return this._attachCurrentInstanceContext(payload);
1336
2042
  }
1337
- async _publishAppEvent(event, payload) {
2043
+ _publishAppEvent(event, payload, source = 'direct') {
1338
2044
  if ((event === 'message.received' || event === 'group.message_created') && isJsonObject(payload)) {
1339
2045
  this._maybeAppendEchoTraceReceive(payload);
1340
2046
  }
2047
+ this._logAppMessagePublish(event, payload, source);
1341
2048
  // 注入本地/远端 agent.md etag,让应用层判断版本一致性;失败不影响业务。
1342
2049
  if (isJsonObject(payload)) {
1343
2050
  try {
@@ -1357,7 +2064,7 @@ export class AUNClient {
1357
2064
  this._clientLog.debug(`agent_md etag inject skipped: ${err instanceof Error ? err.message : String(err)}`);
1358
2065
  }
1359
2066
  }
1360
- await this._dispatcher.publish(event, this._normalizePublishedMessagePayload(event, payload));
2067
+ return this._dispatcher.publishSyncAware(event, this._normalizePublishedMessagePayload(event, payload));
1361
2068
  }
1362
2069
  _echoTimestamp() {
1363
2070
  const now = new Date();
@@ -1383,6 +2090,18 @@ export class AUNClient {
1383
2090
  const trace = `${this._echoTimestamp()} [AUN-SDK.send] aid=${this._aid ?? '-'} conn_uptime=${uptime}s`;
1384
2091
  params.payload = { ...payload, text: payload.text + '\n' + trace };
1385
2092
  }
2093
+ _shouldSkipClientSignature(method, params) {
2094
+ if (method !== 'message.send' && method !== 'group.send')
2095
+ return false;
2096
+ if (params.encrypted || params.encrypt)
2097
+ return false;
2098
+ return this._isEchoPayload(params.payload);
2099
+ }
2100
+ _shouldSkipEventSignature(event) {
2101
+ if (event.encrypted || event.encrypt)
2102
+ return false;
2103
+ return this._isEchoPayload(event.payload);
2104
+ }
1386
2105
  _maybeAppendEchoTraceReceive(msg) {
1387
2106
  if (msg.encrypted)
1388
2107
  return;
@@ -1393,34 +2112,379 @@ export class AUNClient {
1393
2112
  const trace = `${this._echoTimestamp()} [AUN-SDK.receive] aid=${this._aid ?? '-'} conn_uptime=${uptime}s`;
1394
2113
  msg.payload = { ...payload, text: payload.text + '\n' + trace };
1395
2114
  }
2115
+ _debugJson(value) {
2116
+ const seen = new WeakSet();
2117
+ try {
2118
+ return JSON.stringify(value, (_key, item) => {
2119
+ if (typeof item === 'bigint')
2120
+ return item.toString();
2121
+ if (item instanceof Uint8Array) {
2122
+ return {
2123
+ _type: item.constructor.name,
2124
+ len: item.byteLength,
2125
+ base64: Buffer.from(item).toString('base64'),
2126
+ };
2127
+ }
2128
+ if (item && typeof item === 'object') {
2129
+ if (seen.has(item))
2130
+ return '[Circular]';
2131
+ seen.add(item);
2132
+ }
2133
+ return item;
2134
+ });
2135
+ }
2136
+ catch {
2137
+ return String(value);
2138
+ }
2139
+ }
2140
+ _messagePayloadForDebug(message) {
2141
+ if (!isJsonObject(message))
2142
+ return message;
2143
+ const msg = message;
2144
+ if ('payload' in msg)
2145
+ return msg.payload;
2146
+ if ('content' in msg)
2147
+ return msg.content;
2148
+ if (typeof msg.envelope_json === 'string' && msg.envelope_json) {
2149
+ try {
2150
+ return JSON.parse(msg.envelope_json);
2151
+ }
2152
+ catch {
2153
+ return msg.envelope_json;
2154
+ }
2155
+ }
2156
+ if (isJsonObject(msg.legacy_v1)) {
2157
+ const legacy = msg.legacy_v1;
2158
+ if ('payload' in legacy)
2159
+ return legacy.payload;
2160
+ if ('content' in legacy)
2161
+ return legacy.content;
2162
+ }
2163
+ return null;
2164
+ }
2165
+ _messageEnvelopeFieldsForDebug(message) {
2166
+ if (!isJsonObject(message)) {
2167
+ return { value_type: typeof message };
2168
+ }
2169
+ const msg = message;
2170
+ const keys = [
2171
+ 'message_id', 'id', 'from', 'from_aid', 'sender_aid', 'to', 'to_aid',
2172
+ 'group_id', 'seq', 'msg_seq', 'type', 'version', 'timestamp', 't_server',
2173
+ 'device_id', 'slot_id', 'encrypted', 'dispatch_mode', 'dispatch',
2174
+ 'e2ee', 'headers', 'protected_headers', 'context', 'status',
2175
+ '_decrypt_error', '_decrypt_stage',
2176
+ ];
2177
+ const out = {};
2178
+ for (const key of keys) {
2179
+ if (Object.prototype.hasOwnProperty.call(msg, key))
2180
+ out[key] = msg[key];
2181
+ }
2182
+ return out;
2183
+ }
2184
+ _logMessageDebug(stage, source, event, message, opts = {}) {
2185
+ // 关键消息链路诊断日志长期保留在代码中;是否输出由 logger 的 debug/level 控制。
2186
+ const record = {
2187
+ stage,
2188
+ source,
2189
+ event,
2190
+ envelope: this._messageEnvelopeFieldsForDebug(message),
2191
+ payload: opts.payloadOverride !== undefined ? opts.payloadOverride : this._messagePayloadForDebug(message),
2192
+ };
2193
+ if (opts.extra)
2194
+ record.extra = opts.extra;
2195
+ this._clientLog.debug(`message.debug ${this._debugJson(record)}`);
2196
+ }
2197
+ _logAppMessagePublish(event, payload, source) {
2198
+ if (!['message.received', 'message.undecryptable', 'group.message_created', 'group.message_undecryptable'].includes(event)) {
2199
+ return;
2200
+ }
2201
+ this._logMessageDebug('publish', source, event, payload);
2202
+ }
1396
2203
  _messageTargetsCurrentInstance(message) {
1397
2204
  if (!isJsonObject(message))
1398
2205
  return true;
1399
- const targetDeviceId = String(message.device_id ?? '').trim();
1400
- if (targetDeviceId && this._deviceId && targetDeviceId !== this._deviceId) {
2206
+ if ('device_id' in message) {
2207
+ const targetDeviceId = String(message.device_id ?? '').trim();
2208
+ if (targetDeviceId !== this._deviceId) {
2209
+ return false;
2210
+ }
2211
+ }
2212
+ if ('slot_id' in message) {
2213
+ const targetSlotId = String(message.slot_id ?? '').trim();
2214
+ if (targetSlotId !== this._slotId) {
2215
+ return false;
2216
+ }
2217
+ }
2218
+ return true;
2219
+ }
2220
+ _tryAcquirePullGate(key) {
2221
+ if (!key)
2222
+ return 0;
2223
+ const now = Date.now();
2224
+ const gate = this._pullGates.get(key) ?? { inflight: false, startedAt: 0, token: 0 };
2225
+ if (gate.inflight && now - gate.startedAt <= AUNClient.PULL_GATE_STALE_MS) {
2226
+ return null;
2227
+ }
2228
+ if (gate.inflight) {
2229
+ this._clientLog.warn(`pull in-flight stale reset: key=${key} age=${now - gate.startedAt}ms`);
2230
+ }
2231
+ gate.token += 1;
2232
+ gate.inflight = true;
2233
+ gate.startedAt = now;
2234
+ this._pullGates.set(key, gate);
2235
+ return gate.token;
2236
+ }
2237
+ _releasePullGate(key, token) {
2238
+ if (!key || token == null)
2239
+ return;
2240
+ const gate = this._pullGates.get(key);
2241
+ if (!gate || gate.token !== token)
2242
+ return;
2243
+ gate.inflight = false;
2244
+ gate.startedAt = 0;
2245
+ }
2246
+ _pullGateKeyForCall(method, params) {
2247
+ if (method === 'message.pull' || method === 'message.v2.pull') {
2248
+ return this._aid ? `p2p:${this._aid}` : '';
2249
+ }
2250
+ if ((method === 'group.pull' || method === 'group.v2.pull') && String(params.group_id ?? '').trim()) {
2251
+ return `group:${String(params.group_id ?? '').trim()}`;
2252
+ }
2253
+ if (method === 'group.pull_events' && String(params.group_id ?? '').trim()) {
2254
+ return `group_event:${String(params.group_id ?? '').trim()}`;
2255
+ }
2256
+ return '';
2257
+ }
2258
+ _isPullResponseProcessing(key) {
2259
+ if (!key)
1401
2260
  return false;
2261
+ return (this._pullResponseKeys.get(key) ?? 0) > 0;
2262
+ }
2263
+ _emptyPullResultForCall(method) {
2264
+ if (method === 'group.pull_events')
2265
+ return { events: [], count: 0 };
2266
+ if (method === 'message.pull' || method === 'message.v2.pull' || method === 'group.pull' || method === 'group.v2.pull') {
2267
+ return { messages: [], count: 0 };
2268
+ }
2269
+ return {};
2270
+ }
2271
+ _withPullResponseProcessing(key, fn) {
2272
+ if (!key)
2273
+ return fn();
2274
+ this._pullResponseKeys.set(key, (this._pullResponseKeys.get(key) ?? 0) + 1);
2275
+ const release = () => {
2276
+ const next = (this._pullResponseKeys.get(key) ?? 1) - 1;
2277
+ if (next <= 0) {
2278
+ this._pullResponseKeys.delete(key);
2279
+ }
2280
+ else {
2281
+ this._pullResponseKeys.set(key, next);
2282
+ }
2283
+ };
2284
+ try {
2285
+ const result = fn();
2286
+ if (isPromiseLike(result)) {
2287
+ return Promise.resolve(result).finally(release);
2288
+ }
2289
+ release();
2290
+ return result;
2291
+ }
2292
+ catch (exc) {
2293
+ release();
2294
+ throw exc;
2295
+ }
2296
+ }
2297
+ _pullResultCount(result) {
2298
+ if (Array.isArray(result))
2299
+ return result.length;
2300
+ if (!isJsonObject(result))
2301
+ return 0;
2302
+ const obj = result;
2303
+ const rawCount = Number(obj.raw_count ?? 0);
2304
+ if (Number.isFinite(rawCount) && rawCount > 0)
2305
+ return rawCount;
2306
+ if (Array.isArray(obj.messages))
2307
+ return obj.messages.length;
2308
+ if (Array.isArray(obj.events))
2309
+ return obj.events.length;
2310
+ return 0;
2311
+ }
2312
+ _nextPullParams(method, params) {
2313
+ const next = { ...params };
2314
+ delete next._pull_gate_locked;
2315
+ if (method === 'message.pull' || method === 'message.v2.pull') {
2316
+ if (!this._aid)
2317
+ return null;
2318
+ next.after_seq = this._seqTracker.getContiguousSeq(`p2p:${this._aid}`);
2319
+ return next;
2320
+ }
2321
+ if (method === 'group.pull' || method === 'group.v2.pull') {
2322
+ const groupId = normalizeGroupId(String(next.group_id ?? '').trim()) || String(next.group_id ?? '').trim();
2323
+ if (!groupId)
2324
+ return null;
2325
+ next.group_id = groupId;
2326
+ next.after_seq = this._seqTracker.getContiguousSeq(`group:${groupId}`);
2327
+ delete next.after_message_seq;
2328
+ return next;
2329
+ }
2330
+ if (method === 'group.pull_events') {
2331
+ const groupId = normalizeGroupId(String(next.group_id ?? '').trim()) || String(next.group_id ?? '').trim();
2332
+ if (!groupId)
2333
+ return null;
2334
+ next.group_id = groupId;
2335
+ next.after_event_seq = this._seqTracker.getContiguousSeq(`group_event:${groupId}`);
2336
+ return next;
2337
+ }
2338
+ return null;
2339
+ }
2340
+ _pullRequestAfter(method, params) {
2341
+ if (method === 'message.pull' || method === 'message.v2.pull')
2342
+ return Number(params.after_seq ?? 0) || 0;
2343
+ if (method === 'group.pull' || method === 'group.v2.pull')
2344
+ return Number(params.after_seq ?? params.after_message_seq ?? 0) || 0;
2345
+ if (method === 'group.pull_events')
2346
+ return Number(params.after_event_seq ?? 0) || 0;
2347
+ return 0;
2348
+ }
2349
+ _pullRetentionFloor(result, topLevelKey, cursorKey) {
2350
+ const values = [Number(result[topLevelKey] ?? 0)];
2351
+ const cursor = isJsonObject(result.cursor) ? result.cursor : null;
2352
+ if (cursor) {
2353
+ values.push(Number(cursor[cursorKey] ?? 0));
2354
+ values.push(Number(cursor.retention_floor_seq ?? 0));
2355
+ }
2356
+ return Math.max(0, ...values.filter((value) => Number.isFinite(value)));
2357
+ }
2358
+ _schedulePullFollowup(method, params, result) {
2359
+ if (method === 'message.pull')
2360
+ method = 'message.v2.pull';
2361
+ else if (method === 'group.pull')
2362
+ method = 'group.v2.pull';
2363
+ if (this._pullResultCount(result) <= 0)
2364
+ return;
2365
+ const next = this._nextPullParams(method, params);
2366
+ if (!next)
2367
+ return;
2368
+ if (this._pullRequestAfter(method, next) <= this._pullRequestAfter(method, params))
2369
+ return;
2370
+ void (async () => {
2371
+ try {
2372
+ await this._withBackgroundRpc(async () => {
2373
+ if (method === 'message.pull' || method === 'message.v2.pull') {
2374
+ await this.pullV2(Number(next.after_seq ?? 0) || 0, Number(next.limit ?? 50) || 50);
2375
+ return;
2376
+ }
2377
+ if (method === 'group.pull' || method === 'group.v2.pull') {
2378
+ const groupId = String(next.group_id ?? '').trim();
2379
+ if (!groupId)
2380
+ return;
2381
+ await this.pullGroupV2(groupId, Number(next.after_seq ?? next.after_message_seq ?? 0) || 0, Number(next.limit ?? 50) || 50);
2382
+ return;
2383
+ }
2384
+ await this.call(method, next);
2385
+ });
2386
+ }
2387
+ catch (exc) {
2388
+ this._clientLog.debug(`pull follow-up skipped/failed: method=${method} err=${formatCaughtError(exc)}`);
2389
+ }
2390
+ })();
2391
+ }
2392
+ async _withBackgroundRpc(operation) {
2393
+ this._backgroundRpcDepth += 1;
2394
+ try {
2395
+ return await operation();
1402
2396
  }
1403
- const targetSlotId = String(message.slot_id ?? '').trim();
1404
- if (targetSlotId && this._slotId && targetSlotId !== this._slotId) {
2397
+ finally {
2398
+ this._backgroundRpcDepth = Math.max(0, this._backgroundRpcDepth - 1);
2399
+ }
2400
+ }
2401
+ async _runPullSerialized(key, operation) {
2402
+ if (key && this._isPullResponseProcessing(key)) {
2403
+ this._clientLog.debug(`pull skipped while processing pull response: key=${key}`);
2404
+ return [];
2405
+ }
2406
+ let token = this._tryAcquirePullGate(key);
2407
+ if (token === null) {
2408
+ // 显式 pull 可能撞上 push/gap-fill 的后台 pull。这里不并行发第二个 pull,
2409
+ // 也不把后台 in-flight 暴露成业务错误;短等待 gate 释放后再进入连接级 RPC queue。
2410
+ const deadline = Date.now() + AUNClient.PULL_GATE_STALE_MS + 100;
2411
+ while (token === null && Date.now() <= deadline) {
2412
+ await this._sleep(25);
2413
+ token = this._tryAcquirePullGate(key);
2414
+ }
2415
+ if (token === null) {
2416
+ throw new StateError(`pull already in-flight for ${key}`);
2417
+ }
2418
+ }
2419
+ try {
2420
+ return await this._withBackgroundRpc(operation);
2421
+ }
2422
+ finally {
2423
+ this._releasePullGate(key, token);
2424
+ }
2425
+ }
2426
+ async _tryRunBackgroundPull(key, operation, followupOnMessages = false) {
2427
+ if (key && this._isPullResponseProcessing(key))
2428
+ return false;
2429
+ const token = this._tryAcquirePullGate(key);
2430
+ if (token === null)
1405
2431
  return false;
2432
+ let count = 0;
2433
+ try {
2434
+ count = await this._withBackgroundRpc(operation);
2435
+ }
2436
+ finally {
2437
+ this._releasePullGate(key, token);
2438
+ }
2439
+ if (followupOnMessages && count > 0) {
2440
+ // 后台续拉是 fire-and-forget;关闭连接时 transport 会拒绝排队 RPC,
2441
+ // 这里必须本地收口,避免测试/宿主进程看到未处理的 Promise rejection。
2442
+ void this._tryRunBackgroundPull(key, operation, true).catch((exc) => {
2443
+ this._clientLog.debug(`background pull follow-up skipped/failed: key=${key} err=${formatCaughtError(exc)}`);
2444
+ });
1406
2445
  }
1407
2446
  return true;
1408
2447
  }
1409
- async _drainOrderedMessages(ns, beforeSeq) {
2448
+ async _drainOrderedMessages(ns, beforeSeq, pullResponse = false) {
1410
2449
  const queue = this._pendingOrderedMsgs.get(ns);
1411
2450
  if (!queue || queue.size === 0)
1412
2451
  return;
1413
- const contig = this._seqTracker.getContiguousSeq(ns);
1414
- const ready = [...queue.keys()]
1415
- .filter((seq) => seq <= contig && (beforeSeq === undefined || seq < beforeSeq))
1416
- .sort((a, b) => a - b);
1417
- for (const seq of ready) {
2452
+ while (true) {
2453
+ const contig = this._seqTracker.getContiguousSeq(ns);
2454
+ const ready = [...queue.keys()]
2455
+ .filter((seq) => seq <= contig && (beforeSeq === undefined || seq < beforeSeq))
2456
+ .sort((a, b) => a - b);
2457
+ let seq = ready[0];
2458
+ if (seq === undefined) {
2459
+ const nextSeq = contig + 1;
2460
+ if (beforeSeq !== undefined && nextSeq >= beforeSeq)
2461
+ break;
2462
+ if (!queue.has(nextSeq))
2463
+ break;
2464
+ seq = nextSeq;
2465
+ }
1418
2466
  const item = queue.get(seq);
1419
2467
  queue.delete(seq);
1420
- if (!item || this._pushedSeqs.get(ns)?.has(seq))
2468
+ if (!item)
2469
+ continue;
2470
+ if (this._pushedSeqs.get(ns)?.has(seq)) {
2471
+ this._clientLog.debug(`publish ordered drain skipped duplicate: ns=${ns}, seq=${seq}, event=${item.event}`);
2472
+ this._markOrderedSeqDelivered(ns, seq);
1421
2473
  continue;
1422
- await this._publishAppEvent(item.event, item.payload);
2474
+ }
2475
+ if (pullResponse) {
2476
+ const published = this._withPullResponseProcessing(ns, () => this._publishAppEvent(item.event, item.payload, 'ordered-drain'));
2477
+ if (isPromiseLike(published))
2478
+ await published;
2479
+ }
2480
+ else {
2481
+ const published = this._publishAppEvent(item.event, item.payload, 'ordered-drain');
2482
+ if (isPromiseLike(published))
2483
+ await published;
2484
+ }
1423
2485
  this._markPublishedSeq(ns, seq);
2486
+ this._markOrderedSeqDelivered(ns, seq);
2487
+ this._clientLog.debug(`publish ordered drain delivered: ns=${ns}, seq=${seq}, event=${item.event}`);
1424
2488
  }
1425
2489
  if (queue.size === 0)
1426
2490
  this._pendingOrderedMsgs.delete(ns);
@@ -1428,10 +2492,14 @@ export class AUNClient {
1428
2492
  async _publishOrderedMessage(event, ns, seq, payload) {
1429
2493
  const seqNum = Number(seq);
1430
2494
  if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0) {
1431
- await this._publishAppEvent(event, payload);
2495
+ this._clientLog.debug(`publish ordered direct(no-seq): event=${event}, ns=${ns || '<none>'}, seq=${String(seq)}`);
2496
+ const published = this._publishAppEvent(event, payload, 'ordered');
2497
+ if (isPromiseLike(published))
2498
+ await published;
1432
2499
  return true;
1433
2500
  }
1434
2501
  if (this._pushedSeqs.get(ns)?.has(seqNum)) {
2502
+ this._clientLog.debug(`publish ordered skipped duplicate: event=${event}, ns=${ns}, seq=${seqNum}`);
1435
2503
  const queue = this._pendingOrderedMsgs.get(ns);
1436
2504
  queue?.delete(seqNum);
1437
2505
  if (queue && queue.size === 0)
@@ -1439,30 +2507,52 @@ export class AUNClient {
1439
2507
  return false;
1440
2508
  }
1441
2509
  const contig = this._seqTracker.getContiguousSeq(ns);
1442
- if (seqNum > contig) {
2510
+ if (seqNum <= contig) {
2511
+ this._clientLog.debug(`publish ordered stale covered: event=${event}, ns=${ns}, seq=${seqNum}, contiguous=${contig}`);
2512
+ const queue = this._pendingOrderedMsgs.get(ns);
2513
+ queue?.delete(seqNum);
2514
+ if (queue && queue.size === 0)
2515
+ this._pendingOrderedMsgs.delete(ns);
2516
+ return false;
2517
+ }
2518
+ if (seqNum !== contig + 1) {
2519
+ this._clientLog.debug(`publish ordered enqueue(gap): event=${event}, ns=${ns}, seq=${seqNum}, contiguous=${contig}`);
1443
2520
  this._enqueueOrderedMessage(ns, event, seqNum, payload);
1444
2521
  return false;
1445
2522
  }
1446
2523
  await this._drainOrderedMessages(ns, seqNum);
1447
- if (this._pushedSeqs.get(ns)?.has(seqNum))
2524
+ if (this._pushedSeqs.get(ns)?.has(seqNum)) {
2525
+ this._clientLog.debug(`publish ordered skipped after-drain duplicate: event=${event}, ns=${ns}, seq=${seqNum}`);
1448
2526
  return false;
2527
+ }
1449
2528
  const queue = this._pendingOrderedMsgs.get(ns);
1450
2529
  queue?.delete(seqNum);
1451
2530
  if (queue && queue.size === 0)
1452
2531
  this._pendingOrderedMsgs.delete(ns);
1453
- await this._publishAppEvent(event, payload);
2532
+ const published = this._publishAppEvent(event, payload, 'ordered');
2533
+ if (isPromiseLike(published))
2534
+ await published;
1454
2535
  this._markPublishedSeq(ns, seqNum);
2536
+ this._markOrderedSeqDelivered(ns, seqNum);
2537
+ this._clientLog.debug(`publish ordered delivered: event=${event}, ns=${ns}, seq=${seqNum}`);
1455
2538
  await this._drainOrderedMessages(ns);
1456
2539
  return true;
1457
2540
  }
1458
2541
  async _publishPulledMessage(event, ns, seq, payload) {
2542
+ // Pull/gap-fill 批次是服务端对 after_seq 的可用结果集,可能跨过永久空洞。
2543
+ // 这里只能做 namespace+seq 去重并按返回顺序发布,不能套用 push 路径的
2544
+ // seq == contiguous_seq + 1 门控,否则会把空洞后的可用消息错误卡住。
1459
2545
  const seqNum = Number(seq);
1460
2546
  if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0 || !ns) {
1461
- await this._publishAppEvent(event, payload);
2547
+ this._clientLog.debug(`publish pulled direct(no-seq): event=${event}, ns=${ns || '<none>'}, seq=${String(seq)}`);
2548
+ const published = this._withPullResponseProcessing(ns, () => this._publishAppEvent(event, payload, 'pull'));
2549
+ if (isPromiseLike(published))
2550
+ await published;
1462
2551
  return true;
1463
2552
  }
1464
2553
  const queue = this._pendingOrderedMsgs.get(ns);
1465
2554
  if (this._pushedSeqs.get(ns)?.has(seqNum)) {
2555
+ this._clientLog.debug(`publish pulled skipped duplicate: event=${event}, ns=${ns}, seq=${seqNum}`);
1466
2556
  queue?.delete(seqNum);
1467
2557
  if (queue && queue.size === 0)
1468
2558
  this._pendingOrderedMsgs.delete(ns);
@@ -1471,70 +2561,126 @@ export class AUNClient {
1471
2561
  queue?.delete(seqNum);
1472
2562
  if (queue && queue.size === 0)
1473
2563
  this._pendingOrderedMsgs.delete(ns);
1474
- await this._publishAppEvent(event, payload);
2564
+ const published = this._withPullResponseProcessing(ns, () => this._publishAppEvent(event, payload, 'pull'));
2565
+ if (isPromiseLike(published))
2566
+ await published;
1475
2567
  this._markPublishedSeq(ns, seqNum);
2568
+ this._markPulledSeqDelivered(ns, seqNum);
2569
+ await this._drainOrderedMessages(ns, undefined, true);
2570
+ this._clientLog.debug(`publish pulled delivered: event=${event}, ns=${ns}, seq=${seqNum}`);
1476
2571
  return true;
1477
2572
  }
2573
+ _markPulledSeqDelivered(ns, seq) {
2574
+ // Pull 批次是 after_seq 之后服务端当前可用的结果集,可能跨过永久空洞。
2575
+ // 这里仅在应用层发布返回后推进已交付游标,不能改成 push 的相邻 seq 门控。
2576
+ const seqNum = Number(seq);
2577
+ if (!ns || !Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0)
2578
+ return false;
2579
+ const before = this._seqTracker.getContiguousSeq(ns);
2580
+ this._seqTracker.forceContiguousSeq(ns, seqNum);
2581
+ return this._seqTracker.getContiguousSeq(ns) !== before;
2582
+ }
2583
+ _markOrderedSeqDelivered(ns, seq) {
2584
+ if (!ns || !Number.isFinite(seq) || !Number.isInteger(seq) || seq <= 0)
2585
+ return false;
2586
+ const before = this._seqTracker.getContiguousSeq(ns);
2587
+ this._seqTracker.onMessageSeq(ns, seq);
2588
+ return this._seqTracker.getContiguousSeq(ns) !== before;
2589
+ }
1478
2590
  /** 后台补齐群事件空洞 */
1479
2591
  async _fillGroupEventGap(groupId) {
2592
+ groupId = normalizeGroupId(groupId) || String(groupId ?? '').trim();
2593
+ if (!groupId)
2594
+ return;
1480
2595
  const ns = `group_event:${groupId}`;
1481
2596
  const afterSeq = this._seqTracker.getContiguousSeq(ns);
1482
2597
  // 去重:同一 (group_evt:id:after_seq) 只补一次
1483
2598
  const dedupKey = `group_evt:${groupId}:${afterSeq}`;
1484
2599
  if (this._gapFillDone.has(dedupKey))
1485
2600
  return;
2601
+ const token = this._tryAcquirePullGate(ns);
2602
+ if (token === null) {
2603
+ this._clientLog.debug(`group event gap fill skipped: pull in-flight group=${groupId}`);
2604
+ return;
2605
+ }
1486
2606
  this._gapFillDone.set(dedupKey, Date.now());
1487
- this._clientLog.debug(`group event gap fill start: group=${groupId}, after_seq=${afterSeq}`);
2607
+ let filled = 0;
1488
2608
  try {
1489
- const result = await this.call('group.pull_events', {
1490
- group_id: groupId,
1491
- after_event_seq: afterSeq,
1492
- device_id: this._deviceId,
1493
- limit: 50,
1494
- });
1495
- let filled = 0;
1496
- if (isJsonObject(result)) {
2609
+ let nextAfterSeq = afterSeq;
2610
+ const maxPages = 100;
2611
+ let pageCount = 0;
2612
+ while (pageCount < maxPages) {
2613
+ pageCount += 1;
2614
+ this._clientLog.debug(`group event gap fill start: group=${groupId}, after_seq=${nextAfterSeq}`);
2615
+ const result = await this.call('group.pull_events', {
2616
+ group_id: groupId,
2617
+ after_event_seq: nextAfterSeq,
2618
+ device_id: this._deviceId,
2619
+ limit: 50,
2620
+ _pull_gate_locked: true,
2621
+ });
2622
+ if (!isJsonObject(result))
2623
+ return;
1497
2624
  const events = result.events;
1498
- if (Array.isArray(events)) {
1499
- this._seqTracker.onPullResult(ns, events.filter(isJsonObject));
1500
- const cursor = isJsonObject(result.cursor) ? result.cursor : null;
1501
- const serverAck = cursor ? Number(cursor.current_seq ?? 0) : 0;
1502
- if (serverAck > 0) {
1503
- const contigBefore = this._seqTracker.getContiguousSeq(ns);
1504
- if (contigBefore < serverAck) {
1505
- this._clientLog.info(`group.pull_events retention-floor advance: ns=${ns} contiguous=${contigBefore} -> cursor.current_seq=${serverAck}`);
1506
- this._seqTracker.forceContiguousSeq(ns, serverAck);
1507
- }
1508
- }
1509
- // 持久化 cursor + ack_events(与 Python 对齐)
1510
- this._saveSeqTrackerState();
1511
- const contig = this._seqTracker.getContiguousSeq(ns);
1512
- if (contig > 0 && (events.length > 0 || serverAck > 0)) {
1513
- this._transport.call('group.ack_events', {
1514
- group_id: groupId,
1515
- event_seq: contig,
1516
- device_id: this._deviceId,
1517
- slot_id: this._slotId,
1518
- }).catch((e) => { this._clientLog.debug(`group event auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
2625
+ if (!Array.isArray(events))
2626
+ return;
2627
+ const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
2628
+ const eventObjects = events.filter(isJsonObject);
2629
+ const retentionFloor = this._pullRetentionFloor(result, 'retention_floor_event_seq', 'retention_floor_event_seq');
2630
+ if (retentionFloor > 0) {
2631
+ const contigBeforeFloor = this._seqTracker.getContiguousSeq(ns);
2632
+ if (contigBeforeFloor < retentionFloor) {
2633
+ this._clientLog.info(`group.pull_events retention-floor advance: ns=${ns} contiguous=${contigBeforeFloor} -> retention_floor=${retentionFloor}`);
2634
+ this._seqTracker.forceContiguousSeq(ns, retentionFloor);
1519
2635
  }
1520
- for (const evt of events) {
1521
- if (isJsonObject(evt)) {
1522
- evt._from_gap_fill = true;
1523
- const et = String(evt.event_type ?? '');
1524
- // 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
1525
- if (et === 'group.message_created')
1526
- continue;
1527
- // 验签:有 client_signature 就验(与实时事件路径对齐)
1528
- const cs = evt.client_signature;
1529
- if (cs && typeof cs === 'object') {
2636
+ }
2637
+ const eventSeqs = [];
2638
+ for (const evt of eventObjects) {
2639
+ const eventSeq = Number(evt.event_seq ?? 0);
2640
+ if (Number.isFinite(eventSeq) && eventSeq > 0)
2641
+ eventSeqs.push(eventSeq);
2642
+ evt._from_gap_fill = true;
2643
+ const et = String(evt.event_type ?? '');
2644
+ // 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
2645
+ if (et !== 'group.message_created') {
2646
+ // 验签:有 client_signature 就验(与实时事件路径对齐)
2647
+ const cs = evt.client_signature;
2648
+ if (cs && typeof cs === 'object') {
2649
+ if (this._shouldSkipEventSignature(evt)) {
2650
+ delete evt.client_signature;
2651
+ }
2652
+ else {
1530
2653
  evt._verified = await this._verifyEventSignatureAsync(evt, cs);
1531
2654
  }
1532
- // group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
1533
- await this._dispatcher.publish('group.changed', evt);
1534
- filled += 1;
1535
2655
  }
2656
+ // group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
2657
+ await this._dispatcher.publish('group.changed', evt);
2658
+ }
2659
+ if (Number.isFinite(eventSeq) && eventSeq > 0) {
2660
+ this._markPulledSeqDelivered(ns, eventSeq);
1536
2661
  }
2662
+ filled += 1;
2663
+ }
2664
+ const contig = this._seqTracker.getContiguousSeq(ns);
2665
+ if (contig !== pageContigBefore) {
2666
+ this._saveSeqTrackerState();
1537
2667
  }
2668
+ if (eventObjects.length > 0 && contig > 0 && contig !== pageContigBefore) {
2669
+ const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
2670
+ const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
2671
+ this._transport.call('group.ack_events', {
2672
+ group_id: groupId,
2673
+ event_seq: ackSeq,
2674
+ device_id: this._deviceId,
2675
+ slot_id: this._slotId,
2676
+ }, undefined, undefined, true).catch((e) => { this._clientLog.debug(`group event auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
2677
+ }
2678
+ // pull_events 与其它 pull 一样:一次后台任务只消费一个批次。
2679
+ // 非空批次返回后由 pull gate 的 fire-and-forget follow-up 重新排队,直到空批停止。
2680
+ break;
2681
+ }
2682
+ if (pageCount >= maxPages) {
2683
+ this._clientLog.warn(`group event gap fill reached max_pages=${maxPages} group=${groupId} after_seq=${nextAfterSeq}`);
1538
2684
  }
1539
2685
  this._clientLog.debug(`group event gap fill done: group=${groupId}, after_seq=${afterSeq}, filled=${filled}`);
1540
2686
  }
@@ -1543,6 +2689,10 @@ export class AUNClient {
1543
2689
  }
1544
2690
  finally {
1545
2691
  this._gapFillDone.delete(dedupKey);
2692
+ this._releasePullGate(ns, token);
2693
+ if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
2694
+ void this._fillGroupEventGap(groupId);
2695
+ }
1546
2696
  }
1547
2697
  }
1548
2698
  _extractGroupIdFromResult(result) {
@@ -1567,28 +2717,45 @@ export class AUNClient {
1567
2717
  // 验签:有 client_signature 就验,没有默认安全(H20: 严格 boolean)
1568
2718
  const cs = d.client_signature;
1569
2719
  if (cs && isJsonObject(cs)) {
1570
- d._verified = await this._verifyEventSignatureAsync(d, cs);
2720
+ if (this._shouldSkipEventSignature(d)) {
2721
+ delete d.client_signature;
2722
+ }
2723
+ else {
2724
+ d._verified = await this._verifyEventSignatureAsync(d, cs);
2725
+ }
1571
2726
  }
1572
2727
  await this._dispatcher.publish('group.changed', d);
1573
2728
  // V2-only:成员/设备变化会影响 group.v2.bootstrap 的设备集与 state commitment。
1574
2729
  if (groupId) {
1575
2730
  this._v2BootstrapCache.delete(`group:${groupId}`);
1576
2731
  }
1577
- if (groupId && action === 'upsert' && this._v2Session) {
2732
+ const membershipActions = new Set([
2733
+ 'member_added', 'member_left', 'member_removed', 'role_changed',
2734
+ 'owner_transferred', 'joined', 'join_approved', 'invite_code_used',
2735
+ ]);
2736
+ if (groupId && this._v2Session && (action === 'upsert' || membershipActions.has(action))) {
1578
2737
  this._safeAsync(this._v2AutoProposeState(groupId, { leaderDelay: true }));
1579
2738
  }
1580
2739
  // Group SPK 编排:成员变更触发注册/轮换
1581
2740
  if (this._v2Session && groupId) {
1582
- const callFn = async (method, params) => this.call(method, params);
1583
- if (action === 'joined' || action === 'join_approved') {
1584
- this._v2Session.ensureGroupRegistered?.(groupId, callFn)?.catch(exc => {
1585
- this._clientLog.debug(`group SPK registration failed (non-fatal): group=${groupId} action=${action} err=${formatCaughtError(exc)}`);
1586
- });
1587
- }
1588
- else if (['member_added', 'member_left', 'member_removed', 'role_changed', 'owner_transferred'].includes(action)) {
1589
- this._v2Session.rotateGroupSPK?.(groupId, callFn)?.catch(exc => {
1590
- this._clientLog.debug(`group SPK rotation failed (non-fatal): group=${groupId} action=${action} err=${formatCaughtError(exc)}`);
1591
- });
2741
+ if (membershipActions.has(action)) {
2742
+ const callFn = async (method, params) => this.call(method, params);
2743
+ const joinedAid = String(d.joined_aid ?? d.member_aid ?? d.aid ?? '').trim();
2744
+ const actorAid = String(d.actor_aid ?? '').trim();
2745
+ const selfAid = String(this._aid ?? '').trim();
2746
+ const joinActions = new Set(['member_added', 'joined', 'join_approved', 'invite_code_used']);
2747
+ const isSelfJoin = joinActions.has(action) && !!selfAid && (joinedAid === selfAid ||
2748
+ (!joinedAid && (action === 'joined' || action === 'invite_code_used') && actorAid === selfAid));
2749
+ if (isSelfJoin) {
2750
+ this._v2Session.ensureGroupRegistered?.(groupId, callFn)?.catch(exc => {
2751
+ this._clientLog.debug(`group SPK registration failed (non-fatal): group=${groupId} action=${action} err=${formatCaughtError(exc)}`);
2752
+ });
2753
+ }
2754
+ else {
2755
+ this._v2Session.rotateGroupSPK?.(groupId, callFn)?.catch(exc => {
2756
+ this._clientLog.debug(`group SPK rotation failed (non-fatal): group=${groupId} action=${action} err=${formatCaughtError(exc)}`);
2757
+ });
2758
+ }
1592
2759
  }
1593
2760
  }
1594
2761
  // event_seq 空洞检测:持久化后的 group.changed 会携带 event_seq。
@@ -1609,7 +2776,7 @@ export class AUNClient {
1609
2776
  event_seq: contig,
1610
2777
  device_id: this._deviceId,
1611
2778
  slot_id: this._slotId,
1612
- }).catch((e) => { this._clientLog.debug(`group event push auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
2779
+ }, undefined, undefined, true).catch((e) => { this._clientLog.debug(`group event push auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
1613
2780
  }
1614
2781
  }
1615
2782
  // 仅在真实 event gap 时才触发补拉(补洞回来的事件不再触发新补洞)
@@ -1655,12 +2822,17 @@ export class AUNClient {
1655
2822
  // 提交者签名验证(兼容旧版:无签名时继续)
1656
2823
  const cs = d.client_signature;
1657
2824
  if (cs && isJsonObject(cs)) {
1658
- const verified = await this._verifyEventSignatureAsync(d, cs);
1659
- if (verified === false) {
1660
- this._clientLog.warn(`state_committed committer signature verification failed group=${groupId}`);
1661
- return;
2825
+ if (this._shouldSkipEventSignature(d)) {
2826
+ delete d.client_signature;
2827
+ }
2828
+ else {
2829
+ const verified = await this._verifyEventSignatureAsync(d, cs);
2830
+ if (verified === false) {
2831
+ this._clientLog.warn(`state_committed committer signature verification failed group=${groupId}`);
2832
+ return;
2833
+ }
2834
+ d._verified = verified;
1662
2835
  }
1663
- d._verified = verified;
1664
2836
  }
1665
2837
  const stateVersion = Number(d.state_version ?? 0);
1666
2838
  const stateHash = String(d.state_hash ?? '').trim();
@@ -1810,12 +2982,86 @@ export class AUNClient {
1810
2982
  return false;
1811
2983
  }
1812
2984
  }
1813
- /**
1814
- * 获取对方证书(带缓存 + 完整 PKI 验证)。 /**
1815
- * 获取对方证书(带缓存 + 完整 PKI 验证)。
1816
- * 跨域时自动路由到 peer 所在域的 Gateway。
1817
- */
1818
- async _fetchPeerCert(aid, certFingerprint) {
2985
+ async _validateAndCachePeerCert(opts) {
2986
+ const aid = String(opts.aid ?? '').trim();
2987
+ const certPem = String(opts.certPem ?? '').trim();
2988
+ const certFingerprint = String(opts.certFingerprint ?? '').trim() || undefined;
2989
+ if (!aid)
2990
+ throw new ValidationError('peer aid is required for cert validation');
2991
+ if (!certPem)
2992
+ throw new ValidationError(`peer cert is empty for ${aid}`);
2993
+ const gatewayUrl = this._gatewayUrl;
2994
+ if (!gatewayUrl) {
2995
+ throw new ValidationError('gateway url unavailable for e2ee cert validation');
2996
+ }
2997
+ const peerGatewayUrl = AUNClient._resolvePeerGatewayUrl(gatewayUrl, aid);
2998
+ const x509Cert = new crypto.X509Certificate(certPem);
2999
+ // H7: 严格校验指纹(DER SHA-256 或 SPKI SHA-256 任一匹配即可)
3000
+ if (certFingerprint) {
3001
+ const expectedFP = certFingerprint.toLowerCase();
3002
+ if (!expectedFP.startsWith('sha256:')) {
3003
+ throw new ValidationError(`unsupported cert_fingerprint format for ${aid}: ${expectedFP.slice(0, 24)}`);
3004
+ }
3005
+ const expectedHex = expectedFP.slice('sha256:'.length);
3006
+ const derHex = x509Cert.fingerprint256.replace(/:/g, '').toLowerCase();
3007
+ let spkiHex = '';
3008
+ try {
3009
+ const spkiDer = x509Cert.publicKey.export({ type: 'spki', format: 'der' });
3010
+ spkiHex = crypto.createHash('sha256').update(spkiDer).digest('hex');
3011
+ }
3012
+ catch {
3013
+ spkiHex = '';
3014
+ }
3015
+ if (expectedHex !== derHex && (!spkiHex || expectedHex !== spkiHex)) {
3016
+ throw new ValidationError(`peer cert fingerprint mismatch for ${aid}: expected=${expectedFP.slice(0, 24)}...`);
3017
+ }
3018
+ }
3019
+ let cachedBootstrapChain = false;
3020
+ const caChainPems = opts.caChainPems ?? [];
3021
+ if (caChainPems.length > 0) {
3022
+ try {
3023
+ this._auth.cacheGatewayCaChain(peerGatewayUrl, caChainPems, aid);
3024
+ cachedBootstrapChain = true;
3025
+ }
3026
+ catch (exc) {
3027
+ this._clientLog.debug(`bootstrap CA chain cache skipped: peer=${aid}, source=${opts.source ?? 'unknown'}, err=${formatCaughtError(exc)}`);
3028
+ }
3029
+ }
3030
+ try {
3031
+ await this._auth.verifyPeerCertificate(peerGatewayUrl, certPem, aid);
3032
+ }
3033
+ catch (exc) {
3034
+ if (cachedBootstrapChain) {
3035
+ this._auth.discardGatewayCaChain(peerGatewayUrl, aid);
3036
+ }
3037
+ throw new ValidationError(`peer cert verification failed for ${aid}: ${exc instanceof Error ? exc.message : String(exc)}`);
3038
+ }
3039
+ const nowSec = Date.now() / 1000;
3040
+ const entry = {
3041
+ certPem,
3042
+ validatedAt: nowSec,
3043
+ refreshAfter: nowSec + PEER_CERT_CACHE_TTL,
3044
+ };
3045
+ const cacheKey = AUNClient._certCacheKey(aid, certFingerprint);
3046
+ this._certCache.set(cacheKey, entry);
3047
+ const bareKey = AUNClient._certCacheKey(aid);
3048
+ if (bareKey !== cacheKey)
3049
+ this._certCache.set(bareKey, entry);
3050
+ if (!certFingerprint) {
3051
+ const actualFp = `sha256:${x509Cert.fingerprint256.replace(/:/g, '').toLowerCase()}`;
3052
+ this._certCache.set(AUNClient._certCacheKey(aid, actualFp), entry);
3053
+ }
3054
+ try {
3055
+ // peer 证书只存版本目录,不覆盖 cert.pem
3056
+ this._keystore.saveCert(aid, certPem, certFingerprint, { makeActive: false });
3057
+ }
3058
+ catch (exc) {
3059
+ this._clientLog.error(`failed to write cert to keystore (aid=${aid}, fp=${certFingerprint ?? ''}): ${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
3060
+ }
3061
+ return certPem;
3062
+ }
3063
+ /** 获取对方证书(带缓存 + 完整 PKI 验证),跨域时自动路由到 peer 所在域。 */
3064
+ async _fetchPeerCert(aid, certFingerprint, timeoutMs = 30_000) {
1819
3065
  const tStart = Date.now();
1820
3066
  this._clientLog.debug(`_fetchPeerCert enter: aid=${aid}, fp=${certFingerprint ?? ''}`);
1821
3067
  try {
@@ -1830,75 +3076,118 @@ export class AUNClient {
1830
3076
  if (!gatewayUrl) {
1831
3077
  throw new ValidationError('gateway url unavailable for e2ee cert fetch');
1832
3078
  }
1833
- // 跨域时用 peer 所在域的 Gateway URL
1834
3079
  const peerGatewayUrl = AUNClient._resolvePeerGatewayUrl(gatewayUrl, aid);
1835
3080
  let certPem;
1836
3081
  try {
1837
- const certUrl = AUNClient._buildCertUrl(peerGatewayUrl, aid, certFingerprint);
1838
- certPem = await _httpGetText(certUrl, this._configModel.verifySsl);
3082
+ certPem = await _httpGetText(AUNClient._buildCertUrl(peerGatewayUrl, aid, certFingerprint), this._configModel.verifySsl, timeoutMs);
1839
3083
  }
1840
3084
  catch (exc) {
1841
- if (!certFingerprint) {
3085
+ if (!certFingerprint)
1842
3086
  throw exc;
1843
- }
1844
- const fallbackCert = await _httpGetText(AUNClient._buildCertUrl(peerGatewayUrl, aid), this._configModel.verifySsl);
1845
- certPem = fallbackCert;
1846
- }
1847
- // H7: 严格校验指纹(DER SHA-256 或 SPKI SHA-256 任一匹配即可)
1848
- if (certFingerprint) {
1849
- const expectedFP = String(certFingerprint).trim().toLowerCase();
1850
- if (!expectedFP.startsWith('sha256:')) {
1851
- throw new ValidationError(`unsupported cert_fingerprint format for ${aid}: ${expectedFP.slice(0, 24)}`);
1852
- }
1853
- const expectedHex = expectedFP.slice('sha256:'.length);
1854
- const x509Cert = new crypto.X509Certificate(certPem);
1855
- const derHex = x509Cert.fingerprint256.replace(/:/g, '').toLowerCase();
1856
- let spkiHex = '';
3087
+ certPem = await _httpGetText(AUNClient._buildCertUrl(peerGatewayUrl, aid), this._configModel.verifySsl, timeoutMs);
3088
+ }
3089
+ const validated = await this._validateAndCachePeerCert({
3090
+ aid,
3091
+ certPem,
3092
+ certFingerprint,
3093
+ source: 'fetch',
3094
+ });
3095
+ this._clientLog.debug(`_fetchPeerCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} (fetched)`);
3096
+ return validated;
3097
+ }
3098
+ catch (err) {
3099
+ this._clientLog.debug(`_fetchPeerCert exit (error): elapsed=${Date.now() - tStart}ms aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
3100
+ throw err;
3101
+ }
3102
+ }
3103
+ _bootstrapCaChain(material) {
3104
+ let raw;
3105
+ for (const key of ['ca_chain', 'ca_chain_pems', 'cert_chain', 'chain']) {
3106
+ if (material[key] !== undefined && material[key] !== null) {
3107
+ raw = material[key];
3108
+ break;
3109
+ }
3110
+ }
3111
+ if (!Array.isArray(raw))
3112
+ return [];
3113
+ const result = [];
3114
+ for (const item of raw) {
3115
+ let certType = '';
3116
+ let certPem = '';
3117
+ if (isJsonObject(item)) {
3118
+ certType = String(item.cert_type ?? '').trim().toLowerCase();
3119
+ if (certType === 'agent')
3120
+ continue;
3121
+ certPem = String(item.cert_pem ?? item.cert ?? '').trim();
3122
+ }
3123
+ else {
3124
+ certPem = String(item ?? '').trim();
3125
+ }
3126
+ if (!certPem)
3127
+ continue;
3128
+ if (!certType) {
1857
3129
  try {
1858
- const spkiDer = x509Cert.publicKey.export({ type: 'spki', format: 'der' });
1859
- spkiHex = crypto.createHash('sha256').update(spkiDer).digest('hex');
3130
+ if (!new crypto.X509Certificate(certPem).ca)
3131
+ continue;
1860
3132
  }
1861
3133
  catch {
1862
- spkiHex = '';
1863
- }
1864
- if (expectedHex !== derHex && (!spkiHex || expectedHex !== spkiHex)) {
1865
- throw new ValidationError(`peer cert fingerprint mismatch for ${aid}: expected=${expectedFP.slice(0, 24)}...`);
3134
+ continue;
1866
3135
  }
1867
3136
  }
1868
- // 完整 PKI 验证
1869
- try {
1870
- await this._auth.verifyPeerCertificate(peerGatewayUrl, certPem, aid);
1871
- }
1872
- catch (exc) {
1873
- throw new ValidationError(`peer cert verification failed for ${aid}: ${exc instanceof Error ? exc.message : String(exc)}`);
1874
- }
1875
- const nowSec = Date.now() / 1000;
1876
- this._certCache.set(cacheKey, {
1877
- certPem,
1878
- validatedAt: nowSec,
1879
- refreshAfter: nowSec + PEER_CERT_CACHE_TTL,
1880
- });
3137
+ result.push(certPem);
3138
+ }
3139
+ return result;
3140
+ }
3141
+ async _primeBootstrapPeerCerts(bootstrap, peerAid) {
3142
+ const certsRaw = bootstrap.certs;
3143
+ if (!isJsonObject(certsRaw))
3144
+ return;
3145
+ const materials = certsRaw;
3146
+ const expected = new Set();
3147
+ const normalizedPeer = String(peerAid ?? '').trim();
3148
+ if (normalizedPeer)
3149
+ expected.add(normalizedPeer);
3150
+ const audit = Array.isArray(bootstrap.audit_recipients) ? bootstrap.audit_recipients : [];
3151
+ for (const dev of audit) {
3152
+ if (!isJsonObject(dev))
3153
+ continue;
3154
+ const aid = String(dev.aid ?? '').trim();
3155
+ if (aid)
3156
+ expected.add(aid);
3157
+ }
3158
+ for (const aid of expected) {
3159
+ if (aid === this._aid)
3160
+ continue;
3161
+ const material = materials[aid];
3162
+ if (!isJsonObject(material))
3163
+ continue;
3164
+ const certPem = String(material.cert_pem ?? material.cert ?? '').trim();
3165
+ if (!certPem)
3166
+ continue;
3167
+ const certFingerprint = String(material.cert_fingerprint ?? material.fingerprint ?? material.fp ?? '').trim() || undefined;
1881
3168
  try {
1882
- // peer 证书只存版本目录,不覆盖 cert.pem
1883
- this._keystore.saveCert(aid, certPem, certFingerprint, { makeActive: false });
3169
+ await this._validateAndCachePeerCert({
3170
+ aid,
3171
+ certPem,
3172
+ certFingerprint,
3173
+ caChainPems: this._bootstrapCaChain(material),
3174
+ source: 'bootstrap',
3175
+ });
1884
3176
  }
1885
3177
  catch (exc) {
1886
- this._clientLog.error(`failed to write cert to keystore (aid=${aid}, fp=${certFingerprint ?? ''}): ${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
3178
+ this._clientLog.debug(`bootstrap peer cert material ignored: peer=${aid}, err=${formatCaughtError(exc)}`);
1887
3179
  }
1888
- this._clientLog.debug(`_fetchPeerCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} (fetched)`);
1889
- return certPem;
1890
- }
1891
- catch (err) {
1892
- this._clientLog.debug(`_fetchPeerCert exit (error): elapsed=${Date.now() - tStart}ms aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
1893
- throw err;
1894
3180
  }
1895
3181
  }
1896
3182
  async _decryptGroupThoughts(result) {
3183
+ this._clientLog.debug(`group.thought.get decrypt enter: found=${String(result.found ?? '')}, group=${String(result.group_id ?? '')}, sender=${String(result.sender_aid ?? '')}`);
1897
3184
  if (!result.found) {
3185
+ this._clientLog.debug('group.thought.get decrypt exit: not found');
1898
3186
  return { ...result, thoughts: [] };
1899
3187
  }
1900
3188
  const items = Array.isArray(result.thoughts) ? result.thoughts.filter(isJsonObject) : [];
1901
3189
  if (items.length === 0) {
3190
+ this._clientLog.debug('group.thought.get decrypt exit: empty thoughts');
1902
3191
  return { ...result, thoughts: [] };
1903
3192
  }
1904
3193
  const groupId = String(result.group_id ?? '');
@@ -1908,26 +3197,32 @@ export class AUNClient {
1908
3197
  const payload = isJsonObject(item.payload) ? item.payload : null;
1909
3198
  const thoughtId = String(item.thought_id ?? item.message_id ?? '');
1910
3199
  const fromAid = String(item.from ?? item.sender_aid ?? senderAid);
3200
+ this._logMessageDebug('thought-get-raw', 'group.thought.get', 'group.thought.get', item, {
3201
+ extra: { group_id: groupId, thought_id: thoughtId, from: fromAid },
3202
+ });
1911
3203
  let decryptFailed = false;
1912
3204
  let decryptedPayload = payload ?? {};
1913
3205
  let e2ee;
1914
3206
  if (payload?.type === 'e2ee.group_encrypted' && String(payload.version ?? '') === 'v2') {
3207
+ e2ee = this._v2E2eeMeta(payload);
1915
3208
  const plain = await this._decryptV2EnvelopeForThought({ envelope: payload, fromAid });
1916
3209
  if (plain === null) {
1917
3210
  decryptFailed = true;
3211
+ this._clientLog.debug(`group.thought.get decrypt returned null: group=${groupId}, thought_id=${thoughtId}, from=${fromAid}`);
1918
3212
  }
1919
3213
  else {
1920
3214
  decryptedPayload = plain;
1921
- e2ee = {
1922
- version: 'v2',
1923
- suite: String(payload.suite ?? ''),
1924
- encryption_mode: `v2_${String(payload.suite ?? 'unknown')}`,
1925
- forward_secrecy: true,
1926
- };
3215
+ this._logMessageDebug('thought-decrypt-ok', 'group.thought.get', 'group.thought.get', {
3216
+ group_id: groupId,
3217
+ thought_id: thoughtId,
3218
+ from: fromAid,
3219
+ payload: plain,
3220
+ });
1927
3221
  }
1928
3222
  }
1929
3223
  else if (payload?.type === 'e2ee.group_encrypted') {
1930
3224
  decryptFailed = true;
3225
+ this._clientLog.debug(`group.thought.get unsupported encrypted payload: group=${groupId}, thought_id=${thoughtId}, type=${String(payload.type ?? '')}, version=${String(payload.version ?? '')}`);
1931
3226
  }
1932
3227
  const thought = {
1933
3228
  thought_id: thoughtId,
@@ -1935,22 +3230,32 @@ export class AUNClient {
1935
3230
  payload: decryptFailed ? (payload ?? {}) : decryptedPayload,
1936
3231
  created_at: item.created_at,
1937
3232
  };
1938
- if (e2ee !== undefined)
3233
+ if (e2ee !== undefined) {
1939
3234
  thought.e2ee = e2ee;
3235
+ if (isJsonObject(e2ee))
3236
+ this._attachV2EnvelopeMetadata(thought, e2ee);
3237
+ }
1940
3238
  if (decryptFailed)
1941
3239
  thought.decrypt_failed = true;
1942
3240
  if ('context' in item)
1943
3241
  thought.context = item.context;
3242
+ this._logMessageDebug(decryptFailed ? 'thought-decrypt-fail' : 'thought-result', 'group.thought.get', 'group.thought.get', thought, {
3243
+ extra: { group_id: groupId, thought_id: thoughtId },
3244
+ });
1944
3245
  thoughts.push(thought);
1945
3246
  }
3247
+ this._clientLog.debug(`group.thought.get decrypt exit: group=${groupId}, total=${items.length}, returned=${thoughts.length}`);
1946
3248
  return { ...result, thoughts };
1947
3249
  }
1948
3250
  async _decryptMessageThoughts(result) {
3251
+ this._clientLog.debug(`message.thought.get decrypt enter: found=${String(result.found ?? '')}, peer=${String(result.peer_aid ?? '')}, sender=${String(result.sender_aid ?? '')}`);
1949
3252
  if (!result.found) {
3253
+ this._clientLog.debug('message.thought.get decrypt exit: not found');
1950
3254
  return { ...result, thoughts: [] };
1951
3255
  }
1952
3256
  const items = Array.isArray(result.thoughts) ? result.thoughts.filter(isJsonObject) : [];
1953
3257
  if (items.length === 0) {
3258
+ this._clientLog.debug('message.thought.get decrypt exit: empty thoughts');
1954
3259
  return { ...result, thoughts: [] };
1955
3260
  }
1956
3261
  const senderAid = String(result.sender_aid ?? '');
@@ -1961,26 +3266,32 @@ export class AUNClient {
1961
3266
  const thoughtId = String(item.thought_id ?? item.message_id ?? '');
1962
3267
  const fromAid = String(item.from ?? senderAid);
1963
3268
  const toAid = String(item.to ?? peerAid);
3269
+ this._logMessageDebug('thought-get-raw', 'message.thought.get', 'message.thought.get', item, {
3270
+ extra: { thought_id: thoughtId, from: fromAid, to: toAid },
3271
+ });
1964
3272
  let decryptFailed = false;
1965
3273
  let decryptedPayload = payload ?? {};
1966
3274
  let e2ee;
1967
3275
  if (payload?.type === 'e2ee.p2p_encrypted' && String(payload.version ?? '') === 'v2') {
3276
+ e2ee = this._v2E2eeMeta(payload);
1968
3277
  const plain = await this._decryptV2EnvelopeForThought({ envelope: payload, fromAid });
1969
3278
  if (plain === null) {
1970
3279
  decryptFailed = true;
3280
+ this._clientLog.debug(`message.thought.get decrypt returned null: thought_id=${thoughtId}, from=${fromAid}, to=${toAid}`);
1971
3281
  }
1972
3282
  else {
1973
3283
  decryptedPayload = plain;
1974
- e2ee = {
1975
- version: 'v2',
1976
- suite: String(payload.suite ?? ''),
1977
- encryption_mode: `v2_${String(payload.suite ?? 'unknown')}`,
1978
- forward_secrecy: true,
1979
- };
3284
+ this._logMessageDebug('thought-decrypt-ok', 'message.thought.get', 'message.thought.get', {
3285
+ thought_id: thoughtId,
3286
+ from: fromAid,
3287
+ to: toAid,
3288
+ payload: plain,
3289
+ });
1980
3290
  }
1981
3291
  }
1982
3292
  else if (payload?.type === 'e2ee.encrypted' || payload?.type === 'e2ee.p2p_encrypted') {
1983
3293
  decryptFailed = true;
3294
+ this._clientLog.debug(`message.thought.get unsupported encrypted payload: thought_id=${thoughtId}, type=${String(payload.type ?? '')}, version=${String(payload.version ?? '')}`);
1984
3295
  }
1985
3296
  const thought = {
1986
3297
  thought_id: thoughtId,
@@ -1990,14 +3301,21 @@ export class AUNClient {
1990
3301
  payload: decryptFailed ? (payload ?? {}) : decryptedPayload,
1991
3302
  created_at: item.created_at,
1992
3303
  };
1993
- if (e2ee !== undefined)
3304
+ if (e2ee !== undefined) {
1994
3305
  thought.e2ee = e2ee;
3306
+ if (isJsonObject(e2ee))
3307
+ this._attachV2EnvelopeMetadata(thought, e2ee);
3308
+ }
1995
3309
  if (decryptFailed)
1996
3310
  thought.decrypt_failed = true;
1997
3311
  if ('context' in item)
1998
3312
  thought.context = item.context;
3313
+ this._logMessageDebug(decryptFailed ? 'thought-decrypt-fail' : 'thought-result', 'message.thought.get', 'message.thought.get', thought, {
3314
+ extra: { thought_id: thoughtId },
3315
+ });
1999
3316
  thoughts.push(thought);
2000
3317
  }
3318
+ this._clientLog.debug(`message.thought.get decrypt exit: total=${items.length}, returned=${thoughts.length}`);
2001
3319
  return { ...result, thoughts };
2002
3320
  }
2003
3321
  /** 从 keystore 恢复 SeqTracker 状态 */ /** 从 keystore 恢复 SeqTracker 状态 */
@@ -2102,6 +3420,8 @@ export class AUNClient {
2102
3420
  this._gapFillDone.clear();
2103
3421
  this._pushedSeqs.clear();
2104
3422
  this._pendingOrderedMsgs.clear();
3423
+ this._v2SenderIKPending.clear();
3424
+ this._v2SenderIKFetching.clear();
2105
3425
  this._groupSynced.clear();
2106
3426
  }
2107
3427
  _refreshSeqTrackerContext() {
@@ -2112,6 +3432,8 @@ export class AUNClient {
2112
3432
  this._gapFillDone.clear();
2113
3433
  this._pushedSeqs.clear();
2114
3434
  this._pendingOrderedMsgs.clear();
3435
+ this._v2SenderIKPending.clear();
3436
+ this._v2SenderIKFetching.clear();
2115
3437
  this._groupSynced.clear();
2116
3438
  this._seqTrackerContext = nextContext;
2117
3439
  }
@@ -2152,6 +3474,43 @@ export class AUNClient {
2152
3474
  }).catch(() => { });
2153
3475
  }
2154
3476
  }
3477
+ _persistRepairedSeq(ns) {
3478
+ if (!this._aid || !ns)
3479
+ return;
3480
+ const seq = this._seqTracker.getContiguousSeq(ns);
3481
+ try {
3482
+ if (seq > 0 && typeof this._keystore.saveSeq === 'function') {
3483
+ this._keystore.saveSeq(this._aid, this._deviceId, this._slotId, ns, seq);
3484
+ return;
3485
+ }
3486
+ const deleteSeq = this._keystore.deleteSeq;
3487
+ if (seq <= 0 && typeof deleteSeq === 'function') {
3488
+ deleteSeq.call(this._keystore, this._aid, this._deviceId, this._slotId, ns);
3489
+ return;
3490
+ }
3491
+ if (seq > 0) {
3492
+ this._saveSeqTrackerState();
3493
+ }
3494
+ }
3495
+ catch (exc) {
3496
+ this._clientLog.debug(`persist repaired seq failed: ns=${ns} err=${formatCaughtError(exc)}`);
3497
+ }
3498
+ }
3499
+ _repairPushContiguousBound(ns, pushSeq, hasPayload, label) {
3500
+ if (!ns || !Number.isFinite(pushSeq) || pushSeq <= 0) {
3501
+ return ns ? this._seqTracker.getContiguousSeq(ns) : 0;
3502
+ }
3503
+ const contig = this._seqTracker.getContiguousSeq(ns);
3504
+ const shouldRepair = contig > pushSeq;
3505
+ if (!shouldRepair)
3506
+ return contig;
3507
+ const repairedTo = Math.max(0, pushSeq - 1);
3508
+ this._seqTracker.repairContiguousSeq(ns, repairedTo);
3509
+ const repaired = this._seqTracker.getContiguousSeq(ns);
3510
+ this._persistRepairedSeq(ns);
3511
+ this._clientLog.warn(`${label} push repaired contiguous_seq: ns=${ns} payload=${hasPayload} push_seq=${pushSeq} contiguous=${contig}->${repaired}`);
3512
+ return repaired;
3513
+ }
2155
3514
  /** 记录 E2EE 自动编排错误 */
2156
3515
  _logE2eeError(stage, groupId, aid, exc) {
2157
3516
  try {
@@ -2276,7 +3635,7 @@ export class AUNClient {
2276
3635
  catch (exc) {
2277
3636
  this._clientLog.warn(`V2 session init failed (non-fatal): ${formatCaughtError(exc)}`);
2278
3637
  }
2279
- // connect/reconnect 成功后自动触发一次 P2P message.pull,补齐离线期间积压
3638
+ // connect/reconnect 成功后自动触发一次 P2P message.v2.pull,补齐离线期间积压
2280
3639
  // 群消息按惰性触发,不在此处主动 pull
2281
3640
  void this._fillP2pGap().catch((exc) => {
2282
3641
  this._clientLog.warn(`schedule post-connect P2P gap fill failed: ${formatCaughtError(exc)}`);
@@ -2368,45 +3727,245 @@ export class AUNClient {
2368
3727
  this._v2Session = new V2Session(v2Store, this._deviceId, this._aid, aidPriv, aidPubDer);
2369
3728
  await this._v2Session.ensureRegistered(this._v2CallFn());
2370
3729
  this._clientLog.debug(`V2 session initialized aid=${this._aid} device=${this._deviceId}`);
2371
- this._safeAsync(this._v2AutoConfirmPendingProposals());
3730
+ // 群 state proposal 由服务端在 client.online 时定向通知。
3731
+ }
3732
+ async _v2TrustedIKPubDer(aid) {
3733
+ const normalizedAid = String(aid ?? '').trim();
3734
+ if (!normalizedAid)
3735
+ throw new E2EEError('spk_aid_missing');
3736
+ if (this._aid && normalizedAid === this._aid) {
3737
+ if (!this._v2Session)
3738
+ throw new E2EEError('V2 session not initialized');
3739
+ return this._v2Session.currentIkPubDer;
3740
+ }
3741
+ const certPem = await this._fetchPeerCert(normalizedAid);
3742
+ const cert = new crypto.X509Certificate(certPem);
3743
+ const certPubDer = cert.publicKey.export({ type: 'spki', format: 'der' });
3744
+ return new Uint8Array(certPubDer);
3745
+ }
3746
+ _v2SPKTimestampText(value, aid, deviceId, spkId) {
3747
+ if (value === null || value === undefined || value === '') {
3748
+ throw new E2EEError(`spk_timestamp_missing: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
3749
+ }
3750
+ if (typeof value === 'boolean') {
3751
+ throw new E2EEError(`spk_timestamp_invalid: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
3752
+ }
3753
+ if (typeof value === 'number') {
3754
+ if (!Number.isSafeInteger(value)) {
3755
+ throw new E2EEError(`spk_timestamp_invalid: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
3756
+ }
3757
+ return String(value);
3758
+ }
3759
+ const text = String(value).trim();
3760
+ if (!/^\d+$/.test(text)) {
3761
+ throw new E2EEError(`spk_timestamp_invalid: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
3762
+ }
3763
+ return BigInt(text).toString();
3764
+ }
3765
+ async _v2VerifySPKDevice(args) {
3766
+ if (!this._v2Session)
3767
+ throw new E2EEError('V2 session not initialized');
3768
+ const spkId = String(args.dev.spk_id ?? '').trim();
3769
+ if (!spkId)
3770
+ return;
3771
+ if (args.keySource !== 'peer_device_prekey' && args.keySource !== 'group_device_prekey') {
3772
+ throw new E2EEError(`spk_key_source_invalid: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId} key_source=${args.keySource}`);
3773
+ }
3774
+ if (!args.spkPkDer || args.spkPkDer.length === 0) {
3775
+ throw new E2EEError(`spk_public_key_missing: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
3776
+ }
3777
+ const expectedSpkId = `sha256:${crypto.createHash('sha256').update(Buffer.from(args.spkPkDer)).digest('hex').slice(0, 16)}`;
3778
+ if (spkId !== expectedSpkId) {
3779
+ throw new E2EEError(`spk_id_mismatch: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId} expected=${expectedSpkId}`);
3780
+ }
3781
+ const trustedIK = await this._v2TrustedIKPubDer(args.aid);
3782
+ if (!_v2BytesEqual(trustedIK, args.ikPkDer)) {
3783
+ throw new E2EEError(`spk_ik_mismatch: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
3784
+ }
3785
+ if (_v2BytesEqual(args.spkPkDer, trustedIK)) {
3786
+ this._v2Session.markPeerSPKVerified(args.aid, args.deviceId, spkId);
3787
+ return;
3788
+ }
3789
+ const sigB64 = String(args.dev.spk_signature ?? '').trim();
3790
+ if (!sigB64) {
3791
+ throw new E2EEError(`spk_signature_missing: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
3792
+ }
3793
+ let signature;
3794
+ try {
3795
+ signature = _v2B64ToBytesStrict(sigB64);
3796
+ }
3797
+ catch {
3798
+ throw new E2EEError(`spk_signature_invalid_base64: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
3799
+ }
3800
+ const tsText = this._v2SPKTimestampText(args.dev.spk_timestamp, args.aid, args.deviceId, spkId);
3801
+ const signData = Buffer.concat([
3802
+ Buffer.from(args.spkPkDer),
3803
+ Buffer.from(spkId, 'utf8'),
3804
+ Buffer.from(tsText, 'utf8'),
3805
+ ]);
3806
+ if (!ecdsaVerifyRaw(trustedIK, signature, new Uint8Array(signData))) {
3807
+ throw new E2EEError(`spk_signature_invalid: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
3808
+ }
3809
+ this._v2Session.markPeerSPKVerified(args.aid, args.deviceId, spkId);
3810
+ }
3811
+ async _v2BuildTargetFromDevice(args) {
3812
+ const aid = String(args.aid ?? '').trim();
3813
+ const devId = getV2DeviceId(args.dev);
3814
+ const deviceId = devId.present ? devId.value : String(args.deviceId ?? '').trim();
3815
+ const ikPk = String(args.dev.ik_pk ?? '').trim();
3816
+ if (!aid || !devId.present || !ikPk)
3817
+ return null;
3818
+ const ikPkDer = _v2B64ToBytes(ikPk);
3819
+ const spkPkDer = args.dev.spk_pk ? _v2B64ToBytes(String(args.dev.spk_pk)) : undefined;
3820
+ const keySource = String(args.dev.key_source ?? args.defaultKeySource).trim() || args.defaultKeySource;
3821
+ await this._v2VerifySPKDevice({ dev: args.dev, aid, deviceId, ikPkDer, spkPkDer, keySource });
3822
+ this._v2Session?.cachePeerIK(aid, deviceId, ikPkDer);
3823
+ return {
3824
+ aid,
3825
+ deviceId,
3826
+ role: args.role,
3827
+ keySource,
3828
+ ikPkDer,
3829
+ spkPkDer,
3830
+ spkId: String(args.dev.spk_id ?? '').trim(),
3831
+ };
2372
3832
  }
2373
3833
  async _getV2SenderPubDer(fromAid, senderDeviceId) {
2374
3834
  const session = this._v2Session;
2375
3835
  if (!session || !fromAid)
2376
3836
  return null;
2377
- let senderPubDer = session.getPeerIK(fromAid, senderDeviceId);
3837
+ const senderPubDer = session.getPeerIK(fromAid, senderDeviceId);
2378
3838
  if (senderPubDer)
2379
3839
  return senderPubDer;
2380
3840
  try {
2381
- const bs = await this.call('message.v2.bootstrap', { peer_aid: fromAid });
2382
- const peers = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
2383
- for (const dev of peers) {
2384
- const devId = String(dev.device_id ?? dev.owner_device_id ?? '');
2385
- const ikPk = String(dev.ik_pk ?? '');
2386
- if (!devId || !ikPk)
2387
- continue;
2388
- session.cachePeerIK(fromAid, devId, _v2B64ToBytes(ikPk));
2389
- }
2390
- senderPubDer = session.getPeerIK(fromAid, senderDeviceId);
2391
- if (senderPubDer)
2392
- return senderPubDer;
3841
+ const certPem = await this._fetchPeerCert(fromAid, undefined, 3000);
3842
+ const cert = new crypto.X509Certificate(certPem);
3843
+ const certPubDer = cert.publicKey.export({ type: 'spki', format: 'der' });
3844
+ const certPub = new Uint8Array(certPubDer);
3845
+ session.cachePeerIK(fromAid, senderDeviceId, certPub);
3846
+ this._clientLog.debug(`V2 decrypt: sender IK fallback from PKI cert for ${fromAid}`);
3847
+ return certPub;
3848
+ }
3849
+ catch (exc) {
3850
+ this._clientLog.warn(`V2 decrypt: PKI cert sender IK fallback failed for ${fromAid}: ${formatCaughtError(exc)}`);
3851
+ return null;
3852
+ }
3853
+ }
3854
+ _v2PendingSenderIKMessageKey(msg, groupId) {
3855
+ const messageId = String(msg.message_id ?? '').trim();
3856
+ const seq = String(msg.seq ?? '').trim();
3857
+ const prefix = groupId ? `group:${groupId}` : `p2p:${this._aid ?? ''}`;
3858
+ return `${prefix}:${messageId || seq || Math.random().toString(36).slice(2)}`;
3859
+ }
3860
+ _v2PendingSenderIKFetchKey(fromAid, senderDeviceId, groupId) {
3861
+ return `${fromAid}#${senderDeviceId}#${groupId || ''}`;
3862
+ }
3863
+ _cacheV2PeerIKFromDevice(dev, fallbackAid = '') {
3864
+ const session = this._v2Session;
3865
+ if (!session || !isJsonObject(dev))
3866
+ return;
3867
+ const device = dev;
3868
+ const devId = getV2DeviceId(device);
3869
+ const aid = String(device.aid ?? fallbackAid ?? '').trim();
3870
+ const ikPk = String(device.ik_pk ?? '').trim();
3871
+ if (!devId.present || !aid || !ikPk)
3872
+ return;
3873
+ try {
3874
+ session.cachePeerIK(aid, devId.value, _v2B64ToBytes(ikPk));
2393
3875
  }
2394
3876
  catch (exc) {
2395
- this._clientLog.warn(`V2 decrypt: bootstrap for sender ${fromAid} failed: ${formatCaughtError(exc)}`);
3877
+ this._clientLog.debug(`V2 sender IK cache from bootstrap skipped aid=${aid} dev=${devId.value}: ${formatCaughtError(exc)}`);
2396
3878
  }
3879
+ }
3880
+ _scheduleV2SenderIKPending(args) {
3881
+ const fromAid = String(args.fromAid ?? '').trim();
3882
+ if (!fromAid)
3883
+ return;
3884
+ const senderDeviceId = String(args.senderDeviceId ?? '');
3885
+ const groupId = String(args.groupId ?? '').trim();
3886
+ const messageKey = this._v2PendingSenderIKMessageKey(args.msg, groupId);
3887
+ this._v2SenderIKPending.set(messageKey, {
3888
+ msg: { ...args.msg },
3889
+ fromAid,
3890
+ senderDeviceId,
3891
+ groupId,
3892
+ createdAt: Date.now(),
3893
+ });
3894
+ this._clientLog.debug(`V2 decrypt pending sender IK: key=${messageKey} from=${fromAid} device=${senderDeviceId || '-'} group=${groupId || '<p2p>'} pending=${this._v2SenderIKPending.size}`);
3895
+ this._scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupId);
3896
+ }
3897
+ _scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupId) {
3898
+ const fetchKey = this._v2PendingSenderIKFetchKey(fromAid, senderDeviceId, groupId);
3899
+ if (!fromAid || this._v2SenderIKFetching.has(fetchKey))
3900
+ return;
3901
+ this._v2SenderIKFetching.add(fetchKey);
3902
+ this._safeAsync(this._resolveV2SenderIKPending(fromAid, senderDeviceId, groupId, fetchKey));
3903
+ }
3904
+ async _resolveV2SenderIKPending(fromAid, senderDeviceId, groupId, fetchKey) {
2397
3905
  try {
2398
- const certPem = await this._fetchPeerCert(fromAid);
2399
- const cert = new crypto.X509Certificate(certPem);
2400
- const certPubDer = cert.publicKey.export({ type: 'spki', format: 'der' });
2401
- senderPubDer = new Uint8Array(certPubDer);
2402
- if (senderDeviceId) {
2403
- session.cachePeerIK(fromAid, senderDeviceId, senderPubDer);
3906
+ const session = this._v2Session;
3907
+ if (session && fromAid) {
3908
+ try {
3909
+ const bs = await this.call('message.v2.bootstrap', {
3910
+ peer_aid: fromAid,
3911
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
3912
+ });
3913
+ await this._primeBootstrapPeerCerts(bs, fromAid);
3914
+ const peers = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
3915
+ for (const dev of peers)
3916
+ this._cacheV2PeerIKFromDevice(dev, fromAid);
3917
+ }
3918
+ catch (exc) {
3919
+ this._clientLog.warn(`V2 sender IK pending bootstrap failed peer=${fromAid}: ${formatCaughtError(exc)}`);
3920
+ }
3921
+ if (groupId) {
3922
+ try {
3923
+ const gbs = await this.call('group.v2.bootstrap', {
3924
+ group_id: groupId,
3925
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
3926
+ });
3927
+ const devices = (Array.isArray(gbs?.devices) ? gbs.devices : []);
3928
+ const audit = (Array.isArray(gbs?.audit_recipients) ? gbs.audit_recipients : []);
3929
+ for (const dev of devices)
3930
+ this._cacheV2PeerIKFromDevice(dev);
3931
+ for (const dev of audit)
3932
+ this._cacheV2PeerIKFromDevice(dev);
3933
+ }
3934
+ catch (exc) {
3935
+ this._clientLog.warn(`V2 sender IK pending group bootstrap failed group=${groupId}: ${formatCaughtError(exc)}`);
3936
+ }
3937
+ }
3938
+ if (!session.getPeerIK(fromAid, senderDeviceId)) {
3939
+ await this._getV2SenderPubDer(fromAid, senderDeviceId);
3940
+ }
3941
+ }
3942
+ const pendingItems = [...this._v2SenderIKPending.entries()].filter(([, entry]) => entry.fromAid === fromAid && entry.senderDeviceId === senderDeviceId && entry.groupId === groupId);
3943
+ for (const [key, entry] of pendingItems) {
3944
+ let plaintext = null;
3945
+ try {
3946
+ plaintext = await this._decryptV2Message(entry.msg, false);
3947
+ }
3948
+ catch (exc) {
3949
+ this._clientLog.warn(`V2 sender IK pending retry raised: key=${key} err=${formatCaughtError(exc)}`);
3950
+ }
3951
+ this._v2SenderIKPending.delete(key);
3952
+ if (plaintext === null) {
3953
+ this._clientLog.debug(`V2 sender IK pending retry failed: key=${key}`);
3954
+ continue;
3955
+ }
3956
+ const seq = Number(entry.msg.seq ?? 0);
3957
+ if (entry.groupId) {
3958
+ plaintext.group_id = entry.groupId;
3959
+ await this._publishPulledMessage('group.message_created', `group:${entry.groupId}`, seq, plaintext);
3960
+ }
3961
+ else {
3962
+ await this._publishPulledMessage('message.received', `p2p:${this._aid ?? ''}`, seq, plaintext);
3963
+ }
3964
+ this._clientLog.debug(`V2 sender IK pending retry delivered: key=${key}`);
2404
3965
  }
2405
- return senderPubDer;
2406
3966
  }
2407
- catch (exc) {
2408
- this._clientLog.warn(`V2 decrypt: CA fallback for ${fromAid} failed: ${formatCaughtError(exc)}`);
2409
- return null;
3967
+ finally {
3968
+ this._v2SenderIKFetching.delete(fetchKey);
2410
3969
  }
2411
3970
  }
2412
3971
  /**
@@ -2423,20 +3982,30 @@ export class AUNClient {
2423
3982
  const useCache = opts.useCache !== false;
2424
3983
  let peerDevices = [];
2425
3984
  let auditRaw = [];
3985
+ let wrapPolicy = normalizeV2WrapPolicy(undefined);
2426
3986
  const cached = useCache ? this._v2BootstrapCache.get(to) : undefined;
2427
3987
  if (cached && Date.now() - cached.cachedAt < AUNClient.V2_BOOTSTRAP_TTL_MS) {
2428
3988
  peerDevices = cached.devices;
2429
3989
  auditRaw = cached.auditRecipients;
3990
+ wrapPolicy = cached.wrapPolicy ?? wrapPolicy;
3991
+ this._clientLog.debug(`message.v2.bootstrap cache hit: to=${to}, devices=${peerDevices.length}, audit=${auditRaw.length}`);
2430
3992
  }
2431
3993
  else {
2432
- const bs = await this.call('message.v2.bootstrap', { peer_aid: to });
3994
+ const bs = await this.call('message.v2.bootstrap', {
3995
+ peer_aid: to,
3996
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
3997
+ });
3998
+ await this._primeBootstrapPeerCerts(bs, to);
3999
+ wrapPolicy = normalizeV2WrapPolicy(bs.e2ee_wrap_policy);
2433
4000
  peerDevices = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
2434
4001
  auditRaw = (Array.isArray(bs?.audit_recipients) ? bs.audit_recipients : []);
4002
+ this._clientLog.debug(`message.v2.bootstrap fetched: to=${to}, devices=${peerDevices.length}, audit=${auditRaw.length}`);
2435
4003
  if (peerDevices.length > 0) {
2436
4004
  this._v2BootstrapCache.set(to, {
2437
4005
  devices: peerDevices,
2438
4006
  auditRecipients: auditRaw,
2439
4007
  cachedAt: Date.now(),
4008
+ wrapPolicy,
2440
4009
  });
2441
4010
  }
2442
4011
  }
@@ -2445,37 +4014,28 @@ export class AUNClient {
2445
4014
  }
2446
4015
  const targets = [];
2447
4016
  for (const dev of peerDevices) {
2448
- const ikPk = String(dev.ik_pk ?? '');
2449
- if (!ikPk)
2450
- continue;
2451
- const devId = String(dev.device_id ?? dev.owner_device_id ?? '');
2452
- const ikDer = _v2B64ToBytes(ikPk);
2453
- const spkDer = dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined;
2454
- session.cachePeerIK(to, devId, ikDer);
2455
- targets.push({
4017
+ const devId = getV2DeviceId(dev);
4018
+ const target = await this._v2BuildTargetFromDevice({
4019
+ dev,
2456
4020
  aid: to,
2457
- deviceId: devId,
4021
+ deviceId: devId.value,
2458
4022
  role: 'peer',
2459
- keySource: String(dev.key_source ?? 'peer_device_prekey'),
2460
- ikPkDer: ikDer,
2461
- spkPkDer: spkDer,
2462
- spkId: String(dev.spk_id ?? ''),
4023
+ defaultKeySource: 'peer_device_prekey',
2463
4024
  });
4025
+ if (target)
4026
+ targets.push(target);
2464
4027
  }
2465
4028
  const auditTargets = [];
2466
4029
  for (const dev of auditRaw) {
2467
- const ikPk = String(dev.ik_pk ?? '');
2468
- if (!ikPk)
2469
- continue;
2470
- auditTargets.push({
4030
+ const target = await this._v2BuildTargetFromDevice({
4031
+ dev,
2471
4032
  aid: String(dev.aid ?? ''),
2472
4033
  deviceId: String(dev.device_id ?? ''),
2473
4034
  role: 'audit',
2474
- keySource: String(dev.key_source ?? 'peer_device_prekey'),
2475
- ikPkDer: _v2B64ToBytes(ikPk),
2476
- spkPkDer: dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined,
2477
- spkId: String(dev.spk_id ?? ''),
4035
+ defaultKeySource: 'peer_device_prekey',
2478
4036
  });
4037
+ if (target)
4038
+ auditTargets.push(target);
2479
4039
  }
2480
4040
  // self-sync:给同 AID 其它在线/注册设备也 wrap 一份。
2481
4041
  if (this._aid && this._aid !== to) {
@@ -2486,30 +4046,35 @@ export class AUNClient {
2486
4046
  selfDevices = selfCached.devices;
2487
4047
  }
2488
4048
  else {
2489
- const selfBs = await this.call('message.v2.bootstrap', { peer_aid: this._aid });
4049
+ const selfBs = await this.call('message.v2.bootstrap', {
4050
+ peer_aid: this._aid,
4051
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
4052
+ });
4053
+ await this._primeBootstrapPeerCerts(selfBs, this._aid);
2490
4054
  selfDevices = (Array.isArray(selfBs?.peer_devices) ? selfBs.peer_devices : []);
4055
+ const selfWrapPolicy = normalizeV2WrapPolicy(selfBs.e2ee_wrap_policy);
2491
4056
  if (selfDevices.length > 0) {
2492
4057
  this._v2BootstrapCache.set(this._aid, {
2493
4058
  devices: selfDevices,
2494
4059
  auditRecipients: [],
2495
4060
  cachedAt: Date.now(),
4061
+ wrapPolicy: selfWrapPolicy,
2496
4062
  });
2497
4063
  }
2498
4064
  }
2499
4065
  for (const dev of selfDevices) {
2500
- const devId = String(dev.owner_device_id ?? dev.device_id ?? '');
2501
- const ikPk = String(dev.ik_pk ?? '');
2502
- if (!devId || devId === this._deviceId || !ikPk)
4066
+ const devId = getV2DeviceId(dev);
4067
+ if (!devId.present || devId.value === this._deviceId)
2503
4068
  continue;
2504
- targets.push({
4069
+ const target = await this._v2BuildTargetFromDevice({
4070
+ dev,
2505
4071
  aid: this._aid,
2506
- deviceId: devId,
4072
+ deviceId: devId.value,
2507
4073
  role: 'self_sync',
2508
- keySource: String(dev.key_source ?? 'peer_device_prekey'),
2509
- ikPkDer: _v2B64ToBytes(ikPk),
2510
- spkPkDer: dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined,
2511
- spkId: String(dev.spk_id ?? ''),
4074
+ defaultKeySource: 'peer_device_prekey',
2512
4075
  });
4076
+ if (target)
4077
+ targets.push(target);
2513
4078
  }
2514
4079
  }
2515
4080
  catch (exc) {
@@ -2519,12 +4084,32 @@ export class AUNClient {
2519
4084
  if (targets.length === 0) {
2520
4085
  throw new E2EEError(`V2 bootstrap: no usable devices found for ${to}`);
2521
4086
  }
2522
- return encryptP2PMessage(session.getSenderIdentity(), { targets, auditRecipients: auditTargets }, opts.payload, {
4087
+ const envelope = encryptP2PMessage(session.getSenderIdentity(), {
4088
+ targets: applyV2WrapPolicyToTargets(targets, wrapPolicy),
4089
+ auditRecipients: applyV2WrapPolicyToTargets(auditTargets, wrapPolicy),
4090
+ }, opts.payload, {
2523
4091
  messageId: opts.messageId,
2524
4092
  timestamp: opts.timestamp,
2525
4093
  protectedHeaders: opts.protectedHeaders,
2526
4094
  context: opts.context,
2527
4095
  });
4096
+ this._logMessageDebug('send-envelope', 'message.send.v2', 'message.send', {
4097
+ message_id: envelope.message_id,
4098
+ to,
4099
+ type: envelope.type,
4100
+ version: envelope.version,
4101
+ protected_headers: envelope.protected_headers,
4102
+ context: envelope.context,
4103
+ }, {
4104
+ payloadOverride: envelope,
4105
+ extra: {
4106
+ plaintext_payload: opts.payload,
4107
+ target_count: targets.length,
4108
+ audit_count: auditTargets.length,
4109
+ use_cache: useCache,
4110
+ },
4111
+ });
4112
+ return envelope;
2528
4113
  }
2529
4114
  /** V2 P2P 加密发送,推测性缓存失败后刷新 bootstrap 重试一次。 */
2530
4115
  async sendV2(to, payload, opts) {
@@ -2534,7 +4119,13 @@ export class AUNClient {
2534
4119
  throw new ValidationError("message.send requires 'to'");
2535
4120
  if (!isJsonObject(payload))
2536
4121
  throw new ValidationError('message.send payload must be a dict for V2 encryption');
4122
+ this._logMessageDebug('send-plaintext', 'message.send.v2', 'message.send', {
4123
+ to: toAid,
4124
+ message_id: opts?.messageId ?? '',
4125
+ payload,
4126
+ }, { payloadOverride: payload });
2537
4127
  const attempt = async (useCache) => {
4128
+ this._clientLog.debug(`message.v2.send attempt: to=${toAid}, use_cache=${useCache}`);
2538
4129
  const envelope = await this._buildV2P2PEnvelope({
2539
4130
  to: toAid,
2540
4131
  payload,
@@ -2544,11 +4135,13 @@ export class AUNClient {
2544
4135
  context: opts?.context,
2545
4136
  useCache,
2546
4137
  });
2547
- return await this.call('message.send', {
4138
+ const result = await this.call('message.send', {
2548
4139
  to: toAid,
2549
4140
  payload: envelope,
2550
4141
  encrypt: false,
2551
4142
  });
4143
+ this._clientLog.debug(`message.v2.send ok: to=${toAid}, use_cache=${useCache}, seq=${String((isJsonObject(result) ? result.seq : '') ?? '')}`);
4144
+ return result;
2552
4145
  };
2553
4146
  try {
2554
4147
  return await attempt(true);
@@ -2564,94 +4157,153 @@ export class AUNClient {
2564
4157
  }
2565
4158
  }
2566
4159
  /** V2 P2P 拉取并解密;直接方法返回消息数组,call("message.pull") 会包装为 {messages}. */
2567
- async pullV2(afterSeq = 0, limit = 50) {
4160
+ async pullV2(afterSeq = 0, limit = 50, opts) {
2568
4161
  await this._ensureV2SessionReady('message.pull');
2569
4162
  const ns = this._aid ? `p2p:${this._aid}` : '';
2570
- const effective = afterSeq || (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
2571
- const result = await this.call('message.v2.pull', { after_seq: effective, limit });
2572
- const messages = (Array.isArray(result?.messages) ? result.messages : []);
4163
+ if (ns && !opts?.gateLocked) {
4164
+ return await this._runPullSerialized(ns, async () => this.pullV2(afterSeq, limit, {
4165
+ ...(opts ?? {}),
4166
+ gateLocked: true,
4167
+ scheduleFollowup: true,
4168
+ }));
4169
+ }
2573
4170
  const decrypted = [];
2574
- const seqs = messages
2575
- .map((msg) => Number(msg.seq ?? 0))
2576
- .filter((seq) => Number.isFinite(seq) && seq > 0);
2577
- const contigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
2578
- if (ns && seqs.length > 0 && seqs[0] > contigBefore) {
2579
- this._seqTracker.forceContiguousSeq(ns, seqs[0]);
2580
- }
2581
- for (const msg of messages) {
2582
- const seq = Number(msg.seq ?? 0);
2583
- if (!Number.isFinite(seq) || seq <= 0)
2584
- continue;
2585
- const version = String(msg.version ?? 'v2');
2586
- if (version === 'v1') {
2587
- const legacy = isJsonObject(msg.legacy_v1) ? msg.legacy_v1 : {};
2588
- const legacyPayload = legacy.payload;
2589
- const payloadType = isJsonObject(legacyPayload)
2590
- ? String(legacyPayload.type ?? '').trim()
2591
- : '';
2592
- if (legacyPayload !== undefined && legacyPayload !== null && payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
2593
- const v1Msg = {
2594
- message_id: String(msg.message_id ?? ''),
2595
- from: String(msg.from_aid ?? ''),
2596
- to: String(legacy.to ?? this._aid ?? ''),
2597
- seq: msg.seq,
2598
- type: String(msg.type ?? ''),
2599
- timestamp: msg.t_server,
2600
- payload: legacyPayload,
2601
- encrypted: false,
2602
- };
2603
- if (ns)
2604
- await this._publishPulledMessage('message.received', ns, seq, v1Msg);
2605
- else
2606
- await this._publishAppEvent('message.received', v1Msg);
2607
- decrypted.push(v1Msg);
4171
+ let totalRawCount = 0;
4172
+ let nextAfterSeq = afterSeq || (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
4173
+ let pageCount = 0;
4174
+ const maxPages = 100;
4175
+ while (pageCount < maxPages) {
4176
+ pageCount += 1;
4177
+ this._clientLog.debug(`message.v2.pull page request: page=${pageCount}, after_seq=${nextAfterSeq}, limit=${limit}, ns=${ns || '<none>'}`);
4178
+ const result = await this._callRawV2Rpc('message.v2.pull', {
4179
+ after_seq: nextAfterSeq,
4180
+ limit,
4181
+ });
4182
+ const messages = (Array.isArray(result?.messages) ? result.messages : []);
4183
+ totalRawCount += messages.length;
4184
+ this._clientLog.debug(`message.v2.pull page response: page=${pageCount}, raw_count=${messages.length}, has_more=${String(result.has_more ?? '')}, server_ack_seq=${String(result.server_ack_seq ?? '')}`);
4185
+ for (const msg of messages) {
4186
+ this._logMessageDebug('pull-raw', 'message.v2.pull', 'message.received', msg);
4187
+ }
4188
+ const seqs = messages
4189
+ .map((msg) => Number(msg.seq ?? 0))
4190
+ .filter((seq) => Number.isFinite(seq) && seq > 0);
4191
+ const pageContigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
4192
+ let pageMaxSeq = nextAfterSeq;
4193
+ if (seqs.length > 0) {
4194
+ pageMaxSeq = Math.max(...seqs);
4195
+ if (ns) {
4196
+ this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
4197
+ this._clientLog.debug(`message.v2.pull force contiguous: ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
4198
+ }
4199
+ }
4200
+ for (const msg of messages) {
4201
+ const seq = Number(msg.seq ?? 0);
4202
+ if (!Number.isFinite(seq) || seq <= 0)
4203
+ continue;
4204
+ const version = String(msg.version ?? 'v2');
4205
+ if (version === 'v1') {
4206
+ const legacy = isJsonObject(msg.legacy_v1) ? msg.legacy_v1 : {};
4207
+ const legacyPayload = legacy.payload;
4208
+ const payloadType = isJsonObject(legacyPayload)
4209
+ ? String(legacyPayload.type ?? '').trim()
4210
+ : '';
4211
+ if (legacyPayload !== undefined && legacyPayload !== null && payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
4212
+ const v1Msg = {
4213
+ message_id: String(msg.message_id ?? ''),
4214
+ from: String(msg.from_aid ?? ''),
4215
+ to: String(legacy.to ?? this._aid ?? ''),
4216
+ seq: msg.seq,
4217
+ type: String(msg.type ?? ''),
4218
+ timestamp: msg.t_server,
4219
+ payload: legacyPayload,
4220
+ encrypted: false,
4221
+ };
4222
+ if (ns) {
4223
+ await this._publishPulledMessage('message.received', ns, seq, v1Msg);
4224
+ }
4225
+ else {
4226
+ await this._publishAppEvent('message.received', v1Msg, 'pull');
4227
+ }
4228
+ decrypted.push(v1Msg);
4229
+ this._clientLog.debug(`message.v2.pull plaintext V1 delivered: seq=${seq}, ns=${ns || '<none>'}`);
4230
+ }
4231
+ else {
4232
+ this._clientLog.debug(`message.v2.pull skipping V1 envelope seq=${seq} payload_type=${payloadType || '<none>'} (V1 E2EE removed)`);
4233
+ }
4234
+ continue;
4235
+ }
4236
+ if (version !== 'v2') {
4237
+ this._clientLog.debug(`message.v2.pull skipping non-V2 row seq=${seq} version=${String(msg.version ?? '')}`);
4238
+ continue;
4239
+ }
4240
+ const spkId = String(msg.spk_id ?? '');
4241
+ if (spkId && this._v2Session && !this._v2Session.isCurrentSPK(spkId)) {
4242
+ this._v2Session.trackOldSPKMaxSeq(spkId, seq);
4243
+ }
4244
+ const plaintext = await this._decryptV2Message(msg);
4245
+ if (plaintext === null) {
4246
+ this._clientLog.debug(`message.v2.pull decrypt returned null: seq=${seq}, ns=${ns || '<none>'}`);
4247
+ continue;
4248
+ }
4249
+ if (ns) {
4250
+ await this._publishPulledMessage('message.received', ns, seq, plaintext);
2608
4251
  }
2609
4252
  else {
2610
- this._clientLog.debug(`message.v2.pull skipping V1 envelope seq=${seq} payload_type=${payloadType || '<none>'} (V1 E2EE removed)`);
4253
+ await this._publishAppEvent('message.received', plaintext, 'pull');
4254
+ }
4255
+ decrypted.push(plaintext);
4256
+ this._logMessageDebug('decrypt-ok', 'message.v2.pull', 'message.received', plaintext);
4257
+ }
4258
+ const serverAckSeq = Number(result.server_ack_seq ?? 0);
4259
+ if (ns && Number.isFinite(serverAckSeq) && serverAckSeq > 0) {
4260
+ const contig = this._seqTracker.getContiguousSeq(ns);
4261
+ if (contig < serverAckSeq) {
4262
+ this._clientLog.info(`message.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> server_ack_seq=${serverAckSeq}`);
4263
+ this._seqTracker.forceContiguousSeq(ns, serverAckSeq);
2611
4264
  }
2612
- continue;
2613
- }
2614
- if (version !== 'v2') {
2615
- this._clientLog.debug(`message.v2.pull skipping non-V2 row seq=${seq} version=${String(msg.version ?? '')}`);
2616
- continue;
2617
- }
2618
- const spkId = String(msg.spk_id ?? '');
2619
- if (spkId && this._v2Session && !this._v2Session.isCurrentSPK(spkId)) {
2620
- this._v2Session.trackOldSPKMaxSeq(spkId, seq);
2621
- }
2622
- const plaintext = await this._decryptV2Message(msg);
2623
- if (plaintext === null)
2624
- continue;
2625
- if (ns)
2626
- await this._publishPulledMessage('message.received', ns, seq, plaintext);
2627
- else
2628
- await this._publishAppEvent('message.received', plaintext);
2629
- decrypted.push(plaintext);
2630
- }
2631
- if (ns && seqs.length > 0) {
2632
- const maxSeq = Math.max(...seqs);
2633
- const contig = this._seqTracker.getContiguousSeq(ns);
2634
- if (maxSeq > contig) {
2635
- this._seqTracker.forceContiguousSeq(ns, maxSeq);
2636
- await this._drainOrderedMessages(ns);
2637
- }
2638
- const ackSeq = this._seqTracker.getContiguousSeq(ns);
2639
- if (ackSeq !== contigBefore) {
2640
- this._saveSeqTrackerState();
2641
4265
  }
2642
- if (ackSeq > 0 && ackSeq !== contigBefore) {
2643
- this._safeAsync(this.ackV2(ackSeq).then(() => undefined));
4266
+ if (ns) {
4267
+ const ackSeq = this._seqTracker.getContiguousSeq(ns);
4268
+ const contigAdvanced = ackSeq !== pageContigBefore;
4269
+ if (contigAdvanced) {
4270
+ await this._drainOrderedMessages(ns, undefined, true);
4271
+ this._saveSeqTrackerState();
4272
+ }
4273
+ if (messages.length > 0 && contigAdvanced && ackSeq > 0 && !opts?.skipAutoAck) {
4274
+ this._clientLog.debug(`message.v2.pull scheduling auto-ack: ns=${ns}, ack_seq=${ackSeq}, raw_count=${messages.length}`);
4275
+ this._safeAsync(this.ackV2(ackSeq).then(() => undefined));
4276
+ }
2644
4277
  }
4278
+ const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
4279
+ if (messages.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
4280
+ break;
4281
+ nextAfterSeq = nextAfter;
4282
+ }
4283
+ if (pageCount >= maxPages) {
4284
+ this._clientLog.warn(`message.v2.pull reached max_pages=${maxPages} after_seq=${nextAfterSeq}`);
2645
4285
  }
4286
+ this._clientLog.debug(`message.v2.pull done: requested_after_seq=${afterSeq}, pages=${pageCount}, decrypted=${decrypted.length}, ns=${ns || '<none>'}`);
2646
4287
  return decrypted;
2647
4288
  }
2648
4289
  /** V2 P2P ack,并触发旧 SPK 销毁自检。 */
2649
4290
  async ackV2(upToSeq) {
2650
4291
  const ns = this._aid ? `p2p:${this._aid}` : '';
2651
- const seq = Number(upToSeq ?? (ns ? this._seqTracker.getContiguousSeq(ns) : 0));
2652
- if (!Number.isFinite(seq) || seq <= 0)
4292
+ let seq = Number(upToSeq ?? (ns ? this._seqTracker.getContiguousSeq(ns) : 0));
4293
+ if (!Number.isFinite(seq) || seq <= 0) {
4294
+ this._clientLog.debug(`message.v2.ack skipped: ns=${ns || '<none>'}, up_to_seq=${String(upToSeq ?? '')}`);
2653
4295
  return { acked: 0 };
2654
- const raw = await this.call('message.v2.ack', { up_to_seq: seq });
4296
+ }
4297
+ // ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
4298
+ if (ns) {
4299
+ const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
4300
+ if (maxSeen > 0 && seq > maxSeen) {
4301
+ this._clientLog.warn(`ackV2 clamp: up_to_seq=${seq} > max_seen=${maxSeen}, clamp`);
4302
+ seq = maxSeen;
4303
+ }
4304
+ }
4305
+ this._clientLog.debug(`message.v2.ack send: ns=${ns || '<none>'}, up_to_seq=${seq}`);
4306
+ const raw = await this._callRawV2Rpc('message.v2.ack', { up_to_seq: seq });
2655
4307
  const result = isJsonObject(raw)
2656
4308
  ? { ...raw }
2657
4309
  : { result: raw };
@@ -2679,6 +4331,7 @@ export class AUNClient {
2679
4331
  this._clientLog.debug(`V2 SPK destroy failed (non-fatal): ${formatCaughtError(exc)}`);
2680
4332
  }
2681
4333
  }
4334
+ this._clientLog.debug(`message.v2.ack ok: ns=${ns || '<none>'}, requested=${seq}, effective=${actualAckSeq}, acked=${String(result.acked ?? '')}`);
2682
4335
  return result;
2683
4336
  }
2684
4337
  /** V2 Group 加密发送,推测性缓存失败后刷新 bootstrap 重试一次。 */
@@ -2689,7 +4342,13 @@ export class AUNClient {
2689
4342
  throw new ValidationError("group.send requires 'group_id'");
2690
4343
  if (!isJsonObject(payload))
2691
4344
  throw new ValidationError('group.send payload must be a dict for V2 encryption');
4345
+ this._logMessageDebug('send-plaintext', 'group.send.v2', 'group.send', {
4346
+ group_id: gid,
4347
+ message_id: opts?.messageId ?? '',
4348
+ payload,
4349
+ }, { payloadOverride: payload });
2692
4350
  const attempt = async (useCache) => {
4351
+ this._clientLog.debug(`group.v2.send attempt: group=${gid}, use_cache=${useCache}`);
2693
4352
  const envelope = await this._buildV2GroupEnvelope({
2694
4353
  groupId: gid,
2695
4354
  payload,
@@ -2699,10 +4358,12 @@ export class AUNClient {
2699
4358
  context: opts?.context,
2700
4359
  useCache,
2701
4360
  });
2702
- return await this.call('group.v2.send', {
4361
+ const result = await this.call('group.v2.send', {
2703
4362
  group_id: gid,
2704
4363
  envelope: envelope,
2705
4364
  });
4365
+ this._clientLog.debug(`group.v2.send ok: group=${gid}, use_cache=${useCache}, seq=${String((isJsonObject(result) ? result.seq : '') ?? '')}`);
4366
+ return result;
2706
4367
  };
2707
4368
  const markSentSeq = (result) => {
2708
4369
  if (!isJsonObject(result))
@@ -2715,6 +4376,7 @@ export class AUNClient {
2715
4376
  this._seqTracker.onMessageSeq(ns, seq);
2716
4377
  this._markPublishedSeq(ns, seq);
2717
4378
  this._saveSeqTrackerState();
4379
+ this._clientLog.debug(`group.v2.send marked own seq: group=${gid}, ns=${ns}, seq=${seq}`);
2718
4380
  };
2719
4381
  try {
2720
4382
  const result = await attempt(true);
@@ -2747,18 +4409,26 @@ export class AUNClient {
2747
4409
  let auditRecipientsRaw = [];
2748
4410
  let epoch = 0;
2749
4411
  let stateCommitment = { state_version: 0, state_hash: '', state_chain: '' };
4412
+ let wrapPolicy = normalizeV2WrapPolicy(undefined);
2750
4413
  const cached = useCache ? this._v2BootstrapCache.get(cacheKey) : undefined;
2751
4414
  if (cached && Date.now() - cached.cachedAt < AUNClient.V2_BOOTSTRAP_TTL_MS) {
2752
4415
  allDevices = cached.devices;
2753
4416
  auditRecipientsRaw = cached.auditRecipients;
2754
4417
  epoch = cached.epoch ?? 0;
2755
4418
  stateCommitment = cached.stateCommitment ?? stateCommitment;
4419
+ wrapPolicy = cached.wrapPolicy ?? wrapPolicy;
4420
+ this._clientLog.debug(`group.v2.bootstrap cache hit: group=${groupId}, devices=${allDevices.length}, audit=${auditRecipientsRaw.length}, epoch=${epoch}, state_version=${stateCommitment.state_version}`);
2756
4421
  }
2757
4422
  else {
2758
- const bs = await this.call('group.v2.bootstrap', { group_id: groupId });
4423
+ const bs = await this.call('group.v2.bootstrap', {
4424
+ group_id: groupId,
4425
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
4426
+ });
2759
4427
  allDevices = (Array.isArray(bs.devices) ? bs.devices : []);
2760
4428
  auditRecipientsRaw = (Array.isArray(bs.audit_recipients) ? bs.audit_recipients : []);
2761
4429
  epoch = Number(bs.epoch ?? 0) || 0;
4430
+ wrapPolicy = normalizeV2WrapPolicy(bs.e2ee_wrap_policy);
4431
+ this._clientLog.debug(`group.v2.bootstrap fetched: group=${groupId}, devices=${allDevices.length}, audit=${auditRecipientsRaw.length}, epoch=${epoch}, members=${Array.isArray(bs.member_aids) ? bs.member_aids.length : 0}`);
2762
4432
  const stateChain = String(bs.state_chain ?? '');
2763
4433
  await this._v2CheckFork(groupId, stateChain);
2764
4434
  await this._v2VerifyStateSignature(groupId, bs);
@@ -2775,6 +4445,7 @@ export class AUNClient {
2775
4445
  cachedAt: Date.now(),
2776
4446
  epoch,
2777
4447
  stateCommitment,
4448
+ wrapPolicy,
2778
4449
  });
2779
4450
  }
2780
4451
  // lazy sync 触发:发现 pending members 时异步发起提案
@@ -2789,83 +4460,135 @@ export class AUNClient {
2789
4460
  const targets = [];
2790
4461
  for (const dev of allDevices) {
2791
4462
  const devAid = String(dev.aid ?? '').trim();
2792
- const devId = String(dev.device_id ?? '').trim();
2793
- const ikPk = String(dev.ik_pk ?? '').trim();
2794
- if (!devAid || !devId || !ikPk)
2795
- continue;
2796
- if (devAid === this._aid && devId === this._deviceId)
4463
+ const devId = getV2DeviceId(dev);
4464
+ if (devAid === this._aid && devId.present && devId.value === this._deviceId)
2797
4465
  continue;
2798
4466
  const role = devAid === this._aid ? 'self_sync' : 'member';
2799
- targets.push({
4467
+ const target = await this._v2BuildTargetFromDevice({
4468
+ dev,
2800
4469
  aid: devAid,
2801
- deviceId: devId,
4470
+ deviceId: devId.value,
2802
4471
  role,
2803
- keySource: String(dev.key_source ?? 'peer_device_prekey'),
2804
- ikPkDer: _v2B64ToBytes(ikPk),
2805
- spkPkDer: dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined,
2806
- spkId: String(dev.spk_id ?? ''),
4472
+ defaultKeySource: 'peer_device_prekey',
2807
4473
  });
4474
+ if (target)
4475
+ targets.push(target);
2808
4476
  }
2809
4477
  if (targets.length === 0) {
2810
4478
  throw new E2EEError(`V2 group: no target devices for group ${groupId}`);
2811
4479
  }
2812
4480
  for (const dev of auditRecipientsRaw) {
2813
- const ikPk = String(dev.ik_pk ?? '').trim();
2814
- if (!ikPk)
2815
- continue;
2816
- targets.push({
4481
+ const target = await this._v2BuildTargetFromDevice({
4482
+ dev,
2817
4483
  aid: String(dev.aid ?? ''),
2818
4484
  deviceId: String(dev.device_id ?? ''),
2819
4485
  role: 'audit',
2820
- keySource: String(dev.key_source ?? 'peer_device_prekey'),
2821
- ikPkDer: _v2B64ToBytes(ikPk),
2822
- spkPkDer: dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined,
2823
- spkId: String(dev.spk_id ?? ''),
4486
+ defaultKeySource: 'peer_device_prekey',
2824
4487
  });
4488
+ if (target)
4489
+ targets.push(target);
2825
4490
  }
2826
- return encryptGroupMessage(session.getSenderIdentity(), groupId, epoch, targets, opts.payload, {
4491
+ const envelope = encryptGroupMessage(session.getSenderIdentity(), groupId, epoch, applyV2WrapPolicyToTargets(targets, wrapPolicy), opts.payload, {
2827
4492
  messageId: opts.messageId,
2828
4493
  timestamp: opts.timestamp,
2829
4494
  protectedHeaders: opts.protectedHeaders,
2830
4495
  context: opts.context,
2831
4496
  }, stateCommitment);
4497
+ this._logMessageDebug('send-envelope', 'group.send.v2', 'group.send', {
4498
+ group_id: groupId,
4499
+ message_id: envelope.message_id,
4500
+ type: envelope.type,
4501
+ version: envelope.version,
4502
+ protected_headers: envelope.protected_headers,
4503
+ context: envelope.context,
4504
+ }, {
4505
+ payloadOverride: envelope,
4506
+ extra: {
4507
+ plaintext_payload: opts.payload,
4508
+ epoch,
4509
+ target_count: targets.length,
4510
+ audit_count: auditRecipientsRaw.length,
4511
+ state_version: stateCommitment.state_version,
4512
+ use_cache: useCache,
4513
+ },
4514
+ });
4515
+ return envelope;
2832
4516
  }
2833
4517
  async _pullGroupV2Internal(params) {
2834
- await this.pullGroupV2(params.group_id, params.after_seq, params.limit);
4518
+ await this.pullGroupV2(params.group_id, params.after_seq, params.limit, { gateLocked: true });
2835
4519
  }
2836
4520
  /** V2 Group 拉取并解密;直接方法返回消息数组,call("group.pull") 会包装为 {messages}. */
2837
- async pullGroupV2(groupId, afterSeq = 0, limit = 50) {
4521
+ async pullGroupV2(groupId, afterSeq = 0, limit = 50, opts) {
2838
4522
  await this._ensureV2SessionReady('group.pull');
2839
4523
  const gid = normalizeGroupId(groupId) || String(groupId ?? '').trim();
2840
4524
  if (!gid)
2841
4525
  throw new ValidationError('group.pull requires group_id');
2842
4526
  const ns = `group:${gid}`;
2843
- const effective = afterSeq || this._seqTracker.getContiguousSeq(ns);
2844
- const result = await this.call('group.v2.pull', {
2845
- group_id: gid,
2846
- after_seq: effective,
2847
- limit,
2848
- });
2849
- const messages = (Array.isArray(result.messages) ? result.messages : []);
2850
- const decrypted = [];
2851
- const seqs = messages
2852
- .map((msg) => Number(msg.seq ?? 0))
2853
- .filter((seq) => Number.isFinite(seq) && seq > 0);
2854
- const contigBefore = this._seqTracker.getContiguousSeq(ns);
2855
- if (seqs.length > 0 && seqs[0] > contigBefore) {
2856
- this._seqTracker.forceContiguousSeq(ns, seqs[0]);
4527
+ if (!opts?.gateLocked) {
4528
+ return await this._runPullSerialized(ns, async () => this.pullGroupV2(gid, afterSeq, limit, {
4529
+ ...(opts ?? {}),
4530
+ gateLocked: true,
4531
+ scheduleFollowup: true,
4532
+ }));
2857
4533
  }
2858
- for (const msg of messages) {
2859
- const seq = Number(msg.seq ?? 0);
2860
- if (!Number.isFinite(seq) || seq <= 0)
2861
- continue;
2862
- const version = String(msg.version ?? 'v2');
2863
- if (version === 'v1') {
2864
- const payload = msg.payload;
2865
- const payloadObj = isJsonObject(payload) ? payload : null;
2866
- if (payloadObj) {
2867
- const payloadType = String(payloadObj.type ?? '').trim();
2868
- if (payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
4534
+ const decrypted = [];
4535
+ let totalRawCount = 0;
4536
+ let nextAfterSeq = afterSeq || this._seqTracker.getContiguousSeq(ns);
4537
+ let pageCount = 0;
4538
+ const maxPages = 100;
4539
+ while (pageCount < maxPages) {
4540
+ pageCount += 1;
4541
+ this._clientLog.debug(`group.v2.pull page request: group=${gid}, page=${pageCount}, after_seq=${nextAfterSeq}, limit=${limit}, ns=${ns}`);
4542
+ const result = await this._callRawV2Rpc('group.v2.pull', {
4543
+ group_id: gid,
4544
+ after_seq: nextAfterSeq,
4545
+ limit,
4546
+ });
4547
+ const messages = (Array.isArray(result.messages) ? result.messages : []);
4548
+ totalRawCount += messages.length;
4549
+ const cursor = isJsonObject(result.cursor) ? result.cursor : null;
4550
+ this._clientLog.debug(`group.v2.pull page response: group=${gid}, page=${pageCount}, raw_count=${messages.length}, has_more=${String(result.has_more ?? '')}, cursor_current=${String(cursor?.current_seq ?? '')}`);
4551
+ for (const msg of messages) {
4552
+ this._logMessageDebug('pull-raw', 'group.v2.pull', 'group.message_created', msg);
4553
+ }
4554
+ const seqs = messages
4555
+ .map((msg) => Number(msg.seq ?? 0))
4556
+ .filter((seq) => Number.isFinite(seq) && seq > 0);
4557
+ const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
4558
+ let pageMaxSeq = nextAfterSeq;
4559
+ if (seqs.length > 0) {
4560
+ pageMaxSeq = Math.max(...seqs);
4561
+ this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
4562
+ this._clientLog.debug(`group.v2.pull force contiguous: group=${gid}, ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
4563
+ }
4564
+ for (const msg of messages) {
4565
+ const seq = Number(msg.seq ?? 0);
4566
+ if (!Number.isFinite(seq) || seq <= 0)
4567
+ continue;
4568
+ const version = String(msg.version ?? 'v2');
4569
+ if (version === 'v1') {
4570
+ const payload = msg.payload;
4571
+ const payloadObj = isJsonObject(payload) ? payload : null;
4572
+ if (payloadObj) {
4573
+ const payloadType = String(payloadObj.type ?? '').trim();
4574
+ if (payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
4575
+ const v1Msg = {
4576
+ message_id: String(msg.message_id ?? ''),
4577
+ from: String(msg.from_aid ?? ''),
4578
+ group_id: gid,
4579
+ seq: msg.seq,
4580
+ type: String(msg.type ?? ''),
4581
+ timestamp: msg.t_server,
4582
+ payload,
4583
+ encrypted: false,
4584
+ };
4585
+ await this._publishPulledMessage('group.message_created', ns, seq, v1Msg);
4586
+ decrypted.push(v1Msg);
4587
+ this._clientLog.debug(`group.v2.pull plaintext V1 delivered: group=${gid}, seq=${seq}`);
4588
+ continue;
4589
+ }
4590
+ }
4591
+ else if (payload !== undefined && payload !== null) {
2869
4592
  const v1Msg = {
2870
4593
  message_id: String(msg.message_id ?? ''),
2871
4594
  from: String(msg.from_aid ?? ''),
@@ -2878,53 +4601,53 @@ export class AUNClient {
2878
4601
  };
2879
4602
  await this._publishPulledMessage('group.message_created', ns, seq, v1Msg);
2880
4603
  decrypted.push(v1Msg);
4604
+ this._clientLog.debug(`group.v2.pull plaintext V1 delivered: group=${gid}, seq=${seq}`);
2881
4605
  continue;
2882
4606
  }
4607
+ this._clientLog.debug(`group.v2.pull skipping V1 envelope group=${gid} seq=${seq} payload_type=${payloadObj ? String(payloadObj.type ?? '') : '<none>'} (V1 E2EE removed)`);
4608
+ continue;
2883
4609
  }
2884
- else if (payload !== undefined && payload !== null) {
2885
- const v1Msg = {
2886
- message_id: String(msg.message_id ?? ''),
2887
- from: String(msg.from_aid ?? ''),
2888
- group_id: gid,
2889
- seq: msg.seq,
2890
- type: String(msg.type ?? ''),
2891
- timestamp: msg.t_server,
2892
- payload,
2893
- encrypted: false,
2894
- };
2895
- await this._publishPulledMessage('group.message_created', ns, seq, v1Msg);
2896
- decrypted.push(v1Msg);
4610
+ if (version !== 'v2') {
4611
+ this._clientLog.debug(`group.v2.pull skipping non-V2 row group=${gid} seq=${seq} version=${String(msg.version ?? '')}`);
2897
4612
  continue;
2898
4613
  }
2899
- this._clientLog.debug(`group.v2.pull skipping V1 envelope group=${gid} seq=${seq} payload_type=${payloadObj ? String(payloadObj.type ?? '') : '<none>'} (V1 E2EE removed)`);
2900
- continue;
4614
+ const plaintext = await this._decryptV2Message(msg);
4615
+ if (plaintext === null) {
4616
+ this._clientLog.debug(`group.v2.pull decrypt returned null: group=${gid}, seq=${seq}`);
4617
+ continue;
4618
+ }
4619
+ plaintext.group_id = gid;
4620
+ await this._publishPulledMessage('group.message_created', ns, seq, plaintext);
4621
+ decrypted.push(plaintext);
4622
+ this._logMessageDebug('decrypt-ok', 'group.v2.pull', 'group.message_created', plaintext);
4623
+ }
4624
+ const retentionFloor = this._pullRetentionFloor(result, 'retention_floor_message_seq', 'retention_floor_message_seq');
4625
+ if (retentionFloor > 0) {
4626
+ const contig = this._seqTracker.getContiguousSeq(ns);
4627
+ if (contig < retentionFloor) {
4628
+ this._clientLog.info(`group.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> retention_floor=${retentionFloor}`);
4629
+ this._seqTracker.forceContiguousSeq(ns, retentionFloor);
4630
+ }
2901
4631
  }
2902
- if (version !== 'v2') {
2903
- this._clientLog.debug(`group.v2.pull skipping non-V2 row group=${gid} seq=${seq} version=${String(msg.version ?? '')}`);
2904
- continue;
4632
+ const ackSeq = this._seqTracker.getContiguousSeq(ns);
4633
+ const contigAdvanced = ackSeq !== pageContigBefore;
4634
+ if (contigAdvanced) {
4635
+ await this._drainOrderedMessages(ns, undefined, true);
4636
+ this._saveSeqTrackerState();
2905
4637
  }
2906
- const plaintext = await this._decryptV2Message(msg);
2907
- if (plaintext === null)
2908
- continue;
2909
- plaintext.group_id = gid;
2910
- await this._publishPulledMessage('group.message_created', ns, seq, plaintext);
2911
- decrypted.push(plaintext);
2912
- }
2913
- if (seqs.length > 0) {
2914
- const maxSeq = Math.max(...seqs);
2915
- const contig = this._seqTracker.getContiguousSeq(ns);
2916
- if (maxSeq > contig) {
2917
- this._seqTracker.forceContiguousSeq(ns, maxSeq);
2918
- await this._drainOrderedMessages(ns);
4638
+ if (messages.length > 0 && contigAdvanced && ackSeq > 0) {
4639
+ this._clientLog.debug(`group.v2.pull scheduling auto-ack: group=${gid}, ns=${ns}, ack_seq=${ackSeq}, raw_count=${messages.length}`);
4640
+ this._safeAsync(this.ackGroupV2(gid, ackSeq).then(() => undefined));
2919
4641
  }
4642
+ const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
4643
+ if (messages.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
4644
+ break;
4645
+ nextAfterSeq = nextAfter;
2920
4646
  }
2921
- const ackSeq = this._seqTracker.getContiguousSeq(ns);
2922
- if (ackSeq !== contigBefore) {
2923
- this._saveSeqTrackerState();
2924
- }
2925
- if (ackSeq > 0 && ackSeq !== contigBefore) {
2926
- this._safeAsync(this.ackGroupV2(gid, ackSeq).then(() => undefined));
4647
+ if (pageCount >= maxPages) {
4648
+ this._clientLog.warn(`group.v2.pull reached max_pages=${maxPages} group=${gid} after_seq=${nextAfterSeq}`);
2927
4649
  }
4650
+ this._clientLog.debug(`group.v2.pull done: group=${gid}, requested_after_seq=${afterSeq}, pages=${pageCount}, decrypted=${decrypted.length}, ns=${ns}`);
2928
4651
  return decrypted;
2929
4652
  }
2930
4653
  /** V2 Group ack。 */
@@ -2933,13 +4656,24 @@ export class AUNClient {
2933
4656
  if (!gid)
2934
4657
  throw new ValidationError('group.ack_messages requires group_id');
2935
4658
  const ns = `group:${gid}`;
2936
- const seq = Number(upToSeq ?? this._seqTracker.getContiguousSeq(ns));
2937
- if (!Number.isFinite(seq) || seq <= 0)
4659
+ let seq = Number(upToSeq ?? this._seqTracker.getContiguousSeq(ns));
4660
+ if (!Number.isFinite(seq) || seq <= 0) {
4661
+ this._clientLog.debug(`group.v2.ack skipped: group=${gid}, ns=${ns}, up_to_seq=${String(upToSeq ?? '')}`);
2938
4662
  return { acked: 0 };
2939
- return await this.call('group.v2.ack', { group_id: gid, up_to_seq: seq });
4663
+ }
4664
+ // ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
4665
+ const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
4666
+ if (maxSeen > 0 && seq > maxSeen) {
4667
+ this._clientLog.warn(`ackGroupV2 clamp: group=${gid} up_to_seq=${seq} > max_seen=${maxSeen}, clamp`);
4668
+ seq = maxSeen;
4669
+ }
4670
+ this._clientLog.debug(`group.v2.ack send: group=${gid}, ns=${ns}, up_to_seq=${seq}`);
4671
+ const result = await this._callRawV2Rpc('group.v2.ack', { group_id: gid, up_to_seq: seq });
4672
+ this._clientLog.debug(`group.v2.ack ok: group=${gid}, ns=${ns}, requested=${seq}, result=${this._debugJson(result)}`);
4673
+ return result;
2940
4674
  }
2941
- /** 解密单条 V2 pull 消息。失败返回 null 并发布 undecryptable。 */
2942
- async _decryptV2Message(msg) {
4675
+ /** 解密单条 V2 pull 消息。缺 sender IK 时先入 pending,后台补齐后重试。 */
4676
+ async _decryptV2Message(msg, allowPending = true) {
2943
4677
  const session = this._v2Session;
2944
4678
  if (!session)
2945
4679
  return null;
@@ -2954,6 +4688,8 @@ export class AUNClient {
2954
4688
  this._clientLog.warn(`V2 decrypt: invalid envelope_json for msg seq=${String(msg.seq)}`);
2955
4689
  return null;
2956
4690
  }
4691
+ const e2eeMeta = this._v2E2eeMeta(envelope);
4692
+ this._observeAgentMdFromEnvelope(envelope);
2957
4693
  let spkId = '';
2958
4694
  let recipientKeySource = '';
2959
4695
  if (isJsonObject(envelope.recipient)) {
@@ -2968,7 +4704,7 @@ export class AUNClient {
2968
4704
  for (const row of recipients) {
2969
4705
  if (Array.isArray(row) && row.length >= 6
2970
4706
  && String(row[0] ?? '') === this._aid
2971
- && String(row[1] ?? '') === this._deviceId) {
4707
+ && (String(row[1] ?? '') === this._deviceId || String(row[1] ?? '') === '')) {
2972
4708
  if (!spkId)
2973
4709
  spkId = String(row[5] ?? '');
2974
4710
  if (row.length > 3)
@@ -2977,31 +4713,73 @@ export class AUNClient {
2977
4713
  }
2978
4714
  }
2979
4715
  }
2980
- // 根据 aad.group_id 判断使用 group SPK 还是 P2P SPK
4716
+ // group_id 只表示群上下文;getGroupDecryptKeys 内部必须按 group SPK -> P2P device SPK -> IK fallback 查找。
2981
4717
  const aad = isJsonObject(envelope.aad) ? envelope.aad : {};
2982
4718
  const groupIdForKeys = String(aad.group_id ?? msg.group_id ?? '').trim();
4719
+ const undecryptableEvent = groupIdForKeys ? 'group.message_undecryptable' : 'message.undecryptable';
4720
+ this._clientLog.debug(`V2 decrypt start: seq=${String(msg.seq ?? '')}, message_id=${String(msg.message_id ?? '')}, group=${groupIdForKeys || '<p2p>'}, from=${String(msg.from_aid ?? '')}, spk_id=${spkId || '<empty>'}, key_source=${recipientKeySource || '<empty>'}, has_recipient=${String(isJsonObject(envelope.recipient))}, has_recipients=${String(Array.isArray(envelope.recipients))}`);
2983
4721
  let ikPriv;
2984
4722
  let spkPriv;
2985
- if (groupIdForKeys) {
2986
- const keys = session.getGroupDecryptKeys(groupIdForKeys, spkId);
2987
- ikPriv = keys.ikPriv;
2988
- spkPriv = keys.spkPriv ?? undefined;
4723
+ try {
4724
+ if (groupIdForKeys) {
4725
+ const keys = session.getGroupDecryptKeys(groupIdForKeys, spkId);
4726
+ ikPriv = keys.ikPriv;
4727
+ spkPriv = keys.spkPriv ?? undefined;
4728
+ }
4729
+ else {
4730
+ const keys = session.getDecryptKeys(spkId);
4731
+ ikPriv = keys.ikPriv;
4732
+ spkPriv = keys.spkPriv;
4733
+ }
2989
4734
  }
2990
- else {
2991
- const keys = session.getDecryptKeys(spkId);
2992
- ikPriv = keys.ikPriv;
2993
- spkPriv = keys.spkPriv;
4735
+ catch (exc) {
4736
+ this._clientLog.warn(`V2 decrypt: SPK lookup failed seq=${String(msg.seq)} spk_id=${spkId}: ${formatCaughtError(exc)}`);
4737
+ const event = {
4738
+ message_id: String(msg.message_id ?? ''),
4739
+ from: String(msg.from_aid ?? ''),
4740
+ to: String(msg.to ?? ''),
4741
+ seq: msg.seq,
4742
+ timestamp: (msg.t_server ?? msg.timestamp),
4743
+ device_id: String(msg.device_id ?? ''),
4744
+ slot_id: String(msg.slot_id ?? ''),
4745
+ _decrypt_error: String(formatCaughtError(exc)),
4746
+ _decrypt_stage: 'spk_lookup',
4747
+ _envelope_type: String(envelope.type ?? ''),
4748
+ _suite: String(envelope.suite ?? ''),
4749
+ _spk_id: spkId,
4750
+ };
4751
+ this._attachV2EnvelopeMetadata(event, e2eeMeta);
4752
+ this._logMessageDebug('decrypt-fail', 'v2.decrypt', undecryptableEvent, event);
4753
+ await this._dispatcher.publish(undecryptableEvent, event);
4754
+ return null;
2994
4755
  }
4756
+ this._clientLog.debug(`V2 decrypt key lookup ok: seq=${String(msg.seq ?? '')}, group=${groupIdForKeys || '<p2p>'}, ik_len=${ikPriv.byteLength}, spk_len=${spkPriv?.byteLength ?? 0}`);
2995
4757
  const fromAid = String(msg.from_aid ?? '');
2996
4758
  const senderDeviceId = String(aad.from_device ?? '');
2997
4759
  const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId);
2998
4760
  if (!senderPubDer) {
2999
- await this._dispatcher.publish('message.undecryptable', {
4761
+ this._clientLog.warn(`V2 decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
4762
+ if (allowPending) {
4763
+ this._scheduleV2SenderIKPending({ msg, fromAid, senderDeviceId, groupId: groupIdForKeys });
4764
+ return null;
4765
+ }
4766
+ const event = {
3000
4767
  message_id: String(msg.message_id ?? ''),
3001
4768
  from: fromAid,
4769
+ to: String(msg.to ?? ''),
3002
4770
  seq: msg.seq,
4771
+ timestamp: (msg.t_server ?? msg.timestamp),
4772
+ device_id: String(msg.device_id ?? ''),
4773
+ slot_id: String(msg.slot_id ?? ''),
3003
4774
  _decrypt_error: 'sender_ik_not_found',
3004
- });
4775
+ _decrypt_stage: 'sender_ik',
4776
+ _envelope_type: String(envelope.type ?? ''),
4777
+ _suite: String(envelope.suite ?? ''),
4778
+ _sender_device_id: String(aad.from_device ?? ''),
4779
+ };
4780
+ this._attachV2EnvelopeMetadata(event, e2eeMeta);
4781
+ this._logMessageDebug('decrypt-fail', 'v2.decrypt', undecryptableEvent, event);
4782
+ await this._dispatcher.publish(undecryptableEvent, event);
3005
4783
  return null;
3006
4784
  }
3007
4785
  let plaintext;
@@ -3010,16 +4788,29 @@ export class AUNClient {
3010
4788
  }
3011
4789
  catch (exc) {
3012
4790
  this._clientLog.warn(`V2 decrypt failed for msg seq=${String(msg.seq)}: ${formatCaughtError(exc)}`);
3013
- await this._dispatcher.publish('message.undecryptable', {
4791
+ const event = {
3014
4792
  message_id: String(msg.message_id ?? ''),
3015
4793
  from: fromAid,
4794
+ to: String(msg.to ?? ''),
3016
4795
  seq: msg.seq,
4796
+ timestamp: (msg.t_server ?? msg.timestamp),
4797
+ device_id: String(msg.device_id ?? ''),
4798
+ slot_id: String(msg.slot_id ?? ''),
3017
4799
  _decrypt_error: String(formatCaughtError(exc)),
3018
- });
4800
+ _decrypt_stage: 'decrypt',
4801
+ _envelope_type: String(envelope.type ?? ''),
4802
+ _suite: String(envelope.suite ?? ''),
4803
+ _sender_device_id: String(aad.from_device ?? ''),
4804
+ };
4805
+ this._attachV2EnvelopeMetadata(event, e2eeMeta);
4806
+ this._logMessageDebug('decrypt-fail', 'v2.decrypt', undecryptableEvent, event);
4807
+ await this._dispatcher.publish(undecryptableEvent, event);
3019
4808
  return null;
3020
4809
  }
3021
- if (plaintext === null)
4810
+ if (plaintext === null) {
4811
+ this._clientLog.debug(`V2 decrypt returned null plaintext: seq=${String(msg.seq ?? '')}, group=${groupIdForKeys || '<p2p>'}`);
3022
4812
  return null;
4813
+ }
3023
4814
  // 消费触发 SPK 轮换
3024
4815
  if (groupIdForKeys && recipientKeySource === 'group_device_prekey' && session.isLastUploadedGroupSPK(groupIdForKeys, spkId)) {
3025
4816
  // Group SPK 消费触发轮换
@@ -3042,8 +4833,8 @@ export class AUNClient {
3042
4833
  this._clientLog.debug(`V2 SPK rotation failed (non-fatal): ${formatCaughtError(exc)}`);
3043
4834
  });
3044
4835
  }
3045
- const suite = String(envelope.suite ?? '');
3046
- return {
4836
+ const e2ee = this._v2E2eeMeta(envelope);
4837
+ const result = {
3047
4838
  message_id: String(msg.message_id ?? ''),
3048
4839
  from: fromAid,
3049
4840
  to: this._aid ?? '',
@@ -3051,13 +4842,84 @@ export class AUNClient {
3051
4842
  t_server: msg.t_server,
3052
4843
  payload: plaintext,
3053
4844
  encrypted: true,
3054
- e2ee: {
3055
- version: 'v2',
3056
- suite,
3057
- encryption_mode: `v2_${suite || 'unknown'}`,
3058
- forward_secrecy: true,
3059
- },
4845
+ e2ee,
4846
+ };
4847
+ this._attachV2EnvelopeMetadata(result, e2ee);
4848
+ this._logMessageDebug('decrypt-ok', 'v2.decrypt', groupIdForKeys ? 'group.message_created' : 'message.received', result);
4849
+ return result;
4850
+ }
4851
+ _v2E2eeMeta(envelope) {
4852
+ const suite = String(envelope.suite ?? '');
4853
+ const meta = {
4854
+ version: 'v2',
4855
+ suite,
4856
+ encryption_mode: `v2_${suite || 'unknown'}`,
4857
+ forward_secrecy: true,
3060
4858
  };
4859
+ const protectedHeaders = this._metadataWithoutAuth(envelope.protected_headers);
4860
+ if (protectedHeaders && Object.keys(protectedHeaders).length > 0) {
4861
+ meta.protected_headers = protectedHeaders;
4862
+ }
4863
+ const payloadType = String(envelope.payload_type ?? protectedHeaders?.payload_type ?? '').trim();
4864
+ if (payloadType) {
4865
+ meta.payload_type = payloadType;
4866
+ }
4867
+ const context = this._metadataWithoutAuth(envelope.context);
4868
+ if (context && Object.keys(context).length > 0) {
4869
+ meta.context = context;
4870
+ }
4871
+ const agentMd = this._metadataWithoutAuth(envelope.agent_md);
4872
+ if (agentMd && Object.keys(agentMd).length > 0) {
4873
+ meta.agent_md = agentMd;
4874
+ }
4875
+ return meta;
4876
+ }
4877
+ _attachV2EnvelopeMetadata(message, meta) {
4878
+ const payloadType = typeof meta.payload_type === 'string' ? meta.payload_type.trim() : '';
4879
+ if (payloadType)
4880
+ message.payload_type = payloadType;
4881
+ if (isJsonObject(meta.protected_headers)) {
4882
+ message.protected_headers = { ...meta.protected_headers };
4883
+ }
4884
+ if (isJsonObject(meta.agent_md)) {
4885
+ message.agent_md = { ...meta.agent_md };
4886
+ }
4887
+ }
4888
+ _attachV2EnvelopeMetadataFromSource(message, source) {
4889
+ const envelope = this._extractV2EnvelopeFromSource(source);
4890
+ if (envelope) {
4891
+ this._observeAgentMdFromEnvelope(envelope);
4892
+ this._attachV2EnvelopeMetadata(message, this._v2E2eeMeta(envelope));
4893
+ }
4894
+ }
4895
+ _extractV2EnvelopeFromSource(source) {
4896
+ const candidate = source;
4897
+ if (!isJsonObject(candidate))
4898
+ return null;
4899
+ if (isJsonObject(candidate.payload))
4900
+ return candidate.payload;
4901
+ if (typeof candidate.envelope_json === 'string' && candidate.envelope_json) {
4902
+ try {
4903
+ const parsed = JSON.parse(candidate.envelope_json);
4904
+ if (isJsonObject(parsed))
4905
+ return parsed;
4906
+ }
4907
+ catch {
4908
+ return null;
4909
+ }
4910
+ }
4911
+ return null;
4912
+ }
4913
+ _metadataWithoutAuth(value) {
4914
+ const candidate = value;
4915
+ if (!isJsonObject(candidate))
4916
+ return null;
4917
+ const body = {};
4918
+ for (const [key, item] of Object.entries(candidate)) {
4919
+ if (key !== '_auth')
4920
+ body[key] = item;
4921
+ }
4922
+ return body;
3061
4923
  }
3062
4924
  async _putMessageThoughtEncryptedV2(params) {
3063
4925
  const toAid = String(params.to ?? '').trim();
@@ -3070,7 +4932,14 @@ export class AUNClient {
3070
4932
  const thoughtId = String(params.thought_id ?? '').trim() || `mt-${crypto.randomUUID()}`;
3071
4933
  const timestamp = Number(params.timestamp ?? Date.now());
3072
4934
  const protectedHeaders = this._protectedHeadersFromParams(params);
4935
+ this._logMessageDebug('thought-send-plaintext', 'message.thought.put.v2', 'message.thought.put', {
4936
+ to: toAid,
4937
+ thought_id: thoughtId,
4938
+ timestamp,
4939
+ payload,
4940
+ }, { payloadOverride: payload });
3073
4941
  const attempt = async (useCache) => {
4942
+ this._clientLog.debug(`message.thought.put attempt: to=${toAid}, thought_id=${thoughtId}, use_cache=${useCache}`);
3074
4943
  const context = isJsonObject(params.context) ? params.context : undefined;
3075
4944
  const envelope = await this._buildV2P2PEnvelope({
3076
4945
  to: toAid,
@@ -3091,7 +4960,13 @@ export class AUNClient {
3091
4960
  if ('context' in params)
3092
4961
  sendParams.context = params.context;
3093
4962
  this._signClientOperation('message.thought.put', sendParams);
3094
- return await this._transport.call('message.thought.put', sendParams);
4963
+ this._logMessageDebug('thought-send-envelope', 'message.thought.put.v2', 'message.thought.put', sendParams, {
4964
+ payloadOverride: envelope,
4965
+ extra: { to: toAid, thought_id: thoughtId, use_cache: useCache },
4966
+ });
4967
+ const result = await this._transport.call('message.thought.put', sendParams);
4968
+ this._clientLog.debug(`message.thought.put ok: to=${toAid}, thought_id=${thoughtId}, use_cache=${useCache}`);
4969
+ return result;
3095
4970
  };
3096
4971
  try {
3097
4972
  return await attempt(true);
@@ -3116,7 +4991,14 @@ export class AUNClient {
3116
4991
  const thoughtId = String(params.thought_id ?? '').trim() || `gt-${crypto.randomUUID()}`;
3117
4992
  const timestamp = Number(params.timestamp ?? Date.now());
3118
4993
  const protectedHeaders = this._protectedHeadersFromParams(params);
4994
+ this._logMessageDebug('thought-send-plaintext', 'group.thought.put.v2', 'group.thought.put', {
4995
+ group_id: groupId,
4996
+ thought_id: thoughtId,
4997
+ timestamp,
4998
+ payload,
4999
+ }, { payloadOverride: payload });
3119
5000
  const attempt = async (useCache) => {
5001
+ this._clientLog.debug(`group.thought.put attempt: group=${groupId}, thought_id=${thoughtId}, use_cache=${useCache}`);
3120
5002
  const context = isJsonObject(params.context) ? params.context : undefined;
3121
5003
  const envelope = await this._buildV2GroupEnvelope({
3122
5004
  groupId,
@@ -3137,7 +5019,13 @@ export class AUNClient {
3137
5019
  if ('context' in params)
3138
5020
  sendParams.context = params.context;
3139
5021
  this._signClientOperation('group.thought.put', sendParams);
3140
- return await this._transport.call('group.thought.put', sendParams);
5022
+ this._logMessageDebug('thought-send-envelope', 'group.thought.put.v2', 'group.thought.put', sendParams, {
5023
+ payloadOverride: envelope,
5024
+ extra: { group_id: groupId, thought_id: thoughtId, use_cache: useCache },
5025
+ });
5026
+ const result = await this._transport.call('group.thought.put', sendParams);
5027
+ this._clientLog.debug(`group.thought.put ok: group=${groupId}, thought_id=${thoughtId}, use_cache=${useCache}`);
5028
+ return result;
3141
5029
  };
3142
5030
  try {
3143
5031
  return await attempt(true);
@@ -3159,30 +5047,58 @@ export class AUNClient {
3159
5047
  return null;
3160
5048
  const envelope = opts.envelope;
3161
5049
  let spkId = '';
5050
+ let recipientKeySource = '';
3162
5051
  if (Array.isArray(envelope.recipients)) {
3163
5052
  for (const row of envelope.recipients) {
3164
- if (!Array.isArray(row) || row.length < 8)
5053
+ if (!Array.isArray(row) || row.length < 6)
3165
5054
  continue;
3166
- if (String(row[0] ?? '') === this._aid && String(row[1] ?? '') === this._deviceId) {
5055
+ if (String(row[0] ?? '') === this._aid
5056
+ && (String(row[1] ?? '') === this._deviceId || String(row[1] ?? '') === '')) {
3167
5057
  spkId = String(row[5] ?? '');
5058
+ recipientKeySource = String(row[3] ?? '');
3168
5059
  break;
3169
5060
  }
3170
5061
  }
3171
5062
  }
3172
5063
  else if (isJsonObject(envelope.recipient)) {
3173
- spkId = String(envelope.recipient.spk_id ?? '');
5064
+ const recipient = envelope.recipient;
5065
+ spkId = String(recipient.spk_id ?? '');
5066
+ recipientKeySource = String(recipient.key_source ?? '');
3174
5067
  }
3175
- const { ikPriv, spkPriv } = session.getDecryptKeys(spkId);
3176
5068
  const aad = isJsonObject(envelope.aad) ? envelope.aad : {};
5069
+ const groupIdForKeys = String(aad.group_id ?? envelope.group_id ?? '').trim();
3177
5070
  const fromAid = String(opts.fromAid || aad.from || '').trim();
3178
5071
  const senderDeviceId = String(aad.from_device ?? '');
5072
+ this._clientLog.debug(`V2 thought decrypt start: from=${fromAid}, sender_device=${senderDeviceId}, group=${groupIdForKeys || '<p2p>'}, spk_id=${spkId || '<empty>'}, key_source=${recipientKeySource || '<empty>'}, type=${String(envelope.type ?? '')}`);
5073
+ // group_id 只表示群上下文;group lookup 内部按 group SPK -> P2P device SPK -> IK fallback。
5074
+ let ikPriv;
5075
+ let spkPriv;
5076
+ try {
5077
+ if (groupIdForKeys) {
5078
+ const keys = session.getGroupDecryptKeys(groupIdForKeys, spkId);
5079
+ ikPriv = keys.ikPriv;
5080
+ spkPriv = keys.spkPriv ?? undefined;
5081
+ }
5082
+ else {
5083
+ const keys = session.getDecryptKeys(spkId);
5084
+ ikPriv = keys.ikPriv;
5085
+ spkPriv = keys.spkPriv;
5086
+ }
5087
+ }
5088
+ catch (exc) {
5089
+ this._clientLog.warn(`V2 thought decrypt: SPK lookup failed from=${fromAid}, group=${groupIdForKeys || '<p2p>'}, spk_id=${spkId || '<empty>'}: ${formatCaughtError(exc)}`);
5090
+ return null;
5091
+ }
3179
5092
  const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId);
3180
5093
  if (!senderPubDer) {
3181
5094
  this._clientLog.warn(`V2 thought decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
5095
+ this._scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupIdForKeys);
3182
5096
  return null;
3183
5097
  }
3184
5098
  try {
3185
- return decryptMessage(envelope, this._aid ?? '', this._deviceId, ikPriv, spkPriv, senderPubDer);
5099
+ const plain = decryptMessage(envelope, this._aid ?? '', this._deviceId, ikPriv, spkPriv, senderPubDer);
5100
+ this._clientLog.debug(`V2 thought decrypt ok: from=${fromAid}, sender_device=${senderDeviceId}, group=${groupIdForKeys || '<p2p>'}`);
5101
+ return plain;
3186
5102
  }
3187
5103
  catch (exc) {
3188
5104
  this._clientLog.warn(`V2 thought decrypt failed from=${fromAid}: ${formatCaughtError(exc)}`);
@@ -3219,11 +5135,7 @@ export class AUNClient {
3219
5135
  });
3220
5136
  const sigBytes = Buffer.from(stateSignature, 'base64');
3221
5137
  const cacheKey = crypto.createHash('sha256')
3222
- .update(actorAid, 'utf-8')
3223
- .update(Buffer.from([0]))
3224
- .update(signPayload, 'utf-8')
3225
- .update(Buffer.from([0]))
3226
- .update(sigBytes)
5138
+ .update(lengthPrefixedBytesKey(Buffer.from(actorAid, 'utf-8'), Buffer.from(signPayload, 'utf-8'), sigBytes))
3227
5139
  .digest('hex');
3228
5140
  const now = Date.now();
3229
5141
  const cachedExp = this._v2SigCache.get(cacheKey);
@@ -3425,15 +5337,19 @@ export class AUNClient {
3425
5337
  }
3426
5338
  if (myRole !== 'owner' && myRole !== 'admin')
3427
5339
  return false;
3428
- const bootstrapResp = await this.call('group.v2.bootstrap', { group_id: groupId });
5340
+ const bootstrapResp = await this.call('group.v2.bootstrap', {
5341
+ group_id: groupId,
5342
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
5343
+ });
3429
5344
  const devices = isJsonObject(bootstrapResp) && Array.isArray(bootstrapResp.devices)
3430
5345
  ? bootstrapResp.devices.filter(isJsonObject)
3431
5346
  : [];
3432
5347
  const candidates = [];
3433
5348
  for (const dev of devices) {
3434
5349
  const aid = String(dev.aid ?? '').trim();
5350
+ const hasDeviceId = 'device_id' in dev;
3435
5351
  const deviceId = String(dev.device_id ?? '').trim();
3436
- if (aid && deviceId && onlineAdminAids.has(aid)) {
5352
+ if (aid && hasDeviceId && onlineAdminAids.has(aid)) {
3437
5353
  candidates.push(`${aid}\x1f${deviceId}`);
3438
5354
  }
3439
5355
  }
@@ -3449,7 +5365,7 @@ export class AUNClient {
3449
5365
  this._clientLog.debug(`V2 auto propose leader elected: group=${groupId} leader=${leader}`);
3450
5366
  return true;
3451
5367
  }
3452
- const delayMs = this._v2LeaderDelayMs(`${groupId}\x00${myKey}`);
5368
+ const delayMs = this._v2LeaderDelayMs(lengthPrefixedTextKey(groupId, myKey));
3453
5369
  this._clientLog.debug(`V2 auto propose non-leader delay: group=${groupId} leader=${leader} self=${myKey} delay_ms=${delayMs}`);
3454
5370
  await this._sleep(delayMs);
3455
5371
  return true;
@@ -3533,7 +5449,10 @@ export class AUNClient {
3533
5449
  }
3534
5450
  }
3535
5451
  }
3536
- const bootstrapResp = await this.call('group.v2.bootstrap', { group_id: groupId });
5452
+ const bootstrapResp = await this.call('group.v2.bootstrap', {
5453
+ group_id: groupId,
5454
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
5455
+ });
3537
5456
  const allDevices = isJsonObject(bootstrapResp) && Array.isArray(bootstrapResp.devices)
3538
5457
  ? bootstrapResp.devices.filter(isJsonObject)
3539
5458
  : [];
@@ -3719,30 +5638,45 @@ export class AUNClient {
3719
5638
  const pushFrom = isJsonObject(data) ? String(data.from_aid ?? '') : '';
3720
5639
  const pushMsgId = isJsonObject(data) ? String(data.message_id ?? '') : '';
3721
5640
  const envelopeJson = isJsonObject(data) ? data.envelope_json : undefined;
5641
+ const hasPayload = !!envelopeJson;
3722
5642
  const ns = this._aid ? `p2p:${this._aid}` : '';
3723
5643
  let contigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
3724
- this._clientLog.debug(`_onV2PushNotification: push_seq=${pushSeq || 'null'} push_from=${pushFrom} push_msg_id=${pushMsgId} has_payload=${!!envelopeJson} contiguous_seq=${contigBefore}`);
5644
+ this._clientLog.debug(`_onV2PushNotification: push_seq=${pushSeq || 'null'} push_from=${pushFrom} push_msg_id=${pushMsgId} has_payload=${hasPayload} contiguous_seq=${contigBefore}`);
5645
+ // ── Push 修上界:只更新 maxSeenSeq,不动 contiguousSeq ──
5646
+ // 即使 pushSeq 是脏数据(如服务端 bug 导致的 99999),也只影响"已知上界",
5647
+ // 不会污染下界 contiguousSeq,更不会导致 SDK 把脏数据 ack 回服务端。
5648
+ if (pushSeq > 0 && ns) {
5649
+ this._seqTracker.updateMaxSeen(ns, pushSeq);
5650
+ if (contigBefore === pushSeq) {
5651
+ this._clientLog.debug(`_onV2PushNotification: push seq=${pushSeq} already covered by contiguous_seq=${contigBefore}, ignore duplicate push`);
5652
+ return;
5653
+ }
5654
+ contigBefore = this._repairPushContiguousBound(ns, pushSeq, hasPayload, '_raw.peer.v2.message_received');
5655
+ }
3725
5656
  // ── 带 payload 的 push:尝试就地解密 ──
3726
- if (envelopeJson && pushSeq > 0 && ns) {
5657
+ if (hasPayload && pushSeq > 0 && ns) {
3727
5658
  try {
3728
5659
  const decrypted = await this._decryptV2PushMessage(data);
3729
5660
  if (decrypted) {
3730
- // 解密成功:contiguous_seq 上界 = push_seq
3731
- this._seqTracker.onMessageSeq(ns, pushSeq);
3732
- if (pushSeq === contigBefore + 1) {
3733
- this._seqTracker.forceContiguousSeq(ns, pushSeq);
3734
- }
3735
- await this._publishOrderedMessage('message.received', ns, pushSeq, decrypted);
5661
+ // 解密成功也不能先推进 contiguousSeq;必须等应用层发布返回后再推进和 ACK。
5662
+ const published = await this._publishOrderedMessage('message.received', ns, pushSeq, decrypted);
3736
5663
  const newContig = this._seqTracker.getContiguousSeq(ns);
5664
+ const needPull = pushSeq > newContig && !published;
3737
5665
  if (newContig !== contigBefore) {
3738
5666
  this._saveSeqTrackerState();
3739
5667
  }
3740
5668
  if (newContig > 0 && newContig !== contigBefore) {
3741
- this._transport.call('message.v2.ack', { up_to_seq: newContig })
5669
+ // ack clamp:永远不发送超过 maxSeenSeq up_to_seq
5670
+ const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
5671
+ const ackSeq = maxSeen > 0 ? Math.min(newContig, maxSeen) : newContig;
5672
+ this.call('message.v2.ack', { up_to_seq: ackSeq, _rpc_background: true })
3742
5673
  .catch(e => this._clientLog.debug(`V2 P2P push-ack failed: ${formatCaughtError(e)}`));
3743
5674
  }
3744
5675
  this._clientLog.debug(`_onV2PushNotification: push 带 payload 解密成功, contiguous_seq=${contigBefore}->${newContig} push_seq=${pushSeq}`);
3745
- return;
5676
+ if (!needPull && (published || newContig >= pushSeq || pushSeq <= contigBefore)) {
5677
+ return;
5678
+ }
5679
+ this._clientLog.debug(`_onV2PushNotification: payload push seq=${pushSeq} 因空洞挂起,继续 pull 补齐 after_seq=${newContig}`);
3746
5680
  }
3747
5681
  }
3748
5682
  catch (exc) {
@@ -3753,38 +5687,37 @@ export class AUNClient {
3753
5687
  // 纯通知只表示服务端已有 pushSeq 这条消息,内容还没有进入本地,不能先推进 contiguousSeq。
3754
5688
  // 后续 pull 必须从当前 contiguousSeq 开始,否则会跳过 pushSeq 本身。
3755
5689
  if (pushSeq > 0 && ns) {
3756
- if (contigBefore >= pushSeq) {
3757
- this._clientLog.warn(`_onV2PushNotification: contiguous_seq=${contigBefore} 越界(>= push_seq=${pushSeq}),强制修复为 ${pushSeq - 1}`);
3758
- this._seqTracker.forceContiguousSeq(ns, pushSeq - 1);
3759
- contigBefore = pushSeq - 1;
3760
- }
3761
5690
  this._clientLog.debug(`_onV2PushNotification: 纯通知 push_seq=${pushSeq} > contiguous_seq=${contigBefore}, 触发 pull(after_seq=${contigBefore})`);
3762
5691
  }
3763
- if (this._v2PullInflight) {
3764
- this._v2PullPending = true;
5692
+ if (!ns)
3765
5693
  return;
3766
- }
3767
- this._v2PullInflight = true;
3768
- try {
3769
- do {
3770
- this._v2PullPending = false;
3771
- await this.pullV2();
3772
- const newContig = ns ? this._seqTracker.getContiguousSeq(ns) : -1;
5694
+ void this._tryRunBackgroundPull(ns, async () => {
5695
+ const operationBefore = this._seqTracker.getContiguousSeq(ns);
5696
+ const dedupKey = `p2p_pull:${ns}`;
5697
+ if (this._gapFillDone.has(dedupKey))
5698
+ return 0;
5699
+ this._gapFillDone.set(dedupKey, Date.now());
5700
+ try {
5701
+ const pulled = await this.pullV2(0, 50, { gateLocked: true });
5702
+ const newContig = this._seqTracker.getContiguousSeq(ns);
3773
5703
  this._clientLog.debug(`_onV2PushNotification pull done: contiguous_seq=${contigBefore}->${newContig} (push_seq=${pushSeq || 'null'})`);
3774
- } while (this._v2PullPending);
3775
- }
3776
- catch (exc) {
3777
- const newContig = ns ? this._seqTracker.getContiguousSeq(ns) : -1;
5704
+ if (newContig <= operationBefore)
5705
+ return 0;
5706
+ return pulled.length;
5707
+ }
5708
+ finally {
5709
+ this._gapFillDone.delete(dedupKey);
5710
+ }
5711
+ }, true).catch((exc) => {
5712
+ const newContig = this._seqTracker.getContiguousSeq(ns);
3778
5713
  this._clientLog.warn(`V2 push auto-pull failed: contiguous_seq=${contigBefore}->${newContig} err=${formatCaughtError(exc)}`);
3779
- }
3780
- finally {
3781
- this._v2PullInflight = false;
3782
- }
5714
+ });
3783
5715
  }
3784
5716
  async _onV2StateProposed(data) {
3785
5717
  if (!isJsonObject(data) || !this._v2Session)
3786
5718
  return;
3787
- const groupId = String(data.group_id ?? '').trim();
5719
+ const rawGroupId = String(data.group_id ?? '').trim();
5720
+ const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
3788
5721
  if (!groupId)
3789
5722
  return;
3790
5723
  await this._dispatcher.publish('group.v2.state_proposed', data);
@@ -3798,7 +5731,8 @@ export class AUNClient {
3798
5731
  async _onV2StateRetryNeeded(data) {
3799
5732
  if (!isJsonObject(data) || !this._v2Session)
3800
5733
  return;
3801
- const groupId = String(data.group_id ?? '').trim();
5734
+ const rawGroupId = String(data.group_id ?? '').trim();
5735
+ const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
3802
5736
  if (!groupId)
3803
5737
  return;
3804
5738
  await this._dispatcher.publish('group.v2.state_retry_needed', data);
@@ -3812,7 +5746,8 @@ export class AUNClient {
3812
5746
  async _onV2StateConfirmed(data) {
3813
5747
  if (!isJsonObject(data))
3814
5748
  return;
3815
- const groupId = String(data.group_id ?? '').trim();
5749
+ const rawGroupId = String(data.group_id ?? '').trim();
5750
+ const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
3816
5751
  if (groupId) {
3817
5752
  this._v2BootstrapCache.delete(`group:${groupId}`);
3818
5753
  this._v2AutoProposeLastSnapshot.delete(groupId);
@@ -3820,29 +5755,51 @@ export class AUNClient {
3820
5755
  await this._dispatcher.publish('group.v2.state_confirmed', data);
3821
5756
  }
3822
5757
  async _onRawGroupV2MessageCreated(data) {
3823
- if (!isJsonObject(data) || !this._v2Session)
5758
+ if (!isJsonObject(data) || !this._v2Session) {
5759
+ this._clientLog.debug(`_onRawGroupV2MessageCreated skipped: is_object=${String(isJsonObject(data))}, has_v2_session=${String(!!this._v2Session)}`);
3824
5760
  return;
3825
- const groupId = String(data.group_id ?? '').trim();
5761
+ }
5762
+ this._logMessageDebug('server-push', '_raw.group.v2.message_created', 'group.message_created', data);
5763
+ const rawGroupId = String(data.group_id ?? '').trim();
5764
+ const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
3826
5765
  const seq = Number(data.seq ?? 0);
3827
- if (!groupId || !Number.isFinite(seq) || seq <= 0)
5766
+ if (!groupId || !Number.isFinite(seq) || seq <= 0) {
5767
+ this._clientLog.debug(`_onRawGroupV2MessageCreated skipped: group=${groupId || '<empty>'}, seq=${String(data.seq ?? '')}`);
3828
5768
  return;
5769
+ }
3829
5770
  const ns = `group:${groupId}`;
3830
- if (this._pushedSeqs.get(ns)?.has(seq))
3831
- return;
3832
- const afterSeq = this._seqTracker.getContiguousSeq(ns);
3833
- const dedupKey = `v2_group_push:${groupId}:${afterSeq}`;
3834
- if (this._gapFillDone.has(dedupKey))
5771
+ // Push 修上界:先更新 maxSeenSeq
5772
+ this._seqTracker.updateMaxSeen(ns, seq);
5773
+ const contigBefore = this._seqTracker.getContiguousSeq(ns);
5774
+ this._clientLog.debug(`_onRawGroupV2MessageCreated enter: group=${groupId}, seq=${seq}, contiguous=${contigBefore}, max_seen=${this._seqTracker.getMaxSeenSeq(ns)}`);
5775
+ if (contigBefore === seq) {
5776
+ this._clientLog.debug(`_onRawGroupV2MessageCreated duplicate push already covered: group=${groupId} seq=${seq}`);
3835
5777
  return;
3836
- this._gapFillDone.set(dedupKey, Date.now());
3837
- try {
3838
- await this.pullGroupV2(groupId, afterSeq, 50);
3839
5778
  }
3840
- catch (exc) {
5779
+ const afterSeq = this._repairPushContiguousBound(ns, seq, false, '_raw.group.v2.message_created');
5780
+ const dedupKey = `v2_group_push:${groupId}:${afterSeq}`;
5781
+ void this._tryRunBackgroundPull(ns, async () => {
5782
+ const pullAfterSeq = this._seqTracker.getContiguousSeq(ns);
5783
+ if (this._gapFillDone.has(dedupKey)) {
5784
+ this._clientLog.debug(`_onRawGroupV2MessageCreated skipped duplicate in-flight pull: group=${groupId}, dedup=${dedupKey}`);
5785
+ return 0;
5786
+ }
5787
+ this._gapFillDone.set(dedupKey, Date.now());
5788
+ try {
5789
+ this._clientLog.debug(`_onRawGroupV2MessageCreated auto-pull start: group=${groupId}, after_seq=${pullAfterSeq}, push_seq=${seq}`);
5790
+ const pulled = await this.pullGroupV2(groupId, pullAfterSeq, 50, { gateLocked: true });
5791
+ const newContig = this._seqTracker.getContiguousSeq(ns);
5792
+ this._clientLog.debug(`_onRawGroupV2MessageCreated auto-pull done: group=${groupId}, after_seq=${pullAfterSeq}, push_seq=${seq}, contiguous=${newContig}`);
5793
+ if (newContig <= pullAfterSeq)
5794
+ return 0;
5795
+ return pulled.length;
5796
+ }
5797
+ finally {
5798
+ this._gapFillDone.delete(dedupKey);
5799
+ }
5800
+ }, true).catch((exc) => {
3841
5801
  this._clientLog.warn(`V2 group push auto-pull failed: group=${groupId} err=${formatCaughtError(exc)}`);
3842
- }
3843
- finally {
3844
- this._gapFillDone.delete(dedupKey);
3845
- }
5802
+ });
3846
5803
  }
3847
5804
  /** Push 通知带 payload 时的就地解密(复用 _decryptV2Message) */
3848
5805
  async _decryptV2PushMessage(data) {
@@ -3869,6 +5826,11 @@ export class AUNClient {
3869
5826
  }
3870
5827
  /** 从参数中解析 Gateway URL */
3871
5828
  _resolveGateway(params) {
5829
+ const gateways = this._resolveGateways(params);
5830
+ return gateways[0];
5831
+ }
5832
+ /** 从参数中解析所有 Gateway URL(支持 string 或 string[]) */
5833
+ _resolveGateways(params) {
3872
5834
  const topology = params.topology;
3873
5835
  if (isJsonObject(topology)) {
3874
5836
  const topo = topology;
@@ -3880,11 +5842,16 @@ export class AUNClient {
3880
5842
  throw new ValidationError('relay topology is not implemented in the TypeScript SDK');
3881
5843
  }
3882
5844
  }
3883
- const gateway = String(params.gateway ?? '');
3884
- if (!gateway) {
3885
- throw new StateError('missing gateway in connect params');
5845
+ const gw = params.gateway ?? params.gateways;
5846
+ if (Array.isArray(gw)) {
5847
+ const urls = gw.map(g => String(g ?? '')).filter(u => u.length > 0);
5848
+ if (urls.length > 0)
5849
+ return urls;
5850
+ }
5851
+ if (typeof gw === 'string' && gw) {
5852
+ return [gw];
3886
5853
  }
3887
- return gateway;
5854
+ throw new StateError('missing gateway in connect params');
3888
5855
  }
3889
5856
  /** 连接后同步身份信息 */
3890
5857
  _syncIdentityAfterConnect(accessToken) {
@@ -4138,6 +6105,16 @@ export class AUNClient {
4138
6105
  };
4139
6106
  scheduleNext(0);
4140
6107
  }
6108
+ _normalizeOutboundMessagePayload(params, method = '') {
6109
+ if (!Object.prototype.hasOwnProperty.call(params, 'payload') && Object.prototype.hasOwnProperty.call(params, 'content')) {
6110
+ params.payload = params.content;
6111
+ delete params.content;
6112
+ }
6113
+ const payload = params.payload;
6114
+ if (isJsonObject(payload) && !Object.prototype.hasOwnProperty.call(payload, 'type') && typeof payload.text === 'string') {
6115
+ params.payload = { type: 'text', ...payload };
6116
+ }
6117
+ }
4141
6118
  _validateMessageRecipient(toAid) {
4142
6119
  if (isGroupServiceAid(toAid)) {
4143
6120
  throw new ValidationError('message.send receiver cannot be group.{issuer}; use group.send instead');