@fleetless/contracts 5.2.0 → 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 (71) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/artifacts/openapi.json +1975 -415
  3. package/artifacts/routes.json +1697 -251
  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/auth-me-response.schema.json +27 -0
  14. package/artifacts/schema/auth-ok.schema.json +13 -1
  15. package/artifacts/schema/client-accept-invitation-request.schema.json +3 -4
  16. package/artifacts/schema/client-identity.schema.json +13 -1
  17. package/artifacts/schema/client-login-code-request.schema.json +24 -0
  18. package/artifacts/schema/client-login-code-verify-request.schema.json +30 -0
  19. package/artifacts/schema/client-provider-list-response.schema.json +22 -2
  20. package/artifacts/schema/client-register-request.schema.json +3 -4
  21. package/artifacts/schema/client-sign-in-result.schema.json +55 -0
  22. package/artifacts/schema/client-two-factor-disable-request.schema.json +15 -0
  23. package/artifacts/schema/client-two-factor-setup-confirm-request.schema.json +20 -0
  24. package/artifacts/schema/client-two-factor-setup-confirm-response.schema.json +49 -0
  25. package/artifacts/schema/client-two-factor-setup-request.schema.json +12 -0
  26. package/artifacts/schema/client-two-factor-verify-request.schema.json +25 -0
  27. package/artifacts/schema/create-app-invitation-request.schema.json +1 -1
  28. package/artifacts/schema/create-passkey-request.schema.json +25 -0
  29. package/artifacts/schema/create-passkey-response.schema.json +84 -0
  30. package/artifacts/schema/developer-passkey.schema.json +56 -0
  31. package/artifacts/schema/developer-two-factor.schema.json +105 -0
  32. package/artifacts/schema/fleetless-user-list-response.schema.json +22 -0
  33. package/artifacts/schema/fleetless-user.schema.json +22 -0
  34. package/artifacts/schema/invalid-code-details.schema.json +16 -0
  35. package/artifacts/schema/org.schema.json +5 -0
  36. package/artifacts/schema/patch-org-request.schema.json +5 -3
  37. package/artifacts/schema/patch-org-response.schema.json +5 -0
  38. package/artifacts/schema/put-app-auth-look-request.schema.json +22 -0
  39. package/artifacts/schema/put-app-auth-mcp-request.schema.json +1 -14
  40. package/artifacts/schema/put-app-auth-sign-in-request.schema.json +39 -0
  41. package/artifacts/schema/put-app-auth-urls-request.schema.json +31 -4
  42. package/artifacts/schema/recovery-codes-response.schema.json +20 -0
  43. package/artifacts/schema/rename-passkey-request.schema.json +16 -0
  44. package/artifacts/schema/totp-confirm-request.schema.json +15 -0
  45. package/artifacts/schema/totp-confirm-response.schema.json +27 -0
  46. package/artifacts/schema/two-factor-challenge.schema.json +24 -0
  47. package/artifacts/schema/two-factor-setup-response.schema.json +21 -0
  48. package/artifacts/schema/webauthn-options-response.schema.json +18 -0
  49. package/dist/app-users.d.ts +154 -34
  50. package/dist/app-users.js +177 -45
  51. package/dist/client-auth.d.ts +133 -24
  52. package/dist/client-auth.js +139 -28
  53. package/dist/config.d.ts +2 -2
  54. package/dist/errors.d.ts +10 -1
  55. package/dist/errors.js +34 -5
  56. package/dist/identity.d.ts +172 -123
  57. package/dist/identity.js +189 -101
  58. package/dist/index.d.ts +9 -9
  59. package/dist/index.js +11 -7
  60. package/dist/protocol.d.ts +1 -1
  61. package/dist/realtime.d.ts +1 -0
  62. package/dist/rest.d.ts +2 -2
  63. package/dist/rest.js +17 -28
  64. package/dist/routes.d.ts +19 -0
  65. package/dist/routes.js +497 -183
  66. package/package.json +1 -1
  67. package/artifacts/schema/developer-login-request.schema.json +0 -19
  68. package/artifacts/schema/password-reset-confirm.schema.json +0 -19
  69. package/artifacts/schema/password-reset-request.schema.json +0 -14
  70. package/artifacts/schema/sign-up-request.schema.json +0 -26
  71. package/artifacts/schema/sign-up-response.schema.json +0 -127
@@ -69,50 +69,6 @@
69
69
  }
70
70
  ],
71
71
  "paths": {
72
- "/api/auth/signup": {
73
- "post": {
74
- "operationId": "post_api_auth_signup",
75
- "summary": "Creates an org and its founding Owner, and answers a developer session.",
76
- "tags": [
77
- "developer-auth"
78
- ],
79
- "security": [],
80
- "parameters": [],
81
- "responses": {
82
- "201": {
83
- "description": "Success.",
84
- "content": {
85
- "application/json": {
86
- "schema": {
87
- "$ref": "#/components/schemas/sign-up-response"
88
- }
89
- }
90
- }
91
- },
92
- "default": {
93
- "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `signup_closed`, `validation_error`, `email_taken`.",
94
- "content": {
95
- "application/json": {
96
- "schema": {
97
- "$ref": "#/components/schemas/api-error"
98
- }
99
- }
100
- }
101
- }
102
- },
103
- "description": "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.",
104
- "requestBody": {
105
- "required": true,
106
- "content": {
107
- "application/json": {
108
- "schema": {
109
- "$ref": "#/components/schemas/sign-up-request"
110
- }
111
- }
112
- }
113
- }
114
- }
115
- },
116
72
  "/api/auth/refresh": {
117
73
  "post": {
118
74
  "operationId": "post_api_auth_refresh",
@@ -144,7 +100,7 @@
144
100
  }
145
101
  }
146
102
  },
147
- "description": "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.",
103
+ "description": "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.",
148
104
  "requestBody": {
149
105
  "required": true,
150
106
  "content": {
@@ -277,10 +233,48 @@
277
233
  }
278
234
  }
279
235
  },
