@fleetless/contracts 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/LICENSE +202 -0
- package/NOTICE +17 -0
- package/README.md +88 -0
- package/artifacts/constants.json +24 -0
- package/artifacts/openapi.json +17219 -0
- package/artifacts/routes.json +4605 -0
- package/artifacts/schema/accept-team-invite-request.schema.json +22 -0
- package/artifacts/schema/action-config.schema.json +198 -0
- package/artifacts/schema/alert-list-response.schema.json +172 -0
- package/artifacts/schema/api-error.schema.json +20 -0
- package/artifacts/schema/app-auth-config.schema.json +106 -0
- package/artifacts/schema/app-invitation-list-response.schema.json +57 -0
- package/artifacts/schema/app-invitation.schema.json +69 -0
- package/artifacts/schema/app-list-response.schema.json +82 -0
- package/artifacts/schema/app-mail-template-list-response.schema.json +68 -0
- package/artifacts/schema/app-mail-template.schema.json +54 -0
- package/artifacts/schema/app-oidc-provider-list-response.schema.json +93 -0
- package/artifacts/schema/app-oidc-provider.schema.json +80 -0
- package/artifacts/schema/app-user-list-response.schema.json +111 -0
- package/artifacts/schema/app-user.schema.json +98 -0
- package/artifacts/schema/app.schema.json +69 -0
- package/artifacts/schema/apply-error.schema.json +41 -0
- package/artifacts/schema/asset-list-response.schema.json +288 -0
- package/artifacts/schema/asset-sync-request.schema.json +17 -0
- package/artifacts/schema/asset-sync-response.schema.json +16 -0
- package/artifacts/schema/asset-sync-status.schema.json +136 -0
- package/artifacts/schema/asset.schema.json +68 -0
- package/artifacts/schema/audit-actor.schema.json +32 -0
- package/artifacts/schema/audit-event.schema.json +119 -0
- package/artifacts/schema/audit-list-response.schema.json +144 -0
- package/artifacts/schema/audit-query.schema.json +79 -0
- package/artifacts/schema/auth-error.schema.json +23 -0
- package/artifacts/schema/auth-me-response.schema.json +99 -0
- package/artifacts/schema/auth-ok.schema.json +115 -0
- package/artifacts/schema/authorization-server-metadata.schema.json +80 -0
- package/artifacts/schema/bridge-asset-progress.schema.json +99 -0
- package/artifacts/schema/bridge-assets-available.schema.json +25 -0
- package/artifacts/schema/bridge-camera-state.schema.json +78 -0
- package/artifacts/schema/bridge-config-applied.schema.json +67 -0
- package/artifacts/schema/bridge-hello.schema.json +65 -0
- package/artifacts/schema/bridge-introspect.schema.json +114 -0
- package/artifacts/schema/bridge-job-lost.schema.json +22 -0
- package/artifacts/schema/bridge-job-update.schema.json +100 -0
- package/artifacts/schema/bridge-pong.schema.json +19 -0
- package/artifacts/schema/bridge-pressure.schema.json +292 -0
- package/artifacts/schema/bridge-state.schema.json +24 -0
- package/artifacts/schema/bridge-type-definitions.schema.json +169 -0
- package/artifacts/schema/busy-details.schema.json +115 -0
- package/artifacts/schema/camera-descriptor.schema.json +45 -0
- package/artifacts/schema/camera-list-response.schema.json +58 -0
- package/artifacts/schema/camera-source.schema.json +240 -0
- package/artifacts/schema/cancel-request.schema.json +20 -0
- package/artifacts/schema/client-accept-invitation-request.schema.json +35 -0
- package/artifacts/schema/client-auth.schema.json +18 -0
- package/artifacts/schema/client-cancel.schema.json +45 -0
- package/artifacts/schema/client-identity.schema.json +103 -0
- package/artifacts/schema/client-invoke.schema.json +45 -0
- package/artifacts/schema/client-login-request.schema.json +29 -0
- package/artifacts/schema/client-logout-request.schema.json +14 -0
- package/artifacts/schema/client-mcp-interaction-decision-response.schema.json +15 -0
- package/artifacts/schema/client-mcp-interaction.schema.json +59 -0
- package/artifacts/schema/client-oidc-callback-query.schema.json +28 -0
- package/artifacts/schema/client-oidc-exchange-request.schema.json +21 -0
- package/artifacts/schema/client-oidc-start-query.schema.json +36 -0
- package/artifacts/schema/client-password-reset-confirm-request.schema.json +22 -0
- package/artifacts/schema/client-password-reset-request.schema.json +24 -0
- package/artifacts/schema/client-provider-list-query.schema.json +17 -0
- package/artifacts/schema/client-provider-list-response.schema.json +34 -0
- package/artifacts/schema/client-publish.schema.json +40 -0
- package/artifacts/schema/client-refresh-request.schema.json +14 -0
- package/artifacts/schema/client-register-request.schema.json +44 -0
- package/artifacts/schema/client-resend-verification-request.schema.json +24 -0
- package/artifacts/schema/client-subscribe.schema.json +43 -0
- package/artifacts/schema/client-unsubscribe.schema.json +26 -0
- package/artifacts/schema/client-verify-email-request.schema.json +15 -0
- package/artifacts/schema/cloud-asset-request.schema.json +37 -0
- package/artifacts/schema/cloud-camera-start.schema.json +41 -0
- package/artifacts/schema/cloud-camera-stop.schema.json +26 -0
- package/artifacts/schema/cloud-cancel.schema.json +33 -0
- package/artifacts/schema/cloud-config.schema.json +1635 -0
- package/artifacts/schema/cloud-hello-error.schema.json +23 -0
- package/artifacts/schema/cloud-hello-ok.schema.json +19 -0
- package/artifacts/schema/cloud-introspect-request.schema.json +19 -0
- package/artifacts/schema/cloud-invoke.schema.json +40 -0
- package/artifacts/schema/cloud-ping.schema.json +19 -0
- package/artifacts/schema/cloud-publish.schema.json +28 -0
- package/artifacts/schema/cloud-type-request.schema.json +30 -0
- package/artifacts/schema/command-result.schema.json +175 -0
- package/artifacts/schema/config-draft-response.schema.json +1695 -0
- package/artifacts/schema/config-state.schema.json +124 -0
- package/artifacts/schema/config-version-response.schema.json +1641 -0
- package/artifacts/schema/config-versions-response.schema.json +33 -0
- package/artifacts/schema/create-app-invitation-request.schema.json +40 -0
- package/artifacts/schema/create-app-oidc-provider-request.schema.json +70 -0
- package/artifacts/schema/create-app-request.schema.json +30 -0
- package/artifacts/schema/create-app-user-request.schema.json +42 -0
- package/artifacts/schema/create-robot-request.schema.json +14 -0
- package/artifacts/schema/create-robot-response.schema.json +44 -0
- package/artifacts/schema/create-server-key-response.schema.json +65 -0
- package/artifacts/schema/create-team-invite-request.schema.json +43 -0
- package/artifacts/schema/datapoint-alert-row.schema.json +160 -0
- package/artifacts/schema/datapoint-config.schema.json +366 -0
- package/artifacts/schema/datapoint-display.schema.json +31 -0
- package/artifacts/schema/datapoint-event.schema.json +34 -0
- package/artifacts/schema/datapoint-frame.schema.json +28 -0
- package/artifacts/schema/datapoint-list-response.schema.json +61 -0
- package/artifacts/schema/datapoint-value.schema.json +28 -0
- package/artifacts/schema/developer-login-request.schema.json +19 -0
- package/artifacts/schema/dynamic-client-registration-request.schema.json +60 -0
- package/artifacts/schema/dynamic-client-registration-response.schema.json +68 -0
- package/artifacts/schema/error-frame.schema.json +23 -0
- package/artifacts/schema/exposure-counts.schema.json +39 -0
- package/artifacts/schema/exposure-list-response.schema.json +43 -0
- package/artifacts/schema/fetch-types-request.schema.json +19 -0
- package/artifacts/schema/fetch-types-response.schema.json +163 -0
- package/artifacts/schema/fleetless-user-list-response.schema.json +73 -0
- package/artifacts/schema/fleetless-user.schema.json +60 -0
- package/artifacts/schema/history-buckets-response.schema.json +79 -0
- package/artifacts/schema/history-query.schema.json +58 -0
- package/artifacts/schema/history-response.schema.json +150 -0
- package/artifacts/schema/history-samples-response.schema.json +68 -0
- package/artifacts/schema/introspection-response.schema.json +118 -0
- package/artifacts/schema/invoke-or-service-response.schema.json +141 -0
- package/artifacts/schema/invoke-request.schema.json +23 -0
- package/artifacts/schema/invoke-response.schema.json +125 -0
- package/artifacts/schema/job-actor.schema.json +34 -0
- package/artifacts/schema/job-event.schema.json +158 -0
- package/artifacts/schema/job-response.schema.json +123 -0
- package/artifacts/schema/job-run-list-response.schema.json +222 -0
- package/artifacts/schema/job-run-query.schema.json +95 -0
- package/artifacts/schema/job-run-summary-query.schema.json +23 -0
- package/artifacts/schema/job-run-summary.schema.json +33 -0
- package/artifacts/schema/job-run.schema.json +195 -0
- package/artifacts/schema/job-state.schema.json +11 -0
- package/artifacts/schema/job.schema.json +106 -0
- package/artifacts/schema/latency-bucket.schema.json +63 -0
- package/artifacts/schema/live-session-response.schema.json +41 -0
- package/artifacts/schema/mail-outcome.schema.json +20 -0
- package/artifacts/schema/mail-template-preview-request.schema.json +35 -0
- package/artifacts/schema/mail-template-preview-response.schema.json +31 -0
- package/artifacts/schema/mail-template-problem-details.schema.json +24 -0
- package/artifacts/schema/mcp-consent-grant-list-response.schema.json +52 -0
- package/artifacts/schema/mcp-consent-grant.schema.json +39 -0
- package/artifacts/schema/mcp-robot-datasheet.schema.json +115 -0
- package/artifacts/schema/mcp-role-preview-response.schema.json +134 -0
- package/artifacts/schema/missing-asset-query.schema.json +11 -0
- package/artifacts/schema/oauth-authorize-query.schema.json +47 -0
- package/artifacts/schema/oauth-redirect-response.schema.json +15 -0
- package/artifacts/schema/oauth-token-request.schema.json +47 -0
- package/artifacts/schema/oauth-token-response.schema.json +38 -0
- package/artifacts/schema/org-alerts-query.schema.json +15 -0
- package/artifacts/schema/org-event-dropped.schema.json +26 -0
- package/artifacts/schema/org-event-replay.schema.json +97 -0
- package/artifacts/schema/org-event-subscribe.schema.json +14 -0
- package/artifacts/schema/org-event-unsubscribe.schema.json +14 -0
- package/artifacts/schema/org-event.schema.json +75 -0
- package/artifacts/schema/org-firing-alerts-response.schema.json +178 -0
- package/artifacts/schema/org-health-query.schema.json +13 -0
- package/artifacts/schema/org-latency-query.schema.json +42 -0
- package/artifacts/schema/org-latency-response.schema.json +124 -0
- package/artifacts/schema/org-quota-usage-counts.schema.json +42 -0
- package/artifacts/schema/org-quota-usage.schema.json +102 -0
- package/artifacts/schema/org-quotas.schema.json +51 -0
- package/artifacts/schema/org-usage-query.schema.json +19 -0
- package/artifacts/schema/org-usage-response.schema.json +77 -0
- package/artifacts/schema/org.schema.json +30 -0
- package/artifacts/schema/parameter-invalid-details.schema.json +37 -0
- package/artifacts/schema/parameter-spec.schema.json +120 -0
- package/artifacts/schema/parameter-violation.schema.json +24 -0
- package/artifacts/schema/password-change-request.schema.json +21 -0
- package/artifacts/schema/password-reset-confirm.schema.json +19 -0
- package/artifacts/schema/password-reset-request.schema.json +14 -0
- package/artifacts/schema/patch-app-oidc-provider-request.schema.json +50 -0
- package/artifacts/schema/patch-app-user-request.schema.json +34 -0
- package/artifacts/schema/patch-auth-me-request.schema.json +22 -0
- package/artifacts/schema/patch-fleetless-user-request.schema.json +20 -0
- package/artifacts/schema/patch-org-request.schema.json +15 -0
- package/artifacts/schema/patch-org-response.schema.json +40 -0
- package/artifacts/schema/patch-robot-request.schema.json +15 -0
- package/artifacts/schema/patch-robot-response.schema.json +40 -0
- package/artifacts/schema/pending-team-invite-list-response.schema.json +52 -0
- package/artifacts/schema/pending-team-invite.schema.json +39 -0
- package/artifacts/schema/protected-resource-metadata.schema.json +41 -0
- package/artifacts/schema/publish-config-response.schema.json +21 -0
- package/artifacts/schema/publish-request.schema.json +17 -0
- package/artifacts/schema/publisher-config.schema.json +285 -0
- package/artifacts/schema/put-app-auth-config-request.schema.json +93 -0
- package/artifacts/schema/put-app-mail-template-request.schema.json +35 -0
- package/artifacts/schema/put-config-draft-request.schema.json +13 -0
- package/artifacts/schema/put-datapoint-display-request.schema.json +31 -0
- package/artifacts/schema/put-robot-details-request.schema.json +41 -0
- package/artifacts/schema/put-robot-details-response.schema.json +43 -0
- package/artifacts/schema/rate-limit-details.schema.json +15 -0
- package/artifacts/schema/refresh-request.schema.json +13 -0
- package/artifacts/schema/release-live-query.schema.json +13 -0
- package/artifacts/schema/rename-slug-request.schema.json +23 -0
- package/artifacts/schema/rename-slug-response.schema.json +24 -0
- package/artifacts/schema/resource-health-event.schema.json +72 -0
- package/artifacts/schema/resource-health-list-response.schema.json +80 -0
- package/artifacts/schema/resource-health-state.schema.json +68 -0
- package/artifacts/schema/robot-config-doc.schema.json +1616 -0
- package/artifacts/schema/robot-delete-query.schema.json +12 -0
- package/artifacts/schema/robot-deletion-summary.schema.json +63 -0
- package/artifacts/schema/robot-detail-response.schema.json +262 -0
- package/artifacts/schema/robot-details-doc.schema.json +33 -0
- package/artifacts/schema/robot-jobs-response.schema.json +119 -0
- package/artifacts/schema/robot-latency-series.schema.json +81 -0
- package/artifacts/schema/robot-list-item.schema.json +94 -0
- package/artifacts/schema/robot-list-response.schema.json +106 -0
- package/artifacts/schema/robot.schema.json +30 -0
- package/artifacts/schema/role-list-response.schema.json +48 -0
- package/artifacts/schema/role-permissions.schema.json +61 -0
- package/artifacts/schema/role.schema.json +35 -0
- package/artifacts/schema/ros-graph.schema.json +99 -0
- package/artifacts/schema/server-key-list-response.schema.json +64 -0
- package/artifacts/schema/server-key.schema.json +51 -0
- package/artifacts/schema/service-call-response.schema.json +13 -0
- package/artifacts/schema/service-config.schema.json +198 -0
- package/artifacts/schema/session-tokens.schema.json +28 -0
- package/artifacts/schema/sign-up-request.schema.json +26 -0
- package/artifacts/schema/sign-up-response.schema.json +127 -0
- package/artifacts/schema/slug-usage-response.schema.json +32 -0
- package/artifacts/schema/snapshot-header.schema.json +44 -0
- package/artifacts/schema/snapshot-meta-response.schema.json +85 -0
- package/artifacts/schema/subscribe-error.schema.json +31 -0
- package/artifacts/schema/team-invite.schema.json +57 -0
- package/artifacts/schema/tier-change-request.schema.json +17 -0
- package/artifacts/schema/type-definition.schema.json +144 -0
- package/artifacts/schema/types-response.schema.json +156 -0
- package/artifacts/schema/update-app-request.schema.json +32 -0
- package/artifacts/schema/urdf-completeness.schema.json +50 -0
- package/artifacts/schema/validation-issue.schema.json +43 -0
- package/artifacts/schema/waitlist-request.schema.json +15 -0
- package/artifacts/schema-outgoing/bridge-asset-progress.schema.json +102 -0
- package/artifacts/schema-outgoing/bridge-assets-available.schema.json +26 -0
- package/artifacts/schema-outgoing/bridge-camera-state.schema.json +80 -0
- package/artifacts/schema-outgoing/bridge-config-applied.schema.json +69 -0
- package/artifacts/schema-outgoing/bridge-hello.schema.json +68 -0
- package/artifacts/schema-outgoing/bridge-introspect.schema.json +119 -0
- package/artifacts/schema-outgoing/bridge-job-lost.schema.json +23 -0
- package/artifacts/schema-outgoing/bridge-job-update.schema.json +102 -0
- package/artifacts/schema-outgoing/bridge-pong.schema.json +20 -0
- package/artifacts/schema-outgoing/bridge-type-definitions.schema.json +174 -0
- package/artifacts/schema-outgoing/datapoint-frame.schema.json +29 -0
- package/artifacts/schema-outgoing/snapshot-header.schema.json +45 -0
- package/dist/alerts.d.ts +255 -0
- package/dist/alerts.js +193 -0
- package/dist/app-users.d.ts +606 -0
- package/dist/app-users.js +696 -0
- package/dist/apps.d.ts +175 -0
- package/dist/apps.js +267 -0
- package/dist/assets.d.ts +434 -0
- package/dist/assets.js +546 -0
- package/dist/audit.d.ts +129 -0
- package/dist/audit.js +238 -0
- package/dist/client-auth.d.ts +409 -0
- package/dist/client-auth.js +487 -0
- package/dist/common.d.ts +186 -0
- package/dist/common.js +199 -0
- package/dist/config-issues.d.ts +175 -0
- package/dist/config-issues.js +339 -0
- package/dist/config.d.ts +862 -0
- package/dist/config.js +1988 -0
- package/dist/errors.d.ts +52 -0
- package/dist/errors.js +786 -0
- package/dist/identity.d.ts +549 -0
- package/dist/identity.js +503 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +51 -0
- package/dist/introspection.d.ts +99 -0
- package/dist/introspection.js +97 -0
- package/dist/jobs.d.ts +334 -0
- package/dist/jobs.js +345 -0
- package/dist/mcp.d.ts +239 -0
- package/dist/mcp.js +153 -0
- package/dist/oauth.d.ts +344 -0
- package/dist/oauth.js +488 -0
- package/dist/protocol.d.ts +781 -0
- package/dist/protocol.js +715 -0
- package/dist/realtime.d.ts +494 -0
- package/dist/realtime.js +512 -0
- package/dist/rest.d.ts +1989 -0
- package/dist/rest.js +1963 -0
- package/dist/routes.d.ts +94 -0
- package/dist/routes.js +2298 -0
- package/package.json +61 -0
package/dist/routes.js
ADDED
|
@@ -0,0 +1,2298 @@
|
|
|
1
|
+
import { appListResponse, createAppRequest, createServerKeyResponse, app as appSchema, role, roleListResponse, rolePermissions, serverKeyListResponse, updateAppRequest, } from './apps.js';
|
|
2
|
+
import { alertListResponse, orgAlertsQuery, orgFiringAlertsResponse } from './alerts.js';
|
|
3
|
+
import { asset, assetListResponse, assetSyncRequest, assetSyncResponse, assetSyncStatus, missingAssetQuery } from './assets.js';
|
|
4
|
+
import { auditListResponse, auditQuery } from './audit.js';
|
|
5
|
+
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';
|
|
6
|
+
import { appAuthConfig, appInvitation, appInvitationListResponse, appMailTemplate, appMailTemplateListResponse, appOidcProvider, appOidcProviderListResponse, appUser, appUserListResponse, createAppInvitationRequest, createAppOidcProviderRequest, createAppUserRequest, mailOutcome, mailTemplatePreviewRequest, mailTemplatePreviewResponse, patchAppOidcProviderRequest, patchAppUserRequest, putAppAuthConfigRequest, putAppMailTemplateRequest, } from './app-users.js';
|
|
7
|
+
import { acceptTeamInviteRequest, authMeResponse, createTeamInviteRequest, fleetlessUser, fleetlessUserListResponse, passwordChangeRequest, passwordResetConfirm, passwordResetRequest, patchAuthMeRequest, patchFleetlessUserRequest, patchOrgRequest, patchOrgResponse, pendingTeamInviteListResponse, refreshRequest, sessionTokens, signUpRequest, signUpResponse, teamInvite, tierChangeRequest, waitlistRequest, } from './identity.js';
|
|
8
|
+
import { jobRunListResponse, jobRunQuery, jobRunSummary, jobRunSummaryQuery } from './jobs.js';
|
|
9
|
+
import { MCP_APP_PATHS, mcpRolePreviewResponse } from './mcp.js';
|
|
10
|
+
import { authorizationServerMetadata, dynamicClientRegistrationRequest, dynamicClientRegistrationResponse, oauthAuthorizeQuery, oauthRedirectResponse, oauthTokenRequest, oauthTokenResponse, protectedResourceMetadata, } from './oauth.js';
|
|
11
|
+
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';
|
|
12
|
+
export const ROUTE_SECTIONS = [
|
|
13
|
+
{ id: 'health', title: 'Health' },
|
|
14
|
+
{ id: 'developer-auth', title: 'Developer auth' },
|
|
15
|
+
{ id: 'client-auth', title: 'App-user (client) auth' },
|
|
16
|
+
{ id: 'org', title: 'Org' },
|
|
17
|
+
{ id: 'users', title: 'Team' },
|
|
18
|
+
{ id: 'apps', title: 'Apps' },
|
|
19
|
+
{ id: 'robots', title: 'Robots' },
|
|
20
|
+
{ id: 'config', title: 'Configuration (draft/publish)' },
|
|
21
|
+
{ id: 'alerts', title: 'Alerts' },
|
|
22
|
+
{ id: 'commands', title: 'Commands (jobs, publishers)' },
|
|
23
|
+
{ id: 'cameras', title: 'Cameras' },
|
|
24
|
+
{ id: 'assets', title: 'Assets (URDF, meshes)' },
|
|
25
|
+
{ id: 'mcp', title: 'MCP' },
|
|
26
|
+
{ id: 'transports', title: 'Realtime and bridge transports' },
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* **The per-app MCP paths as the manifest spells them**, built by the same
|
|
30
|
+
* function the cloud, the console and the reverse proxy call — with the
|
|
31
|
+
* display parameter `:appIdentifier` where a real identifier goes.
|
|
32
|
+
*
|
|
33
|
+
* The rows below take their `path` from this object rather than from a string
|
|
34
|
+
* literal, which is the discipline `CLIENT_OIDC_CALLBACK_PATH` established: a
|
|
35
|
+
* path an MCP client **discovers** cannot be allowed to be spelled twice, and
|
|
36
|
+
* `MCP_APP_PATHS` is where it is spelled. `test/routes.test.ts` pins the
|
|
37
|
+
* literal characters, because a row compared only against the constant it was
|
|
38
|
+
* built from is two references to one string agreeing with themselves.
|
|
39
|
+
*/
|
|
40
|
+
const MCP_APP = MCP_APP_PATHS(':appIdentifier');
|
|
41
|
+
/** The one path parameter every per-app MCP route takes, described once rather than eight times. */
|
|
42
|
+
const APP_IDENTIFIER = {
|
|
43
|
+
name: 'appIdentifier',
|
|
44
|
+
description: "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool.",
|
|
45
|
+
};
|
|
46
|
+
/** The routes that verify a credential inside the handler; `auth: 'in_handler'` is refused elsewhere. */
|
|
47
|
+
export const IN_HANDLER_ROUTES = [
|
|
48
|
+
'POST /mcp',
|
|
49
|
+
// The per-app endpoint, all three verbs: the bearer decides which app user
|
|
50
|
+
// is calling, and the path names none of that. Built from `MCP_APP` so the
|
|
51
|
+
// list cannot drift from the rows.
|
|
52
|
+
`POST ${MCP_APP.endpoint}`,
|
|
53
|
+
`GET ${MCP_APP.endpoint}`,
|
|
54
|
+
`DELETE ${MCP_APP.endpoint}`,
|
|
55
|
+
// The one route whose bearer is **optional**: it answers the same document
|
|
56
|
+
// with or without one, and only `already_granted` moves.
|
|
57
|
+
'GET /api/client/mcp/interactions/:id',
|
|
58
|
+
'GET /api/asset-links/missing',
|
|
59
|
+
'GET /api/asset-links/:token',
|
|
60
|
+
];
|
|
61
|
+
/**
|
|
62
|
+
* **The three refusals every `auth: 'developer'` route inherits from its guard**,
|
|
63
|
+
* spelled once rather than retyped eighty times.
|
|
64
|
+
*
|
|
65
|
+
* They are the arms of `cloud/src/auth.ts`'s `requireDeveloper`: no bearer or an
|
|
66
|
+
* unverifiable one is `401 unauthorized`, an expired one `401 token_expired`, a
|
|
67
|
+
* vanished account or a bumped `token_version` `401 token_revoked`.
|
|
68
|
+
*
|
|
69
|
+
* **Three, not four: `403 forbidden` went with the Org Admins group.** It stood
|
|
70
|
+
* for "an account that is no longer in the org's Org Admins group", and the
|
|
71
|
+
* two-space cut leaves a Fleetless user who IS the team — `TokenRefusalReason`
|
|
72
|
+
* in `cloud/src/auth.ts` is `'expired' | 'revoked' | 'invalid'`, and
|
|
73
|
+
* `createRequireDeveloper` answers 401 codes only. A removed team member now
|
|
74
|
+
* gets `401 token_revoked`; a reader of the API reference who branched on
|
|
75
|
+
* `forbidden` to render "you lost console access" was branching on an answer no
|
|
76
|
+
* developer-guarded route can send. The `forbidden` producers that remain
|
|
77
|
+
* (`history.ts`, `commands.ts`, `cameras.ts`, `robots.ts`, `mcp.ts`) all sit on
|
|
78
|
+
* `developer_or_client` or MCP surfaces, which is why `CLIENT_GUARD` keeps it.
|
|
79
|
+
*
|
|
80
|
+
* **Not `invalid_token`.** That code exists in `ERROR_CODES` and this guard has
|
|
81
|
+
* never sent it; the three above are what `sendTokenRefusal` actually maps to.
|
|
82
|
+
* Said plainly because the planning note for this file assumed otherwise, and a
|
|
83
|
+
* documented refusal a caller cannot receive is the third failure mode in
|
|
84
|
+
* CLAUDE.md's list.
|
|
85
|
+
*/
|
|
86
|
+
const DEVELOPER_GUARD = ['unauthorized', 'token_expired', 'token_revoked'];
|
|
87
|
+
/**
|
|
88
|
+
* The same, for `auth: 'developer_or_client'` — `createRequireDeveloperOrClient`,
|
|
89
|
+
* which resolves a developer bearer, an end-user bearer **or** a server key
|
|
90
|
+
* through one `resolveAnyToken`.
|
|
91
|
+
*
|
|
92
|
+
* **The same four codes, not five.** `sendTokenRefusal` has a fifth arm,
|
|
93
|
+
* `account_blocked`, and this list carried it for exactly one commit. Nothing
|
|
94
|
+
* reaches it: `TokenRefusalReason` admits `'blocked'`, but no site in
|
|
95
|
+
* `cloud/src` constructs one — the only reasons ever returned are `'invalid'`,
|
|
96
|
+
* `'revoked'` and `'forbidden'`. `auth.ts` says why on the line where the check
|
|
97
|
+
* used to be: D2 replaced "block the account" with "remove the assignment", so
|
|
98
|
+
* an app user who loses access loses it because no assignment resolves, and
|
|
99
|
+
* there is no blocked state left to re-check.
|
|
100
|
+
*
|
|
101
|
+
* Listing it would have documented a refusal no caller can receive — the same
|
|
102
|
+
* mistake as the `invalid_token` above, found by review rather than by any test
|
|
103
|
+
* here, because a code in `ERROR_CODES` satisfies every check this file has.
|
|
104
|
+
*
|
|
105
|
+
* It keeps `forbidden`, which `DEVELOPER_GUARD` no longer carries: this guard's
|
|
106
|
+
* routes have live 403 producers — a slug the role does not grant
|
|
107
|
+
* (`routes/history.ts`), a capability the role does not carry
|
|
108
|
+
* (`routes/commands.ts`), a camera or datapoint outside the grant
|
|
109
|
+
* (`routes/cameras.ts`, `routes/robots.ts`). That divergence is the reason the
|
|
110
|
+
* two lists were kept as separate constants while they still read alike.
|
|
111
|
+
*/
|
|
112
|
+
const CLIENT_GUARD = ['unauthorized', 'token_expired', 'token_revoked', 'forbidden'];
|
|
113
|
+
export const ROUTES = [
|
|
114
|
+
/* ------------------------------------------------------------- health */
|
|
115
|
+
{
|
|
116
|
+
method: 'GET', path: '/healthz', section: 'health',
|
|
117
|
+
summary: 'Reports whether the database, the object store and LiveKit each answered a probe.',
|
|
118
|
+
audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
119
|
+
params: [], query: null, request: null, response: null, errors: [], transport: 'http',
|
|
120
|
+
notes: 'Answers `{ ok, dependencies: { database, storage, liveKit } }` — a cloud-local shape, not a wire contract, so nothing here pins it. ' +
|
|
121
|
+
'The status is always `200`: each dependency is probed independently and a failure is reported in the body rather than thrown, because ' +
|
|
122
|
+
'`/healthz` must never itself be a reason the process looks down. Read `ok`, not the status code.',
|
|
123
|
+
},
|
|
124
|
+
/* ----------------------------------------------------- developer auth */
|
|
125
|
+
{
|
|
126
|
+
method: 'POST', path: '/api/auth/signup', section: 'developer-auth',
|
|
127
|
+
summary: 'Creates an org and its founding Owner, and answers a developer session.',
|
|
128
|
+
audience: 'developer', auth: 'none', rateLimited: true, ownerTier: false, status: 201,
|
|
129
|
+
params: [], query: null, request: signUpRequest, response: signUpResponse,
|
|
130
|
+
errors: ['rate_limited', 'signup_closed', 'validation_error', 'email_taken'], transport: 'http',
|
|
131
|
+
notes: 'While the deployment runs in closed beta this answers `403 signup_closed` before it looks at the body — there is nothing for a ' +
|
|
132
|
+
'validation message, or an `email_taken` answer, to be right about when nothing will be created. Email is globally unique, so an ' +
|
|
133
|
+
'address already registered in any org is refused.',
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
method: 'POST', path: '/api/auth/refresh', section: 'developer-auth',
|
|
137
|
+
summary: 'Rotates a developer refresh token and mints a fresh access token.',
|
|
138
|
+
audience: 'developer', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
139
|
+
params: [], query: null, request: refreshRequest, response: sessionTokens,
|
|
140
|
+
errors: ['rate_limited', 'validation_error', 'token_expired', 'token_revoked'], transport: 'http',
|
|
141
|
+
notes: 'The whole family is re-checked here, not just the token: an account that has been removed from the org, or whose `token_version` was ' +
|
|
142
|
+
'bumped by a password change, cannot mint a fresh console token and answers `token_revoked`. Refusing that only on the other routes ' +
|
|
143
|
+
'would leave a session that is dead everywhere but here.',
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
method: 'POST', path: '/api/auth/logout', section: 'developer-auth',
|
|
147
|
+
summary: 'Revokes the whole refresh family behind a developer refresh token.',
|
|
148
|
+
audience: 'developer', auth: 'none', rateLimited: true, ownerTier: false, status: 204,
|
|
149
|
+
params: [], query: null, request: refreshRequest, response: null,
|
|
150
|
+
errors: ['rate_limited', 'validation_error'], transport: 'http',
|
|
151
|
+
notes: 'Unauthenticated by design — the refresh token in the body is the credential. A token the server does not recognise is still a `204`: ' +
|
|
152
|
+
'the end state a caller asked for is the end state they get, and distinguishing the two would say whether a token ever existed. ' +
|
|
153
|
+
'Open `/realtime` sockets for the session are closed too.',
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
method: 'GET', path: '/api/auth/me', section: 'developer-auth',
|
|
157
|
+
summary: 'Answers the calling developer and the org they belong to.',
|
|
158
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
159
|
+
params: [], query: null, request: null, response: authMeResponse,
|
|
160
|
+
errors: [...DEVELOPER_GUARD], transport: 'http',
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
method: 'PATCH', path: '/api/auth/me', section: 'developer-auth',
|
|
164
|
+
summary: "Changes the calling developer's own display name and nothing else.",
|
|
165
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
166
|
+
params: [], query: null, request: patchAuthMeRequest, response: authMeResponse,
|
|
167
|
+
errors: [...DEVELOPER_GUARD, 'validation_error'], transport: 'http',
|
|
168
|
+
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 ' +
|
|
169
|
+
'held writes nothing and records no audit event — the org activity stream reaches every developer with the console open, and an event ' +
|
|
170
|
+
'for a no-op would misreport that something changed.',
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
method: 'POST', path: '/api/auth/password/change', section: 'developer-auth',
|
|
174
|
+
summary: 'Verifies the current password, sets a new one and answers a fresh session.',
|
|
175
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
176
|
+
params: [], query: null, request: passwordChangeRequest, response: sessionTokens,
|
|
177
|
+
errors: [...DEVELOPER_GUARD, 'validation_error', 'invalid_credentials'], transport: 'http',
|
|
178
|
+
notes: 'Every session of this account ends, including the caller\'s — the request carries nothing identifying its own refresh family, so there ' +
|
|
179
|
+
'is none to spare. The answer is a working replacement pair, which is what the promise has to mean when nothing distinguishes one ' +
|
|
180
|
+
'session from another.',
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
method: 'POST', path: '/api/auth/password/reset', section: 'developer-auth',
|
|
184
|
+
summary: 'Mails a password-reset link to the address, and answers the same either way.',
|
|
185
|
+
audience: 'developer', auth: 'none', rateLimited: true, ownerTier: false, status: 202,
|
|
186
|
+
params: [], query: null, request: passwordResetRequest, response: null,
|
|
187
|
+
errors: ['rate_limited', 'validation_error'], transport: 'http',
|
|
188
|
+
notes: 'Status, body and timing are identical for a known and an unknown address — any difference is an account-enumeration oracle, which is ' +
|
|
189
|
+
'why the unknown branch still pays a real SMTP round trip to a discard address. An account provisioned through OIDC has no Fleetless ' +
|
|
190
|
+
'password and is mailed nothing. A browser form post gets a `303` to the "check your mail" card instead of this `202`.',
|
|
191
|
+
},
|
|
192
|
+
/* ------------------------------------------- client auth (portal pages) */
|
|
193
|
+
{
|
|
194
|
+
method: 'GET', path: '/reset-password', section: 'client-auth',
|
|
195
|
+
summary: 'Serves the auth portal\'s "forgot your password" card as an HTML page.',
|
|
196
|
+
audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
197
|
+
params: [], query: null, request: null, response: null, errors: [], transport: 'http',
|
|
198
|
+
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" ' +
|
|
199
|
+
'state instead — one path, because that second card has no inputs and a second path would exist only to be redirected to. The value is ' +
|
|
200
|
+
'caller-settable and discloses nothing, since the page it draws is a constant.',
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
method: 'GET', path: '/reset-password/:token', section: 'client-auth',
|
|
204
|
+
summary: 'Serves the "pick a new password" page for a mailed reset link.',
|
|
205
|
+
audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
206
|
+
params: [{ name: 'token', description: 'The opaque reset token from the mailed link; it is never sent as a query parameter.' }],
|
|
207
|
+
query: null, request: null, response: null, errors: [], transport: 'http',
|
|
208
|
+
notes: 'HTML. An unknown, spent or expired token renders one "link no longer valid" page at `410` — they are one refusal on the wire already, ' +
|
|
209
|
+
'and splitting them here would tell a stranger which tokens ever existed. No rate limiter: the GET changes nothing, and the POST it ' +
|
|
210
|
+
'leads to is limited per IP.',
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
method: 'POST', path: '/api/auth/password/reset/confirm', section: 'developer-auth',
|
|
214
|
+
summary: 'Spends a reset token, sets the new password and ends every session of the account.',
|
|
215
|
+
audience: 'developer', auth: 'none', rateLimited: true, ownerTier: false, status: 204,
|
|
216
|
+
params: [], query: null, request: passwordResetConfirm, response: null,
|
|
217
|
+
errors: ['rate_limited', 'validation_error', 'token_spent'], transport: 'http',
|
|
218
|
+
notes: 'Unknown, spent and expired tokens all answer `410 token_spent`. Sessions are revoked under the account\'s actual kind — a console admin ' +
|
|
219
|
+
'holds developer sessions, an app user holds end-user ones — so an app user\'s open `/realtime` socket does not outlive the reset. ' +
|
|
220
|
+
'A browser form post gets the rendered "done" page instead of this `204`.',
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
method: 'POST', path: '/api/waitlist', section: 'developer-auth',
|
|
224
|
+
summary: 'Adds an address to the closed-beta waiting list.',
|
|
225
|
+
audience: 'developer', auth: 'none', rateLimited: true, ownerTier: false, status: 202,
|
|
226
|
+
params: [], query: null, request: waitlistRequest, response: null,
|
|
227
|
+
errors: ['rate_limited', 'validation_error'], transport: 'http',
|
|
228
|
+
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 ' +
|
|
229
|
+
'operator notification is detached from the response — awaiting it made latency answer the question the status code refuses to — and is ' +
|
|
230
|
+
'capped by its own global ceiling, above which the row is still written and the mail is skipped.',
|
|
231
|
+
},
|
|
232
|
+
/* ---------------------------------------------------------------- org */
|
|
233
|
+
{
|
|
234
|
+
method: 'GET', path: '/api/audit', section: 'org',
|
|
235
|
+
summary: "Reads the org's audit log, newest first, cursor-paged over the durable sequence number.",
|
|
236
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
237
|
+
params: [], query: auditQuery, request: null, response: auditListResponse,
|
|
238
|
+
errors: [...DEVELOPER_GUARD, 'validation_error'], transport: 'http',
|
|
239
|
+
notes: '`action` and `action_prefix` are mutually exclusive, a cross-field rule no JSON Schema can express — this route is where it is ' +
|
|
240
|
+
'enforced. Nothing redacts an event\'s `details`: it is returned exactly as the call site wrote it.',
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
method: 'GET', path: '/api/audit/export', section: 'org',
|
|
244
|
+
summary: 'Downloads every audit event matching the same filters as a CSV attachment.',
|
|
245
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
246
|
+
params: [], query: auditQuery, request: null, response: null, contentType: 'text/csv',
|
|
247
|
+
errors: [...DEVELOPER_GUARD, 'validation_error'], transport: 'http',
|
|
248
|
+
notes: 'Answers `text/csv; charset=utf-8` with a `Content-Disposition` attachment, not JSON — so it has no response schema. `AUDIT_CSV_COLUMNS` names the ' +
|
|
249
|
+
'columns and their order. Takes the same filters as `GET /api/audit` but refuses `before_seq` and `limit` with `400 validation_error`: ' +
|
|
250
|
+
'an export is not a page, it is everything the filter matches up to a fixed row ceiling.',
|
|
251
|
+
},
|
|
252
|
+
/* --------------------------------------------------------------- apps */
|
|
253
|
+
{
|
|
254
|
+
method: 'POST', path: '/api/apps', section: 'apps',
|
|
255
|
+
summary: 'Creates an app, optionally attaching robots to it at the same time.',
|
|
256
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 201,
|
|
257
|
+
params: [], query: null, request: createAppRequest, response: appSchema,
|
|
258
|
+
errors: [...DEVELOPER_GUARD, 'validation_error', 'identifier_taken', 'quota_exceeded'], transport: 'http',
|
|
259
|
+
notes: 'Every robot id is checked before anything is created, so a bad one never leaves a robotless app to clean up. The identifier `mcp` is ' +
|
|
260
|
+
'reserved by the central MCP server and refused as a `validation_error`. An app belongs to the org and to nothing inside it: the group ' +
|
|
261
|
+
'an app used to be created in, and the `409 target_state_conflict` that refused the Org Admins one, are both gone with the group model.',
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
method: 'GET', path: '/api/apps', section: 'apps',
|
|
265
|
+
summary: "Lists every app in the caller's org.",
|
|
266
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
267
|
+
params: [], query: null, request: null, response: appListResponse,
|
|
268
|
+
errors: [...DEVELOPER_GUARD], transport: 'http',
|
|
269
|
+
notes: 'Answers `{ "apps": [app, …] }` — the whole org, unpaged; an org\'s app count is bounded by quota.',
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
method: 'GET', path: '/api/apps/:id', section: 'apps',
|
|
273
|
+
summary: 'Reads one app of the org, with its robots and default role.',
|
|
274
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
275
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
276
|
+
query: null, request: null, response: appSchema,
|
|
277
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
278
|
+
notes: 'An app belonging to another org reads exactly like one that does not exist — `404`, never a `403`.',
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
method: 'PATCH', path: '/api/apps/:id', section: 'apps',
|
|
282
|
+
summary: "Changes an app's name, its attached robots or its default role.",
|
|
283
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
284
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
285
|
+
query: null, request: updateAppRequest, response: appSchema,
|
|
286
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found'], transport: 'http',
|
|
287
|
+
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. ' +
|
|
288
|
+
'Changing the robot set closes every live subscription the app\'s users hold, since a grant may no longer name a reachable robot.',
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
method: 'POST', path: '/api/apps/:id/roles', section: 'apps',
|
|
292
|
+
summary: 'Creates a custom role on the app.',
|
|
293
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 201,
|
|
294
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
295
|
+
query: null, request: null, response: role,
|
|
296
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'validation_error'], transport: 'http',
|
|
297
|
+
notes: 'The body is `{ "name": string }` — non-empty, trimmed, at most 120 characters — and is deliberately not a contract shape: contracts ' +
|
|
298
|
+
'define the `role` this answers with, not this one trivial request. **The answer is a bare `role`, not an envelope**, unlike the ' +
|
|
299
|
+
'listing beside it.',
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
method: 'GET', path: '/api/apps/:id/roles', section: 'apps',
|
|
303
|
+
summary: "Lists the app's roles, builtin and custom.",
|
|
304
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
305
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
306
|
+
query: null, request: null, response: roleListResponse,
|
|
307
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
308
|
+
notes: 'Answers `{ "roles": [role, …] }`, builtin roles included — a role a developer never created is still one a user can hold.',
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
method: 'PUT', path: '/api/apps/:id/roles/:roleId/permissions', section: 'apps',
|
|
312
|
+
summary: "Replaces a role's grants and capabilities in one write.",
|
|
313
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
314
|
+
params: [
|
|
315
|
+
{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' },
|
|
316
|
+
{ name: 'roleId', description: 'The role\'s uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`.' },
|
|
317
|
+
],
|
|
318
|
+
query: null, request: rolePermissions, response: rolePermissions,
|
|
319
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'validation_error'], transport: 'http',
|
|
320
|
+
notes: '`role_id` in the body must name the role in the path, compared case-insensitively — a uuid is a value, not a string, and a client that ' +
|
|
321
|
+
'uppercases them consistently must not be refused for repeating what the path says. A grant naming a robot the app does not have is ' +
|
|
322
|
+
'refused rather than stored: a permission for something the role cannot reach reads as authoritative to whoever writes the next consumer. ' +
|
|
323
|
+
'Every user holding this role has their live subscriptions re-authorized.',
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
method: 'GET', path: '/api/apps/:id/roles/:roleId/permissions', section: 'apps',
|
|
327
|
+
summary: "Reads a role's grants and capabilities.",
|
|
328
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
329
|
+
params: [
|
|
330
|
+
{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' },
|
|
331
|
+
{ name: 'roleId', description: 'The role\'s uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`.' },
|
|
332
|
+
],
|
|
333
|
+
query: null, request: null, response: rolePermissions,
|
|
334
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
method: 'GET', path: '/api/apps/:id/roles/:roleId/mcp-tools', section: 'apps',
|
|
338
|
+
summary: 'Previews the robot datasheets an MCP caller holding this role would be offered.',
|
|
339
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
340
|
+
params: [
|
|
341
|
+
{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' },
|
|
342
|
+
{ name: 'roleId', description: 'The role\'s uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`.' },
|
|
343
|
+
],
|
|
344
|
+
query: null, request: null, response: mcpRolePreviewResponse,
|
|
345
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
346
|
+
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 ' +
|
|
347
|
+
'offered and consults nothing about any user\'s actual MCP entitlement. A robot the role grants nothing on still appears, with an empty ' +
|
|
348
|
+
'`exposures` — dropping it would read as "not attached", which is a different fact.',
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
method: 'POST', path: '/api/apps/:id/server-keys', section: 'apps',
|
|
352
|
+
summary: 'Mints a server key for the app and returns the raw secret once.',
|
|
353
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: true, status: 201,
|
|
354
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
355
|
+
query: null, request: null, response: createServerKeyResponse,
|
|
356
|
+
errors: [...DEVELOPER_GUARD, 'tier_required', 'invalid_uuid', 'not_found', 'validation_error'], transport: 'http',
|
|
357
|
+
notes: 'Owner tier only: a server key carries full app rights and outlives its creator\'s removal. The body is `{ "name": string }`, the same ' +
|
|
358
|
+
'trivial shape role creation takes. `key` is the only moment the raw secret exists outside the caller\'s hands — it is never in a ' +
|
|
359
|
+
'listing, never in an audit event, and cannot be read back.',
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
method: 'GET', path: '/api/apps/:id/server-keys', section: 'apps',
|
|
363
|
+
summary: "Lists the app's server keys as metadata, never the secrets.",
|
|
364
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
365
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
366
|
+
query: null, request: null, response: serverKeyListResponse,
|
|
367
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
368
|
+
notes: 'Answers `{ "server_keys": [serverKey, …] }`. `serverKey` names the five fields it carries rather than spreading the stored row — that ' +
|
|
369
|
+
'is what keeps this listing from becoming a second place a credential leaves the cloud.',
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
method: 'POST', path: '/api/apps/:id/server-keys/:keyId/rotate', section: 'apps',
|
|
373
|
+
summary: 'Replaces a server key\'s secret in place and returns the new one once.',
|
|
374
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: true, status: 200,
|
|
375
|
+
params: [
|
|
376
|
+
{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' },
|
|
377
|
+
{ name: 'keyId', description: 'The server key\'s uuid, from `GET /api/apps/:id/server-keys`; a key of another app answers `404`.' },
|
|
378
|
+
],
|
|
379
|
+
query: null, request: null, response: createServerKeyResponse,
|
|
380
|
+
errors: [...DEVELOPER_GUARD, 'tier_required', 'invalid_uuid', 'not_found'], transport: 'http',
|
|
381
|
+
notes: 'Owner tier, for the reason creation is. The old secret is refused from this call on, and any `/realtime` socket that authenticated with ' +
|
|
382
|
+
'it is closed — rotation is what a developer reaches for when a key has leaked, and the holder of that socket is exactly who they are ' +
|
|
383
|
+
'rotating against.',
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
method: 'DELETE', path: '/api/apps/:id/server-keys/:keyId', section: 'apps',
|
|
387
|
+
summary: 'Revokes a server key and closes every socket holding it.',
|
|
388
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: true, status: 204,
|
|
389
|
+
params: [
|
|
390
|
+
{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' },
|
|
391
|
+
{ name: 'keyId', description: 'The server key\'s uuid, from `GET /api/apps/:id/server-keys`; a key of another app answers `404`.' },
|
|
392
|
+
],
|
|
393
|
+
query: null, request: null, response: null,
|
|
394
|
+
errors: [...DEVELOPER_GUARD, 'tier_required', 'invalid_uuid', 'not_found'], transport: 'http',
|
|
395
|
+
notes: 'Owner tier, like minting and rotating: all three decide who may speak for the whole app.',
|
|
396
|
+
},
|
|
397
|
+
/* -------------------------------------- the app's users and invitations */
|
|
398
|
+
{
|
|
399
|
+
method: 'GET', path: '/api/apps/:id/users', section: 'apps',
|
|
400
|
+
summary: "Lists the app's users — the developer's own customers, not the Fleetless team.",
|
|
401
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
402
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
403
|
+
query: null, request: null, response: appUserListResponse,
|
|
404
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
405
|
+
notes: 'Answers `{ "users": [appUser, …] }`. **A different identity space from `GET /api/org/users`**, and nothing joins the two: an app user ' +
|
|
406
|
+
'belongs to exactly one app, their address is unique per app rather than globally, and the same address may exist as unrelated accounts ' +
|
|
407
|
+
'in several apps of one org. No password hash, no token and no provider secret appears here — `appUser` names the fields it carries ' +
|
|
408
|
+
'rather than spreading the stored row.',
|
|
409
|
+
},
|
|
410
|
+
{
|
|
411
|
+
method: 'POST', path: '/api/apps/:id/users', section: 'apps',
|
|
412
|
+
summary: 'Creates an app user directly, without an invitation or a self-registration.',
|
|
413
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 201,
|
|
414
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
415
|
+
query: null, request: createAppUserRequest, response: appUser,
|
|
416
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found', 'email_taken', 'target_state_conflict', 'quota_exceeded'], transport: 'http',
|
|
417
|
+
notes: 'The developer-authenticated door into the app\'s user table, and the one place `409 email_taken` is an honest answer about an app user: ' +
|
|
418
|
+
'the caller is authenticated into this app already, so telling them the address is taken discloses nothing they could not read from the ' +
|
|
419
|
+
'listing beside it. `POST /api/client/register` answers `202` to the same fact, because there the caller is a stranger. `404 not_found` ' +
|
|
420
|
+
'is the app, or a `role_id` that is not a role of it — a role of another app is refused rather than stored, since a user holding one ' +
|
|
421
|
+
'would carry rights nothing in this app can resolve. **The password policy answers `400 validation_error`**, not a code of its own: the ' +
|
|
422
|
+
'twelve-character minimum is the `password` field\'s schema rule, and every route in this repository that takes a password refuses a ' +
|
|
423
|
+
'short one exactly the way it refuses any other malformed field. An account created here is `active` immediately: a developer entering ' +
|
|
424
|
+
'somebody by hand has made the decision the verification mail automates, and its address counts as proven. `409 target_state_conflict` ' +
|
|
425
|
+
'names `default_role_id` when `role_id` is absent and the app has no default role, or its default names a role that no longer resolves ' +
|
|
426
|
+
'— a user with no role holds rights nothing in this app can read, so nothing is created. \n\n**`409 quota_exceeded` when the org holds as ' +
|
|
427
|
+
'many app users as `max_end_users` allows**, counted across every app of the org — the same number `GET /api/org/quotas` reports as ' +
|
|
428
|
+
'`usage.max_end_users`, since the same address in two apps is two accounts. `details` carries `{ quota, limit }`, as every count quota\'s ' +
|
|
429
|
+
'refusal does. The check is at **creation** only: an existing user signs in, is patched and is deleted at the quota exactly as under it, ' +
|
|
430
|
+
'because a protection limit that also froze the accounts already made would be an outage rather than a limit.',
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
method: 'GET', path: '/api/apps/:id/users/:userId', section: 'apps',
|
|
434
|
+
summary: 'Reads one user of the app.',
|
|
435
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
436
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }, { name: 'userId', description: 'The app user\'s uuid, from `GET /api/apps/:id/users`; a user of another app answers `404`.' }],
|
|
437
|
+
query: null, request: null, response: appUser,
|
|
438
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
439
|
+
notes: 'A user of another app, or of another org, reads exactly like one that does not exist — `404`, never a `403`.',
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
method: 'PATCH', path: '/api/apps/:id/users/:userId', section: 'apps',
|
|
443
|
+
summary: "Changes an app user's display name, role or status.",
|
|
444
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
445
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }, { name: 'userId', description: 'The app user\'s uuid, from `GET /api/apps/:id/users`; a user of another app answers `404`.' }],
|
|
446
|
+
query: null, request: patchAppUserRequest, response: appUser,
|
|
447
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found', 'target_state_conflict'], transport: 'http',
|
|
448
|
+
notes: 'The address is immutable: it is half of what identifies the account within the app, and a rewrite would silently move every token and ' +
|
|
449
|
+
'invitation addressed to the old one. Setting `status` to `blocked` ends every session the user holds and closes their live ' +
|
|
450
|
+
'`/realtime` subscriptions — blocking somebody who keeps a working socket is not blocking them. Moving them back to `active` mints ' +
|
|
451
|
+
'nothing; they log in again. \n\n**`active` is a way out of `blocked` and out of nothing else.** An account still ' +
|
|
452
|
+
'`pending_verification` answers `409 target_state_conflict` naming `status` with rule `unverified`: activating it would let somebody ' +
|
|
453
|
+
'who typed an address they do not own log in without ever spending the mailed token. Unblocking restores the status the account had — ' +
|
|
454
|
+
'`active` for one whose address was proven, `pending_verification` for one blocked before it ever verified. A write that names the ' +
|
|
455
|
+
'status the account already holds changes nothing and mints no event, so it does not end the sessions a re-sent form would otherwise ' +
|
|
456
|
+
'have killed. A `role_id` naming a role of another app is `404 not_found`, the same refusal creation makes.',
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
method: 'DELETE', path: '/api/apps/:id/users/:userId', section: 'apps',
|
|
460
|
+
summary: 'Deletes an app user and ends every session they hold.',
|
|
461
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 204,
|
|
462
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }, { name: 'userId', description: 'The app user\'s uuid, from `GET /api/apps/:id/users`; a user of another app answers `404`.' }],
|
|
463
|
+
query: null, request: null, response: null,
|
|
464
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
465
|
+
notes: 'Sessions are revoked before the row goes, for the reason `DELETE /api/org/users/:id` states: a live user with a dead session is ' +
|
|
466
|
+
'recoverable by retrying, a deleted user whose token still works is not. Outstanding invitations and unspent tokens for that address are ' +
|
|
467
|
+
'expired with it — a link mailed before the deletion is a standing re-admission ticket. **Nothing outside this app is touched**: a ' +
|
|
468
|
+
'Fleetless user sharing the address keeps their console account, and an account with the same address in a sibling app is a different ' +
|
|
469
|
+
'person as far as this platform is concerned.',
|
|
470
|
+
},
|
|
471
|
+
{
|
|
472
|
+
method: 'POST', path: '/api/apps/:id/users/:userId/reset-password', section: 'apps',
|
|
473
|
+
summary: "Mails an app user a password-reset link on the developer's behalf.",
|
|
474
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 202,
|
|
475
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }, { name: 'userId', description: 'The app user\'s uuid, from `GET /api/apps/:id/users`; a user of another app answers `404`.' }],
|
|
476
|
+
query: null, request: null, response: mailOutcome,
|
|
477
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'target_state_conflict'], transport: 'http',
|
|
478
|
+
notes: 'The support door beside `POST /api/client/password/reset`: the same one-hour token and the same link, triggered by a developer for a ' +
|
|
479
|
+
'user who asked them rather than the form. **No enumeration discipline applies** — the caller is authenticated into the app and can read ' +
|
|
480
|
+
'the user list — so this one answers what actually happened: `{ "mail": mailStatus }`, where `not_configured` is a deployment without a ' +
|
|
481
|
+
'mailer and `failed` is the state worth somebody\'s attention. `409 target_state_conflict` names `reset_url` when the app has configured ' +
|
|
482
|
+
'none: the token would be minted and the link would point nowhere, so nothing is minted. The same `409` names `password` with rule ' +
|
|
483
|
+
'`not_set` for an account that has none — an OIDC-only app user, whom a reset link would hand a second, quieter door — and `status` ' +
|
|
484
|
+
'with rule `blocked` for a blocked one, since `POST /api/client/password/reset` mails a blocked account nothing and the two doors may ' +
|
|
485
|
+
'not disagree. Setting the password directly is deliberately not offered; a developer who could would hold their customers\' ' +
|
|
486
|
+
'credentials.',
|
|
487
|
+
},
|
|
488
|
+
/* ---------------------------- the MCP clients one app user has connected */
|
|
489
|
+
{
|
|
490
|
+
method: 'GET', path: '/api/apps/:id/users/:userId/mcp-grants', section: 'apps',
|
|
491
|
+
summary: 'Lists the MCP clients one app user has consented to.',
|
|
492
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
493
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }, { name: 'userId', description: 'The app user\'s uuid, from `GET /api/apps/:id/users`; a user of another app answers `404`.' }],
|
|
494
|
+
query: null, request: null, response: mcpConsentGrantListResponse,
|
|
495
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
496
|
+
notes: 'A consent is remembered so that a later authorization can skip the app\'s own screen, and a client\'s registration lapsing does not ' +
|
|
497
|
+
'end it — so a person who approved something once had no way back and neither did the developer supporting them. This is the reading ' +
|
|
498
|
+
'half of that door. \n\n**Every name here is a claim the client made about itself.** Dynamic registration takes no credential, so ' +
|
|
499
|
+
'`client_name` is attacker-chosen text, unverified on every row, and `client_name_verified` is the literal `false`; a console that renders it as an ' +
|
|
500
|
+
'identity is rendering a string somebody picked. **Withdrawn grants are absent** rather than listed as withdrawn: the question is what ' +
|
|
501
|
+
'is connected now. \n\nThe user is scoped to the app and the app to the org, so a user of a sibling app and one that does not exist ' +
|
|
502
|
+
'read identically — `404`, never a `403`. **A developer sees which clients their customer connected and nothing those clients did**: ' +
|
|
503
|
+
'this route reads the consent table alone, and no scope, token or session of the person appears in it, because the authorization ' +
|
|
504
|
+
'server issues no scopes at all.',
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
method: 'DELETE', path: '/api/apps/:id/users/:userId/mcp-grants/:clientId', section: 'apps',
|
|
508
|
+
summary: 'Withdraws one app user\'s consent to an MCP client, on the developer\'s behalf.',
|
|
509
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 204,
|
|
510
|
+
params: [
|
|
511
|
+
{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' },
|
|
512
|
+
{ name: 'userId', description: 'The app user\'s uuid, from `GET /api/apps/:id/users`; a user of another app answers `404`.' },
|
|
513
|
+
{ name: 'clientId', description: 'The MCP client, as `GET /api/apps/:id/users/:userId/mcp-grants` reports its `client_id`. Not a uuid — it is the identifier the dynamic registration issued.' },
|
|
514
|
+
],
|
|
515
|
+
query: null, request: null, response: null,
|
|
516
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
517
|
+
notes: 'The support door beside `DELETE /api/client/mcp/grants/:clientId`, which is the same act by the person themselves. Audited as ' +
|
|
518
|
+
'`app_user.mcp_grant_revoked`, whose `details` carry the client id and the app\'s uuid — and nothing else, in particular no token and ' +
|
|
519
|
+
'no name the client chose for itself. \n\n**`204` whether or not there was anything to withdraw**, so a ' +
|
|
520
|
+
'double-clicked button and a client id no grant names both land on the end state the caller asked for. The alternative — `404` for a ' +
|
|
521
|
+
'client this user never approved — would make the route an oracle for which clients somebody has connected, answered before the ' +
|
|
522
|
+
'listing beside it was read; and it would turn the ordinary retry into a refusal. Only a withdrawal that actually ended a standing ' +
|
|
523
|
+
'agreement writes an audit event, so the log counts consents ended rather than buttons pressed. **`404` is still the app and the ' +
|
|
524
|
+
'user**, which are the two things the caller must own. \n\n**It ends a session already running, at that client\'s very next call.** ' +
|
|
525
|
+
'The app\'s MCP endpoint reads this table on every request, beside the account checks it already makes, so a withdrawn client is ' +
|
|
526
|
+
'answered `401` with the `WWW-Authenticate` challenge that sends it back to the consent screen. The refusal is keyed on the ' +
|
|
527
|
+
'`client_id` the access token carries, so it bites at the next call rather than at the next token: that token is still unexpired — ' +
|
|
528
|
+
'up to fifteen minutes are left on it — and is refused anyway. Only this client stops. The person\'s other clients and their own use ' +
|
|
529
|
+
'of the app are untouched, which is the difference from blocking the account (`PATCH /api/apps/:id/users/:userId`).',
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
method: 'GET', path: '/api/apps/:id/invitations', section: 'apps',
|
|
533
|
+
summary: "Lists the app's outstanding invitations, without their tokens.",
|
|
534
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
535
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
536
|
+
query: null, request: null, response: appInvitationListResponse,
|
|
537
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
538
|
+
notes: 'Answers `{ "invitations": [pendingAppInvitation, …] }` — pending only, since an accepted invitation is history rather than something to ' +
|
|
539
|
+
'revoke. **No `accept_url`**, the rule the team listing already keeps: this list exists so a developer can see what is outstanding and ' +
|
|
540
|
+
'withdraw it, and neither needs the token, while a list that carried it would turn every screenshot and browser-history entry of that ' +
|
|
541
|
+
'page into a live credential for somebody else\'s account. `mail` is omitted too — it described what happened at creation time, and ' +
|
|
542
|
+
're-serving it invites a reader to take it as current.',
|
|
543
|
+
},
|
|
544
|
+
{
|
|
545
|
+
method: 'POST', path: '/api/apps/:id/invitations', section: 'apps',
|
|
546
|
+
summary: 'Invites an address into the app with a role, and optionally mails the link.',
|
|
547
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 201,
|
|
548
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
549
|
+
query: null, request: createAppInvitationRequest, response: appInvitation,
|
|
550
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found', 'email_taken', 'target_state_conflict', 'rate_limited'],
|
|
551
|
+
transport: 'http',
|
|
552
|
+
notes: '**An app user, not a team member.** `POST /api/org/invitations` is the other space and leads to the console; this link leads into the ' +
|
|
553
|
+
'developer\'s own app. The role is resolved and stored now, so a later change to `default_role_id` does not re-aim a link already sent. ' +
|
|
554
|
+
'An invitation **always bypasses `allowed_domains`**. \n\nThe answer carries `accept_url`, which is `null` when the app has configured no ' +
|
|
555
|
+
'`invite_url` — there is nowhere for the link to point, and Fleetless serves an app user no page of its own. That is a `201` with a ' +
|
|
556
|
+
'null link, not a refusal: the invitation exists and a developer may hand the token over by another route. Asking to **mail** it in that ' +
|
|
557
|
+
'state is `409 target_state_conflict` naming `invite_url`, because a mail carrying a dead link is worse than no mail. The same `409` ' +
|
|
558
|
+
'names `default_role_id` when `role_id` is absent and the app has no default role, or its default no longer resolves: an invitation ' +
|
|
559
|
+
'that names no role has nothing to hand its acceptor, so it is refused here rather than at the acceptance a week later. `409 ' +
|
|
560
|
+
'email_taken` is an address the app already has as a user; `404 not_found` is the app or a `role_id` that is not one of its roles. ' +
|
|
561
|
+
'\n\nCreating shares the reissue route\'s ceiling of **five invitation mails a minute per app**, answering `429 rate_limited` with ' +
|
|
562
|
+
'`retry_after_ms`: re-creating an invitation for one address replaces it and mails again, so a limit that bound only reissue would be ' +
|
|
563
|
+
'a limit on the wrong door.',
|
|
564
|
+
},
|
|
565
|
+
{
|
|
566
|
+
method: 'POST', path: '/api/apps/:id/invitations/:invId/reissue', section: 'apps',
|
|
567
|
+
summary: 'Mints a fresh token onto the same invitation and returns the new link.',
|
|
568
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
569
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }, { name: 'invId', description: 'The invitation\'s uuid, from `GET /api/apps/:id/invitations`; an invitation of another app answers `404`.' }],
|
|
570
|
+
query: null, request: null, response: appInvitation,
|
|
571
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'rate_limited'], transport: 'http',
|
|
572
|
+
notes: 'The old link stops resolving the instant this returns: the row is found by token hash and the previous hash is gone. Two live links to ' +
|
|
573
|
+
'one invitation would reopen the door the listing\'s missing `accept_url` closes. \n\n**The answer carries a new `id`.** The old row ' +
|
|
574
|
+
'is revoked and a fresh one takes its place, so a caller holding the previous `id` gets `404` from its next revoke or reissue: re-read ' +
|
|
575
|
+
'the listing after this call rather than keeping the id you sent. The seven days start again. \n\nLimited server-side to **five ' +
|
|
576
|
+
'reissues a minute per app** — shared with `POST /api/apps/:id/invitations`, since both mint a link and mail it — answering `429 ' +
|
|
577
|
+
'rate_limited` with `retry_after_ms`. A disabled button is a hint, this is the limit. An invitation that has already been accepted is ' +
|
|
578
|
+
'not pending and answers `404`.',
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
method: 'DELETE', path: '/api/apps/:id/invitations/:invId', section: 'apps',
|
|
582
|
+
summary: 'Revokes a pending invitation so its link stops resolving.',
|
|
583
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 204,
|
|
584
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }, { name: 'invId', description: 'The invitation\'s uuid, from `GET /api/apps/:id/invitations`; an invitation of another app answers `404`.' }],
|
|
585
|
+
query: null, request: null, response: null,
|
|
586
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
587
|
+
notes: 'An invitation that was already accepted is not pending and answers `404`, the same answer one that never existed gets — the account it ' +
|
|
588
|
+
'created is a user now, and deleting that is `DELETE /api/apps/:id/users/:userId`. A revoked token answers `410 token_spent` at ' +
|
|
589
|
+
'`POST /api/client/invitations/accept`, the same answer one that expired or never existed gets — the developer withdrew it deliberately, ' +
|
|
590
|
+
'and an answer saying so would tell whoever still holds the link that it was once real.',
|
|
591
|
+
},
|
|
592
|
+
/* --------------------------------------- the app's OIDC providers (D4) */
|
|
593
|
+
{
|
|
594
|
+
method: 'GET', path: '/api/apps/:id/oidc-providers', section: 'apps',
|
|
595
|
+
summary: "Lists every OIDC provider configured on the app, enabled or not.",
|
|
596
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
597
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
598
|
+
query: null, request: null, response: appOidcProviderListResponse,
|
|
599
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
600
|
+
notes: 'Answers `{ "providers": [appOidcProvider, …] }` — **the management view, so a disabled provider is here** and is absent from the public ' +
|
|
601
|
+
'`GET /api/client/providers`. An app may have any number: the at-most-one rule this replaces was a property of the deleted group, not of ' +
|
|
602
|
+
'identity, and a developer serving two customers needs two. **No client secret appears in the answer**, by construction of ' +
|
|
603
|
+
'`appOidcProvider` — a secret a response can carry is a secret in every log that captured a response, which is the rule server keys and ' +
|
|
604
|
+
'the deleted group provider already kept.',
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
method: 'POST', path: '/api/apps/:id/oidc-providers', section: 'apps',
|
|
608
|
+
summary: 'Configures an OIDC provider on the app after checking that its issuer answers.',
|
|
609
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 201,
|
|
610
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
611
|
+
query: null, request: createAppOidcProviderRequest, response: appOidcProvider,
|
|
612
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found', 'duplicate_slug', 'provider_misconfigured', 'idp_unavailable'],
|
|
613
|
+
transport: 'http',
|
|
614
|
+
notes: '**Discovery runs before the row is written**, so a provider that cannot work is refused while the developer is looking at the form ' +
|
|
615
|
+
'rather than a week later in an app user\'s failed sign-in. `502 idp_unavailable` is an issuer that could not be reached and may work on ' +
|
|
616
|
+
'a retry; `422 provider_misconfigured` is one that answered with something unusable — not a discovery document, an `issuer` disagreeing ' +
|
|
617
|
+
'with the configured one, or an `authorization_endpoint`, `token_endpoint` or `jwks_uri` that is not an http(s) URL — and will answer ' +
|
|
618
|
+
'the same until somebody changes the configuration. That is the whole ' +
|
|
619
|
+
'reason the two codes are separate: one says wait, the other says fix it. \n\nThe issuer is **shape-checked** by `idpIssuer` (http(s), ' +
|
|
620
|
+
'no credentials, query or fragment) and that is not the SSRF defence: it cannot tell a loopback dev provider from a loopback database, ' +
|
|
621
|
+
'and the real check refuses loopback, link-local and private ranges at the fetch itself. `409 duplicate_slug` is a slug this app already ' +
|
|
622
|
+
'uses — slugs are unique per app and immutable, since linked identities are keyed by them. `404 not_found` is the app. **The client ' +
|
|
623
|
+
'secret goes in here and comes back out of nothing**: not this answer, not the read, not an audit detail.',
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
method: 'GET', path: '/api/apps/:id/oidc-providers/:providerId', section: 'apps',
|
|
627
|
+
summary: 'Reads one OIDC provider of the app, without its client secret.',
|
|
628
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
629
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }, { name: 'providerId', description: 'The provider\'s uuid, from `GET /api/apps/:id/oidc-providers`; a provider of another app answers `404`.' }],
|
|
630
|
+
query: null, request: null, response: appOidcProvider,
|
|
631
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
632
|
+
notes: 'A provider of another app, or of another org, reads exactly like one that does not exist — `404`, never a `403`. The stored client ' +
|
|
633
|
+
'secret is not in `appOidcProvider` and there is no route that reads one back; a developer who has lost theirs sends a replacement ' +
|
|
634
|
+
'through the `PATCH`.',
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
method: 'PATCH', path: '/api/apps/:id/oidc-providers/:providerId', section: 'apps',
|
|
638
|
+
summary: "Changes a provider's name, issuer, client, scopes, linking policy or enabled flag.",
|
|
639
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
640
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }, { name: 'providerId', description: 'The provider\'s uuid, from `GET /api/apps/:id/oidc-providers`; a provider of another app answers `404`.' }],
|
|
641
|
+
query: null, request: patchAppOidcProviderRequest, response: appOidcProvider,
|
|
642
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found', 'provider_misconfigured', 'idp_unavailable'], transport: 'http',
|
|
643
|
+
notes: '**`slug` is not in the body and offering it is a `400 validation_error`** naming the field, because the request is strict: the slug is ' +
|
|
644
|
+
'in the path and is what `app_user_identities` rows are keyed by, so a rename would orphan every linked account. An answer that ignored ' +
|
|
645
|
+
'it silently is the failure `updateAppRequest` was made strict to avoid. \n\n**`client_secret` absent means keep the stored one**, so a ' +
|
|
646
|
+
'routine scope edit need not put the secret back on the wire. Changing the issuer re-runs discovery, which is why this route carries the ' +
|
|
647
|
+
'same `422 provider_misconfigured` and `502 idp_unavailable` the create does — and identities linked under the old issuer keep their ' +
|
|
648
|
+
'`(provider, subject)` key rather than being re-resolved. `409 duplicate_slug` is absent because the one field that could collide cannot ' +
|
|
649
|
+
'be written here. Turning `enabled` off keeps the row and its linked identities: the provider disappears from ' +
|
|
650
|
+
'`GET /api/client/providers` and a start answers `provider_disabled`.',
|
|
651
|
+
},
|
|
652
|
+
{
|
|
653
|
+
method: 'DELETE', path: '/api/apps/:id/oidc-providers/:providerId', section: 'apps',
|
|
654
|
+
summary: 'Deletes an OIDC provider and every identity linked through it.',
|
|
655
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 204,
|
|
656
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }, { name: 'providerId', description: 'The provider\'s uuid, from `GET /api/apps/:id/oidc-providers`; a provider of another app answers `404`.' }],
|
|
657
|
+
query: null, request: null, response: null,
|
|
658
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
659
|
+
notes: '**The linked identities go with it, and the app users do not.** An account that only ever signed in through this provider survives with ' +
|
|
660
|
+
'no way back in until the developer mails them a reset link or re-configures the provider — deleting the accounts instead would make a ' +
|
|
661
|
+
'mistyped click destroy the developer\'s customers. Setting `enabled` to `false` on the `PATCH` is the reversible door and is what a ' +
|
|
662
|
+
'developer switching a provider off should use; this one is not reversible, because a re-created provider with the same slug resolves ' +
|
|
663
|
+
'no old `(provider, subject)` link. Answers `204` and `404` only: a provider in use is still deleted, since the alternative is a row ' +
|
|
664
|
+
'nothing can remove.',
|
|
665
|
+
},
|
|
666
|
+
/* ------------------------------- the app's auth configuration and mails */
|
|
667
|
+
{
|
|
668
|
+
method: 'GET', path: '/api/apps/:id/auth-config', section: 'apps',
|
|
669
|
+
summary: "Reads the app's auth settings: self-registration, domains, origins, URLs and the MCP switch.",
|
|
670
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
671
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
672
|
+
query: null, request: null, response: appAuthConfig,
|
|
673
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
674
|
+
notes: 'One row per app, created with the app and never absent — an app that has configured nothing reads back the defaults rather than a ' +
|
|
675
|
+
'`404`. `oidc_callback_url` is in the answer and not in the request: it is minted by the cloud from its own public base URL, is the same ' +
|
|
676
|
+
'for every app and every provider, and is the value a developer registers at their identity provider.',
|
|
677
|
+
},
|
|
678
|
+
{
|
|
679
|
+
method: 'PUT', path: '/api/apps/:id/auth-config', section: 'apps',
|
|
680
|
+
summary: "Replaces the app's auth settings in one write.",
|
|
681
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
682
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
683
|
+
query: null, request: putAppAuthConfigRequest, response: appAuthConfig,
|
|
684
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found'], transport: 'http',
|
|
685
|
+
notes: '**A replace, not a merge, and `.strict()`**: every field arrives or the write is refused, so a client built against an older shape ' +
|
|
686
|
+
'cannot silently clear a setting it does not know about. `oidc_callback_url` and `updated_at` are refused in the body — a writable ' +
|
|
687
|
+
'callback URL would let a caller point the return leg of an OIDC sign-in, which carries an authorization code, at a host they own. ' +
|
|
688
|
+
'\n\n`400 validation_error` is where the three field rules land: a URL template must be https (or `http` on `localhost`) and carry its ' +
|
|
689
|
+
'placeholder exactly once, an origin must be a bare scheme-host-port with no path, and a domain must be lowercase. Each refuses at ' +
|
|
690
|
+
'configuration time because each would otherwise fail silently later — a second placeholder leaves one occurrence literal in a mailed ' +
|
|
691
|
+
'link, an origin with a path can never equal a browser\'s `Origin` header, and a capitalised domain can never match a lowercased address.',
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
method: 'GET', path: '/api/apps/:id/mail-templates', section: 'apps',
|
|
695
|
+
summary: 'Lists the custom mail templates the app has, which may be none.',
|
|
696
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
697
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
698
|
+
query: null, request: null, response: appMailTemplateListResponse,
|
|
699
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
700
|
+
notes: 'Answers `{ "templates": [appMailTemplate, …] }` with **only the kinds that have a custom template** — at most three. A kind that does ' +
|
|
701
|
+
'not appear is one using the Fleetless default text, which is an ordinary state and not a missing row. Mails to *Fleetless* users, a ' +
|
|
702
|
+
'team invitation or a console password reset, are not in this list and are deliberately not customisable: they are about this platform, ' +
|
|
703
|
+
'not about the developer\'s product.',
|
|
704
|
+
},
|
|
705
|
+
{
|
|
706
|
+
method: 'GET', path: '/api/apps/:id/mail-templates/:kind', section: 'apps',
|
|
707
|
+
summary: 'Reads one custom mail template of the app.',
|
|
708
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
709
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }, { name: 'kind', description: 'Which of the three mails this template replaces — a `mailTemplateKind`: `invite`, `verify` or `reset`.' }],
|
|
710
|
+
query: null, request: null, response: appMailTemplate,
|
|
711
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found'], transport: 'http',
|
|
712
|
+
notes: 'The `kind` segment is a `mailTemplateKind`, so a fourth word is `400 validation_error` — the path names a set that is closed, and ' +
|
|
713
|
+
'answering `404` about it would read as "this app has no such template" when the truth is that no app can. `404 not_found` is the app, ' +
|
|
714
|
+
'or a kind this app has left on the Fleetless default: there is no stored row to read back, and inventing one would present the default ' +
|
|
715
|
+
'text as something the developer wrote.',
|
|
716
|
+
},
|
|
717
|
+
{
|
|
718
|
+
method: 'PUT', path: '/api/apps/:id/mail-templates/:kind', section: 'apps',
|
|
719
|
+
summary: 'Stores or replaces the app\'s template for one kind of mail, refusing one that does not render.',
|
|
720
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
721
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }, { name: 'kind', description: 'Which of the three mails this template replaces — a `mailTemplateKind`: `invite`, `verify` or `reset`.' }],
|
|
722
|
+
query: null, request: putAppMailTemplateRequest, response: appMailTemplate,
|
|
723
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found', 'template_invalid', 'rate_limited'], transport: 'http',
|
|
724
|
+
notes: 'The body carries `subject`, `text` and an optional `html`, each a Liquid template; `kind` is in the path and `updated_at` is the ' +
|
|
725
|
+
'server\'s, so neither may arrive. `text` is required even when `html` is given — a mail with no text part is unreadable to a client ' +
|
|
726
|
+
'that refuses HTML. \n\n**Liquid runs in strict mode and every part is rendered here before anything is stored**, so `422 ' +
|
|
727
|
+
'template_invalid` is an unknown variable or a syntax error rather than an empty line in a mail somebody already received. Its `details` ' +
|
|
728
|
+
'is a `mailTemplateProblemDetails` naming which of the three parts failed and the renderer\'s own message, because an error that did not ' +
|
|
729
|
+
'say which leaves the developer re-reading all three. The permitted variables are `MAIL_TEMPLATE_VARIABLES` and the set is closed. ' +
|
|
730
|
+
'Rendering here promises nothing about send time: a template that fails for one recipient falls back to the Fleetless default and writes ' +
|
|
731
|
+
'an audit event, and no answer on this route can say otherwise. \n\nA rendered part is capped while it is being written, so a template ' +
|
|
732
|
+
'that would produce megabytes answers `422 template_invalid` rather than building the string first. Limited server-side to **ten calls ' +
|
|
733
|
+
'a minute per app**, shared with the preview, answering `429 rate_limited` with `retry_after_ms`.',
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
method: 'DELETE', path: '/api/apps/:id/mail-templates/:kind', section: 'apps',
|
|
737
|
+
summary: 'Drops the app\'s custom template for one kind, returning that mail to the Fleetless default.',
|
|
738
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 204,
|
|
739
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }, { name: 'kind', description: 'Which of the three mails this template replaces — a `mailTemplateKind`: `invite`, `verify` or `reset`.' }],
|
|
740
|
+
query: null, request: null, response: null,
|
|
741
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found'], transport: 'http',
|
|
742
|
+
notes: 'The mail keeps being sent — this removes the developer\'s wording, not the message. A kind that already has no custom template answers ' +
|
|
743
|
+
'`404 not_found` rather than `204`: there is nothing here to reach the end state of, and the two facts are worth telling apart to ' +
|
|
744
|
+
'somebody who thinks they still have a template stored.',
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
method: 'POST', path: '/api/apps/:id/mail-templates/:kind/preview', section: 'apps',
|
|
748
|
+
summary: 'Renders a template with sample data and answers the three parts, storing nothing.',
|
|
749
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
750
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }, { name: 'kind', description: 'Which of the three mails this template replaces — a `mailTemplateKind`: `invite`, `verify` or `reset`.' }],
|
|
751
|
+
query: null, request: mailTemplatePreviewRequest, response: mailTemplatePreviewResponse,
|
|
752
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found', 'template_invalid', 'rate_limited'], transport: 'http',
|
|
753
|
+
notes: 'Takes the same document the PUT does and writes nothing, so a developer can see the rendered subject, text and HTML before anybody ' +
|
|
754
|
+
'receives them. The sample data fills every variable in `MAIL_TEMPLATE_VARIABLES`, including `link`, which is a plausible URL and not a ' +
|
|
755
|
+
'live token. `422 template_invalid` carries the same `mailTemplateProblemDetails` the PUT does, which is the point of previewing: the ' +
|
|
756
|
+
'error arrives on the screen where the template is being written. `404 not_found` is the app — a kind with no stored template previews ' +
|
|
757
|
+
'perfectly well, since the body being rendered is the one in the request. \n\n**The sample data does not vary with the `kind`.** ' +
|
|
758
|
+
'Every kind renders against one fixed set: an invite-shaped `link` and `expires_in_hours: 24`, where a real reset mail says 1 and a ' +
|
|
759
|
+
'real invitation says 168. A preview shows how the template renders, not what the recipient of that kind will read. \n\nLimited ' +
|
|
760
|
+
'server-side to **ten calls a minute per app**, shared with the PUT, answering `429 rate_limited` with `retry_after_ms`: rendering is ' +
|
|
761
|
+
'synchronous CPU work on the shared cloud and an unbounded loop of it is a denial of service against every other org.',
|
|
762
|
+
},
|
|
763
|
+
{
|
|
764
|
+
method: 'POST', path: '/api/apps/:id/mail-templates/:kind/test', section: 'apps',
|
|
765
|
+
summary: 'Sends the rendered template as a real mail to the calling developer.',
|
|
766
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 202,
|
|
767
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }, { name: 'kind', description: 'Which of the three mails this template replaces — a `mailTemplateKind`: `invite`, `verify` or `reset`.' }],
|
|
768
|
+
query: null, request: mailTemplatePreviewRequest, response: mailOutcome,
|
|
769
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found', 'template_invalid', 'rate_limited', 'target_state_conflict'],
|
|
770
|
+
transport: 'http',
|
|
771
|
+
notes: '**The recipient is the calling developer\'s own address and cannot be chosen.** A test send that named an arbitrary address would be a ' +
|
|
772
|
+
'mail relay with an authentication step in front of it. The body and the sample data are the preview\'s, so what arrives is what the ' +
|
|
773
|
+
'preview showed, in a real client with real HTML. \n\nThe answer is `{ "mail": mailStatus }` rather than an empty `202`, because the one ' +
|
|
774
|
+
'thing a developer needs next is whether a mail actually left: `not_configured` on a deployment with no mailer looks exactly like a ' +
|
|
775
|
+
'successful send otherwise, and they wait for a message nobody posted. `409 target_state_conflict` is that state made explicit where the ' +
|
|
776
|
+
'deployment can already tell — there is no mailer configured at all, so nothing will be attempted. `422 template_invalid` refuses before ' +
|
|
777
|
+
'sending, and `429 rate_limited` bounds how often this can be used to mail anybody, the developer included.',
|
|
778
|
+
},
|
|
779
|
+
/* ------------------------------------------- the team and its invites */
|
|
780
|
+
{
|
|
781
|
+
method: 'GET', path: '/api/org/users', section: 'users',
|
|
782
|
+
summary: "Lists the organisation's Fleetless users — the team who reach the console.",
|
|
783
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
784
|
+
params: [], query: null, request: null, response: fleetlessUserListResponse,
|
|
785
|
+
errors: [...DEVELOPER_GUARD], transport: 'http',
|
|
786
|
+
notes: '**Fleetless users, not an app\'s users.** The two identity spaces are separate and nothing joins them, so an app\'s users are listed ' +
|
|
787
|
+
'per app and never appear here. There is nothing to narrow by: the group filter this route used to take described a model with no ' +
|
|
788
|
+
'successor, and every Fleetless user of the org is in this answer.',
|
|
789
|
+
},
|
|
790
|
+
{
|
|
791
|
+
method: 'GET', path: '/api/org/users/:id', section: 'users',
|
|
792
|
+
summary: 'Reads one Fleetless user of the org.',
|
|
793
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
794
|
+
params: [{ name: 'id', description: 'The Fleetless user\'s uuid, as listed by `GET /api/org/users`.' }],
|
|
795
|
+
query: null, request: null, response: fleetlessUser,
|
|
796
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
797
|
+
},
|
|
798
|
+
{
|
|
799
|
+
method: 'POST', path: '/api/org/invitations', section: 'users',
|
|
800
|
+
summary: 'Invites an address onto the team and returns the accept link.',
|
|
801
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 201,
|
|
802
|
+
params: [], query: null, request: createTeamInviteRequest, response: teamInvite,
|
|
803
|
+
errors: [...DEVELOPER_GUARD, 'tier_required', 'validation_error', 'email_taken'], transport: 'http',
|
|
804
|
+
notes: '**A Fleetless user, not an app user.** Inviting somebody into an app is `POST /api/apps/:id/invitations` and is a different link into ' +
|
|
805
|
+
'a different space. `tier` is required, because "I did not think about it" and "I meant developer" must not be the same request on the ' +
|
|
806
|
+
'field that decides who can remove whom. **Inviting an Owner is Owner-only** — an invitation carrying `tier: "owner"` is a promotion ' +
|
|
807
|
+
'with an extra step, since the response hands back the `accept_url`. `ownerTier` is `false` here because the gate is on that value, not ' +
|
|
808
|
+
'on the route: any team member may invite a developer. **This collection sits beside `/api/org/users`, not under it**: an invitation is ' +
|
|
809
|
+
'not a user yet, and the old spelling put a literal `invitations` where `GET /api/org/users/:id` expects a uuid — reachable only because ' +
|
|
810
|
+
'a router ranks a static segment above a parametric one.',
|
|
811
|
+
},
|
|
812
|
+
{
|
|
813
|
+
method: 'GET', path: '/api/org/invitations', section: 'users',
|
|
814
|
+
summary: 'Lists the pending team invitations of the org, without their tokens.',
|
|
815
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
816
|
+
params: [], query: null, request: null, response: pendingTeamInviteListResponse,
|
|
817
|
+
errors: [...DEVELOPER_GUARD], transport: 'http',
|
|
818
|
+
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 ' +
|
|
819
|
+
'address they merely control, not so anyone can re-read a link.',
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
method: 'DELETE', path: '/api/org/invitations/:id', section: 'users',
|
|
823
|
+
summary: 'Revokes a pending invitation so its link stops resolving.',
|
|
824
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 204,
|
|
825
|
+
params: [{ name: 'id', description: 'The invitation\'s uuid, as listed by `GET /api/org/invitations`.' }],
|
|
826
|
+
query: null, request: null, response: null,
|
|
827
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
828
|
+
notes: 'An invitation that was already accepted is not pending and answers `404`, the same answer one that never existed gets.',
|
|
829
|
+
},
|
|
830
|
+
{
|
|
831
|
+
method: 'POST', path: '/api/org/invitations/:id/reissue', section: 'users',
|
|
832
|
+
summary: 'Mints a fresh token onto the same invitation and returns the new accept link.',
|
|
833
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
834
|
+
params: [{ name: 'id', description: 'The invitation\'s uuid, as listed by `GET /api/org/invitations`.' }],
|
|
835
|
+
query: null, request: null, response: teamInvite,
|
|
836
|
+
errors: [...DEVELOPER_GUARD, 'tier_required', 'invalid_uuid', 'not_found', 'rate_limited'], transport: 'http',
|
|
837
|
+
notes: 'The old link stops resolving the instant this returns — the row is looked up by token hash and the previous hash is gone. Two live ' +
|
|
838
|
+
'links to one invitation would reopen the door the listing\'s missing `accept_url` closes. Limited server-side to once a minute per ' +
|
|
839
|
+
'invitation, answering `429 rate_limited` with `retry_after_ms`; a disabled button is a hint, this is the limit. Re-issuing an ' +
|
|
840
|
+
'owner-tier invitation needs Owner tier, exactly as creating one does.',
|
|
841
|
+
},
|
|
842
|
+
{
|
|
843
|
+
method: 'POST', path: '/api/org/invitations/accept', section: 'users',
|
|
844
|
+
summary: 'Spends an invitation token and creates the login it was addressed to.',
|
|
845
|
+
audience: 'developer', auth: 'none', rateLimited: true, ownerTier: false, status: 204,
|
|
846
|
+
params: [], query: null, request: acceptTeamInviteRequest, response: null,
|
|
847
|
+
errors: ['rate_limited', 'validation_error', 'token_spent', 'email_taken'], transport: 'http',
|
|
848
|
+
notes: '**`204`, not a session.** The console signs in through its own OAuth portal, so a session minted here would be a second credential door ' +
|
|
849
|
+
'for one account — and every security property would then have to be right in two places. Unknown, expired and already-accepted tokens ' +
|
|
850
|
+
'collapse into `410 token_spent`. A browser form post gets the rendered "you\'re in" page instead.',
|
|
851
|
+
},
|
|
852
|
+
{
|
|
853
|
+
method: 'GET', path: '/accept-invite/:token', section: 'users',
|
|
854
|
+
summary: 'Serves the invitation card a mailed accept link opens.',
|
|
855
|
+
audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
856
|
+
params: [{ name: 'token', description: 'The opaque invitation token from the mailed link; it is never sent as a query parameter.' }],
|
|
857
|
+
query: null, request: null, response: null, errors: [], transport: 'http',
|
|
858
|
+
notes: 'HTML, served by the cloud from the auth portal origin; the form on it posts to `POST /api/org/invitations/accept`. An unknown, ' +
|
|
859
|
+
'spent or expired token renders the "link no longer valid" page at `410`, which offers the password-reset page — the only self-service ' +
|
|
860
|
+
'door the portal has, since an invitation cannot be re-issued by the person holding it.',
|
|
861
|
+
},
|
|
862
|
+
{
|
|
863
|
+
method: 'PATCH', path: '/api/org/users/:id', section: 'users',
|
|
864
|
+
summary: "Changes a team member's display name.",
|
|
865
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
866
|
+
params: [{ name: 'id', description: 'The Fleetless user\'s uuid, as listed by `GET /api/org/users`.' }],
|
|
867
|
+
query: null, request: patchFleetlessUserRequest, response: fleetlessUser,
|
|
868
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'validation_error'], transport: 'http',
|
|
869
|
+
notes: 'Nothing here has a consequence a PATCH body cannot carry: the tier is its own route, because it is owner-only and has a last-owner ' +
|
|
870
|
+
'guard, and the address is immutable. The audit event records which fields were addressed, never their values.',
|
|
871
|
+
},
|
|
872
|
+
{
|
|
873
|
+
method: 'DELETE', path: '/api/org/users/:id', section: 'users',
|
|
874
|
+
summary: 'Removes a team member and ends every session they hold.',
|
|
875
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 204,
|
|
876
|
+
params: [{ name: 'id', description: 'The Fleetless user\'s uuid, as listed by `GET /api/org/users`.' }],
|
|
877
|
+
query: null, request: null, response: null,
|
|
878
|
+
errors: [...DEVELOPER_GUARD, 'tier_required', 'invalid_uuid', 'not_found', 'last_owner'], transport: 'http',
|
|
879
|
+
notes: 'Sessions are revoked before the row is deleted: a still-existing user with a dead session is recoverable by retrying, a deleted user ' +
|
|
880
|
+
'whose old token still works is not. Any team invitation still outstanding for that address is expired too — a link mailed before the ' +
|
|
881
|
+
'removal is a standing re-admission ticket. **App accounts sharing the address are untouched**, in this org and in every other: they are ' +
|
|
882
|
+
'separate identities in a separate space, and deleting a colleague must not delete a customer. Removing an Owner needs Owner tier, and ' +
|
|
883
|
+
'removing the last one is `409 last_owner`.',
|
|
884
|
+
},
|
|
885
|
+
{
|
|
886
|
+
method: 'PUT', path: '/api/org/users/:id/tier', section: 'users',
|
|
887
|
+
summary: 'Promotes or demotes a team member between Owner and developer tier.',
|
|
888
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: true, status: 200,
|
|
889
|
+
params: [{ name: 'id', description: 'The Fleetless user\'s uuid, as listed by `GET /api/org/users`.' }],
|
|
890
|
+
query: null, request: tierChangeRequest, response: fleetlessUser,
|
|
891
|
+
errors: [...DEVELOPER_GUARD, 'tier_required', 'invalid_uuid', 'not_found', 'validation_error', 'last_owner'],
|
|
892
|
+
transport: 'http',
|
|
893
|
+
notes: 'Owner tier, unconditionally — this is the route the whole owner-exclusive list is about. A uuid that is not a Fleetless user of this ' +
|
|
894
|
+
'org answers `404 not_found`, the same as one that does not exist anywhere: the `409 target_state_conflict` documented here until the ' +
|
|
895
|
+
'two-space cut had exactly one producer, the Org Admins membership check, and went with it. Demoting the last Owner is `409 ' +
|
|
896
|
+
'last_owner`, decided by a row lock inside the writing ' +
|
|
897
|
+
'transaction rather than by a read beforehand. Setting the tier already held changes nothing and writes no audit event. No session is ' +
|
|
898
|
+
'revoked: a tier is re-read from the row on every request, so no issued token carries a stale copy of it.',
|
|
899
|
+
},
|
|
900
|
+
{
|
|
901
|
+
method: 'PATCH', path: '/api/org', section: 'org',
|
|
902
|
+
summary: 'Renames the org.',
|
|
903
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: true, status: 200,
|
|
904
|
+
params: [], query: null, request: patchOrgRequest, response: patchOrgResponse,
|
|
905
|
+
errors: [...DEVELOPER_GUARD, 'tier_required', 'validation_error'], transport: 'http',
|
|
906
|
+
notes: 'Answers `{ "org": org }`. Owner tier, and the gate runs ' +
|
|
907
|
+
'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 ' +
|
|
908
|
+
'nothing and records no audit event.',
|
|
909
|
+
},
|
|
910
|
+
/* --------------------------------------------------------------- mcp */
|
|
911
|
+
{
|
|
912
|
+
method: 'GET', path: '/.well-known/oauth-protected-resource/mcp', section: 'mcp',
|
|
913
|
+
summary: 'Publishes what the MCP endpoint says about who may authorize for it.',
|
|
914
|
+
audience: 'client', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
915
|
+
params: [], query: null, request: null, response: protectedResourceMetadata,
|
|
916
|
+
errors: [], transport: 'http',
|
|
917
|
+
notes: 'RFC 9728, for the one central MCP endpoint. `resource` and `authorization_servers` are the same URL: the MCP server is its own ' +
|
|
918
|
+
'authorization server here. One document for the whole deployment, because there is one endpoint and it is scoped to nothing ' +
|
|
919
|
+
'narrower: every Fleetless user of every org authorizes for the same resource, and the token names the person.',
|
|
920
|
+
},
|
|
921
|
+
{
|
|
922
|
+
method: 'GET', path: '/.well-known/oauth-authorization-server/mcp', section: 'mcp',
|
|
923
|
+
summary: 'Publishes the authorization-server metadata an MCP client reads to sign a person in.',
|
|
924
|
+
audience: 'client', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
925
|
+
params: [], query: null, request: null, response: authorizationServerMetadata,
|
|
926
|
+
errors: [], transport: 'http',
|
|
927
|
+
notes: '`registration_endpoint` being present is the whole point of the dynamic-registration work: a client that finds it registers itself and ' +
|
|
928
|
+
'never asks a person for a `client_id`. `authorization_endpoint` is the only field that moves to the auth-portal origin when one is ' +
|
|
929
|
+
'configured — `issuer`, `token_endpoint` and the resource identifier stay canonical, because a client checks a token\'s `iss` and `aud` ' +
|
|
930
|
+
'against those strings and moving them would invalidate every token ever minted.',
|
|
931
|
+
},
|
|
932
|
+
{
|
|
933
|
+
method: 'POST', path: '/mcp/oauth/register', section: 'mcp',
|
|
934
|
+
summary: 'Registers an MCP client dynamically, with no app identifier and no human in the loop.',
|
|
935
|
+
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 201,
|
|
936
|
+
params: [], query: null, request: dynamicClientRegistrationRequest, response: dynamicClientRegistrationResponse,
|
|
937
|
+
errors: ['rate_limited'], transport: 'http',
|
|
938
|
+
notes: 'RFC 7591. **The request schema is what this endpoint accepts, not what it parses**: the handler reads the body field by field, because ' +
|
|
939
|
+
'§3.2.2 distinguishes `invalid_redirect_uri` from `invalid_client_metadata` and one `safeParse` failure cannot say which of the two a ' +
|
|
940
|
+
'caller earned. The shape is deliberately **not** strict, which is the schema agreeing with §3.1 rather than a gap in it — a conforming ' +
|
|
941
|
+
'client sends `client_uri`, `logo_uri` and `software_id`, and both the schema and the server ignore them. `client_name` and ' +
|
|
942
|
+
'`redirect_uris` are the two fields read; `grant_types`, `response_types` and `scope` are accepted and ignored. What comes back is what ' +
|
|
943
|
+
'was actually granted, which §3.2.1 allows a server to substitute — this authorization server issues `authorization_code` only, so a ' +
|
|
944
|
+
'client that asked for `refresh_token` is registered and told plainly that it did not get one. The registration carries a TTL. Refusals ' +
|
|
945
|
+
'are `oauthError`; the rate limiter answers `apiError`.',
|
|
946
|
+
},
|
|
947
|
+
{
|
|
948
|
+
method: 'GET', path: '/mcp/oauth/authorize', section: 'mcp',
|
|
949
|
+
summary: 'Starts an MCP sign-in and redirects the browser to the identify card.',
|
|
950
|
+
audience: 'client', auth: 'none', rateLimited: false, ownerTier: false, status: 302,
|
|
951
|
+
params: [], query: oauthAuthorizeQuery, request: null, response: null, errors: [], transport: 'http',
|
|
952
|
+
notes: '**The query schema is what this endpoint accepts, not what it parses**: the handler reads it parameter by parameter because the ' +
|
|
953
|
+
'answers differ, and one parse would collapse them. ' +
|
|
954
|
+
'Client and `redirect_uri` are validated first and a failure there never redirects, the same open-redirect discipline the app flow ' +
|
|
955
|
+
'applies; those refusals are `oauthError`. Exact `redirect_uri` matching for both client kinds — the loopback-port wildcard of RFC 8252 ' +
|
|
956
|
+
'§7.3 belongs to the one central client alone, whose URIs are configured ahead of time and cannot name an ephemeral port. A client that ' +
|
|
957
|
+
'registered itself seconds ago can name the port it bound, and widening the wildcard there would only widen where a stolen `client_id` ' +
|
|
958
|
+
'may send a browser. Nothing about the person is decided here — the next card asks for an email address and the password step after ' +
|
|
959
|
+
'it resolves the account; this route knows only the client.',
|
|
960
|
+
},
|
|
961
|
+
{
|
|
962
|
+
method: 'GET', path: '/mcp/oauth/interaction/:id', section: 'mcp',
|
|
963
|
+
summary: 'Serves the "what is your email address" card of an MCP sign-in.',
|
|
964
|
+
audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
965
|
+
params: [{ name: 'id', description: 'The interaction id minted by `GET /mcp/oauth/authorize`, which redirects the browser here.' }],
|
|
966
|
+
query: null, request: null, response: null, errors: [], transport: 'http',
|
|
967
|
+
notes: 'HTML, and a GET rather than the body of the authorize response — so it is reloadable, bookmarkable and survives a back button, which ' +
|
|
968
|
+
'the inline page it replaced was not. An expired, consumed, unknown or hand-edited interaction renders one page at `410`, and so does a ' +
|
|
969
|
+
'client whose dynamic registration lapsed in between.',
|
|
970
|
+
},
|
|
971
|
+
{
|
|
972
|
+
method: 'POST', path: '/mcp/oauth/identify', section: 'mcp',
|
|
973
|
+
summary: 'Takes the email address and hands back the password step.',
|
|
974
|
+
audience: 'internal', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
975
|
+
params: [], query: null, request: null, response: null,
|
|
976
|
+
errors: ['rate_limited', 'validation_error', 'token_spent'], transport: 'http',
|
|
977
|
+
notes: 'The identifier-first step, with nothing left to identify: Fleetless users are password-only (design D1/D7), so **this step does not ' +
|
|
978
|
+
'read the address at all** — it renders the password card for a known address, an unknown one and an empty one alike, and the login ' +
|
|
979
|
+
'step below answers the same `401` for all three. That is a property of the shape rather than of two branches agreeing: there is no ' +
|
|
980
|
+
'lookup here whose result could differ. A browser form post gets the password card; a JSON caller gets `{ "next" }`, which has no ' +
|
|
981
|
+
'schema. Still rate limited per (route, ip, email), because it is an unauthenticated endpoint that renders a page.',
|
|
982
|
+
},
|
|
983
|
+
{
|
|
984
|
+
method: 'POST', path: '/mcp/oauth/login', section: 'mcp',
|
|
985
|
+
summary: 'Checks the password and hands back where the MCP sign-in continues.',
|
|
986
|
+
audience: 'internal', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
987
|
+
params: [], query: null, request: null, response: oauthRedirectResponse,
|
|
988
|
+
errors: ['rate_limited', 'validation_error', 'token_spent', 'invalid_credentials'], transport: 'http',
|
|
989
|
+
notes: 'The body is `{ "interaction_id", "email", "password" }`, read field by field rather than through a contract shape. A browser gets a ' +
|
|
990
|
+
'`303` — to the consent screen for a self-registered client, or straight to the callback for the central one — where a JSON caller gets ' +
|
|
991
|
+
'this `200` and `redirect_to`.',
|
|
992
|
+
},
|
|
993
|
+
{
|
|
994
|
+
method: 'GET', path: '/mcp/oauth/consent/:id', section: 'mcp',
|
|
995
|
+
summary: 'Serves the consent screen for an MCP client that registered itself.',
|
|
996
|
+
audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
997
|
+
params: [{ name: 'id', description: 'The interaction id from the sign-in; the login step redirects the browser here.' }],
|
|
998
|
+
query: null, request: null, response: null, errors: [], transport: 'http',
|
|
999
|
+
notes: 'HTML. The browser-proof cookie is checked on this GET, not only on the POST. The **central** client never reaches this screen and ' +
|
|
1000
|
+
'renders the `410` page instead: it is configured by the operator, so there is no self-registered stranger for a person to weigh up.',
|
|
1001
|
+
},
|
|
1002
|
+
{
|
|
1003
|
+
method: 'POST', path: '/mcp/oauth/consent', section: 'mcp',
|
|
1004
|
+
summary: 'Records the allow-or-deny and sends the browser back to the MCP client.',
|
|
1005
|
+
audience: 'internal', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1006
|
+
params: [], query: null, request: null, response: oauthRedirectResponse,
|
|
1007
|
+
errors: ['rate_limited', 'validation_error', 'token_spent'], transport: 'http',
|
|
1008
|
+
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 ' +
|
|
1009
|
+
'Allow value — a missing field included — denies. A denial still answers a `redirect_to`, carrying `error=access_denied` back to the ' +
|
|
1010
|
+
'client, because a client that is refused must learn so from its own callback rather than from a page nobody sent it.',
|
|
1011
|
+
},
|
|
1012
|
+
{
|
|
1013
|
+
method: 'POST', path: '/mcp/oauth/token', section: 'mcp',
|
|
1014
|
+
summary: 'Exchanges an MCP authorization code for an access token.',
|
|
1015
|
+
audience: 'client', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
1016
|
+
params: [], query: null, request: oauthTokenRequest, response: oauthTokenResponse,
|
|
1017
|
+
errors: [], transport: 'http',
|
|
1018
|
+
notes: 'Only `authorization_code` is supported — there is no refresh grant here, so a session ends when its token expires and the client signs ' +
|
|
1019
|
+
'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 ' +
|
|
1020
|
+
'`refresh_token`; the shape is the same `oauthTokenResponse` the app flow answers, whose refresh field is optional. The code is ' +
|
|
1021
|
+
'single-use, PKCE-verified, and its `resource` must match the audience it was authorized for.',
|
|
1022
|
+
},
|
|
1023
|
+
/* ------------------------------- developer auth (the console\'s OAuth portal) */
|
|
1024
|
+
{
|
|
1025
|
+
method: 'GET', path: '/console/oauth/authorize', section: 'developer-auth',
|
|
1026
|
+
summary: 'Starts a console sign-in and redirects the browser to the identify card.',
|
|
1027
|
+
audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 302,
|
|
1028
|
+
params: [], query: null, request: null, response: null, errors: [], transport: 'http',
|
|
1029
|
+
notes: 'The authorization-code leg of the console\'s own OAuth flow: PKCE `S256` is required and `redirect_uri` must match a configured console ' +
|
|
1030
|
+
'callback exactly, never a prefix. Refusals use RFC 6749\'s flat `oauthError` shape, not the `apiError` envelope, so they carry none of ' +
|
|
1031
|
+
'the codes in this reference. A bad `redirect_uri` never redirects — until the URI is known-good, sending a browser to it is the attack; ' +
|
|
1032
|
+
'later errors go back to the callback as query parameters. `?prompt=create` starts at sign-up rather than sign-in.',
|
|
1033
|
+
},
|
|
1034
|
+
{
|
|
1035
|
+
method: 'GET', path: '/console/oauth/interaction/:id', section: 'developer-auth',
|
|
1036
|
+
summary: 'Serves the "what is your email address" card of a console sign-in.',
|
|
1037
|
+
audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
1038
|
+
params: [{ name: 'id', description: 'The interaction id minted by `GET /console/oauth/authorize`, which redirects the browser here.' }],
|
|
1039
|
+
query: null, request: null, response: null, errors: [], transport: 'http',
|
|
1040
|
+
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 ' +
|
|
1041
|
+
'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 ' +
|
|
1042
|
+
'enumeration oracle here at all.',
|
|
1043
|
+
},
|
|
1044
|
+
{
|
|
1045
|
+
method: 'POST', path: '/console/oauth/identify', section: 'developer-auth',
|
|
1046
|
+
summary: 'Takes the email address and hands back the password step.',
|
|
1047
|
+
audience: 'internal', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1048
|
+
params: [], query: null, request: null, response: null,
|
|
1049
|
+
errors: ['rate_limited', 'token_spent'], transport: 'http',
|
|
1050
|
+
notes: 'A browser form post gets the password card as HTML; a JSON caller gets `{ "next": "/console/oauth/login" }`, which has no schema — the ' +
|
|
1051
|
+
'step made no decision, and it says so rather than inventing a redirect. A dead interaction is `410 token_spent`. Rate limited despite ' +
|
|
1052
|
+
'spending no credential: it is an unauthenticated endpoint that renders a page.',
|
|
1053
|
+
},
|
|
1054
|
+
{
|
|
1055
|
+
method: 'POST', path: '/console/oauth/login', section: 'developer-auth',
|
|
1056
|
+
summary: 'Checks the password and mints the authorization code the console exchanges.',
|
|
1057
|
+
audience: 'internal', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1058
|
+
params: [], query: null, request: null, response: oauthRedirectResponse,
|
|
1059
|
+
errors: ['rate_limited', 'token_spent', 'invalid_credentials'], transport: 'http',
|
|
1060
|
+
notes: 'This is the only place a Fleetless developer password may be typed; `POST /api/auth/login` is gone, because a second credential door ' +
|
|
1061
|
+
'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 ' +
|
|
1062
|
+
'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 ' +
|
|
1063
|
+
'it — filtering first would hand back a faster "no" for a non-admin account, which is a timing oracle. Wrong password, unknown address ' +
|
|
1064
|
+
'and "not an org admin" render identical bytes under one `401`.',
|
|
1065
|
+
},
|
|
1066
|
+
{
|
|
1067
|
+
method: 'GET', path: '/console/oauth/signup/:id', section: 'developer-auth',
|
|
1068
|
+
summary: 'Serves step one of console sign-up, the account card.',
|
|
1069
|
+
audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
1070
|
+
params: [{ name: 'id', description: 'The interaction id minted by `GET /console/oauth/authorize` with `?prompt=create`.' }],
|
|
1071
|
+
query: null, request: null, response: null, errors: [], transport: 'http',
|
|
1072
|
+
notes: 'HTML. While the deployment runs in closed beta this renders the "sign-up is closed" card at `403` instead, keeping the interaction alive ' +
|
|
1073
|
+
'and pointing back at sign-in — the person may well already have an account.',
|
|
1074
|
+
},
|
|
1075
|
+
{
|
|
1076
|
+
method: 'POST', path: '/console/oauth/signup', section: 'developer-auth',
|
|
1077
|
+
summary: 'Takes the sign-up email and password and hands back the organization step.',
|
|
1078
|
+
audience: 'internal', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1079
|
+
params: [], query: null, request: null, response: null,
|
|
1080
|
+
errors: ['rate_limited', 'token_spent', 'signup_closed', 'validation_error', 'email_taken'], transport: 'http',
|
|
1081
|
+
notes: 'A browser form post gets the organization card; a JSON caller gets `{ "next", "email" }`, which has no schema. The plaintext password ' +
|
|
1082
|
+
'exists for this one request: what is stored is its argon2 hash, on the interaction row, which expires with it. A per-interaction proof ' +
|
|
1083
|
+
'cookie is set here — it is what stops a third party from finishing a sign-up somebody else started. Sign-up is the one surface whose job ' +
|
|
1084
|
+
'is to say an address is taken, so `409 email_taken` is not a leak here.',
|
|
1085
|
+
},
|
|
1086
|
+
{
|
|
1087
|
+
method: 'POST', path: '/console/oauth/signup/organization', section: 'developer-auth',
|
|
1088
|
+
summary: 'Takes the organization name and creates the org and its founding Owner.',
|
|
1089
|
+
audience: 'internal', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1090
|
+
params: [], query: null, request: null, response: oauthRedirectResponse,
|
|
1091
|
+
errors: ['rate_limited', 'token_spent', 'signup_closed', 'wrong_browser', 'validation_error', 'email_taken'], transport: 'http',
|
|
1092
|
+
notes: 'The same single transaction `POST /api/auth/signup` runs. Step one must have run in **this** browser: a missing or mismatched proof ' +
|
|
1093
|
+
'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 ' +
|
|
1094
|
+
'caller gets `redirect_to` at `200`.',
|
|
1095
|
+
},
|
|
1096
|
+
{
|
|
1097
|
+
method: 'POST', path: '/console/oauth/token', section: 'developer-auth',
|
|
1098
|
+
summary: "Exchanges the console's authorization code for a developer session.",
|
|
1099
|
+
audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
1100
|
+
params: [], query: null, request: null, response: sessionTokens, errors: [], transport: 'http',
|
|
1101
|
+
notes: 'Called by the console\'s own server, never by a browser. Refusals use RFC 6749 §5.2\'s flat `oauthError` shape — it is a token endpoint, ' +
|
|
1102
|
+
'and that is the dialect a caller of one expects — so it emits none of the codes in this reference. Proof of possession is checked before ' +
|
|
1103
|
+
'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 ' +
|
|
1104
|
+
'code was minted earlier, and a user moved out in between must not get a console session.',
|
|
1105
|
+
},
|
|
1106
|
+
/* ---------------------------------------------------- mcp (the endpoint) */
|
|
1107
|
+
{
|
|
1108
|
+
method: 'GET', path: '/mcp/welcome', section: 'mcp',
|
|
1109
|
+
summary: 'Serves the page that tells a person which URL to paste into their MCP client.',
|
|
1110
|
+
audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
1111
|
+
params: [], query: null, request: null, response: null, errors: [], transport: 'http',
|
|
1112
|
+
notes: 'HTML, one fixed document rendered once at startup — its inputs are process configuration, not request state. Cached for five minutes ' +
|
|
1113
|
+
'rather than a day, because the URLs it names can change with a deployment. Its content security policy admits the page\'s own inline ' +
|
|
1114
|
+
'style and script by SHA-256 rather than by `unsafe-inline`, and forbids every external fetch outright. A configured friendly URL that ' +
|
|
1115
|
+
'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 ' +
|
|
1116
|
+
'front of every end user.',
|
|
1117
|
+
},
|
|
1118
|
+
{
|
|
1119
|
+
method: 'POST', path: '/mcp', section: 'mcp',
|
|
1120
|
+
summary: 'The central MCP endpoint: a stateless Streamable HTTP transport carrying the robot and console tool catalogs.',
|
|
1121
|
+
audience: 'client', auth: 'in_handler', rateLimited: false, ownerTier: false, status: 200,
|
|
1122
|
+
params: [], query: null, request: null, response: null,
|
|
1123
|
+
errors: ['unauthorized', 'forbidden'], transport: 'http',
|
|
1124
|
+
notes: 'JSON-RPC over MCP\'s Streamable HTTP, so neither the request nor the response is a shape contracts describes; the tool arguments and ' +
|
|
1125
|
+
'results are the schemas in each tool definition. **Fleetless users only** — an app\'s users reach their own app endpoint instead. ' +
|
|
1126
|
+
'**The bearer is verified inside the handler**, not by a route guard: the identity comes from the token and the path names none, and ' +
|
|
1127
|
+
'the refusal has to carry a `WWW-Authenticate` challenge that a guard shared with the REST surface does not send. `Origin` is checked ' +
|
|
1128
|
+
'against the cloud\'s own, and a foreign one is the `403 forbidden` above. **Both catalogs, unconditionally**: every caller admitted ' +
|
|
1129
|
+
'here is a Fleetless user, so the tool list has nothing left to vary with and the admin-ness check on `tools/call` is gone — a console ' +
|
|
1130
|
+
'tool that is still narrower than the catalog refuses for itself (`console_robot_delete` answers `tier_required` to a non-Owner). ' +
|
|
1131
|
+
'`403 forbidden` is also what an `mcp_session` token whose subject is an **app user** gets: this endpoint serves the team only, and such ' +
|
|
1132
|
+
'a token belongs to its own app\'s endpoint. The code is `forbidden` rather than `mcp_disabled` because nothing is switched off — the ' +
|
|
1133
|
+
'caller is at the wrong server — and per-app sign-in mints exactly such tokens, so the two states must not share a word. ' +
|
|
1134
|
+
'`mcp_access_denied` is gone with the per-user override and the ' +
|
|
1135
|
+
'group flag it read: every Fleetless user has MCP access here (D1). Stateless: a fresh transport per request, no session id, nothing ' +
|
|
1136
|
+
'survives the call.',
|
|
1137
|
+
},
|
|
1138
|
+
/* ----------------------------------------- mcp (one app's own server, D7) */
|
|
1139
|
+
{
|
|
1140
|
+
method: 'POST', path: MCP_APP.endpoint, section: 'mcp',
|
|
1141
|
+
summary: "One app's MCP endpoint: the same stateless Streamable HTTP transport, carrying that app's robots.",
|
|
1142
|
+
audience: 'client', auth: 'in_handler', rateLimited: false, ownerTier: false, status: 200,
|
|
1143
|
+
params: [APP_IDENTIFIER], query: null, request: null, response: null,
|
|
1144
|
+
errors: ['not_found', 'unauthorized', 'forbidden'], transport: 'http',
|
|
1145
|
+
notes: 'JSON-RPC over MCP\'s Streamable HTTP, so neither the request nor the response is a shape contracts describes — exactly as `POST /mcp` ' +
|
|
1146
|
+
'is, and stateless for the same reason: a fresh transport per request, no session id, nothing surviving the call. **App users only.** ' +
|
|
1147
|
+
'The tools are this app\'s robots filtered by the caller\'s role, built by the same builder `GET /api/apps/:id/roles/:roleId/mcp-tools` ' +
|
|
1148
|
+
'previews, so the console\'s preview and the live catalog cannot drift. The console tool family belongs to the central endpoint and is ' +
|
|
1149
|
+
'offered here to nobody. \n\n**The bearer is verified inside the handler**, not by a route guard, for the two reasons the central ' +
|
|
1150
|
+
'endpoint gives — the identity comes from the token and the path names none of it, and the refusal has to carry a `WWW-Authenticate` ' +
|
|
1151
|
+
'challenge a guard shared with the REST surface does not send. The challenge names **this app\'s** protected-resource document (RFC ' +
|
|
1152
|
+
'9728\'s `resource_metadata`), which is how an MCP client discovers the right authorization server from a bare `401`; pointing it at ' +
|
|
1153
|
+
'the central document would send every app\'s client to the wrong sign-in. \n\n**`404 not_found` covers an identifier no app carries AND ' +
|
|
1154
|
+
'an app whose `appAuthConfig.mcp_enabled` is off — one answer for both, the same one the two metadata documents and `register` give.** ' +
|
|
1155
|
+
'A separate `403 mcp_disabled` here would hand an anonymous caller a three-way oracle (`404` = no such app, `403` = the app exists ' +
|
|
1156
|
+
'with MCP off, `401` = the app exists and is live), which is exactly the distinction discovery collapses; there is no point ' +
|
|
1157
|
+
'collapsing it in one place and publishing it in another. The switch is re-read on every request rather than cached off the token, so ' +
|
|
1158
|
+
'a developer turning it off ends the sessions already running, and it is decided **before the bearer is looked at** — the reverse of ' +
|
|
1159
|
+
'the usual order, and deliberate: it is a fact about the path, an app identifier is public, and an absent server that answered `401` ' +
|
|
1160
|
+
'would send a client hunting a credential no credential can satisfy. `401 unauthorized` is a missing, unverifiable or expired bearer, ' +
|
|
1161
|
+
'an `aud` that is not this endpoint, or **a consent this person has since withdrawn from the client the token was minted for**: the ' +
|
|
1162
|
+
'access token names its client, and the standing consent is re-read here on every request exactly as the account is, so ' +
|
|
1163
|
+
'`DELETE /api/client/mcp/grants/:clientId` and its developer twin bite at the next call rather than when the token expires. `403 ' +
|
|
1164
|
+
'forbidden` is a token that verifies and is not this app\'s user: another app\'s session, a Fleetless user\'s central `mcp_session`, ' +
|
|
1165
|
+
'an account that is `blocked` or still `pending_verification`, or a foreign `Origin`.',
|
|
1166
|
+
},
|
|
1167
|
+
{
|
|
1168
|
+
method: 'GET', path: MCP_APP.endpoint, section: 'mcp',
|
|
1169
|
+
summary: "Answers the standalone SSE stream's GET, which a stateless transport does not serve.",
|
|
1170
|
+
audience: 'client', auth: 'in_handler', rateLimited: false, ownerTier: false, status: 405,
|
|
1171
|
+
params: [APP_IDENTIFIER], query: null, request: null, response: null,
|
|
1172
|
+
errors: ['not_found', 'unauthorized', 'forbidden'], transport: 'http',
|
|
1173
|
+
notes: 'MCP\'s Streamable HTTP gives this path three verbs: `POST` carries JSON-RPC, `GET` opens the server-initiated SSE stream, and `DELETE` ' +
|
|
1174
|
+
'ends a session. This server has no sessions — the argument is in `MCP_PROTOCOL_VERSION`\'s own note, and W8\'s second cloud instance is ' +
|
|
1175
|
+
'where a per-process session map would break — so `GET` and `DELETE` answer `405`, which is what a client is built to fall back from. ' +
|
|
1176
|
+
'\n\n**The `405` is this cloud\'s own answer, not the SDK\'s**, and the difference was measured: MCP SDK 1.30.0 opens an SSE stream on ' +
|
|
1177
|
+
'`GET` (`handleGetRequest`) and answers `200` on `DELETE` (`handleDeleteRequest`), neither of which a stateless server has any ' +
|
|
1178
|
+
'business doing, so the cloud writes the `405` itself in the transport\'s own JSON-RPC error shape with `Allow: POST`. \n\n**The row exists so that the `405` is not a `404`.** An unregistered verb answers ' +
|
|
1179
|
+
'`404`, and at a path whose last segment is an app identifier a `404` already means *no such app* — one answer for two states, which is ' +
|
|
1180
|
+
'the failure this project keeps paying for. Registering the verb lets the endpoint say "this app\'s server is here; this verb is not ' +
|
|
1181
|
+
'part of it". The central `/mcp` registers neither verb and does not need to: its path takes no parameter, so nothing can misread its ' +
|
|
1182
|
+
'`404`. \n\n**The `405` body is the transport\'s JSON-RPC error object, not the `apiError` envelope.** The three codes above are the ' +
|
|
1183
|
+
'refusals that come *first* — the app, its switch, then the bearer, in the order `POST` describes — and they are `apiError` because ' +
|
|
1184
|
+
'they are answered before the transport is reached at all. If this server ever becomes stateful, this row and the `DELETE` beside it ' +
|
|
1185
|
+
'are where that lands, and the cloud\'s route-manifest test is what would make both repositories notice.',
|
|
1186
|
+
},
|
|
1187
|
+
{
|
|
1188
|
+
method: 'DELETE', path: MCP_APP.endpoint, section: 'mcp',
|
|
1189
|
+
summary: 'Answers the session-termination DELETE, which a stateless transport has no session to end.',
|
|
1190
|
+
audience: 'client', auth: 'in_handler', rateLimited: false, ownerTier: false, status: 405,
|
|
1191
|
+
params: [APP_IDENTIFIER], query: null, request: null, response: null,
|
|
1192
|
+
errors: ['not_found', 'unauthorized', 'forbidden'], transport: 'http',
|
|
1193
|
+
notes: 'The other half of what the `GET` row above explains, and registered for the same reason: without a row here, a client tidying up after ' +
|
|
1194
|
+
'itself would read `404` and could not tell a stateless server from an app that does not exist. `405`, from the same transport, with ' +
|
|
1195
|
+
'the same three refusals ahead of it. A caller that wants a session to end simply stops sending requests — there is no server-side state ' +
|
|
1196
|
+
'for this verb to remove, which is the point rather than a limitation.',
|
|
1197
|
+
},
|
|
1198
|
+
{
|
|
1199
|
+
method: 'GET', path: MCP_APP.protectedResourceMetadata, section: 'mcp',
|
|
1200
|
+
summary: "Publishes what one app's MCP endpoint says about who may authorize for it.",
|
|
1201
|
+
audience: 'client', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
1202
|
+
params: [APP_IDENTIFIER], query: null, request: null, response: protectedResourceMetadata,
|
|
1203
|
+
errors: ['not_found'], transport: 'http',
|
|
1204
|
+
notes: 'RFC 9728, for the resource `<PUBLIC_API_BASE_URL>/mcp/<identifier>`. `resource` and `authorization_servers` are the same URL: each ' +
|
|
1205
|
+
'app\'s MCP server is its own authorization server, as the central one is, and that identity is what keeps one app\'s tokens out of ' +
|
|
1206
|
+
'another\'s — the audience a token carries is this app\'s endpoint URL and nothing broader. \n\n**The identifier goes last, after the ' +
|
|
1207
|
+
'document name.** §3.1 inserts `/.well-known/oauth-protected-resource` *before* the resource\'s path, so the document for `/mcp/<id>` is ' +
|
|
1208
|
+
'at `/.well-known/oauth-protected-resource/mcp/<id>`; a hand-written `/.well-known/oauth-protected-resource/<id>` is a path no ' +
|
|
1209
|
+
'conforming client ever fetches. `MCP_APP_PATHS` builds both, which is why this row does not spell either. \n\n**An app with MCP ' +
|
|
1210
|
+
'switched off answers `404`, the same as an identifier no app carries, and that is a decision rather than a gap.** A metadata document ' +
|
|
1211
|
+
'is present or it is absent; `403` is not a state a client\'s discovery code models, and one that met it would either error out or ' +
|
|
1212
|
+
'retry forever. Nothing is being hidden — the identifier is public and is in this very path — the two answers are simply the same ' +
|
|
1213
|
+
'answer: there is no MCP server here to authorize for. **Every other unauthenticated route on this surface says the same** — the ' +
|
|
1214
|
+
'authorization-server document, `register`, `authorize` and the transport itself all answer `404` for both states, so nothing an ' +
|
|
1215
|
+
'anonymous caller can reach distinguishes them. A person whose app has the switch off learns that from the console, not from a ' +
|
|
1216
|
+
'status code a stranger can also read.',
|
|
1217
|
+
},
|
|
1218
|
+
{
|
|
1219
|
+
method: 'GET', path: MCP_APP.authorizationServerMetadata, section: 'mcp',
|
|
1220
|
+
summary: "Publishes the authorization-server metadata an MCP client reads to sign in to one app.",
|
|
1221
|
+
audience: 'client', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
1222
|
+
params: [APP_IDENTIFIER], query: null, request: null, response: authorizationServerMetadata,
|
|
1223
|
+
errors: ['not_found'], transport: 'http',
|
|
1224
|
+
notes: 'RFC 8414, for the issuer `<PUBLIC_API_BASE_URL>/mcp/<identifier>` — the same path rule as the document above, and the same `404` for a ' +
|
|
1225
|
+
'switched-off app. `registration_endpoint` is present for the reason the central document states: a client that finds it registers ' +
|
|
1226
|
+
'itself and never asks a person for a `client_id`. \n\n**`issuer`, `token_endpoint` and the resource identifier are minted from the ' +
|
|
1227
|
+
'canonical public base, never from the friendly `mcp.fleetless.dev` alias or the request\'s `Host`**, because a client checks a minted ' +
|
|
1228
|
+
'token\'s `iss` and `aud` against these exact strings. \n\n**Unlike the central document, `authorization_endpoint` does not move to an ' +
|
|
1229
|
+
'auth-portal origin**, and there is nothing here for one to serve: this authorization step renders no Fleetless page at all. It ' +
|
|
1230
|
+
'redirects to the app\'s own `mcp_login_url` (D7), which is on the developer\'s origin already.',
|
|
1231
|
+
},
|
|
1232
|
+
{
|
|
1233
|
+
method: 'POST', path: MCP_APP.register, section: 'mcp',
|
|
1234
|
+
summary: 'Registers an MCP client dynamically for one app, with no human in the loop.',
|
|
1235
|
+
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 201,
|
|
1236
|
+
params: [APP_IDENTIFIER], query: null, request: dynamicClientRegistrationRequest, response: dynamicClientRegistrationResponse,
|
|
1237
|
+
errors: ['rate_limited', 'not_found'], transport: 'http',
|
|
1238
|
+
notes: 'RFC 7591, the same wire and the same handler as `POST /mcp/oauth/register` — `registerMcpDynamicClient`, one implementation, because a ' +
|
|
1239
|
+
'second answer to "is this redirect URI acceptable" would agree with the first only by luck. The request schema is what the endpoint ' +
|
|
1240
|
+
'accepts rather than what it parses, for the reason that row gives: §3.2.2 needs two distinguishable refusals and one `safeParse` ' +
|
|
1241
|
+
'failure offers one. `client_name` and `redirect_uris` are read; `grant_types`, `response_types` and `scope` are accepted and ignored, ' +
|
|
1242
|
+
'and what comes back is what was actually ' +
|
|
1243
|
+
'granted, which §3.2.1 allows — `authorization_code` only, so a client that asked for `refresh_token` is registered and told plainly ' +
|
|
1244
|
+
'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 ' +
|
|
1245
|
+
'authorizes at this app\'s endpoint and nowhere else, so a client registered against one app cannot walk into another\'s authorize with ' +
|
|
1246
|
+
'it, and a developer who switches MCP off is not left with strangers\' registrations valid somewhere adjacent. \n\nRefusals are ' +
|
|
1247
|
+
'`oauthError`; the rate limiter and `404 not_found` answer `apiError`. That `404` covers an unknown identifier **and** an app with the ' +
|
|
1248
|
+
'switch off, mirroring the two metadata documents this endpoint is discovered from — a client that could not read those has no business ' +
|
|
1249
|
+
'registering here, and giving it a third distinct answer would only tell it something the documents deliberately do not.',
|
|
1250
|
+
},
|
|
1251
|
+
{
|
|
1252
|
+
method: 'GET', path: MCP_APP.authorize, section: 'mcp',
|
|
1253
|
+
summary: "Starts an MCP sign-in and redirects the browser to the app's own login page.",
|
|
1254
|
+
audience: 'client', auth: 'none', rateLimited: false, ownerTier: false, status: 302,
|
|
1255
|
+
params: [APP_IDENTIFIER], query: oauthAuthorizeQuery, request: null, response: null,
|
|
1256
|
+
errors: ['not_found', 'target_state_conflict'], transport: 'http',
|
|
1257
|
+
notes: 'The same query as `GET /mcp/oauth/authorize`, read the same way — parameter by parameter, because the answers differ and one parse ' +
|
|
1258
|
+
'would collapse them. \n\n**Fleetless renders no page here, and that is the whole of D7.** The route writes an interaction — ten minutes, as the OIDC ones live ' +
|
|
1259
|
+
'— and redirects to `appAuthConfig.mcp_login_url` with `{interaction}` filled in. The app then authenticates the person with its own ' +
|
|
1260
|
+
'UI, reads `GET /api/client/mcp/interactions/:id` to show the client\'s claimed name and the scopes it asked for, and calls approve or ' +
|
|
1261
|
+
'deny. \n\nClient and `redirect_uri` are validated first and a failure there never redirects — the open-redirect discipline `GET ' +
|
|
1262
|
+
'/mcp/oauth/authorize` and `GET /api/client/oidc/:slug/start` both keep — and those refusals are RFC 6749\'s flat `oauthError`, which ' +
|
|
1263
|
+
'is why none of them appear above. `redirect_uri` is matched **exactly** against the registration, with no loopback-port wildcard: ' +
|
|
1264
|
+
'every client here registered itself minutes ago and can name the port it bound, so a wildcard would only widen where a stolen ' +
|
|
1265
|
+
'`client_id` may send a browser. \n\nThe two codes above are the `apiError` envelope because they are refusals about the **app**, ' +
|
|
1266
|
+
'decided before an OAuth parameter is looked at. **`404 not_found` covers an identifier no app carries AND an app with MCP switched ' +
|
|
1267
|
+
'off** — the same single answer the two metadata documents, `register` and the transport give. An earlier draft answered `403 ' +
|
|
1268
|
+
'mcp_disabled` here, on the argument that a client which registered while the switch was on is owed the difference between "turned ' +
|
|
1269
|
+
'off" and "mistyped"; that argument does not survive the caller being anonymous. This route takes no credential, so the extra code ' +
|
|
1270
|
+
'was readable by anyone who could type an identifier, and it handed back precisely the existence distinction every neighbouring ' +
|
|
1271
|
+
'route collapses. `mcp_disabled` survives only where the caller has already proved they belong to the app — the two decision routes ' +
|
|
1272
|
+
'under `/api/client/mcp/interactions/:id`. `409 ' +
|
|
1273
|
+
'target_state_conflict` names `mcp_login_url` with rule `not_set`: MCP is enabled and no page is configured to send the person to. It ' +
|
|
1274
|
+
'is the same code and the same shape `send_mail` answers for an unconfigured `invite_url`, and the refusal is the honest one — ' +
|
|
1275
|
+
'Fleetless has nowhere to redirect, and rendering a page of its own instead would contradict D2.',
|
|
1276
|
+
},
|
|
1277
|
+
{
|
|
1278
|
+
method: 'POST', path: MCP_APP.token, section: 'mcp',
|
|
1279
|
+
summary: "Exchanges one app's MCP authorization code for an access token.",
|
|
1280
|
+
audience: 'client', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
1281
|
+
params: [APP_IDENTIFIER], query: null, request: oauthTokenRequest, response: oauthTokenResponse,
|
|
1282
|
+
errors: [], transport: 'http',
|
|
1283
|
+
notes: 'Only `authorization_code`, PKCE-verified and single-use. There is no refresh grant here either, so a session ends when its token ' +
|
|
1284
|
+
'expires and the client signs in again; the shape is the same `oauthTokenResponse` the central endpoint answers, whose refresh field is ' +
|
|
1285
|
+
'optional and stays empty. **The `aud` is this app\'s endpoint URL on the canonical public base**, and the code\'s `resource` must match ' +
|
|
1286
|
+
'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 ' +
|
|
1287
|
+
'§5.2\'s `oauthError`, so this route emits none of the codes in this reference — including the ones about the app.** An unknown ' +
|
|
1288
|
+
'identifier and a switched-off app are `invalid_client` here, not the `404` and `403` the authorize route beside it answers. The ' +
|
|
1289
|
+
'difference is who reads the answer: authorize is walked by a browser and its refusal is read by a person, while this endpoint is ' +
|
|
1290
|
+
'called by a client\'s own code in the middle of a flow, and handing that code an envelope its OAuth library cannot parse turns a clean ' +
|
|
1291
|
+
'refusal into an unexplained crash.',
|
|
1292
|
+
},
|
|
1293
|
+
/* -------------------------------------------------- app-user (client) auth */
|
|
1294
|
+
{
|
|
1295
|
+
method: 'POST', path: '/api/client/login', section: 'client-auth',
|
|
1296
|
+
summary: 'Signs an app user in with an app identifier, an email address and a password.',
|
|
1297
|
+
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1298
|
+
params: [], query: null, request: clientLoginRequest, response: sessionTokens,
|
|
1299
|
+
errors: ['rate_limited', 'validation_error', 'invalid_credentials'], transport: 'http',
|
|
1300
|
+
notes: 'One refusal for every miss — unknown app, unknown address, wrong password, a `blocked` account and one still `pending_verification` — ' +
|
|
1301
|
+
'because the caller supplies the `app_identifier` unauthenticated, so "this app knows this user" is not a fact the answer may carry. ' +
|
|
1302
|
+
'The argon2 verify is paid unconditionally, including for an unknown app identifier, so response time is not an oracle either.',
|
|
1303
|
+
},
|
|
1304
|
+
{
|
|
1305
|
+
method: 'POST', path: '/api/client/register', section: 'client-auth',
|
|
1306
|
+
summary: 'Creates an app user in the `pending_verification` state and mails them a verification link.',
|
|
1307
|
+
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 202,
|
|
1308
|
+
params: [], query: null, request: clientRegisterRequest, response: null,
|
|
1309
|
+
errors: ['rate_limited', 'validation_error', 'not_found', 'registration_closed', 'domain_not_allowed', 'target_state_conflict', 'quota_exceeded'],
|
|
1310
|
+
transport: 'http',
|
|
1311
|
+
notes: '**`202` and an empty body for every request policy allows** — a new address, one this app already knows and one it does not answer ' +
|
|
1312
|
+
'identically, in status, body and timing. An answer that depended on existence would be the account-enumeration oracle the whole ' +
|
|
1313
|
+
'client family is built to avoid. The account cannot log in until the mailed link is spent; `POST /api/client/verify-email` is what ' +
|
|
1314
|
+
'does that. \n\n**An address on an account still `pending_verification` is re-registered, not ignored.** The password and display name ' +
|
|
1315
|
+
'from this call replace what is stored, every outstanding verification link for the address stops working, and a fresh one is mailed. ' +
|
|
1316
|
+
'Otherwise whoever typed an address first would own the password of the account its real owner later verifies. An address on an ' +
|
|
1317
|
+
'`active` account changes nothing and sends nothing — that account has already been proven, and its way back in is ' +
|
|
1318
|
+
'`POST /api/client/password/reset`. Neither case is visible in the answer. ' +
|
|
1319
|
+
'\n\nThe refusals it *does* make are about policy or about what the caller typed, never about a person. `403 registration_closed` when the ' +
|
|
1320
|
+
'app has self-registration off and `403 domain_not_allowed` when the address is outside `allowed_domains`: both are the developer\'s own ' +
|
|
1321
|
+
'configuration, and a stranger learns the app\'s policy rather than who is in it. **A password under twelve characters is part of that ' +
|
|
1322
|
+
'`400 validation_error`** and not a code of its own — the minimum is the `password` field\'s schema rule, and the error names the field, ' +
|
|
1323
|
+
'which is what a form needs to mark it. `404 not_found` names an ' +
|
|
1324
|
+
'**app identifier no app carries**, and never an address: an app identifier is already public (it is in the MCP metadata path and in the ' +
|
|
1325
|
+
'developer\'s own URLs), while collapsing it into `registration_closed` sent a developer who mistyped their own identifier hunting a ' +
|
|
1326
|
+
'configuration bug that was not there. `409 target_state_conflict` when the app has configured no `verify_url` or has no default role — ' +
|
|
1327
|
+
'there would be nowhere to send the person and no role to give them, and mailing a link that leads nowhere is worse than refusing. ' +
|
|
1328
|
+
'\n\n**`409 quota_exceeded` when the org is at its `max_end_users` limit**, counted across every app of the org. It is the one refusal ' +
|
|
1329
|
+
'here that is answered **before the address is looked at** — and that ordering is the point rather than an implementation detail: a ' +
|
|
1330
|
+
'quota checked after the existence branch would answer `202` for an address the app already knows and `409` for one it does not, which ' +
|
|
1331
|
+
'is precisely the enumeration oracle every other line of this route exists to close. At the quota, every registration is refused ' +
|
|
1332
|
+
'identically, including one that would only have re-mailed a pending account\'s link.',
|
|
1333
|
+
},
|
|
1334
|
+
{
|
|
1335
|
+
method: 'POST', path: '/api/client/verify-email', section: 'client-auth',
|
|
1336
|
+
summary: 'Spends a verification token, activates the account and answers a session.',
|
|
1337
|
+
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1338
|
+
params: [], query: null, request: clientVerifyEmailRequest, response: sessionTokens,
|
|
1339
|
+
errors: ['rate_limited', 'validation_error', 'token_spent'], transport: 'http',
|
|
1340
|
+
notes: '**The answer is a session, not a `204`.** Somebody who has just proved they can read the mail should not be asked to type their ' +
|
|
1341
|
+
'password again on the next screen, and the app has an access token to carry them into it. The token is spent first and the account is ' +
|
|
1342
|
+
'activated second, as **two writes**: the spend is the atomic one, so a link opened twice cannot mint two sessions, but a process that ' +
|
|
1343
|
+
'died between them would leave a spent token on an account still `pending_verification`, whose recovery is ' +
|
|
1344
|
+
'`POST /api/client/resend-verification`. Spending the token also proves the address, so a later `PATCH` may return the account to ' +
|
|
1345
|
+
'`active` after a block. ' +
|
|
1346
|
+
'\n\n**One refusal for every token that does not work: `410 token_spent`** — unknown, past its twenty-four hours, or already used. ' +
|
|
1347
|
+
'There is one code because distinguishing them would tell a stranger whether a token ever existed, and because the recovery is the same ' +
|
|
1348
|
+
'in all three cases: ask for a fresh link with `POST /api/client/resend-verification`. An app rendering this refusal should offer that ' +
|
|
1349
|
+
'and nothing conditional on which of the three it was.',
|
|
1350
|
+
},
|
|
1351
|
+
{
|
|
1352
|
+
method: 'POST', path: '/api/client/resend-verification', section: 'client-auth',
|
|
1353
|
+
summary: 'Mails the verification link again, and answers the same whether or not the address exists.',
|
|
1354
|
+
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 202,
|
|
1355
|
+
params: [], query: null, request: clientResendVerificationRequest, response: null,
|
|
1356
|
+
errors: ['rate_limited', 'validation_error', 'not_found'], transport: 'http',
|
|
1357
|
+
notes: '**`202` in status, body and timing** for an address that names a `pending_verification` account, one that names an already-active ' +
|
|
1358
|
+
'account, and one that names nothing at all. A mail is sent only in the first case. This is the same discipline `POST ' +
|
|
1359
|
+
'/api/client/register` keeps, by the other door: an answer that varied here would undo it. `404 not_found` is the **app identifier** and ' +
|
|
1360
|
+
'nothing else, exactly as on `register` — the address is never the subject of a refusal. Limited per app, address and IP, so this cannot ' +
|
|
1361
|
+
'be used to mail somebody repeatedly.',
|
|
1362
|
+
},
|
|
1363
|
+
{
|
|
1364
|
+
method: 'POST', path: '/api/client/password/reset', section: 'client-auth',
|
|
1365
|
+
summary: 'Mails an app user a reset link, and answers the same either way.',
|
|
1366
|
+
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 202,
|
|
1367
|
+
params: [], query: null, request: clientPasswordResetRequest, response: null,
|
|
1368
|
+
errors: ['rate_limited', 'validation_error', 'not_found'], transport: 'http',
|
|
1369
|
+
notes: '**The app-user twin of `POST /api/auth/password/reset`, and a different shape** because the two surfaces name a person differently: a ' +
|
|
1370
|
+
'Fleetless address is globally unique and resolves alone, an app user\'s is unique only within their app, so the pair is the identifier. ' +
|
|
1371
|
+
'Status, body and timing are identical for a known and an unknown address. An account with no Fleetless password — one created through an ' +
|
|
1372
|
+
'identity provider — is mailed nothing and still answers `202`. `404 not_found` is the **app identifier**, never the address. The link ' +
|
|
1373
|
+
'points at the app\'s `reset_url`; an app that has configured none can send no mail, which the `202` does not distinguish, because saying ' +
|
|
1374
|
+
'so would answer for the address as well.',
|
|
1375
|
+
},
|
|
1376
|
+
{
|
|
1377
|
+
method: 'POST', path: '/api/client/password/reset/confirm', section: 'client-auth',
|
|
1378
|
+
summary: 'Spends a reset token, sets the new password and answers a fresh session.',
|
|
1379
|
+
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1380
|
+
params: [], query: null, request: clientPasswordResetConfirmRequest, response: sessionTokens,
|
|
1381
|
+
errors: ['rate_limited', 'validation_error', 'token_spent'], transport: 'http',
|
|
1382
|
+
notes: '**Every refresh family of that account is revoked**, then a fresh pair is minted for the caller — a forgotten password is one of the two ' +
|
|
1383
|
+
'states where somebody else may be holding a live session, and the person completing the reset is the one who should keep theirs. The ' +
|
|
1384
|
+
'account is activated if it was still `pending_verification`: reading a mail at that address is the same proof verification asks for. ' +
|
|
1385
|
+
'\n\n**One refusal for every token that does not work: `410 token_spent`** — unknown, past its hour, or already used. There is one code ' +
|
|
1386
|
+
'because distinguishing them would tell a stranger whether a token ever existed, and the recovery is identical either way: ask for a new ' +
|
|
1387
|
+
'link. A replacement password under twelve characters is a `400 validation_error` naming the `new_password` field — the twelve-character ' +
|
|
1388
|
+
'minimum is that field\'s schema rule, and it is refused the way any other malformed field is.',
|
|
1389
|
+
},
|
|
1390
|
+
{
|
|
1391
|
+
method: 'POST', path: '/api/client/invitations/accept', section: 'client-auth',
|
|
1392
|
+
summary: 'Spends an invitation token, creates or activates the app user and answers a session.',
|
|
1393
|
+
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1394
|
+
params: [], query: null, request: clientAcceptInvitationRequest, response: sessionTokens,
|
|
1395
|
+
errors: ['rate_limited', 'validation_error', 'token_spent', 'email_taken', 'target_state_conflict', 'quota_exceeded'], transport: 'http',
|
|
1396
|
+
notes: '**An app invitation, not a team one.** `POST /api/org/invitations/accept` is the other space and answers `204`; this one answers a ' +
|
|
1397
|
+
'session, because the person is landing in the developer\'s app and there is no second door for them to sign in through. The role is the ' +
|
|
1398
|
+
'one the invitation fixed at creation, so a later change to the app\'s default role does not re-aim a link already in somebody\'s inbox, ' +
|
|
1399
|
+
'and the invitation **bypasses `allowed_domains`** — a developer inviting somebody by hand has already made the decision the whitelist ' +
|
|
1400
|
+
'automates. \n\n**One refusal for every token that does not work: `410 token_spent`** — unknown, expired past the seven days, revoked by ' +
|
|
1401
|
+
'the developer, or already accepted. There is one code because telling them apart would say whether a token ever existed, and because ' +
|
|
1402
|
+
'the one thing the holder of a dead link can do is ask the developer for a new one, whichever of the four it was. A chosen password ' +
|
|
1403
|
+
'under twelve characters is part of the `400 validation_error`, naming the `password` field. `409 email_taken` is an address this app ' +
|
|
1404
|
+
'has acquired since the invitation was written **as an account that is already in use** — the invitation stays outstanding rather ' +
|
|
1405
|
+
'than being spent, so the developer can revoke it or point the person at the login. An address that registered itself and is still ' +
|
|
1406
|
+
'`pending_verification` is not that state: accepting sets the password the invitee just chose, activates the account and gives it the ' +
|
|
1407
|
+
'invitation\'s role, because reading the invitation mail proves the address the verification link was waiting on. \n\n`409 ' +
|
|
1408
|
+
'target_state_conflict` names `role_id` with rule `not_set` when the role the invitation was fixed to has since been deleted and the ' +
|
|
1409
|
+
'app has no default role to fall back on: there is no access to hand the acceptor, and creating an account with none would be worse ' +
|
|
1410
|
+
'than saying so. \n\n**`409 quota_exceeded` when accepting would CREATE an account and the org is at its `max_end_users` limit**, counted ' +
|
|
1411
|
+
'across every app of the org. An invitation that names a row the developer already created, and one whose address is held by an ' +
|
|
1412
|
+
'unfinished self-registration, both finish an account that already counts — those are not refused, because the org is not one account ' +
|
|
1413
|
+
'larger afterwards. The token is not spent by the refusal: the developer can raise the limit, or delete somebody, and the same link ' +
|
|
1414
|
+
'still works.',
|
|
1415
|
+
},
|
|
1416
|
+
{
|
|
1417
|
+
method: 'POST', path: '/api/client/refresh', section: 'client-auth',
|
|
1418
|
+
summary: 'Rotates an app-user refresh token and mints a fresh access token.',
|
|
1419
|
+
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1420
|
+
params: [], query: null, request: clientRefreshRequest, response: sessionTokens,
|
|
1421
|
+
errors: ['rate_limited', 'validation_error', 'token_expired', 'token_revoked'], transport: 'http',
|
|
1422
|
+
notes: 'The account is re-proved here, not just the token: refresh is where every session eventually re-proves itself, so a user who was ' +
|
|
1423
|
+
'blocked or deleted loses the family here even if the proactive revoke had not landed. A family minted from a `resource`-carrying token ' +
|
|
1424
|
+
'exchange keeps its audience across every rotation.',
|
|
1425
|
+
},
|
|
1426
|
+
{
|
|
1427
|
+
method: 'POST', path: '/api/client/logout', section: 'client-auth',
|
|
1428
|
+
summary: 'Revokes an app-user refresh family.',
|
|
1429
|
+
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 204,
|
|
1430
|
+
params: [], query: null, request: clientLogoutRequest, response: null,
|
|
1431
|
+
errors: ['rate_limited', 'validation_error'], transport: 'http',
|
|
1432
|
+
notes: '**`204`, and a token the server does not recognise gets it too** — the end state a caller asked for is the end state they get, and ' +
|
|
1433
|
+
'distinguishing the two would say whether a token ever existed. It answered a body until 2026-09-05, reporting what was left of the ' +
|
|
1434
|
+
'session at the identity provider; that belonged to the hosted login flow, where Fleetless owned the browser. The developer\'s app owns ' +
|
|
1435
|
+
'it now and redirects to its own provider itself, knowing which one it is. Open `/realtime` sockets for the session are closed.',
|
|
1436
|
+
},
|
|
1437
|
+
{
|
|
1438
|
+
method: 'POST', path: '/api/client/password/change', section: 'client-auth',
|
|
1439
|
+
summary: "Changes an app user's own password and answers a fresh session.",
|
|
1440
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 200,
|
|
1441
|
+
params: [], query: null, request: passwordChangeRequest, response: sessionTokens,
|
|
1442
|
+
errors: [...CLIENT_GUARD, 'validation_error', 'invalid_credentials', 'target_state_conflict'], transport: 'http',
|
|
1443
|
+
notes: 'The guard admits all three caller kinds, but a password belongs to an app user specifically — a developer bearer or a server key ' +
|
|
1444
|
+
'reaching this is `401 unauthorized`. Every other session of the account ends; the answer is the replacement pair, so the tab that made ' +
|
|
1445
|
+
'the change stays signed in. An app user belongs to one app, so "every session" is this app\'s. An account that has **no password** — ' +
|
|
1446
|
+
'an OIDC-only app user, which the schema admits — answers `409 target_state_conflict` naming the `password` field with rule `not_set`, ' +
|
|
1447
|
+
'not `401`: the session is live and the token is fine, it is the account that has nothing to change, and telling such a caller to sign ' +
|
|
1448
|
+
'in again sends them round a loop that ends here.',
|
|
1449
|
+
},
|
|
1450
|
+
{
|
|
1451
|
+
method: 'GET', path: '/api/client/me', section: 'client-auth',
|
|
1452
|
+
summary: 'Answers who the calling token is and what it is allowed to reach.',
|
|
1453
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 200,
|
|
1454
|
+
params: [], query: null, request: null, response: clientIdentity,
|
|
1455
|
+
errors: [...CLIENT_GUARD], transport: 'http',
|
|
1456
|
+
notes: 'The one route that answers for all three caller kinds — a developer bearer, an app-user bearer and a server key — which is why the ' +
|
|
1457
|
+
'shape names each of `developer_id`, `app_user_id` and `server_key_id` and fills exactly one.',
|
|
1458
|
+
},
|
|
1459
|
+
/* ---------------------------------------- app-user sign-in through an IdP */
|
|
1460
|
+
{
|
|
1461
|
+
method: 'GET', path: '/api/client/providers', section: 'client-auth',
|
|
1462
|
+
summary: "Lists the app's enabled sign-in providers, so the app can draw its buttons.",
|
|
1463
|
+
audience: 'client', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
1464
|
+
params: [], query: clientProviderListQuery, request: null, response: clientProviderListResponse,
|
|
1465
|
+
errors: ['validation_error', 'not_found'], transport: 'http',
|
|
1466
|
+
notes: 'Answers `{ "providers": [{ slug, name }, …] }` and **nothing else**: the issuer, the client id, the scopes and the linking policy are ' +
|
|
1467
|
+
'management-side facts, and this route is public. An app with no provider answers an empty array, which is the state of an app that ' +
|
|
1468
|
+
'offers password login alone; a **disabled** provider is not a button that refuses, it is a button that is not there. \n\n`404 ' +
|
|
1469
|
+
'not_found` is the app identifier and can be nothing else — the answer does not vary by person, so there is no address here to be ' +
|
|
1470
|
+
'silent about. **Not rate limited**, unlike the rest of the public client family: it reads back two strings of the developer\'s own ' +
|
|
1471
|
+
'public configuration, an app\'s login page calls it on every render, and there is nothing behind it to enumerate. The limiter on ' +
|
|
1472
|
+
'`start` is where the cost of this flow actually is.',
|
|
1473
|
+
},
|
|
1474
|
+
{
|
|
1475
|
+
method: 'GET', path: '/api/client/oidc/:slug/start', section: 'client-auth',
|
|
1476
|
+
summary: "Begins a federated sign-in and redirects the browser to the app's identity provider.",
|
|
1477
|
+
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 302,
|
|
1478
|
+
params: [{ name: 'slug', description: 'The provider to sign in with, as listed by `GET /api/client/providers`; an unknown slug answers `404`.' }],
|
|
1479
|
+
query: clientOidcStartQuery, request: null, response: null,
|
|
1480
|
+
errors: ['rate_limited', 'validation_error', 'not_found', 'provider_disabled', 'invalid_redirect_uri', 'provider_misconfigured', 'idp_unavailable'],
|
|
1481
|
+
transport: 'http',
|
|
1482
|
+
notes: '**Every refusal here is JSON, answered before any redirect** — the `apiError` envelope, not the `?error=` redirect the callback uses. ' +
|
|
1483
|
+
'The difference is the open-redirect discipline: the callback knows a `redirect_uri` this route has already confirmed, and this route ' +
|
|
1484
|
+
'does not, so sending a browser anywhere on the strength of an unvalidated parameter is the attack rather than the error report. ' +
|
|
1485
|
+
'`400 invalid_redirect_uri` is a malformed target or an origin outside the app\'s `allowed_origins`, and it is checked **first**. ' +
|
|
1486
|
+
'\n\n`404 not_found` is an unknown `app_identifier` or a slug this app does not carry; `403 provider_disabled` is a slug it carries with ' +
|
|
1487
|
+
'`enabled` off, which is a distinction a developer\'s own page can render as "temporarily off" rather than "gone". `422 ' +
|
|
1488
|
+
'provider_misconfigured` and `502 idp_unavailable` are the provider\'s discovery failing the two ways the create route already ' +
|
|
1489
|
+
'describes. \n\n**The app runs its own PKCE against Fleetless here**, which is a second exchange independent of the one Fleetless runs ' +
|
|
1490
|
+
'against the identity provider: `code_challenge` binds the one-time code the callback returns to a verifier only the app\'s page holds. ' +
|
|
1491
|
+
'`state` comes back unchanged on the success redirect and on the error redirect alike. Rate limited per ip, because this is the ' +
|
|
1492
|
+
'unauthenticated door that makes Fleetless fetch a remote system.',
|
|
1493
|
+
},
|
|
1494
|
+
{
|
|
1495
|
+
method: 'GET', path: CLIENT_OIDC_CALLBACK_PATH, section: 'client-auth',
|
|
1496
|
+
summary: "Takes the identity provider's redirect and sends the browser back to the app.",
|
|
1497
|
+
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 302,
|
|
1498
|
+
params: [], query: clientOidcCallbackQuery, request: null, response: null, errors: ['rate_limited'], transport: 'http',
|
|
1499
|
+
notes: '**One callback URL for every app and every provider**, and the value of `appAuthConfig.oidc_callback_url` — the string a developer ' +
|
|
1500
|
+
'registers at their IdP. `CLIENT_OIDC_CALLBACK_PATH` in `client-auth.ts` is the single spelling of this path; the URL is that path on ' +
|
|
1501
|
+
'the cloud\'s canonical public base, never a friendly alias, because the provider compares the redirect target against the one string ' +
|
|
1502
|
+
'it was given. ' +
|
|
1503
|
+
'\n\n**Rate limited per ip, generously.** The first draft left this route unlimited on the argument that the caller is an identity ' +
|
|
1504
|
+
'provider redirecting somebody\'s browser, so a limiter would drop real sign-ins on the strength of traffic none of those people sent. ' +
|
|
1505
|
+
'The cost of that argument is a `state` obtained from one `start` being replayable for the interaction\'s full ten minutes, unbounded ' +
|
|
1506
|
+
'and unauthenticated, with every replay driving a server-side POST to the developer\'s token endpoint and one audit row into their org ' +
|
|
1507
|
+
'— an amplifier against a third party. The interaction is now spent on **every** terminal outcome, refusals included, which closes ' +
|
|
1508
|
+
'the replay itself; the limiter is the ceiling on how fast the attempts may arrive at all. It is per ip and sized for a browser, so a ' +
|
|
1509
|
+
'person completing a sign-in never meets it. `429 rate_limited` is the one `apiError` this route can answer, and it is not a sign-in ' +
|
|
1510
|
+
'outcome — it is a refusal to begin the work, which is why it does not ride back to the app as an `?error=`. \n\nThe query is the ' +
|
|
1511
|
+
'**provider\'s** rather than a Fleetless shape, and `clientOidcCallbackQuery` describes it **without being strict**: `state` always, ' +
|
|
1512
|
+
'`code` on success, `error` and `error_description` on the provider\'s own refusal, and whatever else that provider adds \u2014 RFC 9207\'s ' +
|
|
1513
|
+
'`iss`, a `session_state`, a vendor field. Refusing those would refuse conforming providers, the trap `POST /mcp/oauth/register` ' +
|
|
1514
|
+
'documents avoiding. `state` is the required field because it is the only one Fleetless minted. ' +
|
|
1515
|
+
'\n\n**It lists `rate_limited` and no other code, because every sign-in outcome it has is a redirect.** Success and failure alike are ' +
|
|
1516
|
+
'a `302` to the app\'s own ' +
|
|
1517
|
+
'`redirect_uri`: `?code=…&state=…` when a session was resolved, `?error=<clientOidcErrorCode>&state=…` when it was not, so the app ' +
|
|
1518
|
+
'renders its own message and can bind either answer to the request it started. Fleetless shows an app user no page (D2). \n\n**The one ' +
|
|
1519
|
+
'exception is a `state` that resolves to no interaction** — unknown, hand-edited, or past its ten minutes. Then there is no confirmed ' +
|
|
1520
|
+
'redirect target to carry the answer to, and bouncing a browser to an unvalidated one is the hole the whole flow is arranged to avoid, ' +
|
|
1521
|
+
'so the cloud renders an HTML problem page at `400`. That is the only Fleetless-rendered surface an app user can reach. It is HTML ' +
|
|
1522
|
+
'rather than an `apiError`, which is why no code is listed: a code here would document an envelope no caller receives, and this ' +
|
|
1523
|
+
'manifest\'s other HTML pages (`GET /mcp/oauth/interaction/:id`, `GET /console/oauth/interaction/:id`) say their status in prose for ' +
|
|
1524
|
+
'the same reason.',
|
|
1525
|
+
},
|
|
1526
|
+
{
|
|
1527
|
+
method: 'POST', path: '/api/client/oidc/exchange', section: 'client-auth',
|
|
1528
|
+
summary: 'Trades the one-time code from the callback for an app-user session.',
|
|
1529
|
+
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1530
|
+
params: [], query: null, request: clientOidcExchangeRequest, response: sessionTokens,
|
|
1531
|
+
errors: ['rate_limited', 'validation_error', 'token_spent'], transport: 'http',
|
|
1532
|
+
notes: 'The second half of the app\'s own PKCE: the `code` from the callback redirect plus the `code_verifier` for the challenge `start` ' +
|
|
1533
|
+
'carried. Sixty seconds, single-use, and worth nothing to whoever intercepted the redirect without the verifier. \n\n**One refusal for ' +
|
|
1534
|
+
'every code that does not work: `410 token_spent`** — unknown, past its sixty seconds, already exchanged, or presented with a verifier ' +
|
|
1535
|
+
'that does not match. There is one code because this code **is a credential**: telling the four apart would say whether a given value ' +
|
|
1536
|
+
'ever existed, and the recovery is the same in all four — start the sign-in again. The MCP interaction routes collapse their four ' +
|
|
1537
|
+
'states the same way and for the same reason, and answer `interaction_expired` rather than this code — the difference is what the value ' +
|
|
1538
|
+
'is, not how vague the answer is: a mailed one-time code is a credential, an interaction id names a pending request, and the two ' +
|
|
1539
|
+
'deserve different advice on the app\'s own page.',
|
|
1540
|
+
},
|
|
1541
|
+
/* --------------------------- the app's own MCP consent screen (D7) */
|
|
1542
|
+
{
|
|
1543
|
+
method: 'GET', path: '/api/client/mcp/interactions/:id', section: 'client-auth',
|
|
1544
|
+
summary: 'Reads a pending MCP authorization so the app can draw its own consent screen.',
|
|
1545
|
+
audience: 'client', auth: 'in_handler', rateLimited: false, ownerTier: false, status: 200,
|
|
1546
|
+
params: [{ name: 'id', description: 'The interaction id, as `GET /mcp/:appIdentifier/oauth/authorize` put it into the app\'s `mcp_login_url`.' }],
|
|
1547
|
+
query: null, request: null, response: clientMcpInteraction,
|
|
1548
|
+
errors: ['interaction_expired'], transport: 'http',
|
|
1549
|
+
notes: '**The bearer is optional, which is why the credential is decided in the handler rather than by a guard.** An app renders this page ' +
|
|
1550
|
+
'before it knows who is at the keyboard — the client\'s claimed name, marked unverified, and the scopes it asked for — and reads the ' +
|
|
1551
|
+
'document again once the person has signed in. The only field that moves is `already_granted`: a grant belongs to a user, so without a ' +
|
|
1552
|
+
'token there is no user for it to be about and it is `false`. An app-user token for a **different** app is treated as absent rather ' +
|
|
1553
|
+
'than refused, for the same reason: nothing in this document is that user\'s, so there is nothing to refuse them, and a `401` would ' +
|
|
1554
|
+
'break the page for somebody whose browser happens to hold another app\'s session. \n\n**One code for every interaction that is not live: ' +
|
|
1555
|
+
'`410 interaction_expired`.** Unknown, past its ten minutes, already decided, an interaction of the central flow, or one whose ' +
|
|
1556
|
+
'authorize step never handed a browser to the app — one status and ' +
|
|
1557
|
+
'one body, so an id nobody holds cannot be told from one that ran out. **The last of those is what makes the redirect stamp a ' +
|
|
1558
|
+
'real gate rather than a note**: an id invented or replayed outside the flow names no interaction this route will describe, and ' +
|
|
1559
|
+
'a page reloading its own consent screen is a second read rather than a second redirect, so it keeps working. A `404` beside it would let a caller who did not start the flow ' +
|
|
1560
|
+
'ask whether somebody else\'s sign-in is in progress, which is the only question this document could be used to answer. The word is ' +
|
|
1561
|
+
'still `interaction_expired` rather than `token_spent`, because an interaction id names a pending request rather than a credential and ' +
|
|
1562
|
+
'the app\'s page owes the person the better advice: "that took too long, start again". \n\n**Not rate limited**, unlike most of the ' +
|
|
1563
|
+
'public client family and unlike the two decisions beside it: the id is ' +
|
|
1564
|
+
'unguessable and names a request the server already holds, the answer says nothing about any person, and the app\'s consent page fetches ' +
|
|
1565
|
+
'it on every render. There is nothing behind it to enumerate — to somebody who did not start the flow, an id that resolves and one ' +
|
|
1566
|
+
'that does not are equally uninformative.',
|
|
1567
|
+
},
|
|
1568
|
+
{
|
|
1569
|
+
method: 'POST', path: '/api/client/mcp/interactions/:id/approve', section: 'client-auth',
|
|
1570
|
+
summary: 'Approves a pending MCP authorization on behalf of the signed-in app user.',
|
|
1571
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: true, ownerTier: false, status: 200,
|
|
1572
|
+
params: [{ name: 'id', description: 'The interaction id the app read with `GET /api/client/mcp/interactions/:id`.' }],
|
|
1573
|
+
query: null, request: null, response: clientMcpInteractionDecisionResponse,
|
|
1574
|
+
errors: [...CLIENT_GUARD, 'rate_limited', 'interaction_expired', 'mcp_disabled'], transport: 'http',
|
|
1575
|
+
notes: 'The person is already signed in **at the app**, by whatever means that app uses, and this is the app telling Fleetless what they ' +
|
|
1576
|
+
'decided. Fleetless never sees that sign-in, which is D7 in one sentence. \n\nThe guard admits all three caller kinds and the handler ' +
|
|
1577
|
+
'takes one: a developer bearer or a server key reaching this is `401 unauthorized`, because a consent is a person\'s and a server key ' +
|
|
1578
|
+
'is not a person — the same shape `POST /api/client/password/change` has. `403 mcp_disabled` is the app\'s switch, re-read here as it is ' +
|
|
1579
|
+
'on every request — and it is the one refusal on this surface that names the switch, because reaching it needs an app-user session ' +
|
|
1580
|
+
'of that very app. \n\n**An interaction of ANOTHER app answers `410 interaction_expired`, not `403`.** An interaction of one app ' +
|
|
1581
|
+
'cannot be decided with a session from another — that is what stops a developer running two apps from letting one speak for the ' +
|
|
1582
|
+
'other — but saying so with a distinct code would tell any bearer holder that the id names a real, live interaction somewhere else, ' +
|
|
1583
|
+
'which is the existence answer the shared `410` exists to withhold. Unknown, expired, already decided, an interaction of the central ' +
|
|
1584
|
+
'flow, and one belonging to a different app are one status and one body. Approve ' +
|
|
1585
|
+
'and deny spend an interaction alike, so the second call gets it whichever route made the first. \n\n**Rate limited per app user, ' +
|
|
1586
|
+
'unlike the read.** The read is a public document about a request the server already holds; this one spends something, and a decision ' +
|
|
1587
|
+
'is the one thing a leaked interaction id would be worth hammering for. The limit is on the signed-in account rather than on the ip, ' +
|
|
1588
|
+
'because that is what the caller has had to prove. \n\n**The answer is a redirect target, not a redirect.** `redirect_to` is the MCP client\'s own callback carrying ' +
|
|
1589
|
+
'the authorization code, and the app\'s page sends the browser there. The app is holding that browser and Fleetless is answering its ' +
|
|
1590
|
+
'JSON call, so a `302` here would be a redirect on the wrong request. Approving records the grant for this user and this client, which ' +
|
|
1591
|
+
'is what a later `already_granted` reads back.',
|
|
1592
|
+
},
|
|
1593
|
+
{
|
|
1594
|
+
method: 'POST', path: '/api/client/mcp/interactions/:id/deny', section: 'client-auth',
|
|
1595
|
+
summary: 'Denies a pending MCP authorization on behalf of the signed-in app user.',
|
|
1596
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: true, ownerTier: false, status: 200,
|
|
1597
|
+
params: [{ name: 'id', description: 'The interaction id the app read with `GET /api/client/mcp/interactions/:id`.' }],
|
|
1598
|
+
query: null, request: null, response: clientMcpInteractionDecisionResponse,
|
|
1599
|
+
errors: [...CLIENT_GUARD, 'rate_limited', 'interaction_expired', 'mcp_disabled'], transport: 'http',
|
|
1600
|
+
notes: 'The same route with the opposite decision, and **it answers a `redirect_to` as well** — the client\'s own callback carrying ' +
|
|
1601
|
+
'`error=access_denied`. A client that is refused must learn so from the place it is waiting rather than from a page nobody sent it, ' +
|
|
1602
|
+
'the discipline `POST /mcp/oauth/consent` already keeps. \n\n**Two routes rather than one with a `decision` field**, which is what the ' +
|
|
1603
|
+
'hosted consent screen has to be: there the decision arrives from a browser form, so anything that is not the Allow value must deny, ' +
|
|
1604
|
+
'and a missing field failing closed is a rule somebody has to keep getting right. Here the caller is the app\'s own server-side code ' +
|
|
1605
|
+
'and the path *is* the decision — there is no value to misread. The refusals are the approve route\'s, for the reasons stated there, ' +
|
|
1606
|
+
'including the limiter: **rate limited per app user**, on the signed-in account rather than the ip, because a denial spends the ' +
|
|
1607
|
+
'interaction exactly as an approval does and a caller holding a leaked id must not be able to burn other people\'s sign-ins in a loop.',
|
|
1608
|
+
},
|
|
1609
|
+
/* ------------------------- the app user's own list of connected clients */
|
|
1610
|
+
{
|
|
1611
|
+
method: 'GET', path: '/api/client/mcp/grants', section: 'client-auth',
|
|
1612
|
+
summary: 'Lists the MCP clients the signed-in app user has consented to.',
|
|
1613
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 200,
|
|
1614
|
+
params: [], query: null, request: null, response: mcpConsentGrantListResponse,
|
|
1615
|
+
errors: [...CLIENT_GUARD], transport: 'http',
|
|
1616
|
+
notes: '**So the developer\'s app can offer a "connected apps" screen of its own**, which is the only place an end user could ever be shown ' +
|
|
1617
|
+
'this: Fleetless renders no page for an app\'s users (D2), and the console is the developer\'s tool rather than their customers\'. ' +
|
|
1618
|
+
'\n\nThe answer is about the bearer\'s own account and takes no user id — there is no id to pass and therefore nothing to pass the ' +
|
|
1619
|
+
'wrong one. The guard admits all three caller kinds because it is shared, and the handler takes one: a developer bearer or a server ' +
|
|
1620
|
+
'key is `401 unauthorized`, the shape `POST /api/client/password/change` has, because a consent is a person\'s and a server key is not ' +
|
|
1621
|
+
'a person. \n\n**Every `client_name` is unverified**, on every row: dynamic registration takes no credential, so the name is text the ' +
|
|
1622
|
+
'client chose about itself and `client_name_verified` is the literal `false`. A screen that renders it as an identity is showing ' +
|
|
1623
|
+
'somebody a string an attacker picked, and this list is read long after the moment of approval, when nobody remembers what they ' +
|
|
1624
|
+
'clicked. **Withdrawn grants are absent**, not listed as withdrawn. \n\n**Not rate limited and not gated on the app\'s MCP switch.** ' +
|
|
1625
|
+
'It reads one small table for one account, and a person must be able to see and end what they agreed to even after a developer ' +
|
|
1626
|
+
'switches MCP off — a withdrawal door that closes with the feature is a door that is shut exactly when somebody wants it.',
|
|
1627
|
+
},
|
|
1628
|
+
{
|
|
1629
|
+
method: 'DELETE', path: '/api/client/mcp/grants/:clientId', section: 'client-auth',
|
|
1630
|
+
summary: 'Withdraws the signed-in app user\'s consent to one MCP client.',
|
|
1631
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 204,
|
|
1632
|
+
params: [{ name: 'clientId', description: 'The MCP client, as `GET /api/client/mcp/grants` reports its `client_id`. Not a uuid — it is the identifier the dynamic registration issued.' }],
|
|
1633
|
+
query: null, request: null, response: null, errors: [...CLIENT_GUARD], transport: 'http',
|
|
1634
|
+
notes: 'The person\'s own door, beside the developer\'s `DELETE /api/apps/:id/users/:userId/mcp-grants/:clientId`. It acts on the bearer\'s ' +
|
|
1635
|
+
'own account and on no other — the path carries a client and never a subject — so there is no user for a caller to name and none to ' +
|
|
1636
|
+
'confuse. The shared guard admits all three caller kinds and the handler takes one: a developer bearer or a server key is `401 ' +
|
|
1637
|
+
'unauthorized`, because withdrawing a consent is the same person\'s act as giving it. Audited as `app_user.mcp_grant_revoked`, ' +
|
|
1638
|
+
'with the app user themselves as the actor. \n\n**`204` whether or not there was ' +
|
|
1639
|
+
'anything to withdraw.** A client id this account never approved, and one it withdrew a minute ago, both answer the end state that was ' +
|
|
1640
|
+
'asked for: a `404` would tell the caller which clients some account has connected, and would make the ordinary double-click a ' +
|
|
1641
|
+
'failure. Only a withdrawal that ended a standing agreement is audited. \n\n**It ends a session already running, at that client\'s ' +
|
|
1642
|
+
'very next call.** The app\'s MCP endpoint reads this table on every request and keys the check on the `client_id` the access token ' +
|
|
1643
|
+
'carries, so the withdrawn client is answered `401` with a challenge and has to ask this person again. The token it holds is still ' +
|
|
1644
|
+
'unexpired — up to fifteen minutes are left on it — and is refused anyway. Nothing else stops: this ends one client, not the ' +
|
|
1645
|
+
'account, which is what blocking would end.',
|
|
1646
|
+
},
|
|
1647
|
+
/* ------------------------------------------------------------- robots */
|
|
1648
|
+
{
|
|
1649
|
+
method: 'POST', path: '/api/robots', section: 'robots',
|
|
1650
|
+
summary: 'Creates a robot and returns its bridge token once.',
|
|
1651
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 201,
|
|
1652
|
+
params: [], query: null, request: createRobotRequest, response: createRobotResponse,
|
|
1653
|
+
errors: [...DEVELOPER_GUARD, 'validation_error', 'quota_exceeded'], transport: 'http',
|
|
1654
|
+
notes: '`token` is the only moment the raw bridge token exists outside the caller\'s hands — the cloud stores a hash, so nothing can read it ' +
|
|
1655
|
+
'back and a caller who loses it rotates rather than recovers. Audited: this mints a credential that can speak for the org from anywhere, ' +
|
|
1656
|
+
'and the event carries no `details`, because the one interesting value here is the token. `max_robots` is checked before anything is ' +
|
|
1657
|
+
'created, which is only safe because robot deletion exists.',
|
|
1658
|
+
},
|
|
1659
|
+
{
|
|
1660
|
+
method: 'GET', path: '/api/robots', section: 'robots',
|
|
1661
|
+
summary: "Lists the org's robots with their connection state and exposure counts.",
|
|
1662
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1663
|
+
params: [], query: null, request: null, response: robotListResponse,
|
|
1664
|
+
errors: [...DEVELOPER_GUARD], transport: 'http',
|
|
1665
|
+
},
|
|
1666
|
+
{
|
|
1667
|
+
method: 'GET', path: '/api/robots/:id', section: 'robots',
|
|
1668
|
+
summary: 'Reads one robot with its published configuration state and live bridge state.',
|
|
1669
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1670
|
+
params: [{ name: 'id', description: 'The robot\'s uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.' }],
|
|
1671
|
+
query: null, request: null, response: robotDetailResponse,
|
|
1672
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
1673
|
+
notes: 'A robot belonging to another org reads exactly like one that does not exist — `404`, never a `403`.',
|
|
1674
|
+
},
|
|
1675
|
+
{
|
|
1676
|
+
method: 'PATCH', path: '/api/robots/:id', section: 'robots',
|
|
1677
|
+
summary: 'Renames the robot.',
|
|
1678
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1679
|
+
params: [{ name: 'id', description: 'The robot\'s uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.' }],
|
|
1680
|
+
query: null, request: patchRobotRequest, response: patchRobotResponse,
|
|
1681
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'validation_error'], transport: 'http',
|
|
1682
|
+
notes: 'Answers `{ "robot": robot }`. The lookup runs before the ' +
|
|
1683
|
+
'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 ' +
|
|
1684
|
+
'held writes nothing and records no audit event.',
|
|
1685
|
+
},
|
|
1686
|
+
{
|
|
1687
|
+
method: 'GET', path: '/api/robots/:id/deletion-preview', section: 'robots',
|
|
1688
|
+
summary: 'Reports what deleting the robot would destroy, without destroying it.',
|
|
1689
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1690
|
+
params: [{ name: 'id', description: 'The robot\'s uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.' }],
|
|
1691
|
+
query: null, request: null, response: robotDeletionSummary,
|
|
1692
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
1693
|
+
notes: 'The same shape the delete\'s own audit event carries, computed by the same function on purpose: the confirmation dialog and the eventual ' +
|
|
1694
|
+
'receipt agree by construction, and any difference between them is real drift — a robot that kept recording in between — rather than two ' +
|
|
1695
|
+
'estimates that quietly disagree.',
|
|
1696
|
+
},
|
|
1697
|
+
{
|
|
1698
|
+
method: 'DELETE', path: '/api/robots/:id', section: 'robots',
|
|
1699
|
+
summary: 'Deletes a robot and everything it produced.',
|
|
1700
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: true, status: 204,
|
|
1701
|
+
params: [{ name: 'id', description: 'The robot\'s uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.' }],
|
|
1702
|
+
query: robotDeleteQuery, request: null, response: null,
|
|
1703
|
+
errors: [...DEVELOPER_GUARD, 'tier_required', 'invalid_uuid', 'not_found', 'robot_in_use', 'robot_deletion_partial'], transport: 'http',
|
|
1704
|
+
notes: 'Owner tier, and the gate runs **after** the org-scoped lookup: a developer-tier admin therefore sees the same `404` a stranger would ' +
|
|
1705
|
+
'for a robot outside their org, rather than a tier refusal that confirms the id exists. A full cascade — everything the robot produced ' +
|
|
1706
|
+
'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 ' +
|
|
1707
|
+
'`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 ' +
|
|
1708
|
+
'fails partway is `500 robot_deletion_partial` with the progress, never a bare `internal_error` that would read as "nothing happened".',
|
|
1709
|
+
},
|
|
1710
|
+
{
|
|
1711
|
+
method: 'PUT', path: '/api/robots/:id/details', section: 'robots',
|
|
1712
|
+
summary: 'Replaces the developer-maintained details document shown alongside the robot.',
|
|
1713
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1714
|
+
params: [{ name: 'id', description: 'The robot\'s uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.' }],
|
|
1715
|
+
query: null, request: putRobotDetailsRequest, response: putRobotDetailsResponse,
|
|
1716
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'validation_error'], transport: 'http',
|
|
1717
|
+
notes: 'Answers `{ "details": robotDetailsDoc }` — the stored document, which is the one that was sent. The update is fanned out to every ' +
|
|
1718
|
+
'`/realtime` subscriber of the `robot_details` built-in, so a client watching the robot sees the new document without polling.',
|
|
1719
|
+
},
|
|
1720
|
+
{
|
|
1721
|
+
method: 'GET', path: '/api/robots/:id/datapoints', section: 'robots',
|
|
1722
|
+
summary: 'Lists the datapoints of a robot, filtered to what the caller\'s role grants.',
|
|
1723
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 200,
|
|
1724
|
+
params: [{ name: 'id', description: 'The robot\'s uuid; an end user reaches it through an app that attaches it.' }],
|
|
1725
|
+
query: null, request: null, response: datapointListResponse,
|
|
1726
|
+
errors: [...CLIENT_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
1727
|
+
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 ' +
|
|
1728
|
+
'robot their app does not attach answers `404` exactly as one that does not exist.',
|
|
1729
|
+
},
|
|
1730
|
+
{
|
|
1731
|
+
method: 'GET', path: '/api/robots/:id/exposures', section: 'robots',
|
|
1732
|
+
summary: 'Lists every grantable slug of a robot with its kind — the material the roles matrix is built from.',
|
|
1733
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1734
|
+
params: [{ name: 'id', description: 'The robot\'s uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.' }],
|
|
1735
|
+
query: null, request: null, response: exposureListResponse,
|
|
1736
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
1737
|
+
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.',
|
|
1738
|
+
},
|
|
1739
|
+
{
|
|
1740
|
+
method: 'GET', path: '/api/robots/:id/datapoints/:slug', section: 'robots',
|
|
1741
|
+
summary: 'Reads the latest value of one datapoint.',
|
|
1742
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 200,
|
|
1743
|
+
params: [
|
|
1744
|
+
{ name: 'id', description: 'The robot\'s uuid; an end user reaches it through an app that attaches it.' },
|
|
1745
|
+
{ name: 'slug', description: 'The datapoint\'s slug from the published configuration, as listed by `GET /api/robots/:id/datapoints`.' },
|
|
1746
|
+
],
|
|
1747
|
+
query: null, request: null, response: datapointValue,
|
|
1748
|
+
errors: [...CLIENT_GUARD, 'invalid_uuid', 'not_found', 'unknown_datapoint', 'no_data'], transport: 'http',
|
|
1749
|
+
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 ' +
|
|
1750
|
+
'a nonexistent one must be one answer. That is why an ungranted slug is `403 forbidden` while a granted-but-unconfigured one is ' +
|
|
1751
|
+
'`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 ' +
|
|
1752
|
+
'told apart. The plane built-ins (`bridge_state`, `robot_details`) answer here too, without appearing in any document.',
|
|
1753
|
+
},
|
|
1754
|
+
/* ------------------------------------------------- config (draft/publish) */
|
|
1755
|
+
{
|
|
1756
|
+
method: 'GET', path: '/api/robots/:id/config/draft', section: 'config',
|
|
1757
|
+
summary: "Reads the robot's configuration draft, its author text and its current issues.",
|
|
1758
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1759
|
+
params: [{ name: 'id', description: 'The robot\'s uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.' }],
|
|
1760
|
+
query: null, request: null, response: configDraftResponse,
|
|
1761
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
1762
|
+
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 ' +
|
|
1763
|
+
'draft that is valid YAML but not a Fleetless configuration — a state the format admits and the publish route refuses.',
|
|
1764
|
+
},
|
|
1765
|
+
{
|
|
1766
|
+
method: 'PUT', path: '/api/robots/:id/config/draft', section: 'config',
|
|
1767
|
+
summary: 'Replaces the draft with the author\'s text and answers the parsed document with its issues.',
|
|
1768
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1769
|
+
params: [{ name: 'id', description: 'The robot\'s uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.' }],
|
|
1770
|
+
query: null, request: putConfigDraftRequest, response: configDraftResponse,
|
|
1771
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'validation_error', 'invalid_yaml', 'unstorable_yaml'], transport: 'http',
|
|
1772
|
+
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 ' +
|
|
1773
|
+
'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 ' +
|
|
1774
|
+
'cannot be stored — an anchor cycle, say — is `422 unstorable_yaml`. A document with schema errors is still stored, because the ' +
|
|
1775
|
+
'draft is where a developer works; publishing is where the errors block.',
|
|
1776
|
+
},
|
|
1777
|
+
{
|
|
1778
|
+
method: 'POST', path: '/api/robots/:id/config/publish', section: 'config',
|
|
1779
|
+
summary: 'Publishes the draft as an immutable version and sends it to the robot.',
|
|
1780
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1781
|
+
params: [{ name: 'id', description: 'The robot\'s uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.' }],
|
|
1782
|
+
query: null, request: null, response: publishConfigResponse,
|
|
1783
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'validation_error', 'draft_not_a_document'], transport: 'http',
|
|
1784
|
+
notes: 'A draft that is valid YAML but not a Fleetless configuration is `422 draft_not_a_document`, carrying every issue rather than the ' +
|
|
1785
|
+
'blocking subset — nothing about that text is publishable, so there is no subset to pick, and the warning naming the checks that could ' +
|
|
1786
|
+
'not run is part of reading the list correctly. A document with `severity: "error"` issues is `422 validation_error` with just those. ' +
|
|
1787
|
+
'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.',
|
|
1788
|
+
},
|
|
1789
|
+
{
|
|
1790
|
+
method: 'GET', path: '/api/robots/:id/config/versions', section: 'config',
|
|
1791
|
+
summary: 'Lists the published configuration versions of a robot with their publish times.',
|
|
1792
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1793
|
+
params: [{ name: 'id', description: 'The robot\'s uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.' }],
|
|
1794
|
+
query: null, request: null, response: configVersionsResponse,
|
|
1795
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
1796
|
+
},
|
|
1797
|
+
{
|
|
1798
|
+
method: 'GET', path: '/api/robots/:id/config/versions/:v', section: 'config',
|
|
1799
|
+
summary: 'Reads one published version: its document and the author text it was published from.',
|
|
1800
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1801
|
+
params: [
|
|
1802
|
+
{ name: 'id', description: 'The robot\'s uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.' },
|
|
1803
|
+
{ name: 'v', description: 'The version number, as listed by `GET /api/robots/:id/config/versions`.' },
|
|
1804
|
+
],
|
|
1805
|
+
query: null, request: null, response: configVersionResponse,
|
|
1806
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
1807
|
+
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.',
|
|
1808
|
+
},
|
|
1809
|
+
{
|
|
1810
|
+
method: 'POST', path: '/api/robots/:id/config/versions/:v/restore', section: 'config',
|
|
1811
|
+
summary: 'Copies a published version back into the draft, text and document both.',
|
|
1812
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1813
|
+
params: [
|
|
1814
|
+
{ name: 'id', description: 'The robot\'s uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.' },
|
|
1815
|
+
{ name: 'v', description: 'The version number, as listed by `GET /api/robots/:id/config/versions`.' },
|
|
1816
|
+
],
|
|
1817
|
+
query: null, request: null, response: configDraftResponse,
|
|
1818
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
1819
|
+
notes: '**Both halves, not just the document** — a restore that put back the document alone would hand the author a configuration stripped of ' +
|
|
1820
|
+
'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 ' +
|
|
1821
|
+
'version that was meant to be written. Nothing is published: the restored draft still has to be published to reach the robot.',
|
|
1822
|
+
},
|
|
1823
|
+
{
|
|
1824
|
+
method: 'POST', path: '/api/robots/:id/config/rename-slug', section: 'config',
|
|
1825
|
+
summary: 'Renames a slug in the draft and rewrites every role grant and history row that named it.',
|
|
1826
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1827
|
+
params: [{ name: 'id', description: 'The robot\'s uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.' }],
|
|
1828
|
+
query: null, request: renameSlugRequest, response: renameSlugResponse,
|
|
1829
|
+
errors: [
|
|
1830
|
+
...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'validation_error', 'draft_not_a_document',
|
|
1831
|
+
'unknown_slug', 'reserved_slug', 'duplicate_slug', 'internal_error',
|
|
1832
|
+
], transport: 'http',
|
|
1833
|
+
notes: 'One transaction over three places a slug is written down: the draft document, every app-role grant carrying it, and the recorded ' +
|
|
1834
|
+
'history rows. The published configuration is immutable, so `requires_publish` says the rename is not live on the robot yet. A draft ' +
|
|
1835
|
+
'that is not a document is `409 draft_not_a_document` — the same word the usage preview uses for the same state.',
|
|
1836
|
+
},
|
|
1837
|
+
{
|
|
1838
|
+
method: 'GET', path: '/api/robots/:id/config/slug-usage/:slug', section: 'config',
|
|
1839
|
+
summary: 'Reports what a rename of one slug would touch, before a developer confirms it.',
|
|
1840
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1841
|
+
params: [
|
|
1842
|
+
{ name: 'id', description: 'The robot\'s uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.' },
|
|
1843
|
+
{ name: 'slug', description: 'The slug in the **draft** whose blast radius is being previewed.' },
|
|
1844
|
+
],
|
|
1845
|
+
query: null, request: null, response: slugUsageResponse,
|
|
1846
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'draft_not_a_document'], transport: 'http',
|
|
1847
|
+
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 ' +
|
|
1848
|
+
'*this slug has no alerts* and *there is no document to ask*, and a zero cannot tell them apart — it would show a smaller blast radius ' +
|
|
1849
|
+
'than the rename actually has. The other three counts are real whatever the draft holds, and a partial answer to a preview whose whole ' +
|
|
1850
|
+
'purpose is to be complete is not worth the ambiguity.',
|
|
1851
|
+
},
|
|
1852
|
+
/* -------------------------------------------------------------- alerts */
|
|
1853
|
+
{
|
|
1854
|
+
method: 'GET', path: '/api/robots/:id/alerts', section: 'alerts',
|
|
1855
|
+
summary: "Lists a robot's alerts as defined in its published configuration, joined with their runtime state.",
|
|
1856
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1857
|
+
params: [{ name: 'id', description: 'The robot\'s uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.' }],
|
|
1858
|
+
query: null, request: null, response: alertListResponse,
|
|
1859
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
1860
|
+
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 ' +
|
|
1861
|
+
'published document, which is what makes every change to one versioned, comparable and revertible. The **published** version is read, ' +
|
|
1862
|
+
'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.',
|
|
1863
|
+
},
|
|
1864
|
+
{
|
|
1865
|
+
method: 'GET', path: '/api/org/alerts', section: 'alerts',
|
|
1866
|
+
summary: 'Lists every firing alert across the org, with the robot each belongs to.',
|
|
1867
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1868
|
+
params: [], query: orgAlertsQuery, request: null, response: orgFiringAlertsResponse,
|
|
1869
|
+
errors: [...DEVELOPER_GUARD, 'validation_error'], transport: 'http',
|
|
1870
|
+
notes: '`?state=firing` is required and is the only value accepted — refused rather than silently ignored, because a door with one answer must ' +
|
|
1871
|
+
'not advertise a dial. A firing row whose definition has left the document, or ' +
|
|
1872
|
+
'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 ' +
|
|
1873
|
+
'open-issues tile forever.',
|
|
1874
|
+
},
|
|
1875
|
+
/* ------------------------------------------------- robots (introspection) */
|
|
1876
|
+
{
|
|
1877
|
+
method: 'GET', path: '/api/robots/:id/introspection', section: 'robots',
|
|
1878
|
+
summary: 'Reads the cached ROS graph of a robot and whether it is stale.',
|
|
1879
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1880
|
+
params: [{ name: 'id', description: 'The robot\'s uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.' }],
|
|
1881
|
+
query: null, request: null, response: introspectionResponse,
|
|
1882
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
1883
|
+
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 ' +
|
|
1884
|
+
'not a missing resource. The response schema describes the non-null case. `stale` is true whenever the bridge is offline — the snapshot ' +
|
|
1885
|
+
'survives a disconnect, since a robot that has never connected is still configurable.',
|
|
1886
|
+
},
|
|
1887
|
+
{
|
|
1888
|
+
method: 'POST', path: '/api/robots/:id/introspection/refresh', section: 'robots',
|
|
1889
|
+
summary: 'Asks the robot for a fresh ROS graph, stores it and answers it.',
|
|
1890
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1891
|
+
params: [{ name: 'id', description: 'The robot\'s uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.' }],
|
|
1892
|
+
query: null, request: null, response: introspectionResponse,
|
|
1893
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'robot_offline', 'bridge_timeout'], transport: 'http',
|
|
1894
|
+
notes: '`stale` is `false` by construction here: the graph came from the robot just now. `409 robot_offline` means nothing is connected; ' +
|
|
1895
|
+
'`504 bridge_timeout` means something was and did not answer. Anything else is rethrown rather than turned into a tidy status.',
|
|
1896
|
+
},
|
|
1897
|
+
{
|
|
1898
|
+
method: 'GET', path: '/api/robots/:id/types', section: 'robots',
|
|
1899
|
+
summary: 'Lists every ROS message type definition stored for the robot.',
|
|
1900
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1901
|
+
params: [{ name: 'id', description: 'The robot\'s uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.' }],
|
|
1902
|
+
query: null, request: null, response: typesResponse,
|
|
1903
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
1904
|
+
notes: 'A plain read with no bridge involved — the robot need not be online.',
|
|
1905
|
+
},
|
|
1906
|
+
{
|
|
1907
|
+
method: 'POST', path: '/api/robots/:id/types/fetch', section: 'robots',
|
|
1908
|
+
summary: 'Fetches named message type definitions from the robot and stores them.',
|
|
1909
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1910
|
+
params: [{ name: 'id', description: 'The robot\'s uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.' }],
|
|
1911
|
+
query: null, request: fetchTypesRequest, response: fetchTypesResponse,
|
|
1912
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'validation_error', 'robot_offline', 'bridge_timeout'], transport: 'http',
|
|
1913
|
+
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 ' +
|
|
1914
|
+
'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 ' +
|
|
1915
|
+
'not the body was also malformed.',
|
|
1916
|
+
},
|
|
1917
|
+
/* ---------------------------------------------- commands (jobs, publishers) */
|
|
1918
|
+
{
|
|
1919
|
+
method: 'GET', path: '/api/robots/:id/jobs', section: 'commands',
|
|
1920
|
+
summary: 'Reads the current job on every slug of the robot the caller is granted.',
|
|
1921
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 200,
|
|
1922
|
+
params: [{ name: 'id', description: 'The robot\'s uuid; an end user reaches it through an app that attaches it.' }],
|
|
1923
|
+
query: null, request: null, response: robotJobsResponse,
|
|
1924
|
+
errors: [...CLIENT_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
1925
|
+
notes: '**At most one entry per slug, and not a history endpoint.** The first version answered every job the registry still held — six rows and ' +
|
|
1926
|
+
'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 ' +
|
|
1927
|
+
'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 ' +
|
|
1928
|
+
'left on a slug a republish removed, has no slug-shaped door to be found through.',
|
|
1929
|
+
},
|
|
1930
|
+
{
|
|
1931
|
+
method: 'GET', path: '/api/robots/:id/jobs/history', section: 'commands',
|
|
1932
|
+
summary: 'Reads what has run on the robot, newest first, cursor-paged.',
|
|
1933
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 200,
|
|
1934
|
+
params: [{ name: 'id', description: 'The robot\'s uuid; an end user reaches it through an app that attaches it.' }],
|
|
1935
|
+
query: jobRunQuery, request: null, response: jobRunListResponse,
|
|
1936
|
+
errors: [...CLIENT_GUARD, 'invalid_uuid', 'not_found', 'capability_required', 'validation_error'], transport: 'http',
|
|
1937
|
+
notes: 'Needs the `action_history` capability, and **this route is what makes that switch mean something** — it was unkeepable while nothing ' +
|
|
1938
|
+
'durable recorded what had run. Two residuals worth stating rather than implying away. `history` is a syntactically valid slug and ' +
|
|
1939
|
+
'Fastify matches a static segment first, so a robot with a service literally slugged `history` can no longer be **read** through ' +
|
|
1940
|
+
'`GET /api/robots/:id/jobs/:slug`; invoking, cancelling and the listing are unaffected. And a run row names its actor by email address, ' +
|
|
1941
|
+
'so an end user holding this capability learns which other people have been driving the machine. `robot_id` in the query is shared with ' +
|
|
1942
|
+
'the org-wide read; a *different* one here is refused rather than quietly answered about the robot in the path.',
|
|
1943
|
+
},
|
|
1944
|
+
{
|
|
1945
|
+
method: 'POST', path: '/api/robots/:id/jobs/:slug', section: 'commands',
|
|
1946
|
+
summary: 'Invokes an action or calls a service on the robot.',
|
|
1947
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 202,
|
|
1948
|
+
params: [
|
|
1949
|
+
{ name: 'id', description: 'The robot\'s uuid; an end user reaches it through an app that attaches it.' },
|
|
1950
|
+
{ name: 'slug', description: 'The action or service slug from the published configuration; the cloud already knows which kind it is.' },
|
|
1951
|
+
],
|
|
1952
|
+
query: null, request: invokeRequest, response: invokeOrServiceResponse,
|
|
1953
|
+
errors: [
|
|
1954
|
+
...CLIENT_GUARD, 'invalid_uuid', 'not_found', 'validation_error', 'parameter_invalid',
|
|
1955
|
+
'robot_offline', 'busy', 'bridge_timeout', 'internal_error',
|
|
1956
|
+
], transport: 'http',
|
|
1957
|
+
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 ' +
|
|
1958
|
+
'`202` with an `invokeResponse` the moment the job exists; a service answers `200` with a `serviceCallResponse` once the result is in — ' +
|
|
1959
|
+
'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): ' +
|
|
1960
|
+
'the same request must get the same verdict whether or not the robot happens to be reachable, or a developer testing against an offline ' +
|
|
1961
|
+
'robot never learns their parameters were wrong. A service the robot reports as failed answers `502` carrying **the job\'s own error ' +
|
|
1962
|
+
'code**, which is an open set and not one of the codes above.',
|
|
1963
|
+
},
|
|
1964
|
+
{
|
|
1965
|
+
method: 'GET', path: '/api/robots/:id/jobs/:slug', section: 'commands',
|
|
1966
|
+
summary: 'Reads the most recent job on one slug.',
|
|
1967
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 200,
|
|
1968
|
+
params: [
|
|
1969
|
+
{ name: 'id', description: 'The robot\'s uuid; an end user reaches it through an app that attaches it.' },
|
|
1970
|
+
{ name: 'slug', description: 'The action or service slug from the published configuration.' },
|
|
1971
|
+
],
|
|
1972
|
+
query: null, request: null, response: jobResponse,
|
|
1973
|
+
errors: [...CLIENT_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
1974
|
+
notes: '`job` is `null` when nothing has ever run on that slug — an answer, not a `404`.',
|
|
1975
|
+
},
|
|
1976
|
+
{
|
|
1977
|
+
method: 'POST', path: '/api/robots/:id/jobs/:slug/cancel', section: 'commands',
|
|
1978
|
+
summary: 'Cancels the job running on one slug.',
|
|
1979
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 200,
|
|
1980
|
+
params: [
|
|
1981
|
+
{ name: 'id', description: 'The robot\'s uuid; an end user reaches it through an app that attaches it.' },
|
|
1982
|
+
{ name: 'slug', description: 'The action slug from the published configuration; a service slug is refused.' },
|
|
1983
|
+
],
|
|
1984
|
+
query: null, request: cancelRequest, requestOptional: true, response: jobResponse,
|
|
1985
|
+
errors: [...CLIENT_GUARD, 'invalid_uuid', 'not_found', 'validation_error', 'not_cancellable', 'robot_offline'], transport: 'http',
|
|
1986
|
+
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 ' +
|
|
1987
|
+
'"cancel whatever is running". A named `job_id` that is **not** what is running cancels nothing and answers `404` — the caller named an ' +
|
|
1988
|
+
'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 ' +
|
|
1989
|
+
'`200` with `job: null`.',
|
|
1990
|
+
},
|
|
1991
|
+
{
|
|
1992
|
+
method: 'POST', path: '/api/robots/:id/publishers/:slug', section: 'commands',
|
|
1993
|
+
summary: 'Publishes one message onto a configured publisher.',
|
|
1994
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 204,
|
|
1995
|
+
params: [
|
|
1996
|
+
{ name: 'id', description: 'The robot\'s uuid; an end user reaches it through an app that attaches it.' },
|
|
1997
|
+
{ name: 'slug', description: 'The publisher slug from the published configuration.' },
|
|
1998
|
+
],
|
|
1999
|
+
query: null, request: publishRequest, response: null,
|
|
2000
|
+
errors: [...CLIENT_GUARD, 'invalid_uuid', 'not_found', 'validation_error', 'parameter_invalid', 'robot_offline', 'publisher_busy'], transport: 'http',
|
|
2001
|
+
notes: 'Fire and forget — not a job, so there is nothing to poll and nothing to cancel. A publisher is held exclusively by one caller until it ' +
|
|
2002
|
+
'has been quiet long enough, and another caller meanwhile is `409 publisher_busy` with the timeout and a retry hint. Parameters are ' +
|
|
2003
|
+
'checked before offline and before exclusivity, the same order the invoke path uses and for the same reason. The **acquisition** is ' +
|
|
2004
|
+
'audited, not every message: auditing only takeovers left the single-operator case with no record of who was driving at all.',
|
|
2005
|
+
},
|
|
2006
|
+
/* ------------------------------------------------------------- cameras */
|
|
2007
|
+
{
|
|
2008
|
+
method: 'GET', path: '/api/robots/:id/cameras', section: 'cameras',
|
|
2009
|
+
summary: 'Lists the cameras of a robot, filtered to what the caller\'s role grants.',
|
|
2010
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 200,
|
|
2011
|
+
params: [{ name: 'id', description: 'The robot\'s uuid; an end user reaches it through an app that attaches it.' }],
|
|
2012
|
+
query: null, request: null, response: cameraListResponse,
|
|
2013
|
+
errors: [...CLIENT_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
2014
|
+
},
|
|
2015
|
+
{
|
|
2016
|
+
method: 'GET', path: '/api/robots/:id/cameras/:slug/snapshot', section: 'cameras',
|
|
2017
|
+
summary: 'Returns the most recent snapshot frame as image bytes.',
|
|
2018
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 200,
|
|
2019
|
+
params: [
|
|
2020
|
+
{ name: 'id', description: 'The robot\'s uuid; an end user reaches it through an app that attaches it.' },
|
|
2021
|
+
{ name: 'slug', description: 'The camera slug from the published configuration, as listed by `GET /api/robots/:id/cameras`.' },
|
|
2022
|
+
],
|
|
2023
|
+
query: null, request: null, response: null, contentType: 'image/*',
|
|
2024
|
+
errors: [...CLIENT_GUARD, 'invalid_uuid', 'not_found', 'no_snapshot_yet'], transport: 'http',
|
|
2025
|
+
notes: 'Image bytes, not JSON, so it has no response schema. `contentType` is the family rather than a type: the frame is served in **the ' +
|
|
2026
|
+
'mime the producer sent it as**, so which image format arrives is the camera configuration\'s answer, not this route\'s. The age, ' +
|
|
2027
|
+
'capture time and dimensions ride in the `x-fleetless-*` headers ' +
|
|
2028
|
+
'`SNAPSHOT_HEADERS` names — which a browser can only read because CORS exposes them. **Never checks whether the bridge is online**: a ' +
|
|
2029
|
+
'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 ' +
|
|
2030
|
+
'nothing here to refuse, and `age_ms` carries the whole honesty story. `cache-control: no-store`, because a picture of someone\'s ' +
|
|
2031
|
+
'premises does not belong on disk longer than the request that fetched it.',
|
|
2032
|
+
},
|
|
2033
|
+
{
|
|
2034
|
+
method: 'GET', path: '/api/robots/:id/cameras/:slug/snapshot/meta', section: 'cameras',
|
|
2035
|
+
summary: 'Reports the age and dimensions of the latest snapshot without downloading it.',
|
|
2036
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 200,
|
|
2037
|
+
params: [
|
|
2038
|
+
{ name: 'id', description: 'The robot\'s uuid; an end user reaches it through an app that attaches it.' },
|
|
2039
|
+
{ name: 'slug', description: 'The camera slug from the published configuration, as listed by `GET /api/robots/:id/cameras`.' },
|
|
2040
|
+
],
|
|
2041
|
+
query: null, request: null, response: snapshotMetaResponse,
|
|
2042
|
+
errors: [...CLIENT_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
2043
|
+
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. ' +
|
|
2044
|
+
'Nothing captured yet is **nulls, not a `404`**: "nothing yet" is an answer.',
|
|
2045
|
+
},
|
|
2046
|
+
{
|
|
2047
|
+
method: 'POST', path: '/api/robots/:id/cameras/:slug/live', section: 'cameras',
|
|
2048
|
+
summary: 'Takes a hold on a live camera stream and returns a room token.',
|
|
2049
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 201,
|
|
2050
|
+
params: [
|
|
2051
|
+
{ name: 'id', description: 'The robot\'s uuid; an end user reaches it through an app that attaches it.' },
|
|
2052
|
+
{ name: 'slug', description: 'The camera slug from the published configuration, as listed by `GET /api/robots/:id/cameras`.' },
|
|
2053
|
+
],
|
|
2054
|
+
query: null, request: null, response: liveSessionResponse,
|
|
2055
|
+
errors: [...CLIENT_GUARD, 'invalid_uuid', 'not_found', 'robot_offline', 'camera_offline', 'live_unavailable'], transport: 'http',
|
|
2056
|
+
notes: 'Refcounted: the first viewer starts the robot publishing and the last release stops it. No token is ever minted for an ungranted or ' +
|
|
2057
|
+
'offline camera — both refusals return before the hold is taken. `409 camera_offline` means the **robot itself** reported the failure; ' +
|
|
2058
|
+
'`502 live_unavailable` means this cloud could not start the stream. The difference matters, and it is why a failure the robot named is ' +
|
|
2059
|
+
'never dressed up as one this side invented.',
|
|
2060
|
+
},
|
|
2061
|
+
{
|
|
2062
|
+
method: 'DELETE', path: '/api/robots/:id/cameras/:slug/live', section: 'cameras',
|
|
2063
|
+
summary: 'Releases a live hold, one session or all of this caller\'s.',
|
|
2064
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 204,
|
|
2065
|
+
params: [
|
|
2066
|
+
{ name: 'id', description: 'The robot\'s uuid; an end user reaches it through an app that attaches it.' },
|
|
2067
|
+
{ name: 'slug', description: 'The camera slug from the published configuration, as listed by `GET /api/robots/:id/cameras`.' },
|
|
2068
|
+
],
|
|
2069
|
+
query: releaseLiveQuery, request: null, response: null,
|
|
2070
|
+
errors: [...CLIENT_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
2071
|
+
notes: '`?session_id=` releases that one hold; omitting it releases every hold this caller\'s identity has on this camera, which a client that ' +
|
|
2072
|
+
'lost its id — or a tab that is already closing — still needs. A malformed `session_id` is `400 invalid_uuid`, never a silent fallback ' +
|
|
2073
|
+
'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 ' +
|
|
2074
|
+
'already ended, is idempotently `204`.',
|
|
2075
|
+
},
|
|
2076
|
+
/* -------------------------------------------------------- robots (history) */
|
|
2077
|
+
{
|
|
2078
|
+
method: 'GET', path: '/api/robots/:id/datapoints/:slug/history', section: 'robots',
|
|
2079
|
+
summary: 'Reads recorded samples of one datapoint, or aggregated buckets over a window.',
|
|
2080
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 200,
|
|
2081
|
+
params: [
|
|
2082
|
+
{ name: 'id', description: 'The robot\'s uuid; an end user reaches it through an app that attaches it.' },
|
|
2083
|
+
{ name: 'slug', description: 'The datapoint\'s slug from the published configuration.' },
|
|
2084
|
+
],
|
|
2085
|
+
query: historyQuery, request: null, response: historyResponse,
|
|
2086
|
+
errors: [...CLIENT_GUARD, 'invalid_uuid', 'not_found', 'validation_error', 'invalid_range', 'not_recorded', 'not_aggregatable'], transport: 'http',
|
|
2087
|
+
notes: 'Two answers, carried by one union (`historyResponse`): without `window` it is a `historySamplesResponse`, with one it is a ' +
|
|
2088
|
+
'`historyBucketsResponse`, told apart by `kind`. `window` and `agg` must be given together or not at all — one without the other is refused rather than ' +
|
|
2089
|
+
'defaulted, since a silently chosen aggregation is a chart that lies quietly. A range and window that would produce more buckets than ' +
|
|
2090
|
+
'`limit` is `400 invalid_range` computed **before** the query runs: the bucket response carries no `truncated` field, so a refusal is ' +
|
|
2091
|
+
'the only honest answer. `409 not_recorded` says retention is off for this slug **right now** and deliberately does not claim the table ' +
|
|
2092
|
+
'is empty — rows written before the switch was flipped still exist, unreadable through any route and still counting against the quota.',
|
|
2093
|
+
},
|
|
2094
|
+
/* ------------------------------------------------------ assets (URDF, meshes) */
|
|
2095
|
+
{
|
|
2096
|
+
method: 'GET', path: '/api/robots/:id/assets', section: 'assets',
|
|
2097
|
+
summary: "Lists the robot's synced assets and how complete its URDF is.",
|
|
2098
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 200,
|
|
2099
|
+
params: [{ name: 'id', description: 'The robot\'s uuid; an end user reaches it through an app that attaches it.' }],
|
|
2100
|
+
query: null, request: null, response: assetListResponse,
|
|
2101
|
+
errors: [...CLIENT_GUARD, 'invalid_uuid', 'not_found', 'capability_required'], transport: 'http',
|
|
2102
|
+
notes: 'Needs the `assets` capability, refused as `403 capability_required` rather than a bare `forbidden`: the code says a capability is ' +
|
|
2103
|
+
'missing and the message says which, so a developer who switched the wrong toggle on is told what to switch. The capability is checked ' +
|
|
2104
|
+
'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 ' +
|
|
2105
|
+
'URDF is present and which of its mesh references have no stored asset.',
|
|
2106
|
+
},
|
|
2107
|
+
{
|
|
2108
|
+
method: 'GET', path: '/api/robots/:id/assets/:assetId', section: 'assets',
|
|
2109
|
+
summary: 'Returns one stored asset as bytes.',
|
|
2110
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 200,
|
|
2111
|
+
params: [
|
|
2112
|
+
{ name: 'id', description: 'The robot\'s uuid; an end user reaches it through an app that attaches it.' },
|
|
2113
|
+
{ name: 'assetId', description: 'The asset\'s uuid, as listed by `GET /api/robots/:id/assets`.' },
|
|
2114
|
+
],
|
|
2115
|
+
query: null, request: null, response: null, contentType: 'application/octet-stream',
|
|
2116
|
+
errors: [...CLIENT_GUARD, 'invalid_uuid', 'not_found', 'capability_required', 'internal_error'], transport: 'http',
|
|
2117
|
+
notes: 'Bytes, so it has no response schema. **`contentType` here is the floor, not the answer**: the header carries the asset\'s own stored ' +
|
|
2118
|
+
'media type when that type is on the cloud\'s allow-list, and `application/octet-stream` only when it is not — an allow-list rather than ' +
|
|
2119
|
+
'a pass-through, because a stored type is developer-supplied and a browser will act on it. `X-Content-Type-Options: nosniff` rides along ' +
|
|
2120
|
+
'for the same reason. A row whose blob has vanished from object storage is a logged ' +
|
|
2121
|
+
'`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".',
|
|
2122
|
+
},
|
|
2123
|
+
{
|
|
2124
|
+
method: 'GET', path: '/api/robots/:id/urdf', section: 'assets',
|
|
2125
|
+
summary: 'Returns the robot\'s URDF with every mesh reference rewritten to a Fleetless URL.',
|
|
2126
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 200,
|
|
2127
|
+
params: [{ name: 'id', description: 'The robot\'s uuid; an end user reaches it through an app that attaches it.' }],
|
|
2128
|
+
query: null, request: null, response: null, contentType: 'application/xml',
|
|
2129
|
+
errors: [...CLIENT_GUARD, 'invalid_uuid', 'not_found', 'capability_required', 'internal_error'], transport: 'http',
|
|
2130
|
+
notes: 'XML, so no response schema. **Every `filename` is rewritten, not only a resolvable `package://` one** — an absolute URL that arrived in ' +
|
|
2131
|
+
'a URDF from ROS graph input must never be served through untouched, because a mesh loader attaches the caller\'s bearer token to ' +
|
|
2132
|
+
'whatever absolute URL it is handed. Anything with no stored asset points at `GET /api/robots/:id/assets/missing` instead. A robot with ' +
|
|
2133
|
+
'no synced URDF is `404`.',
|
|
2134
|
+
},
|
|
2135
|
+
{
|
|
2136
|
+
method: 'GET', path: '/api/robots/:id/assets/missing', section: 'assets',
|
|
2137
|
+
summary: 'The placeholder a rewritten URDF points at for a mesh Fleetless does not hold.',
|
|
2138
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 404,
|
|
2139
|
+
params: [{ name: 'id', description: 'The robot\'s uuid; an end user reaches it through an app that attaches it.' }],
|
|
2140
|
+
query: missingAssetQuery, request: null, response: null,
|
|
2141
|
+
errors: [...CLIENT_GUARD, 'invalid_uuid', 'not_found', 'capability_required', 'asset_missing'], transport: 'http',
|
|
2142
|
+
notes: '**This route has no success answer** — `404 asset_missing` naming the unresolved reference is what it exists to give, and `status` says ' +
|
|
2143
|
+
'so rather than declaring a `200` no caller can ever receive. `?name=` is echoed into the message and changes the sentence, never the ' +
|
|
2144
|
+
'outcome; it discloses nothing, since it is what the caller sent. It carries the same `assets` capability gate as the real bytes would: a ' +
|
|
2145
|
+
'missing-asset placeholder is not an exemption from the authorization the thing it stands in for needs.',
|
|
2146
|
+
},
|
|
2147
|
+
{
|
|
2148
|
+
method: 'GET', path: '/api/asset-links/missing', section: 'assets',
|
|
2149
|
+
summary: 'The bearer-free placeholder a *linked* URDF points at for an unresolvable mesh.',
|
|
2150
|
+
audience: 'client', auth: 'in_handler', rateLimited: false, ownerTier: false, status: 404,
|
|
2151
|
+
params: [], query: null, request: null, response: null,
|
|
2152
|
+
errors: ['asset_missing'], transport: 'http',
|
|
2153
|
+
notes: '**No success answer either**, for the reason its authenticated twin has none. Unauthenticated by design and unauthenticated in fact: it ' +
|
|
2154
|
+
'reads nothing and reveals nothing the caller did not put in the query string itself, so there is no credential for the handler to ' +
|
|
2155
|
+
'verify and none is required. It sits under the signed-link prefix because that is where a linked URDF\'s references have to point.',
|
|
2156
|
+
},
|
|
2157
|
+
{
|
|
2158
|
+
method: 'GET', path: '/api/asset-links/:token', section: 'assets',
|
|
2159
|
+
summary: 'Serves one asset, or a rendered URDF, to whoever holds a signed link.',
|
|
2160
|
+
audience: 'client', auth: 'in_handler', rateLimited: false, ownerTier: false, status: 200,
|
|
2161
|
+
params: [{ name: 'token', description: 'The signed, time-limited link an MCP tool minted; it is the whole credential.' }],
|
|
2162
|
+
query: null, request: null, response: null, contentType: 'application/octet-stream',
|
|
2163
|
+
errors: ['not_found', 'internal_error'], transport: 'http',
|
|
2164
|
+
notes: '**The token is the authorization** — there is no route guard on purpose, and verifying it is the whole gate. An MCP session token is ' +
|
|
2165
|
+
'refused on REST by design, so the asset tools mint a fifteen-minute signed link instead and this spends it. The capability was checked ' +
|
|
2166
|
+
'at mint against the minting caller\'s own access; the residual — whoever holds the URL reads that asset until it expires — is named ' +
|
|
2167
|
+
'rather than closed by a second gate, which would be a different policy for one decision. Every refusal collapses into one `404` with ' +
|
|
2168
|
+
'one message, including a malformed id inside a validly signed token, because a link holder has no business learning which of them it ' +
|
|
2169
|
+
'was. A URDF served this way has **its own references minted as links**, back-dated so they expire with the parent — otherwise spending ' +
|
|
2170
|
+
'a link in its last second would hand out another fifteen minutes, and each of those another. **`contentType` is the floor, not the ' +
|
|
2171
|
+
'answer**: an asset is served in its own stored media type where that type is allow-listed and `application/octet-stream` otherwise, and ' +
|
|
2172
|
+
'a linked URDF is `application/xml`. `cache-control: no-store`, since the URL ' +
|
|
2173
|
+
'itself is the credential.',
|
|
2174
|
+
},
|
|
2175
|
+
{
|
|
2176
|
+
method: 'POST', path: '/api/robots/:id/assets/sync', section: 'assets',
|
|
2177
|
+
summary: 'Asks the robot to upload its URDF and meshes, and returns the sync id.',
|
|
2178
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: true, status: 202,
|
|
2179
|
+
params: [{ name: 'id', description: 'The robot\'s uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.' }],
|
|
2180
|
+
query: null, request: assetSyncRequest, response: assetSyncResponse,
|
|
2181
|
+
errors: [...CLIENT_GUARD, 'tier_required', 'invalid_uuid', 'validation_error', 'not_found', 'robot_offline', 'busy'], transport: 'http',
|
|
2182
|
+
notes: 'Owner tier, unconditionally. The guard admits an end user or a server key, but only a developer session gets past the handler — and the ' +
|
|
2183
|
+
'body is parsed **before** that `401`, because this route has always answered a malformed body first and the order has to survive. The ' +
|
|
2184
|
+
'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 ' +
|
|
2185
|
+
'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 ' +
|
|
2186
|
+
'running, so the caller who pressed the button twice can pick it straight up.',
|
|
2187
|
+
},
|
|
2188
|
+
{
|
|
2189
|
+
method: 'GET', path: '/api/robots/:id/assets/sync/:syncId', section: 'assets',
|
|
2190
|
+
summary: 'Reports how far an asset sync has got.',
|
|
2191
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 200,
|
|
2192
|
+
params: [
|
|
2193
|
+
{ name: 'id', description: 'The robot\'s uuid, as returned by `POST /api/robots` or listed by `GET /api/robots`.' },
|
|
2194
|
+
{ name: 'syncId', description: 'The sync id from `POST /api/robots/:id/assets/sync`, or from its `409 busy` refusal.' },
|
|
2195
|
+
],
|
|
2196
|
+
query: null, request: null, response: assetSyncStatus,
|
|
2197
|
+
errors: [...CLIENT_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
2198
|
+
notes: 'Developer sessions only, like starting a sync: the guard admits three caller kinds and the handler answers `401 unauthorized` to the ' +
|
|
2199
|
+
'other two. A sync belonging to another robot reads exactly like one that never existed, which is why the robot is resolved first.',
|
|
2200
|
+
},
|
|
2201
|
+
/* ------------------------------------------------ org (quotas and fleet reads) */
|
|
2202
|
+
{
|
|
2203
|
+
method: 'GET', path: '/api/org/quotas', section: 'org',
|
|
2204
|
+
summary: "Reports every quota's limit next to what the org is currently using.",
|
|
2205
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
2206
|
+
params: [], query: null, request: null, response: orgQuotaUsage,
|
|
2207
|
+
errors: [...DEVELOPER_GUARD], transport: 'http',
|
|
2208
|
+
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 ' +
|
|
2209
|
+
'than something a developer needs audit access to discover. `max_end_users` counts app users only — an org admin is not an app user, and ' +
|
|
2210
|
+
'counting the whole pool would report the Owner an org has by construction as consumption. It is summed **across the org\'s apps**, ' +
|
|
2211
|
+
'because the same address in two apps is two accounts, and that sum is the number the four routes that create an app user refuse ' +
|
|
2212
|
+
'`409 quota_exceeded` against: `POST /api/apps/:id/users`, `POST /api/client/register`, `POST /api/client/invitations/accept`, and a ' +
|
|
2213
|
+
'federated sign-in that would create an account, which carries `quota_exceeded` back to the app as its error redirect. A gauge nothing ' +
|
|
2214
|
+
'enforces is a number that reads as a limit and is not one.',
|
|
2215
|
+
},
|
|
2216
|
+
{
|
|
2217
|
+
method: 'GET', path: '/api/org/health', section: 'org',
|
|
2218
|
+
summary: 'Reports the health of every camera and streaming resource across the org.',
|
|
2219
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
2220
|
+
params: [], query: orgHealthQuery, request: null, response: resourceHealthListResponse,
|
|
2221
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
2222
|
+
notes: '`?robot_id=` narrows it to one robot; omitted, the answer is the whole org. Org-wide rather than per-robot because ' +
|
|
2223
|
+
'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 ' +
|
|
2224
|
+
'snapshot half of the channel; the live half is the `/realtime` socket.',
|
|
2225
|
+
},
|
|
2226
|
+
{
|
|
2227
|
+
method: 'GET', path: '/api/org/jobs', section: 'org',
|
|
2228
|
+
summary: 'Reads durable job-run history across the org, newest first, cursor-paged.',
|
|
2229
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
2230
|
+
params: [], query: jobRunQuery, request: null, response: jobRunListResponse,
|
|
2231
|
+
errors: [...DEVELOPER_GUARD, 'validation_error'], transport: 'http',
|
|
2232
|
+
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 ' +
|
|
2233
|
+
'one end user which others have been driving the machine. Page until the cursor is null, not until a page looks short. A malformed ' +
|
|
2234
|
+
'`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` — it ' +
|
|
2235
|
+
'is a filter.',
|
|
2236
|
+
},
|
|
2237
|
+
{
|
|
2238
|
+
method: 'GET', path: '/api/org/jobs/summary', section: 'org',
|
|
2239
|
+
summary: 'Counts the running, started and failed job runs since a moment the caller names.',
|
|
2240
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
2241
|
+
params: [], query: jobRunSummaryQuery, request: null, response: jobRunSummary,
|
|
2242
|
+
errors: [...DEVELOPER_GUARD, 'validation_error'], transport: 'http',
|
|
2243
|
+
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 ' +
|
|
2244
|
+
'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 ' +
|
|
2245
|
+
'describing.',
|
|
2246
|
+
},
|
|
2247
|
+
{
|
|
2248
|
+
method: 'GET', path: '/api/org/latency', section: 'org',
|
|
2249
|
+
summary: 'Reads one-minute bridge latency buckets per robot over a window the caller names.',
|
|
2250
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
2251
|
+
params: [], query: orgLatencyQuery, request: null, response: orgLatencyResponse,
|
|
2252
|
+
errors: [...DEVELOPER_GUARD, 'validation_error'], transport: 'http',
|
|
2253
|
+
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 ' +
|
|
2254
|
+
'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 ' +
|
|
2255
|
+
'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 ' +
|
|
2256
|
+
'tail of every series — narrow the window or name a `robot_id`.',
|
|
2257
|
+
},
|
|
2258
|
+
{
|
|
2259
|
+
method: 'GET', path: '/api/org/usage', section: 'org',
|
|
2260
|
+
summary: 'Reads what the org consumed per day, per app and per metric.',
|
|
2261
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
2262
|
+
params: [], query: orgUsageQuery, request: null, response: orgUsageResponse,
|
|
2263
|
+
errors: [...DEVELOPER_GUARD, 'validation_error'], transport: 'http',
|
|
2264
|
+
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 ' +
|
|
2265
|
+
'platform will answer is owed a refusal, not a shorter answer they will mistake for the whole picture. `from_day <= to_day` is a ' +
|
|
2266
|
+
'cross-field rule no JSON Schema can express and is enforced here. The window is echoed back.',
|
|
2267
|
+
},
|
|
2268
|
+
/* ------------------------------------------------- assets (robot upload) */
|
|
2269
|
+
{
|
|
2270
|
+
method: 'POST', path: '/api/bridge/assets', section: 'assets',
|
|
2271
|
+
summary: 'Takes one asset file from a robot during a sync.',
|
|
2272
|
+
audience: 'internal', auth: 'robot_upload', rateLimited: false, ownerTier: false, status: 201,
|
|
2273
|
+
params: [], query: null, request: null, response: asset,
|
|
2274
|
+
errors: ['unauthorized', 'rate_limited', 'asset_too_large', 'validation_error', 'not_found', 'quota_exceeded', 'bad_request'], transport: 'http',
|
|
2275
|
+
notes: 'The body is the **raw file bytes**, not JSON, so it has no request schema; everything about the file — its kind, its name, its sync id ' +
|
|
2276
|
+
'and its announced size — rides in the `x-fleetless-asset-*` headers `ASSET_UPLOAD_HEADERS` names. The credential is a short-lived ' +
|
|
2277
|
+
'upload token minted by `POST /api/robots/:id/assets/sync`, verified in a `preParsing` hook so a refusal precedes the work rather than ' +
|
|
2278
|
+
'following it: a `preHandler` would already have buffered the whole file. The announced size is refused there too, before a single byte ' +
|
|
2279
|
+
'is read — 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 ' +
|
|
2280
|
+
'still caught by the real length check. Past both, Fastify\'s own body limit answers a bare `413 bad_request` with neither ceiling nor ' +
|
|
2281
|
+
'size in it. Rate limited per robot inside that same hook, which is why `rateLimited` is `false`: there is no rate-limiting preHandler ' +
|
|
2282
|
+
'registered on this route.',
|
|
2283
|
+
},
|
|
2284
|
+
/* ------------------------------------ realtime and bridge transports */
|
|
2285
|
+
{
|
|
2286
|
+
method: 'GET', path: '/bridge', section: 'transports',
|
|
2287
|
+
summary: 'The robot bridge\'s WebSocket: the versioned bridge protocol, not a client-facing surface.',
|
|
2288
|
+
audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 101,
|
|
2289
|
+
params: [], query: null, request: null, response: null, errors: ['protocol_mismatch', 'invalid_token'], transport: 'websocket',
|
|
2290
|
+
},
|
|
2291
|
+
{
|
|
2292
|
+
method: 'GET', path: '/realtime', section: 'transports',
|
|
2293
|
+
summary: 'The client WebSocket: subscriptions on datapoints, jobs, bridge state and presence, plus full command parity with REST.',
|
|
2294
|
+
audience: 'client', auth: 'none', rateLimited: false, ownerTier: false, status: 101,
|
|
2295
|
+
params: [], query: null, request: null, response: null, errors: ['invalid_token', 'rate_limited'], transport: 'websocket',
|
|
2296
|
+
notes: 'Authentication happens in the first frame, not on the upgrade. The frame types are the `realtime` schemas.',
|
|
2297
|
+
},
|
|
2298
|
+
];
|