@fleetless/sdk 2.0.1 → 2.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
@@ -14,9 +14,21 @@ var SDK_ERROR_CODES = [
14
14
  "aborted"
15
15
  ];
16
16
  var FleetlessError = class extends Error {
17
+ /** What went wrong, as a stable string — the field to branch on. */
17
18
  code;
19
+ /**
20
+ * Structured detail the server sent with the refusal, if any: the
21
+ * violations behind `parameter_invalid`, the running job behind `busy`,
22
+ * `retry_after_ms` behind `rate_limited`. Parse it rather than assume
23
+ * its shape — `parameterInvalidDetails` is exported for exactly that.
24
+ */
18
25
  details;
26
+ /** The HTTP status of the response that produced this error, if it came from one. */
19
27
  status;
28
+ /**
29
+ * Builds an error. `code` is what a caller branches on and `message` is
30
+ * for a human; anything else the refusal carried goes in `options`.
31
+ */
20
32
  constructor(code, message, options = {}) {
21
33
  super(message);
22
34
  this.name = "FleetlessError";
@@ -82,7 +94,7 @@ var HttpClient = class {
82
94
  this.#credentials = credentials;
83
95
  }
84
96
  /**
85
- * D8 (W6c): calling `request()` with one of `RequestBodyByRoute`'s
97
+ * Calling `request()` with one of `RequestBodyByRoute`'s
86
98
  * literal paths requires `options.body` to match that route's contract
87
99
  * type exactly, via `RequestOptionsFor<P>` below.
88
100
  *
@@ -102,7 +114,7 @@ var HttpClient = class {
102
114
  * RequestOptionsFor<P>` would type-check by construction — the cast
103
115
  * bypasses the very check this exists to add, which is the identical
104
116
  * "special case that quietly exempts calls from the general rule" shape
105
- * D7 already found once in this file. Every current call site to a route
117
+ * this file has already been caught by once. Every current call site to a route
106
118
  * with no body already passes `{}` explicitly for exactly this reason.
107
119
  */
108
120
  async request(path, options) {
@@ -119,7 +131,7 @@ var HttpClient = class {
119
131
  }
120
132
  /**
121
133
  * Like `request`, but for an endpoint that answers with raw bytes instead
122
- * of a JSON body (a camera snapshot, W5) — same auth attachment, same
134
+ * of a JSON body (a camera snapshot) — same auth attachment, same
123
135
  * single retry on `token_expired`, same `FleetlessError` on a non-2xx
124
136
  * response; only what a *successful* response is made of differs, so both
125
137
  * methods share `#send` rather than duplicating that logic.
@@ -139,7 +151,7 @@ var HttpClient = class {
139
151
  return this.#baseUrl;
140
152
  }
141
153
  /**
142
- * `/oauth/token` (RFC 6749 §3.2, §5), and nothing else — deliberately not
154
+ * `/oauth/token` (RFC 6749 sections 3.2 and 5), and nothing else — deliberately not
143
155
  * routed through `request()`/`#send()`, because every difference from
144
156
  * those follows from one fact: this is not our own API.
145
157
  *
@@ -156,7 +168,7 @@ var HttpClient = class {
156
168
  * OAuth/`apiError` split is "by audience, not by accident"; folding this
157
169
  * into `request()`'s error handling would be the identical mistake one
158
170
  * level down. A failure here answers `{ error, error_description }`
159
- * (RFC 6749 §5.2), never `{ code, message }`. `error` becomes this
171
+ * (RFC 6749 section 5.2), never `{ code, message }`. `error` becomes this
160
172
  * `FleetlessError`'s `.code` directly — an OAuth error code (e.g.
161
173
  * `invalid_grant`) IS the stable, branch-on value here, exactly as
162
174
  * `apiError`'s `code` is for every other route.
@@ -456,7 +468,7 @@ function createAssetsApi(http) {
456
468
  };
457
469
  }
458
470
 
459
- // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_1727a3f05719ef3ec49509ea01c84aa4/node_modules/@fleetless/contracts/dist/common.js
471
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/common.js
460
472
  import { z } from "zod";
461
473
  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.";
462
474
  var slug = z.string().min(2).max(63).regex(/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/, SLUG_RULE);
@@ -480,7 +492,7 @@ var applyError = z.object({
480
492
  details: z.record(z.string(), z.unknown()).optional()
481
493
  });
482
494
 
483
- // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_1727a3f05719ef3ec49509ea01c84aa4/node_modules/@fleetless/contracts/dist/mcp.js
495
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/mcp.js
484
496
  import { z as z2 } from "zod";
485
497
  var mcpToolKind = z2.enum(["datapoint", "service", "action", "publisher", "camera"]);
486
498
  var mcpExposure = z2.object({
@@ -515,16 +527,18 @@ var mcpRolePreviewResponse = z2.object({
515
527
  });
516
528
  var MCP_ASSET_LINK_TTL_MS = 15 * 60 * 1e3;
517
529
 
518
- // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_1727a3f05719ef3ec49509ea01c84aa4/node_modules/@fleetless/contracts/dist/protocol.js
530
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/protocol.js
519
531
  import { z as z8 } from "zod";
520
532
 
521
- // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_1727a3f05719ef3ec49509ea01c84aa4/node_modules/@fleetless/contracts/dist/assets.js
533
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/assets.js
522
534
  import { z as z3 } from "zod";
523
535
  var assetKind = z3.enum(["urdf", "mesh", "texture", "other"]);
524
536
  var asset = z3.object({
525
- id: z3.uuid(),
526
- robot_id: z3.uuid(),
527
- kind: assetKind,
537
+ id: z3.uuid().meta({ description: "The asset's id in the store." }),
538
+ robot_id: z3.uuid().meta({ description: "The robot this asset belongs to." }),
539
+ kind: assetKind.meta({
540
+ 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 it has to pre-fetch."
541
+ }),
528
542
  /**
529
543
  * What the robot called it — for a mesh, the `package://` URI the URDF
530
544
  * references, verbatim. That is the only string a developer can match
@@ -559,9 +573,15 @@ var asset = z3.object({
559
573
  * Two identical rules, one of which is enforced and one of which is
560
574
  * documented, is how W7's traversal happened in the first place.
561
575
  */
562
- name: z3.string().min(1).max(500),
563
- media_type: z3.string().min(1).max(120),
564
- size_bytes: z3.number().int().nonnegative(),
576
+ name: z3.string().min(1).max(500).meta({
577
+ description: "What the robot called it \u2014 for a mesh, the `package://` URI the URDF references, verbatim, which is the only string a developer can match against their own workspace. A file the URDF never names (an image a `.dae` loads for itself) is named by joining the mesh's own directory with that internal reference."
578
+ }),
579
+ media_type: z3.string().min(1).max(120).meta({
580
+ description: "The media type of the stored bytes, as the producer reported it."
581
+ }),
582
+ size_bytes: z3.number().int().nonnegative().meta({
583
+ description: "How large the stored file is, in bytes."
584
+ }),
565
585
  /**
566
586
  * The content hash, and the reason two robots sharing a mesh cost one copy.
567
587
  *
@@ -570,14 +590,20 @@ var asset = z3.object({
570
590
  * that re-downloads an identical arm for every robot in a fleet is the
571
591
  * predictable failure of a store that hides it.
572
592
  */
573
- sha256: z3.string().regex(/^[a-f0-9]{64}$/),
574
- created_at: z3.iso.datetime()
593
+ sha256: z3.string().regex(/^[a-f0-9]{64}$/).meta({
594
+ description: 'The content hash, lowercase hex, and the reason two robots sharing a mesh cost one copy. It is exposed because it is the only way a client can tell "this is the same mesh I already have" across robots.'
595
+ }),
596
+ created_at: z3.iso.datetime().meta({
597
+ description: "When the asset was first stored, as an ISO 8601 timestamp."
598
+ })
575
599
  });
576
600
  var urdfCompleteness = z3.object({
577
- /** Whether a URDF has been synced at all. Availability is a different question. */
578
- present: z3.boolean(),
579
- /** How many distinct meshes the URDF references. */
580
- mesh_count: z3.number().int().nonnegative(),
601
+ present: z3.boolean().meta({
602
+ description: "Whether a URDF has been synced at all. Whether one *could* be synced is a different question, answered by `urdf_available`."
603
+ }),
604
+ mesh_count: z3.number().int().nonnegative().meta({
605
+ description: "How many distinct meshes the URDF references."
606
+ }),
581
607
  /**
582
608
  * **Was fehlt, und wovon (W9b, DEF-081).**
583
609
  *
@@ -593,20 +619,34 @@ var urdfCompleteness = z3.object({
593
619
  * Herleitung derselben Tatsache, die von der ersten abweichen kann.
594
620
  */
595
621
  missing: z3.array(z3.object({
596
- uri: z3.string().min(1).max(500),
597
- element: z3.enum(["mesh", "texture"])
598
- }))
622
+ uri: z3.string().min(1).max(500).meta({
623
+ description: "The reference, verbatim, that no stored asset answers \u2014 a `package://` URI the workspace does not hold, or an absolute or bare relative path nothing will ever fetch. A developer whose URDF names one of the latter is entitled to be told so."
624
+ }),
625
+ element: z3.enum(["mesh", "texture"]).meta({
626
+ description: "Which kind of reference it was: geometry the URDF names as a `mesh`, or a `texture` a surface paints with. Without it a client reports a missing texture as a missing mesh, contradicting `mesh_count` beside it."
627
+ })
628
+ })).meta({
629
+ description: "The references nothing in the store answers, each with the element that asked for it. A bare count is a dead end that sends a developer hunting through a workspace by hand; the references are what they can act on, so the references travel."
630
+ })
599
631
  });
600
632
  var assetSyncRequest = z3.object({
601
- source: z3.enum(["bridge"])
633
+ source: z3.enum(["bridge"]).meta({
634
+ description: "Where the bytes come from. `bridge` is the only value today: the connected bridge reads them from the robot's own workspace. It is validated rather than ignored, so a caller naming a source that does not exist yet learns that instead of silently getting a bridge sync."
635
+ })
602
636
  }).strict();
603
637
  var assetSyncResponse = z3.object({
604
- sync_id: z3.uuid()
638
+ sync_id: z3.uuid().meta({
639
+ 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."
640
+ })
605
641
  });
606
642
  var ASSET_UPLOAD_MAX_BYTES = 64 * 1024 * 1024;
607
643
  var assetTooLargeDetails = z3.object({
608
- limit_bytes: z3.number().int().positive(),
609
- size_bytes: z3.number().int().positive()
644
+ limit_bytes: z3.number().int().positive().meta({
645
+ description: "The upload ceiling, in bytes."
646
+ }),
647
+ size_bytes: z3.number().int().positive().meta({
648
+ description: 'How large the refused file actually 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.'
649
+ })
610
650
  });
611
651
  var assetFailureKind = z3.enum(["unresolvable", "upload_failed", "refused", "too_large"]);
