@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
@@ -76,29 +76,6 @@
76
76
  "transport": "http",
77
77
  "notes": "Answers `{ ok, dependencies: { database, storage, liveKit } }` — a cloud-local shape, not a wire contract, so nothing here pins it. The status is always `200`: each dependency is probed independently and a failure is reported in the body rather than thrown, because `/healthz` must never itself be a reason the process looks down. Read `ok`, not the status code."
78
78
  },
79
- {
80
- "method": "POST",
81
- "path": "/api/auth/signup",
82
- "section": "developer-auth",
83
- "summary": "Creates an org and its founding Owner, and answers a developer session.",
84
- "audience": "developer",
85
- "auth": "none",
86
- "rateLimited": true,
87
- "ownerTier": false,
88
- "status": 201,
89
- "params": [],
90
- "query": null,
91
- "request": "sign-up-request",
92
- "response": "sign-up-response",
93
- "errors": [
94
- "rate_limited",
95
- "signup_closed",
96
- "validation_error",
97
- "email_taken"
98
- ],
99
- "transport": "http",
100
- "notes": "While the deployment runs in closed beta this answers `403 signup_closed` before it looks at the body — there is nothing for a validation message, or an `email_taken` answer, to be right about when nothing will be created. Email is globally unique, so an address already registered in any org is refused."
101
- },
102
79
  {
103
80
  "method": "POST",
104
81
  "path": "/api/auth/refresh",
@@ -120,7 +97,7 @@
120
97
  "token_revoked"
121
98
  ],
122
99
  "transport": "http",
123
- "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 bumped by a password change, cannot mint a fresh console token and answers `token_revoked`. Refusing that only on the other routes would leave a session that is dead everywhere but here."
100
+ "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 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 would leave a session that is dead everywhere but here."
124
101
  },
125
102
  {
126
103
  "method": "POST",
@@ -188,10 +165,10 @@
188
165
  "notes": "No Owner tier: this can only ever touch the caller's own row, so there is nothing for a tier check to gate. Saving the name already held writes nothing and records no audit event — the org activity stream reaches every developer with the console open, and an event for a no-op would misreport that something changed."
189
166
  },
190
167
  {
191
- "method": "POST",
192
- "path": "/api/auth/password/change",
168
+ "method": "GET",
169
+ "path": "/api/auth/two-factor",
193
170
  "section": "developer-auth",
194
- "summary": "Verifies the current password, sets a new one and answers a fresh session.",
171
+ "summary": "Answers the calling developer's passkeys, authenticator, recovery codes left and the org's policy.",
195
172
  "audience": "developer",
196
173
  "auth": "developer",
197
174
  "rateLimited": false,
@@ -199,119 +176,232 @@
199
176
  "status": 200,
200
177
  "params": [],
201
178
  "query": null,
202
- "request": "password-change-request",
203
- "response": "session-tokens",
179
+ "request": null,
180
+ "response": "developer-two-factor",
204
181
  "errors": [
205
182
  "unauthorized",
206
183
  "token_expired",
207
- "token_revoked",
208
- "validation_error",
209
- "invalid_credentials"
184
+ "token_revoked"
210
185
  ],
211
186
  "transport": "http",
212
- "notes": "Every session of this account ends, including the caller's — the request carries nothing identifying its own refresh family, so there is none to spare. The answer is a working replacement pair, which is what the promise has to mean when nothing distinguishes one session from another."
187
+ "notes": "What Settings › Profile › Security draws. No key material, secret or code travels here — the passkeys are names and dates, the authenticator is a date, the recovery codes are a count."
213
188
  },
214
189
  {
215
190
  "method": "POST",
216
- "path": "/api/auth/password/reset",
191
+ "path": "/api/auth/passkeys/options",
217
192
  "section": "developer-auth",
218
- "summary": "Mails a password-reset link to the address, and answers the same either way.",
193
+ "summary": "Answers the WebAuthn creation options for registering a passkey.",
219
194
  "audience": "developer",
220
- "auth": "none",
221
- "rateLimited": true,
195
+ "auth": "developer",
196
+ "rateLimited": false,
222
197
  "ownerTier": false,
223
- "status": 202,
198
+ "status": 200,
224
199
  "params": [],
225
200
  "query": null,
226
- "request": "password-reset-request",
227
- "response": null,
201
+ "request": null,
202
+ "response": "webauthn-options-response",
228
203
  "errors": [
229
- "rate_limited",
230
- "validation_error"
204
+ "unauthorized",
205
+ "token_expired",
206
+ "token_revoked"
231
207
  ],
232
208
  "transport": "http",
233
- "notes": "Status, body and timing are identical for a known and an unknown address — any difference is an account-enumeration oracle, which is why the unknown branch still pays a real SMTP round trip to a discard address. An account provisioned through OIDC has no Fleetless password and is mailed nothing. A browser form post gets a `303` to the \"check your mail\" card instead of this `202`."
209
+ "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 portal and in the console alike; user verification is required and the credential is discoverable, so it can sign the person in without an address. The passkeys the caller already has are excluded. The challenge is single-use and expires with the ceremony."
234
210
  },
235
211
  {
236
- "method": "GET",
237
- "path": "/reset-password",
238
- "section": "client-auth",
239
- "summary": "Serves the auth portal's \"forgot your password\" card as an HTML page.",
240
- "audience": "internal",
241
- "auth": "none",
212
+ "method": "POST",
213
+ "path": "/api/auth/passkeys",
214
+ "section": "developer-auth",
215
+ "summary": "Registers a passkey from the browser's answer to the creation options.",
216
+ "audience": "developer",
217
+ "auth": "developer",
242
218
  "rateLimited": false,
243
219
  "ownerTier": false,
244
- "status": 200,
220
+ "status": 201,
245
221
  "params": [],
246
222
  "query": null,
247
- "request": null,
248
- "response": null,
249
- "errors": [],
223
+ "request": "create-passkey-request",
224
+ "response": "create-passkey-response",
225
+ "errors": [
226
+ "unauthorized",
227
+ "token_expired",
228
+ "token_revoked",
229
+ "validation_error"
230
+ ],
250
231
  "transport": "http",
251
- "notes": "HTML, not JSON: this is a page a person opens, served by the cloud from the auth portal origin. `?sent=1` draws the \"check your mail\" state instead — one path, because that second card has no inputs and a second path would exist only to be redirected to. The value is caller-settable and discloses nothing, since the page it draws is a constant."
232
+ "notes": "A ceremony that does not verify — a wrong challenge, origin or relying party, no user verification — is `400 validation_error` naming `credential`. When this is the account's first second factor, ten recovery codes are issued and answered once; otherwise `recovery_codes` is `null` and the existing ones stay valid. Audited as `developer.two_factor_added` with `details.kind` `passkey`."
252
233
  },
253
234
  {
254
- "method": "GET",
255
- "path": "/reset-password/:token",
256
- "section": "client-auth",
257
- "summary": "Serves the \"pick a new password\" page for a mailed reset link.",
258
- "audience": "internal",
259
- "auth": "none",
235
+ "method": "PATCH",
236
+ "path": "/api/auth/passkeys/:id",
237
+ "section": "developer-auth",
238
+ "summary": "Renames one of the caller's passkeys.",
239
+ "audience": "developer",
240
+ "auth": "developer",
260
241
  "rateLimited": false,
261
242
  "ownerTier": false,
262
243
  "status": 200,
263
244
  "params": [
264
245
  {
265
- "name": "token",
266
- "description": "The opaque reset token from the mailed link; it is never sent as a query parameter."
246
+ "name": "id",
247
+ "description": "The passkey's uuid, as listed by `GET /api/auth/two-factor`; another person's passkey answers `404`."
248
+ }
249
+ ],
250
+ "query": null,
251
+ "request": "rename-passkey-request",
252
+ "response": "developer-passkey",
253
+ "errors": [
254
+ "unauthorized",
255
+ "token_expired",
256
+ "token_revoked",
257
+ "invalid_uuid",
258
+ "validation_error",
259
+ "not_found"
260
+ ],
261
+ "transport": "http"
262
+ },
263
+ {
264
+ "method": "DELETE",
265
+ "path": "/api/auth/passkeys/:id",
266
+ "section": "developer-auth",
267
+ "summary": "Removes one of the caller's passkeys.",
268
+ "audience": "developer",
269
+ "auth": "developer",
270
+ "rateLimited": false,
271
+ "ownerTier": false,
272
+ "status": 204,
273
+ "params": [
274
+ {
275
+ "name": "id",
276
+ "description": "The passkey's uuid, as listed by `GET /api/auth/two-factor`; another person's passkey answers `404`."
267
277
  }
268
278
  ],
269
279
  "query": null,
270
280
  "request": null,
271
281
  "response": null,
272
- "errors": [],
282
+ "errors": [
283
+ "unauthorized",
284
+ "token_expired",
285
+ "token_revoked",
286
+ "invalid_uuid",
287
+ "not_found",
288
+ "target_state_conflict"
289
+ ],
273
290
  "transport": "http",
274
- "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, and splitting them here would tell a stranger which tokens ever existed. No rate limiter: the GET changes nothing, and the POST it leads to is limited per IP."
291
+ "notes": "`409 target_state_conflict` names `two_factor` with rule `required_by_org` when this is the caller's last second factor and the organisation requires one. Removing the last one otherwise also voids the recovery codes. Audited as `developer.two_factor_removed` with `details.kind` `passkey`."
275
292
  },
276
293
  {
277
- "method": "GET",
278
- "path": "/favicon.svg",
279
- "section": "client-auth",
280
- "summary": "Serves the Fleetless icon for the auth portal's and the MCP welcome page's browser tab.",
281
- "audience": "internal",
282
- "auth": "none",
294
+ "method": "POST",
295
+ "path": "/api/auth/totp",
296
+ "section": "developer-auth",
297
+ "summary": "Starts an authenticator setup and answers its secret and otpauth URL.",
298
+ "audience": "developer",
299
+ "auth": "developer",
283
300
  "rateLimited": false,
284
301
  "ownerTier": false,
285
302
  "status": 200,
286
303
  "params": [],
287
304
  "query": null,
288
305
  "request": null,
289
- "response": null,
290
- "errors": [],
306
+ "response": "two-factor-setup-response",
307
+ "errors": [
308
+ "unauthorized",
309
+ "token_expired",
310
+ "token_revoked"
311
+ ],
291
312
  "transport": "http",
292
- "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 origin — the one source `img-src 'self'` names. Cached for a day: the bytes change when the brand does, not per deploy."
313
+ "notes": "The secret is pending until `POST /api/auth/totp/confirm` accepts a code from it; a second call replaces a pending secret. A developer who already has an authenticator keeps it until the new one is confirmed, which is how `Replace…` works."
293
314
  },
294
315
  {
295
316
  "method": "POST",
296
- "path": "/api/auth/password/reset/confirm",
317
+ "path": "/api/auth/totp/confirm",
297
318
  "section": "developer-auth",
298
- "summary": "Spends a reset token, sets the new password and ends every session of the account.",
319
+ "summary": "Confirms the pending authenticator with a code it shows now.",
299
320
  "audience": "developer",
300
- "auth": "none",
321
+ "auth": "developer",
301
322
  "rateLimited": true,
302
323
  "ownerTier": false,
303
- "status": 204,
324
+ "status": 200,
304
325
  "params": [],
305
326
  "query": null,
306
- "request": "password-reset-confirm",
307
- "response": null,
327
+ "request": "totp-confirm-request",
328
+ "response": "totp-confirm-response",
308
329
  "errors": [
330
+ "unauthorized",
331
+ "token_expired",
332
+ "token_revoked",
309
333
  "rate_limited",
310
334
  "validation_error",
335
+ "invalid_code",
311
336
  "token_spent"
312
337
  ],
313
338
  "transport": "http",
314
- "notes": "Unknown, spent and expired tokens all answer `410 token_spent`. Sessions are revoked under the account's actual kind — a console admin holds developer sessions, an app user holds end-user ones — so an app user's open `/realtime` socket does not outlive the reset. A browser form post gets the rendered \"done\" page instead of this `204`."
339
+ "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 authenticator replaces any earlier one. Ten recovery codes are answered when it is the account's first second factor, otherwise `null`. Audited as `developer.two_factor_added` with `details.kind` `authenticator`."
340
+ },
341
+ {
342
+ "method": "DELETE",
343
+ "path": "/api/auth/totp",
344
+ "section": "developer-auth",
345
+ "summary": "Removes the caller's authenticator app.",
346
+ "audience": "developer",
347
+ "auth": "developer",
348
+ "rateLimited": false,
349
+ "ownerTier": false,
350
+ "status": 204,
351
+ "params": [],
352
+ "query": null,
353
+ "request": null,
354
+ "response": null,
355
+ "errors": [
356
+ "unauthorized",
357
+ "token_expired",
358
+ "token_revoked",
359
+ "not_found",
360
+ "target_state_conflict"
361
+ ],
362
+ "transport": "http",
363
+ "notes": "`404 not_found` when there is no authenticator. `409 target_state_conflict` names `two_factor` with rule `required_by_org` when it is the caller's last second factor and the organisation requires one. Audited as `developer.two_factor_removed` with `details.kind` `authenticator`."
364
+ },
365
+ {
366
+ "method": "POST",
367
+ "path": "/api/auth/recovery-codes",
368
+ "section": "developer-auth",
369
+ "summary": "Issues ten new recovery codes and voids the old ones.",
370
+ "audience": "developer",
371
+ "auth": "developer",
372
+ "rateLimited": false,
373
+ "ownerTier": false,
374
+ "status": 200,
375
+ "params": [],
376
+ "query": null,
377
+ "request": null,
378
+ "response": "recovery-codes-response",
379
+ "errors": [
380
+ "unauthorized",
381
+ "token_expired",
382
+ "token_revoked",
383
+ "target_state_conflict"
384
+ ],
385
+ "transport": "http",
386
+ "notes": "The codes are shown this once. `409 target_state_conflict` names `two_factor` with rule `off` when the caller has no second factor: recovery codes only stand in for one. Audited as `developer.recovery_codes_generated`."
387
+ },
388
+ {
389
+ "method": "GET",
390
+ "path": "/favicon.svg",
391
+ "section": "client-auth",
392
+ "summary": "Serves the Fleetless icon for the auth portal's and the MCP welcome page's browser tab.",
393
+ "audience": "internal",
394
+ "auth": "none",
395
+ "rateLimited": false,
396
+ "ownerTier": false,
397
+ "status": 200,
398
+ "params": [],
399
+ "query": null,
400
+ "request": null,
401
+ "response": null,
402
+ "errors": [],
403
+ "transport": "http",
404
+ "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 origin — the one source `img-src 'self'` names. Cached for a day: the bytes change when the brand does, not per deploy."
315
405
  },
316
406
  {
317
407
  "method": "POST",
@@ -574,7 +664,7 @@
574
664
  "validation_error"
575
665
  ],
576
666
  "transport": "http",
577
- "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 define the `role` this answers with, not this one trivial request. **The answer is a bare `role`, not an envelope**, unlike the listing beside it."
667
+ "notes": "The body is `{ \"name\": string }` — non-empty, trimmed, at most 120 characters — and is deliberately not a contract shape: contracts define the `role` this answers with, not this one trivial request. **The answer is a bare `role`, not an envelope**, unlike the listing beside it."
578
668
  },
579
669
  {
580
670
  "method": "GET",
@@ -704,77 +794,6 @@
704
794
  "transport": "http",
705
795
  "notes": "Built by the same builder the MCP server's own `robot_describe` uses, so the two cannot drift. It answers what the role *would* be offered and consults nothing about any user's actual MCP entitlement. A robot the role grants nothing on still appears, with an empty `exposures` — dropping it would read as \"not attached\", which is a different fact."
706
796
  },
707
- {
708
- "method": "PATCH",
709
- "path": "/api/apps/:id/roles/:roleId",
710
- "section": "apps",
711
- "summary": "Renames a role; its users keep it.",
712
- "audience": "developer",
713
- "auth": "developer",
714
- "rateLimited": false,
715
- "ownerTier": false,
716
- "status": 200,
717
- "params": [
718
- {
719
- "name": "id",
720
- "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`."
721
- },
722
- {
723
- "name": "roleId",
724
- "description": "The role's uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`."
725
- }
726
- ],
727
- "query": null,
728
- "request": "role-rename-request",
729
- "response": "role",
730
- "errors": [
731
- "unauthorized",
732
- "token_expired",
733
- "token_revoked",
734
- "invalid_uuid",
735
- "not_found",
736
- "validation_error",
737
- "role_name_taken"
738
- ],
739
- "transport": "http",
740
- "notes": "Names are unique per app, compared exactly as stored after trimming. Built-in roles can be renamed."
741
- },
742
- {
743
- "method": "DELETE",
744
- "path": "/api/apps/:id/roles/:roleId",
745
- "section": "apps",
746
- "summary": "Deletes a role, moving its users, pending invitations and default-role status to another role.",
747
- "audience": "developer",
748
- "auth": "developer",
749
- "rateLimited": false,
750
- "ownerTier": false,
751
- "status": 204,
752
- "params": [
753
- {
754
- "name": "id",
755
- "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`."
756
- },
757
- {
758
- "name": "roleId",
759
- "description": "The role's uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`."
760
- }
761
- ],
762
- "query": "role-delete-query",
763
- "request": null,
764
- "response": null,
765
- "errors": [
766
- "unauthorized",
767
- "token_expired",
768
- "token_revoked",
769
- "invalid_uuid",
770
- "not_found",
771
- "validation_error",
772
- "role_in_use",
773
- "last_role"
774
- ],
775
- "transport": "http",
776
- "notes": "Without `move_to`, a role that app users or pending invitations hold, or that is the app's default, answers `409 role_in_use` with `{ users, invitations, is_default }`. With `move_to` — another role of the same app, else `400 validation_error` — one transaction moves `app_users.role_id`, pending invitations and `default_role_id`, then deletes the role and its permissions. The app's only role answers `409 last_role`. Built-in roles can be deleted like any other."
777
- },
778
797
  {
779
798
  "method": "POST",
780
799
  "path": "/api/apps/:id/server-keys",
@@ -1098,18 +1117,18 @@
1098
1117
  "target_state_conflict"
1099
1118
  ],