280
- "/api/auth/password/change": {
236
+ "/api/auth/two-factor": {
237
+ "get": {
238
+ "operationId": "get_api_auth_two_factor",
239
+ "summary": "Answers the calling developer's passkeys, authenticator, recovery codes left and the org's policy.",
240
+ "tags": [
241
+ "developer-auth"
242
+ ],
243
+ "security": [
244
+ {
245
+ "developerSession": []
246
+ }
247
+ ],
248
+ "parameters": [],
249
+ "responses": {
250
+ "200": {
251
+ "description": "Success.",
252
+ "content": {
253
+ "application/json": {
254
+ "schema": {
255
+ "$ref": "#/components/schemas/developer-two-factor"
256
+ }
257
+ }
258
+ }
259
+ },
260
+ "default": {
261
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`.",
262
+ "content": {
263
+ "application/json": {
264
+ "schema": {
265
+ "$ref": "#/components/schemas/api-error"
266
+ }
267
+ }
268
+ }
269
+ }
270
+ },
271
+ "description": "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."
272
+ }
273
+ },
274
+ "/api/auth/passkeys/options": {
281
275
  "post": {
282
- "operationId": "post_api_auth_password_change",
283
- "summary": "Verifies the current password, sets a new one and answers a fresh session.",
276
+ "operationId": "post_api_auth_passkeys_options",
277
+ "summary": "Answers the WebAuthn creation options for registering a passkey.",
284
278
  "tags": [
285
279
  "developer-auth"
286
280
  ],
@@ -296,13 +290,13 @@
296
290
  "content": {
297
291
  "application/json": {
298
292
  "schema": {
299
- "$ref": "#/components/schemas/session-tokens"
293
+ "$ref": "#/components/schemas/webauthn-options-response"
300
294
  }
301
295
  }
302
296
  }
303
297
  },
304
298
  "default": {
305
- "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `validation_error`, `invalid_credentials`.",
299
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`.",
306
300
  "content": {
307
301
  "application/json": {
308
302
  "schema": {
@@ -312,34 +306,93 @@
312
306
  }
313
307
  }
314
308
  },
315
- "description": "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.",
309
+ "description": "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."
310
+ }
311
+ },
312
+ "/api/auth/passkeys": {
313
+ "post": {
314
+ "operationId": "post_api_auth_passkeys",
315
+ "summary": "Registers a passkey from the browser's answer to the creation options.",
316
+ "tags": [
317
+ "developer-auth"
318
+ ],
319
+ "security": [
320
+ {
321
+ "developerSession": []
322
+ }
323
+ ],
324
+ "parameters": [],
325
+ "responses": {
326
+ "201": {
327
+ "description": "Success.",
328
+ "content": {
329
+ "application/json": {
330
+ "schema": {
331
+ "$ref": "#/components/schemas/create-passkey-response"
332
+ }
333
+ }
334
+ }
335
+ },
336
+ "default": {
337
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `validation_error`.",
338
+ "content": {
339
+ "application/json": {
340
+ "schema": {
341
+ "$ref": "#/components/schemas/api-error"
342
+ }
343
+ }
344
+ }
345
+ }
346
+ },
347
+ "description": "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`.",
316
348
  "requestBody": {
317
349
  "required": true,
318
350
  "content": {
319
351
  "application/json": {
320
352
  "schema": {
321
- "$ref": "#/components/schemas/password-change-request"
353
+ "$ref": "#/components/schemas/create-passkey-request"
322
354
  }
323
355
  }
324
356
  }
325
357
  }
326
358
  }
327
359
  },
328
- "/api/auth/password/reset": {
329
- "post": {
330
- "operationId": "post_api_auth_password_reset",
331
- "summary": "Mails a password-reset link to the address, and answers the same either way.",
360
+ "/api/auth/passkeys/{id}": {
361
+ "patch": {
362
+ "operationId": "patch_api_auth_passkeys_id",
363
+ "summary": "Renames one of the caller's passkeys.",
332
364
  "tags": [
333
365
  "developer-auth"
334
366
  ],
335
- "security": [],
336
- "parameters": [],
367
+ "security": [
368
+ {
369
+ "developerSession": []
370
+ }
371
+ ],
372
+ "parameters": [
373
+ {
374
+ "name": "id",
375
+ "in": "path",
376
+ "required": true,
377
+ "description": "The passkey's uuid, as listed by `GET /api/auth/two-factor`; another person's passkey answers `404`.",
378
+ "schema": {
379
+ "type": "string"
380
+ }
381
+ }
382
+ ],
337
383
  "responses": {
338
- "202": {
339
- "description": "Success."
384
+ "200": {
385
+ "description": "Success.",
386
+ "content": {
387
+ "application/json": {
388
+ "schema": {
389
+ "$ref": "#/components/schemas/developer-passkey"
390
+ }
391
+ }
392
+ }
340
393
  },
341
394
  "default": {
342
- "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`.",
395
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`.",
343
396
  "content": {
344
397
  "application/json": {
345
398
  "schema": {
@@ -349,34 +402,150 @@
349
402
  }
350
403
  }
351
404
  },
352
- "description": "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`.",
353
405
  "requestBody": {
354
406
  "required": true,
355
407
  "content": {
356
408
  "application/json": {
357
409
  "schema": {
358
- "$ref": "#/components/schemas/password-reset-request"
410
+ "$ref": "#/components/schemas/rename-passkey-request"
359
411
  }
360
412
  }
361
413
  }
362
414
  }
415
+ },
416
+ "delete": {
417
+ "operationId": "delete_api_auth_passkeys_id",
418
+ "summary": "Removes one of the caller's passkeys.",
419
+ "tags": [
420
+ "developer-auth"
421
+ ],
422
+ "security": [
423
+ {
424
+ "developerSession": []
425
+ }
426
+ ],
427
+ "parameters": [
428
+ {
429
+ "name": "id",
430
+ "in": "path",
431
+ "required": true,
432
+ "description": "The passkey's uuid, as listed by `GET /api/auth/two-factor`; another person's passkey answers `404`.",
433
+ "schema": {
434
+ "type": "string"
435
+ }
436
+ }
437
+ ],
438
+ "responses": {
439
+ "204": {
440
+ "description": "Success."
441
+ },
442
+ "default": {
443
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `target_state_conflict`.",
444
+ "content": {
445
+ "application/json": {
446
+ "schema": {
447
+ "$ref": "#/components/schemas/api-error"
448
+ }
449
+ }
450
+ }
451
+ }
452
+ },
453
+ "description": "`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`."
363
454
  }
364
455
  },
365
- "/api/auth/password/reset/confirm": {
456
+ "/api/auth/totp": {
366
457
  "post": {
367
- "operationId": "post_api_auth_password_reset_confirm",
368
- "summary": "Spends a reset token, sets the new password and ends every session of the account.",
458
+ "operationId": "post_api_auth_totp",
459
+ "summary": "Starts an authenticator setup and answers its secret and otpauth URL.",
369
460
  "tags": [
370
461
  "developer-auth"
371
462
  ],
372
- "security": [],
463
+ "security": [
464
+ {
465
+ "developerSession": []
466
+ }
467
+ ],
468
+ "parameters": [],
469
+ "responses": {
470
+ "200": {
471
+ "description": "Success.",
472
+ "content": {
473
+ "application/json": {
474
+ "schema": {
475
+ "$ref": "#/components/schemas/two-factor-setup-response"
476
+ }
477
+ }
478
+ }
479
+ },
480
+ "default": {
481
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`.",
482
+ "content": {
483
+ "application/json": {
484
+ "schema": {
485
+ "$ref": "#/components/schemas/api-error"
486
+ }
487
+ }
488
+ }
489
+ }
490
+ },
491
+ "description": "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."
492
+ },
493
+ "delete": {
494
+ "operationId": "delete_api_auth_totp",
495
+ "summary": "Removes the caller's authenticator app.",
496
+ "tags": [
497
+ "developer-auth"
498
+ ],
499
+ "security": [
500
+ {
501
+ "developerSession": []
502
+ }
503
+ ],
373
504
  "parameters": [],
374
505
  "responses": {
375
506
  "204": {
376
507
  "description": "Success."
377
508
  },
378
509
  "default": {
379
- "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `token_spent`.",
510
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `not_found`, `target_state_conflict`.",
511
+ "content": {
512
+ "application/json": {
513
+ "schema": {
514
+ "$ref": "#/components/schemas/api-error"
515
+ }
516
+ }
517
+ }
518
+ }
519
+ },
520
+ "description": "`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`."
521
+ }
522
+ },
523
+ "/api/auth/totp/confirm": {
524
+ "post": {
525
+ "operationId": "post_api_auth_totp_confirm",
526
+ "summary": "Confirms the pending authenticator with a code it shows now.",
527
+ "tags": [
528
+ "developer-auth"
529
+ ],
530
+ "security": [
531
+ {
532
+ "developerSession": []
533
+ }
534
+ ],
535
+ "parameters": [],
536
+ "responses": {
537
+ "200": {
538
+ "description": "Success.",
539
+ "content": {
540
+ "application/json": {
541
+ "schema": {
542
+ "$ref": "#/components/schemas/totp-confirm-response"
543
+ }
544
+ }
545
+ }
546
+ },
547
+ "default": {
548
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `rate_limited`, `validation_error`, `invalid_code`, `token_spent`.",
380
549
  "content": {
381
550
  "application/json": {
382
551
  "schema": {
@@ -386,19 +555,57 @@
386
555
  }
387
556
  }
388
557
  },
389
- "description": "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`.",
558
+ "description": "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`.",
390
559
  "requestBody": {
391
560
  "required": true,
392
561
  "content": {
393
562
  "application/json": {
394
563
  "schema": {
395
- "$ref": "#/components/schemas/password-reset-confirm"
564
+ "$ref": "#/components/schemas/totp-confirm-request"
396
565
  }
397
566
  }
398
567
  }
399
568
  }
400
569
  }
401
570
  },
571
+ "/api/auth/recovery-codes": {
572
+ "post": {
573
+ "operationId": "post_api_auth_recovery_codes",
574
+ "summary": "Issues ten new recovery codes and voids the old ones.",
575
+ "tags": [
576
+ "developer-auth"
577
+ ],
578
+ "security": [
579
+ {
580
+ "developerSession": []
581
+ }
582
+ ],
583
+ "parameters": [],
584
+ "responses": {
585
+ "200": {
586
+ "description": "Success.",
587
+ "content": {
588
+ "application/json": {
589
+ "schema": {
590
+ "$ref": "#/components/schemas/recovery-codes-response"
591
+ }
592
+ }
593
+ }
594
+ },
595
+ "default": {
596
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `target_state_conflict`.",
597
+ "content": {
598
+ "application/json": {
599
+ "schema": {
600
+ "$ref": "#/components/schemas/api-error"
601
+ }
602
+ }
603
+ }
604
+ }
605
+ },
606
+ "description": "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`."
607
+ }
608
+ },
402
609
  "/api/waitlist": {
403
610
  "post": {
404
611
  "operationId": "post_api_waitlist",
@@ -1743,11 +1950,68 @@
1743
1950
  }
1744
1951
  ],
1745
1952
  "responses": {
1746
- "204": {
1747
- "description": "Success."
1953
+ "204": {
1954
+ "description": "Success."
1955
+ },
1956
+ "default": {
1957
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
1958
+ "content": {
1959
+ "application/json": {
1960
+ "schema": {
1961
+ "$ref": "#/components/schemas/api-error"
1962
+ }
1963
+ }
1964
+ }
1965
+ }
1966
+ },
1967
+ "description": "Sessions are revoked before the row goes, for the reason `DELETE /api/org/users/:id` states: a live user with a dead session is recoverable by retrying, a deleted user whose token still works is not. Outstanding invitations and unspent tokens for that address are expired with it — a link mailed before the deletion is a standing re-admission ticket. **Nothing outside this app is touched**: a Fleetless user sharing the address keeps their console account, and an account with the same address in a sibling app is a different person as far as this platform is concerned."
1968
+ }
1969
+ },
1970
+ "/api/apps/{id}/users/{userId}/reset-password": {
1971
+ "post": {
1972
+ "operationId": "post_api_apps_id_users_userId_reset_password",
1973
+ "summary": "Mails an app user a password-reset link on the developer's behalf.",
1974
+ "tags": [
1975
+ "apps"
1976
+ ],
1977
+ "security": [
1978
+ {
1979
+ "developerSession": []
1980
+ }
1981
+ ],
1982
+ "parameters": [
1983
+ {
1984
+ "name": "id",
1985
+ "in": "path",
1986
+ "required": true,
1987
+ "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
1988
+ "schema": {
1989
+ "type": "string"
1990
+ }
1991
+ },
1992
+ {
1993
+ "name": "userId",
1994
+ "in": "path",
1995
+ "required": true,
1996
+ "description": "The app user's uuid, from `GET /api/apps/:id/users`; a user of another app answers `404`.",
1997
+ "schema": {
1998
+ "type": "string"
1999
+ }
2000
+ }
2001
+ ],
2002
+ "responses": {
2003
+ "202": {
2004
+ "description": "Success.",
2005
+ "content": {
2006
+ "application/json": {
2007
+ "schema": {
2008
+ "$ref": "#/components/schemas/mail-outcome"
2009
+ }
2010
+ }
2011
+ }
1748
2012
  },
1749
2013
  "default": {
1750
- "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
2014
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `target_state_conflict`.",
1751
2015
  "content": {
1752
2016
  "application/json": {
1753
2017
  "schema": {
@@ -1757,13 +2021,13 @@
1757
2021
  }
1758
2022
  }
1759
2023
  },
1760
- "description": "Sessions are revoked before the row goes, for the reason `DELETE /api/org/users/:id` states: a live user with a dead session is recoverable by retrying, a deleted user whose token still works is not. Outstanding invitations and unspent tokens for that address are expired with it — a link mailed before the deletion is a standing re-admission ticket. **Nothing outside this app is touched**: a Fleetless user sharing the address keeps their console account, and an account with the same address in a sibling app is a different person as far as this platform is concerned."
2024
+ "description": "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."
1761
2025
  }
1762
2026
  },
1763
- "/api/apps/{id}/users/{userId}/reset-password": {
1764
- "post": {
1765
- "operationId": "post_api_apps_id_users_userId_reset_password",
1766
- "summary": "Mails an app user a password-reset link on the developer's behalf.",
2027
+ "/api/apps/{id}/users/{userId}/two-factor": {
2028
+ "delete": {
2029
+ "operationId": "delete_api_apps_id_users_userId_two_factor",
2030
+ "summary": "Removes an app user's authenticator and recovery codes and ends every session they hold.",
1767
2031
  "tags": [
1768
2032
  "apps"
1769
2033
  ],
@@ -1793,18 +2057,11 @@
1793
2057
  }
1794
2058
  ],
1795
2059
  "responses": {
1796
- "202": {
1797
- "description": "Success.",
1798
- "content": {
1799
- "application/json": {
1800
- "schema": {
1801
- "$ref": "#/components/schemas/mail-outcome"
1802
- }
1803
- }
1804
- }
2060
+ "204": {
2061
+ "description": "Success."
1805
2062
  },
1806
2063
  "default": {
1807
- "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `target_state_conflict`.",
2064
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
1808
2065
  "content": {
1809
2066
  "application/json": {
1810
2067
  "schema": {
@@ -1814,7 +2071,7 @@
1814
2071
  }
1815
2072
  }
1816
2073
  },
1817
- "description": "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."
2074
+ "description": "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`."
1818
2075
  }
1819
2076
  },
1820
2077
  "/api/apps/{id}/users/{userId}/mcp-grants": {
@@ -2024,7 +2281,7 @@
2024
2281
  }
2025
2282
  }
2026
2283
  },
2027
- "description": "**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.",
2284
+ "description": "**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.",
2028
2285
  "requestBody": {
2029
2286
  "required": true,
2030
2287
  "content": {
@@ -2421,7 +2678,7 @@
2421
2678
  "/api/apps/{id}/auth-config": {
2422
2679
  "get": {
2423
2680
  "operationId": "get_api_apps_id_auth_config",
2424
- "summary": "Reads the app's auth settings: self-registration, domains, origins, URLs and the MCP switch.",
2681
+ "summary": "Reads the app's auth settings: sign-in methods, two-factor, registration, pages, the hosted look and the MCP switch.",
2425
2682
  "tags": [
2426
2683
  "apps"
2427
2684
  ],
@@ -2463,7 +2720,7 @@
2463
2720
  }
2464
2721
  }
2465
2722
  },
2466
- "description": "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."
2723
+ "description": "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."
2467
2724
  }
2468
2725
  },
2469
2726
  "/api/apps/{id}/auth-config/registration": {
@@ -2511,7 +2768,7 @@
2511
2768
  }
2512
2769
  }
2513
2770
  },
2514
- "description": "**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.",
2771
+ "description": "**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.",
2515
2772
  "requestBody": {
2516
2773
  "required": true,
2517
2774
  "content": {
@@ -2524,10 +2781,68 @@
2524
2781
  }
2525
2782
  }
2526
2783
  },
2784
+ "/api/apps/{id}/auth-config/sign-in": {
2785
+ "put": {
2786
+ "operationId": "put_api_apps_id_auth_config_sign_in",
2787
+ "summary": "Replaces how the app's users sign in and whether they give a second factor.",
2788
+ "tags": [
2789
+ "apps"
2790
+ ],
2791
+ "security": [
2792
+ {
2793
+ "developerSession": []
2794
+ }
2795
+ ],
2796
+ "parameters": [
2797
+ {
2798
+ "name": "id",
2799
+ "in": "path",
2800
+ "required": true,
2801
+ "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
2802
+ "schema": {
2803
+ "type": "string"
2804
+ }
2805
+ }
2806
+ ],
2807
+ "responses": {
2808
+ "200": {
2809
+ "description": "Success.",
2810
+ "content": {
2811
+ "application/json": {
2812
+ "schema": {
2813
+ "$ref": "#/components/schemas/app-auth-config"
2814
+ }
2815
+ }
2816
+ }
2817
+ },
2818
+ "default": {
2819
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`.",
2820
+ "content": {
2821
+ "application/json": {
2822
+ "schema": {
2823
+ "$ref": "#/components/schemas/api-error"
2824
+ }
2825
+ }
2826
+ }
2827
+ }
2828
+ },
2829
+ "description": "**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.",
2830
+ "requestBody": {
2831
+ "required": true,
2832
+ "content": {
2833
+ "application/json": {
2834
+ "schema": {
2835
+ "$ref": "#/components/schemas/put-app-auth-sign-in-request"
2836
+ }
2837
+ }
2838
+ }
2839
+ }
2840
+ }
2841
+ },
2527
2842
  "/api/apps/{id}/auth-config/urls": {
2528
2843
  "put": {
2529
2844
  "operationId": "put_api_apps_id_auth_config_urls",
2530
- "summary": "Replaces the three pages Fleetless's mails point at.",
2845
+ "summary": "Replaces the app's home page and the four pages Fleetless's mails and MCP sign-in point at.",
2531
2846
  "tags": [
2532
2847
  "apps"
2533
2848
  ],
@@ -2569,7 +2884,7 @@
2569
2884
  }
2570
2885
  }
2571
2886
  },
2572
- "description": "**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.",
2887
+ "description": "**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.",
2573
2888
  "requestBody": {
2574
2889
  "required": true,
2575
2890
  "content": {
@@ -2585,7 +2900,7 @@
2585
2900
  "/api/apps/{id}/auth-config/mcp": {
2586
2901
  "put": {
2587
2902
  "operationId": "put_api_apps_id_auth_config_mcp",
2588
- "summary": "Replaces the MCP switch and its login URL together.",
2903
+ "summary": "Turns the app's MCP endpoint on or off.",
2589
2904
  "tags": [
2590
2905
  "apps"
2591
2906
  ],
@@ -2627,7 +2942,7 @@
2627
2942
  }
2628
2943
  }
2629
2944
  },
2630
- "description": "**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.",
2945
+ "description": "**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.",
2631
2946
  "requestBody": {
2632
2947
  "required": true,
2633
2948
  "content": {
@@ -2640,6 +2955,158 @@
2640
2955
  }
2641
2956
  }
2642
2957
  },
2958
+ "/api/apps/{id}/auth-config/look": {
2959
+ "put": {
2960
+ "operationId": "put_api_apps_id_auth_config_look",
2961
+ "summary": "Replaces the hosted pages' accent colour.",
2962
+ "tags": [
2963
+ "apps"
2964
+ ],
2965
+ "security": [
2966
+ {
2967
+ "developerSession": []
2968
+ }
2969
+ ],
2970
+ "parameters": [
2971
+ {
2972
+ "name": "id",
2973
+ "in": "path",
2974
+ "required": true,
2975
+ "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
2976
+ "schema": {
2977
+ "type": "string"
2978
+ }
2979
+ }
2980
+ ],
2981
+ "responses": {
2982
+ "200": {
2983
+ "description": "Success.",
2984
+ "content": {
2985
+ "application/json": {
2986
+ "schema": {
2987
+ "$ref": "#/components/schemas/app-auth-config"
2988
+ }
2989
+ }
2990
+ }
2991
+ },
2992
+ "default": {
2993
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`.",
2994
+ "content": {
2995
+ "application/json": {
2996
+ "schema": {
2997
+ "$ref": "#/components/schemas/api-error"
2998
+ }
2999
+ }
3000
+ }
3001
+ }
3002
+ },
3003
+ "description": "**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.",
3004
+ "requestBody": {
3005
+ "required": true,
3006
+ "content": {
3007
+ "application/json": {
3008
+ "schema": {
3009
+ "$ref": "#/components/schemas/put-app-auth-look-request"
3010
+ }
3011
+ }
3012
+ }
3013
+ }
3014
+ }
3015
+ },
3016
+ "/api/apps/{id}/auth-config/logo": {
3017
+ "put": {
3018
+ "operationId": "put_api_apps_id_auth_config_logo",
3019
+ "summary": "Stores the logo the hosted pages show above the app's name.",
3020
+ "tags": [
3021
+ "apps"
3022
+ ],
3023
+ "security": [
3024
+ {
3025
+ "developerSession": []
3026
+ }
3027
+ ],
3028
+ "parameters": [
3029
+ {
3030
+ "name": "id",
3031
+ "in": "path",
3032
+ "required": true,
3033
+ "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
3034
+ "schema": {
3035
+ "type": "string"
3036
+ }
3037
+ }
3038
+ ],
3039
+ "responses": {
3040
+ "200": {
3041
+ "description": "Success.",
3042
+ "content": {
3043
+ "application/json": {
3044
+ "schema": {
3045
+ "$ref": "#/components/schemas/app-auth-config"
3046
+ }
3047
+ }
3048
+ }
3049
+ },
3050
+ "default": {
3051
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `validation_error`, `not_found`, `unsupported_media_type`.",
3052
+ "content": {
3053
+ "application/json": {
3054
+ "schema": {
3055
+ "$ref": "#/components/schemas/api-error"
3056
+ }
3057
+ }
3058
+ }
3059
+ }
3060
+ },
3061
+ "description": "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."
3062
+ },
3063
+ "delete": {
3064
+ "operationId": "delete_api_apps_id_auth_config_logo",
3065
+ "summary": "Removes the logo from the hosted pages.",
3066
+ "tags": [
3067
+ "apps"
3068
+ ],
3069
+ "security": [
3070
+ {
3071
+ "developerSession": []
3072
+ }
3073
+ ],
3074
+ "parameters": [
3075
+ {
3076
+ "name": "id",
3077
+ "in": "path",
3078
+ "required": true,
3079
+ "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
3080
+ "schema": {
3081
+ "type": "string"
3082
+ }
3083
+ }
3084
+ ],
3085
+ "responses": {
3086
+ "200": {
3087
+ "description": "Success.",
3088
+ "content": {
3089
+ "application/json": {
3090
+ "schema": {
3091
+ "$ref": "#/components/schemas/app-auth-config"
3092
+ }
3093
+ }
3094
+ }
3095
+ },
3096
+ "default": {
3097
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
3098
+ "content": {
3099
+ "application/json": {
3100
+ "schema": {
3101
+ "$ref": "#/components/schemas/api-error"
3102
+ }
3103
+ }
3104
+ }
3105
+ }
3106
+ },
3107
+ "description": "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."
3108
+ }
3109
+ },
2643
3110
  "/api/apps/{id}/mail-templates": {
2644
3111
  "get": {
2645
3112
  "operationId": "get_api_apps_id_mail_templates",
@@ -2685,7 +3152,7 @@
2685
3152
  }
2686
3153
  }
2687
3154
  },
2688
- "description": "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."
3155
+ "description": "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."
2689
3156
  }
2690
3157
  },
2691
3158
  "/api/apps/{id}/mail-templates/{kind}": {
@@ -3348,7 +3815,7 @@
3348
3815
  "/api/org/invitations/accept": {
3349
3816
  "post": {
3350
3817
  "operationId": "post_api_org_invitations_accept",
3351
- "summary": "Spends an invitation token and creates the login it was addressed to.",
3818
+ "summary": "Spends an invitation token and creates the account it was addressed to.",
3352
3819
  "tags": [
3353
3820
  "users"
3354
3821
  ],
@@ -3369,7 +3836,7 @@
3369
3836
  }
3370
3837
  }
3371
3838
  },
3372
- "description": "**`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.",
3839
+ "description": "**`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.",
3373
3840
  "requestBody": {
3374
3841
  "required": true,
3375
3842
  "content": {
@@ -3437,13 +3904,54 @@
3437
3904
  }
3438
3905
  }
3439
3906
  }
3440
- }
3907
+ }
3908
+ }
3909
+ },
3910
+ "/api/org/users/{id}/two-factor": {
3911
+ "delete": {
3912
+ "operationId": "delete_api_org_users_id_two_factor",
3913
+ "summary": "Removes a team member's passkeys, authenticator and recovery codes and ends their sessions.",
3914
+ "tags": [
3915
+ "users"
3916
+ ],
3917
+ "security": [
3918
+ {
3919
+ "developerSession": []
3920
+ }
3921
+ ],
3922
+ "parameters": [
3923
+ {
3924
+ "name": "id",
3925
+ "in": "path",
3926
+ "required": true,
3927
+ "description": "The Fleetless user's uuid, as listed by `GET /api/org/users`.",
3928
+ "schema": {
3929
+ "type": "string"
3930
+ }
3931
+ }
3932
+ ],
3933
+ "responses": {
3934
+ "204": {
3935
+ "description": "Success."
3936
+ },
3937
+ "default": {
3938
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `invalid_uuid`, `not_found`, `target_state_conflict`.",
3939
+ "content": {
3940
+ "application/json": {
3941
+ "schema": {
3942
+ "$ref": "#/components/schemas/api-error"
3943
+ }
3944
+ }
3945
+ }
3946
+ }
3947
+ },
3948
+ "description": "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."
3441
3949
  }
3442
3950
  },
3443
3951
  "/api/org": {
3444
3952
  "patch": {
3445
3953
  "operationId": "patch_api_org",
3446
- "summary": "Renames the org.",
3954
+ "summary": "Renames the org, requires two-factor for its members, or both.",
3447
3955
  "tags": [
3448
3956
  "org"
3449
3957
  ],
@@ -3475,7 +3983,7 @@
3475
3983
  }
3476
3984
  }
3477
3985
  },
