@fleetless/contracts 1.0.6 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/oauth.d.ts CHANGED
@@ -168,26 +168,20 @@ export declare const dynamicClientRegistrationResponse: z.ZodObject<{
168
168
  }, z.core.$strip>;
169
169
  export type DynamicClientRegistrationResponse = z.infer<typeof dynamicClientRegistrationResponse>;
170
170
  /**
171
- * **The MCP token endpoint's request — one grant, because the servers serve
172
- * one.**
171
+ * **The MCP token endpoint's request — two grants, one per half of a
172
+ * session.**
173
173
  *
174
- * Both authorization servers, central and per-app, exchange through one
175
- * implementation, whose first act is to refuse anything but
176
- * `authorization_code` before a single lookup happens. There is no refresh grant here: a session ends when its token
177
- * expires and the client signs in again.
174
+ * `authorization_code` mints the first access token and a refresh token;
175
+ * `refresh_token` rotates that refresh token into a new pair. Both MCP
176
+ * authorization servers, central and per-app, answer both; the console's own
177
+ * OAuth portal answers the code grant only.
178
178
  *
179
- * **This was a `discriminatedUnion` with a `refresh_token` branch, and that
180
- * branch had no producer left.** It described the app-level OAuth surface,
181
- * which is deleted; an app user's refresh runs through `POST
182
- * /api/client/refresh` and `refreshRequest`, a different wire on a different
183
- * route. Keeping it would have published, to every MCP client author reading
184
- * `/openapi.json`, a grant the endpoint answers `unsupported_grant_type` to.
185
- * The argument the branch carried is worth keeping even though the branch is
186
- * not: **RFC 8707's `resource` has to survive rotation**, because a refresh
187
- * that drops the audience mints a successor with no `aud`, and the validating
188
- * resource then refuses a token the caller obtained legitimately — one token
189
- * lifetime after a login that worked, to somebody who did nothing wrong. If a
190
- * refresh grant is ever added here, it carries `resource`.
179
+ * **RFC 8707's `resource` has to survive rotation**: a refresh that drops the
180
+ * audience mints a successor with no `aud`, and the validating resource then
181
+ * refuses a token the caller obtained legitimately. So the server keeps the
182
+ * audience on the refresh token's own row, and a `resource` named here must
183
+ * match it or the answer is `invalid_target` — before the token is consumed,
184
+ * so a typo costs nothing.
191
185
  *
192
186
  * `code_verifier`'s bounds are RFC 7636 §4.1's, charset included. A verifier
193
187
  * is compared, not parsed, so a length nobody checks is a length an attacker
@@ -201,7 +195,7 @@ export type DynamicClientRegistrationResponse = z.infer<typeof dynamicClientRegi
201
195
  * **stripped**, which bit the test for this schema: `safeParse().success`
202
196
  * cannot tell a present field from an absent one. Assert on the parsed value.
203
197
  */