1100
1119
  "transport": "http",
1101
- "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 user who asked them rather than the form. **No enumeration discipline applies** — the caller is authenticated into the app and can read the user list — so this one answers what actually happened: `{ \"mail\": mailStatus }`, where `not_configured` is a deployment without a mailer and `failed` is the state worth somebody's attention. `409 target_state_conflict` names `reset_url` when the app has configured none: the token would be minted and the link would point nowhere, so nothing is minted. The same `409` names `password` with rule `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` with rule `blocked` for a blocked one, since `POST /api/client/password/reset` mails a blocked account nothing and the two doors may not disagree. Setting the password directly is deliberately not offered; a developer who could would hold their customers' credentials."
1120
+ "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 user who asked them rather than the form. **No enumeration discipline applies** — the caller is authenticated into the app and can read the user list — so this one answers what actually happened: `{ \"mail\": mailStatus }`, where `not_configured` is a deployment without a 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 when the app has configured none. `409 target_state_conflict` names `password` with rule `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` with rule `blocked` for a blocked one, since `POST /api/client/password/reset` mails a blocked account nothing and the two doors may not disagree. Setting the password directly is deliberately not offered; a developer who could would hold their customers' credentials."
1102
1121
  },
1103
1122
  {
1104
- "method": "GET",
1105
- "path": "/api/apps/:id/users/:userId/mcp-grants",
1123
+ "method": "DELETE",
1124
+ "path": "/api/apps/:id/users/:userId/two-factor",
1106
1125
  "section": "apps",
1107
- "summary": "Lists the MCP clients one app user has consented to.",
1126
+ "summary": "Removes an app user's authenticator and recovery codes and ends every session they hold.",
1108
1127
  "audience": "developer",
1109
1128
  "auth": "developer",
1110
1129
  "rateLimited": false,
1111
1130
  "ownerTier": false,
1112
- "status": 200,
1131
+ "status": 204,
1113
1132
  "params": [
1114
1133
  {
1115
1134
  "name": "id",
@@ -1122,7 +1141,7 @@
1122
1141
  ],
1123
1142
  "query": null,
1124
1143
  "request": null,
1125
- "response": "mcp-consent-grant-list-response",
1144
+ "response": null,
1126
1145
  "errors": [
1127
1146
  "unauthorized",
1128
1147
  "token_expired",
@@ -1131,10 +1150,43 @@
1131
1150
  "not_found"
1132
1151
  ],
1133
1152
  "transport": "http",
1134
- "notes": "A consent is remembered so that a later authorization can skip the app's own screen, and a client's registration lapsing does not end it — so a person who approved something once had no way back and neither did the developer supporting them. This is the reading half of that door. \n\n**Every name here is a claim the client made about itself.** Dynamic registration takes no credential, so `client_name` is attacker-chosen text, unverified on every row, and `client_name_verified` is the literal `false`; a console that renders it as an identity is rendering a string somebody picked. **Withdrawn grants are absent** rather than listed as withdrawn: the question is what is connected now. \n\nThe user is scoped to the app and the app to the org, so a user of a sibling app and one that does not exist read identically — `404`, never a `403`. **A developer sees which clients their customer connected and nothing those clients did**: this route reads the consent table alone, and no scope, token or session of the person appears in it, because the authorization server issues no scopes at all."
1153
+ "notes": "The support door for a person who lost their authenticator and their recovery codes. The authenticator and every recovery code go, 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 `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 sign-in, before any session exists. Audited as `app_user.two_factor_reset`."
1135
1154
  },
1136
1155
  {
1137
- "method": "DELETE",
1156
+ "method": "GET",
1157
+ "path": "/api/apps/:id/users/:userId/mcp-grants",
1158
+ "section": "apps",
1159
+ "summary": "Lists the MCP clients one app user has consented to.",
1160
+ "audience": "developer",
1161
+ "auth": "developer",
1162
+ "rateLimited": false,
1163
+ "ownerTier": false,
1164
+ "status": 200,
1165
+ "params": [
1166
+ {
1167
+ "name": "id",
1168
+ "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`."
1169
+ },
1170
+ {
1171
+ "name": "userId",
1172
+ "description": "The app user's uuid, from `GET /api/apps/:id/users`; a user of another app answers `404`."
1173
+ }
1174
+ ],
1175
+ "query": null,
1176
+ "request": null,
1177
+ "response": "mcp-consent-grant-list-response",
1178
+ "errors": [
1179
+ "unauthorized",
1180
+ "token_expired",
1181
+ "token_revoked",
1182
+ "invalid_uuid",
1183
+ "not_found"
1184
+ ],
1185
+ "transport": "http",
1186
+ "notes": "A consent is remembered so that a later authorization can skip the app's own screen, and a client's registration lapsing does not end it — so a person who approved something once had no way back and neither did the developer supporting them. This is the reading half of that door. \n\n**Every name here is a claim the client made about itself.** Dynamic registration takes no credential, so `client_name` is attacker-chosen text, unverified on every row, and `client_name_verified` is the literal `false`; a console that renders it as an identity is rendering a string somebody picked. **Withdrawn grants are absent** rather than listed as withdrawn: the question is what is connected now. \n\nThe user is scoped to the app and the app to the org, so a user of a sibling app and one that does not exist read identically — `404`, never a `403`. **A developer sees which clients their customer connected and nothing those clients did**: this route reads the consent table alone, and no scope, token or session of the person appears in it, because the authorization server issues no scopes at all."
1187
+ },
1188
+ {
1189
+ "method": "DELETE",
1138
1190
  "path": "/api/apps/:id/users/:userId/mcp-grants/:clientId",
1139
1191
  "section": "apps",
1140
1192
  "summary": "Withdraws one app user's consent to an MCP client, on the developer's behalf.",
@@ -1230,7 +1282,7 @@
1230
1282
  "rate_limited"
1231
1283
  ],
1232
1284
  "transport": "http",
1233
- "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 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. An invitation **always bypasses `allowed_domains`**. \n\nThe answer carries `accept_url`, which is `null` when the app has configured no `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 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 state is `409 target_state_conflict` naming `invite_url`, because a mail carrying a dead link is worse than no mail. The same `409` names `default_role_id` when `role_id` is absent and the app has no default role, or its default no longer resolves: an invitation that names no role has nothing to hand its acceptor, so it is refused here rather than at the acceptance a week later. `409 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. \n\nCreating shares the reissue route's ceiling of **five invitation mails a minute per app**, answering `429 rate_limited` with `retry_after_ms`: re-creating an invitation for one address replaces it and mails again, so a limit that bound only reissue would be a limit on the wrong door."
1285
+ "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 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. An invitation **always bypasses `allowed_domains`**. \n\nThe answer carries `accept_url`: the app's `invite_url` with the token in it, or the Fleetless-hosted invitation page when the app has configured none — so mailing it is never refused for a missing URL. `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 that names no role has nothing to hand its acceptor, so it is refused here rather than at the acceptance a week later. `409 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. \n\nCreating shares the reissue route's ceiling of **five invitation mails a minute per app**, answering `429 rate_limited` with `retry_after_ms`: re-creating an invitation for one address replaces it and mails again, so a limit that bound only reissue would be a limit on the wrong door."
1234
1286
  },
1235
1287
  {
1236
1288
  "method": "POST",
@@ -1467,7 +1519,7 @@
1467
1519
  "method": "GET",
1468
1520
  "path": "/api/apps/:id/auth-config",
1469
1521
  "section": "apps",
1470
- "summary": "Reads the app's auth settings: self-registration, domains, origins, URLs and the MCP switch.",
1522
+ "summary": "Reads the app's auth settings: sign-in methods, two-factor, registration, pages, the hosted look and the MCP switch.",
1471
1523
  "audience": "developer",
1472
1524
  "auth": "developer",
1473
1525
  "rateLimited": false,
@@ -1490,7 +1542,7 @@
1490
1542
  "not_found"
1491
1543
  ],
1492
1544
  "transport": "http",
1493
- "notes": "One row per app, created with the app and never absent — an app that has configured nothing reads back the defaults rather than a `404`. `oidc_callback_url` is in the answer and not in the request: it is minted by the cloud from its own public base URL, is the same for every app and every provider, and is the value a developer registers at their identity provider. It stays read-only on every slice 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 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 client-supplied value would be a lie about when the row last changed."
1545
+ "notes": "One row per app, created with the app and never absent — an app that has configured nothing reads back the defaults rather than a `404`. `oidc_callback_url` is in the answer and not in the request: it is minted by the cloud from its own public base URL, is the same for every app and every provider, and is the value a developer registers at their identity provider. It stays read-only on every slice 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 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 client-supplied value would be a lie about when the row last changed. `hosted_pages` and `hosted_logo_url` are read-only as well: the cloud mints both from the auth portal's base URL and the app's identifier."
1494
1546
  },
1495
1547
  {
1496
1548
  "method": "PUT",
@@ -1520,13 +1572,43 @@
1520
1572
  "not_found"
1521
1573
  ],
1522
1574
  "transport": "http",
1523
- "notes": "**A replace, not a merge, and `.strict()`**: `self_registration`, `allowed_domains` and `allowed_origins` all arrive or the write is refused, so a client built against an older shape cannot silently clear a setting it does not know about. `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. \n\n`400 validation_error` is where the two field rules land: an entry in `allowed_domains` must be lowercase, since a capitalised one 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 sends nothing longer in its `Origin` header. Each refuses at configuration time rather than failing silently later. \n\nThe merge is server-side against the stored row, so this write never disturbs the urls or mcp slice."
1575
+ "notes": "**A replace, not a merge, and `.strict()`**: `self_registration`, `allowed_domains` and `allowed_origins` all arrive or the write is refused, so a client built against an older shape cannot silently clear a setting it does not know about. `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. \n\n`400 validation_error` is where the two field rules land: an entry in `allowed_domains` must be lowercase, since a capitalised one 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 sends nothing longer in its `Origin` header. Each refuses at configuration time rather than failing silently later. \n\nThe merge is server-side against the stored row, so this write never disturbs another slice."
1576
+ },
1577
+ {
1578
+ "method": "PUT",
1579
+ "path": "/api/apps/:id/auth-config/sign-in",
1580
+ "section": "apps",
1581
+ "summary": "Replaces how the app's users sign in and whether they give a second factor.",
1582
+ "audience": "developer",
1583
+ "auth": "developer",
1584
+ "rateLimited": false,
1585
+ "ownerTier": false,
1586
+ "status": 200,
1587
+ "params": [
1588
+ {
1589
+ "name": "id",
1590
+ "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`."
1591
+ }
1592
+ ],
1593
+ "query": null,
1594
+ "request": "put-app-auth-sign-in-request",
1595
+ "response": "app-auth-config",
1596
+ "errors": [
1597
+ "unauthorized",
1598
+ "token_expired",
1599
+ "token_revoked",
1600
+ "invalid_uuid",
1601
+ "validation_error",
1602
+ "not_found"
1603
+ ],
1604
+ "transport": "http",
1605
+ "notes": "**A replace, not a merge, and `.strict()`**: `sign_in_methods` and `two_factor` both arrive or the write is refused. Both methods off is `400 validation_error` naming `sign_in_methods.password` — an app needs at least one door besides its identity providers. \n\nTurning a method off refuses its routes with `method_not_allowed` from the next request on; a stored password stays stored. Setting `two_factor` to `required` signs nobody out: each person without an authenticator sets one up at their next sign-in, before any session exists. Audited with both old and new values. The merge is server-side against the stored row, so this write never disturbs another slice."
1524
1606
  },
1525
1607
  {
1526
1608
  "method": "PUT",
1527
1609
  "path": "/api/apps/:id/auth-config/urls",
1528
1610
  "section": "apps",
1529
- "summary": "Replaces the three pages Fleetless's mails point at.",
1611
+ "summary": "Replaces the app's home page and the four pages Fleetless's mails and MCP sign-in point at.",
1530
1612
  "audience": "developer",
1531
1613
  "auth": "developer",
1532
1614
  "rateLimited": false,
@@ -1550,13 +1632,13 @@
1550
1632
  "not_found"
1551
1633
  ],
1552
1634
  "transport": "http",
1553
- "notes": "**A replace, not a merge, and `.strict()`**: `invite_url`, `verify_url` and `reset_url` all arrive or the write is refused, so a client built against an older shape cannot silently clear a setting it does not know about. `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. \n\n`400 validation_error` is where the field rule lands: a URL template must be https (or `http` on `localhost`) and carry its placeholder exactly once — a second occurrence leaves one literal in a mailed link, refused here rather than failing silently once the mail is sent. \n\nThe merge is server-side against the stored row, so this write never disturbs the registration or mcp slice."
1635
+ "notes": "**A replace, not a merge, and `.strict()`**: `app_url`, `invite_url`, `verify_url`, `reset_url` and `mcp_login_url` all arrive or the write is refused, so a client built against an older shape cannot silently clear a setting it does not know about. `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. \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. `400 validation_error` is where the field rules land: a URL template must be https (or `http` on `localhost`) and carry its placeholder exactly once — a second occurrence leaves one literal in a mailed link, refused here rather than failing silently once the mail is sent — and `app_url` takes the same host rule with no placeholder. `mcp_login_url` moved here from the `mcp` slice, because one screen owns all four pages. \n\nThe merge is server-side against the stored row, so this write never disturbs another slice."
1554
1636
  },
1555
1637
  {
1556
1638
  "method": "PUT",
1557
1639
  "path": "/api/apps/:id/auth-config/mcp",
1558
1640
  "section": "apps",
1559
- "summary": "Replaces the MCP switch and its login URL together.",
1641
+ "summary": "Turns the app's MCP endpoint on or off.",
1560
1642
  "audience": "developer",
1561
1643
  "auth": "developer",
1562
1644
  "rateLimited": false,
@@ -1580,7 +1662,97 @@
1580
1662
  "not_found"
1581
1663
  ],
1582
1664
  "transport": "http",
1583
- "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 against an older shape cannot silently clear a setting it does not know about. `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. \n\n`mcp_login_url` answers to the same rule as the `urls` slice's three templates — https (or `http` on `localhost`), its placeholder exactly once — refused as `400 validation_error` rather than left to fail mid-OAuth, in a client's browser where no console screen is watching. \n\nThe merge is server-side against the stored row, so this write never disturbs the registration or urls slice."
1665
+ "notes": "**A replace, not a merge, and `.strict()`**: `mcp_enabled` arrives or the write is refused. It used to take `mcp_login_url` as 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 the `urls` slice. A body still carrying it is `400 validation_error`. `oidc_callback_url` and `updated_at` are the server's, 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."
1666
+ },
1667
+ {
1668
+ "method": "PUT",
1669
+ "path": "/api/apps/:id/auth-config/look",
1670
+ "section": "apps",
1671
+ "summary": "Replaces the hosted pages' accent colour.",
1672
+ "audience": "developer",
1673
+ "auth": "developer",
1674
+ "rateLimited": false,
1675
+ "ownerTier": false,
1676
+ "status": 200,
1677
+ "params": [
1678
+ {
1679
+ "name": "id",
1680
+ "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`."
1681
+ }
1682
+ ],
1683
+ "query": null,
1684
+ "request": "put-app-auth-look-request",
1685
+ "response": "app-auth-config",
1686
+ "errors": [
1687
+ "unauthorized",
1688
+ "token_expired",
1689
+ "token_revoked",
1690
+ "invalid_uuid",
1691
+ "validation_error",
1692
+ "not_found"
1693
+ ],
1694
+ "transport": "http",
1695
+ "notes": "**A replace, and `.strict()`**: `hosted_accent` arrives, `#rrggbb` in lowercase, or `null` for the neutral shell's own accent. 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 server-side against the stored row, so this write never disturbs another slice."
1696
+ },
1697
+ {
1698
+ "method": "PUT",
1699
+ "path": "/api/apps/:id/auth-config/logo",
1700
+ "section": "apps",
1701
+ "summary": "Stores the logo the hosted pages show above the app's name.",
1702
+ "audience": "developer",
1703
+ "auth": "developer",
1704
+ "rateLimited": false,
1705
+ "ownerTier": false,
1706
+ "status": 200,
1707
+ "params": [
1708
+ {
1709
+ "name": "id",
1710
+ "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`."
1711
+ }
1712
+ ],
1713
+ "query": null,
1714
+ "request": null,
1715
+ "response": "app-auth-config",
1716
+ "errors": [
1717
+ "unauthorized",
1718
+ "token_expired",
1719
+ "token_revoked",
1720
+ "invalid_uuid",
1721
+ "validation_error",
1722
+ "not_found",
1723
+ "unsupported_media_type"
1724
+ ],
1725
+ "transport": "http",
1726
+ "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`, `image/svg+xml`) and anything else is `415 unsupported_media_type`. At most `HOSTED_LOGO_MAX_BYTES` (100 KB); a larger body, or one 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 `hosted_logo_url` as an image only, and the cloud serves an SVG sandboxed, so a script inside one never runs."
1727
+ },
1728
+ {
1729
+ "method": "DELETE",
1730
+ "path": "/api/apps/:id/auth-config/logo",
1731
+ "section": "apps",
1732
+ "summary": "Removes the logo from the hosted pages.",
1733
+ "audience": "developer",
1734
+ "auth": "developer",
1735
+ "rateLimited": false,
1736
+ "ownerTier": false,
1737
+ "status": 200,
1738
+ "params": [
1739
+ {
1740
+ "name": "id",
1741
+ "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`."
1742
+ }
1743
+ ],
1744
+ "query": null,
1745
+ "request": null,
1746
+ "response": "app-auth-config",
1747
+ "errors": [
1748
+ "unauthorized",
1749
+ "token_expired",
1750
+ "token_revoked",
1751
+ "invalid_uuid",
1752
+ "not_found"
1753
+ ],
1754
+ "transport": "http",
1755
+ "notes": "Answers the whole configuration, with `hosted_logo_url` now `null`; the hosted pages show the app's name alone. An app with no logo answers the same: that is the end state being asked for."
1584
1756
  },