3478
- "description": "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.",
3986
+ "description": "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`.",
3479
3987
  "requestBody": {
3480
3988
  "required": true,
3481
3989
  "content": {
@@ -3693,7 +4201,7 @@
3693
4201
  }
3694
4202
  }
3695
4203
  },
3696
- "description": "**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."
4204
+ "description": "**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."
3697
4205
  }
3698
4206
  },
3699
4207
  "/mcp/oauth/token": {
@@ -3975,7 +4483,7 @@
3975
4483
  }
3976
4484
  }
3977
4485
  },
3978
- "description": "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."
4486
+ "description": "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."
3979
4487
  }
3980
4488
  },
3981
4489
  "/mcp/{appIdentifier}/oauth/register": {
@@ -4035,7 +4543,7 @@
4035
4543
  "/mcp/{appIdentifier}/oauth/authorize": {
4036
4544
  "get": {
4037
4545
  "operationId": "get_mcp_appIdentifier_oauth_authorize",
4038
- "summary": "Starts an MCP sign-in and redirects the browser to the app's own login page.",
4546
+ "summary": "Starts an MCP sign-in and redirects the browser to the app's own login page, or to the hosted one.",
4039
4547
  "tags": [
4040
4548
  "mcp"
4041
4549
  ],
@@ -4124,7 +4632,7 @@
4124
4632
  "description": "Success."
4125
4633
  },
4126
4634
  "default": {
4127
- "description": "An error envelope. Codes this route is known to answer: `not_found`, `target_state_conflict`.",
4635
+ "description": "An error envelope. Codes this route is known to answer: `not_found`.",
4128
4636
  "content": {
4129
4637
  "application/json": {
4130
4638
  "schema": {
@@ -4134,7 +4642,7 @@
4134
4642
  }
4135
4643
  }
4136
4644
  },
4137
- "description": "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."
4645
+ "description": "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`."
4138
4646
  }
4139
4647
  },
4140
4648
  "/mcp/{appIdentifier}/oauth/token": {
@@ -4206,13 +4714,13 @@
4206
4714
  "content": {
4207
4715
  "application/json": {
4208
4716
  "schema": {
4209
- "$ref": "#/components/schemas/session-tokens"
4717
+ "$ref": "#/components/schemas/client-sign-in-result"
4210
4718
  }
4211
4719
  }
4212
4720
  }
4213
4721
  },
4214
4722
  "default": {
4215
- "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `invalid_credentials`.",
4723
+ "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `invalid_credentials`, `method_not_allowed`.",
4216
4724
  "content": {
4217
4725
  "application/json": {
4218
4726
  "schema": {
@@ -4222,7 +4730,7 @@
4222
4730
  }
4223
4731
  }
4224
4732
  },
4225
- "description": "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.",
4733
+ "description": "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.",
4226
4734
  "requestBody": {
4227
4735
  "required": true,
4228
4736
  "content": {
@@ -4235,6 +4743,87 @@
4235
4743
  }
4236
4744
  }
4237
4745
  },
4746
+ "/api/client/login/code": {
4747
+ "post": {
4748
+ "operationId": "post_api_client_login_code",
4749
+ "summary": "Mails a six-digit sign-in code, and answers the same whether or not the address exists.",
4750
+ "tags": [
4751
+ "client-auth"
4752
+ ],
4753
+ "security": [],
4754
+ "parameters": [],
4755
+ "responses": {
4756
+ "202": {
4757
+ "description": "Success."
4758
+ },
4759
+ "default": {
4760
+ "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `not_found`, `method_not_allowed`.",
4761
+ "content": {
4762
+ "application/json": {
4763
+ "schema": {
4764
+ "$ref": "#/components/schemas/api-error"
4765
+ }
4766
+ }
4767
+ }
4768
+ }
4769
+ },
4770
+ "description": "**`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.",
4771
+ "requestBody": {
4772
+ "required": true,
4773
+ "content": {
4774
+ "application/json": {
4775
+ "schema": {
4776
+ "$ref": "#/components/schemas/client-login-code-request"
4777
+ }
4778
+ }
4779
+ }
4780
+ }
4781
+ }
4782
+ },
4783
+ "/api/client/login/code/verify": {
4784
+ "post": {
4785
+ "operationId": "post_api_client_login_code_verify",
4786
+ "summary": "Spends a mailed sign-in code and answers a session or a two-factor challenge.",
4787
+ "tags": [
4788
+ "client-auth"
4789
+ ],
4790
+ "security": [],
4791
+ "parameters": [],
4792
+ "responses": {
4793
+ "200": {
4794
+ "description": "Success.",
4795
+ "content": {
4796
+ "application/json": {
4797
+ "schema": {
4798
+ "$ref": "#/components/schemas/client-sign-in-result"
4799
+ }
4800
+ }
4801
+ }
4802
+ },
4803
+ "default": {
4804
+ "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `invalid_code`, `token_spent`, `method_not_allowed`.",
4805
+ "content": {
4806
+ "application/json": {
4807
+ "schema": {
4808
+ "$ref": "#/components/schemas/api-error"
4809
+ }
4810
+ }
4811
+ }
4812
+ }
4813
+ },
4814
+ "description": "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.",
4815
+ "requestBody": {
4816
+ "required": true,
4817
+ "content": {
4818
+ "application/json": {
4819
+ "schema": {
4820
+ "$ref": "#/components/schemas/client-login-code-verify-request"
4821
+ }
4822
+ }
4823
+ }
4824
+ }
4825
+ }
4826
+ },
4238
4827
  "/api/client/register": {
4239
4828
  "post": {
4240
4829
  "operationId": "post_api_client_register",
@@ -4259,7 +4848,7 @@
4259
4848
  }
4260
4849
  }
4261
4850
  },
4262
- "description": "**`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.",
4851
+ "description": "**`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.",
4263
4852
  "requestBody": {
4264
4853
  "required": true,
4265
4854
  "content": {
@@ -4287,7 +4876,7 @@
4287
4876
  "content": {
4288
4877
  "application/json": {
4289
4878
  "schema": {
4290
- "$ref": "#/components/schemas/session-tokens"
4879
+ "$ref": "#/components/schemas/client-sign-in-result"
4291
4880
  }
4292
4881
  }
4293
4882
  }
@@ -4303,7 +4892,7 @@
4303
4892
  }
4304
4893
  }
4305
4894
  },
4306
- "description": "**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.",
4895
+ "description": "**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.",
4307
4896
  "requestBody": {
4308
4897
  "required": true,
4309
4898
  "content": {
@@ -4377,7 +4966,7 @@
4377
4966
  }
4378
4967
  }
4379
4968
  },
