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.
- package/LICENSE +21 -0
- package/README.md +364 -0
- package/dist/advanced.d.ts +90 -0
- package/dist/advanced.js +41 -0
- package/dist/advanced.js.map +1 -0
- package/dist/backups.d.ts +182 -0
- package/dist/backups.js +360 -0
- package/dist/backups.js.map +1 -0
- package/dist/cache-De5_sALA.d.ts +129 -0
- package/dist/cache-lock-LZILZNLP.js +23 -0
- package/dist/cache-lock-LZILZNLP.js.map +1 -0
- package/dist/chunk-ATUPHVLG.js +441 -0
- package/dist/chunk-ATUPHVLG.js.map +1 -0
- package/dist/chunk-BJ3RUMGI.js +45 -0
- package/dist/chunk-BJ3RUMGI.js.map +1 -0
- package/dist/chunk-CUWB5EWF.js +732 -0
- package/dist/chunk-CUWB5EWF.js.map +1 -0
- package/dist/chunk-FDPAKHQS.js +230 -0
- package/dist/chunk-FDPAKHQS.js.map +1 -0
- package/dist/chunk-IBIEKK36.js +331 -0
- package/dist/chunk-IBIEKK36.js.map +1 -0
- package/dist/chunk-JB4JHM7V.js +474 -0
- package/dist/chunk-JB4JHM7V.js.map +1 -0
- package/dist/chunk-N4RPMX3Y.js +1908 -0
- package/dist/chunk-N4RPMX3Y.js.map +1 -0
- package/dist/chunk-OWLX6PNT.js +231 -0
- package/dist/chunk-OWLX6PNT.js.map +1 -0
- package/dist/chunk-QBCU7OOP.js +20 -0
- package/dist/chunk-QBCU7OOP.js.map +1 -0
- package/dist/chunk-SGISYMT5.js +46 -0
- package/dist/chunk-SGISYMT5.js.map +1 -0
- package/dist/chunk-VBFMMR7Q.js +29 -0
- package/dist/chunk-VBFMMR7Q.js.map +1 -0
- package/dist/drive-Bv94UM8b.d.ts +222 -0
- package/dist/flows.d.ts +306 -0
- package/dist/flows.js +153 -0
- package/dist/flows.js.map +1 -0
- package/dist/groups.d.ts +39 -0
- package/dist/groups.js +103 -0
- package/dist/groups.js.map +1 -0
- package/dist/households.d.ts +214 -0
- package/dist/households.js +410 -0
- package/dist/households.js.map +1 -0
- package/dist/index.d.ts +205 -0
- package/dist/index.js +249 -0
- package/dist/index.js.map +1 -0
- package/dist/kdf-worker.js +2395 -0
- package/dist/kdf-worker.js.map +1 -0
- package/dist/local-2py_vGGy.d.ts +47 -0
- package/dist/local-AM22RWRR.js +7 -0
- package/dist/local-AM22RWRR.js.map +1 -0
- package/dist/machine-ALMkT3mk.d.ts +17 -0
- package/dist/store-ChIGEOph.d.ts +577 -0
- package/dist/sync.d.ts +107 -0
- package/dist/sync.js +3 -0
- package/dist/sync.js.map +1 -0
- package/dist/widgets.d.ts +426 -0
- package/dist/widgets.js +2579 -0
- package/dist/widgets.js.map +1 -0
- package/llms.txt +405 -0
- 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
|
+
}
|