@majikah/majik-message 0.3.6 → 0.3.8

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 (67) hide show
  1. package/README.md +3 -3
  2. package/dist/core/client-state-manager.d.ts +105 -0
  3. package/dist/core/client-state-manager.js +250 -0
  4. package/dist/core/contacts/majik-contact-directory.d.ts +0 -5
  5. package/dist/core/contacts/majik-contact-directory.js +0 -12
  6. package/dist/core/contacts/majik-contact-groups.d.ts +1 -0
  7. package/dist/core/contacts/majik-contact-groups.js +5 -0
  8. package/dist/core/contacts/majik-contact-manager.d.ts +99 -185
  9. package/dist/core/contacts/majik-contact-manager.js +469 -289
  10. package/dist/core/contacts/types.d.ts +1 -0
  11. package/dist/core/crypto/keystore-manager.d.ts +166 -0
  12. package/dist/core/crypto/keystore-manager.js +371 -0
  13. package/dist/core/storage/chats/_types.d.ts +8 -0
  14. package/dist/core/storage/chats/_types.js +1 -0
  15. package/dist/core/storage/chats/adapter-idb.d.ts +3 -0
  16. package/dist/core/storage/chats/adapter-idb.js +5 -0
  17. package/dist/core/storage/chats/adapter-memory.d.ts +23 -0
  18. package/dist/core/storage/chats/adapter-memory.js +44 -0
  19. package/dist/core/storage/chats/adapter-sql.d.ts +17 -0
  20. package/dist/core/storage/chats/adapter-sql.js +85 -0
  21. package/dist/core/storage/client-state/_types.d.ts +37 -0
  22. package/dist/core/storage/client-state/_types.js +16 -0
  23. package/dist/core/storage/client-state/adapter-idb.d.ts +17 -0
  24. package/dist/core/storage/client-state/adapter-idb.js +19 -0
  25. package/dist/core/storage/client-state/adapter-memory.d.ts +20 -0
  26. package/dist/core/storage/client-state/adapter-memory.js +44 -0
  27. package/dist/core/storage/client-state/adapter-sql.d.ts +41 -0
  28. package/dist/core/storage/client-state/adapter-sql.js +104 -0
  29. package/dist/core/storage/contact-directory/contacts/_types.d.ts +3 -0
  30. package/dist/core/storage/contact-directory/contacts/_types.js +1 -0
  31. package/dist/core/storage/contact-directory/contacts/adapter-idb.d.ts +3 -0
  32. package/dist/core/storage/contact-directory/contacts/adapter-idb.js +5 -0
  33. package/dist/core/storage/contact-directory/contacts/adapter-memory.d.ts +14 -0
  34. package/dist/core/storage/contact-directory/contacts/adapter-memory.js +32 -0
  35. package/dist/core/storage/contact-directory/contacts/adapter-sql.d.ts +18 -0
  36. package/dist/core/storage/contact-directory/contacts/adapter-sql.js +97 -0
  37. package/dist/core/storage/contact-directory/groups/_types.d.ts +3 -0
  38. package/dist/core/storage/contact-directory/groups/_types.js +1 -0
  39. package/dist/core/storage/contact-directory/groups/adapter-idb.d.ts +3 -0
  40. package/dist/core/storage/contact-directory/groups/adapter-idb.js +5 -0
  41. package/dist/core/storage/contact-directory/groups/adapter-memory.d.ts +14 -0
  42. package/dist/core/storage/contact-directory/groups/adapter-memory.js +32 -0
  43. package/dist/core/storage/contact-directory/groups/adapter-sql.d.ts +16 -0
  44. package/dist/core/storage/contact-directory/groups/adapter-sql.js +72 -0
  45. package/dist/core/storage/idb-adapter.d.ts +21 -0
  46. package/dist/core/storage/idb-adapter.js +107 -0
  47. package/dist/core/storage/index.d.ts +24 -0
  48. package/dist/core/storage/index.js +19 -0
  49. package/dist/core/storage/keystore/_types.d.ts +3 -0
  50. package/dist/core/storage/keystore/_types.js +1 -0
  51. package/dist/core/storage/keystore/adapter-idb.d.ts +3 -0
  52. package/dist/core/storage/keystore/adapter-idb.js +5 -0
  53. package/dist/core/storage/keystore/adapter-memory.d.ts +14 -0
  54. package/dist/core/storage/keystore/adapter-memory.js +32 -0
  55. package/dist/core/storage/keystore/adapter-sql.d.ts +18 -0
  56. package/dist/core/storage/keystore/adapter-sql.js +93 -0
  57. package/dist/core/storage/sql-db-manager.d.ts +13 -0
  58. package/dist/core/storage/sql-db-manager.js +59 -0
  59. package/dist/core/storage/sql-schema.d.ts +25 -0
  60. package/dist/core/storage/sql-schema.js +122 -0
  61. package/dist/core/storage/storage-adapter.d.ts +22 -0
  62. package/dist/core/storage/storage-adapter.js +1 -0
  63. package/dist/index.d.ts +2 -4
  64. package/dist/index.js +2 -4
  65. package/dist/majik-message.d.ts +114 -174
  66. package/dist/majik-message.js +449 -675
  67. package/package.json +5 -6
@@ -1,36 +1,19 @@
1
- /* -------------------------------
2
- * Types
3
- * ------------------------------- */
1
+ import { MajikContact, } from "@majikah/majik-contact";
4
2
  import { MajikContactDirectory } from "./majik-contact-directory";
5
3
  import { MajikContactGroupManager } from "./majik-contact-groups";
