@majikah/majik-message 0.3.5 → 0.3.7

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 (66) hide show
  1. package/README.md +7 -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 +92 -184
  9. package/dist/core/contacts/majik-contact-manager.js +368 -288
  10. package/dist/core/crypto/keystore-manager.d.ts +166 -0
  11. package/dist/core/crypto/keystore-manager.js +371 -0
  12. package/dist/core/storage/chats/_types.d.ts +8 -0
  13. package/dist/core/storage/chats/_types.js +1 -0
  14. package/dist/core/storage/chats/adapter-idb.d.ts +3 -0
  15. package/dist/core/storage/chats/adapter-idb.js +5 -0
  16. package/dist/core/storage/chats/adapter-memory.d.ts +23 -0
  17. package/dist/core/storage/chats/adapter-memory.js +44 -0
  18. package/dist/core/storage/chats/adapter-sql.d.ts +17 -0
  19. package/dist/core/storage/chats/adapter-sql.js +84 -0
  20. package/dist/core/storage/client-state/_types.d.ts +37 -0
  21. package/dist/core/storage/client-state/_types.js +16 -0
  22. package/dist/core/storage/client-state/adapter-idb.d.ts +17 -0
  23. package/dist/core/storage/client-state/adapter-idb.js +19 -0
  24. package/dist/core/storage/client-state/adapter-memory.d.ts +20 -0
  25. package/dist/core/storage/client-state/adapter-memory.js +44 -0
  26. package/dist/core/storage/client-state/adapter-sql.d.ts +41 -0
  27. package/dist/core/storage/client-state/adapter-sql.js +104 -0
  28. package/dist/core/storage/contact-directory/contacts/_types.d.ts +3 -0
  29. package/dist/core/storage/contact-directory/contacts/_types.js +1 -0
  30. package/dist/core/storage/contact-directory/contacts/adapter-idb.d.ts +3 -0
  31. package/dist/core/storage/contact-directory/contacts/adapter-idb.js +5 -0
  32. package/dist/core/storage/contact-directory/contacts/adapter-memory.d.ts +14 -0
  33. package/dist/core/storage/contact-directory/contacts/adapter-memory.js +32 -0
  34. package/dist/core/storage/contact-directory/contacts/adapter-sql.d.ts +16 -0
  35. package/dist/core/storage/contact-directory/contacts/adapter-sql.js +73 -0
  36. package/dist/core/storage/contact-directory/groups/_types.d.ts +3 -0
  37. package/dist/core/storage/contact-directory/groups/_types.js +1 -0
  38. package/dist/core/storage/contact-directory/groups/adapter-idb.d.ts +3 -0
  39. package/dist/core/storage/contact-directory/groups/adapter-idb.js +5 -0
  40. package/dist/core/storage/contact-directory/groups/adapter-memory.d.ts +14 -0
  41. package/dist/core/storage/contact-directory/groups/adapter-memory.js +32 -0
  42. package/dist/core/storage/contact-directory/groups/adapter-sql.d.ts +16 -0
  43. package/dist/core/storage/contact-directory/groups/adapter-sql.js +71 -0
  44. package/dist/core/storage/idb-adapter.d.ts +21 -0
  45. package/dist/core/storage/idb-adapter.js +107 -0
  46. package/dist/core/storage/index.d.ts +24 -0
  47. package/dist/core/storage/index.js +19 -0
  48. package/dist/core/storage/keystore/_types.d.ts +3 -0
  49. package/dist/core/storage/keystore/_types.js +1 -0
  50. package/dist/core/storage/keystore/adapter-idb.d.ts +3 -0
  51. package/dist/core/storage/keystore/adapter-idb.js +5 -0
  52. package/dist/core/storage/keystore/adapter-memory.d.ts +14 -0
  53. package/dist/core/storage/keystore/adapter-memory.js +32 -0
  54. package/dist/core/storage/keystore/adapter-sql.d.ts +16 -0
  55. package/dist/core/storage/keystore/adapter-sql.js +69 -0
  56. package/dist/core/storage/sql-db-manager.d.ts +13 -0
  57. package/dist/core/storage/sql-db-manager.js +59 -0
  58. package/dist/core/storage/sql-schema.d.ts +10 -0
  59. package/dist/core/storage/sql-schema.js +108 -0
  60. package/dist/core/storage/storage-adapter.d.ts +14 -0
  61. package/dist/core/storage/storage-adapter.js +1 -0
  62. package/dist/index.d.ts +2 -4
  63. package/dist/index.js +2 -4
  64. package/dist/majik-message.d.ts +109 -174
  65. package/dist/majik-message.js +428 -677
  66. package/package.json +5 -6
