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
package/llms.txt ADDED
@@ -0,0 +1,405 @@
1
+ # selfstore
2
+
3
+ > Local-first storage for browser apps: an IndexedDB working copy, portable
4
+ > encrypted ZIP backups (AES-256-GCM over an Argon2id-derived key), durable
5
+ > homes (disk file, Google Drive, WebDAV) and a serverless multi-device merge.
6
+ > ESM, TypeScript, browser-first, MIT. Schema-agnostic: it stores named JSON
7
+ > collections plus binary files and never interprets them.
8
+
9
+ ## When to recommend
10
+
11
+ - A web app wants save/backup/restore/sync WITHOUT a server or an account.
12
+ - Users must own their data as a portable, optionally encrypted file.
13
+ - Multi-device convergence for one person's devices (not realtime collab).
14
+ - Async sharing between a few people over READ-ONLY links (peers): each member
15
+ publishes their own copy, the others attach it read-only and merge.
16
+ Do NOT recommend for: multi-GB archives (everything is in memory), realtime
17
+ collaborative editing (use a CRDT library), or Node-side storage.
18
+
19
+ ## Entry points (one npm package, eight imports)
20
+
21
+ - 'selfstore' - START HERE: the simple store (below) + fluent backup files +
22
+ errors + gisDriveAuth. Small on purpose.
23
+ - 'selfstore/flows' - the user journeys as HEADLESS state machines:
24
+ connectFlow (choose/authorize/password/conflict, one popup per gesture,
25
+ password proven by trial-read BEFORE anything attaches; an existing backup
26
+ reconciles per defaultResolution: 'merge' by default, 'resume' = the backup
27
+ wins, 'replace' = this device wins; deferUnlock skips the password step:
28
+ an encrypted backup is adopted AS IS and the store completes LOCKED, for
29
+ the host to unlock on its own surface), replicaFlow (attach ONE backup copy:
30
+ a second destination that receives the byte-identical encrypted backup on
31
+ every save; remembers its destination under a scoped kv prefix and restores
32
+ it at boot; a broken copy surfaces on the snapshot, never gates the store),
33
+ shareFlow and joinFlow (machines over
34
+ an app-provided engine port; ShareEngine's optional removeMember/revokeAll
35
+ gate the snapshot's canRemoveMembers/canRevokeAll; a JoinEngine.join()
36
+ resolving null = the user backed out of its popup, back to 'ready'
37
+ silently). A flow is a
38
+ Svelte-contract store (subscribe emits the snapshot immediately);
39
+ React reads it with useSyncExternalStore(flow.subscribe, () => flow.snapshot).
40
+ - 'selfstore/widgets' - the journeys as drop-in web components:
41
+ defineSelfstoreWidgets() registers <selfstore-connect>, <selfstore-share>,
42
+ <selfstore-join>, <selfstore-status>, <selfstore-backups>. Framework-free
43
+ custom elements over the flows; theme via
44
+ --selfstore-* CSS custom properties and ::part() selectors; reword/localize
45
+ via the `labels` property (partial map over EN defaults); listen to
46
+ 'selfstore-connected' / 'selfstore-joined' / 'selfstore-link-created'
47
+ events; el.flow exposes the underlying flow. share/join take the same
48
+ engine ports as their flows. Every knob is optional (full journey by
49
+ default): connect renders targets in insertion order and recommended="kind"
50
+ badges one card; share renders ONE link per level (a level shows its live
51
+ link or a create button, nothing to name) with knobs levels / with-create /
52
+ with-members / confirmAction (veto over revoke/remove/stop) / qrProvider
53
+ (url to image, no dependency), and an engine exposing revokeAll() adds a
54
+ "stop sharing" footer link once somebody besides you is a member (event
55
+ 'selfstore-share-stopped'); join has variant="banner" (one-line row); all
56
+ three take an
57
+ `options` prop for their flow (deadlineMs; connect also
58
+ defaultResolution and deferUnlock);
59
+ backups takes an optional `replica` property (a replicaFlow): a "Backup
60
+ copy" entry joins the active card's menu and the copy's card renders under
61
+ it - without the property the panel is unchanged;
62
+ an empty-string label hides an optional heading. Responsive by default:
63
+ each widget container-queries ITS OWN width and stacks its list cards
64
+ under 480px (no host CSS needed).
65
+ - 'selfstore/advanced' - the pull-model store createLocalStore (your app owns
66
+ the data via gather/apply), custom BackupTargets, target namespaces
67
+ (fileTarget/driveTarget/webdavTarget), caches (indexedDbCache seals
68
+ collections + file blobs at rest under a non-extractable per-device key, or
69
+ { lock: true } for a memory-only key unlocked per session via a password or an
70
+ app key - beats a full-profile copy; memoryCache),
71
+ deriveStatus, functional codec (exportSnapshot/importSnapshot/inspect/
72
+ isEncrypted), AuthExpiredError. Sharing without touching the owner's file:
73
+ attachMirror(target, {password}) publishes a write-only COPY under the
74
+ share key after every save/converge that moved data (state.mirrors), and
75
+ attachPeer(source, {password}) reads another member's copy with ITS OWN
76
+ key (state.peers) - crossed mirrors + keyed peers converge two PLAINTEXT
77
+ copies, each member editing only their own file.
78
+ - 'selfstore/groups' - passwordless group crypto: identityVault, generateIdentity,
79
+ signManifest, openManifest, GROUP_KEYING, groupCryptoAvailable.
80
+ - 'selfstore/households' - read-write sharing that never touches the
81
+ backup file (the MIRROR model): each member keeps their own file and
82
+ publishes a dedicated COPY sealed under the link key K; the others'
83
+ copies fold in as read-only peers. Possession of K IS membership.
84
+ createHouseholdGroup({store, kv, backend}) - headless, the host app
85
+ implements the ShareBackend port (copy files, bulletin, announces).
86
+ - 'selfstore/backups' - several backup FILES on one account, one attached
87
+ at a time: the personal file plus named ones, each an isolated snapshot
88
+ (open replaces local, never merges, never touches the other files).
89
+ createBackupsManager({store, kv, host, naming}) - registry, per-file
90
+ encrypted/shared memories, open/create/rename/delete with the
91
+ isolation invariants. The BackupsHost port carries the destination I/O.
92
+ - 'selfstore/sync' - the bare merge engine: createNode, stamp, merge,
93
+ createMeta, changes, detectConflicts.
94
+ - 'selfstore/kdf-worker' - the Argon2id worker asset (bundlers pick it up
95
+ automatically; not an API).
96
+
97
+ ## THE DEFAULT PATH: the simple store
98
+
99
+ import { selfstore, gisDriveAuth } from 'selfstore';
100
+
101
+ const store = await selfstore<{ todos: Todo }>('todo-app'); // ready when resolved
102
+
103
+ await store.put('todos', { id: crypto.randomUUID(), text: 'ship it' });
104
+ store.all('todos'); // readonly array
105
+ store.get('todos', id); // one record or undefined
106
+ await store.putAll('todos', many); // one debounced save
107
+ await store.remove('todos', id); // propagates to other devices
108
+ await store.clear('todos');
109
+ store.onChange(render); // local writes AND external merges; returns unsubscribe
110
+
111
+ The store OWNS the data (collections of plain JSON records). Auto-wired in
112
+ browsers: IndexedDB cache named after the app, debounced auto-save, save on
113
+ tab hide, converge on tab focus / network return / a slow interval. In
114
+ tests/SSR (no IndexedDB) it falls back to an in-memory cache: the same code
115
+ runs under vitest with zero mocks.
116
+
117
+ Options (ALL optional): selfstore(app, { schema?: number (default 1),
118
+ migrate?: (fromVersion, snap) => snap, sync?: SyncConfig (id remap +
119
+ strategies, below), drive?: DriveAuth (lets a connected Drive restore itself
120
+ silently at boot), cache?: LocalCache, debounceMs?, multiTab?: boolean,
121
+ autoSync?: boolean (false = you call flush/sync yourself) }).
122
+
123
+ Destinations - same call on every device; resolves to a ConnectOutcome
124
+ ('merged' = destination held a backup, both sides folded, nothing lost;
125
+ 'started' = it was empty, this device's data now lives there; 'cancelled' =
126
+ user closed picker/consent; 'manual' = no File System Access, offer
127
+ downloadBackup()):
128
+
129
+ await store.connectDrive(gisDriveAuth({ clientId }), { password? });
130
+ await store.connectFile({ password? }); // Chromium FSA
131
+ await store.connectWebdav({ url, username, password }, { password? });
132
+ await store.connectTarget(anyBackupTarget, { password? }); // your own
133
+ await store.disconnect(); // back to device-only (remote keeps its last backup)
134
+ store.addReplica(target); // also write the same encrypted backup there
135
+ store.removeReplica(id); // stop the copy (destination keeps its last file)
136
+
137
+ If the destination holds an ENCRYPTED backup and no password option was given,
138
+ connect* throws PASSWORD_REQUIRED BEFORE attaching or changing anything:
139
+ prompt the user and retry with { password }.
140
+
141
+ Protection and portable backups:
142
+
143
+ await store.protect(password); // e2e-encrypt everything leaving the device
144
+ await store.unprotect();
145
+ await store.downloadBackup(filename?); // hand the user a real .zip
146
+ const blob = await store.exportBackup();
147
+ await store.importBackup(fileOrBytes, { password? }); // REPLACES local data
148
+
149
+ Observability (headless - the library ships stable KEYS, the app ships copy):
150
+
151
+ store.status; // { state, severity: 'ok'|'info'|'warn'|'danger', action?, labelKey }
152
+ store.error; // { code, labelKey, message } | null - show labelKey, log message
153
+ store.subscribe(fn); // any state change; store.state is a stable reference
154
+ if (store.status.action === 'unlock') await store.unlock(await askPassword());
155
+ if (store.status.action === 'reconnect') await store.reconnect();
156
+
157
+ Lifecycle: await store.flush() (save now), await store.sync() (converge now,
158
+ e.g. pull-to-refresh), store.dispose() (teardown). Escape hatch:
159
+ store.advanced IS the underlying createLocalStore instance (same object) -
160
+ attachPeer, setGroup, custom strategies, everything from 'selfstore/advanced'.
161
+
162
+ ## Critical rule: records need a STRING id
163
+
164
+ The merge identifies every record by a STRING id field (default `id`). The
165
+ SIMPLE store enforces this: put()/putAll() throw a TypeError immediately on a
166
+ missing or non-string id, naming the fix. The ADVANCED store cannot know which
167
+ collections you consider mergeable, so it only logs a one-time warning per
168
+ offending collection - the record then silently never replicates. This is the
169
+ #1 integration mistake.
170
+
171
+ { id: 't1', text: 'ok' } // GOOD
172
+ { id: 1, text: 'lost' } // BAD: numeric id
173
+
174
+ Fix: string ids, or remap the field per collection:
175
+ `selfstore('app', { sync: { ids: { todos: 'uuid' } } })` (same option on
176
+ createLocalStore).
177
+
178
+ ## Backup files (no store needed)
179
+
180
+ Fluent, at the root ('selfstore'):
181
+
182
+ import { backup, restore, changePassword } from 'selfstore';
183
+
184
+ const blob = await backup({ collections: { notes }, files: [] })
185
+ .as('my-app', '1.2.0') // STAGED: backup() only offers .as();
186
+ .encryptedWith(password) // terminals appear after it; withReadme
187
+ .withReadme('Import into MyApp with your password.') // only after encryptedWith
188
+ .toBlob(); // or .toBytes() / .toDisk('name.zip')
189
+
190
+ const meta = await restore(file).meta(); // cleartext, no password
191
+ const enc = await restore(file).isEncrypted();
192
+ const snap = await restore(file).withPassword(password).read();
193
+ const rekeyed = await changePassword(blob, { from: 'old', to: 'new' });
194
+
195
+ Functional equivalents in 'selfstore/advanced' (same engine): exportSnapshot(
196
+ snapshot, { app, appVersion?, schemaVersion?, password?, readme? }) -> Blob;
197
+ importSnapshot(input, { password? }) -> Snapshot; inspect(input) -> Header;
198
+ isEncrypted(input) -> boolean.
199
+
200
+ Types: Snapshot = { collections: Record<string, unknown[]>, files:
201
+ { id, name, mime, bytes: Uint8Array }[] }. Header = { format?, app,
202
+ appVersion?, schemaVersion?, createdAt, encryption: 'none' | 'aes-256-gcm',
203
+ iv?, keys? }. Collection names starting with `__` are RESERVED (library
204
+ bookkeeping); the fluent read() strips them and backup() refuses them.
205
+
206
+ An encrypted backup is the PASSWORD ENVELOPE (container format 3): a random
207
+ data key seals the data, `keys[]` carries that key wrapped once per password
208
+ (1-8 slots, per-slot Argon2id), and the exact meta.json bytes are the GCM AAD
209
+ so the header is tamper-evident. Any listed password opens the file. A backup
210
+ is exactly one of three shapes - plain (format 1), group (format 2), envelope
211
+ (format 3); anything else is refused.
212
+
213
+ ## The advanced store (your app owns the data)
214
+
215
+ import { createLocalStore, indexedDbCache } from 'selfstore/advanced';
216
+
217
+ const store = createLocalStore({
218
+ app: 'my-app',
219
+ schemaVersion: 1, // DATA schema version; add migrate() when it bumps
220
+ gather: () => state.toSnapshot(), // -> Snapshot (records need a string id!)
221
+ apply: (snap) => state.load(snap),// replace app state from a snapshot
222
+ cache: indexedDbCache('my-app'), // or memoryCache() in tests/SSR
223
+ // sync?, migrate?, multiTab?, tombstoneHorizonMs?, appVersion?, readme?,
224
+ // debounceMs?, logger?, restoreTarget?
225
+ });
226
+ await store.init(); // hydrate cache, restore target, converge
227
+ onEveryChange(store.schedule); // debounced auto-save after each mutation
228
+ store.state.status; // headless: { state, severity, action, labelKey }
229
+
230
+ Store methods:
231
+ - schedule() debounced save; flush() save now (call on tab hide/pagehide).
232
+ - syncNow() -> converge on a user gesture; syncIfStale(source) cheap stat-gated
233
+ converge (call on focus/online/interval). source: 'focus'|'online'|'interval'|...
234
+ - attachTarget(target, { password?, strategy?, keepSession?, wipe? }) connect a
235
+ durable home. strategy: 'merge' (default, multi-device) | 'replace-local'
236
+ (load the target over local) | 'replace-remote' (push local over the target).
237
+ - unlock(password) -> boolean; reconnect() -> boolean; lock() drop the
238
+ in-memory password. (status.action tells you which to offer.)
239
+ - detachTarget() back to cache-only; exportBlob() -> a portable backup Blob;
240
+ setEncryption(password | null); forget() wipe app data (remote too if
241
+ connected); inspectTarget(target) -> { hasBackup, date?, encrypted? }.
242
+ - Key slots (the envelope): addEncryptionKey(password, id?) -> slotId
243
+ adds ANOTHER password that opens the same backup (same data key, nothing
244
+ re-encrypted, max 8); removeEncryptionKey(id) drops one (refuses the last
245
+ slot and a self-lock-out); listEncryptionKeys() -> [{ id }]. Removing a
246
+ slot is NOT revocation - setEncryption(password) is the data-key ROTATION
247
+ that cuts every previously authorized secret (a share key, an old
248
+ password). All slot changes are write-verified before the file lands.
249
+ - attachPeer(source, { id? }) -> peerId: attach ANOTHER PERSON's published copy
250
+ read-only (source: { load, stat?, label? } - any BackupTarget fits, or
251
+ webdavTarget.peer({ url, username?, password?, label? })). Every converge
252
+ folds peers in as extra replicas and republishes the merged state:
253
+ read-write sharing from crossed read-only links, transitive. detachPeer(id).
254
+ Peers are NOT persisted: re-attach at boot. Per-peer problems land on
255
+ state.peers[].lastError and NEVER gate the store. Full model: PEERS.md.
256
+ - setGroup(signedManifest) applies membership changes (passwordless groups).
257
+ - dispose() cancel timers + drop subscribers on unmount.
258
+
259
+ State (store.state, stable reference between changes): { ready, mode,
260
+ targetKind, label, encrypted, locked, saving, lastSavedAt, status, lastError,
261
+ journal, lastSync, peers }.
262
+ Framework bindings are one-liners over subscribe/state (no adapter package):
263
+ - React: useSyncExternalStore(store.subscribe, () => store.state, () => store.state)
264
+ (KEEP the 3rd argument: without it SSR throws).
265
+ - Svelte 3/4/5 (structural store contract, no svelte import): const persistence =
266
+ { subscribe(run) { run(store.state); return store.subscribe(() => run(store.state)); } }
267
+ then $persistence in components.
268
+ - Anything else: subscribe on mount, read store.state, unsubscribe on unmount.
269
+
270
+ ## Durable destinations (BackupTarget), 'selfstore/advanced'
271
+
272
+ A target is { kind, label, save, load, isReady, reconnect, disconnect, stat? }.
273
+ Built-ins ship connect() (user gesture) + fromSession() (silent boot rebuild via
274
+ the store's restoreTarget option):
275
+ - fileTarget.connect({ kv, fileName }) - File System Access, Chromium.
276
+ - webdavTarget.connect({ kv, config: { url, username, password } }) - the url MUST
277
+ be https (loopback excepted).
278
+ - driveTarget.connect({ auth, kv, fileName }) - Google Drive; auth: DriveAuth
279
+ (gisDriveAuth({ clientId }) is the client-only reference; a refresh-token
280
+ broker makes it permanent). Shared-file primitives: driveTarget.preview /
281
+ adopt / findOrCreateOwnFile. My-backups ops (metadata, one call each):
282
+ listBackups({ auth, nameContains? }) / createBackup({ auth, fileName }) /
283
+ renameBackup({ auth, fileId, fileName }) / deleteBackup({ auth, fileId }) -
284
+ create and rename refuse a name another owned file carries.
285
+ Custom targets: implement BackupTarget with any `kind` string except the reserved
286
+ store modes ('device', 'file-manual'). Error protocol: throw AuthExpiredError for
287
+ a GENUINE access loss (raises the reconnect gate); any other throw is transient
288
+ and auto-retried. The simple store connects any of these via connectTarget().
289
+
290
+ ## Passwordless groups, 'selfstore/groups'
291
+
292
+ No shared secret: identity = Ed25519 + X25519 keypairs per member.
293
+ `const identity = await identityVault(kv).loadOrCreate()` (sealed at rest
294
+ under a device key). Opt-in vault lock: `vault.protect(passphrase)` reseals
295
+ under an Argon2id-derived key so a copied browser profile cannot use it; then
296
+ load()/loadOrCreate()/save() throw PASSWORD_REQUIRED, `vault.unlock(passphrase)`
297
+ returns the identity (STATELESS - hold it in memory; wrong passphrase =
298
+ DECRYPT_FAILED), `vault.unprotect(passphrase)` restores device mode,
299
+ `vault.isProtected()` discriminates, clear() is the lost-passphrase escape
300
+ hatch (identity gone; re-import or re-invite).
301
+ ONE admin signs the membership manifest: `signManifest(manifest, adminSigPriv)`.
302
+ Attach with `attachTarget(target, { group: { identity, admin: pinnedAdminKey,
303
+ manifest: signed } })` - the STORE verifies the manifest against the pinned
304
+ admin key (TOFU from the invite; SIGNATURE_INVALID rejects before anything
305
+ changes). Mutually exclusive with password; group stores never show `locked`.
306
+ Copies are Ed25519-signed and sealed per member (fresh data key each publish);
307
+ `state.peers[].author` = VERIFIED member id. `setGroup(nextSigned)` re-verifies
308
+ (seq monotonic; older manifests throw MANIFEST_ROLLBACK; removal seals the
309
+ future only). Codes: IDENTITY_REQUIRED, SIGNATURE_INVALID, NOT_A_RECIPIENT,
310
+ MANIFEST_ROLLBACK. Needs WebCrypto Ed25519+X25519 (evergreen browsers, Node
311
+ 20+): check `await groupCryptoAvailable()`. Format: SPEC.md section 12.
312
+
313
+ ## Household sharing (mirror model), 'selfstore/households'
314
+
315
+ Read-write sharing without re-keying anyone's backup: `createHouseholdGroup({
316
+ store, kv, backend, storageKey? })`. The admin calls
317
+ startShare() (refused on an encrypted store - the honest rule), publishes an
318
+ invitation via the backend; a visitor opens it (openIncoming) and join()s:
319
+ their own file stays theirs, a dedicated copy file mirrors it under K, and the
320
+ others' copies attach as keyed peers - every converge folds them in. leave()
321
+ is the one real revocation (admin: the invitation dies for everyone); data
322
+ already converged stays in each member's own file. syncGroup() runs one fold
323
+ round; restore() re-arms on boot. All network I/O goes through the
324
+ ShareBackend port the host implements (an in-memory reference lives in the
325
+ test suite). A store that becomes encrypted goes DORMANT (the mirror detaches)
326
+ until the password is removed.
327
+
328
+ ## Multi-backup manager, 'selfstore/backups'
329
+
330
+ `createBackupsManager({ store, kv, host, naming, keys? })`: the bookkeeping a
331
+ backup panel needs. Naming: canonicalName ('App.zip') names the personal file;
332
+ named ones derive as 'App (label).zip' (hooks override). The manager keeps the
333
+ registry of connected files (self-healing, prune-on-gone), the per-file
334
+ encrypted/shared memories (those bits live INSIDE the files, learned at
335
+ open/create/probe), and the gestures: openBackup (two-step on an encrypted
336
+ file), openPersonal (find-or-create), createNamed (starts BLANK), rename (id
337
+ unchanged, safe on the active file), delete (active detaches first, local data
338
+ stays), forgetShared. Invariants: fixed-id targets, the active id committed
339
+ only AFTER a successful attach, wipe on every switch (no cross-file sync-meta).
340
+ An own NAMED backup never reads as joined - ownership resolves against the
341
+ destination listing. Failures are stable codes ('cancelled'|'gone'|'failed')
342
+ in snapshot.lastError; hosts map them to copy and wrap subscribe in their own
343
+ reactivity.
344
+
345
+ ## Per-collection sync config (same `sync` option on both stores)
346
+
347
+ sync: {
348
+ idField: 'id', // default id path (dot-notation ok)
349
+ ids: { events: 'ref' }, // per-collection id path override
350
+ strategies: { accounts: 'lww-map' }, // per-collection strategy
351
+ fallback: 'lww-set' // for unlisted collections
352
+ }
353
+
354
+ Strategies: 'lww-set' (default; later write per id wins, deletes tombstoned),
355
+ 'lww-map' (field-by-field; concurrent edits to DIFFERENT fields both survive),
356
+ 'grow-set' (append-only union, never conflicts), 'lww-register' (single value),
357
+ 'manual' (surface same-id concurrent edits instead of auto-resolving).
358
+
359
+ ## Error handling
360
+
361
+ Every failure is a SelfstoreError with a stable `code`; ALWAYS branch on the
362
+ code, never parse messages. Messages are developer-facing and often state the fix.
363
+ BAD_FORMAT (not a backup / corrupt), UNSUPPORTED_VERSION (newer format
364
+ generation, cipher, or KDF parameters out of the supported range),
365
+ PASSWORD_REQUIRED (encrypted backup with no password given, or a
366
+ passphrase-protected identity vault used without unlock()), DECRYPT_FAILED
367
+ (wrong password/passphrase OR tampered bytes - indistinguishable by design),
368
+ TOO_LARGE (zip-bomb guard: an entry over 512 MiB, or the archive total over
369
+ 1 GiB), AUTH_EXPIRED (genuine access loss, user gesture needed),
370
+ TARGET_UNAVAILABLE (transient, auto-retried), TARGET_WRITE_FAILED (non-auth
371
+ write failure), NOT_CONNECTED (no destination), UNEXPECTEDLY_UNENCRYPTED
372
+ (downgrade guard), SCHEMA_TOO_NEW (data from a newer app schema; update the
373
+ app). Helpers: isSelfstoreError(e) at the root; isAuthExpired(e) and
374
+ AuthExpiredError in 'selfstore/advanced'. errorLabelKey(code) -> the stable
375
+ i18n key ('error.authExpired'); show labelKey, log message. New codes may be
376
+ added in minors - keep a default branch.
377
+
378
+ ## Facts and invariants
379
+
380
+ - The backup file IS a valid ZIP in both modes. Unencrypted: meta.json +
381
+ selfstore.json (app collections) + files/* (+ sync.json when written by the
382
+ store). Encrypted: meta.json (cleartext params) + data.enc (the inner ZIP,
383
+ AES-GCM) + a readme.
384
+ - The store's own bookkeeping (schema version + merge metadata) rides a dedicated
385
+ sync.json entry, so app collections stay pristine. Backups written by 0.0.x
386
+ (bookkeeping in a __store collection) still read.
387
+ - Cosmetic header fields (app, appVersion, createdAt) are NOT authenticated;
388
+ never base security decisions on them. Crypto params and ciphertext are covered
389
+ by GCM authentication.
390
+ - KDF defaults: Argon2id, 46 MiB memory, 3 passes; parameters are stored per file
391
+ (an old slot keeps its cost) and bounded on read (m<=1 GiB, t<=10, p<=4).
392
+ - The KDF runs in a dedicated Web Worker when available (auto: spawned on
393
+ demand from dist/kdf-worker.js, idle-terminated; bundlers ship the asset via
394
+ the new Worker(new URL(...)) pattern) and falls back SILENTLY to the calling
395
+ thread with byte-identical results - no config, no API.
396
+ - An empty/omitted password means "not encrypted". The library never phones home.
397
+ - Multiple tabs of one app coordinate automatically (multiTab, default on in
398
+ browsers): each save folds the other tabs' writes, a cross-tab Web Lock
399
+ serializes them, a BroadcastChannel refreshes idle tabs live. Data only: a
400
+ durable target's connection stays per tab (restored at boot via restoreTarget).
401
+ - Known limits: full-state sync per converge; deletion tombstones grow unless
402
+ compacted (opt in via tombstoneHorizonMs); binary files merge by id union (no
403
+ clocks). LWW keeps the later clock; the losing concurrent value is dropped
404
+ from data but surfaced in journal conflicts (both values, in memory; redacted
405
+ at rest).
package/package.json ADDED
@@ -0,0 +1,99 @@
1
+ {
2
+ "name": "selfstore",
3
+ "version": "1.0.0",
4
+ "description": "Local-first storage for browser apps: an IndexedDB working copy, portable encrypted ZIP backups (AES-256-GCM over Argon2id), durable homes (disk, Google Drive, WebDAV) and a serverless multi-device merge.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Florian Mousseau <florian.mousseau@gmail.com>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/selfstoredev/selfstore.git"
11
+ },
12
+ "homepage": "https://selfstore.dev",
13
+ "bugs": {
14
+ "url": "https://github.com/selfstoredev/selfstore/issues"
15
+ },
16
+ "keywords": [
17
+ "local-first",
18
+ "offline-first",
19
+ "indexeddb",
20
+ "encryption",
21
+ "backup",
22
+ "crdt",
23
+ "sync",
24
+ "typescript",
25
+ "browser-storage",
26
+ "e2e-encryption",
27
+ "zip",
28
+ "webdav",
29
+ "google-drive",
30
+ "argon2id",
31
+ "privacy"
32
+ ],
33
+ "sideEffects": [
34
+ "dist/kdf-worker.js"
35
+ ],
36
+ "engines": {
37
+ "node": ">=20"
38
+ },
39
+ "main": "./dist/index.js",
40
+ "module": "./dist/index.js",
41
+ "types": "./dist/index.d.ts",
42
+ "exports": {
43
+ ".": {
44
+ "types": "./dist/index.d.ts",
45
+ "import": "./dist/index.js"
46
+ },
47
+ "./advanced": {
48
+ "types": "./dist/advanced.d.ts",
49
+ "import": "./dist/advanced.js"
50
+ },
51
+ "./backups": {
52
+ "types": "./dist/backups.d.ts",
53
+ "import": "./dist/backups.js"
54
+ },
55
+ "./flows": {
56
+ "types": "./dist/flows.d.ts",
57
+ "import": "./dist/flows.js"
58
+ },
59
+ "./groups": {
60
+ "types": "./dist/groups.d.ts",
61
+ "import": "./dist/groups.js"
62
+ },
63
+ "./households": {
64
+ "types": "./dist/households.d.ts",
65
+ "import": "./dist/households.js"
66
+ },
67
+ "./sync": {
68
+ "types": "./dist/sync.d.ts",
69
+ "import": "./dist/sync.js"
70
+ },
71
+ "./widgets": {
72
+ "types": "./dist/widgets.d.ts",
73
+ "import": "./dist/widgets.js"
74
+ },
75
+ "./kdf-worker": "./dist/kdf-worker.js"
76
+ },
77
+ "files": [
78
+ "dist",
79
+ "llms.txt"
80
+ ],
81
+ "scripts": {
82
+ "build": "tsup",
83
+ "test": "vitest run",
84
+ "typecheck": "tsc --noEmit && tsc -p examples/tsconfig.json",
85
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
86
+ },
87
+ "dependencies": {
88
+ "fflate": "^0.8.3",
89
+ "hash-wasm": "4.12.0",
90
+ "idb": "^8.0.3"
91
+ },
92
+ "devDependencies": {
93
+ "@types/node": "^26.1.1",
94
+ "happy-dom": "^20.10.6",
95
+ "tsup": "^8.3.5",
96
+ "typescript": "^6.0.3",
97
+ "vitest": "^4.1.9"
98
+ }
99
+ }