6
4
  import { MajikContactManagerError } from "./errors";
7
- /* -------------------------------
8
- * MajikContactManager Class
9
- * ------------------------------- */
10
- /**
11
- * Unified facade over MajikContactDirectory and MajikContactGroupManager.
12
- *
13
- * Responsibilities:
14
- * - Owns both the directory and the group manager as a single cohesive unit
15
- * - Proxies all directory methods so MajikMessage call sites need only change
16
- * `contactDirectory` → `contacts` with no logic changes
17
- * - Wires lifecycle hooks automatically so callers can never forget them:
18
- * • removeContact() → always calls groups.handleContactRemoved()
19
- * • blockContact() → always syncs the Blocked system group
20
- * • unblockContact() → always syncs the Blocked system group
21
- * - Exposes the group manager via `.group` for all group-specific operations
22
- * - Serializes both directory and groups into one unified payload for
23
- * MajikMessage.toJSON() / MajikMessage.fromJSON()
24
- *
25
- * Construction:
26
- * - Pass nothing → fresh directory + fresh group manager (new session)
27
- * - Pass a directory → wraps it, creates a fresh group manager bound to it
28
- * - Pass both → fully restores a prior session (used by fromJSON)
29
- */
5
+ import { arrayBufferToBase64, arrayToBase64, base64ToArrayBuffer, base64ToUint8Array, } from "../utils/utilities";
6
+ import { KEY_ALGO } from "../crypto/constants";
7
+ import { gunzipSync, gzipSync } from "fflate";
8
+ import { InMemoryContactAdapter } from "../storage/contact-directory/contacts/adapter-memory";
9
+ import { InMemoryContactGroupAdapter } from "../storage/contact-directory/groups/adapter-memory";
10
+ import { MajikEnvelope } from "@majikah/majik-envelope";
30
11
  export class MajikContactManager {
31
12
  directory;
32
13
  groupManager;
33
- constructor(directory, groupManager) {
14
+ _contactAdapter;
15
+ _groupAdapter;
16
+ constructor(directory, groupManager, adapters) {
34
17
  this.directory = directory ?? new MajikContactDirectory();
35
18
  if (groupManager) {
36
19
  this.assertGroupManagerInstance(groupManager);
@@ -39,54 +22,169 @@ export class MajikContactManager {
39
22
  else {
40
23
  this.groupManager = new MajikContactGroupManager(this.directory);
41
24
  }
25
+ this._contactAdapter = adapters?.contacts ?? new InMemoryContactAdapter();
26
+ this._groupAdapter = adapters?.groups ?? new InMemoryContactGroupAdapter();
42
27
  }
43
- /* ================================
44
- * Group Manager Access
45
- * ================================ */
28
+ // ── Adapter management ────────────────────────────────────────────────────
29
+ get contactAdapter() {
30
+ return this._contactAdapter;
31
+ }
32
+ get groupAdapter() {
33
+ return this._groupAdapter;
34
+ }
35
+ /**
36
+ * Swap both adapters at runtime. Does NOT migrate data.
37
+ *
38
+ * Migration pattern:
39
+ * ```ts
40
+ * const snap = await manager.toJSON();
41
+ * manager.setAdapters({ contacts: new IDBContactAdapter(), groups: new IDBGroupAdapter() });
42
+ * await manager.hydrate(); // warms from new (empty) adapters
43
+ * await manager.bulkRestoreFromJSON(snap); // writes old data into new adapters
44
+ * ```
45
+ */
46
+ setAdapters(adapters) {
47
+ if (adapters.contacts)
48
+ this._contactAdapter = adapters.contacts;
49
+ if (adapters.groups)
50
+ this._groupAdapter = adapters.groups;
51
+ }
52
+ // ── Hydration ─────────────────────────────────────────────────────────────
53
+ /**
54
+ * Load all contacts and groups from the adapters into the in-memory
55
+ * directory and group manager. Call once after construction (or after
56
+ * swapping adapters).
57
+ *
58
+ * Restoration order:
59
+ * 1. Contacts — must come first so groups can validate member existence.
60
+ * 2. Groups — restored via groupManager.fromJSON() which rebuilds the
61
+ * reverse index and re-bootstraps system groups.
62
+ * 3. Orphan pruning — any group member ID not present in the restored
63
+ * directory is silently removed (guards against data drift).
64
+ */
65
+ async hydrate() {
66
+ // ── 1. Contacts ───────────────────────────────────────────────────────
67
+ const serializedContacts = await this._contactAdapter.list();
68
+ this.directory.clear();
69
+ for (const item of serializedContacts) {
70
+ try {
71
+ const raw = base64ToArrayBuffer(item.publicKeyBase64);
72
+ let publicKey;
73
+ try {
74
+ publicKey = await crypto.subtle.importKey("raw", raw, KEY_ALGO, true, []);
75
+ }
76
+ catch {
77
+ publicKey = { raw: new Uint8Array(raw) };
78
+ }
79
+ const contact = MajikContact.create(item.id, publicKey, item.mlKey, item.fingerprint, item.meta, item.edPublicKeyBase64, item.mlDsaPublicKeyBase64);
80
+ // Use the internal map directly to avoid addContact's duplicate-check
81
+ // (re-hydrating from persisted state, not user-facing add)
82
+ this.directory["contacts"].set(contact.id, contact);
83
+ this.directory["fingerprintMap"].set(contact.fingerprint, contact.id);
84
+ }
85
+ catch (err) {
86
+ console.warn(`MajikContactManager.hydrate: skipping malformed contact "${item?.id}":`, err);
87
+ }
88
+ }
89
+ // ── 2. Groups ─────────────────────────────────────────────────────────
90
+ const serializedGroups = await this._groupAdapter.list();
91
+ this.groupManager.fromJSON({ groups: serializedGroups });
92
+ // ── 3. Orphan pruning ─────────────────────────────────────────────────
93
+ MajikContactManager.pruneOrphanedMembers(this.directory, this.groupManager);
94
+ }
95
+ // ── Write-through helpers ─────────────────────────────────────────────────
46
96
  /**
47
- * Direct access to the full MajikContactGroupManager API.
48
- * Use for all group-specific operations:
49
- * manager.group.addToFavorites(contactId)
50
- * manager.group.createGroup(id, name)
51
- * manager.group.getContactsInGroup(groupId)
52
- * etc.
97
+ * Persists a single contact to the adapter (called after every mutating
98
+ * operation that affects a contact's serialized form).
53
99
  */
54
- get group() {
55
- return this.groupManager;
100
+ async persistContact(contact) {
101
+ const json = await contact.toJSON();
102
+ await this._contactAdapter.save(json);
56
103
  }
57
104
  /**
58
- * Direct access to the underlying MajikContactDirectory.
59
- * Prefer the proxied methods on this class over accessing the directory
60
- * directly — they keep group state in sync automatically.
105
+ * Persists a single group to the adapter.
61
106
  */
62
- get directory_() {
63
- return this.directory;
107
+ async persistGroup(group) {
108
+ await this._groupAdapter.save(group.toJSON());
64
109
  }
65
- /* ================================
66
- * Contact Management (Proxied)
67
- * All signatures are intentionally identical to MajikContactDirectory
68
- * so MajikMessage call sites require zero logic changes.
69
- * ================================ */
70
- addContact(contact) {
110
+ // ── CRUD ──────────────────────────────────────────────────────────────────
111
+ /**
112
+ * Adds a contact to the directory and persists it to the adapter.
113
+ */
114
+ async addContact(contact) {
71
115
  this.directory.addContact(contact);
116
+ await this.persistContact(contact);
72
117
  return this;
73
118
  }
74
- addContacts(contacts) {
119
+ /**
120
+ * Adds multiple contacts atomically — adapter write uses bulkSave.
121
+ */
122
+ async addContacts(contacts) {
75
123
  this.directory.addContacts(contacts);
124
+ const jsons = await Promise.all(contacts.map((c) => c.toJSON()));
125
+ await this._contactAdapter.bulkSave(jsons);
76
126
  return this;
77
127
  }
78
128
  /**
79
- * Removes a contact from the directory and automatically removes them
80
- * from every group they belong to via the group manager hook.
81
- * The two operations are always atomic from the caller's perspective.
129
+ * Removes a contact from the directory, all groups, and the adapter.
82
130
  */
83
- removeContact(id) {
131
+ async removeContact(id) {
84
132
  const result = this.directory.removeContact(id);
85
133
  if (result.success) {
86
134
  this.groupManager.handleContactRemoved(id);
135
+ await this._contactAdapter.remove(id);
136
+ // Persist every group whose membership changed
137
+ await this._persistAllGroups();
87
138
  }
88
139
  return result;
89
140
  }
141
+ /**
142
+ * Updates contact metadata and persists the change.
143
+ */
144
+ async updateContactMeta(id, meta) {
145
+ const contact = this.directory.updateContactMeta(id, meta);
146
+ await this.persistContact(contact);
147
+ return contact;
148
+ }
149
+ /**
150
+ * Blocks a contact and persists both the contact and the Blocked group.
151
+ */
152
+ async blockContact(id) {
153
+ const contact = this.directory.blockContact(id);
154
+ const blocked = this.groupManager.addContactToGroupIfAbsent(this.groupManager.getBlockedGroup().id, id);
155
+ await this.persistContact(contact);
156
+ await this.persistGroup(blocked);
157
+ return contact;
158
+ }
159
+ /**
160
+ * Unblocks a contact and persists both the contact and the Blocked group.
161
+ */
162
+ async unblockContact(id) {
163
+ const contact = this.directory.unblockContact(id);
164
+ const blocked = this.groupManager.removeContactFromGroupIfPresent(this.groupManager.getBlockedGroup().id, id);
165
+ await this.persistContact(contact);
166
+ await this.persistGroup(blocked);
167
+ return contact;
168
+ }
169
+ async setMajikahStatus(id, status) {
170
+ const contact = this.directory.setMajikahStatus(id, status);
171
+ await this.persistContact(contact);
172
+ return contact;
173
+ }
174
+ /**
175
+ * Clears all contacts and groups from both the in-memory stores and adapters.
176
+ */
177
+ async clear() {
178
+ const allContactIds = this.directory.listContacts().map((c) => c.id);
179
+ this.directory.clear();
180
+ allContactIds.forEach((id) => this.groupManager.handleContactRemoved(id));
181
+ this.directory.clear();
182
+ this.groupManager.clear();
183
+ await this._contactAdapter.clear();
184
+ await this._groupAdapter.clear();
185
+ return this;
186
+ }
187
+ // ── Sync reads (unchanged from original) ──────────────────────────────────
90
188
  getContact(id) {
91
189
  return this.directory.getContact(id);
92
190
  }
@@ -94,7 +192,106 @@ export class MajikContactManager {
94
192
  return this.directory.getContactByFingerprint(fingerprint);
95
193
  }
96
194
  async getContactByPublicKeyBase64(publicKeyBase64) {
97
- return this.directory.getContactByPublicKeyBase64(publicKeyBase64);
195
+ return await this.directory.getContactByPublicKeyBase64(publicKeyBase64);
196
+ }
197
+ getContactsByIds(ids, strict = false) {
198
+ if (!ids?.length)
199
+ return [];
200
+ const seen = new Set();
201
+ const results = [];
202
+ for (const id of ids) {
203
+ if (seen.has(id))
204
+ continue;
205
+ seen.add(id);
206
+ const contact = this.directory.getContact(id);
207
+ if (!contact) {
208
+ if (strict) {
209
+ throw new MajikContactManagerError(`Contact not found: ${id}`);
210
+ }
211
+ continue;
212
+ }
213
+ results.push(contact);
214
+ }
215
+ return results;
216
+ }
217
+ async getContactsByPublicKeys(publicKeys, strict = false) {
218
+ if (!publicKeys?.length)
219
+ return [];
220
+ const uniqueKeys = [...new Set(publicKeys)];
221
+ const contacts = await Promise.all(uniqueKeys.map(async (key) => {
222
+ const contact = await this.directory.getContactByPublicKeyBase64(key);
223
+ if (!contact && strict) {
224
+ throw new MajikContactManagerError(`Contact not found for publicKey: ${key}`);
225
+ }
226
+ return contact;
227
+ }));
228
+ return contacts.filter((c) => Boolean(c));
229
+ }
230
+ async getMajikRecipients(mode = "id", input, strict) {
231
+ if (!input?.length)
232
+ throw new MajikContactManagerError("At least 1 id/key is required");
233
+ const contacts = mode === "public_key"
234
+ ? await this.getContactsByPublicKeys(input, strict)
235
+ : this.getContactsByIds(input, strict);
236
+ if (!contacts || contacts.length === 0)
237
+ return [];
238
+ const recipients = [];
239
+ const seen = new Set();
240
+ const invalidContacts = [];
241
+ for (const contact of contacts) {
242
+ if (!contact)
243
+ continue;
244
+ // dedupe by fingerprint
245
+ if (seen.has(contact.fingerprint))
246
+ continue;
247
+ const mlPubKey = base64ToUint8Array(contact.mlKey);
248
+ if (!mlPubKey) {
249
+ invalidContacts.push(contact.fingerprint);
250
+ continue;
251
+ }
252
+ const builtMajikRecipient = await MajikEnvelope.buildMajikRecipientFromContact(contact);
253
+ recipients.push(builtMajikRecipient);
254
+ seen.add(contact.fingerprint);
255
+ }
256
+ if (invalidContacts.length > 0) {
257
+ throw new MajikContactManagerError(`Invalid ML-KEM public key for contact(s): ${invalidContacts.join(", ")}`);
258
+ }
259
+ return recipients;
260
+ }
261
+ async getExpectedSigners(mode = "id", input, strict) {
262
+ if (!input?.length)
263
+ throw new MajikContactManagerError("At least 1 id/key is required");
264
+ const contacts = mode === "public_key"
265
+ ? await this.getContactsByPublicKeys(input, strict)
266
+ : this.getContactsByIds(input, strict);
267
+ if (!contacts || contacts.length === 0)
268
+ return [];
269
+ const signers = [];
270
+ const seen = new Set();
271
+ const invalidContacts = [];
272
+ for (const contact of contacts) {
273
+ if (!contact)
274
+ continue;
275
+ // dedupe by fingerprint
276
+ if (seen.has(contact.fingerprint))
277
+ continue;
278
+ const mlDsaPublicKey = contact.mlDsaPublicKeyBase64;
279
+ if (!mlDsaPublicKey?.trim()) {
280
+ invalidContacts.push(contact.fingerprint);
281
+ continue;
282
+ }
283
+ const expectedSigner = {
284
+ edPublicKey: contact.edPublicKeyBase64,
285
+ mlDsaPublicKey: contact.mlDsaPublicKeyBase64,
286
+ signerId: contact.fingerprint,
287
+ };
288
+ signers.push(expectedSigner);
289
+ seen.add(contact.fingerprint);
290
+ }
291
+ if (invalidContacts.length > 0) {
292
+ throw new MajikContactManagerError(`Invalid ML-KEM public key for contact(s): ${invalidContacts.join(", ")}`);
293
+ }
294
+ return signers;
98
295
  }
99
296
  hasContact(id) {
100
297
  return this.directory.hasContact(id);
@@ -108,263 +305,266 @@ export class MajikContactManager {
108
305
  listContacts(sortedByLabel = false, majikahOnly = false) {
109
306
  return this.directory.listContacts(sortedByLabel, majikahOnly);
110
307
  }
111
- updateContactMeta(id, meta) {
112
- return this.directory.updateContactMeta(id, meta);
113
- }
114
- /**
115
- * Blocks a contact on the directory AND adds them to the system Blocked
116
- * group — both sides are always kept in sync.
117
- */
118
- blockContact(id) {
119
- const contact = this.directory.blockContact(id);
120
- this.groupManager.addContactToGroupIfAbsent(this.groupManager.getBlockedGroup().id, id);
121
- return contact;
122
- }
123
- /**
124
- * Unblocks a contact on the directory AND removes them from the system
125
- * Blocked group — both sides are always kept in sync.
126
- */
127
- unblockContact(id) {
128
- const contact = this.directory.unblockContact(id);
129
- this.groupManager.removeContactFromGroupIfPresent(this.groupManager.getBlockedGroup().id, id);
130
- return contact;
131
- }
132
- setMajikahStatus(id, status) {
133
- return this.directory.setMajikahStatus(id, status);
134
- }
135
308
  isMajikahRegistered(id) {
136
309
  return this.directory.isMajikahRegistered(id);
137
310
  }
138
311
  isMajikahIdentityChecked(id) {
139
312
  return this.directory.isMajikahIdentityChecked(id);
140
313
  }
141
- hasContactForEnvelope(envelope) {
142
- return this.directory.hasContactForEnvelope(envelope);
314
+ // ── Group CRUD (now async, write-through) ─────────────────────────────────
315
+ get group() {
316
+ return this.groupManager;
143
317
  }
144
- /* ================================
145
- * Group CRUD Pass-throughs
146
- * ================================ */
147
- /**
148
- * Creates and registers a new user-defined group.
149
- * Throws if a group with the same ID already exists.
150
- */
151
- createGroup(id, name, meta, initialMemberIds) {
152
- return this.groupManager.createGroup(id, name, meta, initialMemberIds);
318
+ get directory_() {
319
+ return this.directory;
153
320
  }
154
- /**
155
- * Registers an already-constructed MajikContactGroup instance.
156
- * Throws if a group with the same ID already exists.
157
- */
158
- addGroup(group) {
321
+ async createGroup(id, name, meta, initialMemberIds) {
322
+ const group = this.groupManager.createGroup(id, name, meta, initialMemberIds);
323
+ await this.persistGroup(group);
324
+ return group;
325
+ }
326
+ async addGroup(group) {
159
327
  this.groupManager.addGroup(group);
328
+ await this.persistGroup(group);
160
329
  return this;
161
330
  }
162
- /**
163
- * Removes a user group by ID.
164
- * System groups (Favorites, Blocked) cannot be deleted.
165
- */
166
- removeGroup(id) {
167
- return this.groupManager.removeGroup(id);
331
+ async removeGroup(id) {
332
+ const result = this.groupManager.removeGroup(id);
333
+ if (result.success) {
334
+ await this._groupAdapter.remove(id);
335
+ }
336
+ return result;
168
337
  }
169
- /**
170
- * Returns a group by ID, or undefined if not found.
171
- */
172
338
  getGroup(id) {
173
339
  return this.groupManager.getGroup(id);
174
340
  }
175
- /**
176
- * Returns a group by ID. Throws if not found.
177
- */
178
341
  getGroupOrThrow(id) {
179
342
  return this.groupManager.getGroupOrThrow(id);
180
343
  }
181
- /**
182
- * Returns true if a group with the given ID exists.
183
- */
184
344
  hasGroup(id) {
185
345
  return this.groupManager.hasGroup(id);
186
346
  }
187
- /**
188
- * Returns all groups.
189
- *
190
- * @param includeSystem Include system groups (Favorites, Blocked). Default: true.
191
- * @param sortedByName Sort results alphabetically by group name. Default: false.
192
- */
193
347
  listGroups(includeSystem = true, sortedByName = false) {
194
348
  return this.groupManager.listGroups(includeSystem, sortedByName);
195
349
  }
196
- /**
197
- * Returns only user-created groups (excludes Favorites and Blocked).
198
- * Sorted alphabetically by name.
199
- */
200
350
  listUserGroups(sortedByName = true) {
201
351
  return this.groupManager.listGroups(false, sortedByName);
202
352
  }
203
- /**
204
- * Returns only system groups (Favorites and Blocked).
205
- */
206
353
  listSystemGroups() {
207
354
  return this.groupManager.listGroups(true).filter((g) => g.isSystem);
208
355
  }
209
- /**
210
- * Updates mutable metadata on a group (name, description).
211
- * Name is locked on system groups — will throw if attempted.
212
- */
213
- updateGroupMeta(id, meta) {
214
- return this.groupManager.updateGroupMeta(id, meta);
215
- }
216
- /* ================================
217
- * Group Membership Pass-throughs
218
- * ================================ */
219
- /**
220
- * Adds a contact to a group.
221
- * Validates the contact exists in the directory.
222
- * If the group is the system Blocked group, also calls contact.block().
223
- * Throws if the contact is already a member — use addContactToGroupIfAbsent for idempotent.
224
- */
225
- addContactToGroup(groupId, contactId) {
226
- return this.groupManager.addContactToGroup(groupId, contactId);
227
- }
228
- /**
229
- * Idempotent variant — does not throw if the contact is already a member.
230
- */
231
- addContactToGroupIfAbsent(groupId, contactId) {
232
- return this.groupManager.addContactToGroupIfAbsent(groupId, contactId);
233
- }
234
- /**
235
- * Adds multiple contacts to a group in one call (all-or-nothing).
236
- */
237
- addContactsToGroup(groupId, contactIds) {
238
- return this.groupManager.addContactsToGroup(groupId, contactIds);
239
- }
240
- /**
241
- * Removes a contact from a group.
242
- * If the group is the system Blocked group, also calls contact.unblock().
243
- * Throws if the contact is not a member — use removeContactFromGroupIfPresent for idempotent.
244
- */
245
- removeContactFromGroup(groupId, contactId) {
246
- return this.groupManager.removeContactFromGroup(groupId, contactId);
247
- }
248
- /**
249
- * Idempotent variant — does not throw if the contact is not a member.
250
- */
251
- removeContactFromGroupIfPresent(groupId, contactId) {
252
- return this.groupManager.removeContactFromGroupIfPresent(groupId, contactId);
253
- }
254
- /**
255
- * Moves a contact from one group to another atomically.
256
- * Throws if the contact is not a member of the source group.
257
- */
258
- moveContactBetweenGroups(contactId, fromGroupId, toGroupId) {
259
- return this.groupManager.moveContact(contactId, fromGroupId, toGroupId);
260
- }
261
- /* ================================
262
- * Group Query Pass-throughs
263
- * ================================ */
264
- /**
265
- * Returns all hydrated MajikContact instances in the given group.
266
- * Contacts removed from the directory since last save are silently skipped.
267
- */
356
+ async updateGroupMeta(id, meta) {
357
+ const group = this.groupManager.updateGroupMeta(id, meta);
358
+ await this.persistGroup(group);
359
+ return group;
360
+ }
361
+ // ── Group membership (async, write-through) ───────────────────────────────
362
+ async addContactToGroup(groupId, contactId) {
363
+ const group = this.groupManager.addContactToGroup(groupId, contactId);
364
+ await this.persistGroup(group);
365
+ return group;
366
+ }
367
+ async addContactToGroupIfAbsent(groupId, contactId) {
368
+ const group = this.groupManager.addContactToGroupIfAbsent(groupId, contactId);
369
+ await this.persistGroup(group);
370
+ return group;
371
+ }
372
+ async addContactsToGroup(groupId, contactIds) {
373
+ const group = this.groupManager.addContactsToGroup(groupId, contactIds);
374
+ await this.persistGroup(group);
375
+ return group;
376
+ }
377
+ async removeContactFromGroup(groupId, contactId) {
378
+ const group = this.groupManager.removeContactFromGroup(groupId, contactId);
379
+ await this.persistGroup(group);
380
+ return group;
381
+ }
382
+ async removeContactFromGroupIfPresent(groupId, contactId) {
383
+ const group = this.groupManager.removeContactFromGroupIfPresent(groupId, contactId);
384
+ await this.persistGroup(group);
385
+ return group;
386
+ }
387
+ async moveContactBetweenGroups(contactId, fromGroupId, toGroupId) {
388
+ this.groupManager.moveContact(contactId, fromGroupId, toGroupId);
389
+ // Persist both affected groups
390
+ const from = this.groupManager.getGroup(fromGroupId);
391
+ const to = this.groupManager.getGroup(toGroupId);
392
+ const writes = [];
393
+ if (from)
394
+ writes.push(this.persistGroup(from));
395
+ if (to)
396
+ writes.push(this.persistGroup(to));
397
+ await Promise.all(writes);
398
+ }
399
+ // ── Group query pass-throughs (sync, unchanged) ───────────────────────────
268
400
  getContactsInGroup(groupId) {
269
401
  return this.groupManager.getContactsInGroup(groupId);
270
402
  }
271
- /**
272
- * Returns hydrated contacts in the group, sorted by label (or ID if no label).
273
- */
274
403
  getContactsInGroupSorted(groupId) {
275
404
  return this.groupManager.getContactsInGroupSorted(groupId);
276
405
  }
277
- /**
278
- * Returns true if the contact is a member of the given group.
279
- */
280
406
  isContactInGroup(groupId, contactId) {
281
407
  return this.groupManager.isContactInGroup(groupId, contactId);
282
408
  }
283
- /**
284
- * Returns all groups the contact belongs to.
285
- */
286
409
  getGroupsForContact(contactId) {
287
410
  return this.groupManager.getGroupsForContact(contactId);
288
411
  }
289
- /**
290
- * Returns all group IDs the contact belongs to.
291
- */
292
412
  getGroupIdsForContact(contactId) {
293
413
  return this.groupManager.getGroupIdsForContact(contactId);
294
414
  }
295
- /* ================================
296
- * System Group Convenience Pass-throughs
297
- * ================================ */
298
- /**
299
- * Adds the contact to the Favorites group (idempotent).
300
- */
301
- addToFavorites(contactId) {
302
- return this.groupManager.addToFavorites(contactId);
415
+ // ── System group convenience (async, write-through) ───────────────────────
416
+ async addToFavorites(contactId) {
417
+ const group = this.groupManager.addToFavorites(contactId);
418
+ await this.persistGroup(group);
419
+ return group;
303
420
  }
304
- /**
305
- * Removes the contact from the Favorites group (idempotent).
306
- */
307
- removeFromFavorites(contactId) {
308
- return this.groupManager.removeFromFavorites(contactId);
421
+ async removeFromFavorites(contactId) {
422
+ const group = this.groupManager.removeFromFavorites(contactId);
423
+ await this.persistGroup(group);
424
+ return group;
309
425
  }
310
- /**
311
- * Returns true if the contact is in the Favorites group.
312
- */
313
426
  isFavorite(contactId) {
314
427
  return this.groupManager.isFavorite(contactId);
315
428
  }
316
- /**
317
- * Returns true if the contact is in the Blocked group.
318
- */
319
429
  isContactBlocked(contactId) {
320
430
  return this.groupManager.isBlocked(contactId);
321
431
  }
322
- /**
323
- * Returns the Favorites system group instance.
324
- */
325
432
  getFavoritesGroup() {
326
433
  return this.groupManager.getFavoritesGroup();
327
434
  }
328
- /**
329
- * Returns the Blocked system group instance.
330
- */
331
435
  getBlockedGroup() {
332
436
  return this.groupManager.getBlockedGroup();
333
437
  }
334
- /**
335
- * Returns all contacts in the Favorites group as hydrated MajikContact instances.
336
- */
337
438
  getFavoriteContacts() {
338
439
  return this.groupManager.getContactsInGroup(this.groupManager.getFavoritesGroup().id);
339
440
  }
340
- /**
341
- * Returns all contacts in the Blocked group as hydrated MajikContact instances.
342
- */
343
441
  getBlockedContacts() {
344
442
  return this.groupManager.getContactsInGroup(this.groupManager.getBlockedGroup().id);
345
443
  }
346
- /* ================================
347
- * Directory Clear
348
- * ================================ */
349
- /**
350
- * Clears both the directory and all group memberships.
351
- * System groups are preserved (re-bootstrapped by the group manager).
352
- */
353
- clear() {
354
- const allContactIds = this.directory.listContacts().map((c) => c.id);
355
- this.directory.clear();
356
- // Notify the group manager for every contact so the reverse index
357
- // and group memberships are cleaned up properly
358
- allContactIds.forEach((id) => this.groupManager.handleContactRemoved(id));
359
- return this;
444
+ // ── Import / Export (unchanged) ───────────────────────────────────────────
445
+ async exportContactAsJSON(contactId) {
446
+ const contact = this.getContact(contactId);
447
+ if (!contact)
448
+ return null;
449
+ let publicKeyBase64;
450
+ const anyPub = contact.publicKey;
451
+ if (anyPub?.raw instanceof Uint8Array) {
452
+ publicKeyBase64 = arrayBufferToBase64(anyPub.raw.buffer);
453
+ }
454
+ else {
455
+ const raw = await crypto.subtle.exportKey("raw", contact.publicKey);
456
+ publicKeyBase64 = arrayBufferToBase64(raw);
457
+ }
458
+ return JSON.stringify({
459
+ id: contact.id,
460
+ label: contact.meta?.label || "",
461
+ publicKey: publicKeyBase64,
462
+ fingerprint: contact.fingerprint,
463
+ mlKey: contact.mlKey,
464
+ edPublicKeyBase64: contact.edPublicKeyBase64,
465
+ mlDsaPublicKeyBase64: contact.mlDsaPublicKeyBase64,
466
+ }, null, 2);
467
+ }
468
+ async exportContactAsString(contactId) {
469
+ const contact = this.getContact(contactId);
470
+ if (!contact)
471
+ return null;
472
+ return this.exportContactCompressed(contact);
473
+ }
474
+ async importContactFromJSON(jsonStr) {
475
+ try {
476
+ const data = JSON.parse(jsonStr);
477
+ if (!data.id || !data.publicKey || !data.fingerprint) {
478
+ return { success: false, message: "Invalid contact JSON" };
479
+ }
480
+ const rawBuffer = base64ToArrayBuffer(data.publicKey);
481
+ let publicKey;
482
+ try {
483
+ publicKey = await crypto.subtle.importKey("raw", rawBuffer, KEY_ALGO, true, []);
484
+ }
485
+ catch {
486
+ publicKey = { raw: new Uint8Array(rawBuffer) };
487
+ }
488
+ const contact = new MajikContact({
489
+ id: data.id,
490
+ publicKey,
491
+ fingerprint: data.fingerprint,
492
+ meta: { label: data.label },
493
+ mlKey: data.mlKey,
494
+ edPublicKeyBase64: data.edPublicKeyBase64,
495
+ mlDsaPublicKeyBase64: data.mlDsaPublicKeyBase64,
496
+ });
497
+ await this.addContact(contact);
498
+ return { success: true, message: "Contact imported successfully" };
499
+ }
500
+ catch (err) {
501
+ return {
502
+ success: false,
503
+ message: err instanceof Error ? err.message : "Unknown error",
504
+ };
505
+ }
360
506
  }
361
- /* ================================
362
- * Serialization / Persistence
363
- * ================================ */
364
- /**
365
- * Serializes both the directory and all groups into a single unified payload.
366
- * This is what MajikMessage.toJSON() should persist.
367
- */
507
+ async importContactFromString(base64Str) {
508
+ try {
509
+ const contact = await this.importContactCompressed(base64Str);
510
+ await this.addContact(contact);
511
+ return { success: true, message: "Contact imported successfully" };
512
+ }
513
+ catch (err) {
514
+ return {
515
+ success: false,
516
+ message: err instanceof Error ? err.message : "Unknown error",
517
+ };
518
+ }
519
+ }
520
+ async exportContactCompressed(contact) {
521
+ let publicKeyBase64;
522
+ const anyPub = contact.publicKey;
523
+ if (anyPub?.raw instanceof Uint8Array) {
524
+ publicKeyBase64 = arrayBufferToBase64(anyPub.raw.buffer);
525
+ }
526
+ else {
527
+ const raw = await crypto.subtle.exportKey("raw", contact.publicKey);
528
+ publicKeyBase64 = arrayBufferToBase64(raw);
529
+ }
530
+ const jsonObj = {
531
+ id: contact.id,
532
+ label: contact.meta?.label || "",
533
+ publicKey: publicKeyBase64,
534
+ fingerprint: contact.fingerprint,
535
+ mlKey: contact.mlKey,
536
+ edPublicKeyBase64: contact.edPublicKeyBase64,
537
+ mlDsaPublicKeyBase64: contact.mlDsaPublicKeyBase64,
538
+ };
539
+ const compressed = gzipSync(new TextEncoder().encode(JSON.stringify(jsonObj)));
540
+ return arrayToBase64(compressed);
541
+ }
542
+ async importContactCompressed(base64Str) {
543
+ const compressed = base64ToArrayBuffer(base64Str);
544
+ const jsonStr = new TextDecoder().decode(gunzipSync(new Uint8Array(compressed)));
545
+ const data = JSON.parse(jsonStr);
546
+ const rawBuffer = base64ToArrayBuffer(data.publicKey);
547
+ let publicKey;
548
+ try {
549
+ publicKey = await crypto.subtle.importKey("raw", rawBuffer, KEY_ALGO, true, []);
550
+ }
551
+ catch {
552
+ publicKey = { raw: new Uint8Array(rawBuffer) };
553
+ }
554
+ if (!data?.id || !publicKey || !data?.fingerprint || !data?.mlKey) {
555
+ throw new Error("Invalid contact JSON");
556
+ }
557
+ return new MajikContact({
558
+ id: data.id,
559
+ publicKey,
560
+ fingerprint: data.fingerprint,
561
+ meta: { label: data.label },
562
+ mlKey: data.mlKey,
563
+ edPublicKeyBase64: data.edPublicKeyBase64,
564
+ mlDsaPublicKeyBase64: data.mlDsaPublicKeyBase64,
565
+ });
566
+ }
567
+ // ── Serialization ─────────────────────────────────────────────────────────
368
568
  async toJSON() {
369
569
  return {
370
570
  contacts: await this.directory.toJSON(),
@@ -372,18 +572,19 @@ export class MajikContactManager {
372
572
  };
373
573
  }
374
574
  /**
375
- * Restores a MajikContactManager from a unified serialized payload.
376
- *
377
- * Restoration order:
378
- * 1. Restore the directory (contacts + crypto keys)
379
- * 2. Restore groups via the group manager
380
- * 3. Silently strip any group member IDs that no longer exist in the
381
- * restored directory (orphan pruning) — guards against data drift
382
- * between directory and group state across serialization rounds
383
- *
384
- * @param data The payload produced by toJSON().
575
+ * Restore from a JSON snapshot into the current adapters.
576
+ * Writes all contacts and groups through to the adapters.
577
+ * Used after setAdapters() to migrate data into a new store.
385
578
  */
386
- static async fromJSON(data) {
579
+ async bulkRestoreFromJSON(data) {
580
+ if (!data?.contacts || !data?.groups) {
581
+ throw new MajikContactManagerError("bulkRestoreFromJSON: invalid payload — expected { contacts, groups }");
582
+ }
583
+ await this._contactAdapter.bulkSave(data.contacts.contacts);
584
+ await this._groupAdapter.bulkSave(data.groups.groups);
585
+ await this.hydrate();
586
+ }
587
+ static async fromJSON(data, adapters) {
387
588
  if (!data || typeof data !== "object") {
388
589
  throw new MajikContactManagerError("fromJSON: invalid payload — expected { contacts, groups }");
389
590
  }
@@ -393,52 +594,31 @@ export class MajikContactManager {
393
594
  if (!data.groups) {
394
595
  throw new MajikContactManagerError("fromJSON: missing required field 'groups'");
395
596
  }
396
- // Step 1 restore directory
397
- let directory;
398
- try {
399
- directory = new MajikContactDirectory();
400
- await directory.fromJSON(data.contacts);
401
- }
402
- catch (err) {
403
- throw new MajikContactManagerError("fromJSON: failed to restore contact directory", err);
404
- }
405
- // Step 2 — restore group manager bound to the restored directory
406
- let groupManager;
407
- try {
408
- groupManager = new MajikContactGroupManager(directory);
409
- groupManager.fromJSON(data.groups);
410
- }
411
- catch (err) {
412
- throw new MajikContactManagerError("fromJSON: failed to restore group manager", err);
413
- }
414
- // Step 3 — silently prune orphaned member IDs from every group
415
- // An orphan is a contact ID that exists in a group but is absent from
416
- // the restored directory. This can happen if a contact was removed
417
- // between two save cycles or data was partially corrupted.
418
- MajikContactManager.pruneOrphanedMembers(directory, groupManager);
419
- return new MajikContactManager(directory, groupManager);
597
+ const manager = new MajikContactManager(undefined, undefined, adapters);
598
+ await manager.bulkRestoreFromJSON(data);
599
+ return manager;
420
600
  }
601
+ // ── Private helpers ───────────────────────────────────────────────────────
421
602
  /**
422
- * Walks every group and removes any member ID not present in the directory.
423
- * Operates directly on the group instances no re-serialization needed.
603
+ * Persists every group currently in the group manager to the adapter.
604
+ * Used after bulk contact removal where multiple groups may be affected.
424
605
  */
606
+ async _persistAllGroups() {
607
+ const all = this.groupManager.listGroups(true);
608
+ await this._groupAdapter.bulkSave(all.map((g) => g.toJSON()));
609
+ }
425
610
  static pruneOrphanedMembers(directory, groupManager) {
426
- const allGroups = groupManager.listGroups(true); // include system groups
611
+ const allGroups = groupManager.listGroups(true);
427
612
  for (const group of allGroups) {
428
613
  const orphans = group
429
614
  .listMemberIds()
430
615
  .filter((id) => !directory.hasContact(id));
431
616
  for (const orphanId of orphans) {
432
- // Use the idempotent variant — safe even if the index is already clean
433
617
  group.removeMemberIfPresent(orphanId);
434
- // Also clean up the reverse index on the group manager
435
618
  groupManager.handleContactRemoved(orphanId);
436
619
  }
437
620
  }
438
621
  }
439
- /* ================================
440
- * Assertions
441
- * ================================ */
442
622
  assertGroupManagerInstance(gm) {
443
623
  if (!gm || !(gm instanceof MajikContactGroupManager)) {
444
624
  throw new MajikContactManagerError("groupManager must be a valid MajikContactGroupManager instance");