@fleetless/sdk 1.0.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.
@@ -0,0 +1,1681 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Jobs (spec §6.1, §11.3): one running unit of work on a robot — an action
5
+ * goal or a service call — with an id both sides know, so bridge and cloud
6
+ * stay in sync across a disconnect.
7
+ *
8
+ * Two rules shape everything here:
9
+ *
10
+ * 1. **State is observed by slug, not by id.** The id is informative (§11.3);
11
+ * a client watches `robot × slug` and sees whatever job is running there,
12
+ * which is also why every observer of a slug sees the same job.
13
+ * 2. **`lost` is a real outcome and must be said out loud** (§6.1). Job state
14
+ * lives only in the bridge's memory; if it restarts mid-job, the results
15
+ * are gone. The cloud then marks the job `lost` — never leaves it reading
16
+ * "running" because nobody contradicted it. A system that reports a
17
+ * machine is still working when it does not know is worse than one that
18
+ * admits it lost track.
19
+ */
20
+ declare const jobState: z.ZodEnum<{
21
+ running: "running";
22
+ succeeded: "succeeded";
23
+ failed: "failed";
24
+ cancelled: "cancelled";
25
+ lost: "lost";
26
+ }>;
27
+ type JobState = z.infer<typeof jobState>;
28
+ declare const job: z.ZodObject<{
29
+ id: z.ZodUUID;
30
+ robot_id: z.ZodUUID;
31
+ slug: z.ZodString;
32
+ state: z.ZodEnum<{
33
+ running: "running";
34
+ succeeded: "succeeded";
35
+ failed: "failed";
36
+ cancelled: "cancelled";
37
+ lost: "lost";
38
+ }>;
39
+ started_at: z.ZodISODateTime;
40
+ updated_at: z.ZodISODateTime;
41
+ seq: z.ZodNumber;
42
+ result: z.ZodNullable<z.ZodUnknown>;
43
+ error: z.ZodNullable<z.ZodObject<{
44
+ code: z.ZodString;
45
+ message: z.ZodString;
46
+ details: z.ZodOptional<z.ZodUnknown>;
47
+ }, z.core.$strip>>;
48
+ }, z.core.$strip>;
49
+ type Job = z.infer<typeof job>;
50
+ /**
51
+ * One update about a job, pushed to subscribers of its slug.
52
+ *
53
+ * `timestamp_ms` is the bridge's capture time, exactly as for a datapoint
54
+ * (§6.3 says action feedback carries it too) — so a client computes the age
55
+ * of a progress report the same way it computes the age of a sensor value,
56
+ * and a burst of late-delivered feedback after a reconnect is visibly late
57
+ * rather than looking current.
58
+ */
59
+ declare const jobEvent: z.ZodObject<{
60
+ type: z.ZodLiteral<"job">;
61
+ robot_id: z.ZodUUID;
62
+ slug: z.ZodString;
63
+ job: z.ZodObject<{
64
+ id: z.ZodUUID;
65
+ robot_id: z.ZodUUID;
66
+ slug: z.ZodString;
67
+ state: z.ZodEnum<{
68
+ running: "running";
69
+ succeeded: "succeeded";
70
+ failed: "failed";
71
+ cancelled: "cancelled";
72
+ lost: "lost";
73
+ }>;
74
+ started_at: z.ZodISODateTime;
75
+ updated_at: z.ZodISODateTime;
76
+ seq: z.ZodNumber;
77
+ result: z.ZodNullable<z.ZodUnknown>;
78
+ error: z.ZodNullable<z.ZodObject<{
79
+ code: z.ZodString;
80
+ message: z.ZodString;
81
+ details: z.ZodOptional<z.ZodUnknown>;
82
+ }, z.core.$strip>>;
83
+ }, z.core.$strip>;
84
+ feedback: z.ZodNullable<z.ZodUnknown>;
85
+ progress: z.ZodNullable<z.ZodNumber>;
86
+ timestamp_ms: z.ZodNumber;
87
+ }, z.core.$strip>;
88
+ type JobEvent = z.infer<typeof jobEvent>;
89
+ /**
90
+ * What a busy refusal tells the caller (spec §11.3: "inkl. Information, was
91
+ * läuft"). A refusal that only says "busy" forces the caller to guess whether
92
+ * to wait or to give up.
93
+ */
94
+ declare const busyDetails: z.ZodObject<{
95
+ running: z.ZodObject<{
96
+ id: z.ZodUUID;
97
+ robot_id: z.ZodUUID;
98
+ slug: z.ZodString;
99
+ state: z.ZodEnum<{
100
+ running: "running";
101
+ succeeded: "succeeded";
102
+ failed: "failed";
103
+ cancelled: "cancelled";
104
+ lost: "lost";
105
+ }>;
106
+ started_at: z.ZodISODateTime;
107
+ updated_at: z.ZodISODateTime;
108
+ seq: z.ZodNumber;
109
+ result: z.ZodNullable<z.ZodUnknown>;
110
+ error: z.ZodNullable<z.ZodObject<{
111
+ code: z.ZodString;
112
+ message: z.ZodString;
113
+ details: z.ZodOptional<z.ZodUnknown>;
114
+ }, z.core.$strip>>;
115
+ }, z.core.$strip>;
116
+ }, z.core.$strip>;
117
+ type BusyDetails = z.infer<typeof busyDetails>;
118
+
119
+ /**
120
+ * The REST read of one datapoint. For bridge-captured data `timestamp_ms`
121
+ * is the capture time at the bridge (spec §6.3); for the cloud-observed
122
+ * built-in `bridge-state` it is the time the cloud observed the state.
123
+ */
124
+ declare const datapointValue: z.ZodObject<{
125
+ slug: z.ZodString;
126
+ value: z.ZodUnknown;
127
+ timestamp_ms: z.ZodNumber;
128
+ }, z.core.$strip>;
129
+ type DatapointValue = z.infer<typeof datapointValue>;
130
+ /**
131
+ * Every job the platform currently believes this robot has — `GET
132
+ * /api/robots/:id/jobs` (W6b).
133
+ *
134
+ * `jobResponse` answers "what is on this slug", which requires knowing the
135
+ * slug first. That was enough while a job could only exist on a slug the
136
+ * published configuration named. W6b breaks that assumption twice: a
137
+ * reconnecting bridge can name a job the cloud has **no row for** and the
138
+ * cloud adopts it, and a configuration change can leave a job on a slug the
139
+ * document no longer contains. Both are jobs nobody can ask about, because
140
+ * asking requires already knowing what to ask for.
141
+ *
142
+ * So this route exists to answer the question the per-slug route cannot: not
143
+ * "is something running here", but "what is this robot doing". A restarted
144
+ * cloud that has just reconciled a robot's `hello.active_jobs` has exactly
145
+ * this list and, until now, no way to say it out loud.
146
+ *
147
+ * The array is ordered newest first and is **never null**: a robot doing
148
+ * nothing answers `{ jobs: [] }`. "Nothing is running" and "we did not look"
149
+ * are different facts, and a nullable list would merge them — the same
150
+ * distinction `robotDeletionSummary` was made all-required for.
151
+ *
152
+ * **At most one entry per slug: the current job there, exactly what
153
+ * `jobResponse` would answer for that slug.** This is not a history endpoint
154
+ * and must not become one. The first implementation returned every job the
155
+ * registry still held — six rows and four complete Fibonacci results after a
156
+ * few minutes of gate traffic, and unbounded in both count and payload for a
157
+ * robot that has been working all day. The list would have grown until a
158
+ * console page carried a robot's entire past, and the one thing it exists to
159
+ * answer — *what is this robot doing* — would have been the first line of a
160
+ * scroll.
161
+ *
162
+ * A settled job stays visible as its slug's current entry until something
163
+ * else runs there, which is what makes a job that just failed still findable.
164
+ * Read `state` to tell a live one from a finished one, exactly as with
165
+ * `jobResponse`.
166
+ */
167
+ /**
168
+ * What a `rate_limited` refusal tells the caller (W6c).
169
+ *
170
+ * One number, and it is the only one that matters: **when to come back.** A
171
+ * limit that says "too many" without saying "in 800 ms" produces a client that
172
+ * retries immediately, which is the behaviour the limit exists to stop — so
173
+ * omitting it would make the refusal part of the attack.
174
+ *
175
+ * Deliberately **not** carrying the limit, the window, or how many attempts
176
+ * remain: those describe the defence to whoever is probing it, and none of
177
+ * them changes what an honest caller does.
178
+ */
179
+ declare const rateLimitDetails: z.ZodObject<{
180
+ retry_after_ms: z.ZodNumber;
181
+ }, z.core.$strip>;
182
+ type RateLimitDetails = z.infer<typeof rateLimitDetails>;
183
+ declare const cameraDescriptor: z.ZodObject<{
184
+ slug: z.ZodString;
185
+ width: z.ZodNumber;
186
+ height: z.ZodNumber;
187
+ fps: z.ZodNumber;
188
+ snapshot_interval_ms: z.ZodNumber;
189
+ }, z.core.$strip>;
190
+ type CameraDescriptor = z.infer<typeof cameraDescriptor>;
191
+ /**
192
+ * Raw samples. `timestamp_ms` is the **bridge's capture time** (§6.3) — the
193
+ * same instant the live value carried, so a recorded point and a live one can
194
+ * be placed on one axis without apology.
195
+ *
196
+ * `truncated` says the response was cut short. A short array that does not
197
+ * admit it is indistinguishable from a quiet period, and the two lead a
198
+ * developer to opposite conclusions.
199
+ */
200
+ declare const historySamplesResponse: z.ZodObject<{
201
+ slug: z.ZodString;
202
+ kind: z.ZodLiteral<"samples">;
203
+ samples: z.ZodArray<z.ZodObject<{
204
+ timestamp_ms: z.ZodNumber;
205
+ value: z.ZodUnknown;
206
+ }, z.core.$strip>>;
207
+ truncated: z.ZodBoolean;
208
+ truncated_by: z.ZodNullable<z.ZodEnum<{
209
+ limit: "limit";
210
+ bytes: "bytes";
211
+ }>>;
212
+ }, z.core.$strip>;
213
+ type HistorySamplesResponse = z.infer<typeof historySamplesResponse>;
214
+ /**
215
+ * Aggregated buckets — a **separate shape**, not the samples shape with nulls
216
+ * in it, so a client knows by type what it received rather than by
217
+ * inspection.
218
+ *
219
+ * `sample_count` exists because an empty bucket and a bucket whose average is
220
+ * zero are different facts. W5 established at some cost what happens when two
221
+ * facts share one representation, and a chart is the easiest place in this
222
+ * product to draw a gap as a line.
223
+ */
224
+ declare const historyBucketsResponse: z.ZodObject<{
225
+ slug: z.ZodString;
226
+ kind: z.ZodLiteral<"buckets">;
227
+ window_ms: z.ZodNumber;
228
+ agg: z.ZodEnum<{
229
+ min: "min";
230
+ max: "max";
231
+ avg: "avg";
232
+ }>;
233
+ buckets: z.ZodArray<z.ZodObject<{
234
+ bucket_start_ms: z.ZodNumber;
235
+ value: z.ZodNullable<z.ZodNumber>;
236
+ sample_count: z.ZodNumber;
237
+ }, z.core.$strip>>;
238
+ }, z.core.$strip>;
239
+ type HistoryBucketsResponse = z.infer<typeof historyBucketsResponse>;
240
+
241
+ /**
242
+ * One datapoint sample pushed to a subscriber. The current value arrives
243
+ * immediately on subscribe, then every change. `timestamp_ms` semantics as
244
+ * in `datapointValue` (capture time; cloud-observed for `bridge-state`).
245
+ */
246
+ declare const datapointEvent: z.ZodObject<{
247
+ type: z.ZodLiteral<"datapoint">;
248
+ robot_id: z.ZodUUID;
249
+ slug: z.ZodString;
250
+ value: z.ZodUnknown;
251
+ timestamp_ms: z.ZodNumber;
252
+ }, z.core.$strip>;
253
+ type DatapointEvent = z.infer<typeof datapointEvent>;
254
+
255
+ /**
256
+ * Access plus refresh (spec §3.4). The access token is short-lived; the
257
+ * refresh token rotates on every use, so a stolen one is detectable when the
258
+ * original is presented again.
259
+ */
260
+ declare const sessionTokens: z.ZodObject<{
261
+ access_token: z.ZodString;
262
+ refresh_token: z.ZodString;
263
+ expires_in: z.ZodNumber;
264
+ }, z.core.$strip>;
265
+ type SessionTokens = z.infer<typeof sessionTokens>;
266
+ /**
267
+ * An invitation carries its own accept URL: the link is the primary path
268
+ * (the developer shares it), mail is the second. A cloud with no SMTP
269
+ * configured still issues invitations — it just cannot send them, and says so
270
+ * via `mail_sent`.
271
+ */
272
+ /**
273
+ * What happened to the mail, in three words instead of one (W6c).
274
+ *
275
+ * `mail_sent: boolean` could not tell **"we have no SMTP configured"** from
276
+ * **"we tried and the server refused"**, so the console had to pick a sentence
277
+ * for a cause it could not know — and picked the reassuring one, because a
278
+ * link-only invitation is a normal outcome and a bounced one is not. The two
279
+ * need opposite actions from whoever reads them: configure a mail server, or
280
+ * go and look at why the existing one rejected the message.
281
+ *
282
+ * - `sent` — the SMTP server accepted the message. Not "delivered":
283
+ * no sender can promise that, and this value must never
284
+ * be rendered as if it could.
285
+ * - `not_configured` — no SMTP is set up. **An expected state, not a failure**
286
+ * (spec §3.2: invitations work without mail; the link is
287
+ * the primary path). The console must not show it as an
288
+ * error.
289
+ * - `failed` — SMTP was configured, was tried, and refused or was
290
+ * unreachable. This one is worth someone's attention.
291
+ */
292
+ declare const mailStatus: z.ZodEnum<{
293
+ failed: "failed";
294
+ sent: "sent";
295
+ not_configured: "not_configured";
296
+ }>;
297
+ type MailStatus = z.infer<typeof mailStatus>;
298
+ /**
299
+ * What registering answers — deliberately **the same for an address that is
300
+ * new and one that already has an account**.
301
+ *
302
+ * Anything else is an account-enumeration oracle on an unauthenticated route,
303
+ * the same reasoning `passwordResetRequest` carries. An address that already
304
+ * exists still gets a mail, saying so; the caller cannot tell which mail was
305
+ * sent, and there is nothing in this response to tell them.
306
+ *
307
+ * `mail` is safe to return because it describes **the server's configuration**,
308
+ * not the address: `not_configured` means this deployment has no SMTP, which
309
+ * is true regardless of who registered. Note that a deployment with no mail
310
+ * server cannot complete a self-registration at all — the link is the only way
311
+ * through, unlike an invitation, where a developer can hand it over directly.
312
+ */
313
+ declare const clientRegisterResponse: z.ZodObject<{
314
+ mail: z.ZodEnum<{
315
+ failed: "failed";
316
+ sent: "sent";
317
+ not_configured: "not_configured";
318
+ }>;
319
+ }, z.core.$strip>;
320
+ type ClientRegisterResponse = z.infer<typeof clientRegisterResponse>;
321
+
322
+ /**
323
+ * Who the caller turned out to be. Returned by the "who am I" endpoint and by
324
+ * the realtime `auth_ok` frame, so a client can render a session without
325
+ * decoding a token itself — decoding a JWT in the client is how apps end up
326
+ * trusting claims nobody verified.
327
+ *
328
+ * **Three kinds of caller reach the client API, not two.** Besides end users
329
+ * and server keys, a **developer** does: spec §15.2 says the console's
330
+ * playground runs over the real client API and appears in the audit as the
331
+ * developer, and the console's own live views (robot list badges, the Live
332
+ * tab) subscribe on `/realtime` as one. A developer is **org-scoped, not
333
+ * app-scoped** — they own the configuration of every robot in their org — so
334
+ * `app_id` and `role_id` are null for them, and roles do not filter what they
335
+ * see. `kind` states this explicitly rather than leaving it to be inferred
336
+ * from which id happens to be set.
337
+ */
338
+ declare const clientIdentity: z.ZodObject<{
339
+ kind: z.ZodEnum<{
340
+ server_key: "server_key";
341
+ developer: "developer";
342
+ end_user: "end_user";
343
+ }>;
344
+ developer_id: z.ZodNullable<z.ZodUUID>;
345
+ end_user_id: z.ZodNullable<z.ZodUUID>;
346
+ server_key_id: z.ZodNullable<z.ZodUUID>;
347
+ app_id: z.ZodNullable<z.ZodUUID>;
348
+ role_id: z.ZodNullable<z.ZodUUID>;
349
+ email: z.ZodNullable<z.ZodEmail>;
350
+ }, z.core.$strip>;
351
+ type ClientIdentity = z.infer<typeof clientIdentity>;
352
+
353
+ declare const asset: z.ZodObject<{
354
+ id: z.ZodUUID;
355
+ robot_id: z.ZodUUID;
356
+ kind: z.ZodEnum<{
357
+ urdf: "urdf";
358
+ mesh: "mesh";
359
+ texture: "texture";
360
+ other: "other";
361
+ }>;
362
+ name: z.ZodString;
363
+ media_type: z.ZodString;
364
+ size_bytes: z.ZodNumber;
365
+ sha256: z.ZodString;
366
+ created_at: z.ZodISODateTime;
367
+ }, z.core.$strip>;
368
+ type Asset = z.infer<typeof asset>;
369
+ /**
370
+ * Whether a URDF can actually be rendered, which is not the same as whether it
371
+ * was uploaded.
372
+ *
373
+ * `missing` carries **the reference, verbatim, that no asset answers** — for
374
+ * a `package://` mesh the URI the bridge could not resolve in the workspace,
375
+ * and since W7's security fix also the absolute paths and bare relative paths
376
+ * a URDF may carry, which the extractor sees and the sync deliberately never
377
+ * offers. The sentence used to say "the `package://` URIs" and the field
378
+ * carried three kinds of string (Momus-W7); it is widened here rather than
379
+ * narrowed, because a developer whose URDF names `/opt/meshes/arm.stl` is
380
+ * entitled to be told that nothing will ever fetch it.
381
+ *
382
+ * The spec's example is "2 Meshes fehlen" and that number alone is a dead
383
+ * end: it tells a developer to go looking through a workspace by hand. The
384
+ * references are what they can act on, so the references travel.
385
+ *
386
+ * **Every entry must be actionable, and that is a constraint on the
387
+ * producers, not on this field (W7a).** An entry a developer cannot make
388
+ * disappear by fixing what it names is a defect in whoever put it there: for
389
+ * a whole wave `<texture>` references were listed here and no sync would ever
390
+ * offer them, so the honest instruction behind the list was "fix this, it
391
+ * will not help".
392
+ */
393
+ declare const urdfCompleteness: z.ZodObject<{
394
+ present: z.ZodBoolean;
395
+ mesh_count: z.ZodNumber;
396
+ missing: z.ZodArray<z.ZodString>;
397
+ }, z.core.$strip>;
398
+ type UrdfCompleteness = z.infer<typeof urdfCompleteness>;
399
+ declare const assetListResponse: z.ZodObject<{
400
+ assets: z.ZodArray<z.ZodObject<{
401
+ id: z.ZodUUID;
402
+ robot_id: z.ZodUUID;
403
+ kind: z.ZodEnum<{
404
+ urdf: "urdf";
405
+ mesh: "mesh";
406
+ texture: "texture";
407
+ other: "other";
408
+ }>;
409
+ name: z.ZodString;
410
+ media_type: z.ZodString;
411
+ size_bytes: z.ZodNumber;
412
+ sha256: z.ZodString;
413
+ created_at: z.ZodISODateTime;
414
+ }, z.core.$strip>>;
415
+ urdf: z.ZodObject<{
416
+ present: z.ZodBoolean;
417
+ mesh_count: z.ZodNumber;
418
+ missing: z.ZodArray<z.ZodString>;
419
+ }, z.core.$strip>;
420
+ urdf_available: z.ZodNullable<z.ZodBoolean>;
421
+ }, z.core.$strip>;
422
+ type AssetListResponse = z.infer<typeof assetListResponse>;
423
+
424
+ /**
425
+ * One violated §4.4 rule. `details` on the envelope stays `unknown` — codes
426
+ * are an open set, so their payloads cannot all be enumerated — but the
427
+ * payload of `parameter_invalid` **is** pinned here, because otherwise every
428
+ * consumer guesses: the cloud emits one shape, the SDK sniffs for two, the
429
+ * console renders a third, and each is right in its own tests.
430
+ *
431
+ * `field` is the **flat key exactly as the caller sent it** — the same string
432
+ * as the `parameterSpec.name` it violated. That is the entire justification
433
+ * for the flat parameter form: a refusal has to name something the caller can
434
+ * find in what they typed, and a console can attach the error to that one
435
+ * input rather than to the form.
436
+ */
437
+ declare const parameterViolation: z.ZodObject<{
438
+ field: z.ZodString;
439
+ rule: z.ZodString;
440
+ message: z.ZodString;
441
+ }, z.core.$strip>;
442
+ type ParameterViolation = z.infer<typeof parameterViolation>;
443
+ /**
444
+ * The `details` of a `parameter_invalid` refusal. Always at least one
445
+ * violation: a refusal that names none would leave the caller with nothing to
446
+ * fix. All violations are reported at once, not just the first — a caller
447
+ * fixing parameters one round-trip at a time is a caller who gives up.
448
+ */
449
+ declare const parameterInvalidDetails: z.ZodObject<{
450
+ violations: z.ZodArray<z.ZodObject<{
451
+ field: z.ZodString;
452
+ rule: z.ZodString;
453
+ message: z.ZodString;
454
+ }, z.core.$strip>>;
455
+ }, z.core.$strip>;
456
+ type ParameterInvalidDetails = z.infer<typeof parameterInvalidDetails>;
457
+ /**
458
+ * The codes in use as of W2. The wire deliberately allows any string — this
459
+ * list is the shared vocabulary, not a closed set, so a new refusal never
460
+ * needs a contracts release before it can be reported honestly.
461
+ */
462
+ declare const ERROR_CODES: readonly ["not_found", "validation_error", "bad_request", "unknown_datapoint", "invalid_token", "protocol_mismatch", "invalid_frame", "duplicate_slug", "reserved_slug", "unknown_field_path", "unknown_type", "unknown_topic", "invalid_rate", "invalid_range", "config_conflict", "no_data", "robot_offline", "bridge_timeout", "unauthorized", "forbidden", "invalid_credentials", "token_expired", "token_revoked", "invite_expired", "invite_used", "email_taken", "identifier_taken", "weak_password", "not_a_member", "account_blocked", "busy", "parameter_invalid", "job_lost", "publisher_busy", "unknown_command", "not_subscribable", "camera_offline", "no_snapshot_yet", "live_unavailable", "wrong_kind", "not_recorded", "not_aggregatable", "quota_exceeded", "credential_in_use", "goal_timeout", "robot_in_use", "robot_deletion_partial", "job_queue_full", "invalid_uuid", "rate_limited", "tier_required", "token_spent", "service_timeout", "asset_missing", "asset_too_large", "dynamic_registration_disabled", "client_limit_reached", "identity_conflict", "identity_not_provisioned", "idp_unavailable", "mcp_disabled", "tool_not_available"];
463
+ type ErrorCode = (typeof ERROR_CODES)[number];
464
+
465
+ /**
466
+ * Codes the SDK produces itself rather than relaying from the server. Kept
467
+ * out of `@fleetless/contracts`' `ERROR_CODES` deliberately — that list is
468
+ * the *wire* vocabulary, every entry something a server may actually send,
469
+ * and none of these are. Two kinds:
470
+ *
471
+ * - `no_session` / `no_websocket`: a client-side refusal *before* a request
472
+ * ever reaches the network (not logged in; no WebSocket implementation
473
+ * available). Named apart from the server's own `unauthorized` so a
474
+ * caller can tell "the server refused me" from "the SDK refused before
475
+ * asking" by the code alone.
476
+ * - `unparseable_error`: the opposite direction — a real response *did*
477
+ * arrive, its body just wasn't shaped like §11.5's error format. Not a
478
+ * refusal at all, just "we don't know what the server said."
479
+ * - `command_timeout` (W4): a realtime command (`invoke`/`cancel`/`publish`)
480
+ * got no `command_result` within its timeout. The server may still answer
481
+ * later on the same socket — nobody knows — but the caller cannot be made
482
+ * to wait forever for that.
483
+ * - `command_outcome_unknown` (W4): worse than a timeout, and told apart
484
+ * from it on purpose — the realtime connection that carried the command
485
+ * was replaced by a new one (a reconnect) before any reply arrived. A
486
+ * reply can now never come: the server, if it answered at all, answered a
487
+ * socket that no longer exists. The command may or may not have run.
488
+ * Never retried automatically — that could run an action twice — the
489
+ * caller recovers by reading the job (e.g. `actions.subscribe`), since
490
+ * state is observed by slug regardless of which connection asked for it.
491
+ * - `unexpected_response` (W4): the server answered `ok:true` but left out
492
+ * something the command is defined to always return (e.g. no `job` on a
493
+ * successful `invoke`) — a contract violation the SDK noticed, not a
494
+ * refusal.
495
+ * - `invalid_option` (W6b): the caller passed an *SDK-level* argument or
496
+ * option that cannot mean what it looks like it means. Two cases so far:
497
+ * `timeoutMs < patienceMs` on `invoke`/`call` (see `resolveLocalWaitMs` in
498
+ * `commands.ts`) — the SDK would give up locally before the platform's own
499
+ * patience runs out, and report `command_timeout` for a call the platform
500
+ * never actually refused; and a non-string, non-null, non-omitted `jobId`
501
+ * on `cancel` (D6, see `assertValidJobId`) — almost always a caller who
502
+ * upgraded past the pre-W6b `cancel(robotId, slug, options?)` signature
503
+ * and is still passing an options object third. Both are thrown
504
+ * synchronously, before any request is sent — a client-side mistake to
505
+ * fix, not something a server response could ever produce, which is why
506
+ * this code belongs here and not in `@fleetless/contracts`' `ERROR_CODES`.
507
+ * - `untrusted_absolute_url` (W7): `HttpClient` refused to fetch an absolute
508
+ * URL whose origin does not match this client's own configured `baseUrl`
509
+ * — thrown before the request is ever sent, so no `Authorization` header
510
+ * is ever built for it, let alone attached. The one caller that hands
511
+ * `HttpClient` an absolute URL at all is `assets.createMeshLoader`
512
+ * (§4.6), fetching a URDF's rewritten mesh URIs — and a URDF is ROS graph
513
+ * input, not first-party data, so an app rendering one must not silently
514
+ * trust wherever it points. `assets.createMeshLoader`'s `onComplete`
515
+ * surfaces this the same way it surfaces a network failure: `(null, err)`.
516
+ * - `no_urdf_synced` (W7a): `assets.prepareUrdfScene` looked for a
517
+ * `kind: 'urdf'` row in `assets.list()` and found none. Thrown before any
518
+ * asset fetch, rather than left to surface as a confusing downstream
519
+ * failure from `URDFLoader.parse(undefined)` or similar — the caller's
520
+ * fix is "sync a URDF first" (console, Owner-tier, §4.6), which this
521
+ * error can say directly.
522
+ * - `no_hosted_login_attempt` (W7b, Momus's review): `auth.completeHostedLogin()`
523
+ * was called with an empty `expectedState` — nothing was persisted for
524
+ * this attempt. A callback landing in a different tab or window than the
525
+ * one that called `beginHostedLogin`, a restored session, or storage
526
+ * cleared in between all produce exactly this, and none of them is an
527
+ * attack. Told apart from `state_mismatch` on purpose: the two diagnoses
528
+ * have different remedies ("check how you persisted the value" versus
529
+ * "this response belongs to a login you did not start"), the same
530
+ * reasoning this wave already applied once to `identity_conflict` versus
531
+ * `identity_not_provisioned`. Also closes a real gap — comparing two
532
+ * *empty* strings with `!==` is `false`, so without this check first, a
533
+ * caller with nothing persisted at all could reach `state_mismatch`'s
534
+ * comparison having contributed no defence whatsoever.
535
+ * - `aborted` (register row 244, W7c): `assets.prepareUrdfScene()` was given
536
+ * an `AbortSignal` and it fired — either already-aborted before the call
537
+ * started, or mid-flight while a fetch was in progress. Normalized to this
538
+ * one code regardless of which stage the abort landed in, rather than
539
+ * surfacing whatever shape the underlying `fetch()` rejects an aborted
540
+ * request with (a `DOMException` named `AbortError` in a browser, an
541
+ * `Error` named `AbortError` under Node's `fetch` — two different shapes a
542
+ * caller would otherwise have to detect themselves to tell "I cancelled
543
+ * this" from "the network actually failed"). Every partial resource this
544
+ * call had already created (`blob:` URLs) is revoked before this throws —
545
+ * an aborted load must not leak what it fetched before the signal fired,
546
+ * the same guarantee a failed load already had (D6).
547
+ * - `state_mismatch` (W7b): `auth.completeHostedLogin()` was called with a
548
+ * `state` that does not match the `expectedState` its own `beginHostedLogin()`
549
+ * returned for this attempt (or with no `state` at all — `beginHostedLogin`
550
+ * always sets one, so a callback carrying none does not look like a reply
551
+ * to a flow this client started). Thrown before `/oauth/token` is ever
552
+ * called — RFC 6749 §10.12's whole point is that a caller must not
553
+ * complete an authorization response it did not itself request, so this
554
+ * check happens client-side, first, rather than being left to the server
555
+ * to catch (by which point a code exchange would already have been
556
+ * attempted for a flow this client never started).
557
+ */
558
+ declare const SDK_ERROR_CODES: readonly ["no_session", "no_websocket", "unparseable_error", "command_timeout", "command_outcome_unknown", "unexpected_response", "invalid_option", "untrusted_absolute_url", "state_mismatch", "no_hosted_login_attempt", "no_urdf_synced", "aborted"];
559
+ type SdkErrorCode = (typeof SDK_ERROR_CODES)[number];
560
+ /**
561
+ * A stable code a caller can branch on: a server-defined code (open-ended —
562
+ * see `ErrorCode`'s own doc comment), one of the SDK's own client-side
563
+ * codes above, or, since neither list is exhaustive, any other string.
564
+ * `(string & {})` is the standard trick to keep autocomplete on the known
565
+ * values while still accepting an arbitrary one.
566
+ */
567
+ type FleetlessErrorCode = ErrorCode | SdkErrorCode | (string & {});
568
+ /**
569
+ * The one error type the SDK throws for a refused API call (spec §11.5): a
570
+ * stable machine-readable `code` a caller can branch on (`forbidden` vs
571
+ * `token_expired`) plus a human `message` for logs and debugging. Never
572
+ * parse `message` — it is not part of the contract, only `code` is.
573
+ */
574
+ interface FleetlessErrorOptions {
575
+ /** Field-level detail for validation errors, passed through verbatim. */
576
+ details?: unknown;
577
+ /** The HTTP status of the response that produced this error, if any. */
578
+ status?: number;
579
+ }
580
+ declare class FleetlessError extends Error {
581
+ readonly code: FleetlessErrorCode;
582
+ readonly details?: unknown;
583
+ readonly status?: number;
584
+ constructor(code: FleetlessErrorCode, message: string, options?: FleetlessErrorOptions);
585
+ }
586
+
587
+ interface SendCommandOptions {
588
+ /** How long to wait for a `command_result` before rejecting `command_timeout`. Default 10s. */
589
+ timeoutMs?: number;
590
+ }
591
+ /**
592
+ * `invoke`-only: how long **the platform itself** should wait for this one
593
+ * call before giving up on the robot (W6b) — the whole wait for a service
594
+ * call, goal *acceptance* only for an action (once accepted, a job runs as
595
+ * long as it runs and is observed, not awaited).
596
+ *
597
+ * Optional; absent means the platform's own default (`DEFAULT_PATIENCE_MS`,
598
+ * 15s) — exactly today's behaviour for a caller who names no preference.
599
+ * Outside `[MIN_PATIENCE_MS, MAX_PATIENCE_MS]` the platform refuses with
600
+ * `validation_error` rather than clamping, and this SDK does not clamp
601
+ * locally or retry — surfacing the refusal is the whole of what it does
602
+ * with this field. The floor exists because impatience reaches the robot,
603
+ * not just the platform: a patience too short to survive a goal-acceptance
604
+ * round trip made the bridge report `goal_timeout` and then issue a
605
+ * *corrective cancel* against a goal an action server accepted a moment
606
+ * later — a caller who names an unreachable deadline was causing a real
607
+ * cancellation on the machine, repeatably, not just receiving an error.
608
+ *
609
+ * `timeoutMs` bounds how long *this SDK* waits locally for a reply on the
610
+ * wire it already sent on; `patienceMs` travels to the platform and bounds
611
+ * what *it* is willing to wait for from the robot. **They are not set
612
+ * independently of each other, and D3a exists because the first version of
613
+ * this doc comment said they were.** `timeoutMs` left unset is *derived*
614
+ * from `patienceMs` (see `resolveLocalWaitMs`), not defaulted to a fixed
615
+ * number that might be shorter — the SDK giving up locally before the
616
+ * platform's own deadline would report `command_timeout` for a call the
617
+ * platform never actually refused, which is exactly the two-clocks defect
618
+ * this wave exists to remove, just relocated into this SDK instead of
619
+ * between the cloud and the bridge. Setting both explicitly with
620
+ * `timeoutMs < patienceMs` is refused with `invalid_option` before any
621
+ * request is sent, for the same reason.
622
+ */
623
+ interface InvokeOptions extends SendCommandOptions {
624
+ patienceMs?: number;
625
+ }
626
+
627
+ interface JobSubscriptionHandlers {
628
+ /** Called on every update pushed for the slug's current job — state, feedback, progress and result. */
629
+ onJob(event: JobEvent): void;
630
+ /** Called once if the subscription is refused (§11.5: e.g. `forbidden`, an unknown slug). */
631
+ onError?(error: FleetlessError): void;
632
+ }
633
+ interface JobSubscription {
634
+ /** Stops the subscription and, if the channel is currently connected, tells the server. */
635
+ unsubscribe(): void;
636
+ }
637
+
638
+ interface ActionsApi {
639
+ /**
640
+ * Invokes an action (spec §11.3). Resolves as soon as the job is created
641
+ * — the job id is informative, not the result. Feedback, progress and the
642
+ * eventual result arrive separately over `subscribe`. A second invoke of
643
+ * the same slug while one is already running is refused `busy`, with
644
+ * `error.details.running` naming the job that is running.
645
+ *
646
+ * `options.patienceMs` bounds goal *acceptance* only (W6b) — once a goal
647
+ * is accepted this call has already resolved; the job then runs as long
648
+ * as it runs, observed via `subscribe`, never awaited. `options.timeoutMs`
649
+ * (this SDK's own local wait for the acceptance reply) is derived from
650
+ * `patienceMs` when left unset, and the combination `timeoutMs <
651
+ * patienceMs` is refused with `invalid_option` rather than raced — see
652
+ * `resolveLocalWaitMs` in `commands.ts`, D3a.
653
+ */
654
+ invoke(robotId: string, slug: string, params: Record<string, unknown>, options?: InvokeOptions): Promise<Job>;
655
+ /**
656
+ * Cancels a job — a real ROS goal cancel on the robot, not a local forget.
657
+ * Resolves with the `Job` the cancel was actually sent to, or `null` if
658
+ * nothing matched.
659
+ *
660
+ * **Two different requests, both legitimate (W6b):**
661
+ * - `cancel(robotId, slug)` — no `jobId` — is the operator's stop button:
662
+ * whatever is running on this slug, stop it. This is unchanged from
663
+ * before W6b.
664
+ * - `cancel(robotId, slug, jobId)` cancels **that** job specifically. If
665
+ * it is not the one running, the platform answers `not_found` — this
666
+ * never silently falls back to stopping whatever *is* running, because
667
+ * a caller who named an id has already ruled that out. The failure this
668
+ * closes: a cancel arriving just after its own job ended used to stop
669
+ * the *next* caller's job on the same slug.
670
+ *
671
+ * Read the returned job either way: "I stopped the one I meant", "there
672
+ * was nothing there", and "I stopped a job that started after I last
673
+ * looked" are three different outcomes a discarded result cannot tell
674
+ * apart.
675
+ */
676
+ cancel(robotId: string, slug: string, jobId?: string | null, options?: SendCommandOptions): Promise<Job | null>;
677
+ /**
678
+ * Subscribes to the slug's job: state, feedback, progress and result, as
679
+ * they happen. State is observed **by slug**, not by job id (§11.3) — this
680
+ * is what makes late delivery after a reconnect and a second observer
681
+ * watching the same job both work without special-casing either. Naming a
682
+ * job to `cancel` does not change this: a slug is still a *place a job may
683
+ * be running*, not the job itself, and it is still what `subscribe` watches.
684
+ */
685
+ subscribe(robotId: string, slug: string, handlers: JobSubscriptionHandlers): JobSubscription;
686
+ }
687
+
688
+ /** An asset's bytes plus its declared media type — the shape `assets.get` answers with. */
689
+ interface AssetBytes {
690
+ body: Uint8Array;
691
+ mime: string | null;
692
+ }
693
+ /**
694
+ * The signature `urdf-loader`'s own `loadMeshCb` uses (verified against that
695
+ * library's docs, not guessed): `manager`/`material` pass straight through
696
+ * from whatever called this, and `onComplete` is how a mesh loader reports
697
+ * success or failure — it never throws.
698
+ *
699
+ * This SDK does not depend on `urdf-loader` or three.js — `manager`,
700
+ * `material` and the resolved object are all opaque here, exactly what makes
701
+ * `createMeshLoader` usable from any renderer that speaks this same shape,
702
+ * not only that one library.
703
+ */
704
+ type MeshLoaderDelegate = (path: string, manager: unknown, material: unknown, onComplete: (obj: unknown | null, err?: Error) => void) => void;
705
+ interface CreateMeshLoaderOptions {
706
+ /**
707
+ * How long to wait for `delegate`'s `onComplete` before giving up.
708
+ * Defaults to 30s. A URDF pulls in thirty-odd meshes and a dashboard
709
+ * using this callback can run for days — a delegate that never calls back
710
+ * (an exception three.js swallowed internally, a parser stuck on a
711
+ * malformed mesh) must not leak the object URL or hang the load forever.
712
+ */
713
+ timeoutMs?: number;
714
+ }
715
+ /**
716
+ * three.js's own `LoadingManager.setURLModifier(callback)` shape (W7a, D2).
717
+ * Every load the manager oversees is routed through `callback` first — not
718
+ * only the loader you handed the manager to, but every loader it constructs
719
+ * internally on the same manager (`ColladaLoader`'s own `TextureLoader` for
720
+ * a `.dae`'s `<init_from>` images, in particular). That is the one hook
721
+ * that exists "one level up, for everything" (D2) where `createMeshLoader`'s
722
+ * per-loader `loadMeshCb` override does not reach: `TextureLoader` has no
723
+ * override hook of its own.
724
+ *
725
+ * Structural, not `import('three')` — this SDK does not depend on three.js
726
+ * or `urdf-loader`, same discipline as `MeshLoaderDelegate` above.
727
+ */
728
+ interface UrdfSceneManager {
729
+ setURLModifier(callback: (url: string) => string): unknown;
730
+ }
731
+ interface PrepareUrdfSceneOptions {
732
+ /**
733
+ * How many assets to fetch in parallel. Default 6 — a default that keeps
734
+ * memory and connection counts sane for the common case (dozens of
735
+ * meshes), not a number with a sweep behind it. Pass your own if you have
736
+ * a reason to.
737
+ *
738
+ * Must be a positive integer — `0` or negative throws `invalid_option`
739
+ * rather than silently fetching nothing and returning a scene that
740
+ * renders completely blank with no error to explain why.
741
+ */
742
+ concurrency?: number;
743
+ /**
744
+ * Cancels this call (register row 244, W7c) — a caller who navigates away
745
+ * or switches to a different robot mid-load can abort every in-flight
746
+ * fetch this method has started, not merely stop it from starting new
747
+ * ones. Checked before the first request; if it fires while a request is
748
+ * already in progress, `HttpClient` forwards it straight to `fetch()`
749
+ * (`http.ts`'s own `RequestOptions.signal`), so the connection itself is
750
+ * torn down, not just abandoned by this SDK while it keeps running in the
751
+ * background.
752
+ *
753
+ * Every `blob:` URL already created before the abort is revoked before
754
+ * this call rejects with `FleetlessError('aborted', ...)` — the same
755
+ * guarantee a load that fails outright already had (D6): an aborted load
756
+ * must not leak what it had already fetched.
757
+ *
758
+ * `manager`'s URL modifier is only ever installed once every asset has
759
+ * resolved (success or the throw below) — an aborted call never installs
760
+ * a partial one, so `manager` is left exactly as it was if this rejects
761
+ * before that point.
762
+ */
763
+ signal?: AbortSignal;
764
+ }
765
+ interface UrdfSceneResources {
766
+ /**
767
+ * The robot's URDF as raw text — `package://` URIs intact, not rewritten
768
+ * to asset URLs. Hand it straight to `URDFLoader.parse(urdfText)` once
769
+ * `setURLModifier` is installed (this method already installed it on
770
+ * `manager` before returning).
771
+ */
772
+ urdfText: string;
773
+ /**
774
+ * The same reference strings `assets.list()`'s `urdf.missing` reports.
775
+ *
776
+ * **Top-level only — not a `.dae`'s internal references (Kassandra-W7a
777
+ * review correction).** The cloud builds this list from the URDF text
778
+ * alone (`<mesh>`/`<texture>` `filename` attributes), which is the only
779
+ * place it can see without parsing every `.dae` a sync touches; it never
780
+ * has and never can include an internal `<init_from>` reference. An
781
+ * earlier version of this comment claimed both — wrong, and worth naming
782
+ * as a correction rather than silently widening the sentence, since a
783
+ * caller who trusted "both" to mean both would build a completeness check
784
+ * against a list that structurally cannot report the second half. A
785
+ * `.dae`-internal reference the sync could not resolve surfaces through
786
+ * the sync's own failure reporting instead, not here.
787
+ *
788
+ * Not thrown either way: an incomplete URDF still renders what it has
789
+ * (`urdfCompleteness`'s own contract), so the caller decides whether to
790
+ * warn, block, or ignore before calling `URDFLoader.parse(urdfText)`. A
791
+ * reference NOT in this list that still fails to load at render time is a
792
+ * different failure — the store answered but the fetch itself did not.
793
+ */
794
+ missing: string[];
795
+ /**
796
+ * Revokes every `blob:` URL this call created. Call once the scene has
797
+ * finished loading (success or failure) or on unmount — safe to call more
798
+ * than once.
799
+ *
800
+ * **Does not touch `manager`'s URL modifier (D7, Kassandra-W7a review).**
801
+ * An earlier version reset it to the identity function here, which
802
+ * silently reopened D4 the moment the same manager was used again — for a
803
+ * second robot, or for anything else — before a later `prepareUrdfScene`
804
+ * call happened to overwrite it. The installed modifier is left running,
805
+ * and with this call's map now empty it already refuses anything it would
806
+ * have owned and passes through anything it would not have, correctly,
807
+ * on its own.
808
+ */
809
+ dispose(): void;
810
+ }
811
+ interface AssetsApi {
812
+ /**
813
+ * Every asset a robot has, plus whether its URDF is complete (spec §4.6).
814
+ * `urdf.missing` names the `package://` URIs the sync could not resolve —
815
+ * the number alone ("2 Meshes fehlen") sends a developer looking through a
816
+ * workspace by hand, the URIs are what they can act on.
817
+ */
818
+ list(robotId: string): Promise<AssetListResponse>;
819
+ /** One asset's bytes by id — a mesh, or any asset directly, addressed the same way `createMeshLoader` reaches one internally. */
820
+ get(robotId: string, assetId: string): Promise<AssetBytes>;
821
+ /**
822
+ * The robot's URDF, with every `package://` mesh URI already rewritten to
823
+ * an absolute Fleetless asset URL — ready to hand straight to
824
+ * `URDFLoader.parse(xml)`. Decoded as UTF-8 text rather than left as
825
+ * bytes because every consumer needs it as a string for exactly that call.
826
+ */
827
+ urdf(robotId: string): Promise<string>;
828
+ /**
829
+ * The mesh callback for `urdf-loader` (spec §4.6): an `<img>` tag and the
830
+ * default three.js loaders cannot set an `Authorization` header, and the
831
+ * platform deliberately has no signed URLs and no token in the query
832
+ * string (see `@fleetless/contracts` `assets.ts`), so every app would
833
+ * otherwise write this glue itself, and each one differently.
834
+ *
835
+ * Returns a function with `loadMeshCb`'s own signature — assign it
836
+ * directly:
837
+ *
838
+ * ```ts
839
+ * loader.loadMeshCb = client.assets.createMeshLoader(robotId, loader.defaultMeshLoader.bind(loader))
840
+ * ```
841
+ *
842
+ * `delegate` does the actual format-specific parsing (STL/OBJ/DAE/GLTF —
843
+ * `loader.defaultMeshLoader` already knows how); this method's own job is
844
+ * only what a plain loader cannot do: fetch `path` with the
845
+ * `Authorization` header, and hand the delegate something it can load
846
+ * without one. It does that by fetching the bytes itself, wrapping them
847
+ * in a `Blob`, and calling `delegate` with an object URL substituted for
848
+ * `path` — so the delegate never touches the network, and the object URL
849
+ * is revoked the moment `delegate` reports success or failure (or the
850
+ * timeout elapses), never left for the caller to remember.
851
+ *
852
+ * **Refuses, via `onComplete(null, err)`, if the `manager` it is handed
853
+ * already has `prepareUrdfScene`'s URL modifier installed** — the two
854
+ * read different URDF sources and combining them on one manager breaks
855
+ * one half or the other, never obviously (see `prepareUrdfScene`'s own
856
+ * doc comment). Checked at the point the mistake would actually manifest
857
+ * rather than left to a paragraph a developer might not read.
858
+ */
859
+ createMeshLoader(robotId: string, delegate: MeshLoaderDelegate, options?: CreateMeshLoaderOptions): MeshLoaderDelegate;
860
+ /**
861
+ * Authenticated loading for everything three.js fetches to render a
862
+ * textured robot — not only meshes (spec §4.6, W7a, D2). Installs
863
+ * `manager.setURLModifier` so **every** load `manager` oversees resolves
864
+ * to a pre-fetched `blob:` URL: a top-level `<mesh>`, a `<material>`'s
865
+ * `<texture>`, and an image a `.dae` references internally via
866
+ * `<init_from>` — three cases, one mechanism, because the browser never
867
+ * fetches an asset directly. Every network read goes through this SDK
868
+ * with the bearer token first; a `blob:` URL is document-local and dies
869
+ * with the page, so nothing about who may read what changes.
870
+ *
871
+ * ```ts
872
+ * const manager = new THREE.LoadingManager()
873
+ * const { urdfText, missing, dispose } = await client.assets.prepareUrdfScene(robotId, manager)
874
+ * const loader = new URDFLoader(manager)
875
+ * const robot = loader.parse(urdfText)
876
+ * scene.add(robot)
877
+ * // later, once the scene has finished loading (or on unmount):
878
+ * dispose()
879
+ * ```
880
+ *
881
+ * **Do not also install `createMeshLoader` on the same manager.** The two
882
+ * consume different URDF sources — this method fetches the URDF's *raw*
883
+ * bytes, `createMeshLoader` is meant to pair with `urdf()`'s
884
+ * cloud-rewritten text. **Not "double-fetches every mesh" — a first
885
+ * version of this comment said that, and Momus-W7a's review traced it
886
+ * and found it wrong.** What actually happens is asymmetric breakage,
887
+ * whichever URDF text the combination ends up parsing: paired with
888
+ * *this* method's raw text, `createMeshLoader` receives urdf-loader's
889
+ * `resolvePath()` output (`/pkg/rel`) rather than an absolute Fleetless
890
+ * URL and 404s every mesh, while textures still resolve; paired with
891
+ * `urdf()`'s rewritten text instead, meshes load and every texture 401s
892
+ * — `createMeshLoader` bypasses this method's URL modifier entirely for
893
+ * meshes (that's what `loadMeshCb` means), so there is no hook left for
894
+ * a texture. Either way a developer who combined them by accident would
895
+ * debug the wrong symptom, which is worse than the original (already
896
+ * wrong) warning being merely unhelpful.
897
+ *
898
+ * **Enforced, not only documented (Momus-W7a review, via the team
899
+ * lead).** `createMeshLoader`'s returned callback checks whether the
900
+ * `manager` it is handed already has this method's URL modifier
901
+ * installed and fails loudly via `onComplete(null, err)` before ever
902
+ * touching the network, rather than relying on a developer having read
903
+ * this paragraph. See `managersWithPreparedUrdfScene`.
904
+ *
905
+ * **Why raw bytes, not `urdf()`'s rewritten text.** Both `urdf-loader`'s
906
+ * default mesh loading and `ColladaLoader` compute the base path they use
907
+ * to resolve a `.dae`'s internal references (`LoaderUtils.extractUrlBase`)
908
+ * from the URL they were originally asked to load — *before*
909
+ * `manager.resolveURL()`/the URL modifier ever runs; the modifier only
910
+ * changes what bytes get fetched, never what further relative references
911
+ * resolve against. Feeding it `urdf()`'s already-rewritten
912
+ * `.../assets/<uuid>` text would compute a base of `.../assets/`, and
913
+ * `textures/skin.png` joined onto that would never match anything this
914
+ * method's map knows about. Raw `package://` text is what keeps the two
915
+ * in sync.
916
+ *
917
+ * **`urdf-loader` resolves `package://` itself, before any of this runs —
918
+ * a second resolution stage this method has to account for, found by
919
+ * Threepio-W7a running the recipe demo in a real browser rather than
920
+ * reading the source.** `URDFLoader.parse()`'s own `resolvePath()`
921
+ * rewrites `package://pkg/rel` using `this.packages` (default `''`) to
922
+ * `/pkg/rel` — a root-relative URL — and *that* is what reaches
923
+ * `loadMeshCb`/`ColladaLoader`/`manager.resolveURL()`, not the original
924
+ * string. So the modifier is registered under **two** keys per asset: the
925
+ * literal `package://` name (`asset.name`, for a caller who sets
926
+ * `loader.packages = (pkg) => \`package://\${pkg}\`` to reconstruct it,
927
+ * or a renderer that never went through `resolvePath()` at all) and the
928
+ * root-relative form `urdf-loader`'s own *default* `packages: ''`
929
+ * produces (`/pkg/rel`) — covering the common case with zero required
930
+ * caller configuration. A `.dae`'s internal `<init_from>` ref resolves the
931
+ * same way one level deeper: `ColladaLoader` computes its own working
932
+ * path from *its* `url` argument (already `/pkg/rel` by the time it gets
933
+ * there under the default), so the internal reference lands on
934
+ * `/pkg/textures/skin.png` — exactly the second key, derived the same
935
+ * way. A caller whose `loader.packages` does something else entirely
936
+ * (a custom map, not the default and not the reconstruction above) is
937
+ * outside what this method can predict — see the ownership rule below
938
+ * for what happens to that reference.
939
+ *
940
+ * **This method only claims what it owns (D4, revised after Argus-W7a's
941
+ * review) — not every unmapped reference.** `manager` is frequently the
942
+ * caller's own scene-wide `LoadingManager`, shared for an HDRI, an
943
+ * environment map, a font atlas, a ground texture — none of which have
944
+ * anything to do with this robot. A first version refused everything
945
+ * unmapped, which silently emptied every one of those the moment a
946
+ * caller shared their manager. So the rule is narrower: a `package://`
947
+ * reference, or a root-relative path whose leading segment names a ROS
948
+ * package this robot's assets (or `missing`) actually mention, is this
949
+ * method's to resolve or refuse; an unmapped one falls back to the
950
+ * normalized form (D5, below) and then to a shared, inert, page-local
951
+ * `blob:` URL — never the original string, so a hostile URDF naming an
952
+ * unsynced or off-namespace reference still cannot make three.js touch
953
+ * the network for it. Anything else — not in that namespace — is left
954
+ * completely alone, **except** an absolute `http(s)` URL, which is
955
+ * refused regardless of namespace: the one case this method cannot leave
956
+ * ambiguous, because a hostile URDF naming an attacker's host directly
957
+ * (bypassing `package://` entirely) is exactly what D4 exists to close,
958
+ * and three.js would otherwise fetch it for real, off-origin, the moment
959
+ * the direct and namespace checks both miss.
960
+ *
961
+ * **A `.dae`'s own internal reference gets a second-chance, normalized
962
+ * lookup (D5, Momus-W7a review, reproduced in a real browser).** three.js
963
+ * builds the request for one by plain string concatenation — no `..`/`.`
964
+ * collapsing — while `asset.name` carries the *normalized* tail
965
+ * (`@fleetless/contracts`' naming rule). So `../textures/skin.png` or
966
+ * `./textures/skin.png`, both ordinary exporter output, would otherwise
967
+ * miss the direct key even though the reference is entirely resolvable.
968
+ * Verified independently that the top-level `<mesh>`/`<texture>` case
969
+ * never needs this: `asset.name` there is the URDF's own `package://` URI
970
+ * verbatim and `resolvePath()` rewrites it by the same unnormalized
971
+ * concatenation on both sides, so the direct key already matches.
972
+ *
973
+ * **`dispose()` does not undo any of this (D7, Kassandra-W7a review).**
974
+ * See its own doc comment on `UrdfSceneResources`.
975
+ *
976
+ * **Pre-fetch is unavoidable**, not merely a choice: a URL modifier
977
+ * cannot be asynchronous, so every asset it might be asked for has to
978
+ * already be a `blob:` URL before `URDFLoader.parse` runs. Bounded by
979
+ * `options.concurrency` (default 6) and scoped to only `kind: 'mesh'` and
980
+ * `kind: 'texture'` assets — which is already "what the URDF references"
981
+ * (`@fleetless/contracts`' `rest.ts`: a re-sync reconciles, so assets the
982
+ * current URDF no longer references stop belonging to the robot), not an
983
+ * unbounded fetch of everything the robot has ever had.
984
+ */
985
+ prepareUrdfScene(robotId: string, manager: UrdfSceneManager, options?: PrepareUrdfSceneOptions): Promise<UrdfSceneResources>;
986
+ }
987
+
988
+ /**
989
+ * What is kept between calls to stay logged in: exactly the wire shape
990
+ * `sessionTokens` returns, no derived fields. Refresh is reactive (a call
991
+ * that meets an expired access token refreshes and retries) rather than
992
+ * proactive, so there is no `expires_at` to compute or drift out of sync.
993
+ */
994
+ type StoredSession = SessionTokens;
995
+ /**
996
+ * Where the SDK keeps a session. A developer implements this to persist a
997
+ * login (localStorage, a cookie, a native keystore) — the SDK itself never
998
+ * assumes a browser, or any storage, exists.
999
+ */
1000
+ interface TokenStore {
1001
+ load(): StoredSession | null | Promise<StoredSession | null>;
1002
+ save(session: StoredSession | null): void | Promise<void>;
1003
+ }
1004
+ /** The default store: works out of the box, forgets the session on reload. */
1005
+ declare class InMemoryTokenStore implements TokenStore {
1006
+ #private;
1007
+ load(): StoredSession | null;
1008
+ save(session: StoredSession | null): void;
1009
+ }
1010
+
1011
+ /** `beginHostedLogin()`'s input (spec §3.4, §17, W7b). */
1012
+ interface BeginHostedLoginOptions {
1013
+ /**
1014
+ * The opaque `client_id` issued when the developer registered this app's
1015
+ * OAuth client (console, App Settings). **Never `appIdentifier`** — they
1016
+ * are deliberately different identifiers (`contracts/src/oauth.ts`,
1017
+ * `oauthClient`'s doc comment: "the `client_id` on the wire — opaque, and
1018
+ * not the app identifier").
1019
+ */
1020
+ clientId: string;
1021
+ /**
1022
+ * Must be registered, byte-for-byte, as one of that client's
1023
+ * `redirect_uris` — matching at the server is exact-string, never a
1024
+ * prefix (`contracts/src/oauth.ts`, `redirectUri`'s doc comment).
1025
+ */
1026
+ redirectUri: string;
1027
+ scope?: string;
1028
+ /**
1029
+ * RFC 8707 audience binding (register row, W7b→W7c): the resource this
1030
+ * session's token should be usable against — an MCP endpoint
1031
+ * (`/mcp/<app>`, W7c) or another audience-checking resource this
1032
+ * deployment validates. **Omit it for an ordinary app login.** A token
1033
+ * with no `resource` carries no `aud` and works unrestricted against this
1034
+ * app's own REST surface exactly as it always has; that path is
1035
+ * unaffected by this field's existence. Only a caller that is itself
1036
+ * going to present the token to an audience-checking resource needs to
1037
+ * ask for one — and must ask for the *right* one, because `/mcp/<app>`
1038
+ * refuses a token whose `aud` names a different app exactly as hard as it
1039
+ * refuses a token with none at all.
1040
+ */
1041
+ resource?: string;
1042
+ }
1043
+ /** What `beginHostedLogin()` returns — nothing here has touched the network yet. */
1044
+ interface HostedLoginRequest {
1045
+ /** Send the end user's browser here to start the hosted login page. */
1046
+ url: string;
1047
+ /**
1048
+ * Persist this alongside `codeVerifier` before navigating away, and pass
1049
+ * both back into `completeHostedLogin`. **This SDK does not persist them
1050
+ * for you.** The redirect back to `redirectUri` is a fresh page load for a
1051
+ * browser app — nothing kept in this SDK's own memory survives it (the
1052
+ * same reasoning `client.ts` states for `TokenStore`: "the SDK itself
1053
+ * never assumes a browser, or any storage, exists"). An in-memory default
1054
+ * here would not be merely suboptimal, it would be broken for the primary
1055
+ * use case while looking like it worked for anything that never actually
1056
+ * navigates away. `sessionStorage`, a signed cookie, or a plain variable
1057
+ * (a popup flow that never truly navigates) are all valid — that choice is
1058
+ * the caller's.
1059
+ */
1060
+ state: string;
1061
+ codeVerifier: string;
1062
+ }
1063
+ /** `completeHostedLogin()`'s input — the redirect back, plus what `beginHostedLogin` returned for this same attempt. */
1064
+ interface CompleteHostedLoginOptions {
1065
+ /** The `code` query parameter from the redirect back to `redirectUri`. */
1066
+ code: string;
1067
+ /** The `state` query parameter from that same redirect. */
1068
+ state: string;
1069
+ /**
1070
+ * The `state` this attempt's `beginHostedLogin` returned. Checked against
1071
+ * `state` above **before any network call** — RFC 6749 §10.12's whole
1072
+ * point is that a caller must not complete an authorization response it
1073
+ * did not itself request.
1074
+ */
1075
+ expectedState: string;
1076
+ /** The `codeVerifier` this attempt's `beginHostedLogin` returned. */
1077
+ codeVerifier: string;
1078
+ clientId: string;
1079
+ /** Must be the exact same string passed to `beginHostedLogin`. */
1080
+ redirectUri: string;
1081
+ /**
1082
+ * Must be the exact same string passed to `beginHostedLogin`, if any.
1083
+ * Resending it here is not what binds the audience — the server already
1084
+ * bound `resource` to the authorization code at `/oauth/authorize` and
1085
+ * mints `aud` from that stored value regardless of what this call sends —
1086
+ * but RFC 8707 §2 expects a client to name the resource at both steps,
1087
+ * and the cloud rejects a *mismatched* resend outright (`invalid_target`).
1088
+ * Omit it here exactly when it was omitted at `beginHostedLogin`.
1089
+ */
1090
+ resource?: string;
1091
+ }
1092
+ interface AuthApi {
1093
+ /** Exchanges email + password, and the client's configured app identifier, for a session. */
1094
+ login(email: string, password: string): Promise<void>;
1095
+ /**
1096
+ * Starts the hosted login flow (spec §3.4, §17, W7b): a Fleetless-served
1097
+ * login page an app's end user is redirected to, with optional
1098
+ * per-app IdP federation. Builds the `/oauth/authorize` URL (Authorization
1099
+ * Code + PKCE, S256 only — OAuth 2.1 removes `plain`) and generates the
1100
+ * `state`/`codeVerifier` PKCE and CSRF protection need. **Makes no network
1101
+ * call** — everything here is local, so nothing about the app, the
1102
+ * client, or the redirect URI is validated until the browser actually
1103
+ * reaches `/oauth/authorize`.
1104
+ *
1105
+ * Async only because computing the S256 `code_challenge` needs
1106
+ * `crypto.subtle.digest`, which the Web Crypto API only ever offers as a
1107
+ * promise — there is no synchronous digest to call instead.
1108
+ */
1109
+ beginHostedLogin(options: BeginHostedLoginOptions): Promise<HostedLoginRequest>;
1110
+ /**
1111
+ * Completes the hosted login flow: checks `state` against `expectedState`
1112
+ * (before any network call — see `CompleteHostedLoginOptions.expectedState`),
1113
+ * exchanges `code` for tokens at `/oauth/token`, and stores them via the
1114
+ * same `tokenStore` `login()` uses.
1115
+ *
1116
+ * **Two distinct refusals before that check, not one.** An empty
1117
+ * `expectedState` throws `no_hosted_login_attempt` — nothing was
1118
+ * persisted for this attempt (a different tab, a restored session,
1119
+ * cleared storage), not necessarily an attack. Only once `expectedState`
1120
+ * is actually present does a mismatch (or a missing `state` on the
1121
+ * callback itself) throw `state_mismatch`. The two are told apart on
1122
+ * purpose: they call for different remedies, and collapsing them would
1123
+ * tell a developer debugging an ordinary storage gap that their app is
1124
+ * under attack.
1125
+ *
1126
+ * Once past that check and the exchange completes, `me()`, `logout()`,
1127
+ * `changePassword()` and silent refresh all behave identically afterwards,
1128
+ * regardless of which flow the session started from. That is the actual
1129
+ * content of §3.4's *"Beide Wege enden im selben Fleetless-Token"*: not
1130
+ * merely that the bytes match, but that every existing code path treats
1131
+ * the result the same way.
1132
+ *
1133
+ * The wire response is RFC 6749 §5.1's envelope (`token_type`, optional
1134
+ * `scope`), not `sessionTokens` — this method normalizes one into the
1135
+ * other before storing. **Refresh needs no separate handling**: the cloud
1136
+ * mints these tokens through the same session mechanism `/api/client/login`
1137
+ * uses (same `refresh_tokens` row, same rotation), so the existing silent
1138
+ * refresh (`/api/client/refresh`) already works for a hosted-login
1139
+ * session — nothing about the origin of a session is tracked or needs to
1140
+ * be.
1141
+ *
1142
+ * Throws with the OAuth error code as `.code` (e.g. `invalid_grant` for an
1143
+ * expired or already-used `code`) if the exchange itself fails — a
1144
+ * different vocabulary from every other method on this interface, because
1145
+ * `/oauth/token` answers in RFC 6749 §5.2's shape, not `apiError`.
1146
+ *
1147
+ * **Makes exactly one request to `/oauth/token` — never retried, no
1148
+ * timeout-and-resend, no internal concurrency of its own.** Stated
1149
+ * because the constraint that matters here is not this method's, it is
1150
+ * the caller's: **never call this a second time for the same `code`
1151
+ * while a first call is still in flight** (a plain "the first attempt
1152
+ * looked like it timed out, so retry" is exactly the shape this warns
1153
+ * against — it is not a defect in this method, since this method itself
1154
+ * has nothing that could ever cause that). André's decision, 2026-08-18:
1155
+ * the platform treats a second presentation of an authorization code as
1156
+ * theft and revokes the whole token family it belongs to, deliberately,
1157
+ * even though a plain double-submission looks identical on the wire —
1158
+ * because the blast radius is bounded (only a caller already holding the
1159
+ * correct `code_verifier` and `client_id` can trigger it, so a merely
1160
+ * *sniffed* code cannot lock anyone out) and the alternative is a
1161
+ * narrower defence against a real theft.
1162
+ *
1163
+ * **What actually happens if two requests race (measured, Argus-W7c):**
1164
+ * one of the two receives `200` with a refresh token that the server has
1165
+ * already revoked. The access token in that same response keeps working
1166
+ * normally for the rest of its short TTL — nothing about the race is
1167
+ * visible yet. The failure surfaces at this session's **first silent
1168
+ * refresh**, as `token_revoked`, potentially many minutes after the race
1169
+ * that actually caused it and with nothing in that later error pointing
1170
+ * back to a retry that "worked". If your own framework, an HTTP client
1171
+ * wrapper, or a user's impatient double-click can cause this method to
1172
+ * be invoked twice concurrently for the same redirect, guard against
1173
+ * that at the call site — a simple in-flight flag or disabling the
1174
+ * triggering control is enough, since there is only ever one legitimate
1175
+ * exchange per authorization code.
1176
+ */
1177
+ completeHostedLogin(options: CompleteHostedLoginOptions): Promise<void>;
1178
+ /**
1179
+ * Ends the session: revokes the whole refresh-token family server-side
1180
+ * (a stolen refresh token stops working immediately) and closes this
1181
+ * client's live realtime connection, if it has one. Then clears the
1182
+ * local store. Never rejects and always clears the store, even if the
1183
+ * server call fails: a user who presses "log out" must end up logged out
1184
+ * locally regardless of the network. `revoked` reports whether the
1185
+ * server-side revoke actually happened — `false` means the refresh
1186
+ * family may still be alive server-side even though this client has
1187
+ * forgotten it; an app that cares (a kiosk, a shared workstation) can
1188
+ * warn the user or retry, one that doesn't can ignore it.
1189
+ *
1190
+ * **What this does not do:** invalidate the access token already issued.
1191
+ * Access-token checks are stateless (a signed JWT, verified without a
1192
+ * server-side lookup) — logout has nothing to flip on that token, only
1193
+ * on the refresh family behind it. A token stolen before logout keeps
1194
+ * working on REST, and can still open a *new* realtime connection, until
1195
+ * it expires on its own — at most 15 minutes. This is a deliberate
1196
+ * boundary of the stateless-JWT design (the same one that lets a role
1197
+ * change, a block, or a membership removal take effect on the very next
1198
+ * request without a fresh token), not a bug — but a kiosk or shared
1199
+ * workstation needs to know that number.
1200
+ */
1201
+ logout(): Promise<{
1202
+ revoked: boolean;
1203
+ }>;
1204
+ /** Who the caller turned out to be, without decoding a token client-side. */
1205
+ me(): Promise<ClientIdentity>;
1206
+ /**
1207
+ * Starts self-registration into this app's pool (spec §3.2, W6c) — the
1208
+ * second of the pool's two entry paths, alongside a console invitation.
1209
+ * Sets the password here; `confirmRegistration` below only ever sees a
1210
+ * token.
1211
+ *
1212
+ * **This does not log the caller in — there is no session yet.** A domain
1213
+ * filter says *which* domains may register, never *whether the caller
1214
+ * owns the address*, so minting a session here would let anybody who
1215
+ * knows an allowed domain register as somebody else at it and receive
1216
+ * whatever role the app assigns — which on this platform can mean
1217
+ * permission to move a robot. So this mails a confirmation link and waits;
1218
+ * `confirmRegistration(token)` is what proves the address and returns a
1219
+ * session, the same two-step shape as `requestPasswordReset` /
1220
+ * `confirmPasswordReset` below.
1221
+ *
1222
+ * **Resolves identically for an address that is new and one that already
1223
+ * has an account** — same account-enumeration reasoning as
1224
+ * `requestPasswordReset`. `mail` in the resolved value describes this
1225
+ * deployment's mail configuration, not the address, so it is safe to
1226
+ * read — but note that a deployment with no SMTP (`mail:
1227
+ * 'not_configured'`) cannot complete a self-registration at all: unlike
1228
+ * an invitation, where a developer can hand the link over directly, the
1229
+ * confirmation link has no other channel.
1230
+ *
1231
+ * **The role is not yours to choose.** There is no `role` parameter: the
1232
+ * app's own `selfRegistration.role_id` decides it (§3.2 — a pool member
1233
+ * has exactly one role per app), and a signature that accepted one would
1234
+ * be offering a choice the platform has to refuse anyway.
1235
+ *
1236
+ * **Fails the same way whether self-registration is off or the address's
1237
+ * domain is not permitted.** Those two refusals are deliberately
1238
+ * indistinguishable, for the same reason as above: telling them apart
1239
+ * would let a caller probe configuration that is not theirs to see. Do
1240
+ * not build a UI that tries to tell "this app doesn't allow sign-ups"
1241
+ * apart from "your email domain isn't allowed" from the error alone.
1242
+ *
1243
+ * Behind the same limiter as `login` (`rate_limited`, W6c) — creating a
1244
+ * row is the cheapest attack this platform has to defend against, so
1245
+ * expect this to be throttled first under load, not last.
1246
+ */
1247
+ register(email: string, password: string): Promise<ClientRegisterResponse>;
1248
+ /**
1249
+ * Spends a self-registration confirmation token and returns the new
1250
+ * session — the counterpart to `register` above. Stores the session
1251
+ * exactly like `login`, so a caller who has just confirmed is not then
1252
+ * told to log in separately.
1253
+ *
1254
+ * The token is single-use and expires; using it twice, or too late,
1255
+ * answers `token_spent` either way, same as `confirmPasswordReset` below.
1256
+ */
1257
+ confirmRegistration(token: string): Promise<void>;
1258
+ /**
1259
+ * Changes the current end user's password (spec §3, W6c).
1260
+ *
1261
+ * `currentPassword` is required by the server even though the session
1262
+ * already proves identity — it is what stops a stolen *session* from
1263
+ * becoming a stolen *account* (see `passwordChangeRequest` in
1264
+ * `@fleetless/contracts`).
1265
+ *
1266
+ * **Every other session of this identity is revoked on success, and this
1267
+ * call's own session is re-issued, not left alone.** `passwordChangeRequest`
1268
+ * carries nothing that identifies the caller's own refresh family, so the
1269
+ * server cannot spare one token out of the family it just revoked — it
1270
+ * revokes all of them and hands back a fresh pair, which this method
1271
+ * stores exactly like `login` does. Skipping that store would leave the
1272
+ * caller holding tokens the server has already revoked, working only
1273
+ * until the access token expires and then silently logged out —
1274
+ * indistinguishable from the change having failed, which is the one
1275
+ * outcome this route exists to prevent. A user with other tabs or
1276
+ * devices logged in will see *those* signed out the moment this
1277
+ * resolves; if your app does not already make that consequence visible
1278
+ * before they confirm, they will find out from a support ticket instead
1279
+ * of from you.
1280
+ */
1281
+ changePassword(currentPassword: string, newPassword: string): Promise<void>;
1282
+ /**
1283
+ * Requests a password-reset link for `email` (spec §3, W6c) — sent by
1284
+ * mail; the console-issued link is the primary path, this is the
1285
+ * self-service one.
1286
+ *
1287
+ * **Resolves the same way whether or not `email` belongs to an account,
1288
+ * and always did — this is not a bug to work around.** It is the one
1289
+ * unauthenticated route in this SDK where revealing existence would be an
1290
+ * account-enumeration oracle (see `passwordResetRequest`'s doc comment in
1291
+ * `@fleetless/contracts`). There is nothing in this method's return value
1292
+ * or its error codes that distinguishes "sent" from "no such account" —
1293
+ * do not build a UI branch for "no such account" here, because there is
1294
+ * nothing to branch on, on purpose.
1295
+ */
1296
+ requestPasswordReset(email: string): Promise<void>;
1297
+ /**
1298
+ * Completes a password reset using the token from the emailed link (spec
1299
+ * §3, W6c).
1300
+ *
1301
+ * The token is single-use and expires; using it twice, or too late,
1302
+ * answers `token_spent` either way — deliberately one code for both (see
1303
+ * `ERROR_CODES` in `@fleetless/contracts`): telling them apart would tell
1304
+ * a stranger whether a token ever existed, and the recovery is the same
1305
+ * regardless — request a new link. Succeeding revokes every session of
1306
+ * that identity, the same as `changePassword` — a forgotten password is
1307
+ * one of the two states where somebody else may be holding a session.
1308
+ */
1309
+ confirmPasswordReset(token: string, newPassword: string): Promise<void>;
1310
+ }
1311
+
1312
+ /**
1313
+ * The snapshot's metadata alone, without the bytes (spec §10). All fields
1314
+ * are `null` together when nothing has been captured yet for this camera —
1315
+ * a fresh configuration before the first `snapshot_interval_ms` elapses, say
1316
+ * — which is a state, not a failure: the wire answers it with
1317
+ * `no_snapshot_yet`, and both `snapshot`/`snapshotMeta` absorb that code
1318
+ * here rather than throw it, so a caller checks `age_ms === null` instead of
1319
+ * wrapping every poll in a try/catch for something that is not exceptional.
1320
+ *
1321
+ * `age_ms` is **always** the cloud's own figure, never recomputed client-side
1322
+ * as `Date.now() - timestamp_ms`: the cloud is the one clock that knows how
1323
+ * long it has actually held the frame, and recomputing would reintroduce the
1324
+ * viewer's own clock skew as a source of lying about freshness (see
1325
+ * `SNAPSHOT_HEADERS`'s doc comment in `@fleetless/contracts`).
1326
+ */
1327
+ interface CameraSnapshotMeta {
1328
+ mime: string | null;
1329
+ width: number | null;
1330
+ height: number | null;
1331
+ /** The bridge's capture time (§6.3). */
1332
+ timestamp_ms: number | null;
1333
+ age_ms: number | null;
1334
+ }
1335
+ /** One snapshot read: the image bytes plus everything needed to state how old they are (spec §10). */
1336
+ interface CameraSnapshot extends CameraSnapshotMeta {
1337
+ image: Uint8Array | null;
1338
+ }
1339
+ /**
1340
+ * What a LiveKit client needs to join, plus the means to leave (spec §10,
1341
+ * §14.3: "Kamera (Snapshot-URL + LiveKit-Track-Handle)"). Hand `url`/`token`
1342
+ * straight to a LiveKit client SDK (e.g. `Room.connect(url, token)`) — this
1343
+ * SDK stops there on purpose: no video widget, no teleop-style helper
1344
+ * (§14.3 keeps it thin).
1345
+ */
1346
+ interface CameraLiveSession {
1347
+ /**
1348
+ * This viewer's own hold (W6b) — what `release()` releases, and the only
1349
+ * thing distinguishing this session from every other tab of the same
1350
+ * identity watching the same camera. Not previously addressable: a `DELETE`
1351
+ * with no id released **all** of this identity's holds on the slug, so one
1352
+ * tab closing stopped the robot for every other tab too. See `release()`'s
1353
+ * doc comment for what changed and what did not.
1354
+ */
1355
+ session_id: string;
1356
+ url: string;
1357
+ room: string;
1358
+ token: string;
1359
+ /**
1360
+ * When this token can no longer be used to **join** — not when an
1361
+ * already-joined session ends. LiveKit checks a token at connect time
1362
+ * only, so a `Room` that joined before this timestamp keeps streaming
1363
+ * past it untouched; this field bounds how long an unused token sits
1364
+ * around, nothing more.
1365
+ *
1366
+ * It is **not** a backstop for a forgotten `release()`, a crash, or a
1367
+ * `kill -9` after joining: what ends an already-joined session is
1368
+ * `release()` plus disconnecting the `Room`, the cloud noticing (via its
1369
+ * own reconciliation against LiveKit's actual room participants) that
1370
+ * this viewer is gone, or revocation kicking the participant outright.
1371
+ * Do not design around `expires_at` as if it were any of those.
1372
+ */
1373
+ expires_at: string;
1374
+ /**
1375
+ * Tells the cloud this viewer no longer wants to hold the camera live —
1376
+ * **this** hold, addressed by `session_id` (W6b), and no other tab's.
1377
+ *
1378
+ * Before W6b, `DELETE` carried no id and released every hold this identity
1379
+ * had on the slug — so one tab's `release()` (or its unmount cleanup)
1380
+ * stopped the robot out from under every other tab of the same logged-in
1381
+ * user, which kept rendering a frozen frame because a LiveKit token is
1382
+ * checked at join and never again. Each `CameraLiveSession` now releases
1383
+ * only the hold it itself took.
1384
+ *
1385
+ * **This alone does not stop the stream.** The cloud makes LiveKit room
1386
+ * participation the authoritative refcount, not this call — precisely
1387
+ * because a closing tab cannot be relied on to make it. `release()` is a
1388
+ * courteous fast path; the robot actually stops publishing once every
1389
+ * viewer's LiveKit `Room` has disconnected, which the SFU notices on its
1390
+ * own with no cooperation required. **Always pair this with disconnecting
1391
+ * the `Room` you connected with `url`/`token`** — see the README's
1392
+ * Cameras section for the paired cleanup pattern; a `release()` that ran
1393
+ * alone while the `Room` stayed connected would stop nothing.
1394
+ *
1395
+ * Safe to call more than once (only the first call does anything) and
1396
+ * never rejects — this is a courtesy notification, not the thing that
1397
+ * actually stops the stream (see above), so there is nothing a caller
1398
+ * could usefully do with a rejection here. That also makes this safe to
1399
+ * use directly as e.g. a React effect's cleanup return value, including
1400
+ * from `beforeunload`, where a call that could throw would be a liability.
1401
+ *
1402
+ * **A failed DELETE here is not observable anywhere** — not as a
1403
+ * rejection, not as a realtime event, not as a field on this object. This
1404
+ * is a deliberate decision, not an oversight (W6b review): the only
1405
+ * consumer of that information would be code deciding whether to retry,
1406
+ * and the backstop this comment already describes — the cloud's own
1407
+ * LiveKit-participation reconciliation — makes a retry unnecessary for
1408
+ * correctness. If a future caller needs to know "did my release actually
1409
+ * reach the cloud" (telemetry, say), that is a new, additive signal to
1410
+ * design, not a change to this method's contract.
1411
+ */
1412
+ release(): Promise<void>;
1413
+ }
1414
+ interface CamerasApi {
1415
+ /** Every camera exposed on this robot (spec §11.2's per-robot descriptor list, extended to the camera kind). */
1416
+ list(robotId: string): Promise<CameraDescriptor[]>;
1417
+ /**
1418
+ * The current snapshot: image bytes plus its age. Independent of `live` —
1419
+ * a snapshot keeps updating on `snapshot_interval_ms` whether or not
1420
+ * anyone is watching live (§10), and keeps being served, with a growing
1421
+ * age, even while the bridge is offline.
1422
+ */
1423
+ snapshot(robotId: string, slug: string): Promise<CameraSnapshot>;
1424
+ /**
1425
+ * The snapshot's metadata alone — for polling "is there a newer frame
1426
+ * yet?" without re-downloading the image on every check. Prefer this over
1427
+ * `snapshot` for a view that only needs to show an age (e.g. "updated 2s
1428
+ * ago") and fetches pixels on demand.
1429
+ */
1430
+ snapshotMeta(robotId: string, slug: string): Promise<CameraSnapshotMeta>;
1431
+ /**
1432
+ * Takes a refcounted hold on this camera's live stream (spec §10): the
1433
+ * first `live()` on a slug starts the robot publishing, the last viewer
1434
+ * leaving stops it. Deliberately not deduplicated locally across multiple
1435
+ * `live()` calls for the same `(robotId, slug)` — unlike a datapoint
1436
+ * subscription, each call needs its own distinct LiveKit participant, so
1437
+ * a local counter here would just be the same shared-count bug
1438
+ * `slug-subscriptions.ts` fixed, self-inflicted on a resource the cloud
1439
+ * already counts correctly.
1440
+ */
1441
+ live(robotId: string, slug: string): Promise<CameraLiveSession>;
1442
+ }
1443
+
1444
+ interface DatapointSubscriptionHandlers {
1445
+ /** Called with the current value on subscribe, then again on every change. */
1446
+ onEvent(event: DatapointEvent): void;
1447
+ /** Called once if the subscription is refused (§11.5: e.g. `forbidden`, `unknown_datapoint`). */
1448
+ onError?(error: FleetlessError): void;
1449
+ }
1450
+ interface DatapointSubscription {
1451
+ /** Stops the subscription and, if the channel is currently connected, tells the server. */
1452
+ unsubscribe(): void;
1453
+ }
1454
+ /**
1455
+ * Window aggregation for `history` (spec §8). `window` and `agg` always
1456
+ * travel together on the wire — the cloud refuses one without the other
1457
+ * rather than defaulting either, since a silently chosen aggregation is a
1458
+ * chart that lies quietly — so they live in one object here instead of two
1459
+ * independent optional fields a caller could set only one of. The same
1460
+ * reasoning as `cameraSource` being a discriminated union rather than
1461
+ * optional fields: make the impossible combination unrepresentable, not
1462
+ * merely rejected.
1463
+ */
1464
+ interface HistoryAggregation {
1465
+ /** Bucket width, e.g. `10s`, `1m`. */
1466
+ window: string;
1467
+ agg: 'min' | 'max' | 'avg';
1468
+ /** A numeric field inside an object value, e.g. `pose.x` (§4.4 paths). Only meaningful when the datapoint's own value is not itself a number. */
1469
+ field?: string;
1470
+ }
1471
+ interface HistoryOptions {
1472
+ /**
1473
+ * `now-30s` / `now-5m` / `now-1h`, or absolute unix milliseconds — as a
1474
+ * **string** either way, exactly as the wire query expects it. The SDK
1475
+ * does not accept a `Date` or a `number` and stringify it for you: that
1476
+ * would be a convenience that quietly decides which of the two forms you
1477
+ * meant, and the next person reading the wire traffic would not know
1478
+ * which of us made that call.
1479
+ */
1480
+ from: string;
1481
+ /** Same two forms as `from`. Defaults to now. */
1482
+ to?: string;
1483
+ limit?: number;
1484
+ /** Present: the result is aggregated buckets. Absent: raw samples. */
1485
+ aggregate?: HistoryAggregation;
1486
+ }
1487
+ interface DatapointsApi {
1488
+ get(robotId: string, slug: string): Promise<DatapointValue>;
1489
+ /**
1490
+ * Subscribes over the realtime channel. Reconnect and re-authentication
1491
+ * are handled by the shared `RealtimeChannel`; this resends its
1492
+ * `subscribe` frame after every (re)connect, so a network drop is
1493
+ * invisible to the caller beyond a gap in events.
1494
+ *
1495
+ * Reference-counted per `(robotId, slug)`: two subscriptions to the same
1496
+ * pair share one wire subscription. Unsubscribing one never affects the
1497
+ * other — the `unsubscribe` frame is sent only when the last subscriber
1498
+ * on that pair goes away. This matters in practice: two widgets showing
1499
+ * the same battery value, or a component mounted twice under React
1500
+ * StrictMode, both subscribe to the same key. The count itself lives in
1501
+ * `slug-subscriptions.ts`, shared with `actions.subscribe`/`services.call`
1502
+ * — a slug is one namespace across kinds, and so is its subscription.
1503
+ */
1504
+ subscribe(robotId: string, slug: string, handlers: DatapointSubscriptionHandlers): DatapointSubscription;
1505
+ /**
1506
+ * Reads recorded history for a `retention: true` datapoint (spec §8) over
1507
+ * REST — no realtime channel involved, the same way `cameras.snapshot`
1508
+ * isn't. Returns a **discriminated result**: passing `aggregate` gets you
1509
+ * back `HistoryBucketsResponse` (`kind: 'buckets'`), leaving it out gets
1510
+ * you `HistorySamplesResponse` (`kind: 'samples'`) — two overloads so a
1511
+ * caller who already knows which one they asked for isn't forced to
1512
+ * narrow something they determined themselves. `kind` still carries the
1513
+ * same information on both, so code that holds the result dynamically
1514
+ * (e.g. read from a variable typed as the union) can still branch on it.
1515
+ *
1516
+ * **Rejects, does not silently empty out, two specific refusals** —
1517
+ * unlike `cameras.snapshot`'s absorption of `no_snapshot_yet` into a null
1518
+ * read, these two must reach the caller as thrown `FleetlessError`s:
1519
+ * - `not_recorded` — the slug exists and is granted, but is configured
1520
+ * live-only. An empty result here would look exactly like "recorded,
1521
+ * but nothing in this window", and the two need opposite fixes: turn
1522
+ * recording on, versus look at a different range.
1523
+ * - `not_aggregatable` — `aggregate` was given for a value that isn't a
1524
+ * number and no numeric `aggregate.field` was named.
1525
+ */
1526
+ history(robotId: string, slug: string, options: HistoryOptions & {
1527
+ aggregate: HistoryAggregation;
1528
+ }): Promise<HistoryBucketsResponse>;
1529
+ history(robotId: string, slug: string, options: HistoryOptions & {
1530
+ aggregate?: undefined;
1531
+ }): Promise<HistorySamplesResponse>;
1532
+ }
1533
+
1534
+ interface JobsApi {
1535
+ /**
1536
+ * Every job the platform currently believes this robot has — `GET
1537
+ * /api/robots/:id/jobs` (W6b, contracts `robotJobsResponse` doc comment).
1538
+ *
1539
+ * `actions.subscribe`/`services.call` and `GET /jobs/:slug` (the per-slug
1540
+ * route those build on) all require already knowing the slug. That is not
1541
+ * always true: a reconnecting bridge can name a job the cloud only
1542
+ * *adopted*, and a configuration change can leave a job on a slug the
1543
+ * published document no longer contains. Both are jobs no slug can name,
1544
+ * which is exactly what this method is for (register row 2k) — an app
1545
+ * developer had no way to reach them before this.
1546
+ *
1547
+ * At most one entry per slug: the current job there, exactly what a
1548
+ * per-slug read would answer for that slug. Not a history endpoint.
1549
+ * Grant-filtered same as `cameras.list`/`datapoints` — an end user or
1550
+ * server key sees only jobs on slugs their role grants; a developer
1551
+ * session sees every job on the robot. Never empty-vs-missing ambiguity:
1552
+ * a robot doing nothing resolves `[]`.
1553
+ *
1554
+ * **Ordered newest first by `started_at`, with `job.seq` as the
1555
+ * tiebreaker** (`started_at` alone is not a total order — two jobs minted
1556
+ * in the same millisecond used to sort arbitrarily, differently on each
1557
+ * query). But for an **adopted** job, `started_at` is adoption time, not
1558
+ * when it actually started on the robot — the cloud only learns of it at
1559
+ * `hello`, having never minted it, and has no other honest value to put
1560
+ * there. So this is newest-*known*-first: a job the robot has been
1561
+ * running for an hour can sit above one started a minute ago, if the
1562
+ * hour-long one was only just adopted (contracts `robotJobsResponse` doc
1563
+ * comment).
1564
+ */
1565
+ list(robotId: string): Promise<Job[]>;
1566
+ }
1567
+
1568
+ interface PublishersApi {
1569
+ /**
1570
+ * Publishes one message to a publisher (spec §4.2, §6.4).
1571
+ *
1572
+ * This is a plain method call — there is deliberately no deadman switch,
1573
+ * rate governor or "takt" helper here (spec §14.3). The bridge's own
1574
+ * `timeout_ms` failsafe is the platform's safety primitive: if messages
1575
+ * stop arriving — including because this process crashed — the bridge
1576
+ * publishes the configured failsafe message itself. That does **not**
1577
+ * mean the SDK protects a caller who stops calling `publish` on purpose
1578
+ * without stopping cleanly (e.g. no repeated call at a safe rate): the
1579
+ * safety pattern for *how often* and *when* to publish belongs in the
1580
+ * app, not here. See the README's "No teleop helpers" section before
1581
+ * building a publisher-driven control loop.
1582
+ *
1583
+ * Rejects `publisher_busy` while a different user is publishing and has
1584
+ * not been quiet for `quiet_timeout_ms` yet (§6.4) — whoever publishes
1585
+ * holds the publisher implicitly exclusive.
1586
+ */
1587
+ publish(robotId: string, slug: string, message: Record<string, unknown>, options?: SendCommandOptions): Promise<void>;
1588
+ }
1589
+
1590
+ interface ServicesApi {
1591
+ /**
1592
+ * Calls a service and resolves with its result (spec §4.2, §11.3). A
1593
+ * service call is a job underneath — the same `job_id` exchange and
1594
+ * disconnect survival as an action (§6.1: "an action goal or a service
1595
+ * call") — but that is deliberately invisible here: the caller gets a
1596
+ * plain `Promise<result>`, matching the REST `serviceCallResponse` shape's
1597
+ * developer experience. There is nothing to subscribe to for a service —
1598
+ * no feedback, no progress, no cancel — so this call already waits for
1599
+ * the terminal state internally.
1600
+ *
1601
+ * `options.patienceMs` bounds the **whole wait** for a service call (W6b)
1602
+ * — unlike an action, where it bounds acceptance only — because a service
1603
+ * has no further state to observe once it settles; the platform gives up
1604
+ * on the ROS call itself after this long.
1605
+ *
1606
+ * `options.timeoutMs` bounds this SDK's own local wait for the WHOLE
1607
+ * call — the ack that a job was created, plus however much of the
1608
+ * budget is left for it to then reach a terminal state (D10) — not two
1609
+ * separate `timeoutMs`-length windows back to back. A caller who sets
1610
+ * `timeoutMs: 5000` is bounding total latency at ~5s, not ~10s; the
1611
+ * number means what it says, once, for the whole call.
1612
+ *
1613
+ * It is also **not independent** of `patienceMs` (D3a): left unset, it
1614
+ * is derived from `patienceMs` so this SDK's local clock cannot fire
1615
+ * before the platform's own deadline has even been reached. Setting
1616
+ * both, with `timeoutMs` shorter than `patienceMs`, throws
1617
+ * `invalid_option` synchronously rather than letting the two race — see
1618
+ * `resolveLocalWaitMs` in `commands.ts` for the full reasoning.
1619
+ */
1620
+ call(robotId: string, slug: string, params: Record<string, unknown>, options?: InvokeOptions): Promise<unknown>;
1621
+ }
1622
+
1623
+ interface FleetlessClientOptions {
1624
+ /** Base URL of the Fleetless REST API, e.g. `https://api.fleetless.dev`. */
1625
+ apiUrl: string;
1626
+ /** The app's identifier (the slug shown in the console), sent on every login. */
1627
+ appIdentifier: string;
1628
+ /**
1629
+ * Where refresh/access tokens are kept between calls. Defaults to an
1630
+ * in-memory store — pass your own (localStorage, a cookie, a native
1631
+ * keystore) to persist a session across reloads. The SDK never assumes a
1632
+ * browser exists.
1633
+ */
1634
+ tokenStore?: TokenStore;
1635
+ /**
1636
+ * A server key (`flk_...`) for server-side callers with full app rights.
1637
+ * Mutually exclusive with `tokenStore`-based login: a client constructed
1638
+ * with a server key never calls `auth.login`/`auth.logout`.
1639
+ */
1640
+ serverKey?: string;
1641
+ /** Injectable for tests, or a non-global `fetch` implementation. */
1642
+ fetch?: typeof fetch;
1643
+ /** Injectable for tests, or a non-global `WebSocket` implementation. */
1644
+ WebSocket?: typeof WebSocket;
1645
+ /** Defaults to `apiUrl` with http(s) swapped for ws(s) and `/realtime` appended. */
1646
+ realtimeUrl?: string;
1647
+ }
1648
+ interface FleetlessClientConfig {
1649
+ readonly apiUrl: string;
1650
+ readonly appIdentifier: string;
1651
+ readonly realtimeUrl: string;
1652
+ }
1653
+ interface FleetlessClient {
1654
+ readonly config: FleetlessClientConfig;
1655
+ readonly auth: AuthApi;
1656
+ readonly datapoints: DatapointsApi;
1657
+ readonly actions: ActionsApi;
1658
+ readonly services: ServicesApi;
1659
+ readonly publishers: PublishersApi;
1660
+ readonly cameras: CamerasApi;
1661
+ /**
1662
+ * Robot-wide job reads that do not fit under `actions`/`services` because
1663
+ * they are not addressed by slug — see `JobsApi.list`.
1664
+ */
1665
+ readonly jobs: JobsApi;
1666
+ /** URDF + mesh reads (spec §4.6) — list/get/urdf, plus the `urdf-loader` mesh callback. */
1667
+ readonly assets: AssetsApi;
1668
+ /**
1669
+ * Closes the realtime channel and stops it from reconnecting. Safe to
1670
+ * call whether or not any subscription was ever made, and safe to call
1671
+ * more than once. A Node script (the exact use case `serverKey` is for)
1672
+ * that never calls this after subscribing will not exit on its own — an
1673
+ * open WebSocket keeps the event loop alive. `auth.logout()` calls this
1674
+ * automatically; call it yourself too if the process should exit without
1675
+ * logging out (e.g. a server-side caller shutting down).
1676
+ */
1677
+ close(): void;
1678
+ }
1679
+ declare function createClient(options: FleetlessClientOptions): FleetlessClient;
1680
+
1681
+ export { type ActionsApi, type Asset, type AssetBytes, type AssetListResponse, type AssetsApi, type AuthApi, type BeginHostedLoginOptions, type BusyDetails, type CameraDescriptor, type CameraLiveSession, type CameraSnapshot, type CameraSnapshotMeta, type CamerasApi, type ClientIdentity, type ClientRegisterResponse, type CompleteHostedLoginOptions, type CreateMeshLoaderOptions, type DatapointEvent, type DatapointSubscription, type DatapointSubscriptionHandlers, type DatapointValue, type DatapointsApi, type FleetlessClient, type FleetlessClientConfig, type FleetlessClientOptions, FleetlessError, type FleetlessErrorCode, type FleetlessErrorOptions, type HistoryAggregation, type HistoryBucketsResponse, type HistoryOptions, type HistorySamplesResponse, type HostedLoginRequest, InMemoryTokenStore, type Job, type JobEvent, type JobState, type JobSubscription, type JobSubscriptionHandlers, type JobsApi, type MailStatus, type MeshLoaderDelegate, type ParameterInvalidDetails, type ParameterViolation, type PrepareUrdfSceneOptions, type PublishersApi, type RateLimitDetails, SDK_ERROR_CODES, type SdkErrorCode, type SendCommandOptions, type ServicesApi, type StoredSession, type TokenStore, type UrdfCompleteness, type UrdfSceneManager, type UrdfSceneResources, createClient, parameterInvalidDetails };