@majikah/majik-message 0.2.21 → 0.3.1

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.
@@ -1,11 +1,9 @@
1
1
  // MajikMessage.ts
2
2
  import { MajikContact, } from "@majikah/majik-contact";
3
3
  import { KEY_ALGO } from "./core/crypto/constants";
4
- import { ScannerEngine } from "./core/scanner/scanner-engine";
5
4
  import { MessageEnvelope } from "./core/messages/message-envelope";
6
5
  import { EnvelopeCache, } from "./core/messages/envelope-cache";
7
6
  import { MajikKeyStore } from "./core/crypto/keystore";
8
- import { MajikContactDirectory, } from "./core/contacts/majik-contact-directory";
9
7
  import { arrayBufferToBase64, arrayToBase64, base64ToArrayBuffer, base64ToUint8Array, } from "./core/utils/utilities";
10
8
  import { autoSaveMajikFileData, loadSavedMajikFileData, } from "./core/utils/majik-file-utils";
11
9
  import { randomBytes } from "@stablelib/random";
@@ -16,14 +14,13 @@ import { MajikEnvelope, } from "@majikah/majik-envelope";
16
14
  import { MajikFile, MajikFileError, } from "@majikah/majik-file";
17
15
  import { MajikSignature, } from "@majikah/majik-signature";
18
16
  import { gzipSync, gunzipSync } from "fflate";
17
+ import { MajikContactManager } from "./core/contacts/majik-contact-manager";
19
18
  // ─── MajikMessage ─────────────────────────────────────────────────────────────
