@agentunion/fastaun-browser 0.4.7 → 0.4.8

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 (78) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/_packed_docs/CHANGELOG.md +32 -0
  3. package/_packed_docs/INDEX.md +46 -22
  4. package/_packed_docs/KITE_DOCS_GUIDE.md +16 -12
  5. package/_packed_docs/design/AUNClient/346/213/206/345/210/206/351/207/215/346/236/204/346/211/247/350/241/214/346/226/271/346/241/210.md +859 -0
  6. package/dist/agent-md.d.ts +1 -1
  7. package/dist/agent-md.d.ts.map +1 -1
  8. package/dist/agent-md.js +12 -5
  9. package/dist/agent-md.js.map +1 -1
  10. package/dist/aid-store.d.ts +0 -1
  11. package/dist/aid-store.d.ts.map +1 -1
  12. package/dist/aid-store.js +29 -14
  13. package/dist/aid-store.js.map +1 -1
  14. package/dist/aid.d.ts +1 -0
  15. package/dist/aid.d.ts.map +1 -1
  16. package/dist/aid.js +10 -4
  17. package/dist/aid.js.map +1 -1
  18. package/dist/bundle.js +9804 -8845
  19. package/dist/cert-utils.d.ts +5 -1
  20. package/dist/cert-utils.d.ts.map +1 -1
  21. package/dist/cert-utils.js +52 -8
  22. package/dist/cert-utils.js.map +1 -1
  23. package/dist/client/delivery.d.ts +44 -0
  24. package/dist/client/delivery.d.ts.map +1 -0
  25. package/dist/client/delivery.js +1033 -0
  26. package/dist/client/delivery.js.map +1 -0
  27. package/dist/client/group-state.d.ts +32 -0
  28. package/dist/client/group-state.d.ts.map +1 -0
  29. package/dist/client/group-state.js +909 -0
  30. package/dist/client/group-state.js.map +1 -0
  31. package/dist/client/identity.d.ts +8 -0
  32. package/dist/client/identity.d.ts.map +1 -0
  33. package/dist/client/identity.js +34 -0
  34. package/dist/client/identity.js.map +1 -0
  35. package/dist/client/lifecycle.d.ts +10 -0
  36. package/dist/client/lifecycle.d.ts.map +1 -0
  37. package/dist/client/lifecycle.js +222 -0
  38. package/dist/client/lifecycle.js.map +1 -0
  39. package/dist/client/peers.d.ts +11 -0
  40. package/dist/client/peers.d.ts.map +1 -0
  41. package/dist/client/peers.js +42 -0
  42. package/dist/client/peers.js.map +1 -0
  43. package/dist/client/rpc-pipeline.d.ts +36 -0
  44. package/dist/client/rpc-pipeline.d.ts.map +1 -0
  45. package/dist/client/rpc-pipeline.js +446 -0
  46. package/dist/client/rpc-pipeline.js.map +1 -0
  47. package/dist/client/runtime.d.ts +6 -0
  48. package/dist/client/runtime.d.ts.map +1 -0
  49. package/dist/client/runtime.js +7 -0
  50. package/dist/client/runtime.js.map +1 -0
  51. package/dist/client/v2-e2ee.d.ts +104 -0
  52. package/dist/client/v2-e2ee.d.ts.map +1 -0
  53. package/dist/client/v2-e2ee.js +1586 -0
  54. package/dist/client/v2-e2ee.js.map +1 -0
  55. package/dist/client.d.ts +26 -48
  56. package/dist/client.d.ts.map +1 -1
  57. package/dist/client.js +297 -3724
  58. package/dist/client.js.map +1 -1
  59. package/dist/config.d.ts.map +1 -1
  60. package/dist/config.js +4 -3
  61. package/dist/config.js.map +1 -1
  62. package/dist/discovery.d.ts.map +1 -1
  63. package/dist/discovery.js +69 -10
  64. package/dist/discovery.js.map +1 -1
  65. package/dist/v2/crypto/dh-path.js +2 -2
  66. package/dist/v2/crypto/index.js +7 -7
  67. package/dist/v2/e2ee/decrypt.js +8 -8
  68. package/dist/v2/e2ee/encrypt-group.js +9 -9
  69. package/dist/v2/e2ee/encrypt-p2p.js +9 -9
  70. package/dist/v2/e2ee/index.js +5 -5
  71. package/dist/v2/e2ee/metadata-auth.js +1 -1
  72. package/dist/v2/session/index.js +2 -2
  73. package/dist/v2/session/session.js +1 -1
  74. package/dist/v2/state/commitment.js +1 -1
  75. package/dist/v2/state/index.js +1 -1
  76. package/dist/version.d.ts +1 -1
  77. package/dist/version.js +1 -1
  78. package/package.json +2 -2
package/dist/client.js CHANGED
@@ -4,46 +4,31 @@
4
4
  // - HTTP 使用 fetch() 而非 Node http
5
5
  // - 无文件系统(IndexedDB via keystore)
6
6
  // - 后台任务使用 setTimeout/setInterval
7
- import { createConfig, getDeviceId, normalizeSlotId, slotIsolationKey } from './config.js';
7
+ import { createConfig, getDeviceId, normalizeSlotId } from './config.js';
8
8
  import { EventDispatcher } from './events.js';
9
9
  import { normalizeGroupId } from './group-id.js';
10
10
  import { GatewayDiscovery } from './discovery.js';
11
11
  import { RPCTransport } from './transport.js';
12
12
  import { AuthFlow } from './auth.js';
13
13
  import { SeqTracker } from './seq-tracker.js';
14
- import { CryptoProvider, uint8ToBase64, base64ToUint8, pemToArrayBuffer, p1363ToDer, certificateSha256Fingerprint, ecdsaSignDer, ecdsaVerifyDer, importCertPublicKeyEcdsa, importPrivateKeyEcdsa, } from './crypto.js';
14
+ import { ClientRuntime } from './client/runtime.js';
15
+ import { MessageDeliveryEngine } from './client/delivery.js';
16
+ import { IdentityRuntimeManager } from './client/identity.js';
17
+ import { LifecycleController } from './client/lifecycle.js';
18
+ import { PeerDirectory } from './client/peers.js';
19
+ import { RpcPipeline } from './client/rpc-pipeline.js';
20
+ import { V2E2EECoordinator } from './client/v2-e2ee.js';
21
+ import { GroupStateCoordinator } from './client/group-state.js';
22
+ import { CryptoProvider, base64ToUint8, pemToArrayBuffer, ecdsaVerifyDer, importCertPublicKeyEcdsa, } from './crypto.js';
15
23
  import { IndexedDBTokenStore } from './keystore/indexeddb-token-store.js';
16
- import { V2Session, V2KeyStore } from './v2/session/index.js';
17
- import { encryptP2PMessage, encryptGroupMessage, decryptMessage, } from './v2/e2ee/index.js';
24
+ import { decryptMessage, } from './v2/e2ee/index.js';
18
25
  import { ecdsaVerifyRaw } from './v2/crypto/ecdsa.js';
19
- import { computeStateCommitment } from './v2/state/index.js';
20
26
  import { AUNLogger } from './logger.js';
21
27
  import { AgentMdManager } from './agent-md.js';
28
+ import { certMatchesFingerprint, normalizeFingerprintHex } from './cert-utils.js';
22
29
  import { AUNError, AuthError, ConnectionError, E2EEError, NotFoundError, PermissionError, StateError, ValidationError, } from './errors.js';
23
30
  import { isJsonObject, ConnectionState, STATE_TO_PUBLIC, } from './types.js';
24
31
  import { AID } from './aid.js';