1585
1757
  {
1586
1758
  "method": "GET",
@@ -1609,7 +1781,7 @@
1609
1781
  "not_found"
1610
1782
  ],
1611
1783
  "transport": "http",
1612
- "notes": "Answers `{ \"templates\": [appMailTemplate, …] }` with **only the kinds that have a custom template** — at most three. A kind that does not appear is one using the Fleetless default text, which is an ordinary state and not a missing row. Mails to *Fleetless* users, a team invitation or a console password reset, are not in this list and are deliberately not customisable: they are about this platform, not about the developer's product."
1784
+ "notes": "Answers `{ \"templates\": [appMailTemplate, …] }` with **only the kinds that have a custom template** — at most four. A kind that does not appear is one using the Fleetless default text, which is an ordinary state and not a missing row. Mails to *Fleetless* users, a team invitation or a console sign-in code, are not in this list and are deliberately not customisable: they are about this platform, not about the developer's product."
1613
1785
  },
1614
1786
  {
1615
1787
  "method": "GET",
@@ -1949,7 +2121,7 @@
1949
2121
  "method": "POST",
1950
2122
  "path": "/api/org/invitations/accept",
1951
2123
  "section": "users",
1952
- "summary": "Spends an invitation token and creates the login it was addressed to.",
2124
+ "summary": "Spends an invitation token and creates the account it was addressed to.",
1953
2125
  "audience": "developer",
1954
2126
  "auth": "none",
1955
2127
  "rateLimited": true,
@@ -1966,7 +2138,7 @@
1966
2138
  "email_taken"
1967
2139
  ],
1968
2140
  "transport": "http",
1969
- "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 for one account — and every security property would then have to be right in two places. Unknown, expired and already-accepted tokens collapse into `410 token_spent`. A browser form post gets the rendered \"you're in\" page instead."
2141
+ "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 for one account — and every security property would then have to be right in two places. **No password**: the mailed link proves the address, so accepting needs no code either, and the new member signs in by emailed code from then on. When the organisation requires two-factor, the member sets one up at their first sign-in. Unknown, expired and already-accepted tokens collapse into `410 token_spent`. A browser form post gets the rendered \"you're in\" page instead."
1970
2142
  },
1971
2143
  {
1972
2144
  "method": "GET",
@@ -1989,7 +2161,7 @@
1989
2161
  "response": null,
1990
2162
  "errors": [],
1991
2163
  "transport": "http",
1992
- "notes": "HTML, served by the cloud from the auth portal origin; the form on it posts to `POST /api/org/invitations/accept`. An unknown, spent or expired token renders the \"link no longer valid\" page at `410`, which offers the password-reset page — the only self-service door the portal has, since an invitation cannot be re-issued by the person holding it."
2164
+ "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 nothing** — a mail scanner opening the link must not accept the invitation — only the form's POST does. An unknown, spent or expired token renders the \"link no longer valid\" page at `410`, which says to ask the organisation for a new invitation: the person holding a dead link cannot re-issue it."
1993
2165
  },
1994
2166
  {
1995
2167
  "method": "PATCH",
@@ -2084,11 +2256,42 @@
2084
2256
  "transport": "http",
2085
2257
  "notes": "Owner tier, unconditionally — this is the route the whole owner-exclusive list is about. A uuid that is not a Fleetless user of this org answers `404 not_found`, the same as one that does not exist anywhere: the `409 target_state_conflict` documented here until the two-space cut had exactly one producer, the Org Admins membership check, and went with it. Demoting the last Owner is `409 last_owner`, decided by a row lock inside the writing transaction rather than by a read beforehand. Setting the tier already held changes nothing and writes no audit event. No session is revoked: a tier is re-read from the row on every request, so no issued token carries a stale copy of it."
2086
2258
  },
2259
+ {
2260
+ "method": "DELETE",
2261
+ "path": "/api/org/users/:id/two-factor",
2262
+ "section": "users",
2263
+ "summary": "Removes a team member's passkeys, authenticator and recovery codes and ends their sessions.",
2264
+ "audience": "developer",
2265
+ "auth": "developer",
2266
+ "rateLimited": false,
2267
+ "ownerTier": true,
2268
+ "status": 204,
2269
+ "params": [
2270
+ {
2271
+ "name": "id",
2272
+ "description": "The Fleetless user's uuid, as listed by `GET /api/org/users`."
2273
+ }
2274
+ ],
2275
+ "query": null,
2276
+ "request": null,
2277
+ "response": null,
2278
+ "errors": [
2279
+ "unauthorized",
2280
+ "token_expired",
2281
+ "token_revoked",
2282
+ "tier_required",
2283
+ "invalid_uuid",
2284
+ "not_found",
2285
+ "target_state_conflict"
2286
+ ],
2287
+ "transport": "http",
2288
+ "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 the organisation requires two-factor they set one up again at their next sign-in. **An owner cannot reset their own** — `409 target_state_conflict` naming `user_id` with rule `self`; Settings › Profile is where they change it. A member with no second factor answers `204` too. Audited as `developer.two_factor_reset`, naming the owner who did it."
2289
+ },
2087
2290
  {
2088
2291
  "method": "PATCH",
2089
2292
  "path": "/api/org",
2090
2293
  "section": "org",
2091
- "summary": "Renames the org.",
2294
+ "summary": "Renames the org, requires two-factor for its members, or both.",
2092
2295
  "audience": "developer",
2093
2296
  "auth": "developer",
2094
2297
  "rateLimited": false,
@@ -2106,7 +2309,7 @@
2106
2309
  "validation_error"
2107
2310
  ],
2108
2311
  "transport": "http",
2109
- "notes": "Answers `{ \"org\": org }`. Owner tier, and the gate runs before the body is looked at, so a malformed rename and a forbidden one answer the same way. Renaming to the name already held writes nothing and records no audit event."
2312
+ "notes": "Answers `{ \"org\": org }`. Owner tier, and the gate runs before the body is looked at, so a malformed patch and a forbidden one answer the same way — `403 tier_required` for a developer, whichever field they sent. An empty body is `400 validation_error`. Writing the values already held writes nothing and records no audit event. \n\n`require_two_factor` on signs nobody out: each member without a passkey or authenticator sets one up at their next sign-in, before any session exists, on the console and the central MCP endpoint alike. Server keys and robot bridges are not people and are not affected. Audited as `org.two_factor_required_changed`."
2110
2313
  },
2111
2314
  {
2112
2315
  "method": "GET",
@@ -2180,7 +2383,7 @@
2180
2383
  "response": null,
2181
2384
  "errors": [],
2182
2385
  "transport": "http",
2183
- "notes": "**The query schema is what this endpoint accepts, not what it parses**: the handler reads it parameter by parameter because the answers differ, and one parse would collapse them. Client and `redirect_uri` are validated first and a failure there never redirects, the same open-redirect discipline the app flow applies; those refusals are `oauthError`. Exact `redirect_uri` matching for both client kinds — the loopback-port wildcard of RFC 8252 §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 registered itself seconds ago can name the port it bound, and widening the wildcard there would only widen where a stolen `client_id` may send a browser. Nothing about the person is decided here — the next card asks for an email address and the password step after it resolves the account; this route knows only the client."
2386
+ "notes": "**The query schema is what this endpoint accepts, not what it parses**: the handler reads it parameter by parameter because the answers differ, and one parse would collapse them. Client and `redirect_uri` are validated first and a failure there never redirects, the same open-redirect discipline the app flow applies; those refusals are `oauthError`. Exact `redirect_uri` matching for both client kinds — the loopback-port wildcard of RFC 8252 §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 registered itself seconds ago can name the port it bound, and widening the wildcard there would only widen where a stolen `client_id` may send a browser. Nothing about the person is decided here — the next card asks for an email address, or a passkey, and the steps after it resolve the account; this route knows only the client."
2184
2387
  },
2185
2388
  {
2186
2389
  "method": "GET",
@@ -2209,7 +2412,7 @@
2209
2412
  "method": "POST",
2210
2413
  "path": "/mcp/oauth/identify",
2211
2414
  "section": "mcp",
2212
- "summary": "Takes the email address and hands back the password step.",
2415
+ "summary": "Takes the email address, mails a sign-in code and hands back the code step.",
2213
2416
  "audience": "internal",
2214
2417
  "auth": "none",
2215
2418
  "rateLimited": true,
@@ -2225,13 +2428,13 @@
2225
2428
  "token_spent"
2226
2429
  ],
2227
2430
  "transport": "http",
2228
- "notes": "The identifier-first step, with nothing left to identify: Fleetless users are password-only, so **this step does not 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 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 lookup here whose result could differ. A browser form post gets the password card; a JSON caller gets `{ \"next\" }`, which has no schema. Still rate limited per (route, ip, email), because it is an unauthenticated endpoint that renders a page."
2431
+ "notes": "The page answers `Check your email` **for every address**: a known one gets `Your Fleetless sign-in code`, six digits valid ten 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 is closed). So the page never reveals who has an account, and the address is trimmed and compared case-insensitively. A request within sixty seconds of the last one for the same address renders the same page without a second mail. The browser-proof cookie is set here. A browser form post gets the code card; a JSON caller gets `{ \"next\": \"/mcp/oauth/code\" }`, which has no schema. A dead interaction is `410 token_spent`."
2229
2432
  },
2230
2433
  {
2231
2434
  "method": "POST",
2232
- "path": "/mcp/oauth/login",
2435
+ "path": "/mcp/oauth/code",
2233
2436
  "section": "mcp",
2234
- "summary": "Checks the password and hands back where the MCP sign-in continues.",
2437
+ "summary": "Checks the emailed code and finishes the sign-in, or hands back the second step.",
2235
2438
  "audience": "internal",
2236
2439
  "auth": "none",
2237
2440
  "rateLimited": true,
@@ -2245,16 +2448,17 @@
2245
2448
  "rate_limited",
2246
2449
  "validation_error",
2247
2450
  "token_spent",
2248
- "invalid_credentials"
2451
+ "wrong_browser",
2452
+ "invalid_code"
2249
2453
  ],
2250
2454
  "transport": "http",
2251
- "notes": "The body is `{ \"interaction_id\", \"email\", \"password\" }`, read field by field rather than through a contract shape. A browser gets a `303` — to the consent screen for a self-registered client, or straight to the callback for the central one — where a JSON caller gets this `200` and `redirect_to`."
2455
+ "notes": "A wrong code renders the code card again with the attempts left (`400 invalid_code`, `details.attempts_left`); a code spent, past 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 removed before it is checked. With no second factor to give, a browser gets a `303` to the consent screen for a self-registered client, or straight to the callback for the central one, and a JSON caller that URL as `redirect_to`. Otherwise the next step: a JSON caller gets `{ \"next\" }` naming `/mcp/oauth/two-factor` — the person has a passkey or an authenticator — or `/mcp/oauth/two-factor/setup` — the organisation requires one and the person has none — and a browser the page itself. Audited as `developer.login` with `details.method` `email_code` once the sign-in completes."
2252
2456
  },
2253
2457
  {
2254
2458
  "method": "GET",
2255
- "path": "/mcp/oauth/consent/:id",
2459
+ "path": "/mcp/oauth/two-factor/:id",
2256
2460
  "section": "mcp",
2257
- "summary": "Serves the consent screen for an MCP client that registered itself.",
2461
+ "summary": "Serves the second step: the authenticator code, or the passkey prompt.",
2258
2462
  "audience": "internal",
2259
2463
  "auth": "none",
2260
2464
  "rateLimited": false,
@@ -2263,7 +2467,7 @@
2263
2467
  "params": [
2264
2468
  {
2265
2469
  "name": "id",
2266
- "description": "The interaction id from the sign-in; the login step redirects the browser here."
2470
+ "description": "The interaction id of this sign-in; the step before redirects the browser here."
2267
2471
  }
2268
2472
  ],
2269
2473
  "query": null,
@@ -2271,13 +2475,36 @@
2271
2475
  "response": null,
2272
2476
  "errors": [],
2273
2477
  "transport": "http",
2274
- "notes": "HTML. The browser-proof cookie is checked on this GET, not only on the POST. The **central** client never reaches this screen and renders the `410` page instead: it is configured by the operator, so there is no self-registered stranger for a person to weigh up."
2478
+ "notes": "HTML. Six boxes for the authenticator code, `Use a passkey`, and `Use a recovery code`; a developer with passkeys only sees the passkey prompt directly. The browser-proof cookie is checked on this GET too. A dead interaction renders the `410` page."
2479
+ },
2480
+ {
2481
+ "method": "GET",
2482
+ "path": "/mcp/oauth/two-factor/:id/recovery",
2483
+ "section": "mcp",
2484
+ "summary": "Serves the recovery-code page of the second step.",
2485
+ "audience": "internal",
2486
+ "auth": "none",
2487
+ "rateLimited": false,
2488
+ "ownerTier": false,
2489
+ "status": 200,
2490
+ "params": [
2491
+ {
2492
+ "name": "id",
2493
+ "description": "The interaction id of this sign-in; the step before redirects the browser here."
2494
+ }
2495
+ ],
2496
+ "query": null,
2497
+ "request": null,
2498
+ "response": null,
2499
+ "errors": [],
2500
+ "transport": "http",
2501
+ "notes": "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 two-factor in Settings › Team."
2275
2502
  },
2276
2503
  {
2277
2504
  "method": "POST",
2278
- "path": "/mcp/oauth/consent",
2505
+ "path": "/mcp/oauth/two-factor",
2279
2506
  "section": "mcp",
2280
- "summary": "Records the allow-or-deny and sends the browser back to the MCP client.",
2507
+ "summary": "Checks an authenticator code or a recovery code and finishes the sign-in.",
2281
2508
  "audience": "internal",
2282
2509
  "auth": "none",
2283
2510
  "rateLimited": true,
@@ -2290,118 +2517,1110 @@
2290
2517
  "errors": [
2291
2518
  "rate_limited",
2292
2519
  "validation_error",
2293
- "token_spent"
2520
+ "token_spent",
2521
+ "wrong_browser",
2522
+ "invalid_code"
2294
2523
  ],
2295
2524
  "transport": "http",
2296
- "notes": "Fail-closed exactly as the app flow's consent POST is: the body carries the pressed button's `decision`, and anything that is not the Allow value — a missing field included — denies. A denial still answers a `redirect_to`, carrying `error=access_denied` back to the client, because a client that is refused must learn so from its own callback rather than from a page nobody sent it."
2525
+ "notes": "The form carries either `code` or `recovery_code`. **An authenticator code is accepted at most once**, so the same code sent twice finishes one sign-in. A recovery code is spent by its use. Five wrong codes end the interaction (`410 token_spent`). A browser gets a `303` to the consent screen for a self-registered client, or straight to the callback for the central one; a JSON caller that URL as `redirect_to`."
2297
2526
  },
2298
2527
  {
2299
2528
  "method": "POST",
2300
- "path": "/mcp/oauth/token",
2529
+ "path": "/mcp/oauth/passkey/options",
2301
2530
  "section": "mcp",
2302
- "summary": "Exchanges an MCP authorization code for an access token.",
2303
- "audience": "client",
2531
+ "summary": "Answers the WebAuthn request options for a passkey sign-in or second step.",
2532
+ "audience": "internal",
2304
2533
  "auth": "none",
2305
- "rateLimited": false,
2534
+ "rateLimited": true,
2306
2535
  "ownerTier": false,
2307
2536
  "status": 200,
2308
2537
  "params": [],
2309
2538
  "query": null,
2310
- "request": "oauth-token-request",
2311
- "response": "oauth-token-response",
2312
- "errors": [],
2539
+ "request": null,
2540
+ "response": "webauthn-options-response",
2541
+ "errors": [
2542
+ "rate_limited",
2543
+ "token_spent",
2544
+ "wrong_browser"
2545
+ ],
2313
2546
  "transport": "http",
2314
- "notes": "`authorization_code` mints an `mcp_session` access token bound to the central resource and a refresh token; `refresh_token` rotates that pair, and the presented refresh token is consumed — a second presentation revokes the session, as on `/api/auth/refresh`. The refresh token lives ninety days from its last use and is bound to the `client_id` it was issued to. A refresh re-reads the Fleetless user, so a removed account cannot refresh. Refusals are RFC 6749 §5.2's `oauthError`, so this route emits none of the codes in this reference. The code is single-use, PKCE-verified, and its `resource` must match the audience it was authorized for; a `resource` on a refresh must match the session's audience, and is checked before the token is consumed."
2547
+ "notes": "Before an address is known the options name no credential, so the browser offers every discoverable passkey for `fleetless.dev` (`Sign in with a passkey`); after the code step they name the account's own passkeys. User verification is required. The challenge is bound to the interaction and single-use."
2548
+ },
2549
+ {
2550
+ "method": "POST",
2551
+ "path": "/mcp/oauth/passkey",
2552
+ "section": "mcp",
2553
+ "summary": "Checks a passkey assertion; a passkey completes the sign-in on its own.",
2554
+ "audience": "internal",
2555
+ "auth": "none",
2556
+ "rateLimited": true,
2557
+ "ownerTier": false,
2558
+ "status": 200,
2559
+ "params": [],
2560
+ "query": null,
2561
+ "request": null,
2562
+ "response": "oauth-redirect-response",
2563
+ "errors": [
2564
+ "rate_limited",
2565
+ "validation_error",
2566
+ "token_spent",
2567
+ "wrong_browser",
2568
+ "invalid_credentials"
2569
+ ],
2570
+ "transport": "http",
2571
+ "notes": "**A passkey is a full sign-in**: it proves possession and user verification, two factors, so it skips the emailed code and the second step — used as the second step, it finishes it. An assertion that does not verify, or names no passkey of an account, is `401 invalid_credentials`, the same answer for both. When the organisation requires two-factor, a passkey satisfies it. A browser gets a `303` to the consent screen for a self-registered client, or straight to the callback for the central one; a JSON caller that URL as `redirect_to`. Audited as `developer.login` with `details.method` `passkey`."
2315
2572
  },
