@majikah/majik-message 0.2.20 → 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,20 +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);
273
+ }
274
+ async hasContactByPublicKeyBase64(publicKey) {
275
+ if (!publicKey?.trim())
276
+ throw new Error("Invalid contact public key");
277
+ return await this.contacts.hasContactByPublicKeyBase64(publicKey);
307
278
  }
308
279
  async getContactByPublicKey(publicKeyBase64) {
309
280
  if (!publicKeyBase64?.trim())
310
281
  throw new Error("Invalid public key");
311
- return ((await this.contactDirectory.getContactByPublicKeyBase64(publicKeyBase64)) ?? null);
282
+ return ((await this.contacts.getContactByPublicKeyBase64(publicKeyBase64)) ?? null);
312
283
  }
313
- async exportContactAsJSON(contactId) {
314
- const contact = this.contactDirectory.getContact(contactId);
284
+ async exportContactAsJSON(contactID) {
285
+ const contact = this.contacts.getContact(contactID);
315
286
  if (!contact)
316
287
  return null;
317
288
  let publicKeyBase64;
@@ -333,8 +304,8 @@ export class MajikMessage {
333
304
  mlDsaPublicKeyBase64: contact.mlDsaPublicKeyBase64,
334
305
  }, null, 2);
335
306
  }
336
- async exportContactAsString(contactId) {
337
- const contact = this.contactDirectory.getContact(contactId);
307
+ async exportContactAsString(contactID) {
308
+ const contact = this.contacts.getContact(contactID);
338
309
  if (!contact)
339
310
  return null;
340
311
  const compressedString = this.exportContactCompressed(contact);
@@ -445,45 +416,276 @@ export class MajikMessage {
445
416
  !contact?.mlKey) {
446
417
  throw new Error("Invalid contact JSON");
447
418
  }
448
- this.contactDirectory.addContact(contact);
419
+ this.contacts.addContact(contact);
449
420
  this.emit("new-contact", contact);
450
421
  this.scheduleAutosave();
451
422
  }
452
423
  removeContact(id) {
453
- const result = this.contactDirectory.removeContact(id);
424
+ const result = this.contacts.removeContact(id);
454
425
  if (!result.success)
455
426
  throw new Error(result.message);
456
427
  this.emit("removed-contact", id);
457
428
  this.scheduleAutosave();
458
429
  }
459
430
  updateContactMeta(id, meta) {
460
- this.contactDirectory.updateContactMeta(id, meta);
431
+ this.contacts.updateContactMeta(id, meta);
461
432
  this.scheduleAutosave();
462
433
  }
463
434
  blockContact(id) {
464
- this.contactDirectory.blockContact(id);
435
+ this.contacts.blockContact(id);
465
436
  this.scheduleAutosave();
466
437
  }
467
438
  unblockContact(id) {
468
- this.contactDirectory.unblockContact(id);
439
+ this.contacts.unblockContact(id);
469
440
  this.scheduleAutosave();
470
441
  }
471
442
  listContacts(all = true, majikahOnly = false) {
472
- const contacts = this.contactDirectory.listContacts(true, majikahOnly);
443
+ const contacts = this.contacts.listContacts(true, majikahOnly);
473
444
  if (all)
474
445
  return contacts;
475
446
  const ownIds = new Set(this.listOwnAccounts(majikahOnly).map((a) => a.id));
476
447
  return contacts.filter((c) => !ownIds.has(c.id));
477
448
  }
478
449
  isContactMajikahRegistered(id) {
479
- return this.contactDirectory.isMajikahRegistered(id);
450
+ return this.contacts.isMajikahRegistered(id);
480
451
  }
481
452
  isContactMajikahIdentityChecked(id) {
482
- return this.contactDirectory.isMajikahIdentityChecked(id);
453
+ return this.contacts.isMajikahIdentityChecked(id);
483
454
  }
484
455
  setContactMajikahStatus(id, status) {
485
- 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);
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();
486
687
  this.scheduleAutosave();
688
+ return this;
487
689
  }
488
690
  // ── Encryption / Decryption ───────────────────────────────────────────────
489
691
  /**
@@ -759,7 +961,7 @@ export class MajikMessage {
759
961
  * metadata: row,
760
962
  * });
761
963
  * if (signature) {
762
- * const result = await majik.verifyMajikFile(file, { contactId: row.user_id });
964
+ * const result = await majik.verifyMajikFile(file, { contactID: row.user_id });
763
965
  * }
764
966
  * ```
765
967
  */