4380
- "description": "**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.",
4969
+ "description": "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.",
4381
4970
  "requestBody": {
4382
4971
  "required": true,
4383
4972
  "content": {
@@ -4405,13 +4994,13 @@
4405
4994
  "content": {
4406
4995
  "application/json": {
4407
4996
  "schema": {
4408
- "$ref": "#/components/schemas/session-tokens"
4997
+ "$ref": "#/components/schemas/client-sign-in-result"
4409
4998
  }
4410
4999
  }
4411
5000
  }
4412
5001
  },
4413
5002
  "default": {
4414
- "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `token_spent`.",
5003
+ "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `token_spent`, `method_not_allowed`.",
4415
5004
  "content": {
4416
5005
  "application/json": {
4417
5006
  "schema": {
@@ -4421,7 +5010,7 @@
4421
5010
  }
4422
5011
  }
4423
5012
  },
4424
- "description": "**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.",
5013
+ "description": "**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.",
4425
5014
  "requestBody": {
4426
5015
  "required": true,
4427
5016
  "content": {
@@ -4449,7 +5038,7 @@
4449
5038
  "content": {
4450
5039
  "application/json": {
4451
5040
  "schema": {
4452
- "$ref": "#/components/schemas/session-tokens"
5041
+ "$ref": "#/components/schemas/client-sign-in-result"
4453
5042
  }
4454
5043
  }
4455
5044
  }
@@ -4465,7 +5054,7 @@
4465
5054
  }
4466
5055
  }
4467
5056
  },
4468
- "description": "**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.",
5057
+ "description": "**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.",
4469
5058
  "requestBody": {
4470
5059
  "required": true,
4471
5060
  "content": {
@@ -4536,7 +5125,198 @@
4536
5125
  "description": "Success."
4537
5126
  },
4538
5127
  "default": {
4539
- "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`.",
5128
+ "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`.",
5129
+ "content": {
5130
+ "application/json": {
5131
+ "schema": {
5132
+ "$ref": "#/components/schemas/api-error"
5133
+ }
5134
+ }
5135
+ }
5136
+ }
5137
+ },
5138
+ "description": "**`204`, and a token the server does not recognise gets it too** — the end state a caller asked for is the end state they get, and distinguishing the two would say whether a token ever existed. It answered a body until 2026-09-05, reporting what was left of the session at the identity provider; that belonged to the hosted login flow, where Fleetless owned the browser. The developer's app owns it now and redirects to its own provider itself, knowing which one it is. Open `/realtime` sockets for the session are closed.",
5139
+ "requestBody": {
5140
+ "required": true,
5141
+ "content": {
5142
+ "application/json": {
5143
+ "schema": {
5144
+ "$ref": "#/components/schemas/client-logout-request"
5145
+ }
5146
+ }
5147
+ }
5148
+ }
5149
+ }
5150
+ },
5151
+ "/api/client/password/change": {
5152
+ "post": {
5153
+ "operationId": "post_api_client_password_change",
5154
+ "summary": "Changes an app user's own password and answers a fresh session.",
5155
+ "tags": [
5156
+ "client-auth"
5157
+ ],
5158
+ "security": [
5159
+ {
5160
+ "developerSession": []
5161
+ },
5162
+ {
5163
+ "clientToken": []
5164
+ },
5165
+ {
5166
+ "serverKey": []
5167
+ }
5168
+ ],
5169
+ "parameters": [],
5170
+ "responses": {
5171
+ "200": {
5172
+ "description": "Success.",
5173
+ "content": {
5174
+ "application/json": {
5175
+ "schema": {
5176
+ "$ref": "#/components/schemas/session-tokens"
5177
+ }
5178
+ }
5179
+ }
5180
+ },
5181
+ "default": {
5182
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `validation_error`, `invalid_credentials`, `target_state_conflict`.",
5183
+ "content": {
5184
+ "application/json": {
5185
+ "schema": {
5186
+ "$ref": "#/components/schemas/api-error"
5187
+ }
5188
+ }
5189
+ }
5190
+ }
5191
+ },
5192
+ "description": "The guard admits all three caller kinds, but a password belongs to an app user specifically — a developer bearer or a server key reaching this is `401 unauthorized`. Every other session of the account ends; the answer is the replacement pair, so the tab that made the change stays signed in. An app user belongs to one app, so \"every session\" is this app's. An account that has **no password** — an OIDC-only app user, which the schema admits — answers `409 target_state_conflict` naming the `password` field with rule `not_set`, not `401`: the session is live and the token is fine, it is the account that has nothing to change, and telling such a caller to sign in again sends them round a loop that ends here.",
5193
+ "requestBody": {
5194
+ "required": true,
5195
+ "content": {
5196
+ "application/json": {
5197
+ "schema": {
5198
+ "$ref": "#/components/schemas/password-change-request"
5199
+ }
5200
+ }
5201
+ }
5202
+ }
5203
+ }
5204
+ },
5205
+ "/api/client/me": {
5206
+ "get": {
5207
+ "operationId": "get_api_client_me",
5208
+ "summary": "Answers who the calling token is and what it is allowed to reach.",
5209
+ "tags": [
5210
+ "client-auth"
5211
+ ],
5212
+ "security": [
5213
+ {
5214
+ "developerSession": []
5215
+ },
5216
+ {
5217
+ "clientToken": []
5218
+ },
5219
+ {
5220
+ "serverKey": []
5221
+ }
5222
+ ],
5223
+ "parameters": [],
5224
+ "responses": {
5225
+ "200": {
5226
+ "description": "Success.",
5227
+ "content": {
5228
+ "application/json": {
5229
+ "schema": {
5230
+ "$ref": "#/components/schemas/client-identity"
5231
+ }
5232
+ }
5233
+ }
5234
+ },
5235
+ "default": {
5236
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`.",
5237
+ "content": {
5238
+ "application/json": {
5239
+ "schema": {
5240
+ "$ref": "#/components/schemas/api-error"
5241
+ }
5242
+ }
5243
+ }
5244
+ }
5245
+ },
5246
+ "description": "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."
5247
+ }
5248
+ },
5249
+ "/api/client/two-factor/verify": {
5250
+ "post": {
5251
+ "operationId": "post_api_client_two_factor_verify",
5252
+ "summary": "Answers a two-factor challenge with an authenticator or recovery code, and answers the session.",
5253
+ "tags": [
5254
+ "client-auth"
5255
+ ],
5256
+ "security": [],
5257
+ "parameters": [],
5258
+ "responses": {
5259
+ "200": {
5260
+ "description": "Success.",
5261
+ "content": {
5262
+ "application/json": {
5263
+ "schema": {
5264
+ "$ref": "#/components/schemas/session-tokens"
5265
+ }
5266
+ }
5267
+ }
5268
+ },
5269
+ "default": {
5270
+ "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `invalid_code`, `token_spent`.",
5271
+ "content": {
5272
+ "application/json": {
5273
+ "schema": {
5274
+ "$ref": "#/components/schemas/api-error"
5275
+ }
5276
+ }
5277
+ }
5278
+ }
5279
+ },
5280
+ "description": "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`.",
5281
+ "requestBody": {
5282
+ "required": true,
5283
+ "content": {
5284
+ "application/json": {
5285
+ "schema": {
5286
+ "$ref": "#/components/schemas/client-two-factor-verify-request"
5287
+ }
5288
+ }
5289
+ }
5290
+ }
5291
+ }
5292
+ },
5293
+ "/api/client/two-factor/setup": {
5294
+ "post": {
5295
+ "operationId": "post_api_client_two_factor_setup",
5296
+ "summary": "Starts an authenticator setup and answers its secret and otpauth URL.",
5297
+ "tags": [
5298
+ "client-auth"
5299
+ ],
5300
+ "security": [
5301
+ {
5302
+ "clientToken": []
5303
+ },
5304
+ {}
5305
+ ],
5306
+ "parameters": [],
5307
+ "responses": {
5308
+ "200": {
5309
+ "description": "Success.",
5310
+ "content": {
5311
+ "application/json": {
5312
+ "schema": {
5313
+ "$ref": "#/components/schemas/two-factor-setup-response"
5314
+ }
5315
+ }
5316
+ }
5317
+ },
5318
+ "default": {
5319
+ "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `token_spent`, `unauthorized`, `target_state_conflict`.",
4540
5320
  "content": {
4541
5321
  "application/json": {
4542
5322
  "schema": {
@@ -4546,36 +5326,31 @@
4546
5326
  }
4547
5327
  }
4548
5328
  },
4549
- "description": "**`204`, and a token the server does not recognise gets it too** — the end state a caller asked for is the end state they get, and distinguishing the two would say whether a token ever existed. It answered a body until 2026-09-05, reporting what was left of the session at the identity provider; that belonged to the hosted login flow, where Fleetless owned the browser. The developer's app owns it now and redirects to its own provider itself, knowing which one it is. Open `/realtime` sockets for the session are closed.",
5329
+ "description": "**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.",
4550
5330
  "requestBody": {
4551
5331
  "required": true,
4552
5332
  "content": {
4553
5333
  "application/json": {
4554
5334
  "schema": {
4555
- "$ref": "#/components/schemas/client-logout-request"
5335
+ "$ref": "#/components/schemas/client-two-factor-setup-request"
4556
5336
  }
4557
5337
  }
4558
5338
  }
4559
5339
  }
4560
5340
  }
4561
5341
  },
4562
- "/api/client/password/change": {
5342
+ "/api/client/two-factor/setup/confirm": {
4563
5343
  "post": {
4564
- "operationId": "post_api_client_password_change",
4565
- "summary": "Changes an app user's own password and answers a fresh session.",
5344
+ "operationId": "post_api_client_two_factor_setup_confirm",
5345
+ "summary": "Confirms the new authenticator with a code and answers the recovery codes and a session.",
4566
5346
  "tags": [
4567
5347
  "client-auth"
4568
5348
  ],
4569
5349
  "security": [
4570
- {
4571
- "developerSession": []
4572
- },
4573
5350
  {
4574
5351
  "clientToken": []
4575
5352
  },
4576
- {
4577
- "serverKey": []
4578
- }
5353
+ {}
4579
5354
  ],
4580
5355
  "parameters": [],
4581
5356
  "responses": {
@@ -4584,13 +5359,13 @@
4584
5359
  "content": {
4585
5360
  "application/json": {
4586
5361
  "schema": {
4587
- "$ref": "#/components/schemas/session-tokens"
5362
+ "$ref": "#/components/schemas/client-two-factor-setup-confirm-response"
4588
5363
  }
4589
5364
  }
4590
5365
  }
4591
5366
  },
4592
5367
  "default": {
4593
- "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `validation_error`, `invalid_credentials`, `target_state_conflict`.",
5368
+ "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `invalid_code`, `token_spent`, `unauthorized`.",
4594
5369
  "content": {
4595
5370
  "application/json": {
4596
5371
  "schema": {
@@ -4600,23 +5375,23 @@
4600
5375
  }
4601
5376
  }
4602
5377
  },
4603
- "description": "The guard admits all three caller kinds, but a password belongs to an app user specifically — a developer bearer or a server key reaching this is `401 unauthorized`. Every other session of the account ends; the answer is the replacement pair, so the tab that made the change stays signed in. An app user belongs to one app, so \"every session\" is this app's. An account that has **no password** — an OIDC-only app user, which the schema admits — answers `409 target_state_conflict` naming the `password` field with rule `not_set`, not `401`: the session is live and the token is fine, it is the account that has nothing to change, and telling such a caller to sign in again sends them round a loop that ends here.",
5378
+ "description": "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`.",
4604
5379
  "requestBody": {
4605
5380
  "required": true,
4606
5381
  "content": {
4607
5382
  "application/json": {
4608
5383
  "schema": {
4609
- "$ref": "#/components/schemas/password-change-request"
5384
+ "$ref": "#/components/schemas/client-two-factor-setup-confirm-request"
4610
5385
  }
4611
5386
  }
4612
5387
  }
4613
5388
  }
4614
5389
  }
4615
5390
  },
4616
- "/api/client/me": {
4617
- "get": {
4618
- "operationId": "get_api_client_me",
4619
- "summary": "Answers who the calling token is and what it is allowed to reach.",
5391
+ "/api/client/two-factor": {
5392
+ "delete": {
5393
+ "operationId": "delete_api_client_two_factor",
5394
+ "summary": "Turns the signed-in app user's authenticator off.",
4620
5395
  "tags": [
4621
5396
  "client-auth"
4622
5397
  ],
@@ -4633,18 +5408,11 @@
4633
5408
  ],
4634
5409
  "parameters": [],
4635
5410
  "responses": {
4636
- "200": {
4637
- "description": "Success.",
4638
- "content": {
4639
- "application/json": {
4640
- "schema": {
4641
- "$ref": "#/components/schemas/client-identity"
4642
- }
4643
- }
4644
- }
5411
+ "204": {
5412
+ "description": "Success."
4645
5413
  },
4646
5414
  "default": {
4647
- "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`.",
5415
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`, `rate_limited`, `validation_error`, `invalid_code`, `target_state_conflict`.",
4648
5416
  "content": {
4649
5417
  "application/json": {
4650
5418
  "schema": {
@@ -4654,7 +5422,17 @@
4654
5422
  }
4655
5423
  }
4656
5424
  },
4657
- "description": "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."
5425
+ "description": "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`.",
5426
+ "requestBody": {
5427
+ "required": true,
5428
+ "content": {
5429
+ "application/json": {
5430
+ "schema": {
5431
+ "$ref": "#/components/schemas/client-two-factor-disable-request"
5432
+ }
5433
+ }
5434
+ }
5435
+ }
4658
5436
  }
4659
5437
  },
4660
5438
  "/api/client/providers": {
@@ -4849,7 +5627,7 @@
4849
5627
  }
4850
5628
  }
4851
5629
  },
4852
- "description": "**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."
5630
+ "description": "**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."
4853
5631
  }
4854
5632
  },
4855
5633
  "/api/client/oidc/exchange": {
@@ -8505,16 +9283,22 @@
8505
9283
  "minLength": 1,
8506
9284
  "description": "The opaque invitation token from the link. Unknown, expired and already-accepted all collapse into `410 token_spent` — telling them apart would say whether a token ever existed."
8507
9285
  },
8508
- "password": {
8509
- "type": "string",
8510
- "minLength": 12,
8511
- "maxLength": 256,
8512
- "description": "The password the new Fleetless account will use. At least 12 characters."
9286
+ "display_name": {
9287
+ "description": "An optional name, overriding whatever the invitation pre-filled. Absent keeps it.",
9288
+ "anyOf": [
9289
+ {
9290
+ "type": "string",
9291
+ "minLength": 1,
9292
+ "maxLength": 120
9293
+ },
9294
+ {
9295
+ "type": "null"
9296
+ }
9297
+ ]
8513
9298
  }
8514
9299
  },
8515
9300
  "required": [
8516
- "token",
8517
- "password"
9301
+ "token"
8518
9302
  ],
8519
9303
  "additionalProperties": false
8520
9304
  },
@@ -8817,7 +9601,7 @@
8817
9601
  "type": "null"
8818
9602
  }
8819
9603
  ],
8820
- "description": "The page in the developer's app that accepts an invitation, with `{token}` where the token goes. `null` when unconfigured, and then an invitation still issues but `send_mail` is refused with `409 target_state_conflict` — there would be nowhere for the link to point."
9604
+ "description": "The page in the developer's app that accepts an invitation, with `{token}` where the token goes. `null` means the hosted page in `hosted_pages` is used."
8821
9605
  },
8822
9606
  "verify_url": {
8823
9607
  "anyOf": [
@@ -8829,7 +9613,7 @@
8829
9613
  "type": "null"
8830
9614
  }
8831
9615
  ],
8832
- "description": "The page that confirms a new address, with `{token}` where the token goes. Self-registration needs it: without a page to send people to, a registration would leave an account nobody can activate."
9616
+ "description": "The page that confirms a new address, with `{token}` where the token goes. `null` means the hosted page in `hosted_pages` is used."
8833
9617
  },
8834
9618
  "reset_url": {
8835
9619
  "anyOf": [
@@ -8841,7 +9625,7 @@
8841
9625
  "type": "null"
8842
9626
  }
8843
9627
  ],
8844
- "description": "The page that takes a new password, with `{token}` where the token goes."
9628
+ "description": "The page that takes a new password, with `{token}` where the token goes. `null` means the hosted page in `hosted_pages` is used."
8845
9629
  },
8846
9630
  "mcp_login_url": {
8847
9631
  "anyOf": [
@@ -8853,7 +9637,105 @@
8853
9637
  "type": "null"
8854
9638
  }
8855
9639
  ],
8856
- "description": "The page an MCP authorization redirects to, with `{interaction}` where the interaction id goes. Not a token: the id names a pending request the server already holds, and the app authenticates the user itself before approving it."
9640
+ "description": "The page an MCP authorization redirects to, with `{interaction}` where the interaction id goes. Not a token: the id names a pending request the server already holds, and the app authenticates the user itself before approving it. `null` means the hosted MCP sign-in in `hosted_pages` is used."
9641
+ },
9642
+ "app_url": {
9643
+ "anyOf": [
9644
+ {
9645
+ "type": "string",
9646
+ "maxLength": 500,
9647
+ "format": "uri"
9648
+ },
9649
+ {
9650
+ "type": "null"
9651
+ }
9652
+ ],
9653
+ "description": "The app's own home page, linked as `Open <app>` when a hosted flow is done. `null` makes the hosted done page say `You can close this tab`."
9654
+ },
9655
+ "sign_in_methods": {
9656
+ "type": "object",
9657
+ "properties": {
9658
+ "password": {
9659
+ "type": "boolean",
9660
+ "description": "Whether app users may sign in with a password. Off refuses `POST /api/client/login` with `method_not_allowed`, and registration and invitations then take no password."
9661
+ },
9662
+ "email_code": {
9663
+ "type": "boolean",
9664
+ "description": "Whether app users may sign in with a six-digit code mailed to them, valid ten minutes. A code needs no URL, so it works in local development and in an app with no web UI."
9665
+ }
9666
+ },
9667
+ "required": [
9668
+ "password",
9669
+ "email_code"
9670
+ ],
9671
+ "additionalProperties": false,
9672
+ "description": "Which sign-in methods the app offers: password, emailed code, or both — at least one. Identity providers stay on top of either. The default is password only."
9673
+ },
9674
+ "two_factor": {
9675
+ "type": "string",
9676
+ "enum": [
9677
+ "off",
9678
+ "optional",
9679
+ "required"
9680
+ ],
9681
+ "description": "Whether the app asks for an authenticator code: `off` (the default), `optional` or `required`. A person with a confirmed authenticator is asked at every sign-in whatever the policy; a sign-in through an identity provider is never asked."
9682
+ },
9683
+ "hosted_logo_url": {
9684
+ "anyOf": [
9685
+ {
9686
+ "type": "string",
9687
+ "format": "uri"
9688
+ },
9689
+ {
9690
+ "type": "null"
9691
+ }
9692
+ ],
9693
+ "description": "Where the hosted pages load the app's logo from, `<portal>/app/<identifier>/logo`, or `null` when no logo is stored. **Read-only** — the logo is written through `PUT /api/apps/:id/auth-config/logo`."
9694
+ },
9695
+ "hosted_accent": {
9696
+ "anyOf": [
9697
+ {
9698
+ "type": "string",
9699
+ "pattern": "^#[0-9a-f]{6}$"
9700
+ },
9701
+ {
9702
+ "type": "null"
9703
+ }
9704
+ ],
9705
+ "description": "The accent colour of the hosted pages, `#rrggbb` in lowercase, or `null` for the neutral shell's own."
9706
+ },
9707
+ "hosted_pages": {
9708
+ "type": "object",
9709
+ "properties": {
9710
+ "invite_url": {
9711
+ "type": "string",
9712
+ "format": "uri",
9713
+ "description": "The hosted invitation page, `<portal>/app/<identifier>/invite/{token}`."
9714
+ },
9715
+ "verify_url": {
9716
+ "type": "string",
9717
+ "format": "uri",
9718
+ "description": "The hosted email-confirmation page, `<portal>/app/<identifier>/verify/{token}`."
9719
+ },
9720
+ "reset_url": {
9721
+ "type": "string",
9722
+ "format": "uri",
9723
+ "description": "The hosted new-password page, `<portal>/app/<identifier>/reset/{token}`."
9724
+ },
9725
+ "mcp_login_url": {
9726
+ "type": "string",
9727
+ "format": "uri",
9728
+ "description": "The hosted MCP sign-in, `<portal>/app/<identifier>/mcp/{interaction}`."
9729
+ }
9730
+ },
9731
+ "required": [
9732
+ "invite_url",
9733
+ "verify_url",
9734
+ "reset_url",
9735
+ "mcp_login_url"
9736
+ ],
9737
+ "additionalProperties": false,
9738
+ "description": "The Fleetless-hosted pages an unset URL falls back to, as templates. **Read-only**: minted by the cloud from the auth portal and the app's identifier."
8857
9739
  },