2316
2573
  {
2317
2574
  "method": "GET",
2318
- "path": "/console/oauth/authorize",
2319
- "section": "developer-auth",
2320
- "summary": "Starts a console sign-in and redirects the browser to the identify card.",
2575
+ "path": "/mcp/oauth/two-factor/setup/:id",
2576
+ "section": "mcp",
2577
+ "summary": "Serves the \"your organisation requires two-factor\" choice between a passkey and an authenticator.",
2321
2578
  "audience": "internal",
2322
2579
  "auth": "none",
2323
2580
  "rateLimited": false,
2324
2581
  "ownerTier": false,
2325
- "status": 302,
2326
- "params": [],
2582
+ "status": 200,
2583
+ "params": [
2584
+ {
2585
+ "name": "id",
2586
+ "description": "The interaction id of this sign-in; the step before redirects the browser here."
2587
+ }
2588
+ ],
2327
2589
  "query": null,
2328
2590
  "request": null,
2329
2591
  "response": null,
2330
2592
  "errors": [],
2331
2593
  "transport": "http",
2332
- "notes": "The authorization-code leg of the console's own OAuth flow: PKCE `S256` is required and `redirect_uri` must match a configured console callback exactly, never a prefix. Refusals use RFC 6749's flat `oauthError` shape, not the `apiError` envelope, so they carry none of the codes in this reference. A bad `redirect_uri` never redirects — until the URI is known-good, sending a browser to it is the attack; later errors go back to the callback as query parameters. `?prompt=create` starts at sign-up rather than sign-in."
2594
+ "notes": "HTML. Reached when the organisation requires two-factor and the person has none; no session exists until the setup is done. Two options, the passkey recommended because it also signs the person in without an emailed code. `Signed in as <email> · Sign out` under it."
2333
2595
  },
