@majikah/majik-message 0.1.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.
Files changed (51) hide show
  1. package/LICENSE +67 -0
  2. package/README.md +559 -0
  3. package/dist/core/compressor/majik-compressor.d.ts +18 -0
  4. package/dist/core/compressor/majik-compressor.js +100 -0
  5. package/dist/core/contacts/majik-contact-directory.d.ts +42 -0
  6. package/dist/core/contacts/majik-contact-directory.js +203 -0
  7. package/dist/core/contacts/majik-contact.d.ts +72 -0
  8. package/dist/core/contacts/majik-contact.js +192 -0
  9. package/dist/core/crypto/constants.d.ts +8 -0
  10. package/dist/core/crypto/constants.js +7 -0
  11. package/dist/core/crypto/crypto-provider.d.ts +21 -0
  12. package/dist/core/crypto/crypto-provider.js +73 -0
  13. package/dist/core/crypto/encryption-engine.d.ts +59 -0
  14. package/dist/core/crypto/encryption-engine.js +257 -0
  15. package/dist/core/crypto/keystore.d.ts +128 -0
  16. package/dist/core/crypto/keystore.js +596 -0
  17. package/dist/core/database/chat/majik-message-chat.d.ts +117 -0
  18. package/dist/core/database/chat/majik-message-chat.js +513 -0
  19. package/dist/core/database/chat/types.d.ts +14 -0
  20. package/dist/core/database/chat/types.js +1 -0
  21. package/dist/core/database/system/identity.d.ts +61 -0
  22. package/dist/core/database/system/identity.js +171 -0
  23. package/dist/core/database/system/utils.d.ts +1 -0
  24. package/dist/core/database/system/utils.js +8 -0
  25. package/dist/core/database/thread/enums.d.ts +7 -0
  26. package/dist/core/database/thread/enums.js +6 -0
  27. package/dist/core/database/thread/mail/majik-message-mail.d.ts +177 -0
  28. package/dist/core/database/thread/mail/majik-message-mail.js +704 -0
  29. package/dist/core/database/thread/majik-message-thread.d.ts +166 -0
  30. package/dist/core/database/thread/majik-message-thread.js +637 -0
  31. package/dist/core/messages/envelope-cache.d.ts +52 -0
  32. package/dist/core/messages/envelope-cache.js +377 -0
  33. package/dist/core/messages/message-envelope.d.ts +36 -0
  34. package/dist/core/messages/message-envelope.js +161 -0
  35. package/dist/core/scanner/scanner-engine.d.ts +27 -0
  36. package/dist/core/scanner/scanner-engine.js +120 -0
  37. package/dist/core/types.d.ts +28 -0
  38. package/dist/core/types.js +1 -0
  39. package/dist/core/utils/APITranscoder.d.ts +114 -0
  40. package/dist/core/utils/APITranscoder.js +305 -0
  41. package/dist/core/utils/idb-majik-system.d.ts +15 -0
  42. package/dist/core/utils/idb-majik-system.js +44 -0
  43. package/dist/core/utils/majik-file-utils.d.ts +16 -0
  44. package/dist/core/utils/majik-file-utils.js +153 -0
  45. package/dist/core/utils/utilities.d.ts +29 -0
  46. package/dist/core/utils/utilities.js +94 -0
  47. package/dist/index.d.ts +20 -0
  48. package/dist/index.js +18 -0
  49. package/dist/majik-message.d.ts +247 -0
  50. package/dist/majik-message.js +1221 -0
  51. package/package.json +104 -0
