@fleetless/sdk 2.0.2 → 3.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.
package/dist/index.d.cts CHANGED
@@ -18,9 +18,9 @@ import { z } from 'zod';
18
18
  * admits it lost track.
19
19
  */
20
20
  declare const jobState: z.ZodEnum<{
21
+ failed: "failed";
21
22
  running: "running";
22
23
  succeeded: "succeeded";
23
- failed: "failed";
24
24
  cancelled: "cancelled";
25
25
  lost: "lost";
26
26
  }>;
@@ -30,9 +30,9 @@ declare const job: z.ZodObject<{
30
30
  robot_id: z.ZodUUID;
31
31
  slug: z.ZodString;
32
32
  state: z.ZodEnum<{
33
+ failed: "failed";
33
34
  running: "running";
34
35
  succeeded: "succeeded";
35
- failed: "failed";
36
36
  cancelled: "cancelled";
37
37
  lost: "lost";
38
38
  }>;
@@ -65,9 +65,9 @@ declare const jobEvent: z.ZodObject<{
65
65
  robot_id: z.ZodUUID;
66
66
  slug: z.ZodString;
67
67
  state: z.ZodEnum<{
68
+ failed: "failed";
68
69
  running: "running";
69
70
  succeeded: "succeeded";
70
- failed: "failed";
71
71
  cancelled: "cancelled";
72
72
  lost: "lost";
73
73
  }>;
@@ -97,9 +97,9 @@ declare const busyDetails: z.ZodObject<{
97
97
  robot_id: z.ZodUUID;
98
98
  slug: z.ZodString;
99
99
  state: z.ZodEnum<{
100
+ failed: "failed";
100
101
  running: "running";
101
102
  succeeded: "succeeded";
102
- failed: "failed";
103
103
  cancelled: "cancelled";
104
104
  lost: "lost";
105
105
  }>;
@@ -256,6 +256,9 @@ type DatapointEvent = z.infer<typeof datapointEvent>;
256
256
  * Access plus refresh (spec §3.4). The access token is short-lived; the
257
257
  * refresh token rotates on every use, so a stolen one is detectable when the
258
258
  * original is presented again.
259
+ *
260
+ * One shape for both identity spaces: a session is a session, and the claims
261
+ * inside the token are what differ.
259
262
  */
260
263
  declare const sessionTokens: z.ZodObject<{
261
264
  access_token: z.ZodString;
@@ -265,105 +268,149 @@ declare const sessionTokens: z.ZodObject<{
265
268
  type SessionTokens = z.infer<typeof sessionTokens>;
266
269
 
267
270
  /**
268
- * **What logout can and cannot end, said in three separable facts (W9c,
269
- * DEF-098).**
271
+ * **Why a federated sign-in ended without a session, in a code the app can
272
+ * branch on** — carried back to the app's own `redirect_uri` as `error`, not
273
+ * rendered by Fleetless (D2). The only Fleetless-rendered page in this flow is
274
+ * the one for a state that can no longer be resolved to a redirect URI, because
275
+ * then there is nowhere to send the answer.
270
276
  *
271
- * Until now this route answered `204`: the Fleetless session was over and the
272
- * response had nothing to say about the *other* session. For a federated user
273
- * that is the larger half they clicked "log out", the IdP's cookie survived,
274
- * and the next login goes straight through without a password. The register
275
- * row calls that an expectation gap, and it is: the word on the button is
276
- * "log out", not "log out of this app".
277
+ * The five rows of D4's table are the first five values plus `no_access`:
278
+ *
279
+ * - `no_access` the identity is unknown and nothing admits it, or the account
280
+ * it names is not `active`. **One code for both**, because to the person the
281
+ * remedy is the same ask somebody to let you in and a code that split an
282
+ * outcome nobody acts on differently would tell a stranger which half applied.
283
+ * - `email_taken` — the address already belongs to another app user, and the
284
+ * provider is not permitted to link (`link_verified_emails`, or the provider
285
+ * did not assert `email_verified`). Deliberately not `no_access`: the remedy
286
+ * is different — *sign in the way you signed up*.
287
+ * - `email_unverified` — the provider asserted an address without
288
+ * `email_verified`. **An unverified address never produces or links an
289
+ * account**, whatever the rest of the policy says.
290
+ * - `domain_not_allowed`, `registration_closed` — the self-registration policy
291
+ * refused. Honest, because neither is about whether a person exists.
292
+ * - `idp_unavailable`, `exchange_failed`, `claims_incomplete`,
293
+ * `provider_misconfigured`, `provider_disabled` — the provider's or the
294
+ * developer's to fix, and the app can say so.
295
+ * - `invalid_request` — the start parameters did not hold up.
296
+ * - `quota_exceeded` — the org has as many app users as its `max_end_users`
297
+ * quota allows, so no account can be created for this identity. Named rather
298
+ * than folded into `no_access`, for `domain_not_allowed`'s reason: it is not
299
+ * about the person, the app can say what happened, and the remedy belongs to
300
+ * the developer rather than to whoever is trying to sign in. It is raised
301
+ * **only where an account would be created** — an identity that already has
302
+ * one signs in at the quota exactly as it does under it, because refusing a
303
+ * sign-in would turn a protection limit into an outage.
304
+ */
305
+ declare const clientOidcErrorCode: z.ZodEnum<{
306
+ no_access: "no_access";
307
+ email_taken: "email_taken";
308
+ email_unverified: "email_unverified";
309
+ domain_not_allowed: "domain_not_allowed";
310
+ registration_closed: "registration_closed";
311
+ idp_unavailable: "idp_unavailable";
312
+ exchange_failed: "exchange_failed";
313
+ claims_incomplete: "claims_incomplete";
314
+ provider_misconfigured: "provider_misconfigured";
315
+ provider_disabled: "provider_disabled";
316
+ invalid_request: "invalid_request";
317
+ quota_exceeded: "quota_exceeded";
318
+ }>;
319
+ type ClientOidcErrorCode = z.infer<typeof clientOidcErrorCode>;
320
+ /**
321
+ * **A pending MCP authorization, as the app's own consent screen reads it**
322
+ * (D7). Fleetless renders no page here either: `authorize` redirects to the
323
+ * app's `mcp_login_url` with an interaction id, the app authenticates the user
324
+ * with its normal UI, shows this, and approves or denies through the API.
277
325
  *
278
- * **The Fleetless session is ended before this is computed, unconditionally.**
279
- * Nothing below can fail in a way that leaves the caller logged in here a
280
- * logout that depends on reaching a third party is not a logout.
326
+ * `client_name_verified` is `z.literal(false)`, and that is the whole point of
327
+ * the field. The name comes from an **unauthenticated** dynamic registration
328
+ * the client typed it about itself, nobody checked it so a consent screen
329
+ * that rendered it as though it were an identity would be teaching people to
330
+ * trust a string an attacker chooses. A literal rather than a boolean because
331
+ * there is no verified case to distinguish: an app that reads this field at all
332
+ * has to handle the untrusted one, and a `true` branch would be dead code
333
+ * pretending to be a safeguard.
334
+ */
335
+ declare const clientMcpInteraction: z.ZodObject<{
336
+ id: z.ZodString;
337
+ app_id: z.ZodUUID;
338
+ client_name: z.ZodNullable<z.ZodString>;
339
+ client_name_verified: z.ZodLiteral<false>;
340
+ scopes: z.ZodArray<z.ZodString>;
341
+ already_granted: z.ZodBoolean;
342
+ expires_at: z.ZodISODateTime;
343
+ }, z.core.$strip>;
344
+ type ClientMcpInteraction = z.infer<typeof clientMcpInteraction>;
345
+ /**
346
+ * **One standing MCP consent, as both withdrawal doors list it.**
281
347
  *
282
- * **Four outcomes**, and they are deliberately not collapsed into a nullable
283
- * URL. *No IdP was involved* and *an IdP was involved and publishes no
284
- * `end_session_endpoint`* are different things: the first needs no action and
285
- * the second means a session survives that this platform cannot end. A caller
286
- * that renders them identically is choosing to; a contract that cannot tell
287
- * them apart makes the choice for everyone.
348
+ * A grant is what lets a later authorization skip the app's consent screen:
349
+ * `clientMcpInteraction.already_granted` is a read of exactly this row. It is
350
+ * written when a person approves and it is removed by neither the client's
351
+ * registration lapsing nor its access token expiring so without a door it
352
+ * was a decision a person could make once and never unmake.
288
353
  *
289
- * **This sentence said "three" for a whole wave, directly above a four-branch
290
- * union in this same file** found by Momus-W9, along with the same number in
291
- * `sdk/src/auth.ts` and `sdk/README.md`. The type is derived
292
- * (`ClientLogoutResponse['idp_logout']`), so `tsc` had nothing to say, and the
293
- * sweep shows the mechanism plainly: `sdk d065447` is literally titled *"logout
294
- * says three separable things"* — correct when written, never carried forward
295
- * when `hint_unavailable` arrived in `b417d2a`.
354
+ * **Standing only.** A withdrawn grant is stamped rather than deleted, so the
355
+ * store still holds it; neither listing returns one. The question both doors
356
+ * ask is *what is connected right now*, and a row that answered "connected,
357
+ * but no" would be a state every caller has to filter for itself.
296
358
  *
297
- * The count is not the point. **`hint_unavailable` is precisely the case this
298
- * comment warns about** the IdP session survives — so a caller who handles
299
- * the three documented branches drops it into an `else` they believe means
300
- * *nothing to do*.
359
+ * `client_name_verified` is `z.literal(false)` for the reason
360
+ * `clientMcpInteraction` gives at length: the name comes from an
361
+ * unauthenticated dynamic registration, the client chose it about itself, and
362
+ * a list that rendered it as an identity would be teaching people to trust a
363
+ * string an attacker picked. Here it matters more than on the consent screen,
364
+ * not less — a "connected apps" list is read long after the moment of
365
+ * approval, when nobody remembers what they clicked.
301
366
  */
302
- declare const clientLogoutResponse: z.ZodObject<{
303
- idp_logout: z.ZodDiscriminatedUnion<[z.ZodObject<{
304
- status: z.ZodLiteral<"redirect">;
305
- url: z.ZodURL;
306
- }, z.core.$strip>, z.ZodObject<{
307
- status: z.ZodLiteral<"not_federated">;
308
- }, z.core.$strip>, z.ZodObject<{
309
- status: z.ZodLiteral<"unsupported_by_idp">;
310
- }, z.core.$strip>, z.ZodObject<{
311
- status: z.ZodLiteral<"hint_unavailable">;
312
- }, z.core.$strip>, z.ZodObject<{
313
- status: z.ZodLiteral<"session_unknown">;
314
- }, z.core.$strip>], "status">;
367
+ declare const mcpConsentGrant: z.ZodObject<{
368
+ client_id: z.ZodString;
369
+ client_name: z.ZodNullable<z.ZodString>;
370
+ client_name_verified: z.ZodLiteral<false>;
371
+ granted_at: z.ZodISODateTime;
315
372
  }, z.core.$strip>;
316
- type ClientLogoutResponse = z.infer<typeof clientLogoutResponse>;
373
+ type McpConsentGrant = z.infer<typeof mcpConsentGrant>;
317
374
  /**
318
375
  * Who the caller turned out to be. Returned by the "who am I" endpoint and by
319
376
  * the realtime `auth_ok` frame, so a client can render a session without
320
377
  * decoding a token itself — decoding a JWT in the client is how apps end up
321
378
  * trusting claims nobody verified.
322
379
  *
323
- * **Three kinds of caller reach the client API, not two.** Besides end users
324
- * and server keys, a **developer** does: spec §15.2 says the console's
325
- * playground runs over the real client API and appears in the audit as the
326
- * developer, and the console's own live views (robot list badges, the Live
327
- * tab) subscribe on `/realtime` as one. A developer is **org-scoped, not
380
+ * **Three kinds of caller reach the client API.** Besides app users and server
381
+ * keys, a **developer** does: the console's playground runs over the real
382
+ * client API and appears in the audit as the developer, and the console's own
383
+ * live views subscribe on `/realtime` as one. A developer is **org-scoped, not
328
384
  * app-scoped** — they own the configuration of every robot in their org — so
329
385
  * `app_id` and `role_id` are null for them, and roles do not filter what they
330
- * see. `kind` states this explicitly rather than leaving it to be inferred
331
- * from which id happens to be set.
386
+ * see. `kind` states this explicitly rather than leaving it to be inferred from
387
+ * which id happens to be set.
332
388
  *
333
- * **`act` is the real admin behind an impersonation** (spec
334
- * `2026-08-29-org-identity-redesign`, D4, the `act`-claim pattern of RFC
335
- * 8693). An Org Admins member entering an app through the interstitial
336
- * (`impersonationChoice`) gets a token whose *effective* identity is the role
337
- * or user they chose that is what the rest of this shape describes — while
338
- * `act` names **the admin who is actually driving**. So every action can
339
- * audit as "Admin A as User B / as role X", and a client can render the "you
340
- * are acting as …" banner without decoding the token.
389
+ * **`end_user_id` became `app_user_id`, and that is a rename with a meaning.**
390
+ * The old subject was a member of the org's one pool, reachable through an
391
+ * assignment; the new one is a row that belongs to exactly one app. Renaming
392
+ * rather than keeping the key is deliberate: a consumer reading `.end_user_id`
393
+ * would have typechecked and meant something subtly different, which is the
394
+ * quietest way for a cut like this to go wrong.
341
395
  *
342
- * **Optional, not a nullable actor, for `orgUser.tier`'s reason:** absence
343
- * means *this is an ordinary session, nobody is delegating*, which is not the
344
- * same fact as *the actor is unknown*. The overwhelming majority of sessions
345
- * are ordinary and carry no `act` at all; a session that has one is a
346
- * delegation and says who by. The schema cannot check that `act` is present
347
- * exactly when the effective identity was impersonated — that pairing is the
348
- * cloud's, minted at the authorize step. Only the admin's **id** rides here:
349
- * the label is resolved by whoever renders it, not carried as a second
350
- * unverified name on the wire.
396
+ * **`act` is gone.** It named the org admin behind an impersonation (the RFC
397
+ * 8693 pattern). Impersonation is deleted with no successor (D1), so a field
398
+ * that could still arrive would describe a delegation nothing can mint — and a
399
+ * client rendering "you are acting as …" from it would be showing a state the
400
+ * platform cannot enter.
351
401
  */
352
402
  declare const clientIdentity: z.ZodObject<{
353
403
  kind: z.ZodEnum<{
354
- server_key: "server_key";
355
404
  developer: "developer";
356
- end_user: "end_user";
405
+ server_key: "server_key";
406
+ app_user: "app_user";
357
407
  }>;
358
408
  developer_id: z.ZodNullable<z.ZodUUID>;
359
- end_user_id: z.ZodNullable<z.ZodUUID>;
409
+ app_user_id: z.ZodNullable<z.ZodUUID>;
360
410
  server_key_id: z.ZodNullable<z.ZodUUID>;
361
411
  app_id: z.ZodNullable<z.ZodUUID>;
362
412
  role_id: z.ZodNullable<z.ZodUUID>;
363
413
  email: z.ZodNullable<z.ZodEmail>;
364
- act: z.ZodOptional<z.ZodObject<{
365
- admin_user_id: z.ZodUUID;
366
- }, z.core.$strict>>;
367
414
  }, z.core.$strip>;
368
415
  type ClientIdentity = z.infer<typeof clientIdentity>;
369
416
 
@@ -423,9 +470,9 @@ declare const assetSyncStatus: z.ZodObject<{
423
470
  sync_id: z.ZodUUID;
424
471
  robot_id: z.ZodUUID;
425
472
  state: z.ZodEnum<{
473
+ failed: "failed";
426
474
  running: "running";
427
475
  succeeded: "succeeded";
428
- failed: "failed";
429
476
  }>;
430
477
  done: z.ZodNumber;
431
478
  total: z.ZodNumber;
@@ -467,9 +514,9 @@ declare const assetListResponse: z.ZodObject<{
467
514
  sync_id: z.ZodUUID;
468
515
  robot_id: z.ZodUUID;
469
516
  state: z.ZodEnum<{
517
+ failed: "failed";
470
518
  running: "running";
471
519
  succeeded: "succeeded";
472
- failed: "failed";
473
520
  }>;
474
521
  done: z.ZodNumber;
475
522
  total: z.ZodNumber;
@@ -543,84 +590,14 @@ type ParameterInvalidDetails = z.infer<typeof parameterInvalidDetails>;
543
590
  * list is the shared vocabulary, not a closed set, so a new refusal never
544
591
  * needs a contracts release before it can be reported honestly.
545
592
  */
546
- declare const ERROR_CODES: readonly ["not_found", "validation_error", "bad_request", "unknown_datapoint", "invalid_token", "protocol_mismatch", "invalid_frame", "duplicate_slug", "reserved_slug", "unknown_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", "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", "capability_required", "last_owner", "group_not_deletable", "group_in_use", "target_state_conflict", "mcp_access_denied", "signup_closed"];
593
+ declare const ERROR_CODES: readonly ["not_found", "validation_error", "bad_request", "unknown_datapoint", "invalid_token", "protocol_mismatch", "invalid_frame", "duplicate_slug", "reserved_slug", "unknown_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", "email_taken", "identifier_taken", "weak_password", "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", "idp_unavailable", "mcp_disabled", "tool_not_available", "capability_required", "last_owner", "target_state_conflict", "signup_closed", "draft_not_a_document", "internal_error", "not_cancellable", "unsupported_media_type", "wrong_browser", "invalid_yaml", "unstorable_yaml", "registration_closed", "domain_not_allowed", "email_unverified", "origin_not_allowed", "template_invalid", "provider_disabled", "provider_misconfigured", "invalid_redirect_uri", "interaction_expired"];
547
594
  type ErrorCode = (typeof ERROR_CODES)[number];
548
595
 
549
- /**
550
- * **What an end user sees about their own consents, and why it is not
551
- * `consentGrant` (W9c, DEF-099).**
552
- *
553
- * `consentGrant` is the *record* — four ids and a scope string. A person
554
- * deciding whether to revoke something needs to recognise it, and an id is
555
- * not recognisable. So this carries the names that were on the screen when
556
- * they consented: the client's, the app's, and the role's.
557
- *
558
- * `client_id` stays, because it is what a revocation addresses — the names
559
- * are for reading, the id is for acting.
560
- */
561
- declare const consentGrantSummary: z.ZodObject<{
562
- client_id: z.ZodString;
563
- client_name: z.ZodString;
564
- app_id: z.ZodUUID;
565
- app_name: z.ZodString;
566
- role_id: z.ZodUUID;
567
- role_name: z.ZodString;
568
- scope: z.ZodString;
569
- granted_at: z.ZodISODateTime;
570
- }, z.core.$strip>;
571
- type ConsentGrantSummary = z.infer<typeof consentGrantSummary>;
572
- /**
573
- * **Revoking one grant must end the access it authorised, not merely forget
574
- * that it happened (W9c, DEF-099).**
575
- *
576
- * The register row is about a user who wants a specific client to stop, and
577
- * the failure mode to avoid is a revocation that deletes the consent row
578
- * while every already-issued token keeps working until it expires. So the
579
- * response says what was actually ended, and a caller can tell *nothing
580
- * matched* from *matched and ended*.
581
- *
582
- * `tokens_revoked` is the count of refresh **families** ended. It is not a
583
- * count of access tokens, and deliberately so: an access token is stateless
584
- * and short-lived, and a number that claimed to have revoked one would be the
585
- * kind of sentence this project keeps having to take back.
586
- *
587
- * **What actually happens to the access token is stronger than this comment
588
- * first claimed, and narrower than its correction (W9c, 2026-08-19).** The
589
- * first version said *"let the access token expire"*. It does not. The
590
- * correction then said *"the same access token dies"*, which is true and
591
- * under-specified — Data-W9c read `resolveAnyToken` instead of copying the
592
- * sentence and found what the check is actually bound to:
593
- *
594
- * `if (claims.client_id) { ...consent-grant lookup... }`
595
- *
596
- * So the immediate death is scoped to **`client_id`, not to a session and not
597
- * to the end user.** Every currently-valid token issued through *this
598
- * client's* OAuth flow for this end user dies at once — including a second
599
- * tab holding a different token from the same client. A plain `auth.login()`
600
- * session is untouched, because it carries no `client_id` for the check to
601
- * read, and a token bound to a *different* client is untouched too.
602
- *
603
- * That distinction is the whole point of revoking one grant rather than
604
- * logging somebody out: *"without touching any other client's access"* is
605
- * what this route promises, and the check is what makes it true.
606
- *
607
- * That is a better outcome than the contract promised, and it is written down
608
- * here for one reason: **a caller must not build on the weaker sentence.** If
609
- * this platform ever moves to stateless verification without the revocation
610
- * re-check, the access token would start living out its TTL again, and
611
- * anything that quietly relied on immediate death would break silently.
612
- */
613
- declare const consentRevokeResponse: z.ZodObject<{
614
- revoked: z.ZodBoolean;
615
- tokens_revoked: z.ZodNumber;
616
- }, z.core.$strip>;
617
- type ConsentRevokeResponse = z.infer<typeof consentRevokeResponse>;
618
-
619
596
  /**
620
597
  * Codes the SDK produces itself rather than relaying from the server. Kept
621
598
  * out of `@fleetless/contracts`' `ERROR_CODES` deliberately — that list is
622
599
  * the *wire* vocabulary, every entry something a server may actually send,
623
- * and none of these are. Two kinds:
600
+ * and none of these are.
624
601
  *
625
602
  * - `no_session` / `no_websocket`: a client-side refusal *before* a request
626
603
  * ever reaches the network (not logged in; no WebSocket implementation
@@ -628,102 +605,109 @@ type ConsentRevokeResponse = z.infer<typeof consentRevokeResponse>;
628
605
  * caller can tell "the server refused me" from "the SDK refused before
629
606
  * asking" by the code alone.
630
607
  * - `unparseable_error`: the opposite direction — a real response *did*
631
- * arrive, its body just wasn't shaped like §11.5's error format. Not a
632
- * refusal at all, just "we don't know what the server said."
633
- * - `command_timeout` (W4): a realtime command (`invoke`/`cancel`/`publish`)
634
- * got no `command_result` within its timeout. The server may still answer
608
+ * arrive, its body just was not shaped like the platform's error format.
609
+ * Not a refusal at all, just "we do not know what the server said."
610
+ * - `command_timeout`: a realtime command (`invoke`/`cancel`/`publish`) got
611
+ * no `command_result` within its timeout. The server may still answer
635
612
  * later on the same socket — nobody knows — but the caller cannot be made
636
613
  * to wait forever for that.
637
- * - `command_outcome_unknown` (W4): worse than a timeout, and told apart
638
- * from it on purpose — the realtime connection that carried the command
639
- * was replaced by a new one (a reconnect) before any reply arrived. A
640
- * reply can now never come: the server, if it answered at all, answered a
614
+ * - `command_outcome_unknown`: worse than a timeout, and told apart from it
615
+ * on purpose — the realtime connection that carried the command was
616
+ * replaced by a new one (a reconnect) before any reply arrived. A reply
617
+ * can now never come: the server, if it answered at all, answered a
641
618
  * socket that no longer exists. The command may or may not have run.
642
619
  * Never retried automatically — that could run an action twice — the
643
620
  * caller recovers by reading the job (e.g. `actions.subscribe`), since
644
621
  * state is observed by slug regardless of which connection asked for it.
645
- * - `unexpected_response` (W4): the server answered `ok:true` but left out
622
+ * - `unexpected_response`: the server answered `ok:true` but left out
646
623
  * something the command is defined to always return (e.g. no `job` on a
647
624
  * successful `invoke`) — a contract violation the SDK noticed, not a
648
625
  * refusal.
649
- * - `invalid_option` (W6b): the caller passed an *SDK-level* argument or
650
- * option that cannot mean what it looks like it means. Two cases so far:
651
- * `timeoutMs < patienceMs` on `invoke`/`call` (see `resolveLocalWaitMs` in
652
- * `commands.ts`) — the SDK would give up locally before the platform's own
653
- * patience runs out, and report `command_timeout` for a call the platform
654
- * never actually refused; and a non-string, non-null, non-omitted `jobId`
655
- * on `cancel` (D6, see `assertValidJobId`) — almost always a caller who
656
- * upgraded past the pre-W6b `cancel(robotId, slug, options?)` signature
657
- * and is still passing an options object third. Both are thrown
658
- * synchronously, before any request is sent a client-side mistake to
659
- * fix, not something a server response could ever produce, which is why
660
- * this code belongs here and not in `@fleetless/contracts`' `ERROR_CODES`.
661
- * - `untrusted_absolute_url` (W7): `HttpClient` refused to fetch an absolute
662
- * URL whose origin does not match this client's own configured `baseUrl`
663
- * thrown before the request is ever sent, so no `Authorization` header
664
- * is ever built for it, let alone attached. The one caller that hands
665
- * `HttpClient` an absolute URL at all is `assets.createMeshLoader`
666
- * (§4.6), fetching a URDF's rewritten mesh URIs and a URDF is ROS graph
667
- * input, not first-party data, so an app rendering one must not silently
668
- * trust wherever it points. `assets.createMeshLoader`'s `onComplete`
669
- * surfaces this the same way it surfaces a network failure: `(null, err)`.
670
- * - `no_urdf_synced` (W7a): `assets.prepareUrdfScene` looked for a
671
- * `kind: 'urdf'` row in `assets.list()` and found none. Thrown before any
672
- * asset fetch, rather than left to surface as a confusing downstream
673
- * failure from `URDFLoader.parse(undefined)` or similar the caller's
674
- * fix is "sync a URDF first" (console, Owner-tier, §4.6), which this
675
- * error can say directly.
676
- * - `no_hosted_login_attempt` (W7b, Momus's review): `auth.completeHostedLogin()`
677
- * was called with an empty `expectedState` — nothing was persisted for
678
- * this attempt. A callback landing in a different tab or window than the
679
- * one that called `beginHostedLogin`, a restored session, or storage
680
- * cleared in between all produce exactly this, and none of them is an
681
- * attack. Told apart from `state_mismatch` on purpose: the two diagnoses
682
- * have different remedies ("check how you persisted the value" versus
683
- * "this response belongs to a login you did not start"), the same
684
- * reasoning this wave already applied once to `identity_conflict` versus
685
- * `identity_not_provisioned`. Also closes a real gap comparing two
686
- * *empty* strings with `!==` is `false`, so without this check first, a
687
- * caller with nothing persisted at all could reach `state_mismatch`'s
688
- * comparison having contributed no defence whatsoever.
689
- * - `aborted` (register row 244, W7c): `assets.prepareUrdfScene()` was given
690
- * an `AbortSignal` and it fired either already-aborted before the call
691
- * started, or mid-flight while a fetch was in progress. Normalized to this
692
- * one code regardless of which stage the abort landed in, rather than
693
- * surfacing whatever shape the underlying `fetch()` rejects an aborted
694
- * request with (a `DOMException` named `AbortError` in a browser, an
695
- * `Error` named `AbortError` under Node's `fetch` two different shapes a
696
- * caller would otherwise have to detect themselves to tell "I cancelled
697
- * this" from "the network actually failed"). Every partial resource this
698
- * call had already created (`blob:` URLs) is revoked before this throws —
699
- * an aborted load must not leak what it fetched before the signal fired,
700
- * the same guarantee a failed load already had (D6).
701
- * - `state_mismatch` (W7b): `auth.completeHostedLogin()` was called with a
702
- * `state` that does not match the `expectedState` its own `beginHostedLogin()`
703
- * returned for this attempt (or with no `state` at all — `beginHostedLogin`
626
+ * - `invalid_option`: the caller passed an *SDK-level* argument or option
627
+ * that cannot mean what it looks like it means. Three cases so far:
628
+ * `timeoutMs < patienceMs` on `invoke`/`call` the SDK would give up
629
+ * locally before the platform's own patience runs out, and report
630
+ * `command_timeout` for a call the platform never actually refused; a
631
+ * non-string, non-null, non-omitted `jobId` on `cancel` — almost always a
632
+ * caller who upgraded past the older `cancel(robotId, slug, options?)`
633
+ * signature and is still passing an options object third; and a
634
+ * `concurrency` on `assets.prepareUrdfScene` that is not a positive
635
+ * integer, which would otherwise fetch nothing and return a scene that
636
+ * renders blank with no error to explain why. **A fourth since 3.0.0:**
637
+ * every `auth` method needing an app user's own session, called on a client
638
+ * built with a `serverKey` `register`, `login`, `logout`, the password
639
+ * and invitation calls, both OIDC calls, the two MCP decisions and the two
640
+ * grant calls. Those threw a bare `Error` before, which a caller could only
641
+ * catch by message. All of them are refused
642
+ * before any request is sent as a rejection, since every one of those
643
+ * methods is `async`. A client-side mistake to fix, not something a
644
+ * server response could ever produce, which is why this code belongs here
645
+ * and not in `@fleetless/contracts`' `ERROR_CODES`.
646
+ * - `untrusted_absolute_url`: the SDK refused to fetch an absolute URL
647
+ * whose origin does not match this client's own `apiUrl` thrown before
648
+ * the request is ever sent, so no `Authorization` header is ever built for
649
+ * it, let alone attached. The one caller that fetches an absolute URL at
650
+ * all is `assets.createMeshLoader`, following a URDF's rewritten mesh
651
+ * URIs and a URDF is ROS graph input, not first-party data, so an app
652
+ * rendering one must not silently trust wherever it points.
653
+ * `assets.createMeshLoader`'s `onComplete` surfaces this the same way it
654
+ * surfaces a network failure: `(null, err)`.
655
+ * - `no_urdf_synced`: `assets.prepareUrdfScene` looked for a `kind: 'urdf'`
656
+ * row in `assets.list()` and found none. Thrown before any asset fetch,
657
+ * rather than left to surface as a confusing downstream failure from
658
+ * `URDFLoader.parse(undefined)` or similar the caller's fix is "sync a
659
+ * URDF first", which this error can say directly.
660
+ * - `aborted`: `assets.prepareUrdfScene()` was given an `AbortSignal` and it
661
+ * fired either already-aborted before the call started, or mid-flight
662
+ * while a fetch was in progress. Normalized to this one code regardless of
663
+ * which stage the abort landed in, rather than surfacing whatever shape
664
+ * the underlying `fetch()` rejects an aborted request with (a
665
+ * `DOMException` named `AbortError` in a browser, an `Error` named
666
+ * `AbortError` under Node's `fetch` two different shapes a caller would
667
+ * otherwise have to detect themselves to tell "I cancelled this" from "the
668
+ * network actually failed"). Every partial resource this call had already
669
+ * created (`blob:` URLs) is revoked before this throws an aborted load
670
+ * must not leak what it fetched before the signal fired, the same
671
+ * guarantee a failed load already had.
672
+ * - `state_mismatch`: `auth.completeOidcLogin()` was called with a `state`
673
+ * that does not match the `expectedState` its own `beginOidcLogin()`
674
+ * returned for this attempt or with no `state` at all (`beginOidcLogin`
704
675
  * always sets one, so a callback carrying none does not look like a reply
705
- * to a flow this client started). Thrown before `/oauth/token` is ever
706
- * called RFC 6749 §10.12's whole point is that a caller must not
707
- * complete an authorization response it did not itself request, so this
708
- * check happens client-side, first, rather than being left to the server
709
- * to catch (by which point a code exchange would already have been
710
- * attempted for a flow this client never started).
676
+ * to a flow this client started), or with an **empty `expectedState`**,
677
+ * meaning nothing was persisted for this attempt at all. That last case is
678
+ * folded in rather than given its own code: two empty strings compare
679
+ * equal, so it has to be checked explicitly or the comparison defends
680
+ * nothing for exactly the callers most likely to hit it but a caller
681
+ * branching on the code has the same next step either way, which is to
682
+ * start the sign-in again. Which of the two happened is in the message,
683
+ * because the *developer's* remedies do differ ("check how your app
684
+ * persisted the value" versus "this response belongs to a sign-in you did
685
+ * not start"). Thrown before `/api/client/oidc/exchange` is ever called:
686
+ * RFC 6749 section 10.12's whole point is that a caller must not complete
687
+ * an authorization response it did not itself request, so this check
688
+ * happens client-side, first, rather than being left to the server to
689
+ * catch — by which point a one-time code would already have been spent for
690
+ * a flow this client never started.
691
+ */
692
+ 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_urdf_synced", "aborted"];
693
+ /**
694
+ * The union of `SDK_ERROR_CODES` — the SDK's own client-side error
695
+ * vocabulary. A `FleetlessError` whose `code` is one of these was raised by
696
+ * this SDK rather than relayed from the server.
711
697
  */
712
- 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"];
713
698
  type SdkErrorCode = (typeof SDK_ERROR_CODES)[number];
714
699
  /**
715
700
  * A stable code a caller can branch on: a server-defined code (open-ended —
716
- * see `ErrorCode`'s own doc comment), one of the SDK's own client-side
717
- * codes above, or, since neither list is exhaustive, any other string.
701
+ * see `ErrorCode`'s own doc comment), one of `SdkErrorCode`'s client-side
702
+ * codes, or, since neither list is exhaustive, any other string.
718
703
  * `(string & {})` is the standard trick to keep autocomplete on the known
719
704
  * values while still accepting an arbitrary one.
720
705
  */
721
706
  type FleetlessErrorCode = ErrorCode | SdkErrorCode | (string & {});
722
707
  /**
723
- * The one error type the SDK throws for a refused API call (spec §11.5): a
724
- * stable machine-readable `code` a caller can branch on (`forbidden` vs
725
- * `token_expired`) plus a human `message` for logs and debugging. Never
726
- * parse `message` — it is not part of the contract, only `code` is.
708
+ * The third argument of `FleetlessError`'s constructor: what a thrower can
709
+ * attach beyond the code and the message. Both fields are optional, and
710
+ * both are absent on the codes the SDK raises before any request is sent.
727
711
  */
728
712
  interface FleetlessErrorOptions {
729
713
  /** Field-level detail for validation errors, passed through verbatim. */
@@ -731,79 +715,126 @@ interface FleetlessErrorOptions {
731
715
  /** The HTTP status of the response that produced this error, if any. */
732
716
  status?: number;
733
717
  }
718
+ /**
719
+ * The one error type the SDK throws for a refused API call: a stable
720
+ * machine-readable `code` a caller can branch on (`forbidden` versus
721
+ * `token_expired`) plus a human `message` for logs and debugging. Never
722
+ * parse `message` — it is not part of the contract, only `code` is.
723
+ *
724
+ * Catch it by shape rather than by class where you can (`err.code`), since
725
+ * a bundler that ends up with two copies of the SDK also ends up with two
726
+ * classes and `instanceof` then answers `false` for a genuine one.
727
+ */
734
728
  declare class FleetlessError extends Error {
729
+ /** What went wrong, as a stable string — the field to branch on. */
735
730
  readonly code: FleetlessErrorCode;
731
+ /**
732
+ * Structured detail the server sent with the refusal, if any: the
733
+ * violations behind `parameter_invalid`, the running job behind `busy`,
734
+ * `retry_after_ms` behind `rate_limited`. Parse it rather than assume
735
+ * its shape — `parameterInvalidDetails` is exported for exactly that.
736
+ */
736
737
  readonly details?: unknown;
738
+ /** The HTTP status of the response that produced this error, if it came from one. */
737
739
  readonly status?: number;
740
+ /**
741
+ * Builds an error. `code` is what a caller branches on and `message` is
742
+ * for a human; anything else the refusal carried goes in `options`.
743
+ */
738
744
  constructor(code: FleetlessErrorCode, message: string, options?: FleetlessErrorOptions);
739
745
  }
740
746
 
747
+ /**
748
+ * The options every realtime command accepts — `actions.cancel` and
749
+ * `publishers.publish` take exactly these; `InvokeOptions` extends them for
750
+ * `actions.invoke` and `services.call`.
751
+ */
741
752
  interface SendCommandOptions {
742
753
  /** How long to wait for a `command_result` before rejecting `command_timeout`. Default 10s. */
743
754
  timeoutMs?: number;
744
755
  }
745
756
  /**
746
- * `invoke`-only: how long **the platform itself** should wait for this one
747
- * call before giving up on the robot (W6b) the whole wait for a service
748
- * call, goal *acceptance* only for an action (once accepted, a job runs as
749
- * long as it runs and is observed, not awaited).
750
- *
751
- * Optional; absent means the platform's own default (`DEFAULT_PATIENCE_MS`,
752
- * 15s) — exactly today's behaviour for a caller who names no preference.
753
- * Outside `[MIN_PATIENCE_MS, MAX_PATIENCE_MS]` the platform refuses with
754
- * `validation_error` rather than clamping, and this SDK does not clamp
755
- * locally or retry — surfacing the refusal is the whole of what it does
756
- * with this field. The floor exists because impatience reaches the robot,
757
- * not just the platform: a patience too short to survive a goal-acceptance
758
- * round trip made the bridge report `goal_timeout` and then issue a
759
- * *corrective cancel* against a goal an action server accepted a moment
760
- * later — a caller who names an unreachable deadline was causing a real
761
- * cancellation on the machine, repeatably, not just receiving an error.
762
- *
763
- * `timeoutMs` bounds how long *this SDK* waits locally for a reply on the
764
- * wire it already sent on; `patienceMs` travels to the platform and bounds
765
- * what *it* is willing to wait for from the robot. **They are not set
766
- * independently of each other, and D3a exists because the first version of
767
- * this doc comment said they were.** `timeoutMs` left unset is *derived*
768
- * from `patienceMs` (see `resolveLocalWaitMs`), not defaulted to a fixed
769
- * number that might be shorter — the SDK giving up locally before the
770
- * platform's own deadline would report `command_timeout` for a call the
771
- * platform never actually refused, which is exactly the two-clocks defect
772
- * this wave exists to remove, just relocated into this SDK instead of
773
- * between the cloud and the bridge. Setting both explicitly with
774
- * `timeoutMs < patienceMs` is refused with `invalid_option` before any
775
- * request is sent, for the same reason.
757
+ * What `actions.invoke` and `services.call` accept on top of
758
+ * `SendCommandOptions`: the two clocks a command runs under, one local to
759
+ * this SDK and one on the platform.
776
760
  */
777
761
  interface InvokeOptions extends SendCommandOptions {
762
+ /**
763
+ * How long **the platform itself** should wait for this one call before
764
+ * giving up on the robot — the whole wait for a service call, goal
765
+ * *acceptance* only for an action (once accepted, a job runs as long as it
766
+ * runs and is observed, not awaited).
767
+ *
768
+ * Optional; absent means the platform's own `DEFAULT_PATIENCE_MS`, 15s —
769
+ * exactly the behaviour of a caller who names no preference. Outside
770
+ * `MIN_PATIENCE_MS` to `MAX_PATIENCE_MS` (1s to 120s, both exported by
771
+ * `@fleetless/contracts`) the platform refuses with `validation_error`
772
+ * rather than clamping, and this SDK does not clamp locally or retry:
773
+ * surfacing the refusal is the whole of what it does with this field.
774
+ * The floor exists because
775
+ * impatience reaches the robot, not just the platform — a patience too
776
+ * short to survive a goal-acceptance round trip made the bridge report
777
+ * `goal_timeout` and then issue a *corrective cancel* against a goal an
778
+ * action server accepted a moment later, so a caller who names an
779
+ * unreachable deadline was causing a real cancellation on the machine,
780
+ * repeatably, not just receiving an error.
781
+ *
782
+ * `timeoutMs` bounds how long *this SDK* waits locally for a reply on the
783
+ * wire it already sent on; `patienceMs` travels to the platform and bounds
784
+ * what *it* is willing to wait for from the robot. **They are not set
785
+ * independently of each other.** `timeoutMs` left unset is *derived* from
786
+ * `patienceMs`, not defaulted to a fixed number that might be shorter —
787
+ * the SDK giving up locally before the platform's own deadline would
788
+ * report `command_timeout` for a call the platform never actually refused.
789
+ * Setting both explicitly with `timeoutMs < patienceMs` is refused with
790
+ * `invalid_option` before any request is sent, for the same reason.
791
+ */
778
792
  patienceMs?: number;
779
793
  }
780
794
 
795
+ /**
796
+ * The callbacks `actions.subscribe` reports through: one for every job
797
+ * update on the slug, one for a refusal of the subscription itself.
798
+ */
781
799
  interface JobSubscriptionHandlers {
782
800
  /** Called on every update pushed for the slug's current job — state, feedback, progress and result. */
783
801
  onJob(event: JobEvent): void;
784
- /** Called once if the subscription is refused (§11.5: e.g. `forbidden`, an unknown slug). */
802
+ /** Called once if the subscription is refused, e.g. `forbidden` or an unknown slug. */
785
803
  onError?(error: FleetlessError): void;
786
804
  }
805
+ /** A live job subscription, returned by `actions.subscribe`. */
787
806
  interface JobSubscription {
788
- /** Stops the subscription and, if the channel is currently connected, tells the server. */
807
+ /**
808
+ * Stops this subscription. The `unsubscribe` frame reaches the server only
809
+ * when this was the **last** holder of the robot and slug pair, and only if
810
+ * the channel is connected — subscriptions are reference-counted across
811
+ * kinds, so releasing this one while a second `actions.subscribe`, a
812
+ * `datapoints.subscribe` or an in-flight `services.call` still holds the
813
+ * same pair leaves that one's stream running untouched. Safe to call more
814
+ * than once.
815
+ */
789
816
  unsubscribe(): void;
790
817
  }
791
818
 
819
+ /**
820
+ * Long-running work on a robot, reachable as `client.actions`. An action is
821
+ * a ROS action the developer exposed under a slug: it is invoked, it runs
822
+ * for as long as it runs, and it reports back while it does.
823
+ */
792
824
  interface ActionsApi {
793
825
  /**
794
- * Invokes an action (spec §11.3). Resolves as soon as the job is created
826
+ * Invokes an action. Resolves as soon as the job is created
795
827
  * — the job id is informative, not the result. Feedback, progress and the
796
828
  * eventual result arrive separately over `subscribe`. A second invoke of
797
829
  * the same slug while one is already running is refused `busy`, with
798
830
  * `error.details.running` naming the job that is running.
799
831
  *
800
- * `options.patienceMs` bounds goal *acceptance* only (W6b) — once a goal
832
+ * `options.patienceMs` bounds goal *acceptance* only — once a goal
801
833
  * is accepted this call has already resolved; the job then runs as long
802
834
  * as it runs, observed via `subscribe`, never awaited. `options.timeoutMs`
803
835
  * (this SDK's own local wait for the acceptance reply) is derived from
804
836
  * `patienceMs` when left unset, and the combination `timeoutMs <
805
- * patienceMs` is refused with `invalid_option` rather than raced — see
806
- * `resolveLocalWaitMs` in `commands.ts`, D3a.
837
+ * patienceMs` is refused with `invalid_option` rather than raced.
807
838
  */
808
839
  invoke(robotId: string, slug: string, params: Record<string, unknown>, options?: InvokeOptions): Promise<Job>;
809
840
  /**
@@ -811,10 +842,9 @@ interface ActionsApi {
811
842
  * Resolves with the `Job` the cancel was actually sent to, or `null` if
812
843
  * nothing matched.
813
844
  *
814
- * **Two different requests, both legitimate (W6b):**
845
+ * **Two different requests, both legitimate:**
815
846
  * - `cancel(robotId, slug)` — no `jobId` — is the operator's stop button:
816
- * whatever is running on this slug, stop it. This is unchanged from
817
- * before W6b.
847
+ * whatever is running on this slug, stop it.
818
848
  * - `cancel(robotId, slug, jobId)` cancels **that** job specifically. If
819
849
  * it is not the one running, the platform answers `not_found` — this
820
850
  * never silently falls back to stopping whatever *is* running, because
@@ -830,7 +860,7 @@ interface ActionsApi {
830
860
  cancel(robotId: string, slug: string, jobId?: string | null, options?: SendCommandOptions): Promise<Job | null>;
831
861
  /**
832
862
  * Subscribes to the slug's job: state, feedback, progress and result, as
833
- * they happen. State is observed **by slug**, not by job id (§11.3) — this
863
+ * they happen. State is observed **by slug**, not by job id — this
834
864
  * is what makes late delivery after a reconnect and a second observer
835
865
  * watching the same job both work without special-casing either. Naming a
836
866
  * job to `cancel` does not change this: a slug is still a *place a job may
@@ -841,7 +871,9 @@ interface ActionsApi {
841
871
 
842
872
  /** An asset's bytes plus its declared media type — the shape `assets.get` answers with. */
843
873
  interface AssetBytes {
874
+ /** The asset's raw bytes, exactly as stored. */
844
875
  body: Uint8Array;
876
+ /** The declared media type, or `null` if the store did not record one. */
845
877
  mime: string | null;
846
878
  }
847
879
  /**
@@ -856,6 +888,7 @@ interface AssetBytes {
856
888
  * not only that one library.
857
889
  */
858
890
  type MeshLoaderDelegate = (path: string, manager: unknown, material: unknown, onComplete: (obj: unknown | null, err?: Error) => void) => void;
891
+ /** What `assets.createMeshLoader` accepts beyond the robot and the delegate. */
859
892
  interface CreateMeshLoaderOptions {
860
893
  /**
861
894
  * How long to wait for `delegate`'s `onComplete` before giving up.
@@ -867,12 +900,14 @@ interface CreateMeshLoaderOptions {
867
900
  timeoutMs?: number;
868
901
  }
869
902
  /**
870
- * three.js's own `LoadingManager.setURLModifier(callback)` shape (W7a, D2).
903
+ * three.js's own `LoadingManager.setURLModifier(callback)` shape the
904
+ * only thing `assets.prepareUrdfScene` needs from a three.js
905
+ * `LoadingManager`, so a caller passes theirs straight in.
871
906
  * Every load the manager oversees is routed through `callback` first — not
872
907
  * only the loader you handed the manager to, but every loader it constructs
873
908
  * internally on the same manager (`ColladaLoader`'s own `TextureLoader` for
874
909
  * a `.dae`'s `<init_from>` images, in particular). That is the one hook
875
- * that exists "one level up, for everything" (D2) where `createMeshLoader`'s
910
+ * that exists one level up, for everything, where `createMeshLoader`'s
876
911
  * per-loader `loadMeshCb` override does not reach: `TextureLoader` has no
877
912
  * override hook of its own.
878
913
  *
@@ -880,8 +915,10 @@ interface CreateMeshLoaderOptions {
880
915
  * or `urdf-loader`, same discipline as `MeshLoaderDelegate` above.
881
916
  */
882
917
  interface UrdfSceneManager {
918
+ /** Installs a callback every load through this manager is routed through first. */
883
919
  setURLModifier(callback: (url: string) => string): unknown;
884
920
  }
921
+ /** What `assets.prepareUrdfScene` accepts beyond the robot and the manager. */
885
922
  interface PrepareUrdfSceneOptions {
886
923
  /**
887
924
  * How many assets to fetch in parallel. Default 6 — a default that keeps
@@ -889,24 +926,23 @@ interface PrepareUrdfSceneOptions {
889
926
  * meshes), not a number with a sweep behind it. Pass your own if you have
890
927
  * a reason to.
891
928
  *
892
- * Must be a positive integer — `0` or negative throws `invalid_option`
893
- * rather than silently fetching nothing and returning a scene that
894
- * renders completely blank with no error to explain why.
929
+ * Must be a positive integer — `0` or negative rejects with
930
+ * `invalid_option` rather than silently fetching nothing and returning a
931
+ * scene that renders completely blank with no error to explain why.
895
932
  */
896
933
  concurrency?: number;
897
934
  /**
898
- * Cancels this call (register row 244, W7c) — a caller who navigates away
935
+ * Cancels this call — a caller who navigates away
899
936
  * or switches to a different robot mid-load can abort every in-flight
900
937
  * fetch this method has started, not merely stop it from starting new
901
938
  * ones. Checked before the first request; if it fires while a request is
902
- * already in progress, `HttpClient` forwards it straight to `fetch()`
903
- * (`http.ts`'s own `RequestOptions.signal`), so the connection itself is
904
- * torn down, not just abandoned by this SDK while it keeps running in the
905
- * background.
939
+ * already in progress, the SDK forwards it straight to `fetch()`, so the
940
+ * connection itself is torn down, not just abandoned by this SDK while it
941
+ * keeps running in the background.
906
942
  *
907
943
  * Every `blob:` URL already created before the abort is revoked before
908
944
  * this call rejects with `FleetlessError('aborted', ...)` — the same
909
- * guarantee a load that fails outright already had (D6): an aborted load
945
+ * guarantee a load that fails outright already had: an aborted load
910
946
  * must not leak what it had already fetched.
911
947
  *
912
948
  * `manager`'s URL modifier is only ever installed once every asset has
@@ -916,6 +952,10 @@ interface PrepareUrdfSceneOptions {
916
952
  */
917
953
  signal?: AbortSignal;
918
954
  }
955
+ /**
956
+ * What `assets.prepareUrdfScene` resolves with: the URDF text to parse, what
957
+ * the sync could not resolve, and the cleanup for everything it fetched.
958
+ */
919
959
  interface UrdfSceneResources {
920
960
  /**
921
961
  * The robot's URDF as raw text — `package://` URIs intact, not rewritten
@@ -926,24 +966,21 @@ interface UrdfSceneResources {
926
966
  urdfText: string;
927
967
  /**
928
968
  * The same entries `assets.list()`'s `urdf.missing` reports — verbatim,
929
- * not reduced to bare strings (W9b, DEF-081). Each carries `element`
969
+ * not reduced to bare strings. Each carries `element`
930
970
  * (`'mesh' | 'texture'`) alongside `uri`: before this, both kinds arrived
931
971
  * as an undifferentiated `string[]` and a caller could only ever say "N
932
- * meshes missing", wrongly, for a URDF whose gap was actually a texture
933
- * the same defect W9b fixed one layer up, in the console. Reducing this
934
- * field back to `string[]` here would throw the distinction away again at
972
+ * meshes missing", wrongly, for a URDF whose gap was actually a texture.
973
+ * Reducing this field back to `string[]` here would throw the distinction
974
+ * away again at
935
975
  * exactly the point a caller would render it. If you only need the URIs,
936
976
  * `missing.map(m => m.uri)`.
937
977
  *
938
- * **Top-level only — not a `.dae`'s internal references (Kassandra-W7a
939
- * review correction).** The cloud builds this list from the URDF text
978
+ * **Top-level only — not a `.dae`'s internal references.** The cloud
979
+ * builds this list from the URDF text
940
980
  * alone (`<mesh>`/`<texture>` `filename` attributes), which is the only
941
981
  * place it can see without parsing every `.dae` a sync touches; it never
942
- * has and never can include an internal `<init_from>` reference. An
943
- * earlier version of this comment claimed both wrong, and worth naming
944
- * as a correction rather than silently widening the sentence, since a
945
- * caller who trusted "both" to mean both would build a completeness check
946
- * against a list that structurally cannot report the second half. A
982
+ * has and never can include an internal `<init_from>` reference. Do not
983
+ * build a completeness check on it as though it covered both: a
947
984
  * `.dae`-internal reference the sync could not resolve surfaces through
948
985
  * the sync's own failure reporting instead, not here.
949
986
  *
@@ -955,44 +992,53 @@ interface UrdfSceneResources {
955
992
  */
956
993
  missing: UrdfCompleteness['missing'];
957
994
  /**
958
- * Revokes every `blob:` URL this call created. Call once the scene has
959
- * finished loading (success or failure) or on unmount — safe to call more
960
- * than once.
995
+ * Revokes every object URL this call created that carries bytes. Call
996
+ * once the scene has finished loading (success or failure) or on unmount
997
+ * — safe to call more than once.
998
+ *
999
+ * One object URL is deliberately kept: the shared zero-byte placeholder
1000
+ * every refused reference resolves to. It costs nothing to leave alive,
1001
+ * and leaving it is what lets this method stay simple — the installed URL
1002
+ * modifier goes on refusing an owned-but-gone reference correctly, rather
1003
+ * than falling back to the original string once the map is empty.
961
1004
  *
962
- * **Does not touch `manager`'s URL modifier (D7, Kassandra-W7a review).**
963
- * An earlier version reset it to the identity function here, which
964
- * silently reopened D4 the moment the same manager was used again — for a
965
- * second robot, or for anything else — before a later `prepareUrdfScene`
966
- * call happened to overwrite it. The installed modifier is left running,
1005
+ * **Does not touch `manager`'s URL modifier.** Resetting it to the
1006
+ * identity function here would reopen the very hole the modifier exists to
1007
+ * close, the moment the same manager was used again — for a second robot,
1008
+ * or for anything else — until a later `prepareUrdfScene` call happened to
1009
+ * overwrite it. The installed modifier is left running,
967
1010
  * and with this call's map now empty it already refuses anything it would
968
1011
  * have owned and passes through anything it would not have, correctly,
969
1012
  * on its own.
970
1013
  */
971
1014
  dispose(): void;
972
1015
  }
1016
+ /**
1017
+ * A robot's synced files, reachable as `client.assets`: its URDF, the meshes
1018
+ * and textures that URDF references, and the glue a three.js renderer needs
1019
+ * to fetch them with this client's credentials.
1020
+ */
973
1021
  interface AssetsApi {
974
1022
  /**
975
- * Every asset a robot has, plus whether its URDF is complete (spec §4.6).
1023
+ * Every asset a robot has, plus whether its URDF is complete.
976
1024
  * `urdf.missing` names the `package://` URIs the sync could not resolve —
977
- * the number alone ("2 Meshes fehlen") sends a developer looking through a
1025
+ * a count alone ("2 meshes missing") sends a developer looking through a
978
1026
  * workspace by hand, the URIs are what they can act on.
979
1027
  */
980
1028
  list(robotId: string): Promise<AssetListResponse>;
981
1029
  /**
982
1030
  * The status of one sync by id — for **reconnecting** to a sync already in
983
- * flight, not for starting one (W9b, DEF-147).
1031
+ * flight, not for starting one.
984
1032
  *
985
- * **Starting a sync stays out of this SDK, on purpose (spec §4.6):**
986
- * "übertragen wird ausschließlich auf explizite Anforderung des
987
- * Entwicklers über die Console" a developer action, Owner-tier, done
988
- * once, from the console. This method is a different thing: `list()`'s
1033
+ * **Starting a sync stays out of this SDK, on purpose.** Assets are
1034
+ * transferred only on a developer's explicit request from the console —
1035
+ * an Owner-tier action, done once. This method is a different thing:
1036
+ * `list()`'s
989
1037
  * `active_sync` (or a `busy` refusal's `assetSyncBusyDetails`) hands a
990
1038
  * caller a `sync_id` for a sync that is **already running**, and before
991
1039
  * this method existed there was no way for anything built on this SDK to
992
- * do anything with that id except throw it away. That was the actual
993
- * shape of DEF-147: the id was recoverable server-side the whole time
994
- * (`GET .../assets/sync/{id}` has existed since W7) — nothing reachable
995
- * from this SDK ever called it.
1040
+ * do anything with that id except throw it away, even though the sync was
1041
+ * readable server-side the whole time.
996
1042
  *
997
1043
  * A page reload is the case this exists for: whatever held the `sync_id`
998
1044
  * in memory is gone, `list()` (or a fresh `busy` refusal) hands it back,
@@ -1010,11 +1056,11 @@ interface AssetsApi {
1010
1056
  */
1011
1057
  urdf(robotId: string): Promise<string>;
1012
1058
  /**
1013
- * The mesh callback for `urdf-loader` (spec §4.6): an `<img>` tag and the
1014
- * default three.js loaders cannot set an `Authorization` header, and the
1015
- * platform deliberately has no signed URLs and no token in the query
1016
- * string (see `@fleetless/contracts` `assets.ts`), so every app would
1017
- * otherwise write this glue itself, and each one differently.
1059
+ * The mesh callback for `urdf-loader`: an `<img>` tag and the default
1060
+ * three.js loaders cannot set an `Authorization` header, and the platform
1061
+ * deliberately has no signed URLs and no token in the query string, so
1062
+ * every app would otherwise write this glue itself, and each one
1063
+ * differently.
1018
1064
  *
1019
1065
  * Returns a function with `loadMeshCb`'s own signature — assign it
1020
1066
  * directly:
@@ -1043,7 +1089,7 @@ interface AssetsApi {
1043
1089
  createMeshLoader(robotId: string, delegate: MeshLoaderDelegate, options?: CreateMeshLoaderOptions): MeshLoaderDelegate;
1044
1090
  /**
1045
1091
  * Authenticated loading for everything three.js fetches to render a
1046
- * textured robot — not only meshes (spec §4.6, W7a, D2). Installs
1092
+ * textured robot — not only meshes. Installs
1047
1093
  * `manager.setURLModifier` so **every** load `manager` oversees resolves
1048
1094
  * to a pre-fetched `blob:` URL: a top-level `<mesh>`, a `<material>`'s
1049
1095
  * `<texture>`, and an image a `.dae` references internally via
@@ -1065,9 +1111,8 @@ interface AssetsApi {
1065
1111
  * **Do not also install `createMeshLoader` on the same manager.** The two
1066
1112
  * consume different URDF sources — this method fetches the URDF's *raw*
1067
1113
  * bytes, `createMeshLoader` is meant to pair with `urdf()`'s
1068
- * cloud-rewritten text. **Not "double-fetches every mesh" a first
1069
- * version of this comment said that, and Momus-W7a's review traced it
1070
- * and found it wrong.** What actually happens is asymmetric breakage,
1114
+ * cloud-rewritten text. **It is not that every mesh gets fetched twice.**
1115
+ * What actually happens is asymmetric breakage,
1071
1116
  * whichever URDF text the combination ends up parsing: paired with
1072
1117
  * *this* method's raw text, `createMeshLoader` receives urdf-loader's
1073
1118
  * `resolvePath()` output (`/pkg/rel`) rather than an absolute Fleetless
@@ -1079,12 +1124,11 @@ interface AssetsApi {
1079
1124
  * debug the wrong symptom, which is worse than the original (already
1080
1125
  * wrong) warning being merely unhelpful.
1081
1126
  *
1082
- * **Enforced, not only documented (Momus-W7a review, via the team
1083
- * lead).** `createMeshLoader`'s returned callback checks whether the
1084
- * `manager` it is handed already has this method's URL modifier
1085
- * installed and fails loudly via `onComplete(null, err)` before ever
1086
- * touching the network, rather than relying on a developer having read
1087
- * this paragraph. See `managersWithPreparedUrdfScene`.
1127
+ * **Enforced, not only documented.** `createMeshLoader`'s returned
1128
+ * callback checks whether the `manager` it is handed already has this
1129
+ * method's URL modifier installed and fails loudly via
1130
+ * `onComplete(null, err)` before ever touching the network, rather than
1131
+ * relying on a developer having read this paragraph.
1088
1132
  *
1089
1133
  * **Why raw bytes, not `urdf()`'s rewritten text.** Both `urdf-loader`'s
1090
1134
  * default mesh loading and `ColladaLoader` compute the base path they use
@@ -1099,9 +1143,8 @@ interface AssetsApi {
1099
1143
  * in sync.
1100
1144
  *
1101
1145
  * **`urdf-loader` resolves `package://` itself, before any of this runs —
1102
- * a second resolution stage this method has to account for, found by
1103
- * Threepio-W7a running the recipe demo in a real browser rather than
1104
- * reading the source.** `URDFLoader.parse()`'s own `resolvePath()`
1146
+ * a second resolution stage this method has to account for, measured in a
1147
+ * real browser rather than read off the source.** `URDFLoader.parse()`'s own `resolvePath()`
1105
1148
  * rewrites `package://pkg/rel` using `this.packages` (default `''`) to
1106
1149
  * `/pkg/rel` — a root-relative URL — and *that* is what reaches
1107
1150
  * `loadMeshCb`/`ColladaLoader`/`manager.resolveURL()`, not the original
@@ -1121,29 +1164,29 @@ interface AssetsApi {
1121
1164
  * outside what this method can predict — see the ownership rule below
1122
1165
  * for what happens to that reference.
1123
1166
  *
1124
- * **This method only claims what it owns (D4, revised after Argus-W7a's
1125
- * review) — not every unmapped reference.** `manager` is frequently the
1167
+ * **This method only claims what it owns not every unmapped
1168
+ * reference.** `manager` is frequently the
1126
1169
  * caller's own scene-wide `LoadingManager`, shared for an HDRI, an
1127
1170
  * environment map, a font atlas, a ground texture — none of which have
1128
- * anything to do with this robot. A first version refused everything
1129
- * unmapped, which silently emptied every one of those the moment a
1130
- * caller shared their manager. So the rule is narrower: a `package://`
1171
+ * anything to do with this robot. Refusing everything unmapped would
1172
+ * silently empty every one of those the moment a caller shares their
1173
+ * manager. So the rule is narrower: a `package://`
1131
1174
  * reference, or a root-relative path whose leading segment names a ROS
1132
1175
  * package this robot's assets (or `missing`) actually mention, is this
1133
1176
  * method's to resolve or refuse; an unmapped one falls back to the
1134
- * normalized form (D5, below) and then to a shared, inert, page-local
1177
+ * normalized form (below) and then to a shared, inert, page-local
1135
1178
  * `blob:` URL — never the original string, so a hostile URDF naming an
1136
1179
  * unsynced or off-namespace reference still cannot make three.js touch
1137
1180
  * the network for it. Anything else — not in that namespace — is left
1138
1181
  * completely alone, **except** an absolute `http(s)` URL, which is
1139
1182
  * refused regardless of namespace: the one case this method cannot leave
1140
1183
  * ambiguous, because a hostile URDF naming an attacker's host directly
1141
- * (bypassing `package://` entirely) is exactly what D4 exists to close,
1142
- * and three.js would otherwise fetch it for real, off-origin, the moment
1184
+ * (bypassing `package://` entirely) is exactly what this rule exists to
1185
+ * close, and three.js would otherwise fetch it for real, off-origin, the moment
1143
1186
  * the direct and namespace checks both miss.
1144
1187
  *
1145
1188
  * **A `.dae`'s own internal reference gets a second-chance, normalized
1146
- * lookup (D5, Momus-W7a review, reproduced in a real browser).** three.js
1189
+ * lookup, reproduced in a real browser.** three.js
1147
1190
  * builds the request for one by plain string concatenation — no `..`/`.`
1148
1191
  * collapsing — while `asset.name` carries the *normalized* tail
1149
1192
  * (`@fleetless/contracts`' naming rule). So `../textures/skin.png` or
@@ -1154,17 +1197,17 @@ interface AssetsApi {
1154
1197
  * verbatim and `resolvePath()` rewrites it by the same unnormalized
1155
1198
  * concatenation on both sides, so the direct key already matches.
1156
1199
  *
1157
- * **`dispose()` does not undo any of this (D7, Kassandra-W7a review).**
1158
- * See its own doc comment on `UrdfSceneResources`.
1200
+ * **`dispose()` does not undo any of this.** See its own doc comment on
1201
+ * `UrdfSceneResources`.
1159
1202
  *
1160
1203
  * **Pre-fetch is unavoidable**, not merely a choice: a URL modifier
1161
1204
  * cannot be asynchronous, so every asset it might be asked for has to
1162
1205
  * already be a `blob:` URL before `URDFLoader.parse` runs. Bounded by
1163
1206
  * `options.concurrency` (default 6) and scoped to only `kind: 'mesh'` and
1164
- * `kind: 'texture'` assets — which is already "what the URDF references"
1165
- * (`@fleetless/contracts`' `rest.ts`: a re-sync reconciles, so assets the
1166
- * current URDF no longer references stop belonging to the robot), not an
1167
- * unbounded fetch of everything the robot has ever had.
1207
+ * `kind: 'texture'` assets — which is already "what the URDF references",
1208
+ * since a re-sync reconciles and assets the current URDF no longer
1209
+ * references stop belonging to the robot. Not an unbounded fetch of
1210
+ * everything the robot has ever had.
1168
1211
  */
1169
1212
  prepareUrdfScene(robotId: string, manager: UrdfSceneManager, options?: PrepareUrdfSceneOptions): Promise<UrdfSceneResources>;
1170
1213
  }
@@ -1182,356 +1225,380 @@ type StoredSession = SessionTokens;
1182
1225
  * assumes a browser, or any storage, exists.
1183
1226
  */
1184
1227
  interface TokenStore {
1228
+ /**
1229
+ * Returns the stored session, or `null` when nobody is logged in. May be
1230
+ * async, so a store backed by a native keystore or an IndexedDB read
1231
+ * works without a synchronous cache in front of it.
1232
+ */
1185
1233
  load(): StoredSession | null | Promise<StoredSession | null>;
1234
+ /**
1235
+ * Writes the session, or clears it when passed `null`. Called after a
1236
+ * login, after every silent refresh, and on logout — so an implementation
1237
+ * that persists must expect to be called often, not once.
1238
+ */
1186
1239
  save(session: StoredSession | null): void | Promise<void>;
1187
1240
  }
1188
1241
  /** The default store: works out of the box, forgets the session on reload. */
1189
1242
  declare class InMemoryTokenStore implements TokenStore {
1190
1243
  #private;
1244
+ /** Creates an empty store. Nothing is loaded from anywhere — a client built with it starts logged out. */
1245
+ constructor();
1246
+ /** Returns the session held in memory, or `null` if there is none. */
1191
1247
  load(): StoredSession | null;
1248
+ /** Replaces the session held in memory; `null` clears it. */
1192
1249
  save(session: StoredSession | null): void;
1193
1250
  }
1194
1251
 
1195
- /** `beginHostedLogin()`'s input (spec §3.4, §17, W7b). */
1196
- interface BeginHostedLoginOptions {
1252
+ /**
1253
+ * `register()`'s input. The app identifier is **not** here: the client already
1254
+ * holds one (`createClient({ appIdentifier })`) and sends it itself, so there
1255
+ * is no way for a caller to register somebody into a different app than the
1256
+ * one this client speaks for.
1257
+ */
1258
+ interface RegisterOptions {
1259
+ /** The address the verification mail goes to. Nothing works until that link is spent. */
1260
+ email: string;
1261
+ /** At least 12 characters — `clientRegisterRequest` refuses less with a `validation_error`. */
1262
+ password: string;
1197
1263
  /**
1198
- * The opaque `client_id` issued when the developer registered this app's
1199
- * OAuth client (console, App Settings). **Never `appIdentifier`**they
1200
- * are deliberately different identifiers (`contracts/src/oauth.ts`,
1201
- * `oauthClient`'s doc comment: "the `client_id` on the wire — opaque, and
1202
- * not the app identifier").
1264
+ * What the app should call this person. Optional, and **omitted from the
1265
+ * request entirely** when you do not pass it `clientRegisterRequest` is a
1266
+ * strict schema, so a key carrying `undefined` would be a `422` rather than a
1267
+ * default.
1203
1268
  */
1204
- clientId: string;
1269
+ displayName?: string;
1270
+ }
1271
+ /** `acceptInvitation()`'s input — the token out of the mailed link, plus the password the account gets. */
1272
+ interface AcceptInvitationOptions {
1273
+ /** The `token` from the invitation link the developer's app was linked to. */
1274
+ token: string;
1275
+ /** At least 12 characters. The invitation fixes the role; this call fixes the credential. */
1276
+ password: string;
1277
+ /** Optional, and omitted from the request entirely when absent — same strict-schema reason as `RegisterOptions.displayName`. */
1278
+ displayName?: string;
1279
+ }
1280
+ /** One sign-in button on the app's own login screen, as `listProviders()` lists it. */
1281
+ interface ProviderButton {
1282
+ /** What `beginOidcLogin` addresses this provider by. */
1283
+ slug: string;
1284
+ /** The label the developer configured, to be rendered on the button. */
1285
+ name: string;
1286
+ }
1287
+ /** `beginOidcLogin()`'s input: which provider, and where Fleetless should send the browser back to. */
1288
+ interface BeginOidcLoginOptions {
1289
+ /** A `slug` from `listProviders()`. An unknown one is a `404` when the browser reaches the start route, not here. */
1290
+ slug: string;
1205
1291
  /**
1206
- * Must be registered, byte-for-byte, as one of that client's
1207
- * `redirect_uris` matching at the server is exact-string, never a
1208
- * prefix (`contracts/src/oauth.ts`, `redirectUri`'s doc comment).
1292
+ * Where the browser comes back to with `?code=…&state=…` (or `?error=…`).
1293
+ * Checked against the app's **allowed origins** server-side, by origin — so
1294
+ * the path is yours to choose and the origin is not.
1295
+ *
1296
+ * **Two shapes are refused outright, before the origin is compared**, and
1297
+ * neither refusal mentions them: a URL carrying a **fragment**
1298
+ * (`https://app.example.com/#/auth/callback`) and one carrying **userinfo**
1299
+ * (`https://someone@app.example.com/cb`). Both come back as a flat
1300
+ * `400 invalid_redirect_uri` reading "The redirect_uri is not an origin this
1301
+ * app answers for", which sends people to re-check an allow-list that was
1302
+ * never the problem.
1303
+ *
1304
+ * The fragment case is the one that costs time, because hash routing is the
1305
+ * default for a static-hosted SPA with no server rewrite. Give the callback
1306
+ * a real path (`/auth/callback`) and let your router pick the hash route up
1307
+ * from there; a fragment is a browser-side construct the redirect could not
1308
+ * carry a code in anyway.
1209
1309
  */
1210
1310
  redirectUri: string;
1211
- scope?: string;
1311
+ }
1312
+ /** What `beginOidcLogin()` returns. Nothing here has touched the network. */
1313
+ interface OidcLoginRequest {
1212
1314
  /**
1213
- * RFC 8707 audience binding (register row, W7b): the resource this
1214
- * session's token should be usable against. **Omit it for an ordinary app
1215
- * login.** A token with no `resource` carries no `aud` and works
1216
- * unrestricted against this app's own REST surface exactly as it always
1217
- * has; that path is unaffected by this field's existence. Only a caller
1218
- * that is itself going to present the token to an audience-checking
1219
- * resource needs to ask for one.
1220
- *
1221
- * **`/oauth/authorize` accepts exactly two shapes** — anything else is
1222
- * refused with `invalid_target` on the redirect back, before a code is
1223
- * ever issued (`cloud/src/routes/oauth.ts`, `isKnownResource`):
1224
- *
1225
- * - `<base>/mcp-stub/resource` — the global OAuth resource stub, matched
1226
- * byte-for-byte.
1227
- * - `<base>/mcp-stub/resource/<app_identifier>` — the per-app stub, and
1228
- * only for **the calling client's own app**. This is an existence check
1229
- * plus an ownership check, not a shape check: the app must exist *and*
1230
- * be the one this `clientId` is registered to, so a client on app A can
1231
- * never be minted a token whose `aud` names app B.
1232
- *
1233
- * Both are served and validated by
1234
- * `cloud/src/routes/oauth-resource-stub.ts`, whose validator refuses a
1235
- * token with **no** `aud` exactly as hard as one with the wrong `aud` —
1236
- * "unscoped" must never read as "for me" — comparing by equality, never
1237
- * by prefix.
1238
- *
1239
- * **`<base>/mcp/<app_identifier>` is not a resource any more.** There is
1240
- * no per-app MCP endpoint: the cloud registers one central, non-parametric
1241
- * `POST /mcp` (`cloud/src/routes/mcp.ts`, contracts'
1242
- * `MCP_ENDPOINT_PATH`), and the `/oauth/authorize` branch that used to
1243
- * accept a `/mcp/<app>` resource was deleted along with the app-level
1244
- * `mcp_enabled` flag. Asking for one now yields `invalid_target` for
1245
- * every app, **including your own**. The central MCP endpoint has its own
1246
- * OAuth flow (`cloud/src/routes/mcp-oauth.ts`) which this SDK's hosted
1247
- * login does not drive — `beginHostedLogin` always targets
1248
- * `OAUTH_PATHS.authorize`.
1249
- *
1250
- * Whatever you name here is re-checked at the token exchange: it must
1251
- * match what the code was authorized for, and it must still name a
1252
- * resource this client may be issued a token for.
1315
+ * Send the end user's browser here. **This SDK does not navigate** — it has
1316
+ * no opinion about whether that is a full page load, a popup or a native web
1317
+ * view, the same boundary `cameras.live` draws by handing back a URL and a
1318
+ * token and stopping there.
1253
1319
  */
1254
- resource?: string;
1255
- }
1256
- /** What `beginHostedLogin()` returns — nothing here has touched the network yet. */
1257
- interface HostedLoginRequest {
1258
- /** Send the end user's browser here to start the hosted login page. */
1259
1320
  url: string;
1260
1321
  /**
1261
- * Persist this alongside `codeVerifier` before navigating away, and pass
1262
- * both back into `completeHostedLogin`. **This SDK does not persist them
1263
- * for you.** The redirect back to `redirectUri` is a fresh page load for a
1264
- * browser app — nothing kept in this SDK's own memory survives it (the
1265
- * same reasoning `client.ts` states for `TokenStore`: "the SDK itself
1266
- * never assumes a browser, or any storage, exists"). An in-memory default
1267
- * here would not be merely suboptimal, it would be broken for the primary
1268
- * use case while looking like it worked for anything that never actually
1269
- * navigates away. `sessionStorage`, a signed cookie, or a plain variable
1270
- * (a popup flow that never truly navigates) are all valid — that choice is
1271
- * the caller's.
1322
+ * Persist this next to `codeVerifier` **before navigating away**, and pass
1323
+ * both back into `completeOidcLogin`. The SDK does not persist them for you:
1324
+ * the redirect back is a fresh page load for a browser app, and nothing kept
1325
+ * in this SDK's memory survives it. `sessionStorage`, a signed cookie or a
1326
+ * plain variable (a popup flow that never truly navigates) are all valid —
1327
+ * that choice is the caller's, and an in-memory default here would look like
1328
+ * it worked right up until the first real redirect.
1272
1329
  */
1273
1330
  state: string;
1331
+ /**
1332
+ * The PKCE code verifier for this attempt — persist it exactly as `state`.
1333
+ * **The app runs its own PKCE against Fleetless**, a second exchange
1334
+ * independent of the one Fleetless runs against the identity provider, which
1335
+ * is what makes the one-time code in the redirect worth nothing to whoever
1336
+ * else reads that URL.
1337
+ */
1274
1338
  codeVerifier: string;
1275
1339
  }
1276
- /** `completeHostedLogin()`'s input — the redirect back, plus what `beginHostedLogin` returned for this same attempt. */
1277
- interface CompleteHostedLoginOptions {
1278
- /** The `code` query parameter from the redirect back to `redirectUri`. */
1340
+ /** `completeOidcLogin()`'s input — the redirect back, plus what `beginOidcLogin` returned for this same attempt. */
1341
+ interface CompleteOidcLoginOptions {
1342
+ /** The `code` query parameter from the redirect back to `redirectUri`. It lives 60 seconds. */
1279
1343
  code: string;
1280
1344
  /** The `state` query parameter from that same redirect. */
1281
1345
  state: string;
1282
- /**
1283
- * The `state` this attempt's `beginHostedLogin` returned. Checked against
1284
- * `state` above **before any network call** — RFC 6749 §10.12's whole
1285
- * point is that a caller must not complete an authorization response it
1286
- * did not itself request.
1287
- */
1346
+ /** The `state` this attempt's `beginOidcLogin` returned. Compared **before any network call**. */
1288
1347
  expectedState: string;
1289
- /** The `codeVerifier` this attempt's `beginHostedLogin` returned. */
1348
+ /** The `codeVerifier` this attempt's `beginOidcLogin` returned. */
1290
1349
  codeVerifier: string;
1291
- clientId: string;
1292
- /** Must be the exact same string passed to `beginHostedLogin`. */
1293
- redirectUri: string;
1294
- /**
1295
- * Must be the exact same string passed to `beginHostedLogin`, if any.
1296
- * Resending it here is not what binds the audience — the server already
1297
- * bound `resource` to the authorization code at `/oauth/authorize` and
1298
- * mints `aud` from that stored value regardless of what this call sends —
1299
- * but RFC 8707 §2 expects a client to name the resource at both steps,
1300
- * and the cloud rejects a *mismatched* resend outright (`invalid_target`).
1301
- * Omit it here exactly when it was omitted at `beginHostedLogin`.
1302
- */
1303
- resource?: string;
1304
1350
  }
1305
1351
  /**
1306
- * What `logout()` resolves with (W9c, DEF-098) — five separable facts, not
1307
- * one nullable URL, mirroring the wire's `clientLogoutResponse`.
1352
+ * What `approveMcpInteraction`/`denyMcpInteraction` resolve with: **where to
1353
+ * send the browser**, and nothing else.
1308
1354
  *
1309
- * `idp_logout` is `null` exactly when `revoked` is `false`: the server call
1310
- * that would have produced it never got a chance to answer (no local
1311
- * session, or the request itself failed), so there is nothing to report
1312
- * not even "not federated", because this client never learned that either.
1313
- * When `revoked` is `true` and a real response came back, `idp_logout` is
1314
- * one of:
1315
- *
1316
- * - `{ status: 'redirect', url }` — send the browser here to end the
1317
- * session at the IdP too. Nothing else in this SDK does that navigation
1318
- * for you (same "this SDK is thin" reasoning as `cameras.live`).
1319
- * - `{ status: 'not_federated' }` — this session never came from an IdP;
1320
- * there is nothing else to end.
1321
- * - `{ status: 'unsupported_by_idp' }` — it did, and the IdP publishes no
1322
- * `end_session_endpoint` (RP-initiated logout is optional in OIDC).
1323
- * - `{ status: 'hint_unavailable' }` — it did, the IdP *can* end the
1324
- * session, and Fleetless has nothing to ask it with: the stored
1325
- * `id_token_hint` could not be decrypted, or the session predates the fix
1326
- * that started keeping one.
1327
- * - `{ status: 'session_unknown' }` — the server did not find this session at
1328
- * all (the token was unknown, already superseded, revoked, or expired), so
1329
- * it can say **nothing** about an IdP.
1355
+ * A denial carries a redirect too, with `error=access_denied` on it a client
1356
+ * that is refused has to learn so from its own callback rather than from a page
1357
+ * nobody sent it, so both outcomes end the same way for the app: navigate here.
1358
+ */
1359
+ interface McpInteractionDecision {
1360
+ /** The absolute URL to navigate to. Wire field `redirect_to`. */
1361
+ redirectTo: string;
1362
+ }
1363
+ /**
1364
+ * Who the caller is, reachable as `client.auth` — the whole client auth API of
1365
+ * spec §4, as JSON.
1330
1366
  *
1331
- * **`unsupported_by_idp` and `hint_unavailable` both mean the IdP session
1332
- * survives and this platform cannot end it** — do not render either one the
1333
- * same as `not_federated`; that reports a session as fully ended when it
1334
- * isn't.
1367
+ * **Fleetless serves an app user no page.** The developer's own UI owns every
1368
+ * screen: login, registration, verification, invitation acceptance, password
1369
+ * reset, the provider buttons and the MCP consent. These methods are what those
1370
+ * screens call. The hosted, app-branded login and consent pages this SDK used
1371
+ * to drive are gone, along with `beginHostedLogin`/`completeHostedLogin`.
1335
1372
  *
1336
- * **`session_unknown` is a different kind of nothing, and the reason it
1337
- * exists is a second logout** (W9 review). A double click, a repeated POST, an
1338
- * app that logs out on unmount *and* on a route change: the last call wins,
1339
- * and before this outcome existed it answered `not_federated` a positive
1340
- * claim about an IdP the server had never looked up. An app that treats it as
1341
- * *"fully logged out"* skips a redirect the **first** call may well have
1342
- * returned, and the user stays signed in at the IdP after clicking log out.
1373
+ * **The enumeration discipline is the design's, and it shapes this surface.**
1374
+ * `register`, `resendVerification` and `requestPasswordReset` resolve for every
1375
+ * policy-allowed request whether or not the address exists, and `login` answers
1376
+ * the identical `invalid_credentials` for a wrong password, a blocked account
1377
+ * and an unverified one. So: *resolving does not mean an account exists*, and
1378
+ * the only honest refusals are the ones about policy rather than about a
1379
+ * person `registration_closed`, `domain_not_allowed`, `quota_exceeded`.
1343
1380
  *
1344
- * Note that `revoked` cannot help you here: it reports whether the HTTP call
1345
- * succeeded, not whether a session was found so this case arrives as
1346
- * `{ revoked: true, idp_logout: { status: 'session_unknown' } }`, never as the
1347
- * `null` below.
1381
+ * **A client built with a `serverKey` refuses everything that needs an app
1382
+ * user's own session** with `invalid_option`, before any request. `me()`,
1383
+ * `listProviders()`, `mcpInteraction()` and `oidcErrorFromCallback()` still
1384
+ * work on one: the first is what a server key is *for*, the next two are public
1385
+ * reads the cloud answers without any credential at all, and the last touches
1386
+ * no network.
1348
1387
  */
1349
- interface LogoutResult {
1350
- revoked: boolean;
1351
- idp_logout: ClientLogoutResponse['idp_logout'] | null;
1352
- }
1353
1388
  interface AuthApi {
1354
- /** Exchanges email + password, and the client's configured app identifier, for a session. */
1355
- login(email: string, password: string): Promise<void>;
1356
1389
  /**
1357
- * Starts the hosted login flow (spec §3.4, §17, W7b): a Fleetless-served
1358
- * login page an app's end user is redirected to, with optional
1359
- * per-app IdP federation. Builds the `/oauth/authorize` URL (Authorization
1360
- * Code + PKCE, S256 only — OAuth 2.1 removes `plain`) and generates the
1361
- * `state`/`codeVerifier` PKCE and CSRF protection need. **Makes no network
1362
- * call** everything here is local, so nothing about the app, the
1363
- * client, or the redirect URI is validated until the browser actually
1364
- * reaches `/oauth/authorize`.
1365
- *
1366
- * Async only because computing the S256 `code_challenge` needs
1367
- * `crypto.subtle.digest`, which the Web Crypto API only ever offers as a
1368
- * promise there is no synchronous digest to call instead.
1390
+ * Self-registration. Writes the account as `pending_verification` and mails
1391
+ * the app's verification link; **the account cannot log in until that link is
1392
+ * spent** (`verifyEmail`).
1393
+ *
1394
+ * Resolves on the route's `202` which the cloud answers for every
1395
+ * policy-allowed request, whether the address was new or already known. It is
1396
+ * not a claim that an account was created, and an app that renders it as one
1397
+ * ("welcome, Ada!") is showing a stranger the enumeration oracle this whole
1398
+ * family is built to avoid. Render "check your mail" instead.
1399
+ *
1400
+ * Throws a `FleetlessError` carrying the cloud's own code for a refusal, and
1401
+ * that is the distinction this method exists to preserve: `registration_closed`
1402
+ * (the app has self-registration off), `domain_not_allowed` (the address is
1403
+ * outside the app's allowed domains), `quota_exceeded` (the org has as many
1404
+ * app users as its quota allows) and `not_found` (no app carries this
1405
+ * client's `appIdentifier`) are all things the app can say out loud, because
1406
+ * none of them is about whether a person exists.
1407
+ */
1408
+ register(input: RegisterOptions): Promise<void>;
1409
+ /**
1410
+ * Spends a verification token and **stores the session it answers with**, so
1411
+ * the person is not asked to log in immediately after proving they can read
1412
+ * the mail.
1413
+ *
1414
+ * `token_spent` covers unknown, expired and already-used alike — one code,
1415
+ * because the remedy is one thing: ask for a fresh link with
1416
+ * `resendVerification`. An app rendering this refusal should offer that.
1369
1417
  */
1370
- beginHostedLogin(options: BeginHostedLoginOptions): Promise<HostedLoginRequest>;
1418
+ verifyEmail(token: string): Promise<void>;
1419
+ /** Asks for the verification mail again. Resolves on `202` for every policy-allowed request, existing address or not — same reason as `register`. */
1420
+ resendVerification(email: string): Promise<void>;
1371
1421
  /**
1372
- * Completes the hosted login flow: checks `state` against `expectedState`
1373
- * (before any network call — see `CompleteHostedLoginOptions.expectedState`),
1374
- * exchanges `code` for tokens at `/oauth/token`, and stores them via the
1375
- * same `tokenStore` `login()` uses.
1376
- *
1377
- * **Two distinct refusals before that check, not one.** An empty
1378
- * `expectedState` throws `no_hosted_login_attempt` — nothing was
1379
- * persisted for this attempt (a different tab, a restored session,
1380
- * cleared storage), not necessarily an attack. Only once `expectedState`
1381
- * is actually present does a mismatch (or a missing `state` on the
1382
- * callback itself) throw `state_mismatch`. The two are told apart on
1383
- * purpose: they call for different remedies, and collapsing them would
1384
- * tell a developer debugging an ordinary storage gap that their app is
1385
- * under attack.
1386
- *
1387
- * Once past that check and the exchange completes, `me()`, `logout()`,
1388
- * `changePassword()` and silent refresh all behave identically afterwards,
1389
- * regardless of which flow the session started from. That is the actual
1390
- * content of §3.4's *"Beide Wege enden im selben Fleetless-Token"*: not
1391
- * merely that the bytes match, but that every existing code path treats
1392
- * the result the same way.
1393
- *
1394
- * The wire response is RFC 6749 §5.1's envelope (`token_type`, optional
1395
- * `scope`), not `sessionTokens` — this method normalizes one into the
1396
- * other before storing. **Refresh needs no separate handling**: the cloud
1397
- * mints these tokens through the same session mechanism `/api/client/login`
1398
- * uses (same `refresh_tokens` row, same rotation), so the existing silent
1399
- * refresh (`/api/client/refresh`) already works for a hosted-login
1400
- * session — nothing about the origin of a session is tracked or needs to
1401
- * be.
1402
- *
1403
- * Throws with the OAuth error code as `.code` (e.g. `invalid_grant` for an
1404
- * expired or already-used `code`) if the exchange itself fails — a
1405
- * different vocabulary from every other method on this interface, because
1406
- * `/oauth/token` answers in RFC 6749 §5.2's shape, not `apiError`.
1407
- *
1408
- * **Makes exactly one request to `/oauth/token` — never retried, no
1409
- * timeout-and-resend, no internal concurrency of its own.** Stated
1410
- * because the constraint that matters here is not this method's, it is
1411
- * the caller's: **never call this a second time for the same `code`
1412
- * while a first call is still in flight** (a plain "the first attempt
1413
- * looked like it timed out, so retry" is exactly the shape this warns
1414
- * against — it is not a defect in this method, since this method itself
1415
- * has nothing that could ever cause that). André's decision, 2026-08-18:
1416
- * the platform treats a second presentation of an authorization code as
1417
- * theft and revokes the whole token family it belongs to, deliberately,
1418
- * even though a plain double-submission looks identical on the wire —
1419
- * because the blast radius is bounded (only a caller already holding the
1420
- * correct `code_verifier` and `client_id` can trigger it, so a merely
1421
- * *sniffed* code cannot lock anyone out) and the alternative is a
1422
- * narrower defence against a real theft.
1423
- *
1424
- * **What actually happens if two requests race (measured, Argus-W7c):**
1425
- * one of the two receives `200` with a refresh token that the server has
1426
- * already revoked. The access token in that same response keeps working
1427
- * normally for the rest of its short TTL — nothing about the race is
1428
- * visible yet. The failure surfaces at this session's **first silent
1429
- * refresh**, as `token_revoked`, potentially many minutes after the race
1430
- * that actually caused it and with nothing in that later error pointing
1431
- * back to a retry that "worked". If your own framework, an HTTP client
1432
- * wrapper, or a user's impatient double-click can cause this method to
1433
- * be invoked twice concurrently for the same redirect, guard against
1434
- * that at the call site — a simple in-flight flag or disabling the
1435
- * triggering control is enough, since there is only ever one legitimate
1436
- * exchange per authorization code.
1422
+ * Exchanges email + password, and the client's configured app identifier, for
1423
+ * a session.
1424
+ *
1425
+ * `invalid_credentials` is answered identically for a wrong password, a
1426
+ * blocked account and one still waiting to verify. Do not try to tell them
1427
+ * apart there is nothing in the answer that does, deliberately.
1437
1428
  */
1438
- completeHostedLogin(options: CompleteHostedLoginOptions): Promise<void>;
1429
+ login(email: string, password: string): Promise<void>;
1439
1430
  /**
1440
- * Ends the session: revokes the whole refresh-token family server-side
1441
- * (a stolen refresh token stops working immediately) and closes this
1442
- * client's live realtime connection, if it has one. Then clears the
1443
- * local store. Never rejects and always clears the store, even if the
1444
- * server call fails: a user who presses "log out" must end up logged out
1445
- * locally regardless of the network. `revoked` reports whether the
1446
- * server-side revoke actually happened — `false` means the refresh
1447
- * family may still be alive server-side even though this client has
1448
- * forgotten it; an app that cares (a kiosk, a shared workstation) can
1449
- * warn the user or retry, one that doesn't can ignore it.
1431
+ * Ends the session: revokes the whole refresh-token family server-side (a
1432
+ * stolen refresh token stops working immediately), closes this client's live
1433
+ * realtime connection if it has one, and clears the local store.
1434
+ *
1435
+ * **Never rejects, and always clears the store**, even if the server call
1436
+ * fails: a user who presses "log out" must end up logged out locally
1437
+ * regardless of the network.
1450
1438
  *
1451
1439
  * **What this does not do:** invalidate the access token already issued.
1452
- * Access-token checks are stateless (a signed JWT, verified without a
1453
- * server-side lookup) — logout has nothing to flip on that token, only
1454
- * on the refresh family behind it. A token stolen before logout keeps
1455
- * working on REST, and can still open a *new* realtime connection, until
1456
- * it expires on its own — at most 15 minutes. This is a deliberate
1457
- * boundary of the stateless-JWT design (the same one that lets a role
1458
- * change, a block, or a membership removal take effect on the very next
1459
- * request without a fresh token), not a bug but a kiosk or shared
1460
- * workstation needs to know that number.
1461
- *
1462
- * **Nor does it end a federated session at the identity provider (W9c,
1463
- * DEF-098).** Ending the Fleetless session and ending the IdP session are
1464
- * two different things — see `LogoutResult.idp_logout`. This method does
1465
- * not act on that information itself (no redirect, no fetch to the IdP);
1466
- * it only reports what the server found, the same "this SDK stays thin"
1467
- * boundary as everywhere else (`cameras.live` hands back a URL and a
1468
- * token and stops there too).
1469
- *
1470
- * **`idp_logout` is `null` when there was no server answer to report** —
1471
- * and that is *not* the same as `revoked === false`.
1472
- *
1473
- * This block used to state the biconditional (*"`null` exactly when
1474
- * `revoked` is `false`"*), and **it is measurably wrong** (Argus-W9, W9
1475
- * review): calling `logout()` with **no local session** returns
1476
- * `{ revoked: true, idp_logout: null }`. The implementation says so six
1477
- * lines above itself — *"Nothing to revoke: no server-side session lingers,
1478
- * so this counts as revoked — but there is no IdP fact to report either"* —
1479
- * so the public docblock and the code contradicted each other in one file.
1480
- *
1481
- * That mattered more than a wrong sentence usually does, because **this is
1482
- * the rule the text tells a caller to branch on**. Someone who reads
1483
- * `revoked === true` and trusts the promise does not expect `null`.
1484
- * TypeScript catches it (the type is `| null`); a JavaScript caller does
1485
- * not.
1486
- *
1487
- * So: **`null` means this client had nothing to send or the request never
1488
- * answered.** It is not `not_federated`, which is a real finding about a
1489
- * real session — conflating the two is the ambiguity this shape exists to
1490
- * remove, and it is still the reason to read both fields rather than one.
1440
+ * Access-token checks are stateless (a signed JWT, verified without a lookup),
1441
+ * so logout has nothing to flip on that token only on the refresh family
1442
+ * behind it. A token stolen before logout keeps working on REST, and can
1443
+ * still open a *new* realtime connection, until it expires on its own, at
1444
+ * most 15 minutes. That is a deliberate boundary of the stateless-JWT design,
1445
+ * not a bug, but a kiosk or a shared workstation needs to know the number.
1446
+ *
1447
+ * **Nor does it end a session at the identity provider.** It used to report
1448
+ * what was left of one; that apparatus belonged to the hosted login, where
1449
+ * Fleetless owned the browser. The app owns it now, and an app that wants to
1450
+ * end a provider session redirects there itself — knowing its own provider,
1451
+ * which Fleetless never did better than it.
1491
1452
  */
1492
- logout(): Promise<LogoutResult>;
1493
- /** Who the caller turned out to be, without decoding a token client-side. */
1453
+ logout(): Promise<void>;
1454
+ /** Who the caller turned out to be, without decoding a token client-side — which is how apps end up trusting claims nobody verified. */
1494
1455
  me(): Promise<ClientIdentity>;
1495
1456
  /**
1496
- * Changes the current end user's password (spec §3, W6c).
1497
- *
1498
- * `currentPassword` is required by the server even though the session
1499
- * already proves identity — it is what stops a stolen *session* from
1500
- * becoming a stolen *account* (see `passwordChangeRequest` in
1501
- * `@fleetless/contracts`).
1502
- *
1503
- * **Every other session of this identity is revoked on success, and this
1504
- * call's own session is re-issued, not left alone.** `passwordChangeRequest`
1505
- * carries nothing that identifies the caller's own refresh family, so the
1506
- * server cannot spare one token out of the family it just revoked it
1507
- * revokes all of them and hands back a fresh pair, which this method
1508
- * stores exactly like `login` does. Skipping that store would leave the
1509
- * caller holding tokens the server has already revoked, working only
1510
- * until the access token expires and then silently logged out —
1511
- * indistinguishable from the change having failed, which is the one
1512
- * outcome this route exists to prevent. A user with other tabs or
1513
- * devices logged in will see *those* signed out the moment this
1514
- * resolves; if your app does not already make that consequence visible
1515
- * before they confirm, they will find out from a support ticket instead
1516
- * of from you.
1457
+ * Changes the current app user's password.
1458
+ *
1459
+ * `currentPassword` is required even though the session already proves
1460
+ * identity — it is what stops a stolen *session* from becoming a stolen
1461
+ * *account*.
1462
+ *
1463
+ * **Every other session of this identity is revoked, and this call's own
1464
+ * session is re-issued rather than spared.** The request carries nothing
1465
+ * identifying the caller's own refresh family, so the server revokes all of
1466
+ * them and hands back a fresh pair, which this method stores exactly like
1467
+ * `login`. A user with other tabs or devices signed in will see those signed
1468
+ * out the moment this resolves; if your app does not make that consequence
1469
+ * visible before they confirm, they will find out from a support ticket.
1517
1470
  */
1518
1471
  changePassword(currentPassword: string, newPassword: string): Promise<void>;
1472
+ /** Asks for a reset link. Resolves on `202` for a known and an unknown address alike — the answer says nothing about which it was. */
1473
+ requestPasswordReset(email: string): Promise<void>;
1519
1474
  /**
1520
- * The hosted password-reset page, served by the platform on the API
1521
- * origin (`/reset-password`). An app links a user there; nothing is
1522
- * called. The two former methods that posted to `/api/client/password/…`
1523
- * are gone with the routes they named (2.0.0).
1524
- *
1525
- * The page owns the whole flow — it takes the address, mails a
1526
- * single-use link, and takes the new password on the way back — so there
1527
- * is nothing for an app to sequence and nothing here that could reveal
1528
- * whether an address belongs to an account.
1475
+ * Spends a reset token, sets the new password and **stores the session it
1476
+ * answers with**. Every refresh family of that user is revoked first — a
1477
+ * forgotten password is one of the two states where somebody else may be
1478
+ * holding a session.
1529
1479
  */
1530
- passwordResetUrl(): string;
1480
+ confirmPasswordReset(token: string, newPassword: string): Promise<void>;
1481
+ /**
1482
+ * Accepts an app invitation: creates the account (or activates one invited
1483
+ * before it existed) with the role the invitation fixed, and **stores the
1484
+ * session**.
1485
+ *
1486
+ * An invitation always bypasses the app's domain whitelist — a developer
1487
+ * inviting somebody by hand has already made the decision the whitelist
1488
+ * automates.
1489
+ */
1490
+ acceptInvitation(input: AcceptInvitationOptions): Promise<void>;
1491
+ /**
1492
+ * The app's **enabled** sign-in providers, for drawing the buttons on your
1493
+ * own login screen. A disabled provider is not a button that refuses; it is a
1494
+ * button that is not there.
1495
+ *
1496
+ * Public and unauthenticated, and it carries nothing but `slug` and `name` on
1497
+ * purpose: the issuer, the client id, the scopes and the linking policy are
1498
+ * management-side facts that would tell a stranger how the app's federation
1499
+ * is configured.
1500
+ */
1501
+ listProviders(): Promise<ProviderButton[]>;
1502
+ /**
1503
+ * Builds the URL that starts a federated sign-in, with a fresh `state` and a
1504
+ * fresh PKCE verifier. **Makes no network call and does not navigate** —
1505
+ * persist `state` and `codeVerifier`, then send the browser to `url`.
1506
+ *
1507
+ * Nothing about the app, the provider or the redirect URI is validated here;
1508
+ * it is all checked when the browser actually reaches the route, in that
1509
+ * order, with the redirect target checked before the provider so that a
1510
+ * caller who got the target wrong learns nothing about which providers the
1511
+ * app has.
1512
+ *
1513
+ * Async only because the S256 `code_challenge` needs `crypto.subtle.digest`,
1514
+ * which the Web Crypto API only ever offers as a promise.
1515
+ */
1516
+ beginOidcLogin(input: BeginOidcLoginOptions): Promise<OidcLoginRequest>;
1517
+ /**
1518
+ * Completes a federated sign-in: checks `state` against `expectedState`,
1519
+ * trades the one-time `code` for a session, and stores it.
1520
+ *
1521
+ * **The state check runs before any request is sent.** RFC 6749 §10.12's
1522
+ * whole point is that a client must not complete an authorization response it
1523
+ * did not itself request — a check made after the exchange would already have
1524
+ * spent a code for a flow this client never started. Both an outright
1525
+ * mismatch and an *empty* `expectedState` throw `state_mismatch`; the message
1526
+ * says which, because the remedies differ ("check how your app persisted the
1527
+ * value" versus "this response belongs to a sign-in you did not start") even
1528
+ * though the next step is the same either way — start the sign-in again.
1529
+ *
1530
+ * The code lives 60 seconds and is single-use. Unknown, expired, replayed and
1531
+ * "the account was blocked in between" all arrive as one `token_spent`,
1532
+ * because the app has nothing different to do about any of them.
1533
+ */
1534
+ completeOidcLogin(input: CompleteOidcLoginOptions): Promise<void>;
1535
+ /**
1536
+ * Reads a **failed** federated sign-in off the redirect back, as a
1537
+ * `FleetlessError` you can branch on, or `null` when the callback carries no
1538
+ * `error` at all.
1539
+ *
1540
+ * Fleetless renders no page for these: the reason is carried to your own
1541
+ * `redirectUri` as `?error=<code>`, and this turns that string into the same
1542
+ * error type every other method throws. A code the contracts define (see
1543
+ * `ClientOidcErrorCode`) becomes that code verbatim; anything else becomes
1544
+ * `unexpected_response` with the raw value in the message, rather than being
1545
+ * passed through as a code neither side defines.
1546
+ *
1547
+ * Purely local — it parses a query string and asks nothing.
1548
+ */
1549
+ oidcErrorFromCallback(params: URLSearchParams): FleetlessError | null;
1550
+ /**
1551
+ * Reads a pending MCP authorization by the interaction id the browser
1552
+ * arrived with, so the app can render its own consent screen.
1553
+ *
1554
+ * **`client_name` is a string the client typed about itself** during an
1555
+ * unauthenticated dynamic registration — nobody checked it, which is why
1556
+ * `client_name_verified` is the literal `false` rather than a boolean with a
1557
+ * `true` branch that could never happen. Do not render it as an identity.
1558
+ *
1559
+ * `interaction_expired` means exactly that: ten minutes ran out, or the id
1560
+ * was never real. Both answer the same way, so the screen to show is "that
1561
+ * took too long, start again" rather than an error.
1562
+ *
1563
+ * **Call this with the app user already signed in.** The route needs no
1564
+ * credential, but it reads one if present, and `already_granted` is `false`
1565
+ * for an anonymous read whatever the truth is — so a consent screen rendered
1566
+ * from an unauthenticated call asks a person to agree to something they
1567
+ * agreed to already. An **expired** token counts as anonymous to this route,
1568
+ * which answers `200` rather than refusing, so this method probes the
1569
+ * session's liveness first and refreshes if it can; a session that cannot be
1570
+ * refreshed is not an error here, it is genuinely anonymous.
1571
+ */
1572
+ mcpInteraction(id: string): Promise<ClientMcpInteraction>;
1573
+ /** Approves a pending MCP authorization on behalf of the signed-in app user, and returns where to send the browser. */
1574
+ approveMcpInteraction(id: string): Promise<McpInteractionDecision>;
1575
+ /** Denies one. Also returns a redirect — with `error=access_denied` on it, so the client learns from its own callback. */
1576
+ denyMcpInteraction(id: string): Promise<McpInteractionDecision>;
1577
+ /**
1578
+ * Every MCP client this app user has standing consent for — the "connected
1579
+ * apps" list, and the door out of a decision a person could otherwise make
1580
+ * once and never unmake. A withdrawn grant is never listed.
1581
+ *
1582
+ * `client_name_verified` is `false` here for the reason it is on
1583
+ * `mcpInteraction`, and it matters more rather than less: a list like this is
1584
+ * read long after the moment of approval, when nobody remembers what they
1585
+ * clicked.
1586
+ */
1587
+ listMcpGrants(): Promise<McpConsentGrant[]>;
1588
+ /**
1589
+ * Withdraws one standing consent by the client's id.
1590
+ *
1591
+ * Resolves whether or not there was anything to withdraw — a client id this
1592
+ * account never approved and one it withdrew a minute ago both land on the
1593
+ * end state the caller asked for. A refusal there would tell a caller which
1594
+ * clients an account has connected, and would turn a double-clicked button
1595
+ * into a failure.
1596
+ */
1597
+ revokeMcpGrant(clientId: string): Promise<void>;
1531
1598
  }
1532
1599
 
1533
1600
  /**
1534
- * The snapshot's metadata alone, without the bytes (spec §10). All fields
1601
+ * The snapshot's metadata alone, without the bytes. All fields
1535
1602
  * are `null` together when nothing has been captured yet for this camera —
1536
1603
  * a fresh configuration before the first `snapshot_interval_ms` elapses, say
1537
1604
  * — which is a state, not a failure: the wire answers it with
@@ -1546,36 +1613,49 @@ interface AuthApi {
1546
1613
  * `SNAPSHOT_HEADERS`'s doc comment in `@fleetless/contracts`).
1547
1614
  */
1548
1615
  interface CameraSnapshotMeta {
1616
+ /** The image's media type, e.g. `image/jpeg`. */
1549
1617
  mime: string | null;
1618
+ /** The image's width in pixels. */
1550
1619
  width: number | null;
1620
+ /** The image's height in pixels. */
1551
1621
  height: number | null;
1552
- /** The bridge's capture time (§6.3). */
1622
+ /** The bridge's capture time, in unix milliseconds — when the frame was taken, not when it was served. */
1553
1623
  timestamp_ms: number | null;
1624
+ /** How long the cloud has held this frame, in milliseconds. The one figure to read for freshness. */
1554
1625
  age_ms: number | null;
1555
1626
  }
1556
- /** One snapshot read: the image bytes plus everything needed to state how old they are (spec §10). */
1627
+ /** One snapshot read: the image bytes plus everything needed to state how old they are. */
1557
1628
  interface CameraSnapshot extends CameraSnapshotMeta {
1629
+ /** The encoded image, or `null` when nothing has been captured yet. */
1558
1630
  image: Uint8Array | null;
1559
1631
  }
1560
1632
  /**
1561
- * What a LiveKit client needs to join, plus the means to leave (spec §10,
1562
- * §14.3: "Kamera (Snapshot-URL + LiveKit-Track-Handle)"). Hand `url`/`token`
1563
- * straight to a LiveKit client SDK (e.g. `Room.connect(url, token)`) — this
1564
- * SDK stops there on purpose: no video widget, no teleop-style helper
1565
- * (§14.3 keeps it thin).
1633
+ * What a LiveKit client needs to join, plus the means to leave. Returned by
1634
+ * `cameras.live`. Hand `url` and `token` straight to a LiveKit client SDK
1635
+ * (e.g. `Room.connect(url, token)`) — this SDK stops there on purpose: no
1636
+ * video widget and no teleop-style helper, so that the app owns how the
1637
+ * video is presented.
1566
1638
  */
1567
1639
  interface CameraLiveSession {
1568
1640
  /**
1569
- * This viewer's own hold (W6b) — what `release()` releases, and the only
1641
+ * This viewer's own hold — what `release()` releases, and the only
1570
1642
  * thing distinguishing this session from every other tab of the same
1571
- * identity watching the same camera. Not previously addressable: a `DELETE`
1572
- * with no id released **all** of this identity's holds on the slug, so one
1573
- * tab closing stopped the robot for every other tab too. See `release()`'s
1574
- * doc comment for what changed and what did not.
1643
+ * identity watching the same camera.
1575
1644
  */
1576
1645
  session_id: string;
1646
+ /** The LiveKit server URL to connect to. */
1577
1647
  url: string;
1648
+ /** The LiveKit room this session joins. */
1578
1649
  room: string;
1650
+ /**
1651
+ * The LiveKit access token for this session. It is checked when the
1652
+ * participant connects and not again afterwards, so it bounds *joining*,
1653
+ * not the session: a viewer who has already joined keeps receiving video
1654
+ * past `expires_at`. What ends a joined session is `release()` together
1655
+ * with disconnecting the room, the cloud reconciling the hold away
1656
+ * against LiveKit's real participants, or a revocation — a membership,
1657
+ * role or key change — kicking the participant out.
1658
+ */
1579
1659
  token: string;
1580
1660
  /**
1581
1661
  * When this token can no longer be used to **join** — not when an
@@ -1594,14 +1674,10 @@ interface CameraLiveSession {
1594
1674
  expires_at: string;
1595
1675
  /**
1596
1676
  * Tells the cloud this viewer no longer wants to hold the camera live —
1597
- * **this** hold, addressed by `session_id` (W6b), and no other tab's.
1598
- *
1599
- * Before W6b, `DELETE` carried no id and released every hold this identity
1600
- * had on the slug — so one tab's `release()` (or its unmount cleanup)
1601
- * stopped the robot out from under every other tab of the same logged-in
1602
- * user, which kept rendering a frozen frame because a LiveKit token is
1603
- * checked at join and never again. Each `CameraLiveSession` now releases
1604
- * only the hold it itself took.
1677
+ * **this** hold, addressed by `session_id`, and no other tab's. Each
1678
+ * `CameraLiveSession` releases only the hold it itself took, so one tab's
1679
+ * cleanup never stops the robot out from under another tab of the same
1680
+ * logged-in user.
1605
1681
  *
1606
1682
  * **This alone does not stop the stream.** The cloud makes LiveKit room
1607
1683
  * participation the authoritative refcount, not this call — precisely
@@ -1622,7 +1698,7 @@ interface CameraLiveSession {
1622
1698
  *
1623
1699
  * **A failed DELETE here is not observable anywhere** — not as a
1624
1700
  * rejection, not as a realtime event, not as a field on this object. This
1625
- * is a deliberate decision, not an oversight (W6b review): the only
1701
+ * is a deliberate decision, not an oversight: the only
1626
1702
  * consumer of that information would be code deciding whether to retry,
1627
1703
  * and the backstop this comment already describes — the cloud's own
1628
1704
  * LiveKit-participation reconciliation — makes a retry unnecessary for
@@ -1632,13 +1708,18 @@ interface CameraLiveSession {
1632
1708
  */
1633
1709
  release(): Promise<void>;
1634
1710
  }
1711
+ /**
1712
+ * A robot's cameras, reachable as `client.cameras`: what exists, the latest
1713
+ * still frame, and a live video session. All of it is REST — no realtime
1714
+ * channel is involved.
1715
+ */
1635
1716
  interface CamerasApi {
1636
- /** Every camera exposed on this robot (spec §11.2's per-robot descriptor list, extended to the camera kind). */
1717
+ /** Every camera exposed on this robot, as descriptors — the same per-robot list the other kinds use. */
1637
1718
  list(robotId: string): Promise<CameraDescriptor[]>;
1638
1719
  /**
1639
1720
  * The current snapshot: image bytes plus its age. Independent of `live` —
1640
1721
  * a snapshot keeps updating on `snapshot_interval_ms` whether or not
1641
- * anyone is watching live (§10), and keeps being served, with a growing
1722
+ * anyone is watching live, and keeps being served, with a growing
1642
1723
  * age, even while the bridge is offline.
1643
1724
  */
1644
1725
  snapshot(robotId: string, slug: string): Promise<CameraSnapshot>;
@@ -1650,30 +1731,44 @@ interface CamerasApi {
1650
1731
  */
1651
1732
  snapshotMeta(robotId: string, slug: string): Promise<CameraSnapshotMeta>;
1652
1733
  /**
1653
- * Takes a refcounted hold on this camera's live stream (spec §10): the
1734
+ * Takes a refcounted hold on this camera's live stream: the
1654
1735
  * first `live()` on a slug starts the robot publishing, the last viewer
1655
1736
  * leaving stops it. Deliberately not deduplicated locally across multiple
1656
1737
  * `live()` calls for the same `(robotId, slug)` — unlike a datapoint
1657
1738
  * subscription, each call needs its own distinct LiveKit participant, so
1658
- * a local counter here would just be the same shared-count bug
1659
- * `slug-subscriptions.ts` fixed, self-inflicted on a resource the cloud
1660
- * already counts correctly.
1739
+ * a local counter here would just be the same shared-count bug the
1740
+ * subscription layer already fixed, self-inflicted on a resource the
1741
+ * cloud counts correctly on its own.
1661
1742
  */
1662
1743
  live(robotId: string, slug: string): Promise<CameraLiveSession>;
1663
1744
  }
1664
1745
 
1746
+ /**
1747
+ * The callbacks `datapoints.subscribe` reports through: one for values, one
1748
+ * for a refusal. `onEvent` fires immediately with the current value and
1749
+ * again on every change.
1750
+ */
1665
1751
  interface DatapointSubscriptionHandlers {
1666
1752
  /** Called with the current value on subscribe, then again on every change. */
1667
1753
  onEvent(event: DatapointEvent): void;
1668
- /** Called once if the subscription is refused (§11.5: e.g. `forbidden`, `unknown_datapoint`). */
1754
+ /** Called once if the subscription is refused, e.g. `forbidden` or `unknown_datapoint`. */
1669
1755
  onError?(error: FleetlessError): void;
1670
1756
  }
1757
+ /** A live datapoint subscription, returned by `datapoints.subscribe`. */
1671
1758
  interface DatapointSubscription {
1672
- /** Stops the subscription and, if the channel is currently connected, tells the server. */
1759
+ /**
1760
+ * Stops this subscription. The `unsubscribe` frame reaches the server only
1761
+ * when this was the **last** holder of the robot and slug pair, and only if
1762
+ * the channel is connected — subscriptions are reference-counted across
1763
+ * kinds, so releasing this one while a second `datapoints.subscribe`, an
1764
+ * `actions.subscribe` or an in-flight `services.call` still holds the same
1765
+ * pair leaves that one's stream running untouched. Safe to call more than
1766
+ * once.
1767
+ */
1673
1768
  unsubscribe(): void;
1674
1769
  }
1675
1770
  /**
1676
- * Window aggregation for `history` (spec §8). `window` and `agg` always
1771
+ * Window aggregation for `datapoints.history`. `window` and `agg` always
1677
1772
  * travel together on the wire — the cloud refuses one without the other
1678
1773
  * rather than defaulting either, since a silently chosen aggregation is a
1679
1774
  * chart that lies quietly — so they live in one object here instead of two
@@ -1685,10 +1780,16 @@ interface DatapointSubscription {
1685
1780
  interface HistoryAggregation {
1686
1781
  /** Bucket width, e.g. `10s`, `1m`. */
1687
1782
  window: string;
1783
+ /** How to reduce each bucket's samples to one number. */
1688
1784
  agg: 'min' | 'max' | 'avg';
1689
- /** 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. */
1785
+ /** A numeric field inside an object value, e.g. `pose.x`. Only meaningful when the datapoint's own value is not itself a number. */
1690
1786
  field?: string;
1691
1787
  }
1788
+ /**
1789
+ * The window `datapoints.history` reads, and whether it comes back as raw
1790
+ * samples or as aggregated buckets. `aggregate` is what decides which of
1791
+ * the two responses you get.
1792
+ */
1692
1793
  interface HistoryOptions {
1693
1794
  /**
1694
1795
  * `now-30s` / `now-5m` / `now-1h`, or absolute unix milliseconds — as a
@@ -1701,11 +1802,22 @@ interface HistoryOptions {
1701
1802
  from: string;
1702
1803
  /** Same two forms as `from`. Defaults to now. */
1703
1804
  to?: string;
1805
+ /** The most rows to return. The platform applies its own ceiling regardless. */
1704
1806
  limit?: number;
1705
1807
  /** Present: the result is aggregated buckets. Absent: raw samples. */
1706
1808
  aggregate?: HistoryAggregation;
1707
1809
  }
1810
+ /**
1811
+ * A robot's exposed values, reachable as `client.datapoints`: the latest
1812
+ * one, a live subscription to it, and — for a datapoint configured with
1813
+ * retention — its recorded history.
1814
+ */
1708
1815
  interface DatapointsApi {
1816
+ /**
1817
+ * Reads the datapoint's latest value over REST, once. It carries the
1818
+ * bridge's own capture time, so a caller can tell a fresh value from a
1819
+ * stale one without a subscription.
1820
+ */
1709
1821
  get(robotId: string, slug: string): Promise<DatapointValue>;
1710
1822
  /**
1711
1823
  * Subscribes over the realtime channel. Reconnect and re-authentication
@@ -1718,25 +1830,27 @@ interface DatapointsApi {
1718
1830
  * other — the `unsubscribe` frame is sent only when the last subscriber
1719
1831
  * on that pair goes away. This matters in practice: two widgets showing
1720
1832
  * the same battery value, or a component mounted twice under React
1721
- * StrictMode, both subscribe to the same key. The count itself lives in
1722
- * `slug-subscriptions.ts`, shared with `actions.subscribe`/`services.call`
1723
- * — a slug is one namespace across kinds, and so is its subscription.
1833
+ * StrictMode, both subscribe to the same key. That count is shared with
1834
+ * `actions.subscribe` and `services.call` — a slug is one namespace across
1835
+ * kinds, and so is its subscription.
1724
1836
  */
1725
1837
  subscribe(robotId: string, slug: string, handlers: DatapointSubscriptionHandlers): DatapointSubscription;
1726
1838
  /**
1727
- * Reads recorded history for a `retention: true` datapoint (spec §8) over
1728
- * REST — no realtime channel involved, the same way `cameras.snapshot`
1729
- * isn't. Returns a **discriminated result**: passing `aggregate` gets you
1730
- * back `HistoryBucketsResponse` (`kind: 'buckets'`), leaving it out gets
1731
- * you `HistorySamplesResponse` (`kind: 'samples'`) two overloads so a
1732
- * caller who already knows which one they asked for isn't forced to
1733
- * narrow something they determined themselves. `kind` still carries the
1734
- * same information on both, so code that holds the result dynamically
1735
- * (e.g. read from a variable typed as the union) can still branch on it.
1839
+ * Reads recorded history for a `retention: true` datapoint over REST — no
1840
+ * realtime channel involved, the same way `cameras.snapshot` isn't. **This
1841
+ * overload is the aggregated one:** `aggregate` is given, so it resolves
1842
+ * with `HistoryBucketsResponse` (`kind: 'buckets'`) one row per window,
1843
+ * reduced by `aggregate.agg`. Leave `aggregate` out and the other overload
1844
+ * gives you raw samples instead.
1845
+ *
1846
+ * Two overloads rather than one union so a caller who already knows which
1847
+ * they asked for isn't forced to narrow something they determined
1848
+ * themselves. `kind` still carries the same information on both, so code
1849
+ * holding the result dynamically can still branch on it.
1736
1850
  *
1737
1851
  * **Rejects, does not silently empty out, two specific refusals** —
1738
1852
  * unlike `cameras.snapshot`'s absorption of `no_snapshot_yet` into a null
1739
- * read, these two must reach the caller as thrown `FleetlessError`s:
1853
+ * read, these two must reach the caller as a rejected `FleetlessError`:
1740
1854
  * - `not_recorded` — the slug exists and is granted, but is configured
1741
1855
  * live-only. An empty result here would look exactly like "recorded,
1742
1856
  * but nothing in this window", and the two need opposite fixes: turn
@@ -1747,75 +1861,40 @@ interface DatapointsApi {
1747
1861
  history(robotId: string, slug: string, options: HistoryOptions & {
1748
1862
  aggregate: HistoryAggregation;
1749
1863
  }): Promise<HistoryBucketsResponse>;
1864
+ /**
1865
+ * The same read without `aggregate`: resolves with
1866
+ * `HistorySamplesResponse` (`kind: 'samples'`), every recorded sample in
1867
+ * the window as a `timestamp_ms` and a `value`. `timestamp_ms` is the
1868
+ * bridge's own capture time, the same instant the live value carried, so a
1869
+ * recorded point and a live one sit on one axis without apology.
1870
+ *
1871
+ * **Read `truncated`.** The platform caps how much one read returns, by
1872
+ * row count or by bytes, and `truncated_by` says which. A short array that
1873
+ * does not admit it is indistinguishable from a quiet period, and the two
1874
+ * lead to opposite conclusions. Refuses `not_recorded` the same way the
1875
+ * aggregated overload does.
1876
+ */
1750
1877
  history(robotId: string, slug: string, options: HistoryOptions & {
1751
1878
  aggregate?: undefined;
1752
1879
  }): Promise<HistorySamplesResponse>;
1753
1880
  }
1754
1881
 
1755
1882
  /**
1756
- * An end user's own consent grants (spec §3.4/§17, W9c, DEF-099) — every
1757
- * client this identity has ever authorized, and the means to take one back
1758
- * without touching any of the others.
1759
- *
1760
- * **End-user only.** A server key acts with the app's own full rights and
1761
- * never went through a consent screen itself — there is no "self" here for
1762
- * it to list or revoke, same reasoning as `auth.login`/`auth.register` on a
1763
- * `serverKey` client.
1883
+ * Robot-wide job reads, reachable as `client.jobs`. Everything here is
1884
+ * addressed by robot rather than by slug, which is what `actions` and
1885
+ * `services` cannot do.
1764
1886
  */
1765
- interface GrantsApi {
1766
- /**
1767
- * Every client this end user has consented to, most-recently-granted
1768
- * first. Each entry carries names, not only ids (`client_name`, `app_name`,
1769
- * `role_name`) — the id is what `revoke()` addresses, but a person
1770
- * deciding whether to revoke something needs to *recognise* it first, and
1771
- * an id is not recognisable. **`client_name` is not trusted** — a
1772
- * self-registered client chooses its own display name (W7c already
1773
- * measured what that buys: one called itself "Fleetless Official
1774
- * Helper") — do not render it as if Fleetless vouched for it.
1775
- */
1776
- list(): Promise<ConsentGrantSummary[]>;
1777
- /**
1778
- * Revokes one grant by the client's id — the `client_id` from a
1779
- * `list()` entry — without touching any other client's access.
1780
- *
1781
- * `revoked` tells "nothing matched" (`false`) from "matched and ended"
1782
- * (`true`) — revoking a grant that is already gone (a stale id, a double
1783
- * click) is not an error, just a no-op the caller can still tell apart
1784
- * from a real revocation. `tokens_revoked` counts refresh **families**
1785
- * ended, not access tokens — that count is deliberately never a claim
1786
- * about access tokens, which are stateless and short-lived by design.
1787
- *
1788
- * **What actually happens to an already-issued access token is stronger
1789
- * than that count implies, and it was measured, not assumed (W9c gate
1790
- * step 1, 2026-08-19, end to end through this SDK against a real
1791
- * cloud).** It does not simply expire on its own: the cloud re-checks
1792
- * every request for a revoked grant, so **any** currently-valid access
1793
- * token minted through this same client's OAuth flow for this end user
1794
- * — not only the one used to call `revoke()` — answers `401
1795
- * token_revoked` on its very next request, no wait for its TTL. This is
1796
- * scoped to that one client: a session that never went through this
1797
- * client's consent (a plain `auth.login()` session, or one bound to a
1798
- * different client) carries no trace of this client's id and is
1799
- * unaffected. Do not build on the weaker "it will expire eventually" —
1800
- * if this platform ever moves to stateless verification without that
1801
- * re-check, this immediacy would go away silently, which is exactly why
1802
- * it is written down here instead of left implied.
1803
- */
1804
- revoke(clientId: string): Promise<ConsentRevokeResponse>;
1805
- }
1806
-
1807
1887
  interface JobsApi {
1808
1888
  /**
1809
- * Every job the platform currently believes this robot has — `GET
1810
- * /api/robots/:id/jobs` (W6b, contracts `robotJobsResponse` doc comment).
1889
+ * Every job the platform currently believes this robot has.
1811
1890
  *
1812
1891
  * `actions.subscribe`/`services.call` and `GET /jobs/:slug` (the per-slug
1813
1892
  * route those build on) all require already knowing the slug. That is not
1814
1893
  * always true: a reconnecting bridge can name a job the cloud only
1815
1894
  * *adopted*, and a configuration change can leave a job on a slug the
1816
1895
  * published document no longer contains. Both are jobs no slug can name,
1817
- * which is exactly what this method is for (register row 2k) an app
1818
- * developer had no way to reach them before this.
1896
+ * which is exactly what this method is for an app developer has no
1897
+ * other way to reach them.
1819
1898
  *
1820
1899
  * At most one entry per slug: the current job there, exactly what a
1821
1900
  * per-slug read would answer for that slug. Not a history endpoint.
@@ -1832,18 +1911,22 @@ interface JobsApi {
1832
1911
  * `hello`, having never minted it, and has no other honest value to put
1833
1912
  * there. So this is newest-*known*-first: a job the robot has been
1834
1913
  * running for an hour can sit above one started a minute ago, if the
1835
- * hour-long one was only just adopted (contracts `robotJobsResponse` doc
1836
- * comment).
1914
+ * hour-long one was only just adopted.
1837
1915
  */
1838
1916
  list(robotId: string): Promise<Job[]>;
1839
1917
  }
1840
1918
 
1919
+ /**
1920
+ * One-way messages to a robot's publishers, reachable as
1921
+ * `client.publishers` — a velocity command, a goal pose, anything the
1922
+ * developer exposed as a publisher.
1923
+ */
1841
1924
  interface PublishersApi {
1842
1925
  /**
1843
- * Publishes one message to a publisher (spec §4.2, §6.4).
1926
+ * Publishes one message to a publisher.
1844
1927
  *
1845
1928
  * This is a plain method call — there is deliberately no deadman switch,
1846
- * rate governor or "takt" helper here (spec §14.3). The bridge's own
1929
+ * rate governor or "takt" helper here. The bridge's own
1847
1930
  * `timeout_ms` failsafe is the platform's safety primitive: if messages
1848
1931
  * stop arriving — including because this process crashed — the bridge
1849
1932
  * publishes the configured failsafe message itself. That does **not**
@@ -1854,45 +1937,54 @@ interface PublishersApi {
1854
1937
  * building a publisher-driven control loop.
1855
1938
  *
1856
1939
  * Rejects `publisher_busy` while a different user is publishing and has
1857
- * not been quiet for `quiet_timeout_ms` yet (§6.4) — whoever publishes
1940
+ * not been quiet for its configured quiet timeout yet — whoever publishes
1858
1941
  * holds the publisher implicitly exclusive.
1859
1942
  */
1860
1943
  publish(robotId: string, slug: string, message: Record<string, unknown>, options?: SendCommandOptions): Promise<void>;
1861
1944
  }
1862
1945
 
1946
+ /**
1947
+ * Request/response calls to a robot, reachable as `client.services`. A
1948
+ * service answers once and is done, which is why this namespace has a
1949
+ * single method and nothing to subscribe to.
1950
+ */
1863
1951
  interface ServicesApi {
1864
1952
  /**
1865
- * Calls a service and resolves with its result (spec §4.2, §11.3). A
1866
- * service call is a job underneath — the same `job_id` exchange and
1867
- * disconnect survival as an action (§6.1: "an action goal or a service
1868
- * call") — but that is deliberately invisible here: the caller gets a
1953
+ * Calls a service and resolves with its result. A service call is a job
1954
+ * underneath — the same `job_id` exchange and disconnect survival as an
1955
+ * action but that is deliberately invisible here: the caller gets a
1869
1956
  * plain `Promise<result>`, matching the REST `serviceCallResponse` shape's
1870
1957
  * developer experience. There is nothing to subscribe to for a service —
1871
1958
  * no feedback, no progress, no cancel — so this call already waits for
1872
1959
  * the terminal state internally.
1873
1960
  *
1874
- * `options.patienceMs` bounds the **whole wait** for a service call (W6b)
1875
- * unlike an action, where it bounds acceptance only — because a service
1961
+ * `options.patienceMs` bounds the **whole wait** for a service call
1962
+ * unlike an action, where it bounds acceptance only — because a service
1876
1963
  * has no further state to observe once it settles; the platform gives up
1877
1964
  * on the ROS call itself after this long.
1878
1965
  *
1879
1966
  * `options.timeoutMs` bounds this SDK's own local wait for the WHOLE
1880
1967
  * call — the ack that a job was created, plus however much of the
1881
- * budget is left for it to then reach a terminal state (D10) — not two
1968
+ * budget is left for it to then reach a terminal state — not two
1882
1969
  * separate `timeoutMs`-length windows back to back. A caller who sets
1883
1970
  * `timeoutMs: 5000` is bounding total latency at ~5s, not ~10s; the
1884
1971
  * number means what it says, once, for the whole call.
1885
1972
  *
1886
- * It is also **not independent** of `patienceMs` (D3a): left unset, it
1973
+ * It is also **not independent** of `patienceMs`: left unset, it
1887
1974
  * is derived from `patienceMs` so this SDK's local clock cannot fire
1888
1975
  * before the platform's own deadline has even been reached. Setting
1889
- * both, with `timeoutMs` shorter than `patienceMs`, throws
1890
- * `invalid_option` synchronously rather than letting the two race — see
1891
- * `resolveLocalWaitMs` in `commands.ts` for the full reasoning.
1976
+ * both, with `timeoutMs` shorter than `patienceMs`, rejects with
1977
+ * `invalid_option` before any request is sent rather than letting the two
1978
+ * race see `InvokeOptions.patienceMs` for the full reasoning.
1892
1979
  */
1893
1980
  call(robotId: string, slug: string, params: Record<string, unknown>, options?: InvokeOptions): Promise<unknown>;
1894
1981
  }
1895
1982
 
1983
+ /**
1984
+ * Everything `createClient` accepts. `apiUrl` and `appIdentifier` are
1985
+ * required; the rest either select the kind of caller (`tokenStore` versus
1986
+ * `serverKey`) or replace a global the SDK would otherwise reach for.
1987
+ */
1896
1988
  interface FleetlessClientOptions {
1897
1989
  /** Base URL of the Fleetless REST API, e.g. `https://api.fleetless.dev`. */
1898
1990
  apiUrl: string;
@@ -1918,27 +2010,50 @@ interface FleetlessClientOptions {
1918
2010
  /** Defaults to `apiUrl` with http(s) swapped for ws(s) and `/realtime` appended. */
1919
2011
  realtimeUrl?: string;
1920
2012
  }
2013
+ /**
2014
+ * The settled configuration of a client, reachable as `client.config`. It
2015
+ * is frozen and reflects the defaults `createClient` filled in, which is
2016
+ * what makes it worth reading: `realtimeUrl` is usually derived rather than
2017
+ * passed.
2018
+ */
1921
2019
  interface FleetlessClientConfig {
2020
+ /** The REST base URL this client calls, exactly as passed to `createClient`. */
1922
2021
  readonly apiUrl: string;
2022
+ /** The app this client acts as, exactly as passed to `createClient`. */
1923
2023
  readonly appIdentifier: string;
2024
+ /** The realtime WebSocket URL in use, derived from `apiUrl` unless one was passed. */
1924
2025
  readonly realtimeUrl: string;
1925
2026
  }
2027
+ /**
2028
+ * One app's client, returned by `createClient`. Every API the SDK offers is
2029
+ * a property on it, and all of them share this client's identity, its
2030
+ * single realtime channel and its token refresh.
2031
+ */
1926
2032
  interface FleetlessClient {
2033
+ /** The settled configuration, including the defaults `createClient` filled in. */
1927
2034
  readonly config: FleetlessClientConfig;
2035
+ /**
2036
+ * The whole client auth API: registration, verification, login, logout,
2037
+ * password reset, invitations, the app's federated sign-in providers, the
2038
+ * MCP consent screen, and the app user's own standing MCP grants.
2039
+ */
1928
2040
  readonly auth: AuthApi;
1929
- /** An end user's own consent grants (spec §3.4/§17, W9c) not available on a `serverKey` client, same reasoning as `auth`'s session-only methods. */
1930
- readonly grants: GrantsApi;
2041
+ /** A topic's latest value, a live subscription to it, and its recorded history. */
1931
2042
  readonly datapoints: DatapointsApi;
2043
+ /** Long-running work on the robot: invoke, cancel, and watch a job as it runs. */
1932
2044
  readonly actions: ActionsApi;
2045
+ /** Request/response calls to the robot that answer once and are done. */
1933
2046
  readonly services: ServicesApi;
2047
+ /** One-way messages to a robot's publisher, such as a velocity command. */
1934
2048
  readonly publishers: PublishersApi;
2049
+ /** Camera snapshots, their age, and live video sessions. */
1935
2050
  readonly cameras: CamerasApi;
1936
2051
  /**
1937
2052
  * Robot-wide job reads that do not fit under `actions`/`services` because
1938
2053
  * they are not addressed by slug — see `JobsApi.list`.
1939
2054
  */
1940
2055
  readonly jobs: JobsApi;
1941
- /** URDF + mesh reads (spec §4.6) — list/get/urdf, plus the `urdf-loader` mesh callback. */
2056
+ /** URDF and mesh reads — list/get/urdf, plus the `urdf-loader` mesh callback. */
1942
2057
  readonly assets: AssetsApi;
1943
2058
  /**
1944
2059
  * Closes the realtime channel and stops it from reconnecting. Safe to
@@ -1951,6 +2066,16 @@ interface FleetlessClient {
1951
2066
  */
1952
2067
  close(): void;
1953
2068
  }
2069
+ /**
2070
+ * Builds a client for one app. Pass `tokenStore` (or nothing — the default
2071
+ * keeps the session in memory) for an app-user client that signs in with
2072
+ * `auth.login` or a federated provider; pass `serverKey` for a server-side
2073
+ * caller that never holds a user session. Passing both throws, because the
2074
+ * two are different identities and a client acts as exactly one.
2075
+ *
2076
+ * Nothing is fetched here: the realtime channel opens on the first
2077
+ * subscription and closes on `close()` or `auth.logout()`.
2078
+ */
1954
2079
  declare function createClient(options: FleetlessClientOptions): FleetlessClient;
1955
2080
 
1956
- 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 CompleteHostedLoginOptions, type ConsentGrantSummary, type ConsentRevokeResponse, type CreateMeshLoaderOptions, type DatapointEvent, type DatapointSubscription, type DatapointSubscriptionHandlers, type DatapointValue, type DatapointsApi, type FleetlessClient, type FleetlessClientConfig, type FleetlessClientOptions, FleetlessError, type FleetlessErrorCode, type FleetlessErrorOptions, type GrantsApi, type HistoryAggregation, type HistoryBucketsResponse, type HistoryOptions, type HistorySamplesResponse, type HostedLoginRequest, InMemoryTokenStore, type Job, type JobEvent, type JobState, type JobSubscription, type JobSubscriptionHandlers, type JobsApi, type LogoutResult, 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 };
2081
+ export { type AcceptInvitationOptions, type ActionsApi, type Asset, type AssetBytes, type AssetListResponse, type AssetsApi, type AuthApi, type BeginOidcLoginOptions, type BusyDetails, type CameraDescriptor, type CameraLiveSession, type CameraSnapshot, type CameraSnapshotMeta, type CamerasApi, type ClientIdentity, type ClientMcpInteraction, type ClientOidcErrorCode, type CompleteOidcLoginOptions, 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, InMemoryTokenStore, type InvokeOptions, type Job, type JobEvent, type JobState, type JobSubscription, type JobSubscriptionHandlers, type JobsApi, type McpConsentGrant, type McpInteractionDecision, type MeshLoaderDelegate, type OidcLoginRequest, type ParameterInvalidDetails, type ParameterViolation, type PrepareUrdfSceneOptions, type ProviderButton, type PublishersApi, type RateLimitDetails, type RegisterOptions, SDK_ERROR_CODES, type SdkErrorCode, type SendCommandOptions, type ServicesApi, type StoredSession, type TokenStore, type UrdfCompleteness, type UrdfSceneManager, type UrdfSceneResources, createClient, parameterInvalidDetails };