@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bota
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,517 @@
1
+ # Bota SDK for Web
2
+
3
+ `@bota.dev/web-app-sdk` is the foreground browser distribution of the Bota App
4
+ SDK. It combines Web Bluetooth and tenant-scoped browser storage with the shared
5
+ Rust core compiled to WebAssembly. Rust owns protocol sequencing, integrity,
6
+ checkpoints, and stable workflow errors; the application owns authentication,
7
+ backend calls, user consent, and presentation.
8
+
9
+ Historical `@bota.dev/web-sdk@1.2.0-beta.7` is public. The renamed
10
+ `2.0.0-beta.0` candidate retains the foreground implementation; publication
11
+ is pending and physical-device acceptance remains open. Remove the old
12
+ dependency before adding its replacement. Storage namespaces do not change.
13
+
14
+ ## Install
15
+
16
+ After the synchronized candidate is published, pin its exact version:
17
+
18
+ ```bash
19
+ npm install --save-exact @bota.dev/web-app-sdk@2.0.0-beta.0
20
+ ```
21
+
22
+ Use a secure context in a desktop Chromium browser with Web Bluetooth. The
23
+ initial `connect()` call must run directly from a user gesture because it opens
24
+ the browser picker. A previously authorized exact device may be reconnected
25
+ without a picker only when `navigator.bluetooth.getDevices()` is available.
26
+
27
+ ## Create a client and provide backend boundaries
28
+
29
+ Read-only connect and snapshot use require no storage or provider. Durable or
30
+ backend-authorized workflows require a non-empty tenant namespace and the
31
+ applicable host provider. A custom storage adapter must expose the exact same
32
+ namespace.
33
+
34
+ ```ts
35
+ import type {
36
+ EncryptedUploadV2Material,
37
+ EncryptedUploadV2ProviderContext,
38
+ FirmwareDownloadProvider,
39
+ LegacyUploadContext,
40
+ ProvisioningProvider,
41
+ RecordingControlProvider,
42
+ RecordingUploadProvider,
43
+ UploadRequestTemplate,
44
+ } from '@bota.dev/web-app-sdk'
45
+ import { BotaDeviceClient } from '@bota.dev/web-app-sdk'
46
+
47
+ declare global {
48
+ interface Window {
49
+ BOTA_TEST_HOST_ORIGIN?: string
50
+ }
51
+ }
52
+
53
+ const HOST_ORIGIN = window.BOTA_TEST_HOST_ORIGIN ?? 'https://example.invalid'
54
+
55
+ function encodeBytes(value: Uint8Array): string {
56
+ let binary = ''
57
+ for (const byte of value) binary += String.fromCharCode(byte)
58
+ return btoa(binary)
59
+ }
60
+
61
+ function decodeBytes(value: string): Uint8Array {
62
+ return Uint8Array.from(atob(value), (character) => character.charCodeAt(0))
63
+ }
64
+
65
+ async function postHost<T>(
66
+ path: string,
67
+ body: unknown,
68
+ signal?: AbortSignal,
69
+ ): Promise<T> {
70
+ const response = await fetch(new URL(path, HOST_ORIGIN), {
71
+ method: 'POST',
72
+ credentials: 'include',
73
+ headers: { 'content-type': 'application/json' },
74
+ body: JSON.stringify(body),
75
+ signal,
76
+ })
77
+ if (!response.ok) throw new Error(`Host callback failed: ${response.status}`)
78
+ if (response.status === 204) return undefined as T
79
+ return await response.json() as T
80
+ }
81
+
82
+ function uploadRequest(response: {
83
+ url: string
84
+ headers: Record<string, string>
85
+ }): UploadRequestTemplate {
86
+ return { method: 'PUT', url: response.url, headers: response.headers }
87
+ }
88
+
89
+ function legacyBody(context: LegacyUploadContext): Record<string, unknown> {
90
+ return {
91
+ operationId: context.operationId,
92
+ serialNumber: context.serialNumber,
93
+ recordingUuid: context.recording.uuid,
94
+ sizeBytes: context.sizeBytes.toString(),
95
+ plaintextSha256Hex: context.plaintextSha256Hex,
96
+ stagedBodySha256Hex: context.stagedBodySha256Hex,
97
+ encrypted: context.encrypted,
98
+ }
99
+ }
100
+
101
+ function v2Evidence(
102
+ evidence: Parameters<EncryptedUploadV2Material['stagingRequest']>[0],
103
+ ): Record<string, unknown> {
104
+ return {
105
+ ciphertextLength: evidence.ciphertextLength.toString(),
106
+ ciphertextSha256Base64: encodeBytes(evidence.ciphertextSha256),
107
+ manifestLength: evidence.manifestLength,
108
+ manifestSha256Base64: encodeBytes(evidence.manifestSha256),
109
+ blockCount: evidence.blockCount,
110
+ }
111
+ }
112
+
113
+ const provisioning: ProvisioningProvider = {
114
+ async prepare(context) {
115
+ const response = await postHost<{
116
+ materialId: string
117
+ apiEndpointBase64: string
118
+ deviceTokenBase64: string
119
+ mtu: number
120
+ }>('/sdk/provisioning/prepare', {
121
+ attemptId: context.attemptId,
122
+ materialId: context.materialId,
123
+ serialNumber: context.serialNumber,
124
+ nonceBase64: encodeBytes(context.nonce),
125
+ devicePublicKeyBase64: encodeBytes(context.devicePublicKey),
126
+ }, context.signal)
127
+ return {
128
+ materialId: response.materialId,
129
+ apiEndpoint: decodeBytes(response.apiEndpointBase64),
130
+ deviceToken: decodeBytes(response.deviceTokenBase64),
131
+ mtu: response.mtu,
132
+ }
133
+ },
134
+ async confirm(context) {
135
+ await postHost<void>('/sdk/provisioning/confirm', {
136
+ attemptId: context.attemptId,
137
+ serialNumber: context.serialNumber,
138
+ }, context.signal)
139
+ },
140
+ async abort(context) {
141
+ await postHost<void>('/sdk/provisioning/abort', {
142
+ attemptId: context.attemptId,
143
+ serialNumber: context.serialNumber,
144
+ reason: context.reason,
145
+ }, context.signal)
146
+ },
147
+ }
148
+
149
+ const recordingControl: RecordingControlProvider = {
150
+ async prepare(context) {
151
+ const response = await postHost<{ grantBase64: string }>(
152
+ '/sdk/recording-control/prepare',
153
+ {
154
+ operationId: context.operationId,
155
+ serialNumber: context.serialNumber,
156
+ action: context.action,
157
+ authorityId: context.authorityId,
158
+ },
159
+ context.signal,
160
+ )
161
+ return { grant: decodeBytes(response.grantBase64) }
162
+ },
163
+ }
164
+
165
+ const firmwareDownload: FirmwareDownloadProvider = {
166
+ async resolve(context) {
167
+ const response = await postHost<{
168
+ url: string
169
+ headers: Record<string, string>
170
+ }>('/sdk/firmware/resolve', {
171
+ operationId: context.operationId,
172
+ serialNumber: context.serialNumber,
173
+ image: context.image,
174
+ }, context.signal)
175
+ return { method: 'GET', url: response.url, headers: response.headers }
176
+ },
177
+ }
178
+
179
+ const recordingUpload: RecordingUploadProvider = {
180
+ async prepareLegacyUpload(context) {
181
+ const response = await postHost<{
182
+ uploadId: string
183
+ request: { url: string; headers: Record<string, string> }
184
+ }>(
185
+ '/sdk/recordings/legacy/prepare',
186
+ legacyBody(context),
187
+ context.signal,
188
+ )
189
+ return { uploadId: response.uploadId, request: uploadRequest(response.request) }
190
+ },
191
+ async completeLegacyUpload(context) {
192
+ return await postHost<{ cloudCompletionId: string }>(
193
+ '/sdk/recordings/legacy/complete',
194
+ { ...legacyBody(context), uploadId: context.uploadId },
195
+ context.signal,
196
+ )
197
+ },
198
+ async reconcileLegacyUpload(context) {
199
+ return await postHost<
200
+ | { state: 'not_uploaded' }
201
+ | { state: 'cloud_completed'; cloudCompletionId: string }
202
+ >('/sdk/recordings/legacy/reconcile', {
203
+ ...legacyBody(context),
204
+ uploadId: context.uploadId,
205
+ }, context.signal)
206
+ },
207
+ async prepareEncryptedUploadV2(context: EncryptedUploadV2ProviderContext) {
208
+ const response = await postHost<{
209
+ materialId: string
210
+ recordingId: string
211
+ uploadSessionId: string
212
+ ownerRevision: number
213
+ policy: 'legacy_allowed' | 'v2_preferred' | 'v2_required'
214
+ authorizationBase64: string
215
+ }>('/sdk/recordings/v2/prepare', {
216
+ operationId: context.operationId,
217
+ serialNumber: context.serialNumber,
218
+ recording: {
219
+ uuid: context.recording.uuid,
220
+ generation: context.recording.generation,
221
+ storageFormat: context.recording.storageFormat,
222
+ ciphertextLength: context.recording.ciphertextLength.toString(),
223
+ ciphertextSha256Base64: encodeBytes(context.recording.ciphertextSha256),
224
+ },
225
+ capability: {
226
+ rawValueBase64: encodeBytes(context.capability.rawValue),
227
+ sha256Base64: encodeBytes(context.capability.sha256),
228
+ decoded: context.capability.decoded,
229
+ },
230
+ checkpoint: context.checkpoint && {
231
+ uploadSessionId: context.checkpoint.uploadSessionId,
232
+ ownerRevision: context.checkpoint.ownerRevision,
233
+ checkpointRevision: context.checkpoint.checkpointRevision,
234
+ nextCiphertextOffset: context.checkpoint.nextCiphertextOffset.toString(),
235
+ prefixSha256Base64: encodeBytes(context.checkpoint.prefixSha256),
236
+ transportSessionId: context.checkpoint.transportSessionId.toString(),
237
+ sinkId: context.checkpoint.sinkId,
238
+ windowPackets: context.checkpoint.windowPackets,
239
+ dataPayloadBytes: context.checkpoint.dataPayloadBytes,
240
+ },
241
+ }, context.signal)
242
+ const materialId = response.materialId
243
+ return {
244
+ ...response,
245
+ authorization: decodeBytes(response.authorizationBase64),
246
+ async stagingRequest(evidence, signal) {
247
+ const request = await postHost<{
248
+ url: string
249
+ headers: Record<string, string>
250
+ }>('/sdk/recordings/v2/staging-request', {
251
+ materialId,
252
+ evidence: v2Evidence(evidence),
253
+ }, signal)
254
+ return uploadRequest(request)
255
+ },
256
+ async submitManifest(manifest, evidence, signal) {
257
+ await postHost<void>('/sdk/recordings/v2/manifest', {
258
+ materialId,
259
+ manifestBase64: encodeBytes(manifest),
260
+ evidence: v2Evidence(evidence),
261
+ }, signal)
262
+ },
263
+ async finalize(evidence, signal) {
264
+ await postHost<void>('/sdk/recordings/v2/finalize', {
265
+ materialId,
266
+ evidence: v2Evidence(evidence),
267
+ }, signal)
268
+ },
269
+ async completionReceipt(evidence, signal) {
270
+ const receipt = await postHost<{ receiptBase64: string }>(
271
+ '/sdk/recordings/v2/receipt',
272
+ { materialId, evidence: v2Evidence(evidence) },
273
+ signal,
274
+ )
275
+ return decodeBytes(receipt.receiptBase64)
276
+ },
277
+ async cancel(signal) {
278
+ await postHost<void>(
279
+ '/sdk/recordings/v2/cancel',
280
+ { materialId },
281
+ signal,
282
+ )
283
+ },
284
+ }
285
+ },
286
+ }
287
+
288
+ export async function createExampleClient(tenant: {
289
+ organizationId: string
290
+ projectId: string
291
+ userId: string
292
+ }) {
293
+ return await BotaDeviceClient.create({
294
+ storageNamespace:
295
+ `${tenant.organizationId}:${tenant.projectId}:${tenant.userId}`,
296
+ providers: {
297
+ provisioning,
298
+ recordingUpload,
299
+ recordingControl,
300
+ firmwareDownload,
301
+ },
302
+ })
303
+ }
304
+ ```
305
+
306
+ Those application-owned adapters have these exact responsibilities:
307
+
308
+ | Provider | Application responsibility | SDK boundary |
309
+ |---|---|---|
310
+ | `provisioning` | Prepare opaque material for the exact serial, nonce, device public key, and attempt; confirm physical success or abort failure | Material is operation-bound, volatile, and never logged or persisted by the SDK |
311
+ | `recordingUpload` | Resolve short-lived object-storage requests, reconcile ambiguous legacy uploads, complete cloud processing, and issue exact v2 material/receipt | The SDK stages verified bytes, uploads through the returned request, and confirms the device only after durable cloud completion |
312
+ | `recordingControl` | Exchange the serial, action, authority ID, and operation ID for an exact grant | The SDK cannot mint, widen, or reuse a grant for another action |
313
+ | `firmwareDownload` | Resolve stable image identity to a fresh operation-scoped HTTPS `GET` URL and headers | URL and headers stay memory-only; the SDK verifies size, SHA-256, and CRC32 before device mutation |
314
+
315
+ Implement these providers in the application or its backend-client layer. Do
316
+ not put long-lived API credentials, decryption keys, production endpoints, or
317
+ private signing material in browser code. A provider may call an authenticated
318
+ application endpoint such as `https://example.invalid/sdk/provisioning/prepare`;
319
+ the SDK itself never calls the Bota API implicitly. Provider failures must fail
320
+ closed, and provider implementations must not log returned grants, tokens,
321
+ signed documents, presigned URLs, headers, WiFi credentials, receipts, or
322
+ recording content. Every provider callback receives an operation-scoped
323
+ `AbortSignal`; pass it to application I/O. On cancellation the SDK stops
324
+ waiting, observes late settlement, and ignores late results. The SDK sends
325
+ operation-scoped upload and firmware requests
326
+ with redirects disabled; providers must return the exact final HTTPS target
327
+ rather than a redirecting URL.
328
+
329
+ Create one client for the signed-in tenant and keep it for the page lifetime:
330
+
331
+ ```ts
332
+ const bota = await createExampleClient({ organizationId, projectId, userId })
333
+ ```
334
+
335
+ ## Picker connection, reconnect, and snapshot
336
+
337
+ The expected serial must come from the authenticated application device record.
338
+ An advertised Bluetooth name is display/filter metadata only.
339
+
340
+ ```ts
341
+ connectButton.addEventListener('click', async () => {
342
+ const device = await bota.devices.connect({
343
+ expectedSerialNumber: activeDevice.serialNumber,
344
+ })
345
+ console.log(device.serialNumber)
346
+ })
347
+
348
+ // Use after a disconnect or reload. This never opens the picker or probes by name.
349
+ const reconnected = await bota.devices.reconnect({
350
+ expectedSerialNumber: activeDevice.serialNumber,
351
+ })
352
+
353
+ const snapshot = await bota.devices.readSnapshot()
354
+ console.log(reconnected.serialNumber, snapshot.status.batteryPercent)
355
+ ```
356
+
357
+ `connect()` publishes a device only after Device Information serial verification.
358
+ `reconnect()` enumerates previously authorized devices, selects only the saved
359
+ browser device ID, waits for prior notification teardown, and verifies the
360
+ serial again. `readSnapshot()` repeats that verification before returning fresh
361
+ identity, status, and capability values.
362
+
363
+ ## Recording list and sync
364
+
365
+ ```ts
366
+ const recordings = await bota.recordings.list()
367
+ const recording = recordings[0]
368
+
369
+ if (recording) {
370
+ const profile = recording.encryptedUploadV2
371
+ ? 'encrypted_upload_v2'
372
+ : 'legacy'
373
+
374
+ const result = await bota.recordings.sync(recording, {
375
+ profile,
376
+ onProgress: ({ phase, completedBytes, totalBytes }) => {
377
+ renderRecordingProgress(phase, completedBytes, totalBytes)
378
+ },
379
+ })
380
+
381
+ console.log(result.operationId, result.cloudCompletionId)
382
+ }
383
+ ```
384
+
385
+ Encrypted Upload v2 is selected only from a fresh firmware capability read and
386
+ matching provider material; firmware version strings never enable it. The
387
+ ciphertext remains ciphertext in OPFS and through the staging upload, and
388
+ decryption keys never enter the SDK. Legacy plaintext is tenant-scoped and
389
+ removed after confirmed upload. Both paths send device confirmation only after
390
+ durable cloud completion.
391
+
392
+ Use `listPendingOperations()`, `resume(operationId)`,
393
+ `cancel(operationId)`, and recovery-only `confirm(operationId)` for durable
394
+ operations. `confirm()` is not an arbitrary delete API: it requires a journal
395
+ that proves cloud completion and exact confirmation material.
396
+
397
+ ## Provisioning, settings, WiFi, and recording control
398
+
399
+ ```ts
400
+ await bota.provisioning.provision({ attemptId })
401
+
402
+ const settings = await bota.provisioning.readConnectionSettings()
403
+ await bota.provisioning.writeConnectionSettings({
404
+ ...settings,
405
+ enabledConnections: { ...settings.enabledConnections, wifi: true },
406
+ })
407
+
408
+ const networks = await bota.wifi.scanNetworks()
409
+ await bota.wifi.configure(
410
+ { ssid: selectedNetwork.ssid, password: enteredPassword },
411
+ wifiGrant,
412
+ )
413
+ const wifiStatus = await bota.wifi.readStatus()
414
+ const wifiSubscription = await bota.wifi.subscribeToStatus(renderWiFiStatus)
415
+
416
+ await bota.controls.startRecording({ authorityId: recordingAuthorityId })
417
+ await bota.controls.stopRecording({ authorityId: recordingAuthorityId })
418
+
419
+ await wifiSubscription.remove()
420
+ ```
421
+
422
+ Provisioning closes the prepare/physical-result/confirm-or-abort loop. A
423
+ successful backend prepare is not a completed bind. Remove-only deprovisioning
424
+ uses `bota.provisioning.deprovision({ grant })`; it is non-destructive and must
425
+ not be described as factory reset. WiFi credentials and control grants are
426
+ volatile and are scrubbed on terminal paths where JavaScript permits.
427
+
428
+ ## Firmware update and recovery
429
+
430
+ Check the independently reported browser capability before starting:
431
+
432
+ ```ts
433
+ const capabilities = bota.devices.getCapabilities()
434
+ if (!capabilities.firmwareUpdate) {
435
+ throw new Error('Firmware update is unavailable in this browser')
436
+ }
437
+
438
+ await bota.ota.updateFirmware(firmwareImage, {
439
+ operationId: firmwareOperationId,
440
+ onProgress: ({ phase, completedBytes, totalBytes }) => {
441
+ renderFirmwareProgress(phase, completedBytes, totalBytes)
442
+ },
443
+ })
444
+ ```
445
+
446
+ The SDK streams the response to OPFS and verifies exact length, SHA-256, and
447
+ CRC32 before the first OTA GATT mutation. Reboot recovery uses only the exact
448
+ previously verified browser device ID and never opens the picker. Continue a
449
+ durable operation with `resumeFirmwareUpdate(operationId)` and stop it with
450
+ `cancelFirmwareUpdate(operationId)`. Reload validates journal, checkpoint, and
451
+ blob compatibility before GATT. A terminal `cleanup_only` journal performs
452
+ only idempotent local cleanup and does not call the provider or device.
453
+
454
+ ## Device logs
455
+
456
+ ```ts
457
+ const logSubscription = await bota.logs.subscribe(({ message, isBacklog }) => {
458
+ renderDeviceLog({ message, isBacklog })
459
+ })
460
+
461
+ await logSubscription.remove()
462
+ ```
463
+
464
+ Only one device-log owner is allowed. Rust emits complete sanitized lines; raw
465
+ packets and decoder details do not reach the callback. Explicit removal,
466
+ listener failure, disconnect, and `destroy()` cancel the exact workflow and
467
+ remove its characteristic subscription before ownership is released.
468
+
469
+ ## Logout or tenant change
470
+
471
+ ```ts
472
+ await bota.destroy()
473
+ await bota.clearPersistedData()
474
+ ```
475
+
476
+ Use that order. `destroy()` is terminal and idempotent: it rejects new work,
477
+ joins picker/reconnect startup, cancels active owners, removes passive
478
+ subscriptions, and performs one final disconnect. `clearPersistedData()` is
479
+ local-only, BLE-free, and repeatable after destruction; it clears only the
480
+ client's tenant namespace. Do not share a namespace between organizations,
481
+ projects, or users.
482
+
483
+ Manager names exported from the package root are TypeScript instance types.
484
+ Obtain managers from the client; direct manager construction is not public API.
485
+
486
+ ## Capability matrix
487
+
488
+ | Capability | Candidate support |
489
+ |---|---|
490
+ | Explicit picker connect, disconnect, exact-identity snapshot | Supported in the foreground |
491
+ | Saved exact-device reconnect | Supported when authorized-device enumeration is available |
492
+ | Recording list, legacy sync, cancellation, recovery | Supported with durable storage and `recordingUpload` |
493
+ | Encrypted Upload v2 | Supported only when freshly advertised and exactly host-authorized |
494
+ | Provisioning and remove-only deprovision | Supported with `provisioning` |
495
+ | Connection settings | Supported |
496
+ | WiFi scan/configure/disconnect/status/subscription | Supported in the foreground |
497
+ | Recording start/stop | Supported with `recordingControl` |
498
+ | OTA download/transfer/reboot/reconnect/reload recovery | Supported with durable storage and `firmwareDownload` |
499
+ | Device logs | Supported as one foreground subscription |
500
+ | Background scan, service-worker Bluetooth, closed-tab work | Unavailable |
501
+ | Live recording streaming | Unavailable |
502
+ | Authenticated destructive factory reset | Unavailable |
503
+ | Safari/iOS fallback or Bluetooth polyfill | Unavailable |
504
+ | Flutter Web and Windows | Unavailable |
505
+ | Built-in Bota API authentication/client | Unavailable; application-owned by design |
506
+
507
+ Browser capabilities are reported independently by
508
+ `bota.devices.getCapabilities()`. An unavailable optional capability fails
509
+ before device mutation with a stable SDK error. Web Bluetooth permission alone
510
+ is never treated as device identity or backend authorization.
511
+
512
+ ## Physical acceptance
513
+
514
+ Automated Chromium uses deterministic fake Bluetooth and storage boundaries;
515
+ it is not physical-device evidence. Run the supervised matrix
516
+ in [`docs/testing/web-physical-device.md`](../../docs/testing/web-physical-device.md)
517
+ with one exact device and record the result in the matching release evidence.
@@ -0,0 +1,8 @@
1
+ import type { BrowserCapabilities } from './models.ts';
2
+ import type { BrowserBluetoothTransport } from './transport.ts';
3
+ export interface BrowserStorageSupport {
4
+ indexedDB: boolean;
5
+ opfs: boolean;
6
+ }
7
+ export declare function detectBrowserStorageSupport(): BrowserStorageSupport;
8
+ export declare function detectBrowserCapabilities(transport: BrowserBluetoothTransport, storage?: BrowserStorageSupport): BrowserCapabilities;
@@ -0,0 +1,21 @@
1
+ export function detectBrowserStorageSupport() {
2
+ const storage = typeof navigator === 'undefined' ? undefined : navigator.storage;
3
+ return {
4
+ indexedDB: typeof globalThis.indexedDB !== 'undefined' && globalThis.indexedDB !== null,
5
+ opfs: typeof storage?.getDirectory === 'function',
6
+ };
7
+ }
8
+ export function detectBrowserCapabilities(transport, storage = detectBrowserStorageSupport()) {
9
+ const bluetooth = transport.isSupported;
10
+ const durableStorage = storage.indexedDB && storage.opfs;
11
+ return Object.freeze({
12
+ bluetooth,
13
+ authorizedDeviceReconnect: bluetooth && transport.supportsAuthorizedDevices,
14
+ durableStorage,
15
+ largeRecordingSync: bluetooth && durableStorage,
16
+ firmwareUpdate: bluetooth
17
+ && transport.supportsAuthorizedDevices
18
+ && durableStorage
19
+ && typeof globalThis.fetch === 'function',
20
+ });
21
+ }
@@ -0,0 +1,39 @@
1
+ import type { CoreLoader } from './core.ts';
2
+ import { ControlManager } from './controlManager.ts';
3
+ import { DeviceManager } from './deviceManager.ts';
4
+ import { LogManager } from './logManager.ts';
5
+ import { OTAManager } from './otaManager.ts';
6
+ import { ProvisioningManager } from './provisioningManager.ts';
7
+ import type { ProvisioningProvider, FirmwareDownloadProvider, RecordingControlProvider, RecordingUploadProvider } from './providers.ts';
8
+ import { RecordingManager } from './recordingManager.ts';
9
+ import { type BrowserSdkStorage } from './storage.ts';
10
+ import type { BrowserBluetoothTransport } from './transport.ts';
11
+ import { WiFiManager } from './wifiManager.ts';
12
+ export interface BotaDeviceClientOptions {
13
+ storageNamespace?: string;
14
+ coreLoader?: CoreLoader;
15
+ transport?: BrowserBluetoothTransport;
16
+ storage?: BrowserSdkStorage;
17
+ providers?: {
18
+ provisioning?: ProvisioningProvider;
19
+ firmwareDownload?: FirmwareDownloadProvider;
20
+ recordingControl?: RecordingControlProvider;
21
+ recordingUpload?: RecordingUploadProvider;
22
+ };
23
+ }
24
+ export declare class BotaDeviceClient {
25
+ readonly devices: DeviceManager;
26
+ readonly controls: ControlManager;
27
+ readonly provisioning: ProvisioningManager;
28
+ readonly ota: OTAManager;
29
+ readonly logs: LogManager;
30
+ readonly recordings: RecordingManager;
31
+ readonly wifi: WiFiManager;
32
+ private readonly runtime;
33
+ private readonly storage;
34
+ private destroyPromise;
35
+ private constructor();
36
+ static create(options?: BotaDeviceClientOptions): Promise<BotaDeviceClient>;
37
+ clearPersistedData(): Promise<void>;
38
+ destroy(): Promise<void>;
39
+ }