25
- /**
26
- * 递归排序键的 JSON 序列化(Canonical JSON for AUN)
27
- * 等价于 Python json.dumps(sort_keys=True, separators=(",",":"), ensure_ascii=False)
28
- * 非 ASCII 字符直接以 UTF-8 输出。
29
- */
30
- function stableStringify(obj) {
31
- if (obj === null || obj === undefined)
32
- return 'null';
33
- if (typeof obj === 'boolean' || typeof obj === 'number')
34
- return JSON.stringify(obj);
35
- if (typeof obj === 'string')
36
- return JSON.stringify(obj);
37
- if (Array.isArray(obj)) {
38
- return '[' + obj.map(v => stableStringify(v)).join(',') + ']';
39
- }
40
- if (isJsonObject(obj)) {
41
- const keys = Object.keys(obj).sort();
42
- const entries = keys.map(k => stableStringify(k) + ':' + stableStringify(obj[k]));
43
- return '{' + entries.join(',') + '}';
44
- }
45
- return JSON.stringify(obj);
46
- }
47
32
  function getV2DeviceId(dev) {
48
33
  if (Object.prototype.hasOwnProperty.call(dev, 'device_id')) {
49
34
  return { present: true, value: String(dev.device_id ?? '').trim() };
@@ -61,107 +46,22 @@ function isAIDObject(value) {
61
46
  && typeof candidate.aunPath === 'string'
62
47
  && typeof candidate.isPrivateKeyValid === 'function');
63
48
  }
64
- function sortObjectKeys(obj) {
65
- if (obj === null || obj === undefined || typeof obj !== 'object')
66
- return obj;
67
- if (Array.isArray(obj))
68
- return obj.map(sortObjectKeys);
69
- const sorted = {};
70
- for (const key of Object.keys(obj).sort()) {
71
- sorted[key] = sortObjectKeys(obj[key]);
72
- }
73
- return sorted;
74
- }
75
- function uint64BE(value) {
76
- const buf = new ArrayBuffer(8);
77
- new DataView(buf).setBigUint64(0, BigInt(value), false);
78
- return new Uint8Array(buf);
79
- }
80
- function hexToBytes(hex) {
81
- const clean = hex.trim();
82
- if (!clean)
83
- return new Uint8Array(0);
84
- if (clean.length % 2 !== 0)
85
- throw new ValidationError('invalid hex length');
86
- const out = new Uint8Array(clean.length / 2);
87
- for (let i = 0; i < out.length; i++) {
88
- out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
89
- }
90
- return out;
91
- }
92
49
  function bytesToHex(bytes) {
93
50
  return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
94
51
  }
95
- async function computeStateHash(params) {
96
- const sortedMembers = [...params.members].sort((a, b) => a.aid.localeCompare(b.aid));
97
- const membershipBlock = sortedMembers.map((m) => `${m.aid}:${m.role}`).join('|');
98
- const policyBlock = Object.keys(params.policy).length > 0
99
- ? JSON.stringify(sortObjectKeys(params.policy))
100
- : '';
101
- const prevBytes = params.prevStateHash ? hexToBytes(params.prevStateHash) : new Uint8Array(32);
102
- const sep = new Uint8Array([0x00]);
103
- const encoder = new TextEncoder();
104
- const chunks = [
105
- encoder.encode(params.groupId), sep,
106
- uint64BE(params.stateVersion), sep,
107
- uint64BE(params.keyEpoch), sep,
108
- encoder.encode(membershipBlock), sep,
109
- encoder.encode(policyBlock), sep,
110
- prevBytes,
111
- ];
112
- const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
113
- const data = new Uint8Array(total);
114
- let offset = 0;
115
- for (const chunk of chunks) {
116
- data.set(chunk, offset);
117
- offset += chunk.length;
52
+ function exactArrayBuffer(bytes) {
53
+ return bytes.slice().buffer;
54
+ }
55
+ function attachGatewayProximity(message, source) {
56
+ if (isJsonObject(source.proximity)) {
57
+ message.proximity = { ...source.proximity };
58
+ }
59
+ for (const key of ['same_device', 'same_network', 'same_egress_ip']) {
60
+ if (Object.prototype.hasOwnProperty.call(source, key)) {
61
+ message[key] = source[key];
62
+ }
118
63
  }
119
- const digest = await crypto.subtle.digest('SHA-256', data);
120
- return bytesToHex(new Uint8Array(digest));
121
64
  }
122
- /** 内部专用方法(禁止用户直接调用) */
123
- const INTERNAL_ONLY_METHODS = new Set([
124
- 'auth.login1',
125
- 'auth.aid_login1',
126
- 'auth.login2',
127
- 'auth.aid_login2',
128
- 'auth.connect',
129
- 'auth.refresh_token',
130
- 'initialize',
131
- ]);
132
- /** 已移除的旧版 E2EE RPC。 */
133
- const REMOVED_E2EE_METHODS = new Set([
134
- 'group.rotate_epoch',
135
- ]);
136
- /** 需要客户端签名的关键方法 */
137
- const SIGNED_METHODS = new Set([
138
- 'message.send',
139
- 'message.v2.put_peer_pk', 'message.v2.bootstrap',
140
- 'message.v2.group_bootstrap', 'message.v2.pull',
141
- 'message.v2.ack',
142
- 'group.send',
143
- 'group.v2.put_group_pk', 'group.v2.bootstrap',
144
- 'group.v2.send', 'group.v2.pull', 'group.v2.ack',
145
- 'group.v2.propose_state', 'group.v2.confirm_state',
146
- 'group.v2.get_proposal',
147
- 'group.kick', 'group.add_member',
148
- 'group.leave', 'group.remove_member', 'group.update_rules',
149
- 'group.update', 'group.update_announcement',
150
- 'group.update_join_requirements', 'group.set_role',
151
- 'group.transfer_owner', 'group.review_join_request',
152
- 'group.batch_review_join_request',
153
- 'group.request_join', 'group.use_invite_code',
154
- 'group.thought.put',
155
- 'message.thought.put',
156
- 'group.set_settings',
157
- 'group.resources.put', 'group.resources.update',
158
- 'group.resources.delete', 'group.resources.request_add',
159
- 'group.resources.direct_add', 'group.resources.approve_request',
160
- 'group.resources.reject_request',
161
- 'group.commit_state',
162
- 'group.ban', 'group.unban',
163
- 'group.dissolve', 'group.suspend', 'group.resume',
164
- ]);
165
65
  const DEFAULT_SESSION_OPTIONS = {
166
66
  auto_reconnect: true,
167
67
  heartbeat_interval: 30.0,
@@ -178,31 +78,9 @@ const DEFAULT_SESSION_OPTIONS = {
178
78
  http: 30.0,
179
79
  },
180
80
  };
181
- const PUBLIC_CONNECTION_OPTION_KEYS = new Set([
182
- 'auto_reconnect',
183
- 'connect_timeout',
184
- 'retry_initial_delay',
185
- 'retry_max_delay',
186
- 'retry_max_attempts',
187
- 'heartbeat_interval',
188
- 'call_timeout',
189
- 'connection_kind',
190
- 'short_ttl_ms',
191
- 'delivery_mode',
192
- 'extra_info',
193
- 'background_sync',
194
- ]);
195
- const PROTECTED_HEADERS_METHODS = new Set([
196
- 'message.send',
197
- 'group.send',
198
- 'message.thought.put',
199
- 'group.thought.put',
200
- ]);
201
81
  const RECONNECT_MIN_BASE_DELAY_SECONDS = 1.0;
202
82
  const RECONNECT_MAX_BASE_DELAY_SECONDS = 64.0;
203
83
  const TOKEN_REFRESH_CHECK_INTERVAL_MS = 30_000;
204
- const PUSHED_SEQS_LIMIT = 50_000;
205
- const PENDING_ORDERED_LIMIT = 50_000;
206
84
  // 心跳间隔下/上限(秒)。0 = 关闭心跳;负值视为 0;其余值 clamp 到 [10, 600]。
207
85
  // 服务端通过 hello.heartbeat_interval 与 meta.ping pong 中的同名字段下发。
208
86
  const HEARTBEAT_MIN_INTERVAL_SECONDS = 10;
@@ -229,6 +107,35 @@ const NON_IDEMPOTENT_METHODS = new Set([
229
107
  'message.thought.put', 'group.thought.put',
230
108
  'group.add_member',
231
109
  ]);
110
+ /** 需要客户端签名的关键方法。运行时逻辑已迁入 RpcPipeline,此处保留给源码审计测试。 */
111
+ const SIGNED_METHODS = new Set([
112
+ 'message.send',
113
+ 'message.v2.put_peer_pk', 'message.v2.bootstrap',
114
+ 'message.v2.group_bootstrap', 'message.v2.pull',
115
+ 'message.v2.ack',
116
+ 'group.send',
117
+ 'group.v2.put_group_pk', 'group.v2.bootstrap',
118
+ 'group.v2.send', 'group.v2.pull', 'group.v2.ack',
119
+ 'group.v2.propose_state', 'group.v2.confirm_state',
120
+ 'group.v2.get_proposal',
121
+ 'group.kick', 'group.add_member',
122
+ 'group.leave', 'group.remove_member', 'group.update_rules',
123
+ 'group.update', 'group.update_announcement',
124
+ 'group.update_join_requirements', 'group.set_role',
125
+ 'group.transfer_owner', 'group.review_join_request',
126
+ 'group.batch_review_join_request',
127
+ 'group.request_join', 'group.use_invite_code',
128
+ 'group.thought.put',
129
+ 'message.thought.put',
130
+ 'group.set_settings',
131
+ 'group.resources.put', 'group.resources.update',
132
+ 'group.resources.delete', 'group.resources.request_add',
133
+ 'group.resources.direct_add', 'group.resources.approve_request',
134
+ 'group.resources.reject_request',
135
+ 'group.commit_state',
136
+ 'group.ban', 'group.unban',
137
+ 'group.dissolve', 'group.suspend', 'group.resume',
138
+ ]);
232
139
  function clampReconnectDelaySeconds(value, fallback, upper = RECONNECT_MAX_BASE_DELAY_SECONDS) {
233
140
  const parsed = Number(value);
234
141
  const seconds = Number.isFinite(parsed) ? parsed : fallback;
@@ -328,21 +235,22 @@ function createAgentMdManagerForRuntime(opts) {
328
235
  opts.identity.set(await auth.loadIdentityOrNone(target) ?? fallbackIdentity);
329
236
  return token;
330
237
  },
331
- peerResolver: async (aid) => {
238
+ peerResolver: async (aid, certFingerprint) => {
332
239
  const target = String(aid ?? '').trim();
240
+ const expectedFp = String(certFingerprint ?? '').trim().toLowerCase();
333
241
  const current = opts.currentAid();
334
- if (current?.aid === target)
335
- return current;
336
- let certPem = String(await opts.tokenStore().loadCert(target) ?? '').trim();
337
- if (!certPem) {
338
- if (!opts.gateway.get()) {
339
- try {
340
- opts.gateway.set(await opts.gateway.resolve(target));
341
- }
342
- catch { /* best effort */ }
242
+ if (current?.aid === target) {
243
+ if (!expectedFp || await certMatchesFingerprint(current.certPem, expectedFp))
244
+ return current;
245
+ throw new StateError(`current AID certificate fingerprint mismatch for ${target}`);
246
+ }
247
+ if (!opts.gateway.get()) {
248
+ try {
249
+ opts.gateway.set(await opts.gateway.resolve(target));
343
250
  }
344
- certPem = String(await opts.fetchPeerCert(target) ?? '').trim();
251
+ catch { /* best effort */ }
345
252
  }
253
+ const certPem = String(await opts.fetchPeerCert(target, expectedFp || undefined) ?? '').trim();
346
254
  if (!certPem)
347
255
  throw new NotFoundError(`certificate not found for aid: ${target}`);
348
256
  return await AID.create({
@@ -374,80 +282,6 @@ function resolvePeerGatewayUrl(localGatewayUrl, peerAid) {
374
282
  return localGatewayUrl;
375
283
  return localGatewayUrl.replace(`gateway.${localIssuer}`, `gateway.${peerIssuer}`);
376
284
  }
377
- function isGroupServiceAid(value) {
378
- const text = String(value ?? '').trim();
379
- if (!text.includes('.'))
380
- return false;
381
- const [name, ...issuerParts] = text.split('.');
382
- return name === 'group' && issuerParts.join('.').length > 0;
383
- }
384
- function normalizeV2WrapPolicy(raw) {
385
- if (!raw || typeof raw !== 'object' || Array.isArray(raw))
386
- return undefined;
387
- const obj = raw;
388
- let protocol = String(obj.protocol ?? '').trim().toUpperCase();
389
- let scope = String(obj.scope ?? '').trim().toLowerCase();
390
- if (scope !== 'aid' && scope !== 'device') {
391
- if (obj.per_aid_wrap === true)
392
- scope = 'aid';
393
- else if (obj.per_device_wrap === true)
394
- scope = 'device';
395
- else
396
- scope = '';
397
- }
398
- if (protocol !== '1DH' && protocol !== '3DH')
399
- protocol = '';
400
- if (scope === 'aid')
401
- protocol = '1DH';
402
- if (!protocol && !scope)
403
- return undefined;
404
- return {
405
- protocol: protocol ? protocol : undefined,
406
- scope: scope ? scope : undefined,
407
- };
408
- }
409
- function v2WrapCapabilities() {
410
- return {
411
- version: 'v2.1',
412
- protocols: ['1DH', '3DH'],
413
- scopes: ['aid', 'device'],
414
- per_aid_wrap: true,
415
- per_device_wrap: true,
416
- };
417
- }
418
- function applyV2WrapPolicyToTargets(targets, policy) {
419
- if (!policy)
420
- return targets;
421
- const normalized = targets.map((target) => {
422
- const row = { ...target };
423
- if (policy.protocol === '1DH') {
424
- row.keySource = 'aid_master';
425
- row.spkPkDer = undefined;
426
- row.spkId = '';
427
- }
428
- return row;
429
- });
430
- if (policy.scope !== 'aid')
431
- return normalized;
432
- const collapsed = new Map();
433
- for (const target of normalized) {
434
- const key = `${target.aid}\u0000${target.role}`;
435
- if (!collapsed.has(key)) {
436
- collapsed.set(key, { ...target, deviceId: '' });
437
- }
438
- }
439
- return Array.from(collapsed.values());
440
- }
441
- /** 32 字节左侧零填充(用于 P-256 私钥 scalar 规范化) */
442
- function _v2LeftPad32(b) {
443
- if (b.length === 32)
444
- return b;
445
- if (b.length > 32)
446
- return b.subarray(b.length - 32);
447
- const out = new Uint8Array(32);
448
- out.set(b, 32 - b.length);
449
- return out;
450
- }
451
285
  /** Base64 (标准) → Uint8Array */
452
286
  function _v2B64ToBytes(s) {
453
287
  const bin = atob(s);
@@ -481,24 +315,6 @@ function _v2ConcatBytes(...parts) {
481
315
  }
482
316
  return out;
483
317
  }
484
- function _v2LengthPrefixedTextKey(...parts) {
485
- const enc = new TextEncoder();
486
- return parts.map((part) => `${enc.encode(part).length}:${part};`).join('');
487
- }
488
- function _v2LengthPrefixedBytes(...parts) {
489
- const enc = new TextEncoder();
490
- const framed = [];
491
- for (const part of parts) {
492
- framed.push(enc.encode(`${part.length}:`), part, enc.encode(';'));
493
- }
494
- return _v2ConcatBytes(...framed);
495
- }
496
- /** Base64URL → Uint8Array(兼容缺失 padding) */
497
- function _v2B64uToBytes(s) {
498
- const std = s.replace(/-/g, '+').replace(/_/g, '/');
499
- const pad = std.length % 4 === 0 ? '' : '='.repeat(4 - (std.length % 4));
500
- return _v2B64ToBytes(std + pad);
501
- }
502
318
  function formatCaughtError(error) {
503
319
  return error instanceof Error ? error : String(error);
504
320
  }
@@ -564,85 +380,6 @@ function extractV2EnvelopeFromSource(source) {
564
380
  }
565
381
  return null;
566
382
  }
567
- function truthyBool(value) {
568
- if (value === true || value === 1)
569
- return true;
570
- if (typeof value === 'string') {
571
- const normalized = value.trim().toLowerCase();
572
- return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
573
- }
574
- return false;
575
- }
576
- function isEncryptedEnvelopePayload(payload) {
577
- if (!isJsonObject(payload))
578
- return false;
579
- const payloadType = String(payload.type ?? '').trim();
580
- if (payloadType.startsWith('e2ee.'))
581
- return true;
582
- if (!String(payload.ciphertext ?? '').trim())
583
- return false;
584
- return payload.nonce !== undefined
585
- || payload.tag !== undefined
586
- || payload.recipient !== undefined
587
- || payload.recipients !== undefined
588
- || payload.wrapped_key !== undefined
589
- || payload.recipients_digest !== undefined;
590
- }
591
- function encryptedPushEnvelope(msg) {
592
- if (isEncryptedEnvelopePayload(msg.payload))
593
- return msg.payload;
594
- if (typeof msg.envelope_json === 'string' && msg.envelope_json.trim()) {
595
- try {
596
- const parsed = JSON.parse(msg.envelope_json);
597
- if (isEncryptedEnvelopePayload(parsed))
598
- return parsed;
599
- }
600
- catch {
601
- return null;
602
- }
603
- }
604
- return null;
605
- }
606
- function isEncryptedPushMessage(msg) {
607
- if (truthyBool(msg.encrypted))
608
- return true;
609
- return encryptedPushEnvelope(msg) !== null;
610
- }
611
- function isV2EncryptedEnvelopePayload(envelope) {
612
- if (!envelope)
613
- return false;
614
- const payloadType = String(envelope.type ?? '').trim();
615
- if (payloadType === 'e2ee.p2p_encrypted' || payloadType === 'e2ee.group_encrypted')
616
- return true;
617
- return String(envelope.version ?? '').trim().toLowerCase() === 'v2' && payloadType.startsWith('e2ee.');
618
- }
619
- function safeUndecryptablePushEvent(msg, group) {
620
- const event = {
621
- message_id: msg.message_id ?? null,
622
- from: msg.from ?? null,
623
- seq: msg.seq ?? null,
624
- timestamp: msg.timestamp ?? msg.t_server ?? null,
625
- device_id: msg.device_id ?? null,
626
- slot_id: msg.slot_id ?? null,
627
- _decrypt_error: 'encrypted push payload is not decryptable on raw push path',
628
- _decrypt_stage: 'push_envelope',
629
- };
630
- if (group) {
631
- event.group_id = msg.group_id ?? null;
632
- }
633
- else {
634
- event.to = msg.to ?? null;
635
- }
636
- const envelope = encryptedPushEnvelope(msg);
637
- if (envelope) {
638
- event._envelope_type = String(envelope.type ?? '');
639
- event._suite = String(envelope.suite ?? '');
640
- if (isV2EncryptedEnvelopePayload(envelope)) {
641
- attachV2EnvelopeMetadata(event, v2E2eeMeta(envelope));
642
- }
643
- }
644
- return event;
645
- }
646
383
  function metadataWithoutAuth(value) {
647
384
  if (!isJsonObject(value))
648
385
  return null;
@@ -740,15 +477,13 @@ export class AUNClient {
740
477
  _v2Session;
741
478
  _v2KeyStore;
742
479
  _v2SessionInitInFlight = null;
480
+ _v2RuntimeGeneration = 0;
743
481
  _v2BootstrapCache = new Map();
744
482
  _v2SenderIKPending = new Map();
745
483
  _v2SenderIKFetching = new Set();
746
484
  static V2_BOOTSTRAP_TTL_MS = 60 * 60 * 1000;
747
- static V2_RETRYABLE_CODES = new Set([-33011, -33012, -33050, -33052, -33054]);
748
485
  /** V2 state 签名验证缓存:cacheKey(hex) → expiry_unix_ms */
749
486
  _v2SigCache = new Map();
750
- static _V2_SIG_CACHE_TTL = 60 * 60 * 1000;
751
- static _V2_SIG_CACHE_MAX = 16384;
752
487
  /** V2 state chain 本地记录:group_id → [state_version, chain_hash] */
753
488
  _v2StateChains = new Map();
754
489
  /** 群安全等级追踪(变化时发布 group.v2.security_level 事件) */
@@ -773,11 +508,16 @@ export class AUNClient {
773
508
  _pendingOrderedMsgs = new Map();
774
509
  /** Lazy group sync:首次发送群消息前自动拉取历史 */
775
510
  _groupSynced = new Set();
511
+ /** 在线未读 hint 队列:同一 group 只保留最后一条,延迟 drain 降低登录瞬时拉取压力。 */
512
+ _onlineUnreadHintQueue = new Map();
513
+ _onlineUnreadHintTimer = null;
514
+ _onlineUnreadHintDrainActive = false;
515
+ _onlineUnreadHintInitialDelayMs = 750;
516
+ _onlineUnreadHintIntervalMs = 50;
776
517
  /** gap fill 来源标记:true 表示当前正在补洞(pull 触发),false 表示非补洞 */
777
518
  _gapFillActive = false;
778
519
  // Pull Gate:序列化同一 key 的并发 pull 操作,防止重复拉取
779
520
  _pullGates = new Map();
780
- static _PULL_GATE_STALE_MS = 30000;
781
521
  // 重连相关
782
522
  _reconnectActive = false;
783
523
  _reconnectAbort = null;
@@ -803,6 +543,14 @@ export class AUNClient {
803
543
  _tokenStoreLog;
804
544
  _logDiscovery;
805
545
  _logEvents;
546
+ _runtime;
547
+ _identityRuntime;
548
+ _peerDirectory;
549
+ _lifecycle;
550
+ _rpcPipeline;
551
+ _delivery;
552
+ _v2E2EE;
553
+ _groupState;
806
554
  constructor(aid) {
807
555
  if (aid !== null && aid !== undefined && !isAIDObject(aid)) {
808
556
  throw new ValidationError('AUNClient only accepts an AID object or no argument');
@@ -862,7 +610,7 @@ export class AUNClient {
862
610
  },
863
611
  auth: () => this._auth,
864
612
  tokenStore: () => this._tokenStore,
865
- fetchPeerCert: (target) => this._fetchPeerCert(target),
613
+ fetchPeerCert: (target, certFingerprint) => this._fetchPeerCert(target, certFingerprint || undefined),
866
614
  });
867
615
  this._transport = new RPCTransport({
868
616
  eventDispatcher: this._dispatcher,
@@ -874,6 +622,14 @@ export class AUNClient {
874
622
  this._clientLog.debug(`agent.md meta observer skipped: ${String(exc)}`);
875
623
  });
876
624
  });
625
+ this._runtime = new ClientRuntime(this);
626
+ this._identityRuntime = new IdentityRuntimeManager(this._runtime);
627
+ this._peerDirectory = new PeerDirectory(this._runtime);
628
+ this._lifecycle = new LifecycleController(this._runtime);
629
+ this._rpcPipeline = new RpcPipeline(this._runtime);
630
+ this._delivery = new MessageDeliveryEngine(this._runtime);
631
+ this._v2E2EE = new V2E2EECoordinator(this._runtime);
632
+ this._groupState = new GroupStateCoordinator(this._runtime);
877
633
  if (inputAid) {
878
634
  if (inputAid.isPrivateKeyValid()) {
879
635
  this._currentAid = inputAid;
@@ -884,7 +640,7 @@ export class AUNClient {
884
640
  cert: inputAid.certPem,
885
641
  };
886
642
  this._auth.setIdentity(this._identity);
887
- this._state = 'disconnected';
643
+ this._state = 'standby';
888
644
  }
889
645
  }
890
646
  // 注入 logger 到各子模块(构造时未传 logger,构造后通过 setLogger 注入)
@@ -1002,7 +758,53 @@ export class AUNClient {
1002
758
  get lastError() { return this._lastError; }
1003
759
  /** 最近一次错误码(对齐 Python last_error_code) */
1004
760
  get lastErrorCode() { return this._lastErrorCode; }
761
+ _v2SessionMatchesIdentity() {
762
+ if (!this._v2Session)
763
+ return false;
764
+ const session = this._v2Session;
765
+ if (session.currentIkPubDer === undefined)
766
+ return true;
767
+ return session.aid === this._aid && session.deviceId === this._deviceId;
768
+ }
769
+ _resetV2IdentityRuntime() {
770
+ this._v2RuntimeGeneration += 1;
771
+ const keyStore = this._v2KeyStore;
772
+ try {
773
+ keyStore?.close?.();
774
+ }
775
+ catch (exc) {
776
+ this._clientLog?.debug?.(`V2 keystore cleanup skipped: ${exc instanceof Error ? exc.message : String(exc)}`);
777
+ }
778
+ this._v2Session = undefined;
779
+ this._v2KeyStore = undefined;
780
+ this._v2SessionInitInFlight = null;
781
+ this._v2BootstrapCache.clear();
782
+ this._v2SenderIKPending.clear();
783
+ this._v2SenderIKFetching.clear();
784
+ this._v2SigCache.clear();
785
+ this._v2StateChains.clear();
786
+ this._v2GroupSecurityLevels.clear();
787
+ this._v2AutoProposeInflight.clear();
788
+ this._v2AutoProposePending.clear();
789
+ this._v2AutoProposeLastSnapshot.clear();
790
+ this._v2LazyProposeTriggered.clear();
791
+ this._v2PullInflight = false;
792
+ this._v2PullPending = false;
793
+ }
1005
794
  _applyAidRuntimeContext(aid) {
795
+ const oldTransport = this._transport;
796
+ try {
797
+ const closeResult = oldTransport?.close?.();
798
+ if (closeResult && typeof closeResult.catch === 'function') {
799
+ void closeResult.catch((exc) => {
800
+ this._clientLog.debug(`old transport cleanup skipped: ${exc instanceof Error ? exc.message : String(exc)}`);
801
+ });
802
+ }
803
+ }
804
+ catch (exc) {
805
+ this._clientLog.debug(`old transport cleanup skipped: ${exc instanceof Error ? exc.message : String(exc)}`);
806
+ }
807
+ this._resetV2IdentityRuntime();
1006
808
  const nextConfig = createConfig({
1007
809
  aunPath: aid.aunPath,
1008
810
  rootCaPem: aid.rootCaPath,
@@ -1052,7 +854,7 @@ export class AUNClient {
1052
854
  },
1053
855
  auth: () => this._auth,
1054
856
  tokenStore: () => this._tokenStore,
1055
- fetchPeerCert: (target) => this._fetchPeerCert(target),
857
+ fetchPeerCert: (target, certFingerprint) => this._fetchPeerCert(target, certFingerprint || undefined),
1056
858
  });
1057
859
  this._transport = new RPCTransport({
1058
860
  eventDispatcher: this._dispatcher,
@@ -1075,25 +877,7 @@ export class AUNClient {
1075
877
  }
1076
878
  }
1077
879
  loadIdentity(aid) {
1078
- if (!aid?.isPrivateKeyValid())
1079
- throw new StateError('loadIdentity requires an AID with a valid private key');
1080
- const publicState = this.state;
1081
- if (publicState !== ConnectionState.NO_IDENTITY && publicState !== ConnectionState.CLOSED) {
1082
- throw new StateError(`loadIdentity not allowed in state ${publicState}`);
1083
- }
1084
- this._applyAidRuntimeContext(aid);
1085
- this._currentAid = aid;
1086
- this._aid = aid.aid;
1087
- this._identity = {
1088
- aid: aid.aid,
1089
- private_key_pem: aid.privateKeyPem,
1090
- public_key_der_b64: aid.publicKey,
1091
- cert: aid.certPem,
1092
- };
1093
- // 注入内存私钥到 AuthFlow,禁止 AuthFlow 内部再走 keystore 解密
1094
- this._auth.setIdentity(this._identity);
1095
- this._state = 'disconnected';
1096
- this._closing = false;
880
+ this._identityRuntime.loadIdentity(aid);
1097
881
  }
1098
882
  setProtectedHeaders(headers) {
1099
883
  if (!headers) {
@@ -1112,33 +896,16 @@ export class AUNClient {
1112
896
  return this._instanceProtectedHeaders ? { ...this._instanceProtectedHeaders } : null;
1113
897
  }
1114
898
  cachePeer(aid) {
1115
- if (!this.hasIdentity)
1116
- throw new StateError('cachePeer requires a loaded identity');
1117
- if (!aid.isCertValid())
1118
- throw new ValidationError('cachePeer requires an AID with a valid certificate');
1119
- this._peerCache.set(aid.aid, aid);
1120
- return aid;
899
+ return this._peerDirectory.cachePeer(aid);
1121
900
  }
1122
901
  getPeer(aid) {
1123
- if (!this.hasIdentity)
1124
- throw new StateError('getPeer requires a loaded identity');
1125
- return this._peerCache.get(String(aid ?? '').trim()) ?? null;
902
+ return this._peerDirectory.getPeer(aid);
1126
903
  }
1127
904
  async lookupPeer(aid) {
1128
- if (!this.hasIdentity)
1129
- throw new StateError('lookupPeer requires a loaded identity');
1130
- const target = String(aid ?? '').trim();
1131
- if (!target)
1132
- throw new ValidationError('lookupPeer requires non-empty aid');
1133
- const cached = this._peerCache.get(target);
1134
- if (cached)
1135
- return cached;
1136
- throw new NotFoundError(`peer not found in cache: ${target}`);
905
+ return this._peerDirectory.lookupPeer(aid);
1137
906
  }
1138
907
  peers() {
1139
- if (!this.hasIdentity)
1140
- throw new StateError('peers requires a loaded identity');
1141
- return [...this._peerCache.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, v]) => v);
908
+ return this._peerDirectory.peers();
1142
909
  }
1143
910
  get gatewayUrl() {
1144
911
  return this._gatewayUrl;
@@ -1153,130 +920,28 @@ export class AUNClient {
1153
920
  // ── 生命周期 ──────────────────────────────────────
1154
921
  /** 仅认证当前身份,获取/刷新 token,但不建立长连接。 */
1155
922
  async authenticate(options = {}) {
1156
- const tStart = Date.now();
1157
- const target = this._currentAid?.aid ?? this._aid ?? '';
1158
- if (!target || !this._currentAid?.isPrivateKeyValid()) {
1159
- throw new StateError('authenticate requires a loaded AID with a valid private key');
1160
- }
1161
- const publicState = this.state;
1162
- if (publicState !== ConnectionState.STANDBY) {
1163
- throw new StateError(`authenticate not allowed in state ${publicState}`);
1164
- }
1165
- if ('aid' in options || 'access_token' in options || 'token' in options || 'kite_token' in options) {
1166
- throw new ValidationError('authenticate options must not include aid or token fields; load an AID object first');
1167
- }
1168
- this._state = 'connecting';
1169
- try {
1170
- const gateway = String(options.gateway ?? this._gatewayUrl ?? await this._resolveGatewayForAid(target)).trim();
1171
- const result = await this._auth.authenticate(gateway, target);
1172
- this._gatewayUrl = String(result.gateway ?? gateway);
1173
- this._identity = await this._auth.loadIdentityOrNone(target);
1174
- this._state = 'authenticated';
1175
- this._clientLog.debug(`authenticate exit: elapsed=${Date.now() - tStart}ms aid=${target}`);
1176
- return result;
1177
- }
1178
- catch (err) {
1179
- this._state = 'disconnected';
1180
- this._clientLog.debug(`authenticate exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
1181
- throw err;
1182
- }
923
+ return this._lifecycle.authenticate(options);
1183
924
  }
1184
925
  /** 连接到 Gateway;身份来自构造函数或 loadIdentity(aid),认证由 SDK 内部自动完成。 */
1185
926
  async connect(opts) {
1186
- const tStart = Date.now();
1187
- if (opts !== undefined && opts !== null && typeof opts === 'object') {
1188
- const raw = opts;
1189
- const invalid = Object.keys(raw).filter((key) => !PUBLIC_CONNECTION_OPTION_KEYS.has(key)).sort();
1190
- if (invalid.length > 0) {
1191
- throw new ValidationError(`connect options contain unsupported field(s): ${invalid.join(', ')}`);
1192
- }
1193
- }
1194
- const target = this._currentAid?.aid ?? this._aid ?? '';
1195
- if (!target || !this._currentAid?.isPrivateKeyValid()) {
1196
- throw new StateError('connect requires a loaded AID with a valid private key');
1197
- }
1198
- const options = {};
1199
- if (opts?.auto_reconnect !== undefined)
1200
- options.auto_reconnect = opts.auto_reconnect;
1201
- if (opts?.heartbeat_interval !== undefined)
1202
- options.heartbeat_interval = opts.heartbeat_interval;
1203
- if (opts?.connect_timeout !== undefined || opts?.call_timeout !== undefined) {
1204
- options.timeouts = {
1205
- ...(opts.connect_timeout !== undefined ? { connect: opts.connect_timeout } : {}),
1206
- ...(opts.call_timeout !== undefined ? { call: opts.call_timeout } : {}),
1207
- };
1208
- }
1209
- if (opts?.retry_initial_delay !== undefined || opts?.retry_max_delay !== undefined || opts?.retry_max_attempts !== undefined) {
1210
- options.retry = {
1211
- initial_delay: opts.retry_initial_delay ?? 1,
1212
- max_delay: opts.retry_max_delay ?? 64,
1213
- max_attempts: opts.retry_max_attempts ?? 0,
1214
- };
1215
- }
1216
- if (opts?.connection_kind !== undefined)
1217
- options.connection_kind = opts.connection_kind;
1218
- if (opts?.short_ttl_ms !== undefined)
1219
- options.short_ttl_ms = opts.short_ttl_ms;
1220
- if (opts?.delivery_mode !== undefined)
1221
- options.delivery_mode = opts.delivery_mode;
1222
- if (opts?.extra_info !== undefined)
1223
- options.extra_info = opts.extra_info;
1224
- if (opts?.background_sync !== undefined)
1225
- options.background_sync = opts.background_sync;
1226
- this._clientLog.debug(`connect enter: state=${this._state} aid=${this._aid ?? '-'}`);
1227
- const publicState = this.state;
1228
- const allowed = new Set([
1229
- ConnectionState.STANDBY,
1230
- ConnectionState.AUTHENTICATED,
1231
- ConnectionState.RETRY_BACKOFF,
1232
- ConnectionState.CONNECTION_FAILED,
1233
- ]);
1234
- if (!allowed.has(publicState)) {
1235
- this._clientLog.debug(`connect exit (error): elapsed=${Date.now() - tStart}ms err=invalid_state state=${this._state}`);
1236
- throw new StateError(`connect not allowed in state ${publicState}`);
1237
- }
1238
- // gateway 来自 authenticate() 缓存的 this._gatewayUrl;未认证则自动 authenticate()
1239
- if (!this._gatewayUrl) {
1240
- await this.authenticate();
1241
- }
1242
- this._state = 'connecting';
1243
- const gateway = String(this._gatewayUrl ?? '').trim();
1244
- const params = { ...options, gateway };
1245
- const normalized = this._normalizeConnectParams(params);
1246
- this._sessionParams = normalized;
1247
- this._sessionOptions = this._buildSessionOptions(normalized);
1248
- this._transport.setTimeout(this._sessionOptions.timeouts.call);
1249
- this._closing = false;
1250
- const gateways = this._resolveGateways(normalized);
1251
- let lastErr = null;
1252
- for (const gw of gateways) {
1253
- try {
1254
- const gwParams = { ...normalized, gateway: gw };
1255
- await this._connectOnce(gwParams, true);
1256
- this._clientLog.debug(`connect exit: elapsed=${Date.now() - tStart}ms state=${this._state}`);
1257
- return;
1258
- }
1259
- catch (err) {
1260
- lastErr = err;
1261
- if (gateways.length > 1) {
1262
- this._clientLog.warn(`connect: gateway ${gw} failed, trying next: ${err instanceof Error ? err.message : String(err)}`);
1263
- }
1264
- if (this._state === 'connecting' || this._state === 'authenticating') {
1265
- this._state = 'connecting';
1266
- }
1267
- }
1268
- }
1269
- if (this._state === 'connecting' || this._state === 'authenticating') {
1270
- this._state = 'terminal_failed';
1271
- }
1272
- this._clientLog.debug(`connect exit (error): elapsed=${Date.now() - tStart}ms err=${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
1273
- throw lastErr;
927
+ return this._lifecycle.connect(opts);
1274
928
  }
1275
929
  /** 断开连接但保留本地状态,可再次 connect */
1276
930
  async disconnect() {
1277
931
  const tStart = Date.now();
1278
932
  this._clientLog.debug(`disconnect enter: state=${this._state}`);
1279
- if (this._state !== 'connected' && this._state !== 'reconnecting') {
933
+ if (this._closing) {
934
+ this._clientLog.debug(`disconnect exit: elapsed=${Date.now() - tStart}ms reason=closing`);
935
+ return;
936
+ }
937
+ if (![
938
+ ConnectionState.AUTHENTICATED,
939
+ ConnectionState.CONNECTING,
940
+ ConnectionState.READY,
941
+ ConnectionState.RETRY_BACKOFF,
942
+ ConnectionState.RECONNECTING,
943
+ ConnectionState.CONNECTION_FAILED,
944
+ ].includes(this.state)) {
1280
945
  this._clientLog.debug(`disconnect exit: elapsed=${Date.now() - tStart}ms reason=not_connected`);
1281
946
  return;
1282
947
  }
@@ -1288,7 +953,7 @@ export class AUNClient {
1288
953
  this._reconnectActive = false;
1289
954
  }
1290
955
  await this._transport.close();
1291
- this._state = 'disconnected';
956
+ this._state = 'standby';
1292
957
  await this._dispatcher.publish('state_change', { state: this._publicState(this._state) });
1293
958
  this._clientLog.debug(`disconnect exit: elapsed=${Date.now() - tStart}ms`);
1294
959
  }
@@ -1345,49 +1010,7 @@ export class AUNClient {
1345
1010
  }
1346
1011
  }
1347
1012
  async _callImpl(method, params) {
1348
- if (this._state !== 'connected') {
1349
- throw new ConnectionError('client is not connected');
1350
- }
1351
- if (INTERNAL_ONLY_METHODS.has(method)) {
1352
- throw new PermissionError(`method is internal_only: ${method}`);
1353
- }
1354
- if (method.startsWith('message.e2ee.') || method.startsWith('group.e2ee.') || REMOVED_E2EE_METHODS.has(method)) {
1355
- throw new PermissionError(`legacy E2EE method is removed in this SDK: ${method}`);
1356
- }
1357
- const p = { ...(params ?? {}) };
1358
- if (this._instanceProtectedHeaders && PROTECTED_HEADERS_METHODS.has(method)) {
1359
- const existing = isJsonObject(p.protected_headers) ? p.protected_headers : {};
1360
- p.protected_headers = { ...this._instanceProtectedHeaders, ...existing };
1361
- }
1362
- if (method === 'message.send' || method === 'group.send') {
1363
- this._normalizeOutboundMessagePayload(p, method);
1364
- }
1365
- this._validateOutboundCall(method, p);
1366
- this._injectMessageCursorContext(method, p);
1367
- if (method.startsWith('group.')
1368
- && !('_group_cursor_params' in p)
1369
- && !Boolean(p._pull_gate_locked)) {
1370
- const explicitCursorParams = this._groupCursorParams(p);
1371
- if (Object.keys(explicitCursorParams).length > 0) {
1372
- p._group_cursor_params = explicitCursorParams;
1373
- }
1374
- }
1375
- // group.* 方法的 group_id 归一化为 canonical 格式(兼容老/污染数据)
1376
- if (method.startsWith('group.') && p.group_id !== undefined && p.group_id !== null) {
1377
- const rawGroupId = String(p.group_id);
1378
- const normalizedGroupId = normalizeGroupId(rawGroupId);
1379
- if (normalizedGroupId && normalizedGroupId !== rawGroupId) {
1380
- this._clientLog.debug(`call group_id normalized: ${rawGroupId} -> ${normalizedGroupId} method=${method}`);
1381
- }
1382
- p.group_id = normalizedGroupId;
1383
- }
1384
- // group.* 方法注入 device_id(服务端用于多设备消息路由)
1385
- if (method.startsWith('group.') && p.device_id === undefined) {
1386
- p.device_id = this._deviceId;
1387
- }
1388
- if (method.startsWith('group.') && p.slot_id === undefined) {
1389
- p.slot_id = this._slotId;
1390
- }
1013
+ const p = this._rpcPipeline.preflight(method, params).params;
1391
1014
  // 自动加密:message.send 默认加密(encrypt 默认 true)— V2-only
1392
1015
  if (method === 'message.send') {
1393
1016
  const encrypt = p.encrypt !== undefined ? p.encrypt : true;
@@ -1495,101 +1118,13 @@ export class AUNClient {
1495
1118
  return await this._ackGroupV2(String(p.group_id), Number(p.seq ?? p.msg_seq ?? p.up_to_seq ?? 0) || undefined);
1496
1119
  }
1497
1120
  // 关键操作自动附加客户端签名
1498
- if (SIGNED_METHODS.has(method)) {
1499
- if (this._shouldSkipClientSignature(method, p)) {
1500
- delete p.client_signature;
1501
- }
1502
- else {
1503
- await this._signClientOperation(method, p);
1504
- }
1505
- }
1121
+ await this._rpcPipeline.applyClientSignature(method, p);
1506
1122
  // P1-23: 非幂等方法使用更长超时
1507
1123
  const callTimeout = NON_IDEMPOTENT_METHODS.has(method) ? NON_IDEMPOTENT_TIMEOUT : undefined;
1508
1124
  let result = callTimeout
1509
1125
  ? await this._transport.call(method, p, callTimeout)
1510
1126
  : await this._transport.call(method, p);
1511
- if (method === 'group.thought.get' && isJsonObject(result)) {
1512
- result = await this._decryptGroupThoughts(result);
1513
- }
1514
- if (method === 'message.thought.get' && isJsonObject(result)) {
1515
- result = await this._decryptMessageThoughts(result);
1516
- }
1517
- // V2-only 群状态编排:建群/成员变更后同步 propose+confirm state。
1518
- const membershipMethods = new Set([
1519
- 'group.create', 'group.add_member', 'group.kick', 'group.remove_member', 'group.leave',
1520
- 'group.review_join_request', 'group.batch_review_join_request',
1521
- 'group.use_invite_code', 'group.request_join',
1522
- ]);
1523
- if (membershipMethods.has(method) && isJsonObject(result) && !('error' in result) && this._v2Session) {
1524
- const groupId = this._extractGroupIdFromResult(result) || String(p.group_id ?? '');
1525
- if (groupId) {
1526
- try {
1527
- await this._v2AutoProposeState(groupId);
1528
- }
1529
- catch (exc) {
1530
- this._clientLog.debug(`V2 post-membership propose failed (non-fatal): group=${groupId} err=${formatCaughtError(exc)}`);
1531
- }
1532
- // group.create / group.use_invite_code 成功后注册 group SPK
1533
- if (method === 'group.create' || method === 'group.use_invite_code') {
1534
- const callFn = async (m, ps) => this.call(m, ps);
1535
- this._v2Session.ensureGroupRegistered?.(groupId, callFn)?.catch(exc => {
1536
- this._clientLog.debug(`group SPK registration after ${method} failed (non-fatal): group=${groupId} err=${exc}`);
1537
- });
1538
- }
1539
- }
1540
- }
1541
- // message.pull 返回后的 seq 跟踪(V2 路径已在 call() 开头路由,此处为 plaintext pull 的 seq 跟踪)
1542
- if (method === 'message.pull' && isJsonObject(result)) {
1543
- const r = result;
1544
- const messages = r.messages;
1545
- const rawMessages = (Array.isArray(messages) ? messages : []).filter(isJsonObject);
1546
- if (this._aid) {
1547
- const ns = `p2p:${this._aid}`;
1548
- if (rawMessages.length) {
1549
- const pullAfterSeq = Number(p.after_seq ?? 0) || 0;
1550
- this._seqTracker.onPullResult(ns, rawMessages, pullAfterSeq);
1551
- }
1552
- // ⚠️ 逻辑边界 L1/L3:P2P retention floor 通道 = server_ack_seq
1553
- const serverAck = Number(r.server_ack_seq ?? 0);
1554
- if (serverAck > 0) {
1555
- const contig = this._seqTracker.getContiguousSeq(ns);
1556
- if (contig < serverAck) {
1557
- this._clientLog.info('message.pull retention-floor advance: ns=' + ns + ' contiguous=' + contig + ' -> server_ack_seq=' + serverAck);
1558
- this._seqTracker.forceContiguousSeq(ns, serverAck);
1559
- }
1560
- }
1561
- this._saveSeqTrackerState();
1562
- // auto-ack 延迟到 publish 完成后(由 _fillP2pGap 负责)
1563
- }
1564
- }
1565
- // group.pull 返回后的 seq 跟踪(V2 路径已在 call() 开头路由,此处为 plaintext pull 的 seq 跟踪)
1566
- if (method === 'group.pull' && isJsonObject(result)) {
1567
- const r = result;
1568
- const messages = r.messages;
1569
- const rawMessages = (Array.isArray(messages) ? messages : []).filter(isJsonObject);
1570
- const gid = (p.group_id ?? '');
1571
- if (gid) {
1572
- const ns = `group:${gid}`;
1573
- if (rawMessages.length) {
1574
- const pullAfterSeq = Number(p.after_message_seq ?? p.after_seq ?? 0) || 0;
1575
- this._seqTracker.onPullResult(ns, rawMessages, pullAfterSeq);
1576
- }
1577
- // ⚠️ 逻辑边界 L4:group retention floor 通道 = cursor.current_seq
1578
- const cursor = isJsonObject(r.cursor) ? r.cursor : null;
1579
- if (cursor) {
1580
- const serverAck = Number(cursor.current_seq ?? 0);
1581
- if (serverAck > 0) {
1582
- const contig = this._seqTracker.getContiguousSeq(ns);
1583
- if (contig < serverAck) {
1584
- this._clientLog.info('group.pull retention-floor advance: ns=' + ns + ' contiguous=' + contig + ' -> cursor.current_seq=' + serverAck);
1585
- this._seqTracker.forceContiguousSeq(ns, serverAck);
1586
- }
1587
- }
1588
- }
1589
- this._saveSeqTrackerState();
1590
- // auto-ack 延迟到 publish 完成后(由 _fillGroupGap 负责)
1591
- }
1592
- }
1127
+ result = await this._rpcPipeline.postprocessResult(method, p, result);
1593
1128
  // ── Group E2EE 自动编排已移除(V2-only:由 group.v2.bootstrap 驱动)────────
1594
1129
  return result;
1595
1130
  }
@@ -1608,15 +1143,7 @@ export class AUNClient {
1608
1143
  if (method.startsWith('group.') && p.slot_id === undefined) {
1609
1144
  p.slot_id = this._slotId;
1610
1145
  }
1611
- if (SIGNED_METHODS.has(method)) {
1612
- if (this._shouldSkipClientSignature(method, p)) {
1613
- delete p.client_signature;
1614
- }
1615
- else {
1616
- await this._signClientOperation(method, p);
1617
- }
1618
- }
1619
- return await this._transport.call(method, p);
1146
+ return await this._rpcPipeline.rawCall(method, p);
1620
1147
  }
1621
1148
  // ── 事件 ──────────────────────────────────────────
1622
1149
  /**
@@ -1633,141 +1160,27 @@ export class AUNClient {
1633
1160
  this._dispatcher.unsubscribe(event, handler);
1634
1161
  }
1635
1162
  // ── 事件管道:消息解密 ────────────────────────────
1163
+ _isEncryptedPushMessage(msg) {
1164
+ return this._v2E2EE.isEncryptedPushMessage(msg);
1165
+ }
1166
+ _attachV2EnvelopeMetadataFromSource(message, source) {
1167
+ attachV2EnvelopeMetadataFromSource(message, source);
1168
+ }
1636
1169
  /** 处理 transport 层推送的原始消息:re-publish 给用户(V2 加密消息走 _raw.peer.v2.message_received) */
1637
1170
  _onRawMessageReceived(data) {
1638
- this._clientLog.debug(`_onRawMessageReceived enter: from=${data?.from ?? '-'} mid=${data?.message_id ?? '-'} seq=${data?.seq ?? '-'}`);
1639
- this._safeAsync(this._processAndPublishMessage(data));
1640
- this._clientLog.debug(`_onRawMessageReceived exit: elapsed=0ms (dispatched async)`);
1171
+ this._delivery.onRawMessageReceived(data);
1641
1172
  }
1642
1173
  /** 实际处理推送消息的异步任务(V2-only:明文消息直接透传,V2 加密消息走 _onV2PushNotification) */
1643
1174
  async _processAndPublishMessage(data) {
1644
- try {
1645
- if (!isJsonObject(data)) {
1646
- await this._publishAppEvent('message.received', data);
1647
- return;
1648
- }
1649
- const msg = { ...data };
1650
- if (!this._messageTargetsCurrentInstance(msg)) {
1651
- return;
1652
- }
1653
- // P2P 空洞检测
1654
- const seq = msg.seq;
1655
- const encryptedPush = isEncryptedPushMessage(msg);
1656
- if (seq !== undefined && seq !== null && this._aid) {
1657
- const ns = `p2p:${this._aid}`;
1658
- // Push 修上界:先更新 maxSeenSeq
1659
- if (seq > 0)
1660
- this._seqTracker.updateMaxSeen(ns, seq);
1661
- const contigBefore = this._seqTracker.getContiguousSeq(ns);
1662
- const seqNeedsPull = this._seqTracker.onMessageSeq(ns, seq);
1663
- const published = encryptedPush
1664
- ? await this._publishEncryptedPushMessage('message.received', 'message.undecryptable', ns, seq, msg, false)
1665
- : await this._publishOrderedMessage('message.received', ns, seq, msg);
1666
- const contigAfter = this._seqTracker.getContiguousSeq(ns);
1667
- const needPull = seqNeedsPull && !published;
1668
- if (needPull) {
1669
- this._safeAsync(this._fillP2pGap());
1670
- }
1671
- // auto-ack contiguous_seq
1672
- const contig = this._seqTracker.getContiguousSeq(ns);
1673
- if (contig > 0) {
1674
- const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
1675
- const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
1676
- this._transport.call('message.ack', {
1677
- seq: ackSeq,
1678
- device_id: this._deviceId,
1679
- slot_id: this._slotId,
1680
- }).catch((e) => { this._clientLog.warn(`P2P auto-ack failed:${String(e)}`); });
1681
- }
1682
- // 即时持久化 cursor,异常断连后不回退
1683
- if (contigAfter !== contigBefore)
1684
- this._saveSeqTrackerState();
1685
- if (encryptedPush)
1686
- return;
1687
- }
1688
- else {
1689
- if (encryptedPush) {
1690
- await this._publishEncryptedPushMessage('message.received', 'message.undecryptable', '', seq ?? 0, msg, false);
1691
- return;
1692
- }
1693
- await this._publishAppEvent('message.received', msg);
1694
- }
1695
- }
1696
- catch (exc) {
1697
- this._clientLog.warn(`P2P push processing failed:${String(exc)}`);
1698
- if (isJsonObject(data)) {
1699
- const src = data;
1700
- const safeEvent = {
1701
- message_id: (src.message_id ?? null),
1702
- from: (src.from ?? null),
1703
- to: (src.to ?? null),
1704
- seq: (src.seq ?? null),
1705
- timestamp: (src.timestamp ?? null),
1706
- _decrypt_error: String(exc),
1707
- };
1708
- attachV2EnvelopeMetadataFromSource(safeEvent, data);
1709
- await this._publishAppEvent('message.undecryptable', safeEvent);
1710
- }
1711
- }
1175
+ return this._delivery.processAndPublishMessage(data);
1712
1176
  }
1713
1177
  /** 处理群组消息推送:re-publish(V2 加密消息走 V2 push 路径) */
1714
1178
  _onRawGroupMessageCreated(data) {
1715
- this._clientLog.debug(`_onRawGroupMessageCreated enter: group_id=${data?.group_id ?? '-'} from=${data?.from ?? '-'} seq=${data?.seq ?? '-'}`);
1716
- this._safeAsync(this._processAndPublishGroupMessage(data));
1717
- this._clientLog.debug(`_onRawGroupMessageCreated exit: elapsed=0ms (dispatched async)`);
1179
+ return this._delivery.onRawGroupMessageCreated(data);
1718
1180
  }
1719
1181
  /** 处理 V2 群消息通知:主动 pull V2 envelope,由 pullGroupV2 解密并发布。 */
1720
1182
  async _onRawGroupV2MessageCreated(data) {
1721
- const tStart = Date.now();
1722
- if (!isJsonObject(data)) {
1723
- this._clientLog.debug(`_onRawGroupV2MessageCreated skipped: non-object type=${typeof data}`);
1724
- return;
1725
- }
1726
- const groupId = String(data.group_id ?? '').trim();
1727
- const seq = Number(data.seq ?? 0) || 0;
1728
- const messageId = String(data.message_id ?? '').trim();
1729
- const senderAid = String(data.sender_aid ?? '').trim();
1730
- this._clientLog.debug(`_onRawGroupV2MessageCreated group=${groupId} seq=${seq} message_id=${messageId} sender=${senderAid}`);
1731
- if (!groupId || seq <= 0) {
1732
- this._clientLog.debug('_onRawGroupV2MessageCreated skipped: missing group_id or seq');
1733
- return;
1734
- }
1735
- if (!this._v2Session) {
1736
- this._clientLog.debug('_onRawGroupV2MessageCreated skipped: V2 session not initialized');
1737
- return;
1738
- }
1739
- try {
1740
- const ns = `group:${groupId}`;
1741
- // Push 修上界:先更新 maxSeenSeq
1742
- this._seqTracker.updateMaxSeen(ns, seq);
1743
- const contigBefore = this._seqTracker.getContiguousSeq(ns);
1744
- if (contigBefore === seq) {
1745
- this._clientLog.debug(`_onRawGroupV2MessageCreated duplicate push already covered: group=${groupId} seq=${seq}`);
1746
- return;
1747
- }
1748
- const afterSeq = this._repairPushContiguousBound(ns, seq, false, '_raw.group.v2.message_created');
1749
- // per-namespace 去重:同一 group namespace 只允许 1 个 in-flight pull
1750
- const dedupKey = `group_pull:${ns}`;
1751
- if (this._gapFillDone.has(dedupKey)) {
1752
- this._clientLog.debug(`_onRawGroupV2MessageCreated skipped: dedupKey=${dedupKey} in flight`);
1753
- return;
1754
- }
1755
- this._gapFillDone.add(dedupKey);
1756
- try {
1757
- this._clientLog.debug(`_onRawGroupV2MessageCreated -> group.v2.pull group=${groupId} after_seq=${afterSeq}`);
1758
- const messages = await this._pullGroupV2(groupId, afterSeq, 50);
1759
- this._clientLog.debug(`_onRawGroupV2MessageCreated pulled ${messages.length} msgs for group=${groupId}`);
1760
- }
1761
- finally {
1762
- this._gapFillDone.delete(dedupKey);
1763
- }
1764
- }
1765
- catch (exc) {
1766
- this._clientLog.warn(`_onRawGroupV2MessageCreated pull failed group=${groupId}: ${String(exc)}`);
1767
- }
1768
- finally {
1769
- this._clientLog.debug(`_onRawGroupV2MessageCreated exit: elapsed=${Date.now() - tStart}ms`);
1770
- }
1183
+ return this._delivery.onRawGroupV2MessageCreated(data);
1771
1184
  }
1772
1185
  /**
1773
1186
  * 处理群组推送消息的异步任务(V2-only:明文消息直接透传)。
@@ -1776,433 +1189,38 @@ export class AUNClient {
1776
1189
  * 不带 payload 的事件(通知):自动 pull 最新消息。
1777
1190
  */
1778
1191
  async _processAndPublishGroupMessage(data) {
1779
- try {
1780
- if (!isJsonObject(data)) {
1781
- await this._publishAppEvent('group.message_created', data);
1782
- return;
1783
- }
1784
- const msg = { ...data };
1785
- const groupId = (msg.group_id ?? '');
1786
- const seq = msg.seq;
1787
- const payload = msg.payload;
1788
- // 推送路径收到群消息 → 标记已同步,后续发送无需再 lazySyncGroup
1789
- if (groupId) {
1790
- this._groupSynced.add(groupId);
1791
- }
1792
- if (payload === undefined || payload === null
1793
- || (typeof payload === 'object' && Object.keys(payload).length === 0)) {
1794
- // 不带 payload 的通知
1795
- await this._autoPullGroupMessages(msg);
1796
- return;
1797
- }
1798
- // seq 跟踪 + auto-ack
1799
- const encryptedPush = isEncryptedPushMessage(msg);
1800
- if (groupId && seq !== undefined && seq !== null) {
1801
- const ns = `group:${groupId}`;
1802
- // Push 修上界:先更新 maxSeenSeq
1803
- if (seq > 0)
1804
- this._seqTracker.updateMaxSeen(ns, seq);
1805
- const contigBefore = this._seqTracker.getContiguousSeq(ns);
1806
- const seqNeedsPull = this._seqTracker.onMessageSeq(ns, seq);
1807
- const published = encryptedPush
1808
- ? await this._publishEncryptedPushMessage('group.message_created', 'group.message_undecryptable', ns, seq, msg, true)
1809
- : await this._publishOrderedMessage('group.message_created', ns, seq, msg);
1810
- const contigAfter = this._seqTracker.getContiguousSeq(ns);
1811
- const needPull = seqNeedsPull && !published;
1812
- if (needPull) {
1813
- this._safeAsync(this._fillGroupGap(groupId));
1814
- }
1815
- const contig = this._seqTracker.getContiguousSeq(ns);
1816
- if (contig > 0) {
1817
- const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
1818
- const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
1819
- this._transport.call('group.ack_messages', {
1820
- group_id: groupId,
1821
- msg_seq: ackSeq,
1822
- device_id: this._deviceId,
1823
- slot_id: this._slotId,
1824
- }).catch((e) => { this._clientLog.warn('group message auto-ack failed: group=' + groupId, e); });
1825
- }
1826
- if (contigAfter !== contigBefore)
1827
- this._saveSeqTrackerState();
1828
- if (encryptedPush)
1829
- return;
1830
- }
1831
- else {
1832
- if (encryptedPush) {
1833
- await this._publishEncryptedPushMessage('group.message_created', 'group.message_undecryptable', '', seq ?? 0, msg, true);
1834
- return;
1835
- }
1836
- await this._publishAppEvent('group.message_created', msg);
1837
- }
1838
- }
1839
- catch (exc) {
1840
- this._clientLog.warn(`group push processing failed:${String(exc)}`);
1841
- if (isJsonObject(data)) {
1842
- const src = data;
1843
- const safeEvent = {
1844
- message_id: (src.message_id ?? null),
1845
- group_id: (src.group_id ?? null),
1846
- from: (src.from ?? null),
1847
- seq: (src.seq ?? null),
1848
- timestamp: (src.timestamp ?? null),
1849
- _decrypt_error: String(exc),
1850
- };
1851
- attachV2EnvelopeMetadataFromSource(safeEvent, data);
1852
- await this._publishAppEvent('group.message_undecryptable', safeEvent);
1853
- }
1854
- }
1855
- }
1856
- async _decryptEncryptedPushPayload(msg, group) {
1857
- const envelope = encryptedPushEnvelope(msg);
1858
- if (!isV2EncryptedEnvelopePayload(envelope))
1859
- return null;
1860
- const aad = isJsonObject(envelope.aad) ? envelope.aad : {};
1861
- const fromAid = String(msg.from_aid ?? msg.from ?? msg.sender_aid ?? aad.from ?? '').trim();
1862
- const plaintext = await this._decryptV2EnvelopeForThought({ envelope, fromAid });
1863
- if (!plaintext)
1864
- return null;
1865
- const e2eeMeta = v2E2eeMeta(envelope);
1866
- const result = {
1867
- message_id: String(msg.message_id ?? ''),
1868
- from: fromAid,
1869
- seq: msg.seq ?? null,
1870
- timestamp: msg.t_server ?? msg.timestamp ?? null,
1871
- payload: plaintext,
1872
- encrypted: true,
1873
- e2ee: e2eeMeta,
1874
- };
1875
- result.direction = fromAid && fromAid === this._aid ? 'outbound_sync' : 'inbound';
1876
- if (msg.t_server !== undefined)
1877
- result.t_server = msg.t_server;
1878
- if (msg.device_id !== undefined)
1879
- result.device_id = msg.device_id;
1880
- if (msg.slot_id !== undefined)
1881
- result.slot_id = msg.slot_id;
1882
- if (group) {
1883
- result.group_id = msg.group_id ?? aad.group_id ?? envelope.group_id ?? null;
1884
- }
1885
- else {
1886
- result.to = msg.to ?? this._aid ?? '';
1887
- }
1888
- attachV2EnvelopeMetadata(result, e2eeMeta);
1889
- return result;
1890
- }
1891
- async _publishEncryptedPushAsUndecryptable(event, ns, seq, msg, group) {
1892
- const safeEvent = safeUndecryptablePushEvent(msg, group);
1893
- if (ns) {
1894
- return this._publishOrderedMessage(event, ns, seq, safeEvent);
1895
- }
1896
- await this._publishAppEvent(event, safeEvent);
1897
- return true;
1192
+ return this._delivery.processAndPublishGroupMessage(data);
1898
1193
  }
1899
1194
  async _publishEncryptedPushMessage(normalEvent, undecryptableEvent, ns, seq, msg, group) {
1900
- const decrypted = await this._decryptEncryptedPushPayload(msg, group);
1901
- if (decrypted) {
1902
- if (ns)
1903
- return this._publishOrderedMessage(normalEvent, ns, seq, decrypted);
1904
- await this._publishAppEvent(normalEvent, decrypted);
1905
- return true;
1906
- }
1907
- return this._publishEncryptedPushAsUndecryptable(undecryptableEvent, ns, seq, msg, group);
1195
+ return await this._v2E2EE.publishEncryptedPushMessage(normalEvent, undecryptableEvent, ns, seq, msg, group);
1908
1196
  }
1909
- /** 收到不带 payload 的 group.message_created 通知后,自动 pull 最新消息 */
1910
- async _autoPullGroupMessages(notification) {
1911
- const groupId = (notification.group_id ?? '');
1912
- if (!groupId) {
1913
- await this._publishAppEvent('group.message_created', notification);
1914
- return;
1915
- }
1916
- const ns = `group:${groupId}`;
1917
- const afterSeq = this._seqTracker.getContiguousSeq(ns);
1918
- try {
1919
- // V2-only 模式:走 group.v2.pull(合并 V1 明文 + V2 密文并自动解密)
1920
- if (this._v2Session) {
1921
- await this._pullGroupV2Internal({ group_id: groupId, after_seq: afterSeq, limit: 50 });
1922
- return;
1923
- }
1924
- const result = await this.call('group.pull', {
1925
- group_id: groupId,
1926
- after_message_seq: afterSeq,
1927
- device_id: this._deviceId,
1928
- limit: 50,
1929
- });
1930
- if (isJsonObject(result)) {
1931
- const messages = result.messages;
1932
- if (Array.isArray(messages)) {
1933
- // ⚠️ 不再重复调用 onPullResult:call('group.pull') 拦截器已在内部调用过一次
1934
- const pushed = this._pushedSeqs.get(ns);
1935
- for (const msg of messages) {
1936
- if (isJsonObject(msg)) {
1937
- const s = msg.seq;
1938
- if (pushed && s !== undefined && s !== null && pushed.has(s)) {
1939
- continue; // 已发布到应用层,跳过
1940
- }
1941
- if (s !== undefined && s !== null) {
1942
- await this._publishPulledMessage('group.message_created', ns, s, msg);
1943
- }
1944
- else {
1945
- await this._publishAppEvent('group.message_created', msg);
1946
- }
1947
- }
1948
- }
1949
- this._prunePushedSeqs(ns);
1950
- return;
1951
- }
1952
- }
1953
- }
1954
- catch (exc) {
1955
- this._clientLog.warn(`auto pull group message failed:${String(exc)}`);
1956
- }
1957
- // pull 失败时仍透传原始通知
1958
- await this._publishAppEvent('group.message_created', notification);
1197
+ async _decryptV2PushMessage(data) {
1198
+ return await this._v2E2EE.decryptV2PushMessage(data);
1959
1199
  }
1960
1200
  /** 后台补齐群消息空洞 */
1961
1201
  async _fillGroupGap(groupId) {
1962
- // 状态保护:非 connected 或正在关闭时跳过(与 Python 对齐)
1963
- if (this._state !== 'connected' || this._closing)
1964
- return;
1965
- groupId = normalizeGroupId(groupId) || String(groupId ?? '').trim();
1966
- if (!groupId)
1967
- return;
1968
- const ns = `group:${groupId}`;
1969
- const afterSeq = this._seqTracker.getContiguousSeq(ns);
1970
- // per-namespace 去重:同一 group namespace 只允许 1 个 in-flight pull
1971
- const dedupKey = `group_pull:${ns}`;
1972
- if (this._gapFillDone.has(dedupKey))
1973
- return;
1974
- this._gapFillDone.add(dedupKey);
1975
- this._gapFillActive = true;
1976
- let filled = 0;
1977
- try {
1978
- const messages = await this._pullGroupV2(groupId, afterSeq, 50);
1979
- filled = messages.length;
1980
- this._prunePushedSeqs(ns);
1981
- }
1982
- catch (exc) {
1983
- this._clientLog.warn(`group message gap-fill failed:${String(exc)}`);
1984
- }
1985
- finally {
1986
- // S1: 成功 / 失败路径都必须清理飞行标记
1987
- this._gapFillDone.delete(dedupKey);
1988
- this._gapFillActive = false;
1989
- if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
1990
- this._safeAsync(this._fillGroupGap(groupId));
1991
- }
1992
- }
1202
+ return this._delivery.fillGroupGap(groupId);
1993
1203
  }
1994
1204
  /** 后台补齐群事件空洞 */
1995
1205
  async _fillGroupEventGap(groupId) {
1996
- // 状态保护:非 connected 或正在关闭时跳过(与 Python 对齐)
1997
- if (this._state !== 'connected' || this._closing)
1998
- return;
1999
- const ns = `group_event:${groupId}`;
2000
- const afterSeq = this._seqTracker.getContiguousSeq(ns);
2001
- // per-namespace 去重:同一 group_event namespace 只允许 1 个 in-flight pull
2002
- const dedupKey = `group_event_pull:${ns}`;
2003
- if (this._gapFillDone.has(dedupKey))
2004
- return;
2005
- this._gapFillDone.add(dedupKey);
2006
- this._gapFillActive = true;
2007
- try {
2008
- let nextAfterSeq = afterSeq;
2009
- const maxPages = 100;
2010
- let pageCount = 0;
2011
- while (pageCount < maxPages) {
2012
- pageCount += 1;
2013
- const result = await this.call('group.pull_events', {
2014
- group_id: groupId,
2015
- after_event_seq: nextAfterSeq,
2016
- device_id: this._deviceId,
2017
- limit: 50,
2018
- });
2019
- if (!isJsonObject(result))
2020
- return;
2021
- const events = result.events;
2022
- if (!Array.isArray(events))
2023
- return;
2024
- const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
2025
- const eventObjects = events.filter(isJsonObject);
2026
- if (eventObjects.length > 0) {
2027
- this._seqTracker.onPullResult(ns, eventObjects, nextAfterSeq);
2028
- }
2029
- const cursor = isJsonObject(result.cursor) ? result.cursor : null;
2030
- const serverAck = cursor ? Number(cursor.current_seq ?? 0) : 0;
2031
- if (serverAck > 0) {
2032
- const contigBeforeFloor = this._seqTracker.getContiguousSeq(ns);
2033
- if (contigBeforeFloor < serverAck) {
2034
- this._clientLog.info('group.pull_events retention-floor advance: ns=' + ns + ' contiguous=' + contigBeforeFloor + ' -> cursor.current_seq=' + serverAck);
2035
- this._seqTracker.forceContiguousSeq(ns, serverAck);
2036
- }
2037
- }
2038
- const eventSeqs = [];
2039
- for (const evt of eventObjects) {
2040
- const eventSeq = Number(evt.event_seq ?? 0);
2041
- if (Number.isFinite(eventSeq) && eventSeq > 0)
2042
- eventSeqs.push(eventSeq);
2043
- evt._from_gap_fill = true;
2044
- const et = String(evt.event_type ?? '');
2045
- // 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
2046
- if (et === 'group.message_created')
2047
- continue;
2048
- // 验签:有 client_signature 就验(与实时事件路径对齐)
2049
- const cs = evt.client_signature;
2050
- if (cs && typeof cs === 'object') {
2051
- if (this._shouldSkipEventSignature(evt)) {
2052
- delete evt.client_signature;
2053
- }
2054
- else {
2055
- evt._verified = await this._verifyEventSignature(evt, cs);
2056
- }
2057
- }
2058
- // group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
2059
- await this._dispatcher.publish('group.changed', evt);
2060
- }
2061
- const contig = this._seqTracker.getContiguousSeq(ns);
2062
- if (contig !== pageContigBefore) {
2063
- this._saveSeqTrackerState();
2064
- }
2065
- if (eventObjects.length > 0 && contig > 0 && contig !== pageContigBefore) {
2066
- const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
2067
- const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
2068
- this._transport.call('group.ack_events', {
2069
- group_id: groupId,
2070
- event_seq: ackSeq,
2071
- device_id: this._deviceId,
2072
- slot_id: this._slotId,
2073
- }).catch((e) => { this._clientLog.warn('group event auto-ack failed: group=' + groupId, e); });
2074
- }
2075
- const nextAfter = Math.max(eventSeqs.length > 0 ? Math.max(...eventSeqs) : nextAfterSeq, nextAfterSeq);
2076
- if (eventObjects.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
2077
- break;
2078
- nextAfterSeq = nextAfter;
2079
- }
2080
- if (pageCount >= maxPages) {
2081
- this._clientLog.warn(`group event gap fill reached max_pages=${maxPages} group=${groupId} after_seq=${nextAfterSeq}`);
2082
- }
2083
- }
2084
- catch (exc) {
2085
- this._clientLog.warn(`group event gap-fill failed:${String(exc)}`);
2086
- }
2087
- finally {
2088
- // S1: 成功 / 失败路径都必须清理飞行标记
2089
- this._gapFillDone.delete(dedupKey);
2090
- this._gapFillActive = false;
2091
- }
1206
+ return this._delivery.fillGroupEventGap(groupId);
2092
1207
  }
2093
1208
  /** 后台补齐 P2P 消息空洞 */
2094
1209
  async _fillP2pGap() {
2095
- // 状态保护:非 connected 或正在关闭时跳过(与 Python 对齐)
2096
- if (this._state !== 'connected' || this._closing)
2097
- return;
2098
- if (!this._aid)
2099
- return;
2100
- const ns = `p2p:${this._aid}`;
2101
- const afterSeq = this._seqTracker.getContiguousSeq(ns);
2102
- // per-namespace 去重:同一 namespace 只允许 1 个 in-flight pull
2103
- const dedupKey = `p2p_pull:${ns}`;
2104
- if (this._gapFillDone.has(dedupKey))
2105
- return;
2106
- this._gapFillDone.add(dedupKey);
2107
- this._gapFillActive = true;
2108
- let filled = 0;
2109
- try {
2110
- const messages = await this._pullV2(afterSeq, 50);
2111
- filled = messages.length;
2112
- this._prunePushedSeqs(ns);
2113
- }
2114
- catch (exc) {
2115
- this._clientLog.warn(`P2P message gap-fill failed:${String(exc)}`);
2116
- }
2117
- finally {
2118
- // S1: 成功 / 失败路径都必须清理飞行标记
2119
- this._gapFillDone.delete(dedupKey);
2120
- this._gapFillActive = false;
2121
- if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
2122
- this._safeAsync(this._fillP2pGap());
2123
- }
2124
- }
1210
+ return this._delivery.fillP2pGap();
2125
1211
  }
2126
1212
  /** 只按硬上限裁剪 published guard,不能按 contiguousSeq 清理。 */
2127
1213
  _prunePushedSeqs(ns) {
2128
- const pushed = this._pushedSeqs.get(ns);
2129
- if (!pushed)
2130
- return;
2131
- if (pushed.size > PUSHED_SEQS_LIMIT) {
2132
- const keep = [...pushed].sort((a, b) => a - b).slice(-PUSHED_SEQS_LIMIT);
2133
- this._pushedSeqs.set(ns, new Set(keep));
2134
- }
1214
+ this._delivery.prunePushedSeqs(ns);
2135
1215
  }
2136
1216
  _markPublishedSeq(ns, seq) {
2137
- let pushed = this._pushedSeqs.get(ns);
2138
- if (!pushed) {
2139
- pushed = new Set();
2140
- this._pushedSeqs.set(ns, pushed);
2141
- }
2142
- pushed.add(seq);
2143
- if (pushed.size > PUSHED_SEQS_LIMIT) {
2144
- const keep = [...pushed].sort((a, b) => a - b).slice(-PUSHED_SEQS_LIMIT);
2145
- this._pushedSeqs.set(ns, new Set(keep));
2146
- }
2147
- }
2148
- _enqueueOrderedMessage(ns, event, seq, payload) {
2149
- let queue = this._pendingOrderedMsgs.get(ns);
2150
- if (!queue) {
2151
- queue = new Map();
2152
- this._pendingOrderedMsgs.set(ns, queue);
2153
- }
2154
- queue.set(seq, { event, payload });
2155
- if (queue.size > PENDING_ORDERED_LIMIT) {
2156
- const drop = [...queue.keys()].sort((a, b) => a - b).slice(0, queue.size - PENDING_ORDERED_LIMIT);
2157
- for (const oldSeq of drop)
2158
- queue.delete(oldSeq);
2159
- }
2160
- }
2161
- _isInstanceScopedMessageEvent(event) {
2162
- return event === 'message.received'
2163
- || event === 'message.undecryptable'
2164
- || event === 'group.message_created'
2165
- || event === 'group.message_undecryptable';
1217
+ this._delivery.markPublishedSeq(ns, seq);
2166
1218
  }
2167
1219
  _attachCurrentInstanceContext(payload) {
2168
- if (!isJsonObject(payload))
2169
- return payload;
2170
- const result = { ...payload };
2171
- if (!('device_id' in result)) {
2172
- result.device_id = this._deviceId;
2173
- }
2174
- if (!('slot_id' in result)) {
2175
- result.slot_id = this._slotId;
2176
- }
2177
- return result;
2178
- }
2179
- _normalizePublishedMessagePayload(event, payload) {
2180
- if (!this._isInstanceScopedMessageEvent(event))
2181
- return payload;
2182
- return this._attachCurrentInstanceContext(payload);
1220
+ return this._delivery.attachCurrentInstanceContext(payload);
2183
1221
  }
2184
1222
  async _publishAppEvent(event, payload) {
2185
- if ((event === 'message.received' || event === 'group.message_created') && isJsonObject(payload)) {
2186
- this._maybeAppendEchoTraceReceive(payload);
2187
- }
2188
- // 注入本地/远端 agent.md etag,让应用层判断版本一致性;失败不影响业务。
2189
- if (isJsonObject(payload)) {
2190
- try {
2191
- const snapshot = this._agentMdManager.eventSnapshot();
2192
- const localEtag = snapshot?.local_etag || '';
2193
- const remoteEtag = snapshot?.remote_etag || '';
2194
- if ((localEtag || remoteEtag) && payload._agent_md === undefined) {
2195
- payload._agent_md = {
2196
- local_etag: localEtag,
2197
- remote_etag: remoteEtag,
2198
- };
2199
- }
2200
- }
2201
- catch (exc) {
2202
- this._clientLog.debug(`agent_md etag inject skipped: ${String(exc)}`);
2203
- }
2204
- }
2205
- await this._dispatcher.publish(event, this._normalizePublishedMessagePayload(event, payload));
1223
+ await this._delivery.publishAppEvent(event, payload);
2206
1224
  }
2207
1225
  _echoTimestamp() {
2208
1226
  const now = new Date();
@@ -2228,13 +1246,6 @@ export class AUNClient {
2228
1246
  const trace = `${this._echoTimestamp()} [AUN-SDK.send] aid=${this._aid ?? '-'} conn_uptime=${uptime}s`;
2229
1247
  params.payload = { ...payload, text: payload.text + '\n' + trace };
2230
1248
  }
2231
- _shouldSkipClientSignature(method, params) {
2232
- if (method !== 'message.send' && method !== 'group.send')
2233
- return false;
2234
- if (params.encrypted || params.encrypt)
2235
- return false;
2236
- return this._isEchoPayload(params.payload);
2237
- }
2238
1249
  _shouldSkipEventSignature(event) {
2239
1250
  if (event.encrypted || event.encrypt)
2240
1251
  return false;
@@ -2251,90 +1262,16 @@ export class AUNClient {
2251
1262
  msg.payload = { ...payload, text: payload.text + '\n' + trace };
2252
1263
  }
2253
1264
  _messageTargetsCurrentInstance(message) {
2254
- if (!isJsonObject(message))
2255
- return true;
2256
- if ('device_id' in message) {
2257
- const targetDeviceId = String(message.device_id ?? '').trim();
2258
- if (targetDeviceId !== this._deviceId) {
2259
- return false;
2260
- }
2261
- }
2262
- if ('slot_id' in message) {
2263
- const targetSlotId = String(message.slot_id ?? '').trim();
2264
- if (slotIsolationKey(targetSlotId) !== slotIsolationKey(this._slotId)) {
2265
- return false;
2266
- }
2267
- }
2268
- return true;
1265
+ return this._delivery.messageTargetsCurrentInstance(message);
2269
1266
  }
2270
1267
  async _drainOrderedMessages(ns, beforeSeq) {
2271
- const queue = this._pendingOrderedMsgs.get(ns);
2272
- if (!queue || queue.size === 0)
2273
- return;
2274
- const contig = this._seqTracker.getContiguousSeq(ns);
2275
- const ready = [...queue.keys()]
2276
- .filter((seq) => seq <= contig && (beforeSeq === undefined || seq < beforeSeq))
2277
- .sort((a, b) => a - b);
2278
- for (const seq of ready) {
2279
- const item = queue.get(seq);
2280
- queue.delete(seq);
2281
- if (!item || this._pushedSeqs.get(ns)?.has(seq))
2282
- continue;
2283
- await this._publishAppEvent(item.event, item.payload);
2284
- this._markPublishedSeq(ns, seq);
2285
- }
2286
- if (queue.size === 0)
2287
- this._pendingOrderedMsgs.delete(ns);
1268
+ await this._delivery.drainOrderedMessages(ns, beforeSeq);
2288
1269
  }
2289
1270
  async _publishOrderedMessage(event, ns, seq, payload) {
2290
- const seqNum = Number(seq);
2291
- if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0) {
2292
- await this._publishAppEvent(event, payload);
2293
- return true;
2294
- }
2295
- if (this._pushedSeqs.get(ns)?.has(seqNum)) {
2296
- const queue = this._pendingOrderedMsgs.get(ns);
2297
- queue?.delete(seqNum);
2298
- if (queue && queue.size === 0)
2299
- this._pendingOrderedMsgs.delete(ns);
2300
- return false;
2301
- }
2302
- const contig = this._seqTracker.getContiguousSeq(ns);
2303
- if (seqNum > contig) {
2304
- this._enqueueOrderedMessage(ns, event, seqNum, payload);
2305
- return false;
2306
- }
2307
- await this._drainOrderedMessages(ns, seqNum);
2308
- if (this._pushedSeqs.get(ns)?.has(seqNum))
2309
- return false;
2310
- const queue = this._pendingOrderedMsgs.get(ns);
2311
- queue?.delete(seqNum);
2312
- if (queue && queue.size === 0)
2313
- this._pendingOrderedMsgs.delete(ns);
2314
- await this._publishAppEvent(event, payload);
2315
- this._markPublishedSeq(ns, seqNum);
2316
- await this._drainOrderedMessages(ns);
2317
- return true;
1271
+ return this._delivery.publishOrderedMessage(event, ns, seq, payload);
2318
1272
  }
2319
1273
  async _publishPulledMessage(event, ns, seq, payload) {
2320
- const seqNum = Number(seq);
2321
- if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0 || !ns) {
2322
- await this._publishAppEvent(event, payload);
2323
- return true;
2324
- }
2325
- const queue = this._pendingOrderedMsgs.get(ns);
2326
- if (this._pushedSeqs.get(ns)?.has(seqNum)) {
2327
- queue?.delete(seqNum);
2328
- if (queue && queue.size === 0)
2329
- this._pendingOrderedMsgs.delete(ns);
2330
- return false;
2331
- }
2332
- queue?.delete(seqNum);
2333
- if (queue && queue.size === 0)
2334
- this._pendingOrderedMsgs.delete(ns);
2335
- await this._publishAppEvent(event, payload);
2336
- this._markPublishedSeq(ns, seqNum);
2337
- return true;
1274
+ return this._delivery.publishPulledMessage(event, ns, seq, payload);
2338
1275
  }
2339
1276
  _extractGroupIdFromResult(result) {
2340
1277
  const group = isJsonObject(result.group) ? result.group : null;
@@ -2367,52 +1304,8 @@ export class AUNClient {
2367
1304
  }
2368
1305
  await this._dispatcher.publish('group.changed', d);
2369
1306
  const groupId = (d.group_id ?? '');
2370
- // V2 bootstrap 缓存失效:成员变更可能导致 epoch 递增或设备列表变化
2371
- if (groupId) {
2372
- this._v2BootstrapCache.delete(`group:${groupId}`);
2373
- }
2374
- // Group SPK 编排:成员变更触发注册/轮换
2375
- const membershipActions = new Set([
2376
- 'member_added', 'member_left', 'member_removed', 'role_changed',
2377
- 'owner_transferred', 'joined', 'join_approved', 'invite_code_used',
2378
- ]);
2379
- if (this._v2Session && groupId) {
2380
- if (membershipActions.has(action)) {
2381
- const callFn = async (method, params) => this.call(method, params);
2382
- const joinedAid = String(d.joined_aid ?? d.member_aid ?? d.aid ?? '').trim();
2383
- const actorAid = String(d.actor_aid ?? '').trim();
2384
- const selfAid = String(this._aid ?? '').trim();
2385
- const joinActions = new Set(['member_added', 'joined', 'join_approved', 'invite_code_used']);
2386
- const isSelfJoin = joinActions.has(action) && !!selfAid && (joinedAid === selfAid ||
2387
- (!joinedAid && (action === 'joined' || action === 'invite_code_used') && actorAid === selfAid));
2388
- if (isSelfJoin) {
2389
- this._v2Session.ensureGroupRegistered?.(groupId, callFn)?.catch(exc => {
2390
- this._clientLog.debug(`group SPK registration failed (non-fatal): group=${groupId} action=${action} err=${exc}`);
2391
- });
2392
- }
2393
- else {
2394
- this._v2Session.rotateGroupSPK?.(groupId, callFn)?.catch(exc => {
2395
- this._clientLog.debug(`group SPK rotation failed (non-fatal): group=${groupId} action=${action} err=${exc}`);
2396
- });
2397
- }
2398
- }
2399
- }
2400
- if (groupId && this._v2Session && (action === 'upsert' || membershipActions.has(action))) {
2401
- this._safeAsync(this._v2AutoProposeState(groupId, { leaderDelay: true }));
2402
- }
2403
- // event_seq 空洞检测:持久化后的 group.changed 会携带 event_seq。
2404
- let needPull = false;
2405
- const rawEventSeq = d.event_seq;
2406
- if (rawEventSeq != null && groupId) {
2407
- const es = Number(rawEventSeq);
2408
- if (Number.isFinite(es) && es > 0) {
2409
- needPull = this._seqTracker.onMessageSeq(`group_event:${groupId}`, es);
2410
- }
2411
- }
2412
- // 仅在真实 event gap 时才触发补拉(补洞回来的事件不再触发新补洞)
2413
- if (needPull && groupId && !d._from_gap_fill) {
2414
- this._safeAsync(this._fillGroupEventGap(groupId));
2415
- }
1307
+ this._groupState.handleGroupChangedV2Membership(d);
1308
+ this._delivery.handleGroupChangedEventSeq(d, groupId);
2416
1309
  // 群组解散 → 清理本地 seq_tracker、补洞去重缓存
2417
1310
  if (d.action === 'dissolved') {
2418
1311
  if (groupId) {
@@ -2436,123 +1329,7 @@ export class AUNClient {
2436
1329
  * 当 prev_state_hash 与本地不连续时回源 group.get_state,并对回源数据做 hash 验证。
2437
1330
  */
2438
1331
  async _onGroupStateCommitted(data) {
2439
- const tStart = Date.now();
2440
- const groupIdInit = String(data?.group_id ?? '');
2441
- this._clientLog.debug(`_onGroupStateCommitted enter: group_id=${groupIdInit} state_version=${String(data?.state_version ?? '-')}`);
2442
- try {
2443
- if (!isJsonObject(data)) {
2444
- this._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms reason=non_object`);
2445
- return;
2446
- }
2447
- const d = data;
2448
- const groupId = String(d.group_id ?? '').trim();
2449
- if (!groupId) {
2450
- this._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms reason=no_group_id`);
2451
- return;
2452
- }
2453
- await this._onGroupStateCommittedImpl(d, groupId);
2454
- this._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms group_id=${groupId}`);
2455
- }
2456
- catch (err) {
2457
- this._clientLog.debug(`_onGroupStateCommitted exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
2458
- throw err;
2459
- }
2460
- }
2461
- async _onGroupStateCommittedImpl(d, groupId) {
2462
- // 提交者签名验证
2463
- const cs = d.client_signature;
2464
- if (cs && isJsonObject(cs)) {
2465
- if (this._shouldSkipEventSignature(d)) {
2466
- delete d.client_signature;
2467
- }
2468
- else {
2469
- const verified = await this._verifyEventSignature(d, cs);
2470
- if (verified === false) {
2471
- this._clientLog.warn(`state_committed committer signature verify failed group=%s${String(groupId)}`);
2472
- return;
2473
- }
2474
- d._verified = verified;
2475
- }
2476
- }
2477
- const stateVersion = Number(d.state_version ?? 0);
2478
- const stateHash = String(d.state_hash ?? '').trim();
2479
- const prevStateHash = String(d.prev_state_hash ?? '').trim();
2480
- const keyEpoch = Number(d.key_epoch ?? 0);
2481
- const membershipSnapshot = String(d.membership_snapshot ?? '').trim();
2482
- const policySnapshot = String(d.policy_snapshot ?? '').trim();
2483
- // 1. 验证 prev_state_hash 连续性
2484
- const loadFn = this._tokenStore.loadGroupState;
2485
- const localState = loadFn
2486
- ? await loadFn.call(this._tokenStore, groupId)
2487
- : null;
2488
- if (localState && localState.state_hash && localState.state_hash !== prevStateHash) {
2489
- this._clientLog.warn('[aun_core] state_hash 链不连续 group=%s local_sv=%d event_sv=%d', groupId, localState.state_version, stateVersion);
2490
- // 回源同步
2491
- try {
2492
- const serverState = await this._transport.call('group.get_state', { group_id: groupId });
2493
- if (serverState && typeof serverState.state_version !== 'undefined') {
2494
- const sv = Number(serverState.state_version);
2495
- const sHash = String(serverState.state_hash ?? '');
2496
- const sEpoch = Number(serverState.key_epoch ?? 0);
2497
- const sMembersJson = String(serverState.membership_snapshot ?? '');
2498
- const sPolicyJson = String(serverState.policy_snapshot ?? '');
2499
- const sPrev = String(serverState.prev_state_hash ?? '');
2500
- // 回源也做 hash 验证
2501
- if (sMembersJson && sHash) {
2502
- const sMembers = sMembersJson ? JSON.parse(sMembersJson) : [];
2503
- const sPolicy = sPolicyJson ? JSON.parse(sPolicyJson) : {};
2504
- const computed = await computeStateHash({
2505
- groupId, stateVersion: sv, keyEpoch: sEpoch,
2506
- members: sMembers, policy: sPolicy, prevStateHash: sPrev,
2507
- });
2508
- if (computed !== sHash) {
2509
- this._clientLog.warn('[aun_core] 回源 state_hash 验证失败 group=%s sv=%d expected=%s got=%s', groupId, sv, sHash, computed);
2510
- return;
2511
- }
2512
- }
2513
- const saveFn = this._tokenStore.saveGroupState;
2514
- if (saveFn) {
2515
- await saveFn.call(this._tokenStore, groupId, {
2516
- group_id: groupId,
2517
- state_version: sv,
2518
- state_hash: sHash,
2519
- key_epoch: sEpoch,
2520
- membership_json: sMembersJson || membershipSnapshot,
2521
- policy_json: sPolicyJson || policySnapshot,
2522
- updated_at: Date.now(),
2523
- });
2524
- }
2525
- }
2526
- }
2527
- catch (exc) {
2528
- this._clientLog.warn(`state pull-back failed group=%s:${groupId} ${exc}`);
2529
- }
2530
- return;
2531
- }
2532
- // 2. 本地重算验证
2533
- const members = membershipSnapshot ? JSON.parse(membershipSnapshot) : [];
2534
- const policy = policySnapshot ? JSON.parse(policySnapshot) : {};
2535
- const computed = await computeStateHash({
2536
- groupId, stateVersion, keyEpoch,
2537
- members, policy, prevStateHash,
2538
- });
2539
- if (computed !== stateHash) {
2540
- this._clientLog.warn('[aun_core] state_hash 重算不匹配 group=%s sv=%d expected=%s got=%s', groupId, stateVersion, stateHash, computed);
2541
- return;
2542
- }
2543
- // 3. 更新本地存储
2544
- const saveFn = this._tokenStore.saveGroupState;
2545
- if (saveFn) {
2546
- await saveFn.call(this._tokenStore, groupId, {
2547
- group_id: groupId,
2548
- state_version: stateVersion,
2549
- state_hash: stateHash,
2550
- key_epoch: keyEpoch,
2551
- membership_json: membershipSnapshot,
2552
- policy_json: policySnapshot,
2553
- updated_at: Date.now(),
2554
- });
2555
- }
1332
+ return this._groupState.onGroupStateCommitted(data);
2556
1333
  }
2557
1334
  /**
2558
1335
  * 群组解散后清理本地状态:
@@ -2563,7 +1340,7 @@ export class AUNClient {
2563
1340
  */
2564
1341
  _cleanupDissolvedGroup(groupId) {
2565
1342
  // 1. 清理 seq_tracker 中的群消息和群事件命名空间
2566
- this._v2BootstrapCache.delete(`group:${groupId}`);
1343
+ this._v2E2EE.deleteBootstrapCacheEntry(`group:${groupId}`);
2567
1344
  this._v2GroupSecurityLevels.delete(groupId);
2568
1345
  this._v2StateChains.delete(groupId);
2569
1346
  this._seqTracker.removeNamespace(`group:${groupId}`);
@@ -2594,8 +1371,7 @@ export class AUNClient {
2594
1371
  }
2595
1372
  try {
2596
1373
  if (expectedFP) {
2597
- const actualFP = await certificateSha256Fingerprint(cached.certPem);
2598
- if (actualFP !== expectedFP) {
1374
+ if (!(await certMatchesFingerprint(cached.certPem, expectedFP))) {
2599
1375
  this._clientLog.warn(`group event sig verify failed: cert fingerprint mismatch aid=%s${String(sigAid)}`);
2600
1376
  return false;
2601
1377
  }
@@ -2721,176 +1497,10 @@ export class AUNClient {
2721
1497
  }
2722
1498
  }
2723
1499
  async _decryptGroupThoughts(result) {
2724
- if (!result.found) {
2725
- return { ...result, thoughts: [] };
2726
- }
2727
- const items = (Array.isArray(result.thoughts) ? result.thoughts : []).filter(isJsonObject);
2728
- if (!items.length) {
2729
- return { ...result, thoughts: [] };
2730
- }
2731
- const groupId = String(result.group_id ?? '');
2732
- const senderAid = String(result.sender_aid ?? '');
2733
- const thoughts = [];
2734
- for (const item of items) {
2735
- const payload = isJsonObject(item.payload) ? item.payload : null;
2736
- const thoughtId = String(item.thought_id ?? item.message_id ?? '');
2737
- // V2 群 thought envelope:per-device wrap
2738
- const isV2Envelope = payload !== null
2739
- && payload.type === 'e2ee.group_encrypted'
2740
- && (payload.version === 'v2' || Array.isArray(payload.recipients));
2741
- let decryptedPayload = payload;
2742
- let e2eeMeta = null;
2743
- let decryptFailed = false;
2744
- if (isV2Envelope) {
2745
- e2eeMeta = v2E2eeMeta(payload);
2746
- const plaintext = await this._decryptV2EnvelopeForThought({
2747
- envelope: payload,
2748
- fromAid: senderAid,
2749
- });
2750
- if (plaintext === null) {
2751
- this._clientLog.warn(`group.thought.decrypt v2 failed thought_id=${thoughtId}`);
2752
- decryptFailed = true;
2753
- decryptedPayload = payload;
2754
- }
2755
- else {
2756
- decryptedPayload = plaintext;
2757
- const e2eeObj = e2eeMeta;
2758
- // 暴露 protected_headers(去 _auth)
2759
- const ph = payload.protected_headers;
2760
- if (isJsonObject(ph)) {
2761
- const phBody = {};
2762
- for (const [k, v] of Object.entries(ph)) {
2763
- if (k !== '_auth')
2764
- phBody[k] = v;
2765
- }
2766
- if (Object.keys(phBody).length > 0)
2767
- e2eeObj.protected_headers = phBody;
2768
- }
2769
- // 暴露 context(去 _auth)
2770
- const ctx = payload.context;
2771
- if (isJsonObject(ctx)) {
2772
- const ctxBody = {};
2773
- for (const [k, v] of Object.entries(ctx)) {
2774
- if (k !== '_auth')
2775
- ctxBody[k] = v;
2776
- }
2777
- if (Object.keys(ctxBody).length > 0)
2778
- e2eeObj.context = ctxBody;
2779
- }
2780
- e2eeMeta = e2eeObj;
2781
- }
2782
- }
2783
- else if (payload?.type === 'e2ee.group_encrypted') {
2784
- decryptFailed = true;
2785
- decryptedPayload = payload;
2786
- }
2787
- const thought = {
2788
- thought_id: thoughtId,
2789
- message_id: thoughtId,
2790
- payload: decryptedPayload,
2791
- created_at: item.created_at,
2792
- e2ee: e2eeMeta,
2793
- };
2794
- if (isJsonObject(e2eeMeta))
2795
- attachV2EnvelopeMetadata(thought, e2eeMeta);
2796
- if (decryptFailed)
2797
- thought.decrypt_failed = true;
2798
- if ('context' in item)
2799
- thought.context = item.context;
2800
- thoughts.push(thought);
2801
- }
2802
- return { ...result, thoughts };
1500
+ return await this._v2E2EE.decryptGroupThoughts(result);
2803
1501
  }
2804
1502
  async _decryptMessageThoughts(result) {
2805
- if (!result.found) {
2806
- return { ...result, thoughts: [] };
2807
- }
2808
- const items = (Array.isArray(result.thoughts) ? result.thoughts : []).filter(isJsonObject);
2809
- if (!items.length) {
2810
- return { ...result, thoughts: [] };
2811
- }
2812
- const senderAid = String(result.sender_aid ?? '');
2813
- const peerAid = String(result.peer_aid ?? '');
2814
- const thoughts = [];
2815
- for (const item of items) {
2816
- const payload = isJsonObject(item.payload) ? item.payload : null;
2817
- const thoughtId = String(item.thought_id ?? item.message_id ?? '');
2818
- const fromAid = String(item.from ?? senderAid);
2819
- const toAid = String(item.to ?? peerAid);
2820
- const message = {
2821
- from: fromAid,
2822
- to: toAid,
2823
- message_id: thoughtId,
2824
- payload: payload ?? {},
2825
- encrypted: item.encrypted !== false,
2826
- timestamp: Number(item.created_at ?? 0),
2827
- };
2828
- if (isJsonObject(item.context))
2829
- message.context = item.context;
2830
- let decrypted = message;
2831
- let decryptFailed = false;
2832
- // V2 P2P thought envelope:per-device wrap,本设备解密自己的 row
2833
- if (payload?.type === 'e2ee.p2p_encrypted') {
2834
- const e2eeObj = v2E2eeMeta(payload);
2835
- message.e2ee = e2eeObj;
2836
- const plaintext = await this._decryptV2EnvelopeForThought({
2837
- envelope: payload,
2838
- fromAid,
2839
- });
2840
- if (plaintext === null) {
2841
- this._clientLog.warn(`p2p.thought.decrypt v2 failed thought_id=${thoughtId}`);
2842
- decryptFailed = true;
2843
- }
2844
- else {
2845
- decrypted = { ...message };
2846
- decrypted.payload = plaintext;
2847
- // 暴露 protected_headers(去 _auth)
2848
- const ph = payload.protected_headers;
2849
- if (isJsonObject(ph)) {
2850
- const phBody = {};
2851
- for (const [k, v] of Object.entries(ph)) {
2852
- if (k !== '_auth')
2853
- phBody[k] = v;
2854
- }
2855
- if (Object.keys(phBody).length > 0)
2856
- e2eeObj.protected_headers = phBody;
2857
- }
2858
- // 暴露 context(去 _auth)
2859
- const ctx = payload.context;
2860
- if (isJsonObject(ctx)) {
2861
- const ctxBody = {};
2862
- for (const [k, v] of Object.entries(ctx)) {
2863
- if (k !== '_auth')
2864
- ctxBody[k] = v;
2865
- }
2866
- if (Object.keys(ctxBody).length > 0)
2867
- e2eeObj.context = ctxBody;
2868
- }
2869
- decrypted.e2ee = e2eeObj;
2870
- }
2871
- }
2872
- else if (payload?.type === 'e2ee.encrypted') {
2873
- decryptFailed = true;
2874
- }
2875
- const exposedE2ee = (decrypted ?? message).e2ee;
2876
- const thought = {
2877
- thought_id: thoughtId,
2878
- message_id: thoughtId,
2879
- from: fromAid,
2880
- to: toAid,
2881
- payload: (decrypted ?? message).payload,
2882
- created_at: item.created_at,
2883
- e2ee: exposedE2ee,
2884
- };
2885
- if (isJsonObject(exposedE2ee))
2886
- attachV2EnvelopeMetadata(thought, exposedE2ee);
2887
- if (decryptFailed)
2888
- thought.decrypt_failed = true;
2889
- if ('context' in item)
2890
- thought.context = item.context;
2891
- thoughts.push(thought);
2892
- }
2893
- return { ...result, thoughts };
1503
+ return await this._v2E2EE.decryptMessageThoughts(result);
2894
1504
  }
2895
1505
  // ── E2EE 编排:证书与客户端签名 ──────────────────────
2896
1506
  /**
@@ -2915,51 +1525,27 @@ export class AUNClient {
2915
1525
  // 跨域时用 peer 所在域的 Gateway URL
2916
1526
  const peerGatewayUrl = resolvePeerGatewayUrl(gatewayUrl, aid);
2917
1527
  let certPem;
1528
+ const certUrl = buildCertUrl(peerGatewayUrl, aid, certFingerprint);
1529
+ // 兼容旧浏览器,不使用 AbortSignal.timeout(Chrome 103+ 才支持)
1530
+ const controller = new AbortController();
1531
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
2918
1532
  try {
2919
- const certUrl = buildCertUrl(peerGatewayUrl, aid, certFingerprint);
2920
- // 兼容旧浏览器,不使用 AbortSignal.timeout(Chrome 103+ 才支持)
2921
- const controller = new AbortController();
2922
- const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
2923
- try {
2924
- const resp = await fetch(certUrl, { signal: controller.signal });
2925
- if (!resp.ok)
2926
- throw new ValidationError(`failed to fetch peer cert for ${aid}: HTTP ${resp.status}`);
2927
- certPem = await resp.text();
2928
- }
2929
- finally {
2930
- clearTimeout(timeoutId);
2931
- }
1533
+ const resp = await fetch(certUrl, { signal: controller.signal });
1534
+ if (!resp.ok)
1535
+ throw new ValidationError(`failed to fetch peer cert for ${aid}: HTTP ${resp.status}`);
1536
+ certPem = await resp.text();
2932
1537
  }
2933
- catch (exc) {
2934
- if (!certFingerprint) {
2935
- throw exc;
2936
- }
2937
- // 兼容旧浏览器,不使用 AbortSignal.timeout(Chrome 103+ 才支持)
2938
- const fallbackController = new AbortController();
2939
- const fallbackTimeoutId = setTimeout(() => fallbackController.abort(), timeoutMs);
2940
- try {
2941
- const fallbackResp = await fetch(buildCertUrl(peerGatewayUrl, aid), { signal: fallbackController.signal });
2942
- if (!fallbackResp.ok) {
2943
- throw exc;
2944
- }
2945
- certPem = await fallbackResp.text();
2946
- }
2947
- finally {
2948
- clearTimeout(fallbackTimeoutId);
2949
- }
1538
+ finally {
1539
+ clearTimeout(timeoutId);
2950
1540
  }
2951
1541
  // H7: 严格校验指纹(DER SHA-256 或 SPKI SHA-256 任一匹配即可)
2952
1542
  if (certFingerprint) {
2953
1543
  const expectedFP = String(certFingerprint).trim().toLowerCase();
2954
- if (!expectedFP.startsWith('sha256:')) {
1544
+ if (!normalizeFingerprintHex(expectedFP)) {
2955
1545
  throw new ValidationError(`unsupported cert_fingerprint format for ${aid}: ${expectedFP.slice(0, 24)}`);
2956
1546
  }
2957
- const derFP = await this._certFingerprint(certPem);
2958
- if (derFP !== expectedFP) {
2959
- const spkiFP = await this._spkiFingerprint(certPem);
2960
- if (!spkiFP || spkiFP !== expectedFP) {
2961
- throw new ValidationError(`peer cert fingerprint mismatch for ${aid}: expected=${expectedFP.slice(0, 24)}...`);
2962
- }
1547
+ if (!(await certMatchesFingerprint(certPem, expectedFP))) {
1548
+ throw new ValidationError(`peer cert fingerprint mismatch for ${aid}: expected=${expectedFP.slice(0, 24)}...`);
2963
1549
  }
2964
1550
  }
2965
1551
  // 完整 PKI 验证:链 + CRL + OCSP + AID 绑定
@@ -2989,118 +1575,13 @@ export class AUNClient {
2989
1575
  throw err;
2990
1576
  }
2991
1577
  }
2992
- /** 确保发送方证书在本地可用且未过期 */
2993
- async _ensureSenderCertCached(aid, certFingerprint) {
2994
- const cacheKey = certCacheKey(aid, certFingerprint);
2995
- const cached = this._certCache.get(cacheKey);
2996
- const now = Date.now() / 1000;
2997
- if (cached && now < cached.refreshAfter)
2998
- return true;
2999
- const localCert = await this._tokenStore.loadCert(aid, certFingerprint);
3000
- if (localCert) {
3001
- if (certFingerprint) {
3002
- const actualFingerprint = await this._certFingerprint(localCert);
3003
- if (actualFingerprint === String(certFingerprint).trim().toLowerCase()) {
3004
- this._certCache.set(cacheKey, {
3005
- certPem: localCert,
3006
- validatedAt: now,
3007
- refreshAfter: now + PEER_CERT_CACHE_TTL,
3008
- });
3009
- return true;
3010
- }
3011
- }
3012
- else {
3013
- this._certCache.set(cacheKey, {
3014
- certPem: localCert,
3015
- validatedAt: now,
3016
- refreshAfter: now + PEER_CERT_CACHE_TTL,
3017
- });
3018
- return true;
3019
- }
3020
- }
3021
- try {
3022
- const certPem = await this._fetchPeerCert(aid, certFingerprint);
3023
- // peer 证书只存版本目录,不覆盖 cert.pem
3024
- await this._tokenStore.saveCert(aid, certPem, certFingerprint, { makeActive: false });
3025
- return true;
3026
- }
3027
- catch (exc) {
3028
- // 刷新失败时:若缓存有 PKI 验证过的证书(2 倍 TTL 内)则继续用
3029
- if (cached && now < cached.validatedAt + PEER_CERT_CACHE_TTL * 2) {
3030
- this._clientLog.warn(`refresh sender ${aid} cert failed, continue using verified memory cache: ${String(exc)}`);
3031
- return true;
3032
- }
3033
- this._clientLog.warn(`fetch sender ${aid} cert failed and no verify cache, reject trust: ${String(exc)}`);
3034
- return false;
3035
- }
3036
- }
3037
- /**
3038
- * 获取经过 PKI 验证的 peer 证书(仅信任内存缓存中已验证的证书)。
3039
- * 零信任要求:不直接信任 keystore 中可能由恶意服务端注入的证书。
3040
- */
3041
- _getVerifiedPeerCert(aid, certFingerprint) {
3042
- let cached = this._certCache.get(certCacheKey(aid, certFingerprint));
3043
- // 带 fingerprint 查不到时,降级用 aid 再查一次
3044
- if (!cached && certFingerprint) {
3045
- cached = this._certCache.get(certCacheKey(aid, undefined));
3046
- }
3047
- const now = Date.now() / 1000;
3048
- if (cached && now < cached.validatedAt + PEER_CERT_CACHE_TTL * 2) {
3049
- return cached.certPem;
3050
- }
3051
- return null;
3052
- }
3053
1578
  // ── 客户端操作签名 ────────────────────────────────
3054
1579
  /**
3055
1580
  * 为关键操作附加客户端 ECDSA 签名(_client_signature 字段)。
3056
1581
  * 使用 SubtleCrypto 异步签名。
3057
1582
  */
3058
1583
  async _signClientOperation(method, params) {
3059
- const currentAid = this._currentAid;
3060
- if (!currentAid?.privateKeyPem)
3061
- return;
3062
- try {
3063
- const aid = currentAid.aid;
3064
- const ts = String(Math.floor(Date.now() / 1000));
3065
- // 计算 params hash:覆盖所有非 _ 前缀且非 client_signature 的业务字段
3066
- const paramsForHash = {};
3067
- for (const [k, v] of Object.entries(params)) {
3068
- if (k !== 'client_signature' && !k.startsWith('_')) {
3069
- paramsForHash[k] = v;
3070
- }
3071
- }
3072
- const paramsJson = stableStringify(paramsForHash);
3073
- // SHA-256 hash
3074
- const paramsHashBuf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(paramsJson));
3075
- const paramsHash = Array.from(new Uint8Array(paramsHashBuf))
3076
- .map(b => b.toString(16).padStart(2, '0'))
3077
- .join('');
3078
- const signData = new TextEncoder().encode(`${method}|${aid}|${ts}|${paramsHash}`);
3079
- // 导入私钥并签名
3080
- const pkcs8 = pemToArrayBuffer(currentAid.privateKeyPem);
3081
- const cryptoKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']);
3082
- const sigP1363 = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, cryptoKey, signData);
3083
- // P1363 → DER 格式(与 Python 兼容)
3084
- const sigDer = p1363ToDer(new Uint8Array(sigP1363));
3085
- // 证书指纹
3086
- let certFingerprint = '';
3087
- const certPem = currentAid.certPem;
3088
- if (certPem) {
3089
- const certDer = pemToArrayBuffer(certPem);
3090
- const fpBuf = await crypto.subtle.digest('SHA-256', certDer);
3091
- certFingerprint = 'sha256:' + Array.from(new Uint8Array(fpBuf)).map(b => b.toString(16).padStart(2, '0')).join('');
3092
- }
3093
- params.client_signature = {
3094
- aid,
3095
- cert_fingerprint: certFingerprint,
3096
- timestamp: ts,
3097
- params_hash: paramsHash,
3098
- signature: uint8ToBase64(sigDer),
3099
- };
3100
- }
3101
- catch (exc) {
3102
- throw new E2EEError(`客户端签名失败: ${exc instanceof Error ? exc.message : String(exc)}`);
3103
- }
1584
+ await this._rpcPipeline.signClientOperation(method, params);
3104
1585
  }
3105
1586
  // ── 内部:连接 ────────────────────────────────────
3106
1587
  async _connectOnce(params, allowReauth) {
@@ -3162,7 +1643,7 @@ export class AUNClient {
3162
1643
  }
3163
1644
  catch (err) {
3164
1645
  // P1-19: 首连失败时重置状态,避免半连接残留
3165
- this._state = 'disconnected';
1646
+ this._state = 'standby';
3166
1647
  try {
3167
1648
  await this._transport.close();
3168
1649
  }
@@ -3182,22 +1663,16 @@ export class AUNClient {
3182
1663
  this._startBackgroundTasks();
3183
1664
  const connectionKind = String(params.connection_kind ?? 'long');
3184
1665
  const isShortConnection = connectionKind === 'short';
1666
+ const hasExplicitBackgroundSync = Object.prototype.hasOwnProperty.call(params, 'background_sync');
1667
+ const backgroundSyncEnabled = this._sessionOptions?.background_sync !== false
1668
+ && (!isShortConnection || hasExplicitBackgroundSync);
3185
1669
  if (!isShortConnection) {
3186
- // V2 E2EE: 长连接上线时初始化 session 并注册设备 SPK(与 Python `_init_v2_session` 对齐)
3187
- try {
3188
- await this._initV2Session();
3189
- }
3190
- catch (exc) {
3191
- this._clientLog.warn(`V2 session init failed (non-fatal): ${String(exc)}`);
3192
- }
1670
+ await this._v2E2EE.onConnected({ backgroundSync: backgroundSyncEnabled });
3193
1671
  }
3194
1672
  else {
3195
1673
  this._clientLog.debug('V2 session init deferred for short connection');
3196
1674
  }
3197
1675
  // connect/reconnect 成功后自动触发一次 P2P message.pull,补齐离线期间积压
3198
- const hasExplicitBackgroundSync = Object.prototype.hasOwnProperty.call(params, 'background_sync');
3199
- const backgroundSyncEnabled = this._sessionOptions?.background_sync !== false
3200
- && (!isShortConnection || hasExplicitBackgroundSync);
3201
1676
  if (backgroundSyncEnabled) {
3202
1677
  this._safeAsync(this._fillP2pGap());
3203
1678
  }
@@ -3413,11 +1888,7 @@ export class AUNClient {
3413
1888
  this._gapFillDone = new Set(arr.slice(arr.length - 5000));
3414
1889
  }
3415
1890
  const now = Date.now();
3416
- for (const [key, entry] of this._v2BootstrapCache) {
3417
- if (now - entry.cachedAt >= AUNClient.V2_BOOTSTRAP_TTL_MS) {
3418
- this._v2BootstrapCache.delete(key);
3419
- }
3420
- }
1891
+ this._v2E2EE.pruneExpiredBootstrapCache(AUNClient.V2_BOOTSTRAP_TTL_MS, now);
3421
1892
  for (const [key, exp] of this._v2SigCache) {
3422
1893
  if (exp <= now)
3423
1894
  this._v2SigCache.delete(key);
@@ -3572,78 +2043,14 @@ export class AUNClient {
3572
2043
  };
3573
2044
  scheduleRefresh(0);
3574
2045
  }
3575
- _normalizeOutboundMessagePayload(params, method = '') {
3576
- if (!Object.prototype.hasOwnProperty.call(params, 'payload') && Object.prototype.hasOwnProperty.call(params, 'content')) {
3577
- params.payload = params.content;
3578
- delete params.content;
3579
- }
3580
- const payload = params.payload;
3581
- if (isJsonObject(payload) && !Object.prototype.hasOwnProperty.call(payload, 'type') && typeof payload.text === 'string') {
3582
- params.payload = { type: 'text', ...payload };
3583
- }
3584
- }
3585
2046
  _validateMessageRecipient(toAid) {
3586
- if (isGroupServiceAid(toAid)) {
3587
- throw new ValidationError('message.send receiver cannot be group.{issuer}; use group.send instead');
3588
- }
2047
+ this._rpcPipeline.validateMessageRecipient(toAid);
3589
2048
  }
3590
2049
  _validateOutboundCall(method, params) {
3591
- if (method === 'message.send') {
3592
- this._validateMessageRecipient(params.to);
3593
- if ('persist' in params) {
3594
- throw new ValidationError("message.send no longer accepts 'persist'; configure delivery_mode during connect");
3595
- }
3596
- if ('delivery_mode' in params || 'queue_routing' in params || 'affinity_ttl_ms' in params) {
3597
- throw new ValidationError('message.send does not accept delivery_mode; configure delivery_mode during connect');
3598
- }
3599
- }
3600
- if (method === 'group.send') {
3601
- if ('persist' in params) {
3602
- throw new ValidationError("group.send does not accept 'persist'; group messages are always fanout");
3603
- }
3604
- if ('delivery_mode' in params || 'queue_routing' in params || 'affinity_ttl_ms' in params) {
3605
- throw new ValidationError('group.send does not accept delivery_mode; group messages are always fanout');
3606
- }
3607
- }
3608
- if (method === 'group.thought.put' || method === 'group.thought.get'
3609
- || method === 'message.thought.put' || method === 'message.thought.get') {
3610
- const context = isJsonObject(params.context) ? params.context : null;
3611
- const contextType = String(context?.type ?? '').trim();
3612
- const contextId = String(context?.id ?? '').trim();
3613
- const hasContext = contextType.length > 0 && contextId.length > 0;
3614
- if (!hasContext) {
3615
- throw new ValidationError(`${method} requires context.type + context.id`);
3616
- }
3617
- }
3618
- if (method === 'group.thought.get' && !String(params.sender_aid ?? '').trim()) {
3619
- throw new ValidationError('group.thought.get requires sender_aid');
3620
- }
3621
- if (method === 'message.thought.put') {
3622
- this._validateMessageRecipient(params.to);
3623
- if (!String(params.to ?? '').trim()) {
3624
- throw new ValidationError('message.thought.put requires to');
3625
- }
3626
- }
3627
- if (method === 'message.thought.get' && !String(params.sender_aid ?? '').trim()) {
3628
- throw new ValidationError('message.thought.get requires sender_aid');
3629
- }
3630
- }
3631
- _currentMessageDeliveryMode() {
3632
- return { ...this._connectDeliveryMode };
2050
+ this._rpcPipeline.validateOutboundCall(method, params);
3633
2051
  }
3634
2052
  _injectMessageCursorContext(method, params) {
3635
- if (method !== 'message.pull' && method !== 'message.ack') {
3636
- return;
3637
- }
3638
- if ('device_id' in params && String(params.device_id ?? '').trim() !== this._deviceId) {
3639
- throw new ValidationError('message.pull/message.ack device_id must match the current client instance');
3640
- }
3641
- const slotId = normalizeSlotId(params.slot_id ?? this._slotId, this._slotId);
3642
- if (slotIsolationKey(slotId) !== slotIsolationKey(this._slotId)) {
3643
- throw new ValidationError('message.pull/message.ack slot_id must match the current client instance');
3644
- }
3645
- params.device_id = this._deviceId;
3646
- params.slot_id = this._slotId;
2053
+ this._rpcPipeline.injectMessageCursorContext(method, params);
3647
2054
  }
3648
2055
  // ── 内部:断线重连 ────────────────────────────────
3649
2056
  /** 不重连 close code 集合:认证失败/权限错误/被踢等,重连无意义 */
@@ -3673,7 +2080,7 @@ export class AUNClient {
3673
2080
  async _handleTransportDisconnect(error, closeCode) {
3674
2081
  if (this._closing || this._state === 'closed')
3675
2082
  return;
3676
- this._state = 'disconnected';
2083
+ this._state = 'standby';
3677
2084
  // 先停止后台任务,避免心跳/token刷新在重连期间继续触发
3678
2085
  this._stopBackgroundTasks();
3679
2086
  await this._dispatcher.publish('state_change', {
@@ -3805,76 +2212,6 @@ export class AUNClient {
3805
2212
  this._reconnectActive = false;
3806
2213
  this._reconnectAbort = null;
3807
2214
  }
3808
- // ── Named Group(命名群)高层 API ────────────────────────────
3809
- /**
3810
- * 创建命名群:群/P2P 私钥由 V2 数据库存储,不写入 AID 身份私钥存储。
3811
- */
3812
- async createNamedGroup(groupName, opts = {}) {
3813
- const tStart = Date.now();
3814
- this._clientLog.debug(`createNamedGroup enter: name=${groupName}`);
3815
- try {
3816
- const cp = new CryptoProvider();
3817
- const identity = await cp.generateIdentity();
3818
- const params = {};
3819
- for (const [k, v] of Object.entries(opts)) {
3820
- params[k] = v;
3821
- }
3822
- params.group_name = groupName;
3823
- params.public_key = identity.public_key_der_b64;
3824
- params.curve = 'P-256';
3825
- const result = await this.call('group.create', params);
3826
- const groupInfo = result?.group;
3827
- const aidCert = result?.aid_cert;
3828
- const groupAid = String(groupInfo?.group_aid ?? '');
3829
- if (groupAid && aidCert) {
3830
- await this._saveGroupIdentityToV2(groupAid, identity);
3831
- const certPem = String(aidCert.cert ?? '');
3832
- if (certPem) {
3833
- await this._tokenStore.saveCert(groupAid, certPem);
3834
- }
3835
- }
3836
- this._clientLog.debug(`createNamedGroup exit: elapsed=${Date.now() - tStart}ms group_aid=${groupAid}`);
3837
- return result;
3838
- }
3839
- catch (err) {
3840
- this._clientLog.debug(`createNamedGroup exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
3841
- throw err;
3842
- }
3843
- }
3844
- /**
3845
- * 为已有普通群绑定命名 AID(升级为命名群)。
3846
- */
3847
- async bindGroupAid(groupId, groupName) {
3848
- const tStart = Date.now();
3849
- this._clientLog.debug(`bindGroupAid enter: group_id=${groupId} name=${groupName}`);
3850
- try {
3851
- const cp = new CryptoProvider();
3852
- const identity = await cp.generateIdentity();
3853
- const params = {
3854
- group_id: groupId,
3855
- group_name: groupName,
3856
- public_key: identity.public_key_der_b64,
3857
- curve: 'P-256',
3858
- };
3859
- const result = await this.call('group.bind_aid', params);
3860
- const groupInfo = result?.group;
3861
- const aidCert = result?.aid_cert;
3862
- const groupAid = String(groupInfo?.group_aid ?? '');
3863
- if (groupAid && aidCert) {
3864
- await this._saveGroupIdentityToV2(groupAid, identity);
3865
- const certPem = String(aidCert.cert ?? '');
3866
- if (certPem) {
3867
- await this._tokenStore.saveCert(groupAid, certPem);
3868
- }
3869
- }
3870
- this._clientLog.debug(`bindGroupAid exit: elapsed=${Date.now() - tStart}ms group_aid=${groupAid}`);
3871
- return result;
3872
- }
3873
- catch (err) {
3874
- this._clientLog.debug(`bindGroupAid exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
3875
- throw err;
3876
- }
3877
- }
3878
2215
  /** 判断是否应重试重连 */
3879
2216
  _shouldRetryReconnect(error) {
3880
2217
  if (error instanceof AuthError) {
@@ -3898,113 +2235,7 @@ export class AUNClient {
3898
2235
  // ── 内部:工具方法 ────────────────────────────────
3899
2236
  /** 从 keystore 恢复 SeqTracker 状态(真正可 await,确保在 transport.connect 前完成) */
3900
2237
  async _restoreSeqTrackerState() {
3901
- if (!this._aid)
3902
- return;
3903
- const context = this._seqTrackerContext;
3904
- if (!context)
3905
- return;
3906
- const aid = this._aid;
3907
- const deviceId = this._deviceId;
3908
- const slotId = this._slotId;
3909
- try {
3910
- // 优先从 seq_tracker 表按行读取
3911
- const loadAll = this._tokenStore.loadAllSeqs?.bind(this._tokenStore);
3912
- if (typeof loadAll === 'function') {
3913
- let state = await loadAll(aid, deviceId, slotId);
3914
- if (this._seqTrackerContext !== context)
3915
- return;
3916
- if (state && typeof state === 'object' && Object.keys(state).length > 0) {
3917
- state = await this._migrateSeqStateGroupIds(state);
3918
- this._seqTracker.restoreState(state);
3919
- }
3920
- return;
3921
- }
3922
- // fallback: 从旧 instance_state JSON blob 恢复
3923
- const loader = this._tokenStore.loadInstanceState?.bind(this._tokenStore);
3924
- if (typeof loader !== 'function')
3925
- return;
3926
- const stateHolder = await loader(aid, deviceId, slotId);
3927
- if (this._seqTrackerContext !== context)
3928
- return;
3929
- if (stateHolder && typeof stateHolder === 'object') {
3930
- const state = stateHolder.seq_tracker_state;
3931
- if (isJsonObject(state)) {
3932
- const migrated = await this._migrateSeqStateGroupIds(state);
3933
- this._seqTracker.restoreState(migrated);
3934
- }
3935
- }
3936
- }
3937
- catch (exc) {
3938
- this._dispatcher.publish('seq_tracker.persist_error', {
3939
- phase: 'restore',
3940
- aid,
3941
- device_id: deviceId,
3942
- slot_id: slotId,
3943
- error: String(exc),
3944
- }).catch(() => { });
3945
- }
3946
- }
3947
- /**
3948
- * 把 seq_tracker state 里 group_event:/group_msg: 前缀的老/污染 group_id 归一化。
3949
- * 冲突取 max;同时落盘删除老 ns、写入新 ns。
3950
- */
3951
- async _migrateSeqStateGroupIds(state) {
3952
- if (!state || Object.keys(state).length === 0)
3953
- return state;
3954
- const renameMap = {};
3955
- for (const ns of Object.keys(state)) {
3956
- for (const prefix of ['group_event:', 'group_msg:']) {
3957
- if (ns.startsWith(prefix)) {
3958
- const oldGid = ns.slice(prefix.length);
3959
- const newGid = normalizeGroupId(oldGid);
3960
- if (newGid && newGid !== oldGid) {
3961
- renameMap[ns] = `${prefix}${newGid}`;
3962
- }
3963
- break;
3964
- }
3965
- }
3966
- }
3967
- if (Object.keys(renameMap).length === 0)
3968
- return state;
3969
- const newState = { ...state };
3970
- for (const [oldNs, newNs] of Object.entries(renameMap)) {
3971
- const oldVal = Number(newState[oldNs] ?? 0);
3972
- const curVal = Number(newState[newNs] ?? 0);
3973
- delete newState[oldNs];
3974
- newState[newNs] = Math.max(oldVal, curVal);
3975
- }
3976
- if (!this._aid)
3977
- return newState;
3978
- const aid = this._aid;
3979
- const deviceId = this._deviceId;
3980
- const slotId = this._slotId;
3981
- const saver = this._tokenStore.saveSeq?.bind(this._tokenStore);
3982
- const deleter = this._tokenStore.deleteSeq?.bind(this._tokenStore);
3983
- if (typeof saver === 'function') {
3984
- for (const [oldNs, newNs] of Object.entries(renameMap)) {
3985
- if (typeof deleter === 'function') {
3986
- try {
3987
- await deleter(aid, deviceId, slotId, oldNs);
3988
- }
3989
- catch (e) {
3990
- this._dispatcher.publish('seq_tracker.persist_error', {
3991
- phase: 'migrate_delete', aid, device_id: deviceId, slot_id: slotId,
3992
- ns: oldNs, error: String(e),
3993
- }).catch(() => { });
3994
- }
3995
- }
3996
- try {
3997
- await saver(aid, deviceId, slotId, newNs, newState[newNs]);
3998
- }
3999
- catch (e) {
4000
- this._dispatcher.publish('seq_tracker.persist_error', {
4001
- phase: 'migrate_save', aid, device_id: deviceId, slot_id: slotId,
4002
- ns: newNs, error: String(e),
4003
- }).catch(() => { });
4004
- }
4005
- }
4006
- }
4007
- return newState;
2238
+ return this._delivery.restoreSeqTrackerState();
4008
2239
  }
4009
2240
  _currentSeqTrackerContext() {
4010
2241
  if (!this._aid)
@@ -4012,6 +2243,7 @@ export class AUNClient {
4012
2243
  return JSON.stringify([this._aid, this._deviceId, this._slotId]);
4013
2244
  }
4014
2245
  _resetSeqTrackingState() {
2246
+ this._resetV2IdentityRuntime();
4015
2247
  this._seqTracker = new SeqTracker();
4016
2248
  this._seqTrackerContext = null;
4017
2249
  this._gapFillDone.clear();
@@ -4020,6 +2252,12 @@ export class AUNClient {
4020
2252
  this._v2SenderIKPending.clear();
4021
2253
  this._v2SenderIKFetching.clear();
4022
2254
  this._groupSynced.clear();
2255
+ this._onlineUnreadHintQueue.clear();
2256
+ if (this._onlineUnreadHintTimer) {
2257
+ clearTimeout(this._onlineUnreadHintTimer);
2258
+ this._onlineUnreadHintTimer = null;
2259
+ }
2260
+ this._onlineUnreadHintDrainActive = false;
4023
2261
  }
4024
2262
  _refreshSeqTrackerContext() {
4025
2263
  const nextContext = this._currentSeqTrackerContext();
@@ -4032,83 +2270,26 @@ export class AUNClient {
4032
2270
  this._v2SenderIKPending.clear();
4033
2271
  this._v2SenderIKFetching.clear();
4034
2272
  this._groupSynced.clear();
2273
+ this._onlineUnreadHintQueue.clear();
2274
+ if (this._onlineUnreadHintTimer) {
2275
+ clearTimeout(this._onlineUnreadHintTimer);
2276
+ this._onlineUnreadHintTimer = null;
2277
+ }
2278
+ this._onlineUnreadHintDrainActive = false;
4035
2279
  this._seqTrackerContext = nextContext;
4036
2280
  }
4037
2281
  /** 将 SeqTracker 状态保存到 keystore */
4038
2282
  _saveSeqTrackerState() {
4039
- if (!this._aid)
4040
- return;
4041
- const state = this._seqTracker.exportState();
4042
- if (Object.keys(state).length === 0)
4043
- return;
4044
- try {
4045
- // 优先按行写入 seq_tracker 表
4046
- const saveFn = this._tokenStore.saveSeq?.bind(this._tokenStore);
4047
- if (typeof saveFn === 'function') {
4048
- for (const [ns, seq] of Object.entries(state)) {
4049
- saveFn(this._aid, this._deviceId, this._slotId, ns, seq).catch((exc) => {
4050
- this._dispatcher.publish('seq_tracker.persist_error', {
4051
- phase: 'save',
4052
- aid: this._aid,
4053
- device_id: this._deviceId,
4054
- slot_id: this._slotId,
4055
- error: String(exc),
4056
- }).catch(() => { });
4057
- });
4058
- }
4059
- return;
4060
- }
4061
- // fallback: 旧版 updateInstanceState JSON blob
4062
- if (typeof this._tokenStore.updateInstanceState === 'function') {
4063
- this._tokenStore.updateInstanceState(this._aid, this._deviceId, this._slotId, (current) => {
4064
- current.seq_tracker_state = state;
4065
- return current;
4066
- }).catch((exc) => {
4067
- this._dispatcher.publish('seq_tracker.persist_error', {
4068
- phase: 'save',
4069
- aid: this._aid,
4070
- device_id: this._deviceId,
4071
- slot_id: this._slotId,
4072
- error: String(exc),
4073
- }).catch(() => { });
4074
- });
4075
- }
4076
- }
4077
- catch (exc) {
4078
- this._dispatcher.publish('seq_tracker.persist_error', {
4079
- phase: 'save',
4080
- aid: this._aid,
4081
- device_id: this._deviceId,
4082
- slot_id: this._slotId,
4083
- error: String(exc),
4084
- }).catch(() => { });
4085
- }
2283
+ return this._delivery.saveSeqTrackerState();
4086
2284
  }
4087
2285
  _persistRepairedSeq(ns) {
4088
- if (!this._aid || !ns)
4089
- return;
4090
- const seq = this._seqTracker.getContiguousSeq(ns);
4091
- try {
4092
- if (seq > 0 && typeof this._tokenStore.saveSeq === 'function') {
4093
- this._tokenStore.saveSeq(this._aid, this._deviceId, this._slotId, ns, seq).catch((exc) => {
4094
- this._clientLog.debug(`persist repaired seq failed: ns=${ns} err=${formatCaughtError(exc)}`);
4095
- });
4096
- return;
4097
- }
4098
- const deleteSeq = this._tokenStore.deleteSeq;
4099
- if (seq <= 0 && typeof deleteSeq === 'function') {
4100
- deleteSeq.call(this._tokenStore, this._aid, this._deviceId, this._slotId, ns).catch((exc) => {
4101
- this._clientLog.debug(`delete repaired seq failed: ns=${ns} err=${formatCaughtError(exc)}`);
4102
- });
4103
- return;
4104
- }
4105
- if (seq > 0) {
4106
- this._saveSeqTrackerState();
4107
- }
4108
- }
4109
- catch (exc) {
4110
- this._clientLog.debug(`persist repaired seq failed: ns=${ns} err=${formatCaughtError(exc)}`);
4111
- }
2286
+ return this._delivery.persistRepairedSeq(ns);
2287
+ }
2288
+ _clampAckSeq(method, field, ns, seq) {
2289
+ return this._delivery.clampAckSeq(method, field, ns, seq);
2290
+ }
2291
+ _clampAckParams(method, params) {
2292
+ return this._delivery.clampAckParams(method, params);
4112
2293
  }
4113
2294
  _repairPushContiguousBound(ns, pushSeq, hasPayload, label) {
4114
2295
  if (!ns || !Number.isFinite(pushSeq) || pushSeq <= 0) {
@@ -4126,7 +2307,7 @@ export class AUNClient {
4126
2307
  return repaired;
4127
2308
  }
4128
2309
  async _ensureV2SessionReady(method, errorMessage) {
4129
- if (!this._v2Session) {
2310
+ if (!this._v2SessionMatchesIdentity()) {
4130
2311
  if (!this._v2SessionInitInFlight) {
4131
2312
  this._v2SessionInitInFlight = this._initV2Session()
4132
2313
  .finally(() => {
@@ -4135,10 +2316,13 @@ export class AUNClient {
4135
2316
  }
4136
2317
  await this._v2SessionInitInFlight;
4137
2318
  }
4138
- if (!this._v2Session) {
2319
+ if (!this._v2SessionMatchesIdentity()) {
4139
2320
  throw new StateError(errorMessage ?? `V2 session not initialized; encrypted ${method} requires E2EE V2`);
4140
2321
  }
4141
2322
  }
2323
+ _v2CallFn() {
2324
+ return async (method, params) => this.call(method, params);
2325
+ }
4142
2326
  // ── V2 E2EE API(async,与 Python `client.py` `_init_v2_session` / `send_v2` / `pull_v2` / `ack_v2` 对齐) ──
4143
2327
  /**
4144
2328
  * 初始化 V2 session:从 AID PEM 私钥提取 raw scalar + DER 公钥,
@@ -4147,62 +2331,7 @@ export class AUNClient {
4147
2331
  * connect 成功后自动调用,可幂等手动调用。
4148
2332
  */
4149
2333
  async _initV2Session() {
4150
- if (!this._aid)
4151
- return;
4152
- // 私钥来自当前 AID 值对象,AUNClient 不从持久化存储读取私钥。
4153
- const currentAid = this._currentAid;
4154
- if (!currentAid?.privateKeyPem) {
4155
- this._clientLog.warn('V2 session init skipped: no AID private key');
4156
- return;
4157
- }
4158
- if (this._v2Session)
4159
- return;
4160
- // 1. PEM → DER PKCS8(去掉 header/footer 后 base64 解码)
4161
- const pem = currentAid.privateKeyPem.trim();
4162
- const pemBody = pem
4163
- .replace(/-----BEGIN [^-]+-----/g, '')
4164
- .replace(/-----END [^-]+-----/g, '')
4165
- .replace(/\s+/g, '');
4166
- const pkcs8Der = _v2B64ToBytes(pemBody);
4167
- // 2. 用 WebCrypto 导入 PKCS8 → 导出 jwk → 从 d 拿到 raw scalar
4168
- const privKey = await crypto.subtle.importKey('pkcs8', pkcs8Der.slice().buffer, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
4169
- const jwk = await crypto.subtle.exportKey('jwk', privKey);
4170
- if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') {
4171
- throw new Error('AID private key must be EC P-256');
4172
- }
4173
- if (!jwk.d || !jwk.x || !jwk.y) {
4174
- throw new Error('AID private key jwk missing d/x/y');
4175
- }
4176
- const aidPriv = _v2LeftPad32(_v2B64uToBytes(jwk.d));
4177
- // 3. 从 jwk 公钥导出 SPKI DER
4178
- const pubKey = await crypto.subtle.importKey('jwk', { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y, ext: true }, { name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']);
4179
- const aidPubDer = new Uint8Array(await crypto.subtle.exportKey('spki', pubKey));
4180
- // 4. 打开 V2 KeyStore(IndexedDB)
4181
- if (!this._v2KeyStore) {
4182
- this._v2KeyStore = await V2KeyStore.open();
4183
- }
4184
- this._v2Session = new V2Session(this._v2KeyStore, this._deviceId, this._aid, aidPriv, aidPubDer);
4185
- const callFn = async (method, params) => {
4186
- return this.call(method, params);
4187
- };
4188
- await this._v2Session.ensureRegistered(callFn);
4189
- this._clientLog.debug(`V2 session initialized aid=${this._aid} device=${this._deviceId}`);
4190
- // 上线时自动确认 pending state proposals
4191
- this._safeAsync(this._v2AutoConfirmPendingProposals());
4192
- }
4193
- _v2StoreDeviceId() {
4194
- return `aid:${encodeURIComponent(String(this._aid ?? ''))}|device:${encodeURIComponent(String(this._deviceId ?? ''))}`;
4195
- }
4196
- async _saveGroupIdentityToV2(groupAid, identity) {
4197
- const privateKeyPem = String(identity.private_key_pem ?? '').trim();
4198
- const publicKeyDerB64 = String(identity.public_key_der_b64 ?? '').trim();
4199
- if (!groupAid || !privateKeyPem || !publicKeyDerB64) {
4200
- throw new StateError('group identity is incomplete');
4201
- }
4202
- if (!this._v2KeyStore) {
4203
- this._v2KeyStore = await V2KeyStore.open();
4204
- }
4205
- await this._v2KeyStore.saveGroupIdentity(this._v2StoreDeviceId(), groupAid, privateKeyPem, base64ToUint8(publicKeyDerB64));
2334
+ return this._v2E2EE.initV2Session();
4206
2335
  }
4207
2336
  async _v2TrustedIKPubDer(aid) {
4208
2337
  const normalizedAid = String(aid ?? '').trim();
@@ -4248,7 +2377,7 @@ export class AUNClient {
4248
2377
  if (!args.spkPkDer || args.spkPkDer.length === 0) {
4249
2378
  throw new E2EEError(`spk_public_key_missing: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
4250
2379
  }
4251
- const spkHash = bytesToHex(new Uint8Array(await crypto.subtle.digest('SHA-256', args.spkPkDer.slice().buffer)));
2380
+ const spkHash = bytesToHex(new Uint8Array(await crypto.subtle.digest('SHA-256', exactArrayBuffer(args.spkPkDer))));
4252
2381
  const expectedSpkId = `sha256:${spkHash.substring(0, 16)}`;
4253
2382
  if (spkId !== expectedSpkId) {
4254
2383
  throw new E2EEError(`spk_id_mismatch: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId} expected=${expectedSpkId}`);
@@ -4302,34 +2431,8 @@ export class AUNClient {
4302
2431
  spkId: String(args.dev.spk_id ?? '').trim(),
4303
2432
  };
4304
2433
  }
4305
- async _getV2SenderPubDer(fromAid, senderDeviceId) {
4306
- const session = this._v2Session;
4307
- if (!session || !fromAid)
4308
- return null;
4309
- let senderPubDer = session.getPeerIK(fromAid, senderDeviceId);
4310
- if (senderPubDer)
4311
- return senderPubDer;
4312
- try {
4313
- const certPem = await this._fetchPeerCert(fromAid, undefined, 3000);
4314
- const pubKey = await importCertPublicKeyEcdsa(certPem);
4315
- senderPubDer = new Uint8Array(await crypto.subtle.exportKey('spki', pubKey));
4316
- session.cachePeerIK(fromAid, senderDeviceId, senderPubDer);
4317
- this._clientLog.debug(`V2 decrypt: sender IK fallback from PKI cert for ${fromAid}`);
4318
- return senderPubDer;
4319
- }
4320
- catch (exc) {
4321
- this._clientLog.warn(`V2 decrypt: PKI cert sender IK fallback failed for ${fromAid}: ${String(formatCaughtError(exc))}`);
4322
- return null;
4323
- }
4324
- }
4325
- _v2PendingSenderIKMessageKey(msg, groupId) {
4326
- const messageId = String(msg.message_id ?? '').trim();
4327
- const seq = String(msg.seq ?? '').trim();
4328
- const prefix = groupId ? `group:${groupId}` : `p2p:${this._aid ?? ''}`;
4329
- return `${prefix}:${messageId || seq || Math.random().toString(36).slice(2)}`;
4330
- }
4331
- _v2PendingSenderIKFetchKey(fromAid, senderDeviceId, groupId) {
4332
- return `${fromAid}#${senderDeviceId}#${groupId || ''}`;
2434
+ async _getV2SenderPubDer(fromAid, senderDeviceId, certFingerprint) {
2435
+ return await this._v2E2EE.getV2SenderPubDer(fromAid, senderDeviceId, certFingerprint);
4333
2436
  }
4334
2437
  _cacheV2PeerIKFromDevice(dev, fallbackAid = '') {
4335
2438
  const session = this._v2Session;
@@ -4349,94 +2452,10 @@ export class AUNClient {
4349
2452
  }
4350
2453
  }
4351
2454
  _scheduleV2SenderIKPending(args) {
4352
- const fromAid = String(args.fromAid ?? '').trim();
4353
- if (!fromAid)
4354
- return;
4355
- const senderDeviceId = String(args.senderDeviceId ?? '');
4356
- const groupId = String(args.groupId ?? '').trim();
4357
- const messageKey = this._v2PendingSenderIKMessageKey(args.msg, groupId);
4358
- this._v2SenderIKPending.set(messageKey, {
4359
- msg: { ...args.msg },
4360
- fromAid,
4361
- senderDeviceId,
4362
- groupId,
4363
- createdAt: Date.now(),
4364
- });
4365
- this._clientLog.debug(`V2 decrypt pending sender IK: key=${messageKey} from=${fromAid} device=${senderDeviceId || '-'} group=${groupId || '<p2p>'} pending=${this._v2SenderIKPending.size}`);
4366
- this._scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupId);
4367
- }
4368
- _scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupId) {
4369
- const fetchKey = this._v2PendingSenderIKFetchKey(fromAid, senderDeviceId, groupId);
4370
- if (!fromAid || this._v2SenderIKFetching.has(fetchKey))
4371
- return;
4372
- this._v2SenderIKFetching.add(fetchKey);
4373
- this._safeAsync(this._resolveV2SenderIKPending(fromAid, senderDeviceId, groupId, fetchKey));
2455
+ return this._v2E2EE.scheduleSenderIKPending(args);
4374
2456
  }
4375
2457
  async _resolveV2SenderIKPending(fromAid, senderDeviceId, groupId, fetchKey) {
4376
- try {
4377
- const session = this._v2Session;
4378
- if (session && fromAid) {
4379
- try {
4380
- const bs = await this.call('message.v2.bootstrap', {
4381
- peer_aid: fromAid,
4382
- e2ee_wrap_capabilities: v2WrapCapabilities(),
4383
- });
4384
- const peers = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
4385
- for (const dev of peers)
4386
- this._cacheV2PeerIKFromDevice(dev, fromAid);
4387
- }
4388
- catch (exc) {
4389
- this._clientLog.warn(`V2 sender IK pending bootstrap failed peer=${fromAid}: ${String(formatCaughtError(exc))}`);
4390
- }
4391
- if (groupId) {
4392
- try {
4393
- const gbs = await this.call('group.v2.bootstrap', {
4394
- group_id: groupId,
4395
- e2ee_wrap_capabilities: v2WrapCapabilities(),
4396
- });
4397
- const devices = (Array.isArray(gbs?.devices) ? gbs.devices : []);
4398
- const audit = (Array.isArray(gbs?.audit_recipients) ? gbs.audit_recipients : []);
4399
- for (const dev of devices)
4400
- this._cacheV2PeerIKFromDevice(dev);
4401
- for (const dev of audit)
4402
- this._cacheV2PeerIKFromDevice(dev);
4403
- }
4404
- catch (exc) {
4405
- this._clientLog.warn(`V2 sender IK pending group bootstrap failed group=${groupId}: ${String(formatCaughtError(exc))}`);
4406
- }
4407
- }
4408
- if (!session.getPeerIK(fromAid, senderDeviceId)) {
4409
- await this._getV2SenderPubDer(fromAid, senderDeviceId);
4410
- }
4411
- }
4412
- const pendingItems = [...this._v2SenderIKPending.entries()].filter(([, entry]) => entry.fromAid === fromAid && entry.senderDeviceId === senderDeviceId && entry.groupId === groupId);
4413
- for (const [key, entry] of pendingItems) {
4414
- let plaintext = null;
4415
- try {
4416
- plaintext = await this._decryptV2Message(entry.msg, false);
4417
- }
4418
- catch (exc) {
4419
- this._clientLog.warn(`V2 sender IK pending retry raised: key=${key} err=${String(formatCaughtError(exc))}`);
4420
- }
4421
- this._v2SenderIKPending.delete(key);
4422
- if (plaintext === null) {
4423
- this._clientLog.debug(`V2 sender IK pending retry failed: key=${key}`);
4424
- continue;
4425
- }
4426
- const seq = Number(entry.msg.seq ?? 0);
4427
- if (entry.groupId) {
4428
- plaintext.group_id = entry.groupId;
4429
- await this._publishPulledMessage('group.message_created', `group:${entry.groupId}`, seq, plaintext);
4430
- }
4431
- else {
4432
- await this._publishPulledMessage('message.received', `p2p:${this._aid ?? ''}`, seq, plaintext);
4433
- }
4434
- this._clientLog.debug(`V2 sender IK pending retry delivered: key=${key}`);
4435
- }
4436
- }
4437
- finally {
4438
- this._v2SenderIKFetching.delete(fetchKey);
4439
- }
2458
+ return await this._v2E2EE.resolveSenderIKPending(fromAid, senderDeviceId, groupId, fetchKey);
4440
2459
  }
4441
2460
  /**
4442
2461
  * V2 P2P 加密发送(推测性:用缓存 bootstrap 直接发,失败刷新重试一次)。
@@ -4447,42 +2466,7 @@ export class AUNClient {
4447
2466
  * @returns 服务端响应
4448
2467
  */
4449
2468
  async _sendV2(to, payload, opts) {
4450
- if (!this._v2Session) {
4451
- throw new StateError('V2 session not initialized (not connected?)');
4452
- }
4453
- const toAid = String(to ?? '').trim();
4454
- if (!toAid)
4455
- throw new ValidationError("message.send requires 'to'");
4456
- if (!isJsonObject(payload))
4457
- throw new ValidationError('message.send payload must be a dict for V2 encryption');
4458
- const attempt = async (useCache) => {
4459
- const envelope = await this._buildV2P2PEnvelope({
4460
- to: toAid,
4461
- payload,
4462
- messageId: opts?.messageId,
4463
- timestamp: opts?.timestamp,
4464
- protectedHeaders: opts?.protectedHeaders,
4465
- context: opts?.context,
4466
- useCache,
4467
- });
4468
- return this.call('message.send', {
4469
- to: toAid,
4470
- payload: envelope,
4471
- encrypt: false,
4472
- });
4473
- };
4474
- try {
4475
- return await attempt(true);
4476
- }
4477
- catch (exc) {
4478
- const excCode = exc?.code;
4479
- if (AUNClient.V2_RETRYABLE_CODES.has(excCode)) {
4480
- this._clientLog.debug(`V2 P2P speculative send rejected (code=${excCode}), refreshing bootstrap`);
4481
- this._v2BootstrapCache.delete(toAid);
4482
- return attempt(false);
4483
- }
4484
- throw exc;
4485
- }
2469
+ return await this._v2E2EE.sendV2(to, payload, opts);
4486
2470
  }
4487
2471
  /**
4488
2472
  * 拉取并解密 V2 P2P 消息。
@@ -4491,117 +2475,7 @@ export class AUNClient {
4491
2475
  * @param limit 最多拉取条数
4492
2476
  */
4493
2477
  async _pullV2(afterSeq = 0, limit = 50, opts) {
4494
- if (!this._v2Session) {
4495
- throw new StateError('V2 session not initialized (not connected?)');
4496
- }
4497
- const ns = this._aid ? `p2p:${this._aid}` : '';
4498
- const decrypted = [];
4499
- let nextAfterSeq = opts?.force ? afterSeq : (afterSeq || (ns ? this._seqTracker.getContiguousSeq(ns) : 0));
4500
- let pageCount = 0;
4501
- const maxPages = 100;
4502
- while (pageCount < maxPages) {
4503
- pageCount += 1;
4504
- const result = await this._callRawV2Rpc('message.v2.pull', {
4505
- after_seq: nextAfterSeq,
4506
- limit,
4507
- ...(opts?.force ? { force: true } : {}),
4508
- });
4509
- const messages = (Array.isArray(result?.messages) ? result.messages : []);
4510
- const seqs = messages
4511
- .map((msg) => Number(msg.seq ?? 0))
4512
- .filter((seq) => Number.isFinite(seq) && seq > 0);
4513
- const pageContigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
4514
- const pageMaxSeq = seqs.length > 0 ? Math.max(...seqs) : nextAfterSeq;
4515
- if (ns && seqs.length > 0) {
4516
- this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
4517
- }
4518
- for (const msg of messages) {
4519
- const seq = Number(msg.seq ?? 0);
4520
- if (!Number.isFinite(seq) || seq <= 0)
4521
- continue;
4522
- const version = String(msg.version ?? 'v2');
4523
- if (version === 'v1') {
4524
- const legacy = isJsonObject(msg.legacy_v1) ? msg.legacy_v1 : {};
4525
- const legacyPayload = legacy.payload;
4526
- const payloadType = isJsonObject(legacyPayload)
4527
- ? String(legacyPayload.type ?? '').trim()
4528
- : '';
4529
- if (legacyPayload !== undefined && legacyPayload !== null
4530
- && payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
4531
- const v1Msg = {
4532
- message_id: String(msg.message_id ?? ''),
4533
- from: String(msg.from_aid ?? ''),
4534
- to: String(legacy.to ?? this._aid ?? ''),
4535
- seq: msg.seq,
4536
- type: String(msg.type ?? ''),
4537
- timestamp: msg.t_server,
4538
- payload: legacyPayload,
4539
- encrypted: false,
4540
- };
4541
- if (ns)
4542
- await this._publishPulledMessage('message.received', ns, seq, v1Msg);
4543
- else
4544
- await this._publishAppEvent('message.received', v1Msg);
4545
- decrypted.push(v1Msg);
4546
- }
4547
- else {
4548
- this._clientLog.debug(`message.v2.pull skipping V1 envelope seq=${seq} payload_type=${payloadType || '<none>'} (V1 E2EE removed)`);
4549
- }
4550
- continue;
4551
- }
4552
- if (version !== 'v2') {
4553
- this._clientLog.debug(`message.v2.pull skipping non-V2 row seq=${seq} version=${String(msg.version ?? '')}`);
4554
- continue;
4555
- }
4556
- // 跟踪每个旧 SPK 引用的最大 seq(用于消费后销毁)
4557
- const msgSpkId = String(msg.spk_id ?? '');
4558
- if (msgSpkId && this._v2Session && !this._v2Session.isCurrentSPK(msgSpkId)) {
4559
- this._v2Session.trackOldSPKMaxSeq(msgSpkId, seq);
4560
- }
4561
- const plaintext = await this._decryptV2Message(msg);
4562
- if (plaintext === null)
4563
- continue;
4564
- if (ns) {
4565
- await this._publishPulledMessage('message.received', ns, seq, plaintext);
4566
- decrypted.push(plaintext);
4567
- }
4568
- else {
4569
- await this._publishAppEvent('message.received', plaintext);
4570
- decrypted.push(plaintext);
4571
- }
4572
- }
4573
- const hasServerAckSeq = Object.prototype.hasOwnProperty.call(result, 'server_ack_seq');
4574
- const serverAckSeq = Number(result.server_ack_seq ?? 0);
4575
- if (ns && Number.isFinite(serverAckSeq) && serverAckSeq > 0) {
4576
- const contig = this._seqTracker.getContiguousSeq(ns);
4577
- if (contig < serverAckSeq) {
4578
- this._clientLog.info(`message.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> server_ack_seq=${serverAckSeq}`);
4579
- this._seqTracker.forceContiguousSeq(ns, serverAckSeq);
4580
- }
4581
- }
4582
- if (ns) {
4583
- const ackSeq = this._seqTracker.getContiguousSeq(ns);
4584
- const contigAdvanced = ackSeq !== pageContigBefore;
4585
- if (contigAdvanced) {
4586
- await this._drainOrderedMessages(ns);
4587
- this._saveSeqTrackerState();
4588
- }
4589
- const ackNeeded = messages.length > 0
4590
- && ackSeq > 0
4591
- && (contigAdvanced || (hasServerAckSeq && ackSeq > serverAckSeq));
4592
- if (ackNeeded) {
4593
- this._safeAsync(this._ackV2(ackSeq).then(() => undefined));
4594
- }
4595
- }
4596
- const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
4597
- if (messages.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
4598
- break;
4599
- nextAfterSeq = nextAfter;
4600
- }
4601
- if (pageCount >= maxPages) {
4602
- this._clientLog.warn(`message.v2.pull reached max_pages=${maxPages} after_seq=${nextAfterSeq}`);
4603
- }
4604
- return decrypted;
2478
+ return await this._v2E2EE.pullV2(afterSeq, limit, opts);
4605
2479
  }
4606
2480
  /**
4607
2481
  * 确认 V2 消息已消费 + 自检销毁旧 SPK(PFS)。
@@ -4609,47 +2483,7 @@ export class AUNClient {
4609
2483
  * @param upToSeq 确认到此 seq;省略则用当前 contiguous
4610
2484
  */
4611
2485
  async _ackV2(upToSeq) {
4612
- const ns = this._aid ? `p2p:${this._aid}` : '';
4613
- let seq = upToSeq ?? (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
4614
- if (seq <= 0)
4615
- return { acked: 0 };
4616
- // ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
4617
- if (ns) {
4618
- const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
4619
- if (maxSeen > 0 && seq > maxSeen) {
4620
- this._clientLog.warn(`ackV2 clamp: up_to_seq=${seq} > max_seen=${maxSeen}, clamp`);
4621
- seq = maxSeen;
4622
- }
4623
- }
4624
- const raw = await this._callRawV2Rpc('message.v2.ack', { up_to_seq: seq });
4625
- const result = isJsonObject(raw)
4626
- ? { ...raw }
4627
- : { result: raw };
4628
- let actualAckSeq = seq;
4629
- if ('effective_ack_seq' in result)
4630
- actualAckSeq = Number(result.effective_ack_seq ?? 0);
4631
- else if ('ack_seq' in result)
4632
- actualAckSeq = Number(result.ack_seq ?? 0);
4633
- else if ('cursor' in result)
4634
- actualAckSeq = Number(result.cursor ?? 0);
4635
- if (!Number.isFinite(actualAckSeq))
4636
- actualAckSeq = seq;
4637
- result.ack_seq = actualAckSeq;
4638
- result.success = true;
4639
- if (Number(result.acked ?? 0) === 0)
4640
- result.acked = actualAckSeq;
4641
- if (this._v2Session) {
4642
- try {
4643
- const destroyed = await this._v2Session.maybeDestroyOldSPKs(actualAckSeq);
4644
- if (destroyed.length > 0) {
4645
- this._clientLog.info(`V2 destroyed old SPKs after ack: ${destroyed.slice(0, 3)} (PFS)`);
4646
- }
4647
- }
4648
- catch (exc) {
4649
- this._clientLog.debug(`V2 SPK destroy failed (non-fatal): ${String(exc)}`);
4650
- }
4651
- }
4652
- return result;
2486
+ return await this._v2E2EE.ackV2(upToSeq);
4653
2487
  }
4654
2488
  /** 解密单条 V2 消息(与 Python `_decrypt_v2_message` 对齐)。缺 sender IK 时先入 pending,后台补齐后重试。 */
4655
2489
  async _decryptV2Message(msg, allowPending = true) {
@@ -4741,7 +2575,8 @@ export class AUNClient {
4741
2575
  }
4742
2576
  const fromAid = String(msg.from_aid ?? '');
4743
2577
  const senderDeviceId = String(aad.from_device ?? '');
4744
- const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId);
2578
+ const senderCertFingerprint = String(envelope.sender_cert_fingerprint ?? '').trim().toLowerCase();
2579
+ const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId, senderCertFingerprint);
4745
2580
  if (!senderPubDer) {
4746
2581
  this._clientLog.warn(`V2 decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
4747
2582
  if (allowPending) {
@@ -4761,7 +2596,6 @@ export class AUNClient {
4761
2596
  _decrypt_stage: 'sender_ik',
4762
2597
  _envelope_type: String(envelope.type ?? ''),
4763
2598
  _suite: String(envelope.suite ?? ''),
4764
- _sender_device_id: String(aad.from_device ?? ''),
4765
2599
  };
4766
2600
  attachV2EnvelopeMetadata(event, e2eeMeta);
4767
2601
  await this._dispatcher.publish(undecryptableEvent, event);
@@ -4788,7 +2622,6 @@ export class AUNClient {
4788
2622
  _decrypt_stage: 'decrypt',
4789
2623
  _envelope_type: String(envelope.type ?? ''),
4790
2624
  _suite: String(envelope.suite ?? ''),
4791
- _sender_device_id: String(aad.from_device ?? ''),
4792
2625
  };
4793
2626
  attachV2EnvelopeMetadata(event, e2eeMeta);
4794
2627
  await this._dispatcher.publish(undecryptableEvent, event);
@@ -4800,16 +2633,10 @@ export class AUNClient {
4800
2633
  return null;
4801
2634
  // 消费触发 SPK 轮换(fire-and-forget,不阻塞消息处理)
4802
2635
  if (groupIdForKeys && recipientKeySource === 'group_device_prekey' && session.isLastUploadedGroupSPK(groupIdForKeys, spkId)) {
4803
- const callFn = async (method, params) => this.call(method, params);
4804
- session.rotateGroupSPK(groupIdForKeys, callFn).catch(exc => {
4805
- this._clientLog.debug(`V2 group SPK rotation failed (non-fatal): group=${groupIdForKeys} err=${exc}`);
4806
- });
2636
+ this._v2E2EE.scheduleGroupSpkRotation(groupIdForKeys, { reason: 'group_spk_consumed' });
4807
2637
  }
4808
2638
  else if (groupIdForKeys && recipientKeySource === 'peer_device_prekey') {
4809
- const callFn = async (method, params) => this.call(method, params);
4810
- session.ensureGroupRegistered(groupIdForKeys, callFn).catch(exc => {
4811
- this._clientLog.debug(`V2 group SPK registration after peer fallback failed (non-fatal): group=${groupIdForKeys} err=${exc}`);
4812
- });
2639
+ this._v2E2EE.scheduleGroupSpkRegistrationAfterPeerFallback(groupIdForKeys);
4813
2640
  }
4814
2641
  else if (!groupIdForKeys && session.isLastUploadedSPK(spkId)) {
4815
2642
  const callFn = async (method, params) => this.call(method, params);
@@ -4834,6 +2661,7 @@ export class AUNClient {
4834
2661
  result.device_id = msg.device_id;
4835
2662
  if (msg.slot_id !== undefined)
4836
2663
  result.slot_id = msg.slot_id;
2664
+ attachGatewayProximity(result, msg);
4837
2665
  attachV2EnvelopeMetadata(result, e2ee);
4838
2666
  return result;
4839
2667
  }
@@ -4846,65 +2674,10 @@ export class AUNClient {
4846
2674
  * @returns 服务端响应
4847
2675
  */
4848
2676
  async _sendGroupV2(groupId, payload, opts) {
4849
- if (!this._v2Session) {
4850
- throw new StateError('V2 session not initialized (not connected?)');
4851
- }
4852
- const gid = normalizeGroupId(groupId) || String(groupId ?? '').trim();
4853
- if (!gid)
4854
- throw new ValidationError("group.send requires 'group_id'");
4855
- if (!isJsonObject(payload))
4856
- throw new ValidationError('group.send payload must be a dict for V2 encryption');
4857
- const attempt = async (useCache) => {
4858
- const envelope = await this._buildV2GroupEnvelope({
4859
- groupId: gid,
4860
- payload,
4861
- messageId: opts?.messageId,
4862
- timestamp: opts?.timestamp,
4863
- protectedHeaders: opts?.protectedHeaders,
4864
- context: opts?.context,
4865
- useCache,
4866
- });
4867
- return this.call('group.v2.send', {
4868
- group_id: gid,
4869
- envelope: envelope,
4870
- });
4871
- };
4872
- try {
4873
- const result = await attempt(true);
4874
- // 发送成功后记录自己发的消息 seq,保证 SeqTracker 连续 + push 时去重
4875
- if (isJsonObject(result)) {
4876
- const seq = Number(result.seq ?? 0);
4877
- if (seq > 0) {
4878
- const ns = `group:${gid}`;
4879
- this._seqTracker.onMessageSeq(ns, seq);
4880
- this._markPublishedSeq(ns, seq);
4881
- this._saveSeqTrackerState();
4882
- }
4883
- }
4884
- return result;
4885
- }
4886
- catch (exc) {
4887
- const excCode = exc?.code;
4888
- if (AUNClient.V2_RETRYABLE_CODES.has(excCode)) {
4889
- this._clientLog.debug(`V2 group speculative send rejected (code=${excCode}), refreshing bootstrap`);
4890
- this._v2BootstrapCache.delete(`group:${gid}`);
4891
- const result = await attempt(false);
4892
- if (isJsonObject(result)) {
4893
- const seq = Number(result.seq ?? 0);
4894
- if (seq > 0) {
4895
- const ns = `group:${gid}`;
4896
- this._seqTracker.onMessageSeq(ns, seq);
4897
- this._markPublishedSeq(ns, seq);
4898
- this._saveSeqTrackerState();
4899
- }
4900
- }
4901
- return result;
4902
- }
4903
- throw exc;
4904
- }
2677
+ return await this._v2E2EE.sendGroupV2(groupId, payload, opts);
4905
2678
  }
4906
2679
  async _pullGroupV2Internal(params) {
4907
- await this._pullGroupV2(params.group_id, params.after_seq, params.limit);
2680
+ return await this._v2E2EE.pullGroupV2Internal(params);
4908
2681
  }
4909
2682
  /**
4910
2683
  * 拉取并解密 V2 Group 消息。
@@ -4914,146 +2687,7 @@ export class AUNClient {
4914
2687
  * @param limit 最多拉取条数
4915
2688
  */
4916
2689
  async _pullGroupV2(groupId, afterSeq = 0, limit = 50, opts) {
4917
- if (!this._v2Session) {
4918
- throw new StateError('V2 session not initialized (not connected?)');
4919
- }
4920
- const gid = normalizeGroupId(groupId) || String(groupId ?? '').trim();
4921
- if (!gid)
4922
- throw new ValidationError('group.pull requires group_id');
4923
- const ns = `group:${gid}`;
4924
- const decrypted = [];
4925
- const cursorParams = opts?.cursorParams ?? {};
4926
- const ownsCursor = opts?.ownsCursor !== false;
4927
- let nextAfterSeq = opts?.explicitAfterSeq ? afterSeq : (afterSeq || this._seqTracker.getContiguousSeq(ns));
4928
- let pageCount = 0;
4929
- const maxPages = 100;
4930
- while (pageCount < maxPages) {
4931
- pageCount += 1;
4932
- const result = await this._callRawV2Rpc('group.v2.pull', {
4933
- group_id: gid,
4934
- after_seq: nextAfterSeq,
4935
- limit,
4936
- ...cursorParams,
4937
- });
4938
- const messages = (Array.isArray(result?.messages) ? result.messages : []);
4939
- const seqs = messages
4940
- .map((msg) => Number(msg.seq ?? 0))
4941
- .filter((seq) => Number.isFinite(seq) && seq > 0);
4942
- const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
4943
- const pageMaxSeq = seqs.length > 0 ? Math.max(...seqs) : nextAfterSeq;
4944
- if (seqs.length > 0) {
4945
- this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
4946
- }
4947
- for (const msg of messages) {
4948
- const seq = Number(msg.seq ?? 0);
4949
- if (!Number.isFinite(seq) || seq <= 0)
4950
- continue;
4951
- const version = String(msg.version ?? 'v2');
4952
- if (version === 'v1') {
4953
- const payload = msg.payload;
4954
- const payloadObj = isJsonObject(payload) ? payload : null;
4955
- if (payloadObj) {
4956
- const payloadType = String(payloadObj.type ?? '').trim();
4957
- if (payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
4958
- const v1Msg = {
4959
- message_id: String(msg.message_id ?? ''),
4960
- from: String(msg.from_aid ?? ''),
4961
- group_id: gid,
4962
- seq: msg.seq,
4963
- type: String(msg.type ?? ''),
4964
- timestamp: msg.t_server,
4965
- payload,
4966
- encrypted: false,
4967
- };
4968
- await this._publishPulledMessage('group.message_created', ns, seq, v1Msg);
4969
- decrypted.push(v1Msg);
4970
- continue;
4971
- }
4972
- }
4973
- else if (payload !== undefined && payload !== null) {
4974
- const v1Msg = {
4975
- message_id: String(msg.message_id ?? ''),
4976
- from: String(msg.from_aid ?? ''),
4977
- group_id: gid,
4978
- seq: msg.seq,
4979
- type: String(msg.type ?? ''),
4980
- timestamp: msg.t_server,
4981
- payload,
4982
- encrypted: false,
4983
- };
4984
- await this._publishPulledMessage('group.message_created', ns, seq, v1Msg);
4985
- decrypted.push(v1Msg);
4986
- continue;
4987
- }
4988
- this._clientLog.debug(`group.v2.pull skipping V1 envelope group=${gid} seq=${seq} payload_type=${payloadObj ? String(payloadObj.type ?? '') : '<none>'} (V1 E2EE removed)`);
4989
- continue;
4990
- }
4991
- if (version !== 'v2') {
4992
- this._clientLog.debug(`group.v2.pull skipping non-V2 row group=${gid} seq=${seq} version=${String(msg.version ?? '')}`);
4993
- continue;
4994
- }
4995
- const plaintext = await this._decryptV2Message(msg);
4996
- if (plaintext === null)
4997
- continue;
4998
- plaintext.group_id = gid;
4999
- await this._publishPulledMessage('group.message_created', ns, seq, plaintext);
5000
- decrypted.push(plaintext);
5001
- }
5002
- const cursor = isJsonObject(result.cursor) ? result.cursor : null;
5003
- const hasServerCursor = cursor !== null && Object.prototype.hasOwnProperty.call(cursor, 'current_seq');
5004
- const serverAckSeq = Number(cursor?.current_seq ?? 0);
5005
- if (Number.isFinite(serverAckSeq) && serverAckSeq > 0) {
5006
- const contig = this._seqTracker.getContiguousSeq(ns);
5007
- if (contig < serverAckSeq) {
5008
- this._clientLog.info(`group.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> cursor.current_seq=${serverAckSeq}`);
5009
- this._seqTracker.forceContiguousSeq(ns, serverAckSeq);
5010
- }
5011
- }
5012
- const ackSeq = this._seqTracker.getContiguousSeq(ns);
5013
- const contigAdvanced = ackSeq !== pageContigBefore;
5014
- if (contigAdvanced) {
5015
- await this._drainOrderedMessages(ns);
5016
- this._saveSeqTrackerState();
5017
- }
5018
- const ackNeeded = messages.length > 0
5019
- && ackSeq > 0
5020
- && ownsCursor
5021
- && (contigAdvanced || (hasServerCursor && ackSeq > serverAckSeq));
5022
- if (ackNeeded) {
5023
- this._safeAsync(this._ackGroupV2(gid, ackSeq).then(() => undefined));
5024
- }
5025
- const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
5026
- if (!ownsCursor)
5027
- break;
5028
- if (messages.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
5029
- break;
5030
- nextAfterSeq = nextAfter;
5031
- }
5032
- if (pageCount >= maxPages) {
5033
- this._clientLog.warn(`group.v2.pull reached max_pages=${maxPages} group=${gid} after_seq=${nextAfterSeq}`);
5034
- }
5035
- return decrypted;
5036
- }
5037
- _groupCursorParams(params) {
5038
- const cursorParams = {};
5039
- for (const key of ['device_id', 'slot_id', 'device_name', 'device_type']) {
5040
- const value = params[key];
5041
- if (value !== undefined && value !== null)
5042
- cursorParams[key] = value;
5043
- }
5044
- return cursorParams;
5045
- }
5046
- _explicitGroupCursorParams(params) {
5047
- const value = params._group_cursor_params;
5048
- if (!isJsonObject(value))
5049
- return {};
5050
- return { ...value };
5051
- }
5052
- _groupCursorTargetsCurrentInstance(params) {
5053
- const deviceId = String(params.device_id ?? '').trim();
5054
- const slotId = String(params.slot_id ?? '').trim();
5055
- return (!deviceId || deviceId === (this._deviceId ?? ''))
5056
- && (!slotId || slotId === (this._slotId ?? ''));
2690
+ return await this._v2E2EE.pullGroupV2(groupId, afterSeq, limit, opts);
5057
2691
  }
5058
2692
  async _rawGroupAckMessages(params) {
5059
2693
  const p = { ...params };
@@ -5066,132 +2700,15 @@ export class AUNClient {
5066
2700
  * @param upToSeq 确认到此 seq;省略则用当前 contiguous
5067
2701
  */
5068
2702
  async _ackGroupV2(groupId, upToSeq) {
5069
- const gid = normalizeGroupId(groupId) || String(groupId ?? '').trim();
5070
- if (!gid)
5071
- throw new ValidationError('group.ack_messages requires group_id');
5072
- const ns = `group:${gid}`;
5073
- let seq = upToSeq ?? this._seqTracker.getContiguousSeq(ns);
5074
- if (seq <= 0)
5075
- return { acked: 0 };
5076
- // ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
5077
- const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
5078
- if (maxSeen > 0 && seq > maxSeen) {
5079
- this._clientLog.warn(`ackGroupV2 clamp: group=${gid} up_to_seq=${seq} > max_seen=${maxSeen}, clamp`);
5080
- seq = maxSeen;
5081
- }
5082
- return this._callRawV2Rpc('group.v2.ack', { group_id: gid, up_to_seq: seq });
2703
+ return await this._v2E2EE.ackGroupV2(groupId, upToSeq);
5083
2704
  }
5084
- // ── V2 thought(per-device wrap,服务端透传,不持久化)──────────
5085
2705
  /**
5086
2706
  * V2 P2P 多设备 wrap envelope 构造(不发送)。
5087
2707
  * thought.put 复用此函数构造与 message.send 相同的 V2 envelope。
5088
2708
  * 与 Python `_build_v2_p2p_envelope` 对齐。
5089
2709
  */
5090
2710
  async _buildV2P2PEnvelope(opts) {
5091
- if (!this._v2Session) {
5092
- throw new StateError('V2 session not initialized');
5093
- }
5094
- const session = this._v2Session;
5095
- const to = opts.to;
5096
- const useCache = opts.useCache !== false;
5097
- let peerDevices = [];
5098
- let auditRaw = [];
5099
- let wrapPolicy;
5100
- const cached = useCache ? this._v2BootstrapCache.get(to) : undefined;
5101
- if (cached && (Date.now() - cached.cachedAt) < AUNClient.V2_BOOTSTRAP_TTL_MS) {
5102
- peerDevices = cached.devices;
5103
- auditRaw = cached.auditRecipients;
5104
- wrapPolicy = cached.wrapPolicy;
5105
- }
5106
- else {
5107
- const bs = await this.call('message.v2.bootstrap', {
5108
- peer_aid: to,
5109
- e2ee_wrap_capabilities: v2WrapCapabilities(),
5110
- });
5111
- peerDevices = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
5112
- auditRaw = (Array.isArray(bs?.audit_recipients) ? bs.audit_recipients : []);
5113
- wrapPolicy = normalizeV2WrapPolicy(bs?.e2ee_wrap_policy);
5114
- if (peerDevices.length > 0) {
5115
- this._v2BootstrapCache.set(to, {
5116
- devices: peerDevices,
5117
- auditRecipients: auditRaw,
5118
- cachedAt: Date.now(),
5119
- wrapPolicy,
5120
- });
5121
- }
5122
- }
5123
- if (peerDevices.length === 0) {
5124
- throw new E2EEError(`V2 bootstrap: no devices found for ${to}`);
5125
- }
5126
- const targets = [];
5127
- for (const dev of peerDevices) {
5128
- const devId = getV2DeviceId(dev);
5129
- const target = await this._v2BuildTargetFromDevice({
5130
- dev,
5131
- aid: to,
5132
- deviceId: devId.value,
5133
- role: 'peer',
5134
- defaultKeySource: 'peer_device_prekey',
5135
- });
5136
- if (target)
5137
- targets.push(target);
5138
- }
5139
- for (const dev of auditRaw) {
5140
- const target = await this._v2BuildTargetFromDevice({
5141
- dev,
5142
- aid: String(dev.aid ?? ''),
5143
- deviceId: String(dev.device_id ?? ''),
5144
- role: 'audit',
5145
- defaultKeySource: 'peer_device_prekey',
5146
- });
5147
- if (target)
5148
- targets.push(target);
5149
- }
5150
- // self-sync:自己其它设备
5151
- if (this._aid && this._aid !== to) {
5152
- try {
5153
- const selfCached = this._v2BootstrapCache.get(this._aid);
5154
- let selfDevices = [];
5155
- if (selfCached && (Date.now() - selfCached.cachedAt) < AUNClient.V2_BOOTSTRAP_TTL_MS) {
5156
- selfDevices = selfCached.devices;
5157
- }
5158
- else {
5159
- const selfBs = await this.call('message.v2.bootstrap', {
5160
- peer_aid: this._aid,
5161
- e2ee_wrap_capabilities: v2WrapCapabilities(),
5162
- });
5163
- selfDevices = (Array.isArray(selfBs?.peer_devices) ? selfBs.peer_devices : []);
5164
- if (selfDevices.length > 0) {
5165
- this._v2BootstrapCache.set(this._aid, {
5166
- devices: selfDevices,
5167
- auditRecipients: [],
5168
- cachedAt: Date.now(),
5169
- });
5170
- }
5171
- }
5172
- for (const dev of selfDevices) {
5173
- const devId = getV2DeviceId(dev);
5174
- if (!devId.present || devId.value === this._deviceId)
5175
- continue;
5176
- const target = await this._v2BuildTargetFromDevice({
5177
- dev,
5178
- aid: this._aid,
5179
- deviceId: devId.value,
5180
- role: 'self_sync',
5181
- defaultKeySource: 'peer_device_prekey',
5182
- });
5183
- if (target)
5184
- targets.push(target);
5185
- }
5186
- }
5187
- catch (exc) {
5188
- this._clientLog.debug(`V2 thought self-sync bootstrap failed (non-fatal): ${String(exc)}`);
5189
- }
5190
- }
5191
- const sender = await session.getSenderIdentity();
5192
- const sendTargets = applyV2WrapPolicyToTargets(targets, wrapPolicy);
5193
- const envelope = await encryptP2PMessage(sender, { targets: sendTargets, auditRecipients: [] }, opts.payload, { messageId: opts.messageId, timestamp: opts.timestamp, protectedHeaders: opts.protectedHeaders, context: opts.context });
5194
- return envelope;
2711
+ return await this._v2E2EE.buildV2P2PEnvelope(opts);
5195
2712
  }
5196
2713
  /**
5197
2714
  * V2 P2P thought.put:使用 V2 多设备 wrap envelope。
@@ -5199,215 +2716,21 @@ export class AUNClient {
5199
2716
  * 与 Python `_put_message_thought_encrypted_v2` 对齐。
5200
2717
  */
5201
2718
  async _putMessageThoughtEncryptedV2(params) {
5202
- const toAid = String(params.to ?? '').trim();
5203
- this._validateMessageRecipient(toAid);
5204
- const payload = isJsonObject(params.payload) ? params.payload : null;
5205
- if (!toAid) {
5206
- throw new ValidationError('message.thought.put requires to');
5207
- }
5208
- if (payload === null) {
5209
- throw new ValidationError('message.thought.put payload must be an object when encrypt=true');
5210
- }
5211
- const thoughtId = String(params.thought_id ?? '') || `mt-${_uuidV4()}`;
5212
- const timestamp = Number(params.timestamp ?? Date.now());
5213
- const attempt = async (useCache) => {
5214
- const envelopeContext = (params.context && typeof params.context === 'object' && !Array.isArray(params.context))
5215
- ? params.context : undefined;
5216
- const envelope = await this._buildV2P2PEnvelope({
5217
- to: toAid,
5218
- payload,
5219
- messageId: thoughtId,
5220
- timestamp,
5221
- useCache,
5222
- context: envelopeContext,
5223
- });
5224
- const sendParams = {
5225
- to: toAid,
5226
- payload: envelope,
5227
- encrypted: true,
5228
- thought_id: thoughtId,
5229
- timestamp,
5230
- };
5231
- if ('context' in params)
5232
- sendParams.context = params.context;
5233
- await this._signClientOperation('message.thought.put', sendParams);
5234
- return this._transport.call('message.thought.put', sendParams);
5235
- };
5236
- try {
5237
- return await attempt(true);
5238
- }
5239
- catch (exc) {
5240
- const excCode = exc?.code;
5241
- if (AUNClient.V2_RETRYABLE_CODES.has(excCode)) {
5242
- this._clientLog.debug(`V2 P2P thought put speculative rejected (code=${excCode}), refreshing bootstrap`);
5243
- this._v2BootstrapCache.delete(toAid);
5244
- return attempt(false);
5245
- }
5246
- throw exc;
5247
- }
2719
+ return await this._v2E2EE.putMessageThoughtEncryptedV2(params);
5248
2720
  }
5249
2721
  /**
5250
2722
  * V2 Group 多设备 wrap envelope 构造(不发送)。
5251
2723
  * 与 Python `_build_v2_group_envelope` 对齐。
5252
2724
  */
5253
2725
  async _buildV2GroupEnvelope(opts) {
5254
- if (!this._v2Session) {
5255
- throw new StateError('V2 session not initialized');
5256
- }
5257
- const session = this._v2Session;
5258
- const groupId = normalizeGroupId(opts.groupId) || String(opts.groupId ?? '').trim();
5259
- if (!groupId)
5260
- throw new ValidationError("group.send requires 'group_id'");
5261
- const useCache = opts.useCache !== false;
5262
- const cacheKey = `group:${groupId}`;
5263
- let allDevices = [];
5264
- let epoch = 0;
5265
- let stateCommitment = { state_version: 0, state_hash: '', state_chain: '' };
5266
- let auditRecipientsRaw = [];
5267
- let wrapPolicy;
5268
- const cached = useCache ? this._v2BootstrapCache.get(cacheKey) : undefined;
5269
- if (cached && (Date.now() - cached.cachedAt) < AUNClient.V2_BOOTSTRAP_TTL_MS) {
5270
- allDevices = cached.devices;
5271
- epoch = cached.epoch ?? 0;
5272
- stateCommitment = cached.stateCommitment ?? { state_version: 0, state_hash: '', state_chain: '' };
5273
- auditRecipientsRaw = cached.auditRecipients;
5274
- wrapPolicy = cached.wrapPolicy;
5275
- }
5276
- else {
5277
- const bs = await this.call('group.v2.bootstrap', {
5278
- group_id: groupId,
5279
- e2ee_wrap_capabilities: v2WrapCapabilities(),
5280
- });
5281
- allDevices = (Array.isArray(bs?.devices) ? bs.devices : []);
5282
- epoch = Number(bs?.epoch ?? 0);
5283
- auditRecipientsRaw = (Array.isArray(bs?.audit_recipients) ? bs.audit_recipients : []);
5284
- wrapPolicy = normalizeV2WrapPolicy(bs?.e2ee_wrap_policy);
5285
- await this._v2CheckFork(groupId, String(bs?.state_chain ?? ''));
5286
- await this._v2VerifyStateSignature(groupId, bs);
5287
- await this._publishV2GroupSecurityLevel(groupId, bs);
5288
- stateCommitment = {
5289
- state_version: Number(bs?.state_version ?? 0) || 0,
5290
- state_hash: String(bs?.state_hash_signed ?? bs?.state_hash ?? ''),
5291
- state_chain: String(bs?.state_chain ?? ''),
5292
- };
5293
- if (allDevices.length > 0) {
5294
- this._v2BootstrapCache.set(cacheKey, {
5295
- devices: allDevices,
5296
- auditRecipients: auditRecipientsRaw,
5297
- cachedAt: Date.now(),
5298
- epoch,
5299
- stateCommitment: stateCommitment,
5300
- wrapPolicy,
5301
- });
5302
- }
5303
- // lazy sync 触发:发现 pending members 时异步发起提案
5304
- const pendingAdds = Array.isArray(bs?.pending_adds) ? bs.pending_adds : [];
5305
- if (pendingAdds.length > 0 && this._v2Session) {
5306
- this._v2MaybeTriggerAutoPropose(groupId);
5307
- }
5308
- }
5309
- if (allDevices.length === 0) {
5310
- throw new E2EEError(`V2 group bootstrap: no devices for ${groupId}`);
5311
- }
5312
- const targets = [];
5313
- for (const dev of allDevices) {
5314
- const devAid = String(dev.aid ?? '');
5315
- const devId = getV2DeviceId(dev);
5316
- if (devAid === this._aid && devId.present && devId.value === this._deviceId)
5317
- continue;
5318
- const role = devAid === this._aid ? 'self_sync' : 'member';
5319
- const target = await this._v2BuildTargetFromDevice({
5320
- dev,
5321
- aid: devAid,
5322
- deviceId: devId.value,
5323
- role,
5324
- defaultKeySource: 'peer_device_prekey',
5325
- });
5326
- if (target)
5327
- targets.push(target);
5328
- }
5329
- for (const dev of auditRecipientsRaw) {
5330
- const target = await this._v2BuildTargetFromDevice({
5331
- dev,
5332
- aid: String(dev.aid ?? ''),
5333
- deviceId: String(dev.device_id ?? ''),
5334
- role: 'audit',
5335
- defaultKeySource: 'peer_device_prekey',
5336
- });
5337
- if (target)
5338
- targets.push(target);
5339
- }
5340
- if (targets.length === 0) {
5341
- throw new E2EEError(`V2 group: no target devices for ${groupId}`);
5342
- }
5343
- const sender = await session.getSenderIdentity();
5344
- const sendTargets = applyV2WrapPolicyToTargets(targets, wrapPolicy);
5345
- const envelope = await encryptGroupMessage(sender, groupId, epoch, sendTargets, opts.payload, { messageId: opts.messageId, timestamp: opts.timestamp, protectedHeaders: opts.protectedHeaders, context: opts.context }, stateCommitment);
5346
- return envelope;
5347
- }
5348
- async _publishV2GroupSecurityLevel(groupId, bootstrap) {
5349
- const level = String(bootstrap.e2ee_security_level ?? '').trim() || 'end_to_end';
5350
- const previous = this._v2GroupSecurityLevels.get(groupId);
5351
- if (previous === level)
5352
- return;
5353
- this._v2GroupSecurityLevels.set(groupId, level);
5354
- await this._dispatcher.publish('group.v2.security_level', {
5355
- group_id: groupId,
5356
- level,
5357
- warning: String(bootstrap.e2ee_security_warning ?? ''),
5358
- previous_level: previous ?? null,
5359
- });
2726
+ return await this._v2E2EE.buildV2GroupEnvelope(opts);
5360
2727
  }
5361
2728
  /**
5362
2729
  * V2 Group thought.put:多设备 wrap envelope。
5363
2730
  * 与 Python `_put_group_thought_encrypted_v2` 对齐。
5364
2731
  */
5365
2732
  async _putGroupThoughtEncryptedV2(params) {
5366
- const groupId = String(params.group_id ?? '').trim();
5367
- const payload = isJsonObject(params.payload) ? params.payload : null;
5368
- if (!groupId) {
5369
- throw new ValidationError('group.thought.put requires group_id');
5370
- }
5371
- if (payload === null) {
5372
- throw new ValidationError('group.thought.put payload must be an object when encrypt=true');
5373
- }
5374
- const thoughtId = String(params.thought_id ?? '') || `gt-${_uuidV4()}`;
5375
- const timestamp = Number(params.timestamp ?? Date.now());
5376
- const attempt = async (useCache) => {
5377
- const envelopeContext = (params.context && typeof params.context === 'object' && !Array.isArray(params.context))
5378
- ? params.context : undefined;
5379
- const envelope = await this._buildV2GroupEnvelope({
5380
- groupId,
5381
- payload,
5382
- messageId: thoughtId,
5383
- timestamp,
5384
- useCache,
5385
- context: envelopeContext,
5386
- });
5387
- const sendParams = {
5388
- group_id: groupId,
5389
- payload: envelope,
5390
- encrypted: true,
5391
- thought_id: thoughtId,
5392
- timestamp,
5393
- };
5394
- if ('context' in params)
5395
- sendParams.context = params.context;
5396
- await this._signClientOperation('group.thought.put', sendParams);
5397
- return this._transport.call('group.thought.put', sendParams);
5398
- };
5399
- try {
5400
- return await attempt(true);
5401
- }
5402
- catch (exc) {
5403
- const excCode = exc?.code;
5404
- if (AUNClient.V2_RETRYABLE_CODES.has(excCode)) {
5405
- this._clientLog.debug(`V2 group thought put speculative rejected (code=${excCode}), refreshing bootstrap`);
5406
- this._v2BootstrapCache.delete(`group:${groupId}`);
5407
- return attempt(false);
5408
- }
5409
- throw exc;
5410
- }
2733
+ return await this._v2E2EE.putGroupThoughtEncryptedV2(params);
5411
2734
  }
5412
2735
  /**
5413
2736
  * 解密一个 V2 thought envelope(P2P 或 Group),返回 payload dict。
@@ -5415,656 +2738,60 @@ export class AUNClient {
5415
2738
  * 与 Python `_decrypt_v2_envelope_for_thought` 对齐。
5416
2739
  */
5417
2740
  async _decryptV2EnvelopeForThought(opts) {
5418
- const session = this._v2Session;
5419
- if (!session || !opts.envelope)
5420
- return null;
5421
- // 找到本设备所引用的 spk_id 和 key_source:根据 envelope.recipients 中 [aid, device_id, ..., spk_id]
5422
- let spkId = '';
5423
- let recipientKeySource = '';
5424
- const recipients = opts.envelope.recipients;
5425
- if (Array.isArray(recipients)) {
5426
- for (const row of recipients) {
5427
- if (Array.isArray(row) && row.length >= 6) {
5428
- if (row[0] === this._aid && (row[1] === this._deviceId || row[1] === '')) {
5429
- spkId = String(row[5] ?? '');
5430
- recipientKeySource = row.length > 3 ? String(row[3] ?? '') : '';
5431
- break;
5432
- }
5433
- }
5434
- }
5435
- }
5436
- const aad = opts.envelope.aad ?? {};
5437
- const groupIdForKeys = String(aad.group_id ?? opts.envelope.group_id ?? '').trim();
5438
- let ikPriv;
5439
- let spkPriv;
5440
- // group_id 只表示群上下文;group lookup 内部按 group SPK -> P2P device SPK -> IK fallback。
5441
- try {
5442
- if (groupIdForKeys) {
5443
- const keys = await session.getGroupDecryptKeys(groupIdForKeys, spkId);
5444
- ikPriv = keys.ikPriv;
5445
- spkPriv = keys.spkPriv;
5446
- }
5447
- else {
5448
- const keys = await session.getDecryptKeys(spkId);
5449
- ikPriv = keys.ikPriv;
5450
- spkPriv = keys.spkPriv;
5451
- }
5452
- }
5453
- catch (exc) {
5454
- this._clientLog.warn(`V2 thought decrypt: SPK lookup failed from=${opts.fromAid}, group=${groupIdForKeys || '<p2p>'}, spk_id=${spkId || '<empty>'}: ${exc}`);
5455
- return null;
5456
- }
5457
- const fromAid = String(opts.fromAid || aad.from || '').trim();
5458
- const senderDeviceId = String(aad.from_device ?? '');
5459
- const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId);
5460
- if (!senderPubDer) {
5461
- this._clientLog.warn(`V2 thought decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
5462
- this._scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupIdForKeys);
5463
- return null;
5464
- }
5465
- try {
5466
- const plaintext = await decryptMessage(opts.envelope, this._aid ?? '', this._deviceId, ikPriv, spkPriv, senderPubDer);
5467
- // 消费触发 SPK 轮换(与 _decryptV2Message 对齐)
5468
- if (plaintext != null) {
5469
- if (groupIdForKeys && recipientKeySource === 'group_device_prekey' && session.isLastUploadedGroupSPK(groupIdForKeys, spkId)) {
5470
- const callFn = async (method, params) => this.call(method, params);
5471
- session.rotateGroupSPK(groupIdForKeys, callFn).catch(exc => {
5472
- this._clientLog.debug(`V2 thought group SPK rotation failed (non-fatal): group=${groupIdForKeys} err=${exc}`);
5473
- });
5474
- }
5475
- else if (groupIdForKeys && recipientKeySource === 'peer_device_prekey') {
5476
- const callFn = async (method, params) => this.call(method, params);
5477
- session.ensureGroupRegistered(groupIdForKeys, callFn).catch(exc => {
5478
- this._clientLog.debug(`V2 thought group SPK registration after peer fallback failed (non-fatal): group=${groupIdForKeys} err=${exc}`);
5479
- });
5480
- }
5481
- }
5482
- return plaintext;
5483
- }
5484
- catch (exc) {
5485
- this._clientLog.warn(`V2 thought decrypt failed from=${fromAid}: ${String(exc)}`);
5486
- return null;
5487
- }
2741
+ return await this._v2E2EE.decryptV2EnvelopeForThought(opts);
5488
2742
  }
5489
2743
  // ── V2 State 验签 / Fork 检测 / Auto-propose(Phase 3b.3 + 3b.4)──────
2744
+ async _publishV2GroupSecurityLevel(groupId, bootstrap) {
2745
+ return this._groupState.publishV2GroupSecurityLevel(groupId, bootstrap);
2746
+ }
5490
2747
  /**
5491
2748
  * 验证 owner/admin 对 state 的 ECDSA 签名(防服务端篡改 bootstrap 字段)。
5492
2749
  * 与 Python SDK 对齐:证书获取或验签失败时拒绝信任 bootstrap。
5493
2750
  */
5494
2751
  async _v2VerifyStateSignature(groupId, bootstrap) {
5495
- if (!bootstrap)
5496
- return;
5497
- const stateSignature = String(bootstrap.state_signature ?? '');
5498
- const actorAid = String(bootstrap.state_actor_aid ?? '');
5499
- const stateHashSigned = String(bootstrap.state_hash_signed ?? '');
5500
- const membershipSnapshot = String(bootstrap.state_membership_snapshot ?? '');
5501
- const stateVersion = Number(bootstrap.state_version ?? 0) || 0;
5502
- if (stateVersion === 0 || !stateSignature || !actorAid)
5503
- return;
5504
- try {
5505
- // 重构签名 payload(与服务端 propose_state 中的格式一致)
5506
- const signPayloadObj = {
5507
- group_id: groupId,
5508
- membership_snapshot: membershipSnapshot,
5509
- state_hash: stateHashSigned,
5510
- state_version: stateVersion,
5511
- };
5512
- const signPayload = stableStringify(signPayloadObj);
5513
- const signPayloadBytes = new TextEncoder().encode(signPayload);
5514
- const sigBytes = base64ToUint8(stateSignature);
5515
- // 验签缓存检查
5516
- const cacheData = _v2LengthPrefixedBytes(new TextEncoder().encode(actorAid), signPayloadBytes, sigBytes);
5517
- const cacheHashBuf = await crypto.subtle.digest('SHA-256', cacheData.slice().buffer);
5518
- const cacheHashArr = new Uint8Array(cacheHashBuf);
5519
- let cacheKey = '';
5520
- for (let i = 0; i < cacheHashArr.length; i++)
5521
- cacheKey += cacheHashArr[i].toString(16).padStart(2, '0');
5522
- const now = Date.now();
5523
- const cachedExp = this._v2SigCache.get(cacheKey);
5524
- if (cachedExp !== undefined && cachedExp > now) {
5525
- this._clientLog.debug(`V2 state signature cache hit: group=${groupId} sv=${stateVersion}`);
5526
- }
5527
- else {
5528
- // 获取 actor 证书(通过 HTTP PKI 端点,不走 ca.get_cert RPC)
5529
- const certPem = await this._fetchPeerCert(actorAid);
5530
- if (!certPem) {
5531
- this._clientLog.warn(`V2 state verify: no cert for actor=${actorAid}, group=${groupId}`);
5532
- throw new E2EEError(`V2 state verify: cannot fetch actor cert for ${actorAid}`);
5533
- }
5534
- const pubKey = await importCertPublicKeyEcdsa(certPem);
5535
- const ok = await ecdsaVerifyDer(pubKey, sigBytes, signPayloadBytes);
5536
- if (!ok) {
5537
- this._clientLog.warn(`V2 state signature verification FAILED: group=${groupId} sv=${stateVersion} actor=${actorAid}`);
5538
- throw new E2EEError('V2 state signature verification failed');
5539
- }
5540
- // 写入缓存
5541
- this._v2SigCache.set(cacheKey, now + AUNClient._V2_SIG_CACHE_TTL);
5542
- if (this._v2SigCache.size > AUNClient._V2_SIG_CACHE_MAX) {
5543
- const stale = [];
5544
- for (const [k, exp] of this._v2SigCache) {
5545
- if (exp <= now)
5546
- stale.push(k);
5547
- }
5548
- for (const k of stale)
5549
- this._v2SigCache.delete(k);
5550
- if (this._v2SigCache.size > AUNClient._V2_SIG_CACHE_MAX) {
5551
- // 淘汰最旧的 1/4
5552
- const entries = [...this._v2SigCache.entries()].sort((a, b) => a[1] - b[1]);
5553
- const evictCount = Math.floor(AUNClient._V2_SIG_CACHE_MAX / 4);
5554
- for (let i = 0; i < evictCount && i < entries.length; i++) {
5555
- this._v2SigCache.delete(entries[i][0]);
5556
- }
5557
- }
5558
- }
5559
- this._clientLog.debug(`V2 state signature verified: group=${groupId} sv=${stateVersion} actor=${actorAid}`);
5560
- }
5561
- // 验证 member_aids 在签名快照中(best-effort)
5562
- try {
5563
- if (membershipSnapshot.startsWith('[')) {
5564
- const signedSnapshot = JSON.parse(membershipSnapshot);
5565
- const serverMembers = new Set(Array.isArray(bootstrap.member_aids) ? bootstrap.member_aids : []);
5566
- const signedMembers = new Set(signedSnapshot);
5567
- const extra = [];
5568
- for (const m of serverMembers) {
5569
- if (!signedMembers.has(m))
5570
- extra.push(m);
5571
- }
5572
- if (extra.length > 0) {
5573
- let mode = '';
5574
- try {
5575
- const reqResp = await this.call('group.get_join_requirements', { group_id: groupId });
5576
- mode = isJsonObject(reqResp)
5577
- ? String(reqResp.mode ?? '').trim()
5578
- : '';
5579
- }
5580
- catch {
5581
- mode = '';
5582
- }
5583
- if (mode !== 'open' && mode !== 'invite_code' && mode !== 'invite_only') {
5584
- this._clientLog.warn(`V2 state tamper detected: group=${groupId} pending_extra=${extra.sort().join(',')} mode=${mode}`);
5585
- await this._dispatcher.publish('group.v2.state_tampered', {
5586
- group_id: groupId,
5587
- pending_extra: extra.sort(),
5588
- mode,
5589
- });
5590
- }
5591
- }
5592
- }
5593
- }
5594
- catch {
5595
- // snapshot 解析失败不阻断
5596
- }
5597
- }
5598
- catch (exc) {
5599
- if (exc instanceof E2EEError)
5600
- throw exc;
5601
- this._clientLog.warn(`V2 state signature verification failed: group=${groupId} err=${formatCaughtError(exc)}`);
5602
- throw new E2EEError(`V2 state signature verification failed: ${String(formatCaughtError(exc))}`);
5603
- }
2752
+ return this._groupState.verifyStateSignature(groupId, bootstrap);
5604
2753
  }
5605
2754
  /**
5606
2755
  * 分叉检测:比对服务端 state_chain 与本地存储。
5607
2756
  */
5608
2757
  async _v2CheckFork(groupId, serverChain) {
5609
- if (!serverChain)
5610
- return;
5611
- try {
5612
- const local = this._v2StateChains.get(groupId);
5613
- if (local === undefined) {
5614
- this._v2StateChains.set(groupId, [0, serverChain]);
5615
- return;
5616
- }
5617
- const [localSv, localChain] = local;
5618
- if (localChain === serverChain)
5619
- return;
5620
- // 不一致:尝试通过 get_state 判断是正常推进还是分叉
5621
- try {
5622
- const stateResp = await this.call('group.get_state', { group_id: groupId });
5623
- if (stateResp) {
5624
- const serverSv = Number(stateResp.state_version ?? 0);
5625
- if (serverSv > localSv) {
5626
- // 正常推进
5627
- this._v2StateChains.set(groupId, [serverSv, serverChain]);
5628
- return;
5629
- }
5630
- if (serverSv < localSv) {
5631
- this._clientLog.warn(`V2 state chain rollback detected: group=${groupId} server_sv=${serverSv} local_sv=${localSv}`);
5632
- }
5633
- }
5634
- }
5635
- catch {
5636
- // get_state 失败不阻断
5637
- }
5638
- // 告警:分叉
5639
- this._clientLog.warn(`V2 state chain fork detected: group=${groupId} local_chain=${localChain.slice(0, 16)}... server_chain=${serverChain.slice(0, 16)}...`);
5640
- this._dispatcher.publish('group.v2.fork_detected', {
5641
- group_id: groupId,
5642
- local_chain: localChain,
5643
- server_chain: serverChain,
5644
- });
5645
- }
5646
- catch (exc) {
5647
- this._clientLog.debug(`V2 fork check failed (non-fatal): ${exc}`);
5648
- }
2758
+ return this._groupState.checkFork(groupId, serverChain);
5649
2759
  }
5650
2760
  _v2MaybeTriggerAutoPropose(groupId) {
5651
- const now = Date.now();
5652
- const last = this._v2LazyProposeTriggered.get(groupId) ?? 0;
5653
- if (now - last < 10000)
5654
- return;
5655
- this._v2LazyProposeTriggered.set(groupId, now);
5656
- this._safeAsync(this._v2AutoProposeState(groupId, { leaderDelay: true }));
2761
+ this._groupState.maybeTriggerAutoPropose(groupId);
5657
2762
  }
5658
2763
  /**
5659
2764
  * 成员变更后自动 propose state(仅 owner/admin 执行)。
5660
2765
  */
5661
2766
  async _v2AutoProposeState(groupId, options) {
5662
- const normalizedGroupId = normalizeGroupId(groupId) || String(groupId ?? '').trim();
5663
- if (!normalizedGroupId)
5664
- return;
5665
- if (options?.leaderDelay) {
5666
- const shouldContinue = await this._v2AutoProposeLeaderDelay(normalizedGroupId);
5667
- if (!shouldContinue)
5668
- return;
5669
- }
5670
- const inflight = this._v2AutoProposeInflight.get(normalizedGroupId);
5671
- if (inflight) {
5672
- this._v2AutoProposePending.add(normalizedGroupId);
5673
- await inflight;
5674
- return;
5675
- }
5676
- let resolveTask;
5677
- let rejectTask;
5678
- const task = new Promise((resolve, reject) => {
5679
- resolveTask = resolve;
5680
- rejectTask = reject;
5681
- });
5682
- this._v2AutoProposeInflight.set(normalizedGroupId, task);
5683
- void (async () => {
5684
- try {
5685
- do {
5686
- this._v2AutoProposePending.delete(normalizedGroupId);
5687
- await this._doV2AutoProposeState(normalizedGroupId);
5688
- } while (this._v2AutoProposePending.delete(normalizedGroupId));
5689
- resolveTask();
5690
- }
5691
- catch (exc) {
5692
- rejectTask(exc);
5693
- }
5694
- finally {
5695
- if (this._v2AutoProposeInflight.get(normalizedGroupId) === task) {
5696
- this._v2AutoProposeInflight.delete(normalizedGroupId);
5697
- }
5698
- this._v2AutoProposePending.delete(normalizedGroupId);
5699
- }
5700
- })();
5701
- try {
5702
- await task;
5703
- }
5704
- finally {
5705
- if (this._v2AutoProposeInflight.get(normalizedGroupId) === task) {
5706
- this._v2AutoProposeInflight.delete(normalizedGroupId);
5707
- }
5708
- this._v2AutoProposePending.delete(normalizedGroupId);
5709
- }
2767
+ return this._groupState.autoProposeState(groupId, options);
5710
2768
  }
5711
2769
  async _v2LeaderDelayMs(input) {
5712
- const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)));
5713
- const first32 = new DataView(digest.buffer, digest.byteOffset, digest.byteLength)
5714
- .getUint32(0, false);
5715
- return 2000 + (first32 % 4000);
2770
+ return this._groupState.leaderDelayMs(input);
5716
2771
  }
5717
2772
  async _v2AutoProposeLeaderDelay(groupId) {
5718
- try {
5719
- const membersResp = await this.call('group.get_online_members', { group_id: groupId });
5720
- const members = (Array.isArray(membersResp?.members) ? membersResp.members
5721
- : Array.isArray(membersResp?.items) ? membersResp.items
5722
- : Array.isArray(membersResp?.online_members) ? membersResp.online_members : []);
5723
- const myAid = this._aid ?? '';
5724
- let myRole = '';
5725
- const onlineAdminAids = new Set();
5726
- for (const member of members) {
5727
- const aid = String(member.aid ?? '').trim();
5728
- const role = String(member.role ?? '').trim();
5729
- if (!aid)
5730
- continue;
5731
- if ('online' in member && !Boolean(member.online))
5732
- continue;
5733
- if (role === 'owner' || role === 'admin')
5734
- onlineAdminAids.add(aid);
5735
- if (aid === myAid)
5736
- myRole = role;
5737
- }
5738
- if (myRole !== 'owner' && myRole !== 'admin')
5739
- return false;
5740
- const bootstrapResp = await this.call('group.v2.bootstrap', {
5741
- group_id: groupId,
5742
- e2ee_wrap_capabilities: v2WrapCapabilities(),
5743
- });
5744
- const devices = (Array.isArray(bootstrapResp?.devices) ? bootstrapResp.devices : []);
5745
- const candidates = [];
5746
- for (const dev of devices) {
5747
- const aid = String(dev.aid ?? '').trim();
5748
- const hasDeviceId = 'device_id' in dev;
5749
- const deviceId = String(dev.device_id ?? '').trim();
5750
- if (aid && hasDeviceId && onlineAdminAids.has(aid)) {
5751
- candidates.push(`${aid}\x1f${deviceId}`);
5752
- }
5753
- }
5754
- if (candidates.length === 0) {
5755
- for (const aid of [...onlineAdminAids].sort())
5756
- candidates.push(`${aid}\x1f`);
5757
- }
5758
- const myKey = `${myAid}\x1f${this._deviceId ?? ''}`;
5759
- if (!candidates.includes(myKey))
5760
- candidates.push(myKey);
5761
- const leader = [...new Set(candidates)].sort()[0];
5762
- if (leader === myKey) {
5763
- this._clientLog.debug(`V2 auto propose leader elected: group=${groupId} leader=${leader}`);
5764
- return true;
5765
- }
5766
- const delayMs = await this._v2LeaderDelayMs(_v2LengthPrefixedTextKey(groupId, myKey));
5767
- this._clientLog.debug(`V2 auto propose non-leader delay: group=${groupId} leader=${leader} self=${myKey} delay_ms=${delayMs}`);
5768
- await this._sleep(delayMs);
5769
- return true;
5770
- }
5771
- catch (exc) {
5772
- this._clientLog.debug(`V2 auto propose leader check failed, fallback immediate: group=${groupId} err=${formatCaughtError(exc)}`);
5773
- return true;
5774
- }
5775
- }
5776
- async _v2VerifyCommittedStateBase(groupId, stateResp) {
5777
- const currentSv = Number(stateResp.state_version ?? 0) || 0;
5778
- if (currentSv <= 0)
5779
- return true;
5780
- const currentSh = String(stateResp.state_hash ?? '').trim();
5781
- const membershipSnapshot = String(stateResp.membership_snapshot ?? '').trim();
5782
- if (!currentSh || !membershipSnapshot) {
5783
- this._clientLog.warn(`V2 committed state base incomplete: group=${groupId} sv=${currentSv}`);
5784
- return false;
5785
- }
5786
- try {
5787
- const parsed = JSON.parse(membershipSnapshot);
5788
- if (!isJsonObject(parsed)) {
5789
- this._clientLog.warn(`V2 committed state base snapshot is not object: group=${groupId} sv=${currentSv}`);
5790
- return false;
5791
- }
5792
- const computed = await computeStateCommitment(groupId, currentSv, parsed);
5793
- if (computed !== currentSh) {
5794
- this._clientLog.warn(`V2 committed state base hash mismatch: group=${groupId} sv=${currentSv}`);
5795
- return false;
5796
- }
5797
- return true;
5798
- }
5799
- catch (exc) {
5800
- this._clientLog.warn(`V2 committed state base verification failed: group=${groupId} sv=${currentSv} err=${formatCaughtError(exc)}`);
5801
- return false;
5802
- }
2773
+ return this._groupState.autoProposeLeaderDelay(groupId);
5803
2774
  }
5804
2775
  async _doV2AutoProposeState(groupId) {
5805
- try {
5806
- // 获取当前成员列表 + 角色
5807
- const membersResp = await this.call('group.get_members', { group_id: groupId });
5808
- const members = (Array.isArray(membersResp?.members) ? membersResp.members
5809
- : Array.isArray(membersResp?.items) ? membersResp.items : []);
5810
- const myAid = this._aid ?? '';
5811
- let myRole = '';
5812
- const memberAids = [];
5813
- const adminAids = [];
5814
- for (const m of members) {
5815
- const aid = String(m.aid ?? '').trim();
5816
- const role = String(m.role ?? '').trim();
5817
- if (aid) {
5818
- memberAids.push(aid);
5819
- if (role === 'owner' || role === 'admin')
5820
- adminAids.push(aid);
5821
- }
5822
- if (aid === myAid)
5823
- myRole = role;
5824
- }
5825
- if (myRole !== 'owner' && myRole !== 'admin')
5826
- return;
5827
- // 前置检查:如果已有 pending proposal,先尝试 confirm 而非重复 propose
5828
- const proposalResp = await this.call('group.v2.get_proposal', { group_id: groupId });
5829
- if (proposalResp && typeof proposalResp === 'object') {
5830
- const pendingProposal = proposalResp.proposal;
5831
- if (pendingProposal && typeof pendingProposal === 'object' && String(pendingProposal.proposal_id ?? '').trim()) {
5832
- const confirmed = await this._v2ConfirmPendingProposal(groupId);
5833
- if (confirmed)
5834
- return;
5835
- const autoConfirmAt = Number(pendingProposal.auto_confirm_at ?? 0) || 0;
5836
- const nowMs = Date.now();
5837
- if (autoConfirmAt > nowMs) {
5838
- const waitMs = Math.min(autoConfirmAt - nowMs + 500, 35000);
5839
- this._clientLog.debug(`V2 auto propose: pending proposal exists, waiting ${waitMs}ms group=${groupId}`);
5840
- await new Promise((r) => setTimeout(r, waitMs));
5841
- }
5842
- }
5843
- }
5844
- // 获取群所有成员的设备列表(V2 bootstrap)
5845
- const bootstrapResp = await this.call('group.v2.bootstrap', {
5846
- group_id: groupId,
5847
- e2ee_wrap_capabilities: v2WrapCapabilities(),
5848
- });
5849
- const allDevices = (Array.isArray(bootstrapResp?.devices) ? bootstrapResp.devices : []);
5850
- const auditRecipients = (Array.isArray(bootstrapResp?.audit_recipients) ? bootstrapResp.audit_recipients : []);
5851
- const auditAidsList = [...new Set(auditRecipients.map(r => String(r.aid ?? '').trim()).filter(Boolean))].sort();
5852
- // 按 aid 分组设备
5853
- const membersWithDevices = {};
5854
- for (const aid of memberAids)
5855
- membersWithDevices[aid] = [];
5856
- for (const dev of allDevices) {
5857
- const devAid = String(dev.aid ?? '').trim();
5858
- if (devAid in membersWithDevices) {
5859
- membersWithDevices[devAid].push({
5860
- device_id: String(dev.device_id ?? ''),
5861
- ik_fp: String(dev.ik_fp ?? ''),
5862
- });
5863
- }
5864
- }
5865
- const membersPayload = Object.entries(membersWithDevices).map(([aid, devices]) => ({
5866
- aid,
5867
- devices,
5868
- }));
5869
- const statePayload = {
5870
- members: membersPayload,
5871
- audit_aids: auditAidsList,
5872
- admin_set: { admin_aids: adminAids.sort(), threshold: 1 },
5873
- join_policy_hash: null,
5874
- recovery_quorum: null,
5875
- history_policy: 'recent_7_days',
5876
- wrap_protocol: '3DH',
5877
- };
5878
- // 获取当前 state
5879
- const stateResp = await this.call('group.get_state', { group_id: groupId });
5880
- if (!stateResp)
5881
- return;
5882
- if (!isJsonObject(stateResp))
5883
- return;
5884
- if (!(await this._v2VerifyCommittedStateBase(groupId, stateResp)))
5885
- return;
5886
- const currentSv = Number(stateResp.state_version ?? 0);
5887
- const currentSh = String(stateResp.state_hash ?? '');
5888
- const keyEpoch = Number(stateResp.key_epoch ?? 0);
5889
- // 用完整 commitment 算 state_hash
5890
- const stateHash = await computeStateCommitment(groupId, currentSv + 1, statePayload);
5891
- // 签名 state proposal
5892
- const membershipSnapshot = stableStringify(statePayload);
5893
- const lastMembershipSnapshot = this._v2AutoProposeLastSnapshot.get(groupId);
5894
- if (lastMembershipSnapshot === membershipSnapshot) {
5895
- return;
5896
- }
5897
- const currentMembershipSnapshot = String(stateResp.membership_snapshot ?? '');
5898
- if (currentMembershipSnapshot && currentMembershipSnapshot === membershipSnapshot) {
5899
- this._v2AutoProposeLastSnapshot.set(groupId, membershipSnapshot);
5900
- return;
5901
- }
5902
- let signature = '';
5903
- const currentAid = this._currentAid;
5904
- if (currentAid?.privateKeyPem) {
5905
- try {
5906
- const signPayloadObj = {
5907
- group_id: groupId,
5908
- membership_snapshot: membershipSnapshot,
5909
- state_hash: stateHash,
5910
- state_version: currentSv + 1,
5911
- };
5912
- const signPayload = stableStringify(signPayloadObj);
5913
- const signPayloadBytes = new TextEncoder().encode(signPayload);
5914
- const privKey = await importPrivateKeyEcdsa(currentAid.privateKeyPem);
5915
- const sigBytes = await ecdsaSignDer(privKey, signPayloadBytes);
5916
- signature = uint8ToBase64(sigBytes);
5917
- }
5918
- catch (sigExc) {
5919
- this._clientLog.debug(`propose_state signature failed: ${sigExc}`);
5920
- }
5921
- }
5922
- const proposeResult = await this.call('group.v2.propose_state', {
5923
- group_id: groupId,
5924
- state_version: currentSv + 1,
5925
- key_epoch: keyEpoch,
5926
- state_hash: stateHash,
5927
- prev_state_hash: currentSh,
5928
- membership_snapshot: membershipSnapshot,
5929
- signature,
5930
- reason: 'membership_changed',
5931
- auto_confirm_seconds: 30,
5932
- });
5933
- this._clientLog.debug(`V2 auto propose_state: group=${groupId} sv=${currentSv + 1}`);
5934
- const proposalId = isJsonObject(proposeResult) ? String(proposeResult.proposal_id ?? '').trim() : '';
5935
- if (proposalId) {
5936
- try {
5937
- await this.call('group.v2.confirm_state', { proposal_id: proposalId });
5938
- this._v2AutoProposeLastSnapshot.set(groupId, membershipSnapshot);
5939
- this._clientLog.debug(`V2 auto confirm_state: group=${groupId} proposal=${proposalId}`);
5940
- }
5941
- catch (confirmExc) {
5942
- this._clientLog.debug(`V2 auto confirm_state failed (non-fatal): group=${groupId} err=${confirmExc}`);
5943
- }
5944
- }
5945
- }
5946
- catch (exc) {
5947
- this._clientLog.debug(`V2 auto propose_state failed (non-fatal): group=${groupId} err=${exc}`);
5948
- }
5949
- }
5950
- async _v2VerifyPendingProposalAgainstBase(groupId, proposal, stateResp) {
5951
- if (!(await this._v2VerifyCommittedStateBase(groupId, stateResp)))
5952
- return false;
5953
- const currentSv = Number(stateResp.state_version ?? 0) || 0;
5954
- const currentSh = String(stateResp.state_hash ?? '').trim();
5955
- const proposalSv = Number(proposal.state_version ?? 0) || 0;
5956
- const proposalHash = String(proposal.state_hash ?? '').trim();
5957
- const proposalPrev = String(proposal.prev_state_hash ?? '').trim();
5958
- const membershipSnapshot = String(proposal.membership_snapshot ?? '').trim();
5959
- if (proposalSv !== currentSv + 1 || proposalPrev !== currentSh || !proposalHash || !membershipSnapshot) {
5960
- this._clientLog.warn(`V2 pending proposal base mismatch: group=${groupId} current_sv=${currentSv} proposal_sv=${proposalSv}`);
5961
- return false;
5962
- }
5963
- try {
5964
- const parsed = JSON.parse(membershipSnapshot);
5965
- if (!isJsonObject(parsed))
5966
- return false;
5967
- const computed = await computeStateCommitment(groupId, proposalSv, parsed);
5968
- if (computed !== proposalHash) {
5969
- this._clientLog.warn(`V2 pending proposal hash mismatch: group=${groupId} proposal_sv=${proposalSv}`);
5970
- return false;
5971
- }
5972
- return true;
5973
- }
5974
- catch (exc) {
5975
- this._clientLog.warn(`V2 pending proposal verification failed: group=${groupId} err=${formatCaughtError(exc)}`);
5976
- return false;
5977
- }
2776
+ return this._groupState.doAutoProposeState(groupId);
5978
2777
  }
5979
2778
  async _v2ConfirmPendingProposal(groupId) {
5980
- const proposalResp = await this.call('group.v2.get_proposal', { group_id: groupId });
5981
- const proposal = isJsonObject(proposalResp?.proposal) ? proposalResp.proposal : null;
5982
- const proposalId = proposal ? String(proposal.proposal_id ?? '').trim() : '';
5983
- if (!proposal || !proposalId)
5984
- return false;
5985
- const stateResp = await this.call('group.get_state', { group_id: groupId });
5986
- if (!isJsonObject(stateResp))
5987
- return false;
5988
- const currentSv = Number(stateResp.state_version ?? 0) || 0;
5989
- const proposalSv = Number(proposal.state_version ?? 0) || 0;
5990
- if (proposalSv <= currentSv) {
5991
- this._clientLog.debug(`V2 pending proposal already settled: group=${groupId} current_sv=${currentSv} proposal_sv=${proposalSv}`);
5992
- return false;
5993
- }
5994
- if (!(await this._v2VerifyPendingProposalAgainstBase(groupId, proposal, stateResp)))
5995
- return false;
5996
- await this.call('group.v2.confirm_state', { proposal_id: proposalId });
5997
- this._clientLog.info(`V2 confirmed pending proposal: group=${groupId} proposal=${proposalId}`);
5998
- return true;
2779
+ return this._groupState.confirmPendingProposal(groupId);
5999
2780
  }
6000
2781
  /**
6001
2782
  * Owner 上线时自动检查并签名确认 pending state proposals。
6002
2783
  */
6003
2784
  async _v2AutoConfirmPendingProposals() {
6004
- try {
6005
- const myAid = this._aid ?? '';
6006
- if (!myAid)
6007
- return;
6008
- const groupsResp = await this.call('group.list_my', {});
6009
- const groups = (Array.isArray(groupsResp?.groups) ? groupsResp.groups
6010
- : Array.isArray(groupsResp?.items) ? groupsResp.items : []);
6011
- for (const g of groups) {
6012
- const gid = String(g.group_id ?? '').trim();
6013
- const myRole = String(g.role ?? g.my_role ?? '').trim();
6014
- if (!gid || (myRole !== 'owner' && myRole !== 'admin'))
6015
- continue;
6016
- try {
6017
- const confirmed = await this._v2ConfirmPendingProposal(gid);
6018
- if (!confirmed) {
6019
- await this._v2AutoProposeState(gid);
6020
- }
6021
- }
6022
- catch (exc) {
6023
- this._clientLog.debug(`V2 auto confirm/propose failed (non-fatal): group=${gid} err=${exc}`);
6024
- }
6025
- }
6026
- }
6027
- catch (exc) {
6028
- this._clientLog.debug(`V2 auto confirm pending proposals failed (non-fatal): ${exc}`);
6029
- }
2785
+ return this._groupState.autoConfirmPendingProposals();
6030
2786
  }
6031
2787
  async _onV2StateProposed(data) {
6032
- if (!isJsonObject(data) || !this._v2Session)
6033
- return;
6034
- const groupId = String(data.group_id ?? '').trim();
6035
- if (!groupId)
6036
- return;
6037
- await this._dispatcher.publish('group.v2.state_proposed', data);
6038
- try {
6039
- await this._v2ConfirmPendingProposal(groupId);
6040
- }
6041
- catch (exc) {
6042
- this._clientLog.debug(`V2 state_proposed handling failed (non-fatal): group=${groupId} err=${formatCaughtError(exc)}`);
6043
- }
2788
+ return this._groupState.onV2StateProposed(data);
6044
2789
  }
6045
2790
  async _onV2StateRetryNeeded(data) {
6046
- if (!isJsonObject(data) || !this._v2Session)
6047
- return;
6048
- const groupId = String(data.group_id ?? '').trim();
6049
- if (!groupId)
6050
- return;
6051
- await this._dispatcher.publish('group.v2.state_retry_needed', data);
6052
- try {
6053
- await this._v2AutoProposeState(groupId, { leaderDelay: true });
6054
- }
6055
- catch (exc) {
6056
- this._clientLog.debug(`V2 state_retry_needed handling failed (non-fatal): group=${groupId} err=${formatCaughtError(exc)}`);
6057
- }
2791
+ return this._groupState.onV2StateRetryNeeded(data);
6058
2792
  }
6059
2793
  async _onV2StateConfirmed(data) {
6060
- if (!isJsonObject(data))
6061
- return;
6062
- const groupId = String(data.group_id ?? '').trim();
6063
- if (groupId) {
6064
- this._v2BootstrapCache.delete(`group:${groupId}`);
6065
- this._v2AutoProposeLastSnapshot.delete(groupId);
6066
- }
6067
- await this._dispatcher.publish('group.v2.state_confirmed', data);
2794
+ return this._groupState.onV2StateConfirmed(data);
6068
2795
  }
6069
2796
  /**
6070
2797
  * 处理 V2 push 通知:自动 pull + decrypt + emit。
@@ -6072,116 +2799,13 @@ export class AUNClient {
6072
2799
  _v2PullInflight = false;
6073
2800
  _v2PullPending = false;
6074
2801
  async _onV2PushNotification(data) {
6075
- if (!this._v2Session)
6076
- return;
6077
- // 提取 push 通知中的元数据
6078
- const pushSeq = isJsonObject(data) ? Number(data.seq ?? 0) || 0 : 0;
6079
- const pushFrom = isJsonObject(data) ? String(data.from_aid ?? '') : '';
6080
- const pushMsgId = isJsonObject(data) ? String(data.message_id ?? '') : '';
6081
- const envelopeJson = isJsonObject(data) ? data.envelope_json : undefined;
6082
- const hasPayload = !!envelopeJson;
6083
- const ns = this._aid ? `p2p:${this._aid}` : '';
6084
- let contigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
6085
- this._clientLog.debug(`_onV2PushNotification: push_seq=${pushSeq || 'null'} push_from=${pushFrom} push_msg_id=${pushMsgId} has_payload=${hasPayload} contiguous_seq=${contigBefore}`);
6086
- // ── Push 修上界:只更新 maxSeenSeq,不动 contiguousSeq ──
6087
- if (pushSeq > 0 && ns) {
6088
- this._seqTracker.updateMaxSeen(ns, pushSeq);
6089
- if (contigBefore === pushSeq) {
6090
- this._clientLog.debug(`_onV2PushNotification: push seq=${pushSeq} already covered by contiguous_seq=${contigBefore}, ignore duplicate push`);
6091
- return;
6092
- }
6093
- contigBefore = this._repairPushContiguousBound(ns, pushSeq, hasPayload, '_raw.peer.v2.message_received');
6094
- }
6095
- // ── 带 payload 的 push:尝试就地解密 ──
6096
- if (hasPayload && pushSeq > 0 && ns) {
6097
- try {
6098
- const decrypted = await this._decryptV2Message(data);
6099
- if (decrypted) {
6100
- // 解密成功:把 pushSeq 加入 receivedSeqs,让 _tryAdvance 自然推进
6101
- const needPull = this._seqTracker.onMessageSeq(ns, pushSeq);
6102
- const published = await this._publishOrderedMessage('message.received', ns, pushSeq, decrypted);
6103
- const newContig = this._seqTracker.getContiguousSeq(ns);
6104
- if (newContig !== contigBefore) {
6105
- this._saveSeqTrackerState();
6106
- }
6107
- if (newContig > 0 && newContig !== contigBefore) {
6108
- const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
6109
- const ackSeq = maxSeen > 0 ? Math.min(newContig, maxSeen) : newContig;
6110
- this._callRawV2Rpc('message.v2.ack', { up_to_seq: ackSeq })
6111
- .catch(e => this._clientLog.debug(`V2 P2P push-ack failed: ${e}`));
6112
- }
6113
- this._clientLog.debug(`_onV2PushNotification: push 带 payload 解密成功, contiguous_seq=${contigBefore}->${newContig} push_seq=${pushSeq}`);
6114
- if (!needPull && (published || newContig >= pushSeq || pushSeq <= contigBefore)) {
6115
- return;
6116
- }
6117
- this._clientLog.debug(`_onV2PushNotification: payload push seq=${pushSeq} 因空洞挂起,继续 pull 补齐 after_seq=${newContig}`);
6118
- }
6119
- }
6120
- catch (exc) {
6121
- this._clientLog.debug(`_onV2PushNotification: push payload 解密失败, fallback to pull: ${exc}`);
6122
- }
6123
- }
6124
- // ── 不带 payload 或解密失败:触发 pull ──
6125
- // 关键:push 通知只表示"服务端有 seq=pushSeq 的新消息",
6126
- // 此时消息内容尚未到达本地,绝不能调用 onMessageSeq() 推进 contiguousSeq
6127
- // (那会让随后的 pull 用 after_seq=pushSeq,跳过这条消息本身导致拉空)。
6128
- // 正确做法:保持 contiguousSeq 不变,用它作为 pull 的 after_seq;
6129
- // pull 成功 + 解密成功后再由 pull 路径推进 contiguousSeq。
6130
- if (pushSeq > 0 && ns) {
6131
- // 纯通知:不更新 contiguousSeq,由 pull 结果驱动推进
6132
- this._clientLog.debug(`_onV2PushNotification: 纯通知 push_seq=${pushSeq} > contiguous_seq=${contigBefore}, 触发 pull(after_seq=${contigBefore})`);
6133
- }
6134
- // only one in flight + drain pending
6135
- if (this._v2PullInflight) {
6136
- this._v2PullPending = true;
6137
- return;
6138
- }
6139
- this._v2PullInflight = true;
6140
- const dedupKey = `p2p_pull:${ns}`;
6141
- this._gapFillDone.add(dedupKey);
6142
- try {
6143
- do {
6144
- this._v2PullPending = false;
6145
- await this._pullV2();
6146
- const newContig = ns ? this._seqTracker.getContiguousSeq(ns) : -1;
6147
- this._clientLog.debug(`_onV2PushNotification pull done: contiguous_seq=${contigBefore}->${newContig} (push_seq=${pushSeq || 'null'})`);
6148
- } while (this._v2PullPending);
6149
- }
6150
- catch (exc) {
6151
- const newContig = ns ? this._seqTracker.getContiguousSeq(ns) : -1;
6152
- this._clientLog.warn(`V2 push auto-pull failed: contiguous_seq=${contigBefore}->${newContig} err=${exc}`);
6153
- }
6154
- finally {
6155
- this._v2PullInflight = false;
6156
- this._gapFillDone.delete(dedupKey);
6157
- }
2802
+ return this._delivery.onV2PushNotification(data);
6158
2803
  }
6159
2804
  /**
6160
2805
  * 处理 V2 epoch 轮换事件:清除 bootstrap 缓存 + 触发 SPK rotation。
6161
2806
  */
6162
2807
  async _onV2EpochRotated(data) {
6163
- if (!data || typeof data !== 'object')
6164
- return;
6165
- const groupId = String(data.group_id ?? '').trim();
6166
- if (!groupId)
6167
- return;
6168
- const newEpoch = data.epoch ?? 0;
6169
- this._clientLog.debug(`_onV2EpochRotated: group=${groupId} epoch=${newEpoch}`);
6170
- // 清除 bootstrap 缓存
6171
- this._v2BootstrapCache.delete(`group:${groupId}`);
6172
- // 触发 SPK rotation
6173
- if (this._v2Session) {
6174
- try {
6175
- const callFn = async (method, params) => {
6176
- return this.call(method, params);
6177
- };
6178
- await this._v2Session.rotateSPK(callFn);
6179
- this._clientLog.info(`SPK rotated after epoch change: group=${groupId} epoch=${newEpoch}`);
6180
- }
6181
- catch (exc) {
6182
- this._clientLog.debug(`SPK rotation after epoch change failed (non-fatal): ${exc}`);
6183
- }
6184
- }
2808
+ return this._v2E2EE.handleV2EpochRotated(data);
6185
2809
  }
6186
2810
  /** 安全执行异步操作(不阻塞调用方,错误打 warning 便于排障) */
6187
2811
  _safeAsync(promise) {
@@ -6189,65 +2813,24 @@ export class AUNClient {
6189
2813
  this._clientLog.warn(`background task exception:${String(exc)}`);
6190
2814
  });
6191
2815
  }
2816
+ _explicitGroupCursorParams(params) {
2817
+ const value = params._group_cursor_params;
2818
+ if (!isJsonObject(value))
2819
+ return {};
2820
+ return { ...value };
2821
+ }
2822
+ _groupCursorTargetsCurrentInstance(params) {
2823
+ const deviceId = String(params.device_id ?? '').trim();
2824
+ const slotId = String(params.slot_id ?? '').trim();
2825
+ return (!deviceId || deviceId === (this._deviceId ?? ''))
2826
+ && (!slotId || slotId === (this._slotId ?? ''));
2827
+ }
6192
2828
  // ── Pull Gate(序列化同一 key 的并发 pull)──────────────────
6193
2829
  _pullGateKeyForCall(method, params) {
6194
- if (method === 'message.pull' || method === 'message.v2.pull') {
6195
- return this._aid ? `p2p:${this._aid}` : '';
6196
- }
6197
- if (method === 'group.pull' || method === 'group.v2.pull') {
6198
- const gid = String(params.group_id ?? '').trim();
6199
- return gid ? `group:${gid}` : '';
6200
- }
6201
- if (method === 'group.pull_events') {
6202
- const gid = String(params.group_id ?? '').trim();
6203
- return gid ? `group_event:${gid}` : '';
6204
- }
6205
- return '';
6206
- }
6207
- _tryAcquirePullGate(key) {
6208
- if (!key)
6209
- return 0;
6210
- const now = Date.now();
6211
- const gate = this._pullGates.get(key) ?? { inflight: false, startedAt: 0, token: 0 };
6212
- if (gate.inflight && now - gate.startedAt <= AUNClient._PULL_GATE_STALE_MS) {
6213
- return null;
6214
- }
6215
- if (gate.inflight) {
6216
- this._clientLog.warn(`pull in-flight stale reset: key=${key} age=${now - gate.startedAt}ms`);
6217
- }
6218
- gate.token += 1;
6219
- gate.inflight = true;
6220
- gate.startedAt = now;
6221
- this._pullGates.set(key, gate);
6222
- return gate.token;
6223
- }
6224
- _releasePullGate(key, token) {
6225
- if (!key || token == null)
6226
- return;
6227
- const gate = this._pullGates.get(key);
6228
- if (!gate || gate.token !== token)
6229
- return;
6230
- gate.inflight = false;
6231
- gate.startedAt = 0;
2830
+ return this._rpcPipeline.pullGateKeyForCall(method, params);
6232
2831
  }
6233
2832
  async _runPullSerialized(key, operation) {
6234
- let token = this._tryAcquirePullGate(key);
6235
- if (token === null) {
6236
- const deadline = Date.now() + AUNClient._PULL_GATE_STALE_MS + 100;
6237
- while (token === null && Date.now() <= deadline) {
6238
- await this._sleep(25);
6239
- token = this._tryAcquirePullGate(key);
6240
- }
6241
- if (token === null) {
6242
- throw new StateError(`pull already in-flight for ${key}`);
6243
- }
6244
- }
6245
- try {
6246
- return await operation();
6247
- }
6248
- finally {
6249
- this._releasePullGate(key, token);
6250
- }
2833
+ return await this._rpcPipeline.runPullSerialized(key, operation);
6251
2834
  }
6252
2835
  /** 可取消的 sleep */
6253
2836
  _sleep(ms) {
@@ -6256,14 +2839,4 @@ export class AUNClient {
6256
2839
  });
6257
2840
  }
6258
2841
  }
6259
- // ── 内部工具 ────────────────────────────────────────
6260
- /** 生成 UUID v4 */
6261
- function _uuidV4() {
6262
- const bytes = new Uint8Array(16);
6263
- crypto.getRandomValues(bytes);
6264
- bytes[6] = (bytes[6] & 0x0f) | 0x40;
6265
- bytes[8] = (bytes[8] & 0x3f) | 0x80;
6266
- const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
6267
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
6268
- }
6269
2842
  //# sourceMappingURL=client.js.map