selfstore 1.0.0

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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +364 -0
  3. package/dist/advanced.d.ts +90 -0
  4. package/dist/advanced.js +41 -0
  5. package/dist/advanced.js.map +1 -0
  6. package/dist/backups.d.ts +182 -0
  7. package/dist/backups.js +360 -0
  8. package/dist/backups.js.map +1 -0
  9. package/dist/cache-De5_sALA.d.ts +129 -0
  10. package/dist/cache-lock-LZILZNLP.js +23 -0
  11. package/dist/cache-lock-LZILZNLP.js.map +1 -0
  12. package/dist/chunk-ATUPHVLG.js +441 -0
  13. package/dist/chunk-ATUPHVLG.js.map +1 -0
  14. package/dist/chunk-BJ3RUMGI.js +45 -0
  15. package/dist/chunk-BJ3RUMGI.js.map +1 -0
  16. package/dist/chunk-CUWB5EWF.js +732 -0
  17. package/dist/chunk-CUWB5EWF.js.map +1 -0
  18. package/dist/chunk-FDPAKHQS.js +230 -0
  19. package/dist/chunk-FDPAKHQS.js.map +1 -0
  20. package/dist/chunk-IBIEKK36.js +331 -0
  21. package/dist/chunk-IBIEKK36.js.map +1 -0
  22. package/dist/chunk-JB4JHM7V.js +474 -0
  23. package/dist/chunk-JB4JHM7V.js.map +1 -0
  24. package/dist/chunk-N4RPMX3Y.js +1908 -0
  25. package/dist/chunk-N4RPMX3Y.js.map +1 -0
  26. package/dist/chunk-OWLX6PNT.js +231 -0
  27. package/dist/chunk-OWLX6PNT.js.map +1 -0
  28. package/dist/chunk-QBCU7OOP.js +20 -0
  29. package/dist/chunk-QBCU7OOP.js.map +1 -0
  30. package/dist/chunk-SGISYMT5.js +46 -0
  31. package/dist/chunk-SGISYMT5.js.map +1 -0
  32. package/dist/chunk-VBFMMR7Q.js +29 -0
  33. package/dist/chunk-VBFMMR7Q.js.map +1 -0
  34. package/dist/drive-Bv94UM8b.d.ts +222 -0
  35. package/dist/flows.d.ts +306 -0
  36. package/dist/flows.js +153 -0
  37. package/dist/flows.js.map +1 -0
  38. package/dist/groups.d.ts +39 -0
  39. package/dist/groups.js +103 -0
  40. package/dist/groups.js.map +1 -0
  41. package/dist/households.d.ts +214 -0
  42. package/dist/households.js +410 -0
  43. package/dist/households.js.map +1 -0
  44. package/dist/index.d.ts +205 -0
  45. package/dist/index.js +249 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/kdf-worker.js +2395 -0
  48. package/dist/kdf-worker.js.map +1 -0
  49. package/dist/local-2py_vGGy.d.ts +47 -0
  50. package/dist/local-AM22RWRR.js +7 -0
  51. package/dist/local-AM22RWRR.js.map +1 -0
  52. package/dist/machine-ALMkT3mk.d.ts +17 -0
  53. package/dist/store-ChIGEOph.d.ts +577 -0
  54. package/dist/sync.d.ts +107 -0
  55. package/dist/sync.js +3 -0
  56. package/dist/sync.js.map +1 -0
  57. package/dist/widgets.d.ts +426 -0
  58. package/dist/widgets.js +2579 -0
  59. package/dist/widgets.js.map +1 -0
  60. package/llms.txt +405 -0
  61. package/package.json +99 -0