612
652
  var assetFailure = z3.object({
@@ -617,8 +657,12 @@ var assetFailure = z3.object({
617
657
  * is `URDF_ASSET_NAME`, which is **not** a mesh URI: a consumer rendering
618
658
  * this list must not assume every entry is one.
619
659
  */
620
- reference: z3.string().min(1).max(500),
621
- kind: assetFailureKind,
660
+ reference: z3.string().min(1).max(500).meta({
661
+ 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."
662
+ }),
663
+ kind: assetFailureKind.meta({
664
+ 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`."
665
+ }),
622
666
  /**
623
667
  * **Die zwei Zahlen, und warum `too_large` eine eigene Art ist (W9b).**
624
668
  *
@@ -638,7 +682,9 @@ var assetFailure = z3.object({
638
682
  * beschrieben: ein Feld, dessen Regel nur im Kommentar steht, ist eine
639
683
  * Bitte.
640
684
  */
641
- details: assetTooLargeDetails.nullish()
685
+ details: assetTooLargeDetails.nullish().meta({
686
+ 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."
687
+ })
642
688
  }).superRefine((f, ctx) => {
643
689
  if (f.kind === "too_large" && f.details == null) {
644
690
  ctx.addIssue({ code: "custom", path: ["details"], message: "`too_large` without limit_bytes/size_bytes says nothing a developer can act on" });
@@ -649,11 +695,17 @@ var assetFailure = z3.object({
649
695
  });
650
696
  var assetSyncState = z3.enum(["running", "succeeded", "failed"]);
651
697
  var assetSyncStatus = z3.object({
652
- sync_id: z3.uuid(),
653
- robot_id: z3.uuid(),
654
- state: assetSyncState,
655
- done: z3.number().int().nonnegative(),
656
- total: z3.number().int().nonnegative(),
698
+ sync_id: z3.uuid().meta({ description: "The sync this status describes." }),
699
+ robot_id: z3.uuid().meta({ description: "The robot whose assets are being synced." }),
700
+ state: assetSyncState.meta({
701
+ description: "Whether the sync is still `running`, or ended `succeeded` or `failed`. It ends `succeeded` only when nothing was left behind: a single entry in `failed` makes the whole sync `failed`."
702
+ }),
703
+ done: z3.number().int().nonnegative().meta({
704
+ description: "How many files have been transferred so far."
705
+ }),
706
+ total: z3.number().int().nonnegative().meta({
707
+ description: "How many files this sync set out to transfer. It is `0` until the producer has finished working out what there is."
708
+ }),
657
709
  /**
658
710
  * **Every entry says *why*, because reconciliation could not work without
659
711
  * it and a developer could not read it without it** (W7a review, André's
@@ -698,14 +750,23 @@ var assetSyncStatus = z3.object({
698
750
  *
699
751
  * 1000 x 500 bytes is ~0.5 MiB of names, comfortably inside a 2 MiB frame.
700
752
  */
701
- failed: z3.array(assetFailure).max(1e3),
702
- /** Why the sync ended as it did, when that is not a per-URI fact. */
703
- reason: z3.string().min(1).nullable(),
704
- started_at: z3.iso.datetime(),
705
- updated_at: z3.iso.datetime()
753
+ failed: z3.array(assetFailure).max(1e3).meta({
754
+ description: "What could not be provided, one entry per reference, each saying why. Required rather than optional: a sync that quietly drops three meshes and reports success moves the failure into somebody's renderer, where it shows up as a robot with missing limbs and no cause. At most `1000` entries \u2014 a producer at its own ceiling reports one entry saying so rather than growing the list."
755
+ }),
756
+ reason: z3.string().min(1).nullable().meta({
757
+ 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."
758
+ }),
759
+ started_at: z3.iso.datetime().meta({
760
+ description: "When the sync started, as an ISO 8601 timestamp."
761
+ }),
762
+ updated_at: z3.iso.datetime().meta({
763
+ description: "When this status last changed, as an ISO 8601 timestamp. A sync that stops moving is visible here rather than only in `state`."
764
+ })
706
765
  });
707
766
  var assetListResponse = z3.object({
708
- assets: z3.array(asset),
767
+ assets: z3.array(asset).meta({
768
+ description: "Every asset stored for this robot: the URDF, the meshes it references, and the textures those paint with."
769
+ }),
709
770
  /**
710
771
  * Der gerade laufende Sync, oder `null` (W9b, DEF-147).
711
772
  *
@@ -716,8 +777,12 @@ var assetListResponse = z3.object({
716
777
  * die id nicht aufgehoben hatte. Eine Seite, die frisch lädt, drückt keinen
717
778
  * Knopf; sie fragt diese Liste. Also muss die Liste es sagen.
718
779
  */
719
- active_sync: assetSyncStatus.nullable(),
720
- urdf: urdfCompleteness,
780
+ active_sync: assetSyncStatus.nullable().meta({
781
+ description: "The sync running right now, or `null`. It is on this list so a page that reloads and has lost the sync id can still show progress \u2014 a freshly loaded page presses no button, it asks this list."
782
+ }),
783
+ urdf: urdfCompleteness.meta({
784
+ description: "Whether the stored URDF can actually be rendered, and what it is still missing. Not the same question as whether one was uploaded."
785
+ }),
721
786
  /**
722
787
  * What the connected bridge says it *could* transfer, which is deliberately
723
788
  * separate from what has been transferred (§4.6: the bridge "meldet nur
@@ -745,7 +810,9 @@ var assetListResponse = z3.object({
745
810
  * Rosie-W7a closing it, and this comment was still describing the gap a wave
746
811
  * after it was fixed (Momus-W7a, W7a review).
747
812
  */
748
- urdf_available: z3.boolean().nullable()
813
+ urdf_available: z3.boolean().nullable().meta({
814
+ description: 'What the connected bridge says it *could* transfer, which is deliberately separate from what has been transferred. `null` when no bridge is connected \u2014 distinct from `false`, because "no robot is online to ask" and "the robot has no URDF" send a developer to two 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.'
815
+ })
749
816
  });
750
817
  var assetSyncBusyDetails = z3.object({
751
818
  sync_id: z3.uuid(),
@@ -753,10 +820,10 @@ var assetSyncBusyDetails = z3.object({
753
820
  started_at_ms: z3.number().int().nonnegative()
754
821
  });
755
822
 
756
- // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_1727a3f05719ef3ec49509ea01c84aa4/node_modules/@fleetless/contracts/dist/config.js
823
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/config.js
757
824
  import { z as z5 } from "zod";
758
825
 
759
- // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_1727a3f05719ef3ec49509ea01c84aa4/node_modules/@fleetless/contracts/dist/alerts.js
826
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/alerts.js
760
827
  import { z as z4 } from "zod";
761
828
  var alertRowCondition = z4.discriminatedUnion("kind", [
762
829
  z4.strictObject({
@@ -821,7 +888,7 @@ var putDatapointDisplayRequest = z4.object({
821
888
  y_max: z4.number().finite().nullable()
822
889
  }).strict();
823
890
 
824
- // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_1727a3f05719ef3ec49509ea01c84aa4/node_modules/@fleetless/contracts/dist/config.js
891
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/config.js
825
892
  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:`.";
826
893
  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:`.";
827
894
  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.";
@@ -1876,7 +1943,7 @@ var configState = z5.object({
1876
1943
  applied_errors: z5.array(applyError).nullable()
1877
1944
  });
1878
1945
 
1879
- // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_1727a3f05719ef3ec49509ea01c84aa4/node_modules/@fleetless/contracts/dist/introspection.js
1946
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/introspection.js
1880
1947
  import { z as z6 } from "zod";
1881
1948
  var rosGraphEntry = z6.object({
1882
1949
  name: rosName,
@@ -1915,16 +1982,26 @@ var typeDefinition = z6.discriminatedUnion("kind", [
1915
1982
  })
1916
1983
  ]);
1917
1984
 
1918
- // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_1727a3f05719ef3ec49509ea01c84aa4/node_modules/@fleetless/contracts/dist/jobs.js
1985
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/jobs.js
1919
1986
  import { z as z7 } from "zod";
1920
1987
  var jobState = z7.enum(["running", "succeeded", "failed", "cancelled", "lost"]);
1921
1988
  var job = z7.object({
1922
- id: z7.uuid(),
1923
- robot_id: z7.uuid(),
1924
- slug,
1925
- state: jobState,
1926
- started_at: z7.iso.datetime(),
1927
- updated_at: z7.iso.datetime(),
1989
+ id: z7.uuid().meta({
1990
+ description: "The job's id, minted by the cloud when the invocation is accepted. Informative: state is observed by slug, and this id is what a cancel names when a caller wants to stop one specific job rather than whatever is running."
1991
+ }),
1992
+ robot_id: z7.uuid().meta({ description: "The robot this job is running on." }),
1993
+ slug: slug.meta({
1994
+ 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."
1995
+ }),
1996
+ state: jobState.meta({
1997
+ 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 and is said out loud rather than left reading `running` because nobody contradicted it."
1998
+ }),
1999
+ started_at: z7.iso.datetime().meta({
2000
+ description: "When the cloud minted this job, as an ISO 8601 timestamp. For a job adopted from a reconnecting bridge it is **adoption time**, not the real start, because the cloud never minted it and has no honest alternative."
2001
+ }),
2002
+ updated_at: z7.iso.datetime().meta({
2003
+ description: "When this job last changed, as an ISO 8601 timestamp."
2004
+ }),
1928
2005
  /**
1929
2006
  * A monotonic counter, ascending in mint order (W7), and the **named**
1930
2007
  * tiebreaker for any listing that claims an order.
@@ -1941,9 +2018,12 @@ var job = z7.object({
1941
2018
  * orders jobs that coexist in one registry — and stated, because a reader
1942
2019
  * who assumed `auditEvent.seq`'s durable semantics would be wrong.
1943
2020
  */
1944
- seq: z7.number().int().positive(),
1945
- /** Present once the job succeeded; shape is the ROS result's. */
1946
- result: z7.unknown().nullable(),
2021
+ seq: z7.number().int().positive().meta({
2022
+ description: "A monotonic counter ascending in mint order, and the named tiebreaker for any listing that claims one \u2014 `started_at` alone is not a total order. Scoped per cloud process and per run: job state lives in memory, so this restarts with the registry it orders."
2023
+ }),
2024
+ result: z7.unknown().nullable().meta({
2025
+ description: "What the call returned once it succeeded, shaped by the ROS action or service itself. `null` until then, and for a job that did not succeed."
2026
+ }),
1947
2027
  /**
1948
2028
  * Present on `failed`; a human message, plus a code where one exists.
1949
2029
  *
@@ -1962,10 +2042,18 @@ var job = z7.object({
1962
2042
  * `jobQueueFullDetails` — it belongs here, not in the sentence.
1963
2043
  */
1964
2044
  error: z7.object({
1965
- code: z7.string().min(1),
1966
- message: z7.string().min(1),
1967
- details: z7.unknown().optional()
1968
- }).nullable()
2045
+ code: z7.string().min(1).meta({
2046
+ description: "A machine-readable code for the failure, such as `job_queue_full`, where one exists for it."
2047
+ }),
2048
+ message: z7.string().min(1).meta({
2049
+ description: "A human-readable sentence saying what went wrong."
2050
+ }),
2051
+ details: z7.unknown().optional().meta({
2052
+ 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."
2053
+ })
2054
+ }).nullable().meta({
2055
+ 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`."
2056
+ })
1969
2057
  });
1970
2058
  var jobEvent = z7.object({
1971
2059
  type: z7.literal("job"),
@@ -1995,66 +2083,118 @@ var jobQueueFullDetails = z7.object({
1995
2083
  });
1996
2084
  var JOB_RUN_PAGE_MAX = 200;
1997
2085
  var jobActor = z7.object({
1998
- kind: z7.enum(["developer", "end_user", "server_key"]),
1999
- id: z7.uuid(),
2086
+ kind: z7.enum(["developer", "end_user", "server_key"]).meta({
2087
+ description: "What the caller was acting as: a `developer` in the console, an `end_user` of an app, or a `server_key` used by server-side code. A bridge invokes nothing, so it is deliberately not a case here."
2088
+ }),
2089
+ id: z7.uuid().meta({
2090
+ description: "The id of the developer, end user or server key that invoked the run."
2091
+ }),
2000
2092
  /**
2001
2093
  * The email for a developer or end user, the key's `name` for a server key.
2002
2094
  * A display snapshot taken at invoke time: renaming a key afterwards does not
2003
2095
  * rewrite history, which is the point of storing it rather than joining.
2004
2096
  */
2005
- label: z7.string().min(1).max(200)
2097
+ label: z7.string().min(1).max(200).meta({
2098
+ description: "A display name taken at invoke time \u2014 the email for a developer or end user, the key's own name for a server key. Storing it rather than joining is the point: renaming a key afterwards does not rewrite history."
2099
+ })
2006
2100
  });
2007
2101
  var jobRunKind = z7.enum(["action", "service"]);
2008
2102
  var jobRun = z7.object({
2009
- id: z7.uuid(),
2010
- robot_id: z7.uuid(),
2011
- slug,
2012
- kind: jobRunKind,
2013
- state: jobState,
2014
- started_at: z7.iso.datetime(),
2015
- /** `null` while `running` — a run has an end only once it has one. */
2016
- ended_at: z7.iso.datetime().nullable(),
2017
- /** `null` while `running`. Not "0 so far". */
2018
- duration_ms: z7.number().int().nonnegative().nullable(),
2019
- result: z7.unknown().nullable(),
2020
- error: z7.object({ code: z7.string().min(1), message: z7.string().min(1), details: z7.unknown().optional() }).nullable(),
2021
- actor: jobActor,
2103
+ id: z7.uuid().meta({
2104
+ description: "The run's id, which is the same id the invocation was answered with \u2014 so a caller that kept a job id can find its durable record here later."
2105
+ }),
2106
+ robot_id: z7.uuid().meta({ description: "The robot the run happened on." }),
2107
+ slug: slug.meta({
2108
+ description: "The action or service that was invoked, as the published configuration exposed it at the time."
2109
+ }),
2110
+ kind: jobRunKind.meta({
2111
+ description: "Whether the slug was an `action` or a `service`."
2112
+ }),
2113
+ state: jobState.meta({
2114
+ 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."
2115
+ }),
2116
+ started_at: z7.iso.datetime().meta({
2117
+ description: "When the run started, as an ISO 8601 timestamp. Runs are listed and filtered by this instant."
2118
+ }),
2119
+ ended_at: z7.iso.datetime().nullable().meta({
2120
+ 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."
2121
+ }),
2122
+ duration_ms: z7.number().int().nonnegative().nullable().meta({
2123
+ description: 'How long the run took, in milliseconds. `null` while it is still `running`, never `0` standing in for "nothing so far".'
2124
+ }),
2125
+ result: z7.unknown().nullable().meta({
2126
+ description: "What the action or service returned once it succeeded, shaped by ROS itself. `null` otherwise."
2127
+ }),
2128
+ error: z7.object({
2129
+ code: z7.string().min(1).meta({
2130
+ description: "A machine-readable code for the failure, such as `job_queue_full`, where one exists for it."
2131
+ }),
2132
+ message: z7.string().min(1).meta({
2133
+ description: "A human-readable sentence saying what went wrong."
2134
+ }),
2135
+ details: z7.unknown().optional().meta({
2136
+ description: "The structured payload belonging to `code`, for the codes that document one. Absent for a failure with nothing structured to add."
2137
+ })
2138
+ }).nullable().meta({
2139
+ description: "Why the run failed \u2014 a `message`, a `code` where one exists, and the structured `details` some codes carry. `null` unless it failed."
2140
+ }),
2141
+ actor: jobActor.meta({
2142
+ description: "Who invoked the run, and what they were acting as at the time."
2143
+ }),
2022
2144
  /**
2023
2145
  * **Durable, unlike `job.seq`.** That one is a per-process counter that
2024
2146
  * restarts with the cloud; this is a postgres `bigserial` and is the cursor
2025
2147
  * `before_seq` walks.
2026
2148
  */
2027
- seq: z7.number().int().positive(),
2028
- /**
2029
- * Live-only, read from the in-memory registry for rows that are still
2030
- * running. `null` means **"not known right now"** — after a cloud restart,
2031
- * before the bridge reconnects and never "0 %". A fraction, as in
2032
- * `jobEvent.progress`, not a percentage.
2033
- */
2034
- progress: z7.number().min(0).max(1).nullable(),
2035
- feedback: z7.unknown().nullable()
2149
+ seq: z7.number().int().positive().meta({
2150
+ description: "The durable cursor this history is ordered and paged by. Unlike `job.seq` it does not restart when the cloud does; it is the value a caller sends back as `before_seq`."
2151
+ }),
2152
+ progress: z7.number().min(0).max(1).nullable().meta({
2153
+ description: 'How far a still-running run has got, as a fraction from `0` to `1`, read live from the in-memory registry. `null` means **not known right now** \u2014 after a cloud restart, before the bridge reconnects \u2014 and never a `0` standing in for "no progress yet".'
2154
+ }),
2155
+ feedback: z7.unknown().nullable().meta({
2156
+ description: "The most recent action feedback for a run that is still running, shaped by the ROS action. Live-only, so it is `null` for every settled run and whenever the registry has nothing."
2157
+ })
2036
2158
  });
2037
2159
  var jobRunQuery = z7.object({
2038
- /** Only runs with a smaller `seq` — the next, older page. */
2039
- before_seq: wireSeqCursor.optional(),
2040
- limit: z7.union([z7.string().regex(/^\d{1,4}$/), z7.number().int()]).transform((v) => Number(v)).pipe(z7.number().int().positive().max(JOB_RUN_PAGE_MAX)).optional(),
2041
- robot_id: z7.uuid().optional(),
2042
- slug: slug.optional(),
2043
- state: jobState.optional(),
2044
- kind: jobRunKind.optional(),
2045
- /** Half-open `[from, to)`, the same rule the history shapes follow (DEF-062). */
2046
- from_ms: wireTimestampMs.optional(),
2047
- to_ms: wireTimestampMs.optional()
2160
+ before_seq: wireSeqCursor.optional().meta({
2161
+ description: "Return only runs with a `seq` below this value \u2014 the next, older page. Send back the `next_cursor` of the previous response rather than computing one."
2162
+ }),
2163
+ limit: z7.union([z7.string().regex(/^\d{1,4}$/), z7.number().int()]).transform((v) => Number(v)).pipe(z7.number().int().positive().max(JOB_RUN_PAGE_MAX)).optional().meta({
2164
+ description: "How many runs to return, from `1` to `200`. Absent means `100`. It arrives on the query string, so a numeric string and a number are both accepted."
2165
+ }),
2166
+ robot_id: z7.uuid().optional().meta({
2167
+ description: "Only runs on this robot. Absent means every robot in the organisation."
2168
+ }),
2169
+ slug: slug.optional().meta({
2170
+ description: "Only runs of this action or service."
2171
+ }),
2172
+ state: jobState.optional().meta({
2173
+ description: "Only runs in this state \u2014 `running`, `succeeded`, `failed`, `cancelled` or `lost`."
2174
+ }),
2175
+ kind: jobRunKind.optional().meta({
2176
+ description: "Only `action` runs, or only `service` runs."
2177
+ }),
2178
+ from_ms: wireTimestampMs.optional().meta({
2179
+ description: "Only runs that started at or after this unix timestamp in milliseconds. Together with `to_ms` the window is half-open, `[from, to)`, so adjacent windows tile without counting a run twice."
2180
+ }),
2181
+ to_ms: wireTimestampMs.optional().meta({
2182
+ description: "Only runs that started **before** this unix timestamp in milliseconds. The window is half-open, so a run starting exactly on `to_ms` belongs to the next one."
2183
+ })
2048
2184
  }).strict();
2049
2185
  var jobRunListResponse = z7.object({
2050
- runs: z7.array(jobRun),
2186
+ runs: z7.array(jobRun).meta({
2187
+ description: "This page of runs, newest first by `seq`. Empty means the filter matched nothing, not that the history is gone."
2188
+ }),
2051
2189
  /**
2052
2190
  * The `seq` a caller sends as `before_seq` to keep reading — or `null` when
2053
2191
  * there is nothing further. **`null` means the end, and that is a promise
2054
2192
  * rather than an observation.** A caller who instead compares `runs.length`
2055
2193
  * against `limit` is wrong the moment a filter makes a page thin.
2056
2194
  */
2057
- next_cursor: z7.number().int().positive().nullable()
2195
+ next_cursor: z7.number().int().positive().nullable().meta({
2196
+ description: "The `seq` to send as `before_seq` to keep reading, or `null` when there is nothing further. **`null` is a promise, not an observation** \u2014 a caller who instead compares the page length against `limit` is wrong the moment a filter makes a page thin."
2197
+ })
2058
2198
  });
2059
2199
  var jobRunSummaryQuery = z7.object({ since_ms: wireTimestampMs }).strict();
2060
2200
  var jobRunSummary = z7.object({
@@ -2064,7 +2204,7 @@ var jobRunSummary = z7.object({
2064
2204
  since_ms: z7.number().int().nonnegative()
2065
2205
  });
2066
2206
 
2067
- // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_1727a3f05719ef3ec49509ea01c84aa4/node_modules/@fleetless/contracts/dist/protocol.js
2207
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/protocol.js
2068
2208
  var MAX_PATIENCE_MS = 12e4;
2069
2209
  var MIN_PATIENCE_MS = 1e3;
2070
2210
  var activeJob = z8.object({
@@ -2483,11 +2623,11 @@ var bridgeCameraState = z8.object({
2483
2623
  request_id: z8.string().min(1).max(64).nullable()
2484
2624
  });
2485
2625
 
2486
- // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_1727a3f05719ef3ec49509ea01c84aa4/node_modules/@fleetless/contracts/dist/config-issues.js
2626
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/config-issues.js
2487
2627
  var EXPOSURE_SECTIONS = ["datapoints", "actions", "services", "publishers", "cameras"];
2488
2628
  var EXPOSURE_SECTION_NAMES = new Set(EXPOSURE_SECTIONS);
2489
2629
 
2490
- // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_1727a3f05719ef3ec49509ea01c84aa4/node_modules/@fleetless/contracts/dist/rest.js
2630
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/rest.js
2491
2631
  import { z as z9 } from "zod";
2492
2632
  var robot = z9.object({
2493
2633
  id: z9.uuid(),
@@ -2519,9 +2659,13 @@ var robotListResponse = z9.object({
2519
2659
  robots: z9.array(robotListItem)
2520
2660
  });
2521
2661
  var datapointValue = z9.object({
2522
- slug,
2523
- value: z9.unknown(),
2524
- timestamp_ms: z9.number().int().nonnegative()
2662
+ slug: slug.meta({ description: "The datapoint this value belongs to." }),
2663
+ value: z9.unknown().meta({
2664
+ description: "The value itself, shaped by the datapoint: a number, a boolean, a string, or the whole ROS message where the configuration names no field inside it. Any `scale` and `offset` the configuration declares have already been applied, at the robot."
2665
+ }),
2666
+ timestamp_ms: z9.number().int().nonnegative().meta({
2667
+ description: "When the value was captured, as a unix timestamp in milliseconds. This is the **bridge's capture time**, never the time the cloud received it \u2014 the one exception is the built-in `bridge_state`, which the cloud observes by construction."
2668
+ })
2525
2669
  });
2526
2670
  var robotDetailResponse = z9.object({
2527
2671
  ...robotListItem.shape,
@@ -2577,9 +2721,13 @@ var fetchTypesResponse = z9.object({
2577
2721
  unresolved: z9.array(z9.string())
2578
2722
  });
2579
2723
  var datapointDescriptor = z9.object({
2580
- slug,
2581
- builtin: z9.boolean(),
2582
- unit: z9.string().nullable(),
2724
+ slug: slug.meta({ description: "The name a client reads this datapoint by." }),
2725
+ builtin: z9.boolean().meta({
2726
+ 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."
2727
+ }),
2728
+ unit: z9.string().nullable().meta({
2729
+ 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."
2730
+ }),
2583
2731
  /**
2584
2732
  * `null` for a built-in and for a datapoint published with no throttle —
2585
2733
  * the same "no ceiling configured" fact `datapointConfig.rate_throttle_hz`
@@ -2587,15 +2735,21 @@ var datapointDescriptor = z9.object({
2587
2735
  * optional because this shape is a read response, not a document a caller
2588
2736
  * writes. Reuses `rateThrottleHz` so the 20 Hz ceiling is written once.
2589
2737
  */
2590
- rate_throttle_hz: rateThrottleHz.nullable()
2738
+ rate_throttle_hz: rateThrottleHz.nullable().meta({
2739
+ description: "The ceiling on how often this datapoint is sent, in hertz. `null` means no ceiling is configured, which is also the answer for every built-in. A ceiling, not a clock: a slow topic stays slow and no value is repeated to manufacture a rate."
2740
+ })
2591
2741
  });
2592
2742
  var datapointListResponse = z9.object({
2593
- datapoints: z9.array(datapointDescriptor)
2743
+ datapoints: z9.array(datapointDescriptor).meta({
2744
+ 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."
2745
+ })
2594
2746
  });
2595
2747
  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())]));
2596
2748
  var putRobotDetailsRequest = z9.object({ details: robotDetailsDoc });
2597
2749
  var invokeRequest = z9.object({
2598
- params: z9.record(z9.string(), z9.unknown()),
2750
+ params: z9.record(z9.string(), z9.unknown()).meta({
2751
+ description: "The values this call needs, keyed by **parameter name** rather than by field path \u2014 so a name survives the field moving inside the message. Every parameter without a default must be present, and the bounds the configuration declares are enforced in the cloud, before anything reaches the robot."
2752
+ }),
2599
2753
  /**
2600
2754
  * How long **this call** is worth waiting for, in milliseconds (W6b).
2601
2755
  *
@@ -2622,31 +2776,52 @@ var invokeRequest = z9.object({
2622
2776
  * *acceptance* — once a goal is accepted the job runs as long as it runs,
2623
2777
  * and is observed, not awaited.
2624
2778
  */
2625
- patience_ms: z9.number().int().min(MIN_PATIENCE_MS).max(MAX_PATIENCE_MS).optional()
2779
+ patience_ms: z9.number().int().min(MIN_PATIENCE_MS).max(MAX_PATIENCE_MS).optional().meta({
2780
+ description: "How long **this call** is worth waiting for, in milliseconds; absent means the platform default. The number travels to the robot too, so one deadline governs both sides. Above the maximum the call is refused rather than quietly clamped, because a caller given less than they asked for would read the timeout as the robot's failure."
2781
+ })
2626
2782
  });
2627
2783
  var cancelRequest = z9.object({
2628
- job_id: z9.uuid().nullable().optional()
2784
+ job_id: z9.uuid().nullable().optional().meta({
2785
+ description: "The one job to stop. Absent or `null` cancels whatever is currently running on the slug, which is what every caller written before this field existed means. Unknown keys are refused rather than stripped, so a misspelling cannot silently become the slug-wide cancel."
2786
+ })
2629
2787
  }).strict();
2630
2788
  var releaseLiveQuery = z9.object({
2631
- session_id: z9.uuid().optional()
2789
+ session_id: z9.uuid().optional().meta({
2790
+ description: "The one hold to release, as the live session returned it. Absent releases **all** of this identity's holds on this camera \u2014 the blunt form, still needed by a client that has lost its id or is going away, and the one that strands the identity's other tabs."
2791
+ })
2632
2792
  }).strict();
2633
2793
  var invokeResponse = z9.object({
2634
- job,
2794
+ job: job.meta({
2795
+ description: "The job that now exists on this slug. It is returned as soon as the goal is accepted, so `state` is `running` here \u2014 the outcome is observed afterwards, by slug, over polling or a subscription."
2796
+ }),
2635
2797
  /** The slug's kind — see `commandResult.kind` for why the caller needs it. */
2636
- kind: z9.enum(["action", "service"])
2798
+ kind: z9.enum(["action", "service"]).meta({
2799
+ description: "Always `action` in this shape. A caller sends the same request for both kinds and cannot tell from a role grant which it invoked, so the answer says which it was rather than leaving it to be inferred from the shape."
2800
+ })
2637
2801
  });
2638
2802
  var serviceCallResponse = z9.object({
2639
- result: z9.unknown()
2803
+ result: z9.unknown().meta({
2804
+ description: "What the service returned, shaped by the ROS service itself. A service call is awaited to completion, so there is no job to observe afterwards and no id to hold on to."
2805
+ })
2640
2806
  });
2807
+ var invokeOrServiceResponse = z9.union([invokeResponse, serviceCallResponse]);
2641
2808
  var publishRequest = z9.object({
2642
- message: z9.record(z9.string(), z9.unknown())
2809
+ message: z9.record(z9.string(), z9.unknown()).meta({
2810
+ description: "The values to publish, keyed by the **parameter names** the publisher declares \u2014 the same flat form an invoke takes for `params`. They are checked against the declared bounds in the cloud before anything reaches the robot, and a slug another caller is still holding is refused with the remaining wait."
2811
+ })
2812
+ });
2813
+ var jobResponse = z9.object({
2814
+ job: job.nullable().meta({
2815
+ description: "The **most recent** job on this slug, running or already finished, and `null` only when nothing has ever run there. Read `state` to tell a live job from a settled one: a route that forgot a job the moment it settled would let a poller see `running` and then nothing."
2816
+ })
2643
2817
  });
2644
- var jobResponse = z9.object({ job: job.nullable() });
2645
2818
  var rateLimitDetails = z9.object({
2646
2819
  retry_after_ms: z9.number().int().nonnegative()
2647
2820
  });
2648
2821
  var robotJobsResponse = z9.object({
2649
- jobs: z9.array(job)
2822
+ jobs: z9.array(job).meta({
2823
+ description: "At most one entry per slug \u2014 the current job there \u2014 ordered newest **known** first, and never `null`: a robot doing nothing answers an empty array. This is not a history endpoint. For an adopted job `started_at` is adoption time, so a job that has been running for an hour can sit above one started a minute ago."
2824
+ })
2650
2825
  });
2651
2826
  var exposure = z9.object({
2652
2827
  slug,
@@ -2663,10 +2838,16 @@ var SNAPSHOT_HEADERS = {
2663
2838
  height: "x-fleetless-height"
2664
2839
  };
2665
2840
  var cameraDescriptor = z9.object({
2666
- slug,
2667
- width: z9.number().int().positive(),
2668
- height: z9.number().int().positive(),
2669
- fps: z9.number().int().positive(),
2841
+ slug: slug.meta({ description: "The name a client addresses this camera by." }),
2842
+ width: z9.number().int().positive().meta({
2843
+ description: "Frame width in pixels, as the published configuration declares it."
2844
+ }),
2845
+ height: z9.number().int().positive().meta({
2846
+ description: "Frame height in pixels, as the published configuration declares it."
2847
+ }),
2848
+ fps: z9.number().int().positive().meta({
2849
+ description: "How many frames per second the camera is configured to publish while somebody is watching live."
2850
+ }),
2670
2851
  /**
2671
2852
  * Seconds, as the document spells it, reusing `snapshotIntervalSeconds` so
2672
2853
  * the 1–3600 bound is written once. It was `snapshot_interval_ms` after the
@@ -2674,9 +2855,15 @@ var cameraDescriptor = z9.object({
2674
2855
  * this descriptor and not on `datapointDescriptor` beside it — the same
2675
2856
  * drift `rateThrottleHz` was extracted to stop.
2676
2857
  */
2677
- snapshot_interval_seconds: snapshotIntervalSeconds
2858
+ snapshot_interval_seconds: snapshotIntervalSeconds.meta({
2859
+ description: "How often a still frame is captured for the cheap snapshot reads, in seconds, between `1` and `3600`. Independent of `fps`, which is about live video."
2860
+ })
2861
+ });
2862
+ var cameraListResponse = z9.object({
2863
+ cameras: z9.array(cameraDescriptor).meta({
2864
+ description: "Every camera the published configuration exposes on this robot **and** the caller's role grants. A developer sees all of them; an end user sees what their role allows."
2865
+ })
2678
2866
  });
2679
- var cameraListResponse = z9.object({ cameras: z9.array(cameraDescriptor) });
2680
2867
  var liveSessionResponse = z9.object({
2681
2868
  /**
2682
2869
  * This viewer's hold, and the **only** thing `DELETE` should be given
@@ -2695,29 +2882,56 @@ var liveSessionResponse = z9.object({
2695
2882
  * is going away entirely, still needs a way to let go. It is the blunt
2696
2883
  * form, and it is the one that strands other tabs; new callers pass the id.
2697
2884
  */
2698
- session_id: z9.uuid(),
2699
- url: z9.string().min(1),
2700
- room: z9.string().min(1),
2701
- token: z9.string().min(1),
2702
- expires_at: z9.iso.datetime()
2885
+ session_id: z9.uuid().meta({
2886
+ description: "This viewer's hold, and the only thing a release should be given. Two tabs of one logged-in user are two holds; releasing without an id lets go of both and leaves the other tab rendering a stream the robot has already stopped producing."
2887
+ }),
2888
+ url: z9.string().min(1).meta({
2889
+ description: "The LiveKit server to connect to, as a WebSocket URL."
2890
+ }),
2891
+ room: z9.string().min(1).meta({
2892
+ description: "The LiveKit room carrying this camera. Every viewer of one camera on one robot joins the same room, which is what makes the refcount hold meaningful."
2893
+ }),
2894
+ token: z9.string().min(1).meta({
2895
+ description: "The LiveKit access token to join `room` with. It is checked when the participant connects and **not again afterwards** \u2014 which is not the same as irrevocable: the cloud can still disconnect a participant after the fact, and does when membership, a role or a key changes."
2896
+ }),
2897
+ expires_at: z9.iso.datetime().meta({
2898
+ description: "The deadline for **joining**, as an ISO 8601 timestamp \u2014 not a session backstop. A viewer who has already joined keeps receiving video past this moment, so cleanup belongs in an explicit release, never in a timer built on this value."
2899
+ })
2703
2900
  });
2704
2901
  var snapshotMetaResponse = z9.object({
2705
- slug,
2706
- timestamp_ms: z9.number().int().nonnegative().nullable(),
2707
- age_ms: z9.number().int().nonnegative().nullable(),
2708
- width: z9.number().int().positive().nullable(),
2709
- height: z9.number().int().positive().nullable(),
2710
- mime: z9.string().nullable()
2902
+ slug: slug.meta({ description: "The camera this snapshot belongs to." }),
2903
+ timestamp_ms: z9.number().int().nonnegative().nullable().meta({
2904
+ description: "When the stored frame was captured, as a unix timestamp in milliseconds. `null` means nothing has been captured yet, which is an answer rather than an error."
2905
+ }),
2906
+ age_ms: z9.number().int().nonnegative().nullable().meta({
2907
+ description: "How old the stored frame is right now, in milliseconds; `null` when there is none. Snapshots are deliberately cheap and therefore deliberately old, and a cached frame served without its age is indistinguishable from a live one."
2908
+ }),
2909
+ width: z9.number().int().positive().nullable().meta({
2910
+ description: "Width of the stored frame in pixels, or `null` when nothing has been captured yet."
2911
+ }),
2912
+ height: z9.number().int().positive().nullable().meta({
2913
+ description: "Height of the stored frame in pixels, or `null` when nothing has been captured yet."
2914
+ }),
2915
+ mime: z9.string().nullable().meta({
2916
+ description: "The media type of the stored frame, such as `image/jpeg`, or `null` when nothing has been captured yet."
2917
+ })
2711
2918
  });
2712
2919
  var historyQuery = z9.object({
2713
- from: z9.string().min(1).max(32),
2714
- /** Defaults to now. */
2715
- to: z9.string().min(1).max(32).optional(),
2716
- /** Bucket width, e.g. `10s`, `1m`. Absent means raw samples. */
2717
- window: z9.string().min(2).max(16).optional(),
2718
- agg: z9.enum(["min", "max", "avg"]).optional(),
2719
- /** A numeric field inside an object value, e.g. `pose.x` (§4.4 paths). */
2720
- field: z9.string().min(1).max(128).optional(),
2920
+ from: z9.string().min(1).max(32).meta({
2921
+ description: "The start of the window: either a relative expression \u2014 `now-30s`, `now-5m`, `now-1h` \u2014 or absolute unix milliseconds. A chart asks the first way and a report asks the second, and making a client convert would be making it guess our clock."
2922
+ }),
2923
+ to: z9.string().min(1).max(32).optional().meta({
2924
+ description: "The end of the window, in the same two spellings as `from`; absent means now. The window is half-open, `[from, to)`, so a sample landing exactly on `to` belongs to the next window and adjacent windows tile without double-counting."
2925
+ }),
2926
+ window: z9.string().min(2).max(16).optional().meta({
2927
+ description: "The bucket width, such as `10s` or `1m`. Absent means raw samples. It is meaningless without `agg`, and the pair is refused apart rather than defaulted \u2014 a silently chosen aggregation is a chart that lies quietly."
2928
+ }),
2929
+ agg: z9.enum(["min", "max", "avg"]).optional().meta({
2930
+ description: "How each bucket reduces the samples inside it. Valid only together with `window`."
2931
+ }),
2932
+ field: z9.string().min(1).max(128).optional().meta({
2933
+ description: "A dotted path to a numeric field inside an object value, such as `pose.x`. Without it the datapoint's value is used whole, which only works when it is already a number."
2934
+ }),
2721
2935
  /**
2722
2936
  * **A union whose input branch IS the wire, not a coercion (W9d, DEF-059).**
2723
2937
  *
@@ -2758,13 +2972,28 @@ var historyQuery = z9.object({
2758
2972
  // that is arguably stronger: without it the artifact publishes the full
2759
2973
  // safe-integer range, which reads as *nine quadrillion is fine*.
2760
2974
  z9.number().int().describe("Positive integer, 1-10000. The ceiling is enforced after parsing, not by this type.")
2761
- ]).transform((v) => Number(v)).pipe(z9.number().int().positive().max(1e4)).optional()
2975
+ ]).transform((v) => Number(v)).pipe(z9.number().int().positive().max(1e4)).optional().meta({
2976
+ description: "The most samples or buckets to return, from `1` to `10000`. It arrives as text on the query string, so both a numeric string and a number are accepted; the ceiling is enforced after parsing rather than by the published shape."
2977
+ })
2762
2978
  });
2763
2979
  var historySamplesResponse = z9.object({
2764
- slug,
2765
- kind: z9.literal("samples"),
2766
- samples: z9.array(z9.object({ timestamp_ms: z9.number().int().nonnegative(), value: z9.unknown() })),
2767
- truncated: z9.boolean(),
2980
+ slug: slug.meta({ description: "The datapoint these samples belong to." }),
2981
+ kind: z9.literal("samples").meta({
2982
+ description: "Says this is the raw-sample shape, which the query asked for by omitting `window`. A client reads this rather than inspecting which fields arrived."
2983
+ }),
2984
+ samples: z9.array(z9.object({
2985
+ timestamp_ms: z9.number().int().nonnegative().meta({
2986
+ description: "When the sample was captured, as a unix timestamp in milliseconds. It is the **bridge's capture time** \u2014 the same instant the live value carried, so a recorded point and a live one sit on one axis without apology."
2987
+ }),
2988
+ value: z9.unknown().meta({
2989
+ description: "The value as it was stored, shaped by the datapoint. A `field` in the query narrows a message down to one number; without one the whole stored value comes back."
2990
+ })
2991
+ })).meta({
2992
+ description: "The samples in the queried window, oldest first. The window is half-open, `[from, to)`, so a sample landing exactly on `to` belongs to the next window."
2993
+ }),
2994
+ truncated: z9.boolean().meta({
2995
+ description: "Whether the response was cut short. A short array that does not admit it is indistinguishable from a quiet period, and the two lead a developer to opposite conclusions."
2996
+ }),
2768
2997
  /**
2769
2998
  * Why it was cut, `null` when it was not — because the two causes have
2770
2999
  * **different remedies** and a single boolean cannot tell them apart:
@@ -2783,15 +3012,25 @@ var historySamplesResponse = z9.object({
2783
3012
  * `required` in the JSON Schema artifacts, which is the contradiction this
2784
3013
  * project has now hit five times.
2785
3014
  */
2786
- truncated_by: z9.enum(["limit", "bytes"]).nullable()
3015
+ truncated_by: z9.enum(["limit", "bytes"]).nullable().meta({
3016
+ description: "Why it was cut, and `null` when it was not \u2014 the two causes have **different remedies** and one boolean cannot tell them apart. `limit` means too many rows, so raising `limit` helps. `bytes` means the rows are large, so raising `limit` changes nothing: narrow the range, or name a numeric `field` so whole messages are not carried."
3017
+ })
2787
3018
  });
2788
3019
  var historyBucketsResponse = z9.object({
2789
- slug,
2790
- kind: z9.literal("buckets"),
2791
- window_ms: z9.number().int().positive(),
2792
- agg: z9.enum(["min", "max", "avg"]),
3020
+ slug: slug.meta({ description: "The datapoint these buckets summarise." }),
3021
+ kind: z9.literal("buckets").meta({
3022
+ description: "Says this is the aggregated shape, which the query asked for by naming a `window`. A separate shape rather than the sample shape with nulls in it, so a client knows by type what it received rather than by inspection."
3023
+ }),
3024
+ window_ms: z9.number().int().positive().meta({
3025
+ description: "The bucket width actually used, in milliseconds \u2014 the query's `window` resolved to a number, so a rendered chart can say what it is drawing without re-parsing the expression it sent."
3026
+ }),
3027
+ agg: z9.enum(["min", "max", "avg"]).meta({
3028
+ description: "How each bucket reduced the samples inside it, echoed back from the query."
3029
+ }),
2793
3030
  buckets: z9.array(z9.object({
2794
- bucket_start_ms: z9.number().int().nonnegative(),
3031
+ bucket_start_ms: z9.number().int().nonnegative().meta({
3032
+ description: "The instant this bucket opens, as a unix timestamp in milliseconds. Buckets are half-open and `window_ms` wide, so this one covers up to but not including `bucket_start_ms + window_ms`."
3033
+ }),
2795
3034
  /**
2796
3035
  * The aggregate over this bucket's **numeric** samples — or `null` when
2797
3036
  * none of them were numeric, which is **not** the same as the bucket
@@ -2809,7 +3048,9 @@ var historyBucketsResponse = z9.object({
2809
3048
  * so the second row above was indistinguishable from the first and the
2810
3049
  * console rendered "empty — no samples" over live data.
2811
3050
  */
2812
- value: z9.number().nullable(),
3051
+ value: z9.number().nullable().meta({
3052
+ description: "The aggregate over this bucket's **numeric** samples, or `null` when none of them were numeric \u2014 which is **not** the same as the bucket being empty. `sample_count` separates those: `null` with a count of `0` is a gap a chart should draw as a break, `null` with a count above `0` is data that simply has no height."
3053
+ }),
2813
3054
  /**
2814
3055
  * Every sample that landed in this bucket and inside the queried range,
2815
3056
  * whether or not it contributed to `value` — which is the point of the
@@ -2826,9 +3067,14 @@ var historyBucketsResponse = z9.object({
2826
3067
  * and only in-range samples are counted. A low edge count is a
2827
3068
  * boundary effect, not a quiet period.
2828
3069
  */
2829
- sample_count: z9.number().int().nonnegative()
2830
- }))
3070
+ sample_count: z9.number().int().nonnegative().meta({
3071
+ description: "Every sample that landed in this bucket and inside the queried range, whether or not it contributed to `value` \u2014 only a count of *all* samples can prove a bucket empty rather than merely unplottable. Two consequences: `value * sample_count` is **not** a sum, and on a first or last bucket the count reflects the range rather than the bucket, so a low edge count is a boundary effect and not a quiet period."
3072
+ })
3073
+ })).meta({
3074
+ description: "The buckets covering the queried window, oldest first. A range and window that would produce more than `limit` buckets is refused before the query runs, because this shape carries no `truncated` field and a refusal is then the only honest answer."
3075
+ })
2831
3076
  });
3077
+ var historyResponse = z9.union([historySamplesResponse, historyBucketsResponse]);
2832
3078
  var robotDeletionSummary = z9.object({
2833
3079
  /**
2834
3080
  * Datapoints, actions, services and publishers in the **published**
@@ -3125,13 +3371,13 @@ var slugUsageResponse = z9.object({
3125
3371
  alert_count: z9.number().int().nonnegative()
3126
3372
  });
3127
3373
 
3128
- // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_1727a3f05719ef3ec49509ea01c84aa4/node_modules/@fleetless/contracts/dist/realtime.js
3374
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/realtime.js
3129
3375
  import { z as z12 } from "zod";
3130
3376
 
3131
- // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_1727a3f05719ef3ec49509ea01c84aa4/node_modules/@fleetless/contracts/dist/client-auth.js
3377
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/client-auth.js
3132
3378
  import { z as z11 } from "zod";
3133
3379
 
3134
- // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_1727a3f05719ef3ec49509ea01c84aa4/node_modules/@fleetless/contracts/dist/apps.js
3380
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/apps.js
3135
3381
  import { z as z10 } from "zod";
3136
3382
  var appIdentifier = slug;
3137
3383
  var app = z10.object({
@@ -3327,33 +3573,53 @@ var brandingConfig = z10.object({
3327
3573
  footer_text: z10.string().min(1).max(200).optional()
3328
3574
  });
3329
3575
 
3330
- // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_1727a3f05719ef3ec49509ea01c84aa4/node_modules/@fleetless/contracts/dist/client-auth.js
3576
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/client-auth.js
3331
3577
  var clientLoginRequest = z11.object({
3332
- app_identifier: appIdentifier,
3333
- email: z11.email(),
3334
- password: z11.string().min(1)
3578
+ app_identifier: appIdentifier.meta({
3579
+ description: "The app being logged in to, as its globally unique identifier \u2014 the lowercase, underscore-separated string the developer chose when the app was created. There is no organisation context at login, so this is what decides which app the credentials are checked for."
3580
+ }),
3581
+ email: z11.email().meta({
3582
+ description: "The end user's email address. Addresses are global across Fleetless rather than per app, so one person is one identity however many apps they reach."
3583
+ }),
3584
+ password: z11.string().min(1).meta({
3585
+ description: "The end user's password. A wrong pair is refused without saying which half was wrong, so a failed login is not an account-enumeration oracle."
3586
+ })
3335
3587
  });
3336
3588
  var clientRefreshRequest = z11.object({
3337
- refresh_token: z11.string().min(1)
3589
+ refresh_token: z11.string().min(1).meta({
3590
+ description: "The refresh token from the last login or refresh. Refresh tokens rotate on every use, so the value sent here is spent \u2014 keep the one that comes back, and presenting a spent one is treated as theft and ends the whole family."
3591
+ })
3338
3592
  });
3339
3593
  var clientLogoutRequest = z11.object({
3340
- refresh_token: z11.string().min(1)
3594
+ refresh_token: z11.string().min(1).meta({
3595
+ description: "Any refresh token of the session to end. The whole token family is revoked server-side, so a token stolen before this call stops working too \u2014 clearing a client-side store is a gesture, not a revocation."
3596
+ })
3341
3597
  });
3342
3598
  var clientLogoutResponse = z11.object({
3343
3599
  idp_logout: z11.discriminatedUnion("status", [
3344
3600
  z11.object({
3345
- status: z11.literal("redirect"),
3346
- /** Send the browser here. Built from the IdP's own `end_session_endpoint`. */
3347
- url: z11.url().max(2e3)
3601
+ status: z11.literal("redirect").meta({
3602
+ description: "The identity provider publishes an `end_session_endpoint` and `url` says where to send the browser to finish logging out there."
3603
+ }),
3604
+ url: z11.url().max(2e3).meta({
3605
+ description: "Send the browser here to end the session at the identity provider. Built from the provider's own `end_session_endpoint`."
3606
+ })
3607
+ }),
3608
+ z11.object({
3609
+ status: z11.literal("not_federated").meta({
3610
+ description: "This session did not come from an identity provider, so there is no second session and nothing further to do."
3611
+ })
3348
3612
  }),
3349
- /** This session did not come from an IdP — there is nothing else to end. */
3350
- z11.object({ status: z11.literal("not_federated") }),
3351
3613
  /**
3352
3614
  * It did, and the IdP's discovery document names no `end_session_endpoint`
3353
3615
  * (RP-initiated logout is optional in OIDC). **The IdP session survives and
3354
3616
  * this platform cannot end it** — say so rather than implying success.
3355
3617
  */
3356
- z11.object({ status: z11.literal("unsupported_by_idp") }),
3618
+ z11.object({
3619
+ status: z11.literal("unsupported_by_idp").meta({
3620
+ description: "The session was federated and the provider's discovery document names no `end_session_endpoint`, which OpenID Connect leaves optional. **The provider session survives and Fleetless cannot end it.**"
3621
+ })
3622
+ }),
3357
3623
  /**
3358
3624
  * **Federated, the IdP *can* end the session, and we cannot ask it to
3359
3625
  * (W9c, Nimbus-W9c's proposal).**
@@ -3378,7 +3644,11 @@ var clientLogoutResponse = z11.object({
3378
3644
  * to carry a fact no consumer can act on is how a contract grows keys
3379
3645
  * nobody reads.
3380
3646
  */
3381
- z11.object({ status: z11.literal("hint_unavailable") }),
3647
+ z11.object({
3648
+ status: z11.literal("hint_unavailable").meta({
3649
+ description: "The session was federated, the provider *could* end it, and the `id_token_hint` needed to ask is not available. The provider session survives, as with `unsupported_by_idp` \u2014 the difference is that here the cause is on the Fleetless side."
3650
+ })
3651
+ }),
3382
3652
  /**
3383
3653
  * **The server does not know this session, so it can say nothing about an
3384
3654
  * IdP** (W9 review, Argus-W9; André, 2026-08-19).
@@ -3398,22 +3668,47 @@ var clientLogoutResponse = z11.object({
3398
3668
  * a `redirect` that is still worth following. Reading this as *"the user is
3399
3669
  * fully logged out"* is exactly the mistake a second logout invites.
3400
3670
  */
3401
- z11.object({ status: z11.literal("session_unknown") })
3402
- ])
3671
+ z11.object({
3672
+ status: z11.literal("session_unknown").meta({
3673
+ description: "The token was unknown, already superseded, revoked or expired, so the server knows nothing about this session and claims nothing about a provider. Not the same as `not_federated`, and not a statement that the user is fully logged out."
3674
+ })
3675
+ })
3676
+ ]).meta({
3677
+ description: "What is left of the login at the identity provider, if one was involved. The Fleetless session is already over before this is computed, so it says only whether a second session survives and whether this platform can end it."
3678
+ })
3403
3679
  });
3404
3680
  var clientIdentity = z11.object({
3405
- kind: z11.enum(["developer", "end_user", "server_key"]),
3406
- developer_id: z11.uuid().nullable(),
3407
- end_user_id: z11.uuid().nullable(),
3408
- server_key_id: z11.uuid().nullable(),
3409
- app_id: z11.uuid().nullable(),
3410
- role_id: z11.uuid().nullable(),
3411
- email: z11.email().nullable(),
3412
- /** Present only under impersonation the real admin's user id (D4, `act`-claim). */
3413
- act: z11.object({ admin_user_id: z11.uuid() }).strict().optional()
3681
+ kind: z11.enum(["developer", "end_user", "server_key"]).meta({
3682
+ description: "Which of the three kinds of caller this is: a `developer` working through the console, an `end_user` holding a token from a client login, or a `server_key` used by server-side code. Stated outright rather than left to be inferred from which id happens to be set."
3683
+ }),
3684
+ developer_id: z11.uuid().nullable().meta({
3685
+ description: "The developer behind this session, or `null` when `kind` is not `developer`."
3686
+ }),
3687
+ end_user_id: z11.uuid().nullable().meta({
3688
+ description: "The end user behind this session, or `null` when `kind` is not `end_user`."
3689
+ }),
3690
+ server_key_id: z11.uuid().nullable().meta({
3691
+ description: "The server key this session was authenticated with, or `null` when `kind` is not `server_key`."
3692
+ }),
3693
+ app_id: z11.uuid().nullable().meta({
3694
+ description: "The app this session belongs to, and `null` for a developer \u2014 a developer is organisation-scoped and owns the configuration of every robot in the organisation rather than reaching one through an app."
3695
+ }),
3696
+ role_id: z11.uuid().nullable().meta({
3697
+ description: "The role that decides what this caller may reach, and `null` for a developer. Roles are the only visibility filter: what a role does not grant does not exist for that user."
3698
+ }),
3699
+ email: z11.email().nullable().meta({
3700
+ description: "The email of the developer or end user behind this session, and `null` for a server key, which is not a person."
3701
+ }),
3702
+ act: z11.object({
3703
+ admin_user_id: z11.uuid().meta({
3704
+ description: `The real admin's user id. Only the id travels; whoever renders the "you are acting as \u2026" banner resolves the name itself rather than trusting a second unverified one on the wire.`
3705
+ })
3706
+ }).strict().optional().meta({
3707
+ description: "Present only under impersonation, naming the organisation admin who is actually driving. The rest of this shape describes the identity they are acting **as**; absence means an ordinary session with nobody delegating, which is not the same fact as an unknown actor."
3708
+ })
3414
3709
  });
3415
3710
 
3416
- // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_1727a3f05719ef3ec49509ea01c84aa4/node_modules/@fleetless/contracts/dist/realtime.js
3711
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/realtime.js
3417
3712
  var clientAuth = z12.object({
3418
3713
  type: z12.literal("auth"),
3419
3714
  token: z12.string().min(1)
@@ -3701,7 +3996,7 @@ var orgEventDropped = z12.object({
3701
3996
  dropped: z12.number().int().positive()
3702
3997
  }).strict();
3703
3998
 
3704
- // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_1727a3f05719ef3ec49509ea01c84aa4/node_modules/@fleetless/contracts/dist/identity.js
3999
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/identity.js
3705
4000
  import { z as z13 } from "zod";
3706
4001
  var password = z13.string().min(12).max(256);
3707
4002
  var GROUP_NAME_MAX = 120;
@@ -3798,9 +4093,15 @@ var groupListResponse = z13.object({ groups: z13.array(orgGroup) });
3798
4093
  var orgUserListResponse = z13.object({ users: z13.array(orgUser) });
3799
4094
  var appAssignmentListResponse = z13.object({ assignments: z13.array(appAssignment) });
3800
4095
  var sessionTokens = z13.object({
3801
- access_token: z13.string().min(1),
3802
- refresh_token: z13.string().min(1),
3803
- expires_in: z13.number().int().positive()
4096
+ access_token: z13.string().min(1).meta({
4097
+ description: "The token to send as `Authorization: Bearer <token>` on every call. Short-lived: read `expires_in` rather than assuming a lifetime."
4098
+ }),
4099
+ refresh_token: z13.string().min(1).meta({
4100
+ description: "The token that buys the next access token. It rotates on every use, so a value presented twice is detectable theft and ends the whole family."
4101
+ }),
4102
+ expires_in: z13.number().int().positive().meta({
4103
+ description: "How long the access token stays valid, in **seconds** from now. Not a timestamp, and not milliseconds."
4104
+ })
3804
4105
  });
3805
4106
  var refreshRequest = z13.object({ refresh_token: z13.string().min(1) });
3806
4107
  var signUpRequest = z13.object({
@@ -3900,8 +4201,12 @@ var tierRequiredDetails = z13.object({
3900
4201
  actual: orgAdminTier
3901
4202
  });
3902
4203
  var passwordChangeRequest = z13.object({
3903
- current_password: z13.string().min(1),
3904
- new_password: password
4204
+ current_password: z13.string().min(1).meta({
4205
+ description: "The password in use right now. It is required even though the session already proves identity: it is what makes a stolen *session* insufficient to take the *account*."
4206
+ }),
4207
+ new_password: password.meta({
4208
+ description: "The replacement password. Every other session is revoked when it is accepted, while the session that made the change survives \u2014 logging somebody out of the tab they just used is indistinguishable from the change having failed."
4209
+ })
3905
4210
  });
3906
4211
  var passwordResetRequest = z13.object({
3907
4212
  email: z13.email()
@@ -4026,7 +4331,7 @@ var authMeResponse = z13.object({ org, user: orgUser });
4026
4331
  var patchOrgRequest = z13.object({ name: z13.string().min(1).max(120) }).strict();
4027
4332
  var patchAuthMeRequest = z13.object({ display_name: z13.string().min(1).max(USER_DISPLAY_NAME_MAX).nullable() }).strict();
4028
4333
 
4029
- // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_1727a3f05719ef3ec49509ea01c84aa4/node_modules/@fleetless/contracts/dist/audit.js
4334
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/audit.js
4030
4335
  import { z as z14 } from "zod";
4031
4336
  var auditActor = z14.object({
4032
4337
  kind: z14.enum(["developer", "end_user", "server_key", "bridge"]),
@@ -4173,7 +4478,7 @@ var auditListResponse = z14.object({
4173
4478
  next_cursor: z14.number().int().positive().nullable()
4174
4479
  });
4175
4480
 
4176
- // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_1727a3f05719ef3ec49509ea01c84aa4/node_modules/@fleetless/contracts/dist/errors.js
4481
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/errors.js
4177
4482
  import { z as z15 } from "zod";
4178
4483
  var apiError = z15.object({
4179
4484
  code: z15.string().min(1),
@@ -4190,7 +4495,7 @@ var parameterInvalidDetails = z15.object({
4190
4495
  violations: z15.array(parameterViolation).min(1)
4191
4496
  });
4192
4497
 
4193
- // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_1727a3f05719ef3ec49509ea01c84aa4/node_modules/@fleetless/contracts/dist/oauth.js
4498
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/oauth.js
4194
4499
  import { z as z16 } from "zod";
4195
4500
  var oauthErrorCode = z16.enum([
4196
4501
  "invalid_request",
@@ -4266,68 +4571,149 @@ var oauthClient = z16.object({
4266
4571
  last_used_at: z16.iso.datetime().nullable()
4267
4572
  });
4268
4573
  var dynamicClientRegistrationRequest = z16.object({
4269
- client_name: z16.string().min(1).max(200),
4270
- redirect_uris: z16.array(redirectUri).min(1).max(20),
4271
- /** Accepted and echoed for conformance; this server issues only this pair. */
4272
- grant_types: z16.array(z16.enum(["authorization_code", "refresh_token"])).optional(),
4273
- response_types: z16.array(z16.enum(["code"])).optional(),
4274
- /** RFC 7591 allows `none` for public clients; OAuth 2.1 + PKCE is the defence. */
4275
- token_endpoint_auth_method: z16.enum(["none"]).optional(),
4276
- scope: z16.string().max(500).optional()
4574
+ client_name: z16.string().min(1).max(200).meta({
4575
+ description: 'The name the client calls itself. It is **not** vouched for by Fleetless and must never be rendered as if it were \u2014 a self-registered client chooses this string, and one has called itself *"Fleetless Official Helper"*.'
4576
+ }),
4577
+ redirect_uris: z16.array(redirectUri).min(1).max(20).meta({
4578
+ description: "Where the authorization code may be returned. Each must be an `https` URL, or `http` on an explicit loopback address for a native app that cannot hold a certificate, and none may carry a fragment. There must be between `1` and `20` of them."
4579
+ }),
4580
+ grant_types: z16.array(z16.enum(["authorization_code", "refresh_token"])).optional().meta({
4581
+ description: "Accepted and echoed back for conformance with RFC 7591. This server issues `authorization_code` and `refresh_token` and nothing else."
4582
+ }),
4583
+ response_types: z16.array(z16.enum(["code"])).optional().meta({
4584
+ description: "Accepted and echoed back for conformance. `code` is the only response type OAuth 2.1 leaves, the implicit grant having been removed."
4585
+ }),
4586
+ token_endpoint_auth_method: z16.enum(["none"]).optional().meta({
4587
+ description: "`none`, RFC 7591's value for a public client. There is no client secret to hold: mandatory PKCE is the defence."
4588
+ }),
4589
+ scope: z16.string().max(500).optional().meta({
4590
+ description: "The scopes the client asks to be registered for, space-separated."
4591
+ })
4277
4592
  }).strict();
4278
4593
  var dynamicClientRegistrationResponse = z16.object({
4279
- client_id: z16.string().min(1).max(200),
4280
- client_name: z16.string().min(1).max(200),
4281
- redirect_uris: z16.array(redirectUri),
4282
- grant_types: z16.array(z16.string()),
4283
- response_types: z16.array(z16.string()),
4284
- token_endpoint_auth_method: z16.literal("none"),
4285
- client_id_issued_at: z16.number().int().nonnegative(),
4286
- /** Seconds since the epoch, per RFC 7591. `0` would mean "never expires". */
4287
- client_secret_expires_at: z16.literal(0)
4594
+ client_id: z16.string().min(1).max(200).meta({
4595
+ description: "The identifier this client sends at the authorize and token endpoints. Opaque, and not the app identifier."
4596
+ }),
4597
+ client_name: z16.string().min(1).max(200).meta({
4598
+ description: "The name the client registered under, echoed back. Chosen by the client and not vouched for by Fleetless."
4599
+ }),
4600
+ redirect_uris: z16.array(redirectUri).meta({
4601
+ description: "The redirect URIs this registration was accepted for. A code is returned to one of these and nowhere else."
4602
+ }),
4603
+ grant_types: z16.array(z16.string()).meta({
4604
+ description: "The grants this client may use: `authorization_code` and `refresh_token`."
4605
+ }),
4606
+ response_types: z16.array(z16.string()).meta({
4607
+ description: "The response types this client may ask for: `code`."
4608
+ }),
4609
+ token_endpoint_auth_method: z16.literal("none").meta({
4610
+ description: "`none` \u2014 this server registers public clients only, and PKCE rather than a secret is what protects the exchange."
4611
+ }),
4612
+ client_id_issued_at: z16.number().int().nonnegative().meta({
4613
+ description: "When the registration was created, in seconds since the epoch, per RFC 7591."
4614
+ }),
4615
+ client_secret_expires_at: z16.literal(0).meta({
4616
+ 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."
4617
+ })
4288
4618
  });
4289
4619
  var oauthTokenRequest = z16.discriminatedUnion("grant_type", [
4290
4620
  z16.object({
4291
- grant_type: z16.literal("authorization_code"),
4292
- code: z16.string().min(1).max(500),
4293
- redirect_uri: redirectUri,
4294
- client_id: z16.string().min(1).max(200),
4295
- code_verifier: z16.string().regex(/^[A-Za-z0-9\-._~]{43,128}$/, "code_verifier must be 43-128 unreserved characters (RFC 7636 \xA74.1)"),
4296
- resource: z16.url().optional()
4621
+ grant_type: z16.literal("authorization_code").meta({
4622
+ description: "This request exchanges the code from the authorize redirect for tokens."
4623
+ }),
4624
+ code: z16.string().min(1).max(500).meta({
4625
+ description: "The authorization code from the redirect. It may be exchanged once."
4626
+ }),
4627
+ redirect_uri: redirectUri.meta({
4628
+ description: "The same redirect URI the authorize request used. It is compared, not merely recorded."
4629
+ }),
4630
+ client_id: z16.string().min(1).max(200).meta({
4631
+ description: "The client making the exchange, as registered."
4632
+ }),
4633
+ code_verifier: z16.string().regex(/^[A-Za-z0-9\-._~]{43,128}$/, "code_verifier must be 43-128 unreserved characters (RFC 7636 \xA74.1)").meta({
4634
+ description: "The PKCE verifier whose `S256` hash was sent as the challenge at the authorize step. Between `43` and `128` unreserved characters, per RFC 7636 \xA74.1 \u2014 it is compared rather than parsed, so a length nobody checks is a length an attacker chooses. PKCE is mandatory for every client under OAuth 2.1."
4635
+ }),
4636
+ resource: z16.url().optional().meta({
4637
+ description: "The resource the token is being requested for, per RFC 8707. It becomes the token's audience, and a resource refuses a token whose audience names something else."
4638
+ })
4297
4639
  }),
4298
4640
  z16.object({
4299
- grant_type: z16.literal("refresh_token"),
4300
- refresh_token: z16.string().min(1).max(500),
4301
- client_id: z16.string().min(1).max(200),
4302
- resource: z16.url().optional(),
4303
- /** RFC 6749 §6 a refresh may narrow scope, never widen it. */
4304
- scope: z16.string().max(500).optional()
4641
+ grant_type: z16.literal("refresh_token").meta({
4642
+ description: "This request trades a refresh token for a fresh access token."
4643
+ }),
4644
+ refresh_token: z16.string().min(1).max(500).meta({
4645
+ description: "The refresh token to spend. Refresh tokens rotate, and presenting one twice is treated as theft rather than as a retry."
4646
+ }),
4647
+ client_id: z16.string().min(1).max(200).meta({
4648
+ description: "The client refreshing, as registered."
4649
+ }),
4650
+ resource: z16.url().optional().meta({
4651
+ description: "The resource the successor token should be bound to, per RFC 8707. **Carry it forward**: a refresh that drops it mints a token with no audience, and the resource then refuses it one token lifetime after a login that worked, to somebody who did nothing wrong."
4652
+ }),
4653
+ scope: z16.string().max(500).optional().meta({
4654
+ description: "A narrower scope for the successor token. RFC 6749 \xA76 lets a refresh narrow scope, never widen it."
4655
+ })
4305
4656
  })
4306
4657
  ]);
4307
4658
  var oauthTokenResponse = z16.object({
4308
- access_token: z16.string().min(1),
4309
- token_type: z16.literal("Bearer"),
4310
- /** Seconds, per RFC 6749 §5.1 — not a timestamp, and not milliseconds. */
4311
- expires_in: z16.number().int().positive(),
4312
- refresh_token: z16.string().min(1).optional(),
4313
- scope: z16.string().max(500).optional()
4659
+ access_token: z16.string().min(1).meta({
4660
+ 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."
4661
+ }),
4662
+ token_type: z16.literal("Bearer").meta({
4663
+ description: "`Bearer`. RFC 6749 \xA75.1 makes the value case-insensitive for a client reading it; this is the spelling this server emits."
4664
+ }),
4665
+ expires_in: z16.number().int().positive().meta({
4666
+ description: "How long the access token is valid, in **seconds**, per RFC 6749 \xA75.1. Not a timestamp, and not milliseconds."
4667
+ }),
4668
+ refresh_token: z16.string().min(1).optional().meta({
4669
+ description: "The refresh token, when one was issued. It rotates on every use."
4670
+ }),
4671
+ scope: z16.string().max(500).optional().meta({
4672
+ description: "The scopes the issued token actually carries, space-separated."
4673
+ })
4314
4674
  });
4315
4675
  var authorizationServerMetadata = z16.object({
4316
- issuer: z16.url(),
4317
- authorization_endpoint: z16.url(),
4318
- token_endpoint: z16.url(),
4319
- registration_endpoint: z16.url().optional(),
4320
- response_types_supported: z16.array(z16.literal("code")),
4321
- grant_types_supported: z16.array(z16.enum(["authorization_code", "refresh_token"])),
4322
- code_challenge_methods_supported: z16.array(codeChallengeMethod),
4323
- token_endpoint_auth_methods_supported: z16.array(z16.literal("none")),
4324
- scopes_supported: z16.array(z16.string()).optional()
4676
+ issuer: z16.url().meta({
4677
+ description: "The issuer identifier of this authorization server, per RFC 8414 \xA72. It is what a client checks a token's `iss` against."
4678
+ }),
4679
+ authorization_endpoint: z16.url().meta({
4680
+ description: "The URL a client sends the user to in order to authorize."
4681
+ }),
4682
+ token_endpoint: z16.url().meta({
4683
+ description: "The URL where a client exchanges an authorization code, or a refresh token, for tokens."
4684
+ }),
4685
+ registration_endpoint: z16.url().optional().meta({
4686
+ description: "The URL where a client may register itself, per RFC 7591. Absent when the app does not accept dynamic clients."
4687
+ }),
4688
+ response_types_supported: z16.array(z16.literal("code")).meta({
4689
+ description: "The response types this server offers: `code` only, the implicit grant being gone with OAuth 2.1."
4690
+ }),
4691
+ grant_types_supported: z16.array(z16.enum(["authorization_code", "refresh_token"])).meta({
4692
+ description: "The grants this server offers. OAuth 2.1 removes the implicit and password grants, so neither appears here."
4693
+ }),
4694
+ code_challenge_methods_supported: z16.array(codeChallengeMethod).meta({
4695
+ 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."
4696
+ }),
4697
+ token_endpoint_auth_methods_supported: z16.array(z16.literal("none")).meta({
4698
+ description: "How a client authenticates at the token endpoint: `none`, the public-client method, with PKCE protecting the exchange."
4699
+ }),
4700
+ scopes_supported: z16.array(z16.string()).optional().meta({
4701
+ description: "The scopes this server knows about, where it publishes a list."
4702
+ })
4325
4703
  });
4326
4704
  var protectedResourceMetadata = z16.object({
4327
- resource: z16.url(),
4328
- authorization_servers: z16.array(z16.url()).min(1),
4329
- bearer_methods_supported: z16.array(z16.literal("header")),
4330
- scopes_supported: z16.array(z16.string()).optional()
4705
+ resource: z16.url().meta({
4706
+ description: "The resource identifier this document describes, per RFC 9728. A token whose audience names something else is rejected here rather than merely noted."
4707
+ }),
4708
+ authorization_servers: z16.array(z16.url()).min(1).meta({
4709
+ description: "The authorization servers that may issue tokens for this resource. There is always at least one."
4710
+ }),
4711
+ bearer_methods_supported: z16.array(z16.literal("header")).meta({
4712
+ description: "How a token may be presented: in the `Authorization` header only, never in a query parameter or a form field."
4713
+ }),
4714
+ scopes_supported: z16.array(z16.string()).optional().meta({
4715
+ description: "The scopes this resource understands, where it publishes a list."
4716
+ })
4331
4717
  });
4332
4718
  var consentGrant = z16.object({
4333
4719
  client_id: z16.string().min(1).max(200),
@@ -4338,23 +4724,41 @@ var consentGrant = z16.object({
4338
4724
  granted_at: z16.iso.datetime()
4339
4725
  });
4340
4726
  var consentGrantSummary = z16.object({
4341
- client_id: z16.string().min(1).max(200),
4727
+ client_id: z16.string().min(1).max(200).meta({
4728
+ description: "The client this grant is for, and what a revocation addresses. The names beside it are for reading; this is for acting."
4729
+ }),
4342
4730
  /**
4343
4731
  * The client's own declared name. **Not trusted, and the console/page must
4344
4732
  * not render it as if Fleetless vouched for it** — a self-registered client
4345
4733
  * chooses this string, and W7c already measured what that buys: one called
4346
4734
  * itself *"Fleetless Official Helper"*.
4347
4735
  */
4348
- client_name: z16.string().min(1).max(200),
4349
- app_id: z16.uuid(),
4350
- app_name: z16.string().min(1).max(120),
4351
- role_id: z16.uuid(),
4352
- role_name: z16.string().min(1).max(120),
4353
- scope: z16.string().max(500),
4354
- granted_at: z16.iso.datetime()
4736
+ client_name: z16.string().min(1).max(200).meta({
4737
+ description: `The client's own declared name, carried so a user recognises what they consented to. **Not trusted**: a self-registered client chooses this string, and one has called itself *"Fleetless Official Helper"*.`
4738
+ }),
4739
+ app_id: z16.uuid().meta({
4740
+ description: "The app the grant was given within. A consent recorded against a client alone would be reusable for a different app."
4741
+ }),
4742
+ app_name: z16.string().min(1).max(120).meta({
4743
+ description: "The app's name as it stood on the consent screen."
4744
+ }),
4745
+ role_id: z16.uuid().meta({
4746
+ description: "The role that tokens issued under this grant carry, and therefore the limit of what the client can reach."
4747
+ }),
4748
+ role_name: z16.string().min(1).max(120).meta({
4749
+ description: "The role's name as it stood on the consent screen. A user consented to a sentence, and every noun in it is stored so the list can show the sentence again."
4750
+ }),
4751
+ scope: z16.string().max(500).meta({
4752
+ description: "The scopes consented to, space-separated."
4753
+ }),
4754
+ granted_at: z16.iso.datetime().meta({
4755
+ description: "When consent was given, as an ISO 8601 timestamp."
4756
+ })
4355
4757
  });
4356
4758
  var consentGrantListResponse = z16.object({
4357
- grants: z16.array(consentGrantSummary).max(200),
4759
+ grants: z16.array(consentGrantSummary).max(200).meta({
4760
+ description: "The user's consent grants, **newest first** \u2014 this list exists to be revoked from, and the grant somebody came for is almost always the one they just gave. At most `200`; when there are more, `truncated` says so."
4761
+ }),
4358
4762
  /**
4359
4763
  * **`true` heisst: es gibt mehr, und diese Antwort zeigt sie nicht** (W9
4360
4764
  * review, Argus-W9; DEF-151).
@@ -4377,11 +4781,17 @@ var consentGrantListResponse = z16.object({
4377
4781
  * sagt, was der Nutzer wissen muss — **hier fehlt etwas, frag jemanden** —
4378
4782
  * ohne einen Mechanismus zu versprechen, den es nicht gibt.
4379
4783
  */
4380
- truncated: z16.boolean()
4784
+ truncated: z16.boolean().meta({
4785
+ description: '`true` means there are more grants than this response shows. A short page is not a promise that the list is exhausted \u2014 and on the one list a user comes to **switch something off**, "I cannot see it" and "it does not exist" is the most expensive difference there is.'
4786
+ })
4381
4787
  });
4382
4788
  var consentRevokeResponse = z16.object({
4383
- revoked: z16.boolean(),
4384
- tokens_revoked: z16.number().int().nonnegative()
4789
+ revoked: z16.boolean().meta({
4790
+ description: "Whether a grant actually matched and was ended. `false` means nothing matched, which a caller has to be able to tell from a successful revocation."
4791
+ }),
4792
+ tokens_revoked: z16.number().int().nonnegative().meta({
4793
+ description: "How many refresh **families** were ended. Deliberately not a count of access tokens, which are stateless and short-lived \u2014 though every currently-valid one issued through this client for this user does stop working at once."
4794
+ })
4385
4795
  });
4386
4796
  var oauthInteraction = z16.object({
4387
4797
  interaction_id: z16.string().min(1).max(200),
@@ -4430,24 +4840,20 @@ var OAUTH_PATHS = {
4430
4840
  */
4431
4841
  consent: "/oauth/consent",
4432
4842
  /**
4433
- * The two federation legs. **Server-owned redirect targets a client never
4843
+ * The federation return leg. **A server-owned redirect target a client never
4434
4844
  * constructs** — the same category as `authorize`, `token` and `register`,
4435
- * and the reason they belong here rather than as literals.
4436
- *
4437
- * Kassandra-W7b found `/oauth/idp-start` written as a literal in
4438
- * `cloud/src/routes/oauth-federation.ts` **and** in
4439
- * `console/oauth-pages/login/src/App.vue` two repos agreeing on a string
4440
- * with nothing shared between them. Worse than the `/idp` versus
4441
- * `/idp-config` mismatch this wave already met, because the console half
4442
- * ships as a **committed artifact**: the drift would survive a re-pin and an
4443
- * install, and the symptom is a federation button that navigates to a 404,
4444
- * invisible to both suites.
4445
- *
4446
- * `OAUTH_PATHS` was created in this wave with a doc comment citing W7a's
4447
- * five hand-written copies of `assetKind`. The rule was applied to `login`
4448
- * and `consent` and stopped there.
4845
+ * and the reason it belongs here rather than as a literal.
4846
+ *
4847
+ * `OAUTH_PATHS` was created in W7b with a doc comment citing W7a's five
4848
+ * hand-written copies of `assetKind`. The rule was applied to `login` and
4849
+ * `consent` and stopped there.
4850
+ *
4851
+ * It once had a twin, `idpStart: '/oauth/idp-start'`, for the *outbound*
4852
+ * leg. The cloud deleted that route `/oauth/authorize` redirects to the
4853
+ * IdP directly — and the entry stayed here for months, naming a path
4854
+ * nothing served. Removed in FL-007; the cloud's route-manifest test is
4855
+ * what keeps a second one from accumulating.
4449
4856
  */
4450
- idpStart: "/oauth/idp-start",
4451
4857
  idpCallback: "/oauth/idp-callback",
4452
4858
  /**
4453
4859
  * The console-built page the cloud serves from its own origin (see §3.4) —
@@ -4477,7 +4883,7 @@ var OAUTH_PATHS = {
4477
4883
  * role to preview or a user to sign in as. The page is **server-rendered by
4478
4884
  * the cloud from its own origin** — the console never builds it and never
4479
4885
  * hardcodes this path; the login page merely follows the `redirect_to` it is
4480
- * handed. It belongs in this list for the reason `idpStart`/`idpCallback` do:
4886
+ * handed. It belongs in this list for the reason `idpCallback` does:
4481
4887
  * a server-owned target a client is sent to, which must have exactly one
4482
4888
  * definition rather than a literal in `cloud/src/routes/oauth.ts` that a
4483
4889
  * second reader could drift from.
@@ -4485,6 +4891,3021 @@ var OAUTH_PATHS = {
4485
4891
  impersonate: "/oauth/impersonate"
4486
4892
  };
4487
4893
 
4894
+ // node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/routes.js
4895
+ var DEVELOPER_GUARD = ["unauthorized", "token_expired", "token_revoked", "forbidden"];
4896
+ var CLIENT_GUARD = ["unauthorized", "token_expired", "token_revoked", "forbidden"];
4897
+ var ROUTES = [
4898
+ /* ------------------------------------------------------------- health */
4899
+ {
4900
+ method: "GET",
4901
+ path: "/healthz",
4902
+ section: "health",
4903
+ summary: "Reports whether the database, the object store and LiveKit each answered a probe.",
4904
+ audience: "internal",
4905
+ auth: "none",
4906
+ rateLimited: false,
4907
+ ownerTier: false,
4908
+ status: 200,
4909
+ params: [],
4910
+ query: null,
4911
+ request: null,
4912
+ response: null,
4913
+ errors: [],
4914
+ transport: "http",
4915
+ notes: "Answers `{ ok, dependencies: { database, storage, liveKit } }` \u2014 a cloud-local shape, not a wire contract, so nothing here pins it. The status is always `200`: each dependency is probed independently and a failure is reported in the body rather than thrown, because `/healthz` must never itself be a reason the process looks down. Read `ok`, not the status code."
4916
+ },
4917
+ /* ----------------------------------------------------- developer auth */
4918
+ {
4919
+ method: "POST",
4920
+ path: "/api/auth/signup",
4921
+ section: "developer-auth",
4922
+ summary: "Creates an org, its Org Admins group and the founding Owner, and answers a developer session.",
4923
+ audience: "developer",
4924
+ auth: "none",
4925
+ rateLimited: true,
4926
+ ownerTier: false,
4927
+ status: 201,
4928
+ params: [],
4929
+ query: null,
4930
+ request: signUpRequest,
4931
+ response: signUpResponse,
4932
+ errors: ["rate_limited", "signup_closed", "validation_error", "email_taken"],
4933
+ transport: "http",
4934
+ notes: "While the deployment runs in closed beta this answers `403 signup_closed` before it looks at the body \u2014 there is nothing for a validation message, or an `email_taken` answer, to be right about when nothing will be created. Email is globally unique, so an address already registered in any org is refused."
4935
+ },
4936
+ {
4937
+ method: "POST",
4938
+ path: "/api/auth/refresh",
4939
+ section: "developer-auth",
4940
+ summary: "Rotates a developer refresh token and mints a fresh access token.",
4941
+ audience: "developer",
4942
+ auth: "none",
4943
+ rateLimited: true,
4944
+ ownerTier: false,
4945
+ status: 200,
4946
+ params: [],
4947
+ query: null,
4948
+ request: refreshRequest,
4949
+ response: sessionTokens,
4950
+ errors: ["rate_limited", "validation_error", "token_expired", "token_revoked"],
4951
+ transport: "http",
4952
+ notes: "The whole family is re-checked here, not just the token: an account that has left the Org Admins group cannot mint a fresh console token, and answers `token_revoked`. Refusing that only on the other routes would leave a session that is dead everywhere but here."
4953
+ },
4954
+ {
4955
+ method: "POST",
4956
+ path: "/api/auth/logout",
4957
+ section: "developer-auth",
4958
+ summary: "Revokes the whole refresh family behind a developer refresh token.",
4959
+ audience: "developer",
4960
+ auth: "none",
4961
+ rateLimited: true,
4962
+ ownerTier: false,
4963
+ status: 204,
4964
+ params: [],
4965
+ query: null,
4966
+ request: refreshRequest,
4967
+ response: null,
4968
+ errors: ["rate_limited", "validation_error"],
4969
+ transport: "http",
4970
+ notes: "Unauthenticated by design \u2014 the refresh token in the body is the credential. A token the server does not recognise is still a `204`: the end state a caller asked for is the end state they get, and distinguishing the two would say whether a token ever existed. Open `/realtime` sockets for the session are closed too."
4971
+ },
4972
+ {
4973
+ method: "GET",
4974
+ path: "/api/auth/me",
4975
+ section: "developer-auth",
4976
+ summary: "Answers the calling developer and the org they belong to.",
4977
+ audience: "developer",
4978
+ auth: "developer",
4979
+ rateLimited: false,
4980
+ ownerTier: false,
4981
+ status: 200,
4982
+ params: [],
4983
+ query: null,
4984
+ request: null,
4985
+ response: authMeResponse,
4986
+ errors: [...DEVELOPER_GUARD],
4987
+ transport: "http"
4988
+ },
4989
+ {
4990
+ method: "PATCH",
4991
+ path: "/api/auth/me",
4992
+ section: "developer-auth",
4993
+ summary: "Changes the calling developer's own display name and nothing else.",
4994
+ audience: "developer",
4995
+ auth: "developer",
4996
+ rateLimited: false,
4997
+ ownerTier: false,
4998
+ status: 200,
4999
+ params: [],
5000
+ query: null,
5001
+ request: patchAuthMeRequest,
5002
+ response: authMeResponse,
5003
+ errors: [...DEVELOPER_GUARD, "validation_error"],
5004
+ transport: "http",
5005
+ notes: "No Owner tier: this can only ever touch the caller's own row, so there is nothing for a tier check to gate. Saving the name already held writes nothing and records no audit event \u2014 the org activity stream reaches every developer with the console open, and an event for a no-op would misreport that something changed."
5006
+ },
5007
+ {
5008
+ method: "POST",
5009
+ path: "/api/auth/password/change",
5010
+ section: "developer-auth",
5011
+ summary: "Verifies the current password, sets a new one and answers a fresh session.",
5012
+ audience: "developer",
5013
+ auth: "developer",
5014
+ rateLimited: false,
5015
+ ownerTier: false,
5016
+ status: 200,
5017
+ params: [],
5018
+ query: null,
5019
+ request: passwordChangeRequest,
5020
+ response: sessionTokens,
5021
+ errors: [...DEVELOPER_GUARD, "validation_error", "invalid_credentials"],
5022
+ transport: "http",
5023
+ notes: "Every session of this account ends, including the caller's \u2014 the request carries nothing identifying its own refresh family, so there is none to spare. The answer is a working replacement pair, which is what the promise has to mean when nothing distinguishes one session from another."
5024
+ },
5025
+ {
5026
+ method: "POST",
5027
+ path: "/api/auth/password/reset",
5028
+ section: "developer-auth",
5029
+ summary: "Mails a password-reset link to the address, and answers the same either way.",
5030
+ audience: "developer",
5031
+ auth: "none",
5032
+ rateLimited: true,
5033
+ ownerTier: false,
5034
+ status: 202,
5035
+ params: [],
5036
+ query: null,
5037
+ request: passwordResetRequest,
5038
+ response: null,
5039
+ errors: ["rate_limited", "validation_error"],
5040
+ transport: "http",
5041
+ notes: 'Status, body and timing are identical for a known and an unknown address \u2014 any difference is an account-enumeration oracle, which is why the unknown branch still pays a real SMTP round trip to a discard address. An account provisioned through OIDC has no Fleetless password and is mailed nothing. A browser form post gets a `303` to the "check your mail" card instead of this `202`.'
5042
+ },
5043
+ /* ------------------------------------------- client auth (portal pages) */
5044
+ {
5045
+ method: "GET",
5046
+ path: "/reset-password",
5047
+ section: "client-auth",
5048
+ summary: `Serves the auth portal's "forgot your password" card as an HTML page.`,
5049
+ audience: "internal",
5050
+ auth: "none",
5051
+ rateLimited: false,
5052
+ ownerTier: false,
5053
+ status: 200,
5054
+ params: [],
5055
+ query: null,
5056
+ request: null,
5057
+ response: null,
5058
+ errors: [],
5059
+ transport: "http",
5060
+ notes: 'HTML, not JSON: this is a page a person opens, served by the cloud from the auth portal origin. `?sent=1` draws the "check your mail" state instead \u2014 one path, because that second card has no inputs and a second path would exist only to be redirected to. The value is caller-settable and discloses nothing, since the page it draws is a constant.'
5061
+ },
5062
+ {
5063
+ method: "GET",
5064
+ path: "/reset-password/:token",
5065
+ section: "client-auth",
5066
+ summary: 'Serves the "pick a new password" page for a mailed reset link.',
5067
+ audience: "internal",
5068
+ auth: "none",
5069
+ rateLimited: false,
5070
+ ownerTier: false,
5071
+ status: 200,
5072
+ params: [{ name: "token", description: "The opaque reset token from the mailed link; it is never sent as a query parameter." }],
5073
+ query: null,
5074
+ request: null,
5075
+ response: null,
5076
+ errors: [],
5077
+ transport: "http",
5078
+ notes: 'HTML. An unknown, spent or expired token renders one "link no longer valid" page at `410` \u2014 they are one refusal on the wire already, and splitting them here would tell a stranger which tokens ever existed. No rate limiter: the GET changes nothing, and the POST it leads to is limited per IP.'
5079
+ },
5080
+ {
5081
+ method: "POST",
5082
+ path: "/api/auth/password/reset/confirm",
5083
+ section: "developer-auth",
5084
+ summary: "Spends a reset token, sets the new password and ends every session of the account.",
5085
+ audience: "developer",
5086
+ auth: "none",
5087
+ rateLimited: true,
5088
+ ownerTier: false,
5089
+ status: 204,
5090
+ params: [],
5091
+ query: null,
5092
+ request: passwordResetConfirm,
5093
+ response: null,
5094
+ errors: ["rate_limited", "validation_error", "token_spent"],
5095
+ transport: "http",
5096
+ notes: "Unknown, spent and expired tokens all answer `410 token_spent`. Sessions are revoked under the account's actual kind \u2014 a console admin holds developer sessions, an app user holds end-user ones \u2014 so an app user's open `/realtime` socket does not outlive the reset. A browser form post gets the rendered \"done\" page instead of this `204`."
5097
+ },
5098
+ {
5099
+ method: "POST",
5100
+ path: "/api/waitlist",
5101
+ section: "developer-auth",
5102
+ summary: "Adds an address to the closed-beta waiting list.",
5103
+ audience: "developer",
5104
+ auth: "none",
5105
+ rateLimited: true,
5106
+ ownerTier: false,
5107
+ status: 202,
5108
+ params: [],
5109
+ query: null,
5110
+ request: waitlistRequest,
5111
+ response: null,
5112
+ errors: ["rate_limited", "validation_error"],
5113
+ transport: "http",
5114
+ notes: "Answers `202` whether or not the address was already listed: the landing page's form must not be an oracle for who signed up. The operator notification is detached from the response \u2014 awaiting it made latency answer the question the status code refuses to \u2014 and is capped by its own global ceiling, above which the row is still written and the mail is skipped."
5115
+ },
5116
+ /* ---------------------------------------------------------------- org */
5117
+ {
5118
+ method: "GET",
5119
+ path: "/api/audit",
5120
+ section: "org",
5121
+ summary: "Reads the org's audit log, newest first, cursor-paged over the durable sequence number.",
5122
+ audience: "developer",
5123
+ auth: "developer",
5124
+ rateLimited: false,
5125
+ ownerTier: false,
5126
+ status: 200,
5127
+ params: [],
5128
+ query: auditQuery,
5129
+ request: null,
5130
+ response: auditListResponse,
5131
+ errors: [...DEVELOPER_GUARD, "validation_error"],
5132
+ transport: "http",
5133
+ notes: "`action` and `action_prefix` are mutually exclusive, a cross-field rule no JSON Schema can express \u2014 this route is where it is enforced. Nothing redacts an event's `details`: it is returned exactly as the call site wrote it."
5134
+ },
5135
+ {
5136
+ method: "GET",
5137
+ path: "/api/audit/export",
5138
+ section: "org",
5139
+ summary: "Downloads every audit event matching the same filters as a CSV attachment.",
5140
+ audience: "developer",
5141
+ auth: "developer",
5142
+ rateLimited: false,
5143
+ ownerTier: false,
5144
+ status: 200,
5145
+ params: [],
5146
+ query: auditQuery,
5147
+ request: null,
5148
+ response: null,
5149
+ errors: [...DEVELOPER_GUARD, "validation_error"],
5150
+ transport: "http",
5151
+ notes: "Answers `text/csv` with a `Content-Disposition` attachment, not JSON \u2014 so it has no response schema. `AUDIT_CSV_COLUMNS` names the columns and their order. Takes the same filters as `GET /api/audit` but refuses `before_seq` and `limit` with `400 validation_error`: an export is not a page, it is everything the filter matches up to a fixed row ceiling."
5152
+ },
5153
+ /* --------------------------------------------------------------- apps */
5154
+ {
5155
+ method: "POST",
5156
+ path: "/api/apps",
5157
+ section: "apps",
5158
+ summary: "Creates an app in a group, optionally attaching robots to it at the same time.",
5159
+ audience: "developer",
5160
+ auth: "developer",
5161
+ rateLimited: false,
5162
+ ownerTier: false,
5163
+ status: 201,
5164
+ params: [],
5165
+ query: null,
5166
+ request: createAppRequest,
5167
+ response: app,
5168
+ errors: [...DEVELOPER_GUARD, "validation_error", "target_state_conflict", "identifier_taken", "quota_exceeded"],
5169
+ transport: "http",
5170
+ notes: "Every robot id is checked before anything is created, so a bad one never leaves a robotless app to clean up. The Org Admins group is refused with `409 target_state_conflict`: admins hold no assignments, so an app there would be one nobody can be assigned to. The identifier `mcp` is reserved by the central MCP server and refused as a `validation_error`."
5171
+ },
5172
+ {
5173
+ method: "GET",
5174
+ path: "/api/apps",
5175
+ section: "apps",
5176
+ summary: "Lists every app in the caller's org.",
5177
+ audience: "developer",
5178
+ auth: "developer",
5179
+ rateLimited: false,
5180
+ ownerTier: false,
5181
+ status: 200,
5182
+ params: [],
5183
+ query: null,
5184
+ request: null,
5185
+ response: null,
5186
+ errors: [...DEVELOPER_GUARD],
5187
+ transport: "http",
5188
+ notes: 'Answers `{ "apps": [app, \u2026] }`. The envelope has no schema of its own in contracts; each element is an `app`.'
5189
+ },
5190
+ {
5191
+ method: "GET",
5192
+ path: "/api/apps/:id",
5193
+ section: "apps",
5194
+ summary: "Reads one app of the org, with its group, robots and default role.",
5195
+ audience: "developer",
5196
+ auth: "developer",
5197
+ rateLimited: false,
5198
+ ownerTier: false,
5199
+ status: 200,
5200
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
5201
+ query: null,
5202
+ request: null,
5203
+ response: app,
5204
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
5205
+ transport: "http",
5206
+ notes: "An app belonging to another org reads exactly like one that does not exist \u2014 `404`, never a `403`."
5207
+ },
5208
+ {
5209
+ method: "PATCH",
5210
+ path: "/api/apps/:id",
5211
+ section: "apps",
5212
+ summary: "Changes an app's name, its attached robots, its default role or whether it accepts dynamic clients.",
5213
+ audience: "developer",
5214
+ auth: "developer",
5215
+ rateLimited: false,
5216
+ ownerTier: false,
5217
+ status: 200,
5218
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
5219
+ query: null,
5220
+ request: updateAppRequest,
5221
+ response: app,
5222
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "validation_error", "not_found"],
5223
+ transport: "http",
5224
+ 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."
5225
+ },
5226
+ {
5227
+ method: "POST",
5228
+ path: "/api/apps/:id/roles",
5229
+ section: "apps",
5230
+ summary: "Creates a custom role on the app.",
5231
+ audience: "developer",
5232
+ auth: "developer",
5233
+ rateLimited: false,
5234
+ ownerTier: false,
5235
+ status: 201,
5236
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
5237
+ query: null,
5238
+ request: null,
5239
+ response: null,
5240
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
5241
+ transport: "http",
5242
+ notes: 'The body is `{ "name": string }` \u2014 non-empty, trimmed, at most 120 characters \u2014 and is deliberately not a contract shape: contracts define the `role` this answers with, not this one trivial request. The answer is a `role`.'
5243
+ },
5244
+ {
5245
+ method: "GET",
5246
+ path: "/api/apps/:id/roles",
5247
+ section: "apps",
5248
+ summary: "Lists the app's roles, builtin and custom.",
5249
+ audience: "developer",
5250
+ auth: "developer",
5251
+ rateLimited: false,
5252
+ ownerTier: false,
5253
+ status: 200,
5254
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
5255
+ query: null,
5256
+ request: null,
5257
+ response: null,
5258
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
5259
+ transport: "http",
5260
+ notes: 'Answers `{ "roles": [role, \u2026] }`. The envelope has no schema of its own in contracts; each element is a `role`.'
5261
+ },
5262
+ {
5263
+ method: "PUT",
5264
+ path: "/api/apps/:id/roles/:roleId/permissions",
5265
+ section: "apps",
5266
+ summary: "Replaces a role's grants and capabilities in one write.",
5267
+ audience: "developer",
5268
+ auth: "developer",
5269
+ rateLimited: false,
5270
+ ownerTier: false,
5271
+ status: 200,
5272
+ params: [
5273
+ { name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." },
5274
+ { name: "roleId", description: "The role's uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`." }
5275
+ ],
5276
+ query: null,
5277
+ request: rolePermissions,
5278
+ response: rolePermissions,
5279
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
5280
+ transport: "http",
5281
+ notes: "`role_id` in the body must name the role in the path, compared case-insensitively \u2014 a uuid is a value, not a string, and a client that uppercases them consistently must not be refused for repeating what the path says. A grant naming a robot the app does not have is refused rather than stored: a permission for something the role cannot reach reads as authoritative to whoever writes the next consumer. Every user holding this role has their live subscriptions re-authorized."
5282
+ },
5283
+ {
5284
+ method: "GET",
5285
+ path: "/api/apps/:id/roles/:roleId/permissions",
5286
+ section: "apps",
5287
+ summary: "Reads a role's grants and capabilities.",
5288
+ audience: "developer",
5289
+ auth: "developer",
5290
+ rateLimited: false,
5291
+ ownerTier: false,
5292
+ status: 200,
5293
+ params: [
5294
+ { name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." },
5295
+ { name: "roleId", description: "The role's uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`." }
5296
+ ],
5297
+ query: null,
5298
+ request: null,
5299
+ response: rolePermissions,
5300
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
5301
+ transport: "http"
5302
+ },
5303
+ {
5304
+ method: "GET",
5305
+ path: "/api/apps/:id/roles/:roleId/mcp-tools",
5306
+ section: "apps",
5307
+ summary: "Previews the robot datasheets an MCP caller holding this role would be offered.",
5308
+ audience: "developer",
5309
+ auth: "developer",
5310
+ rateLimited: false,
5311
+ ownerTier: false,
5312
+ status: 200,
5313
+ params: [
5314
+ { name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." },
5315
+ { name: "roleId", description: "The role's uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`." }
5316
+ ],
5317
+ query: null,
5318
+ request: null,
5319
+ response: mcpRolePreviewResponse,
5320
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
5321
+ transport: "http",
5322
+ notes: "Built by the same builder the MCP server's own `robot_describe` uses, so the two cannot drift. It answers what the role *would* be offered and consults nothing about any user's actual MCP entitlement. A robot the role grants nothing on still appears, with an empty `exposures` \u2014 dropping it would read as \"not attached\", which is a different fact."
5323
+ },
5324
+ {
5325
+ method: "POST",
5326
+ path: "/api/apps/:id/server-keys",
5327
+ section: "apps",
5328
+ summary: "Mints a server key for the app and returns the raw secret once.",
5329
+ audience: "developer",
5330
+ auth: "developer",
5331
+ rateLimited: false,
5332
+ ownerTier: true,
5333
+ status: 201,
5334
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
5335
+ query: null,
5336
+ request: null,
5337
+ response: createServerKeyResponse,
5338
+ errors: [...DEVELOPER_GUARD, "tier_required", "invalid_uuid", "not_found", "validation_error"],
5339
+ transport: "http",
5340
+ notes: "Owner tier only: a server key carries full app rights and outlives its creator's removal. The body is `{ \"name\": string }`, the same trivial shape role creation takes. `key` is the only moment the raw secret exists outside the caller's hands \u2014 it is never in a listing, never in an audit event, and cannot be read back."
5341
+ },
5342
+ {
5343
+ method: "GET",
5344
+ path: "/api/apps/:id/server-keys",
5345
+ section: "apps",
5346
+ summary: "Lists the app's server keys as metadata, never the secrets.",
5347
+ audience: "developer",
5348
+ auth: "developer",
5349
+ rateLimited: false,
5350
+ ownerTier: false,
5351
+ status: 200,
5352
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
5353
+ query: null,
5354
+ request: null,
5355
+ response: null,
5356
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
5357
+ transport: "http",
5358
+ notes: 'Answers `{ "server_keys": [serverKey, \u2026] }`. The envelope has no schema of its own in contracts; each element is a `serverKey`, which names the five fields it carries rather than spreading the stored row \u2014 that is what keeps this listing from becoming a second place a credential leaves the cloud.'
5359
+ },
5360
+ {
5361
+ method: "POST",
5362
+ path: "/api/apps/:id/server-keys/:keyId/rotate",
5363
+ section: "apps",
5364
+ summary: "Replaces a server key's secret in place and returns the new one once.",
5365
+ audience: "developer",
5366
+ auth: "developer",
5367
+ rateLimited: false,
5368
+ ownerTier: true,
5369
+ status: 200,
5370
+ params: [
5371
+ { name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." },
5372
+ { name: "keyId", description: "The server key's uuid, from `GET /api/apps/:id/server-keys`; a key of another app answers `404`." }
5373
+ ],
5374
+ query: null,
5375
+ request: null,
5376
+ response: createServerKeyResponse,
5377
+ errors: [...DEVELOPER_GUARD, "tier_required", "invalid_uuid", "not_found"],
5378
+ transport: "http",
5379
+ notes: "Owner tier, for the reason creation is. The old secret is refused from this call on, and any `/realtime` socket that authenticated with it is closed \u2014 rotation is what a developer reaches for when a key has leaked, and the holder of that socket is exactly who they are rotating against."
5380
+ },
5381
+ {
5382
+ method: "DELETE",
5383
+ path: "/api/apps/:id/server-keys/:keyId",
5384
+ section: "apps",
5385
+ summary: "Revokes a server key and closes every socket holding it.",
5386
+ audience: "developer",
5387
+ auth: "developer",
5388
+ rateLimited: false,
5389
+ ownerTier: true,
5390
+ status: 204,
5391
+ params: [
5392
+ { name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." },
5393
+ { name: "keyId", description: "The server key's uuid, from `GET /api/apps/:id/server-keys`; a key of another app answers `404`." }
5394
+ ],
5395
+ query: null,
5396
+ request: null,
5397
+ response: null,
5398
+ errors: [...DEVELOPER_GUARD, "tier_required", "invalid_uuid", "not_found"],
5399
+ transport: "http",
5400
+ notes: "Owner tier, like minting and rotating: all three decide who may speak for the whole app."
5401
+ },
5402
+ /* ------------------------------------- users, groups and assignments */
5403
+ {
5404
+ method: "GET",
5405
+ path: "/api/org/groups",
5406
+ section: "users",
5407
+ summary: "Lists every group in the org with its member and app counts.",
5408
+ audience: "developer",
5409
+ auth: "developer",
5410
+ rateLimited: false,
5411
+ ownerTier: false,
5412
+ status: 200,
5413
+ params: [],
5414
+ query: null,
5415
+ request: null,
5416
+ response: groupListResponse,
5417
+ errors: [...DEVELOPER_GUARD],
5418
+ transport: "http"
5419
+ },
5420
+ {
5421
+ method: "GET",
5422
+ path: "/api/org/groups/:id",
5423
+ section: "users",
5424
+ summary: "Reads one group with its member and app counts.",
5425
+ audience: "developer",
5426
+ auth: "developer",
5427
+ rateLimited: false,
5428
+ ownerTier: false,
5429
+ status: 200,
5430
+ params: [{ name: "id", description: "The group's uuid, as listed by `GET /api/org/groups`." }],
5431
+ query: null,
5432
+ request: null,
5433
+ response: orgGroup,
5434
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
5435
+ transport: "http",
5436
+ notes: "A group of another org reads exactly like one that does not exist."
5437
+ },
5438
+ {
5439
+ method: "POST",
5440
+ path: "/api/org/groups",
5441
+ section: "users",
5442
+ summary: "Creates a group, optionally with MCP access enabled for its members.",
5443
+ audience: "developer",
5444
+ auth: "developer",
5445
+ rateLimited: false,
5446
+ ownerTier: false,
5447
+ status: 201,
5448
+ params: [],
5449
+ query: null,
5450
+ request: createGroupRequest,
5451
+ response: orgGroup,
5452
+ errors: [...DEVELOPER_GUARD, "validation_error"],
5453
+ transport: "http",
5454
+ notes: "The `is_org_admins` flag is not an argument here and cannot be: it has exactly one writer, org creation, so no door can mint a second admin group even by accident."
5455
+ },
5456
+ {
5457
+ method: "PATCH",
5458
+ path: "/api/org/groups/:id",
5459
+ section: "users",
5460
+ summary: "Renames a group or flips its MCP gate.",
5461
+ audience: "developer",
5462
+ auth: "developer",
5463
+ rateLimited: false,
5464
+ ownerTier: false,
5465
+ status: 200,
5466
+ params: [{ name: "id", description: "The group's uuid, as listed by `GET /api/org/groups`." }],
5467
+ query: null,
5468
+ request: patchGroupRequest,
5469
+ response: orgGroup,
5470
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
5471
+ transport: "http",
5472
+ notes: "The Org Admins group is renamable through exactly this door. `is_org_admins` cannot arrive at all \u2014 the shape is strict and does not carry it, so offering it is a refusal rather than a silent drop."
5473
+ },
5474
+ {
5475
+ method: "DELETE",
5476
+ path: "/api/org/groups/:id",
5477
+ section: "users",
5478
+ summary: "Deletes an empty group.",
5479
+ audience: "developer",
5480
+ auth: "developer",
5481
+ rateLimited: false,
5482
+ ownerTier: false,
5483
+ status: 204,
5484
+ params: [{ name: "id", description: "The group's uuid, as listed by `GET /api/org/groups`." }],
5485
+ query: null,
5486
+ request: null,
5487
+ response: null,
5488
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "group_not_deletable", "group_in_use"],
5489
+ transport: "http",
5490
+ notes: "The Org Admins group answers `409 group_not_deletable`; a group still holding users or apps answers `409 group_in_use` with both counts. Both refusals are re-counted inside the deleting transaction rather than pre-checked, so there is one policy and not a weaker second one. `GET /api/org/groups/:id/usage` is the read that explains them."
5491
+ },
5492
+ {
5493
+ method: "GET",
5494
+ path: "/api/org/groups/:id/usage",
5495
+ section: "users",
5496
+ summary: "Reports what a group holds, before an admin decides to delete it.",
5497
+ audience: "developer",
5498
+ auth: "developer",
5499
+ rateLimited: false,
5500
+ ownerTier: false,
5501
+ status: 200,
5502
+ params: [{ name: "id", description: "The group's uuid, as listed by `GET /api/org/groups`." }],
5503
+ query: null,
5504
+ request: null,
5505
+ response: groupUsageResponse,
5506
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
5507
+ transport: "http",
5508
+ notes: "`users_affected` is the group's whole membership, not only the members holding assignments: deleting a group affects everyone in it, and a count that excluded the unassigned would understate exactly the people an admin has to re-home first. Counts for showing, never for deciding \u2014 the delete re-counts in its own transaction."
5509
+ },
5510
+ {
5511
+ method: "GET",
5512
+ path: "/api/org/users",
5513
+ section: "users",
5514
+ summary: "Lists the org's user pool, optionally narrowed to one group.",
5515
+ audience: "developer",
5516
+ auth: "developer",
5517
+ rateLimited: false,
5518
+ ownerTier: false,
5519
+ status: 200,
5520
+ params: [],
5521
+ query: null,
5522
+ request: null,
5523
+ response: orgUserListResponse,
5524
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
5525
+ transport: "http",
5526
+ notes: '`?group_id=` narrows it; there is no query schema, the parameter is read directly. A group of another org answers `404` rather than an empty list, which would be indistinguishable from "that group exists here and is empty" \u2014 a statement about somebody else\'s org. A malformed value is `400 invalid_uuid`, so a typo can be told from a deletion.'
5527
+ },
5528
+ {
5529
+ method: "GET",
5530
+ path: "/api/org/users/:id",
5531
+ section: "users",
5532
+ summary: "Reads one user of the org.",
5533
+ audience: "developer",
5534
+ auth: "developer",
5535
+ rateLimited: false,
5536
+ ownerTier: false,
5537
+ status: 200,
5538
+ params: [{ name: "id", description: "The user's uuid, as listed by `GET /api/org/users`." }],
5539
+ query: null,
5540
+ request: null,
5541
+ response: orgUser,
5542
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
5543
+ transport: "http"
5544
+ },
5545
+ {
5546
+ method: "POST",
5547
+ path: "/api/org/users/invitations",
5548
+ section: "users",
5549
+ summary: "Invites an address into a group and returns the accept link.",
5550
+ audience: "developer",
5551
+ auth: "developer",
5552
+ rateLimited: false,
5553
+ ownerTier: false,
5554
+ status: 201,
5555
+ params: [],
5556
+ query: null,
5557
+ request: createUserInviteRequest,
5558
+ response: userInvite,
5559
+ errors: [...DEVELOPER_GUARD, "tier_required", "validation_error", "target_state_conflict", "email_taken"],
5560
+ transport: "http",
5561
+ notes: 'One invitation flow for every group; what the invitee becomes is `group_id`. **Inviting an Owner is Owner-only** \u2014 an invitation carrying `tier: "owner"` is a promotion with an extra step, since the response hands back the `accept_url`. `ownerTier` is `false` here because the gate is on that value, not on the route: any org admin may invite a developer. A tier is required for the Org Admins group and refused for every other.'
5562
+ },
5563
+ {
5564
+ method: "GET",
5565
+ path: "/api/org/users/invitations",
5566
+ section: "users",
5567
+ summary: "Lists the pending invitations of the org, without their tokens.",
5568
+ audience: "developer",
5569
+ auth: "developer",
5570
+ rateLimited: false,
5571
+ ownerTier: false,
5572
+ status: 200,
5573
+ params: [],
5574
+ query: null,
5575
+ request: null,
5576
+ response: userInviteListResponse,
5577
+ errors: [...DEVELOPER_GUARD],
5578
+ transport: "http",
5579
+ notes: "No `accept_url` is in this listing, and that omission is the point: it exists so an admin can spot a backdoor invitation planted for an address they merely control, not so anyone can re-read a link."
5580
+ },
5581
+ {
5582
+ method: "DELETE",
5583
+ path: "/api/org/users/invitations/:id",
5584
+ section: "users",
5585
+ summary: "Revokes a pending invitation so its link stops resolving.",
5586
+ audience: "developer",
5587
+ auth: "developer",
5588
+ rateLimited: false,
5589
+ ownerTier: false,
5590
+ status: 204,
5591
+ params: [{ name: "id", description: "The invitation's uuid, as listed by `GET /api/org/users/invitations`." }],
5592
+ query: null,
5593
+ request: null,
5594
+ response: null,
5595
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
5596
+ transport: "http",
5597
+ notes: "An invitation that was already accepted is not pending and answers `404`, the same answer one that never existed gets."
5598
+ },
5599
+ {
5600
+ method: "POST",
5601
+ path: "/api/org/users/invitations/:id/reissue",
5602
+ section: "users",
5603
+ summary: "Mints a fresh token onto the same invitation and returns the new accept link.",
5604
+ audience: "developer",
5605
+ auth: "developer",
5606
+ rateLimited: false,
5607
+ ownerTier: false,
5608
+ status: 200,
5609
+ params: [{ name: "id", description: "The invitation's uuid, as listed by `GET /api/org/users/invitations`." }],
5610
+ query: null,
5611
+ request: null,
5612
+ response: userInvite,
5613
+ errors: [...DEVELOPER_GUARD, "tier_required", "invalid_uuid", "not_found", "rate_limited"],
5614
+ transport: "http",
5615
+ notes: "The old link stops resolving the instant this returns \u2014 the row is looked up by token hash and the previous hash is gone. Two live links to one invitation would reopen the door the listing's missing `accept_url` closes. Limited server-side to once a minute per invitation, answering `429 rate_limited` with `retry_after_ms`; a disabled button is a hint, this is the limit. Re-issuing an owner-tier invitation needs Owner tier, exactly as creating one does."
5616
+ },
5617
+ {
5618
+ method: "POST",
5619
+ path: "/api/org/users/invitations/accept",
5620
+ section: "users",
5621
+ summary: "Spends an invitation token and creates the login it was addressed to.",
5622
+ audience: "developer",
5623
+ auth: "none",
5624
+ rateLimited: true,
5625
+ ownerTier: false,
5626
+ status: 204,
5627
+ params: [],
5628
+ query: null,
5629
+ request: acceptUserInviteRequest,
5630
+ response: null,
5631
+ errors: ["rate_limited", "validation_error", "token_spent", "email_taken"],
5632
+ transport: "http",
5633
+ notes: "**`204`, not a session.** Most invitees are not org admins, so a console session minted here would be refused on the very next request; and a body whose shape depended on the invitee's group would give one route two answers. Unknown, expired and already-accepted tokens collapse into `410 token_spent`. A browser form post gets the rendered \"you're in\" page instead."
5634
+ },
5635
+ {
5636
+ method: "GET",
5637
+ path: "/accept-invite/:token",
5638
+ section: "users",
5639
+ summary: "Serves the invitation card a mailed accept link opens.",
5640
+ audience: "internal",
5641
+ auth: "none",
5642
+ rateLimited: false,
5643
+ ownerTier: false,
5644
+ status: 200,
5645
+ params: [{ name: "token", description: "The opaque invitation token from the mailed link; it is never sent as a query parameter." }],
5646
+ query: null,
5647
+ request: null,
5648
+ response: null,
5649
+ errors: [],
5650
+ transport: "http",
5651
+ notes: 'HTML, served by the cloud from the auth portal origin; the form on it posts to `POST /api/org/users/invitations/accept`. An unknown, spent or expired token renders the "link no longer valid" page at `410`, which offers the password-reset page \u2014 the only self-service door the portal has, since an invitation cannot be re-issued by the person holding it.'
5652
+ },
5653
+ {
5654
+ method: "PATCH",
5655
+ path: "/api/org/users/:id",
5656
+ section: "users",
5657
+ summary: "Changes a user's display name or their MCP access override.",
5658
+ audience: "developer",
5659
+ auth: "developer",
5660
+ rateLimited: false,
5661
+ ownerTier: false,
5662
+ status: 200,
5663
+ params: [{ name: "id", description: "The user's uuid, as listed by `GET /api/org/users`." }],
5664
+ query: null,
5665
+ request: patchUserRequest,
5666
+ response: orgUser,
5667
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
5668
+ transport: "http",
5669
+ notes: "Nothing here has a consequence a PATCH body cannot carry: the group and the tier are their own routes, because both cascade. The audit event records which fields were addressed, never their values."
5670
+ },
5671
+ {
5672
+ method: "GET",
5673
+ path: "/api/org/users/:id/usage",
5674
+ section: "users",
5675
+ summary: "Previews what moving a user into another group would delete.",
5676
+ audience: "developer",
5677
+ auth: "developer",
5678
+ rateLimited: false,
5679
+ ownerTier: false,
5680
+ status: 200,
5681
+ params: [{ name: "id", description: "The user's uuid, as listed by `GET /api/org/users`." }],
5682
+ query: null,
5683
+ request: null,
5684
+ response: groupUsageResponse,
5685
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
5686
+ transport: "http",
5687
+ notes: "`?group_id=` names the group the user would move into and is required; there is no query schema, the parameter is read directly. Absent is `400 validation_error` with rule `required`, malformed is `400 invalid_uuid`, and well-formed but not a group of this org is `validation_error` with rule `unknown_group` \u2014 three different facts a caller needs told apart. Reads only, and never a lock."
5688
+ },
5689
+ {
5690
+ method: "POST",
5691
+ path: "/api/org/users/:id/move-group",
5692
+ section: "users",
5693
+ summary: "Moves a user into another group, deleting every assignment the move invalidates.",
5694
+ audience: "developer",
5695
+ auth: "developer",
5696
+ rateLimited: false,
5697
+ ownerTier: false,
5698
+ status: 200,
5699
+ params: [{ name: "id", description: "The user's uuid, as listed by `GET /api/org/users`." }],
5700
+ query: null,
5701
+ request: moveUserGroupRequest,
5702
+ response: orgUser,
5703
+ errors: [...DEVELOPER_GUARD, "tier_required", "invalid_uuid", "not_found", "validation_error", "last_owner"],
5704
+ transport: "http",
5705
+ notes: "`acknowledge_assignment_loss: true` is a literal in the shape, so there is no request of this form without it and nothing has to remember to check. **Moving an Owner needs Owner tier** \u2014 it takes the Owner capability away, which is a demotion by another door \u2014 but `ownerTier` is `false` because that gate fires only when the target is an Owner. Moving the last Owner out is `409 last_owner`. A move out of Org Admins closes the user's open `/realtime` socket."
5706
+ },
5707
+ {
5708
+ method: "DELETE",
5709
+ path: "/api/org/users/:id",
5710
+ section: "users",
5711
+ summary: "Removes a user and ends every session they hold.",
5712
+ audience: "developer",
5713
+ auth: "developer",
5714
+ rateLimited: false,
5715
+ ownerTier: false,
5716
+ status: 204,
5717
+ params: [{ name: "id", description: "The user's uuid, as listed by `GET /api/org/users`." }],
5718
+ query: null,
5719
+ request: null,
5720
+ response: null,
5721
+ errors: [...DEVELOPER_GUARD, "tier_required", "invalid_uuid", "not_found", "last_owner"],
5722
+ transport: "http",
5723
+ notes: "Sessions are revoked before the row is deleted: a still-existing user with a dead session is recoverable by retrying, a deleted user whose old token still works is not. Both kinds go, since one row can hold console and app sessions at once. Any invitation still outstanding for that address is expired too \u2014 a link mailed before the removal is a standing re-admission ticket. Removing an Owner needs Owner tier, and removing the last one is `409 last_owner`."
5724
+ },
5725
+ {
5726
+ method: "PUT",
5727
+ path: "/api/org/users/:id/tier",
5728
+ section: "users",
5729
+ summary: "Promotes or demotes an org admin between Owner and developer tier.",
5730
+ audience: "developer",
5731
+ auth: "developer",
5732
+ rateLimited: false,
5733
+ ownerTier: true,
5734
+ status: 200,
5735
+ params: [{ name: "id", description: "The user's uuid, as listed by `GET /api/org/users`; must be a member of the Org Admins group." }],
5736
+ query: null,
5737
+ request: tierChangeRequest,
5738
+ response: orgUser,
5739
+ errors: [...DEVELOPER_GUARD, "tier_required", "invalid_uuid", "not_found", "validation_error", "target_state_conflict", "last_owner"],
5740
+ transport: "http",
5741
+ notes: "Owner tier, unconditionally \u2014 this is the route the whole owner-exclusive list is about. A user outside the Org Admins group carries no tier at all and answers `409 target_state_conflict`. Demoting the last Owner is `409 last_owner`, decided by a row lock inside the writing transaction rather than by a read beforehand. Setting the tier already held changes nothing and writes no audit event. No session is revoked: a tier is re-read from the row on every request, so no issued token carries a stale copy of it."
5742
+ },
5743
+ {
5744
+ method: "GET",
5745
+ path: "/api/org/users/:id/assignments",
5746
+ section: "users",
5747
+ summary: "Lists the apps a user is assigned to and the role they hold in each.",
5748
+ audience: "developer",
5749
+ auth: "developer",
5750
+ rateLimited: false,
5751
+ ownerTier: false,
5752
+ status: 200,
5753
+ params: [{ name: "id", description: "The user's uuid, as listed by `GET /api/org/users`." }],
5754
+ query: null,
5755
+ request: null,
5756
+ response: appAssignmentListResponse,
5757
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
5758
+ transport: "http"
5759
+ },
5760
+ {
5761
+ method: "PUT",
5762
+ path: "/api/org/users/:id/assignments/:appId",
5763
+ section: "users",
5764
+ summary: "Assigns a user to an app in a role, or changes the role they already hold.",
5765
+ audience: "developer",
5766
+ auth: "developer",
5767
+ rateLimited: false,
5768
+ ownerTier: false,
5769
+ status: 200,
5770
+ params: [
5771
+ { name: "id", description: "The user's uuid, as listed by `GET /api/org/users`." },
5772
+ { name: "appId", description: "The app's uuid; it must belong to the same group as the user." }
5773
+ ],
5774
+ query: null,
5775
+ request: putAssignmentRequest,
5776
+ response: appAssignment,
5777
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "target_state_conflict", "validation_error"],
5778
+ transport: "http",
5779
+ notes: "An app outside the user's group is `409 target_state_conflict` with rule `group_mismatch` \u2014 the group is what pairs a user with the apps they can be assigned to. A role belonging to another app is a `validation_error`. Re-roling closes the user's live subscriptions on that app."
5780
+ },
5781
+ {
5782
+ method: "DELETE",
5783
+ path: "/api/org/users/:id/assignments/:appId",
5784
+ section: "users",
5785
+ summary: "Removes a user's assignment to one app and ends their sessions for it.",
5786
+ audience: "developer",
5787
+ auth: "developer",
5788
+ rateLimited: false,
5789
+ ownerTier: false,
5790
+ status: 204,
5791
+ params: [
5792
+ { name: "id", description: "The user's uuid, as listed by `GET /api/org/users`." },
5793
+ { name: "appId", description: "The app's uuid; only sessions and subscriptions for this app are ended." }
5794
+ ],
5795
+ query: null,
5796
+ request: null,
5797
+ response: null,
5798
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
5799
+ transport: "http",
5800
+ notes: "Only that app's sessions end; the user's access to every other app they are assigned to is untouched."
5801
+ },
5802
+ {
5803
+ method: "GET",
5804
+ path: "/api/apps/:id/group-usage",
5805
+ section: "users",
5806
+ summary: "Previews what re-linking an app to another group would delete.",
5807
+ audience: "developer",
5808
+ auth: "developer",
5809
+ rateLimited: false,
5810
+ ownerTier: false,
5811
+ status: 200,
5812
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
5813
+ query: null,
5814
+ request: null,
5815
+ response: groupUsageResponse,
5816
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
5817
+ transport: "http",
5818
+ notes: "`?group_id=` names the target group and is required, with the same three distinct refusals `GET /api/org/users/:id/usage` gives. The app is scoped first, so none of them can confirm that an app the caller does not own exists."
5819
+ },
5820
+ {
5821
+ method: "PUT",
5822
+ path: "/api/apps/:id/group",
5823
+ section: "users",
5824
+ summary: "Re-links an app to another group, deleting every assignment the move invalidates.",
5825
+ audience: "developer",
5826
+ auth: "developer",
5827
+ rateLimited: false,
5828
+ ownerTier: false,
5829
+ status: 200,
5830
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
5831
+ query: null,
5832
+ request: putAppGroupRequest,
5833
+ response: app,
5834
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error", "target_state_conflict"],
5835
+ transport: "http",
5836
+ notes: "The destructive half of the pair: every assignment naming this app whose user is not in the new group dies with the change, which is why the shape demands a literal acknowledgement. The Org Admins group is refused for the reason app creation refuses it. One edit can cut a whole team off at once, so every live subscription on the app is closed."
5837
+ },
5838
+ /* ------------------------------------------------ org (federation, OIDC) */
5839
+ {
5840
+ method: "GET",
5841
+ path: "/api/org/federation",
5842
+ section: "org",
5843
+ summary: "Reads whether a federated login may join an existing account by verified email.",
5844
+ audience: "developer",
5845
+ auth: "developer",
5846
+ rateLimited: false,
5847
+ ownerTier: false,
5848
+ status: 200,
5849
+ params: [],
5850
+ query: null,
5851
+ request: null,
5852
+ response: orgFederationPolicy,
5853
+ errors: [...DEVELOPER_GUARD],
5854
+ transport: "http",
5855
+ notes: "Any org admin may read it; only an Owner may change it."
5856
+ },
5857
+ {
5858
+ method: "PUT",
5859
+ path: "/api/org/federation",
5860
+ section: "org",
5861
+ summary: "Sets whether a federated login may join an existing account by verified email.",
5862
+ audience: "developer",
5863
+ auth: "developer",
5864
+ rateLimited: false,
5865
+ ownerTier: true,
5866
+ status: 200,
5867
+ params: [],
5868
+ query: null,
5869
+ request: orgFederationPolicyRequest,
5870
+ response: orgFederationPolicy,
5871
+ errors: [...DEVELOPER_GUARD, "tier_required", "validation_error"],
5872
+ transport: "http",
5873
+ notes: "Owner tier: what this flag decides is account linking across the whole org, not a per-app setting. It lives on the org rather than on an app because the thing it governs \u2014 one user row per address \u2014 is org-scoped."
5874
+ },
5875
+ {
5876
+ method: "GET",
5877
+ path: "/api/org/groups/:id/oidc-provider",
5878
+ section: "org",
5879
+ summary: "Reads a group's OIDC provider configuration, without the client secret.",
5880
+ audience: "developer",
5881
+ auth: "developer",
5882
+ rateLimited: false,
5883
+ ownerTier: false,
5884
+ status: 200,
5885
+ params: [{ name: "id", description: "The group's uuid, as listed by `GET /api/org/groups`." }],
5886
+ query: null,
5887
+ request: null,
5888
+ response: groupOidcProvider,
5889
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
5890
+ transport: "http",
5891
+ notes: "A group with no provider answers `404`, not an empty object. The secret is write-only and comes back through nothing \u2014 not this read, not the PUT's own response, not an audit detail; the stored row this maps from has none to carry."
5892
+ },
5893
+ {
5894
+ method: "PUT",
5895
+ path: "/api/org/groups/:id/oidc-provider",
5896
+ section: "org",
5897
+ summary: "Sets or rotates a group's OIDC provider and its just-in-time grants.",
5898
+ audience: "developer",
5899
+ auth: "developer",
5900
+ rateLimited: false,
5901
+ ownerTier: false,
5902
+ status: 200,
5903
+ params: [{ name: "id", description: "The group's uuid, as listed by `GET /api/org/groups`." }],
5904
+ query: null,
5905
+ request: putGroupOidcProviderRequest,
5906
+ response: groupOidcProvider,
5907
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "target_state_conflict", "validation_error"],
5908
+ transport: "http",
5909
+ notes: "Any org admin, not Owner only: configuring a group's provider is member management. The Org Admins group is refused with `409 target_state_conflict` \u2014 console login is always the Fleetless password provider. `client_secret` is optional so a rotation need not resend it, but the first write of a provider must carry one. Each JIT grant's app must belong to this group, its role to that app, and no two grants may name the same app. An issuer resolving to a loopback or private address is refused here as a shape check; the authoritative SSRF defence is at the discovery fetch."
5910
+ },
5911
+ {
5912
+ method: "DELETE",
5913
+ path: "/api/org/groups/:id/oidc-provider",
5914
+ section: "org",
5915
+ summary: "Removes a group's OIDC provider.",
5916
+ audience: "developer",
5917
+ auth: "developer",
5918
+ rateLimited: false,
5919
+ ownerTier: false,
5920
+ status: 204,
5921
+ params: [{ name: "id", description: "The group's uuid, as listed by `GET /api/org/groups`." }],
5922
+ query: null,
5923
+ request: null,
5924
+ response: null,
5925
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
5926
+ transport: "http",
5927
+ notes: "Deleting a provider a group never had is an honest `404`, not a silent `204`."
5928
+ },
5929
+ {
5930
+ method: "PATCH",
5931
+ path: "/api/org",
5932
+ section: "org",
5933
+ summary: "Renames the org.",
5934
+ audience: "developer",
5935
+ auth: "developer",
5936
+ rateLimited: false,
5937
+ ownerTier: true,
5938
+ status: 200,
5939
+ params: [],
5940
+ query: null,
5941
+ request: patchOrgRequest,
5942
+ response: null,
5943
+ errors: [...DEVELOPER_GUARD, "tier_required", "validation_error"],
5944
+ transport: "http",
5945
+ notes: 'Answers `{ "org": org }`. The envelope has no schema of its own in contracts; the value is an `org`. Owner tier, and the gate runs before the body is looked at, so a malformed rename and a forbidden one answer the same way. Renaming to the name already held writes nothing and records no audit event.'
5946
+ },
5947
+ /* --------------------------------------------- oauth (app-scoped clients) */
5948
+ {
5949
+ method: "POST",
5950
+ path: "/api/apps/:id/oauth-clients",
5951
+ section: "oauth",
5952
+ summary: "Registers an OAuth client on the app and returns its `client_id`.",
5953
+ audience: "developer",
5954
+ auth: "developer",
5955
+ rateLimited: false,
5956
+ ownerTier: false,
5957
+ status: 201,
5958
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
5959
+ query: null,
5960
+ request: null,
5961
+ response: oauthClient,
5962
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
5963
+ transport: "http",
5964
+ notes: 'The body is `{ "client_name": string, "redirect_uris": string[] }` and has no contract shape of its own: `dynamicClientRegistrationRequest` is RFC 7591\'s wire for `POST /oauth/register` and carries four fields this route ignores, so reusing it here would document a request that is not this one. A `redirect_uri` is refused as `validation_error` with rule `invalid_redirect_uri`. This is a developer\'s own credential-bearing action, so it answers the `apiError` envelope, not RFC 6749\'s.'
5965
+ },
5966
+ {
5967
+ method: "GET",
5968
+ path: "/api/apps/:id/oauth-clients",
5969
+ section: "oauth",
5970
+ summary: "Lists the app's OAuth clients, developer-registered and self-registered alike.",
5971
+ audience: "developer",
5972
+ auth: "developer",
5973
+ rateLimited: false,
5974
+ ownerTier: false,
5975
+ status: 200,
5976
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
5977
+ query: null,
5978
+ request: null,
5979
+ response: null,
5980
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
5981
+ transport: "http",
5982
+ notes: 'Answers `{ "oauth_clients": [oauthClient, \u2026] }`. The envelope has no schema of its own in contracts; each element is an `oauthClient`, and each carries its `registration` \u2014 a developer could otherwise not see the self-registered clients holding their own dynamic-client ceiling shut.'
5983
+ },
5984
+ {
5985
+ method: "DELETE",
5986
+ path: "/api/apps/:id/oauth-clients/:clientId",
5987
+ section: "oauth",
5988
+ summary: "Removes an OAuth client registration from the app.",
5989
+ audience: "developer",
5990
+ auth: "developer",
5991
+ rateLimited: false,
5992
+ ownerTier: false,
5993
+ status: 204,
5994
+ params: [
5995
+ { name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." },
5996
+ { name: "clientId", description: "The OAuth `client_id` string, as listed by `GET /api/apps/:id/oauth-clients`; a client of another app answers `404`." }
5997
+ ],
5998
+ query: null,
5999
+ request: null,
6000
+ response: null,
6001
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
6002
+ transport: "http",
6003
+ notes: "The harsher of the two doors: the registration itself is gone, so the client cannot ask for consent again. `DELETE \u2026/consent` is the other one, which leaves the registration in place."
6004
+ },
6005
+ {
6006
+ method: "DELETE",
6007
+ path: "/api/apps/:id/oauth-clients/:clientId/consent",
6008
+ section: "oauth",
6009
+ summary: "Revokes every end user's consent grant to one OAuth client of the app.",
6010
+ audience: "developer",
6011
+ auth: "developer",
6012
+ rateLimited: false,
6013
+ ownerTier: false,
6014
+ status: 204,
6015
+ params: [
6016
+ { name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." },
6017
+ { name: "clientId", description: "The OAuth `client_id` string, as listed by `GET /api/apps/:id/oauth-clients`; a client of another app answers `404`." }
6018
+ ],
6019
+ query: null,
6020
+ request: null,
6021
+ response: null,
6022
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
6023
+ transport: "http",
6024
+ notes: '"Disconnect that AI tool", without deleting the registration. The revocation is read on the client\'s very next call, so no token refresh has to happen first, and every other client the same end users granted is untouched. A client with no active grant answers `404` rather than a `204` that would claim something was revoked.'
6025
+ },
6026
+ /* ----------------------------------------------- oauth (RFC metadata) */
6027
+ {
6028
+ method: "GET",
6029
+ path: "/.well-known/oauth-authorization-server",
6030
+ section: "oauth",
6031
+ summary: "Publishes the authorization-server metadata every OAuth 2.1 client reads before it does anything else.",
6032
+ audience: "client",
6033
+ auth: "none",
6034
+ rateLimited: false,
6035
+ ownerTier: false,
6036
+ status: 200,
6037
+ params: [],
6038
+ query: null,
6039
+ request: null,
6040
+ response: authorizationServerMetadata,
6041
+ errors: [],
6042
+ transport: "http",
6043
+ notes: 'RFC 8414. No auth and no database read, so a client that can reach only this document can still plan the whole flow. `code_challenge_methods_supported` is `["S256"]` and `token_endpoint_auth_methods_supported` is `["none"]`: every client here is public and PKCE is required.'
6044
+ },
6045
+ {
6046
+ method: "GET",
6047
+ path: "/.well-known/oauth-authorization-server/:appIdentifier",
6048
+ section: "oauth",
6049
+ summary: "The same metadata for one app, whose `registration_endpoint` already carries the app identifier.",
6050
+ audience: "client",
6051
+ auth: "none",
6052
+ rateLimited: false,
6053
+ ownerTier: false,
6054
+ status: 200,
6055
+ params: [{ name: "appIdentifier", description: "The app's `identifier`, the slug a developer chose when the app was created." }],
6056
+ query: null,
6057
+ request: null,
6058
+ response: authorizationServerMetadata,
6059
+ errors: ["not_found"],
6060
+ transport: "http",
6061
+ notes: "RFC 8414 \xA73.1's suffix-insertion rule: an issuer with a path component publishes its document here. The one functional difference from the global document is `registration_endpoint`, which already names `?app_identifier=` \u2014 so a client that starts from this document never needs that value from anywhere else. `authorization_endpoint` and `token_endpoint` stay the global ones, since `client_id` already says which app. An unknown identifier is `404`, not a document describing nothing."
6062
+ },
6063
+ {
6064
+ method: "GET",
6065
+ path: "/.well-known/oauth-protected-resource",
6066
+ section: "oauth",
6067
+ summary: "Publishes what the stub resource says about who may authorize for it.",
6068
+ audience: "client",
6069
+ auth: "none",
6070
+ rateLimited: false,
6071
+ ownerTier: false,
6072
+ status: 200,
6073
+ params: [],
6074
+ query: null,
6075
+ request: null,
6076
+ response: protectedResourceMetadata,
6077
+ errors: [],
6078
+ transport: "http",
6079
+ notes: "RFC 9728, for the global `/mcp-stub/resource`. The per-app document lives under `/.well-known/oauth-protected-resource/mcp-stub/resource/:appIdentifier`."
6080
+ },
6081
+ /* ------------------------------------------- oauth (the app sign-in flow) */
6082
+ {
6083
+ method: "GET",
6084
+ path: "/oauth/authorize",
6085
+ section: "oauth",
6086
+ summary: "Starts an end-user sign-in for an app and redirects the browser to the login card.",
6087
+ audience: "client",
6088
+ auth: "none",
6089
+ rateLimited: true,
6090
+ ownerTier: false,
6091
+ status: 302,
6092
+ params: [],
6093
+ query: null,
6094
+ request: null,
6095
+ response: null,
6096
+ errors: ["rate_limited"],
6097
+ transport: "http",
6098
+ notes: "The query is RFC 6749 \xA74.1.1's \u2014 `client_id`, `redirect_uri`, `response_type`, `code_challenge`, `code_challenge_method`, `state`, `resource` \u2014 read parameter by parameter, and contracts declares no schema for it, so nothing here pins its shape. **`client_id` and `redirect_uri` are validated first, and a failure there never redirects** \u2014 until the URI is known-good, sending a browser to it is the attack. Those two refusals are RFC 6749's flat `oauthError` shape at `400`; everything validated afterwards (`response_type`, PKCE, `resource`) goes back to the callback as query parameters, per \xA74.1.2.1. `S256` is required. A group with a configured OIDC provider is sent to that provider instead of to the Fleetless login card. The only `apiError` this route sends is the rate limiter's `429 rate_limited`."
6099
+ },
6100
+ {
6101
+ method: "POST",
6102
+ path: "/login",
6103
+ section: "oauth",
6104
+ summary: "Checks an end user's password and hands back where the sign-in continues.",
6105
+ audience: "internal",
6106
+ auth: "none",
6107
+ rateLimited: true,
6108
+ ownerTier: false,
6109
+ status: 200,
6110
+ params: [],
6111
+ query: null,
6112
+ request: oauthLoginRequest,
6113
+ response: oauthRedirectResponse,
6114
+ errors: ["rate_limited", "validation_error", "token_spent", "invalid_credentials", "forbidden"],
6115
+ transport: "http",
6116
+ notes: "`interaction_id` travels in the **body**, never the URL: which app is being signed into is a property of the pending request the server already holds, not an assertion the page gets to make. Two dialects, decided on the request body's content type \u2014 a real `<form>` submission gets a `303` to the next step and the rendered pages for every refusal, a programmatic caller gets this `200` and the `apiError` codes above. An org admin is redirected to the impersonation interstitial rather than straight on. `oauthLoginResponse` is contracts' alias for this response shape."
6117
+ },
6118
+ {
6119
+ method: "GET",
6120
+ path: "/login",
6121
+ section: "oauth",
6122
+ summary: "Serves the app login card the authorize step redirects a browser to.",
6123
+ audience: "internal",
6124
+ auth: "none",
6125
+ rateLimited: false,
6126
+ ownerTier: false,
6127
+ status: 200,
6128
+ params: [],
6129
+ query: null,
6130
+ request: null,
6131
+ response: null,
6132
+ errors: [],
6133
+ transport: "http",
6134
+ notes: 'HTML, branded per app. One path, both verbs: this GET draws the form and the POST above takes it. An expired, consumed, unknown or hand-edited interaction renders one "sign-in is over" page at `410` \u2014 and so does a lapsed dynamic client, because a form whose submission is already known to fail is a password typed for nothing.'
6135
+ },
6136
+ {
6137
+ method: "GET",
6138
+ path: "/oauth/impersonate",
6139
+ section: "oauth",
6140
+ summary: 'Serves the "sign in as" card an org admin sees instead of going straight to the app.',
6141
+ audience: "internal",
6142
+ auth: "none",
6143
+ rateLimited: false,
6144
+ ownerTier: false,
6145
+ status: 200,
6146
+ params: [],
6147
+ query: null,
6148
+ request: null,
6149
+ response: null,
6150
+ errors: [],
6151
+ transport: "http",
6152
+ notes: "HTML, and the most disclosive page the portal has: it lists every assignable user of the app's group by email address. So the browser-proof cookie is checked on this **GET** as well as on the POST \u2014 `interaction_id` is not a secret, since the client that starts the flow learns its own from the authorize redirect, and without the cookie a self-registering client could read a customer's roster off a page it never authenticated for. Refusals render the problem page carrying `wrong_browser` or `forbidden` as a document attribute, not as an `apiError` body."
6153
+ },
6154
+ {
6155
+ method: "POST",
6156
+ path: "/oauth/impersonate",
6157
+ section: "oauth",
6158
+ summary: "Takes the org admin's choice of role or user and resumes the app sign-in as them.",
6159
+ audience: "internal",
6160
+ auth: "none",
6161
+ rateLimited: true,
6162
+ ownerTier: false,
6163
+ status: 200,
6164
+ params: [],
6165
+ query: null,
6166
+ request: null,
6167
+ response: oauthRedirectResponse,
6168
+ errors: ["rate_limited", "validation_error", "token_spent", "unauthorized", "forbidden"],
6169
+ transport: "http",
6170
+ notes: 'The JSON body is `{ "interaction_id": string, "choice": impersonationChoice }`; that wrapper has no schema of its own, only the `choice` does. A `<form>` cannot nest an object, so a browser sends one `act_as` field spelled `role:<id>` or `user:<id>`, parsed back into the identical `choice` before anything downstream sees a difference. Signing in as another org admin is refused. The browser-proof cookie is re-checked here, and a browser gets the rendered problem page where a JSON caller gets these codes.'
6171
+ },
6172
+ {
6173
+ method: "GET",
6174
+ path: "/oauth/consent",
6175
+ section: "oauth",
6176
+ summary: "Serves the consent screen for a client asking to act for an end user.",
6177
+ audience: "internal",
6178
+ auth: "none",
6179
+ rateLimited: false,
6180
+ ownerTier: false,
6181
+ status: 200,
6182
+ params: [],
6183
+ query: null,
6184
+ request: null,
6185
+ response: null,
6186
+ errors: [],
6187
+ transport: "http",
6188
+ notes: "HTML. The browser-proof cookie is checked on the GET as well as on the POST \u2014 the same gap that had to be closed on the impersonation interstitial and the MCP consent screen. An interaction that is not authenticated yet, a lapsed dynamic client and the central client all render the same `410` page."
6189
+ },
6190
+ {
6191
+ method: "POST",
6192
+ path: "/oauth/consent",
6193
+ section: "oauth",
6194
+ summary: "Records the end user's allow-or-deny and sends the browser back to the client.",
6195
+ audience: "internal",
6196
+ auth: "none",
6197
+ rateLimited: true,
6198
+ ownerTier: false,
6199
+ status: 200,
6200
+ params: [],
6201
+ query: null,
6202
+ request: consentDecision,
6203
+ response: oauthRedirectResponse,
6204
+ errors: ["rate_limited", "validation_error", "token_spent", "unauthorized", "invalid_credentials"],
6205
+ transport: "http",
6206
+ notes: "A JSON caller sends `consentDecision` and its bytes are untouched. A `<form>` cannot send a boolean, so a browser sends the pressed button's `decision` value and **anything that is not exactly the Allow value denies**, including a submission carrying none \u2014 fail-closed is the only defensible default on a screen whose whole job is to require a deliberate yes. `oauthConsentResponse` is contracts' alias for this response shape."
6207
+ },
6208
+ {
6209
+ method: "POST",
6210
+ path: "/oauth/token",
6211
+ section: "oauth",
6212
+ summary: "Exchanges an authorization code, or a refresh token, for an end-user access token.",
6213
+ audience: "client",
6214
+ auth: "none",
6215
+ rateLimited: true,
6216
+ ownerTier: false,
6217
+ status: 200,
6218
+ params: [],
6219
+ query: null,
6220
+ request: oauthTokenRequest,
6221
+ response: oauthTokenResponse,
6222
+ errors: ["rate_limited"],
6223
+ transport: "http",
6224
+ notes: "Refusals use RFC 6749 \xA75.2's flat `oauthError` shape \u2014 it is a token endpoint, and that is the dialect a caller of one expects \u2014 so it emits none of the other codes in this reference; only the rate limiter answers an `apiError`. The request is a discriminated union on `grant_type` and is deliberately **not** strict: a conformant client may send parameters this server does not read, and refusing those would be a conformance bug. `resource` (RFC 8707) is carried across every refresh rotation \u2014 a successor token minted without it would be refused by the resource one token lifetime after a login that worked."
6225
+ },
6226
+ {
6227
+ method: "POST",
6228
+ path: "/oauth/register",
6229
+ section: "oauth",
6230
+ summary: "Registers a client dynamically against one app, when that app accepts dynamic clients.",
6231
+ audience: "client",
6232
+ auth: "none",
6233
+ rateLimited: true,
6234
+ ownerTier: false,
6235
+ status: 201,
6236
+ params: [],
6237
+ query: null,
6238
+ request: dynamicClientRegistrationRequest,
6239
+ response: dynamicClientRegistrationResponse,
6240
+ errors: ["rate_limited"],
6241
+ transport: "http",
6242
+ notes: "RFC 7591. `?app_identifier=` names the app and is required; it is read directly and contracts declares no schema for this query, so it appears in no parameter table. An app that does not accept dynamic clients answers `oauthError` `access_denied`, as does one that has reached its per-app ceiling. Every refusal here is `oauthError`, not `apiError` \u2014 again, only the rate limiter differs. A registration expires: an unused dynamic client stops working rather than merely stopping to count."
6243
+ },
6244
+ /* ------------------------------- oauth (the validating stub resource) */
6245
+ {
6246
+ method: "GET",
6247
+ path: "/mcp-stub/resource",
6248
+ section: "oauth",
6249
+ summary: "A resource that validates an access token's audience, so the minting side has something that refuses.",
6250
+ audience: "internal",
6251
+ auth: "none",
6252
+ rateLimited: false,
6253
+ ownerTier: false,
6254
+ status: 200,
6255
+ params: [],
6256
+ query: null,
6257
+ request: null,
6258
+ response: null,
6259
+ errors: [],
6260
+ transport: "http",
6261
+ notes: "Answers `{ ok, sub, resource }`, a cloud-local shape with no wire contract. It exists because a minting mechanism with no validator is a check that cannot fail: this refuses a token whose `aud` names something else, and the gate measures the refusal. Its refusals are RFC 6750's tiny `{ error, error_description }` with a `WWW-Authenticate` header naming its own metadata document, not `apiError`."
6262
+ },
6263
+ {
6264
+ method: "GET",
6265
+ path: "/mcp-stub/resource/:appIdentifier",
6266
+ section: "oauth",
6267
+ summary: "The same validating stub, scoped to one app.",
6268
+ audience: "internal",
6269
+ auth: "none",
6270
+ rateLimited: false,
6271
+ ownerTier: false,
6272
+ status: 200,
6273
+ params: [{ name: "appIdentifier", description: "The app's `identifier`, the slug a developer chose when the app was created." }],
6274
+ query: null,
6275
+ request: null,
6276
+ response: null,
6277
+ errors: [],
6278
+ transport: "http",
6279
+ notes: 'The per-app half of the discovery walk: a client holding only this URL follows `401` \u2192 `WWW-Authenticate: resource_metadata=` \u2192 the per-app protected-resource document \u2192 the per-app authorization-server document \u2192 `registration_endpoint`, and needs nothing told to it out of band. An unknown identifier answers RFC 6750\'s `{ error: "invalid_request" }` at `404`, not the `apiError` envelope.'
6280
+ },
6281
+ {
6282
+ method: "GET",
6283
+ path: "/.well-known/oauth-protected-resource/mcp-stub/resource/:appIdentifier",
6284
+ section: "oauth",
6285
+ summary: "The protected-resource metadata for one app's stub resource.",
6286
+ audience: "internal",
6287
+ auth: "none",
6288
+ rateLimited: false,
6289
+ ownerTier: false,
6290
+ status: 200,
6291
+ params: [{ name: "appIdentifier", description: "The app's `identifier`, the slug a developer chose when the app was created." }],
6292
+ query: null,
6293
+ request: null,
6294
+ response: protectedResourceMetadata,
6295
+ errors: ["not_found"],
6296
+ transport: "http",
6297
+ notes: "RFC 9728's path-suffix construction, the same rule the per-app authorization-server document follows. `authorization_servers` names the per-app issuer, which is what lets a client reach the whole chain from the resource URL alone."
6298
+ },
6299
+ {
6300
+ method: "GET",
6301
+ path: "/oauth/idp-callback",
6302
+ section: "oauth",
6303
+ summary: "Takes the identity provider's redirect back and resumes the app sign-in as the federated user.",
6304
+ audience: "internal",
6305
+ auth: "none",
6306
+ rateLimited: false,
6307
+ ownerTier: false,
6308
+ status: 302,
6309
+ params: [],
6310
+ query: null,
6311
+ request: null,
6312
+ response: null,
6313
+ errors: [],
6314
+ transport: "http",
6315
+ notes: "**Every failure renders the honest error page and never the Fleetless login form** \u2014 a form asking for a Fleetless password after an identity-provider round trip is the phishing door this design closes by name. The page carries an `oidcCallbackError` code (`invalid_request`, `exchange_failed`, `provider_misconfigured`, `idp_unreachable`, `claims_incomplete`, `jit_disabled`, `email_collision`, `forbidden`), which is a different vocabulary from this reference's: none of these answers is an `apiError` envelope. `state` is signed and checked before any database read. The SSRF defence sits at the discovery fetch and at the token exchange, on the URL the provider's own document named."
6316
+ },
6317
+ /* --------------------------------------------------------------- mcp */
6318
+ {
6319
+ method: "GET",
6320
+ path: "/.well-known/oauth-protected-resource/mcp",
6321
+ section: "mcp",
6322
+ summary: "Publishes what the MCP endpoint says about who may authorize for it.",
6323
+ audience: "client",
6324
+ auth: "none",
6325
+ rateLimited: false,
6326
+ ownerTier: false,
6327
+ status: 200,
6328
+ params: [],
6329
+ query: null,
6330
+ request: null,
6331
+ response: protectedResourceMetadata,
6332
+ errors: [],
6333
+ transport: "http",
6334
+ notes: "RFC 9728, for the one central MCP endpoint. `resource` and `authorization_servers` are the same URL: the MCP server is its own authorization server here. There is no per-group document, because the path names no group \u2014 the token does."
6335
+ },
6336
+ {
6337
+ method: "GET",
6338
+ path: "/.well-known/oauth-authorization-server/mcp",
6339
+ section: "mcp",
6340
+ summary: "Publishes the authorization-server metadata an MCP client reads to sign a person in.",
6341
+ audience: "client",
6342
+ auth: "none",
6343
+ rateLimited: false,
6344
+ ownerTier: false,
6345
+ status: 200,
6346
+ params: [],
6347
+ query: null,
6348
+ request: null,
6349
+ response: authorizationServerMetadata,
6350
+ errors: [],
6351
+ transport: "http",
6352
+ notes: "`registration_endpoint` being present is the whole point of the dynamic-registration work: a client that finds it registers itself and never asks a person for a `client_id`. `authorization_endpoint` is the only field that moves to the auth-portal origin when one is configured \u2014 `issuer`, `token_endpoint` and the resource identifier stay canonical, because a client checks a token's `iss` and `aud` against those strings and moving them would invalidate every token ever minted."
6353
+ },
6354
+ {
6355
+ method: "POST",
6356
+ path: "/mcp/oauth/register",
6357
+ section: "mcp",
6358
+ summary: "Registers an MCP client dynamically, with no app identifier and no human in the loop.",
6359
+ audience: "client",
6360
+ auth: "none",
6361
+ rateLimited: true,
6362
+ ownerTier: false,
6363
+ status: 201,
6364
+ params: [],
6365
+ query: null,
6366
+ request: null,
6367
+ response: dynamicClientRegistrationResponse,
6368
+ errors: ["rate_limited"],
6369
+ transport: "http",
6370
+ notes: "RFC 7591, and deliberately **not** parsed against `dynamicClientRegistrationRequest`: that shape is strict, and a strict schema here would answer `400` to a conforming client and take the whole paste-the-URL flow down with it. `client_name` and `redirect_uris` are read by hand; everything else is 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`."
6371
+ },
6372
+ {
6373
+ method: "GET",
6374
+ path: "/mcp/oauth/authorize",
6375
+ section: "mcp",
6376
+ summary: "Starts an MCP sign-in and redirects the browser to the identify card.",
6377
+ audience: "client",
6378
+ auth: "none",
6379
+ rateLimited: false,
6380
+ ownerTier: false,
6381
+ status: 302,
6382
+ params: [],
6383
+ query: null,
6384
+ request: null,
6385
+ response: null,
6386
+ errors: [],
6387
+ transport: "http",
6388
+ notes: "Client and `redirect_uri` are validated first and a failure there never redirects, the same open-redirect discipline the app flow applies; those refusals are `oauthError`. Exact `redirect_uri` matching for both client kinds \u2014 the loopback-port wildcard of RFC 8252 \xA77.3 belongs to the one central client alone, whose URIs are configured ahead of time and cannot name an ephemeral port. A client that registered itself seconds ago can name the port it bound, and widening the wildcard there would only widen where a stolen `client_id` may send a browser. No group is chosen here: the email address on the next card decides it."
6389
+ },
6390
+ {
6391
+ method: "GET",
6392
+ path: "/mcp/oauth/interaction/:id",
6393
+ section: "mcp",
6394
+ summary: 'Serves the "what is your email address" card of an MCP sign-in.',
6395
+ audience: "internal",
6396
+ auth: "none",
6397
+ rateLimited: false,
6398
+ ownerTier: false,
6399
+ status: 200,
6400
+ params: [{ name: "id", description: "The interaction id minted by `GET /mcp/oauth/authorize`, which redirects the browser here." }],
6401
+ query: null,
6402
+ request: null,
6403
+ response: null,
6404
+ errors: [],
6405
+ transport: "http",
6406
+ notes: "HTML, and a GET rather than the body of the authorize response \u2014 so it is reloadable, bookmarkable and survives a back button, which the inline page it replaced was not. An expired, consumed, unknown or hand-edited interaction renders one page at `410`, and so does a client whose dynamic registration lapsed in between."
6407
+ },
6408
+ {
6409
+ method: "POST",
6410
+ path: "/mcp/oauth/identify",
6411
+ section: "mcp",
6412
+ summary: "Takes the email address and decides whether this person signs in with a password or through their identity provider.",
6413
+ audience: "internal",
6414
+ auth: "none",
6415
+ rateLimited: true,
6416
+ ownerTier: false,
6417
+ status: 200,
6418
+ params: [],
6419
+ query: null,
6420
+ request: null,
6421
+ response: null,
6422
+ errors: ["rate_limited", "validation_error", "token_spent"],
6423
+ transport: "http",
6424
+ notes: 'The identifier-first step: the **resolved user** decides the group and the provider, never a path segment and never the client. A browser form post gets the password card, or a `303` to the identity provider; a JSON caller gets `{ "next" }`, which has no schema. Rate limited per (route, ip, email) despite spending no credential, because a federated address sends this endpoint on an outbound discovery request. A provider that is misconfigured or unreachable renders the problem page, not an `apiError`.'
6425
+ },
6426
+ {
6427
+ method: "POST",
6428
+ path: "/mcp/oauth/login",
6429
+ section: "mcp",
6430
+ summary: "Checks the password and hands back where the MCP sign-in continues.",
6431
+ audience: "internal",
6432
+ auth: "none",
6433
+ rateLimited: true,
6434
+ ownerTier: false,
6435
+ status: 200,
6436
+ params: [],
6437
+ query: null,
6438
+ request: null,
6439
+ response: oauthRedirectResponse,
6440
+ errors: ["rate_limited", "validation_error", "token_spent", "invalid_credentials"],
6441
+ transport: "http",
6442
+ notes: 'The body is `{ "interaction_id", "email", "password" }`, read field by field rather than through a contract shape. A browser gets a `303` \u2014 to the consent screen for a self-registered client, or straight to the callback for the central one \u2014 where a JSON caller gets this `200` and `redirect_to`.'
6443
+ },
6444
+ {
6445
+ method: "GET",
6446
+ path: "/mcp/oauth/idp-callback",
6447
+ section: "mcp",
6448
+ summary: "Takes the identity provider's redirect back and resumes the MCP sign-in as the federated user.",
6449
+ audience: "internal",
6450
+ auth: "none",
6451
+ rateLimited: false,
6452
+ ownerTier: false,
6453
+ status: 302,
6454
+ params: [],
6455
+ query: null,
6456
+ request: null,
6457
+ response: null,
6458
+ errors: [],
6459
+ transport: "http",
6460
+ notes: "One central callback for every group \u2014 the group is recovered from the interaction the signed `state` names, which is why this URL is a constant a customer can register with their own identity provider once. Every failure renders the honest problem page with an `oidcCallbackError` code, never the Fleetless login form and never an `apiError` envelope, for the reason the app flow's callback gives."
6461
+ },
6462
+ {
6463
+ method: "GET",
6464
+ path: "/mcp/oauth/consent/:id",
6465
+ section: "mcp",
6466
+ summary: "Serves the consent screen for an MCP client that registered itself.",
6467
+ audience: "internal",
6468
+ auth: "none",
6469
+ rateLimited: false,
6470
+ ownerTier: false,
6471
+ status: 200,
6472
+ params: [{ name: "id", description: "The interaction id from the sign-in; the login step redirects the browser here." }],
6473
+ query: null,
6474
+ request: null,
6475
+ response: null,
6476
+ errors: [],
6477
+ transport: "http",
6478
+ notes: "HTML. The browser-proof cookie is checked on this GET, not only on the POST. The **central** client never reaches this screen and renders the `410` page instead: it is configured by the operator, so there is no self-registered stranger for a person to weigh up."
6479
+ },
6480
+ {
6481
+ method: "POST",
6482
+ path: "/mcp/oauth/consent",
6483
+ section: "mcp",
6484
+ summary: "Records the allow-or-deny and sends the browser back to the MCP client.",
6485
+ audience: "internal",
6486
+ auth: "none",
6487
+ rateLimited: true,
6488
+ ownerTier: false,
6489
+ status: 200,
6490
+ params: [],
6491
+ query: null,
6492
+ request: null,
6493
+ response: oauthRedirectResponse,
6494
+ errors: ["rate_limited", "validation_error", "token_spent"],
6495
+ transport: "http",
6496
+ notes: "Fail-closed exactly as the app flow's consent POST is: the body carries the pressed button's `decision`, and anything that is not the Allow value \u2014 a missing field included \u2014 denies. A denial still answers a `redirect_to`, carrying `error=access_denied` back to the client, because a client that is refused must learn so from its own callback rather than from a page nobody sent it."
6497
+ },
6498
+ {
6499
+ method: "POST",
6500
+ path: "/mcp/oauth/token",
6501
+ section: "mcp",
6502
+ summary: "Exchanges an MCP authorization code for an access token.",
6503
+ audience: "client",
6504
+ auth: "none",
6505
+ rateLimited: false,
6506
+ ownerTier: false,
6507
+ status: 200,
6508
+ params: [],
6509
+ query: null,
6510
+ request: null,
6511
+ response: oauthTokenResponse,
6512
+ errors: [],
6513
+ transport: "http",
6514
+ 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."
6515
+ },
6516
+ /* ------------------------------- developer auth (the console\'s OAuth portal) */
6517
+ {
6518
+ method: "GET",
6519
+ path: "/console/oauth/authorize",
6520
+ section: "developer-auth",
6521
+ summary: "Starts a console sign-in and redirects the browser to the identify card.",
6522
+ audience: "internal",
6523
+ auth: "none",
6524
+ rateLimited: false,
6525
+ ownerTier: false,
6526
+ status: 302,
6527
+ params: [],
6528
+ query: null,
6529
+ request: null,
6530
+ response: null,
6531
+ errors: [],
6532
+ transport: "http",
6533
+ notes: "The authorization-code leg of the console's own OAuth flow: PKCE `S256` is required and `redirect_uri` must match a configured console callback exactly, never a prefix. Refusals use RFC 6749's flat `oauthError` shape, not the `apiError` envelope, so they carry none of the codes in this reference. A bad `redirect_uri` never redirects \u2014 until the URI is known-good, sending a browser to it is the attack; later errors go back to the callback as query parameters. `?prompt=create` starts at sign-up rather than sign-in."
6534
+ },
6535
+ {
6536
+ method: "GET",
6537
+ path: "/console/oauth/interaction/:id",
6538
+ section: "developer-auth",
6539
+ summary: 'Serves the "what is your email address" card of a console sign-in.',
6540
+ audience: "internal",
6541
+ auth: "none",
6542
+ rateLimited: false,
6543
+ ownerTier: false,
6544
+ status: 200,
6545
+ params: [{ name: "id", description: "The interaction id minted by `GET /console/oauth/authorize`, which redirects the browser here." }],
6546
+ query: null,
6547
+ request: null,
6548
+ response: null,
6549
+ errors: [],
6550
+ transport: "http",
6551
+ notes: "HTML. An expired, consumed, unknown or hand-edited interaction renders one page at `410`: which of the four it was is not a fact a stranger may learn, and to the person it is one fact anyway. The page resolves nothing about the address typed into it, so there is no enumeration oracle here at all."
6552
+ },
6553
+ {
6554
+ method: "POST",
6555
+ path: "/console/oauth/identify",
6556
+ section: "developer-auth",
6557
+ summary: "Takes the email address and hands back the password step.",
6558
+ audience: "internal",
6559
+ auth: "none",
6560
+ rateLimited: true,
6561
+ ownerTier: false,
6562
+ status: 200,
6563
+ params: [],
6564
+ query: null,
6565
+ request: null,
6566
+ response: null,
6567
+ errors: ["rate_limited", "token_spent"],
6568
+ transport: "http",
6569
+ notes: 'A browser form post gets the password card as HTML; a JSON caller gets `{ "next": "/console/oauth/login" }`, which has no schema \u2014 the step made no decision, and it says so rather than inventing a redirect. A dead interaction is `410 token_spent`. Rate limited despite spending no credential: it is an unauthenticated endpoint that renders a page.'
6570
+ },
6571
+ {
6572
+ method: "POST",
6573
+ path: "/console/oauth/login",
6574
+ section: "developer-auth",
6575
+ summary: "Checks the password and mints the authorization code the console exchanges.",
6576
+ audience: "internal",
6577
+ auth: "none",
6578
+ rateLimited: true,
6579
+ ownerTier: false,
6580
+ status: 200,
6581
+ params: [],
6582
+ query: null,
6583
+ request: null,
6584
+ response: oauthRedirectResponse,
6585
+ errors: ["rate_limited", "token_spent", "invalid_credentials"],
6586
+ transport: "http",
6587
+ notes: 'This is the only place a Fleetless developer password may be typed; `POST /api/auth/login` is gone, because a second credential door means every security property has to be right in two places. A browser form post gets a `303` to the callback URL; a JSON caller gets that same URL as `redirect_to` at `200`. An argon2 verify runs whether or not the address exists, and the Org Admins check runs after it \u2014 filtering first would hand back a faster "no" for a non-admin account, which is a timing oracle. Wrong password, unknown address and "not an org admin" render identical bytes under one `401`.'
6588
+ },
6589
+ {
6590
+ method: "GET",
6591
+ path: "/console/oauth/signup/:id",
6592
+ section: "developer-auth",
6593
+ summary: "Serves step one of console sign-up, the account card.",
6594
+ audience: "internal",
6595
+ auth: "none",
6596
+ rateLimited: false,
6597
+ ownerTier: false,
6598
+ status: 200,
6599
+ params: [{ name: "id", description: "The interaction id minted by `GET /console/oauth/authorize` with `?prompt=create`." }],
6600
+ query: null,
6601
+ request: null,
6602
+ response: null,
6603
+ errors: [],
6604
+ transport: "http",
6605
+ notes: 'HTML. While the deployment runs in closed beta this renders the "sign-up is closed" card at `403` instead, keeping the interaction alive and pointing back at sign-in \u2014 the person may well already have an account.'
6606
+ },
6607
+ {
6608
+ method: "POST",
6609
+ path: "/console/oauth/signup",
6610
+ section: "developer-auth",
6611
+ summary: "Takes the sign-up email and password and hands back the organization step.",
6612
+ audience: "internal",
6613
+ auth: "none",
6614
+ rateLimited: true,
6615
+ ownerTier: false,
6616
+ status: 200,
6617
+ params: [],
6618
+ query: null,
6619
+ request: null,
6620
+ response: null,
6621
+ errors: ["rate_limited", "token_spent", "signup_closed", "validation_error", "email_taken"],
6622
+ transport: "http",
6623
+ notes: 'A browser form post gets the organization card; a JSON caller gets `{ "next", "email" }`, which has no schema. The plaintext password exists for this one request: what is stored is its argon2 hash, on the interaction row, which expires with it. A per-interaction proof cookie is set here \u2014 it is what stops a third party from finishing a sign-up somebody else started. Sign-up is the one surface whose job is to say an address is taken, so `409 email_taken` is not a leak here.'
6624
+ },
6625
+ {
6626
+ method: "POST",
6627
+ path: "/console/oauth/signup/organization",
6628
+ section: "developer-auth",
6629
+ summary: "Takes the organization name and creates the org, its admin group and its founding Owner.",
6630
+ audience: "internal",
6631
+ auth: "none",
6632
+ rateLimited: true,
6633
+ ownerTier: false,
6634
+ status: 200,
6635
+ params: [],
6636
+ query: null,
6637
+ request: null,
6638
+ response: oauthRedirectResponse,
6639
+ errors: ["rate_limited", "token_spent", "signup_closed", "wrong_browser", "validation_error", "email_taken"],
6640
+ transport: "http",
6641
+ notes: "The same single transaction `POST /api/auth/signup` runs. Step one must have run in **this** browser: a missing or mismatched proof cookie is `401 wrong_browser` and the person is sent back to step one. A browser form post gets a `303` to the console callback; a JSON caller gets `redirect_to` at `200`."
6642
+ },
6643
+ {
6644
+ method: "POST",
6645
+ path: "/console/oauth/token",
6646
+ section: "developer-auth",
6647
+ summary: "Exchanges the console's authorization code for a developer session.",
6648
+ audience: "internal",
6649
+ auth: "none",
6650
+ rateLimited: false,
6651
+ ownerTier: false,
6652
+ status: 200,
6653
+ params: [],
6654
+ query: null,
6655
+ request: null,
6656
+ response: sessionTokens,
6657
+ errors: [],
6658
+ transport: "http",
6659
+ notes: "Called by the console's own server, never by a browser. Refusals use RFC 6749 \xA75.2's flat `oauthError` shape \u2014 it is a token endpoint, and that is the dialect a caller of one expects \u2014 so it emits none of the codes in this reference. Proof of possession is checked before the replay check, and the single-use consume is atomic, so exactly one caller ever mints. The Org Admins membership is re-read here: the code was minted earlier, and a user moved out in between must not get a console session."
6660
+ },
6661
+ /* ---------------------------------------------------- mcp (the endpoint) */
6662
+ {
6663
+ method: "GET",
6664
+ path: "/mcp/welcome",
6665
+ section: "mcp",
6666
+ summary: "Serves the page that tells a person which URL to paste into their MCP client.",
6667
+ audience: "internal",
6668
+ auth: "none",
6669
+ rateLimited: false,
6670
+ ownerTier: false,
6671
+ status: 200,
6672
+ params: [],
6673
+ query: null,
6674
+ request: null,
6675
+ response: null,
6676
+ errors: [],
6677
+ transport: "http",
6678
+ notes: "HTML, one fixed document rendered once at startup \u2014 its inputs are process configuration, not request state. Cached for five minutes rather than a day, because the URLs it names can change with a deployment. Its content security policy admits the page's own inline style and script by SHA-256 rather than by `unsafe-inline`, and forbids every external fetch outright. A configured friendly URL that is not a usable absolute http(s) URL is ignored rather than rendered: a typo in a deployment variable must not put a broken URL in front of every end user."
6679
+ },
6680
+ {
6681
+ method: "POST",
6682
+ path: "/mcp",
6683
+ section: "mcp",
6684
+ summary: "The one MCP endpoint: a stateless Streamable HTTP transport carrying the robot and console tool catalogs.",
6685
+ audience: "client",
6686
+ auth: "in_handler",
6687
+ rateLimited: false,
6688
+ ownerTier: false,
6689
+ status: 200,
6690
+ params: [],
6691
+ query: null,
6692
+ request: null,
6693
+ response: null,
6694
+ errors: ["unauthorized", "forbidden", "mcp_access_denied"],
6695
+ transport: "http",
6696
+ notes: "JSON-RPC over MCP's Streamable HTTP, so neither the request nor the response is a shape contracts describes; the tool arguments and results are the schemas in each tool definition. **The bearer is verified inside the handler**, not by a route guard: the group comes from the token and the path names none, and the refusal has to carry a `WWW-Authenticate` challenge that a guard shared with the REST surface does not send. A `401 unauthorized` carries that challenge; a `403 mcp_access_denied` deliberately does not, because re-authenticating cannot help. `Origin` is checked against the cloud's own. The catalog is re-derived per request, so an admin demoted mid-conversation loses the console tools on the next call \u2014 and a console tool name learned elsewhere is refused there too, as a `forbidden` result rather than an unknown-tool error, since the name is real and saying otherwise sends the model hunting for a spelling mistake it did not make. Stateless: a fresh transport per request, no session id, nothing survives the call."
6697
+ },
6698
+ /* ------------------------------------------------------- apps (branding) */
6699
+ {
6700
+ method: "GET",
6701
+ path: "/api/apps/:id/branding",
6702
+ section: "apps",
6703
+ summary: "Reads the app's login-page branding, or null when it has none.",
6704
+ audience: "developer",
6705
+ auth: "developer",
6706
+ rateLimited: false,
6707
+ ownerTier: false,
6708
+ status: 200,
6709
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
6710
+ query: null,
6711
+ request: null,
6712
+ response: brandingConfig,
6713
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
6714
+ transport: "http",
6715
+ notes: "`null` is an answer, not an absence: an app that never configured branding is in that state and renders neutral Fleetless. So this is `200` with a `null` body rather than a `404`, and rather than a default object a caller could mistake for a stored one."
6716
+ },
6717
+ {
6718
+ method: "PUT",
6719
+ path: "/api/apps/:id/branding",
6720
+ section: "apps",
6721
+ summary: "Replaces the app's login-page branding.",
6722
+ audience: "developer",
6723
+ auth: "developer",
6724
+ rateLimited: false,
6725
+ ownerTier: false,
6726
+ status: 200,
6727
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
6728
+ query: null,
6729
+ request: brandingConfig,
6730
+ response: brandingConfig,
6731
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
6732
+ transport: "http",
6733
+ notes: "A replace, not a merge. The app is scoped before the body is parsed, so an app the caller does not own answers `404` whether or not the colour was also malformed."
6734
+ },
6735
+ {
6736
+ method: "DELETE",
6737
+ path: "/api/apps/:id/branding",
6738
+ section: "apps",
6739
+ summary: "Drops the app back to neutral Fleetless branding.",
6740
+ audience: "developer",
6741
+ auth: "developer",
6742
+ rateLimited: false,
6743
+ ownerTier: false,
6744
+ status: 204,
6745
+ params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
6746
+ query: null,
6747
+ request: null,
6748
+ response: null,
6749
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
6750
+ transport: "http",
6751
+ notes: "Deleting branding an app never had is not an error: the end state is what was asked for."
6752
+ },
6753
+ /* --------------------------------------------------- end-user (client) auth */
6754
+ {
6755
+ method: "POST",
6756
+ path: "/api/client/login",
6757
+ section: "client-auth",
6758
+ summary: "Signs an app user in with an app identifier, an email address and a password.",
6759
+ audience: "client",
6760
+ auth: "none",
6761
+ rateLimited: true,
6762
+ ownerTier: false,
6763
+ status: 200,
6764
+ params: [],
6765
+ query: null,
6766
+ request: clientLoginRequest,
6767
+ response: sessionTokens,
6768
+ errors: ["rate_limited", "validation_error", "invalid_credentials"],
6769
+ transport: "http",
6770
+ notes: 'One refusal for every miss \u2014 unknown app, unknown address, wrong password, or no assignment to this app \u2014 because the caller supplies the `app_identifier` unauthenticated, so "this app knows this user" is not a fact the answer may carry. The argon2 verify is paid unconditionally, including for an unknown app identifier, so response time is not an oracle either.'
6771
+ },
6772
+ {
6773
+ method: "POST",
6774
+ path: "/api/client/refresh",
6775
+ section: "client-auth",
6776
+ summary: "Rotates an app-user refresh token and mints a fresh access token.",
6777
+ audience: "client",
6778
+ auth: "none",
6779
+ rateLimited: true,
6780
+ ownerTier: false,
6781
+ status: 200,
6782
+ params: [],
6783
+ query: null,
6784
+ request: clientRefreshRequest,
6785
+ response: sessionTokens,
6786
+ errors: ["rate_limited", "validation_error", "token_expired", "token_revoked"],
6787
+ transport: "http",
6788
+ notes: "The assignment is re-proved here, not just the token: refresh is where every session eventually re-proves itself, so a user whose assignment to this app was removed loses the family here even if the proactive revoke had not landed. A family minted from a `resource`-carrying token exchange keeps its audience across every rotation."
6789
+ },
6790
+ {
6791
+ method: "POST",
6792
+ path: "/api/client/logout",
6793
+ section: "client-auth",
6794
+ summary: "Revokes an app-user refresh family and reports whether the identity provider can also be signed out.",
6795
+ audience: "client",
6796
+ auth: "none",
6797
+ rateLimited: true,
6798
+ ownerTier: false,
6799
+ status: 200,
6800
+ params: [],
6801
+ query: null,
6802
+ request: clientLogoutRequest,
6803
+ response: clientLogoutResponse,
6804
+ errors: ["rate_limited", "validation_error"],
6805
+ transport: "http",
6806
+ notes: 'The Fleetless session is over before the identity provider is consulted, and nothing in that outbound call can put it back. `idp_logout` reports which of five outcomes applies; `session_unknown` means no live session was found for the token, which is not the same fact as "this session had no identity provider". A row that cannot produce an `id_token_hint` is never reported as `redirect`. Open `/realtime` sockets are closed.'
6807
+ },
6808
+ {
6809
+ method: "POST",
6810
+ path: "/api/client/password/change",
6811
+ section: "client-auth",
6812
+ summary: "Changes an app user's own password and answers a fresh session.",
6813
+ audience: "client",
6814
+ auth: "developer_or_client",
6815
+ rateLimited: false,
6816
+ ownerTier: false,
6817
+ status: 200,
6818
+ params: [],
6819
+ query: null,
6820
+ request: passwordChangeRequest,
6821
+ response: sessionTokens,
6822
+ errors: [...CLIENT_GUARD, "validation_error", "invalid_credentials"],
6823
+ transport: "http",
6824
+ notes: "The guard admits all three caller kinds, but a password belongs to an app user specifically \u2014 a developer bearer or a server key reaching this is `401 unauthorized`. Every session of the account ends, across every app, since a password is account-wide; the answer is the replacement pair."
6825
+ },
6826
+ {
6827
+ method: "GET",
6828
+ path: "/api/client/grants",
6829
+ section: "client-auth",
6830
+ summary: "Lists the OAuth clients this app user has consented to.",
6831
+ audience: "client",
6832
+ auth: "developer_or_client",
6833
+ rateLimited: false,
6834
+ ownerTier: false,
6835
+ status: 200,
6836
+ params: [],
6837
+ query: null,
6838
+ request: null,
6839
+ response: consentGrantListResponse,
6840
+ errors: [...CLIENT_GUARD],
6841
+ transport: "http",
6842
+ notes: "App-user only: a developer bearer or a server key reaching this through the client surface is `401 unauthorized`."
6843
+ },
6844
+ {
6845
+ method: "DELETE",
6846
+ path: "/api/client/grants/:client_id",
6847
+ section: "client-auth",
6848
+ summary: "Revokes one consent grant and every refresh family issued under it.",
6849
+ audience: "client",
6850
+ auth: "developer_or_client",
6851
+ rateLimited: false,
6852
+ ownerTier: false,
6853
+ status: 200,
6854
+ params: [{ name: "client_id", description: "The OAuth client id, as listed by `GET /api/client/grants`." }],
6855
+ query: null,
6856
+ request: null,
6857
+ response: consentRevokeResponse,
6858
+ errors: [...CLIENT_GUARD, "not_found"],
6859
+ transport: "http",
6860
+ notes: "Ends both the future and the already-issued: the consent stops being usable, and every refresh family for that client and user is revoked. `tokens_revoked` reports how many. App-user only."
6861
+ },
6862
+ {
6863
+ method: "GET",
6864
+ path: "/api/client/me",
6865
+ section: "client-auth",
6866
+ summary: "Answers who the calling token is and what it is allowed to reach.",
6867
+ audience: "client",
6868
+ auth: "developer_or_client",
6869
+ rateLimited: false,
6870
+ ownerTier: false,
6871
+ status: 200,
6872
+ params: [],
6873
+ query: null,
6874
+ request: null,
6875
+ response: clientIdentity,
6876
+ errors: [...CLIENT_GUARD],
6877
+ transport: "http",
6878
+ notes: "The one route that answers for all three caller kinds \u2014 a developer bearer, an app-user bearer and a server key \u2014 which is why the shape names each of `developer_id`, `end_user_id` and `server_key_id` and fills exactly one."
6879
+ },
6880
+ /* ------------------------------------------------------------- robots */
6881
+ {
6882
+ method: "POST",
6883
+ path: "/api/robots",
6884
+ section: "robots",
6885
+ summary: "Creates a robot and returns its bridge token once.",
6886
+ audience: "developer",
6887
+ auth: "developer",
6888
+ rateLimited: false,
6889
+ ownerTier: false,
6890
+ status: 201,
6891
+ params: [],
6892
+ query: null,
6893
+ request: createRobotRequest,
6894
+ response: createRobotResponse,
6895
+ errors: [...DEVELOPER_GUARD, "validation_error", "quota_exceeded"],
6896
+ transport: "http",
6897
+ 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."
6898
+ },
6899
+ {
6900
+ method: "GET",
6901
+ path: "/api/robots",
6902
+ section: "robots",
6903
+ summary: "Lists the org's robots with their connection state and exposure counts.",
6904
+ audience: "developer",
6905
+ auth: "developer",
6906
+ rateLimited: false,
6907
+ ownerTier: false,
6908
+ status: 200,
6909
+ params: [],
6910
+ query: null,
6911
+ request: null,
6912
+ response: robotListResponse,
6913
+ errors: [...DEVELOPER_GUARD],
6914
+ transport: "http"
6915
+ },
6916
+ {
6917
+ method: "GET",
6918
+ path: "/api/robots/:id",
6919
+ section: "robots",
6920
+ summary: "Reads one robot with its published configuration state and live bridge state.",
6921
+ audience: "developer",
6922
+ auth: "developer",
6923
+ rateLimited: false,
6924
+ ownerTier: false,
6925
+ status: 200,
6926
+ params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
6927
+ query: null,
6928
+ request: null,
6929
+ response: robotDetailResponse,
6930
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
6931
+ transport: "http",
6932
+ notes: "A robot belonging to another org reads exactly like one that does not exist \u2014 `404`, never a `403`."
6933
+ },
6934
+ {
6935
+ method: "PATCH",
6936
+ path: "/api/robots/:id",
6937
+ section: "robots",
6938
+ summary: "Renames the robot.",
6939
+ audience: "developer",
6940
+ auth: "developer",
6941
+ rateLimited: false,
6942
+ ownerTier: false,
6943
+ status: 200,
6944
+ params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
6945
+ query: null,
6946
+ request: patchRobotRequest,
6947
+ response: null,
6948
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
6949
+ transport: "http",
6950
+ notes: 'Answers `{ "robot": robot }`. The envelope has no schema of its own in contracts; the value is a `robot`. The lookup runs before the body is parsed, so a robot outside the caller\'s org answers `404` whether or not the body was also malformed. Saving the name already held writes nothing and records no audit event.'
6951
+ },
6952
+ {
6953
+ method: "GET",
6954
+ path: "/api/robots/:id/deletion-preview",
6955
+ section: "robots",
6956
+ summary: "Reports what deleting the robot would destroy, without destroying it.",
6957
+ audience: "developer",
6958
+ auth: "developer",
6959
+ rateLimited: false,
6960
+ ownerTier: false,
6961
+ status: 200,
6962
+ params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
6963
+ query: null,
6964
+ request: null,
6965
+ response: robotDeletionSummary,
6966
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
6967
+ transport: "http",
6968
+ 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 \u2014 a robot that kept recording in between \u2014 rather than two estimates that quietly disagree."
6969
+ },
6970
+ {
6971
+ method: "DELETE",
6972
+ path: "/api/robots/:id",
6973
+ section: "robots",
6974
+ summary: "Deletes a robot and everything it produced.",
6975
+ audience: "developer",
6976
+ auth: "developer",
6977
+ rateLimited: false,
6978
+ ownerTier: true,
6979
+ status: 204,
6980
+ params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
6981
+ query: null,
6982
+ request: null,
6983
+ response: null,
6984
+ errors: [...DEVELOPER_GUARD, "tier_required", "invalid_uuid", "not_found", "robot_in_use", "robot_deletion_partial"],
6985
+ transport: "http",
6986
+ 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 a robot outside their org, rather than a tier refusal that confirms the id exists. A full cascade \u2014 everything the robot produced goes, except the audit trail, which is a record of what happened and must survive the thing it happened to. An open live session is `409 robot_in_use` unless `?force=true` is passed, matched as the bare string so the caller has to actually say it. A cascade that fails partway is `500 robot_deletion_partial` with the progress, never a bare `internal_error` that would read as "nothing happened".'
6987
+ },
6988
+ {
6989
+ method: "PUT",
6990
+ path: "/api/robots/:id/details",
6991
+ section: "robots",
6992
+ summary: "Replaces the developer-maintained details document shown alongside the robot.",
6993
+ audience: "developer",
6994
+ auth: "developer",
6995
+ rateLimited: false,
6996
+ ownerTier: false,
6997
+ status: 200,
6998
+ params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
6999
+ query: null,
7000
+ request: putRobotDetailsRequest,
7001
+ response: null,
7002
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
7003
+ transport: "http",
7004
+ notes: 'Answers `{ "details": robotDetailsDoc }`. The envelope has no schema of its own in contracts. The update is fanned out to every `/realtime` subscriber of the `robot_details` built-in, so a client watching the robot sees the new document without polling.'
7005
+ },
7006
+ {
7007
+ method: "GET",
7008
+ path: "/api/robots/:id/datapoints",
7009
+ section: "robots",
7010
+ summary: "Lists the datapoints of a robot, filtered to what the caller's role grants.",
7011
+ audience: "client",
7012
+ auth: "developer_or_client",
7013
+ rateLimited: false,
7014
+ ownerTier: false,
7015
+ status: 200,
7016
+ params: [{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." }],
7017
+ query: null,
7018
+ request: null,
7019
+ response: datapointListResponse,
7020
+ errors: [...CLIENT_GUARD, "invalid_uuid", "not_found"],
7021
+ transport: "http",
7022
+ notes: "A developer bearer sees the robot's whole list unfiltered; an end user or a server key sees only the slugs their role grants, and a robot their app does not attach answers `404` exactly as one that does not exist."
7023
+ },
7024
+ {
7025
+ method: "GET",
7026
+ path: "/api/robots/:id/exposures",
7027
+ section: "robots",
7028
+ summary: "Lists every grantable slug of a robot with its kind \u2014 the material the roles matrix is built from.",
7029
+ audience: "developer",
7030
+ auth: "developer",
7031
+ rateLimited: false,
7032
+ ownerTier: false,
7033
+ status: 200,
7034
+ params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
7035
+ query: null,
7036
+ request: null,
7037
+ response: exposureListResponse,
7038
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
7039
+ transport: "http",
7040
+ notes: "Developer-only: this is what a role *could* be granted, which is a configuration fact rather than something an end user is entitled to enumerate."
7041
+ },
7042
+ {
7043
+ method: "GET",
7044
+ path: "/api/robots/:id/datapoints/:slug",
7045
+ section: "robots",
7046
+ summary: "Reads the latest value of one datapoint.",
7047
+ audience: "client",
7048
+ auth: "developer_or_client",
7049
+ rateLimited: false,
7050
+ ownerTier: false,
7051
+ status: 200,
7052
+ params: [
7053
+ { name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
7054
+ { name: "slug", description: "The datapoint's slug from the published configuration, as listed by `GET /api/robots/:id/datapoints`." }
7055
+ ],
7056
+ query: null,
7057
+ request: null,
7058
+ response: datapointValue,
7059
+ errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "unknown_datapoint", "no_data"],
7060
+ transport: "http",
7061
+ notes: "For a client caller the grant check runs **before** any existence lookup, with no extra query on either path to time: a denied slug and a nonexistent one must be one answer. That is why an ungranted slug is `403 forbidden` while a granted-but-unconfigured one is `404 unknown_datapoint` and a configured one with no sample yet is `404 no_data` \u2014 three facts a caller who is entitled to them needs told apart. The plane built-ins (`bridge_state`, `robot_details`) answer here too, without appearing in any document."
7062
+ },
7063
+ /* ------------------------------------------------- config (draft/publish) */
7064
+ {
7065
+ method: "GET",
7066
+ path: "/api/robots/:id/config/draft",
7067
+ section: "config",
7068
+ summary: "Reads the robot's configuration draft, its author text and its current issues.",
7069
+ audience: "developer",
7070
+ auth: "developer",
7071
+ rateLimited: false,
7072
+ ownerTier: false,
7073
+ status: 200,
7074
+ params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
7075
+ query: null,
7076
+ request: null,
7077
+ response: configDraftResponse,
7078
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
7079
+ transport: "http",
7080
+ notes: "Issues are recomputed on every read and every write, so an editor never has to guess whether it may publish. `doc` is `null` for a draft that is valid YAML but not a Fleetless configuration \u2014 a state the format admits and the publish route refuses."
7081
+ },
7082
+ {
7083
+ method: "PUT",
7084
+ path: "/api/robots/:id/config/draft",
7085
+ section: "config",
7086
+ summary: "Replaces the draft with the author's text and answers the parsed document with its issues.",
7087
+ audience: "developer",
7088
+ auth: "developer",
7089
+ rateLimited: false,
7090
+ ownerTier: false,
7091
+ status: 200,
7092
+ params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
7093
+ query: null,
7094
+ request: putConfigDraftRequest,
7095
+ response: configDraftResponse,
7096
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error", "invalid_yaml", "unstorable_yaml"],
7097
+ transport: "http",
7098
+ notes: "The request carries the **text**, not a document: the author's comments and layout are what a restore has to give back, so the source is what is stored and the document is derived from it. Text that is not YAML at all is `422 invalid_yaml`, and text that parses but cannot be stored \u2014 an anchor cycle, say \u2014 is `422 unstorable_yaml`. A document with schema errors is still stored, because the draft is where a developer works; publishing is where the errors block."
7099
+ },
7100
+ {
7101
+ method: "POST",
7102
+ path: "/api/robots/:id/config/publish",
7103
+ section: "config",
7104
+ summary: "Publishes the draft as an immutable version and sends it to the robot.",
7105
+ audience: "developer",
7106
+ auth: "developer",
7107
+ rateLimited: false,
7108
+ ownerTier: false,
7109
+ status: 200,
7110
+ params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
7111
+ query: null,
7112
+ request: null,
7113
+ response: publishConfigResponse,
7114
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error", "draft_not_a_document"],
7115
+ transport: "http",
7116
+ notes: 'A draft that is valid YAML but not a Fleetless configuration is `422 draft_not_a_document`, carrying every issue rather than the blocking subset \u2014 nothing about that text is publishable, so there is no subset to pick, and the warning naming the checks that could not run is part of reading the list correctly. A document with `severity: "error"` issues is `422 validation_error` with just those. The draft\'s own text travels into the version, so a restore later returns what the author wrote rather than a re-rendering of it.'
7117
+ },
7118
+ {
7119
+ method: "GET",
7120
+ path: "/api/robots/:id/config/versions",
7121
+ section: "config",
7122
+ summary: "Lists the published configuration versions of a robot with their publish times.",
7123
+ audience: "developer",
7124
+ auth: "developer",
7125
+ rateLimited: false,
7126
+ ownerTier: false,
7127
+ status: 200,
7128
+ params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
7129
+ query: null,
7130
+ request: null,
7131
+ response: configVersionsResponse,
7132
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
7133
+ transport: "http"
7134
+ },
7135
+ {
7136
+ method: "GET",
7137
+ path: "/api/robots/:id/config/versions/:v",
7138
+ section: "config",
7139
+ summary: "Reads one published version: its document and the author text it was published from.",
7140
+ audience: "developer",
7141
+ auth: "developer",
7142
+ rateLimited: false,
7143
+ ownerTier: false,
7144
+ status: 200,
7145
+ params: [
7146
+ { name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." },
7147
+ { name: "v", description: "The version number, as listed by `GET /api/robots/:id/config/versions`." }
7148
+ ],
7149
+ query: null,
7150
+ request: null,
7151
+ response: configVersionResponse,
7152
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
7153
+ transport: "http",
7154
+ notes: "A `:v` that is not a version number and one that names no version of this robot are the same `404`; the refusal quotes what the caller actually sent."
7155
+ },
7156
+ {
7157
+ method: "POST",
7158
+ path: "/api/robots/:id/config/versions/:v/restore",
7159
+ section: "config",
7160
+ summary: "Copies a published version back into the draft, text and document both.",
7161
+ audience: "developer",
7162
+ auth: "developer",
7163
+ rateLimited: false,
7164
+ ownerTier: false,
7165
+ status: 200,
7166
+ params: [
7167
+ { name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." },
7168
+ { name: "v", description: "The version number, as listed by `GET /api/robots/:id/config/versions`." }
7169
+ ],
7170
+ query: null,
7171
+ request: null,
7172
+ response: configDraftResponse,
7173
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
7174
+ transport: "http",
7175
+ notes: "**Both halves, not just the document** \u2014 a restore that put back the document alone would hand the author a configuration stripped of every comment they wrote, which is the loss this format exists to prevent. The answer is read off the row that was written, not off the version that was meant to be written. Nothing is published: the restored draft still has to be published to reach the robot."
7176
+ },
7177
+ {
7178
+ method: "POST",
7179
+ path: "/api/robots/:id/config/rename-slug",
7180
+ section: "config",
7181
+ summary: "Renames a slug in the draft and rewrites every role grant and history row that named it.",
7182
+ audience: "developer",
7183
+ auth: "developer",
7184
+ rateLimited: false,
7185
+ ownerTier: false,
7186
+ status: 200,
7187
+ params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
7188
+ query: null,
7189
+ request: renameSlugRequest,
7190
+ response: renameSlugResponse,
7191
+ errors: [
7192
+ ...DEVELOPER_GUARD,
7193
+ "invalid_uuid",
7194
+ "not_found",
7195
+ "validation_error",
7196
+ "draft_not_a_document",
7197
+ "unknown_slug",
7198
+ "reserved_slug",
7199
+ "duplicate_slug",
7200
+ "internal_error"
7201
+ ],
7202
+ transport: "http",
7203
+ notes: "One transaction over three places a slug is written down: the draft document, every app-role grant carrying it, and the recorded history rows. The published configuration is immutable, so `requires_publish` says the rename is not live on the robot yet. A draft that is not a document is `409 draft_not_a_document` \u2014 the same word the usage preview uses for the same state."
7204
+ },
7205
+ {
7206
+ method: "GET",
7207
+ path: "/api/robots/:id/config/slug-usage/:slug",
7208
+ section: "config",
7209
+ summary: "Reports what a rename of one slug would touch, before a developer confirms it.",
7210
+ audience: "developer",
7211
+ auth: "developer",
7212
+ rateLimited: false,
7213
+ ownerTier: false,
7214
+ status: 200,
7215
+ params: [
7216
+ { name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." },
7217
+ { name: "slug", description: "The slug in the **draft** whose blast radius is being previewed." }
7218
+ ],
7219
+ query: null,
7220
+ request: null,
7221
+ response: slugUsageResponse,
7222
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "draft_not_a_document"],
7223
+ transport: "http",
7224
+ notes: "A draft that is valid YAML but not a Fleetless document is refused rather than answered with `alert_count: 0`: the two states are *this slug has no alerts* and *there is no document to ask*, and a zero cannot tell them apart \u2014 it would show a smaller blast radius than the rename actually has. The other three counts are real whatever the draft holds, and a partial answer to a preview whose whole purpose is to be complete is not worth the ambiguity."
7225
+ },
7226
+ /* -------------------------------------------------------------- alerts */
7227
+ {
7228
+ method: "GET",
7229
+ path: "/api/robots/:id/alerts",
7230
+ section: "alerts",
7231
+ summary: "Lists a robot's alerts as defined in its published configuration, joined with their runtime state.",
7232
+ audience: "developer",
7233
+ auth: "developer",
7234
+ rateLimited: false,
7235
+ ownerTier: false,
7236
+ status: 200,
7237
+ params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
7238
+ query: null,
7239
+ request: null,
7240
+ response: alertListResponse,
7241
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
7242
+ transport: "http",
7243
+ notes: "**Read-only, and that is the design.** An alert used to be created, edited and deleted through this file; it is now a key in the published document, which is what makes every change to one versioned, comparable and revertible. The **published** version is read, never the draft: an alert typed but not published is evaluated by nothing, and reporting its state would claim a reading no machine has taken."
7244
+ },
7245
+ {
7246
+ method: "GET",
7247
+ path: "/api/org/alerts",
7248
+ section: "alerts",
7249
+ summary: "Lists every firing alert across the org, with the robot each belongs to.",
7250
+ audience: "developer",
7251
+ auth: "developer",
7252
+ rateLimited: false,
7253
+ ownerTier: false,
7254
+ status: 200,
7255
+ params: [],
7256
+ query: null,
7257
+ request: null,
7258
+ response: orgFiringAlertsResponse,
7259
+ errors: [...DEVELOPER_GUARD, "validation_error"],
7260
+ transport: "http",
7261
+ notes: "`?state=firing` is required and is the only value accepted \u2014 refused rather than silently ignored, because a door with one answer must not advertise a dial. There is no query schema; the parameter is read directly. A firing row whose definition has left the document, or has been disabled, is skipped: it can never be evaluated again, so it can never resolve, and it would otherwise sit in the overview's open-issues tile forever."
7262
+ },
7263
+ /* ------------------------------------------------- robots (introspection) */
7264
+ {
7265
+ method: "GET",
7266
+ path: "/api/robots/:id/introspection",
7267
+ section: "robots",
7268
+ summary: "Reads the cached ROS graph of a robot and whether it is stale.",
7269
+ audience: "developer",
7270
+ auth: "developer",
7271
+ rateLimited: false,
7272
+ ownerTier: false,
7273
+ status: 200,
7274
+ params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
7275
+ query: null,
7276
+ request: null,
7277
+ response: introspectionResponse,
7278
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
7279
+ transport: "http",
7280
+ notes: "A robot that has never been introspected answers `200` with a **`null` body**, not a `404`: an enrichment that has not happened yet is not a missing resource. The response schema describes the non-null case. `stale` is true whenever the bridge is offline \u2014 the snapshot survives a disconnect, since a robot that has never connected is still configurable."
7281
+ },
7282
+ {
7283
+ method: "POST",
7284
+ path: "/api/robots/:id/introspection/refresh",
7285
+ section: "robots",
7286
+ summary: "Asks the robot for a fresh ROS graph, stores it and answers it.",
7287
+ audience: "developer",
7288
+ auth: "developer",
7289
+ rateLimited: false,
7290
+ ownerTier: false,
7291
+ status: 200,
7292
+ params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
7293
+ query: null,
7294
+ request: null,
7295
+ response: introspectionResponse,
7296
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "robot_offline", "bridge_timeout"],
7297
+ transport: "http",
7298
+ notes: "`stale` is `false` by construction here: the graph came from the robot just now. `409 robot_offline` means nothing is connected; `504 bridge_timeout` means something was and did not answer. Anything else is rethrown rather than turned into a tidy status."
7299
+ },
7300
+ {
7301
+ method: "GET",
7302
+ path: "/api/robots/:id/types",
7303
+ section: "robots",
7304
+ summary: "Lists every ROS message type definition stored for the robot.",
7305
+ audience: "developer",
7306
+ auth: "developer",
7307
+ rateLimited: false,
7308
+ ownerTier: false,
7309
+ status: 200,
7310
+ params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
7311
+ query: null,
7312
+ request: null,
7313
+ response: typesResponse,
7314
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
7315
+ transport: "http",
7316
+ notes: "A plain read with no bridge involved \u2014 the robot need not be online."
7317
+ },
7318
+ {
7319
+ method: "POST",
7320
+ path: "/api/robots/:id/types/fetch",
7321
+ section: "robots",
7322
+ summary: "Fetches named message type definitions from the robot and stores them.",
7323
+ audience: "developer",
7324
+ auth: "developer",
7325
+ rateLimited: false,
7326
+ ownerTier: false,
7327
+ status: 200,
7328
+ params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
7329
+ query: null,
7330
+ request: fetchTypesRequest,
7331
+ response: fetchTypesResponse,
7332
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error", "robot_offline", "bridge_timeout"],
7333
+ transport: "http",
7334
+ notes: "`unresolved` names the types the robot could not produce; it is an answer, not a failure, because a graph often references a type whose package is not installed. The robot lookup runs before the body is parsed, so a robot outside the caller's org answers `404` whether or not the body was also malformed."
7335
+ },
7336
+ /* ---------------------------------------------- commands (jobs, publishers) */
7337
+ {
7338
+ method: "GET",
7339
+ path: "/api/robots/:id/jobs",
7340
+ section: "commands",
7341
+ summary: "Reads the current job on every slug of the robot the caller is granted.",
7342
+ audience: "client",
7343
+ auth: "developer_or_client",
7344
+ rateLimited: false,
7345
+ ownerTier: false,
7346
+ status: 200,
7347
+ params: [{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." }],
7348
+ query: null,
7349
+ request: null,
7350
+ response: robotJobsResponse,
7351
+ errors: [...CLIENT_GUARD, "invalid_uuid", "not_found"],
7352
+ transport: "http",
7353
+ notes: "**At most one entry per slug, and not a history endpoint.** The first version answered every job the registry still held \u2014 six rows and four full result payloads after a few minutes of traffic on one robot, unbounded for a robot that has run all day. This reads the one-current-job-per-slug map instead. It exists because the per-slug route alone cannot cover it: a reconciled-but-unminted job, or one left on a slug a republish removed, has no slug-shaped door to be found through."
7354
+ },
7355
+ {
7356
+ method: "GET",
7357
+ path: "/api/robots/:id/jobs/history",
7358
+ section: "commands",
7359
+ summary: "Reads what has run on the robot, newest first, cursor-paged.",
7360
+ audience: "client",
7361
+ auth: "developer_or_client",
7362
+ rateLimited: false,
7363
+ ownerTier: false,
7364
+ status: 200,
7365
+ params: [{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." }],
7366
+ query: jobRunQuery,
7367
+ request: null,
7368
+ response: jobRunListResponse,
7369
+ errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "capability_required", "validation_error"],
7370
+ transport: "http",
7371
+ notes: "Needs the `action_history` capability, and **this route is what makes that switch mean something** \u2014 it was unkeepable while nothing durable recorded what had run. Two residuals worth stating rather than implying away. `history` is a syntactically valid slug and Fastify matches a static segment first, so a robot with a service literally slugged `history` can no longer be **read** through `GET /api/robots/:id/jobs/:slug`; invoking, cancelling and the listing are unaffected. And a run row names its actor by email address, so an end user holding this capability learns which other people have been driving the machine. `robot_id` in the query is shared with the org-wide read; a *different* one here is refused rather than quietly answered about the robot in the path."
7372
+ },
7373
+ {
7374
+ method: "POST",
7375
+ path: "/api/robots/:id/jobs/:slug",
7376
+ section: "commands",
7377
+ summary: "Invokes an action or calls a service on the robot.",
7378
+ audience: "client",
7379
+ auth: "developer_or_client",
7380
+ rateLimited: false,
7381
+ ownerTier: false,
7382
+ status: 202,
7383
+ params: [
7384
+ { name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
7385
+ { name: "slug", description: "The action or service slug from the published configuration; the cloud already knows which kind it is." }
7386
+ ],
7387
+ query: null,
7388
+ request: invokeRequest,
7389
+ response: invokeOrServiceResponse,
7390
+ errors: [
7391
+ ...CLIENT_GUARD,
7392
+ "invalid_uuid",
7393
+ "not_found",
7394
+ "validation_error",
7395
+ "parameter_invalid",
7396
+ "robot_offline",
7397
+ "busy",
7398
+ "bridge_timeout",
7399
+ "internal_error"
7400
+ ],
7401
+ transport: "http",
7402
+ 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."
7403
+ },
7404
+ {
7405
+ method: "GET",
7406
+ path: "/api/robots/:id/jobs/:slug",
7407
+ section: "commands",
7408
+ summary: "Reads the most recent job on one slug.",
7409
+ audience: "client",
7410
+ auth: "developer_or_client",
7411
+ rateLimited: false,
7412
+ ownerTier: false,
7413
+ status: 200,
7414
+ params: [
7415
+ { name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
7416
+ { name: "slug", description: "The action or service slug from the published configuration." }
7417
+ ],
7418
+ query: null,
7419
+ request: null,
7420
+ response: jobResponse,
7421
+ errors: [...CLIENT_GUARD, "invalid_uuid", "not_found"],
7422
+ transport: "http",
7423
+ notes: "`job` is `null` when nothing has ever run on that slug \u2014 an answer, not a `404`."
7424
+ },
7425
+ {
7426
+ method: "POST",
7427
+ path: "/api/robots/:id/jobs/:slug/cancel",
7428
+ section: "commands",
7429
+ summary: "Cancels the job running on one slug.",
7430
+ audience: "client",
7431
+ auth: "developer_or_client",
7432
+ rateLimited: false,
7433
+ ownerTier: false,
7434
+ status: 200,
7435
+ params: [
7436
+ { name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
7437
+ { name: "slug", description: "The action slug from the published configuration; a service slug is refused." }
7438
+ ],
7439
+ query: null,
7440
+ request: cancelRequest,
7441
+ requestOptional: true,
7442
+ response: jobResponse,
7443
+ errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "validation_error", "not_cancellable", "robot_offline"],
7444
+ transport: "http",
7445
+ 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`.'
7446
+ },
7447
+ {
7448
+ method: "POST",
7449
+ path: "/api/robots/:id/publishers/:slug",
7450
+ section: "commands",
7451
+ summary: "Publishes one message onto a configured publisher.",
7452
+ audience: "client",
7453
+ auth: "developer_or_client",
7454
+ rateLimited: false,
7455
+ ownerTier: false,
7456
+ status: 204,
7457
+ params: [
7458
+ { name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
7459
+ { name: "slug", description: "The publisher slug from the published configuration." }
7460
+ ],
7461
+ query: null,
7462
+ request: publishRequest,
7463
+ response: null,
7464
+ errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "validation_error", "parameter_invalid", "robot_offline", "publisher_busy"],
7465
+ transport: "http",
7466
+ notes: "Fire and forget \u2014 not a job, so there is nothing to poll and nothing to cancel. A publisher is held exclusively by one caller until it has been quiet long enough, and another caller meanwhile is `409 publisher_busy` with the timeout and a retry hint. Parameters are checked before offline and before exclusivity, the same order the invoke path uses and for the same reason. The **acquisition** is audited, not every message: auditing only takeovers left the single-operator case with no record of who was driving at all."
7467
+ },
7468
+ /* ------------------------------------------------------------- cameras */
7469
+ {
7470
+ method: "GET",
7471
+ path: "/api/robots/:id/cameras",
7472
+ section: "cameras",
7473
+ summary: "Lists the cameras of a robot, filtered to what the caller's role grants.",
7474
+ audience: "client",
7475
+ auth: "developer_or_client",
7476
+ rateLimited: false,
7477
+ ownerTier: false,
7478
+ status: 200,
7479
+ params: [{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." }],
7480
+ query: null,
7481
+ request: null,
7482
+ response: cameraListResponse,
7483
+ errors: [...CLIENT_GUARD, "invalid_uuid", "not_found"],
7484
+ transport: "http"
7485
+ },
7486
+ {
7487
+ method: "GET",
7488
+ path: "/api/robots/:id/cameras/:slug/snapshot",
7489
+ section: "cameras",
7490
+ summary: "Returns the most recent snapshot frame as image bytes.",
7491
+ audience: "client",
7492
+ auth: "developer_or_client",
7493
+ rateLimited: false,
7494
+ ownerTier: false,
7495
+ status: 200,
7496
+ params: [
7497
+ { name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
7498
+ { name: "slug", description: "The camera slug from the published configuration, as listed by `GET /api/robots/:id/cameras`." }
7499
+ ],
7500
+ query: null,
7501
+ request: null,
7502
+ response: null,
7503
+ errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "no_snapshot_yet"],
7504
+ transport: "http",
7505
+ notes: 'Image bytes, not JSON, so it has no response schema; the age, capture time and dimensions ride in the `x-fleetless-*` headers `SNAPSHOT_HEADERS` names \u2014 which a browser can only read because CORS exposes them. **Never checks whether the bridge is online**: a snapshot read is a pure cache read, which is what makes "the last frame, with its real age" true for free across a disconnect. There is nothing here to refuse, and `age_ms` carries the whole honesty story. `cache-control: no-store`, because a picture of someone\'s premises does not belong on disk longer than the request that fetched it.'
7506
+ },
7507
+ {
7508
+ method: "GET",
7509
+ path: "/api/robots/:id/cameras/:slug/snapshot/meta",
7510
+ section: "cameras",
7511
+ summary: "Reports the age and dimensions of the latest snapshot without downloading it.",
7512
+ audience: "client",
7513
+ auth: "developer_or_client",
7514
+ rateLimited: false,
7515
+ ownerTier: false,
7516
+ status: 200,
7517
+ params: [
7518
+ { name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
7519
+ { name: "slug", description: "The camera slug from the published configuration, as listed by `GET /api/robots/:id/cameras`." }
7520
+ ],
7521
+ query: null,
7522
+ request: null,
7523
+ response: snapshotMetaResponse,
7524
+ errors: [...CLIENT_GUARD, "invalid_uuid", "not_found"],
7525
+ transport: "http",
7526
+ notes: 'Exists so a client polling at the camera\'s own interval does not re-fetch a whole frame merely to learn whether a newer one arrived. Nothing captured yet is **nulls, not a `404`**: "nothing yet" is an answer.'
7527
+ },
7528
+ {
7529
+ method: "POST",
7530
+ path: "/api/robots/:id/cameras/:slug/live",
7531
+ section: "cameras",
7532
+ summary: "Takes a hold on a live camera stream and returns a room token.",
7533
+ audience: "client",
7534
+ auth: "developer_or_client",
7535
+ rateLimited: false,
7536
+ ownerTier: false,
7537
+ status: 201,
7538
+ params: [
7539
+ { name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
7540
+ { name: "slug", description: "The camera slug from the published configuration, as listed by `GET /api/robots/:id/cameras`." }
7541
+ ],
7542
+ query: null,
7543
+ request: null,
7544
+ response: liveSessionResponse,
7545
+ errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "robot_offline", "camera_offline", "live_unavailable"],
7546
+ transport: "http",
7547
+ notes: "Refcounted: the first viewer starts the robot publishing and the last release stops it. No token is ever minted for an ungranted or offline camera \u2014 both refusals return before the hold is taken. `409 camera_offline` means the **robot itself** reported the failure; `502 live_unavailable` means this cloud could not start the stream. The difference matters, and it is why a failure the robot named is never dressed up as one this side invented."
7548
+ },
7549
+ {
7550
+ method: "DELETE",
7551
+ path: "/api/robots/:id/cameras/:slug/live",
7552
+ section: "cameras",
7553
+ summary: "Releases a live hold, one session or all of this caller's.",
7554
+ audience: "client",
7555
+ auth: "developer_or_client",
7556
+ rateLimited: false,
7557
+ ownerTier: false,
7558
+ status: 204,
7559
+ params: [
7560
+ { name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
7561
+ { name: "slug", description: "The camera slug from the published configuration, as listed by `GET /api/robots/:id/cameras`." }
7562
+ ],
7563
+ query: releaseLiveQuery,
7564
+ request: null,
7565
+ response: null,
7566
+ errors: [...CLIENT_GUARD, "invalid_uuid", "not_found"],
7567
+ transport: "http",
7568
+ notes: "`?session_id=` releases that one hold; omitting it releases every hold this caller's identity has on this camera, which a client that lost its id \u2014 or a tab that is already closing \u2014 still needs. A malformed `session_id` is `400 invalid_uuid`, never a silent fallback to the blunt form, which would strand this identity's other tabs over a typo. A named-and-unknown id is `404`; a stale one, real and already ended, is idempotently `204`."
7569
+ },
7570
+ /* -------------------------------------------------------- robots (history) */
7571
+ {
7572
+ method: "GET",
7573
+ path: "/api/robots/:id/datapoints/:slug/history",
7574
+ section: "robots",
7575
+ summary: "Reads recorded samples of one datapoint, or aggregated buckets over a window.",
7576
+ audience: "client",
7577
+ auth: "developer_or_client",
7578
+ rateLimited: false,
7579
+ ownerTier: false,
7580
+ status: 200,
7581
+ params: [
7582
+ { name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
7583
+ { name: "slug", description: "The datapoint's slug from the published configuration." }
7584
+ ],
7585
+ query: historyQuery,
7586
+ request: null,
7587
+ response: historyResponse,
7588
+ errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "validation_error", "invalid_range", "not_recorded", "not_aggregatable"],
7589
+ transport: "http",
7590
+ notes: "Two answers, carried by one union (`historyResponse`): without `window` it is a `historySamplesResponse`, with one it is a `historyBucketsResponse`, told apart by `kind`. `window` and `agg` must be given together or not at all \u2014 one without the other is refused rather than defaulted, since a silently chosen aggregation is a chart that lies quietly. A range and window that would produce more buckets than `limit` is `400 invalid_range` computed **before** the query runs: the bucket response carries no `truncated` field, so a refusal is the only honest answer. `409 not_recorded` says retention is off for this slug **right now** and deliberately does not claim the table is empty \u2014 rows written before the switch was flipped still exist, unreadable through any route and still counting against the quota."
7591
+ },
7592
+ /* ------------------------------------------------------ assets (URDF, meshes) */
7593
+ {
7594
+ method: "GET",
7595
+ path: "/api/robots/:id/assets",
7596
+ section: "assets",
7597
+ summary: "Lists the robot's synced assets and how complete its URDF is.",
7598
+ audience: "client",
7599
+ auth: "developer_or_client",
7600
+ rateLimited: false,
7601
+ ownerTier: false,
7602
+ status: 200,
7603
+ params: [{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." }],
7604
+ query: null,
7605
+ request: null,
7606
+ response: assetListResponse,
7607
+ errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "capability_required"],
7608
+ transport: "http",
7609
+ notes: "Needs the `assets` capability, refused as `403 capability_required` rather than a bare `forbidden`: the code says a capability is missing and the message says which, so a developer who switched the wrong toggle on is told what to switch. The capability is checked before existence, so a denied robot and an absent one read alike to a caller with no right to tell them apart. `urdf` reports whether a URDF is present and which of its mesh references have no stored asset."
7610
+ },
7611
+ {
7612
+ method: "GET",
7613
+ path: "/api/robots/:id/assets/:assetId",
7614
+ section: "assets",
7615
+ summary: "Returns one stored asset as bytes.",
7616
+ audience: "client",
7617
+ auth: "developer_or_client",
7618
+ rateLimited: false,
7619
+ ownerTier: false,
7620
+ status: 200,
7621
+ params: [
7622
+ { name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
7623
+ { name: "assetId", description: "The asset's uuid, as listed by `GET /api/robots/:id/assets`." }
7624
+ ],
7625
+ query: null,
7626
+ request: null,
7627
+ response: null,
7628
+ errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "capability_required", "internal_error"],
7629
+ transport: "http",
7630
+ notes: 'Bytes in the asset\'s own media type, so it has no response schema. A row whose blob has vanished from object storage is a logged `500 internal_error`, not a `404`: the asset exists and this cloud could not read it, which is a different fact from "there is no such asset".'
7631
+ },
7632
+ {
7633
+ method: "GET",
7634
+ path: "/api/robots/:id/urdf",
7635
+ section: "assets",
7636
+ summary: "Returns the robot's URDF with every mesh reference rewritten to a Fleetless URL.",
7637
+ audience: "client",
7638
+ auth: "developer_or_client",
7639
+ rateLimited: false,
7640
+ ownerTier: false,
7641
+ status: 200,
7642
+ params: [{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." }],
7643
+ query: null,
7644
+ request: null,
7645
+ response: null,
7646
+ errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "capability_required", "internal_error"],
7647
+ transport: "http",
7648
+ notes: "XML, so no response schema. **Every `filename` is rewritten, not only a resolvable `package://` one** \u2014 an absolute URL that arrived in a URDF from ROS graph input must never be served through untouched, because a mesh loader attaches the caller's bearer token to whatever absolute URL it is handed. Anything with no stored asset points at `GET /api/robots/:id/assets/missing` instead. A robot with no synced URDF is `404`."
7649
+ },
7650
+ {
7651
+ method: "GET",
7652
+ path: "/api/robots/:id/assets/missing",
7653
+ section: "assets",
7654
+ summary: "The placeholder a rewritten URDF points at for a mesh Fleetless does not hold.",
7655
+ audience: "client",
7656
+ auth: "developer_or_client",
7657
+ rateLimited: false,
7658
+ ownerTier: false,
7659
+ status: 404,
7660
+ params: [{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." }],
7661
+ query: null,
7662
+ request: null,
7663
+ response: null,
7664
+ errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "capability_required", "asset_missing"],
7665
+ transport: "http",
7666
+ notes: "**This route has no success answer** \u2014 `404 asset_missing` naming the unresolved reference is what it exists to give, and `status` says so rather than declaring a `200` no caller can ever receive. `?name=` is echoed into the message and is read directly, with no query schema; it discloses nothing, since it is what the caller sent. It carries the same `assets` capability gate as the real bytes would: a missing-asset placeholder is not an exemption from the authorization the thing it stands in for needs."
7667
+ },
7668
+ {
7669
+ method: "GET",
7670
+ path: "/api/asset-links/missing",
7671
+ section: "assets",
7672
+ summary: "The bearer-free placeholder a *linked* URDF points at for an unresolvable mesh.",
7673
+ audience: "client",
7674
+ auth: "in_handler",
7675
+ rateLimited: false,
7676
+ ownerTier: false,
7677
+ status: 404,
7678
+ params: [],
7679
+ query: null,
7680
+ request: null,
7681
+ response: null,
7682
+ errors: ["asset_missing"],
7683
+ transport: "http",
7684
+ notes: "**No success answer either**, for the reason its authenticated twin has none. Unauthenticated by design and unauthenticated in fact: it reads nothing and reveals nothing the caller did not put in the query string itself, so there is no credential for the handler to verify and none is required. It sits under the signed-link prefix because that is where a linked URDF's references have to point."
7685
+ },
7686
+ {
7687
+ method: "GET",
7688
+ path: "/api/asset-links/:token",
7689
+ section: "assets",
7690
+ summary: "Serves one asset, or a rendered URDF, to whoever holds a signed link.",
7691
+ audience: "client",
7692
+ auth: "in_handler",
7693
+ rateLimited: false,
7694
+ ownerTier: false,
7695
+ status: 200,
7696
+ params: [{ name: "token", description: "The signed, time-limited link an MCP tool minted; it is the whole credential." }],
7697
+ query: null,
7698
+ request: null,
7699
+ response: null,
7700
+ errors: ["not_found", "internal_error"],
7701
+ transport: "http",
7702
+ notes: "**The token is the authorization** \u2014 there is no route guard on purpose, and verifying it is the whole gate. An MCP session token is refused on REST by design, so the asset tools mint a fifteen-minute signed link instead and this spends it. The capability was checked at mint against the minting caller's own access; the residual \u2014 whoever holds the URL reads that asset until it expires \u2014 is named rather than closed by a second gate, which would be a different policy for one decision. Every refusal collapses into one `404` with one message, including a malformed id inside a validly signed token, because a link holder has no business learning which of them it was. A URDF served this way has **its own references minted as links**, back-dated so they expire with the parent \u2014 otherwise spending a link in its last second would hand out another fifteen minutes, and each of those another. `cache-control: no-store`, since the URL itself is the credential."
7703
+ },
7704
+ {
7705
+ method: "POST",
7706
+ path: "/api/robots/:id/assets/sync",
7707
+ section: "assets",
7708
+ summary: "Asks the robot to upload its URDF and meshes, and returns the sync id.",
7709
+ audience: "client",
7710
+ auth: "developer_or_client",
7711
+ rateLimited: false,
7712
+ ownerTier: true,
7713
+ status: 202,
7714
+ params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
7715
+ query: null,
7716
+ request: assetSyncRequest,
7717
+ response: assetSyncResponse,
7718
+ errors: [...CLIENT_GUARD, "tier_required", "invalid_uuid", "validation_error", "not_found", "robot_offline", "busy"],
7719
+ transport: "http",
7720
+ notes: "Owner tier, unconditionally. The guard admits an end user or a server key, but only a developer session gets past the handler \u2014 and the body is parsed **before** that `401`, because this route has always answered a malformed body first and the order has to survive. The request is strict: a caller naming a source that does not exist learns so, instead of silently getting a bridge sync they did not ask for. A robot that has reported nothing available to sync is `404`. A second sync is `409 busy` naming the `sync_id` that is actually running, so the caller who pressed the button twice can pick it straight up."
7721
+ },
7722
+ {
7723
+ method: "GET",
7724
+ path: "/api/robots/:id/assets/sync/:syncId",
7725
+ section: "assets",
7726
+ summary: "Reports how far an asset sync has got.",
7727
+ audience: "client",
7728
+ auth: "developer_or_client",
7729
+ rateLimited: false,
7730
+ ownerTier: false,
7731
+ status: 200,
7732
+ params: [
7733
+ { name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." },
7734
+ { name: "syncId", description: "The sync id from `POST /api/robots/:id/assets/sync`, or from its `409 busy` refusal." }
7735
+ ],
7736
+ query: null,
7737
+ request: null,
7738
+ response: assetSyncStatus,
7739
+ errors: [...CLIENT_GUARD, "invalid_uuid", "not_found"],
7740
+ transport: "http",
7741
+ 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."
7742
+ },
7743
+ /* ------------------------------------------------ org (quotas and fleet reads) */
7744
+ {
7745
+ method: "GET",
7746
+ path: "/api/org/quotas",
7747
+ section: "org",
7748
+ summary: "Reports every quota's limit next to what the org is currently using.",
7749
+ audience: "developer",
7750
+ auth: "developer",
7751
+ rateLimited: false,
7752
+ ownerTier: false,
7753
+ status: 200,
7754
+ params: [],
7755
+ query: null,
7756
+ request: null,
7757
+ response: orgQuotaUsage,
7758
+ errors: [...DEVELOPER_GUARD],
7759
+ transport: "http",
7760
+ notes: "Every dial is read at the moment of the call and nothing is cached, so an exhausted quota is self-evident from this one answer rather than something a developer needs audit access to discover. `max_end_users` counts app users only \u2014 an org admin is not an app user, and counting the whole pool would report the Owner an org has by construction as consumption."
7761
+ },
7762
+ {
7763
+ method: "GET",
7764
+ path: "/api/org/health",
7765
+ section: "org",
7766
+ summary: "Reports the health of every camera and streaming resource across the org.",
7767
+ audience: "developer",
7768
+ auth: "developer",
7769
+ rateLimited: false,
7770
+ ownerTier: false,
7771
+ status: 200,
7772
+ params: [],
7773
+ query: null,
7774
+ request: null,
7775
+ response: resourceHealthListResponse,
7776
+ errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
7777
+ transport: "http",
7778
+ notes: "`?robot_id=` narrows it to one robot; there is no query schema, the parameter is read directly. Org-wide rather than per-robot because the console shows health on the robot list too, and a per-robot path would make that N requests to render one screen. This is the snapshot half of the channel; the live half is the `/realtime` socket."
7779
+ },
7780
+ {
7781
+ method: "GET",
7782
+ path: "/api/org/jobs",
7783
+ section: "org",
7784
+ summary: "Reads durable job-run history across the org, newest first, cursor-paged.",
7785
+ audience: "developer",
7786
+ auth: "developer",
7787
+ rateLimited: false,
7788
+ ownerTier: false,
7789
+ status: 200,
7790
+ params: [],
7791
+ query: jobRunQuery,
7792
+ request: null,
7793
+ response: jobRunListResponse,
7794
+ errors: [...DEVELOPER_GUARD, "validation_error"],
7795
+ transport: "http",
7796
+ notes: "Developer-only, and that is a property of the scope: a run row names the actor who invoked it, so a client-facing version would tell one end user which others have been driving the machine. Page until the cursor is null, not until a page looks short. A malformed `robot_id` is refused by the query schema as a `validation_error`; an unknown but well-formed one is an empty list, never a `404` \u2014 it is a filter."
7797
+ },
7798
+ {
7799
+ method: "GET",
7800
+ path: "/api/org/jobs/summary",
7801
+ section: "org",
7802
+ summary: "Counts the running, started and failed job runs since a moment the caller names.",
7803
+ audience: "developer",
7804
+ auth: "developer",
7805
+ rateLimited: false,
7806
+ ownerTier: false,
7807
+ status: 200,
7808
+ params: [],
7809
+ query: jobRunSummaryQuery,
7810
+ request: null,
7811
+ response: jobRunSummary,
7812
+ errors: [...DEVELOPER_GUARD, "validation_error"],
7813
+ transport: "http",
7814
+ notes: '`since_ms` is required and has no default: which day "today" is, only the browser knows, and a cloud that chose its own boundary would show a developer in another timezone a number they cannot reproduce. The window is echoed back so a rendered tile can say what it is describing.'
7815
+ },
7816
+ {
7817
+ method: "GET",
7818
+ path: "/api/org/latency",
7819
+ section: "org",
7820
+ summary: "Reads one-minute bridge latency buckets per robot over a window the caller names.",
7821
+ audience: "developer",
7822
+ auth: "developer",
7823
+ rateLimited: false,
7824
+ ownerTier: false,
7825
+ status: 200,
7826
+ params: [],
7827
+ query: orgLatencyQuery,
7828
+ request: null,
7829
+ response: orgLatencyResponse,
7830
+ errors: [...DEVELOPER_GUARD, "validation_error"],
7831
+ transport: "http",
7832
+ notes: 'Both bounds are required: the table holds a bucket per robot per minute, so "everything" is thousands of rows per robot and a default window would be a query size chosen by whoever forgot to pass one. `from_ms < to_ms` is a cross-field rule the published JSON Schema cannot express, so this route is the only place it is enforced. `truncated` costs whole robots off the end of the id order, not the tail of every series \u2014 narrow the window or name a `robot_id`.'
7833
+ },
7834
+ {
7835
+ method: "GET",
7836
+ path: "/api/org/usage",
7837
+ section: "org",
7838
+ summary: "Reads what the org consumed per day, per app and per metric.",
7839
+ audience: "developer",
7840
+ auth: "developer",
7841
+ rateLimited: false,
7842
+ ownerTier: false,
7843
+ status: 200,
7844
+ params: [],
7845
+ query: orgUsageQuery,
7846
+ request: null,
7847
+ response: orgUsageResponse,
7848
+ errors: [...DEVELOPER_GUARD, "validation_error"],
7849
+ transport: "http",
7850
+ notes: "A window longer than `USAGE_WINDOW_MAX_DAYS` is refused naming the field, not silently capped: a caller who asked for more than the platform will answer is owed a refusal, not a shorter answer they will mistake for the whole picture. `from_day <= to_day` is a cross-field rule no JSON Schema can express and is enforced here. The window is echoed back."
7851
+ },
7852
+ /* ------------------------------------------------- assets (robot upload) */
7853
+ {
7854
+ method: "POST",
7855
+ path: "/api/bridge/assets",
7856
+ section: "assets",
7857
+ summary: "Takes one asset file from a robot during a sync.",
7858
+ audience: "internal",
7859
+ auth: "robot_upload",
7860
+ rateLimited: false,
7861
+ ownerTier: false,
7862
+ status: 201,
7863
+ params: [],
7864
+ query: null,
7865
+ request: null,
7866
+ response: asset,
7867
+ errors: ["unauthorized", "rate_limited", "asset_too_large", "validation_error", "not_found", "quota_exceeded", "bad_request"],
7868
+ transport: "http",
7869
+ 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, Fastify'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."
7870
+ },
7871
+ /* ------------------------------------ realtime and bridge transports */
7872
+ {
7873
+ method: "GET",
7874
+ path: "/bridge",
7875
+ section: "transports",
7876
+ summary: "The robot bridge's WebSocket: the versioned bridge protocol, not a client-facing surface.",
7877
+ audience: "internal",
7878
+ auth: "none",
7879
+ rateLimited: false,
7880
+ ownerTier: false,
7881
+ status: 101,
7882
+ params: [],
7883
+ query: null,
7884
+ request: null,
7885
+ response: null,
7886
+ errors: ["protocol_mismatch", "invalid_token"],
7887
+ transport: "websocket"
7888
+ },
7889
+ {
7890
+ method: "GET",
7891
+ path: "/realtime",
7892
+ section: "transports",
7893
+ summary: "The client WebSocket: subscriptions on datapoints, jobs, bridge state and presence, plus full command parity with REST.",
7894
+ audience: "client",
7895
+ auth: "none",
7896
+ rateLimited: false,
7897
+ ownerTier: false,
7898
+ status: 101,
7899
+ params: [],
7900
+ query: null,
7901
+ request: null,
7902
+ response: null,
7903
+ errors: ["invalid_token", "rate_limited"],
7904
+ transport: "websocket",
7905
+ notes: "Authentication happens in the first frame, not on the upgrade. The frame types are the `realtime` schemas."
7906
+ }
7907
+ ];
7908
+
4488
7909
  // src/pkce.ts
4489
7910
  function requireCrypto() {
4490
7911
  const c = globalThis.crypto;
@@ -5391,9 +8812,14 @@ function createSlugSubscriptions(channel) {
5391
8812
  // src/token-store.ts
5392
8813
  var InMemoryTokenStore = class {
5393
8814
  #session = null;
8815
+ /** Creates an empty store. Nothing is loaded from anywhere — a client built with it starts logged out. */
8816
+ constructor() {
8817
+ }
8818
+ /** Returns the session held in memory, or `null` if there is none. */
5394
8819
  load() {
5395
8820
  return this.#session;
5396
8821
  }
8822
+ /** Replaces the session held in memory; `null` clears it. */
5397
8823
  save(session) {
5398
8824
  this.#session = session;
5399
8825
  }