20
19
  export class MajikMessage {
21
20
  userProfile = "default";
22
- pinHash = null;
23
21
  id;
24
- contactDirectory;
22
+ contacts;
25
23
  envelopeCache;
26
- scanner;
27
24
  listeners = new Map();
28
25
  ownAccounts = new Map();
29
26
  ownAccountsOrder = [];
@@ -34,16 +31,9 @@ export class MajikMessage {
34
31
  constructor(config, id, userProfile = "default") {
35
32
  this.userProfile = userProfile || "default";
36
33
  this.id = id || arrayToBase64(randomBytes(32));
37
- this.contactDirectory =
38
- config.contactDirectory || new MajikContactDirectory();
34
+ this.contacts = config.contactManager ?? new MajikContactManager();
39
35
  this.envelopeCache =
40
36
  config.envelopeCache || new EnvelopeCache(undefined, userProfile);
41
- this.scanner = new ScannerEngine({
42
- contactDirectory: this.contactDirectory,
43
- onEnvelopeFound: (env) => this.handleEnvelope(env),
44
- onUntrusted: (raw) => this.emit("untrusted", raw),
45
- onError: (err, ctx) => this.emit("error", err, ctx),
46
- });
47
37
  const events = [
48
38
  "message",
49
39
  "envelope",
@@ -58,38 +48,13 @@ export class MajikMessage {
58
48
  events.forEach((e) => this.listeners.set(e, []));
59
49
  this.attachAutosaveHandlers();
60
50
  }
61
- // ── Private: Envelope helpers ────────────────────────────────────────────
62
- // /**
63
- // * Resolve a list of account/contact IDs into MajikRecipient objects.
64
- // * Each recipient needs their ML-KEM public key from MajikKeyStore.
65
- // */
66
- // private async _resolveRecipients(ids: string[]): Promise<MajikRecipient[]> {
67
- // return Promise.all(
68
- // ids.map(async (id) => {
69
- // const contact = this.contactDirectory.getContact(id);
70
- // if (!contact) throw new Error(`No contact found for id "${id}"`);
71
- // // const key = await MajikKeyStore.load(id);
72
- // const mlPubKey = base64ToUint8Array(contact.mlKey);
73
- // if (!mlPubKey) {
74
- // throw new Error(
75
- // `Contact "${id}" has no ML-KEM public key. ` +
76
- // `They may need to upgrade their account via importFromMnemonicBackup().`,
77
- // );
78
- // }
79
- // return {
80
- // fingerprint: contact.fingerprint,
81
- // mlKemPublicKey: mlPubKey,
82
- // } satisfies MajikRecipient;
83
- // }),
84
- // );
85
- // }
86
51
  /**
87
52
  * Resolve a list of account/contact IDs into MajikRecipient objects.
88
53
  * Each recipient needs their ML-KEM public key from MajikKeyStore.
89
54
  */
90
55
  async _resolveRecipientsByPublicKey(publicKeys) {
91
56
  return Promise.all(publicKeys.map(async (pkey) => {
92
- const contact = await this.contactDirectory.getContactByPublicKeyBase64(pkey);
57
+ const contact = await this.contacts.getContactByPublicKeyBase64(pkey);
93
58
  if (!contact)
94
59
  throw new Error(`No contact found for public key "${pkey}"`);
95
60
  const mlPubKey = base64ToUint8Array(contact.mlKey);
@@ -159,7 +124,7 @@ export class MajikMessage {
159
124
  */
160
125
  async _resolveFileRecipientsByPublicKey(publicKeys) {
161
126
  return Promise.all(publicKeys.map(async (pkey) => {
162
- const contact = await this.contactDirectory.getContactByPublicKeyBase64(pkey);
127
+ const contact = await this.contacts.getContactByPublicKeyBase64(pkey);
163
128
  if (!contact)
164
129
  throw new Error(`No contact found for public key "${pkey}"`);
165
130
  const mlPubKey = base64ToUint8Array(contact.mlKey);
@@ -216,8 +181,8 @@ export class MajikMessage {
216
181
  this.ownAccountsOrder.push(account.id);
217
182
  }
218
183
  try {
219
- if (!this.contactDirectory.hasContact(account.id)) {
220
- this.contactDirectory.addContact(account);
184
+ if (!this.contacts.hasContact(account.id)) {
185
+ this.contacts.addContact(account);
221
186
  }
222
187
  if (!this.getActiveAccount()) {
223
188
  this.setActiveAccount(account.id);
@@ -298,25 +263,25 @@ export class MajikMessage {
298
263
  getContactByID(id) {
299
264
  if (!id?.trim())
300
265
  throw new Error("Invalid contact ID");
301
- return this.contactDirectory.getContact(id) ?? null;
266
+ return this.contacts.getContact(id) ?? null;
302
267
  }
303
268
  hasContact(id) {
304
269
  if (!id?.trim())
305
270
  throw new Error("Invalid contact ID");
306
- return this.contactDirectory.hasContact(id);
271
+ return this.contacts.hasContact(id);
307
272
  }
308
273
  async hasContactByPublicKeyBase64(publicKey) {
309
274
  if (!publicKey?.trim())
310
275
  throw new Error("Invalid contact public key");
311
- return await this.contactDirectory.hasContactByPublicKeyBase64(publicKey);
276
+ return await this.contacts.hasContactByPublicKeyBase64(publicKey);
312
277
  }
313
278
  async getContactByPublicKey(publicKeyBase64) {
314
279
  if (!publicKeyBase64?.trim())
315
280
  throw new Error("Invalid public key");
316
- return ((await this.contactDirectory.getContactByPublicKeyBase64(publicKeyBase64)) ?? null);
281
+ return ((await this.contacts.getContactByPublicKeyBase64(publicKeyBase64)) ?? null);
317
282
  }
318
- async exportContactAsJSON(contactId) {
319
- const contact = this.contactDirectory.getContact(contactId);
283
+ async exportContactAsJSON(contactID) {
284
+ const contact = this.contacts.getContact(contactID);
320
285
  if (!contact)
321
286
  return null;
322
287
  let publicKeyBase64;
@@ -338,8 +303,8 @@ export class MajikMessage {
338
303
  mlDsaPublicKeyBase64: contact.mlDsaPublicKeyBase64,
339
304
  }, null, 2);
340
305
  }
341
- async exportContactAsString(contactId) {
342
- const contact = this.contactDirectory.getContact(contactId);
306
+ async exportContactAsString(contactID) {
307
+ const contact = this.contacts.getContact(contactID);
343
308
  if (!contact)
344
309
  return null;
345
310
  const compressedString = this.exportContactCompressed(contact);
@@ -450,45 +415,276 @@ export class MajikMessage {
450
415
  !contact?.mlKey) {
451
416
  throw new Error("Invalid contact JSON");
452
417
  }
453
- this.contactDirectory.addContact(contact);
418
+ this.contacts.addContact(contact);
454
419
  this.emit("new-contact", contact);
455
420
  this.scheduleAutosave();
456
421
  }
457
422
  removeContact(id) {
458
- const result = this.contactDirectory.removeContact(id);
423
+ const result = this.contacts.removeContact(id);
459
424
  if (!result.success)
460
425
  throw new Error(result.message);
461
426
  this.emit("removed-contact", id);
462
427
  this.scheduleAutosave();
463
428
  }
464
429
  updateContactMeta(id, meta) {
465
- this.contactDirectory.updateContactMeta(id, meta);
430
+ this.contacts.updateContactMeta(id, meta);
466
431
  this.scheduleAutosave();
467
432
  }
468
433
  blockContact(id) {
469
- this.contactDirectory.blockContact(id);
434
+ this.contacts.blockContact(id);
470
435
  this.scheduleAutosave();
471
436
  }
472
437
  unblockContact(id) {
473
- this.contactDirectory.unblockContact(id);
438
+ this.contacts.unblockContact(id);
474
439
  this.scheduleAutosave();
475
440
  }
476
441
  listContacts(all = true, majikahOnly = false) {
477
- const contacts = this.contactDirectory.listContacts(true, majikahOnly);
442
+ const contacts = this.contacts.listContacts(true, majikahOnly);
478
443
  if (all)
479
444
  return contacts;
480
445
  const ownIds = new Set(this.listOwnAccounts(majikahOnly).map((a) => a.id));
481
446
  return contacts.filter((c) => !ownIds.has(c.id));
482
447
  }
483
448
  isContactMajikahRegistered(id) {
484
- return this.contactDirectory.isMajikahRegistered(id);
449
+ return this.contacts.isMajikahRegistered(id);
485
450
  }
486
451
  isContactMajikahIdentityChecked(id) {
487
- return this.contactDirectory.isMajikahIdentityChecked(id);
452
+ return this.contacts.isMajikahIdentityChecked(id);
488
453
  }
489
454
  setContactMajikahStatus(id, status) {
490
- this.contactDirectory.setMajikahStatus(id, status);
455
+ this.contacts.setMajikahStatus(id, status);
456
+ this.scheduleAutosave();
457
+ }
458
+ /* ================================
459
+ * Group CRUD Pass-throughs
460
+ * ================================ */
461
+ /**
462
+ * Creates and registers a new user-defined group.
463
+ * Throws if a group with the same ID already exists.
464
+ */
465
+ createGroup(id, name, meta, initialMemberIds) {
466
+ const newGroup = this.contacts.createGroup(id, name, meta, initialMemberIds);
467
+ this.emit("new-contact-group", newGroup);
468
+ this.scheduleAutosave();
469
+ return this;
470
+ }
471
+ /**
472
+ * Registers an already-constructed MajikContactGroup instance.
473
+ * Throws if a group with the same ID already exists.
474
+ */
475
+ addGroup(group) {
476
+ this.contacts.addGroup(group);
477
+ this.emit("new-contact-group", group);
478
+ this.scheduleAutosave();
479
+ return this;
480
+ }
481
+ /**
482
+ * Removes a user group by ID.
483
+ * System groups (Favorites, Blocked) cannot be deleted.
484
+ */
485
+ removeGroup(id) {
486
+ const response = this.contacts.removeGroup(id);
487
+ this.emit("removed-contact-group", response.data);
488
+ this.scheduleAutosave();
489
+ return response;
490
+ }
491
+ /**
492
+ * Returns a group by ID, or undefined if not found.
493
+ */
494
+ getContactGroup(id) {
495
+ return this.contacts.getGroup(id);
496
+ }
497
+ /**
498
+ * Returns a group by ID. Throws if not found.
499
+ */
500
+ getGroupOrThrow(id) {
501
+ return this.contacts.getGroupOrThrow(id);
502
+ }
503
+ /**
504
+ * Returns true if a group with the given ID exists.
505
+ */
506
+ hasGroup(id) {
507
+ return this.contacts.hasGroup(id);
508
+ }
509
+ /**
510
+ * Returns all groups.
511
+ *
512
+ * @param includeSystem Include system groups (Favorites, Blocked). Default: true.
513
+ * @param sortedByName Sort results alphabetically by group name. Default: false.
514
+ */
515
+ listContactGroups(includeSystem = true, sortedByName = false) {
516
+ return this.contacts.listGroups(includeSystem, sortedByName);
517
+ }
518
+ /**
519
+ * Returns only user-created groups (excludes Favorites and Blocked).
520
+ * Sorted alphabetically by name.
521
+ */
522
+ listUserGroups(sortedByName = true) {
523
+ return this.contacts.listGroups(false, sortedByName);
524
+ }
525
+ /**
526
+ * Returns only system groups (Favorites and Blocked).
527
+ */
528
+ listSystemGroups() {
529
+ return this.contacts.listGroups(true).filter((g) => g.isSystem);
530
+ }
531
+ /**
532
+ * Updates mutable metadata on a group (name, description).
533
+ * Name is locked on system groups — will throw if attempted.
534
+ */
535
+ updateGroupMeta(id, meta) {
536
+ const updatedGroup = this.contacts.updateGroupMeta(id, meta);
537
+ this.emit("contact-group-change", updatedGroup);
491
538
  this.scheduleAutosave();
539
+ return this;
540
+ }
541
+ /* ================================
542
+ * Group Membership Pass-throughs
543
+ * ================================ */
544
+ /**
545
+ * Adds a contact to a group.
546
+ * Validates the contact exists in the directory.
547
+ * If the group is the system Blocked group, also calls contact.block().
548
+ * Throws if the contact is already a member — use addContactToGroupIfAbsent for idempotent.
549
+ */
550
+ addContactToGroup(groupID, contactID) {
551
+ const updatedGroup = this.contacts.addContactToGroup(groupID, contactID);
552
+ this.emit("contact-group-change", updatedGroup);
553
+ this.scheduleAutosave();
554
+ return this;
555
+ }
556
+ /**
557
+ * Adds multiple contacts to a group in one call (all-or-nothing).
558
+ */
559
+ addContactsToGroup(groupID, contactIds) {
560
+ const updatedGroup = this.contacts.addContactsToGroup(groupID, contactIds);
561
+ this.emit("contact-group-change", updatedGroup);
562
+ this.scheduleAutosave();
563
+ return this;
564
+ }
565
+ /**
566
+ * Removes a contact from a group.
567
+ * If the group is the system Blocked group, also calls contact.unblock().
568
+ * Throws if the contact is not a member — use removeContactFromGroupIfPresent for idempotent.
569
+ */
570
+ removeContactFromGroup(groupID, contactID) {
571
+ const updatedGroup = this.contacts.removeContactFromGroup(groupID, contactID);
572
+ this.emit("contact-group-change", updatedGroup);
573
+ this.scheduleAutosave();
574
+ return this;
575
+ }
576
+ /**
577
+ * Moves a contact from one group to another atomically.
578
+ * Throws if the contact is not a member of the source group.
579
+ */
580
+ moveContactBetweenGroups(contactID, fromGroupId, toGroupId) {
581
+ const updatedGroup = this.contacts.moveContactBetweenGroups(contactID, fromGroupId, toGroupId);
582
+ this.emit("contact-group-change", updatedGroup);
583
+ this.scheduleAutosave();
584
+ return this;
585
+ }
586
+ /* ================================
587
+ * Group Query Pass-throughs
588
+ * ================================ */
589
+ /**
590
+ * Returns all hydrated MajikContact instances in the given group.
591
+ * Contacts removed from the directory since last save are silently skipped.
592
+ */
593
+ getContactsInGroup(groupID) {
594
+ return this.contacts.getContactsInGroup(groupID);
595
+ }
596
+ /**
597
+ * Returns hydrated contacts in the group, sorted by label (or ID if no label).
598
+ */
599
+ getContactsInGroupSorted(groupID) {
600
+ return this.contacts.getContactsInGroupSorted(groupID);
601
+ }
602
+ /**
603
+ * Returns true if the contact is a member of the given group.
604
+ */
605
+ isContactInGroup(groupID, contactID) {
606
+ return this.contacts.isContactInGroup(groupID, contactID);
607
+ }
608
+ /**
609
+ * Returns all groups the contact belongs to.
610
+ */
611
+ getGroupsForContact(contactID) {
612
+ return this.contacts.getGroupsForContact(contactID);
613
+ }
614
+ /**
615
+ * Returns all group IDs the contact belongs to.
616
+ */
617
+ getGroupIdsForContact(contactID) {
618
+ return this.contacts.getGroupIdsForContact(contactID);
619
+ }
620
+ /* ================================
621
+ * System Group Convenience Pass-throughs
622
+ * ================================ */
623
+ /**
624
+ * Adds the contact to the Favorites group (idempotent).
625
+ */
626
+ addContactToFavorites(contactID) {
627
+ const updatedGroup = this.contacts.addToFavorites(contactID);
628
+ this.emit("contact-group-change", updatedGroup);
629
+ this.scheduleAutosave();
630
+ return this;
631
+ }
632
+ /**
633
+ * Removes the contact from the Favorites group (idempotent).
634
+ */
635
+ removeContactFromFavorites(contactID) {
636
+ const updatedGroup = this.contacts.removeFromFavorites(contactID);
637
+ this.emit("contact-group-change", updatedGroup);
638
+ this.scheduleAutosave();
639
+ return this;
640
+ }
641
+ /**
642
+ * Returns true if the contact is in the Favorites group.
643
+ */
644
+ isContactFavorite(contactID) {
645
+ return this.contacts.isFavorite(contactID);
646
+ }
647
+ /**
648
+ * Returns true if the contact is in the Blocked group.
649
+ */
650
+ isContactBlocked(contactID) {
651
+ return this.contacts.isContactBlocked(contactID);
652
+ }
653
+ /**
654
+ * Returns the Favorites system group instance.
655
+ */
656
+ getFavoritesGroup() {
657
+ return this.contacts.getFavoritesGroup();
658
+ }
659
+ /**
660
+ * Returns the Blocked system group instance.
661
+ */
662
+ getBlockedGroup() {
663
+ return this.contacts.getBlockedGroup();
664
+ }
665
+ /**
666
+ * Returns all contacts in the Favorites group as hydrated MajikContact instances.
667
+ */
668
+ getFavoriteContacts() {
669
+ return this.contacts.getContactsInGroup(this.contacts.getFavoritesGroup().id);
670
+ }
671
+ /**
672
+ * Returns all contacts in the Blocked group as hydrated MajikContact instances.
673
+ */
674
+ getBlockedContacts() {
675
+ return this.contacts.getContactsInGroup(this.contacts.getBlockedGroup().id);
676
+ }
677
+ /* ================================
678
+ * Directory Clear
679
+ * ================================ */
680
+ /**
681
+ * Clears both the directory and all group memberships.
682
+ * System groups are preserved (re-bootstrapped by the group manager).
683
+ */
684
+ clearDirectory() {
685
+ this.contacts.clear();
686
+ this.scheduleAutosave();
687
+ return this;
492
688
  }
493
689
  // ── Encryption / Decryption ───────────────────────────────────────────────
494
690
  /**
@@ -764,7 +960,7 @@ export class MajikMessage {
764
960
  * metadata: row,
765
961
  * });
766
962
  * if (signature) {
767
- * const result = await majik.verifyMajikFile(file, { contactId: row.user_id });
963
+ * const result = await majik.verifyMajikFile(file, { contactID: row.user_id });
768
964
  * }
769
965
  * ```
770
966
  */
@@ -857,7 +1053,7 @@ export class MajikMessage {
857
1053
  * if the instance was restored from a metadata-only Supabase row.
858
1054
  *
859
1055
  * Signer resolution:
860
- * - contactId: looked up in the contact directory (own accounts included)
1056
+ * - contactID: looked up in the contact directory (own accounts included)
861
1057
  * - publicKeyBase64: looked up via contact directory
862
1058
  * - key: used directly (skips directory lookup)
863
1059
  * - none provided: falls back to public keys embedded in the signature
@@ -868,7 +1064,7 @@ export class MajikMessage {
868
1064
  * @example — verify against the file's owner contact
869
1065
  * file.attachBinary(await r2.get(row.r2_key).arrayBuffer());
870
1066
  * const result = await majik.verifyMajikFile(file, {
871
- * contactId: ownerContactId,
1067
+ * contactID: ownerContactId,
872
1068
  * });
873
1069
  * if (result?.valid) console.log("Verified, signed by", result.signerId);
874
1070
  */
@@ -908,7 +1104,7 @@ export class MajikMessage {
908
1104
  *
909
1105
  * @example
910
1106
  * const result = await majik.verifyMajikFileBinary(file, {
911
- * contactId: "contact_abc",
1107
+ * contactID: "contact_abc",
912
1108
  * });
913
1109
  * if (result.valid) console.log("Plaintext verified");
914
1110
  */
@@ -1118,7 +1314,7 @@ export class MajikMessage {
1118
1314
  *
1119
1315
  * @example
1120
1316
  * const result = await majik.verifyText("Hello world", sig, {
1121
- * contactId: "contact_abc",
1317
+ * contactID: "contact_abc",
1122
1318
  * });
1123
1319
  * if (result.valid) console.log("Authentic");
1124
1320
  */
@@ -1144,7 +1340,7 @@ export class MajikMessage {
1144
1340
  * @example
1145
1341
  * const row = await db.findOne({ doc_id });
1146
1342
  * const result = await majik.verifyDetached(docBytes, row.signature, {
1147
- * contactId: row.signer_contact_id,
1343
+ * contactID: row.signer_contact_id,
1148
1344
  * });
1149
1345
  * if (result.valid) console.log("Signed by", result.signerId);
1150
1346
  */
@@ -1278,16 +1474,6 @@ export class MajikMessage {
1278
1474
  return false;
1279
1475
  return MajikKeyStore.isPassphraseValid(target.id, passphrase);
1280
1476
  }
1281
- // ── DOM Scanning ──────────────────────────────────────────────────────────
1282
- scanDOM(rootNode) {
1283
- this.scanner.scanDOM(rootNode);
1284
- }
1285
- startDOMObserver(rootNode) {
1286
- this.scanner.startDOMObserver(rootNode);
1287
- }
1288
- stopDOMObserver() {
1289
- this.scanner.stopDOMObserver();
1290
- }
1291
1477
  // ── Events ────────────────────────────────────────────────────────────────
1292
1478
  on(event, callback) {
1293
1479
  this.listeners.get(event)?.push(callback);
@@ -1509,7 +1695,7 @@ export class MajikMessage {
1509
1695
  * > against a known contact fingerprint before trusting the result.
1510
1696
  *
1511
1697
  * @example — verify against a known contact
1512
- * const result = await majik.verifyContent(docBytes, sig, { contactId: "contact_abc" });
1698
+ * const result = await majik.verifyContent(docBytes, sig, { contactID: "contact_abc" });
1513
1699
  * if (result.valid) console.log("Authentic, signed by:", result.signerId);
1514
1700
  *
1515
1701
  * @example — verify using embedded keys (self-reported)
@@ -1545,12 +1731,12 @@ export class MajikMessage {
1545
1731
  * envelope are used (self-reported — see security note on verifyContent).
1546
1732
  *
1547
1733
  * @example — verify a signed PDF against a known contact
1548
- * const result = await majik.verifyFile(signedPdf, { contactId: "contact_abc" });
1734
+ * const result = await majik.verifyFile(signedPdf, { contactID: "contact_abc" });
1549
1735
  * if (result.valid) console.log("Verified:", result.signerId, result.timestamp);
1550
1736
  *
1551
1737
  * @example — check own signed file using active account
1552
1738
  * const result = await majik.verifyFile(signedWav, {
1553
- * contactId: majik.getActiveAccount()?.id,
1739
+ * contactID: majik.getActiveAccount()?.id,
1554
1740
  * });
1555
1741
  */
1556
1742
  async verifyFile(file, options) {
@@ -1564,7 +1750,7 @@ export class MajikMessage {
1564
1750
  return results[0];
1565
1751
  }
1566
1752
  // No signer provided — extract and use self-reported keys from first signature.
1567
- // For full multi-sig verification, pass a contactId or publicKeyBase64.
1753
+ // For full multi-sig verification, pass a contactID or publicKeyBase64.
1568
1754
  const extracted = await MajikSignature.extractFrom(file, {
1569
1755
  mimeType: options?.mimeType,
1570
1756
  });
@@ -1599,7 +1785,7 @@ export class MajikMessage {
1599
1785
  * @example
1600
1786
  * const results = await majik.batchVerifyFiles(
1601
1787
  * [pdfBlob, wavBlob, mp4Blob],
1602
- * { contactId: "contact_abc" },
1788
+ * { contactID: "contact_abc" },
1603
1789
  * );
1604
1790
  * const allValid = results.every(r => r.valid);
1605
1791
  */
@@ -1711,7 +1897,7 @@ export class MajikMessage {
1711
1897
  *
1712
1898
  * @example
1713
1899
  * if (await majik.isFileSigned(file)) {
1714
- * const result = await majik.verifyFile(file, { contactId });
1900
+ * const result = await majik.verifyFile(file, { contactID });
1715
1901
  * }
1716
1902
  */
1717
1903
  async isFileSigned(file, options) {
@@ -2055,22 +2241,22 @@ export class MajikMessage {
2055
2241
  return MajikSignature.publicKeysFromMajikKey(options.key);
2056
2242
  }
2057
2243
  // Option B: contact ID looked up from the contact directory
2058
- if (options.contactId) {
2059
- const contact = this.contactDirectory.getContact(options.contactId);
2244
+ if (options.contactID) {
2245
+ const contact = this.contacts.getContact(options.contactID);
2060
2246
  if (!contact) {
2061
- throw new Error(`No contact found for id "${options.contactId}"`);
2247
+ throw new Error(`No contact found for id "${options.contactID}"`);
2062
2248
  }
2063
2249
  // Own accounts are in the keystore — get their signing keys directly
2064
- const ownAccount = this.getOwnAccountById(options.contactId);
2250
+ const ownAccount = this.getOwnAccountById(options.contactID);
2065
2251
  if (ownAccount) {
2066
- const key = MajikKeyStore.get(options.contactId);
2252
+ const key = MajikKeyStore.get(options.contactID);
2067
2253
  if (key?.hasSigningKeys) {
2068
2254
  return MajikSignature.publicKeysFromMajikKey(key);
2069
2255
  }
2070
2256
  }
2071
2257
  // External contact — resolve from their contact card fields
2072
2258
  if (!contact.edPublicKeyBase64 || !contact.mlDsaPublicKeyBase64) {
2073
- throw new Error(`Contact "${options.contactId}" has no signing public keys. ` +
2259
+ throw new Error(`Contact "${options.contactID}" has no signing public keys. ` +
2074
2260
  `They may need to share an updated contact card.`);
2075
2261
  }
2076
2262
  return {
@@ -2081,7 +2267,7 @@ export class MajikMessage {
2081
2267
  }
2082
2268
  // Option C: raw base64 public key — look up via contact directory
2083
2269
  if (options.publicKeyBase64) {
2084
- const contact = await this.contactDirectory.getContactByPublicKeyBase64(options.publicKeyBase64);
2270
+ const contact = await this.contacts.getContactByPublicKeyBase64(options.publicKeyBase64);
2085
2271
  if (!contact) {
2086
2272
  throw new Error(`No contact found for public key "${options.publicKeyBase64}"`);
2087
2273
  }
@@ -2096,34 +2282,11 @@ export class MajikMessage {
2096
2282
  }
2097
2283
  return null;
2098
2284
  }
2099
- // ── PIN ───────────────────────────────────────────────────────────────────
2100
- async setPIN(pin) {
2101
- if (!pin)
2102
- throw new Error("PIN must be a non-empty string");
2103
- this.pinHash = await MajikMessage._hashPIN(pin);
2104
- this.scheduleAutosave();
2105
- }
2106
- async clearPIN() {
2107
- this.pinHash = null;
2108
- this.scheduleAutosave();
2109
- }
2110
- async isValidPIN(pin) {
2111
- if (!this.pinHash)
2112
- return true;
2113
- return (await MajikMessage._hashPIN(pin)) === this.pinHash;
2114
- }
2115
- getPinHash() {
2116
- return this.pinHash ?? null;
2117
- }
2118
- static async _hashPIN(pin) {
2119
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(pin));
2120
- return arrayBufferToBase64(digest);
2121
- }
2122
2285
  // ── Serialization ─────────────────────────────────────────────────────────
2123
2286
  async toJSON() {
2124
2287
  const json = {
2125
2288
  id: this.id,
2126
- contacts: await this.contactDirectory.toJSON(),
2289
+ contacts: await this.contacts.toJSON(),
2127
2290
  envelopeCache: this.envelopeCache.toJSON(),
2128
2291
  };
2129
2292
  try {
@@ -2138,14 +2301,14 @@ export class MajikMessage {
2138
2301
  catch (e) {
2139
2302
  console.warn("Failed to serialize ownAccounts:", e);
2140
2303
  }
2141
- json.pinHash = this.pinHash ?? null;
2142
2304
  return json;
2143
2305
  }
2144
2306
  static async fromJSON(json) {
2145
- const directory = new MajikContactDirectory();
2146
- const contacts = await directory.fromJSON(json.contacts);
2307
+ // const migratedJSON = migrateMajikMessageJSON(json);
2308
+ // ── Step 2: restore MajikContactManager (directory + groups together)
2309
+ const contactManager = await MajikContactManager.fromJSON(json.contacts, KEY_ALGO);
2147
2310
  const envelopeCache = EnvelopeCache.fromJSON(json.envelopeCache);
2148
- const instance = new this({ contactDirectory: contacts, envelopeCache }, json.id);
2311
+ const instance = new this({ contactManager, envelopeCache }, json.id);
2149
2312
  try {
2150
2313
  if (json.ownAccounts && Array.isArray(json.ownAccounts.accounts)) {
2151
2314
  for (const acct of json.ownAccounts.accounts) {
@@ -2162,19 +2325,19 @@ export class MajikMessage {
2162
2325
  if (Array.isArray(json.ownAccounts.order)) {
2163
2326
  instance.ownAccountsOrder = [...json.ownAccounts.order];
2164
2327
  }
2165
- // Fallback: populate from contactDirectory if accounts array failed
2328
+ // Fallback: populate from contacts if accounts array failed
2166
2329
  if (instance.ownAccounts.size === 0) {
2167
2330
  for (const id of instance.ownAccountsOrder) {
2168
- const c = instance.contactDirectory.getContact(id);
2331
+ const c = instance.contacts.getContact(id);
2169
2332
  if (c)
2170
2333
  instance.ownAccounts.set(id, c);
2171
2334
  }
2172
2335
  }
2173
- // Ensure own accounts are in contactDirectory
2336
+ // Ensure own accounts are in contacts
2174
2337
  instance.ownAccountsOrder.forEach((id) => {
2175
2338
  const c = instance.ownAccounts.get(id);
2176
- if (c && !instance.contactDirectory.hasContact(c.id)) {
2177
- instance.contactDirectory.addContact(c);
2339
+ if (c && !instance.contacts.hasContact(c.id)) {
2340
+ instance.contacts.addContact(c);
2178
2341
  }
2179
2342
  });
2180
2343
  }
@@ -2182,9 +2345,6 @@ export class MajikMessage {
2182
2345
  catch (e) {
2183
2346
  console.warn("Error restoring ownAccounts:", e);
2184
2347
  }
2185
- const anyJson = json;
2186
- if (anyJson.pinHash)
2187
- instance.pinHash = anyJson.pinHash;
2188
2348
  return instance;
2189
2349
  }
2190
2350
  // ── Persistence ───────────────────────────────────────────────────────────
@@ -2235,9 +2395,10 @@ export class MajikMessage {
2235
2395
  if (!saved?.data)
2236
2396
  return;
2237
2397
  const loaded = await loadSavedMajikFileData(saved.data);
2398
+ // Pass raw parsed object — fromJSON handles migration internally
2238
2399
  const restored = await MajikMessage.fromJSON(loaded.j);
2239
2400
  this.id = restored.id;
2240
- this.contactDirectory = restored.contactDirectory;
2401
+ this.contacts = restored.contacts;
2241
2402
  this.envelopeCache = restored.envelopeCache;
2242
2403
  this.ownAccounts = restored.ownAccounts;
2243
2404
  this.ownAccountsOrder = [...restored.ownAccountsOrder];
@@ -2273,7 +2434,7 @@ export class MajikMessage {
2273
2434
  this.ownAccounts.clear();
2274
2435
  this.ownAccountsOrder = [];
2275
2436
  try {
2276
- this.contactDirectory.clear();
2437
+ this.contacts.clear();
2277
2438
  }
2278
2439
  catch {
2279
2440
  /* ignore */
@@ -2284,7 +2445,6 @@ export class MajikMessage {
2284
2445
  catch {
2285
2446
  /* ignore */
2286
2447
  }
2287
- this.pinHash = null;
2288
2448
  this.id = arrayToBase64(randomBytes(32));
2289
2449
  try {
2290
2450
  await clearAllBlobs(userProfile);