@@ -0,0 +1,1908 @@
1
+ import { createNode, createMeta, gcTombstones, stamp, merge, unsyncableCounts, idPathFor, detectConflicts, changes } from './chunk-IBIEKK36.js';
2
+ import { inspect, checkPasswordPolicy, writeBox, BACKUP_MIME, readBoxWithSync, asBytes } from './chunk-JB4JHM7V.js';
3
+ import { openManifest, GROUP_KEYING } from './chunk-OWLX6PNT.js';
4
+ import { seal, isEnvelope, unseal, newDeviceKey } from './chunk-QBCU7OOP.js';
5
+ import { mintSlot, MAX_KEY_SLOTS, mintExternalSlot, openSlot } from './chunk-FDPAKHQS.js';
6
+ import { SelfstoreError, isAuthExpired, errorLabelKey, isSelfstoreError } from './chunk-VBFMMR7Q.js';
7
+ import { openDB } from 'idb';
8
+
9
+ // src/persistence/target.ts
10
+ var RESERVED_STORE_MODES = ["device", "file-manual"];
11
+ function isReservedStoreMode(kind) {
12
+ return RESERVED_STORE_MODES.includes(kind);
13
+ }
14
+
15
+ // src/persistence/status.ts
16
+ var isDurableKind = (kind) => !isReservedStoreMode(kind);
17
+ function deriveStatus(input) {
18
+ if (!input.persistent) {
19
+ return {
20
+ state: "ephemeral",
21
+ severity: "warn",
22
+ actionable: true,
23
+ action: "choose-destination",
24
+ labelKey: "status.ephemeral"
25
+ };
26
+ }
27
+ if (isDurableKind(input.targetKind) && input.needsAttention) {
28
+ return {
29
+ state: "needs-attention",
30
+ severity: "danger",
31
+ actionable: true,
32
+ action: input.locked ? "unlock" : "reconnect",
33
+ labelKey: input.locked ? "status.locked" : "status.needsAttention"
34
+ };
35
+ }
36
+ if (input.saving) {
37
+ return { state: "saving", severity: "info", actionable: false, labelKey: "status.saving" };
38
+ }
39
+ if (input.targetKind === "file-manual" && input.pendingDownload) {
40
+ return {
41
+ state: "pending-download",
42
+ severity: "info",
43
+ actionable: true,
44
+ action: "download",
45
+ labelKey: "status.pendingDownload"
46
+ };
47
+ }
48
+ if (input.targetKind === "device") {
49
+ return {
50
+ state: "cache-only",
51
+ severity: "warn",
52
+ actionable: true,
53
+ action: "choose-destination",
54
+ labelKey: "status.cacheOnly"
55
+ };
56
+ }
57
+ return { state: "saved", severity: "ok", actionable: false, labelKey: "status.saved" };
58
+ }
59
+
60
+ // src/persistence/store.ts
61
+ var storeError = (code, message) => ({
62
+ code,
63
+ labelKey: errorLabelKey(code),
64
+ message
65
+ });
66
+ var RESERVED = "__store";
67
+ var KEY = {
68
+ mode: "mode",
69
+ version: "version",
70
+ syncMeta: "syncMeta",
71
+ lastSavedAt: "lastSavedAt",
72
+ enc: "enc",
73
+ targetKind: "targetKind",
74
+ remoteVersion: "remoteVersion",
75
+ syncJournal: "syncJournal",
76
+ baseMeta: "baseMeta",
77
+ // Bumped on every cache write: one cheap kv read tells a tab whether
78
+ // someone else wrote since it last looked (multi-tab fold).
79
+ epoch: "writeEpoch",
80
+ // SHA-256 of the content last pushed. Identical content skips the save (no
81
+ // "saving" flash, no redundant upload); an unpushed local edit hashes
82
+ // differently and still saves.
83
+ savedHash: "savedHash",
84
+ // The KV has no key enumeration, so forget() needs this index to preserve
85
+ // every group's anti-replay counter.
86
+ groupSeqIds: "groupSeqIds"
87
+ };
88
+ var JOURNAL_MAX = 20;
89
+ var STALE_CHECK_MS = 2e4;
90
+ function createLocalStore(opts) {
91
+ const { app, schemaVersion: version, appVersion, gather, apply, cache, readme, debounceMs = 800 } = opts;
92
+ const logger = opts.logger ?? console;
93
+ const requireEncryption = opts.requireEncryption ?? false;
94
+ const passwordPolicy = opts.passwordPolicy;
95
+ function assertPasswordPolicy(pw) {
96
+ if (!passwordPolicy) return;
97
+ const { ok, unmet } = checkPasswordPolicy(pw, passwordPolicy);
98
+ if (!ok) {
99
+ throw new SelfstoreError("WEAK_PASSWORD", `Password does not meet the policy: ${unmet.join(", ")}.`);
100
+ }
101
+ }
102
+ const journalSilent = new Set(opts.journalSilent ?? []);
103
+ const sync = opts.sync ?? {};
104
+ const kv = cache.kv;
105
+ const multiTab = opts.multiTab ?? (typeof window !== "undefined" && typeof BroadcastChannel !== "undefined");
106
+ const tabId = createNode();
107
+ const channel = multiTab && typeof BroadcastChannel !== "undefined" ? new BroadcastChannel(`selfstore:${app}`) : null;
108
+ const locks = multiTab && typeof navigator !== "undefined" && navigator.locks ? navigator.locks : null;
109
+ const lockName = `selfstore:${app}`;
110
+ let mode = "persistent";
111
+ let targetKind = "device";
112
+ let durable = null;
113
+ let label = null;
114
+ let encrypted = false;
115
+ let needsAttention = false;
116
+ let lastError = null;
117
+ let pendingDownload = false;
118
+ let lastSavedAt = null;
119
+ let ready = false;
120
+ let saving = false;
121
+ let lastSavedContentHash = null;
122
+ let pass = null;
123
+ let pendingExternalSecret = null;
124
+ let envelope = null;
125
+ let group = null;
126
+ let meta = createMeta();
127
+ let timer;
128
+ let idleTimer;
129
+ let remoteVersion = null;
130
+ let journal = [];
131
+ let lastSync = null;
132
+ let baseMeta = null;
133
+ let lastStaleCheck = 0;
134
+ const warnedNoId = /* @__PURE__ */ new Set();
135
+ let seenEpoch;
136
+ let pendingEdit = false;
137
+ let editSeq = 0;
138
+ let queuedKind = null;
139
+ let prefetched = null;
140
+ const PREFETCH_TTL_MS = 6e4;
141
+ const peerList = [];
142
+ const mirrorList = [];
143
+ const replicaList = [];
144
+ let chain = Promise.resolve();
145
+ function serialize(fn) {
146
+ const exec = locks ? () => locks.request(lockName, fn) : fn;
147
+ const run = chain.then(exec, exec);
148
+ chain = run.catch(() => void 0);
149
+ return run;
150
+ }
151
+ const IDLE_LOCK_MS = 30 * 60 * 1e3;
152
+ const durableKind = () => !isReservedStoreMode(targetKind);
153
+ const subs = /* @__PURE__ */ new Set();
154
+ let stateSnapshot;
155
+ const notify = () => {
156
+ stateSnapshot = buildState();
157
+ subs.forEach((f) => f());
158
+ };
159
+ const locked = () => durableKind() && encrypted && !pass && !envelope && !group;
160
+ const ownGroupRead = () => group ? { identity: group.identity, authors: [group.identity.sigPub] } : void 0;
161
+ const peerGroupRead = () => group ? { identity: group.identity, authors: group.manifest.members.map((m) => m.sig) } : void 0;
162
+ const groupSeqKey = (id) => `groupSeq:${id}`;
163
+ async function persistGroupSeq(id, seq) {
164
+ await kv.set(groupSeqKey(id), seq);
165
+ const ids = await kv.get(KEY.groupSeqIds) ?? [];
166
+ if (!ids.includes(id)) await kv.set(KEY.groupSeqIds, [...ids, id]);
167
+ }
168
+ function raiseGate(code, message) {
169
+ needsAttention = true;
170
+ lastError = storeError(code, message);
171
+ notify();
172
+ }
173
+ const transient = (message) => storeError("TARGET_UNAVAILABLE", message);
174
+ function kvSetSafe(key, value, what) {
175
+ kv.set(key, value).catch((e) => logger.error(`[selfstore] ${what} write failed`, e));
176
+ }
177
+ function armIdleLock() {
178
+ clearTimeout(idleTimer);
179
+ if (encrypted && (pass || envelope)) idleTimer = setTimeout(() => lockNow(), IDLE_LOCK_MS);
180
+ }
181
+ function lockNow() {
182
+ clearTimeout(idleTimer);
183
+ if (!pass && !envelope) return;
184
+ setPass(null);
185
+ needsAttention = true;
186
+ notify();
187
+ }
188
+ function setPass(p) {
189
+ pass = p;
190
+ if (p === null) envelope = null;
191
+ armIdleLock();
192
+ }
193
+ const toCachedFiles = (snap) => snap.files.map((f) => ({
194
+ id: f.id,
195
+ name: f.name,
196
+ mime: f.mime,
197
+ blob: new Blob([f.bytes], { type: f.mime })
198
+ }));
199
+ async function cachedToSnapshot(local) {
200
+ return {
201
+ collections: local.collections,
202
+ files: await Promise.all(
203
+ local.files.map(async (f) => ({
204
+ id: f.id,
205
+ name: f.name,
206
+ mime: f.mime,
207
+ bytes: new Uint8Array(await f.blob.arrayBuffer())
208
+ }))
209
+ )
210
+ };
211
+ }
212
+ async function buildBlob(pre) {
213
+ if (requireEncryption && !encrypted && !group) {
214
+ throw new SelfstoreError(
215
+ "ENCRYPTION_REQUIRED",
216
+ "requireEncryption is set: refusing to build a plaintext backup."
217
+ );
218
+ }
219
+ const snap = pre ?? await gather();
220
+ let env;
221
+ if (encrypted && !group) {
222
+ if (!envelope && pass) {
223
+ const dataKey = crypto.getRandomValues(new Uint8Array(32));
224
+ envelope = { dataKey, slots: [await mintSlot(pass, dataKey)] };
225
+ }
226
+ env = envelope ?? void 0;
227
+ if (!env) {
228
+ throw new SelfstoreError(
229
+ "PASSWORD_REQUIRED",
230
+ "Cannot write the encrypted backup while locked - unlock first."
231
+ );
232
+ }
233
+ }
234
+ const stampV = Math.max(version, schemaCeiling);
235
+ const sidecar = { schemaVersion: stampV, meta };
236
+ const groupOpts = group ? {
237
+ recipients: [
238
+ .../* @__PURE__ */ new Set([...group.manifest.members.map((m) => m.enc), group.identity.encPub])
239
+ ],
240
+ sign: { pub: group.identity.sigPub, priv: group.identity.sigPriv }
241
+ } : void 0;
242
+ const bytes = await writeBox(
243
+ { collections: snap.collections, files: snap.files },
244
+ { app, appVersion, schemaVersion: stampV, envelope: env, group: groupOpts, readme },
245
+ sidecar
246
+ );
247
+ return new Blob([bytes], { type: BACKUP_MIME });
248
+ }
249
+ async function publishMirrors() {
250
+ if (mirrorList.length === 0 || locked() || needsAttention) return;
251
+ const snap = await gather();
252
+ for (const m of mirrorList) {
253
+ try {
254
+ if (!m.envelope) {
255
+ const dataKey = crypto.getRandomValues(new Uint8Array(32));
256
+ m.envelope = { dataKey, slots: [await mintSlot(m.password, dataKey)] };
257
+ }
258
+ const sidecar = { schemaVersion: version, meta };
259
+ const bytes = await writeBox(
260
+ { collections: snap.collections, files: snap.files },
261
+ { app, appVersion, schemaVersion: version, envelope: m.envelope, readme },
262
+ sidecar
263
+ );
264
+ await m.target.save(new Blob([bytes], { type: BACKUP_MIME }));
265
+ m.lastPublishAt = Date.now();
266
+ m.lastError = null;
267
+ } catch (e) {
268
+ m.lastError = isAuthExpired(e) ? storeError("AUTH_EXPIRED", "Write access to the mirror expired or was revoked.") : isSelfstoreError(e) ? storeError(e.code, e.message) : transient("Mirror momentarily unreachable.");
269
+ logger.warn(`[selfstore] mirror "${m.id}" publish failed`, e);
270
+ }
271
+ }
272
+ notify();
273
+ }
274
+ async function publishReplicas() {
275
+ if (replicaList.length === 0 || locked() || needsAttention) return;
276
+ let blob;
277
+ try {
278
+ blob = await buildBlob();
279
+ } catch (e) {
280
+ logger.warn("[selfstore] replica publish skipped (cannot build backup)", e);
281
+ return;
282
+ }
283
+ for (const r of replicaList) {
284
+ try {
285
+ await r.target.save(blob);
286
+ r.lastPublishAt = Date.now();
287
+ r.lastError = null;
288
+ } catch (e) {
289
+ r.lastError = isAuthExpired(e) ? storeError("AUTH_EXPIRED", "Write access to the replica expired or was revoked.") : isSelfstoreError(e) ? storeError(e.code, e.message) : transient("Replica momentarily unreachable.");
290
+ logger.warn(`[selfstore] replica "${r.id}" publish failed`, e);
291
+ }
292
+ }
293
+ notify();
294
+ }
295
+ function readBookkeeping(snap, sidecar) {
296
+ if (RESERVED in snap.collections) delete snap.collections[RESERVED];
297
+ const s = sidecar;
298
+ return { fromVersion: s?.schemaVersion ?? 0, sync: s?.meta ?? null };
299
+ }
300
+ const migrated = (snap, fromV) => opts.migrate && fromV < version ? opts.migrate(fromV, snap) : snap;
301
+ function mergeFiles(local, remote) {
302
+ const byId = /* @__PURE__ */ new Map();
303
+ for (const f of remote) byId.set(f.id, f);
304
+ for (const f of local) byId.set(f.id, f);
305
+ return [...byId.values()];
306
+ }
307
+ function warnUnsyncable(collections) {
308
+ const counts = unsyncableCounts(collections, sync);
309
+ for (const [name, n] of Object.entries(counts)) {
310
+ if (warnedNoId.has(name)) continue;
311
+ warnedNoId.add(name);
312
+ const path = idPathFor(sync, name);
313
+ logger.warn(
314
+ `[selfstore] ${n} record(s) in collection "${name}" have no string id at "${path}" and will NOT sync or persist reliably. selfstore identifies records by a string id. Give each record a string id, or map this collection's id field with createLocalStore({ sync: { ids: { ${JSON.stringify(name)}: 'yourIdField' } } }).`
315
+ );
316
+ }
317
+ }
318
+ async function persistLocal() {
319
+ if (!await foldTabWrites()) return;
320
+ const snap = await gather();
321
+ warnUnsyncable(snap.collections);
322
+ if (opts.tombstoneHorizonMs) meta = gcTombstones(meta, Date.now() - opts.tombstoneHorizonMs);
323
+ meta = stamp(meta, snap.collections, sync);
324
+ await cache.saveCollections(snap.collections);
325
+ await cache.saveFiles(toCachedFiles(snap));
326
+ await kv.set(KEY.syncMeta, meta);
327
+ await kv.set(KEY.version, Math.max(version, schemaCeiling));
328
+ lastSavedAt = Date.now();
329
+ await kv.set(KEY.lastSavedAt, lastSavedAt);
330
+ await publishWrite("change");
331
+ }
332
+ async function publishWrite(kind) {
333
+ seenEpoch = createNode();
334
+ await kv.set(KEY.epoch, seenEpoch);
335
+ channel?.postMessage({ tab: tabId, kind });
336
+ }
337
+ async function foldTabWrites() {
338
+ const current = await kv.get(KEY.epoch);
339
+ if (current === void 0 || current === seenEpoch) return true;
340
+ const local = await cache.load();
341
+ if (!local) {
342
+ seenEpoch = current;
343
+ return true;
344
+ }
345
+ const storedV = await kv.get(KEY.version) ?? version;
346
+ if (isNewerSchema(storedV)) {
347
+ gateNewerSchema(storedV);
348
+ seenEpoch = current;
349
+ return false;
350
+ }
351
+ const tabMeta = await kv.get(KEY.syncMeta);
352
+ if (!tabMeta) {
353
+ seenEpoch = current;
354
+ return true;
355
+ }
356
+ const snap = migrated(await cachedToSnapshot(local), storedV);
357
+ const live = await gather();
358
+ meta = stamp(meta, live.collections, sync);
359
+ const merged = merge(
360
+ { collections: live.collections, meta },
361
+ { collections: snap.collections, meta: tabMeta },
362
+ sync
363
+ );
364
+ meta = merged.meta;
365
+ apply({ collections: merged.collections, files: mergeFiles(live.files, snap.files) });
366
+ seenEpoch = current;
367
+ return true;
368
+ }
369
+ async function adoptTabState() {
370
+ const current = await kv.get(KEY.epoch);
371
+ if (current === seenEpoch) return;
372
+ const local = await cache.load();
373
+ const storedV = await kv.get(KEY.version) ?? version;
374
+ if (isNewerSchema(storedV)) gateNewerSchema(storedV);
375
+ if (local) apply(migrated(await cachedToSnapshot(local), storedV));
376
+ else apply({ collections: {}, files: [] });
377
+ meta = await kv.get(KEY.syncMeta) ?? createMeta();
378
+ baseMeta = await kv.get(KEY.baseMeta) ?? null;
379
+ lastSavedAt = await kv.get(KEY.lastSavedAt) ?? null;
380
+ remoteVersion = await kv.get(KEY.remoteVersion) ?? null;
381
+ lastSavedContentHash = await kv.get(KEY.savedHash) ?? null;
382
+ if (durable) {
383
+ encrypted = await kv.get(KEY.enc) === true;
384
+ needsAttention = needsAttention || locked();
385
+ }
386
+ seenEpoch = current;
387
+ notify();
388
+ }
389
+ function onTabMessage(kind) {
390
+ if (!ready || mode !== "persistent") return;
391
+ if (queuedKind) {
392
+ if (kind === "forget") queuedKind = "forget";
393
+ return;
394
+ }
395
+ queuedKind = kind;
396
+ serialize(async () => {
397
+ const k = queuedKind;
398
+ queuedKind = null;
399
+ if (k === "forget") {
400
+ clearTimeout(timer);
401
+ pendingEdit = false;
402
+ mode = await kv.get(KEY.mode) === "ephemeral" ? "ephemeral" : "persistent";
403
+ const savedKind = await kv.get(KEY.targetKind) ?? "device";
404
+ if (durable) {
405
+ await dropDurable({ keepSession: true });
406
+ }
407
+ targetKind = savedKind;
408
+ pendingDownload = false;
409
+ seenEpoch = void 0;
410
+ await adoptTabState();
411
+ } else if (pendingEdit) {
412
+ clearTimeout(timer);
413
+ await doSave();
414
+ } else {
415
+ await adoptTabState();
416
+ }
417
+ }).catch((e) => logger.error("[selfstore] cross-tab refresh failed", e));
418
+ }
419
+ if (channel) {
420
+ channel.onmessage = (ev) => {
421
+ const msg = ev.data;
422
+ if (msg && msg.tab !== tabId && (msg.kind === "change" || msg.kind === "forget")) {
423
+ onTabMessage(msg.kind);
424
+ }
425
+ };
426
+ }
427
+ async function takeFreshPrefetch(t) {
428
+ const p = prefetched;
429
+ prefetched = null;
430
+ if (!p || p.target !== t || p.marker === null) return null;
431
+ if (Date.now() - p.at > PREFETCH_TTL_MS) return null;
432
+ let now;
433
+ try {
434
+ now = t.stat ? await t.stat() : null;
435
+ } catch {
436
+ return null;
437
+ }
438
+ return now !== null && now === p.marker ? p.blob : null;
439
+ }
440
+ async function statRemote() {
441
+ if (!durable?.stat) return null;
442
+ try {
443
+ return await durable.stat();
444
+ } catch {
445
+ return null;
446
+ }
447
+ }
448
+ async function requireReady(where) {
449
+ let ok = false;
450
+ try {
451
+ ok = await durable.isReady();
452
+ } catch (e) {
453
+ if (isAuthExpired(e)) {
454
+ raiseGate("AUTH_EXPIRED", "Access to the destination expired or was revoked.");
455
+ }
456
+ throw e;
457
+ }
458
+ if (!ok) {
459
+ throw new SelfstoreError(
460
+ "TARGET_WRITE_FAILED",
461
+ `${where}(): the destination is unreachable; try again online.`
462
+ );
463
+ }
464
+ }
465
+ async function setRemoteVersion(v) {
466
+ remoteVersion = v;
467
+ if (v === null) await kv.del(KEY.remoteVersion);
468
+ else await kv.set(KEY.remoteVersion, v);
469
+ }
470
+ function redactForStorage(entries) {
471
+ return entries.map(
472
+ (e) => e.conflicts ? { ...e, conflicts: e.conflicts.map(({ collection, id, kept }) => ({ collection, id, kept })) } : e
473
+ );
474
+ }
475
+ function recordJournal(delta, source, conflicts = []) {
476
+ const visible = Object.fromEntries(
477
+ Object.entries(delta).filter(([name]) => !journalSilent.has(name))
478
+ );
479
+ if (Object.keys(visible).length === 0) return null;
480
+ const entry = { at: Date.now(), source, changes: visible };
481
+ if (conflicts.length > 0) entry.conflicts = conflicts;
482
+ journal = [entry, ...journal].slice(0, JOURNAL_MAX);
483
+ lastSync = entry;
484
+ kvSetSafe(KEY.syncJournal, redactForStorage(journal), "journal");
485
+ return entry;
486
+ }
487
+ async function pushDurable(source = "push") {
488
+ if (!durable || locked() || needsAttention) return false;
489
+ try {
490
+ if (!await durable.isReady()) {
491
+ lastError = transient("Save deferred: destination momentarily unreachable.");
492
+ return false;
493
+ }
494
+ const seen = await statRemote();
495
+ if (seen !== null && remoteVersion !== null && seen !== remoteVersion) {
496
+ await pullRemoteIntoLocal(source, seen);
497
+ if (locked() || needsAttention) return false;
498
+ }
499
+ const snap = await gather();
500
+ const digest = await contentDigest(snap);
501
+ if (digest === lastSavedContentHash) {
502
+ lastError = null;
503
+ return true;
504
+ }
505
+ const written = await durable.save(await buildBlob(snap));
506
+ await setRemoteVersion(typeof written === "string" ? written : await statRemote());
507
+ lastSavedContentHash = digest;
508
+ kvSetSafe(KEY.savedHash, digest, "savedHash");
509
+ lastError = null;
510
+ return true;
511
+ } catch (e) {
512
+ if (isAuthExpired(e)) {
513
+ raiseGate("AUTH_EXPIRED", "Access to the destination expired or was revoked.");
514
+ } else if (isSelfstoreError(e) && e.code === "TARGET_GONE") {
515
+ raiseGate("TARGET_GONE", e.message);
516
+ } else {
517
+ lastError = transient("Save deferred: destination momentarily unreachable.");
518
+ }
519
+ logger.error("[selfstore] push to durable failed", e);
520
+ return false;
521
+ }
522
+ }
523
+ async function contentDigest(snap) {
524
+ const enc = new TextEncoder();
525
+ const parts = [enc.encode(JSON.stringify(snap.collections))];
526
+ for (const f of [...snap.files].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) {
527
+ parts.push(enc.encode(`${f.id}${f.mime}`), f.bytes);
528
+ }
529
+ let len = 0;
530
+ for (const p of parts) len += p.length;
531
+ const buf = new Uint8Array(len);
532
+ let off = 0;
533
+ for (const p of parts) {
534
+ buf.set(p, off);
535
+ off += p.length;
536
+ }
537
+ const digest = await crypto.subtle.digest("SHA-256", buf);
538
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
539
+ }
540
+ async function doSave() {
541
+ if (mode !== "persistent") return;
542
+ const seq = editSeq;
543
+ const digest = await contentDigest(await gather());
544
+ if (digest === lastSavedContentHash) {
545
+ if (editSeq === seq) pendingEdit = false;
546
+ return;
547
+ }
548
+ saving = true;
549
+ notify();
550
+ try {
551
+ await persistLocal();
552
+ if (editSeq === seq) pendingEdit = false;
553
+ if (targetKind === "file-manual") {
554
+ lastSavedContentHash = digest;
555
+ kvSetSafe(KEY.savedHash, digest, "savedHash");
556
+ pendingDownload = true;
557
+ return;
558
+ }
559
+ await pushDurable();
560
+ await publishMirrors();
561
+ await publishReplicas();
562
+ } finally {
563
+ saving = false;
564
+ notify();
565
+ }
566
+ }
567
+ const save = () => serialize(doSave);
568
+ async function isDowngraded(remote) {
569
+ if (!encrypted) return false;
570
+ try {
571
+ const h = await inspect(remote);
572
+ if (group) return h.keying !== GROUP_KEYING;
573
+ return h.encryption === "none";
574
+ } catch {
575
+ return false;
576
+ }
577
+ }
578
+ async function importLocked(remote) {
579
+ if (await isDowngraded(remote)) {
580
+ raiseGate(
581
+ "UNEXPECTEDLY_UNENCRYPTED",
582
+ group ? "Expected a signed group copy - found something else. Refused for safety." : "Unexpected unencrypted backup - refused for safety."
583
+ );
584
+ return "locked";
585
+ }
586
+ try {
587
+ const r = await readBoxWithSync(
588
+ await asBytes(remote),
589
+ pass ?? void 0,
590
+ ownGroupRead(),
591
+ envelope?.dataKey,
592
+ // Only during a passkey unlock (a user gesture) is a secret in hand for
593
+ // an external slot; a background converge has none and reads via the held
594
+ // data key or gates for the user to unlock.
595
+ pendingExternalSecret ? async () => pendingExternalSecret : void 0
596
+ );
597
+ if (!group) envelope = r.envelope ?? null;
598
+ return r;
599
+ } catch (e) {
600
+ if (isSelfstoreError(e) && (e.code === "PASSWORD_REQUIRED" || e.code === "DECRYPT_FAILED")) {
601
+ if (!encrypted) {
602
+ encrypted = true;
603
+ kvSetSafe(KEY.enc, true, "enc");
604
+ }
605
+ setPass(null);
606
+ raiseGate(e.code, e.message);
607
+ return "locked";
608
+ }
609
+ if (isSelfstoreError(e) && (e.code === "IDENTITY_REQUIRED" || e.code === "SIGNATURE_INVALID" || e.code === "NOT_A_RECIPIENT")) {
610
+ raiseGate(e.code, e.message);
611
+ return "locked";
612
+ }
613
+ throw e;
614
+ }
615
+ }
616
+ let schemaCeiling = version;
617
+ const isNewerSchema = (fromVersion) => fromVersion > version;
618
+ function gateNewerSchema(fromVersion) {
619
+ schemaCeiling = Math.max(schemaCeiling, fromVersion);
620
+ raiseGate(
621
+ "SCHEMA_TOO_NEW",
622
+ `Backup written by schema v${fromVersion}; this app reads v${version}. Update the app.`
623
+ );
624
+ }
625
+ async function convergeRemote(snap, sidecar, source) {
626
+ const { fromVersion, sync: remoteMeta } = readBookkeeping(snap, sidecar);
627
+ if (isNewerSchema(fromVersion)) {
628
+ gateNewerSchema(fromVersion);
629
+ return null;
630
+ }
631
+ const snapM = migrated(snap, fromVersion);
632
+ if (!remoteMeta) {
633
+ await adoptForeign(snapM);
634
+ return null;
635
+ }
636
+ const local = await gather();
637
+ meta = stamp(meta, local.collections, sync);
638
+ const localState = { collections: local.collections, meta };
639
+ const remoteState = { collections: snapM.collections, meta: remoteMeta };
640
+ const result = merge(localState, remoteState, sync);
641
+ const conflicts = detectConflicts(localState, remoteState, sync, baseMeta ?? void 0);
642
+ const delta = changes(local.collections, result.collections, sync);
643
+ meta = result.meta;
644
+ baseMeta = meta;
645
+ kvSetSafe(KEY.baseMeta, meta, "baseMeta");
646
+ apply({ collections: result.collections, files: mergeFiles(local.files, snapM.files) });
647
+ lastSavedContentHash = await contentDigest({
648
+ collections: snapM.collections,
649
+ files: snapM.files
650
+ });
651
+ kvSetSafe(KEY.savedHash, lastSavedContentHash, "savedHash");
652
+ await persistLocal();
653
+ if (conflicts.length) {
654
+ logger.warn(
655
+ "[selfstore] concurrent edits auto-resolved (latest kept): " + conflicts.map((c) => `${c.collection}/${c.id}`).join(", ")
656
+ );
657
+ }
658
+ return recordJournal(delta, source, conflicts);
659
+ }
660
+ async function pullRemoteIntoLocal(source, seen) {
661
+ if (!durable) return null;
662
+ const marker = seen === void 0 ? await statRemote() : seen;
663
+ let remote = null;
664
+ try {
665
+ remote = await takeFreshPrefetch(durable) ?? await durable.load();
666
+ } catch {
667
+ }
668
+ if (!remote) return null;
669
+ const decoded = await importLocked(remote);
670
+ if (decoded === "locked") return "locked";
671
+ const entry = await convergeRemote(decoded.snapshot, decoded.sidecar, source);
672
+ if (needsAttention) return "locked";
673
+ await setRemoteVersion(marker);
674
+ return entry;
675
+ }
676
+ async function foldPeers(source) {
677
+ if (peerList.length === 0 || locked() || needsAttention) return null;
678
+ let entry = null;
679
+ let folded = false;
680
+ for (const p of peerList) {
681
+ try {
682
+ let marker = null;
683
+ if (p.source.stat) {
684
+ try {
685
+ marker = await p.source.stat();
686
+ } catch (e) {
687
+ if (isAuthExpired(e)) throw e;
688
+ marker = null;
689
+ }
690
+ if (marker !== null && marker === p.lastSeen) {
691
+ p.lastError = null;
692
+ continue;
693
+ }
694
+ }
695
+ const remote = await p.source.load();
696
+ if (!remote) {
697
+ p.lastError = null;
698
+ p.lastSyncAt = Date.now();
699
+ continue;
700
+ }
701
+ if (p.password !== void 0) {
702
+ try {
703
+ if ((await inspect(remote)).encryption === "none") {
704
+ p.lastError = storeError(
705
+ "UNEXPECTEDLY_UNENCRYPTED",
706
+ "Peer copy unexpectedly unencrypted - refused for safety."
707
+ );
708
+ continue;
709
+ }
710
+ } catch {
711
+ }
712
+ } else if (await isDowngraded(remote)) {
713
+ p.lastError = storeError(
714
+ "UNEXPECTEDLY_UNENCRYPTED",
715
+ group ? "Peer copy is not a signed group copy - refused for safety." : "Peer copy unexpectedly unencrypted - refused for safety."
716
+ );
717
+ continue;
718
+ }
719
+ const { snapshot, sidecar, author, envelope: peerEnv } = await readBoxWithSync(
720
+ await asBytes(remote),
721
+ p.password ?? pass ?? void 0,
722
+ peerGroupRead(),
723
+ p.envelope?.dataKey
724
+ // reuse the data key: skip the Argon2 KDF unless rotated
725
+ );
726
+ if (peerEnv) p.envelope = peerEnv;
727
+ const { fromVersion, sync: peerMeta } = readBookkeeping(snapshot, sidecar);
728
+ if (isNewerSchema(fromVersion)) {
729
+ p.lastError = storeError(
730
+ "SCHEMA_TOO_NEW",
731
+ `Peer copy written by schema v${fromVersion}; this app reads v${version}.`
732
+ );
733
+ continue;
734
+ }
735
+ if (!peerMeta) {
736
+ p.lastError = storeError(
737
+ "BAD_FORMAT",
738
+ "Peer copy carries no sync metadata; not a published copy."
739
+ );
740
+ continue;
741
+ }
742
+ const snapM = migrated(snapshot, fromVersion);
743
+ const local = await gather();
744
+ meta = stamp(meta, local.collections, sync);
745
+ const localState = { collections: local.collections, meta };
746
+ const remoteState = { collections: snapM.collections, meta: peerMeta };
747
+ const result = merge(localState, remoteState, sync);
748
+ const conflicts = detectConflicts(localState, remoteState, sync, baseMeta ?? void 0);
749
+ const delta = changes(local.collections, result.collections, sync);
750
+ meta = result.meta;
751
+ baseMeta = meta;
752
+ kvSetSafe(KEY.baseMeta, meta, "baseMeta");
753
+ apply({ collections: result.collections, files: mergeFiles(local.files, snapM.files) });
754
+ folded = true;
755
+ entry = recordJournal(delta, source, conflicts) ?? entry;
756
+ p.lastError = null;
757
+ p.lastSyncAt = Date.now();
758
+ p.lastSeen = marker;
759
+ p.author = author ? group?.manifest.members.find((m) => m.sig === author)?.id ?? null : null;
760
+ } catch (e) {
761
+ p.lastError = isAuthExpired(e) ? storeError("AUTH_EXPIRED", "Read access to the peer copy expired or was revoked.") : isSelfstoreError(e) ? storeError(e.code, e.message) : transient("Peer momentarily unreachable.");
762
+ logger.warn(`[selfstore] peer "${p.id}" fold failed`, e);
763
+ }
764
+ }
765
+ if (folded) await persistLocal();
766
+ return entry;
767
+ }
768
+ async function anyPeerMoved() {
769
+ for (const p of peerList) {
770
+ if (!p.source.stat) return true;
771
+ try {
772
+ const marker = await p.source.stat();
773
+ if (marker === null || marker !== p.lastSeen) return true;
774
+ } catch {
775
+ return true;
776
+ }
777
+ }
778
+ return false;
779
+ }
780
+ async function doPull(source) {
781
+ if (!durable && peerList.length === 0 && mirrorList.length === 0) return null;
782
+ let entry = null;
783
+ if (durable) {
784
+ const r = await pullRemoteIntoLocal(source);
785
+ if (r === "locked") return null;
786
+ entry = r;
787
+ }
788
+ entry = await foldPeers(source) ?? entry;
789
+ if (durable) await pushDurable(source);
790
+ if (entry !== null || source === "connect") {
791
+ await publishMirrors();
792
+ await publishReplicas();
793
+ }
794
+ notify();
795
+ return entry;
796
+ }
797
+ const pull = (source) => serialize(() => doPull(source));
798
+ async function loadFromRemote() {
799
+ if (!durable) return;
800
+ const remote = await takeFreshPrefetch(durable) ?? await durable.load();
801
+ if (remote) {
802
+ const decoded = await importLocked(remote);
803
+ if (decoded === "locked") return;
804
+ const { fromVersion } = readBookkeeping(decoded.snapshot, decoded.sidecar);
805
+ if (isNewerSchema(fromVersion)) {
806
+ gateNewerSchema(fromVersion);
807
+ return;
808
+ }
809
+ apply(migrated(decoded.snapshot, fromVersion));
810
+ await persistLocal();
811
+ }
812
+ await pushDurable();
813
+ }
814
+ async function adoptForeign(snap) {
815
+ const local = await cache.load();
816
+ if (local) return;
817
+ apply(snap);
818
+ await persistLocal();
819
+ }
820
+ async function becomePersistent() {
821
+ if (mode !== "persistent") {
822
+ mode = "persistent";
823
+ await kv.set(KEY.mode, "persistent");
824
+ }
825
+ await cache.requestPersistent?.();
826
+ }
827
+ async function dropDurable({ keepSession = false } = {}) {
828
+ if (durable && !keepSession) await durable.disconnect();
829
+ durable = null;
830
+ label = null;
831
+ encrypted = false;
832
+ needsAttention = false;
833
+ pendingDownload = false;
834
+ setPass(null);
835
+ group = null;
836
+ await kv.del(KEY.enc);
837
+ await setRemoteVersion(null);
838
+ lastSavedContentHash = null;
839
+ await kv.del(KEY.savedHash);
840
+ }
841
+ async function doForget() {
842
+ apply({ collections: {}, files: [] });
843
+ if (durable && !locked() && !needsAttention) {
844
+ await persistLocal();
845
+ await pushDurable();
846
+ } else {
847
+ const seqIds = await kv.get(KEY.groupSeqIds) ?? [];
848
+ const savedSeqs = [];
849
+ for (const id of seqIds) {
850
+ const v = await kv.get(groupSeqKey(id));
851
+ if (v !== void 0) savedSeqs.push([id, v]);
852
+ }
853
+ meta = createMeta();
854
+ await cache.clear();
855
+ await dropDurable();
856
+ for (const [id, v] of savedSeqs) await persistGroupSeq(id, v);
857
+ mode = "persistent";
858
+ targetKind = "device";
859
+ lastSavedAt = null;
860
+ await kv.set(KEY.mode, "persistent");
861
+ await kv.set(KEY.targetKind, "device");
862
+ await kv.set(KEY.syncMeta, meta);
863
+ await kv.del(KEY.lastSavedAt);
864
+ await publishWrite("forget");
865
+ }
866
+ notify();
867
+ }
868
+ function buildState() {
869
+ return {
870
+ ready,
871
+ mode,
872
+ targetKind,
873
+ label,
874
+ encrypted,
875
+ locked: locked(),
876
+ saving,
877
+ lastSavedAt,
878
+ status: deriveStatus({
879
+ persistent: mode === "persistent",
880
+ targetKind,
881
+ saving,
882
+ needsAttention,
883
+ locked: locked(),
884
+ pendingDownload
885
+ }),
886
+ lastError,
887
+ journal,
888
+ lastSync,
889
+ peers: peerList.map(({ id, label: l, lastSyncAt, author, lastError: err }) => ({
890
+ id,
891
+ label: l,
892
+ lastSyncAt,
893
+ author,
894
+ lastError: err
895
+ })),
896
+ mirrors: mirrorList.map(({ id, label: l, lastPublishAt, lastError: err }) => ({
897
+ id,
898
+ label: l,
899
+ lastPublishAt,
900
+ lastError: err
901
+ })),
902
+ replicas: replicaList.map(({ id, label: l, lastPublishAt, lastError: err }) => ({
903
+ id,
904
+ label: l,
905
+ lastPublishAt,
906
+ lastError: err
907
+ }))
908
+ };
909
+ }
910
+ stateSnapshot = buildState();
911
+ return {
912
+ get state() {
913
+ return stateSnapshot;
914
+ },
915
+ subscribe(fn) {
916
+ subs.add(fn);
917
+ return () => subs.delete(fn);
918
+ },
919
+ async init() {
920
+ mode = await kv.get(KEY.mode) === "ephemeral" ? "ephemeral" : "persistent";
921
+ if (mode === "ephemeral") {
922
+ ready = true;
923
+ notify();
924
+ return;
925
+ }
926
+ void Promise.resolve(cache.requestPersistent?.()).catch(() => void 0);
927
+ const local = await cache.load();
928
+ if (local) {
929
+ const snap = await cachedToSnapshot(local);
930
+ const storedV = await kv.get(KEY.version) ?? version;
931
+ if (isNewerSchema(storedV)) gateNewerSchema(storedV);
932
+ apply(migrated(snap, storedV));
933
+ }
934
+ lastSavedAt = await kv.get(KEY.lastSavedAt) ?? null;
935
+ lastSavedContentHash = await kv.get(KEY.savedHash) ?? null;
936
+ meta = await kv.get(KEY.syncMeta) ?? createMeta();
937
+ await kv.set(KEY.syncMeta, meta);
938
+ baseMeta = await kv.get(KEY.baseMeta) ?? null;
939
+ remoteVersion = await kv.get(KEY.remoteVersion) ?? null;
940
+ journal = await kv.get(KEY.syncJournal) ?? [];
941
+ seenEpoch = await kv.get(KEY.epoch);
942
+ const savedKind = await kv.get(KEY.targetKind) ?? "device";
943
+ if (!isReservedStoreMode(savedKind)) {
944
+ encrypted = await kv.get(KEY.enc) === true;
945
+ targetKind = savedKind;
946
+ notify();
947
+ let t = null;
948
+ let restoreThrew = false;
949
+ try {
950
+ t = opts.restoreTarget ? await opts.restoreTarget(savedKind) : null;
951
+ } catch (e) {
952
+ restoreThrew = true;
953
+ lastError = transient("Could not restore the destination; will retry.");
954
+ logger.error("[selfstore] restoreTarget failed", e);
955
+ }
956
+ if (t) {
957
+ durable = t;
958
+ targetKind = savedKind;
959
+ label = t.label;
960
+ let ok = false;
961
+ try {
962
+ ok = await t.isReady();
963
+ } catch {
964
+ raiseGate("AUTH_EXPIRED", "Access to the destination expired or was revoked.");
965
+ }
966
+ needsAttention = needsAttention || locked();
967
+ if (ok && !locked()) {
968
+ try {
969
+ await pull("boot");
970
+ } catch (e) {
971
+ lastError = transient("Boot sync failed; will retry.");
972
+ logger.error("[selfstore] boot sync failed", e);
973
+ }
974
+ }
975
+ } else if (!restoreThrew) {
976
+ targetKind = "device";
977
+ await kv.set(KEY.targetKind, "device");
978
+ }
979
+ } else if (savedKind === "file-manual") {
980
+ targetKind = "file-manual";
981
+ pendingDownload = false;
982
+ }
983
+ if (!durable && peerList.length > 0) {
984
+ try {
985
+ await pull("boot");
986
+ } catch (e) {
987
+ lastError = transient("Boot sync failed; will retry.");
988
+ logger.error("[selfstore] boot sync failed", e);
989
+ }
990
+ }
991
+ ready = true;
992
+ notify();
993
+ },
994
+ schedule() {
995
+ if (!ready) return;
996
+ pendingEdit = true;
997
+ editSeq++;
998
+ clearTimeout(timer);
999
+ timer = setTimeout(
1000
+ () => save().catch((e) => logger.error("[selfstore] scheduled save failed", e)),
1001
+ debounceMs
1002
+ );
1003
+ armIdleLock();
1004
+ },
1005
+ lock: lockNow,
1006
+ async flush() {
1007
+ if (!ready) return;
1008
+ clearTimeout(timer);
1009
+ if (mode === "persistent") await save();
1010
+ },
1011
+ syncIfStale(source) {
1012
+ return serialize(async () => {
1013
+ if (locked() || needsAttention || mode !== "persistent") return;
1014
+ if (!durable?.stat && peerList.length === 0) return;
1015
+ const nowMs = Date.now();
1016
+ if (nowMs - lastStaleCheck < STALE_CHECK_MS) return;
1017
+ lastStaleCheck = nowMs;
1018
+ let moved = false;
1019
+ if (durable?.stat) {
1020
+ const seen = await statRemote();
1021
+ moved = seen !== null && seen !== remoteVersion;
1022
+ }
1023
+ if (!moved) moved = await anyPeerMoved();
1024
+ if (!moved) return;
1025
+ try {
1026
+ await doPull(source);
1027
+ } catch (e) {
1028
+ lastError = transient("Background sync failed; will retry.");
1029
+ logger.error("[selfstore] background sync failed", e);
1030
+ notify();
1031
+ }
1032
+ });
1033
+ },
1034
+ syncNow() {
1035
+ return serialize(() => {
1036
+ lastStaleCheck = Date.now();
1037
+ return doPull("manual");
1038
+ });
1039
+ },
1040
+ attachTarget(target, attachOpts) {
1041
+ if (isReservedStoreMode(target.kind)) {
1042
+ throw new TypeError(
1043
+ `attachTarget(): kind "${target.kind}" is a reserved store mode, not a target kind - pick any other string`
1044
+ );
1045
+ }
1046
+ const cfg = attachOpts?.group ?? null;
1047
+ if (cfg && attachOpts?.password) {
1048
+ throw new TypeError(
1049
+ "attachTarget(): `group` and `password` are mutually exclusive - a group is keyed by member identities."
1050
+ );
1051
+ }
1052
+ if (requireEncryption && !attachOpts?.password && !cfg) {
1053
+ throw new SelfstoreError(
1054
+ "ENCRYPTION_REQUIRED",
1055
+ "attachTarget(): this store requires encryption - attach with a password or a group."
1056
+ );
1057
+ }
1058
+ if (attachOpts?.password) assertPasswordPolicy(attachOpts.password);
1059
+ return serialize(async () => {
1060
+ const g = cfg ? {
1061
+ identity: cfg.identity,
1062
+ admin: cfg.admin,
1063
+ manifest: await openManifest(cfg.manifest, cfg.admin)
1064
+ } : null;
1065
+ if (g && !g.manifest.members.some((m) => m.sig === g.identity.sigPub)) {
1066
+ throw new TypeError(
1067
+ "attachTarget(): this identity is not a member of the manifest - the admin must add it first."
1068
+ );
1069
+ }
1070
+ await becomePersistent();
1071
+ const strategy = attachOpts?.strategy ?? "merge";
1072
+ if (strategy === "replace-local" && durable && !locked() && !needsAttention) {
1073
+ try {
1074
+ await persistLocal();
1075
+ await pushDurable("push");
1076
+ } catch {
1077
+ }
1078
+ }
1079
+ if (g) {
1080
+ const stored = await kv.get(groupSeqKey(g.manifest.group));
1081
+ if (stored !== void 0 && g.manifest.seq < stored) {
1082
+ throw new SelfstoreError(
1083
+ "MANIFEST_ROLLBACK",
1084
+ `Manifest seq ${g.manifest.seq} is older than the last applied (${stored}).`
1085
+ );
1086
+ }
1087
+ await persistGroupSeq(g.manifest.group, Math.max(stored ?? 0, g.manifest.seq));
1088
+ }
1089
+ await dropDurable({ keepSession: attachOpts?.keepSession });
1090
+ if (attachOpts?.wipe) {
1091
+ apply({ collections: {}, files: [] });
1092
+ meta = createMeta();
1093
+ baseMeta = null;
1094
+ await kv.set(KEY.syncMeta, meta);
1095
+ await kv.del(KEY.baseMeta);
1096
+ }
1097
+ durable = target;
1098
+ targetKind = target.kind;
1099
+ label = target.label;
1100
+ const next = attachOpts?.password ?? null;
1101
+ group = g;
1102
+ encrypted = !!next || !!g;
1103
+ setPass(next);
1104
+ envelope = null;
1105
+ await kv.set(KEY.enc, encrypted);
1106
+ await kv.set(KEY.targetKind, target.kind);
1107
+ needsAttention = false;
1108
+ notify();
1109
+ if (strategy === "replace-local") await loadFromRemote();
1110
+ else if (strategy === "replace-remote") await doSave();
1111
+ else await doPull("connect");
1112
+ notify();
1113
+ });
1114
+ },
1115
+ async inspectTarget(target) {
1116
+ const blob = await target.load();
1117
+ if (!blob || blob.size === 0) return { hasBackup: false, date: null, encrypted: false };
1118
+ let marker = null;
1119
+ try {
1120
+ marker = target.stat ? await target.stat() : null;
1121
+ } catch {
1122
+ marker = null;
1123
+ }
1124
+ prefetched = { target, blob, marker, at: Date.now() };
1125
+ const header = await inspect(blob);
1126
+ return {
1127
+ hasBackup: true,
1128
+ date: header.createdAt ?? null,
1129
+ encrypted: header.encryption !== "none"
1130
+ };
1131
+ },
1132
+ setManualFile() {
1133
+ return serialize(async () => {
1134
+ await becomePersistent();
1135
+ await dropDurable();
1136
+ targetKind = "file-manual";
1137
+ pendingDownload = false;
1138
+ await kv.set(KEY.targetKind, "file-manual");
1139
+ await persistLocal();
1140
+ notify();
1141
+ });
1142
+ },
1143
+ async unlock(password) {
1144
+ if (!locked() || !password) return false;
1145
+ setPass(password);
1146
+ needsAttention = schemaCeiling > version;
1147
+ await pull("manual");
1148
+ return !needsAttention;
1149
+ },
1150
+ async reconnect() {
1151
+ if (locked() || !durable) return false;
1152
+ if (await durable.reconnect()) {
1153
+ needsAttention = schemaCeiling > version;
1154
+ if (!needsAttention) lastError = null;
1155
+ await pull("manual");
1156
+ return !needsAttention;
1157
+ }
1158
+ return false;
1159
+ },
1160
+ detachTarget(opts2) {
1161
+ return serialize(async () => {
1162
+ await dropDurable({ keepSession: opts2?.keepSession });
1163
+ targetKind = "device";
1164
+ await kv.set(KEY.targetKind, "device");
1165
+ notify();
1166
+ });
1167
+ },
1168
+ setGroup(next) {
1169
+ if (!group) {
1170
+ throw new TypeError("setGroup(): no group attached - attachTarget with { group } first.");
1171
+ }
1172
+ return serialize(async () => {
1173
+ const manifest = await openManifest(next, group.admin, group.manifest.group);
1174
+ const key = groupSeqKey(manifest.group);
1175
+ const stored = await kv.get(key) ?? 0;
1176
+ const floor = Math.max(stored, group.manifest.seq);
1177
+ if (manifest.seq < floor) {
1178
+ throw new SelfstoreError(
1179
+ "MANIFEST_ROLLBACK",
1180
+ `Manifest seq ${manifest.seq} is older than the last applied (${floor}).`
1181
+ );
1182
+ }
1183
+ await persistGroupSeq(manifest.group, Math.max(stored, manifest.seq));
1184
+ group = { ...group, manifest };
1185
+ notify();
1186
+ lastSavedContentHash = null;
1187
+ await doSave();
1188
+ });
1189
+ },
1190
+ attachPeer(source, peerOpts) {
1191
+ const id = peerOpts?.id ?? createNode();
1192
+ if (peerList.some((p) => p.id === id)) {
1193
+ throw new TypeError(`attachPeer(): a peer with id "${id}" is already attached`);
1194
+ }
1195
+ peerList.push({
1196
+ id,
1197
+ source,
1198
+ password: peerOpts?.password,
1199
+ label: source.label ?? id,
1200
+ lastSyncAt: null,
1201
+ author: null,
1202
+ lastError: null,
1203
+ lastSeen: null,
1204
+ envelope: null
1205
+ });
1206
+ notify();
1207
+ if (ready) {
1208
+ void serialize(() => doPull("connect")).catch(
1209
+ (e) => logger.error("[selfstore] peer converge failed", e)
1210
+ );
1211
+ }
1212
+ return id;
1213
+ },
1214
+ detachPeer(id) {
1215
+ const i = peerList.findIndex((p) => p.id === id);
1216
+ if (i >= 0) {
1217
+ peerList.splice(i, 1);
1218
+ notify();
1219
+ }
1220
+ },
1221
+ attachMirror(target, mirrorOpts) {
1222
+ const id = mirrorOpts.id ?? createNode();
1223
+ if (mirrorList.some((m) => m.id === id)) {
1224
+ throw new TypeError(`attachMirror(): a mirror with id "${id}" is already attached`);
1225
+ }
1226
+ mirrorList.push({
1227
+ id,
1228
+ target,
1229
+ password: mirrorOpts.password,
1230
+ envelope: null,
1231
+ label: target.label ?? id,
1232
+ lastPublishAt: null,
1233
+ lastError: null
1234
+ });
1235
+ notify();
1236
+ if (ready) {
1237
+ void serialize(publishMirrors).catch(
1238
+ (e) => logger.error("[selfstore] mirror publish failed", e)
1239
+ );
1240
+ }
1241
+ return id;
1242
+ },
1243
+ detachMirror(id) {
1244
+ const i = mirrorList.findIndex((m) => m.id === id);
1245
+ if (i >= 0) {
1246
+ mirrorList.splice(i, 1);
1247
+ notify();
1248
+ }
1249
+ },
1250
+ attachReplica(target, replicaOpts) {
1251
+ const id = replicaOpts?.id ?? createNode();
1252
+ if (replicaList.some((r) => r.id === id)) {
1253
+ throw new TypeError(`attachReplica(): a replica with id "${id}" is already attached`);
1254
+ }
1255
+ replicaList.push({ id, target, label: target.label ?? id, lastPublishAt: null, lastError: null });
1256
+ notify();
1257
+ if (ready) {
1258
+ void serialize(publishReplicas).catch(
1259
+ (e) => logger.error("[selfstore] replica publish failed", e)
1260
+ );
1261
+ }
1262
+ return id;
1263
+ },
1264
+ detachReplica(id) {
1265
+ const i = replicaList.findIndex((r) => r.id === id);
1266
+ if (i >= 0) {
1267
+ replicaList.splice(i, 1);
1268
+ notify();
1269
+ }
1270
+ },
1271
+ exportBlob() {
1272
+ return buildBlob();
1273
+ },
1274
+ markDownloaded() {
1275
+ pendingDownload = false;
1276
+ notify();
1277
+ },
1278
+ setEphemeral() {
1279
+ mode = "ephemeral";
1280
+ kvSetSafe(KEY.mode, "ephemeral", "mode");
1281
+ notify();
1282
+ },
1283
+ async leaveEphemeral() {
1284
+ await becomePersistent();
1285
+ await save();
1286
+ },
1287
+ forget() {
1288
+ return serialize(doForget);
1289
+ },
1290
+ async setEncryption(password) {
1291
+ if (group) {
1292
+ throw new TypeError(
1293
+ "setEncryption(): the store is in group mode - encryption is keyed by the membership manifest (setGroup), not a password."
1294
+ );
1295
+ }
1296
+ if (requireEncryption && !password) {
1297
+ throw new SelfstoreError(
1298
+ "ENCRYPTION_REQUIRED",
1299
+ "setEncryption(): this store requires encryption - the password cannot be removed."
1300
+ );
1301
+ }
1302
+ if (password) assertPasswordPolicy(password);
1303
+ if (!durable) {
1304
+ throw new SelfstoreError("NOT_CONNECTED", "setEncryption(): no destination is connected.");
1305
+ }
1306
+ if (locked() || needsAttention) {
1307
+ throw new SelfstoreError(
1308
+ "TARGET_WRITE_FAILED",
1309
+ "setEncryption(): the store needs attention; resolve it before changing encryption."
1310
+ );
1311
+ }
1312
+ return serialize(async () => {
1313
+ await requireReady("setEncryption");
1314
+ const seen = await statRemote();
1315
+ if (seen !== null && remoteVersion !== null && seen !== remoteVersion) {
1316
+ await pullRemoteIntoLocal("push", seen);
1317
+ if (locked() || needsAttention) {
1318
+ throw new SelfstoreError(
1319
+ "TARGET_WRITE_FAILED",
1320
+ "setEncryption(): the pre-rewrite converge needs attention; resolve it first."
1321
+ );
1322
+ }
1323
+ }
1324
+ const prevEncrypted = encrypted;
1325
+ const prevPass = pass;
1326
+ const prevEnvelope = envelope;
1327
+ encrypted = !!password;
1328
+ setPass(password || null);
1329
+ envelope = null;
1330
+ try {
1331
+ await persistLocal();
1332
+ pendingEdit = false;
1333
+ const written = await durable.save(await buildBlob());
1334
+ await setRemoteVersion(typeof written === "string" ? written : await statRemote());
1335
+ lastError = null;
1336
+ await kv.set(KEY.enc, encrypted);
1337
+ } catch (e) {
1338
+ encrypted = prevEncrypted;
1339
+ setPass(prevPass);
1340
+ envelope = prevEnvelope;
1341
+ await persistLocal().catch(() => void 0);
1342
+ if (isAuthExpired(e)) {
1343
+ raiseGate("AUTH_EXPIRED", "Access to the destination expired or was revoked.");
1344
+ }
1345
+ throw e;
1346
+ } finally {
1347
+ notify();
1348
+ }
1349
+ });
1350
+ },
1351
+ async addEncryptionKey(password, id) {
1352
+ if (group) {
1353
+ throw new TypeError(
1354
+ "addEncryptionKey(): the store is in group mode - keys come from the membership manifest (setGroup), not passwords."
1355
+ );
1356
+ }
1357
+ if (!password) {
1358
+ throw new TypeError("addEncryptionKey(): a non-empty password is required.");
1359
+ }
1360
+ assertPasswordPolicy(password);
1361
+ if (!durable) {
1362
+ throw new SelfstoreError("NOT_CONNECTED", "addEncryptionKey(): no destination is connected.");
1363
+ }
1364
+ if (locked() || needsAttention) {
1365
+ throw new SelfstoreError(
1366
+ "TARGET_WRITE_FAILED",
1367
+ "addEncryptionKey(): the store needs attention; resolve it before changing keys."
1368
+ );
1369
+ }
1370
+ if (!encrypted || !pass) {
1371
+ throw new TypeError(
1372
+ "addEncryptionKey(): the backup has no password yet - setEncryption(password) first."
1373
+ );
1374
+ }
1375
+ return serialize(async () => {
1376
+ await requireReady("addEncryptionKey");
1377
+ const seen = await statRemote();
1378
+ if (seen !== null && remoteVersion !== null && seen !== remoteVersion) {
1379
+ await pullRemoteIntoLocal("push", seen);
1380
+ if (locked() || needsAttention) {
1381
+ throw new SelfstoreError(
1382
+ "TARGET_WRITE_FAILED",
1383
+ "addEncryptionKey(): the pre-rewrite converge needs attention; resolve it first."
1384
+ );
1385
+ }
1386
+ }
1387
+ if (!envelope) {
1388
+ const dataKey = crypto.getRandomValues(new Uint8Array(32));
1389
+ envelope = { dataKey, slots: [await mintSlot(pass, dataKey)] };
1390
+ }
1391
+ if (envelope.slots.length >= MAX_KEY_SLOTS) {
1392
+ throw new TypeError(`addEncryptionKey(): this backup already has ${MAX_KEY_SLOTS} key slots.`);
1393
+ }
1394
+ if (id && envelope.slots.some((s) => s.id === id)) {
1395
+ throw new TypeError(`addEncryptionKey(): a key slot named "${id}" already exists.`);
1396
+ }
1397
+ const prevEnvelope = envelope;
1398
+ const slot = await mintSlot(password, envelope.dataKey, id);
1399
+ envelope = { dataKey: envelope.dataKey, slots: [...envelope.slots, slot] };
1400
+ try {
1401
+ const blob = await buildBlob();
1402
+ await readBoxWithSync(await asBytes(blob), password);
1403
+ const written = await durable.save(blob);
1404
+ await setRemoteVersion(typeof written === "string" ? written : await statRemote());
1405
+ lastError = null;
1406
+ return slot.id;
1407
+ } catch (e) {
1408
+ envelope = prevEnvelope;
1409
+ if (isAuthExpired(e)) {
1410
+ raiseGate("AUTH_EXPIRED", "Access to the destination expired or was revoked.");
1411
+ }
1412
+ throw e;
1413
+ } finally {
1414
+ notify();
1415
+ }
1416
+ });
1417
+ },
1418
+ async removeEncryptionKey(id) {
1419
+ if (group) {
1420
+ throw new TypeError(
1421
+ "removeEncryptionKey(): the store is in group mode - keys come from the membership manifest (setGroup), not passwords."
1422
+ );
1423
+ }
1424
+ if (!durable) {
1425
+ throw new SelfstoreError("NOT_CONNECTED", "removeEncryptionKey(): no destination is connected.");
1426
+ }
1427
+ if (locked() || needsAttention) {
1428
+ throw new SelfstoreError(
1429
+ "TARGET_WRITE_FAILED",
1430
+ "removeEncryptionKey(): the store needs attention; resolve it before changing keys."
1431
+ );
1432
+ }
1433
+ if (!encrypted || !pass) {
1434
+ throw new TypeError("removeEncryptionKey(): the backup has no password.");
1435
+ }
1436
+ return serialize(async () => {
1437
+ await requireReady("removeEncryptionKey");
1438
+ const seen = await statRemote();
1439
+ if (seen !== null && remoteVersion !== null && seen !== remoteVersion) {
1440
+ await pullRemoteIntoLocal("push", seen);
1441
+ if (locked() || needsAttention) {
1442
+ throw new SelfstoreError(
1443
+ "TARGET_WRITE_FAILED",
1444
+ "removeEncryptionKey(): the pre-rewrite converge needs attention; resolve it first."
1445
+ );
1446
+ }
1447
+ }
1448
+ if (!envelope) {
1449
+ throw new TypeError(
1450
+ "removeEncryptionKey(): no key slots known - the file predates the envelope or has not been read yet."
1451
+ );
1452
+ }
1453
+ const remaining = envelope.slots.filter((s) => s.id !== id);
1454
+ if (remaining.length === envelope.slots.length) {
1455
+ throw new TypeError(`removeEncryptionKey(): no key slot named "${id}".`);
1456
+ }
1457
+ if (remaining.length === 0) {
1458
+ throw new TypeError(
1459
+ "removeEncryptionKey(): cannot remove the last key - setEncryption(null) decrypts instead."
1460
+ );
1461
+ }
1462
+ let stillOpens = remaining.some((s) => s.kind === "external");
1463
+ for (const s of remaining) {
1464
+ if (stillOpens) break;
1465
+ if (s.kind === "external") continue;
1466
+ if (await openSlot(s, pass)) {
1467
+ stillOpens = true;
1468
+ break;
1469
+ }
1470
+ }
1471
+ if (!stillOpens) {
1472
+ throw new TypeError(
1473
+ "removeEncryptionKey(): removing this slot would lock this session out - rotate with setEncryption(password) instead."
1474
+ );
1475
+ }
1476
+ const prevEnvelope = envelope;
1477
+ envelope = { dataKey: envelope.dataKey, slots: remaining };
1478
+ try {
1479
+ const blob = await buildBlob();
1480
+ await readBoxWithSync(await asBytes(blob), pass);
1481
+ const written = await durable.save(blob);
1482
+ await setRemoteVersion(typeof written === "string" ? written : await statRemote());
1483
+ lastError = null;
1484
+ } catch (e) {
1485
+ envelope = prevEnvelope;
1486
+ if (isAuthExpired(e)) {
1487
+ raiseGate("AUTH_EXPIRED", "Access to the destination expired or was revoked.");
1488
+ }
1489
+ throw e;
1490
+ } finally {
1491
+ notify();
1492
+ }
1493
+ });
1494
+ },
1495
+ async setExternalEncryption(secret, keyRef) {
1496
+ if (group) {
1497
+ throw new TypeError(
1498
+ "setExternalEncryption(): the store is in group mode - keys come from the membership manifest (setGroup), not an external key."
1499
+ );
1500
+ }
1501
+ if (!durable) {
1502
+ throw new SelfstoreError("NOT_CONNECTED", "setExternalEncryption(): no destination is connected.");
1503
+ }
1504
+ if (locked() || needsAttention) {
1505
+ throw new SelfstoreError(
1506
+ "TARGET_WRITE_FAILED",
1507
+ "setExternalEncryption(): the store needs attention; resolve it before changing encryption."
1508
+ );
1509
+ }
1510
+ return serialize(async () => {
1511
+ await requireReady("setExternalEncryption");
1512
+ const seen = await statRemote();
1513
+ if (seen !== null && remoteVersion !== null && seen !== remoteVersion) {
1514
+ await pullRemoteIntoLocal("push", seen);
1515
+ if (locked() || needsAttention) {
1516
+ throw new SelfstoreError(
1517
+ "TARGET_WRITE_FAILED",
1518
+ "setExternalEncryption(): the pre-rewrite converge needs attention; resolve it first."
1519
+ );
1520
+ }
1521
+ }
1522
+ const prevEncrypted = encrypted;
1523
+ const prevPass = pass;
1524
+ const prevEnvelope = envelope;
1525
+ const dataKey = crypto.getRandomValues(new Uint8Array(32));
1526
+ const slot = await mintExternalSlot(secret, keyRef, dataKey);
1527
+ encrypted = true;
1528
+ pass = null;
1529
+ envelope = { dataKey, slots: [slot] };
1530
+ armIdleLock();
1531
+ try {
1532
+ await persistLocal();
1533
+ pendingEdit = false;
1534
+ const blob = await buildBlob();
1535
+ await readBoxWithSync(await asBytes(blob), void 0, void 0, void 0, async () => secret);
1536
+ const written = await durable.save(blob);
1537
+ await setRemoteVersion(typeof written === "string" ? written : await statRemote());
1538
+ lastError = null;
1539
+ await kv.set(KEY.enc, true);
1540
+ } catch (e) {
1541
+ encrypted = prevEncrypted;
1542
+ pass = prevPass;
1543
+ envelope = prevEnvelope;
1544
+ armIdleLock();
1545
+ await persistLocal().catch(() => void 0);
1546
+ if (isAuthExpired(e)) {
1547
+ raiseGate("AUTH_EXPIRED", "Access to the destination expired or was revoked.");
1548
+ }
1549
+ throw e;
1550
+ } finally {
1551
+ notify();
1552
+ }
1553
+ });
1554
+ },
1555
+ async addExternalKey(secret, keyRef, id) {
1556
+ if (group) {
1557
+ throw new TypeError(
1558
+ "addExternalKey(): the store is in group mode - keys come from the membership manifest (setGroup)."
1559
+ );
1560
+ }
1561
+ if (!durable) {
1562
+ throw new SelfstoreError("NOT_CONNECTED", "addExternalKey(): no destination is connected.");
1563
+ }
1564
+ if (locked() || needsAttention) {
1565
+ throw new SelfstoreError(
1566
+ "TARGET_WRITE_FAILED",
1567
+ "addExternalKey(): the store needs attention; resolve it before changing keys."
1568
+ );
1569
+ }
1570
+ if (!encrypted) {
1571
+ throw new TypeError(
1572
+ "addExternalKey(): the backup is not encrypted yet - setEncryption(password) or setExternalEncryption(secret, ref) first."
1573
+ );
1574
+ }
1575
+ return serialize(async () => {
1576
+ await requireReady("addExternalKey");
1577
+ const seen = await statRemote();
1578
+ if (seen !== null && remoteVersion !== null && seen !== remoteVersion) {
1579
+ await pullRemoteIntoLocal("push", seen);
1580
+ if (locked() || needsAttention) {
1581
+ throw new SelfstoreError(
1582
+ "TARGET_WRITE_FAILED",
1583
+ "addExternalKey(): the pre-rewrite converge needs attention; resolve it first."
1584
+ );
1585
+ }
1586
+ }
1587
+ if (!envelope && pass) {
1588
+ const dataKey = crypto.getRandomValues(new Uint8Array(32));
1589
+ envelope = { dataKey, slots: [await mintSlot(pass, dataKey)] };
1590
+ }
1591
+ if (!envelope) {
1592
+ throw new TypeError("addExternalKey(): no data key in hand - unlock the backup first.");
1593
+ }
1594
+ if (envelope.slots.length >= MAX_KEY_SLOTS) {
1595
+ throw new TypeError(`addExternalKey(): this backup already has ${MAX_KEY_SLOTS} key slots.`);
1596
+ }
1597
+ if (id && envelope.slots.some((s) => s.id === id)) {
1598
+ throw new TypeError(`addExternalKey(): a key slot named "${id}" already exists.`);
1599
+ }
1600
+ const prevEnvelope = envelope;
1601
+ const slot = await mintExternalSlot(secret, keyRef, envelope.dataKey, id);
1602
+ envelope = { dataKey: envelope.dataKey, slots: [...envelope.slots, slot] };
1603
+ try {
1604
+ const blob = await buildBlob();
1605
+ await readBoxWithSync(await asBytes(blob), void 0, void 0, void 0, async () => secret);
1606
+ const written = await durable.save(blob);
1607
+ await setRemoteVersion(typeof written === "string" ? written : await statRemote());
1608
+ lastError = null;
1609
+ return slot.id;
1610
+ } catch (e) {
1611
+ envelope = prevEnvelope;
1612
+ if (isAuthExpired(e)) {
1613
+ raiseGate("AUTH_EXPIRED", "Access to the destination expired or was revoked.");
1614
+ }
1615
+ throw e;
1616
+ } finally {
1617
+ notify();
1618
+ }
1619
+ });
1620
+ },
1621
+ async unlockWithExternal(secret) {
1622
+ if (!locked() || !durable) return false;
1623
+ pendingExternalSecret = secret;
1624
+ needsAttention = schemaCeiling > version;
1625
+ try {
1626
+ await pull("manual");
1627
+ } finally {
1628
+ pendingExternalSecret = null;
1629
+ }
1630
+ return !locked() && !needsAttention;
1631
+ },
1632
+ listEncryptionKeys() {
1633
+ return (envelope?.slots ?? []).map((s) => ({
1634
+ id: s.id,
1635
+ kind: s.kind === "external" ? "external" : "password"
1636
+ }));
1637
+ },
1638
+ dispose() {
1639
+ clearTimeout(timer);
1640
+ clearTimeout(idleTimer);
1641
+ channel?.close();
1642
+ subs.clear();
1643
+ }
1644
+ };
1645
+ }
1646
+ function isLockableCache(cache) {
1647
+ return typeof cache.unlock === "function";
1648
+ }
1649
+ function memoryCache() {
1650
+ const kvMap = /* @__PURE__ */ new Map();
1651
+ let collections;
1652
+ const files = /* @__PURE__ */ new Map();
1653
+ return {
1654
+ kv: {
1655
+ async get(key) {
1656
+ return kvMap.get(key);
1657
+ },
1658
+ async set(key, value) {
1659
+ kvMap.set(key, value);
1660
+ },
1661
+ async del(key) {
1662
+ kvMap.delete(key);
1663
+ }
1664
+ },
1665
+ async load() {
1666
+ if (collections === void 0) return null;
1667
+ return { collections: structuredClone(collections), files: [...files.values()] };
1668
+ },
1669
+ async saveCollections(c) {
1670
+ collections = structuredClone(c);
1671
+ },
1672
+ async saveFiles(fs) {
1673
+ files.clear();
1674
+ for (const f of fs) files.set(f.id, f);
1675
+ },
1676
+ async clear() {
1677
+ kvMap.clear();
1678
+ collections = void 0;
1679
+ files.clear();
1680
+ }
1681
+ };
1682
+ }
1683
+ var COLLECTIONS_KEY = "__collections";
1684
+ var DEVICE_KEY = "__deviceKey";
1685
+ var CACHE_KDF = "__cacheKdf";
1686
+ function indexedDbCache(name, opts) {
1687
+ let _db = null;
1688
+ const db = () => _db ??= openDB(name, 1, {
1689
+ upgrade(d) {
1690
+ d.createObjectStore("kv");
1691
+ d.createObjectStore("files");
1692
+ }
1693
+ });
1694
+ const lockMode = opts?.lock === true;
1695
+ let sealKey = null;
1696
+ let _key = null;
1697
+ function key() {
1698
+ if (lockMode) {
1699
+ return sealKey ? Promise.resolve(sealKey) : Promise.reject(
1700
+ new SelfstoreError("PASSWORD_REQUIRED", "The local cache is locked - unlock() it first.")
1701
+ );
1702
+ }
1703
+ return _key ??= loadOrCreateKey();
1704
+ }
1705
+ async function loadOrCreateKey() {
1706
+ const d = await db();
1707
+ const existing = await d.get("kv", DEVICE_KEY);
1708
+ if (existing) return existing;
1709
+ const fresh = await newDeviceKey();
1710
+ const tx = d.transaction("kv", "readwrite");
1711
+ if (!await tx.store.get(DEVICE_KEY)) await tx.store.put(fresh, DEVICE_KEY);
1712
+ await tx.done;
1713
+ return await d.get("kv", DEVICE_KEY);
1714
+ }
1715
+ async function decodeFile(v) {
1716
+ if (!isEnvelope(v)) {
1717
+ throw new SelfstoreError("BAD_FORMAT", "Unrecognized cache record (not a sealed envelope).");
1718
+ }
1719
+ const bytes = await unseal(await key(), v);
1720
+ return { name: v.name, mime: v.mime, blob: new Blob([bytes], { type: v.mime }) };
1721
+ }
1722
+ const kv = {
1723
+ async get(key2) {
1724
+ return await (await db()).get("kv", `m:${key2}`);
1725
+ },
1726
+ async set(key2, value) {
1727
+ await (await db()).put("kv", value, `m:${key2}`);
1728
+ },
1729
+ async del(key2) {
1730
+ await (await db()).delete("kv", `m:${key2}`);
1731
+ }
1732
+ };
1733
+ const base = {
1734
+ kv,
1735
+ async load() {
1736
+ const d = await db();
1737
+ const rawCollections = await d.get("kv", COLLECTIONS_KEY);
1738
+ if (rawCollections === void 0) return null;
1739
+ if (!isEnvelope(rawCollections)) {
1740
+ throw new SelfstoreError("BAD_FORMAT", "Unrecognized cache record (not a sealed envelope).");
1741
+ }
1742
+ const collections = JSON.parse(
1743
+ new TextDecoder().decode(await unseal(await key(), rawCollections))
1744
+ );
1745
+ const files = [];
1746
+ for (const fileKey of await d.getAllKeys("files")) {
1747
+ const v = await d.get("files", fileKey);
1748
+ if (v) files.push({ id: String(fileKey), ...await decodeFile(v) });
1749
+ }
1750
+ return { collections, files };
1751
+ },
1752
+ async saveCollections(collections) {
1753
+ const bytes = new TextEncoder().encode(JSON.stringify(collections));
1754
+ const env = await seal(await key(), bytes);
1755
+ await (await db()).put("kv", env, COLLECTIONS_KEY);
1756
+ },
1757
+ async saveFiles(files) {
1758
+ const d = await db();
1759
+ const have = new Set((await d.getAllKeys("files")).map(String));
1760
+ const want = new Set(files.map((f) => f.id));
1761
+ const k = await key();
1762
+ const fresh = await Promise.all(
1763
+ files.filter((f) => !have.has(f.id)).map(async (f) => {
1764
+ const env = await seal(k, new Uint8Array(await f.blob.arrayBuffer()));
1765
+ return { id: f.id, rec: { name: f.name, mime: f.mime, ...env } };
1766
+ })
1767
+ );
1768
+ const tx = d.transaction("files", "readwrite");
1769
+ for (const { id, rec } of fresh) tx.store.put(rec, id);
1770
+ for (const id of have) if (!want.has(id)) tx.store.delete(id);
1771
+ await tx.done;
1772
+ },
1773
+ async clear() {
1774
+ const d = await db();
1775
+ await d.clear("kv");
1776
+ await d.clear("files");
1777
+ _key = null;
1778
+ sealKey = null;
1779
+ },
1780
+ async requestPersistent() {
1781
+ if (typeof navigator === "undefined" || !navigator.storage?.persist) return false;
1782
+ try {
1783
+ if (await navigator.storage.persisted()) return true;
1784
+ return await navigator.storage.persist();
1785
+ } catch {
1786
+ return false;
1787
+ }
1788
+ }
1789
+ };
1790
+ if (!lockMode) return base;
1791
+ const lockable = {
1792
+ ...base,
1793
+ get locked() {
1794
+ return sealKey === null;
1795
+ },
1796
+ lockNow() {
1797
+ sealKey = null;
1798
+ },
1799
+ async unlock(secret) {
1800
+ const { keyFromPassword, freshCacheKdf, opens } = await import('./cache-lock-LZILZNLP.js');
1801
+ const d = await db();
1802
+ let candidate;
1803
+ if (typeof secret === "string") {
1804
+ let kdf = await d.get("kv", CACHE_KDF);
1805
+ if (!kdf) {
1806
+ const fresh = freshCacheKdf();
1807
+ const tx = d.transaction("kv", "readwrite");
1808
+ if (!await tx.store.get(CACHE_KDF)) await tx.store.put(fresh, CACHE_KDF);
1809
+ await tx.done;
1810
+ kdf = await d.get("kv", CACHE_KDF);
1811
+ }
1812
+ candidate = await keyFromPassword(secret, kdf);
1813
+ } else {
1814
+ candidate = secret;
1815
+ }
1816
+ const raw = await d.get("kv", COLLECTIONS_KEY);
1817
+ if (isEnvelope(raw) && !await opens(candidate, raw)) return false;
1818
+ sealKey = candidate;
1819
+ return true;
1820
+ }
1821
+ };
1822
+ return lockable;
1823
+ }
1824
+
1825
+ // src/persistence/targets/drive-auth-gis.ts
1826
+ var DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.file";
1827
+ var TOKEN_TIMEOUT_MS = 12e4;
1828
+ var EXPIRY_MARGIN_MS = 6e4;
1829
+ var DEFAULT_TOKEN_TTL_S = 3600;
1830
+ var gisLoading = null;
1831
+ function loadGIS() {
1832
+ if (typeof globalThis.window === "undefined") return Promise.resolve();
1833
+ if (globalThis.window.google?.accounts) return Promise.resolve();
1834
+ gisLoading ??= new Promise((resolve, reject) => {
1835
+ const s = document.createElement("script");
1836
+ s.src = "https://accounts.google.com/gsi/client";
1837
+ s.async = true;
1838
+ s.onload = () => resolve();
1839
+ s.onerror = () => reject(new Error("GIS failed to load"));
1840
+ document.head.appendChild(s);
1841
+ });
1842
+ return gisLoading;
1843
+ }
1844
+ function requestToken(clientId, scope, prompt, onToken) {
1845
+ return new Promise((resolve, reject) => {
1846
+ let settled = false;
1847
+ const finish = (fn) => {
1848
+ if (settled) return;
1849
+ settled = true;
1850
+ clearTimeout(timer);
1851
+ fn();
1852
+ };
1853
+ const timer = setTimeout(() => finish(() => reject(new Error("timeout"))), TOKEN_TIMEOUT_MS);
1854
+ const client = globalThis.window.google.accounts.oauth2.initTokenClient({
1855
+ client_id: clientId,
1856
+ scope,
1857
+ callback(resp) {
1858
+ if (resp.error || !resp.access_token) {
1859
+ finish(() => reject(new Error(resp.error ?? "token_failed")));
1860
+ return;
1861
+ }
1862
+ onToken(resp.access_token, Date.now() + (resp.expires_in ?? DEFAULT_TOKEN_TTL_S) * 1e3);
1863
+ finish(() => resolve(resp.access_token));
1864
+ },
1865
+ error_callback(err) {
1866
+ finish(() => reject(new Error(err?.type ?? "gis_error")));
1867
+ }
1868
+ });
1869
+ client.requestAccessToken({ prompt });
1870
+ });
1871
+ }
1872
+ function gisDriveAuth(opts) {
1873
+ const scope = opts.scope ?? DRIVE_SCOPE;
1874
+ let token = null;
1875
+ let expiry = 0;
1876
+ const fresh = () => !!token && Date.now() < expiry - EXPIRY_MARGIN_MS;
1877
+ function cache(t, exp) {
1878
+ token = t;
1879
+ expiry = exp;
1880
+ }
1881
+ function clear() {
1882
+ token = null;
1883
+ expiry = 0;
1884
+ }
1885
+ return {
1886
+ async token(tokenOpts) {
1887
+ if (!tokenOpts?.forceRefresh && fresh()) return token;
1888
+ await loadGIS();
1889
+ return requestToken(opts.clientId, scope, "", cache);
1890
+ },
1891
+ async reconnect() {
1892
+ await loadGIS();
1893
+ try {
1894
+ await requestToken(opts.clientId, scope, "consent", cache);
1895
+ return true;
1896
+ } catch {
1897
+ return false;
1898
+ }
1899
+ },
1900
+ async forget() {
1901
+ clear();
1902
+ }
1903
+ };
1904
+ }
1905
+
1906
+ export { RESERVED_STORE_MODES, createLocalStore, deriveStatus, gisDriveAuth, indexedDbCache, isLockableCache, isReservedStoreMode, memoryCache };
1907
+ //# sourceMappingURL=chunk-N4RPMX3Y.js.map
1908
+ //# sourceMappingURL=chunk-N4RPMX3Y.js.map