@fleetless/sdk 3.1.1 → 4.1.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.js CHANGED
@@ -236,7 +236,6 @@ function isRenderKind(kind) {
236
236
  case "texture":
237
237
  return true;
238
238
  case "urdf":
239
- case "other":
240
239
  return false;
241
240
  default: {
242
241
  const exhaustive = kind;
@@ -411,7 +410,7 @@ function createAssetsApi(http) {
411
410
  };
412
411
  }
413
412
 
414
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/common.js
413
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/common.js
415
414
  import { z } from "zod";
416
415
  var SLUG_RULE = "A name is lower-case: it starts with a letter, continues with letters and digits, and joins further words with a single underscore \u2014 `battery_voltage`. Capitals, dashes, dots, spaces, a leading digit and a doubled or trailing underscore are all refused.";
417
416
  var slug = z.string().min(2).max(63).regex(/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/, SLUG_RULE);
@@ -426,7 +425,14 @@ var wireTimestampMs = z.union([z.string().regex(/^\d{1,15}$/), z.number().int()]
426
425
  const year = new Date(ms).getUTCFullYear();
427
426
  return Number.isFinite(year) && year >= 1 && year <= 9999;
428
427
  }, "must fall within years 1..9999"));
429
- var applyErrorKind = z.enum(["datapoint", "action", "service", "publisher", "camera"]);
428
+ var applyErrorKind = z.enum([
429
+ "datapoint",
430
+ "action",
431
+ "service",
432
+ "publisher",
433
+ "camera",
434
+ "low_bandwidth"
435
+ ]);
430
436
  var applyError = z.object({
431
437
  slug: z.string(),
432
438
  kind: applyErrorKind,
@@ -435,7 +441,7 @@ var applyError = z.object({
435
441
  details: z.record(z.string(), z.unknown()).optional()
436
442
  });
437
443
 
438
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/mcp.js
444
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/mcp.js
439
445
  import { z as z2 } from "zod";
440
446
  function mcpAppEndpointPath(appIdentifier2) {
441
447
  return `/mcp/${appIdentifier2}`;
@@ -484,17 +490,17 @@ var mcpRolePreviewResponse = z2.object({
484
490
  });
485
491
  var MCP_ASSET_LINK_TTL_MS = 15 * 60 * 1e3;
486
492
 
487
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/protocol.js
493
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/protocol.js
488
494
  import { z as z8 } from "zod";
489
495
 
490
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/assets.js
496
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/assets.js
491
497
  import { z as z3 } from "zod";
492
- var assetKind = z3.enum(["urdf", "mesh", "texture", "other"]);
498
+ var assetKind = z3.enum(["urdf", "mesh", "texture"]);
493
499
  var asset = z3.object({
494
500
  id: z3.uuid().meta({ description: "The asset's id in the store." }),
495
501
  robot_id: z3.uuid().meta({ description: "The robot this asset belongs to." }),
496
502
  kind: assetKind.meta({
497
- description: "What the file is: the `urdf` itself, a `mesh` it references, a `texture` a mesh or the URDF paints with, or `other`. A renderer decides from this alone, before fetching anything, what to pre-fetch."
503
+ description: "What the file is: the `urdf` itself, a `mesh` it references, or a `texture` a mesh or the URDF paints with. A renderer decides from this alone, before fetching anything, what to pre-fetch."
498
504
  }),
499
505
  /**
500
506
  * What the robot called it — for a mesh, the `package://` URI the URDF
@@ -593,16 +599,18 @@ var assetSyncResponse = z3.object({
593
599
  description: "The sync that has just started. A sync is long-running, so the answer is something to watch rather than a status that was true at the moment of asking."
594
600
  })
595
601
  });
596
- var ASSET_UPLOAD_MAX_BYTES = 64 * 1024 * 1024;
597
- var assetTooLargeDetails = z3.object({
598
- limit_bytes: z3.number().int().positive().meta({
599
- description: "The upload ceiling, in bytes."
602
+ var assetStoreRefusedDetails = z3.object({
603
+ store_bytes: z3.number().int().positive().meta({
604
+ description: "The robot's store, in bytes."
605
+ }),
606
+ used_bytes: z3.number().int().nonnegative().meta({
607
+ description: "Bytes the robot's assets occupy before this upload."
600
608
  }),
601
609
  size_bytes: z3.number().int().positive().meta({
602
- description: 'How large the refused file is, in bytes. With `limit_bytes` beside it a developer can tell whether to shrink the mesh or raise the limit; "too large" alone answers neither.'
610
+ description: "The refused upload, in bytes."
603
611
  })
604
612
  });
605
- var assetFailureKind = z3.enum(["unresolvable", "upload_failed", "refused", "too_large"]);
613
+ var assetFailureKind = z3.enum(["unresolvable", "upload_failed", "refused"]);
606
614
  var assetFailure = z3.object({
607
615
  /**
608
616
  * What could not be provided, verbatim — the same string `asset.name` would
@@ -615,33 +623,28 @@ var assetFailure = z3.object({
615
623
  description: "What could not be provided, verbatim \u2014 the same string the asset would have been stored under, so a developer can match it against their own workspace by eye. For a failed URDF upload it is `robot_description`, which is **not** a mesh URI: a consumer must not assume every entry is one."
616
624
  }),
617
625
  kind: assetFailureKind.meta({
618
- description: "Why it failed. `unresolvable` means the reference names nothing the producer can find or may read, and is **permanent** \u2014 the only kind reconciliation may treat as gone. `upload_failed` means the bytes exist and the transfer did not succeed, `refused` means it was never attempted because a producer-side ceiling was hit, and `too_large` means it exceeds the upload limit and carries both numbers in `details`."
626
+ description: "Why it failed. `unresolvable` means the reference names nothing the producer can find or may read, and is **permanent** \u2014 the only kind reconciliation may treat as gone. `upload_failed` means the bytes exist and the transfer did not succeed, and `refused` means it was never attempted, either because the robot's asset store had no room \u2014 then `details` carries the three numbers \u2014 or because a producer-side ceiling was hit."
619
627
  }),
620
628
  /**
621
- * **The two numbers, and why `too_large` is a kind of its own.**
629
+ * **The three numbers behind a full store.**
622
630
  *
623
- * `refused` means *never attempted, because a producer-side ceiling was
624
- * hit*. That fits a file skipped for its size **and** the single collective
625
- * entry a sync emits when it stops naming individual failures. Filing both
626
- * under one kind puts two facts on one key, each overwriting the other.
631
+ * A reason without numbers is not one a caller can act on. *"Refused"* does
632
+ * not answer whether to delete an old sync or shrink the mesh;
633
+ * `store_bytes`, `used_bytes` and `size_bytes` do.
627
634
  *
628
- * A reason without numbers is not one a caller can act on. *"Too large"*
629
- * does not answer whether to shrink the mesh or raise the limit;
630
- * `limit_bytes` and `size_bytes` do.
631
- *
632
- * Absent for every other kind — a forced `details: null` on every
633
- * `unresolvable` buys nothing. The pairing is **enforced** below, not merely
634
- * described: a field whose rule lives only in a comment is a request.
635
+ * **Present only on `refused`, and not on every `refused`.** The other half
636
+ * of that kind is the single collective entry a sync emits when it stops
637
+ * naming individual failures, and no store number describes it — requiring
638
+ * details there would mean inventing them. So the enforcement below is the
639
+ * half that can be enforced: details belong to `refused` and to nothing
640
+ * else. A forced `details: null` on every `unresolvable` buys nothing.
635
641
  */
636
- details: assetTooLargeDetails.nullish().meta({
637
- description: "The two numbers behind a `too_large` failure, and absent for every other kind \u2014 a forced `null` on every `unresolvable` entry buys nothing. The pairing is enforced, not merely described."
642
+ details: assetStoreRefusedDetails.nullish().meta({
643
+ description: "The three numbers behind a `refused` entry the robot's store had no room for, and absent for every other kind \u2014 a forced `null` on every `unresolvable` entry buys nothing. A `refused` entry may also carry no details: the producer's own ceiling is the other half of that kind, and no store number describes it."
638
644
  })
639
645
  }).superRefine((f, ctx) => {
640
- if (f.kind === "too_large" && f.details == null) {
641
- ctx.addIssue({ code: "custom", path: ["details"], message: "`too_large` without limit_bytes/size_bytes says nothing a developer can act on" });
642
- }
643
- if (f.kind !== "too_large" && f.details != null) {
644
- ctx.addIssue({ code: "custom", path: ["details"], message: "size details belong to `too_large` only" });
646
+ if (f.kind !== "refused" && f.details != null) {
647
+ ctx.addIssue({ code: "custom", path: ["details"], message: "store details belong to `refused` only" });
645
648
  }
646
649
  });
647
650
  var assetSyncState = z3.enum(["running", "succeeded", "failed"]);
@@ -687,6 +690,36 @@ var assetSyncStatus = z3.object({
687
690
  reason: z3.string().min(1).nullable().meta({
688
691
  description: "Why the sync ended as it did, when that is not a per-reference fact. `null` when `failed` already says everything there is to say."
689
692
  }),
693
+ /**
694
+ * **What the receiver counted, next to what the producer claimed.**
695
+ *
696
+ * `state` is the bridge's own terminal frame and nothing else. A dev stack
697
+ * with no object store answered `500` to every upload and the sync still
698
+ * read `succeeded` — the producer had genuinely sent every file, and no
699
+ * one had asked the store. These two numbers are the cloud's own count,
700
+ * taken after the terminal frame: how many of the announced files
701
+ * (`assets_available`'s URDF and mesh list) its store actually holds.
702
+ *
703
+ * They are a pair because neither alone answers anything. `stored` without
704
+ * `announced` cannot say whether four files is all of them or a tenth of
705
+ * them, and `announced` alone is what the producer said it had, which is
706
+ * the claim under examination.
707
+ *
708
+ * A partial store is still `succeeded`: some meshes were never going to
709
+ * resolve, and the per-reference `failed` entries say which. An empty one
710
+ * under a `succeeded` frame is `failed`, because no transport succeeds at
711
+ * nothing.
712
+ *
713
+ * **`stored` is `null` while the sync is still running.** The count is
714
+ * taken once, after the robot's terminal frame; a `0` before then would
715
+ * say the store is empty when nobody has looked.
716
+ */
717
+ stored: z3.number().int().nonnegative().nullable().meta({
718
+ description: "How many of the announced files the cloud's store actually holds. Counted once, after the robot reports the sync done, and `null` until then \u2014 nobody has looked yet. Read it against `announced`: `state` is what the robot reported, this is what arrived."
719
+ }),
720
+ announced: z3.number().int().nonnegative().meta({
721
+ description: "How many files the robot announced for this sync \u2014 the URDF, if it has one, plus every mesh URI its description references. `0` when the robot announced nothing, and also `0` until it has answered at all: read it beside `stored`, which stays `null` until the terminal frame."
722
+ }),
690
723
  started_at: z3.iso.datetime().meta({
691
724
  description: "When the sync started, as an ISO 8601 timestamp."
692
725
  }),
@@ -730,6 +763,43 @@ var assetListResponse = z3.object({
730
763
  */
731
764
  urdf_available: z3.boolean().nullable().meta({
732
765
  description: 'What the connected bridge says it *could* transfer \u2014 deliberately separate from what has been transferred. `null` when no bridge is connected, distinct from `false`: "no robot is online to ask" and "the robot has no URDF" send a developer to different places. After a publisher is killed rather than shut down this can read `true` for some seconds, on the underlying DDS liveliness timeout rather than on any check made here.'
766
+ }),
767
+ /**
768
+ * **How full this robot's store is, on the list that already names what is
769
+ * in it.** A page showing assets is the page where "will the next sync fit"
770
+ * is asked, and a second round trip to some quota endpoint would answer it
771
+ * about the organisation instead — which is a different number about a
772
+ * different thing.
773
+ */
774
+ store: z3.object({
775
+ bytes: z3.number().int().positive().meta({
776
+ description: "The robot's asset store, `ROBOT_ASSET_STORE_BYTES`."
777
+ }),
778
+ used_bytes: z3.number().int().nonnegative().meta({
779
+ description: "Bytes its assets occupy."
780
+ })
781
+ }).meta({ description: "How full this robot's store is." }),
782
+ /**
783
+ * The datapoint that moves the joints in a renderer, chosen by a developer
784
+ * and stored on the robot. It rides on this list because a client that has
785
+ * just fetched the URDF and the meshes needs exactly one more thing to
786
+ * animate them, and asking a second endpoint for one slug is a round trip
787
+ * that buys nothing.
788
+ *
789
+ * `null` is an ordinary answer: none was ever chosen, or a publish removed
790
+ * the datapoint it named and the cloud cleared the mapping rather than
791
+ * leave it pointing at something that no longer qualifies.
792
+ */
793
+ joint_state_slug: slug.nullable().meta({
794
+ description: "The whole-message `sensor_msgs/msg/JointState` datapoint that drives the console's URDF viewer; null when none is chosen or a publish removed it. Set through `PUT /api/robots/:id/urdf/joint-state`."
795
+ })
796
+ });
797
+ var assetsClearResponse = z3.object({
798
+ deleted: z3.number().int().nonnegative().meta({
799
+ description: "How many assets \u2014 URDF, meshes and textures together \u2014 were removed."
800
+ }),
801
+ bytes_freed: z3.number().int().nonnegative().meta({
802
+ description: "The bytes the robot's store got back."
733
803
  })
734
804
  });
735
805
  var missingAssetQuery = z3.object({
@@ -743,10 +813,10 @@ var assetSyncBusyDetails = z3.object({
743
813
  started_at_ms: z3.number().int().nonnegative()
744
814
  });
745
815
 
746
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/config.js
816
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/config.js
747
817
  import { z as z5 } from "zod";
748
818
 
749
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/alerts.js
819
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/alerts.js
750
820
  import { z as z4 } from "zod";
751
821
  var alertRowCondition = z4.discriminatedUnion("kind", [
752
822
  z4.strictObject({
@@ -816,7 +886,7 @@ var putDatapointDisplayRequest = z4.object({
816
886
  y_max: z4.number().finite().nullable()
817
887
  }).strict();
818
888
 
819
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/config.js
889
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/config.js
820
890
  var RTSP_URL_RULE = "The URL has to begin with `rtsp://` or `rtsps://` \u2014 `rtsp://cam-1.plant.local/stream1`. No other scheme is accepted: the bridge opens this with a library that would equally honour `file:`.";
821
891
  var MJPEG_URL_RULE = "The URL has to begin with `http://` or `https://` \u2014 `http://cam-1.plant.local/video.mjpg`. No other scheme is accepted: the bridge opens this with a library that would equally serve `file:`.";
822
892
  var DEVICE_PATH_RULE = "A capture device is a path under `/dev/`, and the character straight after it is a letter or a digit \u2014 `/dev/video0`, or a stable `/dev/v4l/by-id/...` symlink. Nothing outside `/dev/` is accepted: the string reaches OpenCV, which would as happily open an ordinary file.";
@@ -1207,6 +1277,9 @@ var datapointConfig = strictObject({
1207
1277
  description: "A ceiling on how often this datapoint is sent, in hertz. Omitted or `0` means no throttling. It is **a ceiling, not a clock**: a slow topic stays slow, a value is never repeated to manufacture a rate, and within a window the newest value wins. The bridge enforces it, so the robot's bandwidth is genuinely saved.",
1208
1278
  examples: [2, 0.5]
1209
1279
  }).optional(),
1280
+ low_bandwidth: z5.literal("keep").optional().meta({
1281
+ description: "`keep` exempts this datapoint from the low-bandwidth rate cap; its backfill still pauses."
1282
+ }),
1210
1283
  description: serviceDescription.meta({
1211
1284
  description: "Prose about what this value is, for whoever meets it in the console. It changes nothing the robot does, so a publish that touches only it pushes no configuration at all \u2014 but it is carried verbatim into `robot_describe`, where a model that has never seen this robot reads it. The datapoint is offered whenever the role grants it; without one it is offered with `description: null` and the model has less to go on, as for actions, services, publishers and cameras. Omission is the only way to say nothing; an empty string is refused, here and on all five.",
1212
1285
  /**
@@ -1804,6 +1877,34 @@ var cameraConfig = strictObject({
1804
1877
  /** The value position of one entry — `front: ▮` under `cameras:`. */
1805
1878
  defaultSnippets: [CAMERA_SNIPPET]
1806
1879
  });
1880
+ var lowBandwidthMode = z5.enum(["auto", "on", "off"]);
1881
+ var lowBandwidthCamera = z5.enum(["reduce", "stop"]);
1882
+ var lowBandwidthSection = strictObject({
1883
+ mode: lowBandwidthMode.optional().meta({
1884
+ description: "`auto` decides from the measured lag; `on` and `off` force the mode, for tests and for an operator who knows the link.",
1885
+ enumDescriptions: describeValues(lowBandwidthMode.options, {
1886
+ auto: "The bridge enters and leaves the mode on its own, from the lag the cloud reports and the dwell it measures in its own send queue. The setting to leave alone.",
1887
+ on: "The mode is held on, whatever the link is doing. For a robot on a link known to be poor, and for a test that would otherwise have to wait for a real one.",
1888
+ off: "The mode never engages, whatever the link is doing. The robot then sends at its configured rates over a link that cannot carry them, which is a choice and not a default."
1889
+ })
1890
+ }),
1891
+ enter_lag_ms: z5.number().int().min(100).optional().meta({ description: "Lag or queue dwell above this enters the mode. Checked against exit_lag_ms only when both are in this document; a lone key composes with the bridge's parameter or the default on the robot, and a crossed pair is refused there when the configuration is applied, so name both when you change either." }),
1892
+ enter_after_s: z5.number().int().min(1).optional().meta({ description: "The entry condition must hold this long." }),
1893
+ exit_lag_ms: z5.number().int().min(0).optional().meta({ description: "Lag and dwell both at or below this leave the mode. Must be at or below enter_lag_ms: a crossed pair is a mode that leaves as it arrives. Checked here only when both keys are present; a lone key is checked on the robot against the parameter or default it composes with." }),
1894
+ exit_after_s: z5.number().int().min(1).optional().meta({ description: "The exit condition must hold this long." }),
1895
+ datapoint_max_hz: z5.number().gt(0).max(20).optional().meta({ description: "The long-run rate for every datapoint in the mode, unless the datapoint says `low_bandwidth: keep`. It is an average, not a minimum gap: after a quiet spell two samples may go out close together, and over any longer window the rate holds." }),
1896
+ camera: lowBandwidthCamera.optional().meta({
1897
+ description: "What happens to a running stream in the mode. New streams are refused either way.",
1898
+ enumDescriptions: describeValues(lowBandwidthCamera.options, {
1899
+ reduce: "A running stream is re-encoded at `camera_bitrate_kbps` and keeps running. A viewer sees a worse picture rather than none.",
1900
+ stop: "A running stream ends and the viewer is told why. The uplink is then free for datapoints, which is the right trade where video is the nice-to-have."
1901
+ })
1902
+ }),
1903
+ camera_bitrate_kbps: z5.number().int().min(50).max(2e4).optional().meta({ description: "Bitrate applied to running streams under `reduce`." })
1904
+ }).superRefine((s, ctx) => {
1905
+ if (s.enter_lag_ms !== void 0 && s.exit_lag_ms !== void 0 && s.exit_lag_ms > s.enter_lag_ms)
1906
+ ctx.addIssue({ code: "custom", path: ["exit_lag_ms"], message: "low_bandwidth.exit_lag_ms must be at or below enter_lag_ms" });
1907
+ });
1807
1908
  var FLEETLESS_FORMAT_VERSION = 1;
1808
1909
  var capped = (entry, max, what) => slugKeyed(entry).refine((m) => Object.keys(m).length <= max, { message: `at most ${max} ${what}` });
1809
1910
  var robotConfigDoc = strictObject({
@@ -1815,28 +1916,42 @@ var robotConfigDoc = strictObject({
1815
1916
  defaultSnippets: [underSlug("${1:drive}", SHARED_MESSAGE_SNIPPET)]
1816
1917
  }).optional(),
1817
1918
  datapoints: capped(datapointConfig, 200, "datapoints").meta({
1818
- description: "Values the robot publishes, each one field of one topic or a whole topic, and **never several topics**. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state`, `robot_details` and `bridge_pressure` are built-in, and `history` is reserved because `GET \u2026/jobs/history` would shadow an action of that name; all four are refused when the document is validated.",
1919
+ description: "Values the robot publishes, each one field of one topic or a whole topic, and **never several topics**. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state` and `robot_details` are built-in, and `history` is reserved because `GET \u2026/jobs/history` would shadow an action of that name; all three are refused when the document is validated.",
1819
1920
  defaultSnippets: [
1820
1921
  underSlug("${1:battery_voltage}", DATAPOINT_SNIPPET),
1821
1922
  underSlug("${1:battery}", NUMERIC_DATAPOINT_SNIPPET)
1822
1923
  ]
1823
1924
  }).optional(),
1824
1925
  actions: capped(actionConfig, 200, "actions").meta({
1825
- description: "Things the robot does on request that take time, each reported as a job with progress. **At most one job runs per action slug**: a second call is refused `busy`, and every observer of that slug watches the same job. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state`, `robot_details` and `bridge_pressure` are built-in, and `history` is reserved because `GET \u2026/jobs/history` would shadow an action of that name; all four are refused when the document is validated.",
1926
+ description: "Things the robot does on request that take time, each reported as a job with progress. **At most one job runs per action slug**: a second call is refused `busy`, and every observer of that slug watches the same job. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state` and `robot_details` are built-in, and `history` is reserved because `GET \u2026/jobs/history` would shadow an action of that name; all three are refused when the document is validated.",
1826
1927
  defaultSnippets: [underSlug("${1:navigate}", ACTION_SNIPPET)]
1827
1928
  }).optional(),
1828
1929
  services: capped(serviceConfig, 200, "services").meta({
1829
- description: "ROS service calls the robot answers \u2014 one request, one reply. Unlike an action a service reports **no progress** and the call returns with its result already on the job, so there is nothing left to observe; a second concurrent call is still refused `busy`, exactly as for an action. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state`, `robot_details` and `bridge_pressure` are built-in, and `history` is reserved because `GET \u2026/jobs/history` would shadow an action of that name; all four are refused when the document is validated.",
1930
+ description: "ROS service calls the robot answers \u2014 one request, one reply. Unlike an action a service reports **no progress** and the call returns with its result already on the job, so there is nothing left to observe; a second concurrent call is still refused `busy`, exactly as for an action. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state` and `robot_details` are built-in, and `history` is reserved because `GET \u2026/jobs/history` would shadow an action of that name; all three are refused when the document is validated.",
1830
1931
  defaultSnippets: [underSlug("${1:reset_odometry}", SERVICE_SNIPPET)]
1831
1932
  }).optional(),
1832
1933
  publishers: capped(publisherConfig, 200, "publishers").meta({
1833
- description: "Topics clients may send to, and where the format's whole safety story lives. The `message` template fixes every value a caller cannot change, and **`failsafe` is required**: once a client falls silent the bridge sends the failsafe message itself, so an operator whose window closed does not leave a robot driving. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state`, `robot_details` and `bridge_pressure` are built-in, and `history` is reserved because `GET \u2026/jobs/history` would shadow an action of that name; all four are refused when the document is validated.",
1934
+ description: "Topics clients may send to, and where the format's whole safety story lives. The `message` template fixes every value a caller cannot change, and **`failsafe` is required**: once a client falls silent the bridge sends the failsafe message itself, so an operator whose window closed does not leave a robot driving. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state` and `robot_details` are built-in, and `history` is reserved because `GET \u2026/jobs/history` would shadow an action of that name; all three are refused when the document is validated.",
1834
1935
  defaultSnippets: [underSlug("${1:drive}", PUBLISHER_SNIPPET)]
1835
1936
  }).optional(),
1836
1937
  cameras: capped(cameraConfig, 50, "cameras").meta({
1837
- description: "Video the robot streams, and the still frames the cloud serves from it. `width`, `height`, `fps` and `bitrate_kbps` are what **the bridge produces before sending**, not what the camera captures \u2014 they live in the configuration rather than in a viewer's request precisely so that no viewer can make a robot send more. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state`, `robot_details` and `bridge_pressure` are built-in, and `history` is reserved because `GET \u2026/jobs/history` would shadow an action of that name; all four are refused when the document is validated.",
1938
+ description: "Video the robot streams, and the still frames the cloud serves from it. `width`, `height`, `fps` and `bitrate_kbps` are what **the bridge produces before sending**, not what the camera captures \u2014 they live in the configuration rather than in a viewer's request precisely so that no viewer can make a robot send more. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state` and `robot_details` are built-in, and `history` is reserved because `GET \u2026/jobs/history` would shadow an action of that name; all three are refused when the document is validated.",
1838
1939
  defaultSnippets: [underSlug("${1:front}", CAMERA_SNIPPET)]
1839
- }).optional()
1940
+ }).optional(),
1941
+ low_bandwidth: lowBandwidthSection.optional().meta({
1942
+ description: "Overrides for the bridge's low-bandwidth mode; see the section schema.",
1943
+ /**
1944
+ * The body is the three keys a developer actually reaches for — when the
1945
+ * mode engages, how hard it caps, and what it does to video. The five
1946
+ * timing keys stay out: they exist to be tuned once against a measured
1947
+ * link, and a skeleton that pre-fills them reads as a recommendation.
1948
+ */
1949
+ defaultSnippets: [{
1950
+ label: "low-bandwidth mode, tuned",
1951
+ description: "Enters after ten seconds above two seconds of lag, caps every datapoint to 1 Hz and lets running streams continue at a reduced bitrate.",
1952
+ body: { enter_lag_ms: 2e3, datapoint_max_hz: 1, camera: "reduce" }
1953
+ }]
1954
+ })
1840
1955
  });
1841
1956
  var validationIssue = z5.object({
1842
1957
  path: z5.string().min(1),
@@ -1863,7 +1978,7 @@ var configState = z5.object({
1863
1978
  applied_errors: z5.array(applyError).nullable()
1864
1979
  });
1865
1980
 
1866
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/introspection.js
1981
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/introspection.js
1867
1982
  import { z as z6 } from "zod";
1868
1983
  var rosGraphEntry = z6.object({
1869
1984
  name: rosName,
@@ -1902,9 +2017,11 @@ var typeDefinition = z6.discriminatedUnion("kind", [
1902
2017
  })
1903
2018
  ]);
1904
2019
 
1905
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/jobs.js
2020
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/jobs.js
1906
2021
  import { z as z7 } from "zod";
1907
- var jobState = z7.enum(["running", "succeeded", "failed", "cancelled", "lost"]);
2022
+ var jobState = z7.enum(["running", "unknown", "succeeded", "failed", "cancelled", "lost"]);
2023
+ var reportedJobState = jobState.exclude(["unknown"]);
2024
+ var jobOrigin = z7.enum(["fleetless", "external"]);
1908
2025
  var job = z7.object({
1909
2026
  id: z7.uuid().meta({
1910
2027
  description: "The job's id, minted by the cloud when the invocation is accepted. Informative \u2014 state is observed by slug; a cancel names this id to stop one specific job rather than whatever is running."
@@ -1914,7 +2031,10 @@ var job = z7.object({
1914
2031
  description: "The action or service this job is running, as the published configuration exposes it. One slug carries one job at a time, so every observer of that slug sees the same one."
1915
2032
  }),
1916
2033
  state: jobState.meta({
1917
- description: "Where the job stands: `running`, `succeeded`, `failed`, `cancelled` or `lost`. `lost` is a real outcome \u2014 the bridge restarted mid-job and the result is gone \u2014 stated rather than left reading `running` by default."
2034
+ description: "Where the job stands: `running`, `unknown`, `succeeded`, `failed`, `cancelled` or `lost`. `unknown` is not an outcome \u2014 the robot went offline or silent and the cloud does not know yet; the slug stays occupied and the bridge's next statement resolves it, `error` naming why the cloud lost sight of it. `lost` is final: the bridge stated it does not know the job and nothing else runs on its action, or the action server vanished mid-goal."
2035
+ }),
2036
+ origin: jobOrigin.meta({
2037
+ description: "Who started this job. `fleetless` for everything minted by the cloud; `external` for a goal the bridge found active on a published action without having sent it \u2014 no parameters, no starter, never written to `job_runs`."
1918
2038
  }),
1919
2039
  started_at: z7.iso.datetime().meta({
1920
2040
  description: "When the cloud minted this job, as an ISO 8601 timestamp. For a job adopted from a reconnecting bridge, this is **adoption time**, not the real start \u2014 the cloud never minted it."
@@ -1933,7 +2053,7 @@ var job = z7.object({
1933
2053
  * exists for the same reason on the audit log.
1934
2054
  *
1935
2055
  * **Scoped honestly: per cloud process, per run.** Job state lives in memory
1936
- * — that is why `lost` exists at all — so this counter restarts when
2056
+ * — that is why `unknown` and `lost` exist at all — so this counter restarts when
1937
2057
  * the cloud does, alongside the jobs it orders. Sound, because it only ever
1938
2058
  * orders jobs that coexist in one registry — and stated, because a reader
1939
2059
  * who assumed `auditEvent.seq`'s durable semantics would be wrong.
@@ -1968,7 +2088,7 @@ var job = z7.object({
1968
2088
  description: "The structured payload belonging to `code`, for the codes that document one \u2014 `job_queue_full` carries its `limit` and its `queued` count here. Absent for a failure with nothing structured to add, which is most of them."
1969
2089
  })
1970
2090
  }).nullable().meta({
1971
- description: "Why the job failed: a human `message`, a `code` where one exists, and `details` for the codes that carry a documented payload. `null` unless `state` is `failed`."
2091
+ description: "Why the job failed, or why the cloud does not know how it stands: a human `message`, a `code` where one exists, and `details` for the codes that carry a documented payload. Set on `failed` and `lost`, and on `unknown` \u2014 where `code` is `bridge_disconnected` or `bridge_timeout`, the cloud's own reason for not knowing, cleared when the bridge reports the job running again."
1972
2092
  })
1973
2093
  });
1974
2094
  var jobEvent = z7.object({
@@ -2027,16 +2147,16 @@ var jobRun = z7.object({
2027
2147
  description: "Whether the slug was an `action` or a `service`."
2028
2148
  }),
2029
2149
  state: jobState.meta({
2030
- description: "How the run ended, or `running` while it is still going. `lost` means the bridge restarted mid-run and the outcome is unknowable rather than unknown."
2150
+ description: "How the run ended, or `running` while it is still going. `unknown` while the robot has not accounted for it \u2014 offline or silent \u2014 and updated once the bridge says how it stands. `lost` is final: the bridge did not know the run and nothing else ran on its action, so the outcome is unknowable rather than unknown."
2031
2151
  }),
2032
2152
  started_at: z7.iso.datetime().meta({
2033
2153
  description: "When the run started, as an ISO 8601 timestamp. Runs are listed and filtered by this instant."
2034
2154
  }),
2035
2155
  ended_at: z7.iso.datetime().nullable().meta({
2036
- description: "When the run finished, as an ISO 8601 timestamp. `null` while it is still `running` \u2014 a run has an end only once it has one."
2156
+ description: "When the run finished, as an ISO 8601 timestamp. `null` while it is still `running` or `unknown` \u2014 a run has an end only once it has one."
2037
2157
  }),
2038
2158
  duration_ms: z7.number().int().nonnegative().nullable().meta({
2039
- description: 'How long the run took, in milliseconds. `null` while it is still `running`, never `0` standing in for "nothing so far".'
2159
+ description: 'How long the run took, in milliseconds. `null` while it is still `running` or `unknown`, never `0` standing in for "nothing so far".'
2040
2160
  }),
2041
2161
  result: z7.unknown().nullable().meta({
2042
2162
  description: "What the action or service returned once it succeeded, shaped by ROS itself. `null` otherwise."
@@ -2086,7 +2206,7 @@ var jobRunQuery = z7.object({
2086
2206
  description: "Only runs of this action or service."
2087
2207
  }),
2088
2208
  state: jobState.optional().meta({
2089
- description: "Only runs in this state \u2014 `running`, `succeeded`, `failed`, `cancelled` or `lost`."
2209
+ description: "Only runs in this state \u2014 `running`, `unknown`, `succeeded`, `failed`, `cancelled` or `lost`."
2090
2210
  }),
2091
2211
  kind: jobRunKind.optional().meta({
2092
2212
  description: "Only `action` runs, or only `service` runs."
@@ -2120,13 +2240,14 @@ var jobRunSummary = z7.object({
2120
2240
  since_ms: z7.number().int().nonnegative()
2121
2241
  });
2122
2242
 
2123
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/protocol.js
2243
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/protocol.js
2244
+ var DAY_MS = 24 * 60 * 60 * 1e3;
2124
2245
  var MAX_PATIENCE_MS = 12e4;
2125
2246
  var MIN_PATIENCE_MS = 1e3;
2126
2247
  var activeJob = z8.object({
2127
2248
  job_id: z8.uuid(),
2128
2249
  slug,
2129
- state: jobState
2250
+ state: reportedJobState
2130
2251
  });
2131
2252
  var bridgeHello = z8.object({
2132
2253
  type: z8.literal("hello"),
@@ -2137,16 +2258,14 @@ var bridgeHello = z8.object({
2137
2258
  * Every job this bridge still knows about, right now.
2138
2259
  *
2139
2260
  * A reconnect and a restart look **identical** on the wire — same token,
2140
- * same version, same frame — but must end differently: after a dropped
2141
- * connection the running jobs are still running, after a restart their
2142
- * results are gone forever. Enumerating what the bridge still has settles
2143
- * it without either side guessing: the cloud marks every job it believed
2144
- * running that is *not* named here as `lost`.
2145
- *
2146
- * Deliberately needs no persistence at the bridge: a live process lists its
2147
- * live jobs, a process that just started lists none — exactly the truth
2148
- * the cloud needs. A breadcrumb file would only add a window in which the
2149
- * crash beat the write.
2261
+ * same version, same frame — so the bridge enumerates what it still has:
2262
+ * its live jobs, and after a restart every job whose goal it recognised
2263
+ * again from its persisted job-to-goal mapping. A job the cloud holds
2264
+ * `running` or `unknown` that is *not* named here is "not known to the
2265
+ * bridge"; it becomes `lost` (`job_unknown_to_bridge`) only once the
2266
+ * bridge's goal reports show its action free of goals it cannot
2267
+ * attribute — one of those may be that very job — and at once for a
2268
+ * service job, which has no goals to look at.
2150
2269
  *
2151
2270
  * Defaulted, so a bridge that sends no such field still parses; no jobs
2152
2271
  * and no report both mean the same thing to the cloud: nothing to keep
@@ -2156,7 +2275,20 @@ var bridgeHello = z8.object({
2156
2275
  });
2157
2276
  var cloudHelloOk = z8.object({
2158
2277
  type: z8.literal("hello_ok"),
2159
- robot_id: z8.uuid()
2278
+ robot_id: z8.uuid(),
2279
+ protocol: z8.object({
2280
+ status: z8.enum(["current", "deprecated"]).meta({
2281
+ description: "`current` or `deprecated` \u2014 never `unsupported`, which is a `hello_error`."
2282
+ }),
2283
+ sunset_at: z8.iso.date().nullable().meta({
2284
+ description: "ISO date a deprecated version stops being served; `null` when current."
2285
+ })
2286
+ }).optional().meta({ description: "The cloud's verdict on the announced protocol version; absent from an older cloud." }),
2287
+ bridge: z8.object({
2288
+ latest_version: z8.string().min(1).meta({
2289
+ description: "The newest published fleetless-bridge package version, for the bridge's own upgrade hint."
2290
+ })
2291
+ }).optional().meta({ description: "What the cloud knows about bridge packages; absent from an older cloud." })
2160
2292
  });
2161
2293
  var cloudHelloError = z8.object({
2162
2294
  type: z8.literal("hello_error"),
@@ -2167,16 +2299,25 @@ var datapointFrame = z8.object({
2167
2299
  type: z8.literal("datapoint"),
2168
2300
  slug,
2169
2301
  value: z8.unknown(),
2170
- timestamp_ms: z8.number().int().nonnegative()
2302
+ timestamp_ms: z8.number().int().nonnegative(),
2303
+ backfill: z8.boolean().optional().meta({ description: "true when the sample was captured while the bridge was disconnected and is being replayed after the reconnect. The cloud keeps such a sample out of its lag measure; absent means live." })
2171
2304
  });
2172
2305
  var cloudPing = z8.object({
2173
2306
  type: z8.literal("ping"),
2174
- ts_ms: z8.number().int().nonnegative()
2307
+ ts_ms: z8.number().int().nonnegative(),
2308
+ latency_ms: z8.number().nonnegative().nullable().meta({ description: "Round trip of the last pong in milliseconds; null before the first." }),
2309
+ lag_ms: z8.number().nonnegative().nullable().meta({ description: "Datapoint lag over the link: median of the last five seconds minus the ten-minute minimum, in milliseconds; null until a sample exists, and null again whenever no live sample arrived in the last five seconds, because a stale median would be a lie." })
2175
2310
  });
2176
2311
  var bridgePong = z8.object({
2177
2312
  type: z8.literal("pong"),
2178
2313
  ts_ms: z8.number().int().nonnegative()
2179
2314
  });
2315
+ var bridgeLinkMode = z8.object({
2316
+ type: z8.literal("link_mode"),
2317
+ low_bandwidth: z8.boolean().meta({ description: "Whether the mode is active after this transition." }),
2318
+ reason: z8.enum(["lag", "dwell", "forced", "recovered"]).meta({ description: "`lag`: the cloud-measured lag crossed the threshold; `dwell`: the bridge-measured queue dwell did; `forced`: `mode: on` or `off`; `recovered`: both measures stayed at or below the exit threshold." }),
2319
+ at_ms: z8.number().int().nonnegative().meta({ description: "Bridge time of the transition, epoch milliseconds." })
2320
+ });
2180
2321
  var cloudConfig = z8.object({
2181
2322
  type: z8.literal("config"),
2182
2323
  version: z8.number().int().nonnegative(),
@@ -2230,9 +2371,27 @@ var cloudInvoke = z8.object({
2230
2371
  });
2231
2372
  var cloudCancel = z8.object({
2232
2373
  type: z8.literal("cancel"),
2374
+ request_id: z8.string().min(1).max(64),
2233
2375
  slug,
2234
2376
  job_id: z8.uuid().nullable()
2235
2377
  });
2378
+ var cancelReturnCode = z8.number().int().min(0).max(3);
2379
+ var bridgeCancelResultEntry = z8.object({
2380
+ /** The job the goal belongs to — the bridge's own, or an external goal's derived id. */
2381
+ job_id: z8.uuid(),
2382
+ /** The ROS 2 goal id the cancel was sent for. */
2383
+ goal_id: z8.string().min(1),
2384
+ /** `null` when the action server did not answer the cancel request within the bridge's own bound. */
2385
+ return_code: cancelReturnCode.nullable()
2386
+ });
2387
+ var bridgeCancelResult = z8.object({
2388
+ type: z8.literal("cancel_result"),
2389
+ request_id: z8.string().min(1).max(64),
2390
+ slug,
2391
+ goals: z8.array(bridgeCancelResultEntry),
2392
+ /** Set when the bridge could not send the cancel at all (no such slug, a service, the server gone). */
2393
+ error: z8.object({ code: z8.string().min(1), message: z8.string().min(1) }).nullable()
2394
+ });
2236
2395
  var cloudPublish = z8.object({
2237
2396
  type: z8.literal("publish"),
2238
2397
  slug,
@@ -2252,7 +2411,11 @@ var bridgeJobUpdate = z8.object({
2252
2411
  type: z8.literal("job_update"),
2253
2412
  job_id: z8.uuid(),
2254
2413
  slug,
2255
- state: jobState,
2414
+ /** Never `unknown`: that is the cloud's word for not having heard. */
2415
+ state: reportedJobState,
2416
+ origin: jobOrigin,
2417
+ /** The ROS 2 goal id; `null` for a service job, which has no goal. */
2418
+ goal_id: z8.string().min(1).nullable(),
2256
2419
  feedback: z8.unknown().nullable(),
2257
2420
  progress: z8.number().min(0).max(1).nullable(),
2258
2421
  result: z8.unknown().nullable(),
@@ -2262,7 +2425,29 @@ var bridgeJobUpdate = z8.object({
2262
2425
  });
2263
2426
  var bridgeJobLost = z8.object({
2264
2427
  type: z8.literal("job_lost"),
2265
- job_ids: z8.array(z8.uuid())
2428
+ job_ids: z8.array(z8.uuid()),
2429
+ error: z8.object({ code: z8.string().min(1), message: z8.string().min(1) }).optional()
2430
+ });
2431
+ var cloudJobQuery = z8.object({
2432
+ type: z8.literal("job_query"),
2433
+ request_id: z8.string().min(1).max(64),
2434
+ job_ids: z8.array(z8.uuid()).min(1)
2435
+ });
2436
+ var bridgeJobStatusEntry = z8.object({
2437
+ job_id: z8.uuid(),
2438
+ /** Never `unknown` — the bridge only ever states a definite fact about a job it recognises. */
2439
+ state: reportedJobState,
2440
+ feedback: z8.unknown().nullable(),
2441
+ progress: z8.number().min(0).max(1).nullable(),
2442
+ result: z8.unknown().nullable(),
2443
+ /** Same shape as `job.error`, `details` included — see `jobs.ts`. */
2444
+ error: z8.object({ code: z8.string().min(1), message: z8.string().min(1), details: z8.unknown().optional() }).nullable()
2445
+ });
2446
+ var bridgeJobStatus = z8.object({
2447
+ type: z8.literal("job_status"),
2448
+ request_id: z8.string().min(1).max(64),
2449
+ jobs: z8.array(bridgeJobStatusEntry),
2450
+ unknown_job_ids: z8.array(z8.uuid())
2266
2451
  });
2267
2452
  var cloudIntrospectRequest = z8.object({
2268
2453
  type: z8.literal("introspect_request"),
@@ -2286,75 +2471,8 @@ var bridgeTypeDefinitions = z8.object({
2286
2471
  });
2287
2472
  var bridgeState = z8.object({
2288
2473
  online: z8.boolean(),
2289
- latency_ms: z8.number().nonnegative().nullable()
2290
- });
2291
- var bridgePressureTier = z8.object({
2292
- sent: z8.number().int().nonnegative(),
2293
- bytes: z8.number().int().nonnegative(),
2294
- drops: z8.number().int().nonnegative(),
2295
- high_water: z8.number().int().nonnegative()
2296
- });
2297
- var bridgePressure = z8.object({
2298
- link: z8.object({
2299
- /** bytes/s the socket demonstrably drains, from sends >= 64 KiB
2300
- * only; null until the first large send of the session. */
2301
- rate_bps: z8.number().nonnegative().nullable(),
2302
- /**
2303
- * the byte target snapshots are currently encoded to fit.
2304
- *
2305
- * `.nonnegative()`, not `.positive()`: the target is derived from
2306
- * `rate_bps`, and a link measured below 0.5 B/s floors to 0 here. A
2307
- * schema that rejects 0 does not prevent that link — it only makes the
2308
- * frame reporting it unparseable, and a console that cannot parse a
2309
- * pressure frame shows "no feed", i.e. reports a struggling robot as an
2310
- * *old* one. Zero is a legitimate reading and says something true.
2311
- */
2312
- snapshot_max_bytes: z8.number().int().nonnegative()
2313
- }),
2314
- /**
2315
- * String keys "0".."5" because JSON has no integer keys. Counters are
2316
- * cumulative per session and reset on reconnect; clients window by
2317
- * differencing two samples.
2318
- *
2319
- * **What this schema does not decide:** it does not guarantee all six
2320
- * keys are present (`z.record` over the six literals is exhaustive in
2321
- * zod 4 — tested here, it required every key and rejected none, the
2322
- * opposite of what a partial sample needs — so this is a
2323
- * `.strictObject().partial()` over the same six literal keys instead, a
2324
- * deliberate deviation from the originally sketched `z.record` shape with
2325
- * the same runtime behaviour). A missing tier key reads as zeros; the
2326
- * schema names what it cannot decide rather than implying a completeness
2327
- * it cannot check.
2328
- */
2329
- tiers: z8.strictObject({
2330
- "0": bridgePressureTier,
2331
- "1": bridgePressureTier,
2332
- "2": bridgePressureTier,
2333
- "3": bridgePressureTier,
2334
- "4": bridgePressureTier,
2335
- "5": bridgePressureTier
2336
- }).partial(),
2337
- video: z8.object({
2338
- active_streams: z8.number().int().nonnegative(),
2339
- bitrate_sum_kbps: z8.number().int().nonnegative(),
2340
- /**
2341
- * The uplink budget the bridge was configured with
2342
- * (`FLEETLESS_UPLINK_KBPS`), or `null` when none was set.
2343
- *
2344
- * `.nonnegative()`, not `.positive()`: `FLEETLESS_UPLINK_KBPS=0` is a
2345
- * documented setting meaning "no video budget at all", and the bridge
2346
- * emits that 0 verbatim. `.positive()` made every frame from such a
2347
- * robot fail the console's `safeParse`, which renders an unparseable
2348
- * frame as "no pressure feed" — so the one robot that had *deliberately*
2349
- * turned video off was the one diagnosed as running a bridge too old to
2350
- * report pressure. A value the producer legitimately sends must parse;
2351
- * `null` is the only "not set" this field has.
2352
- */
2353
- uplink_kbps: z8.number().int().nonnegative().nullable(),
2354
- override_kbps: z8.number().int().nonnegative().nullable(),
2355
- video_budget_kbps: z8.number().int().nonnegative().nullable(),
2356
- reserve_kbps: z8.number().int().nonnegative()
2357
- })
2474
+ latency_ms: z8.number().nonnegative().nullable(),
2475
+ low_bandwidth: z8.boolean().meta({ description: "Whether the bridge is in its low-bandwidth mode: datapoints capped, cameras reduced or stopped. Bridge-reported." })
2358
2476
  });
2359
2477
  var snapshotHeader = z8.object({
2360
2478
  type: z8.literal("snapshot"),
@@ -2518,11 +2636,11 @@ var bridgeCameraState = z8.object({
2518
2636
  request_id: z8.string().min(1).max(64).nullable()
2519
2637
  });
2520
2638
 
2521
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/config-issues.js
2639
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/config-issues.js
2522
2640
  var EXPOSURE_SECTIONS = ["datapoints", "actions", "services", "publishers", "cameras"];
2523
2641
  var EXPOSURE_SECTION_NAMES = new Set(EXPOSURE_SECTIONS);
2524
2642
 
2525
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/rest.js
2643
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/rest.js
2526
2644
  import { z as z9 } from "zod";
2527
2645
  var robot = z9.object({
2528
2646
  id: z9.uuid().meta({
@@ -2548,6 +2666,21 @@ var createRobotResponse = z9.object({
2548
2666
  robot,
2549
2667
  token: robotToken
2550
2668
  });
2669
+ var robotTokenRotateResponse = z9.object({
2670
+ token: robotToken.meta({
2671
+ description: "The robot's new bridge token. Returned exactly once; the previous token stops working at the bridge's next hello."
2672
+ })
2673
+ });
2674
+ var jointStatePutRequest = z9.object({
2675
+ slug: slug.nullable().meta({
2676
+ description: "The datapoint to read joint positions from, or `null` to choose none. It must name a whole-message `sensor_msgs/msg/JointState` datapoint of the published configuration; anything else is a `validation_error` naming the rule."
2677
+ })
2678
+ });
2679
+ var jointStatePutResponse = z9.object({
2680
+ joint_state_slug: slug.nullable().meta({
2681
+ description: "The stored mapping after the call, `null` when none is chosen. The same value `assetListResponse.joint_state_slug` carries."
2682
+ })
2683
+ });
2551
2684
  var exposureCounts = z9.object({
2552
2685
  datapoints: z9.number().int().nonnegative(),
2553
2686
  actions: z9.number().int().nonnegative(),
@@ -2555,11 +2688,15 @@ var exposureCounts = z9.object({
2555
2688
  publishers: z9.number().int().nonnegative(),
2556
2689
  cameras: z9.number().int().nonnegative()
2557
2690
  });
2691
+ var protocolStatusValue = z9.enum(["current", "deprecated", "refused"]);
2558
2692
  var robotListItem = z9.object({
2559
2693
  ...robot.shape,
2560
2694
  bridge_state: bridgeState,
2561
2695
  /** Required, not optional: "we did not look" and "it exposes nothing" must not render the same. */
2562
- exposes: exposureCounts
2696
+ exposes: exposureCounts,
2697
+ protocol_status: protocolStatusValue.optional().meta({
2698
+ description: "Where this robot's bridge stands against the protocol window: `current`, `deprecated` (still served, sunset date on the detail), or `refused` (its last hello was refused for its version; offline until upgraded). Absent from a cloud older than 0.21.0; read absence as `current`."
2699
+ })
2563
2700
  });
2564
2701
  var robotListResponse = z9.object({
2565
2702
  robots: z9.array(robotListItem)
@@ -2576,6 +2713,15 @@ var datapointValue = z9.object({
2576
2713
  var robotDetailResponse = z9.object({
2577
2714
  ...robotListItem.shape,
2578
2715
  bridge_version: z9.string().min(1).nullable(),
2716
+ protocol_version: z9.number().int().positive().nullable().optional().meta({
2717
+ description: "The protocol version the bridge announced in its last accepted hello; `null` before the first. Absent from a cloud older than 0.21.0."
2718
+ }),
2719
+ protocol: z9.object({
2720
+ status: protocolStatusValue.meta({ description: "Same values as `protocol_status`." }),
2721
+ sunset_at: z9.iso.date().nullable().meta({
2722
+ description: "ISO date the announced version stops being served; `null` when current or unknown."
2723
+ })
2724
+ }).optional().meta({ description: "The window verdict for `protocol_version`." }),
2579
2725
  /**
2580
2726
  * Cleared (set back to null) by the next successful hello from this
2581
2727
  * robot's bridge — a warning that outlives the condition it warns
@@ -2629,7 +2775,7 @@ var fetchTypesResponse = z9.object({
2629
2775
  var datapointDescriptor = z9.object({
2630
2776
  slug: slug.meta({ description: "The name a client reads this datapoint by." }),
2631
2777
  builtin: z9.boolean().meta({
2632
- description: "`true` for the datapoints every robot has \u2014 `bridge_state`, `robot_details` and `bridge_pressure` \u2014 and `false` for everything the published configuration adds."
2778
+ description: "`true` for the datapoints every robot has \u2014 `bridge_state` and `robot_details` \u2014 and `false` for everything the published configuration adds."
2633
2779
  }),
2634
2780
  unit: z9.string().nullable().meta({
2635
2781
  description: "The unit the value carries **after** any scale and offset, shown beside the number so nobody has to guess whether `15` means percent, volts or minutes. `null` when the configuration names none."
@@ -2647,7 +2793,7 @@ var datapointDescriptor = z9.object({
2647
2793
  });
2648
2794
  var datapointListResponse = z9.object({
2649
2795
  datapoints: z9.array(datapointDescriptor).meta({
2650
- description: "Everything a client may read on this robot: the three built-ins, plus every datapoint the published configuration exposes and the caller's role grants."
2796
+ description: "Everything a client may read on this robot: the two built-ins, plus every datapoint the published configuration exposes and the caller's role grants."
2651
2797
  })
2652
2798
  });
2653
2799
  var robotDetailsDoc = z9.record(z9.string().regex(/^[a-z][a-z0-9_-]{0,63}$/), z9.union([z9.string().max(4096), z9.number(), z9.boolean(), z9.array(z9.unknown()), z9.record(z9.string(), z9.unknown())]));
@@ -3001,22 +3147,25 @@ var robotDeletionSummary = z9.object({
3001
3147
  bytes_freed: z9.number().int().nonnegative(),
3002
3148
  cameras: z9.array(slug),
3003
3149
  /**
3004
- * Assets destroyed with the robot, and **`asset_bytes_freed` is what
3005
- * this org actually gets back** — not the sum of the assets' sizes.
3150
+ * Assets destroyed with the robot, and **`asset_bytes_freed` is what the
3151
+ * robot's own store gives back** — every distinct mesh or texture blob it
3152
+ * holds, counted once, URDF excluded.
3006
3153
  *
3007
- * Storage is content-addressed, so a mesh two robots share survives the
3008
- * deletion of one of them and frees nothing. Reporting the total would tell
3009
- * a developer they are about to recover 400 MB and hand back 4, on the one
3010
- * screen whose entire justification is naming what an irreversible click
3011
- * destroys. Same reasoning that keeps `cameras` out of `slug_count`: this
3012
- * summary is read aloud to a human, and a number that is nearly right is
3013
- * worse here than an absent one.
3154
+ * Storage is content-addressed, but the store and its 1 GB ceiling are now
3155
+ * per robot: a blob another robot also references stays in the object
3156
+ * store but is still credited here, because each robot's counter carries
3157
+ * it regardless of what else points at the same bytes. Same reasoning that
3158
+ * keeps `cameras` out of `slug_count`: this summary is read aloud to a
3159
+ * human, and a number that is nearly right is worse here than an absent
3160
+ * one.
3014
3161
  *
3015
3162
  * `asset_count` is the plain count of the robot's asset rows, all of which
3016
3163
  * do go away.
3017
3164
  */
3018
3165
  asset_count: z9.number().int().nonnegative(),
3019
- asset_bytes_freed: z9.number().int().nonnegative(),
3166
+ asset_bytes_freed: z9.number().int().nonnegative().meta({
3167
+ description: "What the robot's store gives back: every distinct mesh or texture blob it holds, counted once, URDF excluded; a blob another robot also references stays in the object store but is still credited here, because each robot's counter carries it."
3168
+ }),
3020
3169
  /**
3021
3170
  * How many rows of run history go with the robot — every recorded
3022
3171
  * invocation of one of its actions or services, up to
@@ -3155,39 +3304,13 @@ var orgQuotas = z9.object({
3155
3304
  max_end_users: z9.number().int().positive(),
3156
3305
  max_retention_bytes: z9.number().int().nonnegative(),
3157
3306
  max_retention_writes_per_minute: z9.number().int().nonnegative(),
3158
- max_realtime_connections: z9.number().int().positive(),
3159
- /**
3160
- * Asset storage — **its own dial, not part of `max_retention_bytes`.** A
3161
- * sync grows storage in jumps and time series grow steadily; one dial would
3162
- * let the first crowd out the second, and the org that hit its limit would be
3163
- * told to look at the wrong thing.
3164
- *
3165
- * **Counted per distinct blob *this org references* — not per asset row, and
3166
- * not per object the platform stores on its behalf.** The two readings are
3167
- * indistinguishable from the number alone and a customer is entitled to know
3168
- * which one they are being charged for.
3169
- *
3170
- * Within an org, sharing is free: two robots referencing the same mesh cost
3171
- * one copy, which is what dedup means to a customer, and anything else
3172
- * charges an org twice for a fleet of identical robots — the normal case.
3173
- *
3174
- * **Across orgs, sharing is not free.** Storage stays globally
3175
- * content-addressed (one object per sha256; that efficiency is real), but
3176
- * accounting is per-org: an org is charged for each distinct blob it
3177
- * references and credited when its own last reference goes, whether or not
3178
- * the blob survives for somebody else. Global refcounting would make the
3179
- * first org to sync a blob pay for it forever while every later org stored it
3180
- * free — a quota evadable by anyone whose mesh someone else had already
3181
- * uploaded, and an org's own number would depend on who got there first.
3182
- */
3183
- max_asset_storage_bytes: z9.number().int().nonnegative()
3307
+ max_realtime_connections: z9.number().int().positive()
3184
3308
  });
3185
3309
  var orgQuotaUsageCounts = z9.object({
3186
3310
  max_robots: z9.number().int().nonnegative(),
3187
3311
  max_apps: z9.number().int().nonnegative(),
3188
3312
  max_end_users: z9.number().int().nonnegative(),
3189
3313
  max_retention_bytes: z9.number().int().nonnegative(),
3190
- max_asset_storage_bytes: z9.number().int().nonnegative(),
3191
3314
  max_retention_writes_per_minute: z9.number().int().nonnegative(),
3192
3315
  max_realtime_connections: z9.number().int().nonnegative()
3193
3316
  }).partial();
@@ -3282,13 +3405,13 @@ var slugUsageResponse = z9.object({
3282
3405
  alert_count: z9.number().int().nonnegative()
3283
3406
  });
3284
3407
 
3285
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/realtime.js
3408
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/realtime.js
3286
3409
  import { z as z14 } from "zod";
3287
3410
 
3288
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/client-auth.js
3411
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/client-auth.js
3289
3412
  import { z as z13 } from "zod";
3290
3413
 
3291
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/apps.js
3414
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/apps.js
3292
3415
  import { z as z10 } from "zod";
3293
3416
  var appIdentifier = slug;
3294
3417
  var app = z10.object({
@@ -3348,6 +3471,26 @@ var appListResponse = z10.object({
3348
3471
  description: "Every app of the caller's organisation, oldest first by `created_at`. The org scope is the whole filter \u2014 there is no id to narrow by and nothing to refuse."
3349
3472
  })
3350
3473
  });
3474
+ var appDeletionSummary = z10.object({
3475
+ user_count: z10.number().int().nonnegative().meta({
3476
+ description: "App users deleted with the app. They are the developer's own customers, not Fleetless users, and exist in no other app."
3477
+ }),
3478
+ role_count: z10.number().int().nonnegative().meta({
3479
+ description: "Roles deleted with the app, each with its per-robot slug grants."
3480
+ }),
3481
+ server_key_count: z10.number().int().nonnegative().meta({
3482
+ description: "Server keys deleted with the app. A client still holding one is refused at its next request."
3483
+ }),
3484
+ invitation_count: z10.number().int().nonnegative().meta({
3485
+ description: "Outstanding invitations \u2014 unspent and unexpired \u2014 that will never be accepted."
3486
+ }),
3487
+ oidc_provider_count: z10.number().int().nonnegative().meta({
3488
+ description: "Identity providers configured for this app. The providers themselves are somebody else's; only this app's configuration of them goes."
3489
+ }),
3490
+ mail_template_count: z10.number().int().nonnegative().meta({
3491
+ description: "Custom mail templates, of at most three. A kind using the Fleetless default text is not counted \u2014 there is no row to lose."
3492
+ })
3493
+ });
3351
3494
  var createAppRequest = z10.object({
3352
3495
  name: z10.string().min(1).max(120),
3353
3496
  identifier: appIdentifier,
@@ -3470,10 +3613,10 @@ var rolePermissions = z10.object({
3470
3613
  })
3471
3614
  });
3472
3615
 
3473
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/app-users.js
3616
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/app-users.js
3474
3617
  import { z as z12 } from "zod";
3475
3618
 
3476
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/identity.js
3619
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/identity.js
3477
3620
  import { z as z11 } from "zod";
3478
3621
  var password = z11.string().min(12).max(256);
3479
3622
  var USER_DISPLAY_NAME_MAX = 120;
@@ -3635,7 +3778,7 @@ var authMeResponse = z11.object({ org, user: fleetlessUser });
3635
3778
  var patchOrgRequest = z11.object({ name: z11.string().min(1).max(120) }).strict();
3636
3779
  var patchAuthMeRequest = z11.object({ display_name: z11.string().min(1).max(USER_DISPLAY_NAME_MAX).nullable() }).strict();
3637
3780
 
3638
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/app-users.js
3781
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/app-users.js
3639
3782
  var APP_USER_DISPLAY_NAME_MAX = 120;
3640
3783
  var providerSlug = z12.string().max(40).regex(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/, "must be lowercase and hyphen-separated, starting with a letter");
3641
3784
  var appUserStatus = z12.enum(["pending_verification", "active", "blocked"]);
@@ -3854,7 +3997,9 @@ var appAuthConfig = z12.object({
3854
3997
  }),
3855
3998
  updated_at: z12.iso.datetime().meta({ description: "When the configuration was last written, as an ISO 8601 timestamp." })
3856
3999
  });
3857
- var putAppAuthConfigRequest = appAuthConfig.omit({ oidc_callback_url: true, updated_at: true }).strict();
4000
+ var putAppAuthRegistrationRequest = appAuthConfig.pick({ self_registration: true, allowed_domains: true, allowed_origins: true }).strict();
4001
+ var putAppAuthUrlsRequest = appAuthConfig.pick({ invite_url: true, verify_url: true, reset_url: true }).strict();
4002
+ var putAppAuthMcpRequest = appAuthConfig.pick({ mcp_enabled: true, mcp_login_url: true }).strict();
3858
4003
  var mailTemplateKind = z12.enum(["invite", "verify", "reset"]);
3859
4004
  var appMailTemplate = z12.object({
3860
4005
  kind: mailTemplateKind.meta({ description: "Which of the three mails this template replaces." }),
@@ -3886,7 +4031,7 @@ var mailOutcome = z12.object({
3886
4031
  })
3887
4032
  });
3888
4033
 
3889
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/client-auth.js
4034
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/client-auth.js
3890
4035
  var clientLoginRequest = z13.object({
3891
4036
  app_identifier: appIdentifier.meta({
3892
4037
  description: "The app being logged in to: its globally unique, lowercase, underscore-separated identifier, chosen by the developer at creation. There is no organisation context at login, so this is what decides which app the credentials are checked for."
@@ -4075,7 +4220,7 @@ var clientIdentity = z13.object({
4075
4220
  })
4076
4221
  });
4077
4222
 
4078
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/realtime.js
4223
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/realtime.js
4079
4224
  var clientAuth = z14.object({
4080
4225
  type: z14.literal("auth"),
4081
4226
  token: z14.string().min(1)
@@ -4353,7 +4498,7 @@ var orgEventDropped = z14.object({
4353
4498
  dropped: z14.number().int().positive()
4354
4499
  }).strict();
4355
4500
 
4356
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/client-robots.js
4501
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/client-robots.js
4357
4502
  import { z as z15 } from "zod";
4358
4503
  var clientRobotListItem = z15.object({
4359
4504
  ...robot.shape,
@@ -4370,7 +4515,7 @@ var clientRobotListResponse = z15.object({
4370
4515
  })
4371
4516
  });
4372
4517
 
4373
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/audit.js
4518
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/audit.js
4374
4519
  import { z as z16 } from "zod";
4375
4520
  var auditActor = z16.object({
4376
4521
  kind: z16.enum(["developer", "end_user", "app_user", "server_key", "bridge"]),
@@ -4500,7 +4645,7 @@ var auditListResponse = z16.object({
4500
4645
  next_cursor: z16.number().int().positive().nullable()
4501
4646
  });
4502
4647
 
4503
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/errors.js
4648
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/errors.js
4504
4649
  import { z as z17 } from "zod";
4505
4650
  var apiError = z17.object({
4506
4651
  code: z17.string().min(1),
@@ -4517,7 +4662,7 @@ var parameterInvalidDetails = z17.object({
4517
4662
  violations: z17.array(parameterViolation).min(1)
4518
4663
  });
4519
4664
 
4520
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/oauth.js
4665
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/oauth.js
4521
4666
  import { z as z18 } from "zod";
4522
4667
  var oauthErrorCode = z18.enum([
4523
4668
  "invalid_request",
@@ -4584,7 +4729,7 @@ var dynamicClientRegistrationRequest = z18.object({
4584
4729
  description: "`none`, RFC 7591's value for a public client, and the only value either server registers. Any other value is **refused rather than silently downgraded**: a client that believes it holds a secret and does not has a wrong mental model of its own security. There is no client secret to hold \u2014 mandatory PKCE (`S256`) is the defence."
4585
4730
  }),
4586
4731
  grant_types: z18.array(z18.enum(["authorization_code", "refresh_token"])).optional().meta({
4587
- description: "Accepted for conformance with RFC 7591 and then **ignored**. What comes back is what was actually granted, which \xA73.2.1 permits a server to substitute: `authorization_code` and nothing else, so a client that asks for `refresh_token` is registered and told plainly that it did not get one."
4732
+ description: "Accepted for conformance with RFC 7591 and then **ignored**: both MCP authorization servers grant `authorization_code` and `refresh_token` to every registration, and the answer states what was granted (\xA73.2.1) rather than what was asked."
4588
4733
  }),
4589
4734
  response_types: z18.array(z18.enum(["code"])).optional().meta({
4590
4735
  description: "Accepted for conformance and then **ignored**; the response names `code`, which is the only response type OAuth 2.1 leaves, the implicit grant having been removed."
@@ -4606,7 +4751,7 @@ var dynamicClientRegistrationResponse = z18.object({
4606
4751
  description: "The redirect URIs this registration was accepted for. A code is returned to one of these and nowhere else."
4607
4752
  }),
4608
4753
  grant_types: z18.array(z18.string()).meta({
4609
- description: 'The grants this client may use. Always exactly `["authorization_code"]` \u2014 a client that asked for `refresh_token` is registered and told here that it did not get one, which is the substitution RFC 7591 \xA73.2.1 permits.'
4754
+ description: 'The grants this client may use. Always exactly `["authorization_code", "refresh_token"]` \u2014 an exchange mints a refresh token and the token endpoint rotates it.'
4610
4755
  }),
4611
4756
  response_types: z18.array(z18.string()).meta({
4612
4757
  description: "The response types this client may ask for: `code`."
@@ -4621,9 +4766,9 @@ var dynamicClientRegistrationResponse = z18.object({
4621
4766
  description: "Always `0`, which is RFC 7591's way of saying the client secret never expires \u2014 there is none. The **registration** itself does expire: a self-registered client that never completes a flow is an unauthenticated write somebody left behind."
4622
4767
  })
4623
4768
  });
4624
- var oauthTokenRequest = z18.object({
4769
+ var oauthCodeTokenRequest = z18.object({
4625
4770
  grant_type: z18.literal("authorization_code").meta({
4626
- description: "Always `authorization_code`: this request exchanges the code from the authorize redirect for tokens. Any other value \u2014 `refresh_token` included \u2014 is `unsupported_grant_type`, refused before the code is looked up."
4771
+ description: "`authorization_code`: this request exchanges the code from the authorize redirect for an access token and a refresh token."
4627
4772
  }),
4628
4773
  code: z18.string().min(1).max(500).meta({
4629
4774
  description: "The authorization code from the redirect. It may be exchanged once; a second presentation is `invalid_grant`, the same answer a fabricated code gets."
@@ -4643,6 +4788,25 @@ var oauthTokenRequest = z18.object({
4643
4788
  }).meta({
4644
4789
  description: "RFC 6749 \xA74.1.3's authorization-code exchange with PKCE, as either MCP authorization server reads it. Sent as `application/x-www-form-urlencoded`, per \xA74.1.3, though the server accepts a JSON body too."
4645
4790
  });
4791
+ var oauthRefreshTokenRequest = z18.object({
4792
+ grant_type: z18.literal("refresh_token").meta({
4793
+ description: "`refresh_token`: this request rotates a refresh token into a new access token and a new refresh token. The presented token is consumed; presenting it again revokes the whole session."
4794
+ }),
4795
+ refresh_token: z18.string().min(1).max(500).meta({
4796
+ description: "The refresh token from the last token response. Bound to the client that received it and to one identity space: presented by another client, or at the other MCP server, it is `invalid_grant` and stays unconsumed."
4797
+ }),
4798
+ client_id: z18.string().min(1).max(200).meta({
4799
+ description: "The client the refresh token was issued to, as registered. A refresh token is not transferable between clients."
4800
+ }),
4801
+ resource: z18.url().optional().meta({
4802
+ description: "The resource the new token is for, per RFC 8707. Optional; when named it must be the audience the session was issued for, or the answer is `invalid_target` and the refresh token is left untouched. The successor carries the same audience either way."
4803
+ })
4804
+ }).meta({
4805
+ description: "RFC 6749 \xA76's refresh, as either MCP authorization server reads it. Every use rotates: the answer carries a new refresh token and the presented one is dead."
4806
+ });
4807
+ var oauthTokenRequest = z18.discriminatedUnion("grant_type", [oauthCodeTokenRequest, oauthRefreshTokenRequest]).meta({
4808
+ description: "What an MCP token endpoint accepts: the authorization-code exchange, or a refresh. Any other `grant_type` is `unsupported_grant_type`, refused before a lookup happens."
4809
+ });
4646
4810
  var oauthTokenResponse = z18.object({
4647
4811
  access_token: z18.string().min(1).meta({
4648
4812
  description: "The bearer token. It is the same token the client login mints \u2014 only the envelope differs, because an RFC-compliant client parses this one and knows nothing about Fleetless."
@@ -4654,7 +4818,7 @@ var oauthTokenResponse = z18.object({
4654
4818
  description: "How long the access token is valid, in **seconds**, per RFC 6749 \xA75.1. Not a timestamp, and not milliseconds."
4655
4819
  }),
4656
4820
  refresh_token: z18.string().min(1).optional().meta({
4657
- description: "The refresh token, when one was issued. It rotates on every use."
4821
+ description: "The refresh token. Both MCP token endpoints issue one on every exchange and every refresh; it rotates on every use, lives ninety days from its last use, and dies with the account's sessions \u2014 a block, a password change, a withdrawn consent. The console's own OAuth portal issues none."
4658
4822
  }),
4659
4823
  scope: z18.string().max(500).optional().meta({
4660
4824
  description: "The scopes the issued token actually carries, space-separated."
@@ -4677,7 +4841,7 @@ var authorizationServerMetadata = z18.object({
4677
4841
  description: "The response types this server offers: `code` only, the implicit grant being gone with OAuth 2.1."
4678
4842
  }),
4679
4843
  grant_types_supported: z18.array(z18.enum(["authorization_code", "refresh_token"])).meta({
4680
- description: "The grants this server offers. OAuth 2.1 removes the implicit and password grants, so neither appears here."
4844
+ description: "The grants this server offers: `authorization_code` and `refresh_token`. OAuth 2.1 removes the implicit and password grants, so neither appears here."
4681
4845
  }),
4682
4846
  code_challenge_methods_supported: z18.array(codeChallengeMethod).meta({
4683
4847
  description: "The PKCE challenge methods accepted: `S256` only. `plain` is not offered \u2014 a challenge equal to its verifier defends against nothing, and offering it would make a downgrade negotiable."
@@ -4738,7 +4902,7 @@ var oauthAuthorizeQuery = z18.object({
4738
4902
  description: "The authorization request an MCP client sends, per RFC 6749 \xA74.1.1 with mandatory PKCE. The handler reads it parameter by parameter rather than through one parse, because the answers differ: `client_id` and `redirect_uri` are refused flat, with no redirect, since until both are confirmed there is no trusted target to bounce a browser to, and everything after them is reported to the client's own callback as query parameters."
4739
4903
  });
4740
4904
 
4741
- // node_modules/.pnpm/@fleetless+contracts@1.1.0/node_modules/@fleetless/contracts/dist/routes.js
4905
+ // node_modules/.pnpm/@fleetless+contracts@5.0.0/node_modules/@fleetless/contracts/dist/routes.js
4742
4906
  var MCP_APP = MCP_APP_PATHS(":appIdentifier");
4743
4907
  var APP_IDENTIFIER = {
4744
4908
  name: "appIdentifier",
@@ -5108,6 +5272,42 @@ var ROUTES = [
5108
5272
  transport: "http",
5109
5273
  notes: "A `default_role_id` naming a role of another app is refused: it is the one cross-app authorization check this shape can carry. Changing the robot set closes every live subscription the app's users hold, since a grant may no longer name a reachable robot."
5110
5274
  },
5275
+ {
5276
+ method: "GET",
5277
+ path: "/api/apps/:id/deletion-preview",
5278
+ section: "apps",
5279
+ summary: "Reports what deleting the app would destroy, without destroying it.",
5280
+ audience: "developer",
5281
+ auth: "developer",
5282
+ rateLimited: false,
5283
+ ownerTier: false,
5284
+ status: 200,
5285
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
5286
+ query: null,
5287
+ request: null,
5288
+ response: appDeletionSummary,
5289
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
5290
+ transport: "http",
5291
+ notes: "The same shape the delete's own audit event carries, computed by the same function on purpose: the confirmation dialog and the eventual receipt agree by construction, and any difference between them is real drift rather than two estimates that quietly disagree. \n\n**No `force` parameter, unlike the robot pair this is modelled on.** A robot's open live session is a single nameable state whose interruption is its own hazard, which is why that route makes the caller pass `force` explicitly. An app has no equivalent state to force past, and inventing one would be a guess wearing a guard's clothes \u2014 this preview is the guard."
5292
+ },
5293
+ {
5294
+ method: "DELETE",
5295
+ path: "/api/apps/:id",
5296
+ section: "apps",
5297
+ summary: "Deletes an app and everything it produced.",
5298
+ audience: "developer",
5299
+ auth: "developer",
5300
+ rateLimited: false,
5301
+ ownerTier: true,
5302
+ status: 204,
5303
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
5304
+ query: null,
5305
+ request: null,
5306
+ response: null,
5307
+ errors: [...DEVELOPER_GUARD, "tier_required", "invalid_uuid", "not_found"],
5308
+ transport: "http",
5309
+ notes: "Owner tier, and the gate runs **after** the org-scoped lookup: a developer-tier admin therefore sees the same `404` a stranger would for an app outside their org, rather than a tier refusal that confirms the id exists. A full cascade \u2014 its users, roles, server keys, invitations, OIDC provider configuration and mail templates all go, recorded once as `app.deleted` carrying an `appDeletionSummary`. Its robots are untouched: they belong to the org, not to the app. \n\n**No `force` parameter** \u2014 see `GET /api/apps/:id/deletion-preview`."
5310
+ },
5111
5311
  {
5112
5312
  method: "POST",
5113
5313
  path: "/api/apps/:id/roles",
@@ -5614,13 +5814,49 @@ var ROUTES = [
5614
5814
  response: appAuthConfig,
5615
5815
  errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
5616
5816
  transport: "http",
5617
- notes: "One row per app, created with the app and never absent \u2014 an app that has configured nothing reads back the defaults rather than a `404`. `oidc_callback_url` is in the answer and not in the request: it is minted by the cloud from its own public base URL, is the same for every app and every provider, and is the value a developer registers at their identity provider."
5817
+ notes: "One row per app, created with the app and never absent \u2014 an app that has configured nothing reads back the defaults rather than a `404`. `oidc_callback_url` is in the answer and not in the request: it is minted by the cloud from its own public base URL, is the same for every app and every provider, and is the value a developer registers at their identity provider. It stays read-only on every slice write below for a second reason: a writable callback URL would let a caller point the return leg of an OIDC sign-in, which carries an authorization code, at a host they own. `updated_at` is read-only for a duller one: the server stamps it on every write, and a client-supplied value would be a lie about when the row last changed."
5618
5818
  },
5619
5819
  {
5620
5820
  method: "PUT",
5621
- path: "/api/apps/:id/auth-config",
5821
+ path: "/api/apps/:id/auth-config/registration",
5822
+ section: "apps",
5823
+ summary: "Replaces who may self-register, and from where.",
5824
+ audience: "developer",
5825
+ auth: "developer",
5826
+ rateLimited: false,
5827
+ ownerTier: false,
5828
+ status: 200,
5829
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
5830
+ query: null,
5831
+ request: putAppAuthRegistrationRequest,
5832
+ response: appAuthConfig,
5833
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "validation_error", "not_found"],
5834
+ transport: "http",
5835
+ notes: "**A replace, not a merge, and `.strict()`**: `self_registration`, `allowed_domains` and `allowed_origins` all arrive or the write is refused, so a client built against an older shape cannot silently clear a setting it does not know about. `oidc_callback_url` and `updated_at` are the server's, refused in this body as in every slice's \u2014 see `GET`'s notes for why. \n\n`400 validation_error` is where the two field rules land: an entry in `allowed_domains` must be lowercase, since a capitalised one can never match a lowercased address, and an entry in `allowed_origins` must be a bare scheme-host-port with no path, since a browser sends nothing longer in its `Origin` header. Each refuses at configuration time rather than failing silently later. \n\nThe merge is server-side against the stored row, so this write never disturbs the urls or mcp slice."
5836
+ },
5837
+ {
5838
+ method: "PUT",
5839
+ path: "/api/apps/:id/auth-config/urls",
5840
+ section: "apps",
5841
+ summary: "Replaces the three pages Fleetless's mails point at.",
5842
+ audience: "developer",
5843
+ auth: "developer",
5844
+ rateLimited: false,
5845
+ ownerTier: false,
5846
+ status: 200,
5847
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
5848
+ query: null,
5849
+ request: putAppAuthUrlsRequest,
5850
+ response: appAuthConfig,
5851
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "validation_error", "not_found"],
5852
+ transport: "http",
5853
+ notes: "**A replace, not a merge, and `.strict()`**: `invite_url`, `verify_url` and `reset_url` all arrive or the write is refused, so a client built against an older shape cannot silently clear a setting it does not know about. `oidc_callback_url` and `updated_at` are the server's, refused in this body as in every slice's \u2014 see `GET`'s notes for why. \n\n`400 validation_error` is where the field rule lands: a URL template must be https (or `http` on `localhost`) and carry its placeholder exactly once \u2014 a second occurrence leaves one literal in a mailed link, refused here rather than failing silently once the mail is sent. \n\nThe merge is server-side against the stored row, so this write never disturbs the registration or mcp slice."
5854
+ },
5855
+ {
5856
+ method: "PUT",
5857
+ path: "/api/apps/:id/auth-config/mcp",
5622
5858
  section: "apps",
5623
- summary: "Replaces the app's auth settings in one write.",
5859
+ summary: "Replaces the MCP switch and its login URL together.",
5624
5860
  audience: "developer",
5625
5861
  auth: "developer",
5626
5862
  rateLimited: false,
@@ -5628,11 +5864,11 @@ var ROUTES = [
5628
5864
  status: 200,
5629
5865
  params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
5630
5866
  query: null,
5631
- request: putAppAuthConfigRequest,
5867
+ request: putAppAuthMcpRequest,
5632
5868
  response: appAuthConfig,
5633
5869
  errors: [...DEVELOPER_GUARD, "invalid_uuid", "validation_error", "not_found"],
5634
5870
  transport: "http",
5635
- notes: "**A replace, not a merge, and `.strict()`**: every field arrives or the write is refused, so a client built against an older shape cannot silently clear a setting it does not know about. `oidc_callback_url` and `updated_at` are refused in the body \u2014 a writable callback URL would let a caller point the return leg of an OIDC sign-in, which carries an authorization code, at a host they own. \n\n`400 validation_error` is where the three field rules land: a URL template must be https (or `http` on `localhost`) and carry its placeholder exactly once, an origin must be a bare scheme-host-port with no path, and a domain must be lowercase. Each refuses at configuration time because each would otherwise fail silently later \u2014 a second placeholder leaves one occurrence literal in a mailed link, an origin with a path can never equal a browser's `Origin` header, and a capitalised domain can never match a lowercased address."
5871
+ notes: "**A replace, not a merge, and `.strict()`**: `mcp_enabled` and `mcp_login_url` both arrive or the write is refused, so a client built against an older shape cannot silently clear a setting it does not know about. `oidc_callback_url` and `updated_at` are the server's, refused in this body as in every slice's \u2014 see `GET`'s notes for why. \n\n`mcp_login_url` answers to the same rule as the `urls` slice's three templates \u2014 https (or `http` on `localhost`), its placeholder exactly once \u2014 refused as `400 validation_error` rather than left to fail mid-OAuth, in a client's browser where no console screen is watching. \n\nThe merge is server-side against the stored row, so this write never disturbs the registration or urls slice."
5636
5872
  },
5637
5873
  {
5638
5874
  method: "GET",
@@ -6011,7 +6247,7 @@ var ROUTES = [
6011
6247
  response: dynamicClientRegistrationResponse,
6012
6248
  errors: ["rate_limited"],
6013
6249
  transport: "http",
6014
- notes: "RFC 7591. **The request schema is what this endpoint accepts, not what it parses**: the handler reads the body field by field, because \xA73.2.2 distinguishes `invalid_redirect_uri` from `invalid_client_metadata` and one `safeParse` failure cannot say which of the two a caller earned. The shape is deliberately **not** strict, which is the schema agreeing with \xA73.1 rather than a gap in it \u2014 a conforming client sends `client_uri`, `logo_uri` and `software_id`, and both the schema and the server ignore them. `client_name` and `redirect_uris` are the two fields read; `grant_types`, `response_types` and `scope` are accepted and ignored. What comes back is what was actually granted, which \xA73.2.1 allows a server to substitute \u2014 this authorization server issues `authorization_code` only, so a client that asked for `refresh_token` is registered and told plainly that it did not get one. The registration carries a TTL. Refusals are `oauthError`; the rate limiter answers `apiError`."
6250
+ notes: "RFC 7591. **The request schema is what this endpoint accepts, not what it parses**: the handler reads the body field by field, because \xA73.2.2 distinguishes `invalid_redirect_uri` from `invalid_client_metadata` and one `safeParse` failure cannot say which of the two a caller earned. The shape is deliberately **not** strict, which is the schema agreeing with \xA73.1 rather than a gap in it \u2014 a conforming client sends `client_uri`, `logo_uri` and `software_id`, and both the schema and the server ignore them. `client_name` and `redirect_uris` are the two fields read; `grant_types`, `response_types` and `scope` are accepted and ignored. What comes back is what was actually granted, which \xA73.2.1 allows a server to substitute \u2014 this authorization server grants `authorization_code` and `refresh_token` to every registration. The registration carries a TTL. Refusals are `oauthError`; the rate limiter answers `apiError`."
6015
6251
  },
6016
6252
  {
6017
6253
  method: "GET",
@@ -6137,7 +6373,7 @@ var ROUTES = [
6137
6373
  response: oauthTokenResponse,
6138
6374
  errors: [],
6139
6375
  transport: "http",
6140
- notes: "Only `authorization_code` is supported \u2014 there is no refresh grant here, so a session ends when its token expires and the client signs in again. Refusals are RFC 6749 \xA75.2's `oauthError`, so this route emits none of the codes in this reference. The response carries no `refresh_token`; the shape is the same `oauthTokenResponse` the app flow answers, whose refresh field is optional. The code is single-use, PKCE-verified, and its `resource` must match the audience it was authorized for."
6376
+ notes: "`authorization_code` mints an `mcp_session` access token bound to the central resource and a refresh token; `refresh_token` rotates that pair, and the presented refresh token is consumed \u2014 a second presentation revokes the session, as on `/api/auth/refresh`. The refresh token lives ninety days from its last use and is bound to the `client_id` it was issued to. A refresh re-reads the Fleetless user, so a removed account cannot refresh. Refusals are RFC 6749 \xA75.2's `oauthError`, so this route emits none of the codes in this reference. The code is single-use, PKCE-verified, and its `resource` must match the audience it was authorized for; a `resource` on a refresh must match the session's audience, and is checked before the token is consumed."
6141
6377
  },
6142
6378
  /* ------------------------------- developer auth (the console\'s OAuth portal) */
6143
6379
  {
@@ -6428,7 +6664,7 @@ var ROUTES = [
6428
6664
  response: dynamicClientRegistrationResponse,
6429
6665
  errors: ["rate_limited", "not_found"],
6430
6666
  transport: "http",
6431
- notes: "RFC 7591, the same wire and the same handler as `POST /mcp/oauth/register` \u2014 one implementation, because a second answer to \"is this redirect URI acceptable\" would agree with the first only by luck. The request schema is what the endpoint accepts rather than what it parses, for the reason that row gives: \xA73.2.2 needs two distinguishable refusals and one `safeParse` failure offers one. `client_name` and `redirect_uris` are read; `grant_types`, `response_types` and `scope` are accepted and ignored, and what comes back is what was actually granted, which \xA73.2.1 allows \u2014 `authorization_code` only, so a client that asked for `refresh_token` is registered and told plainly that it did not get one. The registration carries a TTL. \n\n**The registration is scoped to this app.** A `client_id` minted here authorizes at this app's endpoint and nowhere else, so a client registered against one app cannot walk into another's authorize with it, and a developer who switches MCP off is not left with strangers' registrations valid somewhere adjacent. \n\nRefusals are `oauthError`; the rate limiter and `404 not_found` answer `apiError`. That `404` covers an unknown identifier **and** an app with the switch off, mirroring the two metadata documents this endpoint is discovered from \u2014 a client that could not read those has no business registering here, and giving it a third distinct answer would only tell it something the documents deliberately do not."
6667
+ notes: "RFC 7591, the same wire and the same handler as `POST /mcp/oauth/register` \u2014 one implementation, because a second answer to \"is this redirect URI acceptable\" would agree with the first only by luck. The request schema is what the endpoint accepts rather than what it parses, for the reason that row gives: \xA73.2.2 needs two distinguishable refusals and one `safeParse` failure offers one. `client_name` and `redirect_uris` are read; `grant_types`, `response_types` and `scope` are accepted and ignored, and what comes back is what was actually granted, which \xA73.2.1 allows \u2014 this authorization server grants `authorization_code` and `refresh_token` to every registration. The registration carries a TTL. \n\n**The registration is scoped to this app.** A `client_id` minted here authorizes at this app's endpoint and nowhere else, so a client registered against one app cannot walk into another's authorize with it, and a developer who switches MCP off is not left with strangers' registrations valid somewhere adjacent. \n\nRefusals are `oauthError`; the rate limiter and `404 not_found` answer `apiError`. That `404` covers an unknown identifier **and** an app with the switch off, mirroring the two metadata documents this endpoint is discovered from \u2014 a client that could not read those has no business registering here, and giving it a third distinct answer would only tell it something the documents deliberately do not."
6432
6668
  },
6433
6669
  {
6434
6670
  method: "GET",
@@ -6464,7 +6700,7 @@ var ROUTES = [
6464
6700
  response: oauthTokenResponse,
6465
6701
  errors: [],
6466
6702
  transport: "http",
6467
- notes: "Only `authorization_code`, PKCE-verified and single-use. There is no refresh grant here either, so a session ends when its token expires and the client signs in again; the shape is the same `oauthTokenResponse` the central endpoint answers, whose refresh field is optional and stays empty. **The `aud` is this app's endpoint URL on the canonical public base**, and the code's `resource` must match it \u2014 that is the whole of what stops a token minted for one app being spent at another's endpoint. \n\n**Every refusal is RFC 6749 \xA75.2's `oauthError`, so this route emits none of the codes in this reference \u2014 including the ones about the app.** An unknown identifier and a switched-off app are `invalid_client` here, not the `404` and `403` the authorize route beside it answers. The difference is who reads the answer: authorize is walked by a browser and its refusal is read by a person, while this endpoint is called by a client's own code in the middle of a flow, and handing that code an envelope its OAuth library cannot parse turns a clean refusal into an unexplained crash."
6703
+ notes: "`authorization_code`, PKCE-verified and single-use, and `refresh_token`, which rotates the pair the exchange minted; the refresh token lives ninety days from its last use, is bound to its client and to this app, and a refresh re-reads the app user's status and their standing consent to the client, so a block or a withdrawn consent ends the session at its next refresh at the latest. **The `aud` is this app's endpoint URL on the canonical public base**, and the code's `resource` must match it \u2014 that is the whole of what stops a token minted for one app being spent at another's endpoint. \n\n**Every refusal is RFC 6749 \xA75.2's `oauthError`, so this route emits none of the codes in this reference \u2014 including the ones about the app.** An unknown identifier and a switched-off app are `invalid_client` here, not the `404` and `403` the authorize route beside it answers. The difference is who reads the answer: authorize is walked by a browser and its refusal is read by a person, while this endpoint is called by a client's own code in the middle of a flow, and handing that code an envelope its OAuth library cannot parse turns a clean refusal into an unexplained crash."
6468
6704
  },
6469
6705
  /* -------------------------------------------------- app-user (client) auth */
6470
6706
  {
@@ -6849,6 +7085,42 @@ var ROUTES = [
6849
7085
  transport: "http",
6850
7086
  notes: "`token` is the only moment the raw bridge token exists outside the caller's hands \u2014 the cloud stores a hash, so nothing can read it back and a caller who loses it rotates rather than recovers. Audited: this mints a credential that can speak for the org from anywhere, and the event carries no `details`, because the one interesting value here is the token. `max_robots` is checked before anything is created, which is only safe because robot deletion exists."
6851
7087
  },
7088
+ {
7089
+ method: "POST",
7090
+ path: "/api/robots/:id/token/rotate",
7091
+ section: "robots",
7092
+ summary: "Mints a new bridge token for the robot and invalidates the old one.",
7093
+ audience: "developer",
7094
+ auth: "developer",
7095
+ rateLimited: false,
7096
+ ownerTier: true,
7097
+ status: 201,
7098
+ params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
7099
+ query: null,
7100
+ request: null,
7101
+ response: robotTokenRotateResponse,
7102
+ errors: [...DEVELOPER_GUARD, "tier_required", "invalid_uuid", "not_found"],
7103
+ transport: "http",
7104
+ notes: "Owner tier, behind the org-scoped lookup, so a developer-tier admin sees the `404` a stranger would for a robot outside their org rather than a tier refusal that confirms the id exists. `token` is the only moment the new secret exists outside the caller's hands \u2014 the cloud stores a hash \u2014 so a caller who loses it rotates again. Audited as `robot.token_rotated`, with no `details`: the one interesting value here is the token. \n\n**It stops the bridge that is connected right now.** The old secret is gone the instant the hash is replaced, so the cloud closes that socket with `CLOSE_TOKEN_ROTATED` rather than leaving a bridge speaking on a credential nothing would accept again. A bridge that does not know the code reconnects and is refused at hello as `invalid_token`, which is the honest answer and ends the same way. **The robot is offline until somebody puts the new token on it** \u2014 this is a deliberate interruption, not a background rekey, and a fleet cannot be rotated without a visit to each robot."
7105
+ },
7106
+ {
7107
+ method: "PUT",
7108
+ path: "/api/robots/:id/urdf/joint-state",
7109
+ section: "robots",
7110
+ summary: "Chooses the datapoint whose joint positions move the robot's URDF, or clears it.",
7111
+ audience: "developer",
7112
+ auth: "developer",
7113
+ rateLimited: false,
7114
+ ownerTier: false,
7115
+ status: 200,
7116
+ params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
7117
+ query: null,
7118
+ request: jointStatePutRequest,
7119
+ response: jointStatePutResponse,
7120
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
7121
+ transport: "http",
7122
+ notes: '**What qualifies**: a datapoint of the **published** configuration whose ROS type is `sensor_msgs/msg/JointState` and which carries no `field` \u2014 the whole message, because positions and names arrive together and a single extracted field is half of a pose. Anything else is a `validation_error` naming that rule rather than a stored mapping that renders a battery reading as a robot. `{ "slug": null }` clears it, which is why the field is required and nullable rather than optional. \n\n**The mapping cannot outlive what it points at.** Every successful publish re-checks it against the new document and clears it when it no longer qualifies, recording `robot.joint_state_cleared` with the version that did it; a slug rename rewrites it like every other reference the editor already rewrites; deleting the robot takes it along. Every write through this route \u2014 a slug or `null` \u2014 is on the record too, as `robot.joint_state_set` with the actor and the slug, so a clear a person made is never mistaken for one a publish made. The stored value reads back on `GET /api/robots/:id/assets` as `joint_state_slug`, so a renderer fetches the URDF, the meshes and the mapping from one place.'
7123
+ },
6852
7124
  {
6853
7125
  method: "GET",
6854
7126
  path: "/api/robots",
@@ -7389,7 +7661,7 @@ var ROUTES = [
7389
7661
  "internal_error"
7390
7662
  ],
7391
7663
  transport: "http",
7392
- notes: "**One route for both kinds**, because a path segment naming the kind would demand a fact a role grant does not carry. An action answers `202` with an `invokeResponse` the moment the job exists; a service answers `200` with a `serviceCallResponse` once the result is in \u2014 two shapes, carried by one union (`invokeOrServiceResponse`) and told apart by whether `kind` or a bare `result` arrives. Parameters are checked **before** anything about the world (offline, busy): the same request must get the same verdict whether or not the robot happens to be reachable, or a developer testing against an offline robot never learns their parameters were wrong. A service the robot reports as failed answers `502` carrying **the job's own error code**, which is an open set and not one of the codes above."
7664
+ notes: "**One route for both kinds**, because a path segment naming the kind would demand a fact a role grant does not carry. An action answers `202` with an `invokeResponse` the moment the job exists; a service answers `200` with a `serviceCallResponse` once the result is in \u2014 two shapes, carried by one union (`invokeOrServiceResponse`) and told apart by whether `kind` or a bare `result` arrives. Parameters are checked **before** anything about the world (offline, busy): the same request must get the same verdict whether or not the robot happens to be reachable, or a developer testing against an offline robot never learns their parameters were wrong. A slug is `409 busy` while it holds a `running` job, an `unknown` one the robot has not accounted for yet, or an `external` goal someone else started; the refusal's `details.running` names that job, `state` and `origin` included. A service the robot reports as failed answers `502` carrying **the job's own error code**, which is an open set and not one of the codes above."
7393
7665
  },
7394
7666
  {
7395
7667
  method: "GET",
@@ -7430,9 +7702,21 @@ var ROUTES = [
7430
7702
  request: cancelRequest,
7431
7703
  requestOptional: true,
7432
7704
  response: jobResponse,
7433
- errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "validation_error", "not_cancellable", "robot_offline"],
7705
+ errors: [
7706
+ ...CLIENT_GUARD,
7707
+ "invalid_uuid",
7708
+ "not_found",
7709
+ "validation_error",
7710
+ "not_cancellable",
7711
+ "robot_offline",
7712
+ "cancel_rejected",
7713
+ "bridge_timeout",
7714
+ "unknown_slug",
7715
+ "action_server_lost",
7716
+ "internal_error"
7717
+ ],
7434
7718
  transport: "http",
7435
- notes: 'The body is optional: a bodyless `POST` was every caller\'s shape before `job_id` existed, and absent or `job_id: null` both mean "cancel whatever is running". A named `job_id` that is **not** what is running cancels nothing and answers `404` \u2014 the caller named an id and thereby ruled the other one out. A service is `422 not_cancellable`: a service call has no goal to cancel. Nothing running is a `200` with `job: null`.'
7719
+ notes: "The body is optional: a bodyless `POST` was every caller's shape before `job_id` existed, and absent or `job_id: null` both mean \"cancel whatever is running\". A named `job_id` that is **not** what is running cancels nothing and answers `404` \u2014 the caller named an id and thereby ruled the other one out. An `external` job is cancelled the same way, through its goal id. Cancelling an `unknown` job also cancels every external goal on its action, since one of them may be that job. A service is `422 not_cancellable`: a service call has no goal to cancel. Nothing running is a `200` with `job: null`. The answer waits for the bridge's `cancel_result`: a `200` means the action server accepted the cancel request, not that the goal has ended \u2014 the job's end arrives as its own update. Any goal answered `ERROR_REJECTED` makes it `409 cancel_rejected`, with every goal and its `return_code` in `details.goals`; no answer within `JOB_HEARTBEAT_TIMEOUT_MS` is `504 bridge_timeout`; a cancel the bridge could not send at all is `502` carrying the bridge's own code (`unknown_slug`, `action_server_lost`, `internal_error`)."
7436
7720
  },
7437
7721
  {
7438
7722
  method: "POST",
@@ -7734,6 +8018,24 @@ var ROUTES = [
7734
8018
  transport: "http",
7735
8019
  notes: "Developer sessions only, like starting a sync: the guard admits three caller kinds and the handler answers `401 unauthorized` to the other two. A sync belonging to another robot reads exactly like one that never existed, which is why the robot is resolved first."
7736
8020
  },
8021
+ {
8022
+ method: "DELETE",
8023
+ path: "/api/robots/:id/assets",
8024
+ section: "assets",
8025
+ summary: "Empties a robot's asset store: every URDF, mesh and texture, gone at once.",
8026
+ audience: "client",
8027
+ auth: "developer_or_client",
8028
+ rateLimited: false,
8029
+ ownerTier: true,
8030
+ status: 200,
8031
+ params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
8032
+ query: null,
8033
+ request: null,
8034
+ response: assetsClearResponse,
8035
+ errors: [...CLIENT_GUARD, "tier_required", "invalid_uuid", "not_found", "busy"],
8036
+ transport: "http",
8037
+ notes: "The store's escape hatch: a full store is never a dead end, and this is the blunt third of the three answers to it \u2014 the URDF upload is exempt from the gate, reconcile after a sync already frees what the new URDF stopped referencing, and this route lets an Owner clear the robot outright. Owner tier, unconditionally, like starting a sync. Removes every asset of the robot and resets its store to `0`; the next sync fills it again. It does not touch the bridge's availability report \u2014 `urdf_available` still answers from the connected robot, unrelated to what this cloud happens to have stored. A clear while a sync is running is `409 busy` naming that sync's details, the same refusal starting a second sync gets, because deleting under a running upload would leave the store counter wrong."
8038
+ },
7737
8039
  /* ------------------------------------------------ org (quotas and fleet reads) */
7738
8040
  {
7739
8041
  method: "GET",
@@ -7858,9 +8160,9 @@ var ROUTES = [
7858
8160
  query: null,
7859
8161
  request: null,
7860
8162
  response: asset,
7861
- errors: ["unauthorized", "rate_limited", "asset_too_large", "validation_error", "not_found", "quota_exceeded", "bad_request"],
8163
+ errors: ["unauthorized", "rate_limited", "validation_error", "not_found", "quota_exceeded", "bad_request"],
7862
8164
  transport: "http",
7863
- notes: "The body is the **raw file bytes**, not JSON, so it has no request schema; everything about the file \u2014 its kind, its name, its sync id and its announced size \u2014 rides in the `x-fleetless-asset-*` headers `ASSET_UPLOAD_HEADERS` names. The credential is a short-lived upload token minted by `POST /api/robots/:id/assets/sync`, verified in a `preParsing` hook so a refusal precedes the work rather than following it: a `preHandler` would already have buffered the whole file. The announced size is refused there too, before a single byte is read \u2014 it is an announcement and not a proof, so it only ever rejects early and never accepts early, and a body that lies small is still caught by the real length check. Past both, the server's own body limit answers a bare `413 bad_request` with neither ceiling nor size in it. Rate limited per robot inside that same hook, which is why `rateLimited` is `false`: there is no rate-limiting preHandler registered on this route."
8165
+ notes: "The body is the **raw file bytes**, not JSON, so it has no request schema; everything about the file \u2014 its kind, its name, its sync id and its announced size \u2014 rides in the `x-fleetless-asset-*` headers `ASSET_UPLOAD_HEADERS` names. The credential is a short-lived upload token minted by `POST /api/robots/:id/assets/sync`, verified in a `preParsing` hook so a refusal precedes the work rather than following it: a `preHandler` would already have buffered the whole file. **Nothing is refused for its own size** \u2014 the robot's asset store is the only limit, so the announced size is checked there against `ROBOT_ASSET_STORE_BYTES` and a file with no room left answers `409 quota_exceeded` carrying `store_bytes`, `used_bytes` and `size_bytes`, while the sync carries on with the next file. Past that, the server's own body limit answers a bare `413 bad_request` with none of those numbers in it. Rate limited per robot inside that same hook, which is why `rateLimited` is `false`: there is no rate-limiting preHandler registered on this route. The URDF itself is never refused for the store; only meshes and textures are charged against it."
7864
8166
  },
7865
8167
  /* ------------------------------------ realtime and bridge transports */
7866
8168
  {
@@ -8135,13 +8437,16 @@ function oidcErrorFromCallbackParams(params) {
8135
8437
  `The federated sign-in ended without a session (${known.data}). See ClientOidcErrorCode in @fleetless/contracts for what each code means, and branch on error.code rather than on this message.`
8136
8438
  );
8137
8439
  }
8138
- function serverKeyRefusal(method, needs) {
8440
+ function sessionlessRefusal(option, method, needs) {
8139
8441
  throw new FleetlessError(
8140
8442
  "invalid_option",
8141
- `auth.${method} is not available on a client constructed with a serverKey \u2014 ${needs}. Build a client with a tokenStore (an app user's own session) for this call.`
8443
+ `auth.${method} is not available on a client constructed with ${option === "serverKey" ? "a serverKey" : "a credentials source"} \u2014 ${needs}. Build a client with a tokenStore (an app user's own session) for this call.`
8142
8444
  );
8143
8445
  }
8144
- function createServerKeyAuth(http, appIdentifier2) {
8446
+ function createServerKeyAuth(http, appIdentifier2, option = "serverKey") {
8447
+ const serverKeyRefusal = (method, needs) => sessionlessRefusal(option, method, needs);
8448
+ const subject = option === "serverKey" ? "a server key" : "a supplied credential";
8449
+ const holder = option === "serverKey" ? "a server-key client" : "a client with a supplied credential";
8145
8450
  return {
8146
8451
  // **`register`, `resendVerification` and `requestPasswordReset` are
8147
8452
  // allowed here** — see `createPublicAuthCalls`. They are public routes
@@ -8150,25 +8455,25 @@ function createServerKeyAuth(http, appIdentifier2) {
8150
8455
  // already has.
8151
8456
  ...createPublicAuthCalls(http, appIdentifier2),
8152
8457
  async verifyEmail() {
8153
- serverKeyRefusal("verifyEmail", "the route answers a session and a server-key client has nowhere to store it, so the session would be silently discarded");
8458
+ serverKeyRefusal("verifyEmail", `the route answers a session and ${holder} has nowhere to store it, so the session would be silently discarded`);
8154
8459
  },
8155
8460
  async login() {
8156
- serverKeyRefusal("login", "a server key IS the credential; there is nothing to exchange");
8461
+ serverKeyRefusal("login", `${subject} IS the credential; there is nothing to exchange`);
8157
8462
  },
8158
8463
  async logout() {
8159
- serverKeyRefusal("logout", "a server key holds no session to end");
8464
+ serverKeyRefusal("logout", `${subject} holds no session to end`);
8160
8465
  },
8161
8466
  async me() {
8162
8467
  return http.request("/api/client/me", {});
8163
8468
  },
8164
8469
  async changePassword() {
8165
- serverKeyRefusal("changePassword", "a server key has no password; rotate the key in the console instead");
8470
+ serverKeyRefusal("changePassword", `${subject} has no password${option === "serverKey" ? "; rotate the key in the console instead" : ""}`);
8166
8471
  },
8167
8472
  async confirmPasswordReset() {
8168
- serverKeyRefusal("confirmPasswordReset", "the route answers a session and a server-key client has nowhere to store it, so the session would be silently discarded");
8473
+ serverKeyRefusal("confirmPasswordReset", `the route answers a session and ${holder} has nowhere to store it, so the session would be silently discarded`);
8169
8474
  },
8170
8475
  async acceptInvitation() {
8171
- serverKeyRefusal("acceptInvitation", "the route answers a session and a server-key client has nowhere to store it, so the session would be silently discarded");
8476
+ serverKeyRefusal("acceptInvitation", `the route answers a session and ${holder} has nowhere to store it, so the session would be silently discarded`);
8172
8477
  },
8173
8478
  async listProviders() {
8174
8479
  const query = new URLSearchParams({ app_identifier: appIdentifier2 });
@@ -8188,16 +8493,16 @@ function createServerKeyAuth(http, appIdentifier2) {
8188
8493
  return http.request(`/api/client/mcp/interactions/${pathSegment(id)}`, {});
8189
8494
  },
8190
8495
  async approveMcpInteraction() {
8191
- serverKeyRefusal("approveMcpInteraction", "a consent is a person's decision, and a server key is not a person");
8496
+ serverKeyRefusal("approveMcpInteraction", `a consent is a person's decision, and ${subject} is not a person`);
8192
8497
  },
8193
8498
  async denyMcpInteraction() {
8194
- serverKeyRefusal("denyMcpInteraction", "a consent is a person's decision, and a server key is not a person");
8499
+ serverKeyRefusal("denyMcpInteraction", `a consent is a person's decision, and ${subject} is not a person`);
8195
8500
  },
8196
8501
  async listMcpGrants() {
8197
- serverKeyRefusal("listMcpGrants", "a server key never went through a consent screen, so it has no grants of its own");
8502
+ serverKeyRefusal("listMcpGrants", `${subject} never went through a consent screen, so it has no grants of its own`);
8198
8503
  },
8199
8504
  async revokeMcpGrant() {
8200
- serverKeyRefusal("revokeMcpGrant", "a server key never went through a consent screen, so it has no grants of its own");
8505
+ serverKeyRefusal("revokeMcpGrant", `${subject} never went through a consent screen, so it has no grants of its own`);
8201
8506
  }
8202
8507
  };
8203
8508
  }
@@ -8937,9 +9242,10 @@ var InMemoryTokenStore = class {
8937
9242
 
8938
9243
  // src/client.ts
8939
9244
  function createClient(options) {
8940
- if (options.serverKey !== void 0 && options.tokenStore !== void 0) {
9245
+ const chosen = ["tokenStore", "serverKey", "credentials"].filter((name) => options[name] !== void 0);
9246
+ if (chosen.length > 1) {
8941
9247
  throw new Error(
8942
- "createClient: pass either `tokenStore` (end-user login) or `serverKey` (a server-side caller), not both."
9248
+ `createClient: pass exactly one of \`tokenStore\` (end-user login), \`serverKey\` (a server-side caller) or \`credentials\` (a bearer the caller owns) \u2014 got ${chosen.join(" and ")}.`
8943
9249
  );
8944
9250
  }
8945
9251
  const config = Object.freeze({
@@ -8955,7 +9261,11 @@ function createClient(options) {
8955
9261
  let auth;
8956
9262
  let http;
8957
9263
  let credentials;
8958
- if (options.serverKey !== void 0) {
9264
+ if (options.credentials !== void 0) {
9265
+ credentials = options.credentials;
9266
+ http = new HttpClient({ baseUrl: config.apiUrl, fetch: fetchImpl, credentials });
9267
+ auth = createServerKeyAuth(http, config.appIdentifier, "credentials");
9268
+ } else if (options.serverKey !== void 0) {
8959
9269
  credentials = new ServerKeyCredentials(options.serverKey);
8960
9270
  http = new HttpClient({ baseUrl: config.apiUrl, fetch: fetchImpl, credentials });
8961
9271
  auth = createServerKeyAuth(http, config.appIdentifier);
@@ -8978,7 +9288,7 @@ function createClient(options) {
8978
9288
  const jobs = createJobsApi(http);
8979
9289
  const assets = createAssetsApi(http);
8980
9290
  const robots = createRobotsApi(http);
8981
- if (options.serverKey === void 0) {
9291
+ if (options.serverKey === void 0 && options.credentials === void 0) {
8982
9292
  const baseLogout = auth.logout.bind(auth);
8983
9293
  auth = {
8984
9294
  ...auth,