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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Florian Mousseau
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
# selfstore
|
|
2
|
+
|
|
3
|
+
**Local-first storage for browser apps, batteries included.** Your data lives
|
|
4
|
+
on the user's device, syncs across their devices without a server, and leaves
|
|
5
|
+
as a portable encrypted backup they actually own.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { selfstore } from 'selfstore';
|
|
9
|
+
|
|
10
|
+
const store = await selfstore('todo-app');
|
|
11
|
+
|
|
12
|
+
await store.put('todos', { id: 't1', text: 'ship it' });
|
|
13
|
+
store.all('todos'); // [{ id: 't1', text: 'ship it' }]
|
|
14
|
+
store.onChange(render); // local writes AND merges from other devices
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
That is a working app: saving to IndexedDB, debounced auto-save on every
|
|
18
|
+
mutation, and the browser sync moments (tab focus, network return, tab hide,
|
|
19
|
+
a slow interval) are already wired. No schema ceremony, no server, nothing to
|
|
20
|
+
host.
|
|
21
|
+
|
|
22
|
+
Docs, guides, a live demo and honest comparisons with the alternatives:
|
|
23
|
+
**[selfstore.dev](https://selfstore.dev)**.
|
|
24
|
+
|
|
25
|
+
## Going further is one call each
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// Multi-device sync: connect a destination the USER owns.
|
|
29
|
+
await store.connectDrive(gisDriveAuth({ clientId })); // Google Drive
|
|
30
|
+
await store.connectFile(); // a file on disk (Chromium)
|
|
31
|
+
await store.connectWebdav({ url, username, password: appPassword }); // Nextcloud & friends
|
|
32
|
+
|
|
33
|
+
// End-to-end encryption of everything that leaves the device.
|
|
34
|
+
await store.protect('a passphrase the user chose');
|
|
35
|
+
|
|
36
|
+
// A portable encrypted .zip the user can walk away with.
|
|
37
|
+
await store.downloadBackup();
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`connectDrive` / `connectFile` / `connectWebdav` all resolve to what actually
|
|
41
|
+
happened: `'merged'` (the destination already held data; both sides were
|
|
42
|
+
folded together, nothing lost), `'started'` (it was empty; this device's data
|
|
43
|
+
now lives there), `'cancelled'` (the user closed the picker), or `'manual'`
|
|
44
|
+
(no File System Access; offer `downloadBackup()` instead). If the destination
|
|
45
|
+
holds an encrypted backup and no password was given, the call throws
|
|
46
|
+
`PASSWORD_REQUIRED` BEFORE touching anything, so you can prompt and retry.
|
|
47
|
+
|
|
48
|
+
## The one rule
|
|
49
|
+
|
|
50
|
+
Records need a non-empty **string `id`**: it is what the multi-device merge
|
|
51
|
+
keys on. `put()` enforces it immediately with a clear error instead of letting
|
|
52
|
+
the record silently never sync. A different field name is one option away:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
const store = await selfstore('crm', { sync: { ids: { contacts: 'uuid' } } });
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Everything else about your records is your business: plain JSON in, plain JSON
|
|
59
|
+
out, no proxies, no injected fields, no ORM to fight.
|
|
60
|
+
|
|
61
|
+
## Status and errors are headless
|
|
62
|
+
|
|
63
|
+
The store never ships UI copy. It exposes a status descriptor
|
|
64
|
+
(`store.status`: `severity`, optional `action`, a stable `labelKey` like
|
|
65
|
+
`status.synced`) and typed errors (`store.error`: `{ code, labelKey }`, keys
|
|
66
|
+
like `error.authExpired`). You map the keys to your own wording and language;
|
|
67
|
+
the raw English `message` is for logs only.
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
store.subscribe(() => statusBar.textContent = t(store.status.labelKey));
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Transient trouble (offline, a cold-started backend, a 5xx) never raises a
|
|
74
|
+
scary reconnect dialog: the edit stays safe in the local cache and the next
|
|
75
|
+
save or sync retries. Only a GENUINE loss of access (token revoked, permission
|
|
76
|
+
withdrawn) surfaces as `AUTH_EXPIRED` with a `reconnect` action.
|
|
77
|
+
|
|
78
|
+
| Code | Meaning |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `BAD_FORMAT` | Not a backup file, or corrupt framing. |
|
|
81
|
+
| `UNSUPPORTED_VERSION` | Written by a newer format generation, cipher or KDF. |
|
|
82
|
+
| `PASSWORD_REQUIRED` | Encrypted backup opened without a password. |
|
|
83
|
+
| `DECRYPT_FAILED` | Wrong password, or tampered/corrupt ciphertext. |
|
|
84
|
+
| `TOO_LARGE` | An archive entry exceeds the zip-bomb guard (512 MiB). |
|
|
85
|
+
| `AUTH_EXPIRED` | Access to the destination genuinely lost; a user gesture reconnects. |
|
|
86
|
+
| `TARGET_UNAVAILABLE` | Transient: offline, cold start, 5xx. Retried automatically. |
|
|
87
|
+
| `TARGET_WRITE_FAILED` | The destination refused or failed the write (non-auth). |
|
|
88
|
+
| `NOT_CONNECTED` | The target has no connected destination. |
|
|
89
|
+
| `ENCRYPTION_REQUIRED` | `requireEncryption` is set: a plaintext attach, backup or export is refused. |
|
|
90
|
+
| `WEAK_PASSWORD` | The password fails the store's `passwordPolicy`. |
|
|
91
|
+
| `UNEXPECTEDLY_UNENCRYPTED` | Downgrade guard: expected encrypted, found plaintext. |
|
|
92
|
+
| `SCHEMA_TOO_NEW` | Data written by a newer app schema; update the app to sync. |
|
|
93
|
+
|
|
94
|
+
Branch on `err.code`, never parse messages. New codes may be ADDED in a minor
|
|
95
|
+
release, so keep a `default` branch in exhaustive switches.
|
|
96
|
+
|
|
97
|
+
## Backup files, on their own
|
|
98
|
+
|
|
99
|
+
The backup is a real ZIP, specified independently of this library
|
|
100
|
+
([SPEC.md](./SPEC.md), with a ~120-line Python reference reader and canonical
|
|
101
|
+
test vectors): unencrypted it opens in any archive tool; encrypted it is still
|
|
102
|
+
a valid ZIP holding the AES-256-GCM ciphertext, the cleartext parameters and a
|
|
103
|
+
readme - never a mystery blob. Working with backup FILES needs no store:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { backup, restore, changePassword } from 'selfstore';
|
|
107
|
+
|
|
108
|
+
// Write: fluent and STAGED - an illegal order does not compile.
|
|
109
|
+
const blob = await backup({ collections: { notes }, files: [] })
|
|
110
|
+
.as('my-app', '1.2.0')
|
|
111
|
+
.encryptedWith(password) // omit for a plain, browsable ZIP
|
|
112
|
+
.withReadme('Import this file into MyApp with your password.')
|
|
113
|
+
.toBlob(); // or .toBytes(), or .toDisk('my-backup.zip')
|
|
114
|
+
|
|
115
|
+
// Read
|
|
116
|
+
const meta = await restore(file).meta(); // cleartext, no password needed
|
|
117
|
+
if (await restore(file).isEncrypted()) { /* ask the user */ }
|
|
118
|
+
const snap = await restore(file).withPassword(password).read();
|
|
119
|
+
|
|
120
|
+
// Rotate, add or remove the password
|
|
121
|
+
const rekeyed = await changePassword(blob, { from: 'old', to: 'new' });
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## When you need more: the deeper layers
|
|
125
|
+
|
|
126
|
+
The package root stays small on purpose. The machinery underneath is public
|
|
127
|
+
too, as subpaths of the same install:
|
|
128
|
+
|
|
129
|
+
| Import | What it is | Reach for it when |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| `selfstore` | the simple store + backup files | almost always: start here |
|
|
132
|
+
| `selfstore/flows` | the user journeys (connect a destination, share panel, join an invitation) as headless, tested state machines | you are building the connect/share/join UI and want the ordering and failure rules - one popup per gesture, password proven before attach, merge by default - already right |
|
|
133
|
+
| `selfstore/widgets` | the same journeys as drop-in web components (`<selfstore-connect>`, `<selfstore-share>`, `<selfstore-join>`) - framework-free, themable via CSS custom properties and `::part()`, reworded/localized via a `labels` map | you want the screens ready-made and your app's look on top |
|
|
134
|
+
| `selfstore/advanced` | the pull-model store (`createLocalStore`), custom `BackupTarget`s, caches, headless status derivation, functional codec | your state lives in its own reactive model (Svelte runes, Redux), or you are writing a destination (S3, your KV...) |
|
|
135
|
+
| `selfstore/groups` | passwordless group encryption: per-member identities, sealed envelopes, signed membership manifests | several PEOPLE share one encrypted store without a shared password ([PEERS.md](./PEERS.md)) |
|
|
136
|
+
| `selfstore/sync` | the bare merge engine (HLC + per-collection strategies, no CRDT runtime) | you only want the algorithm inside your own persistence ([SYNC docs in SPEC.md](./SPEC.md)) |
|
|
137
|
+
|
|
138
|
+
`store.advanced` on the simple store IS the `selfstore/advanced` store - same
|
|
139
|
+
instance - so the two styles compose instead of competing: start simple, reach
|
|
140
|
+
down for one advanced call (`attachPeer`, `setGroup`, a custom target), keep
|
|
141
|
+
the rest.
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { createLocalStore, indexedDbCache } from 'selfstore/advanced';
|
|
145
|
+
|
|
146
|
+
const store = createLocalStore({
|
|
147
|
+
app: 'my-app',
|
|
148
|
+
schemaVersion: 1,
|
|
149
|
+
gather: () => state.toSnapshot(), // YOUR app owns the data
|
|
150
|
+
apply: (snap) => state.load(snap),
|
|
151
|
+
cache: indexedDbCache('my-app')
|
|
152
|
+
});
|
|
153
|
+
await store.init();
|
|
154
|
+
onEveryChange(store.schedule);
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Local-first, demonstrably
|
|
158
|
+
|
|
159
|
+
selfstore exists so an app can *prove* its local-first claims instead of
|
|
160
|
+
asserting them:
|
|
161
|
+
|
|
162
|
+
- **No server anywhere in the loop.** Save, backup, restore and merge all run
|
|
163
|
+
in the browser. There is nothing to host and nothing that sees the data.
|
|
164
|
+
- **The user owns a real file.** See "Backup files" above: a genuine,
|
|
165
|
+
documented ZIP, readable by anything that follows the spec.
|
|
166
|
+
- **Encrypted the moment it leaves the device.** Cleartext at rest locally (so
|
|
167
|
+
you keep queries and DevTools), end-to-end encrypted as soon as it goes to a
|
|
168
|
+
file or the cloud: AES-256-GCM over an Argon2id-derived key, parameters
|
|
169
|
+
stored per file so old backups keep decrypting when defaults improve.
|
|
170
|
+
- **Offline is the normal case, not a fallback.** The working copy is
|
|
171
|
+
IndexedDB; durable homes and merges catch up whenever the device is back
|
|
172
|
+
online.
|
|
173
|
+
- **Leaving is easy.** The format is stable across generations; a file written
|
|
174
|
+
years ago by another app still reads.
|
|
175
|
+
|
|
176
|
+
## Multi-device merge, no CRDT runtime
|
|
177
|
+
|
|
178
|
+
A Hybrid Logical Clock plus pluggable per-collection strategies converge two
|
|
179
|
+
replicas deterministically, while your objects stay plain JSON. The default -
|
|
180
|
+
records keyed by `id`, later edit per record wins, deletes propagate as
|
|
181
|
+
tombstones - fits most apps; `sync: { strategies }` tunes it per collection:
|
|
182
|
+
|
|
183
|
+
| Strategy | Semantics |
|
|
184
|
+
|---|---|
|
|
185
|
+
| `lww-set` | records keyed by id; later write per id wins; deletes tombstoned |
|
|
186
|
+
| `lww-map` | field-by-field merge: concurrent edits to different fields both survive; same field goes to the later clock; deletes stay record-level |
|
|
187
|
+
| `grow-set` | append-only, entries immutable per id; union, never a conflict |
|
|
188
|
+
| `lww-register` | a single value; later write wins |
|
|
189
|
+
| `manual` | surface concurrent same-id edits (via `MergeResult.conflicts`) instead of resolving |
|
|
190
|
+
|
|
191
|
+
**Guarantees (seeded-fuzz tested):** every two-way merge is symmetric and
|
|
192
|
+
idempotent; `lww-set` and `grow-set` (used within their contracts) are also
|
|
193
|
+
order-independent across replicas. **Honest limits:** LWW resolves a
|
|
194
|
+
concurrent same-record (or same-field) edit by KEEPING THE LATER CLOCK - the
|
|
195
|
+
losing value is surfaced in the sync journal's conflicts, not merged. In
|
|
196
|
+
`lww-map`, a record deleted on one replica while another edits one of its
|
|
197
|
+
fields resurrects with the surviving side's fields only. Change detection
|
|
198
|
+
hashes content with 32-bit FNV-1a: a collision (~2^-32 per edit) would miss an
|
|
199
|
+
edit. If concurrent-edit losslessness is your requirement, embed a real CRDT
|
|
200
|
+
document (Yjs/Automerge) as a binary file in the snapshot.
|
|
201
|
+
|
|
202
|
+
## Sharing between people: peers
|
|
203
|
+
|
|
204
|
+
Share a store between several people WITHOUT anyone granting write access:
|
|
205
|
+
each member publishes their own copy on their own storage, shares it
|
|
206
|
+
read-only, and attaches the other members' links as peers
|
|
207
|
+
(`store.advanced.attachPeer(source)`). Every converge folds each peer's copy
|
|
208
|
+
in as one more replica and publishes the merged state back: one writer per
|
|
209
|
+
file (no write races), copies converge to the union (every member is a full
|
|
210
|
+
backup of the group), gossip is transitive (a star around one member is
|
|
211
|
+
enough). Peer problems are recorded per peer on `state.peers` and never gate
|
|
212
|
+
your own saves.
|
|
213
|
+
|
|
214
|
+
Group crypto, two options: ONE shared passphrase (exchanged once out of band),
|
|
215
|
+
or **passwordless groups** (`selfstore/groups`): a keypair per member, every
|
|
216
|
+
copy Ed25519-signed by its author and sealed per recipient (X25519 envelopes),
|
|
217
|
+
membership governed by an admin-signed manifest with rollback protection. The
|
|
218
|
+
full model, the failure table and the operational notes live in
|
|
219
|
+
[PEERS.md](./PEERS.md); the trust analysis in
|
|
220
|
+
[THREAT-MODEL.md](./THREAT-MODEL.md).
|
|
221
|
+
|
|
222
|
+
## Destinations
|
|
223
|
+
|
|
224
|
+
Built in: **disk file** (File System Access, truly zero backend, Chromium),
|
|
225
|
+
**Google Drive** (bring a `DriveAuth`; `gisDriveAuth` is the client-only
|
|
226
|
+
reference - occasional re-consent; a tiny refresh-token broker buys "connected
|
|
227
|
+
for good"; the cloud only ever sees opaque encrypted bytes), **WebDAV**
|
|
228
|
+
(Nextcloud and friends; credentials sealed at rest under a non-extractable
|
|
229
|
+
device key), and **S3-compatible buckets** (Amazon S3, Cloudflare R2, Backblaze
|
|
230
|
+
B2, MinIO): the browser signs each request with SigV4 itself, no vendor SDK, the
|
|
231
|
+
secret sealed at rest like the WebDAV credentials.
|
|
232
|
+
|
|
233
|
+
Yours: implement `BackupTarget { kind / save / load / isReady / reconnect /
|
|
234
|
+
disconnect }` from `selfstore/advanced` with any `kind` string (say `'ipfs'`)
|
|
235
|
+
and hand it to `store.connectTarget(...)`. Signal a genuine loss of access by
|
|
236
|
+
throwing `AuthExpiredError`; anything else you throw is treated as transient
|
|
237
|
+
and retried. `examples/custom-target.ts` is a complete one in ~30 lines.
|
|
238
|
+
|
|
239
|
+
> Honest scope: a disk file needs **no backend**; Drive client-only needs no
|
|
240
|
+
> backend but re-consents roughly hourly (third-party cookies); a permanent
|
|
241
|
+
> Drive connection needs a small refresh-token broker. WebDAV and S3 need only
|
|
242
|
+
> a CORS rule on the server/bucket for your app origin.
|
|
243
|
+
|
|
244
|
+
For resilience, `store.addReplica(target)` keeps a SECOND destination in sync
|
|
245
|
+
with the same backup - Drive as the primary, an S3 bucket as the synced copy -
|
|
246
|
+
so losing one home never loses the data. For locked-down deployments,
|
|
247
|
+
`createLocalStore({ requireEncryption, passwordPolicy })` refuses a plaintext
|
|
248
|
+
backup and a weak password at the engine (not just the UI); offering only
|
|
249
|
+
`webdav` / `s3` in the connect widget, and not mounting the share/join widgets,
|
|
250
|
+
keeps an app on the user's own storage with no sharing surface at all.
|
|
251
|
+
|
|
252
|
+
## Framework bindings
|
|
253
|
+
|
|
254
|
+
`store.subscribe(fn)` plus reading state is the whole contract, and the state
|
|
255
|
+
snapshot is referentially stable between changes - so every framework binds in
|
|
256
|
+
a few lines and there is deliberately NO adapter package to install:
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
// React. The THIRD argument matters: without it useSyncExternalStore throws
|
|
260
|
+
// during SSR (Next, Remix...).
|
|
261
|
+
import { useSyncExternalStore } from 'react';
|
|
262
|
+
const s = useSyncExternalStore(store.subscribe, () => store.state, () => store.state);
|
|
263
|
+
|
|
264
|
+
// Svelte (3, 4 and 5). The store contract is structural - this object IS a
|
|
265
|
+
// readable, no svelte import needed; use it as $persistence in components.
|
|
266
|
+
const persistence = {
|
|
267
|
+
subscribe(run: (s: typeof store.state) => void) {
|
|
268
|
+
run(store.state);
|
|
269
|
+
return store.subscribe(() => run(store.state));
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// Vue, Solid, anything else: subscribe on mount, read store.state, call the
|
|
274
|
+
// returned unsubscriber on unmount.
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
## Security model
|
|
278
|
+
|
|
279
|
+
- Confidentiality and integrity come from AES-256-GCM: a wrong password, a
|
|
280
|
+
flipped ciphertext byte or altered KDF/IV parameters all fail decryption.
|
|
281
|
+
There is no partially-valid read.
|
|
282
|
+
- The KDF is Argon2id (memory-hard), 46 MiB / 3 passes by default, parameters
|
|
283
|
+
stored per file. It runs in a Web Worker (shipped as
|
|
284
|
+
`selfstore/kdf-worker`), falling back to the main thread with byte-identical
|
|
285
|
+
results wherever workers cannot load.
|
|
286
|
+
- The cosmetic header fields (`app`, `appVersion`, `createdAt`) are cleartext
|
|
287
|
+
and NOT authenticated; never base a security decision on them.
|
|
288
|
+
- Passwords are JavaScript strings: the platform gives no way to zeroize them.
|
|
289
|
+
Derived keys are non-extractable WebCrypto keys.
|
|
290
|
+
- Decompression is guarded: any archive entry declaring more than 512 MiB is
|
|
291
|
+
refused before inflation (`TOO_LARGE`), and a backup declaring absurd
|
|
292
|
+
Argon2id parameters is rejected before the KDF runs.
|
|
293
|
+
- WebDAV Basic-auth credentials are refused over plain http (loopback aside);
|
|
294
|
+
S3 requests are refused over plain http as well (loopback aside).
|
|
295
|
+
- The local IndexedDB cache is sealed at rest: the collections snapshot and file
|
|
296
|
+
blobs are AES-256-GCM-encrypted under a non-extractable per-device key (only
|
|
297
|
+
the small sync bookkeeping stays in the clear). Defense-in-depth against
|
|
298
|
+
disk forensics and partial exfiltration, not against a full-profile copy or
|
|
299
|
+
code in the origin - see T13 in the threat model.
|
|
300
|
+
- Ultra-sensitive `cacheLock`: seal that cache under a key held in memory only
|
|
301
|
+
(a password, or an app key such as a passkey PRF), never on disk, so even a
|
|
302
|
+
full-profile copy cannot read it. One unlock per session - `cacheLock` on the
|
|
303
|
+
simple facade, or `indexedDbCache(name, { lock: true })` at the store layer.
|
|
304
|
+
Still bounded by the origin (unlocked, code in the page decrypts).
|
|
305
|
+
- Opt-in hardening for sensitive deployments: `requireEncryption` refuses to
|
|
306
|
+
write or export a plaintext backup, and `passwordPolicy` refuses a password
|
|
307
|
+
weaker than a length / character-class rule - both enforced at the store, so
|
|
308
|
+
the app cannot forget to check.
|
|
309
|
+
- The full analysis - assets, trust boundaries, threats and explicit non-goals
|
|
310
|
+
(XSS, rollback replay, metadata) - is in [THREAT-MODEL.md](./THREAT-MODEL.md).
|
|
311
|
+
|
|
312
|
+
## When not to use selfstore
|
|
313
|
+
|
|
314
|
+
Honest limits: everything fits in memory (backups measured in MB, not GB;
|
|
315
|
+
there is no streaming path and no delta sync - every converge ships the whole
|
|
316
|
+
state); the merge targets one person's devices and small groups, not real-time
|
|
317
|
+
collaboration (use a CRDT library for that); there is no undo or history - an
|
|
318
|
+
LWW overwrite is final locally, so keep dated backup copies if you want time
|
|
319
|
+
travel (`backup(...).toDisk()` makes that a one-liner); and it is a browser
|
|
320
|
+
library (Node >= 20 works for tests, but the client is the point).
|
|
321
|
+
|
|
322
|
+
Other current limits, documented with open eyes: multi-tab coordination is
|
|
323
|
+
data-only (connections and mode switches apply to other tabs on reload;
|
|
324
|
+
`multiTab: false` opts out); deletion tombstones grow unless you opt in to
|
|
325
|
+
compaction (`tombstoneHorizonMs` on the advanced store - safe only if the
|
|
326
|
+
horizon exceeds the longest a device stays offline); binary files merge by id
|
|
327
|
+
union (no clocks: use content-addressed ids and tie file lifetime to a
|
|
328
|
+
record). In Vite DEV, if the KDF worker 404s under dependency pre-bundling,
|
|
329
|
+
add `optimizeDeps: { exclude: ['selfstore'] }`.
|
|
330
|
+
|
|
331
|
+
## Testing your integration
|
|
332
|
+
|
|
333
|
+
The simple store already runs in plain Node/vitest with no browser and no
|
|
334
|
+
mocks: without IndexedDB it lands on an in-memory cache, and
|
|
335
|
+
`await selfstore('app', { cache: memoryCache() })` (cache from
|
|
336
|
+
`selfstore/advanced`) makes the sharing explicit - two stores over one
|
|
337
|
+
`memoryCache()` simulate a reopen; a ~15-line in-memory `BackupTarget` (a Blob
|
|
338
|
+
variable) exercises the full save/sync/restore loop. That is exactly how
|
|
339
|
+
selfstore tests itself.
|
|
340
|
+
|
|
341
|
+
## For AI agents
|
|
342
|
+
|
|
343
|
+
`llms.txt` at the package root summarizes the API with canonical snippets, and
|
|
344
|
+
[RECIPES.md](./RECIPES.md) has copy-paste tasks (the `examples/` folder holds
|
|
345
|
+
the same mini-apps, typechecked). Rules of thumb: every record needs a
|
|
346
|
+
**string `id`** (the simple store enforces it; the advanced one only logs);
|
|
347
|
+
always branch on `err.code`, never parse error messages; treat
|
|
348
|
+
`DECRYPT_FAILED` as "wrong password or corrupted file" (indistinguishable by
|
|
349
|
+
design); an empty password means "not encrypted"; collection names starting
|
|
350
|
+
with `__` are reserved.
|
|
351
|
+
|
|
352
|
+
## Install
|
|
353
|
+
|
|
354
|
+
```sh
|
|
355
|
+
npm install selfstore
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
ESM only, TypeScript types included, browser-first. `fflate` and `hash-wasm`
|
|
359
|
+
are loaded lazily via dynamic import, so they stay out of your critical bundle
|
|
360
|
+
until the first compress/encrypt.
|
|
361
|
+
|
|
362
|
+
## License
|
|
363
|
+
|
|
364
|
+
MIT (c) Florian Mousseau
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { B as BackupTarget } from './store-ChIGEOph.js';
|
|
2
|
+
export { A as AuthExpiredError, a as BuiltinTargetKind, E as EncodeOptions, H as Header, K as KdfParams, L as LocalStore, b as LocalStoreOptions, c as LocalStoreState, M as MirrorState, d as Mode, P as PasswordCheck, e as PasswordPolicy, f as PasswordRequirement, g as PeerSource, h as PeerState, R as RESERVED_COLLECTION_PREFIX, i as RESERVED_STORE_MODES, j as RecipientStanza, k as ReplicaState, S as SelfstoreError, l as SelfstoreErrorCode, m as Severity, n as Snapshot, o as SnapshotFile, p as StatusAction, q as StatusDescriptor, r as StatusInput, s as StorageState, t as StoreError, u as StoreGroupConfig, v as SyncJournalEntry, w as SyncSource, T as TargetKind, x as checkPasswordPolicy, y as createLocalStore, z as deriveStatus, C as errorLabelKey, D as isAuthExpired, F as isReservedStoreMode, G as isSelfstoreError } from './store-ChIGEOph.js';
|
|
3
|
+
import { K as KV } from './cache-De5_sALA.js';
|
|
4
|
+
export { C as CachedFile, L as LocalCache, e as LockableCache, i as indexedDbCache, f as isLockableCache, m as memoryCache } from './cache-De5_sALA.js';
|
|
5
|
+
export { e as exportSnapshot, g as gisDriveAuth, i as importSnapshot, a as inspect, b as isEncrypted, p as pickFromDisk, s as saveToDisk } from './local-2py_vGGy.js';
|
|
6
|
+
import { c as connect$1, f as fromSession$1, p as preview, a as adopt, b as findOrCreateOwnFile, l as listBackups, d as createBackup, e as deleteBackup, r as renameBackup, g as connect$2, h as fromSession$2, i as connect$3, j as fromSession$3, k as peer } from './drive-Bv94UM8b.js';
|
|
7
|
+
export { D as DriveAuth, m as DriveBackupInfo, n as DriveOptions, S as S3Config, o as S3ConnectOptions, W as WebdavConfig, q as WebdavConnectOptions, s as WebdavPeerOptions } from './drive-Bv94UM8b.js';
|
|
8
|
+
import './sync.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Disk-file backup target (Chromium only). The user picks a backup file once via
|
|
12
|
+
* the File System Access API; the store then re-writes that real file on every
|
|
13
|
+
* change. The handle is persisted through the injected KV, so the target stays
|
|
14
|
+
* storage-agnostic. The browser re-confirms write permission once per session
|
|
15
|
+
* (a one-click reconnect).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
interface FileConnectOptions {
|
|
19
|
+
/** Where the picked file handle is persisted across sessions. */
|
|
20
|
+
kv: KV;
|
|
21
|
+
/** Suggested name for the backup file the user creates/picks. */
|
|
22
|
+
fileName: string;
|
|
23
|
+
}
|
|
24
|
+
/** True when the browser supports picking a re-writable file (Chromium). */
|
|
25
|
+
declare function isSupported(): boolean;
|
|
26
|
+
/** Prompt the user to choose/create the backup file. Persists the handle for
|
|
27
|
+
* next sessions. Returns the target, or null if cancelled. */
|
|
28
|
+
declare function connect(opts: FileConnectOptions): Promise<BackupTarget | null>;
|
|
29
|
+
/** Rebuild the target from the handle persisted in a past session, or null. */
|
|
30
|
+
declare function fromSession(opts: {
|
|
31
|
+
kv: KV;
|
|
32
|
+
}): Promise<BackupTarget | null>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* selfstore/advanced - the pull-model store and the machinery under it.
|
|
36
|
+
*
|
|
37
|
+
* The simple `selfstore()` facade (package root) owns its data. This entry
|
|
38
|
+
* inverts that: with createLocalStore your app owns the data and hands the
|
|
39
|
+
* store a gather/apply pair - use it when state lives in your own reactive
|
|
40
|
+
* model (Svelte runes, Redux, signals) and the facade's collections would be
|
|
41
|
+
* a second copy. Everything here also backs `store.advanced` on the simple
|
|
42
|
+
* store: same machinery, same instance, so the two styles compose.
|
|
43
|
+
*
|
|
44
|
+
* Also here: the durable destinations (disk file, Google Drive, WebDAV) as
|
|
45
|
+
* connectable targets, the BackupTarget contract for writing your own, the
|
|
46
|
+
* caches, the headless status derivation, and the functional codec for the
|
|
47
|
+
* backup file format (the fluent one lives at the package root).
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/** Disk-file destination (File System Access, Chromium). */
|
|
51
|
+
declare const fileTarget: {
|
|
52
|
+
connect: typeof connect;
|
|
53
|
+
fromSession: typeof fromSession;
|
|
54
|
+
isSupported: typeof isSupported;
|
|
55
|
+
};
|
|
56
|
+
/** WebDAV destination (Nextcloud, ownCloud, any server the user controls), plus
|
|
57
|
+
* `peer()`: a read-only source over another member's shared WebDAV link, so a
|
|
58
|
+
* group can share read-write without Google Drive (see store.attachPeer). */
|
|
59
|
+
declare const webdavTarget: {
|
|
60
|
+
connect: typeof connect$3;
|
|
61
|
+
fromSession: typeof fromSession$3;
|
|
62
|
+
peer: typeof peer;
|
|
63
|
+
};
|
|
64
|
+
/** S3-compatible destination (Amazon S3, Cloudflare R2, Backblaze B2, MinIO):
|
|
65
|
+
* the browser signs each request with SigV4 and writes one object on a bucket
|
|
66
|
+
* the user controls, no vendor broker. The bucket only ever holds the same
|
|
67
|
+
* end-to-end-encrypted .selfstore bytes. */
|
|
68
|
+
declare const s3Target: {
|
|
69
|
+
connect: typeof connect$2;
|
|
70
|
+
fromSession: typeof fromSession$2;
|
|
71
|
+
};
|
|
72
|
+
/** Google Drive destination, plus the shared-file primitives (preview a specific
|
|
73
|
+
* file, adopt it as this device's backup, find-or-create the user's own file)
|
|
74
|
+
* and the backup-management ops (list the app's files with dates, create a
|
|
75
|
+
* named empty backup, rename one, delete one for good) - the raw pieces a "my
|
|
76
|
+
* backups" panel builds on. */
|
|
77
|
+
declare const driveTarget: {
|
|
78
|
+
connect: typeof connect$1;
|
|
79
|
+
fromSession: typeof fromSession$1;
|
|
80
|
+
preview: typeof preview;
|
|
81
|
+
adopt: typeof adopt;
|
|
82
|
+
findOrCreateOwnFile: typeof findOrCreateOwnFile;
|
|
83
|
+
listBackups: typeof listBackups;
|
|
84
|
+
createBackup: typeof createBackup;
|
|
85
|
+
deleteBackup: typeof deleteBackup;
|
|
86
|
+
renameBackup: typeof renameBackup;
|
|
87
|
+
FILE_ID_KEY: string;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export { BackupTarget, type FileConnectOptions, KV, driveTarget, fileTarget, s3Target, webdavTarget };
|
package/dist/advanced.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export { RESERVED_STORE_MODES, createLocalStore, deriveStatus, gisDriveAuth, indexedDbCache, isLockableCache, isReservedStoreMode, memoryCache } from './chunk-N4RPMX3Y.js';
|
|
2
|
+
export { pickFromDisk, saveToDisk } from './chunk-BJ3RUMGI.js';
|
|
3
|
+
import './chunk-IBIEKK36.js';
|
|
4
|
+
import { FILE_ID_KEY, renameBackup, deleteBackup, createBackup, listBackups, findOrCreateOwnFile, adopt, preview, fromSession, connect, isSupported, fromSession2, connect2, fromSession4, connect4, peer, fromSession3, connect3 } from './chunk-CUWB5EWF.js';
|
|
5
|
+
export { RESERVED_COLLECTION_PREFIX, checkPasswordPolicy, exportSnapshot, importSnapshot, inspect, isEncrypted } from './chunk-JB4JHM7V.js';
|
|
6
|
+
import './chunk-OWLX6PNT.js';
|
|
7
|
+
import './chunk-QBCU7OOP.js';
|
|
8
|
+
import './chunk-FDPAKHQS.js';
|
|
9
|
+
export { AuthExpiredError, SelfstoreError, errorLabelKey, isAuthExpired, isSelfstoreError } from './chunk-VBFMMR7Q.js';
|
|
10
|
+
|
|
11
|
+
// src/entries/advanced.ts
|
|
12
|
+
var fileTarget = {
|
|
13
|
+
connect: connect2,
|
|
14
|
+
fromSession: fromSession2,
|
|
15
|
+
isSupported
|
|
16
|
+
};
|
|
17
|
+
var webdavTarget = {
|
|
18
|
+
connect: connect3,
|
|
19
|
+
fromSession: fromSession3,
|
|
20
|
+
peer
|
|
21
|
+
};
|
|
22
|
+
var s3Target = {
|
|
23
|
+
connect: connect4,
|
|
24
|
+
fromSession: fromSession4
|
|
25
|
+
};
|
|
26
|
+
var driveTarget = {
|
|
27
|
+
connect,
|
|
28
|
+
fromSession,
|
|
29
|
+
preview,
|
|
30
|
+
adopt,
|
|
31
|
+
findOrCreateOwnFile,
|
|
32
|
+
listBackups,
|
|
33
|
+
createBackup,
|
|
34
|
+
deleteBackup,
|
|
35
|
+
renameBackup,
|
|
36
|
+
FILE_ID_KEY
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export { driveTarget, fileTarget, s3Target, webdavTarget };
|
|
40
|
+
//# sourceMappingURL=advanced.js.map
|
|
41
|
+
//# sourceMappingURL=advanced.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/entries/advanced.ts"],"names":["connect","fromSession"],"mappings":";;;;;;;;;;;AA8HO,IAAM,UAAA,GAAa;AAAA,EACzB,OAAA,EAASA,QAAAA;AAAA,EACT,WAAA,EAAaC,YAAAA;AAAA,EACb;AACD;AAKO,IAAM,YAAA,GAAe;AAAA,EAC3B,OAAA,EAASD,QAAAA;AAAA,EACT,WAAA,EAAaC,YAAAA;AAAA,EACb;AACD;AAMO,IAAM,QAAA,GAAW;AAAA,EACvB,OAAA,EAASD,QAAAA;AAAA,EACT,WAAA,EAAaC;AACd;AAOO,IAAM,WAAA,GAAc;AAAA,EAC1B,OAAA;AAAA,EACA,WAAA;AAAA,EACA,OAAA;AAAA,EACA,KAAA;AAAA,EACA,mBAAA;AAAA,EACA,WAAA;AAAA,EACA,YAAA;AAAA,EACA,YAAA;AAAA,EACA,YAAA;AAAA,EACA;AACD","file":"advanced.js","sourcesContent":["/**\n * selfstore/advanced - the pull-model store and the machinery under it.\n *\n * The simple `selfstore()` facade (package root) owns its data. This entry\n * inverts that: with createLocalStore your app owns the data and hands the\n * store a gather/apply pair - use it when state lives in your own reactive\n * model (Svelte runes, Redux, signals) and the facade's collections would be\n * a second copy. Everything here also backs `store.advanced` on the simple\n * store: same machinery, same instance, so the two styles compose.\n *\n * Also here: the durable destinations (disk file, Google Drive, WebDAV) as\n * connectable targets, the BackupTarget contract for writing your own, the\n * caches, the headless status derivation, and the functional codec for the\n * backup file format (the fluent one lives at the package root).\n */\n\n// --- The pull-model store: your app owns the data ---\nexport {\n\tcreateLocalStore,\n\ttype LocalStore,\n\ttype LocalStoreOptions,\n\ttype LocalStoreState,\n\ttype StoreError,\n\ttype Mode,\n\ttype TargetKind,\n\ttype SyncSource,\n\ttype SyncJournalEntry,\n\ttype PeerState,\n\ttype MirrorState,\n\ttype ReplicaState,\n\ttype StoreGroupConfig\n} from '../persistence/store';\n\n// --- On-device caches (the store's working copy) ---\nexport {\n\tindexedDbCache,\n\tmemoryCache,\n\tisLockableCache,\n\ttype LocalCache,\n\ttype LockableCache,\n\ttype CachedFile,\n\ttype KV\n} from '../persistence/cache';\n\n// --- Headless status: map store state to your UI copy ---\nexport {\n\tderiveStatus,\n\ttype StatusDescriptor,\n\ttype StatusInput,\n\ttype Severity,\n\ttype StorageState,\n\ttype StatusAction\n} from '../persistence/status';\n\n// --- The target contract (write your own destination) ---\nexport {\n\tRESERVED_STORE_MODES,\n\tisReservedStoreMode,\n\ttype BackupTarget,\n\ttype BuiltinTargetKind,\n\ttype PeerSource\n} from '../persistence/target';\n\n// --- The backup file format, functional style (fluent lives at the root) ---\nexport {\n\texportSnapshot,\n\timportSnapshot,\n\tinspect,\n\tisEncrypted,\n\tRESERVED_COLLECTION_PREFIX,\n\ttype Snapshot,\n\ttype SnapshotFile,\n\ttype Header,\n\ttype KdfParams,\n\ttype RecipientStanza,\n\ttype EncodeOptions\n} from '../selfstore';\n\n// --- Password strength policy (the store enforces it; the UI previews it) ---\nexport {\n\tcheckPasswordPolicy,\n\ttype PasswordPolicy,\n\ttype PasswordCheck,\n\ttype PasswordRequirement\n} from '../selfstore';\n\n// --- The full error contract (AuthExpiredError is what a custom target or\n// DriveAuth THROWS to signal genuine access loss; everything else it\n// throws is treated as transient and retried) ---\nexport {\n\tSelfstoreError,\n\tAuthExpiredError,\n\tisSelfstoreError,\n\tisAuthExpired,\n\terrorLabelKey,\n\ttype SelfstoreErrorCode\n} from '../selfstore';\n\n// --- Durable destinations (opt-in), each implementing BackupTarget ---\n// Explicit namespace objects: every member is deliberately public, so a future\n// internal helper in a target module can never leak into the contract.\nimport {\n\tconnect as fileConnect,\n\tfromSession as fileFromSession,\n\tisSupported as fileIsSupported\n} from '../persistence/targets/file';\nimport {\n\tconnect as webdavConnect,\n\tfromSession as webdavFromSession,\n\tpeer as webdavPeer\n} from '../persistence/targets/webdav';\nimport { connect as s3Connect, fromSession as s3FromSession } from '../persistence/targets/s3';\nimport {\n\tconnect as driveConnect,\n\tfromSession as driveFromSession,\n\tpreview as drivePreview,\n\tadopt as driveAdopt,\n\tfindOrCreateOwnFile as driveFindOrCreateOwnFile,\n\tlistBackups as driveListBackups,\n\tcreateBackup as driveCreateBackup,\n\tdeleteBackup as driveDeleteBackup,\n\trenameBackup as driveRenameBackup,\n\tFILE_ID_KEY as DRIVE_FILE_ID_KEY\n} from '../persistence/targets/drive';\n\n/** Disk-file destination (File System Access, Chromium). */\nexport const fileTarget = {\n\tconnect: fileConnect,\n\tfromSession: fileFromSession,\n\tisSupported: fileIsSupported\n};\n\n/** WebDAV destination (Nextcloud, ownCloud, any server the user controls), plus\n * `peer()`: a read-only source over another member's shared WebDAV link, so a\n * group can share read-write without Google Drive (see store.attachPeer). */\nexport const webdavTarget = {\n\tconnect: webdavConnect,\n\tfromSession: webdavFromSession,\n\tpeer: webdavPeer\n};\n\n/** S3-compatible destination (Amazon S3, Cloudflare R2, Backblaze B2, MinIO):\n * the browser signs each request with SigV4 and writes one object on a bucket\n * the user controls, no vendor broker. The bucket only ever holds the same\n * end-to-end-encrypted .selfstore bytes. */\nexport const s3Target = {\n\tconnect: s3Connect,\n\tfromSession: s3FromSession\n};\n\n/** Google Drive destination, plus the shared-file primitives (preview a specific\n * file, adopt it as this device's backup, find-or-create the user's own file)\n * and the backup-management ops (list the app's files with dates, create a\n * named empty backup, rename one, delete one for good) - the raw pieces a \"my\n * backups\" panel builds on. */\nexport const driveTarget = {\n\tconnect: driveConnect,\n\tfromSession: driveFromSession,\n\tpreview: drivePreview,\n\tadopt: driveAdopt,\n\tfindOrCreateOwnFile: driveFindOrCreateOwnFile,\n\tlistBackups: driveListBackups,\n\tcreateBackup: driveCreateBackup,\n\tdeleteBackup: driveDeleteBackup,\n\trenameBackup: driveRenameBackup,\n\tFILE_ID_KEY: DRIVE_FILE_ID_KEY\n};\n\nexport type { FileConnectOptions } from '../persistence/targets/file';\nexport type { DriveAuth, DriveOptions, DriveBackupInfo } from '../persistence/targets/drive';\nexport type {\n\tWebdavConfig,\n\tWebdavConnectOptions,\n\tWebdavPeerOptions\n} from '../persistence/targets/webdav';\nexport type { S3Config, S3ConnectOptions } from '../persistence/targets/s3';\nexport { gisDriveAuth } from '../persistence/targets/drive-auth-gis';\nexport { saveToDisk, pickFromDisk } from '../selfstore/targets/local';\n"]}
|