@fleetless/contracts 5.3.0-next.1 → 6.0.0-next.1

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.
Files changed (89) hide show
  1. package/CHANGELOG.md +96 -33
  2. package/artifacts/openapi.json +2049 -773
  3. package/artifacts/routes.json +1665 -314
  4. package/artifacts/schema/accept-team-invite-request.schema.json +13 -7
  5. package/artifacts/schema/app-auth-config.schema.json +108 -4
  6. package/artifacts/schema/app-hosted-pages.schema.json +33 -0
  7. package/artifacts/schema/app-invitation.schema.json +2 -2
  8. package/artifacts/schema/app-mail-template-list-response.schema.json +4 -3
  9. package/artifacts/schema/app-mail-template.schema.json +3 -2
  10. package/artifacts/schema/app-sign-in-methods.schema.json +19 -0
  11. package/artifacts/schema/app-user-list-response.schema.json +36 -0
  12. package/artifacts/schema/app-user.schema.json +36 -0
  13. package/artifacts/schema/audit-query.schema.json +0 -6
  14. package/artifacts/schema/auth-me-response.schema.json +27 -0
  15. package/artifacts/schema/auth-ok.schema.json +13 -1
  16. package/artifacts/schema/client-accept-invitation-request.schema.json +3 -4
  17. package/artifacts/schema/client-identity.schema.json +13 -1
  18. package/artifacts/schema/client-login-code-request.schema.json +24 -0
  19. package/artifacts/schema/client-login-code-verify-request.schema.json +30 -0
  20. package/artifacts/schema/client-provider-list-response.schema.json +22 -2
  21. package/artifacts/schema/client-register-request.schema.json +3 -4
  22. package/artifacts/schema/client-sign-in-result.schema.json +55 -0
  23. package/artifacts/schema/client-two-factor-disable-request.schema.json +15 -0
  24. package/artifacts/schema/client-two-factor-setup-confirm-request.schema.json +20 -0
  25. package/artifacts/schema/client-two-factor-setup-confirm-response.schema.json +49 -0
  26. package/artifacts/schema/client-two-factor-setup-request.schema.json +12 -0
  27. package/artifacts/schema/client-two-factor-verify-request.schema.json +25 -0
  28. package/artifacts/schema/create-app-invitation-request.schema.json +1 -1
  29. package/artifacts/schema/create-passkey-request.schema.json +25 -0
  30. package/artifacts/schema/create-passkey-response.schema.json +84 -0
  31. package/artifacts/schema/developer-passkey.schema.json +56 -0
  32. package/artifacts/schema/developer-two-factor.schema.json +105 -0
  33. package/artifacts/schema/fleetless-user-list-response.schema.json +22 -0
  34. package/artifacts/schema/fleetless-user.schema.json +22 -0
  35. package/artifacts/schema/invalid-code-details.schema.json +16 -0
  36. package/artifacts/schema/job-actor.schema.json +1 -15
  37. package/artifacts/schema/job-run-list-response.schema.json +1 -15
  38. package/artifacts/schema/job-run.schema.json +1 -15
  39. package/artifacts/schema/org.schema.json +5 -0
  40. package/artifacts/schema/patch-org-request.schema.json +5 -3
  41. package/artifacts/schema/patch-org-response.schema.json +5 -0
  42. package/artifacts/schema/put-app-auth-look-request.schema.json +22 -0
  43. package/artifacts/schema/put-app-auth-mcp-request.schema.json +1 -14
  44. package/artifacts/schema/put-app-auth-sign-in-request.schema.json +39 -0
  45. package/artifacts/schema/put-app-auth-urls-request.schema.json +31 -4
  46. package/artifacts/schema/recovery-codes-response.schema.json +20 -0
  47. package/artifacts/schema/{role-rename-request.schema.json → rename-passkey-request.schema.json} +2 -2
  48. package/artifacts/schema/role-list-response.schema.json +1 -1
  49. package/artifacts/schema/role.schema.json +1 -1
  50. package/artifacts/schema/totp-confirm-request.schema.json +15 -0
  51. package/artifacts/schema/totp-confirm-response.schema.json +27 -0
  52. package/artifacts/schema/two-factor-challenge.schema.json +24 -0
  53. package/artifacts/schema/two-factor-setup-response.schema.json +21 -0
  54. package/artifacts/schema/webauthn-options-response.schema.json +18 -0
  55. package/dist/app-users.d.ts +154 -34
  56. package/dist/app-users.js +177 -45
  57. package/dist/apps.d.ts +2 -38
  58. package/dist/apps.js +3 -46
  59. package/dist/audit.d.ts +0 -1
  60. package/dist/audit.js +0 -13
  61. package/dist/client-auth.d.ts +133 -24
  62. package/dist/client-auth.js +139 -28
  63. package/dist/config.d.ts +2 -2
  64. package/dist/errors.d.ts +10 -1
  65. package/dist/errors.js +34 -34
  66. package/dist/identity.d.ts +172 -123
  67. package/dist/identity.js +189 -101
  68. package/dist/index.d.ts +11 -13
  69. package/dist/index.js +12 -10
  70. package/dist/jobs.d.ts +0 -3
  71. package/dist/jobs.js +0 -16
  72. package/dist/protocol.d.ts +1 -1
  73. package/dist/realtime.d.ts +1 -0
  74. package/dist/rest.d.ts +2 -2
  75. package/dist/rest.js +17 -28
  76. package/dist/routes.d.ts +19 -0
  77. package/dist/routes.js +499 -223
  78. package/package.json +1 -1
  79. package/artifacts/schema/developer-login-request.schema.json +0 -19
  80. package/artifacts/schema/feedback-request.schema.json +0 -34
  81. package/artifacts/schema/feedback-response.schema.json +0 -27
  82. package/artifacts/schema/password-reset-confirm.schema.json +0 -19
  83. package/artifacts/schema/password-reset-request.schema.json +0 -14
  84. package/artifacts/schema/role-delete-query.schema.json +0 -13
  85. package/artifacts/schema/role-in-use-details.schema.json +0 -28
  86. package/artifacts/schema/sign-up-request.schema.json +0 -26
  87. package/artifacts/schema/sign-up-response.schema.json +0 -127
  88. package/dist/feedback.d.ts +0 -42
  89. 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, roleDeleteQuery, rolePermissions, roleRenameRequest, serverKeyListResponse, updateAppRequest, } from './apps.js';
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 { feedbackRequest, feedbackResponse } from './feedback.js';
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, fleetlessUser, fleetlessUserListResponse, passwordChangeRequest, passwordResetConfirm, passwordResetRequest, patchAuthMeRequest, patchFleetlessUserRequest, patchOrgRequest, patchOrgResponse, pendingTeamInviteListResponse, refreshRequest, sessionTokens, signUpRequest, signUpResponse, teamInvite, tierChangeRequest, waitlistRequest, } from './identity.js';
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,161 @@ 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. Every step after
130
+ * `identify` needs the browser-proof cookie set there, so a step posted from
131
+ * another browser renders the `wrong_browser` page. The interaction's ten
132
+ * minutes cover every step; only when the last one is done is anything
133
+ * minted. **For `/mcp/oauth`, "done" means the consent step**, as the
134
+ * password did before.
135
+ */
136
+ export function developerSignInRoutes(prefix) {
137
+ const section = prefix === '/console/oauth' ? 'developer-auth' : 'mcp';
138
+ const done = prefix === '/console/oauth'
139
+ ? 'the console callback with the authorization code'
140
+ : 'the consent screen for a self-registered client, or straight to the callback for the central one';
141
+ const page = (path, summary, notes) => ({
142
+ method: 'GET', path: `${prefix}${path}`, section, summary,
143
+ audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
144
+ params: [{ name: 'id', description: 'The interaction id of this sign-in; the step before redirects the browser here.' }],
145
+ query: null, request: null, response: null, errors: [], transport: 'http', notes,
146
+ });
147
+ const step = (path, summary, response, errors, notes) => ({
148
+ method: 'POST', path: `${prefix}${path}`, section, summary,
149
+ audience: 'internal', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
150
+ params: [], query: null, request: null, response, errors: ['rate_limited', ...errors], transport: 'http', notes,
151
+ });
152
+ return [
153
+ step('/identify', 'Takes the email address, mails a sign-in code and hands back the code step.', null, ['validation_error', 'token_spent'], 'The page answers `Check your email` **for every address**: a known one gets `Your Fleetless sign-in code`, six digits valid ten ' +
154
+ '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 ' +
155
+ 'is closed). So the page never reveals who has an account, and the address is trimmed and compared case-insensitively. A request ' +
156
+ 'within sixty seconds of the last one for the same address renders the same page without a second mail. The browser-proof cookie is ' +
157
+ `set here. A browser form post gets the code card; a JSON caller gets \`{ "next": "${prefix}/code" }\`, which has no schema. A dead ` +
158
+ 'interaction is `410 token_spent`.'),
159
+ 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 ' +
160
+ '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 ' +
161
+ `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 ` +
162
+ '`redirect_to`. Otherwise the next step: a JSON caller gets `{ "next" }` naming `' + prefix + '/two-factor` — the person has a ' +
163
+ 'passkey or an authenticator — or `' + prefix + '/two-factor/setup` — the organisation requires one and the person has none — and a ' +
164
+ 'browser the page itself. Audited as `developer.login` with `details.method` `email_code` once the sign-in completes.'),
165
+ 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 ' +
166
+ 'passkey prompt directly. The browser-proof cookie is checked on this GET too. A dead interaction renders the `410` page.'),
167
+ 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 ' +
168
+ 'two-factor in Settings › Team.'),
169
+ 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 ' +
170
+ 'finishes one sign-in. A recovery code is spent by its use. Five wrong codes end the interaction (`410 token_spent`). A browser gets ' +
171
+ `a \`303\` to ${done}; a JSON caller that URL as \`redirect_to\`.`),
172
+ 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` ' +
173
+ '(`Sign in with a passkey`); after the code step they name the account\'s own passkeys. User verification is required. The challenge ' +
174
+ 'is bound to the interaction and single-use.'),
175
+ 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 ' +
176
+ 'second step — used as the second step, it finishes it. An assertion that does not verify, or names no passkey of an account, is ' +
177
+ '`401 invalid_credentials`, the same answer for both. When the organisation requires two-factor, a passkey satisfies it. A browser ' +
178
+ `gets a \`303\` to ${done}; a JSON caller that URL as \`redirect_to\`. Audited as \`developer.login\` with \`details.method\` \`passkey\`.`),
179
+ 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 ' +
180
+ 'options, the passkey recommended because it also signs the person in without an emailed code. `Signed in as <email> · Sign out` ' +
181
+ 'under it.'),
182
+ 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 ' +
183
+ 'stored as confirmed yet.'),
184
+ 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; ' +
185
+ '`I saved my recovery codes` then finishes the sign-in. Audited as `developer.two_factor_added` with `details.kind` `authenticator`.'),
186
+ 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 ' +
187
+ 'required, a discoverable credential.'),
188
+ 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 ' +
189
+ 'where it says so, and ten recovery codes are shown once. Audited as `developer.two_factor_added` with `details.kind` `passkey`.'),
190
+ 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 ' +
191
+ `gets a \`303\` to ${done}; a JSON caller that URL as \`redirect_to\`.`),
192
+ ];
193
+ }
194
+ /**
195
+ * **The Fleetless-hosted pages an app falls back to**, under
196
+ * `/app/:appIdentifier` on the auth portal.
197
+ *
198
+ * An app that leaves one of its URLs unset gets these instead: the invitation,
199
+ * email-confirmation and new-password pages its mails link to, a forgot-password
200
+ * and a sign-up page, and the MCP sign-in. They are pages the portal serves to
201
+ * itself — `audience: 'internal'`, HTML only, forms with no request schema —
202
+ * on a neutral shell carrying the app's name, its optional logo and accent,
203
+ * and `Secured by Fleetless`. They call the same sign-in policy as the
204
+ * `/api/client/*` routes, so there is one set of rules, not two; the portal
205
+ * origin is always allowed for them, whatever the app's `allowed_origins`.
206
+ * They are not a hosted login for the app's own web UI: nothing hands a
207
+ * session to the app's origin.
208
+ *
209
+ * **A mailed link never spends its token on `GET`**: the `GET` renders a form,
210
+ * and only its `POST` spends the token — a mail scanner opening the link
211
+ * changes nothing. Every MCP step after the sign-in needs the browser-proof
212
+ * cookie set there. A done page offers `Open <app>` when the app's `app_url`
213
+ * is set, and `You can close this tab` otherwise; a spent or expired link
214
+ * renders one `410` page with the next step for its kind.
215
+ */
216
+ const HOSTED_TOKEN = {
217
+ name: 'token',
218
+ description: 'The opaque token from the mailed link; it is never sent as a query parameter.',
219
+ };
220
+ const HOSTED_INTERACTION = {
221
+ name: 'interaction',
222
+ description: 'The interaction id `GET /mcp/:appIdentifier/oauth/authorize` put into the hosted MCP sign-in URL.',
223
+ };
224
+ function hostedPage(method, path, summary, notes, params = []) {
225
+ return {
226
+ method, path: `/app/:appIdentifier${path}`, section: 'client-auth', summary,
227
+ audience: 'internal', auth: 'none', rateLimited: method === 'POST', ownerTier: false, status: 200,
228
+ params: [APP_IDENTIFIER, ...params], query: null, request: null, response: null,
229
+ errors: method === 'POST' ? ['rate_limited'] : [], transport: 'http', notes,
230
+ };
231
+ }
232
+ const HOSTED_FORM = 'Renders a form; only its `POST` spends the token, so a mail scanner opening the link changes nothing. ';
233
+ const HOSTED_DEAD = 'A spent, expired or unknown token renders the `410` page with the next step for its kind.';
234
+ const HOSTED_POST = 'HTML: the next page on success, the same page with the problem named on a refusal. ';
235
+ const HOSTED_APP_ROUTES = [
236
+ 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**: ' +
237
+ '`X-Content-Type-Options: nosniff` and `Content-Security-Policy: default-src \'none\'; style-src \'unsafe-inline\'; sandbox`, so a ' +
238
+ 'script inside an SVG never runs, even opened directly. The hosted pages embed it as an image only.'),
239
+ 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`, ' +
240
+ '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 ' +
241
+ '`410` page. The browser-proof cookie is set here.', [HOSTED_INTERACTION]),
242
+ 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 ' +
243
+ 'follows; otherwise the consent page.', [HOSTED_INTERACTION]),
244
+ 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 ' +
245
+ 'in 0:42`, and `Use password instead` where the app has passwords on.', [HOSTED_INTERACTION]),
246
+ 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 ' +
247
+ 'give, the two-factor page follows; otherwise the consent page.', [HOSTED_INTERACTION]),
248
+ 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 ' +
249
+ '/api/client/mcp/interactions/:id/approve` and `…/deny` do. Fail-closed: anything but the Allow value denies. The browser-proof cookie ' +
250
+ 'set at the sign-in must match, or the `wrong_browser` page renders.', [HOSTED_INTERACTION]),
251
+ 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 ' +
252
+ '/api/client/two-factor/verify` checks them. Success continues where the sign-in was going: the MCP consent, or the done page.'),
253
+ 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.'),
254
+ 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 ' +
255
+ '.txt`; `I saved my recovery codes` gates `Continue`.'),
256
+ 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 ' +
257
+ 'the app requires it. ' + HOSTED_DEAD, [HOSTED_TOKEN]),
258
+ 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, ' +
259
+ 'then the done page.'),
260
+ 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]),
261
+ 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 ' +
262
+ 'before the done page.'),
263
+ 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.'),
264
+ 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.'),
265
+ 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 ' +
266
+ '"registration closed" page.'),
267
+ 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 ' +
268
+ 'address.'),
269
+ 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]),
270
+ 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 ' +
271
+ 'the done page.'),
272
+ ];
114
273
  export const ROUTES = [
115
274
  /* ------------------------------------------------------------- health */
116
275
  {
@@ -123,16 +282,6 @@ export const ROUTES = [
123
282
  '`/healthz` must never itself be a reason the process looks down. Read `ok`, not the status code.',
124
283
  },
125
284
  /* ----------------------------------------------------- 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
285
  {
137
286
  method: 'POST', path: '/api/auth/refresh', section: 'developer-auth',
138
287
  summary: 'Rotates a developer refresh token and mints a fresh access token.',
@@ -140,7 +289,7 @@ export const ROUTES = [
140
289
  params: [], query: null, request: refreshRequest, response: sessionTokens,
141
290
  errors: ['rate_limited', 'validation_error', 'token_expired', 'token_revoked'], transport: 'http',
142
291
  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 a password change, cannot mint a fresh console token and answers `token_revoked`. Refusing that only on the other routes ' +
292
+ '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
293
  'would leave a session that is dead everywhere but here.',
145
294
  },
146
295
  {
@@ -170,46 +319,94 @@ export const ROUTES = [
170
319
  'held writes nothing and records no audit event — the org activity stream reaches every developer with the console open, and an event ' +
171
320
  'for a no-op would misreport that something changed.',
172
321
  },
322
+ /* ------------------------------------ a developer's own second factors */
173
323
  {
174
- method: 'POST', path: '/api/auth/password/change', section: 'developer-auth',
175
- summary: 'Verifies the current password, sets a new one and answers a fresh session.',
324
+ method: 'GET', path: '/api/auth/two-factor', section: 'developer-auth',
325
+ summary: "Answers the calling developer's passkeys, authenticator, recovery codes left and the org's policy.",
176
326
  audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
177
- params: [], query: null, request: passwordChangeRequest, response: sessionTokens,
178
- errors: [...DEVELOPER_GUARD, 'validation_error', 'invalid_credentials'], transport: 'http',
179
- notes: 'Every session of this account ends, including the caller\'s — the request carries nothing identifying its own refresh family, so there ' +
180
- 'is none to spare. The answer is a working replacement pair, which is what the promise has to mean when nothing distinguishes one ' +
181
- 'session from another.',
327
+ params: [], query: null, request: null, response: developerTwoFactor,
328
+ errors: [...DEVELOPER_GUARD], transport: 'http',
329
+ notes: 'What Settings › Profile › Security draws. No key material, secret or code travels here — the passkeys are names and dates, the ' +
330
+ 'authenticator is a date, the recovery codes are a count.',
182
331
  },
183
332
  {
184
- method: 'POST', path: '/api/auth/password/reset', section: 'developer-auth',
185
- summary: 'Mails a password-reset link to the address, and answers the same either way.',
186
- audience: 'developer', auth: 'none', rateLimited: true, ownerTier: false, status: 202,
187
- params: [], query: null, request: passwordResetRequest, response: null,
188
- errors: ['rate_limited', 'validation_error'], transport: 'http',
189
- notes: 'Status, body and timing are identical for a known and an unknown address — any difference is an account-enumeration oracle, which is ' +
190
- 'why the unknown branch still pays a real SMTP round trip to a discard address. An account provisioned through OIDC has no Fleetless ' +
191
- 'password and is mailed nothing. A browser form post gets a `303` to the "check your mail" card instead of this `202`.',
333
+ method: 'POST', path: '/api/auth/passkeys/options', section: 'developer-auth',
334
+ summary: 'Answers the WebAuthn creation options for registering a passkey.',
335
+ audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
336
+ params: [], query: null, request: null, response: webauthnOptionsResponse,
337
+ errors: [...DEVELOPER_GUARD], transport: 'http',
338
+ 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 ' +
339
+ 'portal and in the console alike; user verification is required and the credential is discoverable, so it can sign the person in ' +
340
+ 'without an address. The passkeys the caller already has are excluded. The challenge is single-use and expires with the ceremony.',
192
341
  },
193
- /* ------------------------------------------- client auth (portal pages) */
194
342
  {
195
- method: 'GET', path: '/reset-password', section: 'client-auth',
196
- summary: 'Serves the auth portal\'s "forgot your password" card as an HTML page.',
197
- audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
198
- params: [], query: null, request: null, response: null, errors: [], transport: 'http',
199
- notes: 'HTML, not JSON: this is a page a person opens, served by the cloud from the auth portal origin. `?sent=1` draws the "check your mail" ' +
200
- 'state instead — one path, because that second card has no inputs and a second path would exist only to be redirected to. The value is ' +
201
- 'caller-settable and discloses nothing, since the page it draws is a constant.',
343
+ method: 'POST', path: '/api/auth/passkeys', section: 'developer-auth',
344
+ summary: 'Registers a passkey from the browser\'s answer to the creation options.',
345
+ audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 201,
346
+ params: [], query: null, request: createPasskeyRequest, response: createPasskeyResponse,
347
+ errors: [...DEVELOPER_GUARD, 'validation_error'], transport: 'http',
348
+ notes: 'A ceremony that does not verify — a wrong challenge, origin or relying party, no user verification — is `400 validation_error` ' +
349
+ 'naming `credential`. When this is the account\'s first second factor, ten recovery codes are issued and answered once; otherwise ' +
350
+ '`recovery_codes` is `null` and the existing ones stay valid. Audited as `developer.two_factor_added` with `details.kind` `passkey`.',
202
351
  },
203
352
  {
204
- method: 'GET', path: '/reset-password/:token', section: 'client-auth',
205
- summary: 'Serves the "pick a new password" page for a mailed reset link.',
206
- audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
207
- params: [{ name: 'token', description: 'The opaque reset token from the mailed link; it is never sent as a query parameter.' }],
208
- query: null, request: null, response: null, errors: [], transport: 'http',
209
- notes: 'HTML. An unknown, spent or expired token renders one "link no longer valid" page at `410` — they are one refusal on the wire already, ' +
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.',
353
+ method: 'PATCH', path: '/api/auth/passkeys/:id', section: 'developer-auth',
354
+ summary: "Renames one of the caller's passkeys.",
355
+ audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
356
+ params: [{ name: 'id', description: 'The passkey\'s uuid, as listed by `GET /api/auth/two-factor`; another person\'s passkey answers `404`.' }],
357
+ query: null, request: renamePasskeyRequest, response: developerPasskey,
358
+ errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found'], transport: 'http',
359
+ },
360
+ {
361
+ method: 'DELETE', path: '/api/auth/passkeys/:id', section: 'developer-auth',
362
+ summary: "Removes one of the caller's passkeys.",
363
+ audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 204,
364
+ params: [{ name: 'id', description: 'The passkey\'s uuid, as listed by `GET /api/auth/two-factor`; another person\'s passkey answers `404`.' }],
365
+ query: null, request: null, response: null,
366
+ errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'target_state_conflict'], transport: 'http',
367
+ notes: '`409 target_state_conflict` names `two_factor` with rule `required_by_org` when this is the caller\'s last second factor and the ' +
368
+ 'organisation requires one. Removing the last one otherwise also voids the recovery codes. Audited as `developer.two_factor_removed` ' +
369
+ 'with `details.kind` `passkey`.',
212
370
  },
371
+ {
372
+ method: 'POST', path: '/api/auth/totp', section: 'developer-auth',
373
+ summary: 'Starts an authenticator setup and answers its secret and otpauth URL.',
374
+ audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
375
+ params: [], query: null, request: null, response: twoFactorSetupResponse,
376
+ errors: [...DEVELOPER_GUARD], transport: 'http',
377
+ notes: 'The secret is pending until `POST /api/auth/totp/confirm` accepts a code from it; a second call replaces a pending secret. A ' +
378
+ 'developer who already has an authenticator keeps it until the new one is confirmed, which is how `Replace…` works.',
379
+ },
380
+ {
381
+ method: 'POST', path: '/api/auth/totp/confirm', section: 'developer-auth',
382
+ summary: 'Confirms the pending authenticator with a code it shows now.',
383
+ audience: 'developer', auth: 'developer', rateLimited: true, ownerTier: false, status: 200,
384
+ params: [], query: null, request: totpConfirmRequest, response: totpConfirmResponse,
385
+ errors: [...DEVELOPER_GUARD, 'rate_limited', 'validation_error', 'invalid_code', 'token_spent'], transport: 'http',
386
+ 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 ' +
387
+ 'authenticator replaces any earlier one. Ten recovery codes are answered when it is the account\'s first second factor, otherwise ' +
388
+ '`null`. Audited as `developer.two_factor_added` with `details.kind` `authenticator`.',
389
+ },
390
+ {
391
+ method: 'DELETE', path: '/api/auth/totp', section: 'developer-auth',
392
+ summary: "Removes the caller's authenticator app.",
393
+ audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 204,
394
+ params: [], query: null, request: null, response: null,
395
+ errors: [...DEVELOPER_GUARD, 'not_found', 'target_state_conflict'], transport: 'http',
396
+ notes: '`404 not_found` when there is no authenticator. `409 target_state_conflict` names `two_factor` with rule `required_by_org` when it ' +
397
+ 'is the caller\'s last second factor and the organisation requires one. Audited as `developer.two_factor_removed` with ' +
398
+ '`details.kind` `authenticator`.',
399
+ },
400
+ {
401
+ method: 'POST', path: '/api/auth/recovery-codes', section: 'developer-auth',
402
+ summary: 'Issues ten new recovery codes and voids the old ones.',
403
+ audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
404
+ params: [], query: null, request: null, response: recoveryCodesResponse,
405
+ errors: [...DEVELOPER_GUARD, 'target_state_conflict'], transport: 'http',
406
+ notes: 'The codes are shown this once. `409 target_state_conflict` names `two_factor` with rule `off` when the caller has no second factor: ' +
407
+ 'recovery codes only stand in for one. Audited as `developer.recovery_codes_generated`.',
408
+ },
409
+ /* ------------------------------------------- client auth (portal pages) */
213
410
  {
214
411
  method: 'GET', path: '/favicon.svg', section: 'client-auth',
215
412
  summary: 'Serves the Fleetless icon for the auth portal\'s and the MCP welcome page\'s browser tab.',
@@ -218,16 +415,6 @@ export const ROUTES = [
218
415
  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
416
  'origin — the one source `img-src \'self\'` names. Cached for a day: the bytes change when the brand does, not per deploy.',
220
417
  },
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
418
  {
232
419
  method: 'POST', path: '/api/waitlist', section: 'developer-auth',
233
420
  summary: 'Adds an address to the closed-beta waiting list.',
@@ -329,7 +516,7 @@ export const ROUTES = [
329
516
  params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
330
517
  query: null, request: null, response: role,
331
518
  errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found', 'validation_error'], transport: 'http',
332
- notes: 'The body is `{ "name": string }` — non-empty, trimmed, at most 60 characters as on `role.name` — and is deliberately not a contract shape: contracts ' +
519
+ notes: 'The body is `{ "name": string }` — non-empty, trimmed, at most 120 characters — and is deliberately not a contract shape: contracts ' +
333
520
  'define the `role` this answers with, not this one trivial request. **The answer is a bare `role`, not an envelope**, unlike the ' +
334
521
  'listing beside it.',
335
522
  },
@@ -382,33 +569,6 @@ export const ROUTES = [
382
569
  'offered and consults nothing about any user\'s actual MCP entitlement. A robot the role grants nothing on still appears, with an empty ' +
383
570
  '`exposures` — dropping it would read as "not attached", which is a different fact.',
384
571
  },
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
572
  {
413
573
  method: 'POST', path: '/api/apps/:id/server-keys', section: 'apps',
414
574
  summary: 'Mints a server key for the app and returns the raw secret once.',
@@ -540,13 +700,25 @@ export const ROUTES = [
540
700
  notes: 'The support door beside `POST /api/client/password/reset`: the same one-hour token and the same link, triggered by a developer for a ' +
541
701
  'user who asked them rather than the form. **No enumeration discipline applies** — the caller is authenticated into the app and can read ' +
542
702
  '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. `409 target_state_conflict` names `reset_url` when the app has configured ' +
544
- 'none: the token would be minted and the link would point nowhere, so nothing is minted. The same `409` names `password` with rule ' +
703
+ '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 ' +
704
+ 'when the app has configured none. `409 target_state_conflict` names `password` with rule ' +
545
705
  '`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
706
  'with rule `blocked` for a blocked one, since `POST /api/client/password/reset` mails a blocked account nothing and the two doors may ' +
547
707
  'not disagree. Setting the password directly is deliberately not offered; a developer who could would hold their customers\' ' +
548
708
  'credentials.',
549
709
  },
710
+ {
711
+ method: 'DELETE', path: '/api/apps/:id/users/:userId/two-factor', section: 'apps',
712
+ summary: "Removes an app user's authenticator and recovery codes and ends every session they hold.",
713
+ audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 204,
714
+ 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`.' }],
715
+ query: null, request: null, response: null,
716
+ errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
717
+ notes: 'The support door for a person who lost their authenticator and their recovery codes. The authenticator and every recovery code go, ' +
718
+ '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 ' +
719
+ '`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 ' +
720
+ 'sign-in, before any session exists. Audited as `app_user.two_factor_reset`.',
721
+ },
550
722
  /* ---------------------------- the MCP clients one app user has connected */
551
723
  {
552
724
  method: 'GET', path: '/api/apps/:id/users/:userId/mcp-grants', section: 'apps',
@@ -613,11 +785,9 @@ export const ROUTES = [
613
785
  transport: 'http',
614
786
  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
787
  '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`, which is `null` when the app has configured no ' +
617
- '`invite_url` — there is nowhere for the link to point, and Fleetless serves an app user no page of its own. That is a `201` with a ' +
618
- 'null link, not a refusal: the invitation exists and a developer may hand the token over by another route. Asking to **mail** it in that ' +
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 ' +
788
+ 'An invitation **always bypasses `allowed_domains`**. \n\nThe answer carries `accept_url`: the app\'s `invite_url` with the token in it, ' +
789
+ 'or the Fleetless-hosted invitation page when the app has configured none — so mailing it is never refused for a missing URL. ' +
790
+ '`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
791
  '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
792
  '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
793
  '\n\nCreating shares the reissue route\'s ceiling of **five invitation mails a minute per app**, answering `429 rate_limited` with ' +
@@ -728,7 +898,7 @@ export const ROUTES = [
728
898
  /* ------------------------------- the app's auth configuration and mails */
729
899
  {
730
900
  method: 'GET', path: '/api/apps/:id/auth-config', section: 'apps',
731
- summary: "Reads the app's auth settings: self-registration, domains, origins, URLs and the MCP switch.",
901
+ summary: "Reads the app's auth settings: sign-in methods, two-factor, registration, pages, the hosted look and the MCP switch.",
732
902
  audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
733
903
  params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
734
904
  query: null, request: null, response: appAuthConfig,
@@ -738,7 +908,8 @@ export const ROUTES = [
738
908
  '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
909
  '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
910
  '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.',
911
+ 'client-supplied value would be a lie about when the row last changed. `hosted_pages` and `hosted_logo_url` are read-only as well: ' +
912
+ 'the cloud mints both from the auth portal\'s base URL and the app\'s identifier.',
742
913
  },
743
914
  {
744
915
  method: 'PUT', path: '/api/apps/:id/auth-config/registration', section: 'apps',
@@ -753,37 +924,83 @@ export const ROUTES = [
753
924
  '\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
925
  '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
926
  '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 the urls or mcp slice.',
927
+ '\n\nThe merge is server-side against the stored row, so this write never disturbs another slice.',
928
+ },
929
+ {
930
+ method: 'PUT', path: '/api/apps/:id/auth-config/sign-in', section: 'apps',
931
+ summary: 'Replaces how the app\'s users sign in and whether they give a second factor.',
932
+ audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
933
+ params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
934
+ query: null, request: putAppAuthSignInRequest, response: appAuthConfig,
935
+ errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found'], transport: 'http',
936
+ notes: '**A replace, not a merge, and `.strict()`**: `sign_in_methods` and `two_factor` both arrive or the write is refused. Both methods off ' +
937
+ 'is `400 validation_error` naming `sign_in_methods.password` — an app needs at least one door besides its identity providers. ' +
938
+ '\n\nTurning a method off refuses its routes with `method_not_allowed` from the next request on; a stored password stays stored. ' +
939
+ 'Setting `two_factor` to `required` signs nobody out: each person without an authenticator sets one up at their next sign-in, before ' +
940
+ 'any session exists. Audited with both old and new values. The merge is server-side against the stored row, so this write never ' +
941
+ 'disturbs another slice.',
757
942
  },
758
943
  {
759
944
  method: 'PUT', path: '/api/apps/:id/auth-config/urls', section: 'apps',
760
- summary: "Replaces the three pages Fleetless's mails point at.",
945
+ summary: "Replaces the app's home page and the four pages Fleetless's mails and MCP sign-in point at.",
761
946
  audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
762
947
  params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
763
948
  query: null, request: putAppAuthUrlsRequest, response: appAuthConfig,
764
949
  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 `reset_url` all arrive or the write is refused, so a ' +
766
- 'client built against an older shape cannot silently clear a setting it does not know about. `oidc_callback_url` and `updated_at` are ' +
767
- 'the server\'s, refused in this body as in every slice\'s — see `GET`\'s notes for why. ' +
768
- '\n\n`400 validation_error` is where the field rule lands: a URL template must be https (or `http` on `localhost`) and carry its ' +
950
+ notes: '**A replace, not a merge, and `.strict()`**: `app_url`, `invite_url`, `verify_url`, `reset_url` and `mcp_login_url` all arrive or ' +
951
+ 'the write is refused, so a client built against an older shape cannot silently clear a setting it does not know about. ' +
952
+ '`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. ' +
953
+ '\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. ' +
954
+ '`400 validation_error` is where the field rules land: a URL template must be https (or `http` on `localhost`) and carry its ' +
769
955
  '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
- '\n\nThe merge is server-side against the stored row, so this write never disturbs the registration or mcp slice.',
956
+ 'the mail is sent — and `app_url` takes the same host rule with no placeholder. `mcp_login_url` moved here from the `mcp` slice, ' +
957
+ 'because one screen owns all four pages. ' +
958
+ '\n\nThe merge is server-side against the stored row, so this write never disturbs another slice.',
772
959
  },
773
960
  {
774
961
  method: 'PUT', path: '/api/apps/:id/auth-config/mcp', section: 'apps',
775
- summary: 'Replaces the MCP switch and its login URL together.',
962
+ summary: 'Turns the app\'s MCP endpoint on or off.',
776
963
  audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
777
964
  params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
778
965
  query: null, request: putAppAuthMcpRequest, response: appAuthConfig,
779
966
  errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found'], transport: 'http',
780
- notes: '**A replace, not a merge, and `.strict()`**: `mcp_enabled` and `mcp_login_url` both arrive or the write is refused, so a client built ' +
781
- 'against an older shape cannot silently clear a setting it does not know about. `oidc_callback_url` and `updated_at` are the server\'s, ' +
782
- 'refused in this body as in every slice\'s — see `GET`\'s notes for why. ' +
783
- '\n\n`mcp_login_url` answers to the same rule as the `urls` slice\'s three templates — https (or `http` on `localhost`), its placeholder ' +
784
- 'exactly once — refused as `400 validation_error` rather than left to fail mid-OAuth, in a client\'s browser where no console screen ' +
785
- 'is watching. ' +
786
- '\n\nThe merge is server-side against the stored row, so this write never disturbs the registration or urls slice.',
967
+ notes: '**A replace, not a merge, and `.strict()`**: `mcp_enabled` arrives or the write is refused. It used to take `mcp_login_url` as ' +
968
+ '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 ' +
969
+ 'the `urls` slice. A body still carrying it is `400 validation_error`. `oidc_callback_url` and `updated_at` are the server\'s, ' +
970
+ '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.',
971
+ },
972
+ {
973
+ method: 'PUT', path: '/api/apps/:id/auth-config/look', section: 'apps',
974
+ summary: "Replaces the hosted pages' accent colour.",
975
+ audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
976
+ params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
977
+ query: null, request: putAppAuthLookRequest, response: appAuthConfig,
978
+ errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found'], transport: 'http',
979
+ notes: '**A replace, and `.strict()`**: `hosted_accent` arrives, `#rrggbb` in lowercase, or `null` for the neutral shell\'s own accent. ' +
980
+ '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 ' +
981
+ 'server-side against the stored row, so this write never disturbs another slice.',
982
+ },
983
+ {
984
+ method: 'PUT', path: '/api/apps/:id/auth-config/logo', section: 'apps',
985
+ summary: "Stores the logo the hosted pages show above the app's name.",
986
+ audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
987
+ params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
988
+ query: null, request: null, response: appAuthConfig,
989
+ errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'validation_error', 'not_found', 'unsupported_media_type'], transport: 'http',
990
+ 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`, ' +
991
+ '`image/svg+xml`) and anything else is `415 unsupported_media_type`. At most `HOSTED_LOGO_MAX_BYTES` (100 KB); a larger body, or one ' +
992
+ '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 ' +
993
+ '`hosted_logo_url` as an image only, and the cloud serves an SVG sandboxed, so a script inside one never runs.',
994
+ },
995
+ {
996
+ method: 'DELETE', path: '/api/apps/:id/auth-config/logo', section: 'apps',
997
+ summary: 'Removes the logo from the hosted pages.',
998
+ audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: false, status: 200,
999
+ params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
1000
+ query: null, request: null, response: appAuthConfig,
1001
+ errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
1002
+ notes: 'Answers the whole configuration, with `hosted_logo_url` now `null`; the hosted pages show the app\'s name alone. An app with no ' +
1003
+ 'logo answers the same: that is the end state being asked for.',
787
1004
  },
788
1005
  {
789
1006
  method: 'GET', path: '/api/apps/:id/mail-templates', section: 'apps',
@@ -792,9 +1009,9 @@ export const ROUTES = [
792
1009
  params: [{ name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' }],
793
1010
  query: null, request: null, response: appMailTemplateListResponse,
794
1011
  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 three. A kind that does ' +
1012
+ notes: 'Answers `{ "templates": [appMailTemplate, …] }` with **only the kinds that have a custom template** — at most four. A kind that does ' +
796
1013
  '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 password reset, are not in this list and are deliberately not customisable: they are about this platform, ' +
1014
+ 'team invitation or a console sign-in code, are not in this list and are deliberately not customisable: they are about this platform, ' +
798
1015
  'not about the developer\'s product.',
799
1016
  },
800
1017
  {
@@ -936,13 +1153,15 @@ export const ROUTES = [
936
1153
  },
937
1154
  {
938
1155
  method: 'POST', path: '/api/org/invitations/accept', section: 'users',
939
- summary: 'Spends an invitation token and creates the login it was addressed to.',
1156
+ summary: 'Spends an invitation token and creates the account it was addressed to.',
940
1157
  audience: 'developer', auth: 'none', rateLimited: true, ownerTier: false, status: 204,
941
1158
  params: [], query: null, request: acceptTeamInviteRequest, response: null,
942
1159
  errors: ['rate_limited', 'validation_error', 'token_spent', 'email_taken'], transport: 'http',
943
1160
  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. Unknown, expired and already-accepted tokens ' +
945
- 'collapse into `410 token_spent`. A browser form post gets the rendered "you\'re in" page instead.',
1161
+ 'for one account — and every security property would then have to be right in two places. **No password**: the mailed link proves the ' +
1162
+ 'address, so accepting needs no code either, and the new member signs in by emailed code from then on. When the organisation requires ' +
1163
+ 'two-factor, the member sets one up at their first sign-in. Unknown, expired and already-accepted tokens collapse into `410 token_spent`. ' +
1164
+ 'A browser form post gets the rendered "you\'re in" page instead.',
946
1165
  },
947
1166
  {
948
1167
  method: 'GET', path: '/accept-invite/:token', section: 'users',
@@ -950,9 +1169,10 @@ export const ROUTES = [
950
1169
  audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
951
1170
  params: [{ name: 'token', description: 'The opaque invitation token from the mailed link; it is never sent as a query parameter.' }],
952
1171
  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`. An unknown, ' +
954
- 'spent or expired token renders the "link no longer valid" page at `410`, which offers the password-reset page — the only self-service ' +
955
- 'door the portal has, since an invitation cannot be re-issued by the person holding it.',
1172
+ 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 ' +
1173
+ 'nothing** — a mail scanner opening the link must not accept the invitation — only the form\'s POST does. An unknown, spent or ' +
1174
+ 'expired token renders the "link no longer valid" page at `410`, which says to ask the organisation for a new invitation: the person ' +
1175
+ 'holding a dead link cannot re-issue it.',
956
1176
  },
957
1177
  {
958
1178
  method: 'PATCH', path: '/api/org/users/:id', section: 'users',
@@ -992,15 +1212,30 @@ export const ROUTES = [
992
1212
  'transaction rather than by a read beforehand. Setting the tier already held changes nothing and writes no audit event. No session is ' +
993
1213
  'revoked: a tier is re-read from the row on every request, so no issued token carries a stale copy of it.',
994
1214
  },
1215
+ {
1216
+ method: 'DELETE', path: '/api/org/users/:id/two-factor', section: 'users',
1217
+ summary: "Removes a team member's passkeys, authenticator and recovery codes and ends their sessions.",
1218
+ audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: true, status: 204,
1219
+ params: [{ name: 'id', description: 'The Fleetless user\'s uuid, as listed by `GET /api/org/users`.' }],
1220
+ query: null, request: null, response: null,
1221
+ errors: [...DEVELOPER_GUARD, 'tier_required', 'invalid_uuid', 'not_found', 'target_state_conflict'], transport: 'http',
1222
+ 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 ' +
1223
+ 'the organisation requires two-factor they set one up again at their next sign-in. **An owner cannot reset their own** — `409 ' +
1224
+ 'target_state_conflict` naming `user_id` with rule `self`; Settings › Profile is where they change it. A member with no second factor ' +
1225
+ 'answers `204` too. Audited as `developer.two_factor_reset`, naming the owner who did it.',
1226
+ },
995
1227
  {
996
1228
  method: 'PATCH', path: '/api/org', section: 'org',
997
- summary: 'Renames the org.',
1229
+ summary: 'Renames the org, requires two-factor for its members, or both.',
998
1230
  audience: 'developer', auth: 'developer', rateLimited: false, ownerTier: true, status: 200,
999
1231
  params: [], query: null, request: patchOrgRequest, response: patchOrgResponse,
1000
1232
  errors: [...DEVELOPER_GUARD, 'tier_required', 'validation_error'], transport: 'http',
1001
1233
  notes: 'Answers `{ "org": org }`. Owner tier, and the gate runs ' +
1002
- 'before the body is looked at, so a malformed rename and a forbidden one answer the same way. Renaming to the name already held writes ' +
1003
- 'nothing and records no audit event.',
1234
+ 'before the body is looked at, so a malformed patch and a forbidden one answer the same way — `403 tier_required` for a developer, ' +
1235
+ 'whichever field they sent. An empty body is `400 validation_error`. Writing the values already held writes ' +
1236
+ 'nothing and records no audit event. \n\n`require_two_factor` on signs nobody out: each member without a passkey or authenticator ' +
1237
+ 'sets one up at their next sign-in, before any session exists, on the console and the central MCP endpoint alike. Server keys and ' +
1238
+ 'robot bridges are not people and are not affected. Audited as `org.two_factor_required_changed`.',
1004
1239
  },
1005
1240
  /* --------------------------------------------------------------- mcp */
1006
1241
  {
@@ -1050,8 +1285,8 @@ export const ROUTES = [
1050
1285
  'applies; those refusals are `oauthError`. Exact `redirect_uri` matching for both client kinds — the loopback-port wildcard of RFC 8252 ' +
1051
1286
  '§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
1287
  '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 password step after ' +
1054
- 'it resolves the account; this route knows only the client.',
1288
+ '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 ' +
1289
+ 'after it resolve the account; this route knows only the client.',
1055
1290
  },
1056
1291
  {
1057
1292
  method: 'GET', path: '/mcp/oauth/interaction/:id', section: 'mcp',
@@ -1063,33 +1298,12 @@ export const ROUTES = [
1063
1298
  '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
1299
  'client whose dynamic registration lapsed in between.',
1065
1300
  },
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`.',
1087
- },
1301
+ ...developerSignInRoutes('/mcp/oauth'),
1088
1302
  {
1089
1303
  method: 'GET', path: '/mcp/oauth/consent/:id', section: 'mcp',
1090
1304
  summary: 'Serves the consent screen for an MCP client that registered itself.',
1091
1305
  audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
1092
- params: [{ name: 'id', description: 'The interaction id from the sign-in; the login step redirects the browser here.' }],
1306
+ params: [{ name: 'id', description: 'The interaction id from the sign-in; the last sign-in step redirects the browser here.' }],
1093
1307
  query: null, request: null, response: null, errors: [], transport: 'http',
1094
1308
  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
1309
  '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 +1347,41 @@ export const ROUTES = [
1133
1347
  audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
1134
1348
  params: [{ name: 'id', description: 'The interaction id minted by `GET /console/oauth/authorize`, which redirects the browser here.' }],
1135
1349
  query: null, request: null, response: null, errors: [], transport: 'http',
1136
- notes: 'HTML. An expired, consumed, unknown or hand-edited interaction renders one page at `410`: which of the four it was is not a fact a ' +
1137
- 'stranger may learn, and to the person it is one fact anyway. The page resolves nothing about the address typed into it, so there is no ' +
1138
- 'enumeration oracle here at all.',
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`.',
1350
+ notes: 'HTML: the email field, `Email me a code`, and `Sign in with a passkey`. An expired, consumed, unknown or hand-edited interaction ' +
1351
+ '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 ' +
1352
+ 'page resolves nothing about the address typed into it, so there is no enumeration oracle here at all.',
1161
1353
  },
1354
+ ...developerSignInRoutes('/console/oauth'),
1162
1355
  {
1163
1356
  method: 'GET', path: '/console/oauth/signup/:id', section: 'developer-auth',
1164
- summary: 'Serves step one of console sign-up, the account card.',
1357
+ summary: 'Serves step one of console sign-up, the email card.',
1165
1358
  audience: 'internal', auth: 'none', rateLimited: false, ownerTier: false, status: 200,
1166
1359
  params: [{ name: 'id', description: 'The interaction id minted by `GET /console/oauth/authorize` with `?prompt=create`.' }],
1167
1360
  query: null, request: null, response: null, errors: [], transport: 'http',
1168
- notes: 'HTML. While the deployment runs in closed beta this renders the "sign-up is closed" card at `403` instead, keeping the interaction alive ' +
1169
- 'and pointing back at sign-in — the person may well already have an account.',
1361
+ notes: 'HTML: the email field and `Email me a code`, the first of three steps — email, code, organization. While the deployment runs in ' +
1362
+ 'closed beta this renders the "sign-up is closed" card at `403` instead, keeping the interaction alive and pointing back at sign-in and ' +
1363
+ 'the waiting list — the person may well already have an account.',
1170
1364
  },
1171
1365
  {
1172
1366
  method: 'POST', path: '/console/oauth/signup', section: 'developer-auth',
1173
- summary: 'Takes the sign-up email and password and hands back the organization step.',
1367
+ summary: 'Takes the sign-up email, mails a code and hands back the code step.',
1174
1368
  audience: 'internal', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
1175
1369
  params: [], query: null, request: null, response: null,
1176
1370
  errors: ['rate_limited', 'token_spent', 'signup_closed', 'validation_error', 'email_taken'], transport: 'http',
1177
- notes: 'A browser form post gets the organization card; a JSON caller gets `{ "next", "email" }`, which has no schema. The plaintext password ' +
1178
- 'exists for this one request: what is stored is its argon2 hash, on the interaction row, which expires with it. A per-interaction proof ' +
1179
- 'cookie is set here — it is what stops a third party from finishing a sign-up somebody else started. Sign-up is the one surface whose job ' +
1180
- 'is to say an address is taken, so `409 email_taken` is not a leak here.',
1371
+ notes: 'A browser form post gets the code card; a JSON caller gets `{ "next", "email" }`, which has no schema. **No password**: the code ' +
1372
+ 'mailed here, six digits valid ten minutes, proves the address. A per-interaction proof cookie is set here — it is what stops a third ' +
1373
+ '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 ' +
1374
+ 'email_taken` is not a leak here.',
1375
+ },
1376
+ {
1377
+ method: 'POST', path: '/console/oauth/signup/code', section: 'developer-auth',
1378
+ summary: 'Checks the sign-up code and hands back the organization step.',
1379
+ audience: 'internal', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
1380
+ params: [], query: null, request: null, response: null,
1381
+ errors: ['rate_limited', 'token_spent', 'signup_closed', 'wrong_browser', 'validation_error', 'invalid_code'], transport: 'http',
1382
+ notes: 'A wrong code renders the code card again with the attempts left (`400 invalid_code`); a spent, expired or exhausted one is `410 ' +
1383
+ 'token_spent`. The step before must have run in **this** browser (`401 wrong_browser`). A browser form post gets the organization card, ' +
1384
+ 'the address shown `confirmed`; a JSON caller gets `{ "next" }`, which has no schema.',
1181
1385
  },
1182
1386
  {
1183
1387
  method: 'POST', path: '/console/oauth/signup/organization', section: 'developer-auth',
@@ -1185,9 +1389,11 @@ export const ROUTES = [
1185
1389
  audience: 'internal', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
1186
1390
  params: [], query: null, request: null, response: oauthRedirectResponse,
1187
1391
  errors: ['rate_limited', 'token_spent', 'signup_closed', 'wrong_browser', 'validation_error', 'email_taken'], transport: 'http',
1188
- notes: 'The same single transaction `POST /api/auth/signup` runs. Step one must have run in **this** browser: a missing or mismatched proof ' +
1189
- 'cookie is `401 wrong_browser` and the person is sent back to step one. A browser form post gets a `303` to the console callback; a JSON ' +
1190
- 'caller gets `redirect_to` at `200`.',
1392
+ notes: 'The form carries `org_name` only. The org and its founding Owner are created in one transaction, and only after the code step ' +
1393
+ 'confirmed the address. Both steps before must have run in **this** browser: a missing or mismatched proof cookie is `401 ' +
1394
+ 'wrong_browser` and the person is sent back to step one. `409 email_taken` when the address was taken meanwhile. A browser form post ' +
1395
+ 'gets a `303` to the console callback; a JSON caller gets `redirect_to` at `200`. This is the only way an organisation is created: ' +
1396
+ '`POST /api/auth/signup` is gone, because without a password it would hand a session to anybody who names an address.',
1191
1397
  },
1192
1398
  {
1193
1399
  method: 'POST', path: '/console/oauth/token', section: 'developer-auth',
@@ -1199,6 +1405,8 @@ export const ROUTES = [
1199
1405
  '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
1406
  'code was minted earlier, and a user moved out in between must not get a console session.',
1201
1407
  },
1408
+ /* --------------------------------- the Fleetless-hosted app pages */
1409
+ ...HOSTED_APP_ROUTES,
1202
1410
  /* ---------------------------------------------------- mcp (the endpoint) */
1203
1411
  {
1204
1412
  method: 'GET', path: '/mcp/welcome', section: 'mcp',
@@ -1322,8 +1530,8 @@ export const ROUTES = [
1322
1530
  'itself and never asks a person for a `client_id`. \n\n**`issuer`, `token_endpoint` and the resource identifier are minted from the ' +
1323
1531
  'canonical public base, never from the friendly `mcp.fleetless.dev` alias or the request\'s `Host`**, because a client checks a minted ' +
1324
1532
  '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**, and there is nothing here for one to serve: this authorization step renders no Fleetless page at all. It ' +
1326
- 'redirects to the app\'s own `mcp_login_url`, which is on the developer\'s origin already.',
1533
+ 'auth-portal origin**: the authorization step renders no page itself. It redirects to the app\'s own `mcp_login_url`, or to the ' +
1534
+ 'hosted MCP sign-in on the auth portal when the app has configured none.',
1327
1535
  },
1328
1536
  {
1329
1537
  method: 'POST', path: MCP_APP.register, section: 'mcp',
@@ -1346,29 +1554,27 @@ export const ROUTES = [
1346
1554
  },
1347
1555
  {
1348
1556
  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.",
1557
+ summary: "Starts an MCP sign-in and redirects the browser to the app's own login page, or to the hosted one.",
1350
1558
  audience: 'client', auth: 'none', rateLimited: false, ownerTier: false, status: 302,
1351
1559
  params: [APP_IDENTIFIER], query: oauthAuthorizeQuery, request: null, response: null,
1352
- errors: ['not_found', 'target_state_conflict'], transport: 'http',
1560
+ errors: ['not_found'], transport: 'http',
1353
1561
  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**Fleetless renders no page here**, and that is the whole of it. The route writes an interaction — ten minutes, as the OIDC ones live ' +
1562
+ 'would collapse them. \n\n**This route renders no page.** It writes an interaction — ten minutes, as the OIDC ones live ' +
1355
1563
  '— and redirects to `appAuthConfig.mcp_login_url` with `{interaction}` filled in. The app then authenticates the person with its own ' +
1356
1564
  '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. \n\nClient and `redirect_uri` are validated first and a failure there never redirects — the open-redirect discipline `GET ' +
1565
+ 'deny. **An app with no `mcp_login_url` is redirected to the hosted MCP sign-in** (`GET /app/:appIdentifier/mcp/:interaction`), ' +
1566
+ '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
1567
  '/mcp/oauth/authorize` and `GET /api/client/oidc/:slug/start` both keep — and those refusals are RFC 6749\'s flat `oauthError`, which ' +
1359
1568
  'is why none of them appear above. `redirect_uri` is matched **exactly** against the registration, with no loopback-port wildcard: ' +
1360
1569
  '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 two codes above are the `apiError` envelope because they are refusals about the **app**, ' +
1570
+ '`client_id` may send a browser. \n\nThe code above is the `apiError` envelope because it is a refusal about the **app**, ' +
1362
1571
  'decided before an OAuth parameter is looked at. **`404 not_found` covers an identifier no app carries AND an app with MCP switched ' +
1363
1572
  'off** — the same single answer the two metadata documents, `register` and the transport give. An earlier draft answered `403 ' +
1364
1573
  'mcp_disabled` here, on the argument that a client which registered while the switch was on is owed the difference between "turned ' +
1365
1574
  'off" and "mistyped"; that argument does not survive the caller being anonymous. This route takes no credential, so the extra code ' +
1366
1575
  'was readable by anyone who could type an identifier, and it handed back precisely the existence distinction every neighbouring ' +
1367
1576
  '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`. `409 ' +
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.',
1577
+ 'under `/api/client/mcp/interactions/:id`.',
1372
1578
  },
1373
1579
  {
1374
1580
  method: 'POST', path: MCP_APP.token, section: 'mcp',
@@ -1390,11 +1596,40 @@ export const ROUTES = [
1390
1596
  method: 'POST', path: '/api/client/login', section: 'client-auth',
1391
1597
  summary: 'Signs an app user in with an app identifier, an email address and a password.',
1392
1598
  audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
1393
- params: [], query: null, request: clientLoginRequest, response: sessionTokens,
1394
- errors: ['rate_limited', 'validation_error', 'invalid_credentials'], transport: 'http',
1599
+ params: [], query: null, request: clientLoginRequest, response: clientSignInResult,
1600
+ errors: ['rate_limited', 'validation_error', 'invalid_credentials', 'method_not_allowed'], transport: 'http',
1395
1601
  notes: 'One refusal for every miss — unknown app, unknown address, wrong password, a `blocked` account and one still `pending_verification` — ' +
1396
1602
  '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.',
1603
+ 'The argon2 verify is paid unconditionally, including for an unknown app identifier, so response time is not an oracle either. ' +
1604
+ '\n\n**The answer is a `clientSignInResult`**: session tokens, or a `twoFactorChallenge` when the person has a confirmed authenticator ' +
1605
+ 'or the app requires one — then no session exists until `POST /api/client/two-factor/verify` or the setup is done. `403 ' +
1606
+ 'method_not_allowed` when the app has the password method off; it names the app\'s policy, not a person.',
1607
+ },
1608
+ {
1609
+ method: 'POST', path: '/api/client/login/code', section: 'client-auth',
1610
+ summary: 'Mails a six-digit sign-in code, and answers the same whether or not the address exists.',
1611
+ audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 202,
1612
+ params: [], query: null, request: clientLoginCodeRequest, response: null,
1613
+ errors: ['rate_limited', 'validation_error', 'not_found', 'method_not_allowed'], transport: 'http',
1614
+ notes: '**`202` and an empty body for every request the policy allows**, in status, body and timing, whether or not the address names an ' +
1615
+ 'active account of this app — a decoy like `POST /api/client/resend-verification`, so this is no enumeration oracle. A mail goes out ' +
1616
+ 'only for an account that may sign in. The code is six digits, valid ten minutes, takes five wrong attempts, and a new request expires ' +
1617
+ 'the previous one for the same address; a request within sixty seconds of the last sends no second mail. The address is trimmed and ' +
1618
+ 'compared case-insensitively. `404 not_found` is the **app identifier**, never the address; `403 method_not_allowed` when the app has ' +
1619
+ 'the email-code method off. Limited per app, address and IP, so it cannot be used to mail somebody repeatedly.',
1620
+ },
1621
+ {
1622
+ method: 'POST', path: '/api/client/login/code/verify', section: 'client-auth',
1623
+ summary: 'Spends a mailed sign-in code and answers a session or a two-factor challenge.',
1624
+ audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
1625
+ params: [], query: null, request: clientLoginCodeVerifyRequest, response: clientSignInResult,
1626
+ errors: ['rate_limited', 'validation_error', 'invalid_code', 'token_spent', 'method_not_allowed'], transport: 'http',
1627
+ notes: 'A wrong code is `400 invalid_code` with `details.attempts_left` (`invalidCodeDetails`). A code that is spent, past its ten minutes, ' +
1628
+ 'out of attempts, or was never mailed is `410 token_spent` — one answer, because telling them apart would say whether a code was ever ' +
1629
+ 'sent to that address; the recovery is the same, ask for a new code. The address is trimmed and compared case-insensitively, so the ' +
1630
+ 'address typed at the request and here need not match in case. \n\n**The answer is a `clientSignInResult`**, like the password ' +
1631
+ 'login: tokens, or a `twoFactorChallenge` when the person has an authenticator or the app requires one. A pending-verification account ' +
1632
+ 'that spends a code is activated — reading a mail at that address is the proof verification asks for.',
1398
1633
  },
1399
1634
  {
1400
1635
  method: 'POST', path: '/api/client/register', section: 'client-auth',
@@ -1415,11 +1650,12 @@ export const ROUTES = [
1415
1650
  'app has self-registration off and `403 domain_not_allowed` when the address is outside `allowed_domains`: both are the developer\'s own ' +
1416
1651
  '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
1652
  '`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. `404 not_found` names an ' +
1653
+ '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, ' +
1654
+ 'or sent while it is off: an email-code-only app registers people without one. `404 not_found` names an ' +
1419
1655
  '**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
1656
  '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 configured no `verify_url` or has no default role — ' +
1422
- 'there would be nowhere to send the person and no role to give them, and mailing a link that leads nowhere is worse than refusing. ' +
1657
+ 'configuration bug that was not there. `409 target_state_conflict` when the app has no default role — there would be no role to give ' +
1658
+ 'the person. An app with no `verify_url` is not refused: the mailed link points at the hosted confirmation page instead. ' +
1423
1659
  '\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
1660
  'here that is answered **before the address is looked at** — and that ordering is the point rather than an implementation detail: a ' +
1425
1661
  '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 +1666,10 @@ export const ROUTES = [
1430
1666
  method: 'POST', path: '/api/client/verify-email', section: 'client-auth',
1431
1667
  summary: 'Spends a verification token, activates the account and answers a session.',
1432
1668
  audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
1433
- params: [], query: null, request: clientVerifyEmailRequest, response: sessionTokens,
1669
+ params: [], query: null, request: clientVerifyEmailRequest, response: clientSignInResult,
1434
1670
  errors: ['rate_limited', 'validation_error', 'token_spent'], transport: 'http',
1435
- notes: '**The answer is a session, not a `204`.** Somebody who has just proved they can read the mail should not be asked to type their ' +
1671
+ notes: '**The answer is a session, not a `204`** — or, as on every sign-in step, a `twoFactorChallenge` when the app requires two-factor ' +
1672
+ '(`clientSignInResult`). Somebody who has just proved they can read the mail should not be asked to type their ' +
1436
1673
  '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
1674
  '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
1675
  'died between them would leave a spent token on an account still `pending_verification`, whose recovery is ' +
@@ -1461,20 +1698,20 @@ export const ROUTES = [
1461
1698
  audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 202,
1462
1699
  params: [], query: null, request: clientPasswordResetRequest, response: null,
1463
1700
  errors: ['rate_limited', 'validation_error', 'not_found'], transport: 'http',
1464
- notes: '**The app-user twin of `POST /api/auth/password/reset`, and a different shape** because the two surfaces name a person differently: a ' +
1465
- 'Fleetless address is globally unique and resolves alone, an app user\'s is unique only within their app, so the pair is the identifier. ' +
1466
- 'Status, body and timing are identical for a known and an unknown address. An account with no Fleetless password — one created through an ' +
1701
+ notes: 'The pair of app identifier and address is the identifier: an app user\'s address is unique only within their app. ' +
1702
+ 'Status, body and timing are identical for a known and an unknown address. An account with no password — one created through an ' +
1467
1703
  '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`; an app that has configured none can send no mail, which the `202` does not distinguish, because saying ' +
1469
- 'so would answer for the address as well.',
1704
+ 'points at the app\'s `reset_url`, or at the hosted reset page when the app has configured none.',
1470
1705
  },
1471
1706
  {
1472
1707
  method: 'POST', path: '/api/client/password/reset/confirm', section: 'client-auth',
1473
1708
  summary: 'Spends a reset token, sets the new password and answers a fresh session.',
1474
1709
  audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
1475
- params: [], query: null, request: clientPasswordResetConfirmRequest, response: sessionTokens,
1476
- errors: ['rate_limited', 'validation_error', 'token_spent'], transport: 'http',
1477
- notes: '**Every refresh family of that account is revoked**, then a fresh pair is minted for the caller — a forgotten password is one of the two ' +
1710
+ params: [], query: null, request: clientPasswordResetConfirmRequest, response: clientSignInResult,
1711
+ errors: ['rate_limited', 'validation_error', 'token_spent', 'method_not_allowed'], transport: 'http',
1712
+ notes: '**A new password does not bypass the second factor**: a person with an authenticator, or in an app that requires one, gets a ' +
1713
+ '`twoFactorChallenge` instead of tokens (`clientSignInResult`), and the authenticator stays on. `403 method_not_allowed` when the app ' +
1714
+ '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
1715
  '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
1716
  'account is activated if it was still `pending_verification`: reading a mail at that address is the same proof verification asks for. ' +
1480
1717
  '\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 +1723,15 @@ export const ROUTES = [
1486
1723
  method: 'POST', path: '/api/client/invitations/accept', section: 'client-auth',
1487
1724
  summary: 'Spends an invitation token, creates or activates the app user and answers a session.',
1488
1725
  audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
1489
- params: [], query: null, request: clientAcceptInvitationRequest, response: sessionTokens,
1726
+ params: [], query: null, request: clientAcceptInvitationRequest, response: clientSignInResult,
1490
1727
  errors: ['rate_limited', 'validation_error', 'token_spent', 'email_taken', 'target_state_conflict', 'quota_exceeded'], transport: 'http',
1491
1728
  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
1729
  '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
1730
  '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
1731
  'and the invitation **bypasses `allowed_domains`** — a developer inviting somebody by hand has already made the decision the whitelist ' +
1495
- 'automates. \n\n**One refusal for every token that does not work: `410 token_spent`** — unknown, expired past the seven days, revoked by ' +
1732
+ 'automates. The answer is a `clientSignInResult`: a `twoFactorChallenge` instead of tokens when the app requires two-factor. ' +
1733
+ '`password` is required while the app\'s password method is on and refused while it is off, both as `400 validation_error` naming ' +
1734
+ '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
1735
  'the developer, or already accepted. There is one code because telling them apart would say whether a token ever existed, and because ' +
1497
1736
  '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
1737
  'under twelve characters is part of the `400 validation_error`, naming the `password` field. `409 email_taken` is an address this app ' +
@@ -1551,6 +1790,53 @@ export const ROUTES = [
1551
1790
  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
1791
  'shape names each of `developer_id`, `app_user_id` and `server_key_id` and fills exactly one.',
1553
1792
  },
1793
+ /* ------------------------------------------------ app-user two-factor */
1794
+ {
1795
+ method: 'POST', path: '/api/client/two-factor/verify', section: 'client-auth',
1796
+ summary: 'Answers a two-factor challenge with an authenticator or recovery code, and answers the session.',
1797
+ audience: 'client', auth: 'none', rateLimited: true, ownerTier: false, status: 200,
1798
+ params: [], query: null, request: clientTwoFactorVerifyRequest, response: sessionTokens,
1799
+ errors: ['rate_limited', 'validation_error', 'invalid_code', 'token_spent'], transport: 'http',
1800
+ 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 ' +
1801
+ '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 ' +
1802
+ 'is accepted at most once**: the same authenticator code sent twice, even at the same moment, signs in exactly once. A recovery code is ' +
1803
+ 'spent by its use and audited as `app_user.recovery_code_used`. Exactly one of `code` and `recovery_code`, or `400 validation_error`.',
1804
+ },
1805
+ {
1806
+ method: 'POST', path: '/api/client/two-factor/setup', section: 'client-auth',
1807
+ summary: 'Starts an authenticator setup and answers its secret and otpauth URL.',
1808
+ audience: 'client', auth: 'in_handler', rateLimited: true, ownerTier: false, status: 200,
1809
+ params: [], query: null, request: clientTwoFactorSetupRequest, response: twoFactorSetupResponse,
1810
+ errors: ['rate_limited', 'validation_error', 'token_spent', 'unauthorized', 'target_state_conflict'], transport: 'http',
1811
+ notes: '**Two ways in, decided in the handler.** During sign-in the body carries the `two_factor_setup_required` challenge, and that is the ' +
1812
+ 'credential; from the app\'s own account settings the app user\'s bearer is, with no challenge. Neither is `401 unauthorized`, and a ' +
1813
+ 'dead challenge is `410 token_spent`. `409 target_state_conflict` names `two_factor` with rule `off` when the app\'s policy is `off`. ' +
1814
+ '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 ' +
1815
+ 'secret, and an account that already has an authenticator keeps it until the new one is confirmed.',
1816
+ },
1817
+ {
1818
+ method: 'POST', path: '/api/client/two-factor/setup/confirm', section: 'client-auth',
1819
+ summary: 'Confirms the new authenticator with a code and answers the recovery codes and a session.',
1820
+ audience: 'client', auth: 'in_handler', rateLimited: true, ownerTier: false, status: 200,
1821
+ params: [], query: null, request: clientTwoFactorSetupConfirmRequest, response: clientTwoFactorSetupConfirmResponse,
1822
+ errors: ['rate_limited', 'validation_error', 'invalid_code', 'token_spent', 'unauthorized'], transport: 'http',
1823
+ 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 ' +
1824
+ 'challenge, is `410 token_spent`. On success the authenticator is on, ten recovery codes are issued — shown this once, any earlier set ' +
1825
+ '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 ' +
1826
+ 'session of the account ends. Audited as `app_user.two_factor_enabled`.',
1827
+ },
1828
+ {
1829
+ method: 'DELETE', path: '/api/client/two-factor', section: 'client-auth',
1830
+ summary: "Turns the signed-in app user's authenticator off.",
1831
+ audience: 'client', auth: 'developer_or_client', rateLimited: true, ownerTier: false, status: 204,
1832
+ params: [], query: null, request: clientTwoFactorDisableRequest, response: null,
1833
+ errors: [...CLIENT_GUARD, 'rate_limited', 'validation_error', 'invalid_code', 'target_state_conflict'], transport: 'http',
1834
+ 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 ' +
1835
+ 'code proves they still hold the authenticator: a stolen session alone cannot remove it. The authenticator and every recovery code go. ' +
1836
+ '`409 target_state_conflict` names `two_factor` with rule `required` while the app requires two-factor, and with rule `off` when there ' +
1837
+ 'is none to remove. Audited as `app_user.two_factor_disabled`. The developer\'s support door is `DELETE ' +
1838
+ '/api/apps/:id/users/:userId/two-factor`.',
1839
+ },
1554
1840
  /* ---------------------------------------- app-user sign-in through an IdP */
1555
1841
  {
1556
1842
  method: 'GET', path: '/api/client/providers', section: 'client-auth',
@@ -1610,10 +1896,10 @@ export const ROUTES = [
1610
1896
  '\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
1897
  'a `302` to the app\'s own ' +
1612
1898
  '`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. Fleetless shows an app user no page. \n\n**The one ' +
1899
+ '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
1900
  'exception is a `state` that resolves to no interaction** — unknown, hand-edited, or past its ten minutes. Then there is no confirmed ' +
1615
1901
  '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`. That is the only Fleetless-rendered surface an app user can reach. It is HTML ' +
1902
+ 'so the cloud renders an HTML problem page at `400`. It is HTML ' +
1617
1903
  'rather than an `apiError`, which is why no code is listed: a code here would document an envelope no caller receives, and this ' +
1618
1904
  'manifest\'s other HTML pages (`GET /mcp/oauth/interaction/:id`, `GET /console/oauth/interaction/:id`) say their status in prose for ' +
1619
1905
  'the same reason.',
@@ -2445,16 +2731,6 @@ export const ROUTES = [
2445
2731
  '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
2732
  'cross-field rule no JSON Schema can express and is enforced here. The window is echoed back.',
2447
2733
  },
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
2734
  /* ------------------------------------------------- assets (robot upload) */
2459
2735
  {
2460
2736
  method: 'POST', path: '/api/bridge/assets', section: 'assets',