@@ -0,0 +1,1221 @@
1
+ // MajikMessage.ts
2
+ import { MajikContact, } from "./core/contacts/majik-contact";
3
+ import { KEY_ALGO } from "./core/crypto/constants";
4
+ import { ScannerEngine } from "./core/scanner/scanner-engine";
5
+ import { MessageEnvelope } from "./core/messages/message-envelope";
6
+ import { EnvelopeCache, } from "./core/messages/envelope-cache";
7
+ import { EncryptionEngine } from "./core/crypto/encryption-engine";
8
+ import { KeyStore } from "./core/crypto/keystore";
9
+ import { MajikContactDirectory, } from "./core/contacts/majik-contact-directory";
10
+ import { arrayBufferToBase64, arrayToBase64, base64ToArrayBuffer, base64ToUtf8, seedStringToArray, utf8ToBase64, } from "./core/utils/utilities";
11
+ import { autoSaveMajikFileData, loadSavedMajikFileData, } from "./core/utils/majik-file-utils";
12
+ import { randomBytes } from "@stablelib/random";
13
+ import { clearAllBlobs, idbLoadBlob, idbSaveBlob, } from "./core/utils/idb-majik-system";
14
+ import { MajikMessageChat } from "./core/database/chat/majik-message-chat";
15
+ import { MajikCompressor } from "./core/compressor/majik-compressor";
16
+ import { MajikKey } from "@thezelijah/majik-key";
17
+ export class MajikMessage {
18
+ userProfile = "default";
19
+ // Optional PIN protection (hashed). If set, UI should prompt for PIN to unlock.
20
+ pinHash = null;
21
+ id;
22
+ contactDirectory;
23
+ envelopeCache;
24
+ scanner;
25
+ listeners = new Map();
26
+ ownAccounts = new Map();
27
+ ownAccountsOrder = []; // keeps the order of IDs, first is active
28
+ autosaveTimer = null;
29
+ autosaveIntervalMs = 15000; // periodic backup interval
30
+ autosaveDebounceMs = 500; // debounce for rapid changes
31
+ unlocked = false;
32
+ constructor(config, id, userProfile = "default") {
33
+ this.userProfile = userProfile || "default";
34
+ this.id = id || arrayToBase64(randomBytes(32));
35
+ this.contactDirectory =
36
+ config.contactDirectory || new MajikContactDirectory();
37
+ this.envelopeCache =
38
+ config.envelopeCache || new EnvelopeCache(undefined, userProfile);
39
+ // Initialize scanner
40
+ this.scanner = new ScannerEngine({
41
+ contactDirectory: this.contactDirectory,
42
+ onEnvelopeFound: (env) => this.handleEnvelope(env),
43
+ onUntrusted: (raw) => this.emit("untrusted", raw),
44
+ onError: (err, ctx) => this.emit("error", err, ctx),
45
+ });
46
+ // Prepare listeners map
47
+ [
48
+ "message",
49
+ "envelope",
50
+ "untrusted",
51
+ "error",
52
+ "new-account",
53
+ "new-contact",
54
+ "removed-account",
55
+ "removed-contact",
56
+ "active-account-change",
57
+ ].forEach((e) => this.listeners.set(e, []));
58
+ // Attach autosave handlers so state is persisted automatically
59
+ this.attachAutosaveHandlers();
60
+ }
61
+ /* ================================
62
+ * Account Management
63
+ * ================================ */
64
+ /**
65
+ * Create a new account (generates identity via KeyStore) and add it as an own account.
66
+ * Returns the created identity id and a backup blob (base64) that the user should store.
67
+ */
68
+ async createAccount(passphrase, label) {
69
+ const identity = await KeyStore.createIdentity(passphrase);
70
+ // Import public key into a MajikContact
71
+ const contact = new MajikContact({
72
+ id: identity.id,
73
+ publicKey: identity.publicKey,
74
+ fingerprint: identity.fingerprint,
75
+ meta: { label: label || "" },
76
+ });
77
+ this.addOwnAccount(contact);
78
+ const backup = await KeyStore.exportIdentityBackup(identity.id);
79
+ return { id: identity.id, fingerprint: identity.fingerprint, backup };
80
+ }
81
+ /**
82
+ * Import an account from a backup blob (created with `exportIdentityBackup`) and unlock it.
83
+ */
84
+ async importAccountFromBackup(backupBase64, passphrase, label) {
85
+ await KeyStore.importIdentityBackup(backupBase64);
86
+ // Unlock the imported identity
87
+ const decoded = JSON.parse(base64ToUtf8(backupBase64));
88
+ const id = decoded.id;
89
+ const identity = await KeyStore.unlockIdentity(id, passphrase);
90
+ const contact = new MajikContact({
91
+ id: identity.id,
92
+ publicKey: identity.publicKey,
93
+ fingerprint: identity.fingerprint,
94
+ meta: { label: label || "" },
95
+ });
96
+ if (!!this.getOwnAccountById(identity.id)) {
97
+ throw new Error("Account with the same ID already exists");
98
+ }
99
+ this.addOwnAccount(contact);
100
+ return { id: identity.id, fingerprint: identity.fingerprint };
101
+ }
102
+ /**
103
+ * Generate a BIP39 mnemonic for backup (12 words by default).
104
+ */
105
+ generateMnemonic() {
106
+ return KeyStore.generateMnemonic();
107
+ }
108
+ /**
109
+ * Export a mnemonic-encrypted backup for an unlocked identity.
110
+ */
111
+ async exportAccountMnemonicBackup(id, mnemonic) {
112
+ return KeyStore.exportIdentityMnemonicBackup(id, mnemonic);
113
+ }
114
+ /**
115
+ * Import an account from a mnemonic-encrypted backup blob and store it using `passphrase`.
116
+ */
117
+ async importAccountFromMnemonicBackup(backupBase64, mnemonic, passphrase, label) {
118
+ const mJSON = {
119
+ id: backupBase64,
120
+ seed: seedStringToArray(mnemonic),
121
+ phrase: passphrase,
122
+ };
123
+ const importedIdentity = await MajikKey.fromMnemonicJSON(mJSON, passphrase, label);
124
+ await KeyStore.addMajikKey(importedIdentity);
125
+ const accountContact = await importedIdentity.toContact().toJSON();
126
+ const contact = MajikContact.fromJSON(accountContact);
127
+ if (!!this.getOwnAccountById(importedIdentity.id)) {
128
+ throw new Error("Account with the same ID already exists");
129
+ }
130
+ this.addOwnAccount(contact);
131
+ return {
132
+ id: importedIdentity.id,
133
+ fingerprint: importedIdentity.fingerprint,
134
+ };
135
+ }
136
+ /**
137
+ * Create a new account deterministically from `mnemonic` and store it encrypted with `passphrase`.
138
+ * Returns the created identity id (which equals fingerprint) and fingerprint.
139
+ */
140
+ async createAccountFromMnemonic(mnemonic, passphrase, label) {
141
+ const newAccount = await MajikKey.create(mnemonic, passphrase, label);
142
+ await KeyStore.addMajikKey(newAccount);
143
+ const accountContact = await newAccount.toContact().toJSON();
144
+ const contact = MajikContact.fromJSON(accountContact);
145
+ this.addOwnAccount(contact);
146
+ return {
147
+ id: newAccount.id,
148
+ fingerprint: newAccount.fingerprint,
149
+ backup: newAccount.backup,
150
+ };
151
+ }
152
+ addOwnAccount(account) {
153
+ if (!this.ownAccounts.has(account.id)) {
154
+ this.ownAccounts.set(account.id, account);
155
+ this.ownAccountsOrder.push(account.id);
156
+ }
157
+ try {
158
+ if (!this.contactDirectory.hasContact(account.id)) {
159
+ this.contactDirectory.addContact(account);
160
+ }
161
+ if (!this.getActiveAccount()) {
162
+ this.setActiveAccount(account.id);
163
+ this.unlocked = true;
164
+ }
165
+ this.emit("new-account", account);
166
+ }
167
+ catch (e) {
168
+ // ignore if contact can't be added
169
+ }
170
+ this.scheduleAutosave();
171
+ }
172
+ listOwnAccounts(majikahOnly = false) {
173
+ let userAccounts = this.ownAccountsOrder
174
+ .map((id) => this.ownAccounts.get(id))
175
+ .filter((c) => !!c);
176
+ if (majikahOnly) {
177
+ userAccounts = userAccounts.filter((acct) => this.isContactMajikahRegistered(acct.id));
178
+ }
179
+ return userAccounts;
180
+ }
181
+ getOwnAccountById(id) {
182
+ return this.ownAccounts.get(id);
183
+ }
184
+ /**
185
+ * Set an active account (moves it to index 0)
186
+ */
187
+ async setActiveAccount(id, bypassIdentity = false) {
188
+ if (!this.ownAccounts.has(id))
189
+ return false;
190
+ if (!bypassIdentity) {
191
+ // Ensure identity is unlocked
192
+ try {
193
+ await this.ensureIdentityUnlocked(id);
194
+ }
195
+ catch (err) {
196
+ console.warn("Failed to unlock account:", err);
197
+ return false; // don't set as active if unlock fails
198
+ }
199
+ }
200
+ const previousActive = this.getActiveAccount()?.id;
201
+ // Remove ID from current position
202
+ const index = this.ownAccountsOrder.indexOf(id);
203
+ if (index > -1)
204
+ this.ownAccountsOrder.splice(index, 1);
205
+ // Add to the front
206
+ this.ownAccountsOrder.unshift(id);
207
+ this.scheduleAutosave();
208
+ // 🔔 Emit the active account changed event
209
+ if (previousActive !== id) {
210
+ const newActive = this.getActiveAccount();
211
+ this.emit("active-account-change", newActive, previousActive);
212
+ }
213
+ return true;
214
+ }
215
+ getActiveAccount() {
216
+ if (this.ownAccountsOrder.length === 0)
217
+ return null;
218
+ return this.ownAccounts.get(this.ownAccountsOrder[0]) || null;
219
+ }
220
+ isAccountActive(id) {
221
+ if (!this.ownAccounts.has(id))
222
+ return false;
223
+ if (this.ownAccountsOrder.length === 0)
224
+ return false;
225
+ return this.ownAccountsOrder[0] === id;
226
+ }
227
+ /**
228
+ * Remove an own account from the in-memory registry.
229
+ */
230
+ removeOwnAccount(id) {
231
+ if (!this.ownAccounts.has(id))
232
+ return false;
233
+ this.ownAccounts.delete(id);
234
+ const idx = this.ownAccountsOrder.indexOf(id);
235
+ if (idx > -1)
236
+ this.ownAccountsOrder.splice(idx, 1);
237
+ this.removeContact(id);
238
+ // remove cached envelopes addressed to this identity
239
+ this.envelopeCache.deleteByFingerprint(id).catch((error) => {
240
+ console.warn("Account not found in cache: ", error);
241
+ });
242
+ this.emit("removed-account", id);
243
+ this.scheduleAutosave();
244
+ return true;
245
+ }
246
+ /**
247
+ * Retrieve a contact from the directory by ID.
248
+ * Validates that the input is a non-empty string.
249
+ * Returns the MajikContact instance or null if not found.
250
+ */
251
+ getContactByID(id) {
252
+ if (typeof id !== "string" || !id.trim()) {
253
+ throw new Error("Invalid contact ID: must be a non-empty string");
254
+ }
255
+ if (!this.contactDirectory.hasContact(id)) {
256
+ return null; // Not found
257
+ }
258
+ return this.contactDirectory.getContact(id) ?? null;
259
+ }
260
+ /**
261
+ * Retrieve a contact from the directory by its public key.
262
+ * Validates that the input is a non-empty string.
263
+ * Returns the MajikContact instance or null if not found.
264
+ */
265
+ async getContactByPublicKey(id) {
266
+ if (typeof id !== "string" || !id.trim()) {
267
+ throw new Error("Invalid contact ID: must be a non-empty string");
268
+ }
269
+ return ((await this.contactDirectory.getContactByPublicKeyBase64(id)) ?? null);
270
+ }
271
+ /**
272
+ * Returns a JSON string representation of a contact
273
+ * suitable for sharing.
274
+ */
275
+ async exportContactAsJSON(contactId) {
276
+ const contact = this.contactDirectory.getContact(contactId);
277
+ if (!contact)
278
+ return null;
279
+ // Support raw-key wrappers produced by the Stablelib provider
280
+ let publicKeyBase64;
281
+ const anyPub = contact.publicKey;
282
+ if (anyPub && anyPub.raw instanceof Uint8Array) {
283
+ publicKeyBase64 = arrayBufferToBase64(anyPub.raw.buffer);
284
+ }
285
+ else {
286
+ const raw = await crypto.subtle.exportKey("raw", contact.publicKey);
287
+ publicKeyBase64 = arrayBufferToBase64(raw);
288
+ }
289
+ const payload = {
290
+ id: contact.id,
291
+ label: contact.meta?.label || "",
292
+ publicKey: publicKeyBase64,
293
+ fingerprint: contact.fingerprint,
294
+ };
295
+ return JSON.stringify(payload, null, 2); // pretty-print for easier copy-paste
296
+ }
297
+ /**
298
+ * Returns a compact base64 string for sharing a contact.
299
+ * Encodes JSON payload into base64.
300
+ */
301
+ async exportContactAsString(contactId) {
302
+ const json = await this.exportContactAsJSON(contactId);
303
+ if (!json)
304
+ return null;
305
+ return utf8ToBase64(json);
306
+ }
307
+ /* ================================
308
+ * Contact Management
309
+ * ================================ */
310
+ async importContactFromJSON(jsonStr) {
311
+ try {
312
+ const data = JSON.parse(jsonStr);
313
+ if (!data.id || !data.publicKey || !data.fingerprint)
314
+ return {
315
+ success: false,
316
+ message: "Invalid contact JSON",
317
+ };
318
+ // If publicKey is a base64 string, import it
319
+ let publicKeyPromise;
320
+ if (typeof data.publicKey === "string") {
321
+ try {
322
+ const rawBuffer = base64ToArrayBuffer(data.publicKey);
323
+ try {
324
+ publicKeyPromise = await crypto.subtle.importKey("raw", rawBuffer, KEY_ALGO, true, []);
325
+ }
326
+ catch (e) {
327
+ // Fallback: create a raw-key wrapper when the browser does not support the namedCurve
328
+ publicKeyPromise = { raw: new Uint8Array(rawBuffer) };
329
+ }
330
+ }
331
+ catch (e) {
332
+ console.error("Failed to parse publicKey base64", e);
333
+ return {
334
+ success: false,
335
+ message: "Failed to parse publicKey base64",
336
+ };
337
+ }
338
+ }
339
+ else {
340
+ // assume already a CryptoKey
341
+ publicKeyPromise = await Promise.resolve(data.publicKey);
342
+ }
343
+ const contact = new MajikContact({
344
+ id: data.id,
345
+ publicKey: publicKeyPromise,
346
+ fingerprint: data.fingerprint,
347
+ meta: { label: data.label },
348
+ });
349
+ this.addContact(contact);
350
+ return {
351
+ success: true,
352
+ message: "Contact imported successfully",
353
+ };
354
+ }
355
+ catch (err) {
356
+ console.error("Failed to import contact from JSON:", err);
357
+ return {
358
+ success: false,
359
+ message: err instanceof Error ? err.message : "Unknown error",
360
+ };
361
+ }
362
+ }
363
+ /**
364
+ * List cached envelopes stored in the local cache (most recent first).
365
+ * Returns objects: { id, envelope, timestamp, source }
366
+ */
367
+ async listCachedEnvelopes(offset = 0, limit = 50) {
368
+ return await this.envelopeCache.listRecent(offset, limit);
369
+ }
370
+ /**
371
+ * Clear cached envelopes stored in the local cache.
372
+ */
373
+ async clearCachedEnvelopes() {
374
+ const response = await this.envelopeCache.clear();
375
+ if (!response?.success) {
376
+ throw new Error(response.message);
377
+ }
378
+ this.scheduleAutosave();
379
+ return response.success;
380
+ }
381
+ async hasOwnIdentity(fingerprint) {
382
+ return await KeyStore.hasIdentity(fingerprint);
383
+ }
384
+ /**
385
+ * Attempt to decrypt a given envelope and return the plaintext string.
386
+ * Will prompt to unlock identity if necessary.
387
+ */
388
+ async decryptEnvelope(envelope, bypassIdentity = false) {
389
+ if (envelope.isGroup()) {
390
+ // Group message - try all own accounts
391
+ const ownAccounts = this.listOwnAccounts();
392
+ if (ownAccounts.length === 0) {
393
+ throw new Error("No own accounts available to decrypt group message");
394
+ }
395
+ for (const ownAccount of ownAccounts) {
396
+ try {
397
+ const privateKey = await this.ensureIdentityUnlocked(ownAccount.id);
398
+ const decrypted = await EncryptionEngine.decryptGroupMessage(envelope.extractEncryptedPayload(), privateKey, ownAccount.fingerprint);
399
+ // Decompress if needed
400
+ let plaintext = decrypted;
401
+ if (decrypted.startsWith("mjkcmp:")) {
402
+ plaintext = (await MajikCompressor.decompress("plaintext", decrypted));
403
+ }
404
+ await this.envelopeCache.set(envelope, typeof window !== "undefined" && window.location
405
+ ? window.location.hostname
406
+ : "extension");
407
+ return plaintext;
408
+ }
409
+ catch (err) {
410
+ // This account can't decrypt, try next
411
+ continue;
412
+ }
413
+ }
414
+ throw new Error("None of your accounts can decrypt this group message");
415
+ }
416
+ else {
417
+ // Solo message - original logic
418
+ const fingerprint = envelope.extractFingerprint();
419
+ const ownAccount = this.listOwnAccounts().find((a) => a.fingerprint === fingerprint);
420
+ if (!ownAccount) {
421
+ throw new Error("No matching account to decrypt this envelope");
422
+ }
423
+ const privateKey = await this.ensureIdentityUnlocked(ownAccount.id);
424
+ const decrypted = await EncryptionEngine.decryptSoloMessage(envelope.extractEncryptedPayload(), privateKey);
425
+ let plaintext = decrypted;
426
+ if (decrypted.startsWith("mjkcmp:")) {
427
+ plaintext = (await MajikCompressor.decompress("plaintext", decrypted));
428
+ }
429
+ await this.envelopeCache.set(envelope, typeof window !== "undefined" && window.location
430
+ ? window.location.hostname
431
+ : "extension");
432
+ return plaintext;
433
+ }
434
+ }
435
+ async importContactFromString(base64Str) {
436
+ const jsonStr = base64ToUtf8(base64Str);
437
+ const isImportSuccess = await this.importContactFromJSON(jsonStr);
438
+ if (!isImportSuccess.success)
439
+ throw new Error(isImportSuccess.message);
440
+ }
441
+ addContact(contact) {
442
+ this.contactDirectory.addContact(contact);
443
+ this.emit("new-contact", contact);
444
+ this.scheduleAutosave();
445
+ }
446
+ removeContact(id) {
447
+ const removalStatus = this.contactDirectory.removeContact(id);
448
+ if (!removalStatus.success) {
449
+ throw new Error(removalStatus.message);
450
+ }
451
+ this.emit("removed-contact", id);
452
+ this.scheduleAutosave();
453
+ }
454
+ updateContactMeta(id, meta) {
455
+ this.contactDirectory.updateContactMeta(id, meta);
456
+ this.scheduleAutosave();
457
+ }
458
+ blockContact(id) {
459
+ this.contactDirectory.blockContact(id);
460
+ this.scheduleAutosave();
461
+ }
462
+ unblockContact(id) {
463
+ this.contactDirectory.unblockContact(id);
464
+ this.scheduleAutosave();
465
+ }
466
+ listContacts(all = true, majikahOnly = false) {
467
+ const contacts = this.contactDirectory.listContacts(true, majikahOnly);
468
+ if (all) {
469
+ return contacts;
470
+ }
471
+ const userAccounts = this.listOwnAccounts(majikahOnly);
472
+ const userAccountIds = new Set(userAccounts.map((a) => a.id));
473
+ return contacts.filter((contact) => !userAccountIds.has(contact.id));
474
+ }
475
+ isContactMajikahRegistered(id) {
476
+ return this.contactDirectory.isMajikahRegistered(id);
477
+ }
478
+ isContactMajikahIdentityChecked(id) {
479
+ return this.contactDirectory.isMajikahIdentityChecked(id);
480
+ }
481
+ setContactMajikahStatus(id, status) {
482
+ this.contactDirectory.setMajikahStatus(id, status);
483
+ this.scheduleAutosave();
484
+ }
485
+ /**
486
+ * Update the passphrase for an identity.
487
+ * - `id` defaults to the current active account if not provided.
488
+ * - Throws if no active account exists or passphrase is invalid.
489
+ */
490
+ async updatePassphrase(currentPassphrase, newPassphrase, id) {
491
+ // Determine target account
492
+ const targetAccount = id
493
+ ? this.getOwnAccountById(id)
494
+ : this.getActiveAccount();
495
+ if (!targetAccount) {
496
+ throw new Error("No target account specified and no active account available");
497
+ }
498
+ // Delegate to KeyStore
499
+ await KeyStore.updatePassphrase(targetAccount.id, currentPassphrase, newPassphrase);
500
+ // Optionally emit an event or autosave
501
+ this.scheduleAutosave();
502
+ }
503
+ /* ================================
504
+ * Encryption / Decryption
505
+ * ================================ */
506
+ /**
507
+ * Encrypts a plaintext message for a single recipient (solo message)
508
+ * Returns a MessageEnvelope instance and caches it automatically.
509
+ */
510
+ async encryptSoloMessage(toId, plaintext, cache = true) {
511
+ const contact = this.contactDirectory.getContact(toId);
512
+ if (!contact)
513
+ throw new Error(`No contact with id "${toId}"`);
514
+ const payload = await EncryptionEngine.encryptSoloMessage(plaintext, contact.publicKey);
515
+ const payloadJSON = JSON.stringify(payload);
516
+ const encoder = new TextEncoder();
517
+ const payloadBytes = encoder.encode(payloadJSON);
518
+ // Envelope: [version byte][fingerprint][payload]
519
+ const versionByte = new Uint8Array([1]);
520
+ const fingerprintBytes = new Uint8Array(base64ToArrayBuffer(contact.fingerprint));
521
+ const blob = new Uint8Array(versionByte.length + fingerprintBytes.length + payloadBytes.length);
522
+ blob.set(versionByte, 0);
523
+ blob.set(fingerprintBytes, versionByte.length);
524
+ blob.set(payloadBytes, versionByte.length + fingerprintBytes.length);
525
+ const envelope = new MessageEnvelope(blob.buffer);
526
+ if (!!cache) {
527
+ // Cache envelope
528
+ await this.envelopeCache.set(envelope, typeof window !== "undefined" && window.location
529
+ ? window.location.hostname
530
+ : "extension");
531
+ }
532
+ this.scheduleAutosave();
533
+ this.emit("envelope", envelope);
534
+ return envelope;
535
+ }
536
+ /**
537
+ * Encrypts a plaintext message for a group of recipients.
538
+ * Returns a unified group MessageEnvelope instance.
539
+ */
540
+ async encryptGroupMessage(recipientIds, plaintext, cache = true) {
541
+ if (!recipientIds.length) {
542
+ throw new Error("No recipients provided");
543
+ }
544
+ // Resolve recipients and their keys
545
+ const recipients = recipientIds.map((id) => {
546
+ const contact = this.contactDirectory.getContact(id);
547
+ if (!contact)
548
+ throw new Error(`No contact with id "${id}"`);
549
+ return {
550
+ id: contact.id,
551
+ publicKey: contact.publicKey,
552
+ fingerprint: contact.fingerprint,
553
+ };
554
+ });
555
+ // 🔐 Encrypt once for all recipients
556
+ const payload = await EncryptionEngine.encryptGroupMessage(plaintext, recipients);
557
+ // Serialize payload
558
+ const payloadBytes = new TextEncoder().encode(JSON.stringify(payload));
559
+ // Envelope structure: [version byte][sender fingerprint][payload bytes]
560
+ const versionByte = new Uint8Array([2]);
561
+ // // Use sender fingerprint for group envelope
562
+ // const activeAccount = this.getActiveAccount();
563
+ // if (!activeAccount) throw new Error("No active account to send from");
564
+ // const fingerprintBytes = new Uint8Array(
565
+ // base64ToArrayBuffer(activeAccount.fingerprint),
566
+ // );
567
+ // ✅ Use a special marker instead of a specific fingerprint
568
+ // Option 1: All zeros to indicate "multi-recipient"
569
+ const markerBytes = new Uint8Array(32).fill(0);
570
+ // Combine all parts into a single Uint8Array
571
+ const blob = new Uint8Array(versionByte.length + markerBytes.length + payloadBytes.length);
572
+ blob.set(versionByte, 0);
573
+ blob.set(markerBytes, versionByte.length);
574
+ blob.set(payloadBytes, versionByte.length + markerBytes.length);
575
+ // Wrap as MessageEnvelope
576
+ const envelope = new MessageEnvelope(blob.buffer);
577
+ if (!!cache) {
578
+ // Cache envelope
579
+ await this.envelopeCache.set(envelope, typeof window !== "undefined" && window.location
580
+ ? window.location.hostname
581
+ : "extension");
582
+ }
583
+ this.scheduleAutosave();
584
+ this.emit("envelope", envelope);
585
+ return envelope;
586
+ }
587
+ async sendMessage(recipients, plaintext) {
588
+ if (recipients.length === 0 || !recipients) {
589
+ throw new Error("No recipients provided. At least one recipient is required.");
590
+ }
591
+ if (recipients.length === 1) {
592
+ return await this.encryptSoloMessage(recipients[0], plaintext);
593
+ }
594
+ else {
595
+ return await this.encryptGroupMessage(recipients, plaintext);
596
+ }
597
+ }
598
+ /* ================================
599
+ * High-Level DOM Wrapper
600
+ * ================================ */
601
+ /**
602
+ * Create a new MajikMessageChat with compression, then encrypt it.
603
+ * Returns the scanner-ready string containing the encrypted compressed message.
604
+ *
605
+ * Flow: Plaintext → Compress (MajikMessageChat) → Encrypt (EncryptionEngine) → Scanner String
606
+ */
607
+ async createEncryptedMajikMessageChat(account, recipients, plaintext, expiresInMs) {
608
+ if (!plaintext?.trim()) {
609
+ throw new Error("No text provided to encrypt.");
610
+ }
611
+ if (!recipients || recipients.length === 0) {
612
+ const firstOwn = this.listOwnAccounts()[0];
613
+ if (!firstOwn) {
614
+ throw new Error("No own account available for encryption.");
615
+ }
616
+ recipients = [firstOwn.id];
617
+ }
618
+ if (!account) {
619
+ throw new Error("No active account available to send message");
620
+ }
621
+ try {
622
+ // Step 1: Create MajikMessageChat (compresses plaintext)
623
+ const messageChat = await MajikMessageChat.create(account, plaintext, recipients, expiresInMs);
624
+ // Step 2: Get compressed message for encryption
625
+ const compressedMessage = messageChat.getCompressedMessage();
626
+ // Step 3: Encrypt the compressed message using EncryptionEngine
627
+ let encryptedPayload;
628
+ if (recipients.length === 1) {
629
+ // Solo encryption
630
+ const contact = this.contactDirectory.getContact(recipients[0]);
631
+ if (!contact) {
632
+ throw new Error(`No contact found for recipient: ${recipients[0]}`);
633
+ }
634
+ encryptedPayload = await EncryptionEngine.encryptSoloMessage(compressedMessage, contact.publicKey);
635
+ }
636
+ else {
637
+ // Group encryption
638
+ const recipientData = recipients.map((id) => {
639
+ const contact = this.contactDirectory.getContact(id);
640
+ if (!contact) {
641
+ throw new Error(`No contact found for recipient: ${id}`);
642
+ }
643
+ return {
644
+ id: contact.id,
645
+ publicKey: contact.publicKey,
646
+ };
647
+ });
648
+ encryptedPayload = await EncryptionEngine.encryptGroupMessage(compressedMessage, recipientData);
649
+ }
650
+ // Step 4: Convert encrypted payload to base64 string
651
+ const payloadJSON = JSON.stringify(encryptedPayload);
652
+ const payloadBase64 = utf8ToBase64(payloadJSON);
653
+ // Step 5: Create scanner string with MAJIK prefix
654
+ const scannerString = `${MessageEnvelope.PREFIX}:${payloadBase64}`;
655
+ // Step 6: Update the messageChat with encrypted payload for storage
656
+ messageChat.setMessage(payloadJSON); // Store encrypted version
657
+ return { messageChat, scannerString };
658
+ }
659
+ catch (err) {
660
+ this.emit("error", err, { context: "createEncryptedMajikMessageChat" });
661
+ throw err;
662
+ }
663
+ }
664
+ /**
665
+ * Decrypt and decompress a MajikMessageChat message.
666
+ *
667
+ * Flow: Encrypted Payload → Decrypt (EncryptionEngine) → Decompress (MajikCompressor) → Plaintext
668
+ */
669
+ async decryptMajikMessageChat(encryptedPayload, recipientId) {
670
+ const recipient = recipientId
671
+ ? this.getOwnAccountById(recipientId)
672
+ : this.getActiveAccount();
673
+ if (!recipient) {
674
+ throw new Error("No recipient account found for decryption");
675
+ }
676
+ try {
677
+ // Step 1: Ensure identity is unlocked
678
+ const privateKey = await this.ensureIdentityUnlocked(recipient.id);
679
+ // Step 2: Parse the encrypted payload
680
+ const payload = JSON.parse(encryptedPayload);
681
+ // Step 3: Decrypt using EncryptionEngine
682
+ let decryptedCompressed;
683
+ if (payload.keys) {
684
+ // Group message
685
+ decryptedCompressed = await EncryptionEngine.decryptGroupMessage(payload, privateKey, recipient.fingerprint);
686
+ }
687
+ else {
688
+ // Solo message
689
+ decryptedCompressed = await EncryptionEngine.decryptSoloMessage(payload, privateKey);
690
+ }
691
+ // Step 4: Decompress the message
692
+ const plaintext = await MajikCompressor.decompressString(decryptedCompressed);
693
+ return plaintext;
694
+ }
695
+ catch (err) {
696
+ this.emit("error", err, { context: "decryptMajikMessageChat" });
697
+ throw err;
698
+ }
699
+ }
700
+ /**
701
+ * Encrypts currently selected text in the browser DOM for given recipients.
702
+ * If `recipients` is empty, defaults to the first own account.
703
+ * Returns the fully serialized base64 envelope string for the scanner.
704
+ */
705
+ async encryptSelectedTextForScanner(recipients = []) {
706
+ // Delegate to textarea-agnostic implementation
707
+ const plaintext = window.getSelection()?.toString().trim() ?? "";
708
+ return await this.encryptTextForScanner(plaintext, recipients);
709
+ }
710
+ /**
711
+ * Encrypt a provided plaintext string and return a serialized MajikMessage envelope string.
712
+ * Supports single or multiple recipients. Safe to call from background contexts.
713
+ */
714
+ async encryptTextForScanner(plaintext, recipients = [], cache = true) {
715
+ if (!plaintext?.trim()) {
716
+ console.warn("No text provided to encrypt.");
717
+ return null;
718
+ }
719
+ try {
720
+ // Determine recipients: default to first own account if none provided
721
+ if (recipients.length === 0) {
722
+ const firstOwn = this.listOwnAccounts()[0];
723
+ if (!firstOwn)
724
+ throw new Error("No own account available for encryption.");
725
+ recipients = [firstOwn.id];
726
+ }
727
+ let envelope;
728
+ if (recipients.length === 1) {
729
+ // Single recipient → solo message
730
+ envelope = await this.encryptSoloMessage(recipients[0], plaintext, cache);
731
+ }
732
+ else {
733
+ // Multiple recipients → unified group message
734
+ envelope = await this.encryptGroupMessage(recipients, plaintext, cache);
735
+ }
736
+ // Convert envelope to base64 for scanner
737
+ const envelopeBase64 = arrayBufferToBase64(envelope.raw);
738
+ return `${MessageEnvelope.PREFIX}:${envelopeBase64}`;
739
+ }
740
+ catch (err) {
741
+ this.emit("error", err, { context: "encryptTextForScanner" });
742
+ return null;
743
+ }
744
+ }
745
+ /**
746
+ * Encrypt text for a given target (label or ID).
747
+ * - If target is self or not found, just encrypt normally.
748
+ * - If target is another contact, always make a group message including self + target.
749
+ */
750
+ async encryptForTarget(target, // can be label or id
751
+ plaintext) {
752
+ const activeAccount = this.getActiveAccount();
753
+ if (!activeAccount)
754
+ throw new Error("No active account available");
755
+ // Try to find contact by ID first
756
+ let contact = this.listContacts(false).find((c) => c.id === target);
757
+ // If not found by ID, try by label
758
+ if (!contact) {
759
+ contact = this.listContacts(false).find((c) => c.meta?.label === target);
760
+ }
761
+ // If still not found or it's self → solo encryption
762
+ if (!contact || contact.id === activeAccount.id) {
763
+ return this.encryptTextForScanner(plaintext, [activeAccount.id]);
764
+ }
765
+ // Otherwise → group with self + target contact
766
+ return this.encryptTextForScanner(plaintext, [
767
+ activeAccount.id,
768
+ contact.id,
769
+ ]);
770
+ }
771
+ /* ================================
772
+ * DOM Scanning
773
+ * ================================ */
774
+ scanDOM(rootNode) {
775
+ this.scanner.scanDOM(rootNode);
776
+ }
777
+ startDOMObserver(rootNode) {
778
+ this.scanner.startDOMObserver(rootNode);
779
+ }
780
+ stopDOMObserver() {
781
+ this.scanner.stopDOMObserver();
782
+ }
783
+ /* ================================
784
+ * Event Handling
785
+ * ================================ */
786
+ on(event, callback) {
787
+ this.listeners.get(event)?.push(callback);
788
+ }
789
+ /**
790
+ * Remove a previously registered event listener.
791
+ * If `callback` is omitted, all listeners for the event are removed.
792
+ */
793
+ off(event, callback) {
794
+ const callbacks = this.listeners.get(event);
795
+ if (!callbacks || callbacks.length === 0)
796
+ return;
797
+ if (callback) {
798
+ // Remove only the specific callback
799
+ const index = callbacks.indexOf(callback);
800
+ if (index !== -1)
801
+ callbacks.splice(index, 1);
802
+ }
803
+ else {
804
+ // Remove all callbacks for this event
805
+ this.listeners.set(event, []);
806
+ }
807
+ }
808
+ emit(event, ...args) {
809
+ this.listeners.get(event)?.forEach((cb) => cb(...args));
810
+ }
811
+ /* ================================
812
+ * Envelope Handling
813
+ * ================================ */
814
+ async handleEnvelope(envelope) {
815
+ // Skip if already cached
816
+ const cached = await this.envelopeCache.get(envelope);
817
+ if (cached)
818
+ return;
819
+ const fingerprint = envelope.extractFingerprint();
820
+ // Check if this is a group message (all zeros or special marker)
821
+ const isGroupMessage = envelope.isGroup();
822
+ if (isGroupMessage) {
823
+ // For group messages, try all own accounts until one works
824
+ const ownAccounts = this.listOwnAccounts();
825
+ if (ownAccounts.length === 0) {
826
+ this.emit("untrusted", envelope);
827
+ return;
828
+ }
829
+ let decrypted = null;
830
+ let successfulAccount = null;
831
+ for (const ownAccount of ownAccounts) {
832
+ try {
833
+ const privateKey = await this.ensureIdentityUnlocked(ownAccount.id);
834
+ // Try to decrypt with this account
835
+ decrypted = await EncryptionEngine.decryptGroupMessage(envelope.extractEncryptedPayload(), privateKey, ownAccount.fingerprint);
836
+ successfulAccount = ownAccount;
837
+ break; // Success! Stop trying other accounts
838
+ }
839
+ catch (err) {
840
+ // This account doesn't have access, try next one
841
+ continue;
842
+ }
843
+ }
844
+ if (!decrypted || !successfulAccount) {
845
+ this.emit("untrusted", envelope);
846
+ return;
847
+ }
848
+ // Cache and emit
849
+ await this.envelopeCache.set(envelope, typeof window !== "undefined" && window.location
850
+ ? window.location.hostname
851
+ : "extension");
852
+ this.scheduleAutosave();
853
+ this.emit("message", decrypted, envelope, successfulAccount);
854
+ }
855
+ else {
856
+ // Solo message - original logic
857
+ const ownAccount = this.listOwnAccounts().find((a) => a.fingerprint === fingerprint);
858
+ if (!ownAccount) {
859
+ this.emit("untrusted", envelope);
860
+ return;
861
+ }
862
+ try {
863
+ const privateKey = await this.ensureIdentityUnlocked(ownAccount.id);
864
+ const decrypted = await EncryptionEngine.decryptSoloMessage(envelope.extractEncryptedPayload(), privateKey);
865
+ await this.envelopeCache.set(envelope, typeof window !== "undefined" && window.location
866
+ ? window.location.hostname
867
+ : "extension");
868
+ this.scheduleAutosave();
869
+ this.emit("message", decrypted, envelope, ownAccount);
870
+ }
871
+ catch (err) {
872
+ this.emit("error", err, { envelope });
873
+ }
874
+ }
875
+ }
876
+ /**
877
+ * Ensure an identity is unlocked. If locked, prompt the user for a passphrase.
878
+ * `promptFn` can be provided to show a custom UI: either synchronous returning string
879
+ * or async Promise<string>. If omitted, falls back to `window.prompt`.
880
+ */
881
+ async ensureIdentityUnlocked(id, promptFn) {
882
+ try {
883
+ return await KeyStore.getPrivateKey(id);
884
+ }
885
+ catch (err) {
886
+ // If KeyStore indicates unlocking is required, prompt the user
887
+ const needsUnlock = err instanceof Error &&
888
+ /must be unlocked|unlockIdentity/.test(err.message);
889
+ if (!needsUnlock)
890
+ throw err;
891
+ // Ask for passphrase
892
+ let passphrase = null;
893
+ if (promptFn) {
894
+ const res = promptFn(id);
895
+ passphrase = typeof res === "string" ? res : await res;
896
+ }
897
+ else if (KeyStore.onUnlockRequested) {
898
+ const res = KeyStore.onUnlockRequested(id);
899
+ passphrase = typeof res === "string" ? res : await res;
900
+ }
901
+ else if (typeof window !== "undefined" && window.prompt) {
902
+ passphrase = window.prompt("Enter passphrase to unlock identity:", "");
903
+ }
904
+ if (!passphrase) {
905
+ this.unlocked = false;
906
+ throw new Error("Unlock cancelled");
907
+ }
908
+ // Attempt to unlock
909
+ await KeyStore.unlockIdentity(id, passphrase);
910
+ this.unlocked = true;
911
+ return await KeyStore.getPrivateKey(id);
912
+ }
913
+ }
914
+ isUnlocked() {
915
+ return this.unlocked;
916
+ }
917
+ async isPassphraseValid(passphrase, id) {
918
+ const target = id ? this.getOwnAccountById(id) : this.getActiveAccount();
919
+ if (!target)
920
+ return false;
921
+ return KeyStore.isPassphraseValid(target.id, passphrase);
922
+ }
923
+ async toJSON() {
924
+ const finalJSON = {
925
+ id: this.id,
926
+ contacts: await this.contactDirectory.toJSON(),
927
+ envelopeCache: this.envelopeCache.toJSON(),
928
+ };
929
+ // Serialize own accounts (preserve order)
930
+ try {
931
+ const ownAccountsArr = [];
932
+ for (const id of this.ownAccountsOrder) {
933
+ const acct = this.ownAccounts.get(id);
934
+ if (!acct)
935
+ continue;
936
+ ownAccountsArr.push(await acct.toJSON());
937
+ }
938
+ finalJSON.ownAccounts = {
939
+ accounts: ownAccountsArr,
940
+ order: [...this.ownAccountsOrder],
941
+ };
942
+ }
943
+ catch (e) {
944
+ // ignore serialization errors for own accounts
945
+ console.warn("Failed to serialize ownAccounts:", e);
946
+ }
947
+ // include optional PIN hash
948
+ finalJSON.pinHash = this.pinHash || null;
949
+ return finalJSON;
950
+ }
951
+ static async fromJSON(json) {
952
+ const newDirectory = new MajikContactDirectory();
953
+ const parsedContacts = await newDirectory.fromJSON(json.contacts);
954
+ const parsedEnvelopeCache = EnvelopeCache.fromJSON(json.envelopeCache);
955
+ const parsedInstance = new this({
956
+ contactDirectory: parsedContacts,
957
+ envelopeCache: parsedEnvelopeCache,
958
+ keyStore: KeyStore,
959
+ }, json.id);
960
+ // Restore ownAccounts if present
961
+ try {
962
+ if (json.ownAccounts && Array.isArray(json.ownAccounts.accounts)) {
963
+ for (const acct of json.ownAccounts.accounts) {
964
+ try {
965
+ const raw = base64ToArrayBuffer(acct.publicKeyBase64);
966
+ const publicKey = await crypto.subtle.importKey("raw", raw, KEY_ALGO, true, []);
967
+ const contact = MajikContact.create(acct.id, publicKey, acct.fingerprint, acct.meta);
968
+ parsedInstance.ownAccounts.set(contact.id, contact);
969
+ }
970
+ catch (e) {
971
+ // SubtleCrypto may not support importing X25519 keys in some environments.
972
+ // This is non-fatal: we fall back to raw-key wrappers elsewhere.
973
+ console.info("Fallback restoring own account (using raw-key wrapper)", acct.id, e);
974
+ }
975
+ }
976
+ if (Array.isArray(json.ownAccounts.order)) {
977
+ parsedInstance.ownAccountsOrder = [...json.ownAccounts.order];
978
+ }
979
+ // Fallback: if accounts array was empty but order exists, try to populate
980
+ // ownAccounts from the restored contactDirectory entries
981
+ try {
982
+ if (Array.isArray(json.ownAccounts.order) &&
983
+ parsedInstance.ownAccounts.size === 0) {
984
+ for (const id of json.ownAccounts.order) {
985
+ try {
986
+ const c = parsedInstance.contactDirectory.getContact(id);
987
+ if (c)
988
+ parsedInstance.ownAccounts.set(id, c);
989
+ }
990
+ catch (e) {
991
+ // ignore missing contacts
992
+ }
993
+ }
994
+ }
995
+ }
996
+ catch (e) {
997
+ // ignore
998
+ }
999
+ // Also add own accounts into contact directory for discoverability
1000
+ try {
1001
+ parsedInstance.ownAccountsOrder.forEach((id) => {
1002
+ const c = parsedInstance.ownAccounts.get(id);
1003
+ if (c && !parsedInstance.contactDirectory.hasContact(c.id)) {
1004
+ parsedInstance.contactDirectory.addContact(c);
1005
+ }
1006
+ });
1007
+ }
1008
+ catch (e) {
1009
+ // ignore
1010
+ }
1011
+ }
1012
+ }
1013
+ catch (e) {
1014
+ console.warn("Error restoring ownAccounts:", e);
1015
+ }
1016
+ // restore pin hash if present
1017
+ try {
1018
+ const anyJson = json;
1019
+ if (anyJson.pinHash)
1020
+ parsedInstance.pinHash = anyJson.pinHash;
1021
+ }
1022
+ catch (e) {
1023
+ // ignore
1024
+ }
1025
+ return parsedInstance;
1026
+ }
1027
+ /**
1028
+ * Set a PIN (stores hash). Passphrase is any string; we store SHA-256(base64) of it.
1029
+ */
1030
+ async setPIN(pin) {
1031
+ if (!pin)
1032
+ throw new Error("PIN must be a non-empty string");
1033
+ const hash = await MajikMessage.hashPIN(pin);
1034
+ this.pinHash = hash;
1035
+ this.scheduleAutosave();
1036
+ }
1037
+ async clearPIN() {
1038
+ this.pinHash = null;
1039
+ this.scheduleAutosave();
1040
+ }
1041
+ async isValidPIN(pin) {
1042
+ if (!this.pinHash)
1043
+ return true; // no PIN set => always valid
1044
+ const hash = await MajikMessage.hashPIN(pin);
1045
+ return hash === this.pinHash;
1046
+ }
1047
+ getPinHash() {
1048
+ return this.pinHash || null;
1049
+ }
1050
+ static async hashPIN(pin) {
1051
+ const data = new TextEncoder().encode(pin);
1052
+ const digest = await crypto.subtle.digest("SHA-256", data);
1053
+ // base64 encode
1054
+ const b64 = arrayBufferToBase64(digest);
1055
+ return b64;
1056
+ }
1057
+ /* ================================
1058
+ * Persistence
1059
+ * ================================ */
1060
+ autosaveIntervalId = null;
1061
+ attachAutosaveHandlers() {
1062
+ if (typeof window !== "undefined") {
1063
+ // Save before unload (best-effort)
1064
+ try {
1065
+ window.addEventListener("beforeunload", () => {
1066
+ void this.saveState();
1067
+ });
1068
+ }
1069
+ catch (e) {
1070
+ // ignore
1071
+ }
1072
+ // Start periodic backups
1073
+ this.startAutosave();
1074
+ }
1075
+ }
1076
+ startAutosave() {
1077
+ if (this.autosaveIntervalId)
1078
+ return;
1079
+ if (typeof window === "undefined")
1080
+ return;
1081
+ this.autosaveIntervalId = window.setInterval(() => {
1082
+ void this.saveState();
1083
+ }, this.autosaveIntervalMs);
1084
+ }
1085
+ stopAutosave() {
1086
+ if (!this.autosaveIntervalId)
1087
+ return;
1088
+ if (typeof window !== "undefined") {
1089
+ window.clearInterval(this.autosaveIntervalId);
1090
+ }
1091
+ this.autosaveIntervalId = null;
1092
+ }
1093
+ scheduleAutosave() {
1094
+ try {
1095
+ if (this.autosaveTimer) {
1096
+ if (typeof window !== "undefined")
1097
+ window.clearTimeout(this.autosaveTimer);
1098
+ this.autosaveTimer = null;
1099
+ }
1100
+ if (typeof window !== "undefined") {
1101
+ this.autosaveTimer = window.setTimeout(() => {
1102
+ void this.saveState();
1103
+ this.autosaveTimer = null;
1104
+ }, this.autosaveDebounceMs);
1105
+ }
1106
+ }
1107
+ catch (e) {
1108
+ // ignore scheduling errors
1109
+ }
1110
+ }
1111
+ /** Save current state into IndexedDB (autosave). */
1112
+ async saveState() {
1113
+ try {
1114
+ const jsonDocument = await this.toJSON();
1115
+ const autosaveBlob = autoSaveMajikFileData(jsonDocument);
1116
+ await idbSaveBlob("majik-message-state", autosaveBlob, this.userProfile);
1117
+ }
1118
+ catch (err) {
1119
+ console.error("Failed to save MajikMessage state:", err);
1120
+ }
1121
+ }
1122
+ /** Load state from IndexedDB and apply to this instance. */
1123
+ async loadState() {
1124
+ try {
1125
+ const autosaveData = await idbLoadBlob("majik-message-state", this.userProfile);
1126
+ if (!autosaveData?.data)
1127
+ return;
1128
+ const blobFile = autosaveData.data;
1129
+ const loadedData = await loadSavedMajikFileData(blobFile);
1130
+ const parsedJSON = loadedData.j;
1131
+ // Use fromJSON to ensure ownAccounts and other fields are restored consistently
1132
+ const restored = await MajikMessage.fromJSON(parsedJSON);
1133
+ this.id = restored.id;
1134
+ this.contactDirectory = restored.contactDirectory;
1135
+ this.envelopeCache = restored.envelopeCache;
1136
+ this.ownAccounts = restored.ownAccounts;
1137
+ this.ownAccountsOrder = [...restored.ownAccountsOrder];
1138
+ }
1139
+ catch (err) {
1140
+ console.error("Failed to load MajikMessage state:", err);
1141
+ }
1142
+ }
1143
+ /**
1144
+ * Try to load an existing state from IDB; if none exists, create a fresh instance and save it.
1145
+ */
1146
+ static async loadOrCreate(config, userProfile = "default") {
1147
+ try {
1148
+ const saved = await idbLoadBlob("majik-message-state", userProfile);
1149
+ if (saved?.data) {
1150
+ const loaded = await loadSavedMajikFileData(saved.data);
1151
+ const parsedJSON = loaded.j;
1152
+ const instance = (await this.fromJSON(parsedJSON));
1153
+ console.log("Account Loaded Successfully");
1154
+ instance.attachAutosaveHandlers();
1155
+ return instance;
1156
+ }
1157
+ }
1158
+ catch (err) {
1159
+ console.warn("Error trying to load saved MajikMessage state:", err);
1160
+ }
1161
+ // No saved state → create new subclass instance
1162
+ const created = new this(config);
1163
+ await created.saveState();
1164
+ created.attachAutosaveHandlers();
1165
+ return created;
1166
+ }
1167
+ /**
1168
+ * Reset all data to a fresh state.
1169
+ * Clears cache, own accounts, contact directory, keystore, and saved data.
1170
+ * WARNING: This operation is irreversible and will delete all user data.
1171
+ */
1172
+ async resetData(userProfile = "default") {
1173
+ try {
1174
+ // 1. Clear envelope cache
1175
+ await this.clearCachedEnvelopes();
1176
+ // 2. Clear all own accounts from keystore
1177
+ const accountIds = [...this.ownAccountsOrder];
1178
+ for (const id of accountIds) {
1179
+ try {
1180
+ // Delete from KeyStore storage
1181
+ await KeyStore.deleteIdentity?.(id).catch(() => { });
1182
+ }
1183
+ catch (e) {
1184
+ console.warn(`Failed to delete identity ${id} from KeyStore:`, e);
1185
+ }
1186
+ }
1187
+ // 3. Clear own accounts map and order
1188
+ this.ownAccounts.clear();
1189
+ this.ownAccountsOrder = [];
1190
+ // 4. Clear contact directory
1191
+ try {
1192
+ this.contactDirectory.clear();
1193
+ }
1194
+ catch (e) {
1195
+ console.warn(`Failed to clear contacts directory: `, e);
1196
+ }
1197
+ // 5. Clear PIN hash
1198
+ this.pinHash = null;
1199
+ // 6. Reset unlocked state
1200
+ this.unlocked = false;
1201
+ // 7. Clear saved state from IndexedDB
1202
+ try {
1203
+ await clearAllBlobs(userProfile);
1204
+ }
1205
+ catch (e) {
1206
+ console.warn("Failed to clear saved state from IndexedDB:", e);
1207
+ }
1208
+ // 8. Generate new ID for fresh instance
1209
+ this.id = arrayToBase64(randomBytes(32));
1210
+ // 9. Stop and restart autosave to ensure clean state
1211
+ this.stopAutosave();
1212
+ this.startAutosave();
1213
+ this.emit("active-account-change", null);
1214
+ console.log("MajikMessage data reset successfully");
1215
+ }
1216
+ catch (err) {
1217
+ console.error("Error during resetData:", err);
1218
+ throw new Error(`Failed to reset data: ${err instanceof Error ? err.message : err}`);
1219
+ }
1220
+ }
1221
+ }