@agentunion/fastaun-browser 0.4.5 → 0.4.6

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 (54) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/_packed_docs/CHANGELOG.md +28 -0
  3. package/_packed_docs/INDEX.md +2 -2
  4. package/_packed_docs/KITE_DOCS_GUIDE.md +1 -1
  5. package/_packed_docs/agent.md//350/277/234/347/250/213agent.md/347/274/223/345/255/230/344/270/216etag/351/200/217/344/274/240/346/226/271/346/241/210.md +73 -84
  6. package/_packed_docs/sdk/01-/345/277/253/351/200/237/345/274/200/345/247/213.md +15 -14
  7. package/_packed_docs/sdk/02-WebSocket/345/215/217/350/256/256.md +2 -2
  8. package/_packed_docs/sdk/03-/346/240/270/345/277/203/346/246/202/345/277/265.md +22 -5
  9. package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +42 -26
  10. package/_packed_docs/sdk/05-E2EE/345/212/240/345/257/206/351/200/232/344/277/241.md +1 -1
  11. package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +61 -35
  12. package/_packed_docs/sdk/08-/346/234/200/344/275/263/345/256/236/350/267/265.md +3 -3
  13. package/_packed_docs/sdk/09-message-rpc-manual.md +6 -6
  14. package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +6 -4
  15. package/_packed_docs/sdk/INDEX.md +2 -2
  16. package/_packed_docs/sdk/README.md +3 -3
  17. package/dist/agent-md.d.ts +111 -0
  18. package/dist/agent-md.d.ts.map +1 -0
  19. package/dist/agent-md.js +656 -0
  20. package/dist/agent-md.js.map +1 -0
  21. package/dist/aid-store.d.ts +7 -40
  22. package/dist/aid-store.d.ts.map +1 -1
  23. package/dist/aid-store.js +71 -171
  24. package/dist/aid-store.js.map +1 -1
  25. package/dist/auth.js +1 -1
  26. package/dist/auth.js.map +1 -1
  27. package/dist/bundle.js +4224 -4151
  28. package/dist/client.d.ts +3 -61
  29. package/dist/client.d.ts.map +1 -1
  30. package/dist/client.js +136 -703
  31. package/dist/client.js.map +1 -1
  32. package/dist/index.d.ts +3 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/keystore/index.d.ts +6 -2
  37. package/dist/keystore/index.d.ts.map +1 -1
  38. package/dist/keystore/indexeddb-identity-store.d.ts +59 -0
  39. package/dist/keystore/indexeddb-identity-store.d.ts.map +1 -0
  40. package/dist/keystore/indexeddb-identity-store.js +489 -0
  41. package/dist/keystore/indexeddb-identity-store.js.map +1 -0
  42. package/dist/keystore/indexeddb-shared.d.ts +76 -0
  43. package/dist/keystore/indexeddb-shared.d.ts.map +1 -0
  44. package/dist/keystore/indexeddb-shared.js +382 -0
  45. package/dist/keystore/indexeddb-shared.js.map +1 -0
  46. package/dist/keystore/indexeddb-token-store.d.ts +119 -0
  47. package/dist/keystore/indexeddb-token-store.d.ts.map +1 -0
  48. package/dist/keystore/indexeddb-token-store.js +1086 -0
  49. package/dist/keystore/indexeddb-token-store.js.map +1 -0
  50. package/dist/register-flow.d.ts +22 -3
  51. package/dist/register-flow.d.ts.map +1 -1
  52. package/dist/register-flow.js +49 -3
  53. package/dist/register-flow.js.map +1 -1
  54. package/package.json +1 -1
