selfstore 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +364 -0
  3. package/dist/advanced.d.ts +90 -0
  4. package/dist/advanced.js +41 -0
  5. package/dist/advanced.js.map +1 -0
  6. package/dist/backups.d.ts +182 -0
  7. package/dist/backups.js +360 -0
  8. package/dist/backups.js.map +1 -0
  9. package/dist/cache-De5_sALA.d.ts +129 -0
  10. package/dist/cache-lock-LZILZNLP.js +23 -0
  11. package/dist/cache-lock-LZILZNLP.js.map +1 -0
  12. package/dist/chunk-ATUPHVLG.js +441 -0
  13. package/dist/chunk-ATUPHVLG.js.map +1 -0
  14. package/dist/chunk-BJ3RUMGI.js +45 -0
  15. package/dist/chunk-BJ3RUMGI.js.map +1 -0
  16. package/dist/chunk-CUWB5EWF.js +732 -0
  17. package/dist/chunk-CUWB5EWF.js.map +1 -0
  18. package/dist/chunk-FDPAKHQS.js +230 -0
  19. package/dist/chunk-FDPAKHQS.js.map +1 -0
  20. package/dist/chunk-IBIEKK36.js +331 -0
  21. package/dist/chunk-IBIEKK36.js.map +1 -0
  22. package/dist/chunk-JB4JHM7V.js +474 -0
  23. package/dist/chunk-JB4JHM7V.js.map +1 -0
  24. package/dist/chunk-N4RPMX3Y.js +1908 -0
  25. package/dist/chunk-N4RPMX3Y.js.map +1 -0
  26. package/dist/chunk-OWLX6PNT.js +231 -0
  27. package/dist/chunk-OWLX6PNT.js.map +1 -0
  28. package/dist/chunk-QBCU7OOP.js +20 -0
  29. package/dist/chunk-QBCU7OOP.js.map +1 -0
  30. package/dist/chunk-SGISYMT5.js +46 -0
  31. package/dist/chunk-SGISYMT5.js.map +1 -0
  32. package/dist/chunk-VBFMMR7Q.js +29 -0
  33. package/dist/chunk-VBFMMR7Q.js.map +1 -0
  34. package/dist/drive-Bv94UM8b.d.ts +222 -0
  35. package/dist/flows.d.ts +306 -0
  36. package/dist/flows.js +153 -0
  37. package/dist/flows.js.map +1 -0
  38. package/dist/groups.d.ts +39 -0
  39. package/dist/groups.js +103 -0
  40. package/dist/groups.js.map +1 -0
  41. package/dist/households.d.ts +214 -0
  42. package/dist/households.js +410 -0
  43. package/dist/households.js.map +1 -0
  44. package/dist/index.d.ts +205 -0
  45. package/dist/index.js +249 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/kdf-worker.js +2395 -0
  48. package/dist/kdf-worker.js.map +1 -0
  49. package/dist/local-2py_vGGy.d.ts +47 -0
  50. package/dist/local-AM22RWRR.js +7 -0
  51. package/dist/local-AM22RWRR.js.map +1 -0
  52. package/dist/machine-ALMkT3mk.d.ts +17 -0
  53. package/dist/store-ChIGEOph.d.ts +577 -0
  54. package/dist/sync.d.ts +107 -0
  55. package/dist/sync.js +3 -0
  56. package/dist/sync.js.map +1 -0
  57. package/dist/widgets.d.ts +426 -0
  58. package/dist/widgets.js +2579 -0
  59. package/dist/widgets.js.map +1 -0
  60. package/llms.txt +405 -0
  61. package/package.json +99 -0