8858
9740
  "oidc_callback_url": {
8859
9741
  "type": "string",
@@ -8876,6 +9758,12 @@
8876
9758
  "verify_url",
8877
9759
  "reset_url",
8878
9760
  "mcp_login_url",
9761
+ "app_url",
9762
+ "sign_in_methods",
9763
+ "two_factor",
9764
+ "hosted_logo_url",
9765
+ "hosted_accent",
9766
+ "hosted_pages",
8879
9767
  "oidc_callback_url",
8880
9768
  "updated_at"
8881
9769
  ],
@@ -8975,7 +9863,7 @@
8975
9863
  "type": "null"
8976
9864
  }
8977
9865
  ],
8978
- "description": "The link to give the invitee, built from the app's `invite_url` with the token substituted for `{token}`. **`null` when the app has configured no `invite_url`** — there is nowhere for the link to point, and Fleetless serves no page of its own for an app user. Bounded like every other URL that gets mailed, logged and rendered."
9866
+ "description": "The link to give the invitee: the app's `invite_url` with the token substituted for `{token}`, or the Fleetless-hosted invitation page when the app has configured none. Bounded like every other URL that gets mailed, logged and rendered. Nullable for readers of the earlier shape; the cloud always fills it."
8979
9867
  },
8980
9868
  "mail": {
8981
9869
  "type": "string",
@@ -8985,7 +9873,7 @@
8985
9873
  "not_configured",
8986
9874
  "failed"
8987
9875
  ],
8988
- "description": "What happened to the mail: `sent` means the SMTP server accepted it, not that it was delivered; `not_requested` means none was attempted — the caller asked for none, or the app has no `invite_url` for a link to point at; `not_configured` is an expected state and not a failure; `failed` is the one worth somebody's attention."
9876
+ "description": "What happened to the mail: `sent` means the SMTP server accepted it, not that it was delivered; `not_requested` means none was attempted because the caller asked for none; `not_configured` is an expected state and not a failure; `failed` is the one worth somebody's attention."
8989
9877
  }
8990
9878
  },
8991
9879
  "required": [
@@ -9144,9 +10032,10 @@
9144
10032
  "enum": [
9145
10033
  "invite",
9146
10034
  "verify",
9147
- "reset"
10035
+ "reset",
10036
+ "login_code"
9148
10037
  ],
9149
- "description": "Which of the three mails this template replaces."
10038
+ "description": "Which of the four mails this template replaces."
9150
10039
  },
9151
10040
  "subject": {
9152
10041
  "type": "string",
@@ -9193,7 +10082,7 @@
9193
10082
  "type": "object",
9194
10083
  "properties": {
9195
10084
  "templates": {
9196
- "maxItems": 3,
10085
+ "maxItems": 4,
9197
10086
  "type": "array",
9198
10087
  "items": {
9199
10088
  "type": "object",
@@ -9203,9 +10092,10 @@
9203
10092
  "enum": [
9204
10093
  "invite",
9205
10094
  "verify",
9206
- "reset"
10095
+ "reset",
10096
+ "login_code"
9207
10097
  ],
9208
- "description": "Which of the three mails this template replaces."
10098
+ "description": "Which of the four mails this template replaces."
9209
10099
  },
9210
10100
  "subject": {
9211
10101
  "type": "string",
@@ -9503,6 +10393,41 @@
9503
10393
  ],
9504
10394
  "description": "When this user last signed in, or `null` if they never have. Required and nullable rather than optional, so *never logged in* stays distinguishable from *this field was not loaded*."
9505
10395
  },
10396
+ "two_factor": {
10397
+ "type": "object",
10398
+ "properties": {
10399
+ "enabled": {
10400
+ "type": "boolean",
10401
+ "description": "Whether the account has a confirmed authenticator app (TOTP). When it has, every sign-in that yields a session asks for a code as well — whatever the app's policy — except a sign-in through an identity provider, which owns that sign-in."
10402
+ },
10403
+ "enabled_at": {
10404
+ "anyOf": [
10405
+ {
10406
+ "type": "string",
10407
+ "format": "date-time",
10408
+ "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
10409
+ },
10410
+ {
10411
+ "type": "null"
10412
+ }
10413
+ ],
10414
+ "description": "When the authenticator was confirmed, or `null` while `enabled` is `false`."
10415
+ },
10416
+ "recovery_codes_left": {
10417
+ "type": "integer",
10418
+ "minimum": 0,
10419
+ "maximum": 10,
10420
+ "description": "How many of the ten single-use recovery codes are still unspent. `0` while `enabled` is `false`."
10421
+ }
10422
+ },
10423
+ "required": [
10424
+ "enabled",
10425
+ "enabled_at",
10426
+ "recovery_codes_left"
10427
+ ],
10428
+ "additionalProperties": false,
10429
+ "description": "The account's second factor, as a developer's user list shows it. No secret and no code travels here; resetting it is `DELETE /api/apps/:id/users/:userId/two-factor`."
10430
+ },
9506
10431
  "created_at": {
9507
10432
  "type": "string",
9508
10433
  "format": "date-time",
@@ -9520,6 +10445,7 @@
9520
10445
  "has_password",
9521
10446
  "providers",
9522
10447
  "last_login_at",
10448
+ "two_factor",
9523
10449
  "created_at"
9524
10450
  ],
9525
10451
  "additionalProperties": false
@@ -9605,6 +10531,41 @@
9605
10531
  ],
9606
10532
  "description": "When this user last signed in, or `null` if they never have. Required and nullable rather than optional, so *never logged in* stays distinguishable from *this field was not loaded*."
9607
10533
  },
10534
+ "two_factor": {
10535
+ "type": "object",
10536
+ "properties": {
10537
+ "enabled": {
10538
+ "type": "boolean",
10539
+ "description": "Whether the account has a confirmed authenticator app (TOTP). When it has, every sign-in that yields a session asks for a code as well — whatever the app's policy — except a sign-in through an identity provider, which owns that sign-in."
10540
+ },
10541
+ "enabled_at": {
10542
+ "anyOf": [
10543
+ {
10544
+ "type": "string",
10545
+ "format": "date-time",
10546
+ "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
10547
+ },
10548
+ {
10549
+ "type": "null"
10550
+ }
10551
+ ],
10552
+ "description": "When the authenticator was confirmed, or `null` while `enabled` is `false`."
10553
+ },
10554
+ "recovery_codes_left": {
10555
+ "type": "integer",
10556
+ "minimum": 0,
10557
+ "maximum": 10,
10558
+ "description": "How many of the ten single-use recovery codes are still unspent. `0` while `enabled` is `false`."
10559
+ }
10560
+ },
10561
+ "required": [
10562
+ "enabled",
10563
+ "enabled_at",
10564
+ "recovery_codes_left"
10565
+ ],
10566
+ "additionalProperties": false,
10567
+ "description": "The account's second factor, as a developer's user list shows it. No secret and no code travels here; resetting it is `DELETE /api/apps/:id/users/:userId/two-factor`."
10568
+ },
9608
10569
  "created_at": {
9609
10570
  "type": "string",
9610
10571
  "format": "date-time",
@@ -9622,6 +10583,7 @@
9622
10583
  "has_password",
9623
10584
  "providers",
9624
10585
  "last_login_at",
10586
+ "two_factor",
9625
10587
  "created_at"
9626
10588
  ],
9627
10589
  "additionalProperties": false
@@ -10362,6 +11324,10 @@
10362
11324
  "maxLength": 120,
10363
11325
  "description": "The organisation's display name. Free text, changed through `PATCH /api/org`."
10364
11326
  },
11327
+ "require_two_factor": {
11328
+ "type": "boolean",
11329
+ "description": "Whether every member must have a second factor — a passkey or an authenticator app. A member without one sets it up at their next sign-in, before any session exists; nobody is signed out when it is switched on. It covers the console and the central MCP endpoint; server keys and robot bridges are not people and are not affected. Owners change it through `PATCH /api/org`."
11330
+ },
10365
11331
  "created_at": {
10366
11332
  "type": "string",
10367
11333
  "format": "date-time",
@@ -10372,6 +11338,7 @@
10372
11338
  "required": [
10373
11339
  "id",
10374
11340
  "name",
11341
+ "require_two_factor",
10375
11342
  "created_at"
10376
11343
  ],
10377
11344
  "additionalProperties": false
@@ -10418,6 +11385,27 @@
10418
11385
  ],
10419
11386
  "description": "The console powers this person holds. **Required** — every Fleetless user has a tier; it was optional only while the org also held people with no console powers to grade, and that pool is gone."
10420
11387
  },
11388
+ "two_factor": {
11389
+ "type": "object",
11390
+ "properties": {
11391
+ "passkeys": {
11392
+ "type": "integer",
11393
+ "minimum": 0,
11394
+ "maximum": 9007199254740991,
11395
+ "description": "How many passkeys the person has registered."
11396
+ },
11397
+ "authenticator": {
11398
+ "type": "boolean",
11399
+ "description": "Whether the person has a confirmed authenticator app."
11400
+ }
11401
+ },
11402
+ "required": [
11403
+ "passkeys",
11404
+ "authenticator"
11405
+ ],
11406
+ "additionalProperties": false,
11407
+ "description": "The person's second factors, as the team list shows them: none, passkeys, an authenticator, or both. No credential travels here. An owner resets them through `DELETE /api/org/users/:id/two-factor`."
11408
+ },
10421
11409
  "created_at": {
10422
11410
  "type": "string",
10423
11411
  "format": "date-time",
@@ -10431,6 +11419,7 @@
10431
11419
  "email",
10432
11420
  "display_name",
10433
11421
  "tier",
11422
+ "two_factor",
10434
11423
  "created_at"
10435
11424
  ],
10436
11425
  "additionalProperties": false
@@ -10606,10 +11595,10 @@
10606
11595
  "description": "The opaque token from the invitation link, valid seven days. Unknown, expired, revoked and already-accepted all answer `410 token_spent`."
10607
11596
  },
10608
11597
  "password": {
11598
+ "description": "The password the new account will use, at least 12 characters. **Required while the app's password method is on, refused while it is off** — both as `400 validation_error` naming `password`. An email-code-only app accepts invitations without one.",
10609
11599
  "type": "string",
10610
11600
  "minLength": 12,
10611
- "maxLength": 256,
10612
- "description": "The password the new account will use."
11601
+ "maxLength": 256
10613
11602
  },
10614
11603
  "display_name": {
10615
11604
  "description": "An optional name, overriding whatever the invitation pre-filled.",
@@ -10626,8 +11615,7 @@
10626
11615
  }
10627
11616
  },
10628
11617
  "required": [
10629
- "token",
10630
- "password"
11618
+ "token"
10631
11619
  ],
10632
11620
  "additionalProperties": false
10633
11621
  },
@@ -10709,27 +11697,91 @@
10709
11697
  "description": "The role that decides what this caller may reach, and `null` for a developer. Roles are the only visibility filter: what a role does not grant does not exist for that user."
10710
11698
  },