2334
2596
  {
2335
- "method": "GET",
2336
- "path": "/console/oauth/interaction/:id",
2337
- "section": "developer-auth",
2338
- "summary": "Serves the \"what is your email address\" card of a console sign-in.",
2597
+ "method": "POST",
2598
+ "path": "/mcp/oauth/two-factor/setup/totp",
2599
+ "section": "mcp",
2600
+ "summary": "Starts an authenticator setup inside the sign-in and hands back its QR code and key.",
2339
2601
  "audience": "internal",
2340
2602
  "auth": "none",
2341
- "rateLimited": false,
2603
+ "rateLimited": true,
2604
+ "ownerTier": false,
2605
+ "status": 200,
2606
+ "params": [],
2607
+ "query": null,
2608
+ "request": null,
2609
+ "response": "two-factor-setup-response",
2610
+ "errors": [
2611
+ "rate_limited",
2612
+ "token_spent",
2613
+ "wrong_browser"
2614
+ ],
2615
+ "transport": "http",
2616
+ "notes": "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 stored as confirmed yet."
2617
+ },
2618
+ {
2619
+ "method": "POST",
2620
+ "path": "/mcp/oauth/two-factor/setup/totp/confirm",
2621
+ "section": "mcp",
2622
+ "summary": "Confirms the new authenticator and hands back the ten recovery codes.",
2623
+ "audience": "internal",
2624
+ "auth": "none",
2625
+ "rateLimited": true,
2626
+ "ownerTier": false,
2627
+ "status": 200,
2628
+ "params": [],
2629
+ "query": null,
2630
+ "request": null,
2631
+ "response": "recovery-codes-response",
2632
+ "errors": [
2633
+ "rate_limited",
2634
+ "validation_error",
2635
+ "token_spent",
2636
+ "wrong_browser",
2637
+ "invalid_code"
2638
+ ],
2639
+ "transport": "http",
2640
+ "notes": "A code that does not match is `400 invalid_code`. On success the authenticator is on and ten recovery codes are shown once; `I saved my recovery codes` then finishes the sign-in. Audited as `developer.two_factor_added` with `details.kind` `authenticator`."
2641
+ },
2642
+ {
2643
+ "method": "POST",
2644
+ "path": "/mcp/oauth/two-factor/setup/passkey/options",
2645
+ "section": "mcp",
2646
+ "summary": "Answers the WebAuthn creation options for a passkey set up inside the sign-in.",
2647
+ "audience": "internal",
2648
+ "auth": "none",
2649
+ "rateLimited": true,
2650
+ "ownerTier": false,
2651
+ "status": 200,
2652
+ "params": [],
2653
+ "query": null,
2654
+ "request": null,
2655
+ "response": "webauthn-options-response",
2656
+ "errors": [
2657
+ "rate_limited",
2658
+ "token_spent",
2659
+ "wrong_browser"
2660
+ ],
2661
+ "transport": "http",
2662
+ "notes": "The same options `POST /api/auth/passkeys/options` answers a signed-in developer: relying party `fleetless.dev`, user verification required, a discoverable credential."
2663
+ },
2664
+ {
2665
+ "method": "POST",
2666
+ "path": "/mcp/oauth/two-factor/setup/passkey",
2667
+ "section": "mcp",
2668
+ "summary": "Registers the passkey and hands back the ten recovery codes.",
2669
+ "audience": "internal",
2670
+ "auth": "none",
2671
+ "rateLimited": true,
2672
+ "ownerTier": false,
2673
+ "status": 200,
2674
+ "params": [],
2675
+ "query": null,
2676
+ "request": null,
2677
+ "response": "recovery-codes-response",
2678
+ "errors": [
2679
+ "rate_limited",
2680
+ "validation_error",
2681
+ "token_spent",
2682
+ "wrong_browser"
2683
+ ],
2684
+ "transport": "http",
2685
+ "notes": "A ceremony that does not verify is `400 validation_error`. On success the passkey is stored, named after the browser's device where it says so, and ten recovery codes are shown once. Audited as `developer.two_factor_added` with `details.kind` `passkey`."
2686
+ },
2687
+ {
2688
+ "method": "POST",
2689
+ "path": "/mcp/oauth/two-factor/setup/done",
2690
+ "section": "mcp",
2691
+ "summary": "Finishes the sign-in once the recovery codes are saved.",
2692
+ "audience": "internal",
2693
+ "auth": "none",
2694
+ "rateLimited": true,
2695
+ "ownerTier": false,
2696
+ "status": 200,
2697
+ "params": [],
2698
+ "query": null,
2699
+ "request": null,
2700
+ "response": "oauth-redirect-response",
2701
+ "errors": [
2702
+ "rate_limited",
2703
+ "token_spent",
2704
+ "wrong_browser"
2705
+ ],
2706
+ "transport": "http",
2707
+ "notes": "`I saved my recovery codes` gates the button on the page; the step itself only checks that a second factor now exists. A browser gets a `303` to the consent screen for a self-registered client, or straight to the callback for the central one; a JSON caller that URL as `redirect_to`."
2708
+ },
2709
+ {
2710
+ "method": "GET",
2711
+ "path": "/mcp/oauth/consent/:id",
2712
+ "section": "mcp",
2713
+ "summary": "Serves the consent screen for an MCP client that registered itself.",
2714
+ "audience": "internal",
2715
+ "auth": "none",
2716
+ "rateLimited": false,
2717
+ "ownerTier": false,
2718
+ "status": 200,
2719
+ "params": [
2720
+ {
2721
+ "name": "id",
2722
+ "description": "The interaction id from the sign-in; the last sign-in step redirects the browser here."
2723
+ }
2724
+ ],
2725
+ "query": null,
2726
+ "request": null,
2727
+ "response": null,
2728
+ "errors": [],
2729
+ "transport": "http",
2730
+ "notes": "HTML. The browser-proof cookie is checked on this GET, not only on the POST. The **central** client never reaches this screen and renders the `410` page instead: it is configured by the operator, so there is no self-registered stranger for a person to weigh up."
2731
+ },
2732
+ {
2733
+ "method": "POST",
2734
+ "path": "/mcp/oauth/consent",
2735
+ "section": "mcp",
2736
+ "summary": "Records the allow-or-deny and sends the browser back to the MCP client.",
2737
+ "audience": "internal",
2738
+ "auth": "none",
2739
+ "rateLimited": true,
2740
+ "ownerTier": false,
2741
+ "status": 200,
2742
+ "params": [],
2743
+ "query": null,
2744
+ "request": null,
2745
+ "response": "oauth-redirect-response",
2746
+ "errors": [
2747
+ "rate_limited",
2748
+ "validation_error",
2749
+ "token_spent"
2750
+ ],
2751
+ "transport": "http",
2752
+ "notes": "Fail-closed exactly as the app flow's consent POST is: the body carries the pressed button's `decision`, and anything that is not the Allow value — a missing field included — denies. A denial still answers a `redirect_to`, carrying `error=access_denied` back to the client, because a client that is refused must learn so from its own callback rather than from a page nobody sent it."
2753
+ },
2754
+ {
2755
+ "method": "POST",
2756
+ "path": "/mcp/oauth/token",
2757
+ "section": "mcp",
2758
+ "summary": "Exchanges an MCP authorization code for an access token.",
2759
+ "audience": "client",
2760
+ "auth": "none",
2761
+ "rateLimited": false,
2762
+ "ownerTier": false,
2763
+ "status": 200,
2764
+ "params": [],
2765
+ "query": null,
2766
+ "request": "oauth-token-request",
2767
+ "response": "oauth-token-response",
2768
+ "errors": [],
2769
+ "transport": "http",
2770
+ "notes": "`authorization_code` mints an `mcp_session` access token bound to the central resource and a refresh token; `refresh_token` rotates that pair, and the presented refresh token is consumed — a second presentation revokes the session, as on `/api/auth/refresh`. The refresh token lives ninety days from its last use and is bound to the `client_id` it was issued to. A refresh re-reads the Fleetless user, so a removed account cannot refresh. Refusals are RFC 6749 §5.2's `oauthError`, so this route emits none of the codes in this reference. The code is single-use, PKCE-verified, and its `resource` must match the audience it was authorized for; a `resource` on a refresh must match the session's audience, and is checked before the token is consumed."
2771
+ },
2772
+ {
2773
+ "method": "GET",
2774
+ "path": "/console/oauth/authorize",
2775
+ "section": "developer-auth",
2776
+ "summary": "Starts a console sign-in and redirects the browser to the identify card.",
2777
+ "audience": "internal",
2778
+ "auth": "none",
2779
+ "rateLimited": false,
2780
+ "ownerTier": false,
2781
+ "status": 302,
2782
+ "params": [],
2783
+ "query": null,
2784
+ "request": null,
2785
+ "response": null,
2786
+ "errors": [],
2787
+ "transport": "http",
2788
+ "notes": "The authorization-code leg of the console's own OAuth flow: PKCE `S256` is required and `redirect_uri` must match a configured console callback exactly, never a prefix. Refusals use RFC 6749's flat `oauthError` shape, not the `apiError` envelope, so they carry none of the codes in this reference. A bad `redirect_uri` never redirects — until the URI is known-good, sending a browser to it is the attack; later errors go back to the callback as query parameters. `?prompt=create` starts at sign-up rather than sign-in."
2789
+ },
2790
+ {
2791
+ "method": "GET",
2792
+ "path": "/console/oauth/interaction/:id",
2793
+ "section": "developer-auth",
2794
+ "summary": "Serves the \"what is your email address\" card of a console sign-in.",
2795
+ "audience": "internal",
2796
+ "auth": "none",
2797
+ "rateLimited": false,
2798
+ "ownerTier": false,
2799
+ "status": 200,
2800
+ "params": [
2801
+ {
2802
+ "name": "id",
2803
+ "description": "The interaction id minted by `GET /console/oauth/authorize`, which redirects the browser here."
2804
+ }
2805
+ ],
2806
+ "query": null,
2807
+ "request": null,
2808
+ "response": null,
2809
+ "errors": [],
2810
+ "transport": "http",
2811
+ "notes": "HTML: the email field, `Email me a code`, and `Sign in with a passkey`. An expired, consumed, unknown or hand-edited interaction renders one page at `410`: which of the four it was is not a fact a stranger may learn, and to the person it is one fact anyway. The page resolves nothing about the address typed into it, so there is no enumeration oracle here at all."
2812
+ },
2813
+ {
2814
+ "method": "POST",
2815
+ "path": "/console/oauth/identify",
2816
+ "section": "developer-auth",
2817
+ "summary": "Takes the email address, mails a sign-in code and hands back the code step.",
2818
+ "audience": "internal",
2819
+ "auth": "none",
2820
+ "rateLimited": true,
2821
+ "ownerTier": false,
2822
+ "status": 200,
2823
+ "params": [],
2824
+ "query": null,
2825
+ "request": null,
2826
+ "response": null,
2827
+ "errors": [
2828
+ "rate_limited",
2829
+ "validation_error",
2830
+ "token_spent"
2831
+ ],
2832
+ "transport": "http",
2833
+ "notes": "The page answers `Check your email` **for every address**: a known one gets `Your Fleetless sign-in code`, six digits valid ten 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 is closed). So the page never reveals who has an account, and the address is trimmed and compared case-insensitively. A request within sixty seconds of the last one for the same address renders the same page without a second mail. The browser-proof cookie is set here. A browser form post gets the code card; a JSON caller gets `{ \"next\": \"/console/oauth/code\" }`, which has no schema. A dead interaction is `410 token_spent`."
2834
+ },
2835
+ {
2836
+ "method": "POST",
2837
+ "path": "/console/oauth/code",
2838
+ "section": "developer-auth",
2839
+ "summary": "Checks the emailed code and finishes the sign-in, or hands back the second step.",
2840
+ "audience": "internal",
2841
+ "auth": "none",
2842
+ "rateLimited": true,
2843
+ "ownerTier": false,
2844
+ "status": 200,
2845
+ "params": [],
2846
+ "query": null,
2847
+ "request": null,
2848
+ "response": "oauth-redirect-response",
2849
+ "errors": [
2850
+ "rate_limited",
2851
+ "validation_error",
2852
+ "token_spent",
2853
+ "wrong_browser",
2854
+ "invalid_code"
2855
+ ],
2856
+ "transport": "http",
2857
+ "notes": "A wrong code renders the code card again with the attempts left (`400 invalid_code`, `details.attempts_left`); a code spent, past 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 removed before it is checked. With no second factor to give, a browser gets a `303` to the console callback with the authorization code, and a JSON caller that URL as `redirect_to`. Otherwise the next step: a JSON caller gets `{ \"next\" }` naming `/console/oauth/two-factor` — the person has a passkey or an authenticator — or `/console/oauth/two-factor/setup` — the organisation requires one and the person has none — and a browser the page itself. Audited as `developer.login` with `details.method` `email_code` once the sign-in completes."
2858
+ },
2859
+ {
2860
+ "method": "GET",
2861
+ "path": "/console/oauth/two-factor/:id",
2862
+ "section": "developer-auth",
2863
+ "summary": "Serves the second step: the authenticator code, or the passkey prompt.",
2864
+ "audience": "internal",
2865
+ "auth": "none",
2866
+ "rateLimited": false,
2867
+ "ownerTier": false,
2868
+ "status": 200,
2869
+ "params": [
2870
+ {
2871
+ "name": "id",
2872
+ "description": "The interaction id of this sign-in; the step before redirects the browser here."
2873
+ }
2874
+ ],
2875
+ "query": null,
2876
+ "request": null,
2877
+ "response": null,
2878
+ "errors": [],
2879
+ "transport": "http",
2880
+ "notes": "HTML. Six boxes for the authenticator code, `Use a passkey`, and `Use a recovery code`; a developer with passkeys only sees the passkey prompt directly. The browser-proof cookie is checked on this GET too. A dead interaction renders the `410` page."
2881
+ },
2882
+ {
2883
+ "method": "GET",
2884
+ "path": "/console/oauth/two-factor/:id/recovery",
2885
+ "section": "developer-auth",
2886
+ "summary": "Serves the recovery-code page of the second step.",
2887
+ "audience": "internal",
2888
+ "auth": "none",
2889
+ "rateLimited": false,
2890
+ "ownerTier": false,
2891
+ "status": 200,
2892
+ "params": [
2893
+ {
2894
+ "name": "id",
2895
+ "description": "The interaction id of this sign-in; the step before redirects the browser here."
2896
+ }
2897
+ ],
2898
+ "query": null,
2899
+ "request": null,
2900
+ "response": null,
2901
+ "errors": [],
2902
+ "transport": "http",
2903
+ "notes": "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 two-factor in Settings › Team."
2904
+ },
2905
+ {
2906
+ "method": "POST",
2907
+ "path": "/console/oauth/two-factor",
2908
+ "section": "developer-auth",
2909
+ "summary": "Checks an authenticator code or a recovery code and finishes the sign-in.",
2910
+ "audience": "internal",
2911
+ "auth": "none",
2912
+ "rateLimited": true,
2913
+ "ownerTier": false,
2914
+ "status": 200,
2915
+ "params": [],
2916
+ "query": null,
2917
+ "request": null,
2918
+ "response": "oauth-redirect-response",
2919
+ "errors": [
2920
+ "rate_limited",
2921
+ "validation_error",
2922
+ "token_spent",
2923
+ "wrong_browser",
2924
+ "invalid_code"
2925
+ ],
2926
+ "transport": "http",
2927
+ "notes": "The form carries either `code` or `recovery_code`. **An authenticator code is accepted at most once**, so the same code sent twice finishes one sign-in. A recovery code is spent by its use. Five wrong codes end the interaction (`410 token_spent`). A browser gets a `303` to the console callback with the authorization code; a JSON caller that URL as `redirect_to`."
2928
+ },
2929
+ {
2930
+ "method": "POST",
2931
+ "path": "/console/oauth/passkey/options",
2932
+ "section": "developer-auth",
2933
+ "summary": "Answers the WebAuthn request options for a passkey sign-in or second step.",
2934
+ "audience": "internal",
2935
+ "auth": "none",
2936
+ "rateLimited": true,
2937
+ "ownerTier": false,
2938
+ "status": 200,
2939
+ "params": [],
2940
+ "query": null,
2941
+ "request": null,
2942
+ "response": "webauthn-options-response",
2943
+ "errors": [
2944
+ "rate_limited",
2945
+ "token_spent",
2946
+ "wrong_browser"
2947
+ ],
2948
+ "transport": "http",
2949
+ "notes": "Before an address is known the options name no credential, so the browser offers every discoverable passkey for `fleetless.dev` (`Sign in with a passkey`); after the code step they name the account's own passkeys. User verification is required. The challenge is bound to the interaction and single-use."
2950
+ },
2951
+ {
2952
+ "method": "POST",
2953
+ "path": "/console/oauth/passkey",
2954
+ "section": "developer-auth",
2955
+ "summary": "Checks a passkey assertion; a passkey completes the sign-in on its own.",
2956
+ "audience": "internal",
2957
+ "auth": "none",
2958
+ "rateLimited": true,
2959
+ "ownerTier": false,
2960
+ "status": 200,
2961
+ "params": [],
2962
+ "query": null,
2963
+ "request": null,
2964
+ "response": "oauth-redirect-response",
2965
+ "errors": [
2966
+ "rate_limited",
2967
+ "validation_error",
2968
+ "token_spent",
2969
+ "wrong_browser",
2970
+ "invalid_credentials"
2971
+ ],
2972
+ "transport": "http",
2973
+ "notes": "**A passkey is a full sign-in**: it proves possession and user verification, two factors, so it skips the emailed code and the second step — used as the second step, it finishes it. An assertion that does not verify, or names no passkey of an account, is `401 invalid_credentials`, the same answer for both. When the organisation requires two-factor, a passkey satisfies it. A browser gets a `303` to the console callback with the authorization code; a JSON caller that URL as `redirect_to`. Audited as `developer.login` with `details.method` `passkey`."
2974
+ },
2975
+ {
2976
+ "method": "GET",
2977
+ "path": "/console/oauth/two-factor/setup/:id",
2978
+ "section": "developer-auth",
2979
+ "summary": "Serves the \"your organisation requires two-factor\" choice between a passkey and an authenticator.",
2980
+ "audience": "internal",
2981
+ "auth": "none",
2982
+ "rateLimited": false,
2983
+ "ownerTier": false,
2984
+ "status": 200,
2985
+ "params": [
2986
+ {
2987
+ "name": "id",
2988
+ "description": "The interaction id of this sign-in; the step before redirects the browser here."
2989
+ }
2990
+ ],
2991
+ "query": null,
2992
+ "request": null,
2993
+ "response": null,
2994
+ "errors": [],
2995
+ "transport": "http",
2996
+ "notes": "HTML. Reached when the organisation requires two-factor and the person has none; no session exists until the setup is done. Two options, the passkey recommended because it also signs the person in without an emailed code. `Signed in as <email> · Sign out` under it."
2997
+ },
2998
+ {
2999
+ "method": "POST",
3000
+ "path": "/console/oauth/two-factor/setup/totp",
3001
+ "section": "developer-auth",
3002
+ "summary": "Starts an authenticator setup inside the sign-in and hands back its QR code and key.",
3003
+ "audience": "internal",
3004
+ "auth": "none",
3005
+ "rateLimited": true,
3006
+ "ownerTier": false,
3007
+ "status": 200,
3008
+ "params": [],
3009
+ "query": null,
3010
+ "request": null,
3011
+ "response": "two-factor-setup-response",
3012
+ "errors": [
3013
+ "rate_limited",
3014
+ "token_spent",
3015
+ "wrong_browser"
3016
+ ],
3017
+ "transport": "http",
3018
+ "notes": "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 stored as confirmed yet."
3019
+ },
3020
+ {
3021
+ "method": "POST",
3022
+ "path": "/console/oauth/two-factor/setup/totp/confirm",
3023
+ "section": "developer-auth",
3024
+ "summary": "Confirms the new authenticator and hands back the ten recovery codes.",
3025
+ "audience": "internal",
3026
+ "auth": "none",
3027
+ "rateLimited": true,
3028
+ "ownerTier": false,
3029
+ "status": 200,
3030
+ "params": [],
3031
+ "query": null,
3032
+ "request": null,
3033
+ "response": "recovery-codes-response",
3034
+ "errors": [
3035
+ "rate_limited",
3036
+ "validation_error",
3037
+ "token_spent",
3038
+ "wrong_browser",
3039
+ "invalid_code"
3040
+ ],
3041
+ "transport": "http",
3042
+ "notes": "A code that does not match is `400 invalid_code`. On success the authenticator is on and ten recovery codes are shown once; `I saved my recovery codes` then finishes the sign-in. Audited as `developer.two_factor_added` with `details.kind` `authenticator`."
3043
+ },
3044
+ {
3045
+ "method": "POST",
3046
+ "path": "/console/oauth/two-factor/setup/passkey/options",
3047
+ "section": "developer-auth",
3048
+ "summary": "Answers the WebAuthn creation options for a passkey set up inside the sign-in.",
3049
+ "audience": "internal",
3050
+ "auth": "none",
3051
+ "rateLimited": true,
3052
+ "ownerTier": false,
3053
+ "status": 200,
3054
+ "params": [],
3055
+ "query": null,
3056
+ "request": null,
3057
+ "response": "webauthn-options-response",
3058
+ "errors": [
3059
+ "rate_limited",
3060
+ "token_spent",
3061
+ "wrong_browser"
3062
+ ],
3063
+ "transport": "http",
3064
+ "notes": "The same options `POST /api/auth/passkeys/options` answers a signed-in developer: relying party `fleetless.dev`, user verification required, a discoverable credential."
3065
+ },
3066
+ {
3067
+ "method": "POST",
3068
+ "path": "/console/oauth/two-factor/setup/passkey",
3069
+ "section": "developer-auth",
3070
+ "summary": "Registers the passkey and hands back the ten recovery codes.",
3071
+ "audience": "internal",
3072
+ "auth": "none",
3073
+ "rateLimited": true,
3074
+ "ownerTier": false,
3075
+ "status": 200,
3076
+ "params": [],
3077
+ "query": null,
3078
+ "request": null,
3079
+ "response": "recovery-codes-response",
3080
+ "errors": [
3081
+ "rate_limited",
3082
+ "validation_error",
3083
+ "token_spent",
3084
+ "wrong_browser"
3085
+ ],
3086
+ "transport": "http",
3087
+ "notes": "A ceremony that does not verify is `400 validation_error`. On success the passkey is stored, named after the browser's device where it says so, and ten recovery codes are shown once. Audited as `developer.two_factor_added` with `details.kind` `passkey`."
3088
+ },
3089
+ {
3090
+ "method": "POST",
3091
+ "path": "/console/oauth/two-factor/setup/done",
3092
+ "section": "developer-auth",
3093
+ "summary": "Finishes the sign-in once the recovery codes are saved.",
3094
+ "audience": "internal",
3095
+ "auth": "none",
3096
+ "rateLimited": true,
3097
+ "ownerTier": false,
3098
+ "status": 200,
3099
+ "params": [],
3100
+ "query": null,
3101
+ "request": null,
3102
+ "response": "oauth-redirect-response",
3103
+ "errors": [
3104
+ "rate_limited",
3105
+ "token_spent",
3106
+ "wrong_browser"
3107
+ ],
3108
+ "transport": "http",
3109
+ "notes": "`I saved my recovery codes` gates the button on the page; the step itself only checks that a second factor now exists. A browser gets a `303` to the console callback with the authorization code; a JSON caller that URL as `redirect_to`."
3110
+ },
3111
+ {
3112
+ "method": "GET",
3113
+ "path": "/console/oauth/signup/:id",
3114
+ "section": "developer-auth",
3115
+ "summary": "Serves step one of console sign-up, the email card.",
3116
+ "audience": "internal",
3117
+ "auth": "none",
3118
+ "rateLimited": false,
3119
+ "ownerTier": false,
3120
+ "status": 200,
3121
+ "params": [
3122
+ {
3123
+ "name": "id",
3124
+ "description": "The interaction id minted by `GET /console/oauth/authorize` with `?prompt=create`."
3125
+ }
3126
+ ],
3127
+ "query": null,
3128
+ "request": null,
3129
+ "response": null,
3130
+ "errors": [],
3131
+ "transport": "http",
3132
+ "notes": "HTML: the email field and `Email me a code`, the first of three steps — email, code, organization. While the deployment runs in closed beta this renders the \"sign-up is closed\" card at `403` instead, keeping the interaction alive and pointing back at sign-in and the waiting list — the person may well already have an account."
3133
+ },
3134
+ {
3135
+ "method": "POST",
3136
+ "path": "/console/oauth/signup",
3137
+ "section": "developer-auth",
3138
+ "summary": "Takes the sign-up email, mails a code and hands back the code step.",
3139
+ "audience": "internal",
3140
+ "auth": "none",
3141
+ "rateLimited": true,
3142
+ "ownerTier": false,
3143
+ "status": 200,
3144
+ "params": [],
3145
+ "query": null,
3146
+ "request": null,
3147
+ "response": null,
3148
+ "errors": [
3149
+ "rate_limited",
3150
+ "token_spent",
3151
+ "signup_closed",
3152
+ "validation_error",
3153
+ "email_taken"
3154
+ ],
3155
+ "transport": "http",
3156
+ "notes": "A browser form post gets the code card; a JSON caller gets `{ \"next\", \"email\" }`, which has no schema. **No password**: the code mailed here, six digits valid ten minutes, proves the address. A per-interaction proof 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 is to say an address is taken, so `409 email_taken` is not a leak here."
3157
+ },
3158
+ {
3159
+ "method": "POST",
3160
+ "path": "/console/oauth/signup/code",
3161
+ "section": "developer-auth",
3162
+ "summary": "Checks the sign-up code and hands back the organization step.",
3163
+ "audience": "internal",
3164
+ "auth": "none",
3165
+ "rateLimited": true,
3166
+ "ownerTier": false,
3167
+ "status": 200,
3168
+ "params": [],
3169
+ "query": null,
3170
+ "request": null,
3171
+ "response": null,
3172
+ "errors": [
3173
+ "rate_limited",
3174
+ "token_spent",
3175
+ "signup_closed",
3176
+ "wrong_browser",
3177
+ "validation_error",
3178
+ "invalid_code"
3179
+ ],
3180
+ "transport": "http",
3181
+ "notes": "A wrong code renders the code card again with the attempts left (`400 invalid_code`); a spent, expired or exhausted one is `410 token_spent`. The step before must have run in **this** browser (`401 wrong_browser`). A browser form post gets the organization card, the address shown `confirmed`; a JSON caller gets `{ \"next\" }`, which has no schema."
3182
+ },
3183
+ {
3184
+ "method": "POST",
3185
+ "path": "/console/oauth/signup/organization",
3186
+ "section": "developer-auth",
3187
+ "summary": "Takes the organization name and creates the org and its founding Owner.",
3188
+ "audience": "internal",
3189
+ "auth": "none",
3190
+ "rateLimited": true,
3191
+ "ownerTier": false,
3192
+ "status": 200,
3193
+ "params": [],
3194
+ "query": null,
3195
+ "request": null,
3196
+ "response": "oauth-redirect-response",
3197
+ "errors": [
3198
+ "rate_limited",
3199
+ "token_spent",
3200
+ "signup_closed",
3201
+ "wrong_browser",
3202
+ "validation_error",
3203
+ "email_taken"
3204
+ ],
3205
+ "transport": "http",
3206
+ "notes": "The form carries `org_name` only. The org and its founding Owner are created in one transaction, and only after the code step confirmed the address. Both steps before must have run in **this** browser: a missing or mismatched proof cookie is `401 wrong_browser` and the person is sent back to step one. `409 email_taken` when the address was taken meanwhile. A browser form post gets a `303` to the console callback; a JSON caller gets `redirect_to` at `200`. This is the only way an organisation is created: `POST /api/auth/signup` is gone, because without a password it would hand a session to anybody who names an address."
3207
+ },
3208
+ {
3209
+ "method": "POST",
3210
+ "path": "/console/oauth/token",
3211
+ "section": "developer-auth",
3212
+ "summary": "Exchanges the console's authorization code for a developer session.",
3213
+ "audience": "internal",
3214
+ "auth": "none",
3215
+ "rateLimited": false,
3216
+ "ownerTier": false,
3217
+ "status": 200,
3218
+ "params": [],
3219
+ "query": null,
3220
+ "request": null,
3221
+ "response": "session-tokens",
3222
+ "errors": [],
3223
+ "transport": "http",
3224
+ "notes": "Called by the console's own server, never by a browser. Refusals use RFC 6749 §5.2's flat `oauthError` shape — it is a token endpoint, and that is the dialect a caller of one expects — so it emits none of the codes in this reference. Proof of possession is checked before the replay check, and the single-use consume is atomic, so exactly one caller ever mints. The Org Admins membership is re-read here: the code was minted earlier, and a user moved out in between must not get a console session."
3225
+ },
3226
+ {
3227
+ "method": "GET",
3228
+ "path": "/app/:appIdentifier/logo",
3229
+ "section": "client-auth",
3230
+ "summary": "Serves the app's logo for its hosted pages.",
3231
+ "audience": "internal",
3232
+ "auth": "none",
3233
+ "rateLimited": false,
3234
+ "ownerTier": false,
3235
+ "status": 200,
3236
+ "params": [
3237
+ {
3238
+ "name": "appIdentifier",
3239
+ "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool."
3240
+ }
3241
+ ],
3242
+ "query": null,
3243
+ "request": null,
3244
+ "response": null,
3245
+ "errors": [],
3246
+ "transport": "http",
3247
+ "notes": "The stored PNG or SVG, as written through `PUT /api/apps/:id/auth-config/logo`; `404` when the app has none. **Served sandboxed**: `X-Content-Type-Options: nosniff` and `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox`, so a script inside an SVG never runs, even opened directly. The hosted pages embed it as an image only."
3248
+ },
3249
+ {
3250
+ "method": "GET",
3251
+ "path": "/app/:appIdentifier/mcp/:interaction",
3252
+ "section": "client-auth",
3253
+ "summary": "Serves the hosted MCP sign-in, the page an unset `mcp_login_url` falls back to.",
3254
+ "audience": "internal",
3255
+ "auth": "none",
3256
+ "rateLimited": false,
3257
+ "ownerTier": false,
3258
+ "status": 200,
3259
+ "params": [
3260
+ {
3261
+ "name": "appIdentifier",
3262
+ "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool."
3263
+ },
3264
+ {
3265
+ "name": "interaction",
3266
+ "description": "The interaction id `GET /mcp/:appIdentifier/oauth/authorize` put into the hosted MCP sign-in URL."
3267
+ }
3268
+ ],
3269
+ "query": null,
3270
+ "request": null,
3271
+ "response": null,
3272
+ "errors": [],
3273
+ "transport": "http",
3274
+ "notes": "HTML: the app's identity providers, then the sign-in the app offers — email and password with `Email me a sign-in code instead`, 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 `410` page. The browser-proof cookie is set here."
3275
+ },
3276
+ {
3277
+ "method": "POST",
3278
+ "path": "/app/:appIdentifier/mcp/:interaction/password",
3279
+ "section": "client-auth",
3280
+ "summary": "Checks the email and password of the hosted MCP sign-in.",
3281
+ "audience": "internal",
3282
+ "auth": "none",
3283
+ "rateLimited": true,
3284
+ "ownerTier": false,
3285
+ "status": 200,
3286
+ "params": [
3287
+ {
3288
+ "name": "appIdentifier",
3289
+ "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool."
3290
+ },
3291
+ {
3292
+ "name": "interaction",
3293
+ "description": "The interaction id `GET /mcp/:appIdentifier/oauth/authorize` put into the hosted MCP sign-in URL."
3294
+ }
3295
+ ],
3296
+ "query": null,
3297
+ "request": null,
3298
+ "response": null,
3299
+ "errors": [
3300
+ "rate_limited"
3301
+ ],
3302
+ "transport": "http",
3303
+ "notes": "HTML: the next page on success, the same page with the problem named on a refusal. One refusal for every miss, as `POST /api/client/login` answers. With a second factor to give, the two-factor page follows; otherwise the consent page."
3304
+ },
3305
+ {
3306
+ "method": "POST",
3307
+ "path": "/app/:appIdentifier/mcp/:interaction/code",
3308
+ "section": "client-auth",
3309
+ "summary": "Mails a sign-in code for the hosted MCP sign-in and renders the code page.",
3310
+ "audience": "internal",
3311
+ "auth": "none",
3312
+ "rateLimited": true,
3313
+ "ownerTier": false,
3314
+ "status": 200,
3315
+ "params": [
3316
+ {
3317
+ "name": "appIdentifier",
3318
+ "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool."
3319
+ },
3320
+ {
3321
+ "name": "interaction",
3322
+ "description": "The interaction id `GET /mcp/:appIdentifier/oauth/authorize` put into the hosted MCP sign-in URL."
3323
+ }
3324
+ ],
3325
+ "query": null,
3326
+ "request": null,
3327
+ "response": null,
3328
+ "errors": [
3329
+ "rate_limited"
3330
+ ],
3331
+ "transport": "http",
3332
+ "notes": "HTML: the next page on success, the same page with the problem named on a refusal. The same page for a known and an unknown address, as `POST /api/client/login/code` answers; six boxes, `Resend code in 0:42`, and `Use password instead` where the app has passwords on."
3333
+ },
3334
+ {
3335
+ "method": "POST",
3336
+ "path": "/app/:appIdentifier/mcp/:interaction/code/verify",
3337
+ "section": "client-auth",
3338
+ "summary": "Checks the emailed code of the hosted MCP sign-in.",
3339
+ "audience": "internal",
3340
+ "auth": "none",
3341
+ "rateLimited": true,
3342
+ "ownerTier": false,
3343
+ "status": 200,
3344
+ "params": [
3345
+ {
3346
+ "name": "appIdentifier",
3347
+ "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool."
3348
+ },
3349
+ {
3350
+ "name": "interaction",
3351
+ "description": "The interaction id `GET /mcp/:appIdentifier/oauth/authorize` put into the hosted MCP sign-in URL."
3352
+ }
3353
+ ],
3354
+ "query": null,
3355
+ "request": null,
3356
+ "response": null,
3357
+ "errors": [
3358
+ "rate_limited"
3359
+ ],
3360
+ "transport": "http",
3361
+ "notes": "HTML: the next page on success, the same page with the problem named on a refusal. A wrong code renders the code page with the attempts left; a spent one asks for a new code. With a second factor to give, the two-factor page follows; otherwise the consent page."
3362
+ },
3363
+ {
3364
+ "method": "POST",
3365
+ "path": "/app/:appIdentifier/mcp/:interaction/consent",
3366
+ "section": "client-auth",
3367
+ "summary": "Records the allow-or-deny of the hosted MCP sign-in and sends the browser back to the client.",
3368
+ "audience": "internal",
3369
+ "auth": "none",
3370
+ "rateLimited": true,
3371
+ "ownerTier": false,
3372
+ "status": 200,
3373
+ "params": [
3374
+ {
3375
+ "name": "appIdentifier",
3376
+ "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool."
3377
+ },
3378
+ {
3379
+ "name": "interaction",
3380
+ "description": "The interaction id `GET /mcp/:appIdentifier/oauth/authorize` put into the hosted MCP sign-in URL."
3381
+ }
3382
+ ],
3383
+ "query": null,
3384
+ "request": null,
3385
+ "response": null,
3386
+ "errors": [
3387
+ "rate_limited"
3388
+ ],
3389
+ "transport": "http",
3390
+ "notes": "Answers a `303` to the MCP client's callback, carrying the code or `error=access_denied`, as `POST /api/client/mcp/interactions/:id/approve` and `…/deny` do. Fail-closed: anything but the Allow value denies. The browser-proof cookie set at the sign-in must match, or the `wrong_browser` page renders."
3391
+ },
3392
+ {
3393
+ "method": "POST",
3394
+ "path": "/app/:appIdentifier/two-factor",
3395
+ "section": "client-auth",
3396
+ "summary": "Checks an authenticator or recovery code on a hosted page and finishes the sign-in it interrupted.",
3397
+ "audience": "internal",
3398
+ "auth": "none",
3399
+ "rateLimited": true,
3400
+ "ownerTier": false,
3401
+ "status": 200,
3402
+ "params": [
3403
+ {
3404
+ "name": "appIdentifier",
3405
+ "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool."
3406
+ }
3407
+ ],
3408
+ "query": null,
3409
+ "request": null,
3410
+ "response": null,
3411
+ "errors": [
3412
+ "rate_limited"
3413
+ ],
3414
+ "transport": "http",
3415
+ "notes": "HTML: the next page on success, the same page with the problem named on a refusal. The form carries the challenge the step before answered and either `code` or `recovery_code`, checked as `POST /api/client/two-factor/verify` checks them. Success continues where the sign-in was going: the MCP consent, or the done page."
3416
+ },
3417
+ {
3418
+ "method": "POST",
3419
+ "path": "/app/:appIdentifier/two-factor/setup",
3420
+ "section": "client-auth",
3421
+ "summary": "Starts an authenticator setup on a hosted page, when the app requires two-factor.",
3422
+ "audience": "internal",
3423
+ "auth": "none",
3424
+ "rateLimited": true,
3425
+ "ownerTier": false,
3426
+ "status": 200,
3427
+ "params": [
3428
+ {
3429
+ "name": "appIdentifier",
3430
+ "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool."
3431
+ }
3432
+ ],
3433
+ "query": null,
3434
+ "request": null,
3435
+ "response": null,
3436
+ "errors": [
3437
+ "rate_limited"
3438
+ ],
3439
+ "transport": "http",
3440
+ "notes": "HTML: the next page on success, the same page with the problem named on a refusal. Renders the QR code, the key with `Copy key`, and the confirm field, for the challenge the step before answered."
3441
+ },
3442
+ {
3443
+ "method": "POST",
3444
+ "path": "/app/:appIdentifier/two-factor/setup/confirm",
3445
+ "section": "client-auth",
3446
+ "summary": "Confirms the new authenticator on a hosted page and shows the ten recovery codes.",
3447
+ "audience": "internal",
3448
+ "auth": "none",
3449
+ "rateLimited": true,
3450
+ "ownerTier": false,
3451
+ "status": 200,
3452
+ "params": [
3453
+ {
3454
+ "name": "appIdentifier",
3455
+ "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool."
3456
+ }
3457
+ ],
3458
+ "query": null,
3459
+ "request": null,
3460
+ "response": null,
3461
+ "errors": [
3462
+ "rate_limited"
3463
+ ],
3464
+ "transport": "http",
3465
+ "notes": "HTML: the next page on success, the same page with the problem named on a refusal. A wrong code renders the setup page again. On success the ten recovery codes are shown once, with `Copy` and `Download .txt`; `I saved my recovery codes` gates `Continue`."
3466
+ },
3467
+ {
3468
+ "method": "GET",
3469
+ "path": "/app/:appIdentifier/invite/:token",
3470
+ "section": "client-auth",
3471
+ "summary": "Serves the hosted invitation page, the one an unset `invite_url` falls back to.",
3472
+ "audience": "internal",
3473
+ "auth": "none",
3474
+ "rateLimited": false,
3475
+ "ownerTier": false,
3476
+ "status": 200,
3477
+ "params": [
3478
+ {
3479
+ "name": "appIdentifier",
3480
+ "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool."
3481
+ },
3482
+ {
3483
+ "name": "token",
3484
+ "description": "The opaque token from the mailed link; it is never sent as a query parameter."
3485
+ }
3486
+ ],
3487
+ "query": null,
3488
+ "request": null,
3489
+ "response": null,
3490
+ "errors": [],
3491
+ "transport": "http",
3492
+ "notes": "HTML. Renders a form; only its `POST` spends the token, so a mail scanner opening the link changes nothing. Asks for a name, and a password only while the app has passwords on; announces the two-factor setup when the app requires it. A spent, expired or unknown token renders the `410` page with the next step for its kind."
3493
+ },
3494
+ {
3495
+ "method": "POST",
3496
+ "path": "/app/:appIdentifier/invite",
3497
+ "section": "client-auth",
3498
+ "summary": "Accepts an invitation from the hosted invitation page.",
3499
+ "audience": "internal",
3500
+ "auth": "none",
3501
+ "rateLimited": true,
3502
+ "ownerTier": false,
3503
+ "status": 200,
3504
+ "params": [
3505
+ {
3506
+ "name": "appIdentifier",
3507
+ "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool."
3508
+ }
3509
+ ],
3510
+ "query": null,
3511
+ "request": null,
3512
+ "response": null,
3513
+ "errors": [
3514
+ "rate_limited"
3515
+ ],
3516
+ "transport": "http",
3517
+ "notes": "HTML: the next page on success, the same page with the problem named on a refusal. Spends the token as `POST /api/client/invitations/accept` does; the two-factor setup follows when the app requires it, then the done page."
3518
+ },
3519
+ {
3520
+ "method": "GET",
3521
+ "path": "/app/:appIdentifier/reset/:token",
3522
+ "section": "client-auth",
3523
+ "summary": "Serves the hosted new-password page, the one an unset `reset_url` falls back to.",
3524
+ "audience": "internal",
3525
+ "auth": "none",
3526
+ "rateLimited": false,
3527
+ "ownerTier": false,
3528
+ "status": 200,
3529
+ "params": [
3530
+ {
3531
+ "name": "appIdentifier",
3532
+ "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool."
3533
+ },
3534
+ {
3535
+ "name": "token",
3536
+ "description": "The opaque token from the mailed link; it is never sent as a query parameter."
3537
+ }
3538
+ ],
3539
+ "query": null,
3540
+ "request": null,
3541
+ "response": null,
3542
+ "errors": [],
3543
+ "transport": "http",
3544
+ "notes": "HTML. Renders a form; only its `POST` spends the token, so a mail scanner opening the link changes nothing. Says that two-factor stays on. A spent, expired or unknown token renders the `410` page with the next step for its kind."
3545
+ },
3546
+ {
3547
+ "method": "POST",
3548
+ "path": "/app/:appIdentifier/reset",
3549
+ "section": "client-auth",
3550
+ "summary": "Sets the new password from the hosted new-password page.",
3551
+ "audience": "internal",
3552
+ "auth": "none",
3553
+ "rateLimited": true,
2342
3554
  "ownerTier": false,
2343
3555
  "status": 200,
2344
3556
  "params": [
2345
3557
  {
2346
- "name": "id",
2347
- "description": "The interaction id minted by `GET /console/oauth/authorize`, which redirects the browser here."
3558
+ "name": "appIdentifier",
3559
+ "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool."
2348
3560
  }
2349
3561
  ],
2350
3562
  "query": null,
2351
3563
  "request": null,
2352
3564
  "response": null,
2353
- "errors": [],
3565
+ "errors": [
3566
+ "rate_limited"
3567
+ ],
2354
3568
  "transport": "http",
2355
- "notes": "HTML. An expired, consumed, unknown or hand-edited interaction renders one page at `410`: which of the four it was is not a fact a stranger may learn, and to the person it is one fact anyway. The page resolves nothing about the address typed into it, so there is no enumeration oracle here at all."
3569
+ "notes": "HTML: the next page on success, the same page with the problem named on a refusal. Spends the token as `POST /api/client/password/reset/confirm` does; a person with an authenticator gives a code before the done page."
2356
3570
  },
