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 @@
1
+ {"version":3,"sources":["../src/persistence/identity.ts"],"names":[],"mappings":"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","file":"groups.js","sourcesContent":["// At-rest storage for a member identity (passwordless groups). Private keys\n// are sealed under a non-extractable device key in the same kv - defeats disk\n// forensics and partial exfiltration, not code running in the origin or a\n// copy of the whole profile (THREAT-MODEL.md).\n//\n// The opt-in passphrase lock reseals the identity under Argon2id(passphrase)\n// instead, closing the profile-copy gap at the cost of an unlock prompt. The\n// vault keeps no unlocked session state: unlock() returns the identity, the\n// caller holds it in memory, and at rest it stays sealed. A bare envelope\n// keeps meaning device mode, so pre-lock vaults load unchanged.\n//\n// The identity is one person's; move it between their devices over a trusted\n// channel (QR shown locally, encrypted note) - never through the group's\n// shared storage.\n\nimport { seal, unseal, isEnvelope, newDeviceKey, type EncEnvelope } from './cache-crypto';\nimport type { KV } from './cache';\nimport { generateIdentity, type GroupIdentity } from '../selfstore/group';\nimport { deriveKey, freshKdfParams, assertKdfBounds } from '../selfstore/crypto';\nimport type { KdfParams } from '../selfstore/types';\nimport { SelfstoreError } from '../selfstore/errors';\n\nconst VAULT_KEY = 'groupVaultKey';\nconst IDENTITY_KEY = 'groupIdentity';\n\nconst utf8 = { enc: new TextEncoder(), dec: new TextDecoder() };\n\n// Local kv bookkeeping only - not part of the interchange format (SPEC.md).\ninterface VaultLock {\n\tselfstoreVaultLock: 1;\n\tkdf: KdfParams;\n\tenvelope: EncEnvelope;\n}\n\nfunction isVaultLock(v: unknown): v is VaultLock {\n\treturn (\n\t\ttypeof v === 'object' && v !== null && (v as { selfstoreVaultLock?: unknown }).selfstoreVaultLock === 1\n\t);\n}\n\nconst encode = (identity: GroupIdentity): Uint8Array => utf8.enc.encode(JSON.stringify(identity));\nconst decode = (bytes: Uint8Array): GroupIdentity =>\n\tJSON.parse(utf8.dec.decode(bytes)) as GroupIdentity;\n\nexport interface IdentityVault {\n\t/** The stored identity, or null when none exists yet. On a protected\n\t * vault this throws PASSWORD_REQUIRED rather than returning null - a\n\t * locked identity exists, and null would invite loadOrCreate to mint a\n\t * second one over it. */\n\tload(): Promise<GroupIdentity | null>;\n\t/** Seal and store an identity, overwriting any previous one. Throws\n\t * PASSWORD_REQUIRED on a protected vault: a locked identity is never\n\t * silently replaced (unprotect() or clear() first). */\n\tsave(identity: GroupIdentity): Promise<void>;\n\t/** load(), creating and storing a fresh identity when absent. Same\n\t * PASSWORD_REQUIRED rule as load(). */\n\tloadOrCreate(): Promise<GroupIdentity>;\n\t/** Forget the identity on this device. Allowed even when protected -\n\t * forgetting leaks nothing, and it is the honest lost-passphrase escape\n\t * hatch (re-import from another device, or get re-invited). */\n\tclear(): Promise<void>;\n\tisProtected(): Promise<boolean>;\n\t/** Decrypt a protected vault and return the identity. Stateless: the\n\t * caller holds it in memory, the stored value stays sealed. Wrong\n\t * passphrase and tampered blob are one DECRYPT_FAILED; hostile KDF\n\t * params are refused before any derivation. */\n\tunlock(passphrase: string): Promise<GroupIdentity>;\n\t/** Reseal the identity under Argon2id(passphrase); an empty vault gets a\n\t * fresh identity, created-and-protected in one step. Changing the\n\t * passphrase is unprotect(old) then protect(new) - an already-protected\n\t * vault throws PASSWORD_REQUIRED, never a silent rekey. */\n\tprotect(passphrase: string): Promise<void>;\n\t/** Unlock with `passphrase`, then reseal under the device key. A wrong\n\t * passphrase throws DECRYPT_FAILED and leaves the vault untouched. */\n\tunprotect(passphrase: string): Promise<void>;\n}\n\n/** An identity vault over the app's kv (e.g. `indexedDbCache(app).kv`). */\nexport function identityVault(kv: KV): IdentityVault {\n\tasync function vaultKey(): Promise<CryptoKey> {\n\t\tconst existing = await kv.get<CryptoKey>(VAULT_KEY);\n\t\tif (existing) return existing;\n\t\tconst fresh = await newDeviceKey();\n\t\tawait kv.set(VAULT_KEY, fresh);\n\t\treturn fresh;\n\t}\n\n\t/** Device mode's write path: a bare envelope under the device key. */\n\tasync function sealDevice(identity: GroupIdentity): Promise<void> {\n\t\tawait kv.set(IDENTITY_KEY, await seal(await vaultKey(), encode(identity)));\n\t}\n\n\tasync function load(): Promise<GroupIdentity | null> {\n\t\tconst stored = await kv.get(IDENTITY_KEY);\n\t\tif (isVaultLock(stored)) {\n\t\t\tthrow new SelfstoreError(\n\t\t\t\t'PASSWORD_REQUIRED',\n\t\t\t\t'The identity vault is passphrase-protected. Call unlock(passphrase) - ' +\n\t\t\t\t\t'load()/loadOrCreate() never open or replace a locked identity.'\n\t\t\t);\n\t\t}\n\t\tif (!isEnvelope(stored)) return null;\n\t\treturn decode(await unseal(await vaultKey(), stored));\n\t}\n\n\tasync function save(identity: GroupIdentity): Promise<void> {\n\t\tif (isVaultLock(await kv.get(IDENTITY_KEY))) {\n\t\t\tthrow new SelfstoreError(\n\t\t\t\t'PASSWORD_REQUIRED',\n\t\t\t\t'The identity vault is passphrase-protected; save() never overwrites a locked ' +\n\t\t\t\t\t'identity. unprotect(passphrase) first, or clear() to forget it.'\n\t\t\t);\n\t\t}\n\t\tawait sealDevice(identity);\n\t}\n\n\tasync function unlock(passphrase: string): Promise<GroupIdentity> {\n\t\tconst stored = await kv.get(IDENTITY_KEY);\n\t\tif (!isVaultLock(stored)) {\n\t\t\tthrow new TypeError('unlock(): the vault is not passphrase-protected (check isProtected()).');\n\t\t}\n\t\tif (!stored.kdf || !isEnvelope(stored.envelope)) {\n\t\t\tthrow new SelfstoreError('BAD_FORMAT', 'Missing or malformed vault lock parameters.');\n\t\t}\n\t\t// Same read-side ceiling as backup decrypts: a tampered kv must not make\n\t\t// unlock allocate gigabytes (memory-hard bomb) before any passphrase check.\n\t\tassertKdfBounds(stored.kdf);\n\t\ttry {\n\t\t\treturn decode(await unseal(await deriveKey(passphrase, stored.kdf), stored.envelope));\n\t\t} catch (e) {\n\t\t\tif (e instanceof SelfstoreError) throw e;\n\t\t\tthrow new SelfstoreError('DECRYPT_FAILED', 'Wrong passphrase or tampered identity vault.');\n\t\t}\n\t}\n\n\tasync function protect(passphrase: string): Promise<void> {\n\t\tif (!passphrase) throw new TypeError('protect(): a non-empty passphrase is required.');\n\t\tconst stored = await kv.get(IDENTITY_KEY);\n\t\tif (isVaultLock(stored)) {\n\t\t\tthrow new SelfstoreError(\n\t\t\t\t'PASSWORD_REQUIRED',\n\t\t\t\t'The identity vault is already passphrase-protected. To change the passphrase: ' +\n\t\t\t\t\t'unprotect(oldPassphrase), then protect(newPassphrase).'\n\t\t\t);\n\t\t}\n\t\tconst identity = isEnvelope(stored)\n\t\t\t? decode(await unseal(await vaultKey(), stored)) // reseal the existing identity\n\t\t\t: await generateIdentity(); // empty vault: create-and-protect in one step\n\t\tconst kdf = freshKdfParams();\n\t\tconst envelope = await seal(await deriveKey(passphrase, kdf), encode(identity));\n\t\tawait kv.set(IDENTITY_KEY, { selfstoreVaultLock: 1, kdf, envelope } satisfies VaultLock);\n\t}\n\n\treturn {\n\t\tload,\n\t\tsave,\n\t\tunlock,\n\t\tprotect,\n\t\tasync loadOrCreate(): Promise<GroupIdentity> {\n\t\t\tconst existing = await load(); // PASSWORD_REQUIRED when protected: never mint over a lock\n\t\t\tif (existing) return existing;\n\t\t\tconst fresh = await generateIdentity();\n\t\t\tawait save(fresh);\n\t\t\treturn fresh;\n\t\t},\n\t\tasync clear(): Promise<void> {\n\t\t\tawait kv.del(IDENTITY_KEY);\n\t\t},\n\t\tasync isProtected(): Promise<boolean> {\n\t\t\treturn isVaultLock(await kv.get(IDENTITY_KEY));\n\t\t},\n\t\tasync unprotect(passphrase: string): Promise<void> {\n\t\t\t// unlock() enforces \"protected\" and the passphrase; only then rewrite.\n\t\t\tawait sealDevice(await unlock(passphrase));\n\t\t}\n\t};\n}\n"]}
@@ -0,0 +1,214 @@
1
+ import { B as BackupTarget, g as PeerSource, L as LocalStore } from './store-ChIGEOph.js';
2
+ import { K as KV } from './cache-De5_sALA.js';
3
+ import './sync.js';
4
+
5
+ /** Why a code was refused. `malformed` covers bad transport, wrong structure or
6
+ * a code decoded as the wrong kind of payload; `unsupported-version` is a
7
+ * numeric `v` this build does not understand. */
8
+ type HouseholdCodeErrorCode = 'malformed' | 'unsupported-version';
9
+ /** The single typed error this module throws, so callers branch on `code`
10
+ * instead of parsing messages. A decoder that throws never returns a partially
11
+ * parsed object - it fails whole. */
12
+ declare class HouseholdCodeError extends Error {
13
+ readonly code: HouseholdCodeErrorCode;
14
+ constructor(code: HouseholdCodeErrorCode, message: string);
15
+ }
16
+ /** Where a member published their encrypted copy. A discriminated union on
17
+ * `provider` so a future transport (WebDAV, ...) slots in beside Drive without
18
+ * changing the payload shapes. Today: Drive only. */
19
+ type CopyLink = DriveCopyLink;
20
+ /** A copy hosted on the owner's Google Drive. `fileId` is the Drive file id (a
21
+ * public-link file, ciphertext under K); the owner fields are a cosmetic "on
22
+ * X's Drive" hint, both optional. */
23
+ interface DriveCopyLink {
24
+ provider: 'drive';
25
+ fileId: string;
26
+ ownerEmail?: string;
27
+ ownerName?: string;
28
+ }
29
+ /** The bulletin's control record (rides one reserved collection inside the
30
+ * K-encrypted bulletin): where members announce their copy (`mailboxId`, a
31
+ * secret only K-holders can read) and the roster of every known copy (the
32
+ * admin's own first). */
33
+ interface SharePayload {
34
+ v: 1;
35
+ mailboxId: string;
36
+ roster: CopyLink[];
37
+ }
38
+ /** Member -> admin, through the announce mailbox: "here is my copy, fold it".
39
+ * No identity, no signature: knowing the mailboxId (which lives inside the
40
+ * K-encrypted bulletin) already proves the sender holds K. */
41
+ interface AnnouncePayload {
42
+ v: 1;
43
+ copy: CopyLink;
44
+ }
45
+ /** A short, url-safe, random id (9 bytes -> 12 base64url chars) from WebCrypto -
46
+ * used for the announce mailbox id. This repo bans Math.random; getRandomValues
47
+ * is the sanctioned entropy source and exists in every browser and Node 20+. */
48
+ declare function randomId(): string;
49
+ /** Rebuild a CopyLink field by field, so a decoded link carries exactly the
50
+ * known keys (no smuggled extras) and every present field has the right type. */
51
+ declare function toCopyLink(v: unknown): CopyLink;
52
+ /** Every entry of a roster array validated as a CopyLink, de-duplicated by
53
+ * fileId (first occurrence wins). An empty roster is structurally valid.
54
+ * Exported for the backend, which revalidates the roster it re-publishes. */
55
+ declare function toRoster(v: unknown): CopyLink[];
56
+ declare function encodeShare(payload: SharePayload): string;
57
+ declare function decodeShare(code: string): SharePayload;
58
+ declare function encodeAnnounce(payload: AnnouncePayload): string;
59
+ declare function decodeAnnounce(code: string): AnnouncePayload;
60
+
61
+ /** What the bulletin holds once opened: the read-only projection (scrubbed
62
+ * collections, empty when the share is revoked) and the control record. */
63
+ interface IncomingShare {
64
+ projection: Record<string, unknown[]>;
65
+ share: SharePayload;
66
+ }
67
+ /**
68
+ * The sharing transport, the sole Drive/relay/mailbox-specific seam. A test
69
+ * provides an in-memory fake; the host app provides a real one (e.g. Drive).
70
+ * Everything here carries only public data - file ids and the roster.
71
+ */
72
+ interface ShareBackend {
73
+ /** Create this member's dedicated copy file (link-readable, empty until the
74
+ * mirror publishes into it) and return the link others read it through.
75
+ * `existingFileId` reuses a known copy on restore instead of minting a
76
+ * second file. */
77
+ createCopy(existingFileId?: string, shareLabel?: string): Promise<CopyLink>;
78
+ /** A write-capable target over a copy file, for the store MIRROR. Never
79
+ * adopted as the store's own target - the store's own connection stays what
80
+ * it is. */
81
+ copyTarget(fileId: string): BackupTarget;
82
+ /** Teardown: make a copy file private and delete it (best-effort). Only
83
+ * ever called on MY own copy - the others' files are theirs. */
84
+ dropCopy(fileId: string): Promise<void>;
85
+ /** Admin: write (or rewrite) the bulletin - the encrypted file the invitation
86
+ * link points at, holding the read-only projection plus the control record.
87
+ * Idempotent per group; returns where it lives (the link's fileId + key). */
88
+ publishBulletin(key: string, payload: SharePayload): Promise<{
89
+ fileId: string;
90
+ key: string;
91
+ }>;
92
+ /** Admin: stop the invitation - the bulletin drops its projection and roster,
93
+ * so the link no longer previews or admits. */
94
+ revokeBulletin(): Promise<void>;
95
+ /** Open an invitation link's bulletin (through the relay) and STASH it for the
96
+ * join, returning its content for the read-only preview. Null when the link
97
+ * is dead or the key wrong. */
98
+ openIncoming(fileId: string, key: string): Promise<IncomingShare | null>;
99
+ /** The invitation opened by openIncoming and stashed for the join, or null. */
100
+ takeStashedIncoming(): Promise<{
101
+ key: string;
102
+ fileId: string;
103
+ content: IncomingShare;
104
+ } | null>;
105
+ /** Re-read a joined share's bulletin (member): the freshest roster. The
106
+ * engine passes the invitation capability it joined through, so every
107
+ * membership rereads ITS bulletin. Null when unreachable or revoked. */
108
+ rereadJoined(fileId: string, key: string): Promise<SharePayload | null>;
109
+ /** Member: announce my copy to the group's mailbox so the admin folds it. */
110
+ announce(mailboxId: string, copy: CopyLink): Promise<void>;
111
+ /** Admin: drain the announce mailbox - the copies members published. */
112
+ takeAnnounces(mailboxId: string): Promise<CopyLink[]>;
113
+ /** A read-only PeerSource over another member's published copy. */
114
+ peerSource(link: CopyLink): PeerSource;
115
+ }
116
+ /** The default KV key the group lives under. Exported so a caller can wipe
117
+ * it. Hosts with a pre-existing record under another key pass `storageKey`. */
118
+ declare const HOUSEHOLD_GROUP_KEY = "selfstore:households:group:v1";
119
+ /** One membership, as the UI lists them: which silo carries it and whose
120
+ * share it is. */
121
+ interface MembershipInfo {
122
+ /** The wallet file this membership is bound to (null = unbound legacy). */
123
+ walletFileId: string | null;
124
+ isAdmin: boolean;
125
+ /** My published copy's file id for this share. */
126
+ selfFileId: string;
127
+ /** Who shares it with me (the admin's roster label); null for my own share. */
128
+ sharedBy: string | null;
129
+ memberCount: number;
130
+ }
131
+ /** The reactive-friendly snapshot the UI reads (hosts wrap it in their own
132
+ * reactivity - a Svelte rune, a React store, anything). The singular fields
133
+ * describe the PRIMARY membership (the admin one when it exists, else the
134
+ * first); `memberships` lists them all. */
135
+ interface HouseholdGroupState {
136
+ /** A share exists on this device (admin or member). */
137
+ active: boolean;
138
+ isAdmin: boolean;
139
+ /** How many copies are in the roster (this device included). */
140
+ memberCount: number;
141
+ /** The roster's cosmetic owner labels, for a "who has access" list. */
142
+ members: {
143
+ fileId: string;
144
+ label: string;
145
+ }[];
146
+ /** My own copy's file id, so the UI can list everyone ELSE. */
147
+ selfFileId: string | null;
148
+ /** The wallet file the share is bound to, or null when unbound. The host
149
+ * compares it to its own attached-file id to render "this silo carries the
150
+ * share" and to gate its own publications the same way the engine does. */
151
+ walletFileId: string | null;
152
+ /** Admin: the live invitation's capability, so the panel can re-show the
153
+ * link without republishing anything. */
154
+ inviteCapability: {
155
+ fileId: string;
156
+ key: string;
157
+ } | null;
158
+ /** Every membership on this device, admin and joined alike. */
159
+ memberships: MembershipInfo[];
160
+ }
161
+ interface HouseholdGroup {
162
+ readonly state: HouseholdGroupState;
163
+ /** Admin: start sharing. The member's own file is untouched - a dedicated
164
+ * copy is created and mirrored under a fresh K, and the invitation
165
+ * bulletin is published. Refused on a password-protected store: remove
166
+ * the password to share, or keep it and do not. */
167
+ startShare(): Promise<{
168
+ fileId: string;
169
+ key: string;
170
+ }>;
171
+ /** Admin: re-show (and refresh) the standing invitation link. Idempotent. */
172
+ invite(): Promise<{
173
+ fileId: string;
174
+ key: string;
175
+ }>;
176
+ /** /share: open an invitation link's bulletin and remember it for the join. */
177
+ openIncoming(fileId: string, key: string): Promise<IncomingShare | null>;
178
+ /** Member: after openIncoming + own store connected, join read-write now.
179
+ * Your own file stays yours: a dedicated copy is created and mirrored
180
+ * under K, the others' copies attach as keyed peers (the next converge
181
+ * folds the shared data into your own file), and my copy is announced.
182
+ * Same honest rule as startShare: a password-protected store refuses.
183
+ * With a `wallet` getter, one membership per share: joining another share
184
+ * ADDS a membership (bound to the attached silo), and a re-join over the
185
+ * same silo replaces its stale one. Without the getter, the historical
186
+ * single-membership rule holds ('mismatch' on a different share). */
187
+ join(): Promise<'joined' | 'no-invite' | 'mismatch' | 'error'>;
188
+ /** Both: one convergence round (admin folds new announces + rebroadcasts;
189
+ * everyone re-arms mirror/peers). Called on every converge. */
190
+ syncGroup(): Promise<void>;
191
+ /** End ONE share on this device - and, for the admin, this is the real
192
+ * revocation (bearer link: the lock changes for everyone). The bulletin
193
+ * is revoked, my copy file is dropped, mirror and peers detach. The
194
+ * member's own file is untouched: everyone keeps what already converged into
195
+ * their own file - the local-first reality. Re-sharing mints a fresh K
196
+ * and a fresh link for whoever should stay. `walletFileId` names WHICH
197
+ * membership; omitted, the primary one (admin first) goes. */
198
+ leave(walletFileId?: string | null): Promise<void>;
199
+ /** On boot: re-arm the mirror and peers of the current share. */
200
+ restore(): Promise<void>;
201
+ }
202
+ declare function createHouseholdGroup(deps: {
203
+ store: LocalStore;
204
+ kv: KV;
205
+ backend: ShareBackend;
206
+ /** KV key the group record lives under. Default HOUSEHOLD_GROUP_KEY. */
207
+ storageKey?: string;
208
+ /** The currently attached wallet file id, for multi-backup hosts. When
209
+ * given, start/join bind the share to that file and the engine goes
210
+ * dormant whenever another one is attached. Omit on single-wallet hosts. */
211
+ wallet?: () => Promise<string | null>;
212
+ }): HouseholdGroup;
213
+
214
+ export { type AnnouncePayload, type CopyLink, type DriveCopyLink, HOUSEHOLD_GROUP_KEY, HouseholdCodeError, type HouseholdCodeErrorCode, type HouseholdGroup, type HouseholdGroupState, type IncomingShare, type ShareBackend, type SharePayload, createHouseholdGroup, decodeAnnounce, decodeShare, encodeAnnounce, encodeShare, randomId, toCopyLink, toRoster };
@@ -0,0 +1,410 @@
1
+ // src/households/codec.ts
2
+ var HouseholdCodeError = class _HouseholdCodeError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.name = "HouseholdCodeError";
7
+ this.code = code;
8
+ Object.setPrototypeOf(this, _HouseholdCodeError.prototype);
9
+ }
10
+ };
11
+ var malformed = (message) => new HouseholdCodeError("malformed", message);
12
+ var utf8Encoder = new TextEncoder();
13
+ var utf8Decoder = new TextDecoder("utf-8", { fatal: true });
14
+ function bytesToBinary(bytes) {
15
+ let out = "";
16
+ for (let i = 0; i < bytes.length; i++) out += String.fromCharCode(bytes[i]);
17
+ return out;
18
+ }
19
+ function binaryToBytes(binary) {
20
+ const bytes = new Uint8Array(binary.length);
21
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
22
+ return bytes;
23
+ }
24
+ function bytesToBase64url(bytes) {
25
+ return btoa(bytesToBinary(bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
26
+ }
27
+ function base64urlEncode(text) {
28
+ return bytesToBase64url(utf8Encoder.encode(text));
29
+ }
30
+ function base64urlDecode(code) {
31
+ const b64 = code.replace(/-/g, "+").replace(/_/g, "/");
32
+ const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
33
+ return utf8Decoder.decode(binaryToBytes(atob(padded)));
34
+ }
35
+ function randomId() {
36
+ const bytes = new Uint8Array(9);
37
+ crypto.getRandomValues(bytes);
38
+ return bytesToBase64url(bytes);
39
+ }
40
+ function isRecord(v) {
41
+ return typeof v === "object" && v !== null && !Array.isArray(v);
42
+ }
43
+ function isString(v) {
44
+ return typeof v === "string";
45
+ }
46
+ function requireV1(v) {
47
+ if (v === 1) return;
48
+ if (typeof v === "number") {
49
+ throw new HouseholdCodeError("unsupported-version", `payload version ${v} is not supported (expected 1)`);
50
+ }
51
+ throw malformed("missing or non-numeric version field");
52
+ }
53
+ function decodeJson(code) {
54
+ if (typeof code !== "string" || code.length === 0) throw malformed("code must be a non-empty string");
55
+ let json;
56
+ try {
57
+ json = base64urlDecode(code);
58
+ } catch {
59
+ throw malformed("code is not valid base64url");
60
+ }
61
+ try {
62
+ return JSON.parse(json);
63
+ } catch {
64
+ throw malformed("code payload is not valid JSON");
65
+ }
66
+ }
67
+ function toCopyLink(v) {
68
+ if (!isRecord(v)) throw malformed("copy link is not an object");
69
+ if (v.provider === "drive") {
70
+ if (!isString(v.fileId)) throw malformed("drive copy link needs a string fileId");
71
+ if (v.ownerEmail !== void 0 && !isString(v.ownerEmail)) throw malformed("copy link ownerEmail must be a string");
72
+ if (v.ownerName !== void 0 && !isString(v.ownerName)) throw malformed("copy link ownerName must be a string");
73
+ const link = { provider: "drive", fileId: v.fileId };
74
+ if (isString(v.ownerEmail)) link.ownerEmail = v.ownerEmail;
75
+ if (isString(v.ownerName)) link.ownerName = v.ownerName;
76
+ return link;
77
+ }
78
+ throw malformed(`unknown copy-link provider ${JSON.stringify(v.provider)}`);
79
+ }
80
+ function toRoster(v) {
81
+ if (!Array.isArray(v)) throw malformed("roster is not an array");
82
+ const seen = /* @__PURE__ */ new Set();
83
+ const out = [];
84
+ for (const entry of v) {
85
+ const link = toCopyLink(entry);
86
+ if (seen.has(link.fileId)) continue;
87
+ seen.add(link.fileId);
88
+ out.push(link);
89
+ }
90
+ return out;
91
+ }
92
+ function encodeShare(payload) {
93
+ return base64urlEncode(JSON.stringify(payload));
94
+ }
95
+ function decodeShare(code) {
96
+ const raw = decodeJson(code);
97
+ if (!isRecord(raw)) throw malformed("share payload is not an object");
98
+ requireV1(raw.v);
99
+ if (!isString(raw.mailboxId)) throw malformed("share payload needs a string mailboxId");
100
+ return { v: 1, mailboxId: raw.mailboxId, roster: toRoster(raw.roster) };
101
+ }
102
+ function encodeAnnounce(payload) {
103
+ return base64urlEncode(JSON.stringify(payload));
104
+ }
105
+ function decodeAnnounce(code) {
106
+ const raw = decodeJson(code);
107
+ if (!isRecord(raw)) throw malformed("announce is not an object");
108
+ requireV1(raw.v);
109
+ return { v: 1, copy: toCopyLink(raw.copy) };
110
+ }
111
+
112
+ // src/households/group.ts
113
+ var HOUSEHOLD_GROUP_KEY = "selfstore:households:group:v1";
114
+ var mirrorId = (group) => `household-copy:${group.myCopy.fileId}`;
115
+ function createHouseholdGroup(deps) {
116
+ const { store, kv, backend, wallet } = deps;
117
+ const storageKey = deps.storageKey ?? HOUSEHOLD_GROUP_KEY;
118
+ let groups = null;
119
+ let loaded = false;
120
+ const rosterMisses = /* @__PURE__ */ new Map();
121
+ async function load() {
122
+ if (!loaded) {
123
+ const raw = await kv.get(storageKey);
124
+ if (!raw) groups = [];
125
+ else if (raw.v === 2) groups = raw.groups;
126
+ else groups = [raw];
127
+ loaded = true;
128
+ }
129
+ return groups;
130
+ }
131
+ async function persist() {
132
+ loaded = true;
133
+ if (!groups || groups.length === 0) {
134
+ groups = groups ?? [];
135
+ await kv.del(storageKey);
136
+ return;
137
+ }
138
+ await kv.set(storageKey, { v: 2, groups });
139
+ }
140
+ const adminGroup = (list) => list.find((g) => g.role === "admin") ?? null;
141
+ const primaryGroup = (list) => adminGroup(list) ?? list[0] ?? null;
142
+ const sharedByOf = (g) => g.role === "member" ? g.roster[0]?.ownerEmail ?? g.roster[0]?.ownerName ?? null : null;
143
+ function randomKey() {
144
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
145
+ let s = "";
146
+ for (const b of bytes) s += String.fromCharCode(b);
147
+ return btoa(s).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
148
+ }
149
+ function refuseProtected() {
150
+ if (store.state.encrypted) {
151
+ throw new Error(
152
+ "household-group: a password-protected store cannot be shared - remove the password first"
153
+ );
154
+ }
155
+ }
156
+ async function inScope(group) {
157
+ if (!group.walletFileId || !wallet) return true;
158
+ return await wallet() === group.walletFileId;
159
+ }
160
+ function disarm(group) {
161
+ store.detachMirror(mirrorId(group));
162
+ for (const link of group.roster) {
163
+ if (link.fileId !== group.myCopy.fileId) store.detachPeer(link.fileId);
164
+ }
165
+ }
166
+ function ensureMirror(group) {
167
+ const id = mirrorId(group);
168
+ if (store.state.mirrors.some((m) => m.id === id)) return;
169
+ store.attachMirror(backend.copyTarget(group.myCopy.fileId), {
170
+ password: group.key,
171
+ id
172
+ });
173
+ }
174
+ function attachPeers(group) {
175
+ const already = new Set(store.state.peers.map((p) => p.id));
176
+ for (const link of group.roster) {
177
+ if (link.fileId === group.myCopy.fileId) continue;
178
+ if (already.has(link.fileId)) continue;
179
+ store.attachPeer(backend.peerSource(link), { id: link.fileId, password: group.key });
180
+ }
181
+ }
182
+ function mergeRoster(roster, incoming, selfFileId) {
183
+ const byId = new Map(roster.map((l) => [l.fileId, l]));
184
+ for (const link of incoming) {
185
+ if (link.fileId === selfFileId) continue;
186
+ byId.set(link.fileId, link);
187
+ }
188
+ return [...byId.values()];
189
+ }
190
+ return {
191
+ get state() {
192
+ const list = groups ?? [];
193
+ const memberships = list.map((g) => ({
194
+ walletFileId: g.walletFileId ?? null,
195
+ isAdmin: g.role === "admin",
196
+ selfFileId: g.myCopy.fileId,
197
+ sharedBy: sharedByOf(g),
198
+ memberCount: g.roster.length
199
+ }));
200
+ const group = primaryGroup(list);
201
+ if (!group) {
202
+ return {
203
+ active: false,
204
+ isAdmin: false,
205
+ memberCount: 0,
206
+ members: [],
207
+ selfFileId: null,
208
+ walletFileId: null,
209
+ inviteCapability: null,
210
+ memberships
211
+ };
212
+ }
213
+ return {
214
+ active: true,
215
+ isAdmin: group.role === "admin",
216
+ memberCount: group.roster.length,
217
+ members: group.roster.map((l) => ({
218
+ fileId: l.fileId,
219
+ label: l.ownerEmail ?? l.ownerName ?? l.fileId.slice(0, 8)
220
+ })),
221
+ selfFileId: group.myCopy.fileId,
222
+ walletFileId: group.walletFileId ?? null,
223
+ inviteCapability: group.role === "admin" && group.live === true && group.bulletin ? group.bulletin : null,
224
+ memberships
225
+ };
226
+ },
227
+ async startShare() {
228
+ const list = await load();
229
+ if (adminGroup(list)) {
230
+ throw new Error("household-group: a share already exists on this device");
231
+ }
232
+ refuseProtected();
233
+ const key = randomKey();
234
+ const mailboxId = randomId();
235
+ const walletFileId = await wallet?.() ?? void 0;
236
+ const link = await backend.createCopy();
237
+ const roster = [link];
238
+ const group = {
239
+ role: "admin",
240
+ walletFileId,
241
+ key,
242
+ mailboxId,
243
+ roster,
244
+ myCopy: link,
245
+ live: false
246
+ };
247
+ list.push(group);
248
+ await persist();
249
+ ensureMirror(group);
250
+ const where = await backend.publishBulletin(key, { v: 1, mailboxId, roster });
251
+ group.bulletin = where;
252
+ group.live = true;
253
+ await persist();
254
+ return where;
255
+ },
256
+ async invite() {
257
+ const group = adminGroup(await load());
258
+ if (!group) {
259
+ throw new Error("household-group: invite() requires an admin share");
260
+ }
261
+ const where = await backend.publishBulletin(group.key, {
262
+ v: 1,
263
+ mailboxId: group.mailboxId,
264
+ roster: group.roster
265
+ });
266
+ group.bulletin = where;
267
+ group.live = true;
268
+ await persist();
269
+ return where;
270
+ },
271
+ async openIncoming(fileId, key) {
272
+ return backend.openIncoming(fileId, key);
273
+ },
274
+ async join() {
275
+ const list = await load();
276
+ const incoming = await backend.takeStashedIncoming();
277
+ if (!incoming) return list.length > 0 ? "joined" : "no-invite";
278
+ const { key, fileId, content } = incoming;
279
+ if (list.some((g) => g.mailboxId === content.share.mailboxId)) return "joined";
280
+ if (!wallet && list.length > 0) return "mismatch";
281
+ refuseProtected();
282
+ const walletFileId = await wallet?.() ?? void 0;
283
+ if (walletFileId) {
284
+ const stale = list.find((g) => g.walletFileId === walletFileId);
285
+ if (stale) {
286
+ disarm(stale);
287
+ await backend.dropCopy(stale.myCopy.fileId).catch(() => void 0);
288
+ groups = list.filter((g) => g !== stale);
289
+ }
290
+ }
291
+ const link = await backend.createCopy(void 0, content.share.roster[0]?.ownerEmail);
292
+ const roster = mergeRoster(content.share.roster, [link], "");
293
+ const group = {
294
+ role: "member",
295
+ walletFileId,
296
+ key,
297
+ mailboxId: content.share.mailboxId,
298
+ roster,
299
+ myCopy: link,
300
+ // The invitation capability, kept so THIS membership rereads ITS
301
+ // bulletin on converge (roster removals, lost-announce self-heal).
302
+ bulletin: { fileId, key }
303
+ };
304
+ groups.push(group);
305
+ await persist();
306
+ ensureMirror(group);
307
+ attachPeers(group);
308
+ await backend.announce(content.share.mailboxId, link).catch(() => void 0);
309
+ return "joined";
310
+ },
311
+ async syncGroup() {
312
+ const list = await load();
313
+ for (const group of list) {
314
+ if (store.state.encrypted) {
315
+ store.detachMirror(mirrorId(group));
316
+ continue;
317
+ }
318
+ if (!await inScope(group)) {
319
+ disarm(group);
320
+ continue;
321
+ }
322
+ ensureMirror(group);
323
+ if (group.role === "admin") {
324
+ const announced = await backend.takeAnnounces(group.mailboxId);
325
+ const roster = mergeRoster(group.roster, announced, group.myCopy.fileId);
326
+ const grew = roster.length !== group.roster.length;
327
+ const needed = group.needsRebroadcast === true;
328
+ group.roster = roster;
329
+ await persist();
330
+ attachPeers(group);
331
+ if ((grew || needed) && group.live === true) {
332
+ try {
333
+ const where = await backend.publishBulletin(group.key, {
334
+ v: 1,
335
+ mailboxId: group.mailboxId,
336
+ roster
337
+ });
338
+ group.bulletin = where;
339
+ group.needsRebroadcast = void 0;
340
+ } catch {
341
+ group.needsRebroadcast = true;
342
+ }
343
+ await persist();
344
+ }
345
+ } else {
346
+ const share = group.bulletin ? await backend.rereadJoined(group.bulletin.fileId, group.bulletin.key) : null;
347
+ if (share) {
348
+ const byId = new Map(share.roster.map((l) => [l.fileId, l]));
349
+ if (!byId.has(group.myCopy.fileId)) {
350
+ byId.set(group.myCopy.fileId, group.myCopy);
351
+ await backend.announce(group.mailboxId, group.myCopy).catch(() => void 0);
352
+ }
353
+ for (const link of group.roster) {
354
+ if (link.fileId === group.myCopy.fileId) continue;
355
+ const missKey = `${group.mailboxId}:${link.fileId}`;
356
+ if (byId.has(link.fileId)) {
357
+ rosterMisses.delete(missKey);
358
+ continue;
359
+ }
360
+ const misses = (rosterMisses.get(missKey) ?? 0) + 1;
361
+ if (misses >= 2) {
362
+ rosterMisses.delete(missKey);
363
+ store.detachPeer(link.fileId);
364
+ } else {
365
+ rosterMisses.set(missKey, misses);
366
+ byId.set(link.fileId, link);
367
+ }
368
+ }
369
+ group.roster = [...byId.values()];
370
+ await persist();
371
+ attachPeers(group);
372
+ } else {
373
+ attachPeers(group);
374
+ }
375
+ }
376
+ }
377
+ },
378
+ async leave(walletFileId) {
379
+ const list = await load();
380
+ const group = walletFileId != null ? list.find((g) => g.walletFileId === walletFileId) ?? null : primaryGroup(list);
381
+ if (!group) return;
382
+ disarm(group);
383
+ if (group.role === "admin") {
384
+ try {
385
+ await backend.revokeBulletin();
386
+ } catch {
387
+ }
388
+ }
389
+ await backend.dropCopy(group.myCopy.fileId).catch(() => void 0);
390
+ groups = list.filter((g) => g !== group);
391
+ for (const k of [...rosterMisses.keys()]) {
392
+ if (k.startsWith(`${group.mailboxId}:`)) rosterMisses.delete(k);
393
+ }
394
+ await persist();
395
+ },
396
+ async restore() {
397
+ const list = await load();
398
+ if (store.state.encrypted) return;
399
+ for (const group of list) {
400
+ if (!await inScope(group)) continue;
401
+ ensureMirror(group);
402
+ attachPeers(group);
403
+ }
404
+ }
405
+ };
406
+ }
407
+
408
+ export { HOUSEHOLD_GROUP_KEY, HouseholdCodeError, createHouseholdGroup, decodeAnnounce, decodeShare, encodeAnnounce, encodeShare, randomId, toCopyLink, toRoster };
409
+ //# sourceMappingURL=households.js.map
410
+ //# sourceMappingURL=households.js.map