10711
11699
  "email": {
10712
- "anyOf": [
10713
- {
10714
- "type": "string",
10715
- "format": "email",
10716
- "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
10717
- },
10718
- {
10719
- "type": "null"
10720
- }
10721
- ],
10722
- "description": "The address of the Fleetless user or app user behind this session, and `null` for a server key, which is not a person."
11700
+ "anyOf": [
11701
+ {
11702
+ "type": "string",
11703
+ "format": "email",
11704
+ "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
11705
+ },
11706
+ {
11707
+ "type": "null"
11708
+ }
11709
+ ],
11710
+ "description": "The address of the Fleetless user or app user behind this session, and `null` for a server key, which is not a person."
11711
+ },
11712
+ "two_factor_enabled": {
11713
+ "anyOf": [
11714
+ {
11715
+ "type": "boolean"
11716
+ },
11717
+ {
11718
+ "type": "null"
11719
+ }
11720
+ ],
11721
+ "description": "Whether the app user has a confirmed authenticator, so the app's account settings can offer to turn it on or off. `null` unless `kind` is `app_user`."
11722
+ }
11723
+ },
11724
+ "required": [
11725
+ "kind",
11726
+ "developer_id",
11727
+ "app_user_id",
11728
+ "server_key_id",
11729
+ "app_id",
11730
+ "role_id",
11731
+ "email",
11732
+ "two_factor_enabled"
11733
+ ],
11734
+ "additionalProperties": false
11735
+ },
11736
+ "client-login-code-request": {
11737
+ "type": "object",
11738
+ "properties": {
11739
+ "app_identifier": {
11740
+ "type": "string",
11741
+ "minLength": 2,
11742
+ "maxLength": 63,
11743
+ "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
11744
+ "description": "The app to sign in to. An identifier no app carries is `404 not_found`; the address is never the subject of a refusal."
11745
+ },
11746
+ "email": {
11747
+ "type": "string",
11748
+ "format": "email",
11749
+ "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
11750
+ "description": "The address to mail the code to, trimmed and compared case-insensitively. `202` whether or not it names an account of this app."
11751
+ }
11752
+ },
11753
+ "required": [
11754
+ "app_identifier",
11755
+ "email"
11756
+ ],
11757
+ "additionalProperties": false
11758
+ },
11759
+ "client-login-code-verify-request": {
11760
+ "type": "object",
11761
+ "properties": {
11762
+ "app_identifier": {
11763
+ "type": "string",
11764
+ "minLength": 2,
11765
+ "maxLength": 63,
11766
+ "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
11767
+ "description": "The app the code was requested for."
11768
+ },
11769
+ "email": {
11770
+ "type": "string",
11771
+ "format": "email",
11772
+ "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
11773
+ "description": "The address the code was mailed to, as typed when it was requested; trimmed and compared case-insensitively."
11774
+ },
11775
+ "code": {
11776
+ "type": "string",
11777
+ "pattern": "^\\d{6}$",
11778
+ "description": "The six digits from the mail, exactly — leading zeros included, no spaces."
10723
11779
  }
10724
11780
  },
10725
11781
  "required": [
10726
- "kind",
10727
- "developer_id",
10728
- "app_user_id",
10729
- "server_key_id",
10730
- "app_id",
10731
- "role_id",
10732
- "email"
11782
+ "app_identifier",
11783
+ "email",
11784
+ "code"
10733
11785
  ],
10734
11786
  "additionalProperties": false
10735
11787
  },
@@ -10935,11 +11987,31 @@
10935
11987
  ],
10936
11988
  "additionalProperties": false
10937
11989
  },
10938
- "description": "The app's **enabled** providers, slug and display name only. An app with none answers an empty array, which is the state of an app that offers password login alone."
11990
+ "description": "The app's **enabled** providers, slug and display name only. An app with none answers an empty array, which is the state of an app that offers password or code sign-in alone."
11991
+ },
11992
+ "sign_in_methods": {
11993
+ "type": "object",
11994
+ "properties": {
11995
+ "password": {
11996
+ "type": "boolean",
11997
+ "description": "Whether app users may sign in with a password. Off refuses `POST /api/client/login` with `method_not_allowed`, and registration and invitations then take no password."
11998
+ },
11999
+ "email_code": {
12000
+ "type": "boolean",
12001
+ "description": "Whether app users may sign in with a six-digit code mailed to them, valid ten minutes. A code needs no URL, so it works in local development and in an app with no web UI."
12002
+ }
12003
+ },
12004
+ "required": [
12005
+ "password",
12006
+ "email_code"
12007
+ ],
12008
+ "additionalProperties": false,
12009
+ "description": "Which of password and emailed code the app accepts, so its sign-in page draws the right fields without guessing. The same value the developer set; public, like the provider buttons."
10939
12010
  }
10940
12011
  },
10941
12012
  "required": [
10942
- "providers"
12013
+ "providers",
12014
+ "sign_in_methods"
10943
12015
  ],
10944
12016
  "additionalProperties": false
10945
12017
  },
@@ -10973,10 +12045,10 @@
10973
12045
  "description": "The address to register. Unique per app, case-insensitively. An address this app already knows still answers `202`, without a mail — the answer may not say whether an account exists."
10974
12046
  },
10975
12047
  "password": {
12048
+ "description": "The password for the new account, at least 12 characters. **Required while the app's password method is on, refused while it is off** — both as `400 validation_error` naming `password`. An email-code-only app registers people without one.",
10976
12049
  "type": "string",
10977
12050
  "minLength": 12,
10978
- "maxLength": 256,
10979
- "description": "The password for the new account. At least 12 characters; length only, because a rule a user cannot predict is a rule they work around."
12051
+ "maxLength": 256
10980
12052
  },
10981
12053
  "display_name": {
10982
12054
  "description": "An optional human name for the account. The developer's own UI decides whether to ask for it.",
@@ -10994,8 +12066,7 @@
10994
12066
  },
10995
12067
  "required": [
10996
12068
  "app_identifier",
10997
- "email",
10998
- "password"
12069
+ "email"
10999
12070
  ],
11000
12071
  "additionalProperties": false
11001
12072
  },
@@ -11109,6 +12180,176 @@
11109
12180
  ],
11110
12181
  "additionalProperties": false
11111
12182
  },
12183
+ "client-sign-in-result": {
12184
+ "anyOf": [
12185
+ {
12186
+ "type": "object",
12187
+ "properties": {
12188
+ "access_token": {
12189
+ "type": "string",
12190
+ "minLength": 1,
12191
+ "description": "The token to send as `Authorization: Bearer <token>` on every call. Short-lived: read `expires_in` rather than assuming a lifetime."
12192
+ },
12193
+ "refresh_token": {
12194
+ "type": "string",
12195
+ "minLength": 1,
12196
+ "description": "The token that buys the next access token. It rotates on every use, so a value presented twice is detectable theft and ends the whole family."
12197
+ },
12198
+ "expires_in": {
12199
+ "type": "integer",
12200
+ "exclusiveMinimum": 0,
12201
+ "maximum": 9007199254740991,
12202
+ "description": "How long the access token stays valid, in **seconds** from now. Not a timestamp, and not milliseconds."
12203
+ }
12204
+ },
12205
+ "required": [
12206
+ "access_token",
12207
+ "refresh_token",
12208
+ "expires_in"
12209
+ ],
12210
+ "additionalProperties": false
12211
+ },
12212
+ {
12213
+ "type": "object",
12214
+ "properties": {
12215
+ "status": {
12216
+ "type": "string",
12217
+ "enum": [
12218
+ "two_factor_required",
12219
+ "two_factor_setup_required"
12220
+ ],
12221
+ "description": "`two_factor_required`: ask for the authenticator code. `two_factor_setup_required`: the app requires two-factor and the person has none yet, so set one up before any session exists."
12222
+ },
12223
+ "challenge": {
12224
+ "type": "string",
12225
+ "minLength": 1,
12226
+ "description": "The handle the next step spends. Valid five minutes; afterwards it answers `410 token_spent` and the sign-in starts over."
12227
+ }
12228
+ },
12229
+ "required": [
12230
+ "status",
12231
+ "challenge"
12232
+ ],
12233
+ "additionalProperties": false
12234
+ }
12235
+ ]
12236
+ },
12237
+ "client-two-factor-disable-request": {
12238
+ "type": "object",
12239
+ "properties": {
12240
+ "code": {
12241
+ "type": "string",
12242
+ "pattern": "^\\d{6}$",
12243
+ "description": "A code the authenticator shows now."
12244
+ }
12245
+ },
12246
+ "required": [
12247
+ "code"
12248
+ ],
12249
+ "additionalProperties": false
12250
+ },
12251
+ "client-two-factor-setup-confirm-request": {
12252
+ "type": "object",
12253
+ "properties": {
12254
+ "challenge": {
12255
+ "description": "The same challenge as at `setup`, during sign-in; absent with a bearer.",
12256
+ "type": "string",
12257
+ "minLength": 1
12258
+ },
12259
+ "code": {
12260
+ "type": "string",
12261
+ "pattern": "^\\d{6}$",
12262
+ "description": "A code the new authenticator shows now. It proves the secret was copied correctly before anything depends on it."
12263
+ }
12264
+ },
12265
+ "required": [
12266
+ "code"
12267
+ ],
12268
+ "additionalProperties": false
12269
+ },
12270
+ "client-two-factor-setup-confirm-response": {
12271
+ "type": "object",
12272
+ "properties": {
12273
+ "recovery_codes": {
12274
+ "minItems": 10,
12275
+ "maxItems": 10,
12276
+ "type": "array",
12277
+ "items": {
12278
+ "type": "string",
12279
+ "pattern": "^[a-z2-7]{5}-[a-z2-7]{5}$"
12280
+ },
12281
+ "description": "The ten single-use recovery codes, lowercase, shown once. Any earlier set is void."
12282
+ },
12283
+ "session": {
12284
+ "type": "object",
12285
+ "properties": {
12286
+ "access_token": {
12287
+ "type": "string",
12288
+ "minLength": 1,
12289
+ "description": "The token to send as `Authorization: Bearer <token>` on every call. Short-lived: read `expires_in` rather than assuming a lifetime."
12290
+ },
12291
+ "refresh_token": {
12292
+ "type": "string",
12293
+ "minLength": 1,
12294
+ "description": "The token that buys the next access token. It rotates on every use, so a value presented twice is detectable theft and ends the whole family."
12295
+ },
12296
+ "expires_in": {
12297
+ "type": "integer",
12298
+ "exclusiveMinimum": 0,
12299
+ "maximum": 9007199254740991,
12300
+ "description": "How long the access token stays valid, in **seconds** from now. Not a timestamp, and not milliseconds."
12301
+ }
12302
+ },
12303
+ "required": [
12304
+ "access_token",
12305
+ "refresh_token",
12306
+ "expires_in"
12307
+ ],
12308
+ "additionalProperties": false,
12309
+ "description": "The session the sign-in was waiting for, or a fresh one for the account settings."
12310
+ }
12311
+ },
12312
+ "required": [
12313
+ "recovery_codes",
12314
+ "session"
12315
+ ],
12316
+ "additionalProperties": false
12317
+ },
12318
+ "client-two-factor-setup-request": {
12319
+ "type": "object",
12320
+ "properties": {
12321
+ "challenge": {
12322
+ "description": "The `two_factor_setup_required` challenge, during sign-in. Absent when the call carries the app user's bearer instead.",
12323
+ "type": "string",
12324
+ "minLength": 1
12325
+ }
12326
+ },
12327
+ "additionalProperties": false
12328
+ },
12329
+ "client-two-factor-verify-request": {
12330
+ "type": "object",
12331
+ "properties": {
12332
+ "challenge": {
12333
+ "type": "string",
12334
+ "minLength": 1,
12335
+ "description": "The challenge the sign-in step answered."
12336
+ },
12337
+ "code": {
12338
+ "description": "The six-digit code the authenticator shows now. A code already accepted once is refused, so a replay of a seen code does not sign anybody in.",
12339
+ "type": "string",
12340
+ "pattern": "^\\d{6}$"
12341
+ },
12342
+ "recovery_code": {
12343
+ "description": "One of the ten recovery codes, `xxxxx-xxxxx`, in either case. Spent by its use.",
12344
+ "type": "string",
12345
+ "pattern": "^[a-zA-Z2-7]{5}-[a-zA-Z2-7]{5}$"
12346
+ }
12347
+ },
12348
+ "required": [
12349
+ "challenge"
12350
+ ],
12351
+ "additionalProperties": false
12352
+ },
11112
12353
  "client-verify-email-request": {
11113
12354
  "type": "object",
11114
12355
  "properties": {
@@ -13439,7 +14680,7 @@
13439
14680
  },
13440
14681
  "send_mail": {
13441
14682
  "type": "boolean",
13442
- "description": "Whether Fleetless mails the invitation. **Refused with `409 target_state_conflict` naming `invite_url` when the app has configured none** — there would be nowhere for the link to point, and a mail carrying a Fleetless-hosted page is a surface this product does not have."
14683
+ "description": "Whether Fleetless mails the invitation. The link points at the app's `invite_url`, or at the Fleetless-hosted invitation page when the app has configured none — so the mail always leads somewhere, and nothing is refused for a missing URL."
13443
14684
  }
13444
14685
  },
13445
14686
  "required": [
@@ -13561,29 +14802,136 @@
13561
14802
  "maxLength": 256,
13562
14803
  "description": "The initial password. At least 12 characters: length only, because a rule a user cannot predict is a rule they work around."
13563
14804
  },
13564
- "display_name": {
13565
- "description": "Optional human name. Absent leaves it unset; an explicit `null` is the same end state.",
14805
+ "display_name": {
14806
+ "description": "Optional human name. Absent leaves it unset; an explicit `null` is the same end state.",
14807
+ "anyOf": [
14808
+ {
14809
+ "type": "string",
14810
+ "minLength": 1,
14811
+ "maxLength": 120
14812
+ },
14813
+ {
14814
+ "type": "null"
14815
+ }
14816
+ ]
14817
+ },
14818
+ "role_id": {
14819
+ "description": "The role the new user holds. Absent means the app's `default_role_id`, and `409 target_state_conflict` when the app has none or its default no longer resolves; a role belonging to another app is `404 not_found`, the same refusal a role that never existed gets.",
14820
+ "type": "string",
14821
+ "format": "uuid",
14822
+ "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
14823
+ }
14824
+ },
14825
+ "required": [
14826
+ "email",
14827
+ "password"
14828
+ ],
14829
+ "additionalProperties": false
14830
+ },
14831
+ "create-passkey-request": {
14832
+ "type": "object",
14833
+ "properties": {
14834
+ "name": {
14835
+ "type": "string",
14836
+ "minLength": 1,
14837
+ "maxLength": 80,
14838
+ "description": "What to call the passkey, such as the device it lives on."
14839
+ },
14840
+ "credential": {
14841
+ "type": "object",
14842
+ "propertyNames": {
14843
+ "type": "string"
14844
+ },
14845
+ "additionalProperties": {},
14846
+ "description": "The browser's `RegistrationResponseJSON` for the options `POST /api/auth/passkeys/options` answered."
14847
+ }
14848
+ },
14849
+ "required": [
14850
+ "name",
14851
+ "credential"
14852
+ ],
14853
+ "additionalProperties": false
14854
+ },
14855
+ "create-passkey-response": {
14856
+ "type": "object",
14857
+ "properties": {
14858
+ "passkey": {
14859
+ "type": "object",
14860
+ "properties": {
14861
+ "id": {
14862
+ "type": "string",
14863
+ "format": "uuid",
14864
+ "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
14865
+ "description": "The passkey in the API, as renamed and removed through `/api/auth/passkeys/:id`."
14866
+ },
14867
+ "name": {
14868
+ "type": "string",
14869
+ "minLength": 1,
14870
+ "maxLength": 80,
14871
+ "description": "What the person called it, such as the device it lives on."
14872
+ },
14873
+ "created_at": {
14874
+ "type": "string",
14875
+ "format": "date-time",
14876
+ "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
14877
+ "description": "When it was registered."
14878
+ },
14879
+ "last_used_at": {
14880
+ "anyOf": [
14881
+ {
14882
+ "type": "string",
14883
+ "format": "date-time",
14884
+ "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
14885
+ },
14886
+ {
14887
+ "type": "null"
14888
+ }
14889
+ ],
14890
+ "description": "When it last signed the person in or confirmed a sign-in, or `null` if never."
14891
+ },
14892
+ "synced": {
14893
+ "anyOf": [
14894
+ {
14895
+ "type": "boolean"
14896
+ },
14897
+ {
14898
+ "type": "null"
14899
+ }
14900
+ ],
14901
+ "description": "Whether the authenticator reported the passkey as syncable across the person's devices (the backup-eligible flag), or `null` when it said nothing."
14902
+ }
14903
+ },
14904
+ "required": [
14905
+ "id",
14906
+ "name",
14907
+ "created_at",
14908
+ "last_used_at",
14909
+ "synced"
14910
+ ],
14911
+ "additionalProperties": false,
14912
+ "description": "The passkey as it is now stored."
14913
+ },
14914
+ "recovery_codes": {
13566
14915
  "anyOf": [
13567
14916
  {
13568
- "type": "string",
13569
- "minLength": 1,
13570
- "maxLength": 120
14917
+ "minItems": 10,
14918
+ "maxItems": 10,
14919
+ "type": "array",
14920
+ "items": {
14921
+ "type": "string",
14922
+ "pattern": "^[a-z2-7]{5}-[a-z2-7]{5}$"
14923
+ }
13571
14924
  },
13572
14925
  {
13573
14926
  "type": "null"
13574
14927
  }
13575
- ]
13576
- },
13577
- "role_id": {
13578
- "description": "The role the new user holds. Absent means the app's `default_role_id`, and `409 target_state_conflict` when the app has none or its default no longer resolves; a role belonging to another app is `404 not_found`, the same refusal a role that never existed gets.",
13579
- "type": "string",
13580
- "format": "uuid",
13581
- "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
14928
+ ],
14929
+ "description": "The ten recovery codes, shown once, when this passkey is the account's first second factor; `null` when the account already had one and its codes stay valid."
13582
14930
  }
13583
14931
  },
