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