@@ -0,0 +1,306 @@
1
+ import { t as StoreError, B as BackupTarget, L as LocalStore, k as ReplicaState } from './store-ChIGEOph.js';
2
+ import { K as KV } from './cache-De5_sALA.js';
3
+ import { W as WebdavConfig, S as S3Config, D as DriveAuth } from './drive-Bv94UM8b.js';
4
+ import { F as FlowStore } from './machine-ALMkT3mk.js';
5
+ export { w as withDeadline } from './machine-ALMkT3mk.js';
6
+ import './sync.js';
7
+
8
+ type ConnectKind = 'drive' | 'file' | 'webdav' | 's3';
9
+ /** Produce a connected BackupTarget, or null when the user cancelled the
10
+ * gesture (consent refused, picker closed). Custom connectors slot custom
11
+ * targets - or test fakes - into the same journey. */
12
+ type Connector = () => Promise<BackupTarget | null>;
13
+ /** Which destinations the flow offers, and how to open each one. The offer
14
+ * renders in this OBJECT's insertion order: the host decides which
15
+ * destination leads by how it writes the object, not by a ranking here. */
16
+ interface ConnectTargets {
17
+ /** Google Drive: the auth strategy (e.g. gisDriveAuth), or a custom connector. */
18
+ drive?: DriveAuth | Connector;
19
+ /** Local file via the browser's picker (true), or a custom connector. On
20
+ * browsers without the File System Access API the flow degrades to the
21
+ * manual download mode by itself. The object form offers TWO gestures on
22
+ * the same destination: `create` (save picker: start or overwrite a backup
23
+ * file) and `open` (open picker: adopt an EXISTING backup file - it then
24
+ * goes through the same inspect/password/resolution journey). */
25
+ file?: true | Connector | {
26
+ create?: true | Connector;
27
+ open?: true | Connector;
28
+ };
29
+ /** WebDAV: true renders a 'form' step and expects submitWebdav(config);
30
+ * a fixed WebdavConfig skips the form; or a custom connector. */
31
+ webdav?: true | WebdavConfig | Connector;
32
+ /** S3-compatible bucket (Amazon S3, R2, B2, MinIO): true renders a 'form'
33
+ * step and expects submitS3(config); a fixed S3Config skips the form; or a
34
+ * custom connector. Offer it alongside `webdav` and the widget groups both
35
+ * under one "your server" entry with a WebDAV/S3 toggle. */
36
+ s3?: true | S3Config | Connector;
37
+ }
38
+ /** What the flows need from the store. The simple store exposes this as
39
+ * `store.flowHost`; an advanced-store app builds it by hand. */
40
+ interface FlowHost {
41
+ engine: LocalStore;
42
+ kv: KV;
43
+ /** Backup file name on the destination (the simple store uses `app.zip`). */
44
+ backupName: string;
45
+ }
46
+ interface ConnectFlowOptions {
47
+ /** Declare when this device holds data worth a question: only then does an
48
+ * existing backup raise the 'conflict' step with the destructive choices.
49
+ * Default: never - the flow applies defaultResolution. */
50
+ hasLocalData?: () => boolean;
51
+ /** How to reconcile with a destination that already holds a backup when the
52
+ * conflict step is not raised. Default 'merge' - the only choice that
53
+ * loses nothing on either side. 'resume' adopts the backup (the
54
+ * destination wins; this device's working copy is replaced), 'replace'
55
+ * overwrites the backup with this device. An encrypted backup still asks
56
+ * for its password first, whatever the resolution; an empty destination
57
+ * is not concerned (this device's data simply becomes its content). */
58
+ defaultResolution?: ConnectResolution;
59
+ /** Skip the password step on an encrypted backup: adopt it AS is (resume)
60
+ * and complete with the store locked - nothing readable, nothing
61
+ * clobbered - for the host to unlock on its own surface (engine.unlock)
62
+ * after the connect. Adoption is forced whatever defaultResolution says:
63
+ * without the proven key a merge is impossible and a replace would
64
+ * destroy a backup no one proved they own. Hosts that let a device
65
+ * holding real local data connect should keep the password-first
66
+ * journey - adoption replaces the working copy. */
67
+ deferUnlock?: boolean;
68
+ /** Deadline for each network leg (inspect, trial-read, attach), ms. */
69
+ deadlineMs?: number;
70
+ }
71
+ type ConnectStep = 'choose' | 'form' | 'authorizing' | 'password' | 'conflict' | 'connected' | 'error';
72
+ /** How to reconcile with a destination that already holds a backup. */
73
+ type ConnectResolution = 'merge' | 'resume' | 'replace';
74
+ /** What connecting did. 'merged': both sides folded. 'started': empty
75
+ * destination now holds this device's data. 'resumed': the backup won,
76
+ * this device adopted it. 'replaced': this device won, the backup was
77
+ * overwritten. 'manual': degraded download-on-demand file mode. */
78
+ type ConnectFlowOutcome = 'merged' | 'started' | 'resumed' | 'replaced' | 'manual';
79
+ interface ConnectSnapshot {
80
+ step: ConnectStep;
81
+ /** The destinations to offer, in the order given. */
82
+ kinds: readonly ConnectKind[];
83
+ /** The destination being connected (null while choosing). */
84
+ kind: ConnectKind | null;
85
+ /** Set when step === 'connected'. */
86
+ outcome: ConnectFlowOutcome | null;
87
+ /** Known after inspection: the destination already holds a backup. */
88
+ hasBackup: boolean;
89
+ /** ...and that backup is encrypted. */
90
+ encrypted: boolean;
91
+ /** The last submitPassword() was wrong (stay on 'password', say so). */
92
+ passwordError: boolean;
93
+ /** An async leg is in flight (render a spinner, disable the actions). */
94
+ busy: boolean;
95
+ /** Set when step === 'error': show `labelKey`, log `message`. */
96
+ error: StoreError | null;
97
+ }
98
+ interface ConnectFlow extends FlowStore<ConnectSnapshot> {
99
+ /** Pick a destination. Call it inside the user's click: the consent popup /
100
+ * file picker opens synchronously from the gesture. Ignored while a leg is
101
+ * already in flight (one popup per gesture). For a `file` target declared
102
+ * with the object form, `variant` picks the gesture: 'create' (default,
103
+ * save picker) or 'open' (adopt an existing backup file). */
104
+ choose(kind: ConnectKind, variant?: 'create' | 'open'): void;
105
+ /** 'form' step (webdav: true): submit the server config. */
106
+ submitWebdav(config: WebdavConfig): void;
107
+ /** 'form' step (s3: true): submit the bucket config. */
108
+ submitS3(config: S3Config): void;
109
+ /** 'password' step: prove the backup's password. Wrong password sets
110
+ * passwordError and stays here; nothing is attached either way. */
111
+ submitPassword(password: string): void;
112
+ /** 'password' step escape hatch: give up on a forgotten password and start a
113
+ * fresh backup over the protected one. The encrypted backup is overwritten
114
+ * by this device's data (possibly empty) and lost for good - the only way
115
+ * out when the password cannot be recovered. */
116
+ overwrite(): void;
117
+ /** 'conflict' step: pick the reconciliation. */
118
+ resolveConflict(how: ConnectResolution): void;
119
+ /** Abandon the journey in progress: back to 'choose'. A late resolution of
120
+ * the abandoned leg is discarded. */
121
+ cancel(): void;
122
+ /** 'error' step: back to 'choose' to try again. */
123
+ retry(): void;
124
+ }
125
+ declare function connectFlow(store: FlowHost | {
126
+ flowHost: FlowHost;
127
+ }, targets: ConnectTargets, options?: ConnectFlowOptions): ConnectFlow;
128
+
129
+ /** The one replica this flow manages, as its id on the engine. */
130
+ declare const REPLICA_ID = "replica";
131
+ type ReplicaStep = 'idle' | 'choose' | 'form-webdav' | 'form-s3';
132
+ interface ReplicaSnapshot {
133
+ step: ReplicaStep;
134
+ /** The kinds the host offers, in ConnectTargets order. */
135
+ kinds: ConnectKind[];
136
+ busy: boolean;
137
+ error: StoreError | null;
138
+ /** The live engine entry (label, lastPublishAt, lastError), null when none. */
139
+ replica: ReplicaState | null;
140
+ }
141
+ interface ReplicaFlowOptions {
142
+ /** File name of the copy on its destination. Default: the primary backup
143
+ * name with `.replica` before the extension ("App.zip" -> "App.replica.zip"),
144
+ * which a same-account Drive never confuses with the primary file and the
145
+ * backups panel never lists as a backup of its own. */
146
+ fileName?: string;
147
+ /** Rebuild the target of a custom Connector kind on restore() (built-in
148
+ * kinds rebuild by themselves). Without it, custom kinds are not restored. */
149
+ restoreTarget?: (kind: ConnectKind) => Promise<BackupTarget | null>;
150
+ }
151
+ interface ReplicaFlow extends FlowStore<ReplicaSnapshot> {
152
+ /** Show the destination choice (from the user's gesture). */
153
+ open(): void;
154
+ /** Back to idle; never an error. */
155
+ cancel(): void;
156
+ /** Run a destination (inside the user's click - pickers and consent popups
157
+ * open from it). `webdav: true` / `s3: true` route to their form step. */
158
+ pick(kind: ConnectKind): void;
159
+ /** 'form-webdav' step: submit the server config. */
160
+ submitWebdav(config: WebdavConfig): void;
161
+ /** 'form-s3' step: submit the bucket config. */
162
+ submitS3(config: S3Config): void;
163
+ /** Detach the copy and forget its session. Never deletes remote data. */
164
+ remove(): Promise<void>;
165
+ /** Silent reboot: re-attach the copy recorded by a past session, if any.
166
+ * Never throws - an unreachable destination retries at the next boot. */
167
+ restore(): Promise<void>;
168
+ /** Detach from the engine (the replica itself stays attached). */
169
+ dispose(): void;
170
+ }
171
+ declare function replicaFlow(store: FlowHost | {
172
+ flowHost: FlowHost;
173
+ }, targets: ConnectTargets, options?: ReplicaFlowOptions): ReplicaFlow;
174
+
175
+ type ShareLevel = 'read' | 'write';
176
+ interface ShareLinkInfo {
177
+ /** Engine-scoped stable id (revoke keys on it). */
178
+ id: string;
179
+ /** The invitation URL to hand out. */
180
+ url: string;
181
+ level: ShareLevel;
182
+ }
183
+ interface ShareMemberInfo {
184
+ /** Engine-scoped stable id (removeMember keys on it). */
185
+ id: string;
186
+ label?: string;
187
+ /** This row is the local user. */
188
+ self?: boolean;
189
+ /** This row owns the share (the app may render a badge, the flow does not care). */
190
+ owner?: boolean;
191
+ }
192
+ /** The app-side port the flow drives. Implementations wrap whatever transport
193
+ * the app shares through; every method may throw (SelfstoreError preferred,
194
+ * anything is normalized). */
195
+ interface ShareEngine {
196
+ /** The current truth: active links and members. */
197
+ list(): Promise<{
198
+ links: ShareLinkInfo[];
199
+ members: ShareMemberInfo[];
200
+ }>;
201
+ createLink(opts: {
202
+ level: ShareLevel;
203
+ }): Promise<ShareLinkInfo>;
204
+ revokeLink(id: string): Promise<void>;
205
+ /** Optional: engines without member management simply omit it. */
206
+ removeMember?(id: string): Promise<void>;
207
+ /** Optional: END the whole share in one gesture - every link dead, every
208
+ * member out. Engines whose transport has such a move (an invitation to
209
+ * kill, a file to re-privatize) expose it; the widget renders the action
210
+ * only then. */
211
+ revokeAll?(): Promise<void>;
212
+ }
213
+ type ShareBusy = 'refresh' | 'create' | 'revoke' | 'remove' | 'revoke-all' | null;
214
+ interface ShareSnapshot {
215
+ links: readonly ShareLinkInfo[];
216
+ members: readonly ShareMemberInfo[];
217
+ /** The operation in flight, or null. */
218
+ busy: ShareBusy;
219
+ /** The last refresh failed: the lists shown are the last-known good ones. */
220
+ stale: boolean;
221
+ /** The last operation's problem (the lists are never cleared by an error). */
222
+ error: StoreError | null;
223
+ /** True once the engine offers removeMember (render the row action at all). */
224
+ canRemoveMembers: boolean;
225
+ /** True once the engine offers revokeAll (render the end-the-share action). */
226
+ canRevokeAll: boolean;
227
+ }
228
+ interface ShareFlow extends FlowStore<ShareSnapshot> {
229
+ /** Re-list from the engine. Failure keeps the last-known lists (stale). */
230
+ refresh(): Promise<void>;
231
+ /** Create an invitation link. Resolves to it, or null when refused (an
232
+ * operation was already in flight). */
233
+ createLink(opts: {
234
+ level: ShareLevel;
235
+ }): Promise<ShareLinkInfo | null>;
236
+ /** Revoke a link. The link leaves the snapshot only once the engine
237
+ * confirmed. Resolves to whether it did. */
238
+ revokeLink(id: string): Promise<boolean>;
239
+ /** Remove a member (never yourself - refused up front). Resolves to whether
240
+ * the engine confirmed. */
241
+ removeMember(id: string): Promise<boolean>;
242
+ /** End the whole share: every link and member leaves the snapshot only
243
+ * once the engine confirmed. Resolves to whether it did. */
244
+ revokeAll(): Promise<boolean>;
245
+ }
246
+ declare function shareFlow(engine: ShareEngine, options?: {
247
+ deadlineMs?: number;
248
+ }): ShareFlow;
249
+
250
+ /** What the user is about to join - enough to render an honest question. */
251
+ interface JoinPreview {
252
+ /** The share's display name, when the link carries one. */
253
+ label?: string;
254
+ /** Who shared it (a name, an email - whatever the engine knows). */
255
+ from?: string;
256
+ level?: ShareLevel;
257
+ }
258
+ /** How joining ended, other than plain failure:
259
+ * 'joined' - this device now follows the share.
260
+ * 'mismatch' - this device already follows a DIFFERENT share: joining a
261
+ * second one is refused (leave the first, or use another
262
+ * device/profile).
263
+ * 'no-invite' - the invitation is spent, expired, or aimed at another
264
+ * account (switching accounts may be the fix). */
265
+ type JoinOutcome = 'joined' | 'mismatch' | 'no-invite';
266
+ /** The app-side port: how a link is read and honoured on this transport. */
267
+ interface JoinEngine {
268
+ /** Read-only look at the invitation. Must change NOTHING. */
269
+ preview(link: string): Promise<JoinPreview>;
270
+ /** Consume the invitation and wire the share. Resolve null when the user
271
+ * backed out of a popup the engine had to open (an account chooser, a
272
+ * consent screen): the flow returns to 'ready' silently - backing out of
273
+ * a human gesture is not an error, here as everywhere in these flows. */
274
+ join(link: string): Promise<JoinOutcome | null>;
275
+ /** Optional: re-run the account chooser (the invite may belong to another
276
+ * account). The flow re-previews after it. */
277
+ switchAccount?(): Promise<void>;
278
+ }
279
+ type JoinStep = 'previewing' | 'ready' | 'joining' | 'joined' | 'mismatch' | 'no-invite' | 'error';
280
+ interface JoinSnapshot {
281
+ /** The invitation this flow was opened with - held here so the UI can
282
+ * always find it again. */
283
+ link: string;
284
+ step: JoinStep;
285
+ /** Set once previewed (kept through errors, so retry re-renders context). */
286
+ preview: JoinPreview | null;
287
+ /** The engine offers an account switch. */
288
+ canSwitchAccount: boolean;
289
+ busy: boolean;
290
+ /** Set when step === 'error': show `labelKey`, log `message`. */
291
+ error: StoreError | null;
292
+ }
293
+ interface JoinFlow extends FlowStore<JoinSnapshot> {
294
+ /** Say yes: consume the invitation. Only from 'ready'. */
295
+ accept(): void;
296
+ /** Re-run the account chooser, then preview again under the new account.
297
+ * Available from 'ready', 'no-invite' and 'error' when the engine offers it. */
298
+ switchAccount(): void;
299
+ /** From 'error': preview again and resume the journey. */
300
+ retry(): void;
301
+ }
302
+ declare function joinFlow(link: string, engine: JoinEngine, options?: {
303
+ deadlineMs?: number;
304
+ }): JoinFlow;
305
+
306
+ export { type ConnectFlow, type ConnectFlowOptions, type ConnectFlowOutcome, type ConnectKind, type ConnectResolution, type ConnectSnapshot, type ConnectStep, type ConnectTargets, type Connector, type FlowHost, FlowStore, type JoinEngine, type JoinFlow, type JoinOutcome, type JoinPreview, type JoinSnapshot, type JoinStep, REPLICA_ID, type ReplicaFlow, type ReplicaFlowOptions, type ReplicaSnapshot, type ReplicaStep, type ShareBusy, type ShareEngine, type ShareFlow, type ShareLevel, type ShareLinkInfo, type ShareMemberInfo, type ShareSnapshot, connectFlow, joinFlow, replicaFlow, shareFlow };
package/dist/flows.js ADDED
@@ -0,0 +1,153 @@
1
+ export { connectFlow, joinFlow, shareFlow } from './chunk-ATUPHVLG.js';
2
+ import { connect, connect2, connect4, connect3, fromSession, fromSession2, fromSession3, fromSession4 } from './chunk-CUWB5EWF.js';
3
+ import './chunk-JB4JHM7V.js';
4
+ import { makeMachine, toStoreError } from './chunk-SGISYMT5.js';
5
+ export { withDeadline } from './chunk-SGISYMT5.js';
6
+ import './chunk-OWLX6PNT.js';
7
+ import './chunk-QBCU7OOP.js';
8
+ import './chunk-FDPAKHQS.js';
9
+ import './chunk-VBFMMR7Q.js';
10
+
11
+ // src/flows/replica.ts
12
+ var REPLICA_ID = "replica";
13
+ var SCOPE = "replica:";
14
+ var RECORD_KEY = "record";
15
+ function scopedKv(kv, prefix) {
16
+ return {
17
+ get: (key) => kv.get(prefix + key),
18
+ set: (key, value) => kv.set(prefix + key, value),
19
+ del: (key) => kv.del(prefix + key)
20
+ };
21
+ }
22
+ function replicaFileName(backupName) {
23
+ const dot = backupName.lastIndexOf(".");
24
+ return dot > 0 ? `${backupName.slice(0, dot)}.replica${backupName.slice(dot)}` : `${backupName}.replica`;
25
+ }
26
+ function replicaFlow(store, targets, options = {}) {
27
+ const host = "flowHost" in store ? store.flowHost : store;
28
+ const engine = host.engine;
29
+ const skv = scopedKv(host.kv, SCOPE);
30
+ const fileName = options.fileName ?? replicaFileName(host.backupName);
31
+ const kinds = Object.keys(targets).filter(
32
+ (k) => (k === "drive" || k === "file" || k === "webdav" || k === "s3") && targets[k] != null
33
+ );
34
+ const current = () => engine.state.replicas.find((r) => r.id === REPLICA_ID) ?? null;
35
+ const m = makeMachine({
36
+ step: "idle",
37
+ kinds,
38
+ busy: false,
39
+ error: null,
40
+ replica: current()
41
+ });
42
+ const unsub = engine.subscribe(() => m.set({ replica: current() }));
43
+ let attached = null;
44
+ function connectorFor(kind) {
45
+ const spec = targets[kind];
46
+ if (spec == null) return null;
47
+ if (typeof spec === "function") return spec;
48
+ if (kind === "drive") {
49
+ const auth = spec;
50
+ return () => connect({ auth, kv: skv, fileName });
51
+ }
52
+ if (kind === "file") {
53
+ return () => connect2({ kv: skv, fileName });
54
+ }
55
+ if (kind === "s3") {
56
+ const config2 = spec;
57
+ return () => connect4({ kv: skv, config: config2 });
58
+ }
59
+ const config = spec;
60
+ return () => connect3({ kv: skv, config });
61
+ }
62
+ async function run(kind, connector) {
63
+ m.set({ busy: true, error: null });
64
+ try {
65
+ const target = await connector();
66
+ if (!target) {
67
+ m.set({ busy: false });
68
+ return;
69
+ }
70
+ if (current()) engine.detachReplica(REPLICA_ID);
71
+ engine.attachReplica(target, { id: REPLICA_ID });
72
+ attached = target;
73
+ await skv.set(RECORD_KEY, { kind });
74
+ m.set({ step: "idle", busy: false, error: null, replica: current() });
75
+ } catch (e) {
76
+ m.set({ busy: false, error: toStoreError(e) });
77
+ }
78
+ }
79
+ async function builtinFromSession(kind) {
80
+ if (kind === "drive") {
81
+ const spec = targets.drive;
82
+ if (spec == null || typeof spec === "function") return null;
83
+ return fromSession({ auth: spec, kv: skv, fileName });
84
+ }
85
+ if (kind === "file") return fromSession2({ kv: skv });
86
+ if (kind === "webdav") return fromSession3({ kv: skv });
87
+ return fromSession4({ kv: skv });
88
+ }
89
+ return {
90
+ get snapshot() {
91
+ return m.snapshot;
92
+ },
93
+ subscribe: m.subscribe,
94
+ open() {
95
+ if (m.snapshot.busy) return;
96
+ m.set({ step: "choose", error: null });
97
+ },
98
+ cancel() {
99
+ m.set({ step: "idle", busy: false, error: null });
100
+ },
101
+ pick(kind) {
102
+ if (m.snapshot.busy) return;
103
+ const spec = targets[kind];
104
+ if (spec == null) return;
105
+ if (kind === "webdav" && spec === true) {
106
+ m.set({ step: "form-webdav", error: null });
107
+ return;
108
+ }
109
+ if (kind === "s3" && spec === true) {
110
+ m.set({ step: "form-s3", error: null });
111
+ return;
112
+ }
113
+ const connector = connectorFor(kind);
114
+ if (connector) void run(kind, connector);
115
+ },
116
+ submitWebdav(config) {
117
+ if (m.snapshot.busy) return;
118
+ void run("webdav", () => connect3({ kv: skv, config }));
119
+ },
120
+ submitS3(config) {
121
+ if (m.snapshot.busy) return;
122
+ void run("s3", () => connect4({ kv: skv, config }));
123
+ },
124
+ async remove() {
125
+ const target = attached;
126
+ attached = null;
127
+ if (current()) engine.detachReplica(REPLICA_ID);
128
+ await skv.del(RECORD_KEY);
129
+ await target?.disconnect().catch(() => void 0);
130
+ m.set({ step: "idle", busy: false, error: null, replica: current() });
131
+ },
132
+ async restore() {
133
+ try {
134
+ if (current()) return;
135
+ const record = await skv.get(RECORD_KEY);
136
+ if (!record) return;
137
+ const target = await options.restoreTarget?.(record.kind) ?? await builtinFromSession(record.kind);
138
+ if (!target) return;
139
+ engine.attachReplica(target, { id: REPLICA_ID });
140
+ attached = target;
141
+ m.set({ replica: current() });
142
+ } catch {
143
+ }
144
+ },
145
+ dispose() {
146
+ unsub();
147
+ }
148
+ };
149
+ }
150
+
151
+ export { REPLICA_ID, replicaFlow };
152
+ //# sourceMappingURL=flows.js.map
153
+ //# sourceMappingURL=flows.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/flows/replica.ts"],"names":["connect","config","fromSession"],"mappings":"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","file":"flows.js","sourcesContent":["// The backup-copy journey: attach ONE replica (a second destination receiving\n// the same encrypted backup as the primary home) behind a small state machine\n// the widgets can render. Reuses the connect flow's target specs, but there is\n// no password step, no conflict step and no deadline: a replica carries the\n// primary's bytes as-is, and every connector may contain a human gesture.\n\nimport type { BackupTarget } from '../persistence/target';\nimport type { KV } from '../persistence/cache';\nimport type { ReplicaState, StoreError } from '../persistence/store';\nimport { makeMachine, toStoreError, type FlowStore } from './machine';\nimport type { ConnectKind, ConnectTargets, Connector, FlowHost } from './connect';\nimport {\n\tconnect as driveConnect,\n\tfromSession as driveFromSession,\n\ttype DriveAuth\n} from '../persistence/targets/drive';\nimport { connect as fileConnect, fromSession as fileFromSession } from '../persistence/targets/file';\nimport {\n\tconnect as webdavConnect,\n\tfromSession as webdavFromSession,\n\ttype WebdavConfig\n} from '../persistence/targets/webdav';\nimport { connect as s3Connect, fromSession as s3FromSession, type S3Config } from '../persistence/targets/s3';\n\n/** The one replica this flow manages, as its id on the engine. */\nexport const REPLICA_ID = 'replica';\n\n// The replica's target session lives in the SAME kv as the primary's, so every\n// key is prefixed: a second driveTarget (say) must never read or clobber the\n// primary connection's session keys.\nconst SCOPE = 'replica:';\nconst RECORD_KEY = 'record';\n\n/** What survives a reboot: which provider the copy lives on. The target's own\n * session (file id, handle, server config) is persisted by the target module\n * itself, under the scoped kv. */\ninterface ReplicaRecord {\n\tkind: ConnectKind;\n}\n\nexport type ReplicaStep = 'idle' | 'choose' | 'form-webdav' | 'form-s3';\n\nexport interface ReplicaSnapshot {\n\tstep: ReplicaStep;\n\t/** The kinds the host offers, in ConnectTargets order. */\n\tkinds: ConnectKind[];\n\tbusy: boolean;\n\terror: StoreError | null;\n\t/** The live engine entry (label, lastPublishAt, lastError), null when none. */\n\treplica: ReplicaState | null;\n}\n\nexport interface ReplicaFlowOptions {\n\t/** File name of the copy on its destination. Default: the primary backup\n\t * name with `.replica` before the extension (\"App.zip\" -> \"App.replica.zip\"),\n\t * which a same-account Drive never confuses with the primary file and the\n\t * backups panel never lists as a backup of its own. */\n\tfileName?: string;\n\t/** Rebuild the target of a custom Connector kind on restore() (built-in\n\t * kinds rebuild by themselves). Without it, custom kinds are not restored. */\n\trestoreTarget?: (kind: ConnectKind) => Promise<BackupTarget | null>;\n}\n\nexport interface ReplicaFlow extends FlowStore<ReplicaSnapshot> {\n\t/** Show the destination choice (from the user's gesture). */\n\topen(): void;\n\t/** Back to idle; never an error. */\n\tcancel(): void;\n\t/** Run a destination (inside the user's click - pickers and consent popups\n\t * open from it). `webdav: true` / `s3: true` route to their form step. */\n\tpick(kind: ConnectKind): void;\n\t/** 'form-webdav' step: submit the server config. */\n\tsubmitWebdav(config: WebdavConfig): void;\n\t/** 'form-s3' step: submit the bucket config. */\n\tsubmitS3(config: S3Config): void;\n\t/** Detach the copy and forget its session. Never deletes remote data. */\n\tremove(): Promise<void>;\n\t/** Silent reboot: re-attach the copy recorded by a past session, if any.\n\t * Never throws - an unreachable destination retries at the next boot. */\n\trestore(): Promise<void>;\n\t/** Detach from the engine (the replica itself stays attached). */\n\tdispose(): void;\n}\n\nfunction scopedKv(kv: KV, prefix: string): KV {\n\treturn {\n\t\tget: <T = unknown>(key: string) => kv.get<T>(prefix + key),\n\t\tset: (key: string, value: unknown) => kv.set(prefix + key, value),\n\t\tdel: (key: string) => kv.del(prefix + key)\n\t};\n}\n\nfunction replicaFileName(backupName: string): string {\n\tconst dot = backupName.lastIndexOf('.');\n\treturn dot > 0\n\t\t? `${backupName.slice(0, dot)}.replica${backupName.slice(dot)}`\n\t\t: `${backupName}.replica`;\n}\n\nexport function replicaFlow(\n\tstore: FlowHost | { flowHost: FlowHost },\n\ttargets: ConnectTargets,\n\toptions: ReplicaFlowOptions = {}\n): ReplicaFlow {\n\tconst host = 'flowHost' in store ? store.flowHost : store;\n\tconst engine = host.engine;\n\tconst skv = scopedKv(host.kv, SCOPE);\n\tconst fileName = options.fileName ?? replicaFileName(host.backupName);\n\tconst kinds = (Object.keys(targets) as ConnectKind[]).filter(\n\t\t(k) => (k === 'drive' || k === 'file' || k === 'webdav' || k === 's3') && targets[k] != null\n\t);\n\n\tconst current = (): ReplicaState | null =>\n\t\tengine.state.replicas.find((r) => r.id === REPLICA_ID) ?? null;\n\n\tconst m = makeMachine<ReplicaSnapshot>({\n\t\tstep: 'idle',\n\t\tkinds,\n\t\tbusy: false,\n\t\terror: null,\n\t\treplica: current()\n\t});\n\n\t// Follow the engine: every publish (or publish failure) refreshes the entry.\n\tconst unsub = engine.subscribe(() => m.set({ replica: current() }));\n\n\t// Held for remove(): disconnect() lets the target forget its own session.\n\tlet attached: BackupTarget | null = null;\n\n\tfunction connectorFor(kind: ConnectKind): Connector | null {\n\t\tconst spec = targets[kind];\n\t\tif (spec == null) return null;\n\t\tif (typeof spec === 'function') return spec;\n\t\tif (kind === 'drive') {\n\t\t\tconst auth = spec as DriveAuth;\n\t\t\treturn () => driveConnect({ auth, kv: skv, fileName });\n\t\t}\n\t\tif (kind === 'file') {\n\t\t\treturn () => fileConnect({ kv: skv, fileName });\n\t\t}\n\t\tif (kind === 's3') {\n\t\t\tconst config = spec as S3Config; // 's3: true' goes through the form step\n\t\t\treturn () => s3Connect({ kv: skv, config });\n\t\t}\n\t\tconst config = spec as WebdavConfig; // 'webdav: true' goes through the form step\n\t\treturn () => webdavConnect({ kv: skv, config });\n\t}\n\n\tasync function run(kind: ConnectKind, connector: Connector): Promise<void> {\n\t\tm.set({ busy: true, error: null });\n\t\ttry {\n\t\t\tconst target = await connector();\n\t\t\tif (!target) {\n\t\t\t\t// Cancelled picker/consent: stay where the user was, no error.\n\t\t\t\tm.set({ busy: false });\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (current()) engine.detachReplica(REPLICA_ID); // swap, never a dup-id throw\n\t\t\tengine.attachReplica(target, { id: REPLICA_ID });\n\t\t\tattached = target;\n\t\t\tawait skv.set(RECORD_KEY, { kind } satisfies ReplicaRecord);\n\t\t\tm.set({ step: 'idle', busy: false, error: null, replica: current() });\n\t\t} catch (e) {\n\t\t\tm.set({ busy: false, error: toStoreError(e) });\n\t\t}\n\t}\n\n\tasync function builtinFromSession(kind: ConnectKind): Promise<BackupTarget | null> {\n\t\tif (kind === 'drive') {\n\t\t\tconst spec = targets.drive;\n\t\t\tif (spec == null || typeof spec === 'function') return null;\n\t\t\treturn driveFromSession({ auth: spec, kv: skv, fileName });\n\t\t}\n\t\tif (kind === 'file') return fileFromSession({ kv: skv });\n\t\tif (kind === 'webdav') return webdavFromSession({ kv: skv });\n\t\treturn s3FromSession({ kv: skv });\n\t}\n\n\treturn {\n\t\tget snapshot() {\n\t\t\treturn m.snapshot;\n\t\t},\n\t\tsubscribe: m.subscribe,\n\n\t\topen() {\n\t\t\tif (m.snapshot.busy) return;\n\t\t\tm.set({ step: 'choose', error: null });\n\t\t},\n\n\t\tcancel() {\n\t\t\tm.set({ step: 'idle', busy: false, error: null });\n\t\t},\n\n\t\tpick(kind) {\n\t\t\tif (m.snapshot.busy) return;\n\t\t\tconst spec = targets[kind];\n\t\t\tif (spec == null) return;\n\t\t\tif (kind === 'webdav' && spec === true) {\n\t\t\t\tm.set({ step: 'form-webdav', error: null });\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (kind === 's3' && spec === true) {\n\t\t\t\tm.set({ step: 'form-s3', error: null });\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tconst connector = connectorFor(kind);\n\t\t\tif (connector) void run(kind, connector);\n\t\t},\n\n\t\tsubmitWebdav(config) {\n\t\t\tif (m.snapshot.busy) return;\n\t\t\tvoid run('webdav', () => webdavConnect({ kv: skv, config }));\n\t\t},\n\n\t\tsubmitS3(config) {\n\t\t\tif (m.snapshot.busy) return;\n\t\t\tvoid run('s3', () => s3Connect({ kv: skv, config }));\n\t\t},\n\n\t\tasync remove() {\n\t\t\tconst target = attached;\n\t\t\tattached = null;\n\t\t\tif (current()) engine.detachReplica(REPLICA_ID);\n\t\t\tawait skv.del(RECORD_KEY);\n\t\t\t// Forget locally only - a BackupTarget's disconnect never deletes\n\t\t\t// remote data, so the copy stays where it is for a manual recovery.\n\t\t\tawait target?.disconnect().catch(() => undefined);\n\t\t\tm.set({ step: 'idle', busy: false, error: null, replica: current() });\n\t\t},\n\n\t\tasync restore() {\n\t\t\ttry {\n\t\t\t\tif (current()) return;\n\t\t\t\tconst record = await skv.get<ReplicaRecord>(RECORD_KEY);\n\t\t\t\tif (!record) return;\n\t\t\t\tconst target =\n\t\t\t\t\t(await options.restoreTarget?.(record.kind)) ?? (await builtinFromSession(record.kind));\n\t\t\t\tif (!target) return; // unreachable or custom kind: retry next boot\n\t\t\t\tengine.attachReplica(target, { id: REPLICA_ID });\n\t\t\t\tattached = target;\n\t\t\t\tm.set({ replica: current() });\n\t\t\t} catch {\n\t\t\t\t// Silent reboot by contract: the record stays, the next boot retries.\n\t\t\t}\n\t\t},\n\n\t\tdispose() {\n\t\t\tunsub();\n\t\t}\n\t};\n}\n"]}
@@ -0,0 +1,39 @@
1
+ import { G as GroupIdentity, K as KV } from './cache-De5_sALA.js';
2
+ export { a as GROUP_KEYING, b as GroupManifest, c as GroupMember, S as SignedManifest, g as generateIdentity, d as groupCryptoAvailable, k as keyId, n as newGroupId, o as openManifest, p as publicIdentity, s as signManifest } from './cache-De5_sALA.js';
3
+
4
+ interface IdentityVault {
5
+ /** The stored identity, or null when none exists yet. On a protected
6
+ * vault this throws PASSWORD_REQUIRED rather than returning null - a
7
+ * locked identity exists, and null would invite loadOrCreate to mint a
8
+ * second one over it. */
9
+ load(): Promise<GroupIdentity | null>;
10
+ /** Seal and store an identity, overwriting any previous one. Throws
11
+ * PASSWORD_REQUIRED on a protected vault: a locked identity is never
12
+ * silently replaced (unprotect() or clear() first). */
13
+ save(identity: GroupIdentity): Promise<void>;
14
+ /** load(), creating and storing a fresh identity when absent. Same
15
+ * PASSWORD_REQUIRED rule as load(). */
16
+ loadOrCreate(): Promise<GroupIdentity>;
17
+ /** Forget the identity on this device. Allowed even when protected -
18
+ * forgetting leaks nothing, and it is the honest lost-passphrase escape
19
+ * hatch (re-import from another device, or get re-invited). */
20
+ clear(): Promise<void>;
21
+ isProtected(): Promise<boolean>;
22
+ /** Decrypt a protected vault and return the identity. Stateless: the
23
+ * caller holds it in memory, the stored value stays sealed. Wrong
24
+ * passphrase and tampered blob are one DECRYPT_FAILED; hostile KDF
25
+ * params are refused before any derivation. */
26
+ unlock(passphrase: string): Promise<GroupIdentity>;
27
+ /** Reseal the identity under Argon2id(passphrase); an empty vault gets a
28
+ * fresh identity, created-and-protected in one step. Changing the
29
+ * passphrase is unprotect(old) then protect(new) - an already-protected
30
+ * vault throws PASSWORD_REQUIRED, never a silent rekey. */
31
+ protect(passphrase: string): Promise<void>;
32
+ /** Unlock with `passphrase`, then reseal under the device key. A wrong
33
+ * passphrase throws DECRYPT_FAILED and leaves the vault untouched. */
34
+ unprotect(passphrase: string): Promise<void>;
35
+ }
36
+ /** An identity vault over the app's kv (e.g. `indexedDbCache(app).kv`). */
37
+ declare function identityVault(kv: KV): IdentityVault;
38
+
39
+ export { GroupIdentity, type IdentityVault, identityVault };
package/dist/groups.js ADDED
@@ -0,0 +1,103 @@
1
+ import { generateIdentity } from './chunk-OWLX6PNT.js';
2
+ export { GROUP_KEYING, generateIdentity, groupCryptoAvailable, keyId, newGroupId, openManifest, publicIdentity, signManifest } from './chunk-OWLX6PNT.js';
3
+ import { seal, isEnvelope, unseal, newDeviceKey } from './chunk-QBCU7OOP.js';
4
+ import { assertKdfBounds, deriveKey, freshKdfParams } from './chunk-FDPAKHQS.js';
5
+ import { SelfstoreError } from './chunk-VBFMMR7Q.js';
6
+
7
+ // src/persistence/identity.ts
8
+ var VAULT_KEY = "groupVaultKey";
9
+ var IDENTITY_KEY = "groupIdentity";
10
+ var utf8 = { enc: new TextEncoder(), dec: new TextDecoder() };
11
+ function isVaultLock(v) {
12
+ return typeof v === "object" && v !== null && v.selfstoreVaultLock === 1;
13
+ }
14
+ var encode = (identity) => utf8.enc.encode(JSON.stringify(identity));
15
+ var decode = (bytes) => JSON.parse(utf8.dec.decode(bytes));
16
+ function identityVault(kv) {
17
+ async function vaultKey() {
18
+ const existing = await kv.get(VAULT_KEY);
19
+ if (existing) return existing;
20
+ const fresh = await newDeviceKey();
21
+ await kv.set(VAULT_KEY, fresh);
22
+ return fresh;
23
+ }
24
+ async function sealDevice(identity) {
25
+ await kv.set(IDENTITY_KEY, await seal(await vaultKey(), encode(identity)));
26
+ }
27
+ async function load() {
28
+ const stored = await kv.get(IDENTITY_KEY);
29
+ if (isVaultLock(stored)) {
30
+ throw new SelfstoreError(
31
+ "PASSWORD_REQUIRED",
32
+ "The identity vault is passphrase-protected. Call unlock(passphrase) - load()/loadOrCreate() never open or replace a locked identity."
33
+ );
34
+ }
35
+ if (!isEnvelope(stored)) return null;
36
+ return decode(await unseal(await vaultKey(), stored));
37
+ }
38
+ async function save(identity) {
39
+ if (isVaultLock(await kv.get(IDENTITY_KEY))) {
40
+ throw new SelfstoreError(
41
+ "PASSWORD_REQUIRED",
42
+ "The identity vault is passphrase-protected; save() never overwrites a locked identity. unprotect(passphrase) first, or clear() to forget it."
43
+ );
44
+ }
45
+ await sealDevice(identity);
46
+ }
47
+ async function unlock(passphrase) {
48
+ const stored = await kv.get(IDENTITY_KEY);
49
+ if (!isVaultLock(stored)) {
50
+ throw new TypeError("unlock(): the vault is not passphrase-protected (check isProtected()).");
51
+ }
52
+ if (!stored.kdf || !isEnvelope(stored.envelope)) {
53
+ throw new SelfstoreError("BAD_FORMAT", "Missing or malformed vault lock parameters.");
54
+ }
55
+ assertKdfBounds(stored.kdf);
56
+ try {
57
+ return decode(await unseal(await deriveKey(passphrase, stored.kdf), stored.envelope));
58
+ } catch (e) {
59
+ if (e instanceof SelfstoreError) throw e;
60
+ throw new SelfstoreError("DECRYPT_FAILED", "Wrong passphrase or tampered identity vault.");
61
+ }
62
+ }
63
+ async function protect(passphrase) {
64
+ if (!passphrase) throw new TypeError("protect(): a non-empty passphrase is required.");
65
+ const stored = await kv.get(IDENTITY_KEY);
66
+ if (isVaultLock(stored)) {
67
+ throw new SelfstoreError(
68
+ "PASSWORD_REQUIRED",
69
+ "The identity vault is already passphrase-protected. To change the passphrase: unprotect(oldPassphrase), then protect(newPassphrase)."
70
+ );
71
+ }
72
+ const identity = isEnvelope(stored) ? decode(await unseal(await vaultKey(), stored)) : await generateIdentity();
73
+ const kdf = freshKdfParams();
74
+ const envelope = await seal(await deriveKey(passphrase, kdf), encode(identity));
75
+ await kv.set(IDENTITY_KEY, { selfstoreVaultLock: 1, kdf, envelope });
76
+ }
77
+ return {
78
+ load,
79
+ save,
80
+ unlock,
81
+ protect,
82
+ async loadOrCreate() {
83
+ const existing = await load();
84
+ if (existing) return existing;
85
+ const fresh = await generateIdentity();
86
+ await save(fresh);
87
+ return fresh;
88
+ },
89
+ async clear() {
90
+ await kv.del(IDENTITY_KEY);
91
+ },
92
+ async isProtected() {
93
+ return isVaultLock(await kv.get(IDENTITY_KEY));
94
+ },
95
+ async unprotect(passphrase) {
96
+ await sealDevice(await unlock(passphrase));
97
+ }
98
+ };
99
+ }
100
+
101
+ export { identityVault };
102
+ //# sourceMappingURL=groups.js.map
103
+ //# sourceMappingURL=groups.js.map