13584
14932
  "required": [
13585
- "email",
13586
- "password"
14933
+ "passkey",
14934
+ "recovery_codes"
13587
14935
  ],
13588
14936
  "additionalProperties": false
13589
14937
  },
@@ -13836,6 +15184,165 @@
13836
15184
  ],
13837
15185
  "additionalProperties": false
13838
15186
  },
15187
+ "developer-passkey": {
15188
+ "type": "object",
15189
+ "properties": {
15190
+ "id": {
15191
+ "type": "string",
15192
+ "format": "uuid",
15193
+ "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
15194
+ "description": "The passkey in the API, as renamed and removed through `/api/auth/passkeys/:id`."
15195
+ },
15196
+ "name": {
15197
+ "type": "string",
15198
+ "minLength": 1,
15199
+ "maxLength": 80,
15200
+ "description": "What the person called it, such as the device it lives on."
15201
+ },
15202
+ "created_at": {
15203
+ "type": "string",
15204
+ "format": "date-time",
15205
+ "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
15206
+ "description": "When it was registered."
15207
+ },
15208
+ "last_used_at": {
15209
+ "anyOf": [
15210
+ {
15211
+ "type": "string",
15212
+ "format": "date-time",
15213
+ "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
15214
+ },
15215
+ {
15216
+ "type": "null"
15217
+ }
15218
+ ],
15219
+ "description": "When it last signed the person in or confirmed a sign-in, or `null` if never."
15220
+ },
15221
+ "synced": {
15222
+ "anyOf": [
15223
+ {
15224
+ "type": "boolean"
15225
+ },
15226
+ {
15227
+ "type": "null"
15228
+ }
15229
+ ],
15230
+ "description": "Whether the authenticator reported the passkey as syncable across the person's devices (the backup-eligible flag), or `null` when it said nothing."
15231
+ }
15232
+ },
15233
+ "required": [
15234
+ "id",
15235
+ "name",
15236
+ "created_at",
15237
+ "last_used_at",
15238
+ "synced"
15239
+ ],
15240
+ "additionalProperties": false
15241
+ },
15242
+ "developer-two-factor": {
15243
+ "type": "object",
15244
+ "properties": {
15245
+ "passkeys": {
15246
+ "type": "array",
15247
+ "items": {
15248
+ "type": "object",
15249
+ "properties": {
15250
+ "id": {
15251
+ "type": "string",
15252
+ "format": "uuid",
15253
+ "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
15254
+ "description": "The passkey in the API, as renamed and removed through `/api/auth/passkeys/:id`."
15255
+ },
15256
+ "name": {
15257
+ "type": "string",
15258
+ "minLength": 1,
15259
+ "maxLength": 80,
15260
+ "description": "What the person called it, such as the device it lives on."
15261
+ },
15262
+ "created_at": {
15263
+ "type": "string",
15264
+ "format": "date-time",
15265
+ "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
15266
+ "description": "When it was registered."
15267
+ },
15268
+ "last_used_at": {
15269
+ "anyOf": [
15270
+ {
15271
+ "type": "string",
15272
+ "format": "date-time",
15273
+ "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$"
15274
+ },
15275
+ {
15276
+ "type": "null"
15277
+ }
15278
+ ],
15279
+ "description": "When it last signed the person in or confirmed a sign-in, or `null` if never."
15280
+ },
15281
+ "synced": {
15282
+ "anyOf": [
15283
+ {
15284
+ "type": "boolean"
15285
+ },
15286
+ {
15287
+ "type": "null"
15288
+ }
15289
+ ],
15290
+ "description": "Whether the authenticator reported the passkey as syncable across the person's devices (the backup-eligible flag), or `null` when it said nothing."
15291
+ }
15292
+ },
15293
+ "required": [
15294
+ "id",
15295
+ "name",
15296
+ "created_at",
15297
+ "last_used_at",
15298
+ "synced"
15299
+ ],
15300
+ "additionalProperties": false
15301
+ },
15302
+ "description": "Every passkey the caller has registered, oldest first. Empty when none."
15303
+ },
15304
+ "authenticator": {
15305
+ "anyOf": [
15306
+ {
15307
+ "type": "object",
15308
+ "properties": {
15309
+ "created_at": {
15310
+ "type": "string",
15311
+ "format": "date-time",
15312
+ "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
15313
+ "description": "When the authenticator was confirmed."
15314
+ }
15315
+ },
15316
+ "required": [
15317
+ "created_at"
15318
+ ],
15319
+ "additionalProperties": false
15320
+ },
15321
+ {
15322
+ "type": "null"
15323
+ }
15324
+ ],
15325
+ "description": "The confirmed authenticator app, or `null` when there is none. At most one."
15326
+ },
15327
+ "recovery_codes_left": {
15328
+ "type": "integer",
15329
+ "minimum": 0,
15330
+ "maximum": 10,
15331
+ "description": "How many of the ten recovery codes are unspent. `0` while the caller has no second factor."
15332
+ },
15333
+ "required_by_org": {
15334
+ "type": "boolean",
15335
+ "description": "Whether the organisation requires a second factor. While it does, the last one cannot be removed."
15336
+ }
15337
+ },
15338
+ "required": [
15339
+ "passkeys",
15340
+ "authenticator",
15341
+ "recovery_codes_left",
15342
+ "required_by_org"
15343
+ ],
15344
+ "additionalProperties": false
15345
+ },
13839
15346
  "dynamic-client-registration-request": {
13840
15347
  "type": "object",
13841
15348
  "properties": {
@@ -14224,6 +15731,27 @@
14224
15731
  ],
14225
15732
  "description": "The console powers this person holds. **Required** — every Fleetless user has a tier; it was optional only while the org also held people with no console powers to grade, and that pool is gone."
14226
15733
  },
15734
+ "two_factor": {
15735
+ "type": "object",
15736
+ "properties": {
15737
+ "passkeys": {
15738
+ "type": "integer",
15739
+ "minimum": 0,
15740
+ "maximum": 9007199254740991,
15741
+ "description": "How many passkeys the person has registered."
15742
+ },
15743
+ "authenticator": {
15744
+ "type": "boolean",
15745
+ "description": "Whether the person has a confirmed authenticator app."
15746
+ }
15747
+ },
15748
+ "required": [
15749
+ "passkeys",
15750
+ "authenticator"
15751
+ ],
15752
+ "additionalProperties": false,
15753
+ "description": "The person's second factors, as the team list shows them: none, passkeys, an authenticator, or both. No credential travels here. An owner resets them through `DELETE /api/org/users/:id/two-factor`."
15754
+ },
14227
15755
  "created_at": {
14228
15756
  "type": "string",
14229
15757
  "format": "date-time",
@@ -14237,6 +15765,7 @@
14237
15765
  "email",
14238
15766
  "display_name",
14239
15767
  "tier",
15768
+ "two_factor",
14240
15769
  "created_at"
14241
15770
  ],
14242
15771
  "additionalProperties": false
@@ -14288,6 +15817,27 @@
14288
15817
  ],
14289
15818
  "description": "The console powers this person holds. **Required** — every Fleetless user has a tier; it was optional only while the org also held people with no console powers to grade, and that pool is gone."
14290
15819
  },
15820
+ "two_factor": {
15821
+ "type": "object",
15822
+ "properties": {
15823
+ "passkeys": {
15824
+ "type": "integer",
15825
+ "minimum": 0,
15826
+ "maximum": 9007199254740991,
15827
+ "description": "How many passkeys the person has registered."
15828
+ },
15829
+ "authenticator": {
15830
+ "type": "boolean",
15831
+ "description": "Whether the person has a confirmed authenticator app."
15832
+ }
15833
+ },
15834
+ "required": [
15835
+ "passkeys",
15836
+ "authenticator"
15837
+ ],
15838
+ "additionalProperties": false,
15839
+ "description": "The person's second factors, as the team list shows them: none, passkeys, an authenticator, or both. No credential travels here. An owner resets them through `DELETE /api/org/users/:id/two-factor`."
15840
+ },
14291
15841
  "created_at": {
14292
15842
  "type": "string",
14293
15843
  "format": "date-time",
@@ -14301,6 +15851,7 @@
14301
15851
  "email",
14302
15852
  "display_name",
14303
15853
  "tier",
15854
+ "two_factor",
14304
15855
  "created_at"
14305
15856
  ],
14306
15857
  "additionalProperties": false
@@ -16210,37 +17761,6 @@
16210
17761
  "new_password"
16211
17762
  ]
16212
17763
  },
16213
- "password-reset-confirm": {
16214
- "type": "object",
16215
- "properties": {
16216
- "token": {
16217
- "type": "string",
16218
- "minLength": 1
16219
- },
16220
- "new_password": {
16221
- "type": "string",
16222
- "minLength": 12,
16223
- "maxLength": 256
16224
- }
16225
- },
16226
- "required": [
16227
- "token",
16228
- "new_password"
16229
- ]
16230
- },
16231
- "password-reset-request": {
16232
- "type": "object",
16233
- "properties": {
16234
- "email": {
16235
- "type": "string",
16236
- "format": "email",
16237
- "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
16238
- }
16239
- },
16240
- "required": [
16241
- "email"
16242
- ]
16243
- },
16244
17764
  "patch-app-oidc-provider-request": {
16245
17765
  "type": "object",
16246
17766
  "properties": {
@@ -16367,14 +17887,16 @@
16367
17887
  "type": "object",
16368
17888
  "properties": {
16369
17889
  "name": {
17890
+ "description": "The organisation's new display name. Absent leaves it alone.",
16370
17891
  "type": "string",
16371
17892
  "minLength": 1,
16372
17893
  "maxLength": 120
17894
+ },
17895
+ "require_two_factor": {
17896
+ "description": "Whether every member must have a second factor. Turning it on signs nobody out: each member without one sets it up at their next sign-in. Absent leaves it alone.",
17897
+ "type": "boolean"
16373
17898
  }
16374
17899
  },
16375
- "required": [
16376
- "name"
16377
- ],
16378
17900
  "additionalProperties": false
16379
17901
  },
16380
17902
  "patch-org-response": {
@@ -16395,6 +17917,10 @@
16395
17917
  "maxLength": 120,
16396
17918
  "description": "The organisation's display name. Free text, changed through `PATCH /api/org`."
16397
17919
  },
17920
+ "require_two_factor": {
17921
+ "type": "boolean",
17922
+ "description": "Whether every member must have a second factor — a passkey or an authenticator app. A member without one sets it up at their next sign-in, before any session exists; nobody is signed out when it is switched on. It covers the console and the central MCP endpoint; server keys and robot bridges are not people and are not affected. Owners change it through `PATCH /api/org`."
17923
+ },
16398
17924
  "created_at": {
16399
17925
  "type": "string",
16400
17926
  "format": "date-time",
@@ -16405,6 +17931,7 @@
16405
17931
  "required": [
16406
17932
  "id",
16407
17933
  "name",
17934
+ "require_two_factor",
16408
17935
  "created_at"
16409
17936
  ],
16410
17937
  "additionalProperties": false,
@@ -16596,29 +18123,37 @@
16596
18123
  "message"
16597
18124
  ]
16598
18125
  },
16599
- "put-app-auth-mcp-request": {
18126
+ "put-app-auth-look-request": {
16600
18127
  "type": "object",
16601
18128
  "properties": {
16602
- "mcp_enabled": {
16603
- "type": "boolean",
16604
- "description": "Whether this app serves an MCP endpoint at `/mcp/<identifier>`. Off refuses the whole OAuth surface for the app, not merely the tool calls, and is re-read on every request rather than cached off a token."
16605
- },
16606
- "mcp_login_url": {
18129
+ "hosted_accent": {
16607
18130
  "anyOf": [
16608
18131
  {
16609
18132
  "type": "string",
16610
- "maxLength": 500
18133
+ "pattern": "^#[0-9a-f]{6}$"
16611
18134
  },
16612
18135
  {
16613
18136
  "type": "null"
16614
18137
  }
16615
18138
  ],
16616
- "description": "The page an MCP authorization redirects to, with `{interaction}` where the interaction id goes. Not a token: the id names a pending request the server already holds, and the app authenticates the user itself before approving it."
18139
+ "description": "The accent colour of the hosted pages, `#rrggbb` in lowercase, or `null` for the neutral shell's own."
16617
18140
  }
16618
18141
  },
16619
18142
  "required": [
16620
- "mcp_enabled",
16621
- "mcp_login_url"
18143
+ "hosted_accent"
18144
+ ],
18145
+ "additionalProperties": false
18146
+ },
18147
+ "put-app-auth-mcp-request": {
18148
+ "type": "object",
18149
+ "properties": {
18150
+ "mcp_enabled": {
18151
+ "type": "boolean",
18152
+ "description": "Whether this app serves an MCP endpoint at `/mcp/<identifier>`. Off refuses the whole OAuth surface for the app, not merely the tool calls, and is re-read on every request rather than cached off a token."
18153
+ }
18154
+ },
18155
+ "required": [
18156
+ "mcp_enabled"
16622
18157
  ],
16623
18158
  "additionalProperties": false
16624
18159
  },
@@ -16651,15 +18186,66 @@
16651
18186
  }
16652
18187
  },
