@majikah/majik-message 0.2.21 → 0.3.0

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