2357
3571
  {
2358
- "method": "POST",
2359
- "path": "/console/oauth/identify",
2360
- "section": "developer-auth",
2361
- "summary": "Takes the email address and hands back the password step.",
3572
+ "method": "GET",
3573
+ "path": "/app/:appIdentifier/forgot",
3574
+ "section": "client-auth",
3575
+ "summary": "Serves the hosted \"forgot your password\" page.",
2362
3576
  "audience": "internal",
2363
3577
  "auth": "none",
2364
- "rateLimited": true,
3578
+ "rateLimited": false,
2365
3579
  "ownerTier": false,
2366
3580
  "status": 200,
2367
- "params": [],
3581
+ "params": [
3582
+ {
3583
+ "name": "appIdentifier",
3584
+ "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool."
3585
+ }
3586
+ ],
2368
3587
  "query": null,
2369
3588
  "request": null,
2370
3589
  "response": null,
2371
- "errors": [
2372
- "rate_limited",
2373
- "token_spent"
2374
- ],
3590
+ "errors": [],
2375
3591
  "transport": "http",
2376
- "notes": "A browser form post gets the password card as HTML; a JSON caller gets `{ \"next\": \"/console/oauth/login\" }`, which has no schema — the step made no decision, and it says so rather than inventing a redirect. A dead interaction is `410 token_spent`. Rate limited despite spending no credential: it is an unauthenticated endpoint that renders a page."
3592
+ "notes": "HTML: the address field. Reached from the hosted sign-in and the dead-link page."
2377
3593
  },
2378
3594
  {
2379
3595
  "method": "POST",
2380
- "path": "/console/oauth/login",
2381
- "section": "developer-auth",
2382
- "summary": "Checks the password and mints the authorization code the console exchanges.",
3596
+ "path": "/app/:appIdentifier/forgot",
3597
+ "section": "client-auth",
3598
+ "summary": "Mails a reset link from the hosted \"forgot your password\" page.",
2383
3599
  "audience": "internal",
2384
3600
  "auth": "none",
2385
3601
  "rateLimited": true,
2386
3602
  "ownerTier": false,
2387
3603
  "status": 200,
2388
- "params": [],
3604
+ "params": [
3605
+ {
3606
+ "name": "appIdentifier",
3607
+ "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool."
3608
+ }
3609
+ ],
2389
3610
  "query": null,
2390
3611
  "request": null,
2391
- "response": "oauth-redirect-response",
3612
+ "response": null,
2392
3613
  "errors": [
2393
- "rate_limited",
2394
- "token_spent",
2395
- "invalid_credentials"
3614
+ "rate_limited"
2396
3615
  ],
2397
3616
  "transport": "http",
2398
- "notes": "This is the only place a Fleetless developer password may be typed; `POST /api/auth/login` is gone, because a second credential door means every security property has to be right in two places. A browser form post gets a `303` to the callback URL; a JSON caller gets that same URL as `redirect_to` at `200`. An argon2 verify runs whether or not the address exists, and the Org Admins check runs after it — filtering first would hand back a faster \"no\" for a non-admin account, which is a timing oracle. Wrong password, unknown address and \"not an org admin\" render identical bytes under one `401`."
3617
+ "notes": "HTML: the next page on success, the same page with the problem named on a refusal. The same \"check your mail\" page for a known and an unknown address, as `POST /api/client/password/reset` answers."
2399
3618
  },
2400
3619
  {
2401
3620
  "method": "GET",
2402
- "path": "/console/oauth/signup/:id",
2403
- "section": "developer-auth",
2404
- "summary": "Serves step one of console sign-up, the account card.",
3621
+ "path": "/app/:appIdentifier/sign-up",
3622
+ "section": "client-auth",
3623
+ "summary": "Serves the hosted sign-up page, when the app has self-registration on.",
2405
3624
  "audience": "internal",
2406
3625
  "auth": "none",
2407
3626
  "rateLimited": false,
@@ -2409,8 +3628,8 @@
2409
3628
  "status": 200,
2410
3629
  "params": [
2411
3630
  {
2412
- "name": "id",
2413
- "description": "The interaction id minted by `GET /console/oauth/authorize` with `?prompt=create`."
3631
+ "name": "appIdentifier",
3632
+ "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool."
2414
3633
  }
2415
3634
  ],
2416
3635
  "query": null,
@@ -2418,74 +3637,84 @@
2418
3637
  "response": null,
2419
3638
  "errors": [],
2420
3639
  "transport": "http",
2421
- "notes": "HTML. While the deployment runs in closed beta this renders the \"sign-up is closed\" card at `403` instead, keeping the interaction alive and pointing back at sign-in — the person may well already have an account."
3640
+ "notes": "HTML: the address, a password while the app has passwords on, and an optional name. With self-registration off it renders the \"registration closed\" page."
2422
3641
  },
2423
3642
  {
2424
3643
  "method": "POST",
2425
- "path": "/console/oauth/signup",
2426
- "section": "developer-auth",
2427
- "summary": "Takes the sign-up email and password and hands back the organization step.",
3644
+ "path": "/app/:appIdentifier/sign-up",
3645
+ "section": "client-auth",
3646
+ "summary": "Creates an account from the hosted sign-up page and mails the confirmation link.",
2428
3647
  "audience": "internal",
2429
3648
  "auth": "none",
2430
3649
  "rateLimited": true,
2431
3650
  "ownerTier": false,
2432
3651
  "status": 200,
2433
- "params": [],
3652
+ "params": [
3653
+ {
3654
+ "name": "appIdentifier",
3655
+ "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool."
3656
+ }
3657
+ ],
2434
3658
  "query": null,
2435
3659
  "request": null,
2436
3660
  "response": null,
2437
3661
  "errors": [
2438
- "rate_limited",
2439
- "token_spent",
2440
- "signup_closed",
2441
- "validation_error",
2442
- "email_taken"
3662
+ "rate_limited"
2443
3663
  ],
2444
3664
  "transport": "http",
2445
- "notes": "A browser form post gets the organization card; a JSON caller gets `{ \"next\", \"email\" }`, which has no schema. The plaintext password exists for this one request: what is stored is its argon2 hash, on the interaction row, which expires with it. A per-interaction proof cookie is set here — it is what stops a third party from finishing a sign-up somebody else started. Sign-up is the one surface whose job is to say an address is taken, so `409 email_taken` is not a leak here."
3665
+ "notes": "HTML: the next page on success, the same page with the problem named on a refusal. Registers as `POST /api/client/register` does, and renders the same \"check your mail\" page for a new and a known address."
2446
3666
  },
2447
3667
  {
2448
- "method": "POST",
2449
- "path": "/console/oauth/signup/organization",
2450
- "section": "developer-auth",
2451
- "summary": "Takes the organization name and creates the org and its founding Owner.",
3668
+ "method": "GET",
3669
+ "path": "/app/:appIdentifier/verify/:token",
3670
+ "section": "client-auth",
3671
+ "summary": "Serves the hosted email-confirmation page, the one an unset `verify_url` falls back to.",
2452
3672
  "audience": "internal",
2453
3673
  "auth": "none",
2454
- "rateLimited": true,
3674
+ "rateLimited": false,
2455
3675
  "ownerTier": false,
2456
3676
  "status": 200,
2457
- "params": [],
3677
+ "params": [
3678
+ {
3679
+ "name": "appIdentifier",
3680
+ "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool."
3681
+ },
3682
+ {
3683
+ "name": "token",
3684
+ "description": "The opaque token from the mailed link; it is never sent as a query parameter."
3685
+ }
3686
+ ],
2458
3687
  "query": null,
2459
3688
  "request": null,
2460
- "response": "oauth-redirect-response",
2461
- "errors": [
2462
- "rate_limited",
2463
- "token_spent",
2464
- "signup_closed",
2465
- "wrong_browser",
2466
- "validation_error",
2467
- "email_taken"
2468
- ],
3689
+ "response": null,
3690
+ "errors": [],
2469
3691
  "transport": "http",
2470
- "notes": "The same single transaction `POST /api/auth/signup` runs. Step one must have run in **this** browser: a missing or mismatched proof cookie is `401 wrong_browser` and the person is sent back to step one. A browser form post gets a `303` to the console callback; a JSON caller gets `redirect_to` at `200`."
3692
+ "notes": "HTML. Renders a form; only its `POST` spends the token, so a mail scanner opening the link changes nothing. A spent, expired or unknown token renders the `410` page with the next step for its kind."
2471
3693
  },
