@sovereignbase/explorer 0.0.0-alpha.1 → 0.0.0-alpha.2
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 +8 -6
- package/dist/class.cjs +760 -0
- package/dist/class.cjs.map +1 -0
- package/dist/class.d.cts +274 -0
- package/dist/class.d.cts.map +1 -0
- package/dist/class.d.ts +274 -0
- package/dist/class.d.ts.map +1 -0
- package/dist/class.js +759 -0
- package/dist/class.js.map +1 -0
- package/package.json +1 -1
package/dist/class.js
ADDED
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 Sovereignbase
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { deleteObject, loadObject, storeObject } from "@sovereignbase/storage";
|
|
18
|
+
import { afterIdleFor, isRecord } from "@sovereignbase/utils";
|
|
19
|
+
import { Bytes } from "@sovereignbase/bytecodec";
|
|
20
|
+
import { readableUserAgent } from "@sovereignbase/dom-utils";
|
|
21
|
+
import { deriveDeviceEntropy } from "@sovereignbase/hardware-bound";
|
|
22
|
+
import { Cryptographic } from "@sovereignbase/cryptosuite";
|
|
23
|
+
import { Sequenced } from "@sovereignbase/sequenced";
|
|
24
|
+
// src/.errors/class.ts
|
|
25
|
+
/**
|
|
26
|
+
* An Explorer error with a stable, machine-readable code and an optional cause.
|
|
27
|
+
*
|
|
28
|
+
* The message is prefixed with the library name. Use {@link code} rather than
|
|
29
|
+
* parsing the message when handling a particular failure.
|
|
30
|
+
*/
|
|
31
|
+
var ExplorerError = class extends Error {
|
|
32
|
+
/** The machine-readable category of the failure. */
|
|
33
|
+
code;
|
|
34
|
+
/**
|
|
35
|
+
* Creates an error whose name is `ExplorerError`.
|
|
36
|
+
*
|
|
37
|
+
* @param code The category of the failure.
|
|
38
|
+
* @param message The detail to prefix with the library name; defaults to `code`.
|
|
39
|
+
* @param options Error options, including the original `cause`.
|
|
40
|
+
*/
|
|
41
|
+
constructor(code, message, options) {
|
|
42
|
+
super(`{@sovereignbase/explorer} ${message ?? code}`, options);
|
|
43
|
+
this.code = code;
|
|
44
|
+
this.name = "ExplorerError";
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
// src/.errors/unkown.ts
|
|
48
|
+
/**
|
|
49
|
+
* Preserves an existing library error or wraps an unexpected failure once.
|
|
50
|
+
* @param cause The value thrown by the failed operation.
|
|
51
|
+
* @param operation The operation name to include in the error message.
|
|
52
|
+
* @returns The original library error, or an `UNKNOWN_ERROR` retaining `cause`.
|
|
53
|
+
*/
|
|
54
|
+
function unknownError(cause, operation) {
|
|
55
|
+
return cause instanceof ExplorerError ? cause : new ExplorerError("UNKNOWN_ERROR", `${operation} failed.`, { cause });
|
|
56
|
+
}
|
|
57
|
+
// src/CredentialEntries/class.ts
|
|
58
|
+
/**
|
|
59
|
+
* Maintains the database's credential entries.
|
|
60
|
+
*
|
|
61
|
+
* Entries are identified by credential ID and synchronized through
|
|
62
|
+
* a sequenced array. Changes are persisted as encrypted snapshots.
|
|
63
|
+
*/
|
|
64
|
+
var CredentialEntries = class {
|
|
65
|
+
id;
|
|
66
|
+
cipherKey;
|
|
67
|
+
sessionCredentialID;
|
|
68
|
+
state;
|
|
69
|
+
deltaBuffer = new Array();
|
|
70
|
+
eventTarget;
|
|
71
|
+
/**
|
|
72
|
+
* Creates a credential entries list.
|
|
73
|
+
*
|
|
74
|
+
* @param id The identifier of the persisted entries object.
|
|
75
|
+
* @param cipherKey The key used to encrypt the entries object.
|
|
76
|
+
* @param sessionCredentialID The credential ID of the current session.
|
|
77
|
+
* @param actorID The actor identifier used by the sequenced array.
|
|
78
|
+
* @param trustedSnapshot An optional snapshot used to initialize the state.
|
|
79
|
+
*/
|
|
80
|
+
constructor(id, cipherKey, sessionCredentialID, actorID, trustedSnapshot) {
|
|
81
|
+
this.id = id;
|
|
82
|
+
this.cipherKey = cipherKey;
|
|
83
|
+
this.sessionCredentialID = sessionCredentialID;
|
|
84
|
+
try {
|
|
85
|
+
this.state = new Sequenced.Array(actorID, trustedSnapshot);
|
|
86
|
+
const debouncedDispatch = afterIdleFor(250, () => {
|
|
87
|
+
Explorer.dispatchEvent("delta", ["credentialEntries", this.deltaBuffer.splice(0)]);
|
|
88
|
+
});
|
|
89
|
+
this.state.addEventListener("delta", ({ detail }) => {
|
|
90
|
+
this.deltaBuffer.push(...detail);
|
|
91
|
+
debouncedDispatch();
|
|
92
|
+
});
|
|
93
|
+
const debouncedSave = afterIdleFor(250, async (snapshot) => {
|
|
94
|
+
await Explorer.storeObjectByID(this.id, snapshot, this.cipherKey);
|
|
95
|
+
});
|
|
96
|
+
this.state.addEventListener("snapshot", ({ detail }) => {
|
|
97
|
+
debouncedSave(detail);
|
|
98
|
+
});
|
|
99
|
+
this.state.addEventListener("change", ({ detail }) => {
|
|
100
|
+
this.dispatchEvent("change", detail);
|
|
101
|
+
});
|
|
102
|
+
const seen = /* @__PURE__ */ new Set();
|
|
103
|
+
const duplicates = [];
|
|
104
|
+
for (let index = 0; index < this.state.length; index++) {
|
|
105
|
+
const entry = this.state.at(index);
|
|
106
|
+
if (!entry) continue;
|
|
107
|
+
if (seen.has(entry.id)) duplicates.push(index);
|
|
108
|
+
else seen.add(entry.id);
|
|
109
|
+
}
|
|
110
|
+
for (let index = duplicates.length - 1; index >= 0; index--) this.state.delete(duplicates[index]);
|
|
111
|
+
if (duplicates.length > 0 || this.deltaBuffer.length > 0) this.state.snapshot();
|
|
112
|
+
} catch (cause) {
|
|
113
|
+
throw unknownError(cause, "CredentialEntries.constructor()");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Creates or updates a credential entry.
|
|
118
|
+
*
|
|
119
|
+
* Updates the credential's last-used timestamp and the corresponding
|
|
120
|
+
* user agent's last-seen timestamp. Existing properties are preserved
|
|
121
|
+
* unless replacements are provided.
|
|
122
|
+
*
|
|
123
|
+
* @param credentialEvent The credential properties to apply.
|
|
124
|
+
* @throws {ExplorerError} If the event is invalid or setup properties are missing.
|
|
125
|
+
*/
|
|
126
|
+
record(credentialEvent) {
|
|
127
|
+
try {
|
|
128
|
+
if (!credentialEvent || typeof credentialEvent !== "object") throw new ExplorerError("INVALID_CREDENTIAL_EVENT", "A credential event must be an object.");
|
|
129
|
+
const { id, timestamp, userAgent, name, entropy, storage } = credentialEvent;
|
|
130
|
+
if (typeof id !== "string" || typeof timestamp !== "number" || !Number.isFinite(timestamp) || typeof userAgent !== "string") throw new ExplorerError("INVALID_CREDENTIAL_EVENT", "A credential event requires an ID, a finite timestamp, and a user agent.");
|
|
131
|
+
let existing;
|
|
132
|
+
for (let index = this.state.length - 1; index >= 0; index--) {
|
|
133
|
+
const entry = this.state.at(index);
|
|
134
|
+
if (entry?.id === id) {
|
|
135
|
+
existing = entry;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!existing && (typeof name !== "string" || !(entropy instanceof Uint8Array) || typeof storage !== "string")) throw new ExplorerError("MISSING_CREDENTIAL_SETUP_PROPERTIES", "A new credential requires a name, entropy, and binding storage.");
|
|
140
|
+
const userAgents = existing?.userAgents.map((agent) => ({ ...agent })) ?? [];
|
|
141
|
+
const agent = userAgents.find((agent) => agent.name === userAgent);
|
|
142
|
+
if (agent) agent.lastSeen = Math.max(agent.lastSeen, timestamp);
|
|
143
|
+
else userAgents.push({
|
|
144
|
+
name: userAgent,
|
|
145
|
+
lastSeen: timestamp
|
|
146
|
+
});
|
|
147
|
+
const entry = {
|
|
148
|
+
id,
|
|
149
|
+
name: name ?? existing.name,
|
|
150
|
+
entropy: entropy ?? existing.entropy,
|
|
151
|
+
storage: storage ?? existing.storage,
|
|
152
|
+
lastUsed: Math.max(existing?.lastUsed ?? timestamp, timestamp),
|
|
153
|
+
userAgents
|
|
154
|
+
};
|
|
155
|
+
for (let index = this.state.length - 1; index >= 0; index--) if (this.state.at(index)?.id === id) this.state.delete(index);
|
|
156
|
+
this.state.unshift(entry);
|
|
157
|
+
this.state.snapshot();
|
|
158
|
+
} catch (cause) {
|
|
159
|
+
throw unknownError(cause, "CredentialEntries.record()");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Revokes a credential by deleting its persisted data and list entries.
|
|
164
|
+
*
|
|
165
|
+
* The list remains unchanged if deletion of the persisted credential fails.
|
|
166
|
+
*
|
|
167
|
+
* @param credentialID The identifier of the credential to revoke.
|
|
168
|
+
*/
|
|
169
|
+
async revoke(credentialID) {
|
|
170
|
+
try {
|
|
171
|
+
let exists = false;
|
|
172
|
+
for (let index = this.state.length - 1; index >= 0; index--) {
|
|
173
|
+
if (this.state.at(index)?.id !== credentialID) continue;
|
|
174
|
+
exists = true;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
if (!exists) return;
|
|
178
|
+
await Explorer.deleteObjectByID(credentialID);
|
|
179
|
+
for (let index = this.state.length - 1; index >= 0; index--) if (this.state.at(index)?.id === credentialID) this.state.delete(index);
|
|
180
|
+
this.state.snapshot();
|
|
181
|
+
} catch (cause) {
|
|
182
|
+
throw unknownError(cause, "CredentialEntries.revoke()");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Returns the credential entries ordered by most recent use.
|
|
187
|
+
*
|
|
188
|
+
* Entries with equal timestamps are ordered by identifier.
|
|
189
|
+
*/
|
|
190
|
+
list() {
|
|
191
|
+
try {
|
|
192
|
+
return this.state.slice().sort((a, b) => b.lastUsed - a.lastUsed || a.id.localeCompare(b.id));
|
|
193
|
+
} catch (cause) {
|
|
194
|
+
throw unknownError(cause, "CredentialEntries.list()");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Applies a sequenced delta to the credential entries list.
|
|
199
|
+
*
|
|
200
|
+
* @param delta The delta to apply.
|
|
201
|
+
*/
|
|
202
|
+
apply(delta) {
|
|
203
|
+
try {
|
|
204
|
+
this.state.apply(delta);
|
|
205
|
+
this.state.snapshot();
|
|
206
|
+
} catch (cause) {
|
|
207
|
+
throw unknownError(cause, "CredentialEntries.apply()");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Merges a snapshot into the credential entries list.
|
|
212
|
+
*
|
|
213
|
+
* @param snapshot The snapshot to merge.
|
|
214
|
+
*/
|
|
215
|
+
merge(snapshot) {
|
|
216
|
+
try {
|
|
217
|
+
this.state.merge(snapshot);
|
|
218
|
+
this.state.snapshot();
|
|
219
|
+
} catch (cause) {
|
|
220
|
+
throw unknownError(cause, "CredentialEntries.merge()");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Registers an event listener for the specified event type.
|
|
225
|
+
*
|
|
226
|
+
* @param type The event type.
|
|
227
|
+
* @param listener The event listener, or `null`.
|
|
228
|
+
* @param options The event listener options.
|
|
229
|
+
*/
|
|
230
|
+
addEventListener(type, listener, options) {
|
|
231
|
+
try {
|
|
232
|
+
(this.eventTarget ??= new EventTarget()).addEventListener(type, listener, options);
|
|
233
|
+
} catch (cause) {
|
|
234
|
+
throw unknownError(cause, "CredentialEntries.addEventListener()");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Removes an event listener previously registered with
|
|
239
|
+
* {@link addEventListener}.
|
|
240
|
+
*
|
|
241
|
+
* @param type The event type.
|
|
242
|
+
* @param listener The event listener to remove, or `null`.
|
|
243
|
+
* @param options The event listener options.
|
|
244
|
+
*/
|
|
245
|
+
removeEventListener(type, listener, options) {
|
|
246
|
+
try {
|
|
247
|
+
this.eventTarget?.removeEventListener(type, listener, options);
|
|
248
|
+
} catch (cause) {
|
|
249
|
+
throw unknownError(cause, "CredentialEntries.removeEventListener()");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Dispatches an event to the registered listeners.
|
|
254
|
+
*
|
|
255
|
+
* @param type The event type.
|
|
256
|
+
* @param detail The value exposed through `CustomEvent.detail`.
|
|
257
|
+
*/
|
|
258
|
+
dispatchEvent(type, detail) {
|
|
259
|
+
try {
|
|
260
|
+
this.eventTarget?.dispatchEvent(new CustomEvent(type, { detail }));
|
|
261
|
+
} catch (cause) {
|
|
262
|
+
throw unknownError(cause, "CredentialEntries.dispatchEvent()");
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
// src/RecentEntries/class.ts
|
|
267
|
+
/**
|
|
268
|
+
* Maintains the ordered list of recently accessed objects.
|
|
269
|
+
*
|
|
270
|
+
* Entries are ordered from most recently accessed to least recently accessed,
|
|
271
|
+
* deduplicated by identifier, and limited to 20 entries.
|
|
272
|
+
*/
|
|
273
|
+
var RecentEntries = class {
|
|
274
|
+
id;
|
|
275
|
+
cipherKey;
|
|
276
|
+
state;
|
|
277
|
+
deltaBuffer = new Array();
|
|
278
|
+
eventTarget;
|
|
279
|
+
/**
|
|
280
|
+
* Creates a recent entries list.
|
|
281
|
+
*
|
|
282
|
+
* @param id The identifier of the persisted recent entries object.
|
|
283
|
+
* @param cipherKey The key used to encrypt the entries object.
|
|
284
|
+
* @param actorID The actor identifier used by the sequenced array.
|
|
285
|
+
* @param trustedSnapshot An optional snapshot used to initialize the state.
|
|
286
|
+
*/
|
|
287
|
+
constructor(id, cipherKey, actorID, trustedSnapshot) {
|
|
288
|
+
this.id = id;
|
|
289
|
+
this.cipherKey = cipherKey;
|
|
290
|
+
try {
|
|
291
|
+
this.state = new Sequenced.Array(actorID, trustedSnapshot);
|
|
292
|
+
const debouncedDispatch = afterIdleFor(250, () => {
|
|
293
|
+
Explorer.dispatchEvent("delta", ["recentEntries", this.deltaBuffer.splice(0)]);
|
|
294
|
+
});
|
|
295
|
+
this.state.addEventListener("delta", ({ detail }) => {
|
|
296
|
+
this.deltaBuffer.push(...detail);
|
|
297
|
+
debouncedDispatch();
|
|
298
|
+
});
|
|
299
|
+
const debouncedSave = afterIdleFor(250, async (snapshot) => {
|
|
300
|
+
await Explorer.storeObjectByID(this.id, snapshot, this.cipherKey);
|
|
301
|
+
});
|
|
302
|
+
this.state.addEventListener("snapshot", ({ detail }) => {
|
|
303
|
+
debouncedSave(detail);
|
|
304
|
+
});
|
|
305
|
+
this.state.addEventListener("change", ({ detail }) => {
|
|
306
|
+
this.dispatchEvent("change", detail);
|
|
307
|
+
});
|
|
308
|
+
const seen = /* @__PURE__ */ new Set();
|
|
309
|
+
const duplicates = [];
|
|
310
|
+
for (let index = 0; index < this.state.length; index++) {
|
|
311
|
+
const entry = this.state.at(index);
|
|
312
|
+
if (!entry) continue;
|
|
313
|
+
if (seen.has(entry.id) || seen.size >= 20) duplicates.push(index);
|
|
314
|
+
else seen.add(entry.id);
|
|
315
|
+
}
|
|
316
|
+
for (let index = duplicates.length - 1; index >= 0; index--) this.state.delete(duplicates[index]);
|
|
317
|
+
if (duplicates.length > 0 || this.deltaBuffer.length > 0) this.state.snapshot();
|
|
318
|
+
} catch (cause) {
|
|
319
|
+
throw unknownError(cause, "RecentEntries.constructor()");
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Marks an entry as most recently accessed.
|
|
324
|
+
*
|
|
325
|
+
* Any existing entry with the same identifier is removed before the new
|
|
326
|
+
* entry is inserted at the beginning of the list.
|
|
327
|
+
*
|
|
328
|
+
* @param accessed The entry that was accessed.
|
|
329
|
+
*/
|
|
330
|
+
update(accessed) {
|
|
331
|
+
try {
|
|
332
|
+
for (let index = this.state.length - 1; index >= 0; index--) if (this.state.at(index)?.id === accessed.id) this.state.delete(index);
|
|
333
|
+
this.state.unshift(accessed);
|
|
334
|
+
while (this.state.length > 20) this.state.pop();
|
|
335
|
+
this.state.snapshot();
|
|
336
|
+
} catch (cause) {
|
|
337
|
+
throw unknownError(cause, "RecentEntries.update()");
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Removes an entry from the recent entries list.
|
|
342
|
+
*
|
|
343
|
+
* The referenced object is not deleted.
|
|
344
|
+
*
|
|
345
|
+
* @param id The identifier of the entry to remove.
|
|
346
|
+
*/
|
|
347
|
+
delete(id) {
|
|
348
|
+
try {
|
|
349
|
+
let deleted = false;
|
|
350
|
+
for (let index = this.state.length - 1; index >= 0; index--) {
|
|
351
|
+
if (this.state.at(index)?.id !== id) continue;
|
|
352
|
+
this.state.delete(index);
|
|
353
|
+
deleted = true;
|
|
354
|
+
}
|
|
355
|
+
if (deleted) this.state.snapshot();
|
|
356
|
+
} catch (cause) {
|
|
357
|
+
throw unknownError(cause, "RecentEntries.delete()");
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Returns the recent entries ordered by most recent access.
|
|
362
|
+
*/
|
|
363
|
+
list() {
|
|
364
|
+
try {
|
|
365
|
+
return this.state.slice().sort((a, b) => b.dateAccessed - a.dateAccessed || a.id.localeCompare(b.id));
|
|
366
|
+
} catch (cause) {
|
|
367
|
+
throw unknownError(cause, "RecentEntries.list()");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Applies a sequenced delta to the recent entries list.
|
|
372
|
+
*
|
|
373
|
+
* @param delta The delta to apply.
|
|
374
|
+
*/
|
|
375
|
+
apply(delta) {
|
|
376
|
+
try {
|
|
377
|
+
this.state.apply(delta);
|
|
378
|
+
this.state.snapshot();
|
|
379
|
+
} catch (cause) {
|
|
380
|
+
throw unknownError(cause, "RecentEntries.apply()");
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Merges a snapshot into the recent entries list.
|
|
385
|
+
*
|
|
386
|
+
* @param snapshot The snapshot to merge.
|
|
387
|
+
*/
|
|
388
|
+
merge(snapshot) {
|
|
389
|
+
try {
|
|
390
|
+
this.state.merge(snapshot);
|
|
391
|
+
this.state.snapshot();
|
|
392
|
+
} catch (cause) {
|
|
393
|
+
throw unknownError(cause, "RecentEntries.merge()");
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Registers an event listener for the specified event type.
|
|
398
|
+
*
|
|
399
|
+
* @param type The event type.
|
|
400
|
+
* @param listener The event listener, or `null`.
|
|
401
|
+
* @param options The event listener options.
|
|
402
|
+
*/
|
|
403
|
+
addEventListener(type, listener, options) {
|
|
404
|
+
try {
|
|
405
|
+
(this.eventTarget ??= new EventTarget()).addEventListener(type, listener, options);
|
|
406
|
+
} catch (cause) {
|
|
407
|
+
throw unknownError(cause, "RecentEntries.addEventListener()");
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Removes an event listener previously registered with
|
|
412
|
+
* {@link addEventListener}.
|
|
413
|
+
*
|
|
414
|
+
* @param type The event type.
|
|
415
|
+
* @param listener The event listener to remove, or `null`.
|
|
416
|
+
* @param options The event listener options.
|
|
417
|
+
*/
|
|
418
|
+
removeEventListener(type, listener, options) {
|
|
419
|
+
try {
|
|
420
|
+
this.eventTarget?.removeEventListener(type, listener, options);
|
|
421
|
+
} catch (cause) {
|
|
422
|
+
throw unknownError(cause, "RecentEntries.removeEventListener()");
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Dispatches an event to the registered listeners.
|
|
427
|
+
*
|
|
428
|
+
* @param type The event type.
|
|
429
|
+
* @param detail The value exposed through `CustomEvent.detail`.
|
|
430
|
+
*/
|
|
431
|
+
dispatchEvent(type, detail) {
|
|
432
|
+
try {
|
|
433
|
+
this.eventTarget?.dispatchEvent(new CustomEvent(type, { detail }));
|
|
434
|
+
} catch (cause) {
|
|
435
|
+
throw unknownError(cause, "RecentEntries.dispatchEvent()");
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
// src/class.ts
|
|
440
|
+
/**
|
|
441
|
+
* Provides authentication, encrypted object storage, and access to
|
|
442
|
+
* the database's synchronized entries.
|
|
443
|
+
*
|
|
444
|
+
* Maintains the authenticated database context and exposes static methods
|
|
445
|
+
* for managing stored objects, observing entries, and applying remote deltas.
|
|
446
|
+
*/
|
|
447
|
+
var Explorer = class {
|
|
448
|
+
/** The identifier of the authenticated database. */
|
|
449
|
+
static databaseID;
|
|
450
|
+
/** Indicates whether the database has been authenticated. */
|
|
451
|
+
static isAuthenticated;
|
|
452
|
+
static storageURL;
|
|
453
|
+
static eventTarget;
|
|
454
|
+
static databaseSecret;
|
|
455
|
+
static sessionCredential = {};
|
|
456
|
+
/**
|
|
457
|
+
* Caches cipher keys by object identifier.
|
|
458
|
+
*
|
|
459
|
+
* Existing entries are preserved when an object is accessed with
|
|
460
|
+
* a cipher key.
|
|
461
|
+
*/
|
|
462
|
+
static cipherKeyCache = /* @__PURE__ */ new Map();
|
|
463
|
+
/**
|
|
464
|
+
* Authenticates a database using a data PIN and device-bound entropy.
|
|
465
|
+
*
|
|
466
|
+
* Derives the credential and database identifiers from cryptographic
|
|
467
|
+
* secrets. When setup parameters are provided, creates and stores a
|
|
468
|
+
* new credential. Otherwise, loads the existing credential.
|
|
469
|
+
*
|
|
470
|
+
* Records the authenticated credential in the database's credential list.
|
|
471
|
+
*
|
|
472
|
+
* @param dataPIN The PIN used to derive the credential and database secrets.
|
|
473
|
+
* @param storageURL The base URL used to access persisted database objects.
|
|
474
|
+
* @param setupParams Optional parameters for creating a new credential.
|
|
475
|
+
* @returns The authentication result.
|
|
476
|
+
*/
|
|
477
|
+
static async authenticate(dataPIN, storageURL, setupParams) {
|
|
478
|
+
try {
|
|
479
|
+
this.storageURL = storageURL;
|
|
480
|
+
const deviceEntropy = await deriveDeviceEntropy();
|
|
481
|
+
if (!deviceEntropy) return "authenticator-error";
|
|
482
|
+
this.sessionCredential = {
|
|
483
|
+
timestamp: Date.now(),
|
|
484
|
+
userAgent: readableUserAgent()
|
|
485
|
+
};
|
|
486
|
+
const dataPINBytes = Bytes.utf8.decode(dataPIN);
|
|
487
|
+
const credentialSecret = await Bytes.derive(deviceEntropy, dataPINBytes, 32);
|
|
488
|
+
const credentialID = await Cryptographic.identifier.derive(credentialSecret, Bytes.utf8.decode("@sovereignbase/explorer:CredentialID"), 32);
|
|
489
|
+
this.sessionCredential.id = credentialID;
|
|
490
|
+
const credentialCipherKey = await Cryptographic.cipherMessage.deriveKey(credentialSecret, Bytes.utf8.decode("@sovereignbase/explorer:CredentialCipherKey"));
|
|
491
|
+
let virtualEntropy;
|
|
492
|
+
if (isRecord(setupParams)) {
|
|
493
|
+
virtualEntropy = Bytes.generate(64);
|
|
494
|
+
await this.storeObjectByID(credentialID, virtualEntropy, credentialCipherKey);
|
|
495
|
+
this.sessionCredential.name = setupParams.credentialName;
|
|
496
|
+
this.sessionCredential.entropy = deviceEntropy;
|
|
497
|
+
this.sessionCredential.storage = setupParams.bindingStorage;
|
|
498
|
+
} else {
|
|
499
|
+
const result = await this.loadObjectByID(credentialID, credentialCipherKey);
|
|
500
|
+
if (!(result instanceof Uint8Array) || result.byteLength !== 64) return "invalid-data-pin";
|
|
501
|
+
virtualEntropy = result;
|
|
502
|
+
}
|
|
503
|
+
this.databaseSecret = await Bytes.derive(virtualEntropy, dataPINBytes, 64);
|
|
504
|
+
this.databaseID = await Cryptographic.identifier.derive(this.databaseSecret, Bytes.utf8.decode("@sovereignbase/explorer:DatabaseID"), 32);
|
|
505
|
+
this.isAuthenticated = true;
|
|
506
|
+
(await this.loadCredentialEntries()).record(this.sessionCredential);
|
|
507
|
+
delete this.sessionCredential;
|
|
508
|
+
this.dispatchEvent("authenticated", true);
|
|
509
|
+
return "authenticated";
|
|
510
|
+
} catch (cause) {
|
|
511
|
+
throw unknownError(cause, "Explorer.authenticate()");
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Permanently revokes a credential and deletes its persisted data.
|
|
516
|
+
*
|
|
517
|
+
* Removes the credential from the database's registered credentials.
|
|
518
|
+
*
|
|
519
|
+
* @param credentialID The identifier of the credential to revoke.
|
|
520
|
+
* @throws {ExplorerError} If the database is not authenticated.
|
|
521
|
+
*/
|
|
522
|
+
static async permanentlyDeleteCredential(credentialID) {
|
|
523
|
+
try {
|
|
524
|
+
await (await this.loadCredentialEntries()).revoke(credentialID);
|
|
525
|
+
return;
|
|
526
|
+
} catch (cause) {
|
|
527
|
+
throw unknownError(cause, "Explorer.permanentlyDeleteCredential()");
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Returns the registered credentials and optionally observes their changes.
|
|
532
|
+
*
|
|
533
|
+
* The returned tuple contains a cleanup function, the current credential
|
|
534
|
+
* entries, and the identifier of the current session's credential.
|
|
535
|
+
*
|
|
536
|
+
* The cleanup function removes the registered change listener, if any.
|
|
537
|
+
*
|
|
538
|
+
* @param onChange An optional callback invoked when the entries change.
|
|
539
|
+
* @returns The cleanup function, credential entries, and session credential ID.
|
|
540
|
+
* @throws {ExplorerError} If the database is not authenticated.
|
|
541
|
+
*/
|
|
542
|
+
static async listCredentialEntries(onChange) {
|
|
543
|
+
try {
|
|
544
|
+
const credentialEntries = await this.loadCredentialEntries();
|
|
545
|
+
const listener = onChange ? ({ detail }) => onChange(detail) : void 0;
|
|
546
|
+
if (listener) credentialEntries.addEventListener("change", listener);
|
|
547
|
+
return [
|
|
548
|
+
() => {
|
|
549
|
+
if (listener) credentialEntries.removeEventListener("change", listener);
|
|
550
|
+
},
|
|
551
|
+
credentialEntries.list(),
|
|
552
|
+
credentialEntries.sessionCredentialID
|
|
553
|
+
];
|
|
554
|
+
} catch (cause) {
|
|
555
|
+
throw unknownError(cause, "Explorer.listCredentialEntries()");
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Returns the recently accessed objects and optionally observes their changes.
|
|
560
|
+
*
|
|
561
|
+
* The returned tuple contains a cleanup function and the current recent
|
|
562
|
+
* entries. The cleanup function removes the registered change listener,
|
|
563
|
+
* if any.
|
|
564
|
+
*
|
|
565
|
+
* @param onChange An optional callback invoked when the entries change.
|
|
566
|
+
* @returns The cleanup function and recent entries.
|
|
567
|
+
* @throws {ExplorerError} If the database is not authenticated.
|
|
568
|
+
*/
|
|
569
|
+
static async listRecentEntries(onChange) {
|
|
570
|
+
try {
|
|
571
|
+
const recentEntries = await this.loadRecentEntries();
|
|
572
|
+
const listener = onChange ? ({ detail }) => onChange(detail) : void 0;
|
|
573
|
+
if (listener) recentEntries.addEventListener("change", listener);
|
|
574
|
+
return [() => {
|
|
575
|
+
if (listener) recentEntries.removeEventListener("change", listener);
|
|
576
|
+
}, recentEntries.list()];
|
|
577
|
+
} catch (cause) {
|
|
578
|
+
throw unknownError(cause, "Explorer.listRecentEntries()");
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Loads an encrypted object using its identifier and cipher key.
|
|
583
|
+
*
|
|
584
|
+
* Caches the cipher key if the identifier has not been encountered before.
|
|
585
|
+
*
|
|
586
|
+
* @typeParam T The expected type of the loaded object.
|
|
587
|
+
* @param id The identifier of the object to load.
|
|
588
|
+
* @param cipherKey The key used to decrypt the object.
|
|
589
|
+
* @param onNetworkResponse An optional callback for network responses.
|
|
590
|
+
* @returns The loaded object, or `undefined` if no object is available.
|
|
591
|
+
*/
|
|
592
|
+
static async loadObjectByID(id, cipherKey, onNetworkResponse) {
|
|
593
|
+
try {
|
|
594
|
+
if (!this.cipherKeyCache.has(id)) this.cipherKeyCache.set(id, cipherKey);
|
|
595
|
+
return await loadObject(this.storageURL + id, cipherKey, false, onNetworkResponse);
|
|
596
|
+
} catch (cause) {
|
|
597
|
+
throw unknownError(cause, "Explorer.loadObjectByID()");
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Encrypts and stores an object using its identifier and cipher key.
|
|
602
|
+
*
|
|
603
|
+
* Caches the cipher key if the identifier has not been encountered before.
|
|
604
|
+
*
|
|
605
|
+
* @typeParam T The type of the object to store.
|
|
606
|
+
* @param id The identifier of the object to store.
|
|
607
|
+
* @param object The object to encrypt and store.
|
|
608
|
+
* @param cipherKey The key used to encrypt the object.
|
|
609
|
+
*/
|
|
610
|
+
static async storeObjectByID(id, object, cipherKey) {
|
|
611
|
+
try {
|
|
612
|
+
if (!this.cipherKeyCache.has(id)) this.cipherKeyCache.set(id, cipherKey);
|
|
613
|
+
await storeObject(this.storageURL + id, object, cipherKey);
|
|
614
|
+
return;
|
|
615
|
+
} catch (cause) {
|
|
616
|
+
throw unknownError(cause, "Explorer.storeObjectByID()");
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Deletes a persisted object using its identifier.
|
|
621
|
+
*
|
|
622
|
+
* @param id The identifier of the object to delete.
|
|
623
|
+
*/
|
|
624
|
+
static async deleteObjectByID(id) {
|
|
625
|
+
try {
|
|
626
|
+
await deleteObject(this.storageURL + id);
|
|
627
|
+
(await this.loadRecentEntries()).delete(id);
|
|
628
|
+
return;
|
|
629
|
+
} catch (cause) {
|
|
630
|
+
throw unknownError(cause, "Explorer.deleteObjectByID()");
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Schedules the application of a synchronized Explorer delta.
|
|
635
|
+
*
|
|
636
|
+
* Recognized deltas are forwarded to their corresponding entries list.
|
|
637
|
+
* Invalid or unrecognized delta types are ignored.
|
|
638
|
+
*
|
|
639
|
+
* Processing is scheduled in a microtask and is not awaited by the caller.
|
|
640
|
+
*
|
|
641
|
+
* @param data The delta to apply.
|
|
642
|
+
*/
|
|
643
|
+
static apply(data) {
|
|
644
|
+
queueMicrotask(async () => {
|
|
645
|
+
try {
|
|
646
|
+
if (!this.isExplorerDelta(data)) return;
|
|
647
|
+
switch (data[0]) {
|
|
648
|
+
case "recentEntries":
|
|
649
|
+
(await this.loadRecentEntries()).apply(data[1]);
|
|
650
|
+
break;
|
|
651
|
+
case "credentialEntries": (await this.loadCredentialEntries()).apply(data[1]);
|
|
652
|
+
}
|
|
653
|
+
return;
|
|
654
|
+
} catch (cause) {
|
|
655
|
+
throw unknownError(cause, "Explorer.apply()");
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Registers an event listener for the specified event type.
|
|
661
|
+
*
|
|
662
|
+
* @param type The event type.
|
|
663
|
+
* @param listener The event listener, or `null`.
|
|
664
|
+
* @param options The event listener options.
|
|
665
|
+
*/
|
|
666
|
+
static addEventListener(type, listener, options) {
|
|
667
|
+
try {
|
|
668
|
+
(this.eventTarget ??= new EventTarget()).addEventListener(type, listener, options);
|
|
669
|
+
} catch (cause) {
|
|
670
|
+
throw unknownError(cause, "Explorer.addEventListener()");
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Removes an event listener previously registered with
|
|
675
|
+
* {@link addEventListener}.
|
|
676
|
+
*
|
|
677
|
+
* @param type The event type.
|
|
678
|
+
* @param listener The event listener to remove, or `null`.
|
|
679
|
+
* @param options The event listener options.
|
|
680
|
+
*/
|
|
681
|
+
static removeEventListener(type, listener, options) {
|
|
682
|
+
try {
|
|
683
|
+
this.eventTarget?.removeEventListener(type, listener, options);
|
|
684
|
+
} catch (cause) {
|
|
685
|
+
throw unknownError(cause, "Explorer.removeEventListener()");
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Dispatches an event to the registered listeners.
|
|
690
|
+
*
|
|
691
|
+
* @param type The event type.
|
|
692
|
+
* @param detail The value exposed through `CustomEvent.detail`.
|
|
693
|
+
*/
|
|
694
|
+
static dispatchEvent(type, detail) {
|
|
695
|
+
try {
|
|
696
|
+
this.eventTarget?.dispatchEvent(new CustomEvent(type, { detail }));
|
|
697
|
+
} catch (cause) {
|
|
698
|
+
throw unknownError(cause, "Explorer.dispatchEvent()");
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Determines whether a value has the basic structure of an Explorer delta.
|
|
703
|
+
*
|
|
704
|
+
* @param data The value to inspect.
|
|
705
|
+
* @returns Whether the value has a string discriminator.
|
|
706
|
+
*/
|
|
707
|
+
static isExplorerDelta(data) {
|
|
708
|
+
return Array.isArray(data) && typeof data[0] === "string";
|
|
709
|
+
}
|
|
710
|
+
static recentEntries;
|
|
711
|
+
/**
|
|
712
|
+
* Loads or returns the cached recent entries list.
|
|
713
|
+
*
|
|
714
|
+
* Derives the list identifier and cipher key from the database secret
|
|
715
|
+
* and merges subsequent network responses into the loaded state.
|
|
716
|
+
*
|
|
717
|
+
* @returns The recent entries list.
|
|
718
|
+
* @throws {ExplorerError} If the database is not authenticated.
|
|
719
|
+
*/
|
|
720
|
+
static async loadRecentEntries() {
|
|
721
|
+
try {
|
|
722
|
+
if (!this.isAuthenticated) throw new ExplorerError("NOT_AUTHENTICATED");
|
|
723
|
+
if (this.recentEntries) return this.recentEntries;
|
|
724
|
+
const id = await Cryptographic.identifier.derive(this.databaseSecret, Bytes.utf8.decode("@sovereignbase/explorer:RecentEntriesID"), 32);
|
|
725
|
+
const ck = await Cryptographic.cipherMessage.deriveKey(this.databaseSecret, Bytes.utf8.decode("@sovereignbase/explorer:RecentEntriesCipherKey"));
|
|
726
|
+
const trustedSnapshot = await this.loadObjectByID(id, ck, (snapshot) => void this.recentEntries.merge(snapshot));
|
|
727
|
+
this.recentEntries = new RecentEntries(id, ck, 0, trustedSnapshot);
|
|
728
|
+
return this.recentEntries;
|
|
729
|
+
} catch (cause) {
|
|
730
|
+
throw unknownError(cause, "Explorer.loadRecentEntries()");
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
static credentialEntries;
|
|
734
|
+
/**
|
|
735
|
+
* Loads or returns the cached credential entries list.
|
|
736
|
+
*
|
|
737
|
+
* Derives the list identifier and cipher key from the database secret
|
|
738
|
+
* and merges subsequent network responses into the loaded state.
|
|
739
|
+
*
|
|
740
|
+
* @returns The credential entries list.
|
|
741
|
+
* @throws {ExplorerError} If the database is not authenticated.
|
|
742
|
+
*/
|
|
743
|
+
static async loadCredentialEntries() {
|
|
744
|
+
try {
|
|
745
|
+
if (!this.isAuthenticated) throw new ExplorerError("NOT_AUTHENTICATED");
|
|
746
|
+
if (this.credentialEntries) return this.credentialEntries;
|
|
747
|
+
const id = await Cryptographic.identifier.derive(this.databaseSecret, Bytes.utf8.decode("@sovereignbase/explorer:CredentialEntriesID"), 32);
|
|
748
|
+
const ck = await Cryptographic.cipherMessage.deriveKey(this.databaseSecret, Bytes.utf8.decode("@sovereignbase/explorer:CredentialEntriesCipherKey"));
|
|
749
|
+
const trustedSnapshot = await this.loadObjectByID(id, ck, (snapshot) => void this.credentialEntries.merge(snapshot));
|
|
750
|
+
this.credentialEntries = new CredentialEntries(id, ck, this.sessionCredential.id, 0, trustedSnapshot);
|
|
751
|
+
return this.credentialEntries;
|
|
752
|
+
} catch (cause) {
|
|
753
|
+
throw unknownError(cause, "Explorer.loadCredentialEntries()");
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
};
|
|
757
|
+
export { Explorer };
|
|
758
|
+
|
|
759
|
+
//# sourceMappingURL=class.js.map
|