@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,1397 @@
1
+ import { BotaSDKError, normalizeCoreError, } from "./errors.js";
2
+ import { BOTA_STORAGE_SERVICE, DEVICE_INFORMATION_SERVICE, ENCRYPTED_UPLOAD_V2_CAPABILITY_CHARACTERISTIC, RECORDING_LIST_CHARACTERISTIC, SERIAL_NUMBER_CHARACTERISTIC, TRANSFER_CONTROL_CHARACTERISTIC, } from "./gatt.js";
3
+ import { EncryptedUploadV2Host, EncryptedUploadV2TransferControl, parsePersistedEncryptedUploadV2State, } from "./encryptedUploadV2Host.js";
4
+ import { awaitProviderCall } from "./providerCancellation.js";
5
+ import { BrowserStorageError, } from "./storage.js";
6
+ import { BrowserTransportError, } from "./transport.js";
7
+ import { createBrowserPersistenceHost, } from "./workflowRuntime.js";
8
+ const OPFS_STREAM_CHUNK_SIZE = 64 * 1024;
9
+ export class RecordingManager {
10
+ core;
11
+ transport;
12
+ runtime;
13
+ devices;
14
+ storage;
15
+ uploadProvider;
16
+ fetcher;
17
+ now;
18
+ activeOperations = new Map();
19
+ destroyed = false;
20
+ destroyPromise = null;
21
+ constructor(core, transport, runtime, devices, storage, uploadProvider, fetcher = globalThis.fetch.bind(globalThis), now = Date.now) {
22
+ this.core = core;
23
+ this.transport = transport;
24
+ this.runtime = runtime;
25
+ this.devices = devices;
26
+ this.storage = storage;
27
+ this.uploadProvider = uploadProvider;
28
+ this.fetcher = fetcher;
29
+ this.now = now;
30
+ }
31
+ async list() {
32
+ return await this.listWithSignal();
33
+ }
34
+ async listWithSignal(externalSignal) {
35
+ this.ensureAvailable('transfer_recording');
36
+ return await this.withVerifiedConnection('transfer_recording', async ({ device }, signal) => {
37
+ const capability = await this.readOptionalV2Capability(device, signal);
38
+ if (capability
39
+ && this.core.supportsEncryptedUploadV2Batch(capability)) {
40
+ const control = new EncryptedUploadV2TransferControl(this.core, this.transport, device, () => this.runtime.poisonBleOwnership(device.id));
41
+ const listed = await control.list(randomTransportSessionId(), signal);
42
+ return listed.entries.map(encryptedDeviceRecording);
43
+ }
44
+ let subscription = null;
45
+ const notification = deferred();
46
+ let received = false;
47
+ try {
48
+ subscription = await this.transport.subscribe(device, BOTA_STORAGE_SERVICE, RECORDING_LIST_CHARACTERISTIC, ({ characteristicUuid, value }) => {
49
+ if (received
50
+ || characteristicUuid.toLowerCase()
51
+ !== RECORDING_LIST_CHARACTERISTIC) {
52
+ return;
53
+ }
54
+ received = true;
55
+ notification.resolve(value.slice());
56
+ });
57
+ throwIfAborted(signal, 'transfer_recording');
58
+ await awaitWithSignal(this.transport.write(device, BOTA_STORAGE_SERVICE, TRANSFER_CONTROL_CHARACTERISTIC, this.core.encodeRecordingListCommand(), true), signal, 'transfer_recording');
59
+ const bytes = await awaitWithSignal(notification.promise, signal, 'transfer_recording');
60
+ try {
61
+ return this.core.decodeRecordingList(bytes).map(deviceRecording);
62
+ }
63
+ catch {
64
+ throw new BotaSDKError('protocol_error', 'transfer_recording');
65
+ }
66
+ }
67
+ finally {
68
+ await subscription?.remove();
69
+ }
70
+ }, externalSignal);
71
+ }
72
+ async sync(recording, options) {
73
+ this.ensureAvailable('transfer_recording');
74
+ if (options.profile === 'encrypted_upload_v2') {
75
+ validateEncryptedUploadV2Recording(recording);
76
+ const operationId = options.operationId ?? createOperationId();
77
+ validateOperationId(operationId);
78
+ throwIfAborted(options.signal, 'transfer_recording');
79
+ return await this.runManagedOperation(operationId, options.signal, async (signal) => await this.startEncryptedUploadV2(operationId, recording, signal, options.onProgress));
80
+ }
81
+ if (options.profile !== 'legacy') {
82
+ throw new BotaSDKError('invalid_input', 'transfer_recording');
83
+ }
84
+ validateRecording(recording);
85
+ const storage = this.requireStorage();
86
+ this.requireUploadProvider();
87
+ throwIfAborted(options.signal, 'transfer_recording');
88
+ const operationId = options.operationId ?? createOperationId();
89
+ validateOperationId(operationId);
90
+ return await this.runManagedOperation(operationId, options.signal, async (signal) => {
91
+ const existing = await storage.loadRecordingJournal(operationId);
92
+ throwIfAborted(signal, 'transfer_recording');
93
+ if (existing) {
94
+ throw new BotaSDKError('resume_rejected', 'transfer_recording');
95
+ }
96
+ const connection = await this.verifyConnection(signal);
97
+ throwIfAborted(signal, 'transfer_recording');
98
+ const prepared = {
99
+ schemaVersion: 1,
100
+ operationId,
101
+ serialNumber: connection.serialNumber,
102
+ recordingUuid: recording.uuid,
103
+ profile: 'legacy',
104
+ phase: 'prepared',
105
+ sinkId: sinkId(operationId),
106
+ uploadId: null,
107
+ cloudCompletionId: null,
108
+ confirmationDigestHex: null,
109
+ devicePlaintextSha256Hex: null,
110
+ updatedAtEpochMs: this.now(),
111
+ };
112
+ try {
113
+ await storage.saveRecordingJournal(prepared);
114
+ throwIfAborted(signal, 'transfer_recording');
115
+ return await this.transferLegacy(prepared, recording, signal, options.onProgress);
116
+ }
117
+ catch (error) {
118
+ const normalized = recordingError(error, 'transfer_recording');
119
+ if (normalized.code === 'cancelled') {
120
+ const current = await storage.loadRecordingJournal(operationId)
121
+ .catch(() => null);
122
+ if (current) {
123
+ await this.deleteUnverifiedState(current).catch(() => undefined);
124
+ }
125
+ }
126
+ throw normalized;
127
+ }
128
+ });
129
+ }
130
+ async resume(operationId, options = {}) {
131
+ this.ensureAvailable('transfer_recording');
132
+ validateOperationId(operationId);
133
+ const storage = this.requireStorage();
134
+ this.requireUploadProvider();
135
+ throwIfAborted(options.signal, 'transfer_recording');
136
+ return await this.runManagedOperation(operationId, options.signal, async (signal) => {
137
+ try {
138
+ const journal = await storage.loadRecordingJournal(operationId);
139
+ throwIfAborted(signal, 'transfer_recording');
140
+ if (!journal) {
141
+ throw new BotaSDKError('resume_rejected', 'transfer_recording');
142
+ }
143
+ if (journal.profile === 'encrypted_upload_v2') {
144
+ if (journal.phase === 'confirmed') {
145
+ return await this.finishConfirmedJournal(journal);
146
+ }
147
+ return await this.resumeEncryptedUploadV2(journal, signal, options.onProgress);
148
+ }
149
+ if (journal.profile !== 'legacy') {
150
+ throw new BotaSDKError('resume_rejected', 'transfer_recording');
151
+ }
152
+ if (journal.phase === 'confirmed') {
153
+ return await this.finishConfirmedJournal(journal);
154
+ }
155
+ if (journal.phase === 'cloud_completed') {
156
+ return await this.confirmJournal(journal, signal);
157
+ }
158
+ const connected = this.requireConnectedDevice();
159
+ if (connected.serialNumber !== journal.serialNumber) {
160
+ throw new BotaSDKError('identity_mismatch', 'transfer_recording');
161
+ }
162
+ const recordings = await this.listWithSignal(signal);
163
+ throwIfAborted(signal, 'transfer_recording');
164
+ const recording = recordings.find(({ uuid }) => uuid === journal.recordingUuid);
165
+ if (!recording) {
166
+ throw new BotaSDKError('resume_rejected', 'transfer_recording');
167
+ }
168
+ validateRecording(recording);
169
+ switch (journal.phase) {
170
+ case 'prepared':
171
+ case 'transferring':
172
+ return await this.transferLegacy(journal, recording, signal, options.onProgress);
173
+ case 'staged':
174
+ return await this.uploadStaged(journal, recording, signal);
175
+ case 'uploading':
176
+ return await this.reconcileUpload(journal, recording, signal);
177
+ }
178
+ }
179
+ catch (error) {
180
+ const normalized = recordingError(error, 'transfer_recording');
181
+ if (normalized.code === 'cancelled') {
182
+ const current = await storage.loadRecordingJournal(operationId)
183
+ .catch(() => null);
184
+ if (current) {
185
+ await this.deleteUnverifiedState(current).catch(() => undefined);
186
+ }
187
+ }
188
+ throw normalized;
189
+ }
190
+ });
191
+ }
192
+ async cancel(operationId) {
193
+ this.ensureAvailable('transfer_recording');
194
+ validateOperationId(operationId);
195
+ const active = this.activeOperations.get(operationId);
196
+ if (active) {
197
+ active.controller.abort();
198
+ await this.runtime.cancel(operationId);
199
+ await active.settled.promise;
200
+ return;
201
+ }
202
+ const storage = this.requireStorage();
203
+ const journal = await storage.loadRecordingJournal(operationId);
204
+ if (!journal)
205
+ return;
206
+ if ((journal.profile === 'legacy'
207
+ || journal.profile === 'encrypted_upload_v2')
208
+ && (journal.phase === 'prepared' || journal.phase === 'transferring')) {
209
+ await this.deleteUnverifiedState(journal);
210
+ }
211
+ }
212
+ async confirm(operationId) {
213
+ this.ensureAvailable('upload');
214
+ validateOperationId(operationId);
215
+ const storage = this.requireStorage();
216
+ await this.runManagedOperation(operationId, undefined, async (signal) => {
217
+ const journal = await storage.loadRecordingJournal(operationId);
218
+ throwIfAborted(signal, 'upload');
219
+ if (!journal)
220
+ throw new BotaSDKError('resume_rejected', 'upload');
221
+ if (journal.profile === 'encrypted_upload_v2') {
222
+ if (journal.phase !== 'cloud_completed') {
223
+ throw new BotaSDKError('resume_rejected', 'upload');
224
+ }
225
+ await this.resumeEncryptedUploadV2(journal, signal);
226
+ return;
227
+ }
228
+ if (journal.profile !== 'legacy') {
229
+ throw new BotaSDKError('resume_rejected', 'upload');
230
+ }
231
+ if (journal.phase === 'confirmed') {
232
+ await this.finishConfirmedJournal(journal);
233
+ return;
234
+ }
235
+ if (journal.phase !== 'cloud_completed') {
236
+ throw new BotaSDKError('resume_rejected', 'upload');
237
+ }
238
+ await this.confirmJournal(journal, signal);
239
+ }, 'upload');
240
+ }
241
+ async listPendingOperations() {
242
+ this.ensureAvailable('upload');
243
+ return (await this.requireStorage().listRecordingJournals()).map((journal) => ({
244
+ operationId: journal.operationId,
245
+ serialNumber: journal.serialNumber,
246
+ recordingUuid: journal.recordingUuid,
247
+ profile: journal.profile,
248
+ phase: journal.phase,
249
+ updatedAtEpochMs: journal.updatedAtEpochMs,
250
+ }));
251
+ }
252
+ destroy() {
253
+ if (this.destroyPromise)
254
+ return this.destroyPromise;
255
+ this.destroyed = true;
256
+ this.destroyPromise = (async () => {
257
+ const active = [...this.activeOperations.entries()];
258
+ for (const [operationId, operation] of active) {
259
+ operation.controller.abort();
260
+ await this.runtime.cancel(operationId);
261
+ }
262
+ await Promise.all(active.map(([, operation]) => operation.settled.promise));
263
+ })();
264
+ return this.destroyPromise;
265
+ }
266
+ async startEncryptedUploadV2(operationId, recording, signal, onProgress) {
267
+ const storage = this.requireStorage();
268
+ const provider = this.requireUploadProvider();
269
+ if (await storage.loadRecordingJournal(operationId)) {
270
+ throw new BotaSDKError('resume_rejected', 'transfer_recording');
271
+ }
272
+ const capability = await this.readFreshV2Capability(signal);
273
+ const metadata = requireEncryptedUploadV2Metadata(recording);
274
+ this.core.validateEncryptedUploadV2Profile(capability.decoded, metadata.generation, metadata.storageFormat);
275
+ const bounds = negotiatedEncryptedUploadV2Bounds(capability.decoded, this.transport.maximumWriteValueLength);
276
+ const journal = {
277
+ schemaVersion: 1,
278
+ operationId,
279
+ serialNumber: capability.connection.serialNumber,
280
+ recordingUuid: recording.uuid,
281
+ profile: 'encrypted_upload_v2',
282
+ phase: 'prepared',
283
+ sinkId: sinkId(operationId),
284
+ uploadId: null,
285
+ cloudCompletionId: null,
286
+ confirmationDigestHex: null,
287
+ devicePlaintextSha256Hex: null,
288
+ updatedAtEpochMs: this.now(),
289
+ };
290
+ const providerContext = {
291
+ operationId,
292
+ serialNumber: capability.connection.serialNumber,
293
+ recording: {
294
+ uuid: recording.uuid,
295
+ generation: metadata.generation,
296
+ storageFormat: metadata.storageFormat,
297
+ ciphertextLength: metadata.ciphertextLength,
298
+ ciphertextSha256: metadata.ciphertextSha256.slice(),
299
+ },
300
+ capability: {
301
+ rawValue: capability.raw.slice(),
302
+ sha256: capability.sha256.slice(),
303
+ decoded: { ...capability.decoded },
304
+ },
305
+ checkpoint: null,
306
+ signal,
307
+ };
308
+ const material = await this.prepareEncryptedUploadV2Material(provider, providerContext, signal);
309
+ let materialOwnedHere = true;
310
+ try {
311
+ throwIfAborted(signal, 'transfer_recording');
312
+ validateEncryptedUploadV2Material(material, capability.decoded);
313
+ const state = {
314
+ schemaVersion: 1,
315
+ operationId,
316
+ serialNumber: journal.serialNumber,
317
+ recording: {
318
+ ...providerContext.recording,
319
+ ciphertextSha256: providerContext.recording.ciphertextSha256.slice(),
320
+ },
321
+ materialId: material.materialId,
322
+ recordingId: material.recordingId,
323
+ uploadSessionId: material.uploadSessionId,
324
+ ownerRevision: material.ownerRevision,
325
+ policy: material.policy,
326
+ transportSessionId: randomTransportSessionId(),
327
+ sinkId: journal.sinkId,
328
+ windowPackets: bounds.windowPackets,
329
+ dataPayloadBytes: bounds.dataPayloadBytes,
330
+ maximumSignedBlobBytes: capability.decoded.maximumSignedBlobBytes,
331
+ maximumMissingSequences: bounds.maximumMissingSequences,
332
+ checkpointIntervalBlocks: capability.decoded.durableCheckpointIntervalBlocks,
333
+ capabilitySha256Hex: hex(capability.sha256),
334
+ coreCheckpoint: null,
335
+ highestContiguousSequence: null,
336
+ evidence: null,
337
+ };
338
+ await storage.saveEncryptedUploadV2Operation(operationId, state, journal);
339
+ throwIfAborted(signal, 'transfer_recording');
340
+ const transferring = await this.saveJournal(journal, {
341
+ phase: 'transferring',
342
+ });
343
+ throwIfAborted(signal, 'transfer_recording');
344
+ materialOwnedHere = false;
345
+ return await this.runEncryptedUploadV2(transferring, state, capability.decoded, material, signal, onProgress);
346
+ }
347
+ catch (error) {
348
+ if (materialOwnedHere) {
349
+ await destroyEncryptedUploadV2Material(material, signal)
350
+ .catch(() => undefined);
351
+ const current = await storage.loadRecordingJournal(operationId)
352
+ .catch(() => null);
353
+ if (current) {
354
+ await this.deleteUnverifiedState(current).catch(() => undefined);
355
+ }
356
+ }
357
+ throw recordingError(error, 'transfer_recording');
358
+ }
359
+ }
360
+ async resumeEncryptedUploadV2(journal, signal, onProgress) {
361
+ const storage = this.requireStorage();
362
+ const rawState = await storage.loadEncryptedUploadV2Checkpoint(journal.operationId);
363
+ if (!rawState)
364
+ throw new BotaSDKError('resume_rejected', 'transfer_recording');
365
+ const state = parsePersistedEncryptedUploadV2State(rawState);
366
+ validateEncryptedUploadV2JournalState(journal, state);
367
+ const capability = await this.readFreshV2Capability(signal);
368
+ if (capability.connection.serialNumber !== state.serialNumber
369
+ || hex(capability.sha256) !== state.capabilitySha256Hex)
370
+ throw new BotaSDKError('integrity_failed', 'transfer_recording');
371
+ this.core.validateEncryptedUploadV2Profile(capability.decoded, state.recording.generation, state.recording.storageFormat);
372
+ const bounds = negotiatedEncryptedUploadV2Bounds(capability.decoded, this.transport.maximumWriteValueLength);
373
+ if (bounds.windowPackets !== state.windowPackets
374
+ || bounds.dataPayloadBytes !== state.dataPayloadBytes
375
+ || bounds.maximumMissingSequences !== state.maximumMissingSequences)
376
+ throw new BotaSDKError('integrity_failed', 'transfer_recording');
377
+ const provider = this.requireUploadProvider();
378
+ const material = await this.prepareEncryptedUploadV2Material(provider, {
379
+ operationId: state.operationId,
380
+ serialNumber: state.serialNumber,
381
+ recording: {
382
+ ...state.recording,
383
+ ciphertextSha256: state.recording.ciphertextSha256.slice(),
384
+ },
385
+ capability: {
386
+ rawValue: capability.raw.slice(),
387
+ sha256: capability.sha256.slice(),
388
+ decoded: { ...capability.decoded },
389
+ },
390
+ checkpoint: encryptedUploadV2CheckpointSummary(state),
391
+ signal,
392
+ }, signal);
393
+ let materialOwnedHere = true;
394
+ try {
395
+ throwIfAborted(signal, 'transfer_recording');
396
+ validateEncryptedUploadV2Material(material, capability.decoded, state);
397
+ materialOwnedHere = false;
398
+ return await this.runEncryptedUploadV2(journal, state, capability.decoded, material, signal, onProgress);
399
+ }
400
+ catch (error) {
401
+ if (materialOwnedHere) {
402
+ await destroyEncryptedUploadV2Material(material, signal)
403
+ .catch(() => undefined);
404
+ }
405
+ throw recordingError(error, 'transfer_recording');
406
+ }
407
+ }
408
+ async runEncryptedUploadV2(initialJournal, state, capabilities, material, signal, onProgress) {
409
+ const storage = this.requireStorage();
410
+ let host = null;
411
+ let workflowCompleted = false;
412
+ try {
413
+ throwIfAborted(signal, 'transfer_recording');
414
+ const connection = this.requireConnectedDevice();
415
+ if (connection.serialNumber !== state.serialNumber) {
416
+ throw new BotaSDKError('identity_mismatch', 'transfer_recording');
417
+ }
418
+ const blob = await storage.openBlob(state.sinkId);
419
+ throwIfAborted(signal, 'transfer_recording');
420
+ let journal = initialJournal;
421
+ const savePhase = async (phase, update = {}) => {
422
+ if (recordingPhaseIndex(journal.phase) > recordingPhaseIndex(phase))
423
+ return;
424
+ journal = await this.saveJournal(journal, { phase, ...update });
425
+ };
426
+ host = new EncryptedUploadV2Host({
427
+ core: this.core,
428
+ transport: this.transport,
429
+ device: connection.device,
430
+ storage,
431
+ blob,
432
+ material,
433
+ state,
434
+ fetcher: this.fetcher,
435
+ recoveryPhase: journal.phase === 'confirmed'
436
+ ? 'cloud_completed'
437
+ : journal.phase,
438
+ expectedReceiptSha256Hex: journal.confirmationDigestHex,
439
+ onOwnershipUncertain: () => {
440
+ this.runtime.poisonBleOwnership(connection.device.id);
441
+ },
442
+ callbacks: {
443
+ transferCompleted: async (evidence) => {
444
+ await savePhase('staged');
445
+ onProgress?.({
446
+ phase: 'staged',
447
+ completedBytes: evidence.ciphertextLength,
448
+ totalBytes: state.recording.ciphertextLength,
449
+ });
450
+ },
451
+ uploading: async () => {
452
+ await savePhase('uploading', { uploadId: state.materialId });
453
+ },
454
+ cloudCompleted: async (receiptSha256) => {
455
+ await savePhase('cloud_completed', {
456
+ uploadId: state.materialId,
457
+ cloudCompletionId: state.recordingId,
458
+ confirmationDigestHex: hex(receiptSha256),
459
+ });
460
+ },
461
+ confirmed: async () => {
462
+ await savePhase('confirmed', {
463
+ uploadId: state.materialId,
464
+ cloudCompletionId: state.recordingId,
465
+ });
466
+ },
467
+ },
468
+ });
469
+ const cancellationId = randomCancellationId();
470
+ await this.runtime.run(state.operationId, cancellationId, () => this.core.startEncryptedUploadV2({
471
+ serialNumber: state.serialNumber,
472
+ recordingUuid: state.recording.uuid,
473
+ recordingGeneration: state.recording.generation,
474
+ storageFormat: state.recording.storageFormat,
475
+ uploadSessionId: state.uploadSessionId,
476
+ ownerRevision: state.ownerRevision,
477
+ transportSessionId: state.transportSessionId,
478
+ materialId: state.materialId,
479
+ sinkId: state.sinkId,
480
+ policy: state.policy,
481
+ capabilities,
482
+ windowPackets: state.windowPackets,
483
+ dataPayloadBytes: state.dataPayloadBytes,
484
+ ciphertextLength: state.recording.ciphertextLength,
485
+ ciphertextSha256: state.recording.ciphertextSha256.slice(),
486
+ cancellationId,
487
+ }), {
488
+ persistence: createBrowserPersistenceHost(storage),
489
+ encryptedUploadV2: host,
490
+ }, {
491
+ onProgress: (completedBytes, totalBytes) => {
492
+ onProgress?.({
493
+ phase: 'transferring',
494
+ completedBytes,
495
+ totalBytes,
496
+ });
497
+ },
498
+ });
499
+ workflowCompleted = true;
500
+ const confirmed = await storage.loadRecordingJournal(state.operationId);
501
+ if (!confirmed || confirmed.phase !== 'confirmed') {
502
+ throw new BotaSDKError('internal_error', 'transfer_recording');
503
+ }
504
+ return await this.finishConfirmedJournal(confirmed);
505
+ }
506
+ catch (error) {
507
+ if (!workflowCompleted) {
508
+ if (host) {
509
+ const confirmationAttempted = await host
510
+ .confirmationAttemptedOrClaimCancellation()
511
+ .catch(() => true);
512
+ if (!confirmationAttempted) {
513
+ await host.cancel().catch(() => undefined);
514
+ }
515
+ }
516
+ else {
517
+ await destroyEncryptedUploadV2Material(material, signal)
518
+ .catch(() => undefined);
519
+ }
520
+ }
521
+ const normalized = recordingError(error, 'transfer_recording');
522
+ if (normalized.code === 'cancelled') {
523
+ const current = await storage.loadRecordingJournal(state.operationId)
524
+ .catch(() => null);
525
+ if (current) {
526
+ await this.deleteUnverifiedState(current).catch(() => undefined);
527
+ }
528
+ }
529
+ throw normalized;
530
+ }
531
+ }
532
+ async prepareEncryptedUploadV2Material(provider, context, signal) {
533
+ let pending = null;
534
+ try {
535
+ pending = provider.prepareEncryptedUploadV2(context);
536
+ return await awaitProviderCall(pending, signal, 'upload');
537
+ }
538
+ catch (error) {
539
+ if (signal.aborted && pending) {
540
+ void pending.then(async (material) => {
541
+ await destroyEncryptedUploadV2Material(material, signal)
542
+ .catch(() => undefined);
543
+ }, () => undefined);
544
+ }
545
+ throw uploadError(error, signal);
546
+ }
547
+ }
548
+ async readFreshV2Capability(signal) {
549
+ return await this.withVerifiedConnection('transfer_recording', async (connection, runtimeSignal) => {
550
+ const raw = await awaitWithSignal(this.transport.read(connection.device, BOTA_STORAGE_SERVICE, ENCRYPTED_UPLOAD_V2_CAPABILITY_CHARACTERISTIC), runtimeSignal, 'transfer_recording');
551
+ const decoded = this.core.decodeEncryptedUploadV2Capabilities(raw);
552
+ return {
553
+ connection,
554
+ raw: raw.slice(),
555
+ sha256: hashBytes(this.core, raw),
556
+ decoded,
557
+ };
558
+ }, signal);
559
+ }
560
+ async readOptionalV2Capability(device, signal) {
561
+ let raw;
562
+ try {
563
+ raw = await awaitWithSignal(this.transport.read(device, BOTA_STORAGE_SERVICE, ENCRYPTED_UPLOAD_V2_CAPABILITY_CHARACTERISTIC), signal, 'transfer_recording');
564
+ }
565
+ catch (error) {
566
+ if (error instanceof BrowserTransportError
567
+ && error.code === 'characteristic_not_found')
568
+ return null;
569
+ throw error;
570
+ }
571
+ return this.core.decodeEncryptedUploadV2Capabilities(raw);
572
+ }
573
+ async transferLegacy(journal, recording, signal, onProgress) {
574
+ const storage = this.requireStorage();
575
+ throwIfAborted(signal, 'transfer_recording');
576
+ await storage.deleteWorkflowCheckpoint(journal.operationId);
577
+ throwIfAborted(signal, 'transfer_recording');
578
+ const blob = await storage.openBlob(journal.sinkId);
579
+ throwIfAborted(signal, 'transfer_recording');
580
+ const currentSizeValue = await blob.size();
581
+ throwIfAborted(signal, 'transfer_recording');
582
+ const currentSize = safeBrowserNumber(currentSizeValue, 'transfer_recording');
583
+ if (currentSize > 0) {
584
+ await blob.truncate(0);
585
+ throwIfAborted(signal, 'transfer_recording');
586
+ }
587
+ const transferring = await this.saveJournal(journal, {
588
+ phase: 'transferring',
589
+ uploadId: null,
590
+ cloudCompletionId: null,
591
+ });
592
+ throwIfAborted(signal, 'transfer_recording');
593
+ const sink = createRecordingSinkHost(journal.sinkId, blob, () => this.core.createIntegrityHasher());
594
+ const cancellationId = randomCancellationId();
595
+ try {
596
+ const result = await this.runtime.run(journal.operationId, cancellationId, () => this.core.startRecordingTransfer({
597
+ serialNumber: journal.serialNumber,
598
+ recordingUuid: journal.recordingUuid,
599
+ sinkId: journal.sinkId,
600
+ totalUnits: recording.fileSizeBytes,
601
+ cancellationId,
602
+ }), {
603
+ persistence: createBrowserPersistenceHost(storage),
604
+ recordingSink: sink.host,
605
+ }, {
606
+ onProgress: (completedBytes, totalBytes) => {
607
+ onProgress?.({
608
+ phase: 'transferring',
609
+ completedBytes,
610
+ totalBytes,
611
+ });
612
+ },
613
+ });
614
+ const completed = [...result.notifications].reverse().find((notification) => notification.kind === 'recording_transfer_completed');
615
+ if (!completed
616
+ || completed.kind !== 'recording_transfer_completed'
617
+ || !sink.state.finalized) {
618
+ throw new BotaSDKError('internal_error', 'transfer_recording');
619
+ }
620
+ const staged = await this.saveJournal(transferring, {
621
+ phase: 'staged',
622
+ devicePlaintextSha256Hex: completed.sha256
623
+ ? hex(completed.sha256)
624
+ : null,
625
+ });
626
+ onProgress?.({
627
+ phase: 'staged',
628
+ completedBytes: BigInt(safeBrowserNumber(await blob.size(), 'transfer_recording')),
629
+ totalBytes: recording.fileSizeBytes,
630
+ });
631
+ return await this.uploadStaged(staged, { ...recording, encrypted: completed.encrypted }, signal);
632
+ }
633
+ catch (error) {
634
+ const normalized = recordingError(error, 'transfer_recording');
635
+ if (normalized.code === 'cancelled' || normalized.code === 'integrity_failed') {
636
+ const current = await storage.loadRecordingJournal(journal.operationId)
637
+ .catch(() => null);
638
+ if (current) {
639
+ await this.deleteUnverifiedState(current).catch(() => undefined);
640
+ }
641
+ }
642
+ throw normalized;
643
+ }
644
+ }
645
+ async uploadStaged(journal, recording, signal) {
646
+ const storage = this.requireStorage();
647
+ const provider = this.requireUploadProvider();
648
+ const blob = await storage.openBlob(journal.sinkId);
649
+ const context = await this.legacyContext(journal, recording, blob, signal);
650
+ let prepared;
651
+ try {
652
+ prepared = await awaitProviderCall(provider.prepareLegacyUpload(context), signal, 'upload');
653
+ }
654
+ catch (error) {
655
+ throw uploadError(error, signal);
656
+ }
657
+ validateProviderIdentifier(prepared.uploadId);
658
+ validateUploadRequest(prepared.request);
659
+ const uploading = await this.saveJournal(journal, {
660
+ phase: 'uploading',
661
+ uploadId: prepared.uploadId,
662
+ });
663
+ try {
664
+ await this.uploadBlob(prepared.request, blob, signal);
665
+ const completion = await awaitProviderCall(provider.completeLegacyUpload({
666
+ ...context,
667
+ uploadId: prepared.uploadId,
668
+ }), signal, 'upload');
669
+ validateProviderIdentifier(completion.cloudCompletionId);
670
+ const cloudCompleted = await this.saveJournal(uploading, {
671
+ phase: 'cloud_completed',
672
+ cloudCompletionId: completion.cloudCompletionId,
673
+ });
674
+ return await this.confirmJournal(cloudCompleted, signal);
675
+ }
676
+ catch (error) {
677
+ throw uploadError(error, signal);
678
+ }
679
+ }
680
+ async reconcileUpload(journal, recording, signal) {
681
+ if (!journal.uploadId)
682
+ throw new BotaSDKError('resume_rejected', 'upload');
683
+ const provider = this.requireUploadProvider();
684
+ const blob = await this.requireStorage().openBlob(journal.sinkId);
685
+ const context = await this.legacyContext(journal, recording, blob, signal);
686
+ let reconciliation;
687
+ try {
688
+ reconciliation = await awaitProviderCall(provider.reconcileLegacyUpload({
689
+ ...context,
690
+ uploadId: journal.uploadId,
691
+ }), signal, 'upload');
692
+ }
693
+ catch (error) {
694
+ throw uploadError(error, signal);
695
+ }
696
+ if (reconciliation.state === 'cloud_completed') {
697
+ validateProviderIdentifier(reconciliation.cloudCompletionId);
698
+ const cloudCompleted = await this.saveJournal(journal, {
699
+ phase: 'cloud_completed',
700
+ cloudCompletionId: reconciliation.cloudCompletionId,
701
+ });
702
+ return await this.confirmJournal(cloudCompleted, signal);
703
+ }
704
+ if (reconciliation.state !== 'not_uploaded') {
705
+ throw new BotaSDKError('upload_failed', 'upload', { retryable: true });
706
+ }
707
+ const staged = await this.saveJournal(journal, {
708
+ phase: 'staged',
709
+ uploadId: null,
710
+ });
711
+ return await this.uploadStaged(staged, recording, signal);
712
+ }
713
+ async confirmJournal(journal, signal) {
714
+ if (journal.profile !== 'legacy'
715
+ || journal.phase !== 'cloud_completed'
716
+ || !journal.cloudCompletionId) {
717
+ throw new BotaSDKError('resume_rejected', 'upload');
718
+ }
719
+ throwIfAborted(signal, 'upload');
720
+ return await this.withVerifiedConnection('upload', async ({ device, serialNumber }, runtimeSignal) => {
721
+ if (serialNumber !== journal.serialNumber) {
722
+ throw new BotaSDKError('identity_mismatch', 'upload');
723
+ }
724
+ throwIfAborted(runtimeSignal, 'upload');
725
+ const write = this.transport.write(device, BOTA_STORAGE_SERVICE, TRANSFER_CONTROL_CHARACTERISTIC, this.core.encodeRecordingConfirm(journal.recordingUuid), true);
726
+ await write;
727
+ const confirmed = await this.saveJournal(journal, {
728
+ phase: 'confirmed',
729
+ });
730
+ return await this.finishConfirmedJournal(confirmed);
731
+ }, signal);
732
+ }
733
+ async finishConfirmedJournal(journal) {
734
+ if (journal.phase !== 'confirmed' || !journal.cloudCompletionId) {
735
+ throw new BotaSDKError('resume_rejected', 'upload');
736
+ }
737
+ const storage = this.requireStorage();
738
+ await (await storage.openBlob(journal.sinkId)).delete();
739
+ await storage.deleteWorkflowCheckpoint(journal.operationId);
740
+ if (journal.profile === 'encrypted_upload_v2') {
741
+ await storage.deleteEncryptedUploadV2Operation(journal.operationId);
742
+ }
743
+ else {
744
+ await storage.deleteRecordingJournal(journal.operationId);
745
+ }
746
+ return resultFromJournal(journal);
747
+ }
748
+ async legacyContext(journal, recording, blob, signal) {
749
+ const hasher = this.core.createIntegrityHasher();
750
+ let streamedSize = 0;
751
+ for await (const chunk of blob.stream(OPFS_STREAM_CHUNK_SIZE)) {
752
+ throwIfAborted(signal, 'upload');
753
+ streamedSize = safeBrowserRange(streamedSize, chunk.byteLength, 'upload');
754
+ hasher.update(chunk);
755
+ }
756
+ const reportedSize = safeBrowserNumber(await blob.size(), 'upload');
757
+ if (reportedSize !== streamedSize) {
758
+ throw new BotaSDKError('integrity_failed', 'upload');
759
+ }
760
+ return {
761
+ operationId: journal.operationId,
762
+ serialNumber: journal.serialNumber,
763
+ recording,
764
+ sizeBytes: BigInt(reportedSize),
765
+ plaintextSha256Hex: journal.devicePlaintextSha256Hex,
766
+ stagedBodySha256Hex: hex(hasher.sha256Snapshot()),
767
+ encrypted: recording.encrypted,
768
+ signal,
769
+ };
770
+ }
771
+ async uploadBlob(request, blob, signal) {
772
+ throwIfAborted(signal, 'upload');
773
+ const body = blobReadableStream(blob, signal);
774
+ const init = {
775
+ method: 'PUT',
776
+ headers: { ...request.headers },
777
+ body,
778
+ signal,
779
+ redirect: 'error',
780
+ };
781
+ if (fetchRequiresDuplex())
782
+ init.duplex = 'half';
783
+ const response = await awaitWithSignal(this.fetcher(request.url, init), signal, 'upload');
784
+ if (!response.ok) {
785
+ await response.body?.cancel().catch(() => undefined);
786
+ throw new BotaSDKError('upload_failed', 'upload', { retryable: true });
787
+ }
788
+ }
789
+ async saveJournal(journal, update) {
790
+ const next = {
791
+ ...journal,
792
+ phase: update.phase,
793
+ uploadId: update.uploadId === undefined
794
+ ? journal.uploadId
795
+ : update.uploadId,
796
+ cloudCompletionId: update.cloudCompletionId === undefined
797
+ ? journal.cloudCompletionId
798
+ : update.cloudCompletionId,
799
+ confirmationDigestHex: update.confirmationDigestHex === undefined
800
+ ? journal.confirmationDigestHex
801
+ : update.confirmationDigestHex,
802
+ devicePlaintextSha256Hex: update.devicePlaintextSha256Hex === undefined
803
+ ? journal.devicePlaintextSha256Hex
804
+ : update.devicePlaintextSha256Hex,
805
+ updatedAtEpochMs: Math.max(journal.updatedAtEpochMs, this.now()),
806
+ };
807
+ await this.requireStorage().saveRecordingJournal(next);
808
+ return next;
809
+ }
810
+ async deleteUnverifiedState(journal) {
811
+ if (journal.phase !== 'prepared' && journal.phase !== 'transferring')
812
+ return;
813
+ const storage = this.requireStorage();
814
+ if (journal.profile === 'encrypted_upload_v2') {
815
+ const rawState = await storage.loadEncryptedUploadV2Checkpoint(journal.operationId);
816
+ if (rawState) {
817
+ const state = parsePersistedEncryptedUploadV2State(rawState);
818
+ validateEncryptedUploadV2JournalState(journal, state);
819
+ state.coreCheckpoint = null;
820
+ state.highestContiguousSequence = null;
821
+ await storage.saveEncryptedUploadV2Checkpoint(journal.operationId, state);
822
+ }
823
+ await (await storage.openBlob(journal.sinkId)).delete();
824
+ await storage.deleteWorkflowCheckpoint(journal.operationId);
825
+ await storage.deleteEncryptedUploadV2Operation(journal.operationId);
826
+ return;
827
+ }
828
+ await (await storage.openBlob(journal.sinkId)).delete();
829
+ await storage.deleteWorkflowCheckpoint(journal.operationId);
830
+ await storage.deleteRecordingJournal(journal.operationId);
831
+ }
832
+ async verifyConnection(signal) {
833
+ throwIfAborted(signal, 'transfer_recording');
834
+ return await this.withVerifiedConnection('transfer_recording', async (connection, runtimeSignal) => {
835
+ throwIfAborted(runtimeSignal, 'transfer_recording');
836
+ return connection;
837
+ }, signal);
838
+ }
839
+ async withVerifiedConnection(operation, body, externalSignal) {
840
+ const connection = this.requireConnectedDevice();
841
+ try {
842
+ return await this.runtime.runExclusive(operation, async (signal) => {
843
+ const combinedSignal = combineSignals(externalSignal, signal);
844
+ throwIfAborted(combinedSignal, operation);
845
+ const current = this.requireConnectedDevice();
846
+ if (current.device.id !== connection.device.id) {
847
+ throw new BotaSDKError('device_disconnected', operation);
848
+ }
849
+ const value = await awaitWithSignal(this.transport.read(current.device, DEVICE_INFORMATION_SERVICE, SERIAL_NUMBER_CHARACTERISTIC), combinedSignal, operation);
850
+ if (decodeSerial(value, operation) !== current.serialNumber) {
851
+ throw new BotaSDKError('identity_mismatch', operation);
852
+ }
853
+ return await body(current, combinedSignal);
854
+ });
855
+ }
856
+ catch (error) {
857
+ const normalized = recordingError(error, operation);
858
+ if (normalized.code === 'identity_mismatch') {
859
+ await this.devices.disconnect().catch(() => undefined);
860
+ }
861
+ throw normalized;
862
+ }
863
+ }
864
+ requireConnectedDevice() {
865
+ const connected = this.devices.connectedDevice;
866
+ const device = this.runtime.connectedDeviceHandle;
867
+ if (!connected || !device || connected.id !== device.id) {
868
+ throw new BotaSDKError('device_disconnected', 'transfer_recording');
869
+ }
870
+ return { device, serialNumber: connected.serialNumber };
871
+ }
872
+ async runManagedOperation(operationId, externalSignal, body, operation = 'transfer_recording') {
873
+ this.ensureAvailable(operation);
874
+ if (this.activeOperations.has(operationId)) {
875
+ throw new BotaSDKError('operation_in_progress', operation);
876
+ }
877
+ throwIfAborted(externalSignal, operation);
878
+ const controller = new AbortController();
879
+ const settled = deferred();
880
+ const abort = () => controller.abort();
881
+ const cancelRuntime = () => {
882
+ void this.runtime.cancel(operationId).catch(() => undefined);
883
+ };
884
+ externalSignal?.addEventListener('abort', abort, { once: true });
885
+ controller.signal.addEventListener('abort', cancelRuntime, { once: true });
886
+ if (externalSignal?.aborted)
887
+ controller.abort();
888
+ const active = {
889
+ controller,
890
+ settled,
891
+ removeExternalAbort: () => externalSignal?.removeEventListener('abort', abort),
892
+ };
893
+ this.activeOperations.set(operationId, active);
894
+ try {
895
+ return await body(controller.signal);
896
+ }
897
+ finally {
898
+ active.removeExternalAbort();
899
+ controller.signal.removeEventListener('abort', cancelRuntime);
900
+ if (this.activeOperations.get(operationId) === active) {
901
+ this.activeOperations.delete(operationId);
902
+ }
903
+ settled.resolve(undefined);
904
+ }
905
+ }
906
+ requireStorage() {
907
+ if (!this.storage)
908
+ throw new BotaSDKError('storage_unavailable', 'upload');
909
+ return this.storage;
910
+ }
911
+ requireUploadProvider() {
912
+ if (!this.uploadProvider) {
913
+ throw new BotaSDKError('unsupported_capability', 'upload');
914
+ }
915
+ return this.uploadProvider;
916
+ }
917
+ ensureAvailable(operation) {
918
+ if (this.destroyed)
919
+ throw new BotaSDKError('cancelled', operation);
920
+ }
921
+ }
922
+ function createRecordingSinkHost(sinkId, blob, createHasher) {
923
+ const state = {
924
+ hasher: createHasher(),
925
+ finalized: false,
926
+ };
927
+ const host = {
928
+ execute: async (envelope) => {
929
+ const effect = envelope.effect;
930
+ try {
931
+ switch (effect.kind) {
932
+ case 'recording_sink_truncate': {
933
+ assertSink(effect.sinkId, sinkId);
934
+ const size = safeBrowserBigint(effect.completedUnits, 'transfer_recording');
935
+ await blob.truncate(size);
936
+ state.hasher = createHasher();
937
+ let restored = 0;
938
+ for await (const chunk of blob.stream(OPFS_STREAM_CHUNK_SIZE)) {
939
+ restored = safeBrowserRange(restored, chunk.byteLength, 'transfer_recording');
940
+ state.hasher.update(chunk);
941
+ }
942
+ if (restored !== size) {
943
+ return sinkFailure(envelope.requestId);
944
+ }
945
+ return {
946
+ requestId: envelope.requestId,
947
+ kind: 'recording_sink_truncated',
948
+ };
949
+ }
950
+ case 'recording_sink_append': {
951
+ assertSink(effect.sinkId, sinkId);
952
+ const offset = safeBrowserNumber(await blob.size(), 'transfer_recording');
953
+ const durable = safeBrowserRange(offset, effect.payload.byteLength, 'transfer_recording');
954
+ await blob.write(offset, effect.payload);
955
+ state.hasher.update(effect.payload);
956
+ return {
957
+ requestId: envelope.requestId,
958
+ kind: 'recording_sink_append_completed',
959
+ durableUnits: BigInt(durable),
960
+ };
961
+ }
962
+ case 'recording_sink_finalize': {
963
+ assertSink(effect.sinkId, sinkId);
964
+ const size = safeBrowserNumber(await blob.size(), 'transfer_recording');
965
+ if (effect.expectedCrc32 !== null
966
+ && state.hasher.crc32() !== effect.expectedCrc32) {
967
+ return {
968
+ requestId: envelope.requestId,
969
+ kind: 'recording_sink_integrity_failed',
970
+ };
971
+ }
972
+ state.finalized = true;
973
+ return {
974
+ requestId: envelope.requestId,
975
+ kind: 'recording_sink_finalized',
976
+ durableUnits: BigInt(size),
977
+ };
978
+ }
979
+ case 'recording_sink_discard':
980
+ assertSink(effect.sinkId, sinkId);
981
+ await blob.delete();
982
+ return null;
983
+ default:
984
+ throw new BotaSDKError('internal_error', 'transfer_recording');
985
+ }
986
+ }
987
+ catch (error) {
988
+ if (error instanceof BotaSDKError || error instanceof BrowserStorageError) {
989
+ throw error;
990
+ }
991
+ return sinkFailure(envelope.requestId);
992
+ }
993
+ },
994
+ cancel: async () => undefined,
995
+ };
996
+ return { host, state };
997
+ }
998
+ function sinkFailure(requestId) {
999
+ return {
1000
+ requestId,
1001
+ kind: 'recording_sink_failed',
1002
+ platformCode: null,
1003
+ };
1004
+ }
1005
+ function assertSink(actual, expected) {
1006
+ if (actual !== expected) {
1007
+ throw new BotaSDKError('internal_error', 'transfer_recording');
1008
+ }
1009
+ }
1010
+ function deviceRecording(recording) {
1011
+ return {
1012
+ uuid: recording.uuid,
1013
+ startedAtTimestampSeconds: recording.startedAtTimestampSeconds,
1014
+ durationMilliseconds: recording.durationMilliseconds,
1015
+ fileSizeBytes: recording.fileSizeBytes,
1016
+ codec: recording.codec,
1017
+ ...(recording.codecRaw === undefined ? {} : { codecRaw: recording.codecRaw }),
1018
+ encrypted: recording.encrypted,
1019
+ encryptedUploadV2: null,
1020
+ };
1021
+ }
1022
+ function encryptedDeviceRecording(recording) {
1023
+ if (recording.startedAt < 0n
1024
+ || recording.startedAt > BigInt(Number.MAX_SAFE_INTEGER)
1025
+ || recording.durationSeconds > Math.floor(Number.MAX_SAFE_INTEGER / 1000)
1026
+ || recording.plaintextLength > BigInt(Number.MAX_SAFE_INTEGER))
1027
+ throw new BotaSDKError('protocol_error', 'transfer_recording');
1028
+ return {
1029
+ uuid: recording.recordingUuid,
1030
+ startedAtTimestampSeconds: Number(recording.startedAt),
1031
+ durationMilliseconds: BigInt(recording.durationSeconds) * 1000n,
1032
+ fileSizeBytes: recording.plaintextLength,
1033
+ codec: 'unknown',
1034
+ encrypted: true,
1035
+ encryptedUploadV2: {
1036
+ generation: recording.recordingGeneration,
1037
+ storageFormat: recording.storageFormat,
1038
+ plaintextLength: recording.plaintextLength,
1039
+ ciphertextLength: recording.ciphertextLength,
1040
+ ciphertextSha256: recording.ciphertextSha256.slice(),
1041
+ },
1042
+ };
1043
+ }
1044
+ function validateRecording(recording) {
1045
+ if (!Number.isSafeInteger(recording.startedAtTimestampSeconds)
1046
+ || recording.startedAtTimestampSeconds < 0
1047
+ || recording.durationMilliseconds < 0n
1048
+ || recording.fileSizeBytes < 0n
1049
+ || recording.fileSizeBytes > BigInt(Number.MAX_SAFE_INTEGER)) {
1050
+ throw new BotaSDKError('invalid_input', 'transfer_recording');
1051
+ }
1052
+ }
1053
+ function validateEncryptedUploadV2Recording(recording) {
1054
+ validateRecording(recording);
1055
+ const metadata = recording.encryptedUploadV2;
1056
+ if (!recording.encrypted
1057
+ || !metadata
1058
+ || !isUint32(metadata.generation)
1059
+ || !Number.isInteger(metadata.storageFormat)
1060
+ || metadata.storageFormat <= 0
1061
+ || metadata.storageFormat > 0xff
1062
+ || metadata.plaintextLength < 0n
1063
+ || metadata.ciphertextLength <= 0n
1064
+ || metadata.ciphertextLength > BigInt(Number.MAX_SAFE_INTEGER)
1065
+ || metadata.ciphertextSha256.byteLength !== 32)
1066
+ throw new BotaSDKError('invalid_input', 'transfer_recording');
1067
+ }
1068
+ function requireEncryptedUploadV2Metadata(recording) {
1069
+ validateEncryptedUploadV2Recording(recording);
1070
+ return recording.encryptedUploadV2;
1071
+ }
1072
+ function negotiatedEncryptedUploadV2Bounds(capabilities, transportMaximumWriteValueLength) {
1073
+ const frameBytes = Math.min(transportMaximumWriteValueLength, 128);
1074
+ if (frameBytes < 128) {
1075
+ throw new BotaSDKError('unsupported_capability', 'transfer_recording');
1076
+ }
1077
+ const maximumMissingSequences = Math.min(capabilities.maximumMissingSequences, Math.floor((frameBytes - 68) / 4));
1078
+ const windowPackets = Math.min(capabilities.maximumWindowPackets, maximumMissingSequences);
1079
+ const dataPayloadBytes = Math.min(capabilities.maximumDataPayloadBytes, frameBytes - 28);
1080
+ if (maximumMissingSequences <= 0
1081
+ || windowPackets <= 0
1082
+ || dataPayloadBytes <= 0)
1083
+ throw new BotaSDKError('unsupported_capability', 'transfer_recording');
1084
+ return { windowPackets, dataPayloadBytes, maximumMissingSequences };
1085
+ }
1086
+ function validateEncryptedUploadV2Material(material, capabilities, expected) {
1087
+ validateProviderIdentifier(material.materialId);
1088
+ validateProviderIdentifier(material.recordingId);
1089
+ if (!isUuid(material.uploadSessionId)
1090
+ || !isUint32(material.ownerRevision)
1091
+ || material.ownerRevision === 0
1092
+ || (material.policy !== 'legacy_allowed'
1093
+ && material.policy !== 'v2_preferred'
1094
+ && material.policy !== 'v2_required')
1095
+ || !(material.authorization instanceof Uint8Array)
1096
+ || material.authorization.byteLength !== 408
1097
+ || material.authorization.byteLength > capabilities.maximumSignedBlobBytes
1098
+ || typeof material.stagingRequest !== 'function'
1099
+ || typeof material.submitManifest !== 'function'
1100
+ || typeof material.finalize !== 'function'
1101
+ || typeof material.completionReceipt !== 'function'
1102
+ || typeof material.cancel !== 'function')
1103
+ throw new BotaSDKError('upload_failed', 'upload');
1104
+ if (expected
1105
+ && (material.materialId !== expected.materialId
1106
+ || material.recordingId !== expected.recordingId
1107
+ || material.uploadSessionId !== expected.uploadSessionId
1108
+ || material.ownerRevision !== expected.ownerRevision
1109
+ || material.policy !== expected.policy))
1110
+ throw new BotaSDKError('integrity_failed', 'upload');
1111
+ }
1112
+ async function destroyEncryptedUploadV2Material(material, signal) {
1113
+ if (material.authorization instanceof Uint8Array) {
1114
+ material.authorization.fill(0);
1115
+ }
1116
+ if (typeof material.cancel === 'function') {
1117
+ await awaitProviderCall(material.cancel(signal), signal, 'upload');
1118
+ }
1119
+ }
1120
+ function validateEncryptedUploadV2JournalState(journal, state) {
1121
+ const phase = recordingPhaseIndex(journal.phase);
1122
+ if (journal.profile !== 'encrypted_upload_v2'
1123
+ || journal.operationId !== state.operationId
1124
+ || journal.serialNumber !== state.serialNumber
1125
+ || journal.recordingUuid !== state.recording.uuid
1126
+ || journal.sinkId !== state.sinkId
1127
+ || (phase >= recordingPhaseIndex('staged') && !state.evidence)
1128
+ || (phase >= recordingPhaseIndex('uploading')
1129
+ && journal.uploadId !== state.materialId)
1130
+ || (phase >= recordingPhaseIndex('cloud_completed')
1131
+ && (journal.cloudCompletionId !== state.recordingId
1132
+ || !journal.confirmationDigestHex
1133
+ || !/^[0-9a-f]{64}$/.test(journal.confirmationDigestHex))))
1134
+ throw new BotaSDKError('integrity_failed', 'transfer_recording');
1135
+ }
1136
+ function encryptedUploadV2CheckpointSummary(state) {
1137
+ const checkpoint = state.coreCheckpoint;
1138
+ if (!checkpoint)
1139
+ return null;
1140
+ return {
1141
+ uploadSessionId: state.uploadSessionId,
1142
+ ownerRevision: state.ownerRevision,
1143
+ checkpointRevision: checkpoint.checkpointRevision,
1144
+ nextCiphertextOffset: checkpoint.nextCiphertextOffset,
1145
+ prefixSha256: checkpoint.prefixSha256.slice(),
1146
+ transportSessionId: state.transportSessionId,
1147
+ sinkId: state.sinkId,
1148
+ windowPackets: state.windowPackets,
1149
+ dataPayloadBytes: state.dataPayloadBytes,
1150
+ };
1151
+ }
1152
+ function hashBytes(core, value) {
1153
+ const hasher = core.createIntegrityHasher();
1154
+ hasher.update(value);
1155
+ return hasher.sha256Snapshot();
1156
+ }
1157
+ function randomTransportSessionId() {
1158
+ const value = crypto.getRandomValues(new Uint32Array(2));
1159
+ const session = (BigInt(value[0] ?? 0) << 32n) | BigInt(value[1] ?? 0);
1160
+ return session === 0n ? 1n : session;
1161
+ }
1162
+ function recordingPhaseIndex(phase) {
1163
+ return [
1164
+ 'prepared',
1165
+ 'transferring',
1166
+ 'staged',
1167
+ 'uploading',
1168
+ 'cloud_completed',
1169
+ 'confirmed',
1170
+ ].indexOf(phase);
1171
+ }
1172
+ function isUint32(value) {
1173
+ return typeof value === 'number'
1174
+ && Number.isInteger(value)
1175
+ && value >= 0
1176
+ && value <= 0xffffffff;
1177
+ }
1178
+ function isUuid(value) {
1179
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
1180
+ }
1181
+ function validateOperationId(operationId) {
1182
+ if (operationId.length === 0 || !isWellFormedUtf16(operationId)) {
1183
+ throw new BotaSDKError('invalid_input', 'transfer_recording');
1184
+ }
1185
+ }
1186
+ function validateProviderIdentifier(value) {
1187
+ if (typeof value !== 'string'
1188
+ || value.length === 0
1189
+ || !isWellFormedUtf16(value)) {
1190
+ throw new BotaSDKError('upload_failed', 'upload');
1191
+ }
1192
+ }
1193
+ function validateUploadRequest(request) {
1194
+ if (typeof request !== 'object' || request === null) {
1195
+ throw new BotaSDKError('upload_failed', 'upload');
1196
+ }
1197
+ const candidate = request;
1198
+ if (candidate.method !== 'PUT' || typeof candidate.url !== 'string') {
1199
+ throw new BotaSDKError('upload_failed', 'upload');
1200
+ }
1201
+ let url;
1202
+ try {
1203
+ url = new URL(candidate.url);
1204
+ }
1205
+ catch {
1206
+ throw new BotaSDKError('upload_failed', 'upload');
1207
+ }
1208
+ if (url.protocol !== 'https:') {
1209
+ throw new BotaSDKError('upload_failed', 'upload');
1210
+ }
1211
+ if (typeof candidate.headers !== 'object'
1212
+ || candidate.headers === null
1213
+ || Array.isArray(candidate.headers)) {
1214
+ throw new BotaSDKError('upload_failed', 'upload');
1215
+ }
1216
+ for (const [name, value] of Object.entries(candidate.headers)) {
1217
+ if (!name
1218
+ || typeof value !== 'string'
1219
+ || /[\r\n]/.test(name)
1220
+ || /[\r\n]/.test(value)) {
1221
+ throw new BotaSDKError('upload_failed', 'upload');
1222
+ }
1223
+ }
1224
+ }
1225
+ function resultFromJournal(journal) {
1226
+ if (!journal.cloudCompletionId) {
1227
+ throw new BotaSDKError('resume_rejected', 'upload');
1228
+ }
1229
+ return {
1230
+ operationId: journal.operationId,
1231
+ recordingUuid: journal.recordingUuid,
1232
+ profile: journal.profile,
1233
+ cloudCompletionId: journal.cloudCompletionId,
1234
+ };
1235
+ }
1236
+ function sinkId(operationId) {
1237
+ return `recording:${operationId}`;
1238
+ }
1239
+ function createOperationId() {
1240
+ return `transfer_recording:${crypto.randomUUID()}`;
1241
+ }
1242
+ function randomCancellationId() {
1243
+ return crypto.getRandomValues(new Uint8Array(16));
1244
+ }
1245
+ function decodeSerial(value, operation) {
1246
+ try {
1247
+ const serial = new TextDecoder('utf-8', { fatal: true })
1248
+ .decode(value)
1249
+ .replace(/^[\0\s]+|[\0\s]+$/g, '');
1250
+ if (!serial)
1251
+ throw new Error('empty serial');
1252
+ return serial;
1253
+ }
1254
+ catch {
1255
+ throw new BotaSDKError('protocol_error', operation);
1256
+ }
1257
+ }
1258
+ function safeBrowserBigint(value, operation) {
1259
+ if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) {
1260
+ throw new BotaSDKError('invalid_input', operation);
1261
+ }
1262
+ return Number(value);
1263
+ }
1264
+ function safeBrowserNumber(value, operation) {
1265
+ if (!Number.isSafeInteger(value) || value < 0) {
1266
+ throw new BotaSDKError('invalid_input', operation);
1267
+ }
1268
+ return value;
1269
+ }
1270
+ function safeBrowserRange(offset, length, operation) {
1271
+ safeBrowserNumber(offset, operation);
1272
+ safeBrowserNumber(length, operation);
1273
+ if (length > Number.MAX_SAFE_INTEGER - offset) {
1274
+ throw new BotaSDKError('invalid_input', operation);
1275
+ }
1276
+ return offset + length;
1277
+ }
1278
+ function blobReadableStream(blob, signal) {
1279
+ const iterator = blob.stream(OPFS_STREAM_CHUNK_SIZE)[Symbol.asyncIterator]();
1280
+ return new ReadableStream({
1281
+ pull: async (controller) => {
1282
+ try {
1283
+ throwIfAborted(signal, 'upload');
1284
+ const next = await iterator.next();
1285
+ if (next.done) {
1286
+ controller.close();
1287
+ }
1288
+ else {
1289
+ controller.enqueue(next.value);
1290
+ }
1291
+ }
1292
+ catch (error) {
1293
+ controller.error(recordingError(error, 'upload'));
1294
+ }
1295
+ },
1296
+ cancel: async () => {
1297
+ await iterator.return?.();
1298
+ },
1299
+ });
1300
+ }
1301
+ let duplexRequired = null;
1302
+ function fetchRequiresDuplex() {
1303
+ if (duplexRequired !== null)
1304
+ return duplexRequired;
1305
+ if (typeof Request !== 'function' || typeof ReadableStream !== 'function') {
1306
+ duplexRequired = false;
1307
+ return duplexRequired;
1308
+ }
1309
+ try {
1310
+ new Request('https://example.invalid', {
1311
+ method: 'PUT',
1312
+ body: new ReadableStream(),
1313
+ });
1314
+ duplexRequired = false;
1315
+ }
1316
+ catch {
1317
+ duplexRequired = true;
1318
+ }
1319
+ return duplexRequired;
1320
+ }
1321
+ async function awaitWithSignal(promise, signal, operation) {
1322
+ throwIfAborted(signal, operation);
1323
+ let rejectAbort;
1324
+ const aborted = new Promise((_resolve, reject) => {
1325
+ rejectAbort = reject;
1326
+ });
1327
+ const onAbort = () => {
1328
+ rejectAbort(new BotaSDKError('cancelled', operation));
1329
+ };
1330
+ signal.addEventListener('abort', onAbort, { once: true });
1331
+ try {
1332
+ return await Promise.race([promise, aborted]);
1333
+ }
1334
+ finally {
1335
+ signal.removeEventListener('abort', onAbort);
1336
+ }
1337
+ }
1338
+ function throwIfAborted(signal, operation) {
1339
+ if (signal?.aborted)
1340
+ throw new BotaSDKError('cancelled', operation);
1341
+ }
1342
+ function combineSignals(first, second) {
1343
+ if (!first)
1344
+ return second;
1345
+ return AbortSignal.any([first, second]);
1346
+ }
1347
+ function recordingError(error, operation) {
1348
+ if (error instanceof BotaSDKError)
1349
+ return error;
1350
+ if (error instanceof BrowserStorageError) {
1351
+ return new BotaSDKError(error.code, operation);
1352
+ }
1353
+ if (error instanceof BrowserTransportError) {
1354
+ const code = error.code === 'disconnected'
1355
+ ? 'device_disconnected'
1356
+ : error.code === 'permission_denied'
1357
+ ? 'permission_denied'
1358
+ : 'bluetooth_unavailable';
1359
+ return new BotaSDKError(code, operation);
1360
+ }
1361
+ return normalizeCoreError(error, operation);
1362
+ }
1363
+ function uploadError(error, signal) {
1364
+ if (signal.aborted)
1365
+ return new BotaSDKError('cancelled', 'upload');
1366
+ if (error instanceof BotaSDKError)
1367
+ return error;
1368
+ if (error instanceof BrowserStorageError) {
1369
+ return new BotaSDKError(error.code, 'upload');
1370
+ }
1371
+ return new BotaSDKError('upload_failed', 'upload', { retryable: true });
1372
+ }
1373
+ function deferred() {
1374
+ let resolve;
1375
+ const promise = new Promise((resolvePromise) => {
1376
+ resolve = resolvePromise;
1377
+ });
1378
+ return { promise, resolve };
1379
+ }
1380
+ function hex(value) {
1381
+ return Array.from(value, (byte) => byte.toString(16).padStart(2, '0')).join('');
1382
+ }
1383
+ function isWellFormedUtf16(value) {
1384
+ for (let index = 0; index < value.length; index += 1) {
1385
+ const codeUnit = value.charCodeAt(index);
1386
+ if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) {
1387
+ const next = value.charCodeAt(index + 1);
1388
+ if (!(next >= 0xdc00 && next <= 0xdfff))
1389
+ return false;
1390
+ index += 1;
1391
+ }
1392
+ else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) {
1393
+ return false;
1394
+ }
1395
+ }
1396
+ return true;
1397
+ }