2472
3694
  {
2473
3695
  "method": "POST",
2474
- "path": "/console/oauth/token",
2475
- "section": "developer-auth",
2476
- "summary": "Exchanges the console's authorization code for a developer session.",
3696
+ "path": "/app/:appIdentifier/verify",
3697
+ "section": "client-auth",
3698
+ "summary": "Confirms the address from the hosted email-confirmation page.",
2477
3699
  "audience": "internal",
2478
3700
  "auth": "none",
2479
- "rateLimited": false,
3701
+ "rateLimited": true,
2480
3702
  "ownerTier": false,
2481
3703
  "status": 200,
2482
- "params": [],
3704
+ "params": [
3705
+ {
3706
+ "name": "appIdentifier",
3707
+ "description": "The app's public identifier — `app.identifier`, what the console prints beside its copy button. It is not a secret: it appears in this path, in both metadata documents, and in the URL an app user pastes into their AI tool."
3708
+ }
3709
+ ],
2483
3710
  "query": null,
2484
3711
  "request": null,
2485
- "response": "session-tokens",
2486
- "errors": [],
3712
+ "response": null,
3713
+ "errors": [
3714
+ "rate_limited"
3715
+ ],
2487
3716
  "transport": "http",
2488
- "notes": "Called by the console's own server, never by a browser. Refusals use RFC 6749 §5.2's flat `oauthError` shape — it is a token endpoint, and that is the dialect a caller of one expects — so it emits none of the codes in this reference. Proof of possession is checked before the replay check, and the single-use consume is atomic, so exactly one caller ever mints. The Org Admins membership is re-read here: the code was minted earlier, and a user moved out in between must not get a console session."
3717
+ "notes": "HTML: the next page on success, the same page with the problem named on a refusal. Spends the token as `POST /api/client/verify-email` does; the two-factor setup follows when the app requires it, then the done page."
2489
3718
  },
2490
3719
  {
2491
3720
  "method": "GET",
@@ -2655,7 +3884,7 @@
2655
3884
  "not_found"
2656
3885
  ],
2657
3886
  "transport": "http",
2658
- "notes": "RFC 8414, for the issuer `<PUBLIC_API_BASE_URL>/mcp/<identifier>` — the same path rule as the document above, and the same `404` for a switched-off app. `registration_endpoint` is present for the reason the central document states: a client that finds it registers itself and never asks a person for a `client_id`. \n\n**`issuer`, `token_endpoint` and the resource identifier are minted from the canonical public base, never from the friendly `mcp.fleetless.dev` alias or the request's `Host`**, because a client checks a minted token's `iss` and `aud` against these exact strings. \n\n**Unlike the central document, `authorization_endpoint` does not move to an auth-portal origin**, and there is nothing here for one to serve: this authorization step renders no Fleetless page at all. It redirects to the app's own `mcp_login_url`, which is on the developer's origin already."
3887
+ "notes": "RFC 8414, for the issuer `<PUBLIC_API_BASE_URL>/mcp/<identifier>` — the same path rule as the document above, and the same `404` for a switched-off app. `registration_endpoint` is present for the reason the central document states: a client that finds it registers itself and never asks a person for a `client_id`. \n\n**`issuer`, `token_endpoint` and the resource identifier are minted from the canonical public base, never from the friendly `mcp.fleetless.dev` alias or the request's `Host`**, because a client checks a minted token's `iss` and `aud` against these exact strings. \n\n**Unlike the central document, `authorization_endpoint` does not move to an auth-portal origin**: the authorization step renders no page itself. It redirects to the app's own `mcp_login_url`, or to the hosted MCP sign-in on the auth portal when the app has configured none."
2659
3888
  },
2660
3889
  {
2661
3890
  "method": "POST",
@@ -2687,7 +3916,7 @@
2687
3916
  "method": "GET",
2688
3917
  "path": "/mcp/:appIdentifier/oauth/authorize",
2689
3918
  "section": "mcp",
2690
- "summary": "Starts an MCP sign-in and redirects the browser to the app's own login page.",
3919
+ "summary": "Starts an MCP sign-in and redirects the browser to the app's own login page, or to the hosted one.",
2691
3920
  "audience": "client",
2692
3921
  "auth": "none",
2693
3922
  "rateLimited": false,
@@ -2703,11 +3932,10 @@
2703
3932
  "request": null,
2704
3933
  "response": null,
2705
3934
  "errors": [
2706
- "not_found",
2707
- "target_state_conflict"
3935
+ "not_found"
2708
3936
  ],
2709
3937
  "transport": "http",
2710
- "notes": "The same query as `GET /mcp/oauth/authorize`, read the same way — parameter by parameter, because the answers differ and one parse 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 — and redirects to `appAuthConfig.mcp_login_url` with `{interaction}` filled in. The app then authenticates the person with its own 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 deny. \n\nClient and `redirect_uri` are validated first and a failure there never redirects — the open-redirect discipline `GET /mcp/oauth/authorize` and `GET /api/client/oidc/:slug/start` both keep — and those refusals are RFC 6749's flat `oauthError`, which is why none of them appear above. `redirect_uri` is matched **exactly** against the registration, with no loopback-port wildcard: every client here registered itself minutes ago and can name the port it bound, so a wildcard would only widen where a stolen `client_id` may send a browser. \n\nThe two codes above are the `apiError` envelope because they are refusals about the **app**, decided before an OAuth parameter is looked at. **`404 not_found` covers an identifier no app carries AND an app with MCP switched off** — the same single answer the two metadata documents, `register` and the transport give. An earlier draft answered `403 mcp_disabled` here, on the argument that a client which registered while the switch was on is owed the difference between \"turned off\" and \"mistyped\"; that argument does not survive the caller being anonymous. This route takes no credential, so the extra code was readable by anyone who could type an identifier, and it handed back precisely the existence distinction every neighbouring route collapses. `mcp_disabled` survives only where the caller has already proved they belong to the app — the two decision routes under `/api/client/mcp/interactions/:id`. `409 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 is the same code and the same shape `send_mail` answers for an unconfigured `invite_url`, and the refusal is the honest one — Fleetless has nowhere to redirect, and rendering a page of its own would contradict the rule that Fleetless shows an app user no page."
3938
+ "notes": "The same query as `GET /mcp/oauth/authorize`, read the same way — parameter by parameter, because the answers differ and one parse would collapse them. \n\n**This route renders no page.** It writes an interaction — ten minutes, as the OIDC ones live — and redirects to `appAuthConfig.mcp_login_url` with `{interaction}` filled in. The app then authenticates the person with its own 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 deny. **An app with no `mcp_login_url` is redirected to the hosted MCP sign-in** (`GET /app/:appIdentifier/mcp/:interaction`), 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 /mcp/oauth/authorize` and `GET /api/client/oidc/:slug/start` both keep — and those refusals are RFC 6749's flat `oauthError`, which is why none of them appear above. `redirect_uri` is matched **exactly** against the registration, with no loopback-port wildcard: every client here registered itself minutes ago and can name the port it bound, so a wildcard would only widen where a stolen `client_id` may send a browser. \n\nThe code above is the `apiError` envelope because it is a refusal about the **app**, decided before an OAuth parameter is looked at. **`404 not_found` covers an identifier no app carries AND an app with MCP switched off** — the same single answer the two metadata documents, `register` and the transport give. An earlier draft answered `403 mcp_disabled` here, on the argument that a client which registered while the switch was on is owed the difference between \"turned off\" and \"mistyped\"; that argument does not survive the caller being anonymous. This route takes no credential, so the extra code was readable by anyone who could type an identifier, and it handed back precisely the existence distinction every neighbouring route collapses. `mcp_disabled` survives only where the caller has already proved they belong to the app — the two decision routes under `/api/client/mcp/interactions/:id`."
2711
3939
  },
2712
3940
  {
2713
3941
  "method": "POST",
@@ -2745,14 +3973,62 @@
2745
3973
  "params": [],
2746
3974
  "query": null,
2747
3975
  "request": "client-login-request",
2748
- "response": "session-tokens",
3976
+ "response": "client-sign-in-result",
2749
3977
  "errors": [
2750
3978
  "rate_limited",
2751
3979
  "validation_error",
2752
- "invalid_credentials"
3980
+ "invalid_credentials",
3981
+ "method_not_allowed"
3982
+ ],
3983
+ "transport": "http",
3984
+ "notes": "One refusal for every miss — unknown app, unknown address, wrong password, a `blocked` account and one still `pending_verification` — because the caller supplies the `app_identifier` unauthenticated, so \"this app knows this user\" is not a fact the answer may carry. The argon2 verify is paid unconditionally, including for an unknown app identifier, so response time is not an oracle either. \n\n**The answer is a `clientSignInResult`**: session tokens, or a `twoFactorChallenge` when the person has a confirmed authenticator or the app requires one — then no session exists until `POST /api/client/two-factor/verify` or the setup is done. `403 method_not_allowed` when the app has the password method off; it names the app's policy, not a person."
3985
+ },
3986
+ {
3987
+ "method": "POST",
3988
+ "path": "/api/client/login/code",
3989
+ "section": "client-auth",
3990
+ "summary": "Mails a six-digit sign-in code, and answers the same whether or not the address exists.",
3991
+ "audience": "client",
3992
+ "auth": "none",
3993
+ "rateLimited": true,
3994
+ "ownerTier": false,
3995
+ "status": 202,
3996
+ "params": [],
3997
+ "query": null,
3998
+ "request": "client-login-code-request",
3999
+ "response": null,
4000
+ "errors": [
4001
+ "rate_limited",
4002
+ "validation_error",
4003
+ "not_found",
4004
+ "method_not_allowed"
4005
+ ],
4006
+ "transport": "http",
4007
+ "notes": "**`202` and an empty body for every request the policy allows**, in status, body and timing, whether or not the address names an active account of this app — a decoy like `POST /api/client/resend-verification`, so this is no enumeration oracle. A mail goes out 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 the previous one for the same address; a request within sixty seconds of the last sends no second mail. The address is trimmed and compared case-insensitively. `404 not_found` is the **app identifier**, never the address; `403 method_not_allowed` when the app has the email-code method off. Limited per app, address and IP, so it cannot be used to mail somebody repeatedly."
4008
+ },
4009
+ {
4010
+ "method": "POST",
4011
+ "path": "/api/client/login/code/verify",
4012
+ "section": "client-auth",
4013
+ "summary": "Spends a mailed sign-in code and answers a session or a two-factor challenge.",
4014
+ "audience": "client",
4015
+ "auth": "none",
4016
+ "rateLimited": true,
4017
+ "ownerTier": false,
4018
+ "status": 200,
4019
+ "params": [],
4020
+ "query": null,
4021
+ "request": "client-login-code-verify-request",
4022
+ "response": "client-sign-in-result",
4023
+ "errors": [
4024
+ "rate_limited",
4025
+ "validation_error",
4026
+ "invalid_code",
4027
+ "token_spent",
4028
+ "method_not_allowed"
2753
4029
  ],
2754
4030
  "transport": "http",
2755
- "notes": "One refusal for every miss — unknown app, unknown address, wrong password, a `blocked` account and one still `pending_verification` — because the caller supplies the `app_identifier` unauthenticated, so \"this app knows this user\" is not a fact the answer may carry. The argon2 verify is paid unconditionally, including for an unknown app identifier, so response time is not an oracle either."
4031
+ "notes": "A wrong code is `400 invalid_code` with `details.attempts_left` (`invalidCodeDetails`). A code that is spent, past its ten minutes, out of attempts, or was never mailed is `410 token_spent` — one answer, because telling them apart would say whether a code was ever sent to that address; the recovery is the same, ask for a new code. The address is trimmed and compared case-insensitively, so the address typed at the request and here need not match in case. \n\n**The answer is a `clientSignInResult`**, like the password login: tokens, or a `twoFactorChallenge` when the person has an authenticator or the app requires one. A pending-verification account that spends a code is activated — reading a mail at that address is the proof verification asks for."
2756
4032
  },
2757
4033
  {
2758
4034
  "method": "POST",
@@ -2778,7 +4054,7 @@
2778
4054
  "quota_exceeded"
2779
4055
  ],
2780
4056
  "transport": "http",
2781
- "notes": "**`202` and an empty body for every request policy allows** — a new address, one this app already knows and one it does not answer identically, in status, body and timing. An answer that depended on existence would be the account-enumeration oracle the whole client family is built to avoid. The account cannot log in until the mailed link is spent; `POST /api/client/verify-email` is what does that. \n\n**An address on an account still `pending_verification` is re-registered, not ignored.** The password and display name from this call replace what is stored, every outstanding verification link for the address stops working, and a fresh one is mailed. Otherwise whoever typed an address first would own the password of the account its real owner later verifies. An address on an `active` account changes nothing and sends nothing — that account has already been proven, and its way back in is `POST /api/client/password/reset`. Neither case is visible in the answer. \n\nThe refusals it *does* make are about policy or about what the caller typed, never about a person. `403 registration_closed` when the app has self-registration off and `403 domain_not_allowed` when the address is outside `allowed_domains`: both are the developer's own configuration, and a stranger learns the app's policy rather than who is in it. **A password under twelve characters is part of that `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, which is what a form needs to mark it. `404 not_found` names an **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 developer's own URLs), while collapsing it into `registration_closed` sent a developer who mistyped their own identifier hunting a configuration bug that was not there. `409 target_state_conflict` when the app has configured no `verify_url` or has no default role — 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. \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 here that is answered **before the address is looked at** — and that ordering is the point rather than an implementation detail: a quota checked after the existence branch would answer `202` for an address the app already knows and `409` for one it does not, which is precisely the enumeration oracle every other line of this route exists to close. At the quota, every registration is refused identically, including one that would only have re-mailed a pending account's link."
4057
+ "notes": "**`202` and an empty body for every request policy allows** — a new address, one this app already knows and one it does not answer identically, in status, body and timing. An answer that depended on existence would be the account-enumeration oracle the whole client family is built to avoid. The account cannot log in until the mailed link is spent; `POST /api/client/verify-email` is what does that. \n\n**An address on an account still `pending_verification` is re-registered, not ignored.** The password and display name from this call replace what is stored, every outstanding verification link for the address stops working, and a fresh one is mailed. Otherwise whoever typed an address first would own the password of the account its real owner later verifies. An address on an `active` account changes nothing and sends nothing — that account has already been proven, and its way back in is `POST /api/client/password/reset`. Neither case is visible in the answer. \n\nThe refusals it *does* make are about policy or about what the caller typed, never about a person. `403 registration_closed` when the app has self-registration off and `403 domain_not_allowed` when the address is outside `allowed_domains`: both are the developer's own configuration, and a stranger learns the app's policy rather than who is in it. **A password under twelve characters is part of that `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, 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, or sent while it is off: an email-code-only app registers people without one. `404 not_found` names an **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 developer's own URLs), while collapsing it into `registration_closed` sent a developer who mistyped their own identifier hunting a configuration bug that was not there. `409 target_state_conflict` when the app has no default role — there would be no role to give the person. An app with no `verify_url` is not refused: the mailed link points at the hosted confirmation page instead. \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 here that is answered **before the address is looked at** — and that ordering is the point rather than an implementation detail: a quota checked after the existence branch would answer `202` for an address the app already knows and `409` for one it does not, which is precisely the enumeration oracle every other line of this route exists to close. At the quota, every registration is refused identically, including one that would only have re-mailed a pending account's link."
2782
4058
  },
2783
4059
  {
2784
4060
  "method": "POST",
@@ -2793,14 +4069,14 @@
2793
4069
  "params": [],
2794
4070
  "query": null,
2795
4071
  "request": "client-verify-email-request",
2796
- "response": "session-tokens",
4072
+ "response": "client-sign-in-result",
2797
4073
  "errors": [
2798
4074
  "rate_limited",
2799
4075
  "validation_error",
2800
4076
  "token_spent"
2801
4077
  ],
2802
4078
  "transport": "http",
2803
- "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 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 activated second, as **two writes**: the spend is the atomic one, so a link opened twice cannot mint two sessions, but a process that died between them would leave a spent token on an account still `pending_verification`, whose recovery is `POST /api/client/resend-verification`. Spending the token also proves the address, so a later `PATCH` may return the account to `active` after a block. \n\n**One refusal for every token that does not work: `410 token_spent`** — unknown, past its twenty-four hours, or already used. There is one code because distinguishing them would tell a stranger whether a token ever existed, and because the recovery is the same in all three cases: ask for a fresh link with `POST /api/client/resend-verification`. An app rendering this refusal should offer that and nothing conditional on which of the three it was."
4079
+ "notes": "**The answer is a session, not a `204`** — or, as on every sign-in step, a `twoFactorChallenge` when the app requires two-factor (`clientSignInResult`). Somebody who has just proved they can read the mail should not be asked to type their 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 activated second, as **two writes**: the spend is the atomic one, so a link opened twice cannot mint two sessions, but a process that died between them would leave a spent token on an account still `pending_verification`, whose recovery is `POST /api/client/resend-verification`. Spending the token also proves the address, so a later `PATCH` may return the account to `active` after a block. \n\n**One refusal for every token that does not work: `410 token_spent`** — unknown, past its twenty-four hours, or already used. There is one code because distinguishing them would tell a stranger whether a token ever existed, and because the recovery is the same in all three cases: ask for a fresh link with `POST /api/client/resend-verification`. An app rendering this refusal should offer that and nothing conditional on which of the three it was."
2804
4080
  },
