@bota.dev/web-app-sdk 2.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +517 -0
  3. package/dist/capabilities.d.ts +8 -0
  4. package/dist/capabilities.js +21 -0
  5. package/dist/client.d.ts +39 -0
  6. package/dist/client.js +153 -0
  7. package/dist/controlManager.d.ts +38 -0
  8. package/dist/controlManager.js +344 -0
  9. package/dist/core.d.ts +698 -0
  10. package/dist/core.js +13 -0
  11. package/dist/deviceManager.d.ts +52 -0
  12. package/dist/deviceManager.js +491 -0
  13. package/dist/encryptedUploadV2Host.d.ts +239 -0
  14. package/dist/encryptedUploadV2Host.js +2136 -0
  15. package/dist/errors.d.ts +24 -0
  16. package/dist/errors.js +230 -0
  17. package/dist/gatt.d.ts +39 -0
  18. package/dist/gatt.js +57 -0
  19. package/dist/generated/bota_device_sdk_core.d.ts +202 -0
  20. package/dist/generated/bota_device_sdk_core.js +1025 -0
  21. package/dist/generated/bota_device_sdk_core_bg.wasm +0 -0
  22. package/dist/index.d.ts +13 -0
  23. package/dist/index.js +2 -0
  24. package/dist/indexedDbWorkflowStore.d.ts +52 -0
  25. package/dist/indexedDbWorkflowStore.js +763 -0
  26. package/dist/logManager.d.ts +24 -0
  27. package/dist/logManager.js +205 -0
  28. package/dist/models.d.ts +217 -0
  29. package/dist/models.js +1 -0
  30. package/dist/opfsBlobStore.d.ts +12 -0
  31. package/dist/opfsBlobStore.js +250 -0
  32. package/dist/otaManager.d.ts +49 -0
  33. package/dist/otaManager.js +951 -0
  34. package/dist/providerCancellation.d.ts +2 -0
  35. package/dist/providerCancellation.js +23 -0
  36. package/dist/providers.d.ts +138 -0
  37. package/dist/providers.js +1 -0
  38. package/dist/provisioningManager.d.ts +48 -0
  39. package/dist/provisioningManager.js +753 -0
  40. package/dist/recordingManager.d.ts +53 -0
  41. package/dist/recordingManager.js +1397 -0
  42. package/dist/storage.d.ts +103 -0
  43. package/dist/storage.js +60 -0
  44. package/dist/transport.d.ts +33 -0
  45. package/dist/transport.js +8 -0
  46. package/dist/wasmCore.d.ts +3 -0
  47. package/dist/wasmCore.js +1651 -0
  48. package/dist/webBluetoothTransport.d.ts +23 -0
  49. package/dist/webBluetoothTransport.js +369 -0
  50. package/dist/wifiManager.d.ts +54 -0
  51. package/dist/wifiManager.js +528 -0
  52. package/dist/workflowRuntime.d.ts +115 -0
  53. package/dist/workflowRuntime.js +1508 -0
  54. package/package.json +46 -0