16653
18188
  "required": [
16654
- "self_registration",
16655
- "allowed_domains",
16656
- "allowed_origins"
18189
+ "self_registration",
18190
+ "allowed_domains",
18191
+ "allowed_origins"
18192
+ ],
18193
+ "additionalProperties": false
18194
+ },
18195
+ "put-app-auth-sign-in-request": {
18196
+ "type": "object",
18197
+ "properties": {
18198
+ "sign_in_methods": {
18199
+ "type": "object",
18200
+ "properties": {
18201
+ "password": {
18202
+ "type": "boolean",
18203
+ "description": "Whether app users may sign in with a password. Off refuses `POST /api/client/login` with `method_not_allowed`, and registration and invitations then take no password."
18204
+ },
18205
+ "email_code": {
18206
+ "type": "boolean",
18207
+ "description": "Whether app users may sign in with a six-digit code mailed to them, valid ten minutes. A code needs no URL, so it works in local development and in an app with no web UI."
18208
+ }
18209
+ },
18210
+ "required": [
18211
+ "password",
18212
+ "email_code"
18213
+ ],
18214
+ "additionalProperties": false,
18215
+ "description": "Which sign-in methods the app offers: password, emailed code, or both — at least one. Identity providers stay on top of either. The default is password only."
18216
+ },
18217
+ "two_factor": {
18218
+ "type": "string",
18219
+ "enum": [
18220
+ "off",
18221
+ "optional",
18222
+ "required"
18223
+ ],
18224
+ "description": "Whether the app asks for an authenticator code: `off` (the default), `optional` or `required`. A person with a confirmed authenticator is asked at every sign-in whatever the policy; a sign-in through an identity provider is never asked."
18225
+ }
18226
+ },
18227
+ "required": [
18228
+ "sign_in_methods",
18229
+ "two_factor"
16657
18230
  ],
16658
18231
  "additionalProperties": false
16659
18232
  },
16660
18233
  "put-app-auth-urls-request": {
16661
18234
  "type": "object",
16662
18235
  "properties": {
18236
+ "app_url": {
18237
+ "anyOf": [
18238
+ {
18239
+ "type": "string",
18240
+ "maxLength": 500,
18241
+ "format": "uri"
18242
+ },
18243
+ {
18244
+ "type": "null"
18245
+ }
18246
+ ],
18247
+ "description": "The app's own home page, linked as `Open <app>` when a hosted flow is done. `null` makes the hosted done page say `You can close this tab`."
18248
+ },
16663
18249
  "invite_url": {
16664
18250
  "anyOf": [
16665
18251
  {
@@ -16670,7 +18256,7 @@
16670
18256
  "type": "null"
16671
18257
  }
16672
18258
  ],
16673
- "description": "The page in the developer's app that accepts an invitation, with `{token}` where the token goes. `null` when unconfigured, and then an invitation still issues but `send_mail` is refused with `409 target_state_conflict` — there would be nowhere for the link to point."
18259
+ "description": "The page in the developer's app that accepts an invitation, with `{token}` where the token goes. `null` means the hosted page in `hosted_pages` is used."
16674
18260
  },
16675
18261
  "verify_url": {
16676
18262
  "anyOf": [
@@ -16682,7 +18268,7 @@
16682
18268
  "type": "null"
16683
18269
  }
16684
18270
  ],
16685
- "description": "The page that confirms a new address, with `{token}` where the token goes. Self-registration needs it: without a page to send people to, a registration would leave an account nobody can activate."
18271
+ "description": "The page that confirms a new address, with `{token}` where the token goes. `null` means the hosted page in `hosted_pages` is used."
16686
18272
  },
16687
18273
  "reset_url": {
16688
18274
  "anyOf": [
@@ -16694,13 +18280,27 @@
16694
18280
  "type": "null"
16695
18281
  }
16696
18282
  ],
16697
- "description": "The page that takes a new password, with `{token}` where the token goes."
18283
+ "description": "The page that takes a new password, with `{token}` where the token goes. `null` means the hosted page in `hosted_pages` is used."
18284
+ },
18285
+ "mcp_login_url": {
18286
+ "anyOf": [
18287
+ {
18288
+ "type": "string",
18289
+ "maxLength": 500
18290
+ },
18291
+ {
18292
+ "type": "null"
18293
+ }
18294
+ ],
18295
+ "description": "The page an MCP authorization redirects to, with `{interaction}` where the interaction id goes. Not a token: the id names a pending request the server already holds, and the app authenticates the user itself before approving it. `null` means the hosted MCP sign-in in `hosted_pages` is used."
16698
18296
  }
16699
18297
  },
16700
18298
  "required": [
18299
+ "app_url",
16701
18300
  "invite_url",
16702
18301
  "verify_url",
16703
- "reset_url"
18302
+ "reset_url",
18303
+ "mcp_login_url"
16704
18304
  ],
16705
18305
  "additionalProperties": false
16706
18306
  },
@@ -16832,6 +18432,25 @@
16832
18432
  ],
16833
18433
  "additionalProperties": false
16834
18434
  },
18435
+ "recovery-codes-response": {
18436
+ "type": "object",
18437
+ "properties": {
18438
+ "recovery_codes": {
18439
+ "minItems": 10,
18440
+ "maxItems": 10,
18441
+ "type": "array",
18442
+ "items": {
18443
+ "type": "string",
18444
+ "pattern": "^[a-z2-7]{5}-[a-z2-7]{5}$"
18445
+ },
18446
+ "description": "The ten new recovery codes, lowercase, shown once. Every earlier code is void."
18447
+ }
18448
+ },
18449
+ "required": [
18450
+ "recovery_codes"
18451
+ ],
18452
+ "additionalProperties": false
18453
+ },
16835
18454
  "refresh-request": {
16836
18455
  "type": "object",
16837
18456
  "properties": {
@@ -16844,6 +18463,21 @@
16844
18463
  "refresh_token"
16845
18464
  ]
16846
18465
  },
18466
+ "rename-passkey-request": {
18467
+ "type": "object",
18468
+ "properties": {
18469
+ "name": {
18470
+ "type": "string",
18471
+ "minLength": 1,
18472
+ "maxLength": 80,
18473
+ "description": "The new name."
18474
+ }
18475
+ },
18476
+ "required": [
18477
+ "name"
18478
+ ],
18479
+ "additionalProperties": false
18480
+ },
16847
18481
  "rename-slug-request": {
16848
18482
  "type": "object",
16849
18483
  "properties": {
@@ -17845,157 +19479,6 @@
17845
19479
  ],
17846
19480
  "additionalProperties": false
17847
19481
  },
17848
- "sign-up-request": {
17849
- "type": "object",
17850
- "properties": {
17851
- "org_name": {
17852
- "type": "string",
17853
- "minLength": 1,
17854
- "maxLength": 120
17855
- },
17856
- "email": {
17857
- "type": "string",
17858
- "format": "email",
17859
- "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
17860
- },
17861
- "password": {
17862
- "type": "string",
17863
- "minLength": 12,
17864
- "maxLength": 256
17865
- }
17866
- },
17867
- "required": [
17868
- "org_name",
17869
- "email",
17870
- "password"
17871
- ]
17872
- },
17873
- "sign-up-response": {
17874
- "type": "object",
17875
- "properties": {
17876
- "org": {
17877
- "type": "object",
17878
- "properties": {
17879
- "id": {
17880
- "type": "string",
17881
- "format": "uuid",
17882
- "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
17883
- "description": "The organisation. Every developer route is scoped to the caller's org already, so a client rarely has to send this anywhere."
17884
- },
17885
- "name": {
17886
- "type": "string",
17887
- "minLength": 1,
17888
- "maxLength": 120,
17889
- "description": "The organisation's display name. Free text, changed through `PATCH /api/org`."
17890
- },
17891
- "created_at": {
17892
- "type": "string",
17893
- "format": "date-time",
17894
- "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
17895
- "description": "When the organisation was created, as an ISO 8601 timestamp."
17896
- }
17897
- },
17898
- "required": [
17899
- "id",
17900
- "name",
17901
- "created_at"
17902
- ],
17903
- "additionalProperties": false
17904
- },
17905
- "user": {
17906
- "type": "object",
17907
- "properties": {
17908
- "id": {
17909
- "type": "string",
17910
- "format": "uuid",
17911
- "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
17912
- "description": "The Fleetless user in the API, assigned by the cloud and stable for the life of the account."
17913
- },
17914
- "org_id": {
17915
- "type": "string",
17916
- "format": "uuid",
17917
- "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
17918
- "description": "The organisation this person belongs to. Every developer route is already scoped to the caller's org, so this confirms what a client is looking at, not a filter it applies."
17919
- },
17920
- "email": {
17921
- "type": "string",
17922
- "format": "email",
17923
- "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
17924
- "description": "The address the account is identified by, **globally unique** across every organisation. Immutable after creation: it is what every invitation, reset link and audit line names."
17925
- },
17926
- "display_name": {
17927
- "anyOf": [
17928
- {
17929
- "type": "string",
17930
- "minLength": 1,
17931
- "maxLength": 120
17932
- },
17933
- {
17934
- "type": "null"
17935
- }
17936
- ],
17937
- "description": "Optional human name, shown by the console instead of the address where present. Self-service through `PATCH /api/auth/me`; never used for authentication. `null` when the person never supplied one."
17938
- },
17939
- "tier": {
17940
- "type": "string",
17941
- "enum": [
17942
- "owner",
17943
- "developer"
17944
- ],
17945
- "description": "The console powers this person holds. **Required** — every Fleetless user has a tier; it was optional only while the org also held people with no console powers to grade, and that pool is gone."
17946
- },
17947
- "created_at": {
17948
- "type": "string",
17949
- "format": "date-time",
17950
- "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$",
17951
- "description": "When the account was created, as an ISO 8601 timestamp."
17952
- }
17953
- },
17954
- "required": [
17955
- "id",
17956
- "org_id",
17957
- "email",
17958
- "display_name",
17959
- "tier",
17960
- "created_at"
17961
- ],
17962
- "additionalProperties": false
17963
- },
17964
- "tokens": {
17965
- "type": "object",
17966
- "properties": {
17967
- "access_token": {
17968
- "type": "string",
17969
- "minLength": 1,
17970
- "description": "The token to send as `Authorization: Bearer <token>` on every call. Short-lived: read `expires_in` rather than assuming a lifetime."
17971
- },
17972
- "refresh_token": {
17973
- "type": "string",
17974
- "minLength": 1,
17975
- "description": "The token that buys the next access token. It rotates on every use, so a value presented twice is detectable theft and ends the whole family."
17976
- },
17977
- "expires_in": {
17978
- "type": "integer",
17979
- "exclusiveMinimum": 0,
17980
- "maximum": 9007199254740991,
17981
- "description": "How long the access token stays valid, in **seconds** from now. Not a timestamp, and not milliseconds."
17982
- }
17983
- },
17984
- "required": [
17985
- "access_token",
17986
- "refresh_token",
17987
- "expires_in"
17988
- ],
17989
- "additionalProperties": false
17990
- }
17991
- },
17992
- "required": [
17993
- "org",
17994
- "user",
17995
- "tokens"
17996
- ],
17997
- "additionalProperties": false
17998
- },
17999
19482
  "slug-usage-response": {
18000
19483
  "type": "object",
18001
19484
  "properties": {
@@ -18183,6 +19666,66 @@
18183
19666
  ],
18184
19667
  "additionalProperties": false
18185
19668
  },
19669
+ "totp-confirm-request": {
19670
+ "type": "object",
19671
+ "properties": {
19672
+ "code": {
19673
+ "type": "string",
19674
+ "pattern": "^\\d{6}$",
19675
+ "description": "A code the new authenticator shows now. It proves the secret was copied correctly before anything depends on it."
19676
+ }
19677
+ },
19678
+ "required": [
19679
+ "code"
19680
+ ],
19681
+ "additionalProperties": false
19682
+ },
19683
+ "totp-confirm-response": {
19684
+ "type": "object",
19685
+ "properties": {
19686
+ "recovery_codes": {
19687
+ "anyOf": [
19688
+ {
19689
+ "minItems": 10,
19690
+ "maxItems": 10,
19691
+ "type": "array",
19692
+ "items": {
19693
+ "type": "string",
19694
+ "pattern": "^[a-z2-7]{5}-[a-z2-7]{5}$"
19695
+ }
19696
+ },
19697
+ {
19698
+ "type": "null"
19699
+ }
19700
+ ],
19701
+ "description": "The ten recovery codes, shown once, when this is the account's first second factor; `null` otherwise."
19702
+ }
19703
+ },
19704
+ "required": [
19705
+ "recovery_codes"
19706
+ ],
19707
+ "additionalProperties": false
19708
+ },
19709
+ "two-factor-setup-response": {
19710
+ "type": "object",
19711
+ "properties": {
19712
+ "secret": {
19713
+ "type": "string",
19714
+ "minLength": 1,
19715
+ "description": "The shared secret, base32, for an authenticator app that cannot scan a QR code. Shown once; the cloud stores it encrypted."
19716
+ },
19717
+ "otpauth_url": {
19718
+ "type": "string",
19719
+ "pattern": "^otpauth:\\/\\/totp\\/.*",
19720
+ "description": "The same secret as an `otpauth://totp/` URL, to render as a QR code. It carries the secret: never log it."
19721
+ }
19722
+ },
19723
+ "required": [
19724
+ "secret",
19725
+ "otpauth_url"
19726
+ ],
19727
+ "additionalProperties": false
19728
+ },
18186
19729
  "types-response": {
18187
19730
  "type": "object",
18188
19731
  "properties": {
@@ -18380,6 +19923,23 @@
18380
19923
  "required": [
18381
19924
  "email"
18382
19925
  ]
19926
+ },
19927
+ "webauthn-options-response": {
19928
+ "type": "object",
19929
+ "properties": {
19930
+ "options": {
19931
+ "type": "object",
19932
+ "propertyNames": {
19933
+ "type": "string"
19934
+ },
19935
+ "additionalProperties": {},
19936
+ "description": "The `PublicKeyCredentialCreationOptionsJSON` or `PublicKeyCredentialRequestOptionsJSON` to pass to the browser. Its challenge is single-use and short-lived."
19937
+ }
19938
+ },
19939
+ "required": [
19940
+ "options"
19941
+ ],
19942
+ "additionalProperties": false
18383
19943
  }
18384
19944
  }
18385
19945
  }