2805
4081
  {
2806
4082
  "method": "POST",
@@ -2844,7 +4120,7 @@
2844
4120
  "not_found"
2845
4121
  ],
2846
4122
  "transport": "http",
2847
- "notes": "**The app-user twin of `POST /api/auth/password/reset`, and a different shape** because the two surfaces name a person differently: a Fleetless address is globally unique and resolves alone, an app user's is unique only within their app, so the pair is the identifier. Status, body and timing are identical for a known and an unknown address. An account with no Fleetless password — one created through an identity provider — is mailed nothing and still answers `202`. `404 not_found` is the **app identifier**, never the address. The link 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 so would answer for the address as well."
4123
+ "notes": "The pair of app identifier and address is the identifier: an app user's address is unique only within their app. Status, body and timing are identical for a known and an unknown address. An account with no password — one created through an identity provider — is mailed nothing and still answers `202`. `404 not_found` is the **app identifier**, never the address. The link points at the app's `reset_url`, or at the hosted reset page when the app has configured none."
2848
4124
  },
2849
4125
  {
2850
4126
  "method": "POST",
@@ -2859,14 +4135,15 @@
2859
4135
  "params": [],
2860
4136
  "query": null,
2861
4137
  "request": "client-password-reset-confirm-request",
2862
- "response": "session-tokens",
4138
+ "response": "client-sign-in-result",
2863
4139
  "errors": [
2864
4140
  "rate_limited",
2865
4141
  "validation_error",
2866
- "token_spent"
4142
+ "token_spent",
4143
+ "method_not_allowed"
2867
4144
  ],
2868
4145
  "transport": "http",
2869
- "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 states where somebody else may be holding a live session, and the person completing the reset is the one who should keep theirs. The account is activated if it was still `pending_verification`: reading a mail at that address is the same proof verification asks for. \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 because distinguishing them would tell a stranger whether a token ever existed, and the recovery is identical either way: ask for a new link. A replacement password under twelve characters is a `400 validation_error` naming the `new_password` field — the twelve-character minimum is that field's schema rule, and it is refused the way any other malformed field is."
4146
+ "notes": "**A new password does not bypass the second factor**: a person with an authenticator, or in an app that requires one, gets a `twoFactorChallenge` instead of tokens (`clientSignInResult`), and the authenticator stays on. `403 method_not_allowed` when the app 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 states where somebody else may be holding a live session, and the person completing the reset is the one who should keep theirs. The account is activated if it was still `pending_verification`: reading a mail at that address is the same proof verification asks for. \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 because distinguishing them would tell a stranger whether a token ever existed, and the recovery is identical either way: ask for a new link. A replacement password under twelve characters is a `400 validation_error` naming the `new_password` field — the twelve-character minimum is that field's schema rule, and it is refused the way any other malformed field is."
2870
4147
  },
2871
4148
  {
2872
4149
  "method": "POST",
@@ -2881,7 +4158,7 @@
2881
4158
  "params": [],
2882
4159
  "query": null,
2883
4160
  "request": "client-accept-invitation-request",
2884
- "response": "session-tokens",
4161
+ "response": "client-sign-in-result",
2885
4162
  "errors": [
2886
4163
  "rate_limited",
2887
4164
  "validation_error",
@@ -2891,7 +4168,7 @@
2891
4168
  "quota_exceeded"
2892
4169
  ],
2893
4170
  "transport": "http",
2894
- "notes": "**An app invitation, not a team one.** `POST /api/org/invitations/accept` is the other space and answers `204`; this one answers a 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 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, and the invitation **bypasses `allowed_domains`** — a developer inviting somebody by hand has already made the decision the whitelist automates. \n\n**One refusal for every token that does not work: `410 token_spent`** — unknown, expired past the seven days, revoked by the developer, or already accepted. There is one code because telling them apart would say whether a token ever existed, and because 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 under twelve characters is part of the `400 validation_error`, naming the `password` field. `409 email_taken` is an address this app has acquired since the invitation was written **as an account that is already in use** — the invitation stays outstanding rather than being spent, so the developer can revoke it or point the person at the login. An address that registered itself and is still `pending_verification` is not that state: accepting sets the password the invitee just chose, activates the account and gives it the invitation's role, because reading the invitation mail proves the address the verification link was waiting on. \n\n`409 target_state_conflict` names `role_id` with rule `not_set` when the role the invitation was fixed to has since been deleted and the app has no default role to fall back on: there is no access to hand the acceptor, and creating an account with none would be worse than saying so. \n\n**`409 quota_exceeded` when accepting would CREATE an account and the org is at its `max_end_users` limit**, counted across every app of the org. An invitation that names a row the developer already created, and one whose address is held by an unfinished self-registration, both finish an account that already counts — those are not refused, because the org is not one account larger afterwards. The token is not spent by the refusal: the developer can raise the limit, or delete somebody, and the same link still works."
4171
+ "notes": "**An app invitation, not a team one.** `POST /api/org/invitations/accept` is the other space and answers `204`; this one answers a 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 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, and the invitation **bypasses `allowed_domains`** — a developer inviting somebody by hand has already made the decision the whitelist automates. The answer is a `clientSignInResult`: a `twoFactorChallenge` instead of tokens when the app requires two-factor. `password` is required while the app's password method is on and refused while it is off, both as `400 validation_error` naming the field. \n\n**One refusal for every token that does not work: `410 token_spent`** — unknown, expired past the seven days, revoked by the developer, or already accepted. There is one code because telling them apart would say whether a token ever existed, and because 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 under twelve characters is part of the `400 validation_error`, naming the `password` field. `409 email_taken` is an address this app has acquired since the invitation was written **as an account that is already in use** — the invitation stays outstanding rather than being spent, so the developer can revoke it or point the person at the login. An address that registered itself and is still `pending_verification` is not that state: accepting sets the password the invitee just chose, activates the account and gives it the invitation's role, because reading the invitation mail proves the address the verification link was waiting on. \n\n`409 target_state_conflict` names `role_id` with rule `not_set` when the role the invitation was fixed to has since been deleted and the app has no default role to fall back on: there is no access to hand the acceptor, and creating an account with none would be worse than saying so. \n\n**`409 quota_exceeded` when accepting would CREATE an account and the org is at its `max_end_users` limit**, counted across every app of the org. An invitation that names a row the developer already created, and one whose address is held by an unfinished self-registration, both finish an account that already counts — those are not refused, because the org is not one account larger afterwards. The token is not spent by the refusal: the developer can raise the limit, or delete somebody, and the same link still works."
2895
4172
  },
2896
4173
  {
2897
4174
  "method": "POST",
@@ -2986,6 +4263,104 @@
2986
4263
  "transport": "http",
2987
4264
  "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 shape names each of `developer_id`, `app_user_id` and `server_key_id` and fills exactly one."
2988
4265
  },
4266
+ {
4267
+ "method": "POST",
4268
+ "path": "/api/client/two-factor/verify",
4269
+ "section": "client-auth",
4270
+ "summary": "Answers a two-factor challenge with an authenticator or recovery code, and answers the session.",
4271
+ "audience": "client",
4272
+ "auth": "none",
4273
+ "rateLimited": true,
4274
+ "ownerTier": false,
4275
+ "status": 200,
4276
+ "params": [],
4277
+ "query": null,
4278
+ "request": "client-two-factor-verify-request",
4279
+ "response": "session-tokens",
4280
+ "errors": [
4281
+ "rate_limited",
4282
+ "validation_error",
4283
+ "invalid_code",
4284
+ "token_spent"
4285
+ ],
4286
+ "transport": "http",
4287
+ "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 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 is accepted at most once**: the same authenticator code sent twice, even at the same moment, signs in exactly once. A recovery code is spent by its use and audited as `app_user.recovery_code_used`. Exactly one of `code` and `recovery_code`, or `400 validation_error`."
4288
+ },
4289
+ {
4290
+ "method": "POST",
4291
+ "path": "/api/client/two-factor/setup",
4292
+ "section": "client-auth",
4293
+ "summary": "Starts an authenticator setup and answers its secret and otpauth URL.",
4294
+ "audience": "client",
4295
+ "auth": "in_handler",
4296
+ "rateLimited": true,
4297
+ "ownerTier": false,
4298
+ "status": 200,
4299
+ "params": [],
4300
+ "query": null,
4301
+ "request": "client-two-factor-setup-request",
4302
+ "response": "two-factor-setup-response",
4303
+ "errors": [
4304
+ "rate_limited",
4305
+ "validation_error",
4306
+ "token_spent",
4307
+ "unauthorized",
4308
+ "target_state_conflict"
4309
+ ],
4310
+ "transport": "http",
4311
+ "notes": "**Two ways in, decided in the handler.** During sign-in the body carries the `two_factor_setup_required` challenge, and that is the credential; from the app's own account settings the app user's bearer is, with no challenge. Neither is `401 unauthorized`, and a dead challenge is `410 token_spent`. `409 target_state_conflict` names `two_factor` with rule `off` when the app's policy is `off`. 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 secret, and an account that already has an authenticator keeps it until the new one is confirmed."
4312
+ },
4313
+ {
4314
+ "method": "POST",
4315
+ "path": "/api/client/two-factor/setup/confirm",
4316
+ "section": "client-auth",
4317
+ "summary": "Confirms the new authenticator with a code and answers the recovery codes and a session.",
4318
+ "audience": "client",
4319
+ "auth": "in_handler",
4320
+ "rateLimited": true,
4321
+ "ownerTier": false,
4322
+ "status": 200,
4323
+ "params": [],
4324
+ "query": null,
4325
+ "request": "client-two-factor-setup-confirm-request",
4326
+ "response": "client-two-factor-setup-confirm-response",
4327
+ "errors": [
4328
+ "rate_limited",
4329
+ "validation_error",
4330
+ "invalid_code",
4331
+ "token_spent",
4332
+ "unauthorized"
4333
+ ],
4334
+ "transport": "http",
4335
+ "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 challenge, is `410 token_spent`. On success the authenticator is on, ten recovery codes are issued — shown this once, any earlier set 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 session of the account ends. Audited as `app_user.two_factor_enabled`."
4336
+ },
4337
+ {
4338
+ "method": "DELETE",
4339
+ "path": "/api/client/two-factor",
4340
+ "section": "client-auth",
4341
+ "summary": "Turns the signed-in app user's authenticator off.",
4342
+ "audience": "client",
4343
+ "auth": "developer_or_client",
4344
+ "rateLimited": true,
4345
+ "ownerTier": false,
4346
+ "status": 204,
4347
+ "params": [],
4348
+ "query": null,
4349
+ "request": "client-two-factor-disable-request",
4350
+ "response": null,
4351
+ "errors": [
4352
+ "unauthorized",
4353
+ "token_expired",
4354
+ "token_revoked",
4355
+ "forbidden",
4356
+ "rate_limited",
4357
+ "validation_error",
4358
+ "invalid_code",
4359
+ "target_state_conflict"
4360
+ ],
4361
+ "transport": "http",
4362
+ "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 code proves they still hold the authenticator: a stolen session alone cannot remove it. The authenticator and every recovery code go. `409 target_state_conflict` names `two_factor` with rule `required` while the app requires two-factor, and with rule `off` when there is none to remove. Audited as `app_user.two_factor_disabled`. The developer's support door is `DELETE /api/apps/:id/users/:userId/two-factor`."
4363
+ },
2989
4364
  {
2990
4365
  "method": "GET",
2991
4366
  "path": "/api/client/providers",
@@ -3056,7 +4431,7 @@
3056
4431
  "rate_limited"
3057
4432
  ],
3058
4433
  "transport": "http",
3059
- "notes": "**One callback URL for every app and every provider**, and the value of `appAuthConfig.oidc_callback_url` — the string a developer registers at their IdP. `CLIENT_OIDC_CALLBACK_PATH` in `client-auth.ts` is the single spelling of this path; the URL is that path on the cloud's canonical public base, never a friendly alias, because the provider compares the redirect target against the one string it was given. \n\n**Rate limited per ip, generously.** The first draft left this route unlimited on the argument that the caller is an identity provider redirecting somebody's browser, so a limiter would drop real sign-ins on the strength of traffic none of those people sent. The cost of that argument is a `state` obtained from one `start` being replayable for the interaction's full ten minutes, unbounded and unauthenticated, with every replay driving a server-side POST to the developer's token endpoint and one audit row into their org — an amplifier against a third party. The interaction is now spent on **every** terminal outcome, refusals included, which closes the replay itself; the limiter is the ceiling on how fast the attempts may arrive at all. It is per ip and sized for a browser, so a person completing a sign-in never meets it. `429 rate_limited` is the one `apiError` this route can answer, and it is not a sign-in outcome — it is a refusal to begin the work, which is why it does not ride back to the app as an `?error=`. \n\nThe query is the **provider's** rather than a Fleetless shape, and `clientOidcCallbackQuery` describes it **without being strict**: `state` always, `code` on success, `error` and `error_description` on the provider's own refusal, and whatever else that provider adds — RFC 9207's `iss`, a `session_state`, a vendor field. Refusing those would refuse conforming providers, the trap `POST /mcp/oauth/register` documents avoiding. `state` is the required field because it is the only one Fleetless minted. \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 a `302` to the app's own `redirect_uri`: `?code=…&state=…` when a session was resolved, `?error=<clientOidcErrorCode>&state=…` when it was not, so the app 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 exception is a `state` that resolves to no interaction** — unknown, hand-edited, or past its ten minutes. Then there is no confirmed 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, 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 rather than an `apiError`, which is why no code is listed: a code here would document an envelope no caller receives, and this manifest's other HTML pages (`GET /mcp/oauth/interaction/:id`, `GET /console/oauth/interaction/:id`) say their status in prose for the same reason."
4434
+ "notes": "**One callback URL for every app and every provider**, and the value of `appAuthConfig.oidc_callback_url` — the string a developer registers at their IdP. `CLIENT_OIDC_CALLBACK_PATH` in `client-auth.ts` is the single spelling of this path; the URL is that path on the cloud's canonical public base, never a friendly alias, because the provider compares the redirect target against the one string it was given. \n\n**Rate limited per ip, generously.** The first draft left this route unlimited on the argument that the caller is an identity provider redirecting somebody's browser, so a limiter would drop real sign-ins on the strength of traffic none of those people sent. The cost of that argument is a `state` obtained from one `start` being replayable for the interaction's full ten minutes, unbounded and unauthenticated, with every replay driving a server-side POST to the developer's token endpoint and one audit row into their org — an amplifier against a third party. The interaction is now spent on **every** terminal outcome, refusals included, which closes the replay itself; the limiter is the ceiling on how fast the attempts may arrive at all. It is per ip and sized for a browser, so a person completing a sign-in never meets it. `429 rate_limited` is the one `apiError` this route can answer, and it is not a sign-in outcome — it is a refusal to begin the work, which is why it does not ride back to the app as an `?error=`. \n\nThe query is the **provider's** rather than a Fleetless shape, and `clientOidcCallbackQuery` describes it **without being strict**: `state` always, `code` on success, `error` and `error_description` on the provider's own refusal, and whatever else that provider adds — RFC 9207's `iss`, a `session_state`, a vendor field. Refusing those would refuse conforming providers, the trap `POST /mcp/oauth/register` documents avoiding. `state` is the required field because it is the only one Fleetless minted. \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 a `302` to the app's own `redirect_uri`: `?code=…&state=…` when a session was resolved, `?error=<clientOidcErrorCode>&state=…` when it was not, so the app 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 exception is a `state` that resolves to no interaction** — unknown, hand-edited, or past its ten minutes. Then there is no confirmed 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, so the cloud renders an HTML problem page at `400`. It is HTML rather than an `apiError`, which is why no code is listed: a code here would document an envelope no caller receives, and this manifest's other HTML pages (`GET /mcp/oauth/interaction/:id`, `GET /console/oauth/interaction/:id`) say their status in prose for the same reason."
3060
4435
  },
3061
4436
  {
3062
4437
  "method": "POST",
@@ -4892,30 +6267,6 @@
4892
6267
  "transport": "http",
4893
6268
  "notes": "A window longer than `USAGE_WINDOW_MAX_DAYS` is refused naming the field, not silently capped: a caller who asked for more than the platform will answer is owed a refusal, not a shorter answer they will mistake for the whole picture. `from_day <= to_day` is a cross-field rule no JSON Schema can express and is enforced here. The window is echoed back."
4894
6269
  },
4895
- {
4896
- "method": "POST",
4897
- "path": "/api/feedback",
4898
- "section": "org",
4899
- "summary": "Sends a message from a developer to the people who build Fleetless.",
4900
- "audience": "developer",
4901
- "auth": "developer",
4902
- "rateLimited": true,
4903
- "ownerTier": false,
4904
- "status": 202,
4905
- "params": [],
4906
- "query": null,
4907
- "request": "feedback-request",
4908
- "response": "feedback-response",
4909
- "errors": [
4910
- "unauthorized",
4911
- "token_expired",
4912
- "token_revoked",
4913
- "validation_error",
4914
- "rate_limited"
4915
- ],
4916
- "transport": "http",
4917
- "notes": "The message is stored before any mail is tried, so `202` means it is kept whatever `mail` says: `sent`, `failed`, or `not_configured` when this cloud has no feedback address. At most 10 messages per developer per hour; the 11th answers `429 rate_limited` with `retry_after_ms`. Replies come by mail, to the sender's address."
4918
- },
4919
6270
  {
4920
6271
  "method": "POST",
4921
6272
  "path": "/api/bridge/assets",