204
- export declare const oauthTokenRequest: z.ZodObject<{
198
+ export declare const oauthCodeTokenRequest: z.ZodObject<{
205
199
  grant_type: z.ZodLiteral<"authorization_code">;
206
200
  code: z.ZodString;
207
201
  redirect_uri: z.ZodString;
@@ -209,6 +203,27 @@ export declare const oauthTokenRequest: z.ZodObject<{
209
203
  code_verifier: z.ZodString;
210
204
  resource: z.ZodOptional<z.ZodURL>;
211
205
  }, z.core.$strip>;
206
+ export type OauthCodeTokenRequest = z.infer<typeof oauthCodeTokenRequest>;
207
+ export declare const oauthRefreshTokenRequest: z.ZodObject<{
208
+ grant_type: z.ZodLiteral<"refresh_token">;
209
+ refresh_token: z.ZodString;
210
+ client_id: z.ZodString;
211
+ resource: z.ZodOptional<z.ZodURL>;
212
+ }, z.core.$strip>;
213
+ export type OauthRefreshTokenRequest = z.infer<typeof oauthRefreshTokenRequest>;
214
+ export declare const oauthTokenRequest: z.ZodDiscriminatedUnion<[z.ZodObject<{
215
+ grant_type: z.ZodLiteral<"authorization_code">;
216
+ code: z.ZodString;
217
+ redirect_uri: z.ZodString;
218
+ client_id: z.ZodString;
219
+ code_verifier: z.ZodString;
220
+ resource: z.ZodOptional<z.ZodURL>;
221
+ }, z.core.$strip>, z.ZodObject<{
222
+ grant_type: z.ZodLiteral<"refresh_token">;
223
+ refresh_token: z.ZodString;
224
+ client_id: z.ZodString;
225
+ resource: z.ZodOptional<z.ZodURL>;
226
+ }, z.core.$strip>], "grant_type">;
212
227
  export type OauthTokenRequest = z.infer<typeof oauthTokenRequest>;
213
228
  /**
214
229
  * RFC 6749 §5.1's success envelope — **the second deliberate dialect, and this
package/dist/oauth.js CHANGED
@@ -188,7 +188,7 @@ export const dynamicClientRegistrationRequest = z
188
188
  description: '`none`, RFC 7591\'s value for a public client, and the only value either server registers. Any other value is **refused rather than silently downgraded**: a client that believes it holds a secret and does not has a wrong mental model of its own security. There is no client secret to hold — mandatory PKCE (`S256`) is the defence.',
189
189
  }),
190
190
  grant_types: z.array(z.enum(['authorization_code', 'refresh_token'])).optional().meta({
191
- description: 'Accepted for conformance with RFC 7591 and then **ignored**. What comes back is what was actually granted, which §3.2.1 permits a server to substitute: `authorization_code` and nothing else, so a client that asks for `refresh_token` is registered and told plainly that it did not get one.',
191
+ description: 'Accepted for conformance with RFC 7591 and then **ignored**: both MCP authorization servers grant `authorization_code` and `refresh_token` to every registration, and the answer states what was granted (§3.2.1) rather than what was asked.',
192
192
  }),
193
193
  response_types: z.array(z.enum(['code'])).optional().meta({
194
194
  description: 'Accepted for conformance and then **ignored**; the response names `code`, which is the only response type OAuth 2.1 leaves, the implicit grant having been removed.',
@@ -211,7 +211,7 @@ export const dynamicClientRegistrationResponse = z.object({
211
211
  description: 'The redirect URIs this registration was accepted for. A code is returned to one of these and nowhere else.',
212
212
  }),
213
213
  grant_types: z.array(z.string()).meta({
214
- description: 'The grants this client may use. Always exactly `["authorization_code"]` — a client that asked for `refresh_token` is registered and told here that it did not get one, which is the substitution RFC 7591 §3.2.1 permits.',
214
+ description: 'The grants this client may use. Always exactly `["authorization_code", "refresh_token"]` — an exchange mints a refresh token and the token endpoint rotates it.',
215
215
  }),
216
216
  response_types: z.array(z.string()).meta({
217
217
  description: 'The response types this client may ask for: `code`.',
@@ -227,26 +227,20 @@ export const dynamicClientRegistrationResponse = z.object({
227
227
  }),
228
228
  });
229
229
  /**
230
- * **The MCP token endpoint's request — one grant, because the servers serve
231
- * one.**
230
+ * **The MCP token endpoint's request — two grants, one per half of a
231
+ * session.**
232
232
  *
233
- * Both authorization servers, central and per-app, exchange through one
234
- * implementation, whose first act is to refuse anything but
235
- * `authorization_code` before a single lookup happens. There is no refresh grant here: a session ends when its token
236
- * expires and the client signs in again.
233
+ * `authorization_code` mints the first access token and a refresh token;
234
+ * `refresh_token` rotates that refresh token into a new pair. Both MCP
235
+ * authorization servers, central and per-app, answer both; the console's own
236
+ * OAuth portal answers the code grant only.
237
237
  *
238
- * **This was a `discriminatedUnion` with a `refresh_token` branch, and that
239
- * branch had no producer left.** It described the app-level OAuth surface,
240
- * which is deleted; an app user's refresh runs through `POST
241
- * /api/client/refresh` and `refreshRequest`, a different wire on a different
242
- * route. Keeping it would have published, to every MCP client author reading
243
- * `/openapi.json`, a grant the endpoint answers `unsupported_grant_type` to.
244
- * The argument the branch carried is worth keeping even though the branch is
245
- * not: **RFC 8707's `resource` has to survive rotation**, because a refresh
246
- * that drops the audience mints a successor with no `aud`, and the validating
247
- * resource then refuses a token the caller obtained legitimately — one token
248
- * lifetime after a login that worked, to somebody who did nothing wrong. If a
249
- * refresh grant is ever added here, it carries `resource`.
238
+ * **RFC 8707's `resource` has to survive rotation**: a refresh that drops the
239
+ * audience mints a successor with no `aud`, and the validating resource then
240
+ * refuses a token the caller obtained legitimately. So the server keeps the
241
+ * audience on the refresh token's own row, and a `resource` named here must
242
+ * match it or the answer is `invalid_target` — before the token is consumed,
243
+ * so a typo costs nothing.
250
244
  *
251
245
  * `code_verifier`'s bounds are RFC 7636 §4.1's, charset included. A verifier
252
246
  * is compared, not parsed, so a length nobody checks is a length an attacker
@@ -260,10 +254,10 @@ export const dynamicClientRegistrationResponse = z.object({
260
254
  * **stripped**, which bit the test for this schema: `safeParse().success`
261
255
  * cannot tell a present field from an absent one. Assert on the parsed value.
262
256
  */
263
- export const oauthTokenRequest = z
257
+ export const oauthCodeTokenRequest = z
264
258
  .object({
265
259
  grant_type: z.literal('authorization_code').meta({
266
- description: 'Always `authorization_code`: this request exchanges the code from the authorize redirect for tokens. Any other value — `refresh_token` included — is `unsupported_grant_type`, refused before the code is looked up.',
260
+ description: '`authorization_code`: this request exchanges the code from the authorize redirect for an access token and a refresh token.',
267
261
  }),
268
262
  code: z.string().min(1).max(500).meta({
269
263
  description: 'The authorization code from the redirect. It may be exchanged once; a second presentation is `invalid_grant`, the same answer a fabricated code gets.',
@@ -284,6 +278,27 @@ export const oauthTokenRequest = z
284
278
  .meta({
285
279
  description: "RFC 6749 §4.1.3's authorization-code exchange with PKCE, as either MCP authorization server reads it. Sent as `application/x-www-form-urlencoded`, per §4.1.3, though the server accepts a JSON body too.",
286
280
  });
281
+ export const oauthRefreshTokenRequest = z
282
+ .object({
283
+ grant_type: z.literal('refresh_token').meta({
284
+ description: '`refresh_token`: this request rotates a refresh token into a new access token and a new refresh token. The presented token is consumed; presenting it again revokes the whole session.',
285
+ }),
286
+ refresh_token: z.string().min(1).max(500).meta({
287
+ description: 'The refresh token from the last token response. Bound to the client that received it and to one identity space: presented by another client, or at the other MCP server, it is `invalid_grant` and stays unconsumed.',
288
+ }),
289
+ client_id: z.string().min(1).max(200).meta({
290
+ description: 'The client the refresh token was issued to, as registered. A refresh token is not transferable between clients.',
291
+ }),
292
+ resource: z.url().optional().meta({
293
+ description: 'The resource the new token is for, per RFC 8707. Optional; when named it must be the audience the session was issued for, or the answer is `invalid_target` and the refresh token is left untouched. The successor carries the same audience either way.',
294
+ }),
295
+ })
296
+ .meta({
297
+ description: "RFC 6749 §6's refresh, as either MCP authorization server reads it. Every use rotates: the answer carries a new refresh token and the presented one is dead.",
298
+ });
299
+ export const oauthTokenRequest = z.discriminatedUnion('grant_type', [oauthCodeTokenRequest, oauthRefreshTokenRequest]).meta({
300
+ description: 'What an MCP token endpoint accepts: the authorization-code exchange, or a refresh. Any other `grant_type` is `unsupported_grant_type`, refused before a lookup happens.',
301
+ });
287
302
  /**
288
303
  * RFC 6749 §5.1's success envelope — **the second deliberate dialect, and this
289
304
  * one is a success shape rather than an error shape.**
@@ -311,7 +326,7 @@ export const oauthTokenResponse = z.object({
311
326
  description: 'How long the access token is valid, in **seconds**, per RFC 6749 §5.1. Not a timestamp, and not milliseconds.',
312
327
  }),
313
328
  refresh_token: z.string().min(1).optional().meta({
314
- description: 'The refresh token, when one was issued. It rotates on every use.',
329
+ description: 'The refresh token. Both MCP token endpoints issue one on every exchange and every refresh; it rotates on every use, lives ninety days from its last use, and dies with the account\'s sessions — a block, a password change, a withdrawn consent. The console\'s own OAuth portal issues none.',
315
330
  }),
316
331
  scope: z.string().max(500).optional().meta({
317
332
  description: 'The scopes the issued token actually carries, space-separated.',
@@ -335,7 +350,7 @@ export const authorizationServerMetadata = z.object({
335
350
  description: 'The response types this server offers: `code` only, the implicit grant being gone with OAuth 2.1.',
336
351
  }),
337
352
  grant_types_supported: z.array(z.enum(['authorization_code', 'refresh_token'])).meta({
338
- description: 'The grants this server offers. OAuth 2.1 removes the implicit and password grants, so neither appears here.',
353
+ description: 'The grants this server offers: `authorization_code` and `refresh_token`. OAuth 2.1 removes the implicit and password grants, so neither appears here.',
339
354
  }),
340
355
  code_challenge_methods_supported: z.array(codeChallengeMethod).meta({
341
356
  description: 'The PKCE challenge methods accepted: `S256` only. `plain` is not offered — a challenge equal to its verifier defends against nothing, and offering it would make a downgrade negotiable.',
@@ -254,8 +254,8 @@ export type DatapointEvent = z.infer<typeof datapointEvent>;
254
254
  */
255
255
  export declare const liveSessionEndReason: z.ZodEnum<{
256
256
  unknown: "unknown";
257
- robot_offline: "robot_offline";
258
257
  publish_failed: "publish_failed";
258
+ robot_offline: "robot_offline";
259
259
  released_by_peer: "released_by_peer";
260
260
  config_changed: "config_changed";
261
261
  revoked: "revoked";
@@ -285,8 +285,8 @@ export declare const liveSessionEvent: z.ZodObject<{
285
285
  state: z.ZodLiteral<"ended">;
286
286
  reason: z.ZodEnum<{
287
287
  unknown: "unknown";
288
- robot_offline: "robot_offline";
289
288
  publish_failed: "publish_failed";
289
+ robot_offline: "robot_offline";
290
290
  released_by_peer: "released_by_peer";
291
291
  config_changed: "config_changed";
292
292
  revoked: "revoked";
package/dist/routes.js CHANGED
@@ -4,10 +4,11 @@ import { alertListResponse, orgAlertsQuery, orgFiringAlertsResponse } from './al
4
4
  import { asset, assetListResponse, assetSyncRequest, assetSyncResponse, assetSyncStatus, missingAssetQuery } from './assets.js';
5
5
  import { auditListResponse, auditQuery } from './audit.js';
6
6
  import { CLIENT_OIDC_CALLBACK_PATH, clientAcceptInvitationRequest, clientIdentity, clientLoginRequest, clientLogoutRequest, clientMcpInteraction, clientMcpInteractionDecisionResponse, clientOidcCallbackQuery, clientOidcExchangeRequest, clientOidcStartQuery, clientPasswordResetConfirmRequest, clientPasswordResetRequest, clientProviderListQuery, clientProviderListResponse, clientRefreshRequest, clientRegisterRequest, clientResendVerificationRequest, clientVerifyEmailRequest, mcpConsentGrantListResponse, } from './client-auth.js';
7
+ import { clientRobotListResponse } from './client-robots.js';
7
8
  import { appAuthConfig, appInvitation, appInvitationListResponse, appMailTemplate, appMailTemplateListResponse, appOidcProvider, appOidcProviderListResponse, appUser, appUserListResponse, createAppInvitationRequest, createAppOidcProviderRequest, createAppUserRequest, mailOutcome, mailTemplatePreviewRequest, mailTemplatePreviewResponse, patchAppOidcProviderRequest, patchAppUserRequest, putAppAuthConfigRequest, putAppMailTemplateRequest, } from './app-users.js';
8
9
  import { acceptTeamInviteRequest, authMeResponse, createTeamInviteRequest, fleetlessUser, fleetlessUserListResponse, passwordChangeRequest, passwordResetConfirm, passwordResetRequest, patchAuthMeRequest, patchFleetlessUserRequest, patchOrgRequest, patchOrgResponse, pendingTeamInviteListResponse, refreshRequest, sessionTokens, signUpRequest, signUpResponse, teamInvite, tierChangeRequest, waitlistRequest, } from './identity.js';
9
10
  import { jobRunListResponse, jobRunQuery, jobRunSummary, jobRunSummaryQuery } from './jobs.js';
10
- import { MCP_APP_PATHS, mcpRolePreviewResponse } from './mcp.js';
11
+ import { MCP_APP_PATHS, mcpRobotDatasheet, mcpRolePreviewResponse } from './mcp.js';
11
12
  import { authorizationServerMetadata, dynamicClientRegistrationRequest, dynamicClientRegistrationResponse, oauthAuthorizeQuery, oauthRedirectResponse, oauthTokenRequest, oauthTokenResponse, protectedResourceMetadata, } from './oauth.js';
12
13
  import { cameraListResponse, cancelRequest, configDraftResponse, configVersionResponse, configVersionsResponse, createRobotRequest, createRobotResponse, datapointListResponse, datapointValue, exposureListResponse, fetchTypesRequest, fetchTypesResponse, historyQuery, historyResponse, introspectionResponse, invokeOrServiceResponse, invokeRequest, jobResponse, liveSessionResponse, orgHealthQuery, orgLatencyQuery, orgLatencyResponse, orgQuotaUsage, orgUsageQuery, orgUsageResponse, patchRobotRequest, patchRobotResponse, publishConfigResponse, publishRequest, putConfigDraftRequest, putRobotDetailsRequest, putRobotDetailsResponse, releaseLiveQuery, renameSlugRequest, renameSlugResponse, robotDeleteQuery, resourceHealthListResponse, robotDeletionSummary, robotDetailResponse, robotJobsResponse, robotListResponse, slugUsageResponse, snapshotMetaResponse, typesResponse, } from './rest.js';
13
14
  export const ROUTE_SECTIONS = [
@@ -208,6 +209,14 @@ export const ROUTES = [
208
209
  'and splitting them here would tell a stranger which tokens ever existed. No rate limiter: the GET changes nothing, and the POST it ' +
209
210
  'leads to is limited per IP.',
210
211
  },
212
+ {
213
+ method: 'GET', path: '/favicon.svg', section: 'client-auth',
214
+ summary: 'Serves the Fleetless icon for the auth portal\'s and the MCP welcome page\'s browser tab.',
215
+ audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
216
+ params: [], query: null, request: null, response: null, errors: [], transport: 'http',
217
+ notes: 'An SVG, not JSON. Those pages carry a Content-Security-Policy that admits no `data:` image, so the icon is a file on their own ' +
218
+ 'origin — the one source `img-src \'self\'` names. Cached for a day: the bytes change when the brand does, not per deploy.',
219
+ },
211
220
  {
212
221
  method: 'POST', path: '/api/auth/password/reset/confirm', section: 'developer-auth',
213
222
  summary: 'Spends a reset token, sets the new password and ends every session of the account.',
@@ -939,8 +948,8 @@ export const ROUTES = [
939
948
  'caller earned. The shape is deliberately **not** strict, which is the schema agreeing with §3.1 rather than a gap in it — a conforming ' +
940
949
  'client sends `client_uri`, `logo_uri` and `software_id`, and both the schema and the server ignore them. `client_name` and ' +
941
950
  '`redirect_uris` are the two fields read; `grant_types`, `response_types` and `scope` are accepted and ignored. What comes back is what ' +
942
- 'was actually granted, which §3.2.1 allows a server to substitute — this authorization server issues `authorization_code` only, so a ' +
943
- 'client that asked for `refresh_token` is registered and told plainly that it did not get one. The registration carries a TTL. Refusals ' +
951
+ 'was actually granted, which §3.2.1 allows a server to substitute — this authorization server grants `authorization_code` and ' +
952
+ '`refresh_token` to every registration. The registration carries a TTL. Refusals ' +
944
953
  'are `oauthError`; the rate limiter answers `apiError`.',
945
954
  },
946
955
  {
@@ -1014,10 +1023,11 @@ export const ROUTES = [
1014
1023
  audience: 'client', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
1015
1024
  params: [], query: null, request: oauthTokenRequest, response: oauthTokenResponse,
1016
1025
  errors: [], transport: 'http',
1017
- notes: 'Only `authorization_code` is supported — there is no refresh grant here, so a session ends when its token expires and the client signs ' +
1018
- 'in again. Refusals are RFC 6749 §5.2\'s `oauthError`, so this route emits none of the codes in this reference. The response carries no ' +
1019
- '`refresh_token`; the shape is the same `oauthTokenResponse` the app flow answers, whose refresh field is optional. The code is ' +
1020
- 'single-use, PKCE-verified, and its `resource` must match the audience it was authorized for.',
1026
+ notes: '`authorization_code` mints an `mcp_session` access token bound to the central resource and a refresh token; `refresh_token` rotates that pair, ' +
1027
+ 'and the presented refresh token is consumed — a second presentation revokes the session, as on `/api/auth/refresh`. The refresh token lives ninety days ' +
1028
+ 'from its last use and is bound to the `client_id` it was issued to. A refresh re-reads the Fleetless user, so a removed account cannot refresh. ' +
1029
+ 'Refusals are RFC 6749 §5.2\'s `oauthError`, so this route emits none of the codes in this reference. The code is single-use, PKCE-verified, and its ' +
1030
+ '`resource` must match the audience it was authorized for; a `resource` on a refresh must match the session\'s audience, and is checked before the token is consumed.',
1021
1031
  },
1022
1032
  /* ------------------------------- developer auth (the console\'s OAuth portal) */
1023
1033
  {
@@ -1239,8 +1249,8 @@ export const ROUTES = [
1239
1249
  'accepts rather than what it parses, for the reason that row gives: §3.2.2 needs two distinguishable refusals and one `safeParse` ' +
1240
1250
  'failure offers one. `client_name` and `redirect_uris` are read; `grant_types`, `response_types` and `scope` are accepted and ignored, ' +
1241
1251
  'and what comes back is what was actually ' +
1242
- 'granted, which §3.2.1 allows — `authorization_code` only, so a client that asked for `refresh_token` is registered and told plainly ' +
1243
- 'that it did not get one. The registration carries a TTL. \n\n**The registration is scoped to this app.** A `client_id` minted here ' +
1252
+ 'granted, which §3.2.1 allows — this authorization server grants `authorization_code` and `refresh_token` to every registration. ' +
1253
+ 'The registration carries a TTL. \n\n**The registration is scoped to this app.** A `client_id` minted here ' +
1244
1254
  'authorizes at this app\'s endpoint and nowhere else, so a client registered against one app cannot walk into another\'s authorize with ' +
1245
1255
  'it, and a developer who switches MCP off is not left with strangers\' registrations valid somewhere adjacent. \n\nRefusals are ' +
1246
1256
  '`oauthError`; the rate limiter and `404 not_found` answer `apiError`. That `404` covers an unknown identifier **and** an app with the ' +
@@ -1279,9 +1289,8 @@ export const ROUTES = [
1279
1289
  audience: 'client', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
1280
1290
  params: [APP_IDENTIFIER], query: null, request: oauthTokenRequest, response: oauthTokenResponse,
1281
1291
  errors: [], transport: 'http',
1282
- notes: 'Only `authorization_code`, PKCE-verified and single-use. There is no refresh grant here either, so a session ends when its token ' +
1283
- 'expires and the client signs in again; the shape is the same `oauthTokenResponse` the central endpoint answers, whose refresh field is ' +
1284
- 'optional and stays empty. **The `aud` is this app\'s endpoint URL on the canonical public base**, and the code\'s `resource` must match ' +
1292
+ notes: '`authorization_code`, PKCE-verified and single-use, and `refresh_token`, which rotates the pair the exchange minted; the refresh token lives ninety days from its last use, is bound to its client and to this app, and a refresh re-reads the app user\'s status and their standing consent to the client, so a block or a withdrawn consent ends the session at its next refresh at the latest. ' +
1293
+ '**The `aud` is this app\'s endpoint URL on the canonical public base**, and the code\'s `resource` must match ' +
1285
1294
  'it — that is the whole of what stops a token minted for one app being spent at another\'s endpoint. \n\n**Every refusal is RFC 6749 ' +
1286
1295
  '§5.2\'s `oauthError`, so this route emits none of the codes in this reference — including the ones about the app.** An unknown ' +
1287
1296
  'identifier and a switched-off app are `invalid_client` here, not the `404` and `403` the authorize route beside it answers. The ' +
@@ -1750,6 +1759,33 @@ export const ROUTES = [
1750
1759
  '`404 unknown_datapoint` and a configured one with no sample yet is `404 no_data` — three facts a caller who is entitled to them needs ' +
1751
1760
  'told apart. The plane built-ins (`bridge_state`, `robot_details`) answer here too, without appearing in any document.',
1752
1761
  },
1762
+ /* ------------------------------------------ discovery: the REST twins of the two MCP tools a session starts from */
1763
+ {
1764
+ method: 'GET', path: '/api/client/robots', section: 'robots',
1765
+ summary: 'Lists the robots the caller reaches, with bridge state and the published configuration version.',
1766
+ audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 200,
1767
+ params: [], query: null, request: null, response: clientRobotListResponse,
1768
+ errors: [...CLIENT_GUARD], transport: 'http',
1769
+ notes: '**The REST twin of the MCP tool `robots_list`**, and the one robot question no robot-scoped route can answer: which robots may I name ' +
1770
+ 'at all. An app user sees the robots their app attaches on which their role grants at least one slug or capability; a server key sees ' +
1771
+ 'every robot its app attaches; a developer bearer sees the organisation\'s robots. Name order, id as the tiebreak. A robot on which the ' +
1772
+ 'role grants nothing is absent rather than listed empty — the same answer `robots_list` gives, for the same reason: reach is a grant, ' +
1773
+ 'not an attachment. Under `/api/client/` because it names no robot; every robot-scoped read stays under `/api/robots/:id/…`.',
1774
+ },
1775
+ {
1776
+ method: 'GET', path: '/api/robots/:id/datasheet', section: 'robots',
1777
+ summary: 'Describes everything the caller\'s role lets them do on one robot, with parameter schemas.',
1778
+ audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 200,
1779
+ params: [{ name: 'id', description: 'The robot\'s uuid, as `GET /api/client/robots` lists it.' }],
1780
+ query: null, request: null, response: mcpRobotDatasheet,
1781
+ errors: [...CLIENT_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
1782
+ notes: '**The REST twin of the MCP tool `robot_describe`**: one answer per robot — every datapoint, action, service, publisher and camera the ' +
1783
+ 'role grants, each with its `input_schema` where it takes parameters, plus the two capabilities that gate whole features, ' +
1784
+ '`action_history` and `assets`. A robot with nothing published answers an empty `exposures` list, never a refusal. A robot the caller ' +
1785
+ 'does not reach — not attached to their app, or attached with a role that grants nothing on it — answers `404` exactly as one that ' +
1786
+ 'does not exist. The app-user datapoint and camera listings under this prefix stay; this is the one read that also names actions, ' +
1787
+ 'services, publishers and capabilities, which is what an app needs before it can draw a screen.',
1788
+ },
1753
1789
  /* ------------------------------------------------- config (draft/publish) */
1754
1790
  {
1755
1791
  method: 'GET', path: '/api/robots/:id/config/draft', section: 'config',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fleetless/contracts",
3
- "version": "1.0.6",
3
+ "version": "1.2.0",
4
4
  "description": "Fleetless wire contracts: the bridge-cloud protocol, the REST API schemas and the error codes, as zod schemas with generated JSON Schema and OpenAPI artifacts.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dehne Robotik GmbH",