@@ -852,7 +1054,7 @@ export class MajikMessage {
852
1054
  * if the instance was restored from a metadata-only Supabase row.
853
1055
  *
854
1056
  * Signer resolution:
855
- * - contactId: looked up in the contact directory (own accounts included)
1057
+ * - contactID: looked up in the contact directory (own accounts included)
856
1058
  * - publicKeyBase64: looked up via contact directory
857
1059
  * - key: used directly (skips directory lookup)
858
1060
  * - none provided: falls back to public keys embedded in the signature
@@ -863,7 +1065,7 @@ export class MajikMessage {
863
1065
  * @example — verify against the file's owner contact
864
1066
  * file.attachBinary(await r2.get(row.r2_key).arrayBuffer());
865
1067
  * const result = await majik.verifyMajikFile(file, {
866
- * contactId: ownerContactId,
1068
+ * contactID: ownerContactId,
867
1069
  * });
868
1070
  * if (result?.valid) console.log("Verified, signed by", result.signerId);
869
1071
  */
@@ -903,7 +1105,7 @@ export class MajikMessage {
903
1105
  *
904
1106
  * @example
905
1107
  * const result = await majik.verifyMajikFileBinary(file, {
906
- * contactId: "contact_abc",
1108
+ * contactID: "contact_abc",
907
1109
  * });
908
1110
  * if (result.valid) console.log("Plaintext verified");
909
1111
  */
@@ -1113,7 +1315,7 @@ export class MajikMessage {
1113
1315
  *
1114
1316
  * @example
1115
1317
  * const result = await majik.verifyText("Hello world", sig, {
1116
- * contactId: "contact_abc",
1318
+ * contactID: "contact_abc",
1117
1319
  * });
1118
1320
  * if (result.valid) console.log("Authentic");
1119
1321
  */
@@ -1139,7 +1341,7 @@ export class MajikMessage {
1139
1341
  * @example
1140
1342
  * const row = await db.findOne({ doc_id });
1141
1343
  * const result = await majik.verifyDetached(docBytes, row.signature, {
1142
- * contactId: row.signer_contact_id,
1344
+ * contactID: row.signer_contact_id,
1143
1345
  * });
1144
1346
  * if (result.valid) console.log("Signed by", result.signerId);
1145
1347
  */
@@ -1273,16 +1475,6 @@ export class MajikMessage {
1273
1475
  return false;
1274
1476
  return MajikKeyStore.isPassphraseValid(target.id, passphrase);
1275
1477
  }
1276
- // ── DOM Scanning ──────────────────────────────────────────────────────────
1277
- scanDOM(rootNode) {
1278
- this.scanner.scanDOM(rootNode);
1279
- }
1280
- startDOMObserver(rootNode) {
1281
- this.scanner.startDOMObserver(rootNode);
1282
- }
1283
- stopDOMObserver() {
1284
- this.scanner.stopDOMObserver();
1285
- }
1286
1478
  // ── Events ────────────────────────────────────────────────────────────────
1287
1479
  on(event, callback) {
1288
1480
  this.listeners.get(event)?.push(callback);
@@ -1504,7 +1696,7 @@ export class MajikMessage {
1504
1696
  * > against a known contact fingerprint before trusting the result.
1505
1697
  *
1506
1698
  * @example — verify against a known contact
1507
- * const result = await majik.verifyContent(docBytes, sig, { contactId: "contact_abc" });
1699
+ * const result = await majik.verifyContent(docBytes, sig, { contactID: "contact_abc" });
1508
1700
  * if (result.valid) console.log("Authentic, signed by:", result.signerId);
1509
1701
  *
1510
1702
  * @example — verify using embedded keys (self-reported)
@@ -1540,12 +1732,12 @@ export class MajikMessage {
1540
1732
  * envelope are used (self-reported — see security note on verifyContent).
1541
1733
  *
1542
1734
  * @example — verify a signed PDF against a known contact
1543
- * const result = await majik.verifyFile(signedPdf, { contactId: "contact_abc" });
1735
+ * const result = await majik.verifyFile(signedPdf, { contactID: "contact_abc" });
1544
1736
  * if (result.valid) console.log("Verified:", result.signerId, result.timestamp);
1545
1737
  *
1546
1738
  * @example — check own signed file using active account
1547
1739
  * const result = await majik.verifyFile(signedWav, {
1548
- * contactId: majik.getActiveAccount()?.id,
1740
+ * contactID: majik.getActiveAccount()?.id,
1549
1741
  * });
1550
1742
  */
1551
1743
  async verifyFile(file, options) {
@@ -1559,7 +1751,7 @@ export class MajikMessage {
1559
1751
  return results[0];
1560
1752
  }
1561
1753
  // No signer provided — extract and use self-reported keys from first signature.
1562
- // For full multi-sig verification, pass a contactId or publicKeyBase64.
1754
+ // For full multi-sig verification, pass a contactID or publicKeyBase64.
1563
1755
  const extracted = await MajikSignature.extractFrom(file, {
1564
1756
  mimeType: options?.mimeType,
1565
1757
  });
@@ -1594,7 +1786,7 @@ export class MajikMessage {
1594
1786
  * @example
1595
1787
  * const results = await majik.batchVerifyFiles(
1596
1788
  * [pdfBlob, wavBlob, mp4Blob],
1597
- * { contactId: "contact_abc" },
1789
+ * { contactID: "contact_abc" },
1598
1790
  * );
1599
1791
  * const allValid = results.every(r => r.valid);
1600
1792
  */
@@ -1706,7 +1898,7 @@ export class MajikMessage {
1706
1898
  *
1707
1899
  * @example
1708
1900
  * if (await majik.isFileSigned(file)) {
1709
- * const result = await majik.verifyFile(file, { contactId });
1901
+ * const result = await majik.verifyFile(file, { contactID });
1710
1902
  * }
1711
1903
  */
1712
1904
  async isFileSigned(file, options) {
@@ -2050,22 +2242,22 @@ export class MajikMessage {
2050
2242
  return MajikSignature.publicKeysFromMajikKey(options.key);
2051
2243
  }
2052
2244
  // Option B: contact ID looked up from the contact directory
2053
- if (options.contactId) {
2054
- const contact = this.contactDirectory.getContact(options.contactId);
2245
+ if (options.contactID) {
2246
+ const contact = this.contacts.getContact(options.contactID);
2055
2247
  if (!contact) {
2056
- throw new Error(`No contact found for id "${options.contactId}"`);
2248
+ throw new Error(`No contact found for id "${options.contactID}"`);
2057
2249
  }
2058
2250
  // Own accounts are in the keystore — get their signing keys directly
2059
- const ownAccount = this.getOwnAccountById(options.contactId);
2251
+ const ownAccount = this.getOwnAccountById(options.contactID);
2060
2252
  if (ownAccount) {
2061
- const key = MajikKeyStore.get(options.contactId);
2253
+ const key = MajikKeyStore.get(options.contactID);
2062
2254
  if (key?.hasSigningKeys) {
2063
2255
  return MajikSignature.publicKeysFromMajikKey(key);
2064
2256
  }
2065
2257
  }
2066
2258
  // External contact — resolve from their contact card fields
2067
2259
  if (!contact.edPublicKeyBase64 || !contact.mlDsaPublicKeyBase64) {
2068
- throw new Error(`Contact "${options.contactId}" has no signing public keys. ` +
2260
+ throw new Error(`Contact "${options.contactID}" has no signing public keys. ` +
2069
2261
  `They may need to share an updated contact card.`);
2070
2262
  }
2071
2263
  return {
@@ -2076,7 +2268,7 @@ export class MajikMessage {
2076
2268
  }
2077
2269
  // Option C: raw base64 public key — look up via contact directory
2078
2270
  if (options.publicKeyBase64) {
2079
- const contact = await this.contactDirectory.getContactByPublicKeyBase64(options.publicKeyBase64);
2271
+ const contact = await this.contacts.getContactByPublicKeyBase64(options.publicKeyBase64);
2080
2272
  if (!contact) {
2081
2273
  throw new Error(`No contact found for public key "${options.publicKeyBase64}"`);
2082
2274
  }
@@ -2091,34 +2283,11 @@ export class MajikMessage {
2091
2283
  }
2092
2284
  return null;
2093
2285
  }
2094
- // ── PIN ───────────────────────────────────────────────────────────────────
2095
- async setPIN(pin) {
2096
- if (!pin)
2097
- throw new Error("PIN must be a non-empty string");
2098
- this.pinHash = await MajikMessage._hashPIN(pin);
2099
- this.scheduleAutosave();
2100
- }
2101
- async clearPIN() {
2102
- this.pinHash = null;
2103
- this.scheduleAutosave();
2104
- }
2105
- async isValidPIN(pin) {
2106
- if (!this.pinHash)
2107
- return true;
2108
- return (await MajikMessage._hashPIN(pin)) === this.pinHash;
2109
- }
2110
- getPinHash() {
2111
- return this.pinHash ?? null;
2112
- }
2113
- static async _hashPIN(pin) {
2114
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(pin));
2115
- return arrayBufferToBase64(digest);
2116
- }
2117
2286
  // ── Serialization ─────────────────────────────────────────────────────────
2118
2287
  async toJSON() {
2119
2288
  const json = {
2120
2289
  id: this.id,
2121
- contacts: await this.contactDirectory.toJSON(),
2290
+ contacts: await this.contacts.toJSON(),
2122
2291
  envelopeCache: this.envelopeCache.toJSON(),
2123
2292
  };
2124
2293
  try {
@@ -2133,14 +2302,14 @@ export class MajikMessage {
2133
2302
  catch (e) {
2134
2303
  console.warn("Failed to serialize ownAccounts:", e);
2135
2304
  }
2136
- json.pinHash = this.pinHash ?? null;
2137
2305
  return json;
2138
2306
  }
2139
2307
  static async fromJSON(json) {
2140
- const directory = new MajikContactDirectory();
2141
- const contacts = await directory.fromJSON(json.contacts);
2142
- const envelopeCache = EnvelopeCache.fromJSON(json.envelopeCache);
2143
- 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);
2144
2313
  try {
2145
2314
  if (json.ownAccounts && Array.isArray(json.ownAccounts.accounts)) {
2146
2315
  for (const acct of json.ownAccounts.accounts) {
@@ -2157,19 +2326,19 @@ export class MajikMessage {
2157
2326
  if (Array.isArray(json.ownAccounts.order)) {
2158
2327
  instance.ownAccountsOrder = [...json.ownAccounts.order];
2159
2328
  }
2160
- // Fallback: populate from contactDirectory if accounts array failed
2329
+ // Fallback: populate from contacts if accounts array failed
2161
2330
  if (instance.ownAccounts.size === 0) {
2162
2331
  for (const id of instance.ownAccountsOrder) {
2163
- const c = instance.contactDirectory.getContact(id);
2332
+ const c = instance.contacts.getContact(id);
2164
2333
  if (c)
2165
2334
  instance.ownAccounts.set(id, c);
2166
2335
  }
2167
2336
  }
2168
- // Ensure own accounts are in contactDirectory
2337
+ // Ensure own accounts are in contacts
2169
2338
  instance.ownAccountsOrder.forEach((id) => {
2170
2339
  const c = instance.ownAccounts.get(id);
2171
- if (c && !instance.contactDirectory.hasContact(c.id)) {
2172
- instance.contactDirectory.addContact(c);
2340
+ if (c && !instance.contacts.hasContact(c.id)) {
2341
+ instance.contacts.addContact(c);
2173
2342
  }
2174
2343
  });
2175
2344
  }
@@ -2177,9 +2346,6 @@ export class MajikMessage {
2177
2346
  catch (e) {
2178
2347
  console.warn("Error restoring ownAccounts:", e);
2179
2348
  }
2180
- const anyJson = json;
2181
- if (anyJson.pinHash)
2182
- instance.pinHash = anyJson.pinHash;
2183
2349
  return instance;
2184
2350
  }
2185
2351
  // ── Persistence ───────────────────────────────────────────────────────────
@@ -2232,7 +2398,7 @@ export class MajikMessage {
2232
2398
  const loaded = await loadSavedMajikFileData(saved.data);
2233
2399
  const restored = await MajikMessage.fromJSON(loaded.j);
2234
2400
  this.id = restored.id;
2235
- this.contactDirectory = restored.contactDirectory;
2401
+ this.contacts = restored.contacts;
2236
2402
  this.envelopeCache = restored.envelopeCache;
2237
2403
  this.ownAccounts = restored.ownAccounts;
2238
2404
  this.ownAccountsOrder = [...restored.ownAccountsOrder];
@@ -2268,7 +2434,7 @@ export class MajikMessage {
2268
2434
  this.ownAccounts.clear();
2269
2435
  this.ownAccountsOrder = [];
2270
2436
  try {
2271
- this.contactDirectory.clear();
2437
+ this.contacts.clear();
2272
2438
  }
2273
2439
  catch {
2274
2440
  /* ignore */
@@ -2279,7 +2445,6 @@ export class MajikMessage {
2279
2445
  catch {
2280
2446
  /* ignore */
2281
2447
  }
2282
- this.pinHash = null;
2283
2448
  this.id = arrayToBase64(randomBytes(32));
2284
2449
  try {
2285
2450
  await clearAllBlobs(userProfile);