@@ -0,0 +1,763 @@
1
+ const ERROR_MESSAGES = {
2
+ invalid_input: 'The storage operation contains invalid input.',
3
+ storage_unavailable: 'Durable browser storage is unavailable.',
4
+ storage_quota_exceeded: 'Durable browser storage quota was exceeded.',
5
+ resume_rejected: 'Persisted workflow state is incompatible.',
6
+ };
7
+ export class BrowserStorageError extends Error {
8
+ code;
9
+ constructor(code, options = {}) {
10
+ super(ERROR_MESSAGES[code], { cause: options.cause });
11
+ this.name = 'BrowserStorageError';
12
+ this.code = code;
13
+ }
14
+ }
15
+ export function mapBrowserStorageError(error) {
16
+ if (error instanceof BrowserStorageError)
17
+ return error;
18
+ if (domExceptionName(error) === 'QuotaExceededError') {
19
+ return new BrowserStorageError('storage_quota_exceeded', { cause: error });
20
+ }
21
+ if (domExceptionName(error) === 'VersionError')
22
+ return resumeRejected();
23
+ return new BrowserStorageError('storage_unavailable', { cause: error });
24
+ }
25
+ export function validateStorageNamespace(namespace) {
26
+ if (typeof namespace !== 'string'
27
+ || namespace.trim().length === 0
28
+ || namespace.length > 256
29
+ || !isWellFormedUtf16(namespace)) {
30
+ throw new BrowserStorageError('invalid_input');
31
+ }
32
+ }
33
+ export function validateStorageIdentifier(value) {
34
+ if (typeof value !== 'string'
35
+ || value.length === 0
36
+ || !isWellFormedUtf16(value)) {
37
+ throw new BrowserStorageError('invalid_input');
38
+ }
39
+ return value;
40
+ }
41
+ const DATABASE_NAME = 'bota-app-sdk';
42
+ const DATABASE_VERSION = 1;
43
+ const STORE_NAMES = [
44
+ 'verified_devices',
45
+ 'workflow_checkpoints',
46
+ 'encrypted_upload_v2_checkpoints',
47
+ 'recording_journals',
48
+ 'provisioning_journals',
49
+ 'firmware_journals',
50
+ ];
51
+ const RECORDING_PHASES = [
52
+ 'prepared',
53
+ 'transferring',
54
+ 'staged',
55
+ 'uploading',
56
+ 'cloud_completed',
57
+ 'confirmed',
58
+ ];
59
+ export class IndexedDbWorkflowStore {
60
+ namespace;
61
+ indexedDB;
62
+ keyRange;
63
+ namespacePrefix;
64
+ databasePromise = null;
65
+ databaseInvalidated = false;
66
+ constructor(namespace, indexedDB, keyRange) {
67
+ validateStorageNamespace(namespace);
68
+ this.namespace = namespace;
69
+ this.indexedDB = indexedDB;
70
+ this.keyRange = keyRange;
71
+ this.namespacePrefix = `v1:${encodeKeyPart(namespace)}:`;
72
+ }
73
+ async loadVerifiedDevice(serialNumber) {
74
+ const value = await this.get('verified_devices', serialNumber);
75
+ if (value === undefined)
76
+ return null;
77
+ const hint = verifiedDevice(value);
78
+ if (hint.serialNumber !== serialNumber)
79
+ throw resumeRejected();
80
+ return hint;
81
+ }
82
+ async saveVerifiedDevice(value) {
83
+ const sanitized = verifiedDevice(value);
84
+ await this.put('verified_devices', sanitized.serialNumber, sanitized);
85
+ }
86
+ async deleteVerifiedDevice(serialNumber) {
87
+ await this.delete('verified_devices', serialNumber);
88
+ }
89
+ async loadWorkflowCheckpoint(operationId) {
90
+ const value = await this.get('workflow_checkpoints', operationId);
91
+ if (value === undefined)
92
+ return null;
93
+ return workflowCheckpoint(value, operationId).checkpoint;
94
+ }
95
+ async saveWorkflowCheckpoint(operationId, checkpoint) {
96
+ const record = {
97
+ schemaVersion: 1,
98
+ operationId: validIdentifier(operationId),
99
+ checkpoint,
100
+ };
101
+ await this.put('workflow_checkpoints', operationId, record);
102
+ }
103
+ async deleteWorkflowCheckpoint(operationId) {
104
+ await this.delete('workflow_checkpoints', operationId);
105
+ }
106
+ async loadEncryptedUploadV2Checkpoint(operationId) {
107
+ const value = await this.get('encrypted_upload_v2_checkpoints', operationId);
108
+ if (value === undefined)
109
+ return null;
110
+ return encryptedUploadV2Checkpoint(value, operationId).checkpoint;
111
+ }
112
+ async saveEncryptedUploadV2Checkpoint(operationId, checkpoint) {
113
+ const record = {
114
+ schemaVersion: 1,
115
+ operationId: validIdentifier(operationId),
116
+ checkpoint,
117
+ };
118
+ await this.put('encrypted_upload_v2_checkpoints', operationId, record);
119
+ }
120
+ async deleteEncryptedUploadV2Checkpoint(operationId) {
121
+ await this.delete('encrypted_upload_v2_checkpoints', operationId);
122
+ }
123
+ async saveEncryptedUploadV2Operation(operationId, checkpoint, journal) {
124
+ const id = validIdentifier(operationId);
125
+ const sanitizedJournal = recordingJournal(journal);
126
+ if (sanitizedJournal.operationId !== id
127
+ || sanitizedJournal.profile !== 'encrypted_upload_v2'
128
+ || sanitizedJournal.phase !== 'prepared')
129
+ throw resumeRejected();
130
+ const checkpointRecord = {
131
+ schemaVersion: 1,
132
+ operationId: id,
133
+ checkpoint,
134
+ };
135
+ const key = this.key(id);
136
+ try {
137
+ const database = await this.database();
138
+ const transaction = database.transaction(['encrypted_upload_v2_checkpoints', 'recording_journals'], 'readwrite');
139
+ const completion = transactionCompletion(transaction);
140
+ const checkpointStore = transaction.objectStore('encrypted_upload_v2_checkpoints');
141
+ const journalStore = transaction.objectStore('recording_journals');
142
+ try {
143
+ const [existingCheckpoint, existingJournal] = await Promise.all([
144
+ requestResult(checkpointStore.get(key)),
145
+ requestResult(journalStore.get(key)),
146
+ ]);
147
+ if (existingCheckpoint !== undefined || existingJournal !== undefined) {
148
+ throw resumeRejected();
149
+ }
150
+ checkpointStore.put(checkpointRecord, key);
151
+ journalStore.put(sanitizedJournal, key);
152
+ }
153
+ catch (error) {
154
+ transaction.abort();
155
+ await completion.catch(() => undefined);
156
+ throw error;
157
+ }
158
+ await completion;
159
+ }
160
+ catch (error) {
161
+ throw mapBrowserStorageError(error);
162
+ }
163
+ }
164
+ async deleteEncryptedUploadV2Operation(operationId) {
165
+ const key = this.key(operationId);
166
+ try {
167
+ const database = await this.database();
168
+ const transaction = database.transaction(['encrypted_upload_v2_checkpoints', 'recording_journals'], 'readwrite');
169
+ const completion = transactionCompletion(transaction);
170
+ transaction.objectStore('encrypted_upload_v2_checkpoints').delete(key);
171
+ transaction.objectStore('recording_journals').delete(key);
172
+ await completion;
173
+ }
174
+ catch (error) {
175
+ throw mapBrowserStorageError(error);
176
+ }
177
+ }
178
+ async loadRecordingJournal(operationId) {
179
+ const value = await this.get('recording_journals', operationId);
180
+ if (value === undefined)
181
+ return null;
182
+ const journal = recordingJournal(value);
183
+ if (journal.operationId !== operationId)
184
+ throw resumeRejected();
185
+ return journal;
186
+ }
187
+ async saveRecordingJournal(journal) {
188
+ const sanitized = recordingJournal(journal);
189
+ await this.saveMonotonicJournal('recording_journals', sanitized.operationId, sanitized, (existing) => {
190
+ const previous = recordingJournal(existing);
191
+ if (previous.operationId !== sanitized.operationId
192
+ || previous.serialNumber !== sanitized.serialNumber
193
+ || previous.recordingUuid !== sanitized.recordingUuid
194
+ || previous.profile !== sanitized.profile
195
+ || previous.sinkId !== sanitized.sinkId) {
196
+ throw resumeRejected();
197
+ }
198
+ assertNondecreasingTimestamp(previous, sanitized);
199
+ const reconciledNotUploaded = previous.profile === 'legacy'
200
+ && previous.phase === 'uploading'
201
+ && sanitized.phase === 'staged'
202
+ && sanitized.uploadId === null
203
+ && sanitized.cloudCompletionId === null
204
+ && sanitized.confirmationDigestHex === null;
205
+ if (!reconciledNotUploaded) {
206
+ assertEstablishedEvidence(previous.uploadId, sanitized.uploadId);
207
+ }
208
+ assertEstablishedEvidence(previous.cloudCompletionId, sanitized.cloudCompletionId);
209
+ assertEstablishedEvidence(previous.confirmationDigestHex, sanitized.confirmationDigestHex);
210
+ if (RECORDING_PHASES.indexOf(previous.phase)
211
+ >= RECORDING_PHASES.indexOf('staged')
212
+ && previous.devicePlaintextSha256Hex
213
+ !== sanitized.devicePlaintextSha256Hex) {
214
+ throw resumeRejected();
215
+ }
216
+ if (!reconciledNotUploaded) {
217
+ assertNextPhase(RECORDING_PHASES, previous.phase, sanitized.phase);
218
+ }
219
+ }, () => {
220
+ if (sanitized.phase !== 'prepared')
221
+ throw resumeRejected();
222
+ });
223
+ }
224
+ async listRecordingJournals() {
225
+ const entries = await this.getAll('recording_journals');
226
+ return entries
227
+ .map(({ key, value }) => {
228
+ const journal = recordingJournal(value);
229
+ if (key !== this.key(journal.operationId))
230
+ throw resumeRejected();
231
+ return journal;
232
+ })
233
+ .sort((left, right) => left.operationId.localeCompare(right.operationId));
234
+ }
235
+ async deleteRecordingJournal(operationId) {
236
+ await this.delete('recording_journals', operationId);
237
+ }
238
+ async loadProvisioningJournal(attemptId) {
239
+ const value = await this.get('provisioning_journals', attemptId);
240
+ if (value === undefined)
241
+ return null;
242
+ const journal = provisioningJournal(value);
243
+ if (journal.attemptId !== attemptId)
244
+ throw resumeRejected();
245
+ return journal;
246
+ }
247
+ async saveProvisioningJournal(journal) {
248
+ const sanitized = provisioningJournal(journal);
249
+ await this.saveMonotonicJournal('provisioning_journals', sanitized.attemptId, sanitized, (existing) => {
250
+ const previous = provisioningJournal(existing);
251
+ if (previous.attemptId !== sanitized.attemptId
252
+ || previous.materialId !== sanitized.materialId
253
+ || previous.serialNumber !== sanitized.serialNumber) {
254
+ throw resumeRejected();
255
+ }
256
+ assertNondecreasingTimestamp(previous, sanitized);
257
+ if (previous.phase === sanitized.phase)
258
+ return;
259
+ const permitted = (previous.phase === 'prepared'
260
+ && (sanitized.phase === 'device_applied' || sanitized.phase === 'aborted'))
261
+ || (previous.phase === 'device_applied'
262
+ && sanitized.phase === 'backend_confirmed');
263
+ if (!permitted)
264
+ throw resumeRejected();
265
+ }, () => {
266
+ if (sanitized.phase !== 'prepared')
267
+ throw resumeRejected();
268
+ });
269
+ }
270
+ async listProvisioningJournals() {
271
+ const entries = await this.getAll('provisioning_journals');
272
+ return entries
273
+ .map(({ key, value }) => {
274
+ const journal = provisioningJournal(value);
275
+ if (key !== this.key(journal.attemptId))
276
+ throw resumeRejected();
277
+ return journal;
278
+ })
279
+ .sort((left, right) => left.attemptId.localeCompare(right.attemptId));
280
+ }
281
+ async deleteProvisioningJournal(attemptId) {
282
+ await this.delete('provisioning_journals', attemptId);
283
+ }
284
+ async loadFirmwareJournal(operationId) {
285
+ const value = await this.get('firmware_journals', operationId);
286
+ if (value === undefined)
287
+ return null;
288
+ const journal = firmwareJournal(value);
289
+ if (journal.operationId !== operationId)
290
+ throw resumeRejected();
291
+ return journal;
292
+ }
293
+ async saveFirmwareJournal(journal) {
294
+ const sanitized = firmwareJournal(journal);
295
+ await this.saveMonotonicJournal('firmware_journals', sanitized.operationId, sanitized, (value) => {
296
+ const existing = firmwareJournal(value);
297
+ if (existing.operationId !== sanitized.operationId
298
+ || existing.serialNumber !== sanitized.serialNumber
299
+ || existing.imageId !== sanitized.imageId
300
+ || existing.downloadId !== sanitized.downloadId
301
+ || existing.version !== sanitized.version
302
+ || existing.sizeBytes !== sanitized.sizeBytes
303
+ || existing.crc32 !== sanitized.crc32
304
+ || existing.sha256Hex !== sanitized.sha256Hex
305
+ || existing.blobId !== sanitized.blobId
306
+ || sanitized.downloadedBytes < existing.downloadedBytes
307
+ || (existing.verified && !sanitized.verified)
308
+ || (firmwareJournalState(existing) === 'cleanup_only'
309
+ && firmwareJournalState(sanitized) !== 'cleanup_only')) {
310
+ throw resumeRejected();
311
+ }
312
+ assertNondecreasingTimestamp(existing, sanitized);
313
+ }, () => {
314
+ if (firmwareJournalState(sanitized) !== 'active')
315
+ throw resumeRejected();
316
+ });
317
+ }
318
+ async deleteFirmwareJournal(operationId) {
319
+ await this.delete('firmware_journals', operationId);
320
+ }
321
+ async clear() {
322
+ try {
323
+ const database = await this.database();
324
+ const transaction = database.transaction([...STORE_NAMES], 'readwrite');
325
+ const completion = transactionCompletion(transaction);
326
+ const range = this.namespaceRange();
327
+ for (const storeName of STORE_NAMES) {
328
+ transaction.objectStore(storeName).delete(range);
329
+ }
330
+ await completion;
331
+ }
332
+ catch (error) {
333
+ throw mapBrowserStorageError(error);
334
+ }
335
+ }
336
+ async get(storeName, id) {
337
+ const key = this.key(id);
338
+ try {
339
+ const database = await this.database();
340
+ const transaction = database.transaction(storeName, 'readonly');
341
+ const completion = transactionCompletion(transaction);
342
+ const request = requestResult(transaction.objectStore(storeName).get(key));
343
+ const [value] = await Promise.all([request, completion]);
344
+ return value;
345
+ }
346
+ catch (error) {
347
+ throw mapBrowserStorageError(error);
348
+ }
349
+ }
350
+ async getAll(storeName) {
351
+ try {
352
+ const database = await this.database();
353
+ const transaction = database.transaction(storeName, 'readonly');
354
+ const completion = transactionCompletion(transaction);
355
+ const store = transaction.objectStore(storeName);
356
+ const range = this.namespaceRange();
357
+ const [keys, values] = await Promise.all([
358
+ requestResult(store.getAllKeys(range)),
359
+ requestResult(store.getAll(range)),
360
+ completion,
361
+ ]);
362
+ if (keys.length !== values.length)
363
+ throw resumeRejected();
364
+ return values.map((value, index) => {
365
+ const key = keys[index];
366
+ if (key === undefined)
367
+ throw resumeRejected();
368
+ return { key, value };
369
+ });
370
+ }
371
+ catch (error) {
372
+ throw mapBrowserStorageError(error);
373
+ }
374
+ }
375
+ async put(storeName, id, value) {
376
+ const key = this.key(id);
377
+ try {
378
+ const database = await this.database();
379
+ const transaction = database.transaction(storeName, 'readwrite');
380
+ const completion = transactionCompletion(transaction);
381
+ transaction.objectStore(storeName).put(value, key);
382
+ await completion;
383
+ }
384
+ catch (error) {
385
+ throw mapBrowserStorageError(error);
386
+ }
387
+ }
388
+ async delete(storeName, id) {
389
+ const key = this.key(id);
390
+ try {
391
+ const database = await this.database();
392
+ const transaction = database.transaction(storeName, 'readwrite');
393
+ const completion = transactionCompletion(transaction);
394
+ transaction.objectStore(storeName).delete(key);
395
+ await completion;
396
+ }
397
+ catch (error) {
398
+ throw mapBrowserStorageError(error);
399
+ }
400
+ }
401
+ async saveMonotonicJournal(storeName, id, value, validateExisting, validateInitial) {
402
+ const key = this.key(id);
403
+ try {
404
+ const database = await this.database();
405
+ const transaction = database.transaction(storeName, 'readwrite');
406
+ const completion = transactionCompletion(transaction);
407
+ const store = transaction.objectStore(storeName);
408
+ try {
409
+ const existing = await requestResult(store.get(key));
410
+ if (existing === undefined)
411
+ validateInitial();
412
+ else
413
+ validateExisting(existing);
414
+ store.put(value, key);
415
+ }
416
+ catch (error) {
417
+ await completion.catch(() => undefined);
418
+ throw error;
419
+ }
420
+ await completion;
421
+ }
422
+ catch (error) {
423
+ throw mapBrowserStorageError(error);
424
+ }
425
+ }
426
+ key(id) {
427
+ return `${this.namespacePrefix}${encodeKeyPart(validIdentifier(id))}`;
428
+ }
429
+ namespaceRange() {
430
+ return this.keyRange.bound(this.namespacePrefix, `${this.namespacePrefix}\uffff`);
431
+ }
432
+ async database() {
433
+ if (this.databaseInvalidated)
434
+ throw resumeRejected();
435
+ if (!this.databasePromise) {
436
+ this.databasePromise = this.openDatabase().catch((error) => {
437
+ this.databasePromise = null;
438
+ throw error;
439
+ });
440
+ }
441
+ const database = await this.databasePromise;
442
+ if (this.databaseInvalidated)
443
+ throw resumeRejected();
444
+ return database;
445
+ }
446
+ async openDatabase() {
447
+ try {
448
+ return await new Promise((resolve, reject) => {
449
+ const request = this.indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
450
+ request.addEventListener('upgradeneeded', () => {
451
+ for (const storeName of STORE_NAMES) {
452
+ if (!request.result.objectStoreNames.contains(storeName)) {
453
+ request.result.createObjectStore(storeName);
454
+ }
455
+ }
456
+ });
457
+ request.addEventListener('success', () => {
458
+ const database = request.result;
459
+ try {
460
+ assertCompatibleDatabase(database);
461
+ database.addEventListener('versionchange', () => {
462
+ this.databaseInvalidated = true;
463
+ this.databasePromise = null;
464
+ database.close();
465
+ });
466
+ resolve(database);
467
+ }
468
+ catch (error) {
469
+ database.close();
470
+ reject(error);
471
+ }
472
+ }, { once: true });
473
+ request.addEventListener('error', () => reject(request.error), { once: true });
474
+ request.addEventListener('blocked', () => {
475
+ reject(new BrowserStorageError('storage_unavailable'));
476
+ }, { once: true });
477
+ });
478
+ }
479
+ catch (error) {
480
+ throw mapBrowserStorageError(error);
481
+ }
482
+ }
483
+ }
484
+ function assertCompatibleDatabase(database) {
485
+ const actualStoreNames = Array.from(database.objectStoreNames);
486
+ if (database.version !== DATABASE_VERSION
487
+ || actualStoreNames.length !== STORE_NAMES.length
488
+ || STORE_NAMES.some((storeName) => !database.objectStoreNames.contains(storeName))) {
489
+ throw resumeRejected();
490
+ }
491
+ let transaction;
492
+ try {
493
+ transaction = database.transaction([...STORE_NAMES], 'readonly');
494
+ }
495
+ catch {
496
+ throw resumeRejected();
497
+ }
498
+ for (const storeName of STORE_NAMES) {
499
+ const store = transaction.objectStore(storeName);
500
+ if (store.name !== storeName
501
+ || store.keyPath !== null
502
+ || store.autoIncrement
503
+ || store.indexNames.length !== 0) {
504
+ throw resumeRejected();
505
+ }
506
+ }
507
+ }
508
+ function workflowCheckpoint(value, operationId) {
509
+ const record = versionedRecord(value);
510
+ if (record.operationId !== operationId
511
+ || !Object.hasOwn(record, 'checkpoint')) {
512
+ throw resumeRejected();
513
+ }
514
+ return {
515
+ schemaVersion: 1,
516
+ operationId,
517
+ checkpoint: record.checkpoint,
518
+ };
519
+ }
520
+ function encryptedUploadV2Checkpoint(value, operationId) {
521
+ return workflowCheckpoint(value, operationId);
522
+ }
523
+ function verifiedDevice(value) {
524
+ const record = versionedRecord(value);
525
+ return {
526
+ schemaVersion: 1,
527
+ serialNumber: recordString(record, 'serialNumber'),
528
+ browserDeviceId: recordString(record, 'browserDeviceId'),
529
+ name: nullableDisplayName(record.name),
530
+ updatedAtEpochMs: safeNonnegativeInteger(record.updatedAtEpochMs),
531
+ };
532
+ }
533
+ function recordingJournal(value) {
534
+ const record = versionedRecord(value);
535
+ const phase = record.phase;
536
+ const profile = record.profile;
537
+ if (!RECORDING_PHASES.includes(phase)) {
538
+ throw resumeRejected();
539
+ }
540
+ if (profile !== 'legacy' && profile !== 'encrypted_upload_v2') {
541
+ throw resumeRejected();
542
+ }
543
+ const journal = {
544
+ schemaVersion: 1,
545
+ operationId: recordString(record, 'operationId'),
546
+ serialNumber: recordString(record, 'serialNumber'),
547
+ recordingUuid: recordString(record, 'recordingUuid'),
548
+ profile,
549
+ phase: phase,
550
+ sinkId: recordString(record, 'sinkId'),
551
+ uploadId: nullableIdentifier(record.uploadId),
552
+ cloudCompletionId: nullableIdentifier(record.cloudCompletionId),
553
+ confirmationDigestHex: nullableDigest(record.confirmationDigestHex),
554
+ devicePlaintextSha256Hex: Object.hasOwn(record, 'devicePlaintextSha256Hex')
555
+ ? nullableDigest(record.devicePlaintextSha256Hex)
556
+ : null,
557
+ updatedAtEpochMs: safeNonnegativeInteger(record.updatedAtEpochMs),
558
+ };
559
+ assertRecordingEvidence(journal);
560
+ return journal;
561
+ }
562
+ function provisioningJournal(value) {
563
+ const record = versionedRecord(value);
564
+ const phase = record.phase;
565
+ if (phase !== 'prepared'
566
+ && phase !== 'device_applied'
567
+ && phase !== 'backend_confirmed'
568
+ && phase !== 'aborted') {
569
+ throw resumeRejected();
570
+ }
571
+ return {
572
+ schemaVersion: 1,
573
+ attemptId: recordString(record, 'attemptId'),
574
+ materialId: record.materialId === undefined || record.materialId === null
575
+ ? null
576
+ : recordString(record, 'materialId'),
577
+ serialNumber: recordString(record, 'serialNumber'),
578
+ phase,
579
+ updatedAtEpochMs: safeNonnegativeInteger(record.updatedAtEpochMs),
580
+ };
581
+ }
582
+ function firmwareJournal(value) {
583
+ const record = versionedRecord(value);
584
+ if (typeof record.downloadId !== 'bigint' || record.downloadId < 0n) {
585
+ throw resumeRejected();
586
+ }
587
+ if (typeof record.verified !== 'boolean')
588
+ throw resumeRejected();
589
+ const sizeBytes = safeNonnegativeInteger(record.sizeBytes);
590
+ const downloadedBytes = safeNonnegativeInteger(record.downloadedBytes);
591
+ if (downloadedBytes > sizeBytes)
592
+ throw resumeRejected();
593
+ const state = record.state;
594
+ if (state !== undefined && state !== 'active' && state !== 'cleanup_only') {
595
+ throw resumeRejected();
596
+ }
597
+ if (state === 'cleanup_only'
598
+ && (!record.verified || downloadedBytes !== sizeBytes)) {
599
+ throw resumeRejected();
600
+ }
601
+ return {
602
+ schemaVersion: 1,
603
+ operationId: recordString(record, 'operationId'),
604
+ serialNumber: recordString(record, 'serialNumber'),
605
+ imageId: recordString(record, 'imageId'),
606
+ downloadId: record.downloadId,
607
+ version: recordString(record, 'version'),
608
+ sizeBytes,
609
+ crc32: unsigned32(record.crc32),
610
+ sha256Hex: digest(record.sha256Hex),
611
+ blobId: recordString(record, 'blobId'),
612
+ downloadedBytes,
613
+ verified: record.verified,
614
+ ...(state === undefined ? {} : { state }),
615
+ updatedAtEpochMs: safeNonnegativeInteger(record.updatedAtEpochMs),
616
+ };
617
+ }
618
+ function firmwareJournalState(journal) {
619
+ return journal.state ?? 'active';
620
+ }
621
+ function versionedRecord(value) {
622
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
623
+ throw resumeRejected();
624
+ }
625
+ const record = value;
626
+ if (record.schemaVersion !== 1)
627
+ throw resumeRejected();
628
+ return record;
629
+ }
630
+ function recordString(record, key) {
631
+ return validPersistedIdentifier(record[key]);
632
+ }
633
+ function validIdentifier(value) {
634
+ return validateStorageIdentifier(value);
635
+ }
636
+ function validPersistedIdentifier(value) {
637
+ if (typeof value !== 'string'
638
+ || value.length === 0
639
+ || !isWellFormedUtf16(value)) {
640
+ throw resumeRejected();
641
+ }
642
+ return value;
643
+ }
644
+ function nullableIdentifier(value) {
645
+ if (value === null)
646
+ return null;
647
+ return validPersistedIdentifier(value);
648
+ }
649
+ function nullableDisplayName(value) {
650
+ if (value === null || typeof value === 'string')
651
+ return value;
652
+ throw resumeRejected();
653
+ }
654
+ function nullableDigest(value) {
655
+ return value === null ? null : digest(value);
656
+ }
657
+ function digest(value) {
658
+ if (typeof value !== 'string' || !/^[0-9a-f]{64}$/.test(value)) {
659
+ throw resumeRejected();
660
+ }
661
+ return value;
662
+ }
663
+ function safeNonnegativeInteger(value) {
664
+ if (!Number.isSafeInteger(value) || value < 0) {
665
+ throw resumeRejected();
666
+ }
667
+ return value;
668
+ }
669
+ function unsigned32(value) {
670
+ const result = safeNonnegativeInteger(value);
671
+ if (result > 0xffff_ffff)
672
+ throw resumeRejected();
673
+ return result;
674
+ }
675
+ function assertNextPhase(phases, previous, next) {
676
+ const previousIndex = phases.indexOf(previous);
677
+ const nextIndex = phases.indexOf(next);
678
+ if (nextIndex !== previousIndex && nextIndex !== previousIndex + 1) {
679
+ throw resumeRejected();
680
+ }
681
+ }
682
+ function assertNondecreasingTimestamp(previous, next) {
683
+ if (next.updatedAtEpochMs < previous.updatedAtEpochMs)
684
+ throw resumeRejected();
685
+ }
686
+ function assertEstablishedEvidence(previous, next) {
687
+ if (previous !== null && previous !== next)
688
+ throw resumeRejected();
689
+ }
690
+ function assertRecordingEvidence(journal) {
691
+ const phaseIndex = RECORDING_PHASES.indexOf(journal.phase);
692
+ const uploadingIndex = RECORDING_PHASES.indexOf('uploading');
693
+ const cloudCompletedIndex = RECORDING_PHASES.indexOf('cloud_completed');
694
+ if (phaseIndex < uploadingIndex && journal.uploadId !== null) {
695
+ throw resumeRejected();
696
+ }
697
+ if (phaseIndex < cloudCompletedIndex
698
+ && (journal.cloudCompletionId !== null || journal.confirmationDigestHex !== null)) {
699
+ throw resumeRejected();
700
+ }
701
+ if (phaseIndex < RECORDING_PHASES.indexOf('staged')
702
+ && journal.devicePlaintextSha256Hex !== null) {
703
+ throw resumeRejected();
704
+ }
705
+ if (journal.profile === 'legacy') {
706
+ if (phaseIndex >= uploadingIndex && journal.uploadId === null) {
707
+ throw resumeRejected();
708
+ }
709
+ if (phaseIndex >= cloudCompletedIndex && journal.cloudCompletionId === null) {
710
+ throw resumeRejected();
711
+ }
712
+ if (journal.confirmationDigestHex !== null)
713
+ throw resumeRejected();
714
+ return;
715
+ }
716
+ if (phaseIndex >= cloudCompletedIndex
717
+ && journal.confirmationDigestHex === null) {
718
+ throw resumeRejected();
719
+ }
720
+ }
721
+ function encodeKeyPart(value) {
722
+ if (!isWellFormedUtf16(value)) {
723
+ throw new BrowserStorageError('invalid_input');
724
+ }
725
+ const bytes = new TextEncoder().encode(value);
726
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
727
+ return `${bytes.byteLength}:${hex}`;
728
+ }
729
+ function isWellFormedUtf16(value) {
730
+ for (let index = 0; index < value.length; index += 1) {
731
+ const codeUnit = value.charCodeAt(index);
732
+ if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
733
+ const next = value.charCodeAt(index + 1);
734
+ if (!(next >= 0xdc00 && next <= 0xdfff))
735
+ return false;
736
+ index += 1;
737
+ }
738
+ else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
739
+ return false;
740
+ }
741
+ }
742
+ return true;
743
+ }
744
+ async function requestResult(request) {
745
+ return await new Promise((resolve, reject) => {
746
+ request.addEventListener('success', () => resolve(request.result), { once: true });
747
+ request.addEventListener('error', () => reject(request.error), { once: true });
748
+ });
749
+ }
750
+ async function transactionCompletion(transaction) {
751
+ await new Promise((resolve, reject) => {
752
+ transaction.addEventListener('complete', () => resolve(), { once: true });
753
+ transaction.addEventListener('abort', () => reject(transaction.error), { once: true });
754
+ });
755
+ }
756
+ function resumeRejected() {
757
+ return new BrowserStorageError('resume_rejected');
758
+ }
759
+ function domExceptionName(error) {
760
+ if (typeof error !== 'object' || error === null || !('name' in error))
761
+ return null;
762
+ return typeof error.name === 'string' ? error.name : null;
763
+ }