@fleetless/contracts 5.3.0-next.1 → 6.0.0-next.2
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 +108 -33
- package/artifacts/openapi.json +2053 -784
- package/artifacts/routes.json +1680 -323
- package/artifacts/schema/accept-team-invite-request.schema.json +13 -7
- package/artifacts/schema/app-auth-config.schema.json +108 -4
- package/artifacts/schema/app-hosted-pages.schema.json +33 -0
- package/artifacts/schema/app-invitation.schema.json +5 -12
- package/artifacts/schema/app-mail-template-list-response.schema.json +4 -3
- package/artifacts/schema/app-mail-template.schema.json +3 -2
- package/artifacts/schema/app-sign-in-methods.schema.json +19 -0
- package/artifacts/schema/app-user-list-response.schema.json +36 -0
- package/artifacts/schema/app-user.schema.json +36 -0
- package/artifacts/schema/audit-query.schema.json +0 -6
- package/artifacts/schema/auth-me-response.schema.json +27 -0
- package/artifacts/schema/auth-ok.schema.json +13 -1
- package/artifacts/schema/client-accept-invitation-request.schema.json +3 -4
- package/artifacts/schema/client-identity.schema.json +13 -1
- package/artifacts/schema/client-login-code-request.schema.json +24 -0
- package/artifacts/schema/client-login-code-verify-request.schema.json +30 -0
- package/artifacts/schema/client-provider-list-response.schema.json +22 -2
- package/artifacts/schema/client-register-request.schema.json +3 -4
- package/artifacts/schema/client-sign-in-result.schema.json +55 -0
- package/artifacts/schema/client-two-factor-disable-request.schema.json +15 -0
- package/artifacts/schema/client-two-factor-setup-confirm-request.schema.json +20 -0
- package/artifacts/schema/client-two-factor-setup-confirm-response.schema.json +49 -0
- package/artifacts/schema/client-two-factor-setup-request.schema.json +12 -0
- package/artifacts/schema/client-two-factor-verify-request.schema.json +25 -0
- package/artifacts/schema/create-app-invitation-request.schema.json +1 -1
- package/artifacts/schema/create-passkey-request.schema.json +25 -0
- package/artifacts/schema/create-passkey-response.schema.json +84 -0
- package/artifacts/schema/developer-passkey.schema.json +56 -0
- package/artifacts/schema/developer-two-factor.schema.json +105 -0
- package/artifacts/schema/fleetless-user-list-response.schema.json +22 -0
- package/artifacts/schema/fleetless-user.schema.json +22 -0
- package/artifacts/schema/invalid-code-details.schema.json +16 -0
- package/artifacts/schema/job-actor.schema.json +1 -15
- package/artifacts/schema/job-run-list-response.schema.json +1 -15
- package/artifacts/schema/job-run.schema.json +1 -15
- package/artifacts/schema/org.schema.json +5 -0
- package/artifacts/schema/patch-org-request.schema.json +5 -3
- package/artifacts/schema/patch-org-response.schema.json +5 -0
- package/artifacts/schema/put-app-auth-look-request.schema.json +22 -0
- package/artifacts/schema/put-app-auth-mcp-request.schema.json +1 -14
- package/artifacts/schema/put-app-auth-sign-in-request.schema.json +39 -0
- package/artifacts/schema/put-app-auth-urls-request.schema.json +31 -4
- package/artifacts/schema/recovery-codes-response.schema.json +20 -0
- package/artifacts/schema/{role-rename-request.schema.json → rename-passkey-request.schema.json} +2 -2
- package/artifacts/schema/role-list-response.schema.json +1 -1
- package/artifacts/schema/role.schema.json +1 -1
- package/artifacts/schema/totp-confirm-request.schema.json +15 -0
- package/artifacts/schema/totp-confirm-response.schema.json +27 -0
- package/artifacts/schema/two-factor-challenge.schema.json +24 -0
- package/artifacts/schema/two-factor-setup-response.schema.json +21 -0
- package/artifacts/schema/webauthn-options-response.schema.json +18 -0
- package/dist/app-users.d.ts +154 -35
- package/dist/app-users.js +177 -46
- package/dist/apps.d.ts +2 -38
- package/dist/apps.js +3 -46
- package/dist/audit.d.ts +0 -1
- package/dist/audit.js +0 -13
- package/dist/client-auth.d.ts +133 -24
- package/dist/client-auth.js +139 -28
- package/dist/config.d.ts +2 -2
- package/dist/errors.d.ts +10 -1
- package/dist/errors.js +37 -38
- package/dist/identity.d.ts +175 -128
- package/dist/identity.js +192 -106
- package/dist/index.d.ts +11 -13
- package/dist/index.js +12 -10
- package/dist/jobs.d.ts +0 -3
- package/dist/jobs.js +0 -16
- package/dist/protocol.d.ts +1 -1
- package/dist/realtime.d.ts +1 -0
- package/dist/rest.d.ts +2 -2
- package/dist/rest.js +17 -28
- package/dist/routes.d.ts +26 -0
- package/dist/routes.js +528 -234
- package/package.json +1 -1
- package/artifacts/schema/developer-login-request.schema.json +0 -19
- package/artifacts/schema/feedback-request.schema.json +0 -34
- package/artifacts/schema/feedback-response.schema.json +0 -27
- package/artifacts/schema/password-reset-confirm.schema.json +0 -19
- package/artifacts/schema/password-reset-request.schema.json +0 -14
- package/artifacts/schema/role-delete-query.schema.json +0 -13
- package/artifacts/schema/role-in-use-details.schema.json +0 -28
- package/artifacts/schema/sign-up-request.schema.json +0 -26
- package/artifacts/schema/sign-up-response.schema.json +0 -127
- package/dist/feedback.d.ts +0 -42
- package/dist/feedback.js +0 -35
package/dist/routes.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
import { appListResponse, appDeletionSummary, createAppRequest, createServerKeyResponse, app as appSchema, role, roleListResponse,
|
|
2
|
+
import { appListResponse, appDeletionSummary, createAppRequest, createServerKeyResponse, app as appSchema, role, roleListResponse, rolePermissions, serverKeyListResponse, updateAppRequest, } from './apps.js';
|
|
3
3
|
import { alertListResponse, orgAlertsQuery, orgFiringAlertsResponse } from './alerts.js';
|
|
4
4
|
import { asset, assetListResponse, assetsClearResponse, assetSyncRequest, assetSyncResponse, assetSyncStatus, missingAssetQuery } from './assets.js';
|
|
5
5
|
import { auditListResponse, auditQuery } from './audit.js';
|
|
6
|
-
import {
|
|
7
|
-
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 { CLIENT_OIDC_CALLBACK_PATH, clientAcceptInvitationRequest, clientIdentity, clientLoginCodeRequest, clientLoginCodeVerifyRequest, clientLoginRequest, clientLogoutRequest, clientMcpInteraction, clientMcpInteractionDecisionResponse, clientOidcCallbackQuery, clientOidcExchangeRequest, clientOidcStartQuery, clientPasswordResetConfirmRequest, clientPasswordResetRequest, clientProviderListQuery, clientProviderListResponse, clientRefreshRequest, clientRegisterRequest, clientResendVerificationRequest, clientSignInResult, clientTwoFactorDisableRequest, clientTwoFactorSetupConfirmRequest, clientTwoFactorSetupConfirmResponse, clientTwoFactorSetupRequest, clientTwoFactorVerifyRequest, clientVerifyEmailRequest, mcpConsentGrantListResponse, } from './client-auth.js';
|
|
8
7
|
import { clientRobotListResponse } from './client-robots.js';
|
|
9
|
-
import { appAuthConfig, appInvitation, appInvitationListResponse, appMailTemplate, appMailTemplateListResponse, appOidcProvider, appOidcProviderListResponse, appUser, appUserListResponse, createAppInvitationRequest, createAppOidcProviderRequest, createAppUserRequest, mailOutcome, mailTemplatePreviewRequest, mailTemplatePreviewResponse, patchAppOidcProviderRequest, patchAppUserRequest, putAppAuthMcpRequest, putAppAuthRegistrationRequest, putAppAuthUrlsRequest, putAppMailTemplateRequest, } from './app-users.js';
|
|
10
|
-
import { acceptTeamInviteRequest, authMeResponse, createTeamInviteRequest,
|
|
8
|
+
import { appAuthConfig, appInvitation, appInvitationListResponse, appMailTemplate, appMailTemplateListResponse, appOidcProvider, appOidcProviderListResponse, appUser, appUserListResponse, createAppInvitationRequest, createAppOidcProviderRequest, createAppUserRequest, mailOutcome, mailTemplatePreviewRequest, mailTemplatePreviewResponse, patchAppOidcProviderRequest, patchAppUserRequest, putAppAuthLookRequest, putAppAuthMcpRequest, putAppAuthRegistrationRequest, putAppAuthSignInRequest, putAppAuthUrlsRequest, putAppMailTemplateRequest, } from './app-users.js';
|
|
9
|
+
import { acceptTeamInviteRequest, authMeResponse, createPasskeyRequest, createPasskeyResponse, createTeamInviteRequest, developerPasskey, developerTwoFactor, fleetlessUser, fleetlessUserListResponse, passwordChangeRequest, patchAuthMeRequest, patchFleetlessUserRequest, patchOrgRequest, patchOrgResponse, pendingTeamInviteListResponse, refreshRequest, sessionTokens, teamInvite, recoveryCodesResponse, renamePasskeyRequest, tierChangeRequest, totpConfirmRequest, totpConfirmResponse, twoFactorSetupResponse, waitlistRequest, webauthnOptionsResponse, } from './identity.js';
|
|
11
10
|
import { jobRunListResponse, jobRunQuery, jobRunSummary, jobRunSummaryQuery } from './jobs.js';
|
|
12
11
|
import { MCP_APP_PATHS, mcpRobotDatasheet, mcpRolePreviewResponse } from './mcp.js';
|
|
13
12
|
import { authorizationServerMetadata, dynamicClientRegistrationRequest, dynamicClientRegistrationResponse, oauthAuthorizeQuery, oauthRedirectResponse, oauthTokenRequest, oauthTokenResponse, protectedResourceMetadata, } from './oauth.js';
|
|
@@ -58,6 +57,11 @@ export const IN_HANDLER_ROUTES = [
|
|
|
58
57
|
// The one route whose bearer is **optional**: it answers the same document
|
|
59
58
|
// with or without one, and only `already_granted` moves.
|
|
60
59
|
'GET /api/client/mcp/interactions/:id',
|
|
60
|
+
// Two-factor setup: during sign-in the challenge in the body is the
|
|
61
|
+
// credential, from the app's account settings the app user's bearer is.
|
|
62
|
+
// Either one, decided in the handler.
|
|
63
|
+
'POST /api/client/two-factor/setup',
|
|
64
|
+
'POST /api/client/two-factor/setup/confirm',
|
|
61
65
|
'GET /api/asset-links/missing',
|
|
62
66
|
'GET /api/asset-links/:token',
|
|
63
67
|
];
|
|
@@ -111,6 +115,170 @@ const DEVELOPER_GUARD = ['unauthorized', 'token_expired', 'token_revoked'];
|
|
|
111
115
|
* two lists were kept as separate constants while they still read alike.
|
|
112
116
|
*/
|
|
113
117
|
const CLIENT_GUARD = ['unauthorized', 'token_expired', 'token_revoked', 'forbidden'];
|
|
118
|
+
/**
|
|
119
|
+
* **The steps of a developer sign-in, written once for both portal flows.**
|
|
120
|
+
*
|
|
121
|
+
* The console's own OAuth flow (`/console/oauth`) and the central MCP
|
|
122
|
+
* endpoint's (`/mcp/oauth`) sign the same people in the same way: an emailed
|
|
123
|
+
* code or a passkey, then the second factor where the person has one or the
|
|
124
|
+
* organisation requires one. Two hand-written copies of thirteen rows would
|
|
125
|
+
* drift; this returns them for a prefix, and `ROUTES` spreads both.
|
|
126
|
+
*
|
|
127
|
+
* Every step is a page the auth portal serves to itself: `audience:
|
|
128
|
+
* 'internal'`, no request schema — the handlers read form fields by hand — and
|
|
129
|
+
* HTML for a browser form post, JSON for a JSON caller.
|
|
130
|
+
*
|
|
131
|
+
* **The browser-proof cookie binds the interaction to one browser**, and it
|
|
132
|
+
* is set by whichever of these comes first for the interaction: the email
|
|
133
|
+
* card (`GET <prefix>/interaction/:id`), `POST <prefix>/identify`, or `POST
|
|
134
|
+
* <prefix>/passkey/options`. The email card is what a browser normally opens
|
|
135
|
+
* first; the two steps set it for a caller that never loaded the page, so
|
|
136
|
+
* `Sign in with a passkey` works without an email step. Once the interaction
|
|
137
|
+
* is bound, every step — those three included — without the matching cookie
|
|
138
|
+
* renders the `wrong_browser` page. The interaction's ten
|
|
139
|
+
* minutes cover every step; only when the last one is done is anything
|
|
140
|
+
* minted. **For `/mcp/oauth`, "done" means the consent step**, as the
|
|
141
|
+
* password did before.
|
|
142
|
+
*/
|
|
143
|
+
export function developerSignInRoutes(prefix) {
|
|
144
|
+
const section = prefix === '/console/oauth' ? 'developer-auth' : 'mcp';
|
|
145
|
+
const done = prefix === '/console/oauth'
|
|
146
|
+
? 'the console callback with the authorization code'
|
|
147
|
+
: 'the consent screen for a self-registered client, or straight to the callback for the central one';
|
|
148
|
+
const page = (path, summary, notes) => ({
|
|
149
|
+
method: 'GET', path: `${prefix}${path}`, section, summary,
|
|
150
|
+
audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
151
|
+
params: [{ name: 'id', description: 'The interaction id of this sign-in; the step before redirects the browser here.' }],
|
|
152
|
+
query: null, request: null, response: null, errors: [], transport: 'http', notes,
|
|
153
|
+
});
|
|
154
|
+
const step = (path, summary, response, errors, notes) => ({
|
|
155
|
+
method: 'POST', path: `${prefix}${path}`, section, summary,
|
|
156
|
+
audience: 'internal', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
157
|
+
params: [], query: null, request: null, response, errors: ['rate_limited', ...errors], transport: 'http', notes,
|
|
158
|
+
});
|
|
159
|
+
return [
|
|
160
|
+
step('/identify', 'Takes the email address, mails a sign-in code and hands back the code step.', null, ['validation_error', 'token_spent', 'wrong_browser'], 'The page answers `Check your email` **for every address**: a known one gets `Your Fleetless sign-in code`, six digits valid ten ' +
|
|
161
|
+
'minutes; an unknown one gets a mail saying no Fleetless account uses it, with a link to sign up (or the waiting list while sign-up ' +
|
|
162
|
+
'is closed). So the page never reveals who has an account, and the address is trimmed and compared case-insensitively. A request ' +
|
|
163
|
+
'within sixty seconds of the last one for the same address renders the same page without a second mail. The browser-proof cookie is ' +
|
|
164
|
+
`set here when the interaction has none yet, and checked when it has. A browser form post gets the code card; a JSON caller gets \`{ "next": "${prefix}/code" }\`, which has no schema. A dead ` +
|
|
165
|
+
'interaction is `410 token_spent`.'),
|
|
166
|
+
step('/code', 'Checks the emailed code and finishes the sign-in, or hands back the second step.', oauthRedirectResponse, ['validation_error', 'token_spent', 'wrong_browser', 'invalid_code'], 'A wrong code renders the code card again with the attempts left (`400 invalid_code`, `details.attempts_left`); a code spent, past ' +
|
|
167
|
+
'its ten minutes or out of its five attempts is `410 token_spent` and a new one has to be asked for. Spaces inside a typed code are ' +
|
|
168
|
+
`removed before it is checked. With no second factor to give, a browser gets a \`303\` to ${done}, and a JSON caller that URL as ` +
|
|
169
|
+
'`redirect_to`. Otherwise the next step: a JSON caller gets `{ "next" }` naming `' + prefix + '/two-factor` — the person has a ' +
|
|
170
|
+
'passkey or an authenticator — or `' + prefix + '/two-factor/setup` — the organisation requires one and the person has none — and a ' +
|
|
171
|
+
'browser the page itself. Audited as `developer.login` with `details.method` `email_code` once the sign-in completes.'),
|
|
172
|
+
page('/two-factor/:id', 'Serves the second step: the authenticator code, or the passkey prompt.', 'HTML. Six boxes for the authenticator code, `Use a passkey`, and `Use a recovery code`; a developer with passkeys only sees the ' +
|
|
173
|
+
'passkey prompt directly. The browser-proof cookie is checked on this GET too. A dead interaction renders the `410` page.'),
|
|
174
|
+
page('/two-factor/:id/recovery', 'Serves the recovery-code page of the second step.', 'HTML. One field for a recovery code, and the way out when none is left: an owner of the organisation can reset the member\'s ' +
|
|
175
|
+
'two-factor in Settings › Team.'),
|
|
176
|
+
step('/two-factor', 'Checks an authenticator code or a recovery code and finishes the sign-in.', oauthRedirectResponse, ['validation_error', 'token_spent', 'wrong_browser', 'invalid_code'], 'The form carries either `code` or `recovery_code`. **An authenticator code is accepted at most once**, so the same code sent twice ' +
|
|
177
|
+
'finishes one sign-in. A recovery code is spent by its use. Five wrong codes end the interaction (`410 token_spent`). A browser gets ' +
|
|
178
|
+
`a \`303\` to ${done}; a JSON caller that URL as \`redirect_to\`.`),
|
|
179
|
+
step('/passkey/options', 'Answers the WebAuthn request options for a passkey sign-in or second step.', webauthnOptionsResponse, ['token_spent', 'wrong_browser'], 'Before an address is known the options name no credential, so the browser offers every discoverable passkey for `fleetless.dev` ' +
|
|
180
|
+
'(`Sign in with a passkey`); after the code step they name the account\'s own passkeys. User verification is required. The challenge ' +
|
|
181
|
+
'is bound to the interaction and single-use. **This is the first step of a passkey sign-in**, which has no email step: the ' +
|
|
182
|
+
'browser-proof cookie is set here when the interaction has none yet, and checked when it has — so `POST ' + prefix + '/passkey` ' +
|
|
183
|
+
'can require it.'),
|
|
184
|
+
step('/passkey', 'Checks a passkey assertion; a passkey completes the sign-in on its own.', oauthRedirectResponse, ['validation_error', 'token_spent', 'wrong_browser', 'invalid_credentials'], '**A passkey is a full sign-in**: it proves possession and user verification, two factors, so it skips the emailed code and the ' +
|
|
185
|
+
'second step — used as the second step, it finishes it. An assertion that does not verify, or names no passkey of an account, is ' +
|
|
186
|
+
'`401 invalid_credentials`, the same answer for both. When the organisation requires two-factor, a passkey satisfies it. A browser ' +
|
|
187
|
+
`gets a \`303\` to ${done}; a JSON caller that URL as \`redirect_to\`. Audited as \`developer.login\` with \`details.method\` \`passkey\`.`),
|
|
188
|
+
page('/two-factor/setup/:id', 'Serves the "your organisation requires two-factor" choice between a passkey and an authenticator.', 'HTML. Reached when the organisation requires two-factor and the person has none; no session exists until the setup is done. Two ' +
|
|
189
|
+
'options, the passkey recommended because it also signs the person in without an emailed code. `Signed in as <email> · Sign out` ' +
|
|
190
|
+
'under it.'),
|
|
191
|
+
step('/two-factor/setup/totp', 'Starts an authenticator setup inside the sign-in and hands back its QR code and key.', twoFactorSetupResponse, ['token_spent', 'wrong_browser'], 'A browser gets the page with the QR code, the key and the confirm field; a JSON caller the secret and `otpauth_url`. Nothing is ' +
|
|
192
|
+
'stored as confirmed yet.'),
|
|
193
|
+
step('/two-factor/setup/totp/confirm', 'Confirms the new authenticator and hands back the ten recovery codes.', recoveryCodesResponse, ['validation_error', 'token_spent', 'wrong_browser', 'invalid_code'], 'A code that does not match is `400 invalid_code`. On success the authenticator is on and ten recovery codes are shown once; ' +
|
|
194
|
+
'`I saved my recovery codes` then finishes the sign-in. Audited as `developer.two_factor_added` with `details.kind` `authenticator`.'),
|
|
195
|
+
step('/two-factor/setup/passkey/options', 'Answers the WebAuthn creation options for a passkey set up inside the sign-in.', webauthnOptionsResponse, ['token_spent', 'wrong_browser'], 'The same options `POST /api/auth/passkeys/options` answers a signed-in developer: relying party `fleetless.dev`, user verification ' +
|
|
196
|
+
'required, a discoverable credential.'),
|
|
197
|
+
step('/two-factor/setup/passkey', 'Registers the passkey and hands back the ten recovery codes.', recoveryCodesResponse, ['validation_error', 'token_spent', 'wrong_browser'], 'A ceremony that does not verify is `400 validation_error`. On success the passkey is stored, named after the browser\'s device ' +
|
|
198
|
+
'where it says so, and ten recovery codes are shown once. Audited as `developer.two_factor_added` with `details.kind` `passkey`.'),
|
|
199
|
+
step('/two-factor/setup/done', 'Finishes the sign-in once the recovery codes are saved.', oauthRedirectResponse, ['token_spent', 'wrong_browser'], '`I saved my recovery codes` gates the button on the page; the step itself only checks that a second factor now exists. A browser ' +
|
|
200
|
+
`gets a \`303\` to ${done}; a JSON caller that URL as \`redirect_to\`.`),
|
|
201
|
+
];
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* **The Fleetless-hosted pages an app falls back to**, under
|
|
205
|
+
* `/app/:appIdentifier` on the auth portal.
|
|
206
|
+
*
|
|
207
|
+
* An app that leaves one of its URLs unset gets these instead: the invitation,
|
|
208
|
+
* email-confirmation and new-password pages its mails link to, a forgot-password
|
|
209
|
+
* and a sign-up page, and the MCP sign-in. They are pages the portal serves to
|
|
210
|
+
* itself — `audience: 'internal'`, HTML only, forms with no request schema —
|
|
211
|
+
* on a neutral shell carrying the app's name, its optional logo and accent,
|
|
212
|
+
* and `Secured by Fleetless`. They call the same sign-in policy as the
|
|
213
|
+
* `/api/client/*` routes, so there is one set of rules, not two; the portal
|
|
214
|
+
* origin is always allowed for them, whatever the app's `allowed_origins`.
|
|
215
|
+
* They are not a hosted login for the app's own web UI: nothing hands a
|
|
216
|
+
* session to the app's origin.
|
|
217
|
+
*
|
|
218
|
+
* **A mailed link never spends its token on `GET`**: the `GET` renders a form,
|
|
219
|
+
* and only its `POST` spends the token — a mail scanner opening the link
|
|
220
|
+
* changes nothing. Every MCP step after the sign-in needs the browser-proof
|
|
221
|
+
* cookie set there. A done page offers `Open <app>` when the app's `app_url`
|
|
222
|
+
* is set, and `You can close this tab` otherwise; a spent or expired link
|
|
223
|
+
* renders one `410` page with the next step for its kind.
|
|
224
|
+
*/
|
|
225
|
+
const HOSTED_TOKEN = {
|
|
226
|
+
name: 'token',
|
|
227
|
+
description: 'The opaque token from the mailed link; it is never sent as a query parameter.',
|
|
228
|
+
};
|
|
229
|
+
const HOSTED_INTERACTION = {
|
|
230
|
+
name: 'interaction',
|
|
231
|
+
description: 'The interaction id `GET /mcp/:appIdentifier/oauth/authorize` put into the hosted MCP sign-in URL.',
|
|
232
|
+
};
|
|
233
|
+
function hostedPage(method, path, summary, notes, params = []) {
|
|
234
|
+
return {
|
|
235
|
+
method, path: `/app/:appIdentifier${path}`, section: 'client-auth', summary,
|
|
236
|
+
audience: 'internal', auth: 'none', rateLimited: method === 'POST', ownerTier: false, status: 200,
|
|
237
|
+
params: [APP_IDENTIFIER, ...params], query: null, request: null, response: null,
|
|
238
|
+
errors: method === 'POST' ? ['rate_limited'] : [], transport: 'http', notes,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const HOSTED_FORM = 'Renders a form; only its `POST` spends the token, so a mail scanner opening the link changes nothing. ';
|
|
242
|
+
const HOSTED_DEAD = 'A spent, expired or unknown token renders the `410` page with the next step for its kind.';
|
|
243
|
+
const HOSTED_POST = 'HTML: the next page on success, the same page with the problem named on a refusal. ';
|
|
244
|
+
const HOSTED_APP_ROUTES = [
|
|
245
|
+
hostedPage('GET', '/logo', "Serves the app's logo for its hosted pages.", 'The stored PNG or SVG, as written through `PUT /api/apps/:id/auth-config/logo`; `404` when the app has none. **Served sandboxed**: ' +
|
|
246
|
+
'`X-Content-Type-Options: nosniff` and `Content-Security-Policy: default-src \'none\'; style-src \'unsafe-inline\'; sandbox`, so a ' +
|
|
247
|
+
'script inside an SVG never runs, even opened directly. The hosted pages embed it as an image only.'),
|
|
248
|
+
hostedPage('GET', '/mcp/:interaction', 'Serves the hosted MCP sign-in, the page an unset `mcp_login_url` falls back to.', 'HTML: the app\'s identity providers, then the sign-in the app offers — email and password with `Email me a sign-in code instead`, ' +
|
|
249
|
+
'or email and `Email me a code` for a code-only app — and `Create one` when self-registration is on. A dead interaction renders the ' +
|
|
250
|
+
'`410` page. The browser-proof cookie is set here.', [HOSTED_INTERACTION]),
|
|
251
|
+
hostedPage('POST', '/mcp/:interaction/password', 'Checks the email and password of the hosted MCP sign-in.', HOSTED_POST + 'One refusal for every miss, as `POST /api/client/login` answers. With a second factor to give, the two-factor page ' +
|
|
252
|
+
'follows; otherwise the consent page.', [HOSTED_INTERACTION]),
|
|
253
|
+
hostedPage('POST', '/mcp/:interaction/code', 'Mails a sign-in code for the hosted MCP sign-in and renders the code page.', HOSTED_POST + 'The same page for a known and an unknown address, as `POST /api/client/login/code` answers; six boxes, `Resend code ' +
|
|
254
|
+
'in 0:42`, and `Use password instead` where the app has passwords on.', [HOSTED_INTERACTION]),
|
|
255
|
+
hostedPage('POST', '/mcp/:interaction/code/verify', 'Checks the emailed code of the hosted MCP sign-in.', HOSTED_POST + 'A wrong code renders the code page with the attempts left; a spent one asks for a new code. With a second factor to ' +
|
|
256
|
+
'give, the two-factor page follows; otherwise the consent page.', [HOSTED_INTERACTION]),
|
|
257
|
+
hostedPage('POST', '/mcp/:interaction/consent', 'Records the allow-or-deny of the hosted MCP sign-in and sends the browser back to the client.', 'Answers a `303` to the MCP client\'s callback, carrying the code or `error=access_denied`, as `POST ' +
|
|
258
|
+
'/api/client/mcp/interactions/:id/approve` and `…/deny` do. Fail-closed: anything but the Allow value denies. The browser-proof cookie ' +
|
|
259
|
+
'set at the sign-in must match, or the `wrong_browser` page renders.', [HOSTED_INTERACTION]),
|
|
260
|
+
hostedPage('POST', '/two-factor', 'Checks an authenticator or recovery code on a hosted page and finishes the sign-in it interrupted.', HOSTED_POST + 'The form carries the challenge the step before answered and either `code` or `recovery_code`, checked as `POST ' +
|
|
261
|
+
'/api/client/two-factor/verify` checks them. Success continues where the sign-in was going: the MCP consent, or the done page.'),
|
|
262
|
+
hostedPage('POST', '/two-factor/setup', 'Starts an authenticator setup on a hosted page, when the app requires two-factor.', HOSTED_POST + 'Renders the QR code, the key with `Copy key`, and the confirm field, for the challenge the step before answered.'),
|
|
263
|
+
hostedPage('POST', '/two-factor/setup/confirm', 'Confirms the new authenticator on a hosted page and shows the ten recovery codes.', HOSTED_POST + 'A wrong code renders the setup page again. On success the ten recovery codes are shown once, with `Copy` and `Download ' +
|
|
264
|
+
'.txt`; `I saved my recovery codes` gates `Continue`.'),
|
|
265
|
+
hostedPage('GET', '/invite/:token', 'Serves the hosted invitation page, the one an unset `invite_url` falls back to.', 'HTML. ' + HOSTED_FORM + 'Asks for a name, and a password only while the app has passwords on; announces the two-factor setup when ' +
|
|
266
|
+
'the app requires it. ' + HOSTED_DEAD, [HOSTED_TOKEN]),
|
|
267
|
+
hostedPage('POST', '/invite', 'Accepts an invitation from the hosted invitation page.', HOSTED_POST + 'Spends the token as `POST /api/client/invitations/accept` does; the two-factor setup follows when the app requires it, ' +
|
|
268
|
+
'then the done page.'),
|
|
269
|
+
hostedPage('GET', '/reset/:token', 'Serves the hosted new-password page, the one an unset `reset_url` falls back to.', 'HTML. ' + HOSTED_FORM + 'Says that two-factor stays on. ' + HOSTED_DEAD, [HOSTED_TOKEN]),
|
|
270
|
+
hostedPage('POST', '/reset', 'Sets the new password from the hosted new-password page.', HOSTED_POST + 'Spends the token as `POST /api/client/password/reset/confirm` does; a person with an authenticator gives a code ' +
|
|
271
|
+
'before the done page.'),
|
|
272
|
+
hostedPage('GET', '/forgot', 'Serves the hosted "forgot your password" page.', 'HTML: the address field. Reached from the hosted sign-in and the dead-link page.'),
|
|
273
|
+
hostedPage('POST', '/forgot', 'Mails a reset link from the hosted "forgot your password" page.', HOSTED_POST + 'The same "check your mail" page for a known and an unknown address, as `POST /api/client/password/reset` answers.'),
|
|
274
|
+
hostedPage('GET', '/sign-up', 'Serves the hosted sign-up page, when the app has self-registration on.', 'HTML: the address, a password while the app has passwords on, and an optional name. With self-registration off it renders the ' +
|
|
275
|
+
'"registration closed" page.'),
|
|
276
|
+
hostedPage('POST', '/sign-up', 'Creates an account from the hosted sign-up page and mails the confirmation link.', HOSTED_POST + 'Registers as `POST /api/client/register` does, and renders the same "check your mail" page for a new and a known ' +
|
|
277
|
+
'address.'),
|
|
278
|
+
hostedPage('GET', '/verify/:token', 'Serves the hosted email-confirmation page, the one an unset `verify_url` falls back to.', 'HTML. ' + HOSTED_FORM + HOSTED_DEAD, [HOSTED_TOKEN]),
|
|
279
|
+
hostedPage('POST', '/verify', 'Confirms the address from the hosted email-confirmation page.', HOSTED_POST + 'Spends the token as `POST /api/client/verify-email` does; the two-factor setup follows when the app requires it, then ' +
|
|
280
|
+
'the done page.'),
|
|
281
|
+
];
|
|
114
282
|
export const ROUTES = [
|
|
115
283
|
/* ------------------------------------------------------------- health */
|
|
116
284
|
{
|
|
@@ -123,16 +291,6 @@ export const ROUTES = [
|
|
|
123
291
|
'`/healthz` must never itself be a reason the process looks down. Read `ok`, not the status code.',
|
|
124
292
|
},
|
|
125
293
|
/* ----------------------------------------------------- developer auth */
|
|
126
|
-
{
|
|
127
|
-
method: 'POST', path: '/api/auth/signup', section: 'developer-auth',
|
|
128
|
-
summary: 'Creates an org and its founding Owner, and answers a developer session.',
|
|
129
|
-
audience: 'developer', auth: 'none', rateLimited: true, ownerTier: false, status: 201,
|
|
130
|
-
params: [], query: null, request: signUpRequest, response: signUpResponse,
|
|
131
|
-
errors: ['rate_limited', 'signup_closed', 'validation_error', 'email_taken'], transport: 'http',
|
|
132
|
-
notes: 'While the deployment runs in closed beta this answers `403 signup_closed` before it looks at the body — there is nothing for a ' +
|
|
133
|
-
'validation message, or an `email_taken` answer, to be right about when nothing will be created. Email is globally unique, so an ' +
|
|
134
|
-
'address already registered in any org is refused.',
|
|
135
|
-
},
|
|
136
294
|
{
|
|
137
295
|
method: 'POST', path: '/api/auth/refresh', section: 'developer-auth',
|
|
138
296
|
summary: 'Rotates a developer refresh token and mints a fresh access token.',
|
|
@@ -140,7 +298,7 @@ export const ROUTES = [
|
|
|
140
298
|
params: [], query: null, request: refreshRequest, response: sessionTokens,
|
|
141
299
|
errors: ['rate_limited', 'validation_error', 'token_expired', 'token_revoked'], transport: 'http',
|
|
142
300
|
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 ' +
|
|
143
|
-
'bumped by
|
|
301
|
+
'bumped by an owner\'s two-factor reset, cannot mint a fresh console token and answers `token_revoked`. Refusing that only on the other routes ' +
|
|
144
302
|
'would leave a session that is dead everywhere but here.',
|
|
145
303
|
},
|
|
146
304
|
{
|
|
@@ -170,46 +328,94 @@ export const ROUTES = [
|
|
|
170
328
|
'held writes nothing and records no audit event — the org activity stream reaches every developer with the console open, and an event ' +
|
|
171
329
|
'for a no-op would misreport that something changed.',
|
|
172
330
|
},
|
|
331
|
+
/* ------------------------------------ a developer's own second factors */
|
|
173
332
|
{
|
|
174
|
-
method: '
|
|
175
|
-
summary:
|
|
333
|
+
method: 'GET', path: '/api/auth/two-factor', section: 'developer-auth',
|
|
334
|
+
summary: "Answers the calling developer's passkeys, authenticator, recovery codes left and the org's policy.",
|
|
176
335
|
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
177
|
-
params: [], query: null, request:
|
|
178
|
-
errors: [...DEVELOPER_GUARD
|
|
179
|
-
notes: '
|
|
180
|
-
'
|
|
181
|
-
'session from another.',
|
|
336
|
+
params: [], query: null, request: null, response: developerTwoFactor,
|
|
337
|
+
errors: [...DEVELOPER_GUARD], transport: 'http',
|
|
338
|
+
notes: 'What Settings › Profile › Security draws. No key material, secret or code travels here — the passkeys are names and dates, the ' +
|
|
339
|
+
'authenticator is a date, the recovery codes are a count.',
|
|
182
340
|
},
|
|
183
341
|
{
|
|
184
|
-
method: 'POST', path: '/api/auth/
|
|
185
|
-
summary: '
|
|
186
|
-
audience: 'developer', auth: '
|
|
187
|
-
params: [], query: null, request:
|
|
188
|
-
errors: [
|
|
189
|
-
notes: '
|
|
190
|
-
'
|
|
191
|
-
'
|
|
342
|
+
method: 'POST', path: '/api/auth/passkeys/options', section: 'developer-auth',
|
|
343
|
+
summary: 'Answers the WebAuthn creation options for registering a passkey.',
|
|
344
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
345
|
+
params: [], query: null, request: null, response: webauthnOptionsResponse,
|
|
346
|
+
errors: [...DEVELOPER_GUARD], transport: 'http',
|
|
347
|
+
notes: 'Hand `options` to the browser\'s WebAuthn API as it is. The relying party is `fleetless.dev`, so the passkey works on the auth ' +
|
|
348
|
+
'portal and in the console alike; user verification is required and the credential is discoverable, so it can sign the person in ' +
|
|
349
|
+
'without an address. The passkeys the caller already has are excluded. The challenge is single-use and expires with the ceremony.',
|
|
192
350
|
},
|
|
193
|
-
/* ------------------------------------------- client auth (portal pages) */
|
|
194
351
|
{
|
|
195
|
-
method: '
|
|
196
|
-
summary: '
|
|
197
|
-
audience: '
|
|
198
|
-
params: [], query: null, request:
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
'
|
|
352
|
+
method: 'POST', path: '/api/auth/passkeys', section: 'developer-auth',
|
|
353
|
+
summary: 'Registers a passkey from the browser\'s answer to the creation options.',
|
|
354
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 201,
|
|
355
|
+
params: [], query: null, request: createPasskeyRequest, response: createPasskeyResponse,
|
|
356
|
+
errors: [...DEVELOPER_GUARD, 'validation_error'], transport: 'http',
|
|
357
|
+
notes: 'A ceremony that does not verify — a wrong challenge, origin or relying party, no user verification — is `400 validation_error` ' +
|
|
358
|
+
'naming `credential`. When this is the account\'s first second factor, ten recovery codes are issued and answered once; otherwise ' +
|
|
359
|
+
'`recovery_codes` is `null` and the existing ones stay valid. Audited as `developer.two_factor_added` with `details.kind` `passkey`.',
|
|
202
360
|
},
|
|
203
361
|
{
|
|
204
|
-
method: '
|
|
205
|
-
summary:
|
|
206
|
-
audience: '
|
|
207
|
-
params: [{ name: '
|
|
208
|
-
query: null, request:
|
|
209
|
-
|
|
210
|
-
'and splitting them here would tell a stranger which tokens ever existed. No rate limiter: the GET changes nothing, and the POST it ' +
|
|
211
|
-
'leads to is limited per IP.',
|
|
362
|
+
method: 'PATCH', path: '/api/auth/passkeys/:id', section: 'developer-auth',
|
|
363
|
+
summary: "Renames one of the caller's passkeys.",
|
|
364
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
365
|
+
params: [{ name: 'id', description: 'The passkey\'s uuid, as listed by `GET /api/auth/two-factor`; another person\'s passkey answers `404`.' }],
|
|
366
|
+
query: null, request: renamePasskeyRequest, response: developerPasskey,
|
|
367
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found'], transport: 'http',
|
|
212
368
|
},
|
|
369
|
+
{
|
|
370
|
+
method: 'DELETE', path: '/api/auth/passkeys/:id', section: 'developer-auth',
|
|
371
|
+
summary: "Removes one of the caller's passkeys.",
|
|
372
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 204,
|
|
373
|
+
params: [{ name: 'id', description: 'The passkey\'s uuid, as listed by `GET /api/auth/two-factor`; another person\'s passkey answers `404`.' }],
|
|
374
|
+
query: null, request: null, response: null,
|
|
375
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'target_state_conflict'], transport: 'http',
|
|
376
|
+
notes: '`409 target_state_conflict` names `two_factor` with rule `required_by_org` when this is the caller\'s last second factor and the ' +
|
|
377
|
+
'organisation requires one. Removing the last one otherwise also voids the recovery codes. Audited as `developer.two_factor_removed` ' +
|
|
378
|
+
'with `details.kind` `passkey`.',
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
method: 'POST', path: '/api/auth/totp', section: 'developer-auth',
|
|
382
|
+
summary: 'Starts an authenticator setup and answers its secret and otpauth URL.',
|
|
383
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
384
|
+
params: [], query: null, request: null, response: twoFactorSetupResponse,
|
|
385
|
+
errors: [...DEVELOPER_GUARD], transport: 'http',
|
|
386
|
+
notes: 'The secret is pending until `POST /api/auth/totp/confirm` accepts a code from it; a second call replaces a pending secret. A ' +
|
|
387
|
+
'developer who already has an authenticator keeps it until the new one is confirmed, which is how `Replace…` works.',
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
method: 'POST', path: '/api/auth/totp/confirm', section: 'developer-auth',
|
|
391
|
+
summary: 'Confirms the pending authenticator with a code it shows now.',
|
|
392
|
+
audience: 'developer', auth: 'developer', rateLimited: true, ownerTier: false, status: 200,
|
|
393
|
+
params: [], query: null, request: totpConfirmRequest, response: totpConfirmResponse,
|
|
394
|
+
errors: [...DEVELOPER_GUARD, 'rate_limited', 'validation_error', 'invalid_code', 'token_spent'], transport: 'http',
|
|
395
|
+
notes: 'A code that does not match the pending secret is `400 invalid_code`; no pending setup is `410 token_spent`. On success the new ' +
|
|
396
|
+
'authenticator replaces any earlier one. Ten recovery codes are answered when it is the account\'s first second factor, otherwise ' +
|
|
397
|
+
'`null`. Audited as `developer.two_factor_added` with `details.kind` `authenticator`.',
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
method: 'DELETE', path: '/api/auth/totp', section: 'developer-auth',
|
|
401
|
+
summary: "Removes the caller's authenticator app.",
|
|
402
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 204,
|
|
403
|
+
params: [], query: null, request: null, response: null,
|
|
404
|
+
errors: [...DEVELOPER_GUARD, 'not_found', 'target_state_conflict'], transport: 'http',
|
|
405
|
+
notes: '`404 not_found` when there is no authenticator. `409 target_state_conflict` names `two_factor` with rule `required_by_org` when it ' +
|
|
406
|
+
'is the caller\'s last second factor and the organisation requires one. Audited as `developer.two_factor_removed` with ' +
|
|
407
|
+
'`details.kind` `authenticator`.',
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
method: 'POST', path: '/api/auth/recovery-codes', section: 'developer-auth',
|
|
411
|
+
summary: 'Issues ten new recovery codes and voids the old ones.',
|
|
412
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
413
|
+
params: [], query: null, request: null, response: recoveryCodesResponse,
|
|
414
|
+
errors: [...DEVELOPER_GUARD, 'target_state_conflict'], transport: 'http',
|
|
415
|
+
notes: 'The codes are shown this once. `409 target_state_conflict` names `two_factor` with rule `off` when the caller has no second factor: ' +
|
|
416
|
+
'recovery codes only stand in for one. Audited as `developer.recovery_codes_generated`.',
|
|
417
|
+
},
|
|
418
|
+
/* ------------------------------------------- client auth (portal pages) */
|
|
213
419
|
{
|
|
214
420
|
method: 'GET', path: '/favicon.svg', section: 'client-auth',
|
|
215
421
|
summary: 'Serves the Fleetless icon for the auth portal\'s and the MCP welcome page\'s browser tab.',
|
|
@@ -218,16 +424,6 @@ export const ROUTES = [
|
|
|
218
424
|
notes: 'An SVG, not JSON. Those pages carry a Content-Security-Policy that admits no `data:` image, so the icon is a file on their own ' +
|
|
219
425
|
'origin — the one source `img-src \'self\'` names. Cached for a day: the bytes change when the brand does, not per deploy.',
|
|
220
426
|
},
|
|
221
|
-
{
|
|
222
|
-
method: 'POST', path: '/api/auth/password/reset/confirm', section: 'developer-auth',
|
|
223
|
-
summary: 'Spends a reset token, sets the new password and ends every session of the account.',
|
|
224
|
-
audience: 'developer', auth: 'none', rateLimited: true, ownerTier: false, status: 204,
|
|
225
|
-
params: [], query: null, request: passwordResetConfirm, response: null,
|
|
226
|
-
errors: ['rate_limited', 'validation_error', 'token_spent'], transport: 'http',
|
|
227
|
-
notes: 'Unknown, spent and expired tokens all answer `410 token_spent`. Sessions are revoked under the account\'s actual kind — a console admin ' +
|
|
228
|
-
'holds developer sessions, an app user holds end-user ones — so an app user\'s open `/realtime` socket does not outlive the reset. ' +
|
|
229
|
-
'A browser form post gets the rendered "done" page instead of this `204`.',
|
|
230
|
-
},
|
|
231
427
|
{
|
|
232
428
|
method: 'POST', path: '/api/waitlist', section: 'developer-auth',
|
|
233
429
|
summary: 'Adds an address to the closed-beta waiting list.',
|
|
@@ -329,7 +525,7 @@ export const ROUTES = [
|
|
|
329
525
|
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
330
526
|
query: null, request: null, response: role,
|
|
331
527
|
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'validation_error'], transport: 'http',
|
|
332
|
-
notes: 'The body is `{ "name": string }` — non-empty, trimmed, at most
|
|
528
|
+
notes: 'The body is `{ "name": string }` — non-empty, trimmed, at most 120 characters — and is deliberately not a contract shape: contracts ' +
|
|
333
529
|
'define the `role` this answers with, not this one trivial request. **The answer is a bare `role`, not an envelope**, unlike the ' +
|
|
334
530
|
'listing beside it.',
|
|
335
531
|
},
|
|
@@ -382,33 +578,6 @@ export const ROUTES = [
|
|
|
382
578
|
'offered and consults nothing about any user\'s actual MCP entitlement. A robot the role grants nothing on still appears, with an empty ' +
|
|
383
579
|
'`exposures` — dropping it would read as "not attached", which is a different fact.',
|
|
384
580
|
},
|
|
385
|
-
{
|
|
386
|
-
method: 'PATCH', path: '/api/apps/:id/roles/:roleId', section: 'apps',
|
|
387
|
-
summary: 'Renames a role; its users keep it.',
|
|
388
|
-
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
389
|
-
params: [
|
|
390
|
-
{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' },
|
|
391
|
-
{ name: 'roleId', description: 'The role\'s uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`.' },
|
|
392
|
-
],
|
|
393
|
-
query: null, request: roleRenameRequest, response: role,
|
|
394
|
-
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'validation_error', 'role_name_taken'], transport: 'http',
|
|
395
|
-
notes: 'Names are unique per app, compared exactly as stored after trimming. Built-in roles can be renamed.',
|
|
396
|
-
},
|
|
397
|
-
{
|
|
398
|
-
method: 'DELETE', path: '/api/apps/:id/roles/:roleId', section: 'apps',
|
|
399
|
-
summary: 'Deletes a role, moving its users, pending invitations and default-role status to another role.',
|
|
400
|
-
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 204,
|
|
401
|
-
params: [
|
|
402
|
-
{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' },
|
|
403
|
-
{ name: 'roleId', description: 'The role\'s uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`.' },
|
|
404
|
-
],
|
|
405
|
-
query: roleDeleteQuery, request: null, response: null,
|
|
406
|
-
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'validation_error', 'role_in_use', 'last_role'], transport: 'http',
|
|
407
|
-
notes: 'Without `move_to`, a role that app users or pending invitations hold, or that is the app\'s default, answers ' +
|
|
408
|
-
'`409 role_in_use` with `{ users, invitations, is_default }`. With `move_to` — another role of the same app, else ' +
|
|
409
|
-
'`400 validation_error` — one transaction moves `app_users.role_id`, pending invitations and `default_role_id`, then deletes ' +
|
|
410
|
-
'the role and its permissions. The app\'s only role answers `409 last_role`. Built-in roles can be deleted like any other.',
|
|
411
|
-
},
|
|
412
581
|
{
|
|
413
582
|
method: 'POST', path: '/api/apps/:id/server-keys', section: 'apps',
|
|
414
583
|
summary: 'Mints a server key for the app and returns the raw secret once.',
|
|
@@ -536,17 +705,30 @@ export const ROUTES = [
|
|
|
536
705
|
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 202,
|
|
537
706
|
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`.' }],
|
|
538
707
|
query: null, request: null, response: mailOutcome,
|
|
539
|
-
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'target_state_conflict'], transport: 'http',
|
|
540
|
-
notes: 'The support door beside `POST /api/client/password/reset
|
|
708
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'target_state_conflict', 'method_not_allowed'], transport: 'http',
|
|
709
|
+
notes: 'The support door beside `POST /api/client/password/reset`, refused like it with `403 method_not_allowed` while the app has the ' +
|
|
710
|
+
'password method off: the same one-hour token and the same link, triggered by a developer for a ' +
|
|
541
711
|
'user who asked them rather than the form. **No enumeration discipline applies** — the caller is authenticated into the app and can read ' +
|
|
542
712
|
'the user list — so this one answers what actually happened: `{ "mail": mailStatus }`, where `not_configured` is a deployment without a ' +
|
|
543
|
-
'mailer and `failed` is the state worth somebody\'s attention.
|
|
544
|
-
'
|
|
713
|
+
'mailer and `failed` is the state worth somebody\'s attention. The link points at the app\'s `reset_url`, or at the hosted reset page ' +
|
|
714
|
+
'when the app has configured none. `409 target_state_conflict` names `password` with rule ' +
|
|
545
715
|
'`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` ' +
|
|
546
716
|
'with rule `blocked` for a blocked one, since `POST /api/client/password/reset` mails a blocked account nothing and the two doors may ' +
|
|
547
717
|
'not disagree. Setting the password directly is deliberately not offered; a developer who could would hold their customers\' ' +
|
|
548
718
|
'credentials.',
|
|
549
719
|
},
|
|
720
|
+
{
|
|
721
|
+
method: 'DELETE', path: '/api/apps/:id/users/:userId/two-factor', section: 'apps',
|
|
722
|
+
summary: "Removes an app user's authenticator and recovery codes and ends every session they hold.",
|
|
723
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 204,
|
|
724
|
+
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`.' }],
|
|
725
|
+
query: null, request: null, response: null,
|
|
726
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
727
|
+
notes: 'The support door for a person who lost their authenticator and their recovery codes. The authenticator and every recovery code go, ' +
|
|
728
|
+
'and so does every session of the account — whoever held one may be the reason for the reset. **A user with no second factor answers ' +
|
|
729
|
+
'`204` too**: that is the end state being asked for. When the app requires two-factor, the person sets it up again at their next ' +
|
|
730
|
+
'sign-in, before any session exists. Audited as `app_user.two_factor_reset`.',
|
|
731
|
+
},
|
|
550
732
|
/* ---------------------------- the MCP clients one app user has connected */
|
|
551
733
|
{
|
|
552
734
|
method: 'GET', path: '/api/apps/:id/users/:userId/mcp-grants', section: 'apps',
|
|
@@ -613,11 +795,9 @@ export const ROUTES = [
|
|
|
613
795
|
transport: 'http',
|
|
614
796
|
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 ' +
|
|
615
797
|
'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. ' +
|
|
616
|
-
'An invitation **always bypasses `allowed_domains`**. \n\nThe answer carries `accept_url
|
|
617
|
-
'
|
|
618
|
-
'
|
|
619
|
-
'state is `409 target_state_conflict` naming `invite_url`, because a mail carrying a dead link is worse than no mail. The same `409` ' +
|
|
620
|
-
'names `default_role_id` when `role_id` is absent and the app has no default role, or its default no longer resolves: an invitation ' +
|
|
798
|
+
'An invitation **always bypasses `allowed_domains`**. \n\nThe answer carries `accept_url`: the app\'s `invite_url` with the token in it, ' +
|
|
799
|
+
'or the Fleetless-hosted invitation page when the app has configured none — so mailing it is never refused for a missing URL. ' +
|
|
800
|
+
'`409 target_state_conflict` names `default_role_id` when `role_id` is absent and the app has no default role, or its default no longer resolves: an invitation ' +
|
|
621
801
|
'that names no role has nothing to hand its acceptor, so it is refused here rather than at the acceptance a week later. `409 ' +
|
|
622
802
|
'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. ' +
|
|
623
803
|
'\n\nCreating shares the reissue route\'s ceiling of **five invitation mails a minute per app**, answering `429 rate_limited` with ' +
|
|
@@ -728,7 +908,7 @@ export const ROUTES = [
|
|
|
728
908
|
/* ------------------------------- the app's auth configuration and mails */
|
|
729
909
|
{
|
|
730
910
|
method: 'GET', path: '/api/apps/:id/auth-config', section: 'apps',
|
|
731
|
-
summary: "Reads the app's auth settings:
|
|
911
|
+
summary: "Reads the app's auth settings: sign-in methods, two-factor, registration, pages, the hosted look and the MCP switch.",
|
|
732
912
|
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
733
913
|
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
734
914
|
query: null, request: null, response: appAuthConfig,
|
|
@@ -738,7 +918,8 @@ export const ROUTES = [
|
|
|
738
918
|
'for every app and every provider, and is the value a developer registers at their identity provider. It stays read-only on every slice ' +
|
|
739
919
|
'write below for a second reason: a writable callback URL would let a caller point the return leg of an OIDC sign-in, which carries an ' +
|
|
740
920
|
'authorization code, at a host they own. `updated_at` is read-only for a duller one: the server stamps it on every write, and a ' +
|
|
741
|
-
'client-supplied value would be a lie about when the row last changed.'
|
|
921
|
+
'client-supplied value would be a lie about when the row last changed. `hosted_pages` and `hosted_logo_url` are read-only as well: ' +
|
|
922
|
+
'the cloud mints both from the auth portal\'s base URL and the app\'s identifier.',
|
|
742
923
|
},
|
|
743
924
|
{
|
|
744
925
|
method: 'PUT', path: '/api/apps/:id/auth-config/registration', section: 'apps',
|
|
@@ -753,37 +934,83 @@ export const ROUTES = [
|
|
|
753
934
|
'\n\n`400 validation_error` is where the two field rules land: an entry in `allowed_domains` must be lowercase, since a capitalised one ' +
|
|
754
935
|
'can never match a lowercased address, and an entry in `allowed_origins` must be a bare scheme-host-port with no path, since a browser ' +
|
|
755
936
|
'sends nothing longer in its `Origin` header. Each refuses at configuration time rather than failing silently later. ' +
|
|
756
|
-
'\n\nThe merge is server-side against the stored row, so this write never disturbs
|
|
937
|
+
'\n\nThe merge is server-side against the stored row, so this write never disturbs another slice.',
|
|
938
|
+
},
|
|
939
|
+
{
|
|
940
|
+
method: 'PUT', path: '/api/apps/:id/auth-config/sign-in', section: 'apps',
|
|
941
|
+
summary: 'Replaces how the app\'s users sign in and whether they give a second factor.',
|
|
942
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
943
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
944
|
+
query: null, request: putAppAuthSignInRequest, response: appAuthConfig,
|
|
945
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found'], transport: 'http',
|
|
946
|
+
notes: '**A replace, not a merge, and `.strict()`**: `sign_in_methods` and `two_factor` both arrive or the write is refused. Both methods off ' +
|
|
947
|
+
'is `400 validation_error` naming `sign_in_methods.password` — an app needs at least one door besides its identity providers. ' +
|
|
948
|
+
'\n\nTurning a method off refuses its routes with `method_not_allowed` from the next request on; a stored password stays stored. ' +
|
|
949
|
+
'Setting `two_factor` to `required` signs nobody out: each person without an authenticator sets one up at their next sign-in, before ' +
|
|
950
|
+
'any session exists. Audited with both old and new values. The merge is server-side against the stored row, so this write never ' +
|
|
951
|
+
'disturbs another slice.',
|
|
757
952
|
},
|
|
758
953
|
{
|
|
759
954
|
method: 'PUT', path: '/api/apps/:id/auth-config/urls', section: 'apps',
|
|
760
|
-
summary: "Replaces the
|
|
955
|
+
summary: "Replaces the app's home page and the four pages Fleetless's mails and MCP sign-in point at.",
|
|
761
956
|
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
762
957
|
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
763
958
|
query: null, request: putAppAuthUrlsRequest, response: appAuthConfig,
|
|
764
959
|
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found'], transport: 'http',
|
|
765
|
-
notes: '**A replace, not a merge, and `.strict()`**: `invite_url`, `verify_url` and `
|
|
766
|
-
'client built against an older shape cannot silently clear a setting it does not know about.
|
|
767
|
-
'the server\'s, refused in this body as in every slice\'s — see `GET`\'s notes for why. ' +
|
|
768
|
-
'\n\
|
|
960
|
+
notes: '**A replace, not a merge, and `.strict()`**: `app_url`, `invite_url`, `verify_url`, `reset_url` and `mcp_login_url` all arrive or ' +
|
|
961
|
+
'the write is refused, so a client built against an older shape cannot silently clear a setting it does not know about. ' +
|
|
962
|
+
'`oidc_callback_url` and `updated_at` are the server\'s, refused in this body as in every slice\'s — see `GET`\'s notes for why. ' +
|
|
963
|
+
'\n\nEach may be `null`, and then the Fleetless-hosted page in `hosted_pages` stands in for it: nothing is refused for a missing URL. ' +
|
|
964
|
+
'`400 validation_error` is where the field rules land: a URL template must be https (or `http` on `localhost`) and carry its ' +
|
|
769
965
|
'placeholder exactly once — a second occurrence leaves one literal in a mailed link, refused here rather than failing silently once ' +
|
|
770
|
-
'the mail is sent. ' +
|
|
771
|
-
'
|
|
966
|
+
'the mail is sent — and `app_url` takes the same host rule with no placeholder. `mcp_login_url` moved here from the `mcp` slice, ' +
|
|
967
|
+
'because one screen owns all four pages. ' +
|
|
968
|
+
'\n\nThe merge is server-side against the stored row, so this write never disturbs another slice.',
|
|
772
969
|
},
|
|
773
970
|
{
|
|
774
971
|
method: 'PUT', path: '/api/apps/:id/auth-config/mcp', section: 'apps',
|
|
775
|
-
summary: '
|
|
972
|
+
summary: 'Turns the app\'s MCP endpoint on or off.',
|
|
776
973
|
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
777
974
|
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
778
975
|
query: null, request: putAppAuthMcpRequest, response: appAuthConfig,
|
|
779
976
|
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found'], transport: 'http',
|
|
780
|
-
notes: '**A replace, not a merge, and `.strict()`**: `mcp_enabled`
|
|
781
|
-
'
|
|
782
|
-
'
|
|
783
|
-
'
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
977
|
+
notes: '**A replace, not a merge, and `.strict()`**: `mcp_enabled` arrives or the write is refused. It used to take `mcp_login_url` as ' +
|
|
978
|
+
'well, because on without a URL refused every sign-in; the hosted MCP sign-in now stands in for an unset URL, and the URL moved to ' +
|
|
979
|
+
'the `urls` slice. A body still carrying it is `400 validation_error`. `oidc_callback_url` and `updated_at` are the server\'s, ' +
|
|
980
|
+
'refused in this body as in every slice\'s. The merge is server-side against the stored row, so this write never disturbs another slice.',
|
|
981
|
+
},
|
|
982
|
+
{
|
|
983
|
+
method: 'PUT', path: '/api/apps/:id/auth-config/look', section: 'apps',
|
|
984
|
+
summary: "Replaces the hosted pages' accent colour.",
|
|
985
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
986
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
987
|
+
query: null, request: putAppAuthLookRequest, response: appAuthConfig,
|
|
988
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found'], transport: 'http',
|
|
989
|
+
notes: '**A replace, and `.strict()`**: `hosted_accent` arrives, `#rrggbb` in lowercase, or `null` for the neutral shell\'s own accent. ' +
|
|
990
|
+
'The logo is its own write, `PUT /api/apps/:id/auth-config/logo`, because it is an image rather than a field. The merge is ' +
|
|
991
|
+
'server-side against the stored row, so this write never disturbs another slice.',
|
|
992
|
+
},
|
|
993
|
+
{
|
|
994
|
+
method: 'PUT', path: '/api/apps/:id/auth-config/logo', section: 'apps',
|
|
995
|
+
summary: "Stores the logo the hosted pages show above the app's name.",
|
|
996
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
997
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
998
|
+
query: null, request: null, response: appAuthConfig,
|
|
999
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found', 'unsupported_media_type'], transport: 'http',
|
|
1000
|
+
notes: 'The body is the **raw image**, not JSON, so it has no request schema: `Content-Type` is one of `HOSTED_LOGO_TYPES` (`image/png`, ' +
|
|
1001
|
+
'`image/svg+xml`) and anything else is `415 unsupported_media_type`. At most `HOSTED_LOGO_MAX_BYTES` (100 KB); a larger body, or one ' +
|
|
1002
|
+
'that is not the image its type names, is `400 validation_error`. A new logo replaces the stored one. The hosted pages load it from ' +
|
|
1003
|
+
'`hosted_logo_url` as an image only, and the cloud serves an SVG sandboxed, so a script inside one never runs.',
|
|
1004
|
+
},
|
|
1005
|
+
{
|
|
1006
|
+
method: 'DELETE', path: '/api/apps/:id/auth-config/logo', section: 'apps',
|
|
1007
|
+
summary: 'Removes the logo from the hosted pages.',
|
|
1008
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
1009
|
+
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
1010
|
+
query: null, request: null, response: appAuthConfig,
|
|
1011
|
+
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
1012
|
+
notes: 'Answers the whole configuration, with `hosted_logo_url` now `null`; the hosted pages show the app\'s name alone. An app with no ' +
|
|
1013
|
+
'logo answers the same: that is the end state being asked for.',
|
|
787
1014
|
},
|
|
788
1015
|
{
|
|
789
1016
|
method: 'GET', path: '/api/apps/:id/mail-templates', section: 'apps',
|
|
@@ -792,16 +1019,16 @@ export const ROUTES = [
|
|
|
792
1019
|
params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
|
|
793
1020
|
query: null, request: null, response: appMailTemplateListResponse,
|
|
794
1021
|
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
|
|
795
|
-
notes: 'Answers `{ "templates": [appMailTemplate, …] }` with **only the kinds that have a custom template** — at most
|
|
1022
|
+
notes: 'Answers `{ "templates": [appMailTemplate, …] }` with **only the kinds that have a custom template** — at most four. A kind that does ' +
|
|
796
1023
|
'not appear is one using the Fleetless default text, which is an ordinary state and not a missing row. Mails to *Fleetless* users, a ' +
|
|
797
|
-
'team invitation or a console
|
|
1024
|
+
'team invitation or a console sign-in code, are not in this list and are deliberately not customisable: they are about this platform, ' +
|
|
798
1025
|
'not about the developer\'s product.',
|
|
799
1026
|
},
|
|
800
1027
|
{
|
|
801
1028
|
method: 'GET', path: '/api/apps/:id/mail-templates/:kind', section: 'apps',
|
|
802
1029
|
summary: 'Reads one custom mail template of the app.',
|
|
803
1030
|
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
804
|
-
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
|
|
1031
|
+
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 four mails this template replaces — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`.' }],
|
|
805
1032
|
query: null, request: null, response: appMailTemplate,
|
|
806
1033
|
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found'], transport: 'http',
|
|
807
1034
|
notes: 'The `kind` segment is a `mailTemplateKind`, so a fourth word is `400 validation_error` — the path names a set that is closed, and ' +
|
|
@@ -813,7 +1040,7 @@ export const ROUTES = [
|
|
|
813
1040
|
method: 'PUT', path: '/api/apps/:id/mail-templates/:kind', section: 'apps',
|
|
814
1041
|
summary: 'Stores or replaces the app\'s template for one kind of mail, refusing one that does not render.',
|
|
815
1042
|
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
816
|
-
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
|
|
1043
|
+
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 four mails this template replaces — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`.' }],
|
|
817
1044
|
query: null, request: putAppMailTemplateRequest, response: appMailTemplate,
|
|
818
1045
|
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found', 'template_invalid', 'rate_limited'], transport: 'http',
|
|
819
1046
|
notes: 'The body carries `subject`, `text` and an optional `html`, each a Liquid template; `kind` is in the path and `updated_at` is the ' +
|
|
@@ -831,7 +1058,7 @@ export const ROUTES = [
|
|
|
831
1058
|
method: 'DELETE', path: '/api/apps/:id/mail-templates/:kind', section: 'apps',
|
|
832
1059
|
summary: 'Drops the app\'s custom template for one kind, returning that mail to the Fleetless default.',
|
|
833
1060
|
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 204,
|
|
834
|
-
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
|
|
1061
|
+
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 four mails this template replaces — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`.' }],
|
|
835
1062
|
query: null, request: null, response: null,
|
|
836
1063
|
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found'], transport: 'http',
|
|
837
1064
|
notes: 'The mail keeps being sent — this removes the developer\'s wording, not the message. A kind that already has no custom template answers ' +
|
|
@@ -842,7 +1069,7 @@ export const ROUTES = [
|
|
|
842
1069
|
method: 'POST', path: '/api/apps/:id/mail-templates/:kind/preview', section: 'apps',
|
|
843
1070
|
summary: 'Renders a template with sample data and answers the three parts, storing nothing.',
|
|
844
1071
|
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
|
|
845
|
-
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
|
|
1072
|
+
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 four mails this template replaces — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`.' }],
|
|
846
1073
|
query: null, request: mailTemplatePreviewRequest, response: mailTemplatePreviewResponse,
|
|
847
1074
|
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found', 'template_invalid', 'rate_limited'], transport: 'http',
|
|
848
1075
|
notes: 'Takes the same document the PUT does and writes nothing, so a developer can see the rendered subject, text and HTML before anybody ' +
|
|
@@ -859,7 +1086,7 @@ export const ROUTES = [
|
|
|
859
1086
|
method: 'POST', path: '/api/apps/:id/mail-templates/:kind/test', section: 'apps',
|
|
860
1087
|
summary: 'Sends the rendered template as a real mail to the calling developer.',
|
|
861
1088
|
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 202,
|
|
862
|
-
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
|
|
1089
|
+
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 four mails this template replaces — a `mailTemplateKind`: `invite`, `verify`, `reset` or `login_code`.' }],
|
|
863
1090
|
query: null, request: mailTemplatePreviewRequest, response: mailOutcome,
|
|
864
1091
|
errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found', 'template_invalid', 'rate_limited', 'target_state_conflict'],
|
|
865
1092
|
transport: 'http',
|
|
@@ -936,13 +1163,15 @@ export const ROUTES = [
|
|
|
936
1163
|
},
|
|
937
1164
|
{
|
|
938
1165
|
method: 'POST', path: '/api/org/invitations/accept', section: 'users',
|
|
939
|
-
summary: 'Spends an invitation token and creates the
|
|
1166
|
+
summary: 'Spends an invitation token and creates the account it was addressed to.',
|
|
940
1167
|
audience: 'developer', auth: 'none', rateLimited: true, ownerTier: false, status: 204,
|
|
941
1168
|
params: [], query: null, request: acceptTeamInviteRequest, response: null,
|
|
942
1169
|
errors: ['rate_limited', 'validation_error', 'token_spent', 'email_taken'], transport: 'http',
|
|
943
1170
|
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 ' +
|
|
944
|
-
'for one account — and every security property would then have to be right in two places.
|
|
945
|
-
'
|
|
1171
|
+
'for one account — and every security property would then have to be right in two places. **No password**: the mailed link proves the ' +
|
|
1172
|
+
'address, so accepting needs no code either, and the new member signs in by emailed code from then on. When the organisation requires ' +
|
|
1173
|
+
'two-factor, the member sets one up at their first sign-in. Unknown, expired and already-accepted tokens collapse into `410 token_spent`. ' +
|
|
1174
|
+
'A browser form post gets the rendered "you\'re in" page instead.',
|
|
946
1175
|
},
|
|
947
1176
|
{
|
|
948
1177
|
method: 'GET', path: '/accept-invite/:token', section: 'users',
|
|
@@ -950,9 +1179,10 @@ export const ROUTES = [
|
|
|
950
1179
|
audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
951
1180
|
params: [{ name: 'token', description: 'The opaque invitation token from the mailed link; it is never sent as a query parameter.' }],
|
|
952
1181
|
query: null, request: null, response: null, errors: [], transport: 'http',
|
|
953
|
-
notes: 'HTML, served by the cloud from the auth portal origin; the form on it posts to `POST /api/org/invitations/accept`.
|
|
954
|
-
'
|
|
955
|
-
'
|
|
1182
|
+
notes: 'HTML, served by the cloud from the auth portal origin; the form on it posts to `POST /api/org/invitations/accept`. **The GET spends ' +
|
|
1183
|
+
'nothing** — a mail scanner opening the link must not accept the invitation — only the form\'s POST does. An unknown, spent or ' +
|
|
1184
|
+
'expired token renders the "link no longer valid" page at `410`, which says to ask the organisation for a new invitation: the person ' +
|
|
1185
|
+
'holding a dead link cannot re-issue it.',
|
|
956
1186
|
},
|
|
957
1187
|
{
|
|
958
1188
|
method: 'PATCH', path: '/api/org/users/:id', section: 'users',
|
|
@@ -992,15 +1222,30 @@ export const ROUTES = [
|
|
|
992
1222
|
'transaction rather than by a read beforehand. Setting the tier already held changes nothing and writes no audit event. No session is ' +
|
|
993
1223
|
'revoked: a tier is re-read from the row on every request, so no issued token carries a stale copy of it.',
|
|
994
1224
|
},
|
|
1225
|
+
{
|
|
1226
|
+
method: 'DELETE', path: '/api/org/users/:id/two-factor', section: 'users',
|
|
1227
|
+
summary: "Removes a team member's passkeys, authenticator and recovery codes and ends their sessions.",
|
|
1228
|
+
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: true, status: 204,
|
|
1229
|
+
params: [{ name: 'id', description: 'The Fleetless user\'s uuid, as listed by `GET /api/org/users`.' }],
|
|
1230
|
+
query: null, request: null, response: null,
|
|
1231
|
+
errors: [...DEVELOPER_GUARD, 'tier_required', 'invalid_uuid', 'not_found', 'target_state_conflict'], transport: 'http',
|
|
1232
|
+
notes: 'Owner tier: the door for a member who lost every second factor and every recovery code. Every session of the member ends, and when ' +
|
|
1233
|
+
'the organisation requires two-factor they set one up again at their next sign-in. **An owner cannot reset their own** — `409 ' +
|
|
1234
|
+
'target_state_conflict` naming `user_id` with rule `self`; Settings › Profile is where they change it. A member with no second factor ' +
|
|
1235
|
+
'answers `204` too. Audited as `developer.two_factor_reset`, naming the owner who did it.',
|
|
1236
|
+
},
|
|
995
1237
|
{
|
|
996
1238
|
method: 'PATCH', path: '/api/org', section: 'org',
|
|
997
|
-
summary: 'Renames the org.',
|
|
1239
|
+
summary: 'Renames the org, requires two-factor for its members, or both.',
|
|
998
1240
|
audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: true, status: 200,
|
|
999
1241
|
params: [], query: null, request: patchOrgRequest, response: patchOrgResponse,
|
|
1000
1242
|
errors: [...DEVELOPER_GUARD, 'tier_required', 'validation_error'], transport: 'http',
|
|
1001
1243
|
notes: 'Answers `{ "org": org }`. Owner tier, and the gate runs ' +
|
|
1002
|
-
'before the body is looked at, so a malformed
|
|
1003
|
-
'
|
|
1244
|
+
'before the body is looked at, so a malformed patch and a forbidden one answer the same way — `403 tier_required` for a developer, ' +
|
|
1245
|
+
'whichever field they sent. An empty body is `400 validation_error`. Writing the values already held writes ' +
|
|
1246
|
+
'nothing and records no audit event. \n\n`require_two_factor` on signs nobody out: each member without a passkey or authenticator ' +
|
|
1247
|
+
'sets one up at their next sign-in, before any session exists, on the console and the central MCP endpoint alike. Server keys and ' +
|
|
1248
|
+
'robot bridges are not people and are not affected. Audited as `org.two_factor_required_changed`.',
|
|
1004
1249
|
},
|
|
1005
1250
|
/* --------------------------------------------------------------- mcp */
|
|
1006
1251
|
{
|
|
@@ -1050,8 +1295,8 @@ export const ROUTES = [
|
|
|
1050
1295
|
'applies; those refusals are `oauthError`. Exact `redirect_uri` matching for both client kinds — the loopback-port wildcard of RFC 8252 ' +
|
|
1051
1296
|
'§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 ' +
|
|
1052
1297
|
'registered itself seconds ago can name the port it bound, and widening the wildcard there would only widen where a stolen `client_id` ' +
|
|
1053
|
-
'may send a browser. Nothing about the person is decided here — the next card asks for an email address and the
|
|
1054
|
-
'it
|
|
1298
|
+
'may send a browser. Nothing about the person is decided here — the next card asks for an email address, or a passkey, and the steps ' +
|
|
1299
|
+
'after it resolve the account; this route knows only the client.',
|
|
1055
1300
|
},
|
|
1056
1301
|
{
|
|
1057
1302
|
method: 'GET', path: '/mcp/oauth/interaction/:id', section: 'mcp',
|
|
@@ -1061,35 +1306,15 @@ export const ROUTES = [
|
|
|
1061
1306
|
query: null, request: null, response: null, errors: [], transport: 'http',
|
|
1062
1307
|
notes: 'HTML, and a GET rather than the body of the authorize response — so it is reloadable, bookmarkable and survives a back button, which ' +
|
|
1063
1308
|
'the inline page it replaced was not. An expired, consumed, unknown or hand-edited interaction renders one page at `410`, and so does a ' +
|
|
1064
|
-
'client whose dynamic registration lapsed in between.'
|
|
1065
|
-
|
|
1066
|
-
{
|
|
1067
|
-
method: 'POST', path: '/mcp/oauth/identify', section: 'mcp',
|
|
1068
|
-
summary: 'Takes the email address and hands back the password step.',
|
|
1069
|
-
audience: 'internal', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1070
|
-
params: [], query: null, request: null, response: null,
|
|
1071
|
-
errors: ['rate_limited', 'validation_error', 'token_spent'], transport: 'http',
|
|
1072
|
-
notes: 'The identifier-first step, with nothing left to identify: Fleetless users are password-only, so **this step does not ' +
|
|
1073
|
-
'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 ' +
|
|
1074
|
-
'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 ' +
|
|
1075
|
-
'lookup here whose result could differ. A browser form post gets the password card; a JSON caller gets `{ "next" }`, which has no ' +
|
|
1076
|
-
'schema. Still rate limited per (route, ip, email), because it is an unauthenticated endpoint that renders a page.',
|
|
1077
|
-
},
|
|
1078
|
-
{
|
|
1079
|
-
method: 'POST', path: '/mcp/oauth/login', section: 'mcp',
|
|
1080
|
-
summary: 'Checks the password and hands back where the MCP sign-in continues.',
|
|
1081
|
-
audience: 'internal', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1082
|
-
params: [], query: null, request: null, response: oauthRedirectResponse,
|
|
1083
|
-
errors: ['rate_limited', 'validation_error', 'token_spent', 'invalid_credentials'], transport: 'http',
|
|
1084
|
-
notes: 'The body is `{ "interaction_id", "email", "password" }`, read field by field rather than through a contract shape. A browser gets a ' +
|
|
1085
|
-
'`303` — to the consent screen for a self-registered client, or straight to the callback for the central one — where a JSON caller gets ' +
|
|
1086
|
-
'this `200` and `redirect_to`.',
|
|
1309
|
+
'client whose dynamic registration lapsed in between. It offers the email field and `Sign in with a passkey`, and opening it binds ' +
|
|
1310
|
+
'the interaction to this browser with the browser-proof cookie, which every sign-in step after it checks.',
|
|
1087
1311
|
},
|
|
1312
|
+
...developerSignInRoutes('/mcp/oauth'),
|
|
1088
1313
|
{
|
|
1089
1314
|
method: 'GET', path: '/mcp/oauth/consent/:id', section: 'mcp',
|
|
1090
1315
|
summary: 'Serves the consent screen for an MCP client that registered itself.',
|
|
1091
1316
|
audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
1092
|
-
params: [{ name: 'id', description: 'The interaction id from the sign-in; the
|
|
1317
|
+
params: [{ name: 'id', description: 'The interaction id from the sign-in; the last sign-in step redirects the browser here.' }],
|
|
1093
1318
|
query: null, request: null, response: null, errors: [], transport: 'http',
|
|
1094
1319
|
notes: 'HTML. The browser-proof cookie is checked on this GET, not only on the POST. The **central** client never reaches this screen and ' +
|
|
1095
1320
|
'renders the `410` page instead: it is configured by the operator, so there is no self-registered stranger for a person to weigh up.',
|
|
@@ -1133,51 +1358,42 @@ export const ROUTES = [
|
|
|
1133
1358
|
audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
1134
1359
|
params: [{ name: 'id', description: 'The interaction id minted by `GET /console/oauth/authorize`, which redirects the browser here.' }],
|
|
1135
1360
|
query: null, request: null, response: null, errors: [], transport: 'http',
|
|
1136
|
-
notes: 'HTML
|
|
1137
|
-
'
|
|
1138
|
-
'
|
|
1139
|
-
|
|
1140
|
-
{
|
|
1141
|
-
method: 'POST', path: '/console/oauth/identify', section: 'developer-auth',
|
|
1142
|
-
summary: 'Takes the email address and hands back the password step.',
|
|
1143
|
-
audience: 'internal', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1144
|
-
params: [], query: null, request: null, response: null,
|
|
1145
|
-
errors: ['rate_limited', 'token_spent'], transport: 'http',
|
|
1146
|
-
notes: 'A browser form post gets the password card as HTML; a JSON caller gets `{ "next": "/console/oauth/login" }`, which has no schema — the ' +
|
|
1147
|
-
'step made no decision, and it says so rather than inventing a redirect. A dead interaction is `410 token_spent`. Rate limited despite ' +
|
|
1148
|
-
'spending no credential: it is an unauthenticated endpoint that renders a page.',
|
|
1149
|
-
},
|
|
1150
|
-
{
|
|
1151
|
-
method: 'POST', path: '/console/oauth/login', section: 'developer-auth',
|
|
1152
|
-
summary: 'Checks the password and mints the authorization code the console exchanges.',
|
|
1153
|
-
audience: 'internal', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1154
|
-
params: [], query: null, request: null, response: oauthRedirectResponse,
|
|
1155
|
-
errors: ['rate_limited', 'token_spent', 'invalid_credentials'], transport: 'http',
|
|
1156
|
-
notes: 'This is the only place a Fleetless developer password may be typed; `POST /api/auth/login` is gone, because a second credential door ' +
|
|
1157
|
-
'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 ' +
|
|
1158
|
-
'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 ' +
|
|
1159
|
-
'it — filtering first would hand back a faster "no" for a non-admin account, which is a timing oracle. Wrong password, unknown address ' +
|
|
1160
|
-
'and "not an org admin" render identical bytes under one `401`.',
|
|
1361
|
+
notes: 'HTML: the email field, `Email me a code`, and `Sign in with a passkey`. Opening it binds the interaction to this browser with the ' +
|
|
1362
|
+
'browser-proof cookie, which every sign-in step after it checks. An expired, consumed, unknown or hand-edited interaction ' +
|
|
1363
|
+
'renders one page at `410`: which of the four it was is not a fact a stranger may learn, and to the person it is one fact anyway. The ' +
|
|
1364
|
+
'page resolves nothing about the address typed into it, so there is no enumeration oracle here at all.',
|
|
1161
1365
|
},
|
|
1366
|
+
...developerSignInRoutes('/console/oauth'),
|
|
1162
1367
|
{
|
|
1163
1368
|
method: 'GET', path: '/console/oauth/signup/:id', section: 'developer-auth',
|
|
1164
|
-
summary: 'Serves step one of console sign-up, the
|
|
1369
|
+
summary: 'Serves step one of console sign-up, the email card.',
|
|
1165
1370
|
audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
|
|
1166
1371
|
params: [{ name: 'id', description: 'The interaction id minted by `GET /console/oauth/authorize` with `?prompt=create`.' }],
|
|
1167
1372
|
query: null, request: null, response: null, errors: [], transport: 'http',
|
|
1168
|
-
notes: 'HTML
|
|
1169
|
-
'
|
|
1373
|
+
notes: 'HTML: the email field and `Email me a code`, the first of three steps — email, code, organization. While the deployment runs in ' +
|
|
1374
|
+
'closed beta this renders the "sign-up is closed" card at `403` instead, keeping the interaction alive and pointing back at sign-in and ' +
|
|
1375
|
+
'the waiting list — the person may well already have an account.',
|
|
1170
1376
|
},
|
|
1171
1377
|
{
|
|
1172
1378
|
method: 'POST', path: '/console/oauth/signup', section: 'developer-auth',
|
|
1173
|
-
summary: 'Takes the sign-up email
|
|
1379
|
+
summary: 'Takes the sign-up email, mails a code and hands back the code step.',
|
|
1174
1380
|
audience: 'internal', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1175
1381
|
params: [], query: null, request: null, response: null,
|
|
1176
1382
|
errors: ['rate_limited', 'token_spent', 'signup_closed', 'validation_error', 'email_taken'], transport: 'http',
|
|
1177
|
-
notes: 'A browser form post gets the
|
|
1178
|
-
'
|
|
1179
|
-
'
|
|
1180
|
-
'
|
|
1383
|
+
notes: 'A browser form post gets the code card; a JSON caller gets `{ "next", "email" }`, which has no schema. **No password**: the code ' +
|
|
1384
|
+
'mailed here, six digits valid ten minutes, proves the address. A per-interaction proof cookie is set here — it is what stops a third ' +
|
|
1385
|
+
'party from finishing a sign-up somebody else started. Sign-up is the one surface whose job is to say an address is taken, so `409 ' +
|
|
1386
|
+
'email_taken` is not a leak here.',
|
|
1387
|
+
},
|
|
1388
|
+
{
|
|
1389
|
+
method: 'POST', path: '/console/oauth/signup/code', section: 'developer-auth',
|
|
1390
|
+
summary: 'Checks the sign-up code and hands back the organization step.',
|
|
1391
|
+
audience: 'internal', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1392
|
+
params: [], query: null, request: null, response: null,
|
|
1393
|
+
errors: ['rate_limited', 'token_spent', 'signup_closed', 'wrong_browser', 'validation_error', 'invalid_code'], transport: 'http',
|
|
1394
|
+
notes: 'A wrong code renders the code card again with the attempts left (`400 invalid_code`); a spent, expired or exhausted one is `410 ' +
|
|
1395
|
+
'token_spent`. The step before must have run in **this** browser (`401 wrong_browser`). A browser form post gets the organization card, ' +
|
|
1396
|
+
'the address shown `confirmed`; a JSON caller gets `{ "next" }`, which has no schema.',
|
|
1181
1397
|
},
|
|
1182
1398
|
{
|
|
1183
1399
|
method: 'POST', path: '/console/oauth/signup/organization', section: 'developer-auth',
|
|
@@ -1185,9 +1401,11 @@ export const ROUTES = [
|
|
|
1185
1401
|
audience: 'internal', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1186
1402
|
params: [], query: null, request: null, response: oauthRedirectResponse,
|
|
1187
1403
|
errors: ['rate_limited', 'token_spent', 'signup_closed', 'wrong_browser', 'validation_error', 'email_taken'], transport: 'http',
|
|
1188
|
-
notes: 'The
|
|
1189
|
-
'
|
|
1190
|
-
'
|
|
1404
|
+
notes: 'The form carries `org_name` only. The org and its founding Owner are created in one transaction, and only after the code step ' +
|
|
1405
|
+
'confirmed the address. Both steps before must have run in **this** browser: a missing or mismatched proof cookie is `401 ' +
|
|
1406
|
+
'wrong_browser` and the person is sent back to step one. `409 email_taken` when the address was taken meanwhile. A browser form post ' +
|
|
1407
|
+
'gets a `303` to the console callback; a JSON caller gets `redirect_to` at `200`. This is the only way an organisation is created: ' +
|
|
1408
|
+
'`POST /api/auth/signup` is gone, because without a password it would hand a session to anybody who names an address.',
|
|
1191
1409
|
},
|
|
1192
1410
|
{
|
|
1193
1411
|
method: 'POST', path: '/console/oauth/token', section: 'developer-auth',
|
|
@@ -1199,6 +1417,8 @@ export const ROUTES = [
|
|
|
1199
1417
|
'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 ' +
|
|
1200
1418
|
'code was minted earlier, and a user moved out in between must not get a console session.',
|
|
1201
1419
|
},
|
|
1420
|
+
/* --------------------------------- the Fleetless-hosted app pages */
|
|
1421
|
+
...HOSTED_APP_ROUTES,
|
|
1202
1422
|
/* ---------------------------------------------------- mcp (the endpoint) */
|
|
1203
1423
|
{
|
|
1204
1424
|
method: 'GET', path: '/mcp/welcome', section: 'mcp',
|
|
@@ -1322,8 +1542,8 @@ export const ROUTES = [
|
|
|
1322
1542
|
'itself and never asks a person for a `client_id`. \n\n**`issuer`, `token_endpoint` and the resource identifier are minted from the ' +
|
|
1323
1543
|
'canonical public base, never from the friendly `mcp.fleetless.dev` alias or the request\'s `Host`**, because a client checks a minted ' +
|
|
1324
1544
|
'token\'s `iss` and `aud` against these exact strings. \n\n**Unlike the central document, `authorization_endpoint` does not move to an ' +
|
|
1325
|
-
'auth-portal origin
|
|
1326
|
-
'
|
|
1545
|
+
'auth-portal origin**: the authorization step renders no page itself. It redirects to the app\'s own `mcp_login_url`, or to the ' +
|
|
1546
|
+
'hosted MCP sign-in on the auth portal when the app has configured none.',
|
|
1327
1547
|
},
|
|
1328
1548
|
{
|
|
1329
1549
|
method: 'POST', path: MCP_APP.register, section: 'mcp',
|
|
@@ -1346,29 +1566,27 @@ export const ROUTES = [
|
|
|
1346
1566
|
},
|
|
1347
1567
|
{
|
|
1348
1568
|
method: 'GET', path: MCP_APP.authorize, section: 'mcp',
|
|
1349
|
-
summary: "Starts an MCP sign-in and redirects the browser to the app's own login page.",
|
|
1569
|
+
summary: "Starts an MCP sign-in and redirects the browser to the app's own login page, or to the hosted one.",
|
|
1350
1570
|
audience: 'client', auth: 'none', rateLimited: false, ownerTier: false, status: 302,
|
|
1351
1571
|
params: [APP_IDENTIFIER], query: oauthAuthorizeQuery, request: null, response: null,
|
|
1352
|
-
errors: ['not_found'
|
|
1572
|
+
errors: ['not_found'], transport: 'http',
|
|
1353
1573
|
notes: 'The same query as `GET /mcp/oauth/authorize`, read the same way — parameter by parameter, because the answers differ and one parse ' +
|
|
1354
|
-
'would collapse them. \n\n**
|
|
1574
|
+
'would collapse them. \n\n**This route renders no page.** It writes an interaction — ten minutes, as the OIDC ones live ' +
|
|
1355
1575
|
'— and redirects to `appAuthConfig.mcp_login_url` with `{interaction}` filled in. The app then authenticates the person with its own ' +
|
|
1356
1576
|
'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 ' +
|
|
1357
|
-
'deny.
|
|
1577
|
+
'deny. **An app with no `mcp_login_url` is redirected to the hosted MCP sign-in** (`GET /app/:appIdentifier/mcp/:interaction`), ' +
|
|
1578
|
+
'which runs the same steps on the auth portal; nothing is refused for a missing URL. \n\nClient and `redirect_uri` are validated first and a failure there never redirects — the open-redirect discipline `GET ' +
|
|
1358
1579
|
'/mcp/oauth/authorize` and `GET /api/client/oidc/:slug/start` both keep — and those refusals are RFC 6749\'s flat `oauthError`, which ' +
|
|
1359
1580
|
'is why none of them appear above. `redirect_uri` is matched **exactly** against the registration, with no loopback-port wildcard: ' +
|
|
1360
1581
|
'every client here registered itself minutes ago and can name the port it bound, so a wildcard would only widen where a stolen ' +
|
|
1361
|
-
'`client_id` may send a browser. \n\nThe
|
|
1582
|
+
'`client_id` may send a browser. \n\nThe code above is the `apiError` envelope because it is a refusal about the **app**, ' +
|
|
1362
1583
|
'decided before an OAuth parameter is looked at. **`404 not_found` covers an identifier no app carries AND an app with MCP switched ' +
|
|
1363
1584
|
'off** — the same single answer the two metadata documents, `register` and the transport give. An earlier draft answered `403 ' +
|
|
1364
1585
|
'mcp_disabled` here, on the argument that a client which registered while the switch was on is owed the difference between "turned ' +
|
|
1365
1586
|
'off" and "mistyped"; that argument does not survive the caller being anonymous. This route takes no credential, so the extra code ' +
|
|
1366
1587
|
'was readable by anyone who could type an identifier, and it handed back precisely the existence distinction every neighbouring ' +
|
|
1367
1588
|
'route collapses. `mcp_disabled` survives only where the caller has already proved they belong to the app — the two decision routes ' +
|
|
1368
|
-
'under `/api/client/mcp/interactions/:id`.
|
|
1369
|
-
'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 ' +
|
|
1370
|
-
'is the same code and the same shape `send_mail` answers for an unconfigured `invite_url`, and the refusal is the honest one — ' +
|
|
1371
|
-
'Fleetless has nowhere to redirect, and rendering a page of its own would contradict the rule that Fleetless shows an app user no page.',
|
|
1589
|
+
'under `/api/client/mcp/interactions/:id`.',
|
|
1372
1590
|
},
|
|
1373
1591
|
{
|
|
1374
1592
|
method: 'POST', path: MCP_APP.token, section: 'mcp',
|
|
@@ -1390,11 +1608,41 @@ export const ROUTES = [
|
|
|
1390
1608
|
method: 'POST', path: '/api/client/login', section: 'client-auth',
|
|
1391
1609
|
summary: 'Signs an app user in with an app identifier, an email address and a password.',
|
|
1392
1610
|
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1393
|
-
params: [], query: null, request: clientLoginRequest, response:
|
|
1394
|
-
errors: ['rate_limited', 'validation_error', 'invalid_credentials'], transport: 'http',
|
|
1611
|
+
params: [], query: null, request: clientLoginRequest, response: clientSignInResult,
|
|
1612
|
+
errors: ['rate_limited', 'validation_error', 'invalid_credentials', 'method_not_allowed'], transport: 'http',
|
|
1395
1613
|
notes: 'One refusal for every miss — unknown app, unknown address, wrong password, a `blocked` account and one still `pending_verification` — ' +
|
|
1396
1614
|
'because the caller supplies the `app_identifier` unauthenticated, so "this app knows this user" is not a fact the answer may carry. ' +
|
|
1397
|
-
'The argon2 verify is paid unconditionally, including for an unknown app identifier, so response time is not an oracle either.'
|
|
1615
|
+
'The argon2 verify is paid unconditionally, including for an unknown app identifier, so response time is not an oracle either. ' +
|
|
1616
|
+
'\n\n**The answer is a `clientSignInResult`**: session tokens, or a `twoFactorChallenge` when the person has a confirmed authenticator ' +
|
|
1617
|
+
'or the app requires one — then no session exists until `POST /api/client/two-factor/verify` or the setup is done. `403 ' +
|
|
1618
|
+
'method_not_allowed` when the app has the password method off; it names the app\'s policy, not a person.',
|
|
1619
|
+
},
|
|
1620
|
+
{
|
|
1621
|
+
method: 'POST', path: '/api/client/login/code', section: 'client-auth',
|
|
1622
|
+
summary: 'Mails a six-digit sign-in code, and answers the same whether or not the address exists.',
|
|
1623
|
+
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 202,
|
|
1624
|
+
params: [], query: null, request: clientLoginCodeRequest, response: null,
|
|
1625
|
+
errors: ['rate_limited', 'validation_error', 'not_found', 'method_not_allowed'], transport: 'http',
|
|
1626
|
+
notes: '**`202` and an empty body for every request the policy allows**, in status, body and timing, whether or not the address names an ' +
|
|
1627
|
+
'active account of this app — a decoy like `POST /api/client/resend-verification`, so this is no enumeration oracle. A mail goes out ' +
|
|
1628
|
+
'for an `active` account and for one still `pending_verification` — spending the code proves the address, as the verification link ' +
|
|
1629
|
+
'would — and never for a `blocked` one or an unknown address. The code is six digits, valid ten minutes, takes five wrong attempts, and a new request expires ' +
|
|
1630
|
+
'the previous one for the same address; a request within sixty seconds of the last sends no second mail. The address is trimmed and ' +
|
|
1631
|
+
'compared case-insensitively. `404 not_found` is the **app identifier**, never the address; `403 method_not_allowed` when the app has ' +
|
|
1632
|
+
'the email-code method off. Limited per app, address and IP, so it cannot be used to mail somebody repeatedly.',
|
|
1633
|
+
},
|
|
1634
|
+
{
|
|
1635
|
+
method: 'POST', path: '/api/client/login/code/verify', section: 'client-auth',
|
|
1636
|
+
summary: 'Spends a mailed sign-in code and answers a session or a two-factor challenge.',
|
|
1637
|
+
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1638
|
+
params: [], query: null, request: clientLoginCodeVerifyRequest, response: clientSignInResult,
|
|
1639
|
+
errors: ['rate_limited', 'validation_error', 'invalid_code', 'token_spent', 'method_not_allowed'], transport: 'http',
|
|
1640
|
+
notes: 'A wrong code is `400 invalid_code` with `details.attempts_left` (`invalidCodeDetails`). A code that is spent, past its ten minutes, ' +
|
|
1641
|
+
'out of attempts, or was never mailed is `410 token_spent` — one answer, because telling them apart would say whether a code was ever ' +
|
|
1642
|
+
'sent to that address; the recovery is the same, ask for a new code. The address is trimmed and compared case-insensitively, so the ' +
|
|
1643
|
+
'address typed at the request and here need not match in case. \n\n**The answer is a `clientSignInResult`**, like the password ' +
|
|
1644
|
+
'login: tokens, or a `twoFactorChallenge` when the person has an authenticator or the app requires one. A pending-verification account ' +
|
|
1645
|
+
'that spends a code is activated — reading a mail at that address is the proof verification asks for.',
|
|
1398
1646
|
},
|
|
1399
1647
|
{
|
|
1400
1648
|
method: 'POST', path: '/api/client/register', section: 'client-auth',
|
|
@@ -1415,11 +1663,12 @@ export const ROUTES = [
|
|
|
1415
1663
|
'app has self-registration off and `403 domain_not_allowed` when the address is outside `allowed_domains`: both are the developer\'s own ' +
|
|
1416
1664
|
'configuration, and a stranger learns the app\'s policy rather than who is in it. **A password under twelve characters is part of that ' +
|
|
1417
1665
|
'`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, ' +
|
|
1418
|
-
'which is what a form needs to mark it.
|
|
1666
|
+
'which is what a form needs to mark it. The same `400` names `password` when one is missing while the app\'s password method is on, ' +
|
|
1667
|
+
'or sent while it is off: an email-code-only app registers people without one. `404 not_found` names an ' +
|
|
1419
1668
|
'**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 ' +
|
|
1420
1669
|
'developer\'s own URLs), while collapsing it into `registration_closed` sent a developer who mistyped their own identifier hunting a ' +
|
|
1421
|
-
'configuration bug that was not there. `409 target_state_conflict` when the app has
|
|
1422
|
-
'
|
|
1670
|
+
'configuration bug that was not there. `409 target_state_conflict` when the app has no default role — there would be no role to give ' +
|
|
1671
|
+
'the person. An app with no `verify_url` is not refused: the mailed link points at the hosted confirmation page instead. ' +
|
|
1423
1672
|
'\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 ' +
|
|
1424
1673
|
'here that is answered **before the address is looked at** — and that ordering is the point rather than an implementation detail: a ' +
|
|
1425
1674
|
'quota checked after the existence branch would answer `202` for an address the app already knows and `409` for one it does not, which ' +
|
|
@@ -1430,9 +1679,10 @@ export const ROUTES = [
|
|
|
1430
1679
|
method: 'POST', path: '/api/client/verify-email', section: 'client-auth',
|
|
1431
1680
|
summary: 'Spends a verification token, activates the account and answers a session.',
|
|
1432
1681
|
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1433
|
-
params: [], query: null, request: clientVerifyEmailRequest, response:
|
|
1682
|
+
params: [], query: null, request: clientVerifyEmailRequest, response: clientSignInResult,
|
|
1434
1683
|
errors: ['rate_limited', 'validation_error', 'token_spent'], transport: 'http',
|
|
1435
|
-
notes: '**The answer is a session, not a `204
|
|
1684
|
+
notes: '**The answer is a session, not a `204`** — or, as on every sign-in step, a `twoFactorChallenge` when the app requires two-factor ' +
|
|
1685
|
+
'(`clientSignInResult`). Somebody who has just proved they can read the mail should not be asked to type their ' +
|
|
1436
1686
|
'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 ' +
|
|
1437
1687
|
'activated second, as **two writes**: the spend is the atomic one, so a link opened twice cannot mint two sessions, but a process that ' +
|
|
1438
1688
|
'died between them would leave a spent token on an account still `pending_verification`, whose recovery is ' +
|
|
@@ -1460,21 +1710,23 @@ export const ROUTES = [
|
|
|
1460
1710
|
summary: 'Mails an app user a reset link, and answers the same either way.',
|
|
1461
1711
|
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 202,
|
|
1462
1712
|
params: [], query: null, request: clientPasswordResetRequest, response: null,
|
|
1463
|
-
errors: ['rate_limited', 'validation_error', 'not_found'], transport: 'http',
|
|
1464
|
-
notes: '
|
|
1465
|
-
'
|
|
1466
|
-
'
|
|
1713
|
+
errors: ['rate_limited', 'validation_error', 'not_found', 'method_not_allowed'], transport: 'http',
|
|
1714
|
+
notes: 'The pair of app identifier and address is the identifier: an app user\'s address is unique only within their app. ' +
|
|
1715
|
+
'`403 method_not_allowed` when the app has the password method off — a reset link whose confirmation would be refused is not mailed; ' +
|
|
1716
|
+
'the code names the app\'s policy, not a person. ' +
|
|
1717
|
+
'Status, body and timing are identical for a known and an unknown address. An account with no password — one created through an ' +
|
|
1467
1718
|
'identity provider — is mailed nothing and still answers `202`. `404 not_found` is the **app identifier**, never the address. The link ' +
|
|
1468
|
-
'points at the app\'s `reset_url
|
|
1469
|
-
'so would answer for the address as well.',
|
|
1719
|
+
'points at the app\'s `reset_url`, or at the hosted reset page when the app has configured none.',
|
|
1470
1720
|
},
|
|
1471
1721
|
{
|
|
1472
1722
|
method: 'POST', path: '/api/client/password/reset/confirm', section: 'client-auth',
|
|
1473
1723
|
summary: 'Spends a reset token, sets the new password and answers a fresh session.',
|
|
1474
1724
|
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1475
|
-
params: [], query: null, request: clientPasswordResetConfirmRequest, response:
|
|
1476
|
-
errors: ['rate_limited', 'validation_error', 'token_spent'], transport: 'http',
|
|
1477
|
-
notes: '**
|
|
1725
|
+
params: [], query: null, request: clientPasswordResetConfirmRequest, response: clientSignInResult,
|
|
1726
|
+
errors: ['rate_limited', 'validation_error', 'token_spent', 'method_not_allowed'], transport: 'http',
|
|
1727
|
+
notes: '**A new password does not bypass the second factor**: a person with an authenticator, or in an app that requires one, gets a ' +
|
|
1728
|
+
'`twoFactorChallenge` instead of tokens (`clientSignInResult`), and the authenticator stays on. `403 method_not_allowed` when the app ' +
|
|
1729
|
+
'has the password method off. \n\n**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 ' +
|
|
1478
1730
|
'states where somebody else may be holding a live session, and the person completing the reset is the one who should keep theirs. The ' +
|
|
1479
1731
|
'account is activated if it was still `pending_verification`: reading a mail at that address is the same proof verification asks for. ' +
|
|
1480
1732
|
'\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 ' +
|
|
@@ -1486,13 +1738,15 @@ export const ROUTES = [
|
|
|
1486
1738
|
method: 'POST', path: '/api/client/invitations/accept', section: 'client-auth',
|
|
1487
1739
|
summary: 'Spends an invitation token, creates or activates the app user and answers a session.',
|
|
1488
1740
|
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1489
|
-
params: [], query: null, request: clientAcceptInvitationRequest, response:
|
|
1741
|
+
params: [], query: null, request: clientAcceptInvitationRequest, response: clientSignInResult,
|
|
1490
1742
|
errors: ['rate_limited', 'validation_error', 'token_spent', 'email_taken', 'target_state_conflict', 'quota_exceeded'], transport: 'http',
|
|
1491
1743
|
notes: '**An app invitation, not a team one.** `POST /api/org/invitations/accept` is the other space and answers `204`; this one answers a ' +
|
|
1492
1744
|
'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 ' +
|
|
1493
1745
|
'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, ' +
|
|
1494
1746
|
'and the invitation **bypasses `allowed_domains`** — a developer inviting somebody by hand has already made the decision the whitelist ' +
|
|
1495
|
-
'automates.
|
|
1747
|
+
'automates. The answer is a `clientSignInResult`: a `twoFactorChallenge` instead of tokens when the app requires two-factor. ' +
|
|
1748
|
+
'`password` is required while the app\'s password method is on and refused while it is off, both as `400 validation_error` naming ' +
|
|
1749
|
+
'the field. \n\n**One refusal for every token that does not work: `410 token_spent`** — unknown, expired past the seven days, revoked by ' +
|
|
1496
1750
|
'the developer, or already accepted. There is one code because telling them apart would say whether a token ever existed, and because ' +
|
|
1497
1751
|
'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 ' +
|
|
1498
1752
|
'under twelve characters is part of the `400 validation_error`, naming the `password` field. `409 email_taken` is an address this app ' +
|
|
@@ -1534,8 +1788,10 @@ export const ROUTES = [
|
|
|
1534
1788
|
summary: "Changes an app user's own password and answers a fresh session.",
|
|
1535
1789
|
audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 200,
|
|
1536
1790
|
params: [], query: null, request: passwordChangeRequest, response: sessionTokens,
|
|
1537
|
-
errors: [...CLIENT_GUARD, 'validation_error', 'invalid_credentials', 'target_state_conflict'], transport: 'http',
|
|
1538
|
-
notes: '
|
|
1791
|
+
errors: [...CLIENT_GUARD, 'validation_error', 'invalid_credentials', 'target_state_conflict', 'method_not_allowed'], transport: 'http',
|
|
1792
|
+
notes: '`403 method_not_allowed` when the app has the password method off: a stored password stays stored but is not in use, so it is not ' +
|
|
1793
|
+
'changed either. ' +
|
|
1794
|
+
'The guard admits all three caller kinds, but a password belongs to an app user specifically — a developer bearer or a server key ' +
|
|
1539
1795
|
'reaching this is `401 unauthorized`. Every other session of the account ends; the answer is the replacement pair, so the tab that made ' +
|
|
1540
1796
|
'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** — ' +
|
|
1541
1797
|
'an OIDC-only app user, which the schema admits — answers `409 target_state_conflict` naming the `password` field with rule `not_set`, ' +
|
|
@@ -1551,6 +1807,54 @@ export const ROUTES = [
|
|
|
1551
1807
|
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 ' +
|
|
1552
1808
|
'shape names each of `developer_id`, `app_user_id` and `server_key_id` and fills exactly one.',
|
|
1553
1809
|
},
|
|
1810
|
+
/* ------------------------------------------------ app-user two-factor */
|
|
1811
|
+
{
|
|
1812
|
+
method: 'POST', path: '/api/client/two-factor/verify', section: 'client-auth',
|
|
1813
|
+
summary: 'Answers a two-factor challenge with an authenticator or recovery code, and answers the session.',
|
|
1814
|
+
audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
|
|
1815
|
+
params: [], query: null, request: clientTwoFactorVerifyRequest, response: sessionTokens,
|
|
1816
|
+
errors: ['rate_limited', 'validation_error', 'invalid_code', 'token_spent'], transport: 'http',
|
|
1817
|
+
notes: 'The challenge is the one a sign-in step answered with `two_factor_required`; it lives five minutes and takes five wrong codes, after ' +
|
|
1818
|
+
'which it is `410 token_spent` and the sign-in starts over. A wrong code is `400 invalid_code` with `details.attempts_left`. **A code ' +
|
|
1819
|
+
'is accepted at most once**: the same authenticator code sent twice, even at the same moment, signs in exactly once. A recovery code is ' +
|
|
1820
|
+
'spent by its use and audited as `app_user.recovery_code_used`. Exactly one of `code` and `recovery_code`, or `400 validation_error`.',
|
|
1821
|
+
},
|
|
1822
|
+
{
|
|
1823
|
+
method: 'POST', path: '/api/client/two-factor/setup', section: 'client-auth',
|
|
1824
|
+
summary: 'Starts an authenticator setup and answers its secret and otpauth URL.',
|
|
1825
|
+
audience: 'client', auth: 'in_handler', rateLimited: true, ownerTier: false, status: 200,
|
|
1826
|
+
params: [], query: null, request: clientTwoFactorSetupRequest, requestOptional: true, response: twoFactorSetupResponse,
|
|
1827
|
+
errors: ['rate_limited', 'validation_error', 'token_spent', 'unauthorized', 'target_state_conflict'], transport: 'http',
|
|
1828
|
+
notes: '**Two ways in, decided in the handler.** During sign-in the body carries the `two_factor_setup_required` challenge, and that is the ' +
|
|
1829
|
+
'credential; from the app\'s own account settings the app user\'s bearer is, with no challenge and an empty or missing body. Neither ' +
|
|
1830
|
+
'is `401 unauthorized`, and a ' +
|
|
1831
|
+
'dead challenge is `410 token_spent`. `409 target_state_conflict` names `two_factor` with rule `off` when the app\'s policy is `off`. ' +
|
|
1832
|
+
'The secret is not in use until `POST /api/client/two-factor/setup/confirm` accepts a code from it; a second call replaces a pending ' +
|
|
1833
|
+
'secret, and an account that already has an authenticator keeps it until the new one is confirmed.',
|
|
1834
|
+
},
|
|
1835
|
+
{
|
|
1836
|
+
method: 'POST', path: '/api/client/two-factor/setup/confirm', section: 'client-auth',
|
|
1837
|
+
summary: 'Confirms the new authenticator with a code and answers the recovery codes and a session.',
|
|
1838
|
+
audience: 'client', auth: 'in_handler', rateLimited: true, ownerTier: false, status: 200,
|
|
1839
|
+
params: [], query: null, request: clientTwoFactorSetupConfirmRequest, response: clientTwoFactorSetupConfirmResponse,
|
|
1840
|
+
errors: ['rate_limited', 'validation_error', 'invalid_code', 'token_spent', 'unauthorized'], transport: 'http',
|
|
1841
|
+
notes: 'The same two ways in as `setup`. A code that does not match the pending secret is `400 invalid_code`; no pending setup, or a dead ' +
|
|
1842
|
+
'challenge, is `410 token_spent`. On success the authenticator is on, ten recovery codes are issued — shown this once, any earlier set ' +
|
|
1843
|
+
'void — and the answer carries a session: the one the sign-in was waiting for, or, from account settings, a fresh one while every other ' +
|
|
1844
|
+
'session of the account ends. Audited as `app_user.two_factor_enabled`.',
|
|
1845
|
+
},
|
|
1846
|
+
{
|
|
1847
|
+
method: 'DELETE', path: '/api/client/two-factor', section: 'client-auth',
|
|
1848
|
+
summary: "Turns the signed-in app user's authenticator off.",
|
|
1849
|
+
audience: 'client', auth: 'developer_or_client', rateLimited: true, ownerTier: false, status: 204,
|
|
1850
|
+
params: [], query: null, request: clientTwoFactorDisableRequest, response: null,
|
|
1851
|
+
errors: [...CLIENT_GUARD, 'rate_limited', 'validation_error', 'invalid_code', 'target_state_conflict'], transport: 'http',
|
|
1852
|
+
notes: 'The app user\'s own door; a developer bearer or a server key is `401 unauthorized`, because the factor is the person\'s. A current ' +
|
|
1853
|
+
'code proves they still hold the authenticator: a stolen session alone cannot remove it. The authenticator and every recovery code go. ' +
|
|
1854
|
+
'`409 target_state_conflict` names `two_factor` with rule `required` while the app requires two-factor, and with rule `off` when there ' +
|
|
1855
|
+
'is none to remove. Audited as `app_user.two_factor_disabled`. The developer\'s support door is `DELETE ' +
|
|
1856
|
+
'/api/apps/:id/users/:userId/two-factor`.',
|
|
1857
|
+
},
|
|
1554
1858
|
/* ---------------------------------------- app-user sign-in through an IdP */
|
|
1555
1859
|
{
|
|
1556
1860
|
method: 'GET', path: '/api/client/providers', section: 'client-auth',
|
|
@@ -1610,10 +1914,10 @@ export const ROUTES = [
|
|
|
1610
1914
|
'\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 ' +
|
|
1611
1915
|
'a `302` to the app\'s own ' +
|
|
1612
1916
|
'`redirect_uri`: `?code=…&state=…` when a session was resolved, `?error=<clientOidcErrorCode>&state=…` when it was not, so the app ' +
|
|
1613
|
-
'renders its own message and can bind either answer to the request it started.
|
|
1917
|
+
'renders its own message and can bind either answer to the request it started. This route renders no page for an outcome. \n\n**The one ' +
|
|
1614
1918
|
'exception is a `state` that resolves to no interaction** — unknown, hand-edited, or past its ten minutes. Then there is no confirmed ' +
|
|
1615
1919
|
'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, ' +
|
|
1616
|
-
'so the cloud renders an HTML problem page at `400`.
|
|
1920
|
+
'so the cloud renders an HTML problem page at `400`. It is HTML ' +
|
|
1617
1921
|
'rather than an `apiError`, which is why no code is listed: a code here would document an envelope no caller receives, and this ' +
|
|
1618
1922
|
'manifest\'s other HTML pages (`GET /mcp/oauth/interaction/:id`, `GET /console/oauth/interaction/:id`) say their status in prose for ' +
|
|
1619
1923
|
'the same reason.',
|
|
@@ -2445,16 +2749,6 @@ export const ROUTES = [
|
|
|
2445
2749
|
'platform will answer is owed a refusal, not a shorter answer they will mistake for the whole picture. `from_day <= to_day` is a ' +
|
|
2446
2750
|
'cross-field rule no JSON Schema can express and is enforced here. The window is echoed back.',
|
|
2447
2751
|
},
|
|
2448
|
-
{
|
|
2449
|
-
method: 'POST', path: '/api/feedback', section: 'org',
|
|
2450
|
-
summary: 'Sends a message from a developer to the people who build Fleetless.',
|
|
2451
|
-
audience: 'developer', auth: 'developer', rateLimited: true, ownerTier: false, status: 202,
|
|
2452
|
-
params: [], query: null, request: feedbackRequest, response: feedbackResponse,
|
|
2453
|
-
errors: [...DEVELOPER_GUARD, 'validation_error', 'rate_limited'], transport: 'http',
|
|
2454
|
-
notes: 'The message is stored before any mail is tried, so `202` means it is kept whatever `mail` says: `sent`, `failed`, or ' +
|
|
2455
|
-
'`not_configured` when this cloud has no feedback address. At most 10 messages per developer per hour; the 11th answers ' +
|
|
2456
|
-
'`429 rate_limited` with `retry_after_ms`. Replies come by mail, to the sender\'s address.',
|
|
2457
|
-
},
|
|
2458
2752
|
/* ------------------------------------------------- assets (robot upload) */
|
|
2459
2753
|
{
|
|
2460
2754
|
method: 'POST', path: '/api/bridge/assets', section: 'assets',
|