@@ -0,0 +1,1086 @@
1
+ // ── IndexedDB TokenStore 实现 ──────────────────────────────
2
+ // 不含私钥操作:证书、实例态、seq、prekeys、群组密钥、sessions、信任根、metadata。
3
+ // AUNClient / AuthFlow 持有此类型。
4
+ //
5
+ // 与 IndexedDBIdentityStore 共用同一 IndexedDB 数据库(aun-keystore),
6
+ // 但二者之间无任何代码依赖——共享基础设施集中在 indexeddb-shared.ts。
7
+ import { pemToArrayBuffer } from '../crypto.js';
8
+ import { _noopLog, STORE_CERTS, STORE_METADATA, STORE_INSTANCE_STATE, STORE_PREKEYS, STORE_GROUP_CURRENT, STORE_GROUP_OLD_EPOCHS, STORE_SESSIONS, STORE_GROUP_STATE, STORE_AGENT_MD_CACHE, STRUCTURED_RECOVERY_RETENTION_MS, safeAid, encodePart, deepClone, isRecord, sameJson, metadataStoreKey, normalizeCertFingerprint, fingerprintFromCertPem, certStoreKey, instanceStateStoreKey, prekeyPrefix, prekeyStoreKey, groupCurrentPrefix, groupCurrentStoreKey, groupOldPrefix, groupOldStoreKey, sessionPrefix, sessionStoreKey, seqTrackerPrefix, seqTrackerStoreKey, agentMdCachePrefix, agentMdCacheStoreKey, stripStructuredFields, normalizeAgentMdCacheRecord, mergeAgentMdCacheRecord, idbGet, idbGetAllByPrefix, idbPut, idbDelete, } from './indexeddb-shared.js';
9
+ /**
10
+ * 基于 IndexedDB 的 TokenStore 实现(不含私钥操作)。
11
+ *
12
+ * 设计语义:
13
+ * - metadata 只保存普通 metadata 字段;
14
+ * - e2ee_prekeys / group_secrets 只保存到结构化 store;
15
+ * - 若检测到旧版本把结构化数据写进了 metadata,会自动迁移到结构化 store。
16
+ */
17
+ export class IndexedDBTokenStore {
18
+ _log = _noopLog;
19
+ setLogger(log) { this._log = log; }
20
+ static _aidTails = new Map();
21
+ async _withAidLock(aid, fn) {
22
+ const key = safeAid(aid);
23
+ const previous = IndexedDBTokenStore._aidTails.get(key) ?? Promise.resolve();
24
+ let release;
25
+ const current = new Promise((resolve) => {
26
+ release = resolve;
27
+ });
28
+ const tail = previous.catch(() => undefined).then(() => current);
29
+ IndexedDBTokenStore._aidTails.set(key, tail);
30
+ await previous.catch(() => undefined);
31
+ try {
32
+ return await fn();
33
+ }
34
+ finally {
35
+ release();
36
+ if (IndexedDBTokenStore._aidTails.get(key) === tail) {
37
+ IndexedDBTokenStore._aidTails.delete(key);
38
+ }
39
+ }
40
+ }
41
+ // ── 证书 ──────────────────────────────────────────
42
+ async loadCert(aid, certFingerprint) {
43
+ const tStart = Date.now();
44
+ this._log.debug(`loadCert enter: aid=${aid} fingerprint=${certFingerprint ?? '<none>'}`);
45
+ try {
46
+ const normalized = normalizeCertFingerprint(certFingerprint);
47
+ if (normalized) {
48
+ const versioned = await idbGet(STORE_CERTS, certStoreKey(aid, normalized));
49
+ if (typeof versioned === 'string') {
50
+ this._log.debug(`loadCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} found=true source=versioned`);
51
+ return versioned;
52
+ }
53
+ const active = await idbGet(STORE_CERTS, certStoreKey(aid));
54
+ if (typeof active === 'string' && await fingerprintFromCertPem(active) === normalized) {
55
+ this._log.debug(`loadCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} found=true source=active`);
56
+ return active;
57
+ }
58
+ this._log.debug(`loadCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} found=false`);
59
+ return null;
60
+ }
61
+ const data = await idbGet(STORE_CERTS, certStoreKey(aid));
62
+ const found = typeof data === 'string';
63
+ this._log.debug(`loadCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} found=${found}`);
64
+ return found ? data : null;
65
+ }
66
+ catch (err) {
67
+ this._log.debug(`loadCert exit (error): elapsed=${Date.now() - tStart}ms aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
68
+ throw err;
69
+ }
70
+ }
71
+ async saveCert(aid, certPem, certFingerprint, opts) {
72
+ const tStart = Date.now();
73
+ this._log.debug(`saveCert enter: aid=${aid} fingerprint=${certFingerprint ?? '<none>'} make_active=${opts?.makeActive ?? false}`);
74
+ try {
75
+ const normalized = normalizeCertFingerprint(certFingerprint);
76
+ if (normalized) {
77
+ await idbPut(STORE_CERTS, certStoreKey(aid, normalized), certPem);
78
+ if (opts?.makeActive) {
79
+ await idbPut(STORE_CERTS, certStoreKey(aid), certPem);
80
+ }
81
+ this._log.debug(`saveCert exit: elapsed=${Date.now() - tStart}ms aid=${aid}`);
82
+ return;
83
+ }
84
+ await idbPut(STORE_CERTS, certStoreKey(aid), certPem);
85
+ this._log.debug(`saveCert exit: elapsed=${Date.now() - tStart}ms aid=${aid}`);
86
+ }
87
+ catch (err) {
88
+ this._log.debug(`saveCert exit (error): elapsed=${Date.now() - tStart}ms aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
89
+ throw err;
90
+ }
91
+ }
92
+ // ── 实例态 ──────────────────────────────────────────
93
+ async loadInstanceState(aid, deviceId, slotId = '') {
94
+ return this._withAidLock(aid, async () => {
95
+ const data = await idbGet(STORE_INSTANCE_STATE, instanceStateStoreKey(aid, deviceId, slotId));
96
+ return isRecord(data) ? deepClone(data) : null;
97
+ });
98
+ }
99
+ async saveInstanceState(aid, deviceId, slotId, state) {
100
+ await this._withAidLock(aid, async () => {
101
+ await idbPut(STORE_INSTANCE_STATE, instanceStateStoreKey(aid, deviceId, slotId), deepClone(state));
102
+ });
103
+ }
104
+ async updateInstanceState(aid, deviceId, slotId, updater) {
105
+ return this._withAidLock(aid, async () => {
106
+ const currentRaw = await idbGet(STORE_INSTANCE_STATE, instanceStateStoreKey(aid, deviceId, slotId));
107
+ const current = isRecord(currentRaw) ? deepClone(currentRaw) : {};
108
+ const working = deepClone(current);
109
+ const updated = updater(working) ?? working;
110
+ await idbPut(STORE_INSTANCE_STATE, instanceStateStoreKey(aid, deviceId, slotId), deepClone(updated));
111
+ return deepClone(updated);
112
+ });
113
+ }
114
+ // ── 结构化 prekeys ───────────────────────────────────
115
+ async loadE2EEPrekeys(aid, deviceId) {
116
+ return this._withAidLock(aid, async () => {
117
+ await this._migrateLegacyStructuredStateUnlocked(aid);
118
+ return this._loadPrekeysUnlocked(aid, String(deviceId ?? '').trim());
119
+ });
120
+ }
121
+ /**
122
+ * 按 prekey_id 单点查询本地 prekey(O(log N) IndexedDB primary-key 查找)。
123
+ *
124
+ * 解密入站消息时,envelope 里只带 prekey_id,没有 device_id。优先用 IndexedDB
125
+ * 主键直查;命中则 O(log N),未命中再回退到前缀扫描,保证跨 device_id 也能找到。
126
+ */
127
+ async loadE2EEPrekeyById(aid, prekeyId) {
128
+ return this._withAidLock(aid, async () => {
129
+ await this._migrateLegacyStructuredStateUnlocked(aid);
130
+ return this._loadPrekeyByIdUnlocked(aid, prekeyId);
131
+ });
132
+ }
133
+ async saveE2EEPrekey(aid, prekeyId, prekeyData, deviceId) {
134
+ await this._withAidLock(aid, async () => {
135
+ await this._migrateLegacyStructuredStateUnlocked(aid);
136
+ const record = deepClone(prekeyData);
137
+ record.prekey_id = prekeyId;
138
+ const normalizedDeviceId = String(deviceId ?? '').trim();
139
+ if (normalizedDeviceId) {
140
+ record.device_id = normalizedDeviceId;
141
+ }
142
+ else {
143
+ delete record.device_id;
144
+ }
145
+ const targetKey = prekeyStoreKey(aid, prekeyId, normalizedDeviceId);
146
+ for (const item of await idbGetAllByPrefix(STORE_PREKEYS, prekeyPrefix(aid))) {
147
+ if (!isRecord(item.value))
148
+ continue;
149
+ const existingPrekeyId = typeof item.value.prekey_id === 'string'
150
+ ? item.value.prekey_id
151
+ : '';
152
+ const existingDeviceId = String(item.value.device_id ?? '').trim();
153
+ if (existingPrekeyId === prekeyId && existingDeviceId === normalizedDeviceId && item.key !== targetKey) {
154
+ await idbDelete(STORE_PREKEYS, item.key);
155
+ }
156
+ }
157
+ await idbPut(STORE_PREKEYS, targetKey, record);
158
+ });
159
+ }
160
+ async cleanupE2EEPrekeys(aid, cutoffMs, keepLatest = 7, deviceId) {
161
+ return this._withAidLock(aid, async () => {
162
+ await this._migrateLegacyStructuredStateUnlocked(aid);
163
+ const normalizedDeviceId = String(deviceId ?? '').trim();
164
+ const prekeys = await this._loadPrekeysUnlocked(aid, normalizedDeviceId);
165
+ const retainedIds = new Set(Object.entries(prekeys)
166
+ .sort((left, right) => {
167
+ const leftMarker = Number(left[1].created_at ?? left[1].updated_at ?? left[1].expires_at ?? 0);
168
+ const rightMarker = Number(right[1].created_at ?? right[1].updated_at ?? right[1].expires_at ?? 0);
169
+ if (rightMarker !== leftMarker)
170
+ return rightMarker - leftMarker;
171
+ return right[0].localeCompare(left[0]);
172
+ })
173
+ .slice(0, keepLatest)
174
+ .map(([prekeyId]) => prekeyId));
175
+ const removed = Object.entries(prekeys)
176
+ .filter(([prekeyId, data]) => Number(data.created_at ?? data.updated_at ?? data.expires_at ?? 0) < cutoffMs
177
+ && !retainedIds.has(prekeyId))
178
+ .map(([prekeyId]) => prekeyId);
179
+ const removedIds = new Set(removed);
180
+ for (const item of await idbGetAllByPrefix(STORE_PREKEYS, prekeyPrefix(aid))) {
181
+ if (!isRecord(item.value))
182
+ continue;
183
+ const recordDeviceId = String(item.value.device_id ?? '').trim();
184
+ if (recordDeviceId !== normalizedDeviceId)
185
+ continue;
186
+ const prekeyId = typeof item.value.prekey_id === 'string' ? item.value.prekey_id : '';
187
+ if (removedIds.has(prekeyId))
188
+ await idbDelete(STORE_PREKEYS, item.key);
189
+ }
190
+ return removed;
191
+ });
192
+ }
193
+ // ── 结构化群组密钥 ─────────────────────────────────
194
+ async listGroupSecretIds(aid) {
195
+ return this._withAidLock(aid, async () => {
196
+ await this._migrateLegacyStructuredStateUnlocked(aid);
197
+ const ids = new Set();
198
+ for (const item of await idbGetAllByPrefix(STORE_GROUP_CURRENT, groupCurrentPrefix(aid))) {
199
+ const [, encodedGroupId] = item.key.split('|');
200
+ if (encodedGroupId)
201
+ ids.add(decodeURIComponent(encodedGroupId));
202
+ }
203
+ for (const item of await idbGetAllByPrefix(STORE_GROUP_OLD_EPOCHS, groupOldPrefix(aid))) {
204
+ const [, encodedGroupId] = item.key.split('|');
205
+ if (encodedGroupId)
206
+ ids.add(decodeURIComponent(encodedGroupId));
207
+ }
208
+ return [...ids].sort();
209
+ });
210
+ }
211
+ async cleanupGroupOldEpochsState(aid, groupId, cutoffMs) {
212
+ return this._withAidLock(aid, async () => {
213
+ await this._migrateLegacyStructuredStateUnlocked(aid);
214
+ let removed = 0;
215
+ for (const item of await idbGetAllByPrefix(STORE_GROUP_OLD_EPOCHS, groupOldPrefix(aid, groupId))) {
216
+ if (!isRecord(item.value))
217
+ continue;
218
+ if (Number(item.value.updated_at ?? 0) <= cutoffMs) {
219
+ await idbDelete(STORE_GROUP_OLD_EPOCHS, item.key);
220
+ removed += 1;
221
+ }
222
+ }
223
+ return removed;
224
+ });
225
+ }
226
+ async loadGroupSecretEpoch(aid, groupId, epoch) {
227
+ return this._withAidLock(aid, async () => {
228
+ await this._migrateLegacyStructuredStateUnlocked(aid);
229
+ const current = await idbGet(STORE_GROUP_CURRENT, groupCurrentStoreKey(aid, groupId));
230
+ if (isRecord(current)) {
231
+ const record = deepClone(current);
232
+ delete record.group_id;
233
+ if (epoch === undefined || epoch === null || Number(record.epoch ?? 0) === Number(epoch)) {
234
+ return record;
235
+ }
236
+ }
237
+ else if (epoch === undefined || epoch === null) {
238
+ return null;
239
+ }
240
+ const old = await idbGet(STORE_GROUP_OLD_EPOCHS, groupOldStoreKey(aid, groupId, Number(epoch)));
241
+ if (!isRecord(old))
242
+ return null;
243
+ const record = deepClone(old);
244
+ delete record.group_id;
245
+ return record;
246
+ });
247
+ }
248
+ async loadGroupSecretEpochs(aid, groupId) {
249
+ return this._withAidLock(aid, async () => {
250
+ await this._migrateLegacyStructuredStateUnlocked(aid);
251
+ const result = [];
252
+ const current = await idbGet(STORE_GROUP_CURRENT, groupCurrentStoreKey(aid, groupId));
253
+ if (isRecord(current)) {
254
+ const record = deepClone(current);
255
+ delete record.group_id;
256
+ result.push(record);
257
+ }
258
+ for (const item of await idbGetAllByPrefix(STORE_GROUP_OLD_EPOCHS, groupOldPrefix(aid, groupId))) {
259
+ if (!isRecord(item.value))
260
+ continue;
261
+ const record = deepClone(item.value);
262
+ delete record.group_id;
263
+ result.push(record);
264
+ }
265
+ return result;
266
+ });
267
+ }
268
+ async storeGroupSecretTransition(aid, groupId, opts) {
269
+ return this._withAidLock(aid, async () => {
270
+ await this._migrateLegacyStructuredStateUnlocked(aid);
271
+ return this._storeGroupSecretTransitionUnlocked(aid, groupId, opts);
272
+ });
273
+ }
274
+ async storeGroupSecretEpoch(aid, groupId, opts) {
275
+ return this._withAidLock(aid, async () => {
276
+ await this._migrateLegacyStructuredStateUnlocked(aid);
277
+ return this._storeGroupSecretEpochUnlocked(aid, groupId, opts);
278
+ });
279
+ }
280
+ async discardPendingGroupSecretState(aid, groupId, epoch, rotationId) {
281
+ return this._withAidLock(aid, async () => {
282
+ const rid = String(rotationId ?? '').trim();
283
+ if (!rid)
284
+ return false;
285
+ await this._migrateLegacyStructuredStateUnlocked(aid);
286
+ return this._discardPendingGroupSecretUnlocked(aid, groupId, epoch, rid);
287
+ });
288
+ }
289
+ async deleteGroupSecretState(aid, groupId) {
290
+ await this._withAidLock(aid, async () => {
291
+ await idbDelete(STORE_GROUP_CURRENT, groupCurrentStoreKey(aid, groupId));
292
+ for (const item of await idbGetAllByPrefix(STORE_GROUP_OLD_EPOCHS, groupOldPrefix(aid, groupId))) {
293
+ await idbDelete(STORE_GROUP_OLD_EPOCHS, item.key);
294
+ }
295
+ });
296
+ }
297
+ // ── E2EE Sessions ─────────────────────────────────────
298
+ async loadE2EESessions(aid) {
299
+ return this._withAidLock(aid, async () => {
300
+ await this._migrateLegacySessionsUnlocked(aid);
301
+ return this._loadSessionsUnlocked(aid);
302
+ });
303
+ }
304
+ async saveE2EESession(aid, sessionId, data) {
305
+ await this._withAidLock(aid, async () => {
306
+ await this._migrateLegacySessionsUnlocked(aid);
307
+ const record = deepClone(data);
308
+ record.session_id = sessionId;
309
+ await idbPut(STORE_SESSIONS, sessionStoreKey(aid, sessionId), record);
310
+ });
311
+ }
312
+ /**
313
+ * 读取单个 metadata KV(如 gateway_url 等跨进程缓存项)。
314
+ * 不存在时返回空字符串,与 Python keystore 的 get_metadata 语义保持一致。
315
+ */
316
+ async getMetadata(aid, key) {
317
+ if (!key)
318
+ return '';
319
+ return this._withAidLock(aid, async () => {
320
+ const md = (await this._loadMetadataOnlyUnlocked(aid)) ?? {};
321
+ const value = md[key];
322
+ if (typeof value === 'string')
323
+ return value;
324
+ if (value === null || value === undefined)
325
+ return '';
326
+ return String(value);
327
+ });
328
+ }
329
+ /**
330
+ * 写入单个 metadata KV。空字符串视为删除该字段。
331
+ * 与 _saveMetadataOnlyUnlocked 不同,本方法只更新指定 key,不影响其他字段。
332
+ */
333
+ async setMetadata(aid, key, value) {
334
+ if (!key)
335
+ return;
336
+ await this._withAidLock(aid, async () => {
337
+ const current = (await this._loadMetadataOnlyUnlocked(aid)) ?? {};
338
+ const next = deepClone(current);
339
+ if (value === '' || value === undefined || value === null) {
340
+ delete next[key];
341
+ }
342
+ else {
343
+ next[key] = value;
344
+ }
345
+ await this._saveMetadataOnlyUnlocked(aid, next);
346
+ });
347
+ }
348
+ // ── Seq Tracker ─────────────────────────────────────────
349
+ async saveSeq(aid, deviceId, slotId, namespace, contiguousSeq) {
350
+ const key = seqTrackerStoreKey(aid, deviceId, slotId, namespace);
351
+ await idbPut(STORE_INSTANCE_STATE, key, { contiguous_seq: contiguousSeq, updated_at: Date.now() });
352
+ }
353
+ async loadSeq(aid, deviceId, slotId, namespace) {
354
+ const key = seqTrackerStoreKey(aid, deviceId, slotId, namespace);
355
+ const data = await idbGet(STORE_INSTANCE_STATE, key);
356
+ return data?.contiguous_seq ?? 0;
357
+ }
358
+ async loadAllSeqs(aid, deviceId, slotId) {
359
+ const prefix = seqTrackerPrefix(aid, deviceId, slotId);
360
+ const items = await idbGetAllByPrefix(STORE_INSTANCE_STATE, prefix);
361
+ const result = {};
362
+ for (const item of items) {
363
+ if (!item.key.startsWith(prefix))
364
+ continue;
365
+ const ns = decodeURIComponent(item.key.slice(prefix.length));
366
+ if (ns && typeof item.value?.contiguous_seq === 'number') {
367
+ result[ns] = item.value.contiguous_seq;
368
+ }
369
+ }
370
+ return result;
371
+ }
372
+ async deleteSeq(aid, deviceId, slotId, namespace) {
373
+ const key = seqTrackerStoreKey(aid, deviceId, slotId, namespace);
374
+ await idbDelete(STORE_INSTANCE_STATE, key);
375
+ }
376
+ // ── agent.md Cache ───────────────────────────────────────
377
+ async loadAgentMdCache(ownerAid, targetAid) {
378
+ const owner = String(ownerAid ?? '').trim();
379
+ const target = String(targetAid ?? '').trim();
380
+ if (!owner || !target)
381
+ return null;
382
+ const data = await idbGet(STORE_AGENT_MD_CACHE, agentMdCacheStoreKey(owner, target));
383
+ const record = normalizeAgentMdCacheRecord(target, data);
384
+ return record ? deepClone(record) : null;
385
+ }
386
+ async upsertAgentMdCache(ownerAid, targetAid, fields) {
387
+ const owner = String(ownerAid ?? '').trim();
388
+ const target = String(targetAid ?? '').trim();
389
+ if (!owner || !target) {
390
+ throw new Error('upsertAgentMdCache requires ownerAid and targetAid');
391
+ }
392
+ const current = await this.loadAgentMdCache(owner, target);
393
+ const record = mergeAgentMdCacheRecord(target, current, fields ?? {});
394
+ await idbPut(STORE_AGENT_MD_CACHE, agentMdCacheStoreKey(owner, target), record);
395
+ return deepClone(record);
396
+ }
397
+ async listAgentMdContentAids(agentMdPath) {
398
+ const root = String(agentMdPath ?? '').trim();
399
+ if (!root)
400
+ return [];
401
+ const prefix = agentMdCachePrefix(root);
402
+ const suffix = '/agent.md';
403
+ const aids = new Set();
404
+ for (const item of await idbGetAllByPrefix(STORE_AGENT_MD_CACHE, prefix)) {
405
+ const encodedTarget = item.key.slice(prefix.length);
406
+ let target = '';
407
+ try {
408
+ target = decodeURIComponent(encodedTarget);
409
+ }
410
+ catch {
411
+ target = encodedTarget;
412
+ }
413
+ if (!target.endsWith(suffix))
414
+ continue;
415
+ const aid = target.slice(0, -suffix.length).trim();
416
+ if (aid)
417
+ aids.add(aid);
418
+ }
419
+ return [...aids].sort();
420
+ }
421
+ // ── Group State(群组状态快照) ─────────────────────────────
422
+ async saveGroupState(groupId, state) {
423
+ const key = encodePart(groupId);
424
+ await idbPut(STORE_GROUP_STATE, key, {
425
+ group_id: groupId,
426
+ state_version: state.state_version,
427
+ state_hash: state.state_hash,
428
+ key_epoch: state.key_epoch,
429
+ membership_json: state.membership_json,
430
+ policy_json: state.policy_json,
431
+ updated_at: state.updated_at ?? Date.now(),
432
+ });
433
+ }
434
+ async loadGroupState(groupId) {
435
+ const key = encodePart(groupId);
436
+ const data = await idbGet(STORE_GROUP_STATE, key);
437
+ if (!data || typeof data.state_version !== 'number')
438
+ return null;
439
+ return {
440
+ group_id: groupId,
441
+ state_version: data.state_version,
442
+ state_hash: data.state_hash ?? '',
443
+ key_epoch: data.key_epoch ?? 0,
444
+ membership_json: data.membership_json ?? '[]',
445
+ policy_json: data.policy_json ?? '{}',
446
+ updated_at: data.updated_at ?? 0,
447
+ };
448
+ }
449
+ // ── Trust Root Storage(信任根证书存储) ─────────────────
450
+ // 浏览器环境无文件系统,统一存入 STORE_INSTANCE_STATE 并以特定前缀的 key 区分。
451
+ // 对外返回的"路径"为虚拟标识(indexeddb://...),仅用于日志/兼容字段。
452
+ static _TRUST_LIST_KEY = '__trust_roots:list';
453
+ static _TRUST_BUNDLE_KEY = '__trust_roots:bundle';
454
+ static _TRUST_CERT_PREFIX = '__trust_roots:cert:';
455
+ static _TRUST_ISSUER_PREFIX = '__trust_roots:issuer:';
456
+ /** 计算 PEM 证书的 SHA-256 指纹(hex,无冒号) */
457
+ async _pemFingerprint(pem) {
458
+ try {
459
+ const der = new Uint8Array(pemToArrayBuffer(pem));
460
+ const hash = await crypto.subtle.digest('SHA-256', der);
461
+ return Array.from(new Uint8Array(hash))
462
+ .map(b => b.toString(16).padStart(2, '0'))
463
+ .join('');
464
+ }
465
+ catch {
466
+ const enc = new TextEncoder().encode(pem);
467
+ const hash = await crypto.subtle.digest('SHA-256', enc);
468
+ return Array.from(new Uint8Array(hash))
469
+ .map(b => b.toString(16).padStart(2, '0'))
470
+ .join('');
471
+ }
472
+ }
473
+ /** 拆分 bundle PEM 文本为单个 PEM 证书数组 */
474
+ _splitPemBundle(bundleText) {
475
+ return bundleText
476
+ .split(/(?<=-----END CERTIFICATE-----)\s*/)
477
+ .map(s => s.trim())
478
+ .filter(s => s.startsWith('-----BEGIN CERTIFICATE-----'));
479
+ }
480
+ async saveTrustRoots(trustList, rootCerts) {
481
+ for (let i = 0; i < rootCerts.length; i++) {
482
+ const item = rootCerts[i];
483
+ const certId = item.id || item.fingerprint_sha256 || `root-${i + 1}`;
484
+ const safeName = certId.replace(/[^A-Za-z0-9_.-]+/g, '_').slice(0, 120);
485
+ await idbPut(STORE_INSTANCE_STATE, IndexedDBTokenStore._TRUST_CERT_PREFIX + safeName, item.cert_pem);
486
+ }
487
+ const bundle = rootCerts.map(i => i.cert_pem.trim()).join('\n') + '\n';
488
+ await idbPut(STORE_INSTANCE_STATE, IndexedDBTokenStore._TRUST_BUNDLE_KEY, bundle);
489
+ await idbPut(STORE_INSTANCE_STATE, IndexedDBTokenStore._TRUST_LIST_KEY, deepClone(trustList));
490
+ return 'indexeddb://trust-roots/bundle';
491
+ }
492
+ async saveIssuerRootCert(issuer, certPem, fingerprintSha256 = '') {
493
+ const safeIssuer = (issuer || 'issuer').replace(/[^A-Za-z0-9_.-]+/g, '_').slice(0, 120);
494
+ const certKey = IndexedDBTokenStore._TRUST_ISSUER_PREFIX + safeIssuer;
495
+ const normalizedPem = certPem.trim() + '\n';
496
+ await idbPut(STORE_INSTANCE_STATE, certKey, normalizedPem);
497
+ const existingPems = new Map();
498
+ const existingBundle = await idbGet(STORE_INSTANCE_STATE, IndexedDBTokenStore._TRUST_BUNDLE_KEY);
499
+ if (typeof existingBundle === 'string' && existingBundle) {
500
+ for (const pem of this._splitPemBundle(existingBundle)) {
501
+ const fp = await this._pemFingerprint(pem);
502
+ existingPems.set(fp, pem);
503
+ }
504
+ }
505
+ let newFp = await this._pemFingerprint(normalizedPem);
506
+ if (fingerprintSha256) {
507
+ newFp = fingerprintSha256.toLowerCase().replace(/^sha256:/, '');
508
+ }
509
+ existingPems.set(newFp, normalizedPem);
510
+ const merged = Array.from(existingPems.values()).map(p => p.trim()).join('\n') + '\n';
511
+ await idbPut(STORE_INSTANCE_STATE, IndexedDBTokenStore._TRUST_BUNDLE_KEY, merged);
512
+ return [`indexeddb://trust-roots/issuers/${safeIssuer}`, 'indexeddb://trust-roots/bundle'];
513
+ }
514
+ async loadTrustRoots() {
515
+ const data = await idbGet(STORE_INSTANCE_STATE, IndexedDBTokenStore._TRUST_LIST_KEY);
516
+ if (!isRecord(data))
517
+ return null;
518
+ return deepClone(data);
519
+ }
520
+ // ── 内部辅助 ─────────────────────────────────────────
521
+ async _loadMetadataOnlyUnlocked(aid) {
522
+ const data = await idbGet(STORE_METADATA, metadataStoreKey(aid));
523
+ return isRecord(data) ? deepClone(data) : null;
524
+ }
525
+ async _saveMetadataOnlyUnlocked(aid, metadata) {
526
+ const plain = stripStructuredFields(metadata);
527
+ await idbPut(STORE_METADATA, metadataStoreKey(aid), plain);
528
+ }
529
+ async _migrateLegacyStructuredStateUnlocked(aid) {
530
+ const metadataOnly = (await this._loadMetadataOnlyUnlocked(aid)) ?? {};
531
+ const hasLegacyPrekeys = isRecord(metadataOnly.e2ee_prekeys);
532
+ const hasLegacyGroups = isRecord(metadataOnly.group_secrets);
533
+ const hasLegacySessions = Array.isArray(metadataOnly.e2ee_sessions) && metadataOnly.e2ee_sessions.length > 0;
534
+ if (!hasLegacyPrekeys && !hasLegacyGroups && !hasLegacySessions) {
535
+ return metadataOnly;
536
+ }
537
+ const cleaned = deepClone(metadataOnly);
538
+ if (hasLegacyPrekeys) {
539
+ const legacyPrekeys = metadataOnly.e2ee_prekeys;
540
+ const structuredPrekeys = await this._loadPrekeysUnlocked(aid);
541
+ let changed = false;
542
+ if (isRecord(legacyPrekeys)) {
543
+ for (const [prekeyId, rawData] of Object.entries(legacyPrekeys)) {
544
+ if (!isRecord(rawData) || structuredPrekeys[prekeyId])
545
+ continue;
546
+ if (!this._isPrekeyRecoverable(rawData))
547
+ continue;
548
+ structuredPrekeys[prekeyId] = deepClone(rawData);
549
+ changed = true;
550
+ }
551
+ }
552
+ if (changed) {
553
+ await this._replacePrekeysUnlocked(aid, structuredPrekeys);
554
+ }
555
+ delete cleaned.e2ee_prekeys;
556
+ }
557
+ if (hasLegacyGroups) {
558
+ const legacyGroups = metadataOnly.group_secrets;
559
+ const structuredGroups = await this._loadGroupEntriesUnlocked(aid);
560
+ let changed = false;
561
+ if (isRecord(legacyGroups)) {
562
+ for (const [groupId, rawEntry] of Object.entries(legacyGroups)) {
563
+ if (!isRecord(rawEntry))
564
+ continue;
565
+ const merged = this._mergeGroupEntryFromLegacy(structuredGroups[groupId], rawEntry);
566
+ if (!sameJson(structuredGroups[groupId] ?? null, merged)) {
567
+ if (Object.keys(merged).length > 0) {
568
+ structuredGroups[groupId] = merged;
569
+ changed = true;
570
+ }
571
+ }
572
+ }
573
+ }
574
+ if (changed) {
575
+ await this._replaceGroupEntriesUnlocked(aid, structuredGroups);
576
+ }
577
+ delete cleaned.group_secrets;
578
+ }
579
+ if (hasLegacySessions) {
580
+ const legacySessions = metadataOnly.e2ee_sessions;
581
+ for (const session of legacySessions) {
582
+ const sid = typeof session.session_id === 'string' ? session.session_id : '';
583
+ if (!sid)
584
+ continue;
585
+ const record = deepClone(session);
586
+ record.session_id = sid;
587
+ await idbPut(STORE_SESSIONS, sessionStoreKey(aid, sid), record);
588
+ }
589
+ delete cleaned.e2ee_sessions;
590
+ }
591
+ await this._saveMetadataOnlyUnlocked(aid, cleaned);
592
+ return cleaned;
593
+ }
594
+ async _loadPrekeysUnlocked(aid, deviceId = '') {
595
+ const items = await idbGetAllByPrefix(STORE_PREKEYS, prekeyPrefix(aid));
596
+ const prefix = prekeyPrefix(aid);
597
+ const result = {};
598
+ const normalizedDeviceId = String(deviceId ?? '').trim();
599
+ for (const item of items) {
600
+ if (!item.key.startsWith(prefix) || !isRecord(item.value))
601
+ continue;
602
+ const recordDeviceId = String(item.value.device_id ?? '').trim();
603
+ if (recordDeviceId !== normalizedDeviceId)
604
+ continue;
605
+ const prekeyId = typeof item.value.prekey_id === 'string'
606
+ ? item.value.prekey_id
607
+ : item.key.slice(prefix.length);
608
+ const record = deepClone(item.value);
609
+ delete record.prekey_id;
610
+ delete record.device_id;
611
+ const canonicalKey = prekeyStoreKey(aid, prekeyId, normalizedDeviceId);
612
+ if (!(prekeyId in result) || item.key === canonicalKey) {
613
+ result[prekeyId] = record;
614
+ }
615
+ }
616
+ return result;
617
+ }
618
+ async _loadPrekeyByIdUnlocked(aid, prekeyId) {
619
+ if (!prekeyId)
620
+ return null;
621
+ const directKey = prekeyStoreKey(aid, prekeyId, '');
622
+ const direct = await idbGet(STORE_PREKEYS, directKey);
623
+ if (isRecord(direct)) {
624
+ const record = deepClone(direct);
625
+ delete record.prekey_id;
626
+ delete record.device_id;
627
+ return record;
628
+ }
629
+ for (const item of await idbGetAllByPrefix(STORE_PREKEYS, prekeyPrefix(aid))) {
630
+ if (!isRecord(item.value))
631
+ continue;
632
+ const itemPrekeyId = typeof item.value.prekey_id === 'string' ? item.value.prekey_id : '';
633
+ if (itemPrekeyId !== prekeyId)
634
+ continue;
635
+ const record = deepClone(item.value);
636
+ delete record.prekey_id;
637
+ delete record.device_id;
638
+ return record;
639
+ }
640
+ return null;
641
+ }
642
+ async _replacePrekeysUnlocked(aid, prekeys, deviceId = '') {
643
+ const desired = new Set(Object.keys(prekeys));
644
+ const normalizedDeviceId = String(deviceId ?? '').trim();
645
+ for (const item of await idbGetAllByPrefix(STORE_PREKEYS, prekeyPrefix(aid))) {
646
+ if (!isRecord(item.value))
647
+ continue;
648
+ const recordDeviceId = String(item.value.device_id ?? '').trim();
649
+ if (recordDeviceId !== normalizedDeviceId)
650
+ continue;
651
+ const prekeyId = typeof item.value.prekey_id === 'string' ? item.value.prekey_id : '';
652
+ const canonicalKey = prekeyStoreKey(aid, prekeyId, normalizedDeviceId);
653
+ if (!desired.has(prekeyId) || item.key !== canonicalKey) {
654
+ await idbDelete(STORE_PREKEYS, item.key);
655
+ }
656
+ }
657
+ for (const [prekeyId, prekeyData] of Object.entries(prekeys)) {
658
+ const record = deepClone(prekeyData);
659
+ if (normalizedDeviceId) {
660
+ record.device_id = normalizedDeviceId;
661
+ }
662
+ else {
663
+ delete record.device_id;
664
+ }
665
+ await idbPut(STORE_PREKEYS, prekeyStoreKey(aid, prekeyId, normalizedDeviceId), { ...record, prekey_id: prekeyId });
666
+ }
667
+ }
668
+ async _loadGroupEntriesUnlocked(aid) {
669
+ const result = {};
670
+ for (const item of await idbGetAllByPrefix(STORE_GROUP_CURRENT, groupCurrentPrefix(aid))) {
671
+ if (!isRecord(item.value))
672
+ continue;
673
+ const groupId = typeof item.value.group_id === 'string'
674
+ ? item.value.group_id
675
+ : item.key.slice(groupCurrentPrefix(aid).length);
676
+ const record = deepClone(item.value);
677
+ delete record.group_id;
678
+ result[groupId] = record;
679
+ }
680
+ for (const item of await idbGetAllByPrefix(STORE_GROUP_OLD_EPOCHS, groupOldPrefix(aid))) {
681
+ if (!isRecord(item.value))
682
+ continue;
683
+ const groupId = typeof item.value.group_id === 'string' ? item.value.group_id : '';
684
+ if (!groupId)
685
+ continue;
686
+ const record = deepClone(item.value);
687
+ delete record.group_id;
688
+ const entry = result[groupId] ?? {};
689
+ const oldEpochs = Array.isArray(entry.old_epochs) ? entry.old_epochs : [];
690
+ oldEpochs.push(record);
691
+ oldEpochs.sort((a, b) => Number(a.epoch ?? 0) - Number(b.epoch ?? 0));
692
+ entry.old_epochs = oldEpochs;
693
+ result[groupId] = entry;
694
+ }
695
+ return result;
696
+ }
697
+ async _replaceGroupEntriesUnlocked(aid, groups) {
698
+ const desiredGroups = new Set(Object.keys(groups));
699
+ for (const item of await idbGetAllByPrefix(STORE_GROUP_CURRENT, groupCurrentPrefix(aid))) {
700
+ const groupId = isRecord(item.value) && typeof item.value.group_id === 'string'
701
+ ? item.value.group_id
702
+ : item.key.slice(groupCurrentPrefix(aid).length);
703
+ if (!desiredGroups.has(groupId))
704
+ await idbDelete(STORE_GROUP_CURRENT, item.key);
705
+ }
706
+ for (const item of await idbGetAllByPrefix(STORE_GROUP_OLD_EPOCHS, groupOldPrefix(aid))) {
707
+ if (!isRecord(item.value))
708
+ continue;
709
+ const groupId = typeof item.value.group_id === 'string' ? item.value.group_id : '';
710
+ if (!groupId || desiredGroups.has(groupId))
711
+ continue;
712
+ await idbDelete(STORE_GROUP_OLD_EPOCHS, item.key);
713
+ }
714
+ for (const [groupId, rawEntry] of Object.entries(groups)) {
715
+ if (!isRecord(rawEntry))
716
+ continue;
717
+ await this._saveSingleGroupEntryUnlocked(aid, groupId, rawEntry);
718
+ }
719
+ }
720
+ async _saveSingleGroupEntryUnlocked(aid, groupId, entry) {
721
+ for (const item of await idbGetAllByPrefix(STORE_GROUP_OLD_EPOCHS, groupOldPrefix(aid, groupId))) {
722
+ if (!isRecord(item.value))
723
+ continue;
724
+ if (typeof item.value.group_id === 'string' && item.value.group_id === groupId) {
725
+ await idbDelete(STORE_GROUP_OLD_EPOCHS, item.key);
726
+ }
727
+ }
728
+ const current = deepClone(entry);
729
+ const oldEpochs = Array.isArray(current.old_epochs) ? current.old_epochs : [];
730
+ delete current.old_epochs;
731
+ if (typeof current.epoch === 'number') {
732
+ await idbPut(STORE_GROUP_CURRENT, groupCurrentStoreKey(aid, groupId), { ...current, group_id: groupId });
733
+ }
734
+ else {
735
+ await idbDelete(STORE_GROUP_CURRENT, groupCurrentStoreKey(aid, groupId));
736
+ }
737
+ for (const oldEpoch of oldEpochs) {
738
+ if (!isRecord(oldEpoch) || typeof oldEpoch.epoch !== 'number')
739
+ continue;
740
+ await idbPut(STORE_GROUP_OLD_EPOCHS, groupOldStoreKey(aid, groupId, oldEpoch.epoch), {
741
+ ...deepClone(oldEpoch), group_id: groupId,
742
+ });
743
+ }
744
+ }
745
+ async _storeGroupSecretTransitionUnlocked(aid, groupId, opts) {
746
+ const currentKey = groupCurrentStoreKey(aid, groupId);
747
+ const currentRaw = await idbGet(STORE_GROUP_CURRENT, currentKey);
748
+ const now = Date.now();
749
+ const epoch = Number(opts.epoch);
750
+ const members = [...(opts.memberAids ?? [])].map((item) => String(item)).sort();
751
+ const pendingRotationId = String(opts.pendingRotationId ?? '').trim();
752
+ if (isRecord(currentRaw)) {
753
+ const current = deepClone(currentRaw);
754
+ delete current.group_id;
755
+ const localEpoch = Number(current.epoch ?? 0);
756
+ if (epoch < localEpoch)
757
+ return false;
758
+ if (epoch === localEpoch && typeof current.secret === 'string') {
759
+ if (current.secret !== opts.secret) {
760
+ if (String(current.pending_rotation_id ?? '').trim()) {
761
+ await idbPut(STORE_GROUP_CURRENT, currentKey, {
762
+ ...this._buildGroupCurrentRecord(groupId, opts, members, now),
763
+ });
764
+ return true;
765
+ }
766
+ return false;
767
+ }
768
+ const updated = { ...current };
769
+ let changed = false;
770
+ const oldMembers = Array.isArray(updated.member_aids) ? updated.member_aids.map(String).sort() : [];
771
+ if (members.length > 0 && JSON.stringify(oldMembers) !== JSON.stringify(members)) {
772
+ updated.member_aids = members;
773
+ updated.commitment = opts.commitment;
774
+ updated.updated_at = now;
775
+ changed = true;
776
+ }
777
+ if (opts.epochChain !== undefined && updated.epoch_chain !== opts.epochChain) {
778
+ updated.epoch_chain = opts.epochChain;
779
+ updated.updated_at = now;
780
+ changed = true;
781
+ }
782
+ if (opts.epochChainUnverified === true) {
783
+ if (updated.epoch_chain_unverified !== true) {
784
+ updated.epoch_chain_unverified = true;
785
+ changed = true;
786
+ }
787
+ if (opts.epochChainUnverifiedReason && updated.epoch_chain_unverified_reason !== opts.epochChainUnverifiedReason) {
788
+ updated.epoch_chain_unverified_reason = opts.epochChainUnverifiedReason;
789
+ changed = true;
790
+ }
791
+ }
792
+ else if (opts.epochChainUnverified === false) {
793
+ if ('epoch_chain_unverified' in updated || 'epoch_chain_unverified_reason' in updated) {
794
+ delete updated.epoch_chain_unverified;
795
+ delete updated.epoch_chain_unverified_reason;
796
+ changed = true;
797
+ }
798
+ }
799
+ if (pendingRotationId && updated.pending_rotation_id !== pendingRotationId) {
800
+ updated.pending_rotation_id = pendingRotationId;
801
+ updated.pending_created_at = now;
802
+ changed = true;
803
+ }
804
+ if (!pendingRotationId && updated.pending_rotation_id) {
805
+ delete updated.pending_rotation_id;
806
+ delete updated.pending_created_at;
807
+ changed = true;
808
+ }
809
+ if (changed) {
810
+ await idbPut(STORE_GROUP_CURRENT, currentKey, { ...updated, group_id: groupId });
811
+ }
812
+ return true;
813
+ }
814
+ if (localEpoch !== epoch) {
815
+ const oldEntry = { ...current };
816
+ oldEntry.expires_at = Number(current.updated_at ?? now) + opts.oldEpochRetentionMs;
817
+ await idbPut(STORE_GROUP_OLD_EPOCHS, groupOldStoreKey(aid, groupId, localEpoch), {
818
+ ...oldEntry,
819
+ group_id: groupId,
820
+ });
821
+ }
822
+ else {
823
+ const merged = { ...current, ...this._buildGroupCurrentRecord(groupId, opts, members, now) };
824
+ if (!opts.epochChain && current.epoch_chain) {
825
+ merged.epoch_chain = current.epoch_chain;
826
+ }
827
+ if (current.pending_rotation_id && !opts.pendingRotationId) {
828
+ merged.pending_rotation_id = current.pending_rotation_id;
829
+ if (current.pending_created_at)
830
+ merged.pending_created_at = current.pending_created_at;
831
+ }
832
+ await idbPut(STORE_GROUP_CURRENT, currentKey, merged);
833
+ return true;
834
+ }
835
+ }
836
+ await idbPut(STORE_GROUP_CURRENT, currentKey, {
837
+ ...this._buildGroupCurrentRecord(groupId, opts, members, now),
838
+ });
839
+ return true;
840
+ }
841
+ async _storeGroupSecretEpochUnlocked(aid, groupId, opts) {
842
+ const now = Date.now();
843
+ const epoch = Number(opts.epoch);
844
+ const members = [...(opts.memberAids ?? [])].map((item) => String(item)).sort();
845
+ const pendingRotationId = String(opts.pendingRotationId ?? '').trim();
846
+ const currentKey = groupCurrentStoreKey(aid, groupId);
847
+ const currentRaw = await idbGet(STORE_GROUP_CURRENT, currentKey);
848
+ const newRecord = this._buildGroupCurrentRecord(groupId, opts, members, now);
849
+ if (!isRecord(currentRaw)) {
850
+ await idbPut(STORE_GROUP_CURRENT, currentKey, newRecord);
851
+ return true;
852
+ }
853
+ const current = deepClone(currentRaw);
854
+ delete current.group_id;
855
+ const localEpoch = Number(current.epoch ?? 0);
856
+ if (epoch > localEpoch) {
857
+ const expiresAt = Date.now() + (opts.oldEpochRetentionMs ?? 604800_000);
858
+ const oldRecord = { ...current, group_id: groupId, expires_at: expiresAt };
859
+ const oldKey = groupOldStoreKey(aid, groupId, localEpoch);
860
+ await idbPut(STORE_GROUP_OLD_EPOCHS, oldKey, oldRecord);
861
+ await idbPut(STORE_GROUP_CURRENT, currentKey, newRecord);
862
+ return true;
863
+ }
864
+ if (epoch === localEpoch) {
865
+ if (typeof current.secret === 'string' && current.secret !== opts.secret) {
866
+ if (!String(current.pending_rotation_id ?? '').trim())
867
+ return false;
868
+ await idbPut(STORE_GROUP_CURRENT, currentKey, newRecord);
869
+ return true;
870
+ }
871
+ const updated = { ...current };
872
+ let changed = false;
873
+ const oldMembers = Array.isArray(updated.member_aids) ? updated.member_aids.map(String).sort() : [];
874
+ if (members.length > 0 && JSON.stringify(oldMembers) !== JSON.stringify(members)) {
875
+ updated.member_aids = members;
876
+ updated.commitment = opts.commitment;
877
+ updated.updated_at = now;
878
+ changed = true;
879
+ }
880
+ if (opts.epochChain !== undefined && updated.epoch_chain !== opts.epochChain) {
881
+ updated.epoch_chain = opts.epochChain;
882
+ updated.updated_at = now;
883
+ changed = true;
884
+ }
885
+ if (opts.epochChainUnverified === true) {
886
+ if (updated.epoch_chain_unverified !== true) {
887
+ updated.epoch_chain_unverified = true;
888
+ changed = true;
889
+ }
890
+ if (opts.epochChainUnverifiedReason && updated.epoch_chain_unverified_reason !== opts.epochChainUnverifiedReason) {
891
+ updated.epoch_chain_unverified_reason = opts.epochChainUnverifiedReason;
892
+ changed = true;
893
+ }
894
+ }
895
+ else if (opts.epochChainUnverified === false) {
896
+ if ('epoch_chain_unverified' in updated || 'epoch_chain_unverified_reason' in updated) {
897
+ delete updated.epoch_chain_unverified;
898
+ delete updated.epoch_chain_unverified_reason;
899
+ changed = true;
900
+ }
901
+ }
902
+ if (pendingRotationId && updated.pending_rotation_id !== pendingRotationId) {
903
+ updated.pending_rotation_id = pendingRotationId;
904
+ updated.pending_created_at = now;
905
+ changed = true;
906
+ }
907
+ if (!pendingRotationId && updated.pending_rotation_id) {
908
+ delete updated.pending_rotation_id;
909
+ delete updated.pending_created_at;
910
+ changed = true;
911
+ }
912
+ if (changed)
913
+ await idbPut(STORE_GROUP_CURRENT, currentKey, { ...updated, group_id: groupId });
914
+ return true;
915
+ }
916
+ const oldKey = groupOldStoreKey(aid, groupId, epoch);
917
+ const oldRaw = await idbGet(STORE_GROUP_OLD_EPOCHS, oldKey);
918
+ if (isRecord(oldRaw)) {
919
+ const old = deepClone(oldRaw);
920
+ if (typeof old.secret === 'string' && old.secret !== opts.secret)
921
+ return false;
922
+ }
923
+ await idbPut(STORE_GROUP_OLD_EPOCHS, oldKey, {
924
+ ...newRecord,
925
+ epoch,
926
+ expires_at: now + opts.oldEpochRetentionMs,
927
+ });
928
+ return true;
929
+ }
930
+ async _discardPendingGroupSecretUnlocked(aid, groupId, epoch, rotationId) {
931
+ const currentKey = groupCurrentStoreKey(aid, groupId);
932
+ const currentRaw = await idbGet(STORE_GROUP_CURRENT, currentKey);
933
+ if (!isRecord(currentRaw))
934
+ return false;
935
+ const current = deepClone(currentRaw);
936
+ if (Number(current.epoch ?? 0) !== Number(epoch))
937
+ return false;
938
+ if (String(current.pending_rotation_id ?? '').trim() !== rotationId)
939
+ return false;
940
+ let restored = null;
941
+ let restoredKey = '';
942
+ for (const item of await idbGetAllByPrefix(STORE_GROUP_OLD_EPOCHS, groupOldPrefix(aid, groupId))) {
943
+ if (!isRecord(item.value))
944
+ continue;
945
+ const old = deepClone(item.value);
946
+ const oldEpoch = Number(old.epoch ?? 0);
947
+ if (oldEpoch >= Number(epoch) || !old.secret)
948
+ continue;
949
+ if (!restored || oldEpoch > Number(restored.epoch ?? 0)) {
950
+ restored = old;
951
+ restoredKey = item.key;
952
+ }
953
+ }
954
+ if (!restored) {
955
+ await idbDelete(STORE_GROUP_CURRENT, currentKey);
956
+ for (const item of await idbGetAllByPrefix(STORE_GROUP_OLD_EPOCHS, groupOldPrefix(aid, groupId))) {
957
+ await idbDelete(STORE_GROUP_OLD_EPOCHS, item.key);
958
+ }
959
+ return true;
960
+ }
961
+ delete restored.group_id;
962
+ delete restored.expires_at;
963
+ await idbPut(STORE_GROUP_CURRENT, currentKey, { ...restored, group_id: groupId, updated_at: Date.now() });
964
+ if (restoredKey)
965
+ await idbDelete(STORE_GROUP_OLD_EPOCHS, restoredKey);
966
+ return true;
967
+ }
968
+ _buildGroupCurrentRecord(groupId, opts, memberAids, now) {
969
+ const record = {
970
+ group_id: groupId,
971
+ epoch: opts.epoch,
972
+ secret: opts.secret,
973
+ commitment: opts.commitment,
974
+ member_aids: memberAids,
975
+ updated_at: now,
976
+ };
977
+ if (opts.epochChain !== undefined)
978
+ record.epoch_chain = opts.epochChain;
979
+ const pendingRotationId = String(opts.pendingRotationId ?? '').trim();
980
+ if (pendingRotationId) {
981
+ record.pending_rotation_id = pendingRotationId;
982
+ record.pending_created_at = now;
983
+ }
984
+ if (opts.epochChainUnverified === true) {
985
+ record.epoch_chain_unverified = true;
986
+ if (opts.epochChainUnverifiedReason)
987
+ record.epoch_chain_unverified_reason = opts.epochChainUnverifiedReason;
988
+ }
989
+ return record;
990
+ }
991
+ _mergeGroupEntryFromLegacy(existing, incoming) {
992
+ const oldByEpoch = new Map();
993
+ let current = existing && typeof existing.epoch === 'number'
994
+ ? deepClone(Object.fromEntries(Object.entries(existing).filter(([key]) => key !== 'old_epochs')))
995
+ : null;
996
+ if (existing && Array.isArray(existing.old_epochs)) {
997
+ for (const rawOld of existing.old_epochs) {
998
+ if (typeof rawOld.epoch !== 'number')
999
+ continue;
1000
+ oldByEpoch.set(rawOld.epoch, deepClone(rawOld));
1001
+ }
1002
+ }
1003
+ if (typeof incoming.epoch === 'number' && this._isGroupEpochRecoverable(incoming)) {
1004
+ const incomingCurrent = deepClone(Object.fromEntries(Object.entries(incoming).filter(([key]) => key !== 'old_epochs')));
1005
+ if (!current) {
1006
+ current = incomingCurrent;
1007
+ }
1008
+ else if (incomingCurrent.epoch > current.epoch) {
1009
+ oldByEpoch.set(current.epoch, this._preferNewerGroupEpochRecord(oldByEpoch.get(current.epoch), current));
1010
+ current = incomingCurrent;
1011
+ }
1012
+ else if (incomingCurrent.epoch === current.epoch) {
1013
+ current = this._preferNewerGroupEpochRecord(current, incomingCurrent);
1014
+ }
1015
+ else {
1016
+ oldByEpoch.set(incomingCurrent.epoch, this._preferNewerGroupEpochRecord(oldByEpoch.get(incomingCurrent.epoch), incomingCurrent));
1017
+ }
1018
+ }
1019
+ const incomingOldEpochs = Array.isArray(incoming.old_epochs) ? incoming.old_epochs : [];
1020
+ for (const rawOld of incomingOldEpochs) {
1021
+ if (typeof rawOld.epoch !== 'number' || !this._isGroupEpochRecoverable(rawOld))
1022
+ continue;
1023
+ oldByEpoch.set(rawOld.epoch, this._preferNewerGroupEpochRecord(oldByEpoch.get(rawOld.epoch), rawOld));
1024
+ }
1025
+ const merged = current ? deepClone(current) : {};
1026
+ if (current && typeof current.epoch === 'number') {
1027
+ oldByEpoch.delete(current.epoch);
1028
+ }
1029
+ if (oldByEpoch.size > 0) {
1030
+ merged.old_epochs = [...oldByEpoch.entries()].sort(([a], [b]) => a - b).map(([, value]) => deepClone(value));
1031
+ }
1032
+ return merged;
1033
+ }
1034
+ _preferNewerGroupEpochRecord(existing, incoming) {
1035
+ if (!existing)
1036
+ return deepClone(incoming);
1037
+ return Number(incoming.updated_at ?? 0) > Number(existing.updated_at ?? 0)
1038
+ ? deepClone(incoming)
1039
+ : deepClone(existing);
1040
+ }
1041
+ _isUnexpiredRecord(record, fallbackKey) {
1042
+ const expiresAt = typeof record.expires_at === 'number' ? record.expires_at : null;
1043
+ if (expiresAt !== null)
1044
+ return expiresAt >= Date.now();
1045
+ const marker = typeof record[fallbackKey] === 'number' ? record[fallbackKey] : null;
1046
+ return marker !== null && marker + STRUCTURED_RECOVERY_RETENTION_MS >= Date.now();
1047
+ }
1048
+ _isPrekeyRecoverable(record) {
1049
+ return this._isUnexpiredRecord(record, 'created_at');
1050
+ }
1051
+ _isGroupEpochRecoverable(record) {
1052
+ const secret = record.secret;
1053
+ if (!secret || (typeof secret === 'string' && secret.length === 0))
1054
+ return false;
1055
+ return this._isUnexpiredRecord(record, 'updated_at');
1056
+ }
1057
+ // ── Sessions 内部辅助 ─────────────────────────────────
1058
+ async _loadSessionsUnlocked(aid) {
1059
+ const items = await idbGetAllByPrefix(STORE_SESSIONS, sessionPrefix(aid));
1060
+ const result = [];
1061
+ for (const item of items) {
1062
+ if (!isRecord(item.value))
1063
+ continue;
1064
+ result.push(deepClone(item.value));
1065
+ }
1066
+ return result;
1067
+ }
1068
+ async _migrateLegacySessionsUnlocked(aid) {
1069
+ const metadataOnly = (await this._loadMetadataOnlyUnlocked(aid)) ?? {};
1070
+ if (!Array.isArray(metadataOnly.e2ee_sessions) || metadataOnly.e2ee_sessions.length === 0) {
1071
+ return;
1072
+ }
1073
+ for (const session of metadataOnly.e2ee_sessions) {
1074
+ const sid = typeof session.session_id === 'string' ? session.session_id : '';
1075
+ if (!sid)
1076
+ continue;
1077
+ const record = deepClone(session);
1078
+ record.session_id = sid;
1079
+ await idbPut(STORE_SESSIONS, sessionStoreKey(aid, sid), record);
1080
+ }
1081
+ const cleaned = deepClone(metadataOnly);
1082
+ delete cleaned.e2ee_sessions;
1083
+ await this._saveMetadataOnlyUnlocked(aid, cleaned);
1084
+ }
1085
+ }
1086
+ //# sourceMappingURL=indexeddb-token-store.js.map