@@ -1,36 +1,18 @@
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, } 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";
30
10
  export class MajikContactManager {
31
11
  directory;
32
12
  groupManager;
33
- constructor(directory, groupManager) {
13
+ _contactAdapter;
14
+ _groupAdapter;
15
+ constructor(directory, groupManager, adapters) {
34
16
  this.directory = directory ?? new MajikContactDirectory();
35
17
  if (groupManager) {
36
18
  this.assertGroupManagerInstance(groupManager);
@@ -39,54 +21,169 @@ export class MajikContactManager {
39
21
  else {
40
22
  this.groupManager = new MajikContactGroupManager(this.directory);
41
23
  }
24
+ this._contactAdapter = adapters?.contacts ?? new InMemoryContactAdapter();
25
+ this._groupAdapter = adapters?.groups ?? new InMemoryContactGroupAdapter();
42
26
  }
43
- /* ================================
44
- * Group Manager Access
45
- * ================================ */
27
+ // ── Adapter management ────────────────────────────────────────────────────
28
+ get contactAdapter() {
29
+ return this._contactAdapter;
30
+ }
31
+ get groupAdapter() {
32
+ return this._groupAdapter;
33
+ }
34
+ /**
35
+ * Swap both adapters at runtime. Does NOT migrate data.
36
+ *
37
+ * Migration pattern:
38
+ * ```ts
39
+ * const snap = await manager.toJSON();
40
+ * manager.setAdapters({ contacts: new IDBContactAdapter(), groups: new IDBGroupAdapter() });
41
+ * await manager.hydrate(); // warms from new (empty) adapters
42
+ * await manager.bulkRestoreFromJSON(snap); // writes old data into new adapters
43
+ * ```
44
+ */
45
+ setAdapters(adapters) {
46
+ if (adapters.contacts)
47
+ this._contactAdapter = adapters.contacts;
48
+ if (adapters.groups)
49
+ this._groupAdapter = adapters.groups;
50
+ }
51
+ // ── Hydration ─────────────────────────────────────────────────────────────
52
+ /**
53
+ * Load all contacts and groups from the adapters into the in-memory
54
+ * directory and group manager. Call once after construction (or after
55
+ * swapping adapters).
56
+ *
57
+ * Restoration order:
58
+ * 1. Contacts — must come first so groups can validate member existence.
59
+ * 2. Groups — restored via groupManager.fromJSON() which rebuilds the
60
+ * reverse index and re-bootstraps system groups.
61
+ * 3. Orphan pruning — any group member ID not present in the restored
62
+ * directory is silently removed (guards against data drift).
63
+ */
64
+ async hydrate() {
65
+ // ── 1. Contacts ───────────────────────────────────────────────────────
66
+ const serializedContacts = await this._contactAdapter.list();
67
+ this.directory.clear();
68
+ for (const item of serializedContacts) {
69
+ try {
70
+ const raw = base64ToArrayBuffer(item.publicKeyBase64);
71
+ let publicKey;
72
+ try {
73
+ publicKey = await crypto.subtle.importKey("raw", raw, KEY_ALGO, true, []);
74
+ }
75
+ catch {
76
+ publicKey = { raw: new Uint8Array(raw) };
77
+ }
78
+ const contact = MajikContact.create(item.id, publicKey, item.mlKey, item.fingerprint, item.meta, item.edPublicKeyBase64, item.mlDsaPublicKeyBase64);
79
+ // Use the internal map directly to avoid addContact's duplicate-check
80
+ // (re-hydrating from persisted state, not user-facing add)
81
+ this.directory["contacts"].set(contact.id, contact);
82
+ this.directory["fingerprintMap"].set(contact.fingerprint, contact.id);
83
+ }
84
+ catch (err) {
85
+ console.warn(`MajikContactManager.hydrate: skipping malformed contact "${item?.id}":`, err);
86
+ }
87
+ }
88
+ // ── 2. Groups ─────────────────────────────────────────────────────────
89
+ const serializedGroups = await this._groupAdapter.list();
90
+ this.groupManager.fromJSON({ groups: serializedGroups });
91
+ // ── 3. Orphan pruning ─────────────────────────────────────────────────
92
+ MajikContactManager.pruneOrphanedMembers(this.directory, this.groupManager);
93
+ }
94
+ // ── Write-through helpers ─────────────────────────────────────────────────
46
95
  /**
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.
96
+ * Persists a single contact to the adapter (called after every mutating
97
+ * operation that affects a contact's serialized form).
53
98
  */
54
- get group() {
55
- return this.groupManager;
99
+ async persistContact(contact) {
100
+ const json = await contact.toJSON();
101
+ await this._contactAdapter.save(json);
56
102
  }
57
103
  /**
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.
104
+ * Persists a single group to the adapter.
61
105
  */
62
- get directory_() {
63
- return this.directory;
106
+ async persistGroup(group) {
107
+ await this._groupAdapter.save(group.toJSON());
64
108
  }
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) {
109
+ // ── CRUD ──────────────────────────────────────────────────────────────────
110
+ /**
111
+ * Adds a contact to the directory and persists it to the adapter.
112
+ */
113
+ async addContact(contact) {
71
114
  this.directory.addContact(contact);
115
+ await this.persistContact(contact);
72
116
  return this;
73
117
  }
74
- addContacts(contacts) {
118
+ /**
119
+ * Adds multiple contacts atomically — adapter write uses bulkSave.
120
+ */
121
+ async addContacts(contacts) {
75
122
  this.directory.addContacts(contacts);
123
+ const jsons = await Promise.all(contacts.map((c) => c.toJSON()));
124
+ await this._contactAdapter.bulkSave(jsons);
76
125
  return this;
77
126
  }
78
127
  /**
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.
128
+ * Removes a contact from the directory, all groups, and the adapter.
82
129
  */
83
- removeContact(id) {
130
+ async removeContact(id) {
84
131
  const result = this.directory.removeContact(id);
85
132
  if (result.success) {
86
133
  this.groupManager.handleContactRemoved(id);
134
+ await this._contactAdapter.remove(id);
135
+ // Persist every group whose membership changed
136
+ await this._persistAllGroups();
87
137
  }
88
138
  return result;
89
139
  }
140
+ /**
141
+ * Updates contact metadata and persists the change.
142
+ */
143
+ async updateContactMeta(id, meta) {
144
+ const contact = this.directory.updateContactMeta(id, meta);
145
+ await this.persistContact(contact);
146
+ return contact;
147
+ }
148
+ /**
149
+ * Blocks a contact and persists both the contact and the Blocked group.
150
+ */
151
+ async blockContact(id) {
152
+ const contact = this.directory.blockContact(id);
153
+ const blocked = this.groupManager.addContactToGroupIfAbsent(this.groupManager.getBlockedGroup().id, id);
154
+ await this.persistContact(contact);
155
+ await this.persistGroup(blocked);
156
+ return contact;
157
+ }
158
+ /**
159
+ * Unblocks a contact and persists both the contact and the Blocked group.
160
+ */
161
+ async unblockContact(id) {
162
+ const contact = this.directory.unblockContact(id);
163
+ const blocked = this.groupManager.removeContactFromGroupIfPresent(this.groupManager.getBlockedGroup().id, id);
164
+ await this.persistContact(contact);
165
+ await this.persistGroup(blocked);
166
+ return contact;
167
+ }
168
+ async setMajikahStatus(id, status) {
169
+ const contact = this.directory.setMajikahStatus(id, status);
170
+ await this.persistContact(contact);
171
+ return contact;
172
+ }
173
+ /**
174
+ * Clears all contacts and groups from both the in-memory stores and adapters.
175
+ */
176
+ async clear() {
177
+ const allContactIds = this.directory.listContacts().map((c) => c.id);
178
+ this.directory.clear();
179
+ allContactIds.forEach((id) => this.groupManager.handleContactRemoved(id));
180
+ this.directory.clear();
181
+ this.groupManager.clear();
182
+ await this._contactAdapter.clear();
183
+ await this._groupAdapter.clear();
184
+ return this;
185
+ }
186
+ // ── Sync reads (unchanged from original) ──────────────────────────────────
90
187
  getContact(id) {
91
188
  return this.directory.getContact(id);
92
189
  }
@@ -108,263 +205,266 @@ export class MajikContactManager {
108
205
  listContacts(sortedByLabel = false, majikahOnly = false) {
109
206
  return this.directory.listContacts(sortedByLabel, majikahOnly);
110
207
  }
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
208
  isMajikahRegistered(id) {
136
209
  return this.directory.isMajikahRegistered(id);
137
210
  }
138
211
  isMajikahIdentityChecked(id) {
139
212
  return this.directory.isMajikahIdentityChecked(id);
140
213
  }
141
- hasContactForEnvelope(envelope) {
142
- return this.directory.hasContactForEnvelope(envelope);
214
+ // ── Group CRUD (now async, write-through) ─────────────────────────────────
215
+ get group() {
216
+ return this.groupManager;
143
217
  }
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);
218
+ get directory_() {
219
+ return this.directory;
153
220
  }
154
- /**
155
- * Registers an already-constructed MajikContactGroup instance.
156
- * Throws if a group with the same ID already exists.
157
- */
158
- addGroup(group) {
221
+ async createGroup(id, name, meta, initialMemberIds) {
222
+ const group = this.groupManager.createGroup(id, name, meta, initialMemberIds);
223
+ await this.persistGroup(group);
224
+ return group;
225
+ }
226
+ async addGroup(group) {
159
227
  this.groupManager.addGroup(group);
228
+ await this.persistGroup(group);
160
229
  return this;
161
230
  }
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);
231
+ async removeGroup(id) {
232
+ const result = this.groupManager.removeGroup(id);
233
+ if (result.success) {
234
+ await this._groupAdapter.remove(id);
235
+ }
236
+ return result;
168
237
  }
169
- /**
170
- * Returns a group by ID, or undefined if not found.
171
- */
172
238
  getGroup(id) {
173
239
  return this.groupManager.getGroup(id);
174
240
  }
175
- /**
176
- * Returns a group by ID. Throws if not found.
177
- */
178
241
  getGroupOrThrow(id) {
179
242
  return this.groupManager.getGroupOrThrow(id);
180
243
  }
181
- /**
182
- * Returns true if a group with the given ID exists.
183
- */
184
244
  hasGroup(id) {
185
245
  return this.groupManager.hasGroup(id);
186
246
  }
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
247
  listGroups(includeSystem = true, sortedByName = false) {
194
248
  return this.groupManager.listGroups(includeSystem, sortedByName);
195
249
  }
196
- /**
197
- * Returns only user-created groups (excludes Favorites and Blocked).
198
- * Sorted alphabetically by name.
199
- */
200
250
  listUserGroups(sortedByName = true) {
201
251
  return this.groupManager.listGroups(false, sortedByName);
202
252
  }
203
- /**
204
- * Returns only system groups (Favorites and Blocked).
205
- */
206
253
  listSystemGroups() {
207
254
  return this.groupManager.listGroups(true).filter((g) => g.isSystem);
208
255
  }
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
- */
256
+ async updateGroupMeta(id, meta) {
257
+ const group = this.groupManager.updateGroupMeta(id, meta);
258
+ await this.persistGroup(group);
259
+ return group;
260
+ }
261
+ // ── Group membership (async, write-through) ───────────────────────────────
262
+ async addContactToGroup(groupId, contactId) {
263
+ const group = this.groupManager.addContactToGroup(groupId, contactId);
264
+ await this.persistGroup(group);
265
+ return group;
266
+ }
267
+ async addContactToGroupIfAbsent(groupId, contactId) {
268
+ const group = this.groupManager.addContactToGroupIfAbsent(groupId, contactId);
269
+ await this.persistGroup(group);
270
+ return group;
271
+ }
272
+ async addContactsToGroup(groupId, contactIds) {
273
+ const group = this.groupManager.addContactsToGroup(groupId, contactIds);
274
+ await this.persistGroup(group);
275
+ return group;
276
+ }
277
+ async removeContactFromGroup(groupId, contactId) {
278
+ const group = this.groupManager.removeContactFromGroup(groupId, contactId);
279
+ await this.persistGroup(group);
280
+ return group;
281
+ }
282
+ async removeContactFromGroupIfPresent(groupId, contactId) {
283
+ const group = this.groupManager.removeContactFromGroupIfPresent(groupId, contactId);
284
+ await this.persistGroup(group);
285
+ return group;
286
+ }
287
+ async moveContactBetweenGroups(contactId, fromGroupId, toGroupId) {
288
+ this.groupManager.moveContact(contactId, fromGroupId, toGroupId);
289
+ // Persist both affected groups
290
+ const from = this.groupManager.getGroup(fromGroupId);
291
+ const to = this.groupManager.getGroup(toGroupId);
292
+ const writes = [];
293
+ if (from)
294
+ writes.push(this.persistGroup(from));
295
+ if (to)
296
+ writes.push(this.persistGroup(to));
297
+ await Promise.all(writes);
298
+ }
299
+ // ── Group query pass-throughs (sync, unchanged) ───────────────────────────
268
300
  getContactsInGroup(groupId) {
269
301
  return this.groupManager.getContactsInGroup(groupId);
270
302
  }
271
- /**
272
- * Returns hydrated contacts in the group, sorted by label (or ID if no label).
273
- */
274
303
  getContactsInGroupSorted(groupId) {
275
304
  return this.groupManager.getContactsInGroupSorted(groupId);
276
305
  }
277
- /**
278
- * Returns true if the contact is a member of the given group.
279
- */
280
306
  isContactInGroup(groupId, contactId) {
281
307
  return this.groupManager.isContactInGroup(groupId, contactId);
282
308
  }
283
- /**
284
- * Returns all groups the contact belongs to.
285
- */
286
309
  getGroupsForContact(contactId) {
287
310
  return this.groupManager.getGroupsForContact(contactId);
288
311
  }
289
- /**
290
- * Returns all group IDs the contact belongs to.
291
- */
292
312
  getGroupIdsForContact(contactId) {
293
313
  return this.groupManager.getGroupIdsForContact(contactId);
294
314
  }
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);
315
+ // ── System group convenience (async, write-through) ───────────────────────
316
+ async addToFavorites(contactId) {
317
+ const group = this.groupManager.addToFavorites(contactId);
318
+ await this.persistGroup(group);
319
+ return group;
303
320
  }
304
- /**
305
- * Removes the contact from the Favorites group (idempotent).
306
- */
307
- removeFromFavorites(contactId) {
308
- return this.groupManager.removeFromFavorites(contactId);
321
+ async removeFromFavorites(contactId) {
322
+ const group = this.groupManager.removeFromFavorites(contactId);
323
+ await this.persistGroup(group);
324
+ return group;
309
325
  }
310
- /**
311
- * Returns true if the contact is in the Favorites group.
312
- */
313
326
  isFavorite(contactId) {
314
327
  return this.groupManager.isFavorite(contactId);
315
328
  }
316
- /**
317
- * Returns true if the contact is in the Blocked group.
318
- */
319
329
  isContactBlocked(contactId) {
320
330
  return this.groupManager.isBlocked(contactId);
321
331
  }
322
- /**
323
- * Returns the Favorites system group instance.
324
- */
325
332
  getFavoritesGroup() {
326
333
  return this.groupManager.getFavoritesGroup();
327
334
  }
328
- /**
329
- * Returns the Blocked system group instance.
330
- */
331
335
  getBlockedGroup() {
332
336
  return this.groupManager.getBlockedGroup();
333
337
  }
334
- /**
335
- * Returns all contacts in the Favorites group as hydrated MajikContact instances.
336
- */
337
338
  getFavoriteContacts() {
338
339
  return this.groupManager.getContactsInGroup(this.groupManager.getFavoritesGroup().id);
339
340
  }
340
- /**
341
- * Returns all contacts in the Blocked group as hydrated MajikContact instances.
342
- */
343
341
  getBlockedContacts() {
344
342
  return this.groupManager.getContactsInGroup(this.groupManager.getBlockedGroup().id);
345
343
  }
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;
344
+ // ── Import / Export (unchanged) ───────────────────────────────────────────
345
+ async exportContactAsJSON(contactId) {
346
+ const contact = this.getContact(contactId);
347
+ if (!contact)
348
+ return null;
349
+ let publicKeyBase64;
350
+ const anyPub = contact.publicKey;
351
+ if (anyPub?.raw instanceof Uint8Array) {
352
+ publicKeyBase64 = arrayBufferToBase64(anyPub.raw.buffer);
353
+ }
354
+ else {
355
+ const raw = await crypto.subtle.exportKey("raw", contact.publicKey);
356
+ publicKeyBase64 = arrayBufferToBase64(raw);
357
+ }
358
+ return JSON.stringify({
359
+ id: contact.id,
360
+ label: contact.meta?.label || "",
361
+ publicKey: publicKeyBase64,
362
+ fingerprint: contact.fingerprint,
363
+ mlKey: contact.mlKey,
364
+ edPublicKeyBase64: contact.edPublicKeyBase64,
365
+ mlDsaPublicKeyBase64: contact.mlDsaPublicKeyBase64,
366
+ }, null, 2);
367
+ }
368
+ async exportContactAsString(contactId) {
369
+ const contact = this.getContact(contactId);
370
+ if (!contact)
371
+ return null;
372
+ return this.exportContactCompressed(contact);
373
+ }
374
+ async importContactFromJSON(jsonStr) {
375
+ try {
376
+ const data = JSON.parse(jsonStr);
377
+ if (!data.id || !data.publicKey || !data.fingerprint) {
378
+ return { success: false, message: "Invalid contact JSON" };
379
+ }
380
+ const rawBuffer = base64ToArrayBuffer(data.publicKey);
381
+ let publicKey;
382
+ try {
383
+ publicKey = await crypto.subtle.importKey("raw", rawBuffer, KEY_ALGO, true, []);
384
+ }
385
+ catch {
386
+ publicKey = { raw: new Uint8Array(rawBuffer) };
387
+ }
388
+ const contact = new MajikContact({
389
+ id: data.id,
390
+ publicKey,
391
+ fingerprint: data.fingerprint,
392
+ meta: { label: data.label },
393
+ mlKey: data.mlKey,
394
+ edPublicKeyBase64: data.edPublicKeyBase64,
395
+ mlDsaPublicKeyBase64: data.mlDsaPublicKeyBase64,
396
+ });
397
+ await this.addContact(contact);
398
+ return { success: true, message: "Contact imported successfully" };
399
+ }
400
+ catch (err) {
401
+ return {
402
+ success: false,
403
+ message: err instanceof Error ? err.message : "Unknown error",
404
+ };
405
+ }
360
406
  }
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
- */
407
+ async importContactFromString(base64Str) {
408
+ try {
409
+ const contact = await this.importContactCompressed(base64Str);
410
+ await this.addContact(contact);
411
+ return { success: true, message: "Contact imported successfully" };
412
+ }
413
+ catch (err) {
414
+ return {
415
+ success: false,
416
+ message: err instanceof Error ? err.message : "Unknown error",
417
+ };
418
+ }
419
+ }
420
+ async exportContactCompressed(contact) {
421
+ let publicKeyBase64;
422
+ const anyPub = contact.publicKey;
423
+ if (anyPub?.raw instanceof Uint8Array) {
424
+ publicKeyBase64 = arrayBufferToBase64(anyPub.raw.buffer);
425
+ }
426
+ else {
427
+ const raw = await crypto.subtle.exportKey("raw", contact.publicKey);
428
+ publicKeyBase64 = arrayBufferToBase64(raw);
429
+ }
430
+ const jsonObj = {
431
+ id: contact.id,
432
+ label: contact.meta?.label || "",
433
+ publicKey: publicKeyBase64,
434
+ fingerprint: contact.fingerprint,
435
+ mlKey: contact.mlKey,
436
+ edPublicKeyBase64: contact.edPublicKeyBase64,
437
+ mlDsaPublicKeyBase64: contact.mlDsaPublicKeyBase64,
438
+ };
439
+ const compressed = gzipSync(new TextEncoder().encode(JSON.stringify(jsonObj)));
440
+ return arrayToBase64(compressed);
441
+ }
442
+ async importContactCompressed(base64Str) {
443
+ const compressed = base64ToArrayBuffer(base64Str);
444
+ const jsonStr = new TextDecoder().decode(gunzipSync(new Uint8Array(compressed)));
445
+ const data = JSON.parse(jsonStr);
446
+ const rawBuffer = base64ToArrayBuffer(data.publicKey);
447
+ let publicKey;
448
+ try {
449
+ publicKey = await crypto.subtle.importKey("raw", rawBuffer, KEY_ALGO, true, []);
450
+ }
451
+ catch {
452
+ publicKey = { raw: new Uint8Array(rawBuffer) };
453
+ }
454
+ if (!data?.id || !publicKey || !data?.fingerprint || !data?.mlKey) {
455
+ throw new Error("Invalid contact JSON");
456
+ }
457
+ return new MajikContact({
458
+ id: data.id,
459
+ publicKey,
460
+ fingerprint: data.fingerprint,
461
+ meta: { label: data.label },
462
+ mlKey: data.mlKey,
463
+ edPublicKeyBase64: data.edPublicKeyBase64,
464
+ mlDsaPublicKeyBase64: data.mlDsaPublicKeyBase64,
465
+ });
466
+ }
467
+ // ── Serialization ─────────────────────────────────────────────────────────
368
468
  async toJSON() {
369
469
  return {
370
470
  contacts: await this.directory.toJSON(),
@@ -372,18 +472,19 @@ export class MajikContactManager {
372
472
  };
373
473
  }
374
474
  /**
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().
475
+ * Restore from a JSON snapshot into the current adapters.
476
+ * Writes all contacts and groups through to the adapters.
477
+ * Used after setAdapters() to migrate data into a new store.
385
478
  */
386
- static async fromJSON(data) {
479
+ async bulkRestoreFromJSON(data) {
480
+ if (!data?.contacts || !data?.groups) {
481
+ throw new MajikContactManagerError("bulkRestoreFromJSON: invalid payload — expected { contacts, groups }");
482
+ }
483
+ await this._contactAdapter.bulkSave(data.contacts.contacts);
484
+ await this._groupAdapter.bulkSave(data.groups.groups);
485
+ await this.hydrate();
486
+ }
487
+ static async fromJSON(data, adapters) {
387
488
  if (!data || typeof data !== "object") {
388
489
  throw new MajikContactManagerError("fromJSON: invalid payload — expected { contacts, groups }");
389
490
  }
@@ -393,52 +494,31 @@ export class MajikContactManager {
393
494
  if (!data.groups) {
394
495
  throw new MajikContactManagerError("fromJSON: missing required field 'groups'");
395
496
  }
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);
497
+ const manager = new MajikContactManager(undefined, undefined, adapters);
498
+ await manager.bulkRestoreFromJSON(data);
499
+ return manager;
420
500
  }
501
+ // ── Private helpers ───────────────────────────────────────────────────────
421
502
  /**
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.
503
+ * Persists every group currently in the group manager to the adapter.
504
+ * Used after bulk contact removal where multiple groups may be affected.
424
505
  */
506
+ async _persistAllGroups() {
507
+ const all = this.groupManager.listGroups(true);
508
+ await this._groupAdapter.bulkSave(all.map((g) => g.toJSON()));
509
+ }
425
510
  static pruneOrphanedMembers(directory, groupManager) {
426
- const allGroups = groupManager.listGroups(true); // include system groups
511
+ const allGroups = groupManager.listGroups(true);
427
512
  for (const group of allGroups) {
428
513
  const orphans = group
429
514
  .listMemberIds()
430
515
  .filter((id) => !directory.hasContact(id));
431
516
  for (const orphanId of orphans) {
432
- // Use the idempotent variant — safe even if the index is already clean
433
517
  group.removeMemberIfPresent(orphanId);
434
- // Also clean up the reverse index on the group manager
435
518
  groupManager.handleContactRemoved(orphanId);
436
519
  }
437
520
  }
438
521
  }
439
- /* ================================
440
- * Assertions
441
- * ================================ */
442
522
  assertGroupManagerInstance(gm) {
443
523
  if (!gm || !(gm instanceof MajikContactGroupManager)) {
444
524
  throw new MajikContactManagerError("groupManager must be a valid MajikContactGroupManager instance");