@fleetless/sdk 2.0.2 → 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/CHANGELOG.md +17 -0
- package/dist/index.cjs +3700 -274
- package/dist/index.d.cts +560 -327
- package/dist/index.d.ts +560 -327
- package/dist/index.js +3700 -274
- package/package.json +3 -2
package/dist/index.cjs
CHANGED
|
@@ -44,9 +44,21 @@ var SDK_ERROR_CODES = [
|
|
|
44
44
|
"aborted"
|
|
45
45
|
];
|
|
46
46
|
var FleetlessError = class extends Error {
|
|
47
|
+
/** What went wrong, as a stable string — the field to branch on. */
|
|
47
48
|
code;
|
|
49
|
+
/**
|
|
50
|
+
* Structured detail the server sent with the refusal, if any: the
|
|
51
|
+
* violations behind `parameter_invalid`, the running job behind `busy`,
|
|
52
|
+
* `retry_after_ms` behind `rate_limited`. Parse it rather than assume
|
|
53
|
+
* its shape — `parameterInvalidDetails` is exported for exactly that.
|
|
54
|
+
*/
|
|
48
55
|
details;
|
|
56
|
+
/** The HTTP status of the response that produced this error, if it came from one. */
|
|
49
57
|
status;
|
|
58
|
+
/**
|
|
59
|
+
* Builds an error. `code` is what a caller branches on and `message` is
|
|
60
|
+
* for a human; anything else the refusal carried goes in `options`.
|
|
61
|
+
*/
|
|
50
62
|
constructor(code, message, options = {}) {
|
|
51
63
|
super(message);
|
|
52
64
|
this.name = "FleetlessError";
|
|
@@ -112,7 +124,7 @@ var HttpClient = class {
|
|
|
112
124
|
this.#credentials = credentials;
|
|
113
125
|
}
|
|
114
126
|
/**
|
|
115
|
-
*
|
|
127
|
+
* Calling `request()` with one of `RequestBodyByRoute`'s
|
|
116
128
|
* literal paths requires `options.body` to match that route's contract
|
|
117
129
|
* type exactly, via `RequestOptionsFor<P>` below.
|
|
118
130
|
*
|
|
@@ -132,7 +144,7 @@ var HttpClient = class {
|
|
|
132
144
|
* RequestOptionsFor<P>` would type-check by construction — the cast
|
|
133
145
|
* bypasses the very check this exists to add, which is the identical
|
|
134
146
|
* "special case that quietly exempts calls from the general rule" shape
|
|
135
|
-
*
|
|
147
|
+
* this file has already been caught by once. Every current call site to a route
|
|
136
148
|
* with no body already passes `{}` explicitly for exactly this reason.
|
|
137
149
|
*/
|
|
138
150
|
async request(path, options) {
|
|
@@ -149,7 +161,7 @@ var HttpClient = class {
|
|
|
149
161
|
}
|
|
150
162
|
/**
|
|
151
163
|
* Like `request`, but for an endpoint that answers with raw bytes instead
|
|
152
|
-
* of a JSON body (a camera snapshot
|
|
164
|
+
* of a JSON body (a camera snapshot) — same auth attachment, same
|
|
153
165
|
* single retry on `token_expired`, same `FleetlessError` on a non-2xx
|
|
154
166
|
* response; only what a *successful* response is made of differs, so both
|
|
155
167
|
* methods share `#send` rather than duplicating that logic.
|
|
@@ -169,7 +181,7 @@ var HttpClient = class {
|
|
|
169
181
|
return this.#baseUrl;
|
|
170
182
|
}
|
|
171
183
|
/**
|
|
172
|
-
* `/oauth/token` (RFC 6749
|
|
184
|
+
* `/oauth/token` (RFC 6749 sections 3.2 and 5), and nothing else — deliberately not
|
|
173
185
|
* routed through `request()`/`#send()`, because every difference from
|
|
174
186
|
* those follows from one fact: this is not our own API.
|
|
175
187
|
*
|
|
@@ -186,7 +198,7 @@ var HttpClient = class {
|
|
|
186
198
|
* OAuth/`apiError` split is "by audience, not by accident"; folding this
|
|
187
199
|
* into `request()`'s error handling would be the identical mistake one
|
|
188
200
|
* level down. A failure here answers `{ error, error_description }`
|
|
189
|
-
* (RFC 6749
|
|
201
|
+
* (RFC 6749 section 5.2), never `{ code, message }`. `error` becomes this
|
|
190
202
|
* `FleetlessError`'s `.code` directly — an OAuth error code (e.g.
|
|
191
203
|
* `invalid_grant`) IS the stable, branch-on value here, exactly as
|
|
192
204
|
* `apiError`'s `code` is for every other route.
|
|
@@ -486,7 +498,7 @@ function createAssetsApi(http) {
|
|
|
486
498
|
};
|
|
487
499
|
}
|
|
488
500
|
|
|
489
|
-
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-
|
|
501
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/common.js
|
|
490
502
|
var import_zod = require("zod");
|
|
491
503
|
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.";
|
|
492
504
|
var slug = import_zod.z.string().min(2).max(63).regex(/^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/, SLUG_RULE);
|
|
@@ -510,7 +522,7 @@ var applyError = import_zod.z.object({
|
|
|
510
522
|
details: import_zod.z.record(import_zod.z.string(), import_zod.z.unknown()).optional()
|
|
511
523
|
});
|
|
512
524
|
|
|
513
|
-
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-
|
|
525
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/mcp.js
|
|
514
526
|
var import_zod2 = require("zod");
|
|
515
527
|
var mcpToolKind = import_zod2.z.enum(["datapoint", "service", "action", "publisher", "camera"]);
|
|
516
528
|
var mcpExposure = import_zod2.z.object({
|
|
@@ -545,16 +557,18 @@ var mcpRolePreviewResponse = import_zod2.z.object({
|
|
|
545
557
|
});
|
|
546
558
|
var MCP_ASSET_LINK_TTL_MS = 15 * 60 * 1e3;
|
|
547
559
|
|
|
548
|
-
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-
|
|
560
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/protocol.js
|
|
549
561
|
var import_zod8 = require("zod");
|
|
550
562
|
|
|
551
|
-
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-
|
|
563
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/assets.js
|
|
552
564
|
var import_zod3 = require("zod");
|
|
553
565
|
var assetKind = import_zod3.z.enum(["urdf", "mesh", "texture", "other"]);
|
|
554
566
|
var asset = import_zod3.z.object({
|
|
555
|
-
id: import_zod3.z.uuid(),
|
|
556
|
-
robot_id: import_zod3.z.uuid(),
|
|
557
|
-
kind: assetKind
|
|
567
|
+
id: import_zod3.z.uuid().meta({ description: "The asset's id in the store." }),
|
|
568
|
+
robot_id: import_zod3.z.uuid().meta({ description: "The robot this asset belongs to." }),
|
|
569
|
+
kind: assetKind.meta({
|
|
570
|
+
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."
|
|
571
|
+
}),
|
|
558
572
|
/**
|
|
559
573
|
* What the robot called it — for a mesh, the `package://` URI the URDF
|
|
560
574
|
* references, verbatim. That is the only string a developer can match
|
|
@@ -589,9 +603,15 @@ var asset = import_zod3.z.object({
|
|
|
589
603
|
* Two identical rules, one of which is enforced and one of which is
|
|
590
604
|
* documented, is how W7's traversal happened in the first place.
|
|
591
605
|
*/
|
|
592
|
-
name: import_zod3.z.string().min(1).max(500)
|
|
593
|
-
|
|
594
|
-
|
|
606
|
+
name: import_zod3.z.string().min(1).max(500).meta({
|
|
607
|
+
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."
|
|
608
|
+
}),
|
|
609
|
+
media_type: import_zod3.z.string().min(1).max(120).meta({
|
|
610
|
+
description: "The media type of the stored bytes, as the producer reported it."
|
|
611
|
+
}),
|
|
612
|
+
size_bytes: import_zod3.z.number().int().nonnegative().meta({
|
|
613
|
+
description: "How large the stored file is, in bytes."
|
|
614
|
+
}),
|
|
595
615
|
/**
|
|
596
616
|
* The content hash, and the reason two robots sharing a mesh cost one copy.
|
|
597
617
|
*
|
|
@@ -600,14 +620,20 @@ var asset = import_zod3.z.object({
|
|
|
600
620
|
* that re-downloads an identical arm for every robot in a fleet is the
|
|
601
621
|
* predictable failure of a store that hides it.
|
|
602
622
|
*/
|
|
603
|
-
sha256: import_zod3.z.string().regex(/^[a-f0-9]{64}$/)
|
|
604
|
-
|
|
623
|
+
sha256: import_zod3.z.string().regex(/^[a-f0-9]{64}$/).meta({
|
|
624
|
+
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.'
|
|
625
|
+
}),
|
|
626
|
+
created_at: import_zod3.z.iso.datetime().meta({
|
|
627
|
+
description: "When the asset was first stored, as an ISO 8601 timestamp."
|
|
628
|
+
})
|
|
605
629
|
});
|
|
606
630
|
var urdfCompleteness = import_zod3.z.object({
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
mesh_count: import_zod3.z.number().int().nonnegative()
|
|
631
|
+
present: import_zod3.z.boolean().meta({
|
|
632
|
+
description: "Whether a URDF has been synced at all. Whether one *could* be synced is a different question, answered by `urdf_available`."
|
|
633
|
+
}),
|
|
634
|
+
mesh_count: import_zod3.z.number().int().nonnegative().meta({
|
|
635
|
+
description: "How many distinct meshes the URDF references."
|
|
636
|
+
}),
|
|
611
637
|
/**
|
|
612
638
|
* **Was fehlt, und wovon (W9b, DEF-081).**
|
|
613
639
|
*
|
|
@@ -623,20 +649,34 @@ var urdfCompleteness = import_zod3.z.object({
|
|
|
623
649
|
* Herleitung derselben Tatsache, die von der ersten abweichen kann.
|
|
624
650
|
*/
|
|
625
651
|
missing: import_zod3.z.array(import_zod3.z.object({
|
|
626
|
-
uri: import_zod3.z.string().min(1).max(500)
|
|
627
|
-
|
|
628
|
-
|
|
652
|
+
uri: import_zod3.z.string().min(1).max(500).meta({
|
|
653
|
+
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."
|
|
654
|
+
}),
|
|
655
|
+
element: import_zod3.z.enum(["mesh", "texture"]).meta({
|
|
656
|
+
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."
|
|
657
|
+
})
|
|
658
|
+
})).meta({
|
|
659
|
+
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."
|
|
660
|
+
})
|
|
629
661
|
});
|
|
630
662
|
var assetSyncRequest = import_zod3.z.object({
|
|
631
|
-
source: import_zod3.z.enum(["bridge"])
|
|
663
|
+
source: import_zod3.z.enum(["bridge"]).meta({
|
|
664
|
+
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."
|
|
665
|
+
})
|
|
632
666
|
}).strict();
|
|
633
667
|
var assetSyncResponse = import_zod3.z.object({
|
|
634
|
-
sync_id: import_zod3.z.uuid()
|
|
668
|
+
sync_id: import_zod3.z.uuid().meta({
|
|
669
|
+
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."
|
|
670
|
+
})
|
|
635
671
|
});
|
|
636
672
|
var ASSET_UPLOAD_MAX_BYTES = 64 * 1024 * 1024;
|
|
637
673
|
var assetTooLargeDetails = import_zod3.z.object({
|
|
638
|
-
limit_bytes: import_zod3.z.number().int().positive()
|
|
639
|
-
|
|
674
|
+
limit_bytes: import_zod3.z.number().int().positive().meta({
|
|
675
|
+
description: "The upload ceiling, in bytes."
|
|
676
|
+
}),
|
|
677
|
+
size_bytes: import_zod3.z.number().int().positive().meta({
|
|
678
|
+
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.'
|
|
679
|
+
})
|
|
640
680
|
});
|
|
641
681
|
var assetFailureKind = import_zod3.z.enum(["unresolvable", "upload_failed", "refused", "too_large"]);
|
|
642
682
|
var assetFailure = import_zod3.z.object({
|
|
@@ -647,8 +687,12 @@ var assetFailure = import_zod3.z.object({
|
|
|
647
687
|
* is `URDF_ASSET_NAME`, which is **not** a mesh URI: a consumer rendering
|
|
648
688
|
* this list must not assume every entry is one.
|
|
649
689
|
*/
|
|
650
|
-
reference: import_zod3.z.string().min(1).max(500)
|
|
651
|
-
|
|
690
|
+
reference: import_zod3.z.string().min(1).max(500).meta({
|
|
691
|
+
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."
|
|
692
|
+
}),
|
|
693
|
+
kind: assetFailureKind.meta({
|
|
694
|
+
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`."
|
|
695
|
+
}),
|
|
652
696
|
/**
|
|
653
697
|
* **Die zwei Zahlen, und warum `too_large` eine eigene Art ist (W9b).**
|
|
654
698
|
*
|
|
@@ -668,7 +712,9 @@ var assetFailure = import_zod3.z.object({
|
|
|
668
712
|
* beschrieben: ein Feld, dessen Regel nur im Kommentar steht, ist eine
|
|
669
713
|
* Bitte.
|
|
670
714
|
*/
|
|
671
|
-
details: assetTooLargeDetails.nullish()
|
|
715
|
+
details: assetTooLargeDetails.nullish().meta({
|
|
716
|
+
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."
|
|
717
|
+
})
|
|
672
718
|
}).superRefine((f, ctx) => {
|
|
673
719
|
if (f.kind === "too_large" && f.details == null) {
|
|
674
720
|
ctx.addIssue({ code: "custom", path: ["details"], message: "`too_large` without limit_bytes/size_bytes says nothing a developer can act on" });
|
|
@@ -679,11 +725,17 @@ var assetFailure = import_zod3.z.object({
|
|
|
679
725
|
});
|
|
680
726
|
var assetSyncState = import_zod3.z.enum(["running", "succeeded", "failed"]);
|
|
681
727
|
var assetSyncStatus = import_zod3.z.object({
|
|
682
|
-
sync_id: import_zod3.z.uuid(),
|
|
683
|
-
robot_id: import_zod3.z.uuid(),
|
|
684
|
-
state: assetSyncState
|
|
685
|
-
|
|
686
|
-
|
|
728
|
+
sync_id: import_zod3.z.uuid().meta({ description: "The sync this status describes." }),
|
|
729
|
+
robot_id: import_zod3.z.uuid().meta({ description: "The robot whose assets are being synced." }),
|
|
730
|
+
state: assetSyncState.meta({
|
|
731
|
+
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`."
|
|
732
|
+
}),
|
|
733
|
+
done: import_zod3.z.number().int().nonnegative().meta({
|
|
734
|
+
description: "How many files have been transferred so far."
|
|
735
|
+
}),
|
|
736
|
+
total: import_zod3.z.number().int().nonnegative().meta({
|
|
737
|
+
description: "How many files this sync set out to transfer. It is `0` until the producer has finished working out what there is."
|
|
738
|
+
}),
|
|
687
739
|
/**
|
|
688
740
|
* **Every entry says *why*, because reconciliation could not work without
|
|
689
741
|
* it and a developer could not read it without it** (W7a review, André's
|
|
@@ -728,14 +780,23 @@ var assetSyncStatus = import_zod3.z.object({
|
|
|
728
780
|
*
|
|
729
781
|
* 1000 x 500 bytes is ~0.5 MiB of names, comfortably inside a 2 MiB frame.
|
|
730
782
|
*/
|
|
731
|
-
failed: import_zod3.z.array(assetFailure).max(1e3)
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
783
|
+
failed: import_zod3.z.array(assetFailure).max(1e3).meta({
|
|
784
|
+
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."
|
|
785
|
+
}),
|
|
786
|
+
reason: import_zod3.z.string().min(1).nullable().meta({
|
|
787
|
+
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."
|
|
788
|
+
}),
|
|
789
|
+
started_at: import_zod3.z.iso.datetime().meta({
|
|
790
|
+
description: "When the sync started, as an ISO 8601 timestamp."
|
|
791
|
+
}),
|
|
792
|
+
updated_at: import_zod3.z.iso.datetime().meta({
|
|
793
|
+
description: "When this status last changed, as an ISO 8601 timestamp. A sync that stops moving is visible here rather than only in `state`."
|
|
794
|
+
})
|
|
736
795
|
});
|
|
737
796
|
var assetListResponse = import_zod3.z.object({
|
|
738
|
-
assets: import_zod3.z.array(asset)
|
|
797
|
+
assets: import_zod3.z.array(asset).meta({
|
|
798
|
+
description: "Every asset stored for this robot: the URDF, the meshes it references, and the textures those paint with."
|
|
799
|
+
}),
|
|
739
800
|
/**
|
|
740
801
|
* Der gerade laufende Sync, oder `null` (W9b, DEF-147).
|
|
741
802
|
*
|
|
@@ -746,8 +807,12 @@ var assetListResponse = import_zod3.z.object({
|
|
|
746
807
|
* die id nicht aufgehoben hatte. Eine Seite, die frisch lädt, drückt keinen
|
|
747
808
|
* Knopf; sie fragt diese Liste. Also muss die Liste es sagen.
|
|
748
809
|
*/
|
|
749
|
-
active_sync: assetSyncStatus.nullable()
|
|
750
|
-
|
|
810
|
+
active_sync: assetSyncStatus.nullable().meta({
|
|
811
|
+
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."
|
|
812
|
+
}),
|
|
813
|
+
urdf: urdfCompleteness.meta({
|
|
814
|
+
description: "Whether the stored URDF can actually be rendered, and what it is still missing. Not the same question as whether one was uploaded."
|
|
815
|
+
}),
|
|
751
816
|
/**
|
|
752
817
|
* What the connected bridge says it *could* transfer, which is deliberately
|
|
753
818
|
* separate from what has been transferred (§4.6: the bridge "meldet nur
|
|
@@ -775,7 +840,9 @@ var assetListResponse = import_zod3.z.object({
|
|
|
775
840
|
* Rosie-W7a closing it, and this comment was still describing the gap a wave
|
|
776
841
|
* after it was fixed (Momus-W7a, W7a review).
|
|
777
842
|
*/
|
|
778
|
-
urdf_available: import_zod3.z.boolean().nullable()
|
|
843
|
+
urdf_available: import_zod3.z.boolean().nullable().meta({
|
|
844
|
+
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.'
|
|
845
|
+
})
|
|
779
846
|
});
|
|
780
847
|
var assetSyncBusyDetails = import_zod3.z.object({
|
|
781
848
|
sync_id: import_zod3.z.uuid(),
|
|
@@ -783,10 +850,10 @@ var assetSyncBusyDetails = import_zod3.z.object({
|
|
|
783
850
|
started_at_ms: import_zod3.z.number().int().nonnegative()
|
|
784
851
|
});
|
|
785
852
|
|
|
786
|
-
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-
|
|
853
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/config.js
|
|
787
854
|
var import_zod5 = require("zod");
|
|
788
855
|
|
|
789
|
-
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-
|
|
856
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/alerts.js
|
|
790
857
|
var import_zod4 = require("zod");
|
|
791
858
|
var alertRowCondition = import_zod4.z.discriminatedUnion("kind", [
|
|
792
859
|
import_zod4.z.strictObject({
|
|
@@ -851,7 +918,7 @@ var putDatapointDisplayRequest = import_zod4.z.object({
|
|
|
851
918
|
y_max: import_zod4.z.number().finite().nullable()
|
|
852
919
|
}).strict();
|
|
853
920
|
|
|
854
|
-
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-
|
|
921
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/config.js
|
|
855
922
|
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:`.";
|
|
856
923
|
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:`.";
|
|
857
924
|
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.";
|
|
@@ -1906,7 +1973,7 @@ var configState = import_zod5.z.object({
|
|
|
1906
1973
|
applied_errors: import_zod5.z.array(applyError).nullable()
|
|
1907
1974
|
});
|
|
1908
1975
|
|
|
1909
|
-
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-
|
|
1976
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/introspection.js
|
|
1910
1977
|
var import_zod6 = require("zod");
|
|
1911
1978
|
var rosGraphEntry = import_zod6.z.object({
|
|
1912
1979
|
name: rosName,
|
|
@@ -1945,16 +2012,26 @@ var typeDefinition = import_zod6.z.discriminatedUnion("kind", [
|
|
|
1945
2012
|
})
|
|
1946
2013
|
]);
|
|
1947
2014
|
|
|
1948
|
-
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-
|
|
2015
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/jobs.js
|
|
1949
2016
|
var import_zod7 = require("zod");
|
|
1950
2017
|
var jobState = import_zod7.z.enum(["running", "succeeded", "failed", "cancelled", "lost"]);
|
|
1951
2018
|
var job = import_zod7.z.object({
|
|
1952
|
-
id: import_zod7.z.uuid()
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
2019
|
+
id: import_zod7.z.uuid().meta({
|
|
2020
|
+
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."
|
|
2021
|
+
}),
|
|
2022
|
+
robot_id: import_zod7.z.uuid().meta({ description: "The robot this job is running on." }),
|
|
2023
|
+
slug: slug.meta({
|
|
2024
|
+
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."
|
|
2025
|
+
}),
|
|
2026
|
+
state: jobState.meta({
|
|
2027
|
+
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."
|
|
2028
|
+
}),
|
|
2029
|
+
started_at: import_zod7.z.iso.datetime().meta({
|
|
2030
|
+
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."
|
|
2031
|
+
}),
|
|
2032
|
+
updated_at: import_zod7.z.iso.datetime().meta({
|
|
2033
|
+
description: "When this job last changed, as an ISO 8601 timestamp."
|
|
2034
|
+
}),
|
|
1958
2035
|
/**
|
|
1959
2036
|
* A monotonic counter, ascending in mint order (W7), and the **named**
|
|
1960
2037
|
* tiebreaker for any listing that claims an order.
|
|
@@ -1971,9 +2048,12 @@ var job = import_zod7.z.object({
|
|
|
1971
2048
|
* orders jobs that coexist in one registry — and stated, because a reader
|
|
1972
2049
|
* who assumed `auditEvent.seq`'s durable semantics would be wrong.
|
|
1973
2050
|
*/
|
|
1974
|
-
seq: import_zod7.z.number().int().positive()
|
|
1975
|
-
|
|
1976
|
-
|
|
2051
|
+
seq: import_zod7.z.number().int().positive().meta({
|
|
2052
|
+
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."
|
|
2053
|
+
}),
|
|
2054
|
+
result: import_zod7.z.unknown().nullable().meta({
|
|
2055
|
+
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."
|
|
2056
|
+
}),
|
|
1977
2057
|
/**
|
|
1978
2058
|
* Present on `failed`; a human message, plus a code where one exists.
|
|
1979
2059
|
*
|
|
@@ -1992,10 +2072,18 @@ var job = import_zod7.z.object({
|
|
|
1992
2072
|
* `jobQueueFullDetails` — it belongs here, not in the sentence.
|
|
1993
2073
|
*/
|
|
1994
2074
|
error: import_zod7.z.object({
|
|
1995
|
-
code: import_zod7.z.string().min(1)
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
2075
|
+
code: import_zod7.z.string().min(1).meta({
|
|
2076
|
+
description: "A machine-readable code for the failure, such as `job_queue_full`, where one exists for it."
|
|
2077
|
+
}),
|
|
2078
|
+
message: import_zod7.z.string().min(1).meta({
|
|
2079
|
+
description: "A human-readable sentence saying what went wrong."
|
|
2080
|
+
}),
|
|
2081
|
+
details: import_zod7.z.unknown().optional().meta({
|
|
2082
|
+
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."
|
|
2083
|
+
})
|
|
2084
|
+
}).nullable().meta({
|
|
2085
|
+
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`."
|
|
2086
|
+
})
|
|
1999
2087
|
});
|
|
2000
2088
|
var jobEvent = import_zod7.z.object({
|
|
2001
2089
|
type: import_zod7.z.literal("job"),
|
|
@@ -2025,66 +2113,118 @@ var jobQueueFullDetails = import_zod7.z.object({
|
|
|
2025
2113
|
});
|
|
2026
2114
|
var JOB_RUN_PAGE_MAX = 200;
|
|
2027
2115
|
var jobActor = import_zod7.z.object({
|
|
2028
|
-
kind: import_zod7.z.enum(["developer", "end_user", "server_key"])
|
|
2029
|
-
|
|
2116
|
+
kind: import_zod7.z.enum(["developer", "end_user", "server_key"]).meta({
|
|
2117
|
+
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."
|
|
2118
|
+
}),
|
|
2119
|
+
id: import_zod7.z.uuid().meta({
|
|
2120
|
+
description: "The id of the developer, end user or server key that invoked the run."
|
|
2121
|
+
}),
|
|
2030
2122
|
/**
|
|
2031
2123
|
* The email for a developer or end user, the key's `name` for a server key.
|
|
2032
2124
|
* A display snapshot taken at invoke time: renaming a key afterwards does not
|
|
2033
2125
|
* rewrite history, which is the point of storing it rather than joining.
|
|
2034
2126
|
*/
|
|
2035
|
-
label: import_zod7.z.string().min(1).max(200)
|
|
2127
|
+
label: import_zod7.z.string().min(1).max(200).meta({
|
|
2128
|
+
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."
|
|
2129
|
+
})
|
|
2036
2130
|
});
|
|
2037
2131
|
var jobRunKind = import_zod7.z.enum(["action", "service"]);
|
|
2038
2132
|
var jobRun = import_zod7.z.object({
|
|
2039
|
-
id: import_zod7.z.uuid()
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2133
|
+
id: import_zod7.z.uuid().meta({
|
|
2134
|
+
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."
|
|
2135
|
+
}),
|
|
2136
|
+
robot_id: import_zod7.z.uuid().meta({ description: "The robot the run happened on." }),
|
|
2137
|
+
slug: slug.meta({
|
|
2138
|
+
description: "The action or service that was invoked, as the published configuration exposed it at the time."
|
|
2139
|
+
}),
|
|
2140
|
+
kind: jobRunKind.meta({
|
|
2141
|
+
description: "Whether the slug was an `action` or a `service`."
|
|
2142
|
+
}),
|
|
2143
|
+
state: jobState.meta({
|
|
2144
|
+
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."
|
|
2145
|
+
}),
|
|
2146
|
+
started_at: import_zod7.z.iso.datetime().meta({
|
|
2147
|
+
description: "When the run started, as an ISO 8601 timestamp. Runs are listed and filtered by this instant."
|
|
2148
|
+
}),
|
|
2149
|
+
ended_at: import_zod7.z.iso.datetime().nullable().meta({
|
|
2150
|
+
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."
|
|
2151
|
+
}),
|
|
2152
|
+
duration_ms: import_zod7.z.number().int().nonnegative().nullable().meta({
|
|
2153
|
+
description: 'How long the run took, in milliseconds. `null` while it is still `running`, never `0` standing in for "nothing so far".'
|
|
2154
|
+
}),
|
|
2155
|
+
result: import_zod7.z.unknown().nullable().meta({
|
|
2156
|
+
description: "What the action or service returned once it succeeded, shaped by ROS itself. `null` otherwise."
|
|
2157
|
+
}),
|
|
2158
|
+
error: import_zod7.z.object({
|
|
2159
|
+
code: import_zod7.z.string().min(1).meta({
|
|
2160
|
+
description: "A machine-readable code for the failure, such as `job_queue_full`, where one exists for it."
|
|
2161
|
+
}),
|
|
2162
|
+
message: import_zod7.z.string().min(1).meta({
|
|
2163
|
+
description: "A human-readable sentence saying what went wrong."
|
|
2164
|
+
}),
|
|
2165
|
+
details: import_zod7.z.unknown().optional().meta({
|
|
2166
|
+
description: "The structured payload belonging to `code`, for the codes that document one. Absent for a failure with nothing structured to add."
|
|
2167
|
+
})
|
|
2168
|
+
}).nullable().meta({
|
|
2169
|
+
description: "Why the run failed \u2014 a `message`, a `code` where one exists, and the structured `details` some codes carry. `null` unless it failed."
|
|
2170
|
+
}),
|
|
2171
|
+
actor: jobActor.meta({
|
|
2172
|
+
description: "Who invoked the run, and what they were acting as at the time."
|
|
2173
|
+
}),
|
|
2052
2174
|
/**
|
|
2053
2175
|
* **Durable, unlike `job.seq`.** That one is a per-process counter that
|
|
2054
2176
|
* restarts with the cloud; this is a postgres `bigserial` and is the cursor
|
|
2055
2177
|
* `before_seq` walks.
|
|
2056
2178
|
*/
|
|
2057
|
-
seq: import_zod7.z.number().int().positive()
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2179
|
+
seq: import_zod7.z.number().int().positive().meta({
|
|
2180
|
+
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`."
|
|
2181
|
+
}),
|
|
2182
|
+
progress: import_zod7.z.number().min(0).max(1).nullable().meta({
|
|
2183
|
+
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".'
|
|
2184
|
+
}),
|
|
2185
|
+
feedback: import_zod7.z.unknown().nullable().meta({
|
|
2186
|
+
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."
|
|
2187
|
+
})
|
|
2066
2188
|
});
|
|
2067
2189
|
var jobRunQuery = import_zod7.z.object({
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2190
|
+
before_seq: wireSeqCursor.optional().meta({
|
|
2191
|
+
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."
|
|
2192
|
+
}),
|
|
2193
|
+
limit: import_zod7.z.union([import_zod7.z.string().regex(/^\d{1,4}$/), import_zod7.z.number().int()]).transform((v) => Number(v)).pipe(import_zod7.z.number().int().positive().max(JOB_RUN_PAGE_MAX)).optional().meta({
|
|
2194
|
+
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."
|
|
2195
|
+
}),
|
|
2196
|
+
robot_id: import_zod7.z.uuid().optional().meta({
|
|
2197
|
+
description: "Only runs on this robot. Absent means every robot in the organisation."
|
|
2198
|
+
}),
|
|
2199
|
+
slug: slug.optional().meta({
|
|
2200
|
+
description: "Only runs of this action or service."
|
|
2201
|
+
}),
|
|
2202
|
+
state: jobState.optional().meta({
|
|
2203
|
+
description: "Only runs in this state \u2014 `running`, `succeeded`, `failed`, `cancelled` or `lost`."
|
|
2204
|
+
}),
|
|
2205
|
+
kind: jobRunKind.optional().meta({
|
|
2206
|
+
description: "Only `action` runs, or only `service` runs."
|
|
2207
|
+
}),
|
|
2208
|
+
from_ms: wireTimestampMs.optional().meta({
|
|
2209
|
+
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."
|
|
2210
|
+
}),
|
|
2211
|
+
to_ms: wireTimestampMs.optional().meta({
|
|
2212
|
+
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."
|
|
2213
|
+
})
|
|
2078
2214
|
}).strict();
|
|
2079
2215
|
var jobRunListResponse = import_zod7.z.object({
|
|
2080
|
-
runs: import_zod7.z.array(jobRun)
|
|
2216
|
+
runs: import_zod7.z.array(jobRun).meta({
|
|
2217
|
+
description: "This page of runs, newest first by `seq`. Empty means the filter matched nothing, not that the history is gone."
|
|
2218
|
+
}),
|
|
2081
2219
|
/**
|
|
2082
2220
|
* The `seq` a caller sends as `before_seq` to keep reading — or `null` when
|
|
2083
2221
|
* there is nothing further. **`null` means the end, and that is a promise
|
|
2084
2222
|
* rather than an observation.** A caller who instead compares `runs.length`
|
|
2085
2223
|
* against `limit` is wrong the moment a filter makes a page thin.
|
|
2086
2224
|
*/
|
|
2087
|
-
next_cursor: import_zod7.z.number().int().positive().nullable()
|
|
2225
|
+
next_cursor: import_zod7.z.number().int().positive().nullable().meta({
|
|
2226
|
+
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."
|
|
2227
|
+
})
|
|
2088
2228
|
});
|
|
2089
2229
|
var jobRunSummaryQuery = import_zod7.z.object({ since_ms: wireTimestampMs }).strict();
|
|
2090
2230
|
var jobRunSummary = import_zod7.z.object({
|
|
@@ -2094,7 +2234,7 @@ var jobRunSummary = import_zod7.z.object({
|
|
|
2094
2234
|
since_ms: import_zod7.z.number().int().nonnegative()
|
|
2095
2235
|
});
|
|
2096
2236
|
|
|
2097
|
-
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-
|
|
2237
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/protocol.js
|
|
2098
2238
|
var MAX_PATIENCE_MS = 12e4;
|
|
2099
2239
|
var MIN_PATIENCE_MS = 1e3;
|
|
2100
2240
|
var activeJob = import_zod8.z.object({
|
|
@@ -2513,11 +2653,11 @@ var bridgeCameraState = import_zod8.z.object({
|
|
|
2513
2653
|
request_id: import_zod8.z.string().min(1).max(64).nullable()
|
|
2514
2654
|
});
|
|
2515
2655
|
|
|
2516
|
-
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-
|
|
2656
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/config-issues.js
|
|
2517
2657
|
var EXPOSURE_SECTIONS = ["datapoints", "actions", "services", "publishers", "cameras"];
|
|
2518
2658
|
var EXPOSURE_SECTION_NAMES = new Set(EXPOSURE_SECTIONS);
|
|
2519
2659
|
|
|
2520
|
-
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-
|
|
2660
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/rest.js
|
|
2521
2661
|
var import_zod9 = require("zod");
|
|
2522
2662
|
var robot = import_zod9.z.object({
|
|
2523
2663
|
id: import_zod9.z.uuid(),
|
|
@@ -2549,9 +2689,13 @@ var robotListResponse = import_zod9.z.object({
|
|
|
2549
2689
|
robots: import_zod9.z.array(robotListItem)
|
|
2550
2690
|
});
|
|
2551
2691
|
var datapointValue = import_zod9.z.object({
|
|
2552
|
-
slug,
|
|
2553
|
-
value: import_zod9.z.unknown()
|
|
2554
|
-
|
|
2692
|
+
slug: slug.meta({ description: "The datapoint this value belongs to." }),
|
|
2693
|
+
value: import_zod9.z.unknown().meta({
|
|
2694
|
+
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."
|
|
2695
|
+
}),
|
|
2696
|
+
timestamp_ms: import_zod9.z.number().int().nonnegative().meta({
|
|
2697
|
+
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."
|
|
2698
|
+
})
|
|
2555
2699
|
});
|
|
2556
2700
|
var robotDetailResponse = import_zod9.z.object({
|
|
2557
2701
|
...robotListItem.shape,
|
|
@@ -2607,9 +2751,13 @@ var fetchTypesResponse = import_zod9.z.object({
|
|
|
2607
2751
|
unresolved: import_zod9.z.array(import_zod9.z.string())
|
|
2608
2752
|
});
|
|
2609
2753
|
var datapointDescriptor = import_zod9.z.object({
|
|
2610
|
-
slug,
|
|
2611
|
-
builtin: import_zod9.z.boolean()
|
|
2612
|
-
|
|
2754
|
+
slug: slug.meta({ description: "The name a client reads this datapoint by." }),
|
|
2755
|
+
builtin: import_zod9.z.boolean().meta({
|
|
2756
|
+
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."
|
|
2757
|
+
}),
|
|
2758
|
+
unit: import_zod9.z.string().nullable().meta({
|
|
2759
|
+
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."
|
|
2760
|
+
}),
|
|
2613
2761
|
/**
|
|
2614
2762
|
* `null` for a built-in and for a datapoint published with no throttle —
|
|
2615
2763
|
* the same "no ceiling configured" fact `datapointConfig.rate_throttle_hz`
|
|
@@ -2617,15 +2765,21 @@ var datapointDescriptor = import_zod9.z.object({
|
|
|
2617
2765
|
* optional because this shape is a read response, not a document a caller
|
|
2618
2766
|
* writes. Reuses `rateThrottleHz` so the 20 Hz ceiling is written once.
|
|
2619
2767
|
*/
|
|
2620
|
-
rate_throttle_hz: rateThrottleHz.nullable()
|
|
2768
|
+
rate_throttle_hz: rateThrottleHz.nullable().meta({
|
|
2769
|
+
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."
|
|
2770
|
+
})
|
|
2621
2771
|
});
|
|
2622
2772
|
var datapointListResponse = import_zod9.z.object({
|
|
2623
|
-
datapoints: import_zod9.z.array(datapointDescriptor)
|
|
2773
|
+
datapoints: import_zod9.z.array(datapointDescriptor).meta({
|
|
2774
|
+
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."
|
|
2775
|
+
})
|
|
2624
2776
|
});
|
|
2625
2777
|
var robotDetailsDoc = import_zod9.z.record(import_zod9.z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/), import_zod9.z.union([import_zod9.z.string().max(4096), import_zod9.z.number(), import_zod9.z.boolean(), import_zod9.z.array(import_zod9.z.unknown()), import_zod9.z.record(import_zod9.z.string(), import_zod9.z.unknown())]));
|
|
2626
2778
|
var putRobotDetailsRequest = import_zod9.z.object({ details: robotDetailsDoc });
|
|
2627
2779
|
var invokeRequest = import_zod9.z.object({
|
|
2628
|
-
params: import_zod9.z.record(import_zod9.z.string(), import_zod9.z.unknown())
|
|
2780
|
+
params: import_zod9.z.record(import_zod9.z.string(), import_zod9.z.unknown()).meta({
|
|
2781
|
+
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."
|
|
2782
|
+
}),
|
|
2629
2783
|
/**
|
|
2630
2784
|
* How long **this call** is worth waiting for, in milliseconds (W6b).
|
|
2631
2785
|
*
|
|
@@ -2652,31 +2806,52 @@ var invokeRequest = import_zod9.z.object({
|
|
|
2652
2806
|
* *acceptance* — once a goal is accepted the job runs as long as it runs,
|
|
2653
2807
|
* and is observed, not awaited.
|
|
2654
2808
|
*/
|
|
2655
|
-
patience_ms: import_zod9.z.number().int().min(MIN_PATIENCE_MS).max(MAX_PATIENCE_MS).optional()
|
|
2809
|
+
patience_ms: import_zod9.z.number().int().min(MIN_PATIENCE_MS).max(MAX_PATIENCE_MS).optional().meta({
|
|
2810
|
+
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."
|
|
2811
|
+
})
|
|
2656
2812
|
});
|
|
2657
2813
|
var cancelRequest = import_zod9.z.object({
|
|
2658
|
-
job_id: import_zod9.z.uuid().nullable().optional()
|
|
2814
|
+
job_id: import_zod9.z.uuid().nullable().optional().meta({
|
|
2815
|
+
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."
|
|
2816
|
+
})
|
|
2659
2817
|
}).strict();
|
|
2660
2818
|
var releaseLiveQuery = import_zod9.z.object({
|
|
2661
|
-
session_id: import_zod9.z.uuid().optional()
|
|
2819
|
+
session_id: import_zod9.z.uuid().optional().meta({
|
|
2820
|
+
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."
|
|
2821
|
+
})
|
|
2662
2822
|
}).strict();
|
|
2663
2823
|
var invokeResponse = import_zod9.z.object({
|
|
2664
|
-
job
|
|
2824
|
+
job: job.meta({
|
|
2825
|
+
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."
|
|
2826
|
+
}),
|
|
2665
2827
|
/** The slug's kind — see `commandResult.kind` for why the caller needs it. */
|
|
2666
|
-
kind: import_zod9.z.enum(["action", "service"])
|
|
2828
|
+
kind: import_zod9.z.enum(["action", "service"]).meta({
|
|
2829
|
+
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."
|
|
2830
|
+
})
|
|
2667
2831
|
});
|
|
2668
2832
|
var serviceCallResponse = import_zod9.z.object({
|
|
2669
|
-
result: import_zod9.z.unknown()
|
|
2833
|
+
result: import_zod9.z.unknown().meta({
|
|
2834
|
+
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."
|
|
2835
|
+
})
|
|
2670
2836
|
});
|
|
2837
|
+
var invokeOrServiceResponse = import_zod9.z.union([invokeResponse, serviceCallResponse]);
|
|
2671
2838
|
var publishRequest = import_zod9.z.object({
|
|
2672
|
-
message: import_zod9.z.record(import_zod9.z.string(), import_zod9.z.unknown())
|
|
2839
|
+
message: import_zod9.z.record(import_zod9.z.string(), import_zod9.z.unknown()).meta({
|
|
2840
|
+
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."
|
|
2841
|
+
})
|
|
2842
|
+
});
|
|
2843
|
+
var jobResponse = import_zod9.z.object({
|
|
2844
|
+
job: job.nullable().meta({
|
|
2845
|
+
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."
|
|
2846
|
+
})
|
|
2673
2847
|
});
|
|
2674
|
-
var jobResponse = import_zod9.z.object({ job: job.nullable() });
|
|
2675
2848
|
var rateLimitDetails = import_zod9.z.object({
|
|
2676
2849
|
retry_after_ms: import_zod9.z.number().int().nonnegative()
|
|
2677
2850
|
});
|
|
2678
2851
|
var robotJobsResponse = import_zod9.z.object({
|
|
2679
|
-
jobs: import_zod9.z.array(job)
|
|
2852
|
+
jobs: import_zod9.z.array(job).meta({
|
|
2853
|
+
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."
|
|
2854
|
+
})
|
|
2680
2855
|
});
|
|
2681
2856
|
var exposure = import_zod9.z.object({
|
|
2682
2857
|
slug,
|
|
@@ -2693,10 +2868,16 @@ var SNAPSHOT_HEADERS = {
|
|
|
2693
2868
|
height: "x-fleetless-height"
|
|
2694
2869
|
};
|
|
2695
2870
|
var cameraDescriptor = import_zod9.z.object({
|
|
2696
|
-
slug,
|
|
2697
|
-
width: import_zod9.z.number().int().positive()
|
|
2698
|
-
|
|
2699
|
-
|
|
2871
|
+
slug: slug.meta({ description: "The name a client addresses this camera by." }),
|
|
2872
|
+
width: import_zod9.z.number().int().positive().meta({
|
|
2873
|
+
description: "Frame width in pixels, as the published configuration declares it."
|
|
2874
|
+
}),
|
|
2875
|
+
height: import_zod9.z.number().int().positive().meta({
|
|
2876
|
+
description: "Frame height in pixels, as the published configuration declares it."
|
|
2877
|
+
}),
|
|
2878
|
+
fps: import_zod9.z.number().int().positive().meta({
|
|
2879
|
+
description: "How many frames per second the camera is configured to publish while somebody is watching live."
|
|
2880
|
+
}),
|
|
2700
2881
|
/**
|
|
2701
2882
|
* Seconds, as the document spells it, reusing `snapshotIntervalSeconds` so
|
|
2702
2883
|
* the 1–3600 bound is written once. It was `snapshot_interval_ms` after the
|
|
@@ -2704,9 +2885,15 @@ var cameraDescriptor = import_zod9.z.object({
|
|
|
2704
2885
|
* this descriptor and not on `datapointDescriptor` beside it — the same
|
|
2705
2886
|
* drift `rateThrottleHz` was extracted to stop.
|
|
2706
2887
|
*/
|
|
2707
|
-
snapshot_interval_seconds: snapshotIntervalSeconds
|
|
2888
|
+
snapshot_interval_seconds: snapshotIntervalSeconds.meta({
|
|
2889
|
+
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."
|
|
2890
|
+
})
|
|
2891
|
+
});
|
|
2892
|
+
var cameraListResponse = import_zod9.z.object({
|
|
2893
|
+
cameras: import_zod9.z.array(cameraDescriptor).meta({
|
|
2894
|
+
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."
|
|
2895
|
+
})
|
|
2708
2896
|
});
|
|
2709
|
-
var cameraListResponse = import_zod9.z.object({ cameras: import_zod9.z.array(cameraDescriptor) });
|
|
2710
2897
|
var liveSessionResponse = import_zod9.z.object({
|
|
2711
2898
|
/**
|
|
2712
2899
|
* This viewer's hold, and the **only** thing `DELETE` should be given
|
|
@@ -2725,29 +2912,56 @@ var liveSessionResponse = import_zod9.z.object({
|
|
|
2725
2912
|
* is going away entirely, still needs a way to let go. It is the blunt
|
|
2726
2913
|
* form, and it is the one that strands other tabs; new callers pass the id.
|
|
2727
2914
|
*/
|
|
2728
|
-
session_id: import_zod9.z.uuid()
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2915
|
+
session_id: import_zod9.z.uuid().meta({
|
|
2916
|
+
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."
|
|
2917
|
+
}),
|
|
2918
|
+
url: import_zod9.z.string().min(1).meta({
|
|
2919
|
+
description: "The LiveKit server to connect to, as a WebSocket URL."
|
|
2920
|
+
}),
|
|
2921
|
+
room: import_zod9.z.string().min(1).meta({
|
|
2922
|
+
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."
|
|
2923
|
+
}),
|
|
2924
|
+
token: import_zod9.z.string().min(1).meta({
|
|
2925
|
+
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."
|
|
2926
|
+
}),
|
|
2927
|
+
expires_at: import_zod9.z.iso.datetime().meta({
|
|
2928
|
+
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."
|
|
2929
|
+
})
|
|
2733
2930
|
});
|
|
2734
2931
|
var snapshotMetaResponse = import_zod9.z.object({
|
|
2735
|
-
slug,
|
|
2736
|
-
timestamp_ms: import_zod9.z.number().int().nonnegative().nullable()
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2932
|
+
slug: slug.meta({ description: "The camera this snapshot belongs to." }),
|
|
2933
|
+
timestamp_ms: import_zod9.z.number().int().nonnegative().nullable().meta({
|
|
2934
|
+
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."
|
|
2935
|
+
}),
|
|
2936
|
+
age_ms: import_zod9.z.number().int().nonnegative().nullable().meta({
|
|
2937
|
+
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."
|
|
2938
|
+
}),
|
|
2939
|
+
width: import_zod9.z.number().int().positive().nullable().meta({
|
|
2940
|
+
description: "Width of the stored frame in pixels, or `null` when nothing has been captured yet."
|
|
2941
|
+
}),
|
|
2942
|
+
height: import_zod9.z.number().int().positive().nullable().meta({
|
|
2943
|
+
description: "Height of the stored frame in pixels, or `null` when nothing has been captured yet."
|
|
2944
|
+
}),
|
|
2945
|
+
mime: import_zod9.z.string().nullable().meta({
|
|
2946
|
+
description: "The media type of the stored frame, such as `image/jpeg`, or `null` when nothing has been captured yet."
|
|
2947
|
+
})
|
|
2741
2948
|
});
|
|
2742
2949
|
var historyQuery = import_zod9.z.object({
|
|
2743
|
-
from: import_zod9.z.string().min(1).max(32)
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2950
|
+
from: import_zod9.z.string().min(1).max(32).meta({
|
|
2951
|
+
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."
|
|
2952
|
+
}),
|
|
2953
|
+
to: import_zod9.z.string().min(1).max(32).optional().meta({
|
|
2954
|
+
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."
|
|
2955
|
+
}),
|
|
2956
|
+
window: import_zod9.z.string().min(2).max(16).optional().meta({
|
|
2957
|
+
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."
|
|
2958
|
+
}),
|
|
2959
|
+
agg: import_zod9.z.enum(["min", "max", "avg"]).optional().meta({
|
|
2960
|
+
description: "How each bucket reduces the samples inside it. Valid only together with `window`."
|
|
2961
|
+
}),
|
|
2962
|
+
field: import_zod9.z.string().min(1).max(128).optional().meta({
|
|
2963
|
+
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."
|
|
2964
|
+
}),
|
|
2751
2965
|
/**
|
|
2752
2966
|
* **A union whose input branch IS the wire, not a coercion (W9d, DEF-059).**
|
|
2753
2967
|
*
|
|
@@ -2788,13 +3002,28 @@ var historyQuery = import_zod9.z.object({
|
|
|
2788
3002
|
// that is arguably stronger: without it the artifact publishes the full
|
|
2789
3003
|
// safe-integer range, which reads as *nine quadrillion is fine*.
|
|
2790
3004
|
import_zod9.z.number().int().describe("Positive integer, 1-10000. The ceiling is enforced after parsing, not by this type.")
|
|
2791
|
-
]).transform((v) => Number(v)).pipe(import_zod9.z.number().int().positive().max(1e4)).optional()
|
|
3005
|
+
]).transform((v) => Number(v)).pipe(import_zod9.z.number().int().positive().max(1e4)).optional().meta({
|
|
3006
|
+
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."
|
|
3007
|
+
})
|
|
2792
3008
|
});
|
|
2793
3009
|
var historySamplesResponse = import_zod9.z.object({
|
|
2794
|
-
slug,
|
|
2795
|
-
kind: import_zod9.z.literal("samples")
|
|
2796
|
-
|
|
2797
|
-
|
|
3010
|
+
slug: slug.meta({ description: "The datapoint these samples belong to." }),
|
|
3011
|
+
kind: import_zod9.z.literal("samples").meta({
|
|
3012
|
+
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."
|
|
3013
|
+
}),
|
|
3014
|
+
samples: import_zod9.z.array(import_zod9.z.object({
|
|
3015
|
+
timestamp_ms: import_zod9.z.number().int().nonnegative().meta({
|
|
3016
|
+
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."
|
|
3017
|
+
}),
|
|
3018
|
+
value: import_zod9.z.unknown().meta({
|
|
3019
|
+
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."
|
|
3020
|
+
})
|
|
3021
|
+
})).meta({
|
|
3022
|
+
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."
|
|
3023
|
+
}),
|
|
3024
|
+
truncated: import_zod9.z.boolean().meta({
|
|
3025
|
+
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."
|
|
3026
|
+
}),
|
|
2798
3027
|
/**
|
|
2799
3028
|
* Why it was cut, `null` when it was not — because the two causes have
|
|
2800
3029
|
* **different remedies** and a single boolean cannot tell them apart:
|
|
@@ -2813,15 +3042,25 @@ var historySamplesResponse = import_zod9.z.object({
|
|
|
2813
3042
|
* `required` in the JSON Schema artifacts, which is the contradiction this
|
|
2814
3043
|
* project has now hit five times.
|
|
2815
3044
|
*/
|
|
2816
|
-
truncated_by: import_zod9.z.enum(["limit", "bytes"]).nullable()
|
|
3045
|
+
truncated_by: import_zod9.z.enum(["limit", "bytes"]).nullable().meta({
|
|
3046
|
+
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."
|
|
3047
|
+
})
|
|
2817
3048
|
});
|
|
2818
3049
|
var historyBucketsResponse = import_zod9.z.object({
|
|
2819
|
-
slug,
|
|
2820
|
-
kind: import_zod9.z.literal("buckets")
|
|
2821
|
-
|
|
2822
|
-
|
|
3050
|
+
slug: slug.meta({ description: "The datapoint these buckets summarise." }),
|
|
3051
|
+
kind: import_zod9.z.literal("buckets").meta({
|
|
3052
|
+
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."
|
|
3053
|
+
}),
|
|
3054
|
+
window_ms: import_zod9.z.number().int().positive().meta({
|
|
3055
|
+
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."
|
|
3056
|
+
}),
|
|
3057
|
+
agg: import_zod9.z.enum(["min", "max", "avg"]).meta({
|
|
3058
|
+
description: "How each bucket reduced the samples inside it, echoed back from the query."
|
|
3059
|
+
}),
|
|
2823
3060
|
buckets: import_zod9.z.array(import_zod9.z.object({
|
|
2824
|
-
bucket_start_ms: import_zod9.z.number().int().nonnegative()
|
|
3061
|
+
bucket_start_ms: import_zod9.z.number().int().nonnegative().meta({
|
|
3062
|
+
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`."
|
|
3063
|
+
}),
|
|
2825
3064
|
/**
|
|
2826
3065
|
* The aggregate over this bucket's **numeric** samples — or `null` when
|
|
2827
3066
|
* none of them were numeric, which is **not** the same as the bucket
|
|
@@ -2839,7 +3078,9 @@ var historyBucketsResponse = import_zod9.z.object({
|
|
|
2839
3078
|
* so the second row above was indistinguishable from the first and the
|
|
2840
3079
|
* console rendered "empty — no samples" over live data.
|
|
2841
3080
|
*/
|
|
2842
|
-
value: import_zod9.z.number().nullable()
|
|
3081
|
+
value: import_zod9.z.number().nullable().meta({
|
|
3082
|
+
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."
|
|
3083
|
+
}),
|
|
2843
3084
|
/**
|
|
2844
3085
|
* Every sample that landed in this bucket and inside the queried range,
|
|
2845
3086
|
* whether or not it contributed to `value` — which is the point of the
|
|
@@ -2856,9 +3097,14 @@ var historyBucketsResponse = import_zod9.z.object({
|
|
|
2856
3097
|
* and only in-range samples are counted. A low edge count is a
|
|
2857
3098
|
* boundary effect, not a quiet period.
|
|
2858
3099
|
*/
|
|
2859
|
-
sample_count: import_zod9.z.number().int().nonnegative()
|
|
2860
|
-
|
|
3100
|
+
sample_count: import_zod9.z.number().int().nonnegative().meta({
|
|
3101
|
+
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."
|
|
3102
|
+
})
|
|
3103
|
+
})).meta({
|
|
3104
|
+
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."
|
|
3105
|
+
})
|
|
2861
3106
|
});
|
|
3107
|
+
var historyResponse = import_zod9.z.union([historySamplesResponse, historyBucketsResponse]);
|
|
2862
3108
|
var robotDeletionSummary = import_zod9.z.object({
|
|
2863
3109
|
/**
|
|
2864
3110
|
* Datapoints, actions, services and publishers in the **published**
|
|
@@ -3155,13 +3401,13 @@ var slugUsageResponse = import_zod9.z.object({
|
|
|
3155
3401
|
alert_count: import_zod9.z.number().int().nonnegative()
|
|
3156
3402
|
});
|
|
3157
3403
|
|
|
3158
|
-
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-
|
|
3404
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/realtime.js
|
|
3159
3405
|
var import_zod12 = require("zod");
|
|
3160
3406
|
|
|
3161
|
-
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-
|
|
3407
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/client-auth.js
|
|
3162
3408
|
var import_zod11 = require("zod");
|
|
3163
3409
|
|
|
3164
|
-
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-
|
|
3410
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/apps.js
|
|
3165
3411
|
var import_zod10 = require("zod");
|
|
3166
3412
|
var appIdentifier = slug;
|
|
3167
3413
|
var app = import_zod10.z.object({
|
|
@@ -3357,33 +3603,53 @@ var brandingConfig = import_zod10.z.object({
|
|
|
3357
3603
|
footer_text: import_zod10.z.string().min(1).max(200).optional()
|
|
3358
3604
|
});
|
|
3359
3605
|
|
|
3360
|
-
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-
|
|
3606
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/client-auth.js
|
|
3361
3607
|
var clientLoginRequest = import_zod11.z.object({
|
|
3362
|
-
app_identifier: appIdentifier
|
|
3363
|
-
|
|
3364
|
-
|
|
3608
|
+
app_identifier: appIdentifier.meta({
|
|
3609
|
+
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."
|
|
3610
|
+
}),
|
|
3611
|
+
email: import_zod11.z.email().meta({
|
|
3612
|
+
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."
|
|
3613
|
+
}),
|
|
3614
|
+
password: import_zod11.z.string().min(1).meta({
|
|
3615
|
+
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."
|
|
3616
|
+
})
|
|
3365
3617
|
});
|
|
3366
3618
|
var clientRefreshRequest = import_zod11.z.object({
|
|
3367
|
-
refresh_token: import_zod11.z.string().min(1)
|
|
3619
|
+
refresh_token: import_zod11.z.string().min(1).meta({
|
|
3620
|
+
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."
|
|
3621
|
+
})
|
|
3368
3622
|
});
|
|
3369
3623
|
var clientLogoutRequest = import_zod11.z.object({
|
|
3370
|
-
refresh_token: import_zod11.z.string().min(1)
|
|
3624
|
+
refresh_token: import_zod11.z.string().min(1).meta({
|
|
3625
|
+
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."
|
|
3626
|
+
})
|
|
3371
3627
|
});
|
|
3372
3628
|
var clientLogoutResponse = import_zod11.z.object({
|
|
3373
3629
|
idp_logout: import_zod11.z.discriminatedUnion("status", [
|
|
3374
3630
|
import_zod11.z.object({
|
|
3375
|
-
status: import_zod11.z.literal("redirect")
|
|
3376
|
-
|
|
3377
|
-
|
|
3631
|
+
status: import_zod11.z.literal("redirect").meta({
|
|
3632
|
+
description: "The identity provider publishes an `end_session_endpoint` and `url` says where to send the browser to finish logging out there."
|
|
3633
|
+
}),
|
|
3634
|
+
url: import_zod11.z.url().max(2e3).meta({
|
|
3635
|
+
description: "Send the browser here to end the session at the identity provider. Built from the provider's own `end_session_endpoint`."
|
|
3636
|
+
})
|
|
3637
|
+
}),
|
|
3638
|
+
import_zod11.z.object({
|
|
3639
|
+
status: import_zod11.z.literal("not_federated").meta({
|
|
3640
|
+
description: "This session did not come from an identity provider, so there is no second session and nothing further to do."
|
|
3641
|
+
})
|
|
3378
3642
|
}),
|
|
3379
|
-
/** This session did not come from an IdP — there is nothing else to end. */
|
|
3380
|
-
import_zod11.z.object({ status: import_zod11.z.literal("not_federated") }),
|
|
3381
3643
|
/**
|
|
3382
3644
|
* It did, and the IdP's discovery document names no `end_session_endpoint`
|
|
3383
3645
|
* (RP-initiated logout is optional in OIDC). **The IdP session survives and
|
|
3384
3646
|
* this platform cannot end it** — say so rather than implying success.
|
|
3385
3647
|
*/
|
|
3386
|
-
import_zod11.z.object({
|
|
3648
|
+
import_zod11.z.object({
|
|
3649
|
+
status: import_zod11.z.literal("unsupported_by_idp").meta({
|
|
3650
|
+
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.**"
|
|
3651
|
+
})
|
|
3652
|
+
}),
|
|
3387
3653
|
/**
|
|
3388
3654
|
* **Federated, the IdP *can* end the session, and we cannot ask it to
|
|
3389
3655
|
* (W9c, Nimbus-W9c's proposal).**
|
|
@@ -3408,7 +3674,11 @@ var clientLogoutResponse = import_zod11.z.object({
|
|
|
3408
3674
|
* to carry a fact no consumer can act on is how a contract grows keys
|
|
3409
3675
|
* nobody reads.
|
|
3410
3676
|
*/
|
|
3411
|
-
import_zod11.z.object({
|
|
3677
|
+
import_zod11.z.object({
|
|
3678
|
+
status: import_zod11.z.literal("hint_unavailable").meta({
|
|
3679
|
+
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."
|
|
3680
|
+
})
|
|
3681
|
+
}),
|
|
3412
3682
|
/**
|
|
3413
3683
|
* **The server does not know this session, so it can say nothing about an
|
|
3414
3684
|
* IdP** (W9 review, Argus-W9; André, 2026-08-19).
|
|
@@ -3428,22 +3698,47 @@ var clientLogoutResponse = import_zod11.z.object({
|
|
|
3428
3698
|
* a `redirect` that is still worth following. Reading this as *"the user is
|
|
3429
3699
|
* fully logged out"* is exactly the mistake a second logout invites.
|
|
3430
3700
|
*/
|
|
3431
|
-
import_zod11.z.object({
|
|
3432
|
-
|
|
3701
|
+
import_zod11.z.object({
|
|
3702
|
+
status: import_zod11.z.literal("session_unknown").meta({
|
|
3703
|
+
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."
|
|
3704
|
+
})
|
|
3705
|
+
})
|
|
3706
|
+
]).meta({
|
|
3707
|
+
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."
|
|
3708
|
+
})
|
|
3433
3709
|
});
|
|
3434
3710
|
var clientIdentity = import_zod11.z.object({
|
|
3435
|
-
kind: import_zod11.z.enum(["developer", "end_user", "server_key"])
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3711
|
+
kind: import_zod11.z.enum(["developer", "end_user", "server_key"]).meta({
|
|
3712
|
+
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."
|
|
3713
|
+
}),
|
|
3714
|
+
developer_id: import_zod11.z.uuid().nullable().meta({
|
|
3715
|
+
description: "The developer behind this session, or `null` when `kind` is not `developer`."
|
|
3716
|
+
}),
|
|
3717
|
+
end_user_id: import_zod11.z.uuid().nullable().meta({
|
|
3718
|
+
description: "The end user behind this session, or `null` when `kind` is not `end_user`."
|
|
3719
|
+
}),
|
|
3720
|
+
server_key_id: import_zod11.z.uuid().nullable().meta({
|
|
3721
|
+
description: "The server key this session was authenticated with, or `null` when `kind` is not `server_key`."
|
|
3722
|
+
}),
|
|
3723
|
+
app_id: import_zod11.z.uuid().nullable().meta({
|
|
3724
|
+
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."
|
|
3725
|
+
}),
|
|
3726
|
+
role_id: import_zod11.z.uuid().nullable().meta({
|
|
3727
|
+
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."
|
|
3728
|
+
}),
|
|
3729
|
+
email: import_zod11.z.email().nullable().meta({
|
|
3730
|
+
description: "The email of the developer or end user behind this session, and `null` for a server key, which is not a person."
|
|
3731
|
+
}),
|
|
3732
|
+
act: import_zod11.z.object({
|
|
3733
|
+
admin_user_id: import_zod11.z.uuid().meta({
|
|
3734
|
+
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.`
|
|
3735
|
+
})
|
|
3736
|
+
}).strict().optional().meta({
|
|
3737
|
+
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."
|
|
3738
|
+
})
|
|
3444
3739
|
});
|
|
3445
3740
|
|
|
3446
|
-
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-
|
|
3741
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/realtime.js
|
|
3447
3742
|
var clientAuth = import_zod12.z.object({
|
|
3448
3743
|
type: import_zod12.z.literal("auth"),
|
|
3449
3744
|
token: import_zod12.z.string().min(1)
|
|
@@ -3731,7 +4026,7 @@ var orgEventDropped = import_zod12.z.object({
|
|
|
3731
4026
|
dropped: import_zod12.z.number().int().positive()
|
|
3732
4027
|
}).strict();
|
|
3733
4028
|
|
|
3734
|
-
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-
|
|
4029
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/identity.js
|
|
3735
4030
|
var import_zod13 = require("zod");
|
|
3736
4031
|
var password = import_zod13.z.string().min(12).max(256);
|
|
3737
4032
|
var GROUP_NAME_MAX = 120;
|
|
@@ -3828,9 +4123,15 @@ var groupListResponse = import_zod13.z.object({ groups: import_zod13.z.array(org
|
|
|
3828
4123
|
var orgUserListResponse = import_zod13.z.object({ users: import_zod13.z.array(orgUser) });
|
|
3829
4124
|
var appAssignmentListResponse = import_zod13.z.object({ assignments: import_zod13.z.array(appAssignment) });
|
|
3830
4125
|
var sessionTokens = import_zod13.z.object({
|
|
3831
|
-
access_token: import_zod13.z.string().min(1)
|
|
3832
|
-
|
|
3833
|
-
|
|
4126
|
+
access_token: import_zod13.z.string().min(1).meta({
|
|
4127
|
+
description: "The token to send as `Authorization: Bearer <token>` on every call. Short-lived: read `expires_in` rather than assuming a lifetime."
|
|
4128
|
+
}),
|
|
4129
|
+
refresh_token: import_zod13.z.string().min(1).meta({
|
|
4130
|
+
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."
|
|
4131
|
+
}),
|
|
4132
|
+
expires_in: import_zod13.z.number().int().positive().meta({
|
|
4133
|
+
description: "How long the access token stays valid, in **seconds** from now. Not a timestamp, and not milliseconds."
|
|
4134
|
+
})
|
|
3834
4135
|
});
|
|
3835
4136
|
var refreshRequest = import_zod13.z.object({ refresh_token: import_zod13.z.string().min(1) });
|
|
3836
4137
|
var signUpRequest = import_zod13.z.object({
|
|
@@ -3930,8 +4231,12 @@ var tierRequiredDetails = import_zod13.z.object({
|
|
|
3930
4231
|
actual: orgAdminTier
|
|
3931
4232
|
});
|
|
3932
4233
|
var passwordChangeRequest = import_zod13.z.object({
|
|
3933
|
-
current_password: import_zod13.z.string().min(1)
|
|
3934
|
-
|
|
4234
|
+
current_password: import_zod13.z.string().min(1).meta({
|
|
4235
|
+
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*."
|
|
4236
|
+
}),
|
|
4237
|
+
new_password: password.meta({
|
|
4238
|
+
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."
|
|
4239
|
+
})
|
|
3935
4240
|
});
|
|
3936
4241
|
var passwordResetRequest = import_zod13.z.object({
|
|
3937
4242
|
email: import_zod13.z.email()
|
|
@@ -4056,7 +4361,7 @@ var authMeResponse = import_zod13.z.object({ org, user: orgUser });
|
|
|
4056
4361
|
var patchOrgRequest = import_zod13.z.object({ name: import_zod13.z.string().min(1).max(120) }).strict();
|
|
4057
4362
|
var patchAuthMeRequest = import_zod13.z.object({ display_name: import_zod13.z.string().min(1).max(USER_DISPLAY_NAME_MAX).nullable() }).strict();
|
|
4058
4363
|
|
|
4059
|
-
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-
|
|
4364
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/audit.js
|
|
4060
4365
|
var import_zod14 = require("zod");
|
|
4061
4366
|
var auditActor = import_zod14.z.object({
|
|
4062
4367
|
kind: import_zod14.z.enum(["developer", "end_user", "server_key", "bridge"]),
|
|
@@ -4203,7 +4508,7 @@ var auditListResponse = import_zod14.z.object({
|
|
|
4203
4508
|
next_cursor: import_zod14.z.number().int().positive().nullable()
|
|
4204
4509
|
});
|
|
4205
4510
|
|
|
4206
|
-
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-
|
|
4511
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/errors.js
|
|
4207
4512
|
var import_zod15 = require("zod");
|
|
4208
4513
|
var apiError = import_zod15.z.object({
|
|
4209
4514
|
code: import_zod15.z.string().min(1),
|
|
@@ -4220,7 +4525,7 @@ var parameterInvalidDetails = import_zod15.z.object({
|
|
|
4220
4525
|
violations: import_zod15.z.array(parameterViolation).min(1)
|
|
4221
4526
|
});
|
|
4222
4527
|
|
|
4223
|
-
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-
|
|
4528
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/oauth.js
|
|
4224
4529
|
var import_zod16 = require("zod");
|
|
4225
4530
|
var oauthErrorCode = import_zod16.z.enum([
|
|
4226
4531
|
"invalid_request",
|
|
@@ -4296,68 +4601,149 @@ var oauthClient = import_zod16.z.object({
|
|
|
4296
4601
|
last_used_at: import_zod16.z.iso.datetime().nullable()
|
|
4297
4602
|
});
|
|
4298
4603
|
var dynamicClientRegistrationRequest = import_zod16.z.object({
|
|
4299
|
-
client_name: import_zod16.z.string().min(1).max(200)
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4604
|
+
client_name: import_zod16.z.string().min(1).max(200).meta({
|
|
4605
|
+
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"*.'
|
|
4606
|
+
}),
|
|
4607
|
+
redirect_uris: import_zod16.z.array(redirectUri).min(1).max(20).meta({
|
|
4608
|
+
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."
|
|
4609
|
+
}),
|
|
4610
|
+
grant_types: import_zod16.z.array(import_zod16.z.enum(["authorization_code", "refresh_token"])).optional().meta({
|
|
4611
|
+
description: "Accepted and echoed back for conformance with RFC 7591. This server issues `authorization_code` and `refresh_token` and nothing else."
|
|
4612
|
+
}),
|
|
4613
|
+
response_types: import_zod16.z.array(import_zod16.z.enum(["code"])).optional().meta({
|
|
4614
|
+
description: "Accepted and echoed back for conformance. `code` is the only response type OAuth 2.1 leaves, the implicit grant having been removed."
|
|
4615
|
+
}),
|
|
4616
|
+
token_endpoint_auth_method: import_zod16.z.enum(["none"]).optional().meta({
|
|
4617
|
+
description: "`none`, RFC 7591's value for a public client. There is no client secret to hold: mandatory PKCE is the defence."
|
|
4618
|
+
}),
|
|
4619
|
+
scope: import_zod16.z.string().max(500).optional().meta({
|
|
4620
|
+
description: "The scopes the client asks to be registered for, space-separated."
|
|
4621
|
+
})
|
|
4307
4622
|
}).strict();
|
|
4308
4623
|
var dynamicClientRegistrationResponse = import_zod16.z.object({
|
|
4309
|
-
client_id: import_zod16.z.string().min(1).max(200)
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4624
|
+
client_id: import_zod16.z.string().min(1).max(200).meta({
|
|
4625
|
+
description: "The identifier this client sends at the authorize and token endpoints. Opaque, and not the app identifier."
|
|
4626
|
+
}),
|
|
4627
|
+
client_name: import_zod16.z.string().min(1).max(200).meta({
|
|
4628
|
+
description: "The name the client registered under, echoed back. Chosen by the client and not vouched for by Fleetless."
|
|
4629
|
+
}),
|
|
4630
|
+
redirect_uris: import_zod16.z.array(redirectUri).meta({
|
|
4631
|
+
description: "The redirect URIs this registration was accepted for. A code is returned to one of these and nowhere else."
|
|
4632
|
+
}),
|
|
4633
|
+
grant_types: import_zod16.z.array(import_zod16.z.string()).meta({
|
|
4634
|
+
description: "The grants this client may use: `authorization_code` and `refresh_token`."
|
|
4635
|
+
}),
|
|
4636
|
+
response_types: import_zod16.z.array(import_zod16.z.string()).meta({
|
|
4637
|
+
description: "The response types this client may ask for: `code`."
|
|
4638
|
+
}),
|
|
4639
|
+
token_endpoint_auth_method: import_zod16.z.literal("none").meta({
|
|
4640
|
+
description: "`none` \u2014 this server registers public clients only, and PKCE rather than a secret is what protects the exchange."
|
|
4641
|
+
}),
|
|
4642
|
+
client_id_issued_at: import_zod16.z.number().int().nonnegative().meta({
|
|
4643
|
+
description: "When the registration was created, in seconds since the epoch, per RFC 7591."
|
|
4644
|
+
}),
|
|
4645
|
+
client_secret_expires_at: import_zod16.z.literal(0).meta({
|
|
4646
|
+
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."
|
|
4647
|
+
})
|
|
4318
4648
|
});
|
|
4319
4649
|
var oauthTokenRequest = import_zod16.z.discriminatedUnion("grant_type", [
|
|
4320
4650
|
import_zod16.z.object({
|
|
4321
|
-
grant_type: import_zod16.z.literal("authorization_code")
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4651
|
+
grant_type: import_zod16.z.literal("authorization_code").meta({
|
|
4652
|
+
description: "This request exchanges the code from the authorize redirect for tokens."
|
|
4653
|
+
}),
|
|
4654
|
+
code: import_zod16.z.string().min(1).max(500).meta({
|
|
4655
|
+
description: "The authorization code from the redirect. It may be exchanged once."
|
|
4656
|
+
}),
|
|
4657
|
+
redirect_uri: redirectUri.meta({
|
|
4658
|
+
description: "The same redirect URI the authorize request used. It is compared, not merely recorded."
|
|
4659
|
+
}),
|
|
4660
|
+
client_id: import_zod16.z.string().min(1).max(200).meta({
|
|
4661
|
+
description: "The client making the exchange, as registered."
|
|
4662
|
+
}),
|
|
4663
|
+
code_verifier: import_zod16.z.string().regex(/^[A-Za-z0-9\-._~]{43,128}$/, "code_verifier must be 43-128 unreserved characters (RFC 7636 \xA74.1)").meta({
|
|
4664
|
+
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."
|
|
4665
|
+
}),
|
|
4666
|
+
resource: import_zod16.z.url().optional().meta({
|
|
4667
|
+
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."
|
|
4668
|
+
})
|
|
4327
4669
|
}),
|
|
4328
4670
|
import_zod16.z.object({
|
|
4329
|
-
grant_type: import_zod16.z.literal("refresh_token")
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4671
|
+
grant_type: import_zod16.z.literal("refresh_token").meta({
|
|
4672
|
+
description: "This request trades a refresh token for a fresh access token."
|
|
4673
|
+
}),
|
|
4674
|
+
refresh_token: import_zod16.z.string().min(1).max(500).meta({
|
|
4675
|
+
description: "The refresh token to spend. Refresh tokens rotate, and presenting one twice is treated as theft rather than as a retry."
|
|
4676
|
+
}),
|
|
4677
|
+
client_id: import_zod16.z.string().min(1).max(200).meta({
|
|
4678
|
+
description: "The client refreshing, as registered."
|
|
4679
|
+
}),
|
|
4680
|
+
resource: import_zod16.z.url().optional().meta({
|
|
4681
|
+
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."
|
|
4682
|
+
}),
|
|
4683
|
+
scope: import_zod16.z.string().max(500).optional().meta({
|
|
4684
|
+
description: "A narrower scope for the successor token. RFC 6749 \xA76 lets a refresh narrow scope, never widen it."
|
|
4685
|
+
})
|
|
4335
4686
|
})
|
|
4336
4687
|
]);
|
|
4337
4688
|
var oauthTokenResponse = import_zod16.z.object({
|
|
4338
|
-
access_token: import_zod16.z.string().min(1)
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4689
|
+
access_token: import_zod16.z.string().min(1).meta({
|
|
4690
|
+
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."
|
|
4691
|
+
}),
|
|
4692
|
+
token_type: import_zod16.z.literal("Bearer").meta({
|
|
4693
|
+
description: "`Bearer`. RFC 6749 \xA75.1 makes the value case-insensitive for a client reading it; this is the spelling this server emits."
|
|
4694
|
+
}),
|
|
4695
|
+
expires_in: import_zod16.z.number().int().positive().meta({
|
|
4696
|
+
description: "How long the access token is valid, in **seconds**, per RFC 6749 \xA75.1. Not a timestamp, and not milliseconds."
|
|
4697
|
+
}),
|
|
4698
|
+
refresh_token: import_zod16.z.string().min(1).optional().meta({
|
|
4699
|
+
description: "The refresh token, when one was issued. It rotates on every use."
|
|
4700
|
+
}),
|
|
4701
|
+
scope: import_zod16.z.string().max(500).optional().meta({
|
|
4702
|
+
description: "The scopes the issued token actually carries, space-separated."
|
|
4703
|
+
})
|
|
4344
4704
|
});
|
|
4345
4705
|
var authorizationServerMetadata = import_zod16.z.object({
|
|
4346
|
-
issuer: import_zod16.z.url()
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4706
|
+
issuer: import_zod16.z.url().meta({
|
|
4707
|
+
description: "The issuer identifier of this authorization server, per RFC 8414 \xA72. It is what a client checks a token's `iss` against."
|
|
4708
|
+
}),
|
|
4709
|
+
authorization_endpoint: import_zod16.z.url().meta({
|
|
4710
|
+
description: "The URL a client sends the user to in order to authorize."
|
|
4711
|
+
}),
|
|
4712
|
+
token_endpoint: import_zod16.z.url().meta({
|
|
4713
|
+
description: "The URL where a client exchanges an authorization code, or a refresh token, for tokens."
|
|
4714
|
+
}),
|
|
4715
|
+
registration_endpoint: import_zod16.z.url().optional().meta({
|
|
4716
|
+
description: "The URL where a client may register itself, per RFC 7591. Absent when the app does not accept dynamic clients."
|
|
4717
|
+
}),
|
|
4718
|
+
response_types_supported: import_zod16.z.array(import_zod16.z.literal("code")).meta({
|
|
4719
|
+
description: "The response types this server offers: `code` only, the implicit grant being gone with OAuth 2.1."
|
|
4720
|
+
}),
|
|
4721
|
+
grant_types_supported: import_zod16.z.array(import_zod16.z.enum(["authorization_code", "refresh_token"])).meta({
|
|
4722
|
+
description: "The grants this server offers. OAuth 2.1 removes the implicit and password grants, so neither appears here."
|
|
4723
|
+
}),
|
|
4724
|
+
code_challenge_methods_supported: import_zod16.z.array(codeChallengeMethod).meta({
|
|
4725
|
+
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."
|
|
4726
|
+
}),
|
|
4727
|
+
token_endpoint_auth_methods_supported: import_zod16.z.array(import_zod16.z.literal("none")).meta({
|
|
4728
|
+
description: "How a client authenticates at the token endpoint: `none`, the public-client method, with PKCE protecting the exchange."
|
|
4729
|
+
}),
|
|
4730
|
+
scopes_supported: import_zod16.z.array(import_zod16.z.string()).optional().meta({
|
|
4731
|
+
description: "The scopes this server knows about, where it publishes a list."
|
|
4732
|
+
})
|
|
4355
4733
|
});
|
|
4356
4734
|
var protectedResourceMetadata = import_zod16.z.object({
|
|
4357
|
-
resource: import_zod16.z.url()
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4735
|
+
resource: import_zod16.z.url().meta({
|
|
4736
|
+
description: "The resource identifier this document describes, per RFC 9728. A token whose audience names something else is rejected here rather than merely noted."
|
|
4737
|
+
}),
|
|
4738
|
+
authorization_servers: import_zod16.z.array(import_zod16.z.url()).min(1).meta({
|
|
4739
|
+
description: "The authorization servers that may issue tokens for this resource. There is always at least one."
|
|
4740
|
+
}),
|
|
4741
|
+
bearer_methods_supported: import_zod16.z.array(import_zod16.z.literal("header")).meta({
|
|
4742
|
+
description: "How a token may be presented: in the `Authorization` header only, never in a query parameter or a form field."
|
|
4743
|
+
}),
|
|
4744
|
+
scopes_supported: import_zod16.z.array(import_zod16.z.string()).optional().meta({
|
|
4745
|
+
description: "The scopes this resource understands, where it publishes a list."
|
|
4746
|
+
})
|
|
4361
4747
|
});
|
|
4362
4748
|
var consentGrant = import_zod16.z.object({
|
|
4363
4749
|
client_id: import_zod16.z.string().min(1).max(200),
|
|
@@ -4368,23 +4754,41 @@ var consentGrant = import_zod16.z.object({
|
|
|
4368
4754
|
granted_at: import_zod16.z.iso.datetime()
|
|
4369
4755
|
});
|
|
4370
4756
|
var consentGrantSummary = import_zod16.z.object({
|
|
4371
|
-
client_id: import_zod16.z.string().min(1).max(200)
|
|
4757
|
+
client_id: import_zod16.z.string().min(1).max(200).meta({
|
|
4758
|
+
description: "The client this grant is for, and what a revocation addresses. The names beside it are for reading; this is for acting."
|
|
4759
|
+
}),
|
|
4372
4760
|
/**
|
|
4373
4761
|
* The client's own declared name. **Not trusted, and the console/page must
|
|
4374
4762
|
* not render it as if Fleetless vouched for it** — a self-registered client
|
|
4375
4763
|
* chooses this string, and W7c already measured what that buys: one called
|
|
4376
4764
|
* itself *"Fleetless Official Helper"*.
|
|
4377
4765
|
*/
|
|
4378
|
-
client_name: import_zod16.z.string().min(1).max(200)
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4766
|
+
client_name: import_zod16.z.string().min(1).max(200).meta({
|
|
4767
|
+
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"*.`
|
|
4768
|
+
}),
|
|
4769
|
+
app_id: import_zod16.z.uuid().meta({
|
|
4770
|
+
description: "The app the grant was given within. A consent recorded against a client alone would be reusable for a different app."
|
|
4771
|
+
}),
|
|
4772
|
+
app_name: import_zod16.z.string().min(1).max(120).meta({
|
|
4773
|
+
description: "The app's name as it stood on the consent screen."
|
|
4774
|
+
}),
|
|
4775
|
+
role_id: import_zod16.z.uuid().meta({
|
|
4776
|
+
description: "The role that tokens issued under this grant carry, and therefore the limit of what the client can reach."
|
|
4777
|
+
}),
|
|
4778
|
+
role_name: import_zod16.z.string().min(1).max(120).meta({
|
|
4779
|
+
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."
|
|
4780
|
+
}),
|
|
4781
|
+
scope: import_zod16.z.string().max(500).meta({
|
|
4782
|
+
description: "The scopes consented to, space-separated."
|
|
4783
|
+
}),
|
|
4784
|
+
granted_at: import_zod16.z.iso.datetime().meta({
|
|
4785
|
+
description: "When consent was given, as an ISO 8601 timestamp."
|
|
4786
|
+
})
|
|
4385
4787
|
});
|
|
4386
4788
|
var consentGrantListResponse = import_zod16.z.object({
|
|
4387
|
-
grants: import_zod16.z.array(consentGrantSummary).max(200)
|
|
4789
|
+
grants: import_zod16.z.array(consentGrantSummary).max(200).meta({
|
|
4790
|
+
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."
|
|
4791
|
+
}),
|
|
4388
4792
|
/**
|
|
4389
4793
|
* **`true` heisst: es gibt mehr, und diese Antwort zeigt sie nicht** (W9
|
|
4390
4794
|
* review, Argus-W9; DEF-151).
|
|
@@ -4407,11 +4811,17 @@ var consentGrantListResponse = import_zod16.z.object({
|
|
|
4407
4811
|
* sagt, was der Nutzer wissen muss — **hier fehlt etwas, frag jemanden** —
|
|
4408
4812
|
* ohne einen Mechanismus zu versprechen, den es nicht gibt.
|
|
4409
4813
|
*/
|
|
4410
|
-
truncated: import_zod16.z.boolean()
|
|
4814
|
+
truncated: import_zod16.z.boolean().meta({
|
|
4815
|
+
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.'
|
|
4816
|
+
})
|
|
4411
4817
|
});
|
|
4412
4818
|
var consentRevokeResponse = import_zod16.z.object({
|
|
4413
|
-
revoked: import_zod16.z.boolean()
|
|
4414
|
-
|
|
4819
|
+
revoked: import_zod16.z.boolean().meta({
|
|
4820
|
+
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."
|
|
4821
|
+
}),
|
|
4822
|
+
tokens_revoked: import_zod16.z.number().int().nonnegative().meta({
|
|
4823
|
+
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."
|
|
4824
|
+
})
|
|
4415
4825
|
});
|
|
4416
4826
|
var oauthInteraction = import_zod16.z.object({
|
|
4417
4827
|
interaction_id: import_zod16.z.string().min(1).max(200),
|
|
@@ -4460,24 +4870,20 @@ var OAUTH_PATHS = {
|
|
|
4460
4870
|
*/
|
|
4461
4871
|
consent: "/oauth/consent",
|
|
4462
4872
|
/**
|
|
4463
|
-
* The
|
|
4873
|
+
* The federation return leg. **A server-owned redirect target a client never
|
|
4464
4874
|
* constructs** — the same category as `authorize`, `token` and `register`,
|
|
4465
|
-
* and the reason
|
|
4466
|
-
*
|
|
4467
|
-
*
|
|
4468
|
-
*
|
|
4469
|
-
* `
|
|
4470
|
-
*
|
|
4471
|
-
*
|
|
4472
|
-
*
|
|
4473
|
-
*
|
|
4474
|
-
*
|
|
4475
|
-
*
|
|
4476
|
-
* `OAUTH_PATHS` was created in this wave with a doc comment citing W7a's
|
|
4477
|
-
* five hand-written copies of `assetKind`. The rule was applied to `login`
|
|
4478
|
-
* and `consent` and stopped there.
|
|
4875
|
+
* and the reason it belongs here rather than as a literal.
|
|
4876
|
+
*
|
|
4877
|
+
* `OAUTH_PATHS` was created in W7b with a doc comment citing W7a's five
|
|
4878
|
+
* hand-written copies of `assetKind`. The rule was applied to `login` and
|
|
4879
|
+
* `consent` and stopped there.
|
|
4880
|
+
*
|
|
4881
|
+
* It once had a twin, `idpStart: '/oauth/idp-start'`, for the *outbound*
|
|
4882
|
+
* leg. The cloud deleted that route — `/oauth/authorize` redirects to the
|
|
4883
|
+
* IdP directly — and the entry stayed here for months, naming a path
|
|
4884
|
+
* nothing served. Removed in FL-007; the cloud's route-manifest test is
|
|
4885
|
+
* what keeps a second one from accumulating.
|
|
4479
4886
|
*/
|
|
4480
|
-
idpStart: "/oauth/idp-start",
|
|
4481
4887
|
idpCallback: "/oauth/idp-callback",
|
|
4482
4888
|
/**
|
|
4483
4889
|
* The console-built page the cloud serves from its own origin (see §3.4) —
|
|
@@ -4507,7 +4913,7 @@ var OAUTH_PATHS = {
|
|
|
4507
4913
|
* role to preview or a user to sign in as. The page is **server-rendered by
|
|
4508
4914
|
* the cloud from its own origin** — the console never builds it and never
|
|
4509
4915
|
* hardcodes this path; the login page merely follows the `redirect_to` it is
|
|
4510
|
-
* handed. It belongs in this list for the reason `
|
|
4916
|
+
* handed. It belongs in this list for the reason `idpCallback` does:
|
|
4511
4917
|
* a server-owned target a client is sent to, which must have exactly one
|
|
4512
4918
|
* definition rather than a literal in `cloud/src/routes/oauth.ts` that a
|
|
4513
4919
|
* second reader could drift from.
|
|
@@ -4515,6 +4921,3021 @@ var OAUTH_PATHS = {
|
|
|
4515
4921
|
impersonate: "/oauth/impersonate"
|
|
4516
4922
|
};
|
|
4517
4923
|
|
|
4924
|
+
// node_modules/.pnpm/@fleetless+contracts@git+ssh+++git@gitlab.dehne-robotik.de+fleetless+fleetless-contract_caf295a2dfcb59061dae71fef9f47725/node_modules/@fleetless/contracts/dist/routes.js
|
|
4925
|
+
var DEVELOPER_GUARD = ["unauthorized", "token_expired", "token_revoked", "forbidden"];
|
|
4926
|
+
var CLIENT_GUARD = ["unauthorized", "token_expired", "token_revoked", "forbidden"];
|
|
4927
|
+
var ROUTES = [
|
|
4928
|
+
/* ------------------------------------------------------------- health */
|
|
4929
|
+
{
|
|
4930
|
+
method: "GET",
|
|
4931
|
+
path: "/healthz",
|
|
4932
|
+
section: "health",
|
|
4933
|
+
summary: "Reports whether the database, the object store and LiveKit each answered a probe.",
|
|
4934
|
+
audience: "internal",
|
|
4935
|
+
auth: "none",
|
|
4936
|
+
rateLimited: false,
|
|
4937
|
+
ownerTier: false,
|
|
4938
|
+
status: 200,
|
|
4939
|
+
params: [],
|
|
4940
|
+
query: null,
|
|
4941
|
+
request: null,
|
|
4942
|
+
response: null,
|
|
4943
|
+
errors: [],
|
|
4944
|
+
transport: "http",
|
|
4945
|
+
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."
|
|
4946
|
+
},
|
|
4947
|
+
/* ----------------------------------------------------- developer auth */
|
|
4948
|
+
{
|
|
4949
|
+
method: "POST",
|
|
4950
|
+
path: "/api/auth/signup",
|
|
4951
|
+
section: "developer-auth",
|
|
4952
|
+
summary: "Creates an org, its Org Admins group and the founding Owner, and answers a developer session.",
|
|
4953
|
+
audience: "developer",
|
|
4954
|
+
auth: "none",
|
|
4955
|
+
rateLimited: true,
|
|
4956
|
+
ownerTier: false,
|
|
4957
|
+
status: 201,
|
|
4958
|
+
params: [],
|
|
4959
|
+
query: null,
|
|
4960
|
+
request: signUpRequest,
|
|
4961
|
+
response: signUpResponse,
|
|
4962
|
+
errors: ["rate_limited", "signup_closed", "validation_error", "email_taken"],
|
|
4963
|
+
transport: "http",
|
|
4964
|
+
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."
|
|
4965
|
+
},
|
|
4966
|
+
{
|
|
4967
|
+
method: "POST",
|
|
4968
|
+
path: "/api/auth/refresh",
|
|
4969
|
+
section: "developer-auth",
|
|
4970
|
+
summary: "Rotates a developer refresh token and mints a fresh access token.",
|
|
4971
|
+
audience: "developer",
|
|
4972
|
+
auth: "none",
|
|
4973
|
+
rateLimited: true,
|
|
4974
|
+
ownerTier: false,
|
|
4975
|
+
status: 200,
|
|
4976
|
+
params: [],
|
|
4977
|
+
query: null,
|
|
4978
|
+
request: refreshRequest,
|
|
4979
|
+
response: sessionTokens,
|
|
4980
|
+
errors: ["rate_limited", "validation_error", "token_expired", "token_revoked"],
|
|
4981
|
+
transport: "http",
|
|
4982
|
+
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."
|
|
4983
|
+
},
|
|
4984
|
+
{
|
|
4985
|
+
method: "POST",
|
|
4986
|
+
path: "/api/auth/logout",
|
|
4987
|
+
section: "developer-auth",
|
|
4988
|
+
summary: "Revokes the whole refresh family behind a developer refresh token.",
|
|
4989
|
+
audience: "developer",
|
|
4990
|
+
auth: "none",
|
|
4991
|
+
rateLimited: true,
|
|
4992
|
+
ownerTier: false,
|
|
4993
|
+
status: 204,
|
|
4994
|
+
params: [],
|
|
4995
|
+
query: null,
|
|
4996
|
+
request: refreshRequest,
|
|
4997
|
+
response: null,
|
|
4998
|
+
errors: ["rate_limited", "validation_error"],
|
|
4999
|
+
transport: "http",
|
|
5000
|
+
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."
|
|
5001
|
+
},
|
|
5002
|
+
{
|
|
5003
|
+
method: "GET",
|
|
5004
|
+
path: "/api/auth/me",
|
|
5005
|
+
section: "developer-auth",
|
|
5006
|
+
summary: "Answers the calling developer and the org they belong to.",
|
|
5007
|
+
audience: "developer",
|
|
5008
|
+
auth: "developer",
|
|
5009
|
+
rateLimited: false,
|
|
5010
|
+
ownerTier: false,
|
|
5011
|
+
status: 200,
|
|
5012
|
+
params: [],
|
|
5013
|
+
query: null,
|
|
5014
|
+
request: null,
|
|
5015
|
+
response: authMeResponse,
|
|
5016
|
+
errors: [...DEVELOPER_GUARD],
|
|
5017
|
+
transport: "http"
|
|
5018
|
+
},
|
|
5019
|
+
{
|
|
5020
|
+
method: "PATCH",
|
|
5021
|
+
path: "/api/auth/me",
|
|
5022
|
+
section: "developer-auth",
|
|
5023
|
+
summary: "Changes the calling developer's own display name and nothing else.",
|
|
5024
|
+
audience: "developer",
|
|
5025
|
+
auth: "developer",
|
|
5026
|
+
rateLimited: false,
|
|
5027
|
+
ownerTier: false,
|
|
5028
|
+
status: 200,
|
|
5029
|
+
params: [],
|
|
5030
|
+
query: null,
|
|
5031
|
+
request: patchAuthMeRequest,
|
|
5032
|
+
response: authMeResponse,
|
|
5033
|
+
errors: [...DEVELOPER_GUARD, "validation_error"],
|
|
5034
|
+
transport: "http",
|
|
5035
|
+
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."
|
|
5036
|
+
},
|
|
5037
|
+
{
|
|
5038
|
+
method: "POST",
|
|
5039
|
+
path: "/api/auth/password/change",
|
|
5040
|
+
section: "developer-auth",
|
|
5041
|
+
summary: "Verifies the current password, sets a new one and answers a fresh session.",
|
|
5042
|
+
audience: "developer",
|
|
5043
|
+
auth: "developer",
|
|
5044
|
+
rateLimited: false,
|
|
5045
|
+
ownerTier: false,
|
|
5046
|
+
status: 200,
|
|
5047
|
+
params: [],
|
|
5048
|
+
query: null,
|
|
5049
|
+
request: passwordChangeRequest,
|
|
5050
|
+
response: sessionTokens,
|
|
5051
|
+
errors: [...DEVELOPER_GUARD, "validation_error", "invalid_credentials"],
|
|
5052
|
+
transport: "http",
|
|
5053
|
+
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."
|
|
5054
|
+
},
|
|
5055
|
+
{
|
|
5056
|
+
method: "POST",
|
|
5057
|
+
path: "/api/auth/password/reset",
|
|
5058
|
+
section: "developer-auth",
|
|
5059
|
+
summary: "Mails a password-reset link to the address, and answers the same either way.",
|
|
5060
|
+
audience: "developer",
|
|
5061
|
+
auth: "none",
|
|
5062
|
+
rateLimited: true,
|
|
5063
|
+
ownerTier: false,
|
|
5064
|
+
status: 202,
|
|
5065
|
+
params: [],
|
|
5066
|
+
query: null,
|
|
5067
|
+
request: passwordResetRequest,
|
|
5068
|
+
response: null,
|
|
5069
|
+
errors: ["rate_limited", "validation_error"],
|
|
5070
|
+
transport: "http",
|
|
5071
|
+
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`.'
|
|
5072
|
+
},
|
|
5073
|
+
/* ------------------------------------------- client auth (portal pages) */
|
|
5074
|
+
{
|
|
5075
|
+
method: "GET",
|
|
5076
|
+
path: "/reset-password",
|
|
5077
|
+
section: "client-auth",
|
|
5078
|
+
summary: `Serves the auth portal's "forgot your password" card as an HTML page.`,
|
|
5079
|
+
audience: "internal",
|
|
5080
|
+
auth: "none",
|
|
5081
|
+
rateLimited: false,
|
|
5082
|
+
ownerTier: false,
|
|
5083
|
+
status: 200,
|
|
5084
|
+
params: [],
|
|
5085
|
+
query: null,
|
|
5086
|
+
request: null,
|
|
5087
|
+
response: null,
|
|
5088
|
+
errors: [],
|
|
5089
|
+
transport: "http",
|
|
5090
|
+
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.'
|
|
5091
|
+
},
|
|
5092
|
+
{
|
|
5093
|
+
method: "GET",
|
|
5094
|
+
path: "/reset-password/:token",
|
|
5095
|
+
section: "client-auth",
|
|
5096
|
+
summary: 'Serves the "pick a new password" page for a mailed reset link.',
|
|
5097
|
+
audience: "internal",
|
|
5098
|
+
auth: "none",
|
|
5099
|
+
rateLimited: false,
|
|
5100
|
+
ownerTier: false,
|
|
5101
|
+
status: 200,
|
|
5102
|
+
params: [{ name: "token", description: "The opaque reset token from the mailed link; it is never sent as a query parameter." }],
|
|
5103
|
+
query: null,
|
|
5104
|
+
request: null,
|
|
5105
|
+
response: null,
|
|
5106
|
+
errors: [],
|
|
5107
|
+
transport: "http",
|
|
5108
|
+
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.'
|
|
5109
|
+
},
|
|
5110
|
+
{
|
|
5111
|
+
method: "POST",
|
|
5112
|
+
path: "/api/auth/password/reset/confirm",
|
|
5113
|
+
section: "developer-auth",
|
|
5114
|
+
summary: "Spends a reset token, sets the new password and ends every session of the account.",
|
|
5115
|
+
audience: "developer",
|
|
5116
|
+
auth: "none",
|
|
5117
|
+
rateLimited: true,
|
|
5118
|
+
ownerTier: false,
|
|
5119
|
+
status: 204,
|
|
5120
|
+
params: [],
|
|
5121
|
+
query: null,
|
|
5122
|
+
request: passwordResetConfirm,
|
|
5123
|
+
response: null,
|
|
5124
|
+
errors: ["rate_limited", "validation_error", "token_spent"],
|
|
5125
|
+
transport: "http",
|
|
5126
|
+
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`."
|
|
5127
|
+
},
|
|
5128
|
+
{
|
|
5129
|
+
method: "POST",
|
|
5130
|
+
path: "/api/waitlist",
|
|
5131
|
+
section: "developer-auth",
|
|
5132
|
+
summary: "Adds an address to the closed-beta waiting list.",
|
|
5133
|
+
audience: "developer",
|
|
5134
|
+
auth: "none",
|
|
5135
|
+
rateLimited: true,
|
|
5136
|
+
ownerTier: false,
|
|
5137
|
+
status: 202,
|
|
5138
|
+
params: [],
|
|
5139
|
+
query: null,
|
|
5140
|
+
request: waitlistRequest,
|
|
5141
|
+
response: null,
|
|
5142
|
+
errors: ["rate_limited", "validation_error"],
|
|
5143
|
+
transport: "http",
|
|
5144
|
+
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."
|
|
5145
|
+
},
|
|
5146
|
+
/* ---------------------------------------------------------------- org */
|
|
5147
|
+
{
|
|
5148
|
+
method: "GET",
|
|
5149
|
+
path: "/api/audit",
|
|
5150
|
+
section: "org",
|
|
5151
|
+
summary: "Reads the org's audit log, newest first, cursor-paged over the durable sequence number.",
|
|
5152
|
+
audience: "developer",
|
|
5153
|
+
auth: "developer",
|
|
5154
|
+
rateLimited: false,
|
|
5155
|
+
ownerTier: false,
|
|
5156
|
+
status: 200,
|
|
5157
|
+
params: [],
|
|
5158
|
+
query: auditQuery,
|
|
5159
|
+
request: null,
|
|
5160
|
+
response: auditListResponse,
|
|
5161
|
+
errors: [...DEVELOPER_GUARD, "validation_error"],
|
|
5162
|
+
transport: "http",
|
|
5163
|
+
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."
|
|
5164
|
+
},
|
|
5165
|
+
{
|
|
5166
|
+
method: "GET",
|
|
5167
|
+
path: "/api/audit/export",
|
|
5168
|
+
section: "org",
|
|
5169
|
+
summary: "Downloads every audit event matching the same filters as a CSV attachment.",
|
|
5170
|
+
audience: "developer",
|
|
5171
|
+
auth: "developer",
|
|
5172
|
+
rateLimited: false,
|
|
5173
|
+
ownerTier: false,
|
|
5174
|
+
status: 200,
|
|
5175
|
+
params: [],
|
|
5176
|
+
query: auditQuery,
|
|
5177
|
+
request: null,
|
|
5178
|
+
response: null,
|
|
5179
|
+
errors: [...DEVELOPER_GUARD, "validation_error"],
|
|
5180
|
+
transport: "http",
|
|
5181
|
+
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."
|
|
5182
|
+
},
|
|
5183
|
+
/* --------------------------------------------------------------- apps */
|
|
5184
|
+
{
|
|
5185
|
+
method: "POST",
|
|
5186
|
+
path: "/api/apps",
|
|
5187
|
+
section: "apps",
|
|
5188
|
+
summary: "Creates an app in a group, optionally attaching robots to it at the same time.",
|
|
5189
|
+
audience: "developer",
|
|
5190
|
+
auth: "developer",
|
|
5191
|
+
rateLimited: false,
|
|
5192
|
+
ownerTier: false,
|
|
5193
|
+
status: 201,
|
|
5194
|
+
params: [],
|
|
5195
|
+
query: null,
|
|
5196
|
+
request: createAppRequest,
|
|
5197
|
+
response: app,
|
|
5198
|
+
errors: [...DEVELOPER_GUARD, "validation_error", "target_state_conflict", "identifier_taken", "quota_exceeded"],
|
|
5199
|
+
transport: "http",
|
|
5200
|
+
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`."
|
|
5201
|
+
},
|
|
5202
|
+
{
|
|
5203
|
+
method: "GET",
|
|
5204
|
+
path: "/api/apps",
|
|
5205
|
+
section: "apps",
|
|
5206
|
+
summary: "Lists every app in the caller's org.",
|
|
5207
|
+
audience: "developer",
|
|
5208
|
+
auth: "developer",
|
|
5209
|
+
rateLimited: false,
|
|
5210
|
+
ownerTier: false,
|
|
5211
|
+
status: 200,
|
|
5212
|
+
params: [],
|
|
5213
|
+
query: null,
|
|
5214
|
+
request: null,
|
|
5215
|
+
response: null,
|
|
5216
|
+
errors: [...DEVELOPER_GUARD],
|
|
5217
|
+
transport: "http",
|
|
5218
|
+
notes: 'Answers `{ "apps": [app, \u2026] }`. The envelope has no schema of its own in contracts; each element is an `app`.'
|
|
5219
|
+
},
|
|
5220
|
+
{
|
|
5221
|
+
method: "GET",
|
|
5222
|
+
path: "/api/apps/:id",
|
|
5223
|
+
section: "apps",
|
|
5224
|
+
summary: "Reads one app of the org, with its group, robots and default role.",
|
|
5225
|
+
audience: "developer",
|
|
5226
|
+
auth: "developer",
|
|
5227
|
+
rateLimited: false,
|
|
5228
|
+
ownerTier: false,
|
|
5229
|
+
status: 200,
|
|
5230
|
+
params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
|
|
5231
|
+
query: null,
|
|
5232
|
+
request: null,
|
|
5233
|
+
response: app,
|
|
5234
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
5235
|
+
transport: "http",
|
|
5236
|
+
notes: "An app belonging to another org reads exactly like one that does not exist \u2014 `404`, never a `403`."
|
|
5237
|
+
},
|
|
5238
|
+
{
|
|
5239
|
+
method: "PATCH",
|
|
5240
|
+
path: "/api/apps/:id",
|
|
5241
|
+
section: "apps",
|
|
5242
|
+
summary: "Changes an app's name, its attached robots, its default role or whether it accepts dynamic clients.",
|
|
5243
|
+
audience: "developer",
|
|
5244
|
+
auth: "developer",
|
|
5245
|
+
rateLimited: false,
|
|
5246
|
+
ownerTier: false,
|
|
5247
|
+
status: 200,
|
|
5248
|
+
params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
|
|
5249
|
+
query: null,
|
|
5250
|
+
request: updateAppRequest,
|
|
5251
|
+
response: app,
|
|
5252
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "validation_error", "not_found"],
|
|
5253
|
+
transport: "http",
|
|
5254
|
+
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."
|
|
5255
|
+
},
|
|
5256
|
+
{
|
|
5257
|
+
method: "POST",
|
|
5258
|
+
path: "/api/apps/:id/roles",
|
|
5259
|
+
section: "apps",
|
|
5260
|
+
summary: "Creates a custom role on the app.",
|
|
5261
|
+
audience: "developer",
|
|
5262
|
+
auth: "developer",
|
|
5263
|
+
rateLimited: false,
|
|
5264
|
+
ownerTier: false,
|
|
5265
|
+
status: 201,
|
|
5266
|
+
params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
|
|
5267
|
+
query: null,
|
|
5268
|
+
request: null,
|
|
5269
|
+
response: null,
|
|
5270
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
|
|
5271
|
+
transport: "http",
|
|
5272
|
+
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`.'
|
|
5273
|
+
},
|
|
5274
|
+
{
|
|
5275
|
+
method: "GET",
|
|
5276
|
+
path: "/api/apps/:id/roles",
|
|
5277
|
+
section: "apps",
|
|
5278
|
+
summary: "Lists the app's roles, builtin and custom.",
|
|
5279
|
+
audience: "developer",
|
|
5280
|
+
auth: "developer",
|
|
5281
|
+
rateLimited: false,
|
|
5282
|
+
ownerTier: false,
|
|
5283
|
+
status: 200,
|
|
5284
|
+
params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
|
|
5285
|
+
query: null,
|
|
5286
|
+
request: null,
|
|
5287
|
+
response: null,
|
|
5288
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
5289
|
+
transport: "http",
|
|
5290
|
+
notes: 'Answers `{ "roles": [role, \u2026] }`. The envelope has no schema of its own in contracts; each element is a `role`.'
|
|
5291
|
+
},
|
|
5292
|
+
{
|
|
5293
|
+
method: "PUT",
|
|
5294
|
+
path: "/api/apps/:id/roles/:roleId/permissions",
|
|
5295
|
+
section: "apps",
|
|
5296
|
+
summary: "Replaces a role's grants and capabilities in one write.",
|
|
5297
|
+
audience: "developer",
|
|
5298
|
+
auth: "developer",
|
|
5299
|
+
rateLimited: false,
|
|
5300
|
+
ownerTier: false,
|
|
5301
|
+
status: 200,
|
|
5302
|
+
params: [
|
|
5303
|
+
{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." },
|
|
5304
|
+
{ name: "roleId", description: "The role's uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`." }
|
|
5305
|
+
],
|
|
5306
|
+
query: null,
|
|
5307
|
+
request: rolePermissions,
|
|
5308
|
+
response: rolePermissions,
|
|
5309
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
|
|
5310
|
+
transport: "http",
|
|
5311
|
+
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."
|
|
5312
|
+
},
|
|
5313
|
+
{
|
|
5314
|
+
method: "GET",
|
|
5315
|
+
path: "/api/apps/:id/roles/:roleId/permissions",
|
|
5316
|
+
section: "apps",
|
|
5317
|
+
summary: "Reads a role's grants and capabilities.",
|
|
5318
|
+
audience: "developer",
|
|
5319
|
+
auth: "developer",
|
|
5320
|
+
rateLimited: false,
|
|
5321
|
+
ownerTier: false,
|
|
5322
|
+
status: 200,
|
|
5323
|
+
params: [
|
|
5324
|
+
{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." },
|
|
5325
|
+
{ name: "roleId", description: "The role's uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`." }
|
|
5326
|
+
],
|
|
5327
|
+
query: null,
|
|
5328
|
+
request: null,
|
|
5329
|
+
response: rolePermissions,
|
|
5330
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
5331
|
+
transport: "http"
|
|
5332
|
+
},
|
|
5333
|
+
{
|
|
5334
|
+
method: "GET",
|
|
5335
|
+
path: "/api/apps/:id/roles/:roleId/mcp-tools",
|
|
5336
|
+
section: "apps",
|
|
5337
|
+
summary: "Previews the robot datasheets an MCP caller holding this role would be offered.",
|
|
5338
|
+
audience: "developer",
|
|
5339
|
+
auth: "developer",
|
|
5340
|
+
rateLimited: false,
|
|
5341
|
+
ownerTier: false,
|
|
5342
|
+
status: 200,
|
|
5343
|
+
params: [
|
|
5344
|
+
{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." },
|
|
5345
|
+
{ name: "roleId", description: "The role's uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`." }
|
|
5346
|
+
],
|
|
5347
|
+
query: null,
|
|
5348
|
+
request: null,
|
|
5349
|
+
response: mcpRolePreviewResponse,
|
|
5350
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
5351
|
+
transport: "http",
|
|
5352
|
+
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."
|
|
5353
|
+
},
|
|
5354
|
+
{
|
|
5355
|
+
method: "POST",
|
|
5356
|
+
path: "/api/apps/:id/server-keys",
|
|
5357
|
+
section: "apps",
|
|
5358
|
+
summary: "Mints a server key for the app and returns the raw secret once.",
|
|
5359
|
+
audience: "developer",
|
|
5360
|
+
auth: "developer",
|
|
5361
|
+
rateLimited: false,
|
|
5362
|
+
ownerTier: true,
|
|
5363
|
+
status: 201,
|
|
5364
|
+
params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
|
|
5365
|
+
query: null,
|
|
5366
|
+
request: null,
|
|
5367
|
+
response: createServerKeyResponse,
|
|
5368
|
+
errors: [...DEVELOPER_GUARD, "tier_required", "invalid_uuid", "not_found", "validation_error"],
|
|
5369
|
+
transport: "http",
|
|
5370
|
+
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."
|
|
5371
|
+
},
|
|
5372
|
+
{
|
|
5373
|
+
method: "GET",
|
|
5374
|
+
path: "/api/apps/:id/server-keys",
|
|
5375
|
+
section: "apps",
|
|
5376
|
+
summary: "Lists the app's server keys as metadata, never the secrets.",
|
|
5377
|
+
audience: "developer",
|
|
5378
|
+
auth: "developer",
|
|
5379
|
+
rateLimited: false,
|
|
5380
|
+
ownerTier: false,
|
|
5381
|
+
status: 200,
|
|
5382
|
+
params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
|
|
5383
|
+
query: null,
|
|
5384
|
+
request: null,
|
|
5385
|
+
response: null,
|
|
5386
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
5387
|
+
transport: "http",
|
|
5388
|
+
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.'
|
|
5389
|
+
},
|
|
5390
|
+
{
|
|
5391
|
+
method: "POST",
|
|
5392
|
+
path: "/api/apps/:id/server-keys/:keyId/rotate",
|
|
5393
|
+
section: "apps",
|
|
5394
|
+
summary: "Replaces a server key's secret in place and returns the new one once.",
|
|
5395
|
+
audience: "developer",
|
|
5396
|
+
auth: "developer",
|
|
5397
|
+
rateLimited: false,
|
|
5398
|
+
ownerTier: true,
|
|
5399
|
+
status: 200,
|
|
5400
|
+
params: [
|
|
5401
|
+
{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." },
|
|
5402
|
+
{ name: "keyId", description: "The server key's uuid, from `GET /api/apps/:id/server-keys`; a key of another app answers `404`." }
|
|
5403
|
+
],
|
|
5404
|
+
query: null,
|
|
5405
|
+
request: null,
|
|
5406
|
+
response: createServerKeyResponse,
|
|
5407
|
+
errors: [...DEVELOPER_GUARD, "tier_required", "invalid_uuid", "not_found"],
|
|
5408
|
+
transport: "http",
|
|
5409
|
+
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."
|
|
5410
|
+
},
|
|
5411
|
+
{
|
|
5412
|
+
method: "DELETE",
|
|
5413
|
+
path: "/api/apps/:id/server-keys/:keyId",
|
|
5414
|
+
section: "apps",
|
|
5415
|
+
summary: "Revokes a server key and closes every socket holding it.",
|
|
5416
|
+
audience: "developer",
|
|
5417
|
+
auth: "developer",
|
|
5418
|
+
rateLimited: false,
|
|
5419
|
+
ownerTier: true,
|
|
5420
|
+
status: 204,
|
|
5421
|
+
params: [
|
|
5422
|
+
{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." },
|
|
5423
|
+
{ name: "keyId", description: "The server key's uuid, from `GET /api/apps/:id/server-keys`; a key of another app answers `404`." }
|
|
5424
|
+
],
|
|
5425
|
+
query: null,
|
|
5426
|
+
request: null,
|
|
5427
|
+
response: null,
|
|
5428
|
+
errors: [...DEVELOPER_GUARD, "tier_required", "invalid_uuid", "not_found"],
|
|
5429
|
+
transport: "http",
|
|
5430
|
+
notes: "Owner tier, like minting and rotating: all three decide who may speak for the whole app."
|
|
5431
|
+
},
|
|
5432
|
+
/* ------------------------------------- users, groups and assignments */
|
|
5433
|
+
{
|
|
5434
|
+
method: "GET",
|
|
5435
|
+
path: "/api/org/groups",
|
|
5436
|
+
section: "users",
|
|
5437
|
+
summary: "Lists every group in the org with its member and app counts.",
|
|
5438
|
+
audience: "developer",
|
|
5439
|
+
auth: "developer",
|
|
5440
|
+
rateLimited: false,
|
|
5441
|
+
ownerTier: false,
|
|
5442
|
+
status: 200,
|
|
5443
|
+
params: [],
|
|
5444
|
+
query: null,
|
|
5445
|
+
request: null,
|
|
5446
|
+
response: groupListResponse,
|
|
5447
|
+
errors: [...DEVELOPER_GUARD],
|
|
5448
|
+
transport: "http"
|
|
5449
|
+
},
|
|
5450
|
+
{
|
|
5451
|
+
method: "GET",
|
|
5452
|
+
path: "/api/org/groups/:id",
|
|
5453
|
+
section: "users",
|
|
5454
|
+
summary: "Reads one group with its member and app counts.",
|
|
5455
|
+
audience: "developer",
|
|
5456
|
+
auth: "developer",
|
|
5457
|
+
rateLimited: false,
|
|
5458
|
+
ownerTier: false,
|
|
5459
|
+
status: 200,
|
|
5460
|
+
params: [{ name: "id", description: "The group's uuid, as listed by `GET /api/org/groups`." }],
|
|
5461
|
+
query: null,
|
|
5462
|
+
request: null,
|
|
5463
|
+
response: orgGroup,
|
|
5464
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
5465
|
+
transport: "http",
|
|
5466
|
+
notes: "A group of another org reads exactly like one that does not exist."
|
|
5467
|
+
},
|
|
5468
|
+
{
|
|
5469
|
+
method: "POST",
|
|
5470
|
+
path: "/api/org/groups",
|
|
5471
|
+
section: "users",
|
|
5472
|
+
summary: "Creates a group, optionally with MCP access enabled for its members.",
|
|
5473
|
+
audience: "developer",
|
|
5474
|
+
auth: "developer",
|
|
5475
|
+
rateLimited: false,
|
|
5476
|
+
ownerTier: false,
|
|
5477
|
+
status: 201,
|
|
5478
|
+
params: [],
|
|
5479
|
+
query: null,
|
|
5480
|
+
request: createGroupRequest,
|
|
5481
|
+
response: orgGroup,
|
|
5482
|
+
errors: [...DEVELOPER_GUARD, "validation_error"],
|
|
5483
|
+
transport: "http",
|
|
5484
|
+
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."
|
|
5485
|
+
},
|
|
5486
|
+
{
|
|
5487
|
+
method: "PATCH",
|
|
5488
|
+
path: "/api/org/groups/:id",
|
|
5489
|
+
section: "users",
|
|
5490
|
+
summary: "Renames a group or flips its MCP gate.",
|
|
5491
|
+
audience: "developer",
|
|
5492
|
+
auth: "developer",
|
|
5493
|
+
rateLimited: false,
|
|
5494
|
+
ownerTier: false,
|
|
5495
|
+
status: 200,
|
|
5496
|
+
params: [{ name: "id", description: "The group's uuid, as listed by `GET /api/org/groups`." }],
|
|
5497
|
+
query: null,
|
|
5498
|
+
request: patchGroupRequest,
|
|
5499
|
+
response: orgGroup,
|
|
5500
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
|
|
5501
|
+
transport: "http",
|
|
5502
|
+
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."
|
|
5503
|
+
},
|
|
5504
|
+
{
|
|
5505
|
+
method: "DELETE",
|
|
5506
|
+
path: "/api/org/groups/:id",
|
|
5507
|
+
section: "users",
|
|
5508
|
+
summary: "Deletes an empty group.",
|
|
5509
|
+
audience: "developer",
|
|
5510
|
+
auth: "developer",
|
|
5511
|
+
rateLimited: false,
|
|
5512
|
+
ownerTier: false,
|
|
5513
|
+
status: 204,
|
|
5514
|
+
params: [{ name: "id", description: "The group's uuid, as listed by `GET /api/org/groups`." }],
|
|
5515
|
+
query: null,
|
|
5516
|
+
request: null,
|
|
5517
|
+
response: null,
|
|
5518
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "group_not_deletable", "group_in_use"],
|
|
5519
|
+
transport: "http",
|
|
5520
|
+
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."
|
|
5521
|
+
},
|
|
5522
|
+
{
|
|
5523
|
+
method: "GET",
|
|
5524
|
+
path: "/api/org/groups/:id/usage",
|
|
5525
|
+
section: "users",
|
|
5526
|
+
summary: "Reports what a group holds, before an admin decides to delete it.",
|
|
5527
|
+
audience: "developer",
|
|
5528
|
+
auth: "developer",
|
|
5529
|
+
rateLimited: false,
|
|
5530
|
+
ownerTier: false,
|
|
5531
|
+
status: 200,
|
|
5532
|
+
params: [{ name: "id", description: "The group's uuid, as listed by `GET /api/org/groups`." }],
|
|
5533
|
+
query: null,
|
|
5534
|
+
request: null,
|
|
5535
|
+
response: groupUsageResponse,
|
|
5536
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
5537
|
+
transport: "http",
|
|
5538
|
+
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."
|
|
5539
|
+
},
|
|
5540
|
+
{
|
|
5541
|
+
method: "GET",
|
|
5542
|
+
path: "/api/org/users",
|
|
5543
|
+
section: "users",
|
|
5544
|
+
summary: "Lists the org's user pool, optionally narrowed to one group.",
|
|
5545
|
+
audience: "developer",
|
|
5546
|
+
auth: "developer",
|
|
5547
|
+
rateLimited: false,
|
|
5548
|
+
ownerTier: false,
|
|
5549
|
+
status: 200,
|
|
5550
|
+
params: [],
|
|
5551
|
+
query: null,
|
|
5552
|
+
request: null,
|
|
5553
|
+
response: orgUserListResponse,
|
|
5554
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
5555
|
+
transport: "http",
|
|
5556
|
+
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.'
|
|
5557
|
+
},
|
|
5558
|
+
{
|
|
5559
|
+
method: "GET",
|
|
5560
|
+
path: "/api/org/users/:id",
|
|
5561
|
+
section: "users",
|
|
5562
|
+
summary: "Reads one user of the org.",
|
|
5563
|
+
audience: "developer",
|
|
5564
|
+
auth: "developer",
|
|
5565
|
+
rateLimited: false,
|
|
5566
|
+
ownerTier: false,
|
|
5567
|
+
status: 200,
|
|
5568
|
+
params: [{ name: "id", description: "The user's uuid, as listed by `GET /api/org/users`." }],
|
|
5569
|
+
query: null,
|
|
5570
|
+
request: null,
|
|
5571
|
+
response: orgUser,
|
|
5572
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
5573
|
+
transport: "http"
|
|
5574
|
+
},
|
|
5575
|
+
{
|
|
5576
|
+
method: "POST",
|
|
5577
|
+
path: "/api/org/users/invitations",
|
|
5578
|
+
section: "users",
|
|
5579
|
+
summary: "Invites an address into a group and returns the accept link.",
|
|
5580
|
+
audience: "developer",
|
|
5581
|
+
auth: "developer",
|
|
5582
|
+
rateLimited: false,
|
|
5583
|
+
ownerTier: false,
|
|
5584
|
+
status: 201,
|
|
5585
|
+
params: [],
|
|
5586
|
+
query: null,
|
|
5587
|
+
request: createUserInviteRequest,
|
|
5588
|
+
response: userInvite,
|
|
5589
|
+
errors: [...DEVELOPER_GUARD, "tier_required", "validation_error", "target_state_conflict", "email_taken"],
|
|
5590
|
+
transport: "http",
|
|
5591
|
+
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.'
|
|
5592
|
+
},
|
|
5593
|
+
{
|
|
5594
|
+
method: "GET",
|
|
5595
|
+
path: "/api/org/users/invitations",
|
|
5596
|
+
section: "users",
|
|
5597
|
+
summary: "Lists the pending invitations of the org, without their tokens.",
|
|
5598
|
+
audience: "developer",
|
|
5599
|
+
auth: "developer",
|
|
5600
|
+
rateLimited: false,
|
|
5601
|
+
ownerTier: false,
|
|
5602
|
+
status: 200,
|
|
5603
|
+
params: [],
|
|
5604
|
+
query: null,
|
|
5605
|
+
request: null,
|
|
5606
|
+
response: userInviteListResponse,
|
|
5607
|
+
errors: [...DEVELOPER_GUARD],
|
|
5608
|
+
transport: "http",
|
|
5609
|
+
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."
|
|
5610
|
+
},
|
|
5611
|
+
{
|
|
5612
|
+
method: "DELETE",
|
|
5613
|
+
path: "/api/org/users/invitations/:id",
|
|
5614
|
+
section: "users",
|
|
5615
|
+
summary: "Revokes a pending invitation so its link stops resolving.",
|
|
5616
|
+
audience: "developer",
|
|
5617
|
+
auth: "developer",
|
|
5618
|
+
rateLimited: false,
|
|
5619
|
+
ownerTier: false,
|
|
5620
|
+
status: 204,
|
|
5621
|
+
params: [{ name: "id", description: "The invitation's uuid, as listed by `GET /api/org/users/invitations`." }],
|
|
5622
|
+
query: null,
|
|
5623
|
+
request: null,
|
|
5624
|
+
response: null,
|
|
5625
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
5626
|
+
transport: "http",
|
|
5627
|
+
notes: "An invitation that was already accepted is not pending and answers `404`, the same answer one that never existed gets."
|
|
5628
|
+
},
|
|
5629
|
+
{
|
|
5630
|
+
method: "POST",
|
|
5631
|
+
path: "/api/org/users/invitations/:id/reissue",
|
|
5632
|
+
section: "users",
|
|
5633
|
+
summary: "Mints a fresh token onto the same invitation and returns the new accept link.",
|
|
5634
|
+
audience: "developer",
|
|
5635
|
+
auth: "developer",
|
|
5636
|
+
rateLimited: false,
|
|
5637
|
+
ownerTier: false,
|
|
5638
|
+
status: 200,
|
|
5639
|
+
params: [{ name: "id", description: "The invitation's uuid, as listed by `GET /api/org/users/invitations`." }],
|
|
5640
|
+
query: null,
|
|
5641
|
+
request: null,
|
|
5642
|
+
response: userInvite,
|
|
5643
|
+
errors: [...DEVELOPER_GUARD, "tier_required", "invalid_uuid", "not_found", "rate_limited"],
|
|
5644
|
+
transport: "http",
|
|
5645
|
+
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."
|
|
5646
|
+
},
|
|
5647
|
+
{
|
|
5648
|
+
method: "POST",
|
|
5649
|
+
path: "/api/org/users/invitations/accept",
|
|
5650
|
+
section: "users",
|
|
5651
|
+
summary: "Spends an invitation token and creates the login it was addressed to.",
|
|
5652
|
+
audience: "developer",
|
|
5653
|
+
auth: "none",
|
|
5654
|
+
rateLimited: true,
|
|
5655
|
+
ownerTier: false,
|
|
5656
|
+
status: 204,
|
|
5657
|
+
params: [],
|
|
5658
|
+
query: null,
|
|
5659
|
+
request: acceptUserInviteRequest,
|
|
5660
|
+
response: null,
|
|
5661
|
+
errors: ["rate_limited", "validation_error", "token_spent", "email_taken"],
|
|
5662
|
+
transport: "http",
|
|
5663
|
+
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."
|
|
5664
|
+
},
|
|
5665
|
+
{
|
|
5666
|
+
method: "GET",
|
|
5667
|
+
path: "/accept-invite/:token",
|
|
5668
|
+
section: "users",
|
|
5669
|
+
summary: "Serves the invitation card a mailed accept link opens.",
|
|
5670
|
+
audience: "internal",
|
|
5671
|
+
auth: "none",
|
|
5672
|
+
rateLimited: false,
|
|
5673
|
+
ownerTier: false,
|
|
5674
|
+
status: 200,
|
|
5675
|
+
params: [{ name: "token", description: "The opaque invitation token from the mailed link; it is never sent as a query parameter." }],
|
|
5676
|
+
query: null,
|
|
5677
|
+
request: null,
|
|
5678
|
+
response: null,
|
|
5679
|
+
errors: [],
|
|
5680
|
+
transport: "http",
|
|
5681
|
+
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.'
|
|
5682
|
+
},
|
|
5683
|
+
{
|
|
5684
|
+
method: "PATCH",
|
|
5685
|
+
path: "/api/org/users/:id",
|
|
5686
|
+
section: "users",
|
|
5687
|
+
summary: "Changes a user's display name or their MCP access override.",
|
|
5688
|
+
audience: "developer",
|
|
5689
|
+
auth: "developer",
|
|
5690
|
+
rateLimited: false,
|
|
5691
|
+
ownerTier: false,
|
|
5692
|
+
status: 200,
|
|
5693
|
+
params: [{ name: "id", description: "The user's uuid, as listed by `GET /api/org/users`." }],
|
|
5694
|
+
query: null,
|
|
5695
|
+
request: patchUserRequest,
|
|
5696
|
+
response: orgUser,
|
|
5697
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
|
|
5698
|
+
transport: "http",
|
|
5699
|
+
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."
|
|
5700
|
+
},
|
|
5701
|
+
{
|
|
5702
|
+
method: "GET",
|
|
5703
|
+
path: "/api/org/users/:id/usage",
|
|
5704
|
+
section: "users",
|
|
5705
|
+
summary: "Previews what moving a user into another group would delete.",
|
|
5706
|
+
audience: "developer",
|
|
5707
|
+
auth: "developer",
|
|
5708
|
+
rateLimited: false,
|
|
5709
|
+
ownerTier: false,
|
|
5710
|
+
status: 200,
|
|
5711
|
+
params: [{ name: "id", description: "The user's uuid, as listed by `GET /api/org/users`." }],
|
|
5712
|
+
query: null,
|
|
5713
|
+
request: null,
|
|
5714
|
+
response: groupUsageResponse,
|
|
5715
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
|
|
5716
|
+
transport: "http",
|
|
5717
|
+
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."
|
|
5718
|
+
},
|
|
5719
|
+
{
|
|
5720
|
+
method: "POST",
|
|
5721
|
+
path: "/api/org/users/:id/move-group",
|
|
5722
|
+
section: "users",
|
|
5723
|
+
summary: "Moves a user into another group, deleting every assignment the move invalidates.",
|
|
5724
|
+
audience: "developer",
|
|
5725
|
+
auth: "developer",
|
|
5726
|
+
rateLimited: false,
|
|
5727
|
+
ownerTier: false,
|
|
5728
|
+
status: 200,
|
|
5729
|
+
params: [{ name: "id", description: "The user's uuid, as listed by `GET /api/org/users`." }],
|
|
5730
|
+
query: null,
|
|
5731
|
+
request: moveUserGroupRequest,
|
|
5732
|
+
response: orgUser,
|
|
5733
|
+
errors: [...DEVELOPER_GUARD, "tier_required", "invalid_uuid", "not_found", "validation_error", "last_owner"],
|
|
5734
|
+
transport: "http",
|
|
5735
|
+
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."
|
|
5736
|
+
},
|
|
5737
|
+
{
|
|
5738
|
+
method: "DELETE",
|
|
5739
|
+
path: "/api/org/users/:id",
|
|
5740
|
+
section: "users",
|
|
5741
|
+
summary: "Removes a user and ends every session they hold.",
|
|
5742
|
+
audience: "developer",
|
|
5743
|
+
auth: "developer",
|
|
5744
|
+
rateLimited: false,
|
|
5745
|
+
ownerTier: false,
|
|
5746
|
+
status: 204,
|
|
5747
|
+
params: [{ name: "id", description: "The user's uuid, as listed by `GET /api/org/users`." }],
|
|
5748
|
+
query: null,
|
|
5749
|
+
request: null,
|
|
5750
|
+
response: null,
|
|
5751
|
+
errors: [...DEVELOPER_GUARD, "tier_required", "invalid_uuid", "not_found", "last_owner"],
|
|
5752
|
+
transport: "http",
|
|
5753
|
+
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`."
|
|
5754
|
+
},
|
|
5755
|
+
{
|
|
5756
|
+
method: "PUT",
|
|
5757
|
+
path: "/api/org/users/:id/tier",
|
|
5758
|
+
section: "users",
|
|
5759
|
+
summary: "Promotes or demotes an org admin between Owner and developer tier.",
|
|
5760
|
+
audience: "developer",
|
|
5761
|
+
auth: "developer",
|
|
5762
|
+
rateLimited: false,
|
|
5763
|
+
ownerTier: true,
|
|
5764
|
+
status: 200,
|
|
5765
|
+
params: [{ name: "id", description: "The user's uuid, as listed by `GET /api/org/users`; must be a member of the Org Admins group." }],
|
|
5766
|
+
query: null,
|
|
5767
|
+
request: tierChangeRequest,
|
|
5768
|
+
response: orgUser,
|
|
5769
|
+
errors: [...DEVELOPER_GUARD, "tier_required", "invalid_uuid", "not_found", "validation_error", "target_state_conflict", "last_owner"],
|
|
5770
|
+
transport: "http",
|
|
5771
|
+
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."
|
|
5772
|
+
},
|
|
5773
|
+
{
|
|
5774
|
+
method: "GET",
|
|
5775
|
+
path: "/api/org/users/:id/assignments",
|
|
5776
|
+
section: "users",
|
|
5777
|
+
summary: "Lists the apps a user is assigned to and the role they hold in each.",
|
|
5778
|
+
audience: "developer",
|
|
5779
|
+
auth: "developer",
|
|
5780
|
+
rateLimited: false,
|
|
5781
|
+
ownerTier: false,
|
|
5782
|
+
status: 200,
|
|
5783
|
+
params: [{ name: "id", description: "The user's uuid, as listed by `GET /api/org/users`." }],
|
|
5784
|
+
query: null,
|
|
5785
|
+
request: null,
|
|
5786
|
+
response: appAssignmentListResponse,
|
|
5787
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
5788
|
+
transport: "http"
|
|
5789
|
+
},
|
|
5790
|
+
{
|
|
5791
|
+
method: "PUT",
|
|
5792
|
+
path: "/api/org/users/:id/assignments/:appId",
|
|
5793
|
+
section: "users",
|
|
5794
|
+
summary: "Assigns a user to an app in a role, or changes the role they already hold.",
|
|
5795
|
+
audience: "developer",
|
|
5796
|
+
auth: "developer",
|
|
5797
|
+
rateLimited: false,
|
|
5798
|
+
ownerTier: false,
|
|
5799
|
+
status: 200,
|
|
5800
|
+
params: [
|
|
5801
|
+
{ name: "id", description: "The user's uuid, as listed by `GET /api/org/users`." },
|
|
5802
|
+
{ name: "appId", description: "The app's uuid; it must belong to the same group as the user." }
|
|
5803
|
+
],
|
|
5804
|
+
query: null,
|
|
5805
|
+
request: putAssignmentRequest,
|
|
5806
|
+
response: appAssignment,
|
|
5807
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "target_state_conflict", "validation_error"],
|
|
5808
|
+
transport: "http",
|
|
5809
|
+
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."
|
|
5810
|
+
},
|
|
5811
|
+
{
|
|
5812
|
+
method: "DELETE",
|
|
5813
|
+
path: "/api/org/users/:id/assignments/:appId",
|
|
5814
|
+
section: "users",
|
|
5815
|
+
summary: "Removes a user's assignment to one app and ends their sessions for it.",
|
|
5816
|
+
audience: "developer",
|
|
5817
|
+
auth: "developer",
|
|
5818
|
+
rateLimited: false,
|
|
5819
|
+
ownerTier: false,
|
|
5820
|
+
status: 204,
|
|
5821
|
+
params: [
|
|
5822
|
+
{ name: "id", description: "The user's uuid, as listed by `GET /api/org/users`." },
|
|
5823
|
+
{ name: "appId", description: "The app's uuid; only sessions and subscriptions for this app are ended." }
|
|
5824
|
+
],
|
|
5825
|
+
query: null,
|
|
5826
|
+
request: null,
|
|
5827
|
+
response: null,
|
|
5828
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
5829
|
+
transport: "http",
|
|
5830
|
+
notes: "Only that app's sessions end; the user's access to every other app they are assigned to is untouched."
|
|
5831
|
+
},
|
|
5832
|
+
{
|
|
5833
|
+
method: "GET",
|
|
5834
|
+
path: "/api/apps/:id/group-usage",
|
|
5835
|
+
section: "users",
|
|
5836
|
+
summary: "Previews what re-linking an app to another group would delete.",
|
|
5837
|
+
audience: "developer",
|
|
5838
|
+
auth: "developer",
|
|
5839
|
+
rateLimited: false,
|
|
5840
|
+
ownerTier: false,
|
|
5841
|
+
status: 200,
|
|
5842
|
+
params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
|
|
5843
|
+
query: null,
|
|
5844
|
+
request: null,
|
|
5845
|
+
response: groupUsageResponse,
|
|
5846
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
|
|
5847
|
+
transport: "http",
|
|
5848
|
+
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."
|
|
5849
|
+
},
|
|
5850
|
+
{
|
|
5851
|
+
method: "PUT",
|
|
5852
|
+
path: "/api/apps/:id/group",
|
|
5853
|
+
section: "users",
|
|
5854
|
+
summary: "Re-links an app to another group, deleting every assignment the move invalidates.",
|
|
5855
|
+
audience: "developer",
|
|
5856
|
+
auth: "developer",
|
|
5857
|
+
rateLimited: false,
|
|
5858
|
+
ownerTier: false,
|
|
5859
|
+
status: 200,
|
|
5860
|
+
params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
|
|
5861
|
+
query: null,
|
|
5862
|
+
request: putAppGroupRequest,
|
|
5863
|
+
response: app,
|
|
5864
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error", "target_state_conflict"],
|
|
5865
|
+
transport: "http",
|
|
5866
|
+
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."
|
|
5867
|
+
},
|
|
5868
|
+
/* ------------------------------------------------ org (federation, OIDC) */
|
|
5869
|
+
{
|
|
5870
|
+
method: "GET",
|
|
5871
|
+
path: "/api/org/federation",
|
|
5872
|
+
section: "org",
|
|
5873
|
+
summary: "Reads whether a federated login may join an existing account by verified email.",
|
|
5874
|
+
audience: "developer",
|
|
5875
|
+
auth: "developer",
|
|
5876
|
+
rateLimited: false,
|
|
5877
|
+
ownerTier: false,
|
|
5878
|
+
status: 200,
|
|
5879
|
+
params: [],
|
|
5880
|
+
query: null,
|
|
5881
|
+
request: null,
|
|
5882
|
+
response: orgFederationPolicy,
|
|
5883
|
+
errors: [...DEVELOPER_GUARD],
|
|
5884
|
+
transport: "http",
|
|
5885
|
+
notes: "Any org admin may read it; only an Owner may change it."
|
|
5886
|
+
},
|
|
5887
|
+
{
|
|
5888
|
+
method: "PUT",
|
|
5889
|
+
path: "/api/org/federation",
|
|
5890
|
+
section: "org",
|
|
5891
|
+
summary: "Sets whether a federated login may join an existing account by verified email.",
|
|
5892
|
+
audience: "developer",
|
|
5893
|
+
auth: "developer",
|
|
5894
|
+
rateLimited: false,
|
|
5895
|
+
ownerTier: true,
|
|
5896
|
+
status: 200,
|
|
5897
|
+
params: [],
|
|
5898
|
+
query: null,
|
|
5899
|
+
request: orgFederationPolicyRequest,
|
|
5900
|
+
response: orgFederationPolicy,
|
|
5901
|
+
errors: [...DEVELOPER_GUARD, "tier_required", "validation_error"],
|
|
5902
|
+
transport: "http",
|
|
5903
|
+
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."
|
|
5904
|
+
},
|
|
5905
|
+
{
|
|
5906
|
+
method: "GET",
|
|
5907
|
+
path: "/api/org/groups/:id/oidc-provider",
|
|
5908
|
+
section: "org",
|
|
5909
|
+
summary: "Reads a group's OIDC provider configuration, without the client secret.",
|
|
5910
|
+
audience: "developer",
|
|
5911
|
+
auth: "developer",
|
|
5912
|
+
rateLimited: false,
|
|
5913
|
+
ownerTier: false,
|
|
5914
|
+
status: 200,
|
|
5915
|
+
params: [{ name: "id", description: "The group's uuid, as listed by `GET /api/org/groups`." }],
|
|
5916
|
+
query: null,
|
|
5917
|
+
request: null,
|
|
5918
|
+
response: groupOidcProvider,
|
|
5919
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
5920
|
+
transport: "http",
|
|
5921
|
+
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."
|
|
5922
|
+
},
|
|
5923
|
+
{
|
|
5924
|
+
method: "PUT",
|
|
5925
|
+
path: "/api/org/groups/:id/oidc-provider",
|
|
5926
|
+
section: "org",
|
|
5927
|
+
summary: "Sets or rotates a group's OIDC provider and its just-in-time grants.",
|
|
5928
|
+
audience: "developer",
|
|
5929
|
+
auth: "developer",
|
|
5930
|
+
rateLimited: false,
|
|
5931
|
+
ownerTier: false,
|
|
5932
|
+
status: 200,
|
|
5933
|
+
params: [{ name: "id", description: "The group's uuid, as listed by `GET /api/org/groups`." }],
|
|
5934
|
+
query: null,
|
|
5935
|
+
request: putGroupOidcProviderRequest,
|
|
5936
|
+
response: groupOidcProvider,
|
|
5937
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "target_state_conflict", "validation_error"],
|
|
5938
|
+
transport: "http",
|
|
5939
|
+
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."
|
|
5940
|
+
},
|
|
5941
|
+
{
|
|
5942
|
+
method: "DELETE",
|
|
5943
|
+
path: "/api/org/groups/:id/oidc-provider",
|
|
5944
|
+
section: "org",
|
|
5945
|
+
summary: "Removes a group's OIDC provider.",
|
|
5946
|
+
audience: "developer",
|
|
5947
|
+
auth: "developer",
|
|
5948
|
+
rateLimited: false,
|
|
5949
|
+
ownerTier: false,
|
|
5950
|
+
status: 204,
|
|
5951
|
+
params: [{ name: "id", description: "The group's uuid, as listed by `GET /api/org/groups`." }],
|
|
5952
|
+
query: null,
|
|
5953
|
+
request: null,
|
|
5954
|
+
response: null,
|
|
5955
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
5956
|
+
transport: "http",
|
|
5957
|
+
notes: "Deleting a provider a group never had is an honest `404`, not a silent `204`."
|
|
5958
|
+
},
|
|
5959
|
+
{
|
|
5960
|
+
method: "PATCH",
|
|
5961
|
+
path: "/api/org",
|
|
5962
|
+
section: "org",
|
|
5963
|
+
summary: "Renames the org.",
|
|
5964
|
+
audience: "developer",
|
|
5965
|
+
auth: "developer",
|
|
5966
|
+
rateLimited: false,
|
|
5967
|
+
ownerTier: true,
|
|
5968
|
+
status: 200,
|
|
5969
|
+
params: [],
|
|
5970
|
+
query: null,
|
|
5971
|
+
request: patchOrgRequest,
|
|
5972
|
+
response: null,
|
|
5973
|
+
errors: [...DEVELOPER_GUARD, "tier_required", "validation_error"],
|
|
5974
|
+
transport: "http",
|
|
5975
|
+
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.'
|
|
5976
|
+
},
|
|
5977
|
+
/* --------------------------------------------- oauth (app-scoped clients) */
|
|
5978
|
+
{
|
|
5979
|
+
method: "POST",
|
|
5980
|
+
path: "/api/apps/:id/oauth-clients",
|
|
5981
|
+
section: "oauth",
|
|
5982
|
+
summary: "Registers an OAuth client on the app and returns its `client_id`.",
|
|
5983
|
+
audience: "developer",
|
|
5984
|
+
auth: "developer",
|
|
5985
|
+
rateLimited: false,
|
|
5986
|
+
ownerTier: false,
|
|
5987
|
+
status: 201,
|
|
5988
|
+
params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
|
|
5989
|
+
query: null,
|
|
5990
|
+
request: null,
|
|
5991
|
+
response: oauthClient,
|
|
5992
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
|
|
5993
|
+
transport: "http",
|
|
5994
|
+
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.'
|
|
5995
|
+
},
|
|
5996
|
+
{
|
|
5997
|
+
method: "GET",
|
|
5998
|
+
path: "/api/apps/:id/oauth-clients",
|
|
5999
|
+
section: "oauth",
|
|
6000
|
+
summary: "Lists the app's OAuth clients, developer-registered and self-registered alike.",
|
|
6001
|
+
audience: "developer",
|
|
6002
|
+
auth: "developer",
|
|
6003
|
+
rateLimited: false,
|
|
6004
|
+
ownerTier: false,
|
|
6005
|
+
status: 200,
|
|
6006
|
+
params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
|
|
6007
|
+
query: null,
|
|
6008
|
+
request: null,
|
|
6009
|
+
response: null,
|
|
6010
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
6011
|
+
transport: "http",
|
|
6012
|
+
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.'
|
|
6013
|
+
},
|
|
6014
|
+
{
|
|
6015
|
+
method: "DELETE",
|
|
6016
|
+
path: "/api/apps/:id/oauth-clients/:clientId",
|
|
6017
|
+
section: "oauth",
|
|
6018
|
+
summary: "Removes an OAuth client registration from the app.",
|
|
6019
|
+
audience: "developer",
|
|
6020
|
+
auth: "developer",
|
|
6021
|
+
rateLimited: false,
|
|
6022
|
+
ownerTier: false,
|
|
6023
|
+
status: 204,
|
|
6024
|
+
params: [
|
|
6025
|
+
{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." },
|
|
6026
|
+
{ name: "clientId", description: "The OAuth `client_id` string, as listed by `GET /api/apps/:id/oauth-clients`; a client of another app answers `404`." }
|
|
6027
|
+
],
|
|
6028
|
+
query: null,
|
|
6029
|
+
request: null,
|
|
6030
|
+
response: null,
|
|
6031
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
6032
|
+
transport: "http",
|
|
6033
|
+
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."
|
|
6034
|
+
},
|
|
6035
|
+
{
|
|
6036
|
+
method: "DELETE",
|
|
6037
|
+
path: "/api/apps/:id/oauth-clients/:clientId/consent",
|
|
6038
|
+
section: "oauth",
|
|
6039
|
+
summary: "Revokes every end user's consent grant to one OAuth client of the app.",
|
|
6040
|
+
audience: "developer",
|
|
6041
|
+
auth: "developer",
|
|
6042
|
+
rateLimited: false,
|
|
6043
|
+
ownerTier: false,
|
|
6044
|
+
status: 204,
|
|
6045
|
+
params: [
|
|
6046
|
+
{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." },
|
|
6047
|
+
{ name: "clientId", description: "The OAuth `client_id` string, as listed by `GET /api/apps/:id/oauth-clients`; a client of another app answers `404`." }
|
|
6048
|
+
],
|
|
6049
|
+
query: null,
|
|
6050
|
+
request: null,
|
|
6051
|
+
response: null,
|
|
6052
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
6053
|
+
transport: "http",
|
|
6054
|
+
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.'
|
|
6055
|
+
},
|
|
6056
|
+
/* ----------------------------------------------- oauth (RFC metadata) */
|
|
6057
|
+
{
|
|
6058
|
+
method: "GET",
|
|
6059
|
+
path: "/.well-known/oauth-authorization-server",
|
|
6060
|
+
section: "oauth",
|
|
6061
|
+
summary: "Publishes the authorization-server metadata every OAuth 2.1 client reads before it does anything else.",
|
|
6062
|
+
audience: "client",
|
|
6063
|
+
auth: "none",
|
|
6064
|
+
rateLimited: false,
|
|
6065
|
+
ownerTier: false,
|
|
6066
|
+
status: 200,
|
|
6067
|
+
params: [],
|
|
6068
|
+
query: null,
|
|
6069
|
+
request: null,
|
|
6070
|
+
response: authorizationServerMetadata,
|
|
6071
|
+
errors: [],
|
|
6072
|
+
transport: "http",
|
|
6073
|
+
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.'
|
|
6074
|
+
},
|
|
6075
|
+
{
|
|
6076
|
+
method: "GET",
|
|
6077
|
+
path: "/.well-known/oauth-authorization-server/:appIdentifier",
|
|
6078
|
+
section: "oauth",
|
|
6079
|
+
summary: "The same metadata for one app, whose `registration_endpoint` already carries the app identifier.",
|
|
6080
|
+
audience: "client",
|
|
6081
|
+
auth: "none",
|
|
6082
|
+
rateLimited: false,
|
|
6083
|
+
ownerTier: false,
|
|
6084
|
+
status: 200,
|
|
6085
|
+
params: [{ name: "appIdentifier", description: "The app's `identifier`, the slug a developer chose when the app was created." }],
|
|
6086
|
+
query: null,
|
|
6087
|
+
request: null,
|
|
6088
|
+
response: authorizationServerMetadata,
|
|
6089
|
+
errors: ["not_found"],
|
|
6090
|
+
transport: "http",
|
|
6091
|
+
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."
|
|
6092
|
+
},
|
|
6093
|
+
{
|
|
6094
|
+
method: "GET",
|
|
6095
|
+
path: "/.well-known/oauth-protected-resource",
|
|
6096
|
+
section: "oauth",
|
|
6097
|
+
summary: "Publishes what the stub resource says about who may authorize for it.",
|
|
6098
|
+
audience: "client",
|
|
6099
|
+
auth: "none",
|
|
6100
|
+
rateLimited: false,
|
|
6101
|
+
ownerTier: false,
|
|
6102
|
+
status: 200,
|
|
6103
|
+
params: [],
|
|
6104
|
+
query: null,
|
|
6105
|
+
request: null,
|
|
6106
|
+
response: protectedResourceMetadata,
|
|
6107
|
+
errors: [],
|
|
6108
|
+
transport: "http",
|
|
6109
|
+
notes: "RFC 9728, for the global `/mcp-stub/resource`. The per-app document lives under `/.well-known/oauth-protected-resource/mcp-stub/resource/:appIdentifier`."
|
|
6110
|
+
},
|
|
6111
|
+
/* ------------------------------------------- oauth (the app sign-in flow) */
|
|
6112
|
+
{
|
|
6113
|
+
method: "GET",
|
|
6114
|
+
path: "/oauth/authorize",
|
|
6115
|
+
section: "oauth",
|
|
6116
|
+
summary: "Starts an end-user sign-in for an app and redirects the browser to the login card.",
|
|
6117
|
+
audience: "client",
|
|
6118
|
+
auth: "none",
|
|
6119
|
+
rateLimited: true,
|
|
6120
|
+
ownerTier: false,
|
|
6121
|
+
status: 302,
|
|
6122
|
+
params: [],
|
|
6123
|
+
query: null,
|
|
6124
|
+
request: null,
|
|
6125
|
+
response: null,
|
|
6126
|
+
errors: ["rate_limited"],
|
|
6127
|
+
transport: "http",
|
|
6128
|
+
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`."
|
|
6129
|
+
},
|
|
6130
|
+
{
|
|
6131
|
+
method: "POST",
|
|
6132
|
+
path: "/login",
|
|
6133
|
+
section: "oauth",
|
|
6134
|
+
summary: "Checks an end user's password and hands back where the sign-in continues.",
|
|
6135
|
+
audience: "internal",
|
|
6136
|
+
auth: "none",
|
|
6137
|
+
rateLimited: true,
|
|
6138
|
+
ownerTier: false,
|
|
6139
|
+
status: 200,
|
|
6140
|
+
params: [],
|
|
6141
|
+
query: null,
|
|
6142
|
+
request: oauthLoginRequest,
|
|
6143
|
+
response: oauthRedirectResponse,
|
|
6144
|
+
errors: ["rate_limited", "validation_error", "token_spent", "invalid_credentials", "forbidden"],
|
|
6145
|
+
transport: "http",
|
|
6146
|
+
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."
|
|
6147
|
+
},
|
|
6148
|
+
{
|
|
6149
|
+
method: "GET",
|
|
6150
|
+
path: "/login",
|
|
6151
|
+
section: "oauth",
|
|
6152
|
+
summary: "Serves the app login card the authorize step redirects a browser to.",
|
|
6153
|
+
audience: "internal",
|
|
6154
|
+
auth: "none",
|
|
6155
|
+
rateLimited: false,
|
|
6156
|
+
ownerTier: false,
|
|
6157
|
+
status: 200,
|
|
6158
|
+
params: [],
|
|
6159
|
+
query: null,
|
|
6160
|
+
request: null,
|
|
6161
|
+
response: null,
|
|
6162
|
+
errors: [],
|
|
6163
|
+
transport: "http",
|
|
6164
|
+
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.'
|
|
6165
|
+
},
|
|
6166
|
+
{
|
|
6167
|
+
method: "GET",
|
|
6168
|
+
path: "/oauth/impersonate",
|
|
6169
|
+
section: "oauth",
|
|
6170
|
+
summary: 'Serves the "sign in as" card an org admin sees instead of going straight to the app.',
|
|
6171
|
+
audience: "internal",
|
|
6172
|
+
auth: "none",
|
|
6173
|
+
rateLimited: false,
|
|
6174
|
+
ownerTier: false,
|
|
6175
|
+
status: 200,
|
|
6176
|
+
params: [],
|
|
6177
|
+
query: null,
|
|
6178
|
+
request: null,
|
|
6179
|
+
response: null,
|
|
6180
|
+
errors: [],
|
|
6181
|
+
transport: "http",
|
|
6182
|
+
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."
|
|
6183
|
+
},
|
|
6184
|
+
{
|
|
6185
|
+
method: "POST",
|
|
6186
|
+
path: "/oauth/impersonate",
|
|
6187
|
+
section: "oauth",
|
|
6188
|
+
summary: "Takes the org admin's choice of role or user and resumes the app sign-in as them.",
|
|
6189
|
+
audience: "internal",
|
|
6190
|
+
auth: "none",
|
|
6191
|
+
rateLimited: true,
|
|
6192
|
+
ownerTier: false,
|
|
6193
|
+
status: 200,
|
|
6194
|
+
params: [],
|
|
6195
|
+
query: null,
|
|
6196
|
+
request: null,
|
|
6197
|
+
response: oauthRedirectResponse,
|
|
6198
|
+
errors: ["rate_limited", "validation_error", "token_spent", "unauthorized", "forbidden"],
|
|
6199
|
+
transport: "http",
|
|
6200
|
+
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.'
|
|
6201
|
+
},
|
|
6202
|
+
{
|
|
6203
|
+
method: "GET",
|
|
6204
|
+
path: "/oauth/consent",
|
|
6205
|
+
section: "oauth",
|
|
6206
|
+
summary: "Serves the consent screen for a client asking to act for an end user.",
|
|
6207
|
+
audience: "internal",
|
|
6208
|
+
auth: "none",
|
|
6209
|
+
rateLimited: false,
|
|
6210
|
+
ownerTier: false,
|
|
6211
|
+
status: 200,
|
|
6212
|
+
params: [],
|
|
6213
|
+
query: null,
|
|
6214
|
+
request: null,
|
|
6215
|
+
response: null,
|
|
6216
|
+
errors: [],
|
|
6217
|
+
transport: "http",
|
|
6218
|
+
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."
|
|
6219
|
+
},
|
|
6220
|
+
{
|
|
6221
|
+
method: "POST",
|
|
6222
|
+
path: "/oauth/consent",
|
|
6223
|
+
section: "oauth",
|
|
6224
|
+
summary: "Records the end user's allow-or-deny and sends the browser back to the client.",
|
|
6225
|
+
audience: "internal",
|
|
6226
|
+
auth: "none",
|
|
6227
|
+
rateLimited: true,
|
|
6228
|
+
ownerTier: false,
|
|
6229
|
+
status: 200,
|
|
6230
|
+
params: [],
|
|
6231
|
+
query: null,
|
|
6232
|
+
request: consentDecision,
|
|
6233
|
+
response: oauthRedirectResponse,
|
|
6234
|
+
errors: ["rate_limited", "validation_error", "token_spent", "unauthorized", "invalid_credentials"],
|
|
6235
|
+
transport: "http",
|
|
6236
|
+
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."
|
|
6237
|
+
},
|
|
6238
|
+
{
|
|
6239
|
+
method: "POST",
|
|
6240
|
+
path: "/oauth/token",
|
|
6241
|
+
section: "oauth",
|
|
6242
|
+
summary: "Exchanges an authorization code, or a refresh token, for an end-user access token.",
|
|
6243
|
+
audience: "client",
|
|
6244
|
+
auth: "none",
|
|
6245
|
+
rateLimited: true,
|
|
6246
|
+
ownerTier: false,
|
|
6247
|
+
status: 200,
|
|
6248
|
+
params: [],
|
|
6249
|
+
query: null,
|
|
6250
|
+
request: oauthTokenRequest,
|
|
6251
|
+
response: oauthTokenResponse,
|
|
6252
|
+
errors: ["rate_limited"],
|
|
6253
|
+
transport: "http",
|
|
6254
|
+
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."
|
|
6255
|
+
},
|
|
6256
|
+
{
|
|
6257
|
+
method: "POST",
|
|
6258
|
+
path: "/oauth/register",
|
|
6259
|
+
section: "oauth",
|
|
6260
|
+
summary: "Registers a client dynamically against one app, when that app accepts dynamic clients.",
|
|
6261
|
+
audience: "client",
|
|
6262
|
+
auth: "none",
|
|
6263
|
+
rateLimited: true,
|
|
6264
|
+
ownerTier: false,
|
|
6265
|
+
status: 201,
|
|
6266
|
+
params: [],
|
|
6267
|
+
query: null,
|
|
6268
|
+
request: dynamicClientRegistrationRequest,
|
|
6269
|
+
response: dynamicClientRegistrationResponse,
|
|
6270
|
+
errors: ["rate_limited"],
|
|
6271
|
+
transport: "http",
|
|
6272
|
+
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."
|
|
6273
|
+
},
|
|
6274
|
+
/* ------------------------------- oauth (the validating stub resource) */
|
|
6275
|
+
{
|
|
6276
|
+
method: "GET",
|
|
6277
|
+
path: "/mcp-stub/resource",
|
|
6278
|
+
section: "oauth",
|
|
6279
|
+
summary: "A resource that validates an access token's audience, so the minting side has something that refuses.",
|
|
6280
|
+
audience: "internal",
|
|
6281
|
+
auth: "none",
|
|
6282
|
+
rateLimited: false,
|
|
6283
|
+
ownerTier: false,
|
|
6284
|
+
status: 200,
|
|
6285
|
+
params: [],
|
|
6286
|
+
query: null,
|
|
6287
|
+
request: null,
|
|
6288
|
+
response: null,
|
|
6289
|
+
errors: [],
|
|
6290
|
+
transport: "http",
|
|
6291
|
+
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`."
|
|
6292
|
+
},
|
|
6293
|
+
{
|
|
6294
|
+
method: "GET",
|
|
6295
|
+
path: "/mcp-stub/resource/:appIdentifier",
|
|
6296
|
+
section: "oauth",
|
|
6297
|
+
summary: "The same validating stub, scoped to one app.",
|
|
6298
|
+
audience: "internal",
|
|
6299
|
+
auth: "none",
|
|
6300
|
+
rateLimited: false,
|
|
6301
|
+
ownerTier: false,
|
|
6302
|
+
status: 200,
|
|
6303
|
+
params: [{ name: "appIdentifier", description: "The app's `identifier`, the slug a developer chose when the app was created." }],
|
|
6304
|
+
query: null,
|
|
6305
|
+
request: null,
|
|
6306
|
+
response: null,
|
|
6307
|
+
errors: [],
|
|
6308
|
+
transport: "http",
|
|
6309
|
+
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.'
|
|
6310
|
+
},
|
|
6311
|
+
{
|
|
6312
|
+
method: "GET",
|
|
6313
|
+
path: "/.well-known/oauth-protected-resource/mcp-stub/resource/:appIdentifier",
|
|
6314
|
+
section: "oauth",
|
|
6315
|
+
summary: "The protected-resource metadata for one app's stub resource.",
|
|
6316
|
+
audience: "internal",
|
|
6317
|
+
auth: "none",
|
|
6318
|
+
rateLimited: false,
|
|
6319
|
+
ownerTier: false,
|
|
6320
|
+
status: 200,
|
|
6321
|
+
params: [{ name: "appIdentifier", description: "The app's `identifier`, the slug a developer chose when the app was created." }],
|
|
6322
|
+
query: null,
|
|
6323
|
+
request: null,
|
|
6324
|
+
response: protectedResourceMetadata,
|
|
6325
|
+
errors: ["not_found"],
|
|
6326
|
+
transport: "http",
|
|
6327
|
+
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."
|
|
6328
|
+
},
|
|
6329
|
+
{
|
|
6330
|
+
method: "GET",
|
|
6331
|
+
path: "/oauth/idp-callback",
|
|
6332
|
+
section: "oauth",
|
|
6333
|
+
summary: "Takes the identity provider's redirect back and resumes the app sign-in as the federated user.",
|
|
6334
|
+
audience: "internal",
|
|
6335
|
+
auth: "none",
|
|
6336
|
+
rateLimited: false,
|
|
6337
|
+
ownerTier: false,
|
|
6338
|
+
status: 302,
|
|
6339
|
+
params: [],
|
|
6340
|
+
query: null,
|
|
6341
|
+
request: null,
|
|
6342
|
+
response: null,
|
|
6343
|
+
errors: [],
|
|
6344
|
+
transport: "http",
|
|
6345
|
+
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."
|
|
6346
|
+
},
|
|
6347
|
+
/* --------------------------------------------------------------- mcp */
|
|
6348
|
+
{
|
|
6349
|
+
method: "GET",
|
|
6350
|
+
path: "/.well-known/oauth-protected-resource/mcp",
|
|
6351
|
+
section: "mcp",
|
|
6352
|
+
summary: "Publishes what the MCP endpoint says about who may authorize for it.",
|
|
6353
|
+
audience: "client",
|
|
6354
|
+
auth: "none",
|
|
6355
|
+
rateLimited: false,
|
|
6356
|
+
ownerTier: false,
|
|
6357
|
+
status: 200,
|
|
6358
|
+
params: [],
|
|
6359
|
+
query: null,
|
|
6360
|
+
request: null,
|
|
6361
|
+
response: protectedResourceMetadata,
|
|
6362
|
+
errors: [],
|
|
6363
|
+
transport: "http",
|
|
6364
|
+
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."
|
|
6365
|
+
},
|
|
6366
|
+
{
|
|
6367
|
+
method: "GET",
|
|
6368
|
+
path: "/.well-known/oauth-authorization-server/mcp",
|
|
6369
|
+
section: "mcp",
|
|
6370
|
+
summary: "Publishes the authorization-server metadata an MCP client reads to sign a person in.",
|
|
6371
|
+
audience: "client",
|
|
6372
|
+
auth: "none",
|
|
6373
|
+
rateLimited: false,
|
|
6374
|
+
ownerTier: false,
|
|
6375
|
+
status: 200,
|
|
6376
|
+
params: [],
|
|
6377
|
+
query: null,
|
|
6378
|
+
request: null,
|
|
6379
|
+
response: authorizationServerMetadata,
|
|
6380
|
+
errors: [],
|
|
6381
|
+
transport: "http",
|
|
6382
|
+
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."
|
|
6383
|
+
},
|
|
6384
|
+
{
|
|
6385
|
+
method: "POST",
|
|
6386
|
+
path: "/mcp/oauth/register",
|
|
6387
|
+
section: "mcp",
|
|
6388
|
+
summary: "Registers an MCP client dynamically, with no app identifier and no human in the loop.",
|
|
6389
|
+
audience: "client",
|
|
6390
|
+
auth: "none",
|
|
6391
|
+
rateLimited: true,
|
|
6392
|
+
ownerTier: false,
|
|
6393
|
+
status: 201,
|
|
6394
|
+
params: [],
|
|
6395
|
+
query: null,
|
|
6396
|
+
request: null,
|
|
6397
|
+
response: dynamicClientRegistrationResponse,
|
|
6398
|
+
errors: ["rate_limited"],
|
|
6399
|
+
transport: "http",
|
|
6400
|
+
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`."
|
|
6401
|
+
},
|
|
6402
|
+
{
|
|
6403
|
+
method: "GET",
|
|
6404
|
+
path: "/mcp/oauth/authorize",
|
|
6405
|
+
section: "mcp",
|
|
6406
|
+
summary: "Starts an MCP sign-in and redirects the browser to the identify card.",
|
|
6407
|
+
audience: "client",
|
|
6408
|
+
auth: "none",
|
|
6409
|
+
rateLimited: false,
|
|
6410
|
+
ownerTier: false,
|
|
6411
|
+
status: 302,
|
|
6412
|
+
params: [],
|
|
6413
|
+
query: null,
|
|
6414
|
+
request: null,
|
|
6415
|
+
response: null,
|
|
6416
|
+
errors: [],
|
|
6417
|
+
transport: "http",
|
|
6418
|
+
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."
|
|
6419
|
+
},
|
|
6420
|
+
{
|
|
6421
|
+
method: "GET",
|
|
6422
|
+
path: "/mcp/oauth/interaction/:id",
|
|
6423
|
+
section: "mcp",
|
|
6424
|
+
summary: 'Serves the "what is your email address" card of an MCP sign-in.',
|
|
6425
|
+
audience: "internal",
|
|
6426
|
+
auth: "none",
|
|
6427
|
+
rateLimited: false,
|
|
6428
|
+
ownerTier: false,
|
|
6429
|
+
status: 200,
|
|
6430
|
+
params: [{ name: "id", description: "The interaction id minted by `GET /mcp/oauth/authorize`, which redirects the browser here." }],
|
|
6431
|
+
query: null,
|
|
6432
|
+
request: null,
|
|
6433
|
+
response: null,
|
|
6434
|
+
errors: [],
|
|
6435
|
+
transport: "http",
|
|
6436
|
+
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."
|
|
6437
|
+
},
|
|
6438
|
+
{
|
|
6439
|
+
method: "POST",
|
|
6440
|
+
path: "/mcp/oauth/identify",
|
|
6441
|
+
section: "mcp",
|
|
6442
|
+
summary: "Takes the email address and decides whether this person signs in with a password or through their identity provider.",
|
|
6443
|
+
audience: "internal",
|
|
6444
|
+
auth: "none",
|
|
6445
|
+
rateLimited: true,
|
|
6446
|
+
ownerTier: false,
|
|
6447
|
+
status: 200,
|
|
6448
|
+
params: [],
|
|
6449
|
+
query: null,
|
|
6450
|
+
request: null,
|
|
6451
|
+
response: null,
|
|
6452
|
+
errors: ["rate_limited", "validation_error", "token_spent"],
|
|
6453
|
+
transport: "http",
|
|
6454
|
+
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`.'
|
|
6455
|
+
},
|
|
6456
|
+
{
|
|
6457
|
+
method: "POST",
|
|
6458
|
+
path: "/mcp/oauth/login",
|
|
6459
|
+
section: "mcp",
|
|
6460
|
+
summary: "Checks the password and hands back where the MCP sign-in continues.",
|
|
6461
|
+
audience: "internal",
|
|
6462
|
+
auth: "none",
|
|
6463
|
+
rateLimited: true,
|
|
6464
|
+
ownerTier: false,
|
|
6465
|
+
status: 200,
|
|
6466
|
+
params: [],
|
|
6467
|
+
query: null,
|
|
6468
|
+
request: null,
|
|
6469
|
+
response: oauthRedirectResponse,
|
|
6470
|
+
errors: ["rate_limited", "validation_error", "token_spent", "invalid_credentials"],
|
|
6471
|
+
transport: "http",
|
|
6472
|
+
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`.'
|
|
6473
|
+
},
|
|
6474
|
+
{
|
|
6475
|
+
method: "GET",
|
|
6476
|
+
path: "/mcp/oauth/idp-callback",
|
|
6477
|
+
section: "mcp",
|
|
6478
|
+
summary: "Takes the identity provider's redirect back and resumes the MCP sign-in as the federated user.",
|
|
6479
|
+
audience: "internal",
|
|
6480
|
+
auth: "none",
|
|
6481
|
+
rateLimited: false,
|
|
6482
|
+
ownerTier: false,
|
|
6483
|
+
status: 302,
|
|
6484
|
+
params: [],
|
|
6485
|
+
query: null,
|
|
6486
|
+
request: null,
|
|
6487
|
+
response: null,
|
|
6488
|
+
errors: [],
|
|
6489
|
+
transport: "http",
|
|
6490
|
+
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."
|
|
6491
|
+
},
|
|
6492
|
+
{
|
|
6493
|
+
method: "GET",
|
|
6494
|
+
path: "/mcp/oauth/consent/:id",
|
|
6495
|
+
section: "mcp",
|
|
6496
|
+
summary: "Serves the consent screen for an MCP client that registered itself.",
|
|
6497
|
+
audience: "internal",
|
|
6498
|
+
auth: "none",
|
|
6499
|
+
rateLimited: false,
|
|
6500
|
+
ownerTier: false,
|
|
6501
|
+
status: 200,
|
|
6502
|
+
params: [{ name: "id", description: "The interaction id from the sign-in; the login step redirects the browser here." }],
|
|
6503
|
+
query: null,
|
|
6504
|
+
request: null,
|
|
6505
|
+
response: null,
|
|
6506
|
+
errors: [],
|
|
6507
|
+
transport: "http",
|
|
6508
|
+
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."
|
|
6509
|
+
},
|
|
6510
|
+
{
|
|
6511
|
+
method: "POST",
|
|
6512
|
+
path: "/mcp/oauth/consent",
|
|
6513
|
+
section: "mcp",
|
|
6514
|
+
summary: "Records the allow-or-deny and sends the browser back to the MCP client.",
|
|
6515
|
+
audience: "internal",
|
|
6516
|
+
auth: "none",
|
|
6517
|
+
rateLimited: true,
|
|
6518
|
+
ownerTier: false,
|
|
6519
|
+
status: 200,
|
|
6520
|
+
params: [],
|
|
6521
|
+
query: null,
|
|
6522
|
+
request: null,
|
|
6523
|
+
response: oauthRedirectResponse,
|
|
6524
|
+
errors: ["rate_limited", "validation_error", "token_spent"],
|
|
6525
|
+
transport: "http",
|
|
6526
|
+
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."
|
|
6527
|
+
},
|
|
6528
|
+
{
|
|
6529
|
+
method: "POST",
|
|
6530
|
+
path: "/mcp/oauth/token",
|
|
6531
|
+
section: "mcp",
|
|
6532
|
+
summary: "Exchanges an MCP authorization code for an access token.",
|
|
6533
|
+
audience: "client",
|
|
6534
|
+
auth: "none",
|
|
6535
|
+
rateLimited: false,
|
|
6536
|
+
ownerTier: false,
|
|
6537
|
+
status: 200,
|
|
6538
|
+
params: [],
|
|
6539
|
+
query: null,
|
|
6540
|
+
request: null,
|
|
6541
|
+
response: oauthTokenResponse,
|
|
6542
|
+
errors: [],
|
|
6543
|
+
transport: "http",
|
|
6544
|
+
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."
|
|
6545
|
+
},
|
|
6546
|
+
/* ------------------------------- developer auth (the console\'s OAuth portal) */
|
|
6547
|
+
{
|
|
6548
|
+
method: "GET",
|
|
6549
|
+
path: "/console/oauth/authorize",
|
|
6550
|
+
section: "developer-auth",
|
|
6551
|
+
summary: "Starts a console sign-in and redirects the browser to the identify card.",
|
|
6552
|
+
audience: "internal",
|
|
6553
|
+
auth: "none",
|
|
6554
|
+
rateLimited: false,
|
|
6555
|
+
ownerTier: false,
|
|
6556
|
+
status: 302,
|
|
6557
|
+
params: [],
|
|
6558
|
+
query: null,
|
|
6559
|
+
request: null,
|
|
6560
|
+
response: null,
|
|
6561
|
+
errors: [],
|
|
6562
|
+
transport: "http",
|
|
6563
|
+
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."
|
|
6564
|
+
},
|
|
6565
|
+
{
|
|
6566
|
+
method: "GET",
|
|
6567
|
+
path: "/console/oauth/interaction/:id",
|
|
6568
|
+
section: "developer-auth",
|
|
6569
|
+
summary: 'Serves the "what is your email address" card of a console sign-in.',
|
|
6570
|
+
audience: "internal",
|
|
6571
|
+
auth: "none",
|
|
6572
|
+
rateLimited: false,
|
|
6573
|
+
ownerTier: false,
|
|
6574
|
+
status: 200,
|
|
6575
|
+
params: [{ name: "id", description: "The interaction id minted by `GET /console/oauth/authorize`, which redirects the browser here." }],
|
|
6576
|
+
query: null,
|
|
6577
|
+
request: null,
|
|
6578
|
+
response: null,
|
|
6579
|
+
errors: [],
|
|
6580
|
+
transport: "http",
|
|
6581
|
+
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."
|
|
6582
|
+
},
|
|
6583
|
+
{
|
|
6584
|
+
method: "POST",
|
|
6585
|
+
path: "/console/oauth/identify",
|
|
6586
|
+
section: "developer-auth",
|
|
6587
|
+
summary: "Takes the email address and hands back the password step.",
|
|
6588
|
+
audience: "internal",
|
|
6589
|
+
auth: "none",
|
|
6590
|
+
rateLimited: true,
|
|
6591
|
+
ownerTier: false,
|
|
6592
|
+
status: 200,
|
|
6593
|
+
params: [],
|
|
6594
|
+
query: null,
|
|
6595
|
+
request: null,
|
|
6596
|
+
response: null,
|
|
6597
|
+
errors: ["rate_limited", "token_spent"],
|
|
6598
|
+
transport: "http",
|
|
6599
|
+
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.'
|
|
6600
|
+
},
|
|
6601
|
+
{
|
|
6602
|
+
method: "POST",
|
|
6603
|
+
path: "/console/oauth/login",
|
|
6604
|
+
section: "developer-auth",
|
|
6605
|
+
summary: "Checks the password and mints the authorization code the console exchanges.",
|
|
6606
|
+
audience: "internal",
|
|
6607
|
+
auth: "none",
|
|
6608
|
+
rateLimited: true,
|
|
6609
|
+
ownerTier: false,
|
|
6610
|
+
status: 200,
|
|
6611
|
+
params: [],
|
|
6612
|
+
query: null,
|
|
6613
|
+
request: null,
|
|
6614
|
+
response: oauthRedirectResponse,
|
|
6615
|
+
errors: ["rate_limited", "token_spent", "invalid_credentials"],
|
|
6616
|
+
transport: "http",
|
|
6617
|
+
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`.'
|
|
6618
|
+
},
|
|
6619
|
+
{
|
|
6620
|
+
method: "GET",
|
|
6621
|
+
path: "/console/oauth/signup/:id",
|
|
6622
|
+
section: "developer-auth",
|
|
6623
|
+
summary: "Serves step one of console sign-up, the account card.",
|
|
6624
|
+
audience: "internal",
|
|
6625
|
+
auth: "none",
|
|
6626
|
+
rateLimited: false,
|
|
6627
|
+
ownerTier: false,
|
|
6628
|
+
status: 200,
|
|
6629
|
+
params: [{ name: "id", description: "The interaction id minted by `GET /console/oauth/authorize` with `?prompt=create`." }],
|
|
6630
|
+
query: null,
|
|
6631
|
+
request: null,
|
|
6632
|
+
response: null,
|
|
6633
|
+
errors: [],
|
|
6634
|
+
transport: "http",
|
|
6635
|
+
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.'
|
|
6636
|
+
},
|
|
6637
|
+
{
|
|
6638
|
+
method: "POST",
|
|
6639
|
+
path: "/console/oauth/signup",
|
|
6640
|
+
section: "developer-auth",
|
|
6641
|
+
summary: "Takes the sign-up email and password and hands back the organization step.",
|
|
6642
|
+
audience: "internal",
|
|
6643
|
+
auth: "none",
|
|
6644
|
+
rateLimited: true,
|
|
6645
|
+
ownerTier: false,
|
|
6646
|
+
status: 200,
|
|
6647
|
+
params: [],
|
|
6648
|
+
query: null,
|
|
6649
|
+
request: null,
|
|
6650
|
+
response: null,
|
|
6651
|
+
errors: ["rate_limited", "token_spent", "signup_closed", "validation_error", "email_taken"],
|
|
6652
|
+
transport: "http",
|
|
6653
|
+
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.'
|
|
6654
|
+
},
|
|
6655
|
+
{
|
|
6656
|
+
method: "POST",
|
|
6657
|
+
path: "/console/oauth/signup/organization",
|
|
6658
|
+
section: "developer-auth",
|
|
6659
|
+
summary: "Takes the organization name and creates the org, its admin group and its founding Owner.",
|
|
6660
|
+
audience: "internal",
|
|
6661
|
+
auth: "none",
|
|
6662
|
+
rateLimited: true,
|
|
6663
|
+
ownerTier: false,
|
|
6664
|
+
status: 200,
|
|
6665
|
+
params: [],
|
|
6666
|
+
query: null,
|
|
6667
|
+
request: null,
|
|
6668
|
+
response: oauthRedirectResponse,
|
|
6669
|
+
errors: ["rate_limited", "token_spent", "signup_closed", "wrong_browser", "validation_error", "email_taken"],
|
|
6670
|
+
transport: "http",
|
|
6671
|
+
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`."
|
|
6672
|
+
},
|
|
6673
|
+
{
|
|
6674
|
+
method: "POST",
|
|
6675
|
+
path: "/console/oauth/token",
|
|
6676
|
+
section: "developer-auth",
|
|
6677
|
+
summary: "Exchanges the console's authorization code for a developer session.",
|
|
6678
|
+
audience: "internal",
|
|
6679
|
+
auth: "none",
|
|
6680
|
+
rateLimited: false,
|
|
6681
|
+
ownerTier: false,
|
|
6682
|
+
status: 200,
|
|
6683
|
+
params: [],
|
|
6684
|
+
query: null,
|
|
6685
|
+
request: null,
|
|
6686
|
+
response: sessionTokens,
|
|
6687
|
+
errors: [],
|
|
6688
|
+
transport: "http",
|
|
6689
|
+
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."
|
|
6690
|
+
},
|
|
6691
|
+
/* ---------------------------------------------------- mcp (the endpoint) */
|
|
6692
|
+
{
|
|
6693
|
+
method: "GET",
|
|
6694
|
+
path: "/mcp/welcome",
|
|
6695
|
+
section: "mcp",
|
|
6696
|
+
summary: "Serves the page that tells a person which URL to paste into their MCP client.",
|
|
6697
|
+
audience: "internal",
|
|
6698
|
+
auth: "none",
|
|
6699
|
+
rateLimited: false,
|
|
6700
|
+
ownerTier: false,
|
|
6701
|
+
status: 200,
|
|
6702
|
+
params: [],
|
|
6703
|
+
query: null,
|
|
6704
|
+
request: null,
|
|
6705
|
+
response: null,
|
|
6706
|
+
errors: [],
|
|
6707
|
+
transport: "http",
|
|
6708
|
+
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."
|
|
6709
|
+
},
|
|
6710
|
+
{
|
|
6711
|
+
method: "POST",
|
|
6712
|
+
path: "/mcp",
|
|
6713
|
+
section: "mcp",
|
|
6714
|
+
summary: "The one MCP endpoint: a stateless Streamable HTTP transport carrying the robot and console tool catalogs.",
|
|
6715
|
+
audience: "client",
|
|
6716
|
+
auth: "in_handler",
|
|
6717
|
+
rateLimited: false,
|
|
6718
|
+
ownerTier: false,
|
|
6719
|
+
status: 200,
|
|
6720
|
+
params: [],
|
|
6721
|
+
query: null,
|
|
6722
|
+
request: null,
|
|
6723
|
+
response: null,
|
|
6724
|
+
errors: ["unauthorized", "forbidden", "mcp_access_denied"],
|
|
6725
|
+
transport: "http",
|
|
6726
|
+
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."
|
|
6727
|
+
},
|
|
6728
|
+
/* ------------------------------------------------------- apps (branding) */
|
|
6729
|
+
{
|
|
6730
|
+
method: "GET",
|
|
6731
|
+
path: "/api/apps/:id/branding",
|
|
6732
|
+
section: "apps",
|
|
6733
|
+
summary: "Reads the app's login-page branding, or null when it has none.",
|
|
6734
|
+
audience: "developer",
|
|
6735
|
+
auth: "developer",
|
|
6736
|
+
rateLimited: false,
|
|
6737
|
+
ownerTier: false,
|
|
6738
|
+
status: 200,
|
|
6739
|
+
params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
|
|
6740
|
+
query: null,
|
|
6741
|
+
request: null,
|
|
6742
|
+
response: brandingConfig,
|
|
6743
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
6744
|
+
transport: "http",
|
|
6745
|
+
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."
|
|
6746
|
+
},
|
|
6747
|
+
{
|
|
6748
|
+
method: "PUT",
|
|
6749
|
+
path: "/api/apps/:id/branding",
|
|
6750
|
+
section: "apps",
|
|
6751
|
+
summary: "Replaces the app's login-page branding.",
|
|
6752
|
+
audience: "developer",
|
|
6753
|
+
auth: "developer",
|
|
6754
|
+
rateLimited: false,
|
|
6755
|
+
ownerTier: false,
|
|
6756
|
+
status: 200,
|
|
6757
|
+
params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
|
|
6758
|
+
query: null,
|
|
6759
|
+
request: brandingConfig,
|
|
6760
|
+
response: brandingConfig,
|
|
6761
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
|
|
6762
|
+
transport: "http",
|
|
6763
|
+
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."
|
|
6764
|
+
},
|
|
6765
|
+
{
|
|
6766
|
+
method: "DELETE",
|
|
6767
|
+
path: "/api/apps/:id/branding",
|
|
6768
|
+
section: "apps",
|
|
6769
|
+
summary: "Drops the app back to neutral Fleetless branding.",
|
|
6770
|
+
audience: "developer",
|
|
6771
|
+
auth: "developer",
|
|
6772
|
+
rateLimited: false,
|
|
6773
|
+
ownerTier: false,
|
|
6774
|
+
status: 204,
|
|
6775
|
+
params: [{ name: "id", description: "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`." }],
|
|
6776
|
+
query: null,
|
|
6777
|
+
request: null,
|
|
6778
|
+
response: null,
|
|
6779
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
6780
|
+
transport: "http",
|
|
6781
|
+
notes: "Deleting branding an app never had is not an error: the end state is what was asked for."
|
|
6782
|
+
},
|
|
6783
|
+
/* --------------------------------------------------- end-user (client) auth */
|
|
6784
|
+
{
|
|
6785
|
+
method: "POST",
|
|
6786
|
+
path: "/api/client/login",
|
|
6787
|
+
section: "client-auth",
|
|
6788
|
+
summary: "Signs an app user in with an app identifier, an email address and a password.",
|
|
6789
|
+
audience: "client",
|
|
6790
|
+
auth: "none",
|
|
6791
|
+
rateLimited: true,
|
|
6792
|
+
ownerTier: false,
|
|
6793
|
+
status: 200,
|
|
6794
|
+
params: [],
|
|
6795
|
+
query: null,
|
|
6796
|
+
request: clientLoginRequest,
|
|
6797
|
+
response: sessionTokens,
|
|
6798
|
+
errors: ["rate_limited", "validation_error", "invalid_credentials"],
|
|
6799
|
+
transport: "http",
|
|
6800
|
+
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.'
|
|
6801
|
+
},
|
|
6802
|
+
{
|
|
6803
|
+
method: "POST",
|
|
6804
|
+
path: "/api/client/refresh",
|
|
6805
|
+
section: "client-auth",
|
|
6806
|
+
summary: "Rotates an app-user refresh token and mints a fresh access token.",
|
|
6807
|
+
audience: "client",
|
|
6808
|
+
auth: "none",
|
|
6809
|
+
rateLimited: true,
|
|
6810
|
+
ownerTier: false,
|
|
6811
|
+
status: 200,
|
|
6812
|
+
params: [],
|
|
6813
|
+
query: null,
|
|
6814
|
+
request: clientRefreshRequest,
|
|
6815
|
+
response: sessionTokens,
|
|
6816
|
+
errors: ["rate_limited", "validation_error", "token_expired", "token_revoked"],
|
|
6817
|
+
transport: "http",
|
|
6818
|
+
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."
|
|
6819
|
+
},
|
|
6820
|
+
{
|
|
6821
|
+
method: "POST",
|
|
6822
|
+
path: "/api/client/logout",
|
|
6823
|
+
section: "client-auth",
|
|
6824
|
+
summary: "Revokes an app-user refresh family and reports whether the identity provider can also be signed out.",
|
|
6825
|
+
audience: "client",
|
|
6826
|
+
auth: "none",
|
|
6827
|
+
rateLimited: true,
|
|
6828
|
+
ownerTier: false,
|
|
6829
|
+
status: 200,
|
|
6830
|
+
params: [],
|
|
6831
|
+
query: null,
|
|
6832
|
+
request: clientLogoutRequest,
|
|
6833
|
+
response: clientLogoutResponse,
|
|
6834
|
+
errors: ["rate_limited", "validation_error"],
|
|
6835
|
+
transport: "http",
|
|
6836
|
+
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.'
|
|
6837
|
+
},
|
|
6838
|
+
{
|
|
6839
|
+
method: "POST",
|
|
6840
|
+
path: "/api/client/password/change",
|
|
6841
|
+
section: "client-auth",
|
|
6842
|
+
summary: "Changes an app user's own password and answers a fresh session.",
|
|
6843
|
+
audience: "client",
|
|
6844
|
+
auth: "developer_or_client",
|
|
6845
|
+
rateLimited: false,
|
|
6846
|
+
ownerTier: false,
|
|
6847
|
+
status: 200,
|
|
6848
|
+
params: [],
|
|
6849
|
+
query: null,
|
|
6850
|
+
request: passwordChangeRequest,
|
|
6851
|
+
response: sessionTokens,
|
|
6852
|
+
errors: [...CLIENT_GUARD, "validation_error", "invalid_credentials"],
|
|
6853
|
+
transport: "http",
|
|
6854
|
+
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."
|
|
6855
|
+
},
|
|
6856
|
+
{
|
|
6857
|
+
method: "GET",
|
|
6858
|
+
path: "/api/client/grants",
|
|
6859
|
+
section: "client-auth",
|
|
6860
|
+
summary: "Lists the OAuth clients this app user has consented to.",
|
|
6861
|
+
audience: "client",
|
|
6862
|
+
auth: "developer_or_client",
|
|
6863
|
+
rateLimited: false,
|
|
6864
|
+
ownerTier: false,
|
|
6865
|
+
status: 200,
|
|
6866
|
+
params: [],
|
|
6867
|
+
query: null,
|
|
6868
|
+
request: null,
|
|
6869
|
+
response: consentGrantListResponse,
|
|
6870
|
+
errors: [...CLIENT_GUARD],
|
|
6871
|
+
transport: "http",
|
|
6872
|
+
notes: "App-user only: a developer bearer or a server key reaching this through the client surface is `401 unauthorized`."
|
|
6873
|
+
},
|
|
6874
|
+
{
|
|
6875
|
+
method: "DELETE",
|
|
6876
|
+
path: "/api/client/grants/:client_id",
|
|
6877
|
+
section: "client-auth",
|
|
6878
|
+
summary: "Revokes one consent grant and every refresh family issued under it.",
|
|
6879
|
+
audience: "client",
|
|
6880
|
+
auth: "developer_or_client",
|
|
6881
|
+
rateLimited: false,
|
|
6882
|
+
ownerTier: false,
|
|
6883
|
+
status: 200,
|
|
6884
|
+
params: [{ name: "client_id", description: "The OAuth client id, as listed by `GET /api/client/grants`." }],
|
|
6885
|
+
query: null,
|
|
6886
|
+
request: null,
|
|
6887
|
+
response: consentRevokeResponse,
|
|
6888
|
+
errors: [...CLIENT_GUARD, "not_found"],
|
|
6889
|
+
transport: "http",
|
|
6890
|
+
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."
|
|
6891
|
+
},
|
|
6892
|
+
{
|
|
6893
|
+
method: "GET",
|
|
6894
|
+
path: "/api/client/me",
|
|
6895
|
+
section: "client-auth",
|
|
6896
|
+
summary: "Answers who the calling token is and what it is allowed to reach.",
|
|
6897
|
+
audience: "client",
|
|
6898
|
+
auth: "developer_or_client",
|
|
6899
|
+
rateLimited: false,
|
|
6900
|
+
ownerTier: false,
|
|
6901
|
+
status: 200,
|
|
6902
|
+
params: [],
|
|
6903
|
+
query: null,
|
|
6904
|
+
request: null,
|
|
6905
|
+
response: clientIdentity,
|
|
6906
|
+
errors: [...CLIENT_GUARD],
|
|
6907
|
+
transport: "http",
|
|
6908
|
+
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."
|
|
6909
|
+
},
|
|
6910
|
+
/* ------------------------------------------------------------- robots */
|
|
6911
|
+
{
|
|
6912
|
+
method: "POST",
|
|
6913
|
+
path: "/api/robots",
|
|
6914
|
+
section: "robots",
|
|
6915
|
+
summary: "Creates a robot and returns its bridge token once.",
|
|
6916
|
+
audience: "developer",
|
|
6917
|
+
auth: "developer",
|
|
6918
|
+
rateLimited: false,
|
|
6919
|
+
ownerTier: false,
|
|
6920
|
+
status: 201,
|
|
6921
|
+
params: [],
|
|
6922
|
+
query: null,
|
|
6923
|
+
request: createRobotRequest,
|
|
6924
|
+
response: createRobotResponse,
|
|
6925
|
+
errors: [...DEVELOPER_GUARD, "validation_error", "quota_exceeded"],
|
|
6926
|
+
transport: "http",
|
|
6927
|
+
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."
|
|
6928
|
+
},
|
|
6929
|
+
{
|
|
6930
|
+
method: "GET",
|
|
6931
|
+
path: "/api/robots",
|
|
6932
|
+
section: "robots",
|
|
6933
|
+
summary: "Lists the org's robots with their connection state and exposure counts.",
|
|
6934
|
+
audience: "developer",
|
|
6935
|
+
auth: "developer",
|
|
6936
|
+
rateLimited: false,
|
|
6937
|
+
ownerTier: false,
|
|
6938
|
+
status: 200,
|
|
6939
|
+
params: [],
|
|
6940
|
+
query: null,
|
|
6941
|
+
request: null,
|
|
6942
|
+
response: robotListResponse,
|
|
6943
|
+
errors: [...DEVELOPER_GUARD],
|
|
6944
|
+
transport: "http"
|
|
6945
|
+
},
|
|
6946
|
+
{
|
|
6947
|
+
method: "GET",
|
|
6948
|
+
path: "/api/robots/:id",
|
|
6949
|
+
section: "robots",
|
|
6950
|
+
summary: "Reads one robot with its published configuration state and live bridge state.",
|
|
6951
|
+
audience: "developer",
|
|
6952
|
+
auth: "developer",
|
|
6953
|
+
rateLimited: false,
|
|
6954
|
+
ownerTier: false,
|
|
6955
|
+
status: 200,
|
|
6956
|
+
params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
|
|
6957
|
+
query: null,
|
|
6958
|
+
request: null,
|
|
6959
|
+
response: robotDetailResponse,
|
|
6960
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
6961
|
+
transport: "http",
|
|
6962
|
+
notes: "A robot belonging to another org reads exactly like one that does not exist \u2014 `404`, never a `403`."
|
|
6963
|
+
},
|
|
6964
|
+
{
|
|
6965
|
+
method: "PATCH",
|
|
6966
|
+
path: "/api/robots/:id",
|
|
6967
|
+
section: "robots",
|
|
6968
|
+
summary: "Renames the robot.",
|
|
6969
|
+
audience: "developer",
|
|
6970
|
+
auth: "developer",
|
|
6971
|
+
rateLimited: false,
|
|
6972
|
+
ownerTier: false,
|
|
6973
|
+
status: 200,
|
|
6974
|
+
params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
|
|
6975
|
+
query: null,
|
|
6976
|
+
request: patchRobotRequest,
|
|
6977
|
+
response: null,
|
|
6978
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
|
|
6979
|
+
transport: "http",
|
|
6980
|
+
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.'
|
|
6981
|
+
},
|
|
6982
|
+
{
|
|
6983
|
+
method: "GET",
|
|
6984
|
+
path: "/api/robots/:id/deletion-preview",
|
|
6985
|
+
section: "robots",
|
|
6986
|
+
summary: "Reports what deleting the robot would destroy, without destroying it.",
|
|
6987
|
+
audience: "developer",
|
|
6988
|
+
auth: "developer",
|
|
6989
|
+
rateLimited: false,
|
|
6990
|
+
ownerTier: false,
|
|
6991
|
+
status: 200,
|
|
6992
|
+
params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
|
|
6993
|
+
query: null,
|
|
6994
|
+
request: null,
|
|
6995
|
+
response: robotDeletionSummary,
|
|
6996
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
6997
|
+
transport: "http",
|
|
6998
|
+
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."
|
|
6999
|
+
},
|
|
7000
|
+
{
|
|
7001
|
+
method: "DELETE",
|
|
7002
|
+
path: "/api/robots/:id",
|
|
7003
|
+
section: "robots",
|
|
7004
|
+
summary: "Deletes a robot and everything it produced.",
|
|
7005
|
+
audience: "developer",
|
|
7006
|
+
auth: "developer",
|
|
7007
|
+
rateLimited: false,
|
|
7008
|
+
ownerTier: true,
|
|
7009
|
+
status: 204,
|
|
7010
|
+
params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
|
|
7011
|
+
query: null,
|
|
7012
|
+
request: null,
|
|
7013
|
+
response: null,
|
|
7014
|
+
errors: [...DEVELOPER_GUARD, "tier_required", "invalid_uuid", "not_found", "robot_in_use", "robot_deletion_partial"],
|
|
7015
|
+
transport: "http",
|
|
7016
|
+
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".'
|
|
7017
|
+
},
|
|
7018
|
+
{
|
|
7019
|
+
method: "PUT",
|
|
7020
|
+
path: "/api/robots/:id/details",
|
|
7021
|
+
section: "robots",
|
|
7022
|
+
summary: "Replaces the developer-maintained details document shown alongside the robot.",
|
|
7023
|
+
audience: "developer",
|
|
7024
|
+
auth: "developer",
|
|
7025
|
+
rateLimited: false,
|
|
7026
|
+
ownerTier: false,
|
|
7027
|
+
status: 200,
|
|
7028
|
+
params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
|
|
7029
|
+
query: null,
|
|
7030
|
+
request: putRobotDetailsRequest,
|
|
7031
|
+
response: null,
|
|
7032
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error"],
|
|
7033
|
+
transport: "http",
|
|
7034
|
+
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.'
|
|
7035
|
+
},
|
|
7036
|
+
{
|
|
7037
|
+
method: "GET",
|
|
7038
|
+
path: "/api/robots/:id/datapoints",
|
|
7039
|
+
section: "robots",
|
|
7040
|
+
summary: "Lists the datapoints of a robot, filtered to what the caller's role grants.",
|
|
7041
|
+
audience: "client",
|
|
7042
|
+
auth: "developer_or_client",
|
|
7043
|
+
rateLimited: false,
|
|
7044
|
+
ownerTier: false,
|
|
7045
|
+
status: 200,
|
|
7046
|
+
params: [{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." }],
|
|
7047
|
+
query: null,
|
|
7048
|
+
request: null,
|
|
7049
|
+
response: datapointListResponse,
|
|
7050
|
+
errors: [...CLIENT_GUARD, "invalid_uuid", "not_found"],
|
|
7051
|
+
transport: "http",
|
|
7052
|
+
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."
|
|
7053
|
+
},
|
|
7054
|
+
{
|
|
7055
|
+
method: "GET",
|
|
7056
|
+
path: "/api/robots/:id/exposures",
|
|
7057
|
+
section: "robots",
|
|
7058
|
+
summary: "Lists every grantable slug of a robot with its kind \u2014 the material the roles matrix is built from.",
|
|
7059
|
+
audience: "developer",
|
|
7060
|
+
auth: "developer",
|
|
7061
|
+
rateLimited: false,
|
|
7062
|
+
ownerTier: false,
|
|
7063
|
+
status: 200,
|
|
7064
|
+
params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
|
|
7065
|
+
query: null,
|
|
7066
|
+
request: null,
|
|
7067
|
+
response: exposureListResponse,
|
|
7068
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
7069
|
+
transport: "http",
|
|
7070
|
+
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."
|
|
7071
|
+
},
|
|
7072
|
+
{
|
|
7073
|
+
method: "GET",
|
|
7074
|
+
path: "/api/robots/:id/datapoints/:slug",
|
|
7075
|
+
section: "robots",
|
|
7076
|
+
summary: "Reads the latest value of one datapoint.",
|
|
7077
|
+
audience: "client",
|
|
7078
|
+
auth: "developer_or_client",
|
|
7079
|
+
rateLimited: false,
|
|
7080
|
+
ownerTier: false,
|
|
7081
|
+
status: 200,
|
|
7082
|
+
params: [
|
|
7083
|
+
{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
|
|
7084
|
+
{ name: "slug", description: "The datapoint's slug from the published configuration, as listed by `GET /api/robots/:id/datapoints`." }
|
|
7085
|
+
],
|
|
7086
|
+
query: null,
|
|
7087
|
+
request: null,
|
|
7088
|
+
response: datapointValue,
|
|
7089
|
+
errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "unknown_datapoint", "no_data"],
|
|
7090
|
+
transport: "http",
|
|
7091
|
+
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."
|
|
7092
|
+
},
|
|
7093
|
+
/* ------------------------------------------------- config (draft/publish) */
|
|
7094
|
+
{
|
|
7095
|
+
method: "GET",
|
|
7096
|
+
path: "/api/robots/:id/config/draft",
|
|
7097
|
+
section: "config",
|
|
7098
|
+
summary: "Reads the robot's configuration draft, its author text and its current issues.",
|
|
7099
|
+
audience: "developer",
|
|
7100
|
+
auth: "developer",
|
|
7101
|
+
rateLimited: false,
|
|
7102
|
+
ownerTier: false,
|
|
7103
|
+
status: 200,
|
|
7104
|
+
params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
|
|
7105
|
+
query: null,
|
|
7106
|
+
request: null,
|
|
7107
|
+
response: configDraftResponse,
|
|
7108
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
7109
|
+
transport: "http",
|
|
7110
|
+
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."
|
|
7111
|
+
},
|
|
7112
|
+
{
|
|
7113
|
+
method: "PUT",
|
|
7114
|
+
path: "/api/robots/:id/config/draft",
|
|
7115
|
+
section: "config",
|
|
7116
|
+
summary: "Replaces the draft with the author's text and answers the parsed document with its issues.",
|
|
7117
|
+
audience: "developer",
|
|
7118
|
+
auth: "developer",
|
|
7119
|
+
rateLimited: false,
|
|
7120
|
+
ownerTier: false,
|
|
7121
|
+
status: 200,
|
|
7122
|
+
params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
|
|
7123
|
+
query: null,
|
|
7124
|
+
request: putConfigDraftRequest,
|
|
7125
|
+
response: configDraftResponse,
|
|
7126
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error", "invalid_yaml", "unstorable_yaml"],
|
|
7127
|
+
transport: "http",
|
|
7128
|
+
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."
|
|
7129
|
+
},
|
|
7130
|
+
{
|
|
7131
|
+
method: "POST",
|
|
7132
|
+
path: "/api/robots/:id/config/publish",
|
|
7133
|
+
section: "config",
|
|
7134
|
+
summary: "Publishes the draft as an immutable version and sends it to the robot.",
|
|
7135
|
+
audience: "developer",
|
|
7136
|
+
auth: "developer",
|
|
7137
|
+
rateLimited: false,
|
|
7138
|
+
ownerTier: false,
|
|
7139
|
+
status: 200,
|
|
7140
|
+
params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
|
|
7141
|
+
query: null,
|
|
7142
|
+
request: null,
|
|
7143
|
+
response: publishConfigResponse,
|
|
7144
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error", "draft_not_a_document"],
|
|
7145
|
+
transport: "http",
|
|
7146
|
+
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.'
|
|
7147
|
+
},
|
|
7148
|
+
{
|
|
7149
|
+
method: "GET",
|
|
7150
|
+
path: "/api/robots/:id/config/versions",
|
|
7151
|
+
section: "config",
|
|
7152
|
+
summary: "Lists the published configuration versions of a robot with their publish times.",
|
|
7153
|
+
audience: "developer",
|
|
7154
|
+
auth: "developer",
|
|
7155
|
+
rateLimited: false,
|
|
7156
|
+
ownerTier: false,
|
|
7157
|
+
status: 200,
|
|
7158
|
+
params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
|
|
7159
|
+
query: null,
|
|
7160
|
+
request: null,
|
|
7161
|
+
response: configVersionsResponse,
|
|
7162
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
7163
|
+
transport: "http"
|
|
7164
|
+
},
|
|
7165
|
+
{
|
|
7166
|
+
method: "GET",
|
|
7167
|
+
path: "/api/robots/:id/config/versions/:v",
|
|
7168
|
+
section: "config",
|
|
7169
|
+
summary: "Reads one published version: its document and the author text it was published from.",
|
|
7170
|
+
audience: "developer",
|
|
7171
|
+
auth: "developer",
|
|
7172
|
+
rateLimited: false,
|
|
7173
|
+
ownerTier: false,
|
|
7174
|
+
status: 200,
|
|
7175
|
+
params: [
|
|
7176
|
+
{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." },
|
|
7177
|
+
{ name: "v", description: "The version number, as listed by `GET /api/robots/:id/config/versions`." }
|
|
7178
|
+
],
|
|
7179
|
+
query: null,
|
|
7180
|
+
request: null,
|
|
7181
|
+
response: configVersionResponse,
|
|
7182
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
7183
|
+
transport: "http",
|
|
7184
|
+
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."
|
|
7185
|
+
},
|
|
7186
|
+
{
|
|
7187
|
+
method: "POST",
|
|
7188
|
+
path: "/api/robots/:id/config/versions/:v/restore",
|
|
7189
|
+
section: "config",
|
|
7190
|
+
summary: "Copies a published version back into the draft, text and document both.",
|
|
7191
|
+
audience: "developer",
|
|
7192
|
+
auth: "developer",
|
|
7193
|
+
rateLimited: false,
|
|
7194
|
+
ownerTier: false,
|
|
7195
|
+
status: 200,
|
|
7196
|
+
params: [
|
|
7197
|
+
{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." },
|
|
7198
|
+
{ name: "v", description: "The version number, as listed by `GET /api/robots/:id/config/versions`." }
|
|
7199
|
+
],
|
|
7200
|
+
query: null,
|
|
7201
|
+
request: null,
|
|
7202
|
+
response: configDraftResponse,
|
|
7203
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
7204
|
+
transport: "http",
|
|
7205
|
+
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."
|
|
7206
|
+
},
|
|
7207
|
+
{
|
|
7208
|
+
method: "POST",
|
|
7209
|
+
path: "/api/robots/:id/config/rename-slug",
|
|
7210
|
+
section: "config",
|
|
7211
|
+
summary: "Renames a slug in the draft and rewrites every role grant and history row that named it.",
|
|
7212
|
+
audience: "developer",
|
|
7213
|
+
auth: "developer",
|
|
7214
|
+
rateLimited: false,
|
|
7215
|
+
ownerTier: false,
|
|
7216
|
+
status: 200,
|
|
7217
|
+
params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
|
|
7218
|
+
query: null,
|
|
7219
|
+
request: renameSlugRequest,
|
|
7220
|
+
response: renameSlugResponse,
|
|
7221
|
+
errors: [
|
|
7222
|
+
...DEVELOPER_GUARD,
|
|
7223
|
+
"invalid_uuid",
|
|
7224
|
+
"not_found",
|
|
7225
|
+
"validation_error",
|
|
7226
|
+
"draft_not_a_document",
|
|
7227
|
+
"unknown_slug",
|
|
7228
|
+
"reserved_slug",
|
|
7229
|
+
"duplicate_slug",
|
|
7230
|
+
"internal_error"
|
|
7231
|
+
],
|
|
7232
|
+
transport: "http",
|
|
7233
|
+
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."
|
|
7234
|
+
},
|
|
7235
|
+
{
|
|
7236
|
+
method: "GET",
|
|
7237
|
+
path: "/api/robots/:id/config/slug-usage/:slug",
|
|
7238
|
+
section: "config",
|
|
7239
|
+
summary: "Reports what a rename of one slug would touch, before a developer confirms it.",
|
|
7240
|
+
audience: "developer",
|
|
7241
|
+
auth: "developer",
|
|
7242
|
+
rateLimited: false,
|
|
7243
|
+
ownerTier: false,
|
|
7244
|
+
status: 200,
|
|
7245
|
+
params: [
|
|
7246
|
+
{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." },
|
|
7247
|
+
{ name: "slug", description: "The slug in the **draft** whose blast radius is being previewed." }
|
|
7248
|
+
],
|
|
7249
|
+
query: null,
|
|
7250
|
+
request: null,
|
|
7251
|
+
response: slugUsageResponse,
|
|
7252
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "draft_not_a_document"],
|
|
7253
|
+
transport: "http",
|
|
7254
|
+
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."
|
|
7255
|
+
},
|
|
7256
|
+
/* -------------------------------------------------------------- alerts */
|
|
7257
|
+
{
|
|
7258
|
+
method: "GET",
|
|
7259
|
+
path: "/api/robots/:id/alerts",
|
|
7260
|
+
section: "alerts",
|
|
7261
|
+
summary: "Lists a robot's alerts as defined in its published configuration, joined with their runtime state.",
|
|
7262
|
+
audience: "developer",
|
|
7263
|
+
auth: "developer",
|
|
7264
|
+
rateLimited: false,
|
|
7265
|
+
ownerTier: false,
|
|
7266
|
+
status: 200,
|
|
7267
|
+
params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
|
|
7268
|
+
query: null,
|
|
7269
|
+
request: null,
|
|
7270
|
+
response: alertListResponse,
|
|
7271
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
7272
|
+
transport: "http",
|
|
7273
|
+
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."
|
|
7274
|
+
},
|
|
7275
|
+
{
|
|
7276
|
+
method: "GET",
|
|
7277
|
+
path: "/api/org/alerts",
|
|
7278
|
+
section: "alerts",
|
|
7279
|
+
summary: "Lists every firing alert across the org, with the robot each belongs to.",
|
|
7280
|
+
audience: "developer",
|
|
7281
|
+
auth: "developer",
|
|
7282
|
+
rateLimited: false,
|
|
7283
|
+
ownerTier: false,
|
|
7284
|
+
status: 200,
|
|
7285
|
+
params: [],
|
|
7286
|
+
query: null,
|
|
7287
|
+
request: null,
|
|
7288
|
+
response: orgFiringAlertsResponse,
|
|
7289
|
+
errors: [...DEVELOPER_GUARD, "validation_error"],
|
|
7290
|
+
transport: "http",
|
|
7291
|
+
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."
|
|
7292
|
+
},
|
|
7293
|
+
/* ------------------------------------------------- robots (introspection) */
|
|
7294
|
+
{
|
|
7295
|
+
method: "GET",
|
|
7296
|
+
path: "/api/robots/:id/introspection",
|
|
7297
|
+
section: "robots",
|
|
7298
|
+
summary: "Reads the cached ROS graph of a robot and whether it is stale.",
|
|
7299
|
+
audience: "developer",
|
|
7300
|
+
auth: "developer",
|
|
7301
|
+
rateLimited: false,
|
|
7302
|
+
ownerTier: false,
|
|
7303
|
+
status: 200,
|
|
7304
|
+
params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
|
|
7305
|
+
query: null,
|
|
7306
|
+
request: null,
|
|
7307
|
+
response: introspectionResponse,
|
|
7308
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
7309
|
+
transport: "http",
|
|
7310
|
+
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."
|
|
7311
|
+
},
|
|
7312
|
+
{
|
|
7313
|
+
method: "POST",
|
|
7314
|
+
path: "/api/robots/:id/introspection/refresh",
|
|
7315
|
+
section: "robots",
|
|
7316
|
+
summary: "Asks the robot for a fresh ROS graph, stores it and answers it.",
|
|
7317
|
+
audience: "developer",
|
|
7318
|
+
auth: "developer",
|
|
7319
|
+
rateLimited: false,
|
|
7320
|
+
ownerTier: false,
|
|
7321
|
+
status: 200,
|
|
7322
|
+
params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
|
|
7323
|
+
query: null,
|
|
7324
|
+
request: null,
|
|
7325
|
+
response: introspectionResponse,
|
|
7326
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "robot_offline", "bridge_timeout"],
|
|
7327
|
+
transport: "http",
|
|
7328
|
+
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."
|
|
7329
|
+
},
|
|
7330
|
+
{
|
|
7331
|
+
method: "GET",
|
|
7332
|
+
path: "/api/robots/:id/types",
|
|
7333
|
+
section: "robots",
|
|
7334
|
+
summary: "Lists every ROS message type definition stored for the robot.",
|
|
7335
|
+
audience: "developer",
|
|
7336
|
+
auth: "developer",
|
|
7337
|
+
rateLimited: false,
|
|
7338
|
+
ownerTier: false,
|
|
7339
|
+
status: 200,
|
|
7340
|
+
params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
|
|
7341
|
+
query: null,
|
|
7342
|
+
request: null,
|
|
7343
|
+
response: typesResponse,
|
|
7344
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
7345
|
+
transport: "http",
|
|
7346
|
+
notes: "A plain read with no bridge involved \u2014 the robot need not be online."
|
|
7347
|
+
},
|
|
7348
|
+
{
|
|
7349
|
+
method: "POST",
|
|
7350
|
+
path: "/api/robots/:id/types/fetch",
|
|
7351
|
+
section: "robots",
|
|
7352
|
+
summary: "Fetches named message type definitions from the robot and stores them.",
|
|
7353
|
+
audience: "developer",
|
|
7354
|
+
auth: "developer",
|
|
7355
|
+
rateLimited: false,
|
|
7356
|
+
ownerTier: false,
|
|
7357
|
+
status: 200,
|
|
7358
|
+
params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
|
|
7359
|
+
query: null,
|
|
7360
|
+
request: fetchTypesRequest,
|
|
7361
|
+
response: fetchTypesResponse,
|
|
7362
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found", "validation_error", "robot_offline", "bridge_timeout"],
|
|
7363
|
+
transport: "http",
|
|
7364
|
+
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."
|
|
7365
|
+
},
|
|
7366
|
+
/* ---------------------------------------------- commands (jobs, publishers) */
|
|
7367
|
+
{
|
|
7368
|
+
method: "GET",
|
|
7369
|
+
path: "/api/robots/:id/jobs",
|
|
7370
|
+
section: "commands",
|
|
7371
|
+
summary: "Reads the current job on every slug of the robot the caller is granted.",
|
|
7372
|
+
audience: "client",
|
|
7373
|
+
auth: "developer_or_client",
|
|
7374
|
+
rateLimited: false,
|
|
7375
|
+
ownerTier: false,
|
|
7376
|
+
status: 200,
|
|
7377
|
+
params: [{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." }],
|
|
7378
|
+
query: null,
|
|
7379
|
+
request: null,
|
|
7380
|
+
response: robotJobsResponse,
|
|
7381
|
+
errors: [...CLIENT_GUARD, "invalid_uuid", "not_found"],
|
|
7382
|
+
transport: "http",
|
|
7383
|
+
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."
|
|
7384
|
+
},
|
|
7385
|
+
{
|
|
7386
|
+
method: "GET",
|
|
7387
|
+
path: "/api/robots/:id/jobs/history",
|
|
7388
|
+
section: "commands",
|
|
7389
|
+
summary: "Reads what has run on the robot, newest first, cursor-paged.",
|
|
7390
|
+
audience: "client",
|
|
7391
|
+
auth: "developer_or_client",
|
|
7392
|
+
rateLimited: false,
|
|
7393
|
+
ownerTier: false,
|
|
7394
|
+
status: 200,
|
|
7395
|
+
params: [{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." }],
|
|
7396
|
+
query: jobRunQuery,
|
|
7397
|
+
request: null,
|
|
7398
|
+
response: jobRunListResponse,
|
|
7399
|
+
errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "capability_required", "validation_error"],
|
|
7400
|
+
transport: "http",
|
|
7401
|
+
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."
|
|
7402
|
+
},
|
|
7403
|
+
{
|
|
7404
|
+
method: "POST",
|
|
7405
|
+
path: "/api/robots/:id/jobs/:slug",
|
|
7406
|
+
section: "commands",
|
|
7407
|
+
summary: "Invokes an action or calls a service on the robot.",
|
|
7408
|
+
audience: "client",
|
|
7409
|
+
auth: "developer_or_client",
|
|
7410
|
+
rateLimited: false,
|
|
7411
|
+
ownerTier: false,
|
|
7412
|
+
status: 202,
|
|
7413
|
+
params: [
|
|
7414
|
+
{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
|
|
7415
|
+
{ name: "slug", description: "The action or service slug from the published configuration; the cloud already knows which kind it is." }
|
|
7416
|
+
],
|
|
7417
|
+
query: null,
|
|
7418
|
+
request: invokeRequest,
|
|
7419
|
+
response: invokeOrServiceResponse,
|
|
7420
|
+
errors: [
|
|
7421
|
+
...CLIENT_GUARD,
|
|
7422
|
+
"invalid_uuid",
|
|
7423
|
+
"not_found",
|
|
7424
|
+
"validation_error",
|
|
7425
|
+
"parameter_invalid",
|
|
7426
|
+
"robot_offline",
|
|
7427
|
+
"busy",
|
|
7428
|
+
"bridge_timeout",
|
|
7429
|
+
"internal_error"
|
|
7430
|
+
],
|
|
7431
|
+
transport: "http",
|
|
7432
|
+
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."
|
|
7433
|
+
},
|
|
7434
|
+
{
|
|
7435
|
+
method: "GET",
|
|
7436
|
+
path: "/api/robots/:id/jobs/:slug",
|
|
7437
|
+
section: "commands",
|
|
7438
|
+
summary: "Reads the most recent job on one slug.",
|
|
7439
|
+
audience: "client",
|
|
7440
|
+
auth: "developer_or_client",
|
|
7441
|
+
rateLimited: false,
|
|
7442
|
+
ownerTier: false,
|
|
7443
|
+
status: 200,
|
|
7444
|
+
params: [
|
|
7445
|
+
{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
|
|
7446
|
+
{ name: "slug", description: "The action or service slug from the published configuration." }
|
|
7447
|
+
],
|
|
7448
|
+
query: null,
|
|
7449
|
+
request: null,
|
|
7450
|
+
response: jobResponse,
|
|
7451
|
+
errors: [...CLIENT_GUARD, "invalid_uuid", "not_found"],
|
|
7452
|
+
transport: "http",
|
|
7453
|
+
notes: "`job` is `null` when nothing has ever run on that slug \u2014 an answer, not a `404`."
|
|
7454
|
+
},
|
|
7455
|
+
{
|
|
7456
|
+
method: "POST",
|
|
7457
|
+
path: "/api/robots/:id/jobs/:slug/cancel",
|
|
7458
|
+
section: "commands",
|
|
7459
|
+
summary: "Cancels the job running on one slug.",
|
|
7460
|
+
audience: "client",
|
|
7461
|
+
auth: "developer_or_client",
|
|
7462
|
+
rateLimited: false,
|
|
7463
|
+
ownerTier: false,
|
|
7464
|
+
status: 200,
|
|
7465
|
+
params: [
|
|
7466
|
+
{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
|
|
7467
|
+
{ name: "slug", description: "The action slug from the published configuration; a service slug is refused." }
|
|
7468
|
+
],
|
|
7469
|
+
query: null,
|
|
7470
|
+
request: cancelRequest,
|
|
7471
|
+
requestOptional: true,
|
|
7472
|
+
response: jobResponse,
|
|
7473
|
+
errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "validation_error", "not_cancellable", "robot_offline"],
|
|
7474
|
+
transport: "http",
|
|
7475
|
+
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`.'
|
|
7476
|
+
},
|
|
7477
|
+
{
|
|
7478
|
+
method: "POST",
|
|
7479
|
+
path: "/api/robots/:id/publishers/:slug",
|
|
7480
|
+
section: "commands",
|
|
7481
|
+
summary: "Publishes one message onto a configured publisher.",
|
|
7482
|
+
audience: "client",
|
|
7483
|
+
auth: "developer_or_client",
|
|
7484
|
+
rateLimited: false,
|
|
7485
|
+
ownerTier: false,
|
|
7486
|
+
status: 204,
|
|
7487
|
+
params: [
|
|
7488
|
+
{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
|
|
7489
|
+
{ name: "slug", description: "The publisher slug from the published configuration." }
|
|
7490
|
+
],
|
|
7491
|
+
query: null,
|
|
7492
|
+
request: publishRequest,
|
|
7493
|
+
response: null,
|
|
7494
|
+
errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "validation_error", "parameter_invalid", "robot_offline", "publisher_busy"],
|
|
7495
|
+
transport: "http",
|
|
7496
|
+
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."
|
|
7497
|
+
},
|
|
7498
|
+
/* ------------------------------------------------------------- cameras */
|
|
7499
|
+
{
|
|
7500
|
+
method: "GET",
|
|
7501
|
+
path: "/api/robots/:id/cameras",
|
|
7502
|
+
section: "cameras",
|
|
7503
|
+
summary: "Lists the cameras of a robot, filtered to what the caller's role grants.",
|
|
7504
|
+
audience: "client",
|
|
7505
|
+
auth: "developer_or_client",
|
|
7506
|
+
rateLimited: false,
|
|
7507
|
+
ownerTier: false,
|
|
7508
|
+
status: 200,
|
|
7509
|
+
params: [{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." }],
|
|
7510
|
+
query: null,
|
|
7511
|
+
request: null,
|
|
7512
|
+
response: cameraListResponse,
|
|
7513
|
+
errors: [...CLIENT_GUARD, "invalid_uuid", "not_found"],
|
|
7514
|
+
transport: "http"
|
|
7515
|
+
},
|
|
7516
|
+
{
|
|
7517
|
+
method: "GET",
|
|
7518
|
+
path: "/api/robots/:id/cameras/:slug/snapshot",
|
|
7519
|
+
section: "cameras",
|
|
7520
|
+
summary: "Returns the most recent snapshot frame as image bytes.",
|
|
7521
|
+
audience: "client",
|
|
7522
|
+
auth: "developer_or_client",
|
|
7523
|
+
rateLimited: false,
|
|
7524
|
+
ownerTier: false,
|
|
7525
|
+
status: 200,
|
|
7526
|
+
params: [
|
|
7527
|
+
{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
|
|
7528
|
+
{ name: "slug", description: "The camera slug from the published configuration, as listed by `GET /api/robots/:id/cameras`." }
|
|
7529
|
+
],
|
|
7530
|
+
query: null,
|
|
7531
|
+
request: null,
|
|
7532
|
+
response: null,
|
|
7533
|
+
errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "no_snapshot_yet"],
|
|
7534
|
+
transport: "http",
|
|
7535
|
+
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.'
|
|
7536
|
+
},
|
|
7537
|
+
{
|
|
7538
|
+
method: "GET",
|
|
7539
|
+
path: "/api/robots/:id/cameras/:slug/snapshot/meta",
|
|
7540
|
+
section: "cameras",
|
|
7541
|
+
summary: "Reports the age and dimensions of the latest snapshot without downloading it.",
|
|
7542
|
+
audience: "client",
|
|
7543
|
+
auth: "developer_or_client",
|
|
7544
|
+
rateLimited: false,
|
|
7545
|
+
ownerTier: false,
|
|
7546
|
+
status: 200,
|
|
7547
|
+
params: [
|
|
7548
|
+
{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
|
|
7549
|
+
{ name: "slug", description: "The camera slug from the published configuration, as listed by `GET /api/robots/:id/cameras`." }
|
|
7550
|
+
],
|
|
7551
|
+
query: null,
|
|
7552
|
+
request: null,
|
|
7553
|
+
response: snapshotMetaResponse,
|
|
7554
|
+
errors: [...CLIENT_GUARD, "invalid_uuid", "not_found"],
|
|
7555
|
+
transport: "http",
|
|
7556
|
+
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.'
|
|
7557
|
+
},
|
|
7558
|
+
{
|
|
7559
|
+
method: "POST",
|
|
7560
|
+
path: "/api/robots/:id/cameras/:slug/live",
|
|
7561
|
+
section: "cameras",
|
|
7562
|
+
summary: "Takes a hold on a live camera stream and returns a room token.",
|
|
7563
|
+
audience: "client",
|
|
7564
|
+
auth: "developer_or_client",
|
|
7565
|
+
rateLimited: false,
|
|
7566
|
+
ownerTier: false,
|
|
7567
|
+
status: 201,
|
|
7568
|
+
params: [
|
|
7569
|
+
{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
|
|
7570
|
+
{ name: "slug", description: "The camera slug from the published configuration, as listed by `GET /api/robots/:id/cameras`." }
|
|
7571
|
+
],
|
|
7572
|
+
query: null,
|
|
7573
|
+
request: null,
|
|
7574
|
+
response: liveSessionResponse,
|
|
7575
|
+
errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "robot_offline", "camera_offline", "live_unavailable"],
|
|
7576
|
+
transport: "http",
|
|
7577
|
+
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."
|
|
7578
|
+
},
|
|
7579
|
+
{
|
|
7580
|
+
method: "DELETE",
|
|
7581
|
+
path: "/api/robots/:id/cameras/:slug/live",
|
|
7582
|
+
section: "cameras",
|
|
7583
|
+
summary: "Releases a live hold, one session or all of this caller's.",
|
|
7584
|
+
audience: "client",
|
|
7585
|
+
auth: "developer_or_client",
|
|
7586
|
+
rateLimited: false,
|
|
7587
|
+
ownerTier: false,
|
|
7588
|
+
status: 204,
|
|
7589
|
+
params: [
|
|
7590
|
+
{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
|
|
7591
|
+
{ name: "slug", description: "The camera slug from the published configuration, as listed by `GET /api/robots/:id/cameras`." }
|
|
7592
|
+
],
|
|
7593
|
+
query: releaseLiveQuery,
|
|
7594
|
+
request: null,
|
|
7595
|
+
response: null,
|
|
7596
|
+
errors: [...CLIENT_GUARD, "invalid_uuid", "not_found"],
|
|
7597
|
+
transport: "http",
|
|
7598
|
+
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`."
|
|
7599
|
+
},
|
|
7600
|
+
/* -------------------------------------------------------- robots (history) */
|
|
7601
|
+
{
|
|
7602
|
+
method: "GET",
|
|
7603
|
+
path: "/api/robots/:id/datapoints/:slug/history",
|
|
7604
|
+
section: "robots",
|
|
7605
|
+
summary: "Reads recorded samples of one datapoint, or aggregated buckets over a window.",
|
|
7606
|
+
audience: "client",
|
|
7607
|
+
auth: "developer_or_client",
|
|
7608
|
+
rateLimited: false,
|
|
7609
|
+
ownerTier: false,
|
|
7610
|
+
status: 200,
|
|
7611
|
+
params: [
|
|
7612
|
+
{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
|
|
7613
|
+
{ name: "slug", description: "The datapoint's slug from the published configuration." }
|
|
7614
|
+
],
|
|
7615
|
+
query: historyQuery,
|
|
7616
|
+
request: null,
|
|
7617
|
+
response: historyResponse,
|
|
7618
|
+
errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "validation_error", "invalid_range", "not_recorded", "not_aggregatable"],
|
|
7619
|
+
transport: "http",
|
|
7620
|
+
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."
|
|
7621
|
+
},
|
|
7622
|
+
/* ------------------------------------------------------ assets (URDF, meshes) */
|
|
7623
|
+
{
|
|
7624
|
+
method: "GET",
|
|
7625
|
+
path: "/api/robots/:id/assets",
|
|
7626
|
+
section: "assets",
|
|
7627
|
+
summary: "Lists the robot's synced assets and how complete its URDF is.",
|
|
7628
|
+
audience: "client",
|
|
7629
|
+
auth: "developer_or_client",
|
|
7630
|
+
rateLimited: false,
|
|
7631
|
+
ownerTier: false,
|
|
7632
|
+
status: 200,
|
|
7633
|
+
params: [{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." }],
|
|
7634
|
+
query: null,
|
|
7635
|
+
request: null,
|
|
7636
|
+
response: assetListResponse,
|
|
7637
|
+
errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "capability_required"],
|
|
7638
|
+
transport: "http",
|
|
7639
|
+
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."
|
|
7640
|
+
},
|
|
7641
|
+
{
|
|
7642
|
+
method: "GET",
|
|
7643
|
+
path: "/api/robots/:id/assets/:assetId",
|
|
7644
|
+
section: "assets",
|
|
7645
|
+
summary: "Returns one stored asset as bytes.",
|
|
7646
|
+
audience: "client",
|
|
7647
|
+
auth: "developer_or_client",
|
|
7648
|
+
rateLimited: false,
|
|
7649
|
+
ownerTier: false,
|
|
7650
|
+
status: 200,
|
|
7651
|
+
params: [
|
|
7652
|
+
{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." },
|
|
7653
|
+
{ name: "assetId", description: "The asset's uuid, as listed by `GET /api/robots/:id/assets`." }
|
|
7654
|
+
],
|
|
7655
|
+
query: null,
|
|
7656
|
+
request: null,
|
|
7657
|
+
response: null,
|
|
7658
|
+
errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "capability_required", "internal_error"],
|
|
7659
|
+
transport: "http",
|
|
7660
|
+
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".'
|
|
7661
|
+
},
|
|
7662
|
+
{
|
|
7663
|
+
method: "GET",
|
|
7664
|
+
path: "/api/robots/:id/urdf",
|
|
7665
|
+
section: "assets",
|
|
7666
|
+
summary: "Returns the robot's URDF with every mesh reference rewritten to a Fleetless URL.",
|
|
7667
|
+
audience: "client",
|
|
7668
|
+
auth: "developer_or_client",
|
|
7669
|
+
rateLimited: false,
|
|
7670
|
+
ownerTier: false,
|
|
7671
|
+
status: 200,
|
|
7672
|
+
params: [{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." }],
|
|
7673
|
+
query: null,
|
|
7674
|
+
request: null,
|
|
7675
|
+
response: null,
|
|
7676
|
+
errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "capability_required", "internal_error"],
|
|
7677
|
+
transport: "http",
|
|
7678
|
+
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`."
|
|
7679
|
+
},
|
|
7680
|
+
{
|
|
7681
|
+
method: "GET",
|
|
7682
|
+
path: "/api/robots/:id/assets/missing",
|
|
7683
|
+
section: "assets",
|
|
7684
|
+
summary: "The placeholder a rewritten URDF points at for a mesh Fleetless does not hold.",
|
|
7685
|
+
audience: "client",
|
|
7686
|
+
auth: "developer_or_client",
|
|
7687
|
+
rateLimited: false,
|
|
7688
|
+
ownerTier: false,
|
|
7689
|
+
status: 404,
|
|
7690
|
+
params: [{ name: "id", description: "The robot's uuid; an end user reaches it through an app that attaches it." }],
|
|
7691
|
+
query: null,
|
|
7692
|
+
request: null,
|
|
7693
|
+
response: null,
|
|
7694
|
+
errors: [...CLIENT_GUARD, "invalid_uuid", "not_found", "capability_required", "asset_missing"],
|
|
7695
|
+
transport: "http",
|
|
7696
|
+
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."
|
|
7697
|
+
},
|
|
7698
|
+
{
|
|
7699
|
+
method: "GET",
|
|
7700
|
+
path: "/api/asset-links/missing",
|
|
7701
|
+
section: "assets",
|
|
7702
|
+
summary: "The bearer-free placeholder a *linked* URDF points at for an unresolvable mesh.",
|
|
7703
|
+
audience: "client",
|
|
7704
|
+
auth: "in_handler",
|
|
7705
|
+
rateLimited: false,
|
|
7706
|
+
ownerTier: false,
|
|
7707
|
+
status: 404,
|
|
7708
|
+
params: [],
|
|
7709
|
+
query: null,
|
|
7710
|
+
request: null,
|
|
7711
|
+
response: null,
|
|
7712
|
+
errors: ["asset_missing"],
|
|
7713
|
+
transport: "http",
|
|
7714
|
+
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."
|
|
7715
|
+
},
|
|
7716
|
+
{
|
|
7717
|
+
method: "GET",
|
|
7718
|
+
path: "/api/asset-links/:token",
|
|
7719
|
+
section: "assets",
|
|
7720
|
+
summary: "Serves one asset, or a rendered URDF, to whoever holds a signed link.",
|
|
7721
|
+
audience: "client",
|
|
7722
|
+
auth: "in_handler",
|
|
7723
|
+
rateLimited: false,
|
|
7724
|
+
ownerTier: false,
|
|
7725
|
+
status: 200,
|
|
7726
|
+
params: [{ name: "token", description: "The signed, time-limited link an MCP tool minted; it is the whole credential." }],
|
|
7727
|
+
query: null,
|
|
7728
|
+
request: null,
|
|
7729
|
+
response: null,
|
|
7730
|
+
errors: ["not_found", "internal_error"],
|
|
7731
|
+
transport: "http",
|
|
7732
|
+
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."
|
|
7733
|
+
},
|
|
7734
|
+
{
|
|
7735
|
+
method: "POST",
|
|
7736
|
+
path: "/api/robots/:id/assets/sync",
|
|
7737
|
+
section: "assets",
|
|
7738
|
+
summary: "Asks the robot to upload its URDF and meshes, and returns the sync id.",
|
|
7739
|
+
audience: "client",
|
|
7740
|
+
auth: "developer_or_client",
|
|
7741
|
+
rateLimited: false,
|
|
7742
|
+
ownerTier: true,
|
|
7743
|
+
status: 202,
|
|
7744
|
+
params: [{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." }],
|
|
7745
|
+
query: null,
|
|
7746
|
+
request: assetSyncRequest,
|
|
7747
|
+
response: assetSyncResponse,
|
|
7748
|
+
errors: [...CLIENT_GUARD, "tier_required", "invalid_uuid", "validation_error", "not_found", "robot_offline", "busy"],
|
|
7749
|
+
transport: "http",
|
|
7750
|
+
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."
|
|
7751
|
+
},
|
|
7752
|
+
{
|
|
7753
|
+
method: "GET",
|
|
7754
|
+
path: "/api/robots/:id/assets/sync/:syncId",
|
|
7755
|
+
section: "assets",
|
|
7756
|
+
summary: "Reports how far an asset sync has got.",
|
|
7757
|
+
audience: "client",
|
|
7758
|
+
auth: "developer_or_client",
|
|
7759
|
+
rateLimited: false,
|
|
7760
|
+
ownerTier: false,
|
|
7761
|
+
status: 200,
|
|
7762
|
+
params: [
|
|
7763
|
+
{ name: "id", description: "The robot's uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`." },
|
|
7764
|
+
{ name: "syncId", description: "The sync id from `POST /api/robots/:id/assets/sync`, or from its `409 busy` refusal." }
|
|
7765
|
+
],
|
|
7766
|
+
query: null,
|
|
7767
|
+
request: null,
|
|
7768
|
+
response: assetSyncStatus,
|
|
7769
|
+
errors: [...CLIENT_GUARD, "invalid_uuid", "not_found"],
|
|
7770
|
+
transport: "http",
|
|
7771
|
+
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."
|
|
7772
|
+
},
|
|
7773
|
+
/* ------------------------------------------------ org (quotas and fleet reads) */
|
|
7774
|
+
{
|
|
7775
|
+
method: "GET",
|
|
7776
|
+
path: "/api/org/quotas",
|
|
7777
|
+
section: "org",
|
|
7778
|
+
summary: "Reports every quota's limit next to what the org is currently using.",
|
|
7779
|
+
audience: "developer",
|
|
7780
|
+
auth: "developer",
|
|
7781
|
+
rateLimited: false,
|
|
7782
|
+
ownerTier: false,
|
|
7783
|
+
status: 200,
|
|
7784
|
+
params: [],
|
|
7785
|
+
query: null,
|
|
7786
|
+
request: null,
|
|
7787
|
+
response: orgQuotaUsage,
|
|
7788
|
+
errors: [...DEVELOPER_GUARD],
|
|
7789
|
+
transport: "http",
|
|
7790
|
+
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."
|
|
7791
|
+
},
|
|
7792
|
+
{
|
|
7793
|
+
method: "GET",
|
|
7794
|
+
path: "/api/org/health",
|
|
7795
|
+
section: "org",
|
|
7796
|
+
summary: "Reports the health of every camera and streaming resource across the org.",
|
|
7797
|
+
audience: "developer",
|
|
7798
|
+
auth: "developer",
|
|
7799
|
+
rateLimited: false,
|
|
7800
|
+
ownerTier: false,
|
|
7801
|
+
status: 200,
|
|
7802
|
+
params: [],
|
|
7803
|
+
query: null,
|
|
7804
|
+
request: null,
|
|
7805
|
+
response: resourceHealthListResponse,
|
|
7806
|
+
errors: [...DEVELOPER_GUARD, "invalid_uuid", "not_found"],
|
|
7807
|
+
transport: "http",
|
|
7808
|
+
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."
|
|
7809
|
+
},
|
|
7810
|
+
{
|
|
7811
|
+
method: "GET",
|
|
7812
|
+
path: "/api/org/jobs",
|
|
7813
|
+
section: "org",
|
|
7814
|
+
summary: "Reads durable job-run history across the org, newest first, cursor-paged.",
|
|
7815
|
+
audience: "developer",
|
|
7816
|
+
auth: "developer",
|
|
7817
|
+
rateLimited: false,
|
|
7818
|
+
ownerTier: false,
|
|
7819
|
+
status: 200,
|
|
7820
|
+
params: [],
|
|
7821
|
+
query: jobRunQuery,
|
|
7822
|
+
request: null,
|
|
7823
|
+
response: jobRunListResponse,
|
|
7824
|
+
errors: [...DEVELOPER_GUARD, "validation_error"],
|
|
7825
|
+
transport: "http",
|
|
7826
|
+
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."
|
|
7827
|
+
},
|
|
7828
|
+
{
|
|
7829
|
+
method: "GET",
|
|
7830
|
+
path: "/api/org/jobs/summary",
|
|
7831
|
+
section: "org",
|
|
7832
|
+
summary: "Counts the running, started and failed job runs since a moment the caller names.",
|
|
7833
|
+
audience: "developer",
|
|
7834
|
+
auth: "developer",
|
|
7835
|
+
rateLimited: false,
|
|
7836
|
+
ownerTier: false,
|
|
7837
|
+
status: 200,
|
|
7838
|
+
params: [],
|
|
7839
|
+
query: jobRunSummaryQuery,
|
|
7840
|
+
request: null,
|
|
7841
|
+
response: jobRunSummary,
|
|
7842
|
+
errors: [...DEVELOPER_GUARD, "validation_error"],
|
|
7843
|
+
transport: "http",
|
|
7844
|
+
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.'
|
|
7845
|
+
},
|
|
7846
|
+
{
|
|
7847
|
+
method: "GET",
|
|
7848
|
+
path: "/api/org/latency",
|
|
7849
|
+
section: "org",
|
|
7850
|
+
summary: "Reads one-minute bridge latency buckets per robot over a window the caller names.",
|
|
7851
|
+
audience: "developer",
|
|
7852
|
+
auth: "developer",
|
|
7853
|
+
rateLimited: false,
|
|
7854
|
+
ownerTier: false,
|
|
7855
|
+
status: 200,
|
|
7856
|
+
params: [],
|
|
7857
|
+
query: orgLatencyQuery,
|
|
7858
|
+
request: null,
|
|
7859
|
+
response: orgLatencyResponse,
|
|
7860
|
+
errors: [...DEVELOPER_GUARD, "validation_error"],
|
|
7861
|
+
transport: "http",
|
|
7862
|
+
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`.'
|
|
7863
|
+
},
|
|
7864
|
+
{
|
|
7865
|
+
method: "GET",
|
|
7866
|
+
path: "/api/org/usage",
|
|
7867
|
+
section: "org",
|
|
7868
|
+
summary: "Reads what the org consumed per day, per app and per metric.",
|
|
7869
|
+
audience: "developer",
|
|
7870
|
+
auth: "developer",
|
|
7871
|
+
rateLimited: false,
|
|
7872
|
+
ownerTier: false,
|
|
7873
|
+
status: 200,
|
|
7874
|
+
params: [],
|
|
7875
|
+
query: orgUsageQuery,
|
|
7876
|
+
request: null,
|
|
7877
|
+
response: orgUsageResponse,
|
|
7878
|
+
errors: [...DEVELOPER_GUARD, "validation_error"],
|
|
7879
|
+
transport: "http",
|
|
7880
|
+
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."
|
|
7881
|
+
},
|
|
7882
|
+
/* ------------------------------------------------- assets (robot upload) */
|
|
7883
|
+
{
|
|
7884
|
+
method: "POST",
|
|
7885
|
+
path: "/api/bridge/assets",
|
|
7886
|
+
section: "assets",
|
|
7887
|
+
summary: "Takes one asset file from a robot during a sync.",
|
|
7888
|
+
audience: "internal",
|
|
7889
|
+
auth: "robot_upload",
|
|
7890
|
+
rateLimited: false,
|
|
7891
|
+
ownerTier: false,
|
|
7892
|
+
status: 201,
|
|
7893
|
+
params: [],
|
|
7894
|
+
query: null,
|
|
7895
|
+
request: null,
|
|
7896
|
+
response: asset,
|
|
7897
|
+
errors: ["unauthorized", "rate_limited", "asset_too_large", "validation_error", "not_found", "quota_exceeded", "bad_request"],
|
|
7898
|
+
transport: "http",
|
|
7899
|
+
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."
|
|
7900
|
+
},
|
|
7901
|
+
/* ------------------------------------ realtime and bridge transports */
|
|
7902
|
+
{
|
|
7903
|
+
method: "GET",
|
|
7904
|
+
path: "/bridge",
|
|
7905
|
+
section: "transports",
|
|
7906
|
+
summary: "The robot bridge's WebSocket: the versioned bridge protocol, not a client-facing surface.",
|
|
7907
|
+
audience: "internal",
|
|
7908
|
+
auth: "none",
|
|
7909
|
+
rateLimited: false,
|
|
7910
|
+
ownerTier: false,
|
|
7911
|
+
status: 101,
|
|
7912
|
+
params: [],
|
|
7913
|
+
query: null,
|
|
7914
|
+
request: null,
|
|
7915
|
+
response: null,
|
|
7916
|
+
errors: ["protocol_mismatch", "invalid_token"],
|
|
7917
|
+
transport: "websocket"
|
|
7918
|
+
},
|
|
7919
|
+
{
|
|
7920
|
+
method: "GET",
|
|
7921
|
+
path: "/realtime",
|
|
7922
|
+
section: "transports",
|
|
7923
|
+
summary: "The client WebSocket: subscriptions on datapoints, jobs, bridge state and presence, plus full command parity with REST.",
|
|
7924
|
+
audience: "client",
|
|
7925
|
+
auth: "none",
|
|
7926
|
+
rateLimited: false,
|
|
7927
|
+
ownerTier: false,
|
|
7928
|
+
status: 101,
|
|
7929
|
+
params: [],
|
|
7930
|
+
query: null,
|
|
7931
|
+
request: null,
|
|
7932
|
+
response: null,
|
|
7933
|
+
errors: ["invalid_token", "rate_limited"],
|
|
7934
|
+
transport: "websocket",
|
|
7935
|
+
notes: "Authentication happens in the first frame, not on the upgrade. The frame types are the `realtime` schemas."
|
|
7936
|
+
}
|
|
7937
|
+
];
|
|
7938
|
+
|
|
4518
7939
|
// src/pkce.ts
|
|
4519
7940
|
function requireCrypto() {
|
|
4520
7941
|
const c = globalThis.crypto;
|
|
@@ -5421,9 +8842,14 @@ function createSlugSubscriptions(channel) {
|
|
|
5421
8842
|
// src/token-store.ts
|
|
5422
8843
|
var InMemoryTokenStore = class {
|
|
5423
8844
|
#session = null;
|
|
8845
|
+
/** Creates an empty store. Nothing is loaded from anywhere — a client built with it starts logged out. */
|
|
8846
|
+
constructor() {
|
|
8847
|
+
}
|
|
8848
|
+
/** Returns the session held in memory, or `null` if there is none. */
|
|
5424
8849
|
load() {
|
|
5425
8850
|
return this.#session;
|
|
5426
8851
|
}
|
|
8852
|
+
/** Replaces the session held in memory; `null` clears it. */
|
|
5427
8853
|
save(session) {
|
|
5428
8854
|
this.#session = session;
|
|
5429
8855
|
}
|