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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +96 -33
  2. package/artifacts/openapi.json +2049 -773
  3. package/artifacts/routes.json +1665 -314
  4. package/artifacts/schema/accept-team-invite-request.schema.json +13 -7
  5. package/artifacts/schema/app-auth-config.schema.json +108 -4
  6. package/artifacts/schema/app-hosted-pages.schema.json +33 -0
  7. package/artifacts/schema/app-invitation.schema.json +2 -2
  8. package/artifacts/schema/app-mail-template-list-response.schema.json +4 -3
  9. package/artifacts/schema/app-mail-template.schema.json +3 -2
  10. package/artifacts/schema/app-sign-in-methods.schema.json +19 -0
  11. package/artifacts/schema/app-user-list-response.schema.json +36 -0
  12. package/artifacts/schema/app-user.schema.json +36 -0
  13. package/artifacts/schema/audit-query.schema.json +0 -6
  14. package/artifacts/schema/auth-me-response.schema.json +27 -0
  15. package/artifacts/schema/auth-ok.schema.json +13 -1
  16. package/artifacts/schema/client-accept-invitation-request.schema.json +3 -4
  17. package/artifacts/schema/client-identity.schema.json +13 -1
  18. package/artifacts/schema/client-login-code-request.schema.json +24 -0
  19. package/artifacts/schema/client-login-code-verify-request.schema.json +30 -0
  20. package/artifacts/schema/client-provider-list-response.schema.json +22 -2
  21. package/artifacts/schema/client-register-request.schema.json +3 -4
  22. package/artifacts/schema/client-sign-in-result.schema.json +55 -0
  23. package/artifacts/schema/client-two-factor-disable-request.schema.json +15 -0
  24. package/artifacts/schema/client-two-factor-setup-confirm-request.schema.json +20 -0
  25. package/artifacts/schema/client-two-factor-setup-confirm-response.schema.json +49 -0
  26. package/artifacts/schema/client-two-factor-setup-request.schema.json +12 -0
  27. package/artifacts/schema/client-two-factor-verify-request.schema.json +25 -0
  28. package/artifacts/schema/create-app-invitation-request.schema.json +1 -1
  29. package/artifacts/schema/create-passkey-request.schema.json +25 -0
  30. package/artifacts/schema/create-passkey-response.schema.json +84 -0
  31. package/artifacts/schema/developer-passkey.schema.json +56 -0
  32. package/artifacts/schema/developer-two-factor.schema.json +105 -0
  33. package/artifacts/schema/fleetless-user-list-response.schema.json +22 -0
  34. package/artifacts/schema/fleetless-user.schema.json +22 -0
  35. package/artifacts/schema/invalid-code-details.schema.json +16 -0
  36. package/artifacts/schema/job-actor.schema.json +1 -15
  37. package/artifacts/schema/job-run-list-response.schema.json +1 -15
  38. package/artifacts/schema/job-run.schema.json +1 -15
  39. package/artifacts/schema/org.schema.json +5 -0
  40. package/artifacts/schema/patch-org-request.schema.json +5 -3
  41. package/artifacts/schema/patch-org-response.schema.json +5 -0
  42. package/artifacts/schema/put-app-auth-look-request.schema.json +22 -0
  43. package/artifacts/schema/put-app-auth-mcp-request.schema.json +1 -14
  44. package/artifacts/schema/put-app-auth-sign-in-request.schema.json +39 -0
  45. package/artifacts/schema/put-app-auth-urls-request.schema.json +31 -4
  46. package/artifacts/schema/recovery-codes-response.schema.json +20 -0
  47. package/artifacts/schema/{role-rename-request.schema.json → rename-passkey-request.schema.json} +2 -2
  48. package/artifacts/schema/role-list-response.schema.json +1 -1
  49. package/artifacts/schema/role.schema.json +1 -1
  50. package/artifacts/schema/totp-confirm-request.schema.json +15 -0
  51. package/artifacts/schema/totp-confirm-response.schema.json +27 -0
  52. package/artifacts/schema/two-factor-challenge.schema.json +24 -0
  53. package/artifacts/schema/two-factor-setup-response.schema.json +21 -0
  54. package/artifacts/schema/webauthn-options-response.schema.json +18 -0
  55. package/dist/app-users.d.ts +154 -34
  56. package/dist/app-users.js +177 -45
  57. package/dist/apps.d.ts +2 -38
  58. package/dist/apps.js +3 -46
  59. package/dist/audit.d.ts +0 -1
  60. package/dist/audit.js +0 -13
  61. package/dist/client-auth.d.ts +133 -24
  62. package/dist/client-auth.js +139 -28
  63. package/dist/config.d.ts +2 -2
  64. package/dist/errors.d.ts +10 -1
  65. package/dist/errors.js +34 -34
  66. package/dist/identity.d.ts +172 -123
  67. package/dist/identity.js +189 -101
  68. package/dist/index.d.ts +11 -13
  69. package/dist/index.js +12 -10
  70. package/dist/jobs.d.ts +0 -3
  71. package/dist/jobs.js +0 -16
  72. package/dist/protocol.d.ts +1 -1
  73. package/dist/realtime.d.ts +1 -0
  74. package/dist/rest.d.ts +2 -2
  75. package/dist/rest.js +17 -28
  76. package/dist/routes.d.ts +19 -0
  77. package/dist/routes.js +499 -223
  78. package/package.json +1 -1
  79. package/artifacts/schema/developer-login-request.schema.json +0 -19
  80. package/artifacts/schema/feedback-request.schema.json +0 -34
  81. package/artifacts/schema/feedback-response.schema.json +0 -27
  82. package/artifacts/schema/password-reset-confirm.schema.json +0 -19
  83. package/artifacts/schema/password-reset-request.schema.json +0 -14
  84. package/artifacts/schema/role-delete-query.schema.json +0 -13
  85. package/artifacts/schema/role-in-use-details.schema.json +0 -28
  86. package/artifacts/schema/sign-up-request.schema.json +0 -26
  87. package/artifacts/schema/sign-up-response.schema.json +0 -127
  88. package/dist/feedback.d.ts +0 -42
  89. package/dist/feedback.js +0 -35
@@ -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,51 @@
296
290
  "content": {
297
291
  "application/json": {
298
292
  "schema": {
299
- "$ref": "#/components/schemas/session-tokens"
293
+ "$ref": "#/components/schemas/webauthn-options-response"
294
+ }
295
+ }
296
+ }
297
+ },
298
+ "default": {
299
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`.",
300
+ "content": {
301
+ "application/json": {
302
+ "schema": {
303
+ "$ref": "#/components/schemas/api-error"
304
+ }
305
+ }
306
+ }
307
+ }
308
+ },
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"
300
332
  }
301
333
  }
302
334
  }
303
335
  },
304
336
  "default": {
305
- "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `validation_error`, `invalid_credentials`.",
337
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `validation_error`.",
306
338
  "content": {
307
339
  "application/json": {
308
340
  "schema": {
@@ -312,34 +344,55 @@
312
344
  }
313
345
  }
314
346
  },
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.",
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",
@@ -525,17 +732,6 @@
525
732
  "maxLength": 40
526
733
  }
527
734
  },
528
- {
529
- "name": "target_id",
530
- "in": "query",
531
- "required": false,
532
- "schema": {
533
- "description": "Events whose target is this id; for a robot also the events that name it in `details.robot_id` (`action.invoked`, `service.called`, …), so a robot's events include what was started on it.",
534
- "type": "string",
535
- "minLength": 1,
536
- "maxLength": 200
537
- }
538
- },
539
735
  {
540
736
  "name": "from_ms",
541
737
  "in": "query",
@@ -687,17 +883,6 @@
687
883
  "maxLength": 40
688
884
  }
689
885
  },
690
- {
691
- "name": "target_id",
692
- "in": "query",
693
- "required": false,
694
- "schema": {
695
- "description": "Events whose target is this id; for a robot also the events that name it in `details.robot_id` (`action.invoked`, `service.called`, …), so a robot's events include what was started on it.",
696
- "type": "string",
697
- "minLength": 1,
698
- "maxLength": 200
699
- }
700
- },
701
886
  {
702
887
  "name": "from_ms",
703
888
  "in": "query",
@@ -1080,7 +1265,7 @@
1080
1265
  }
1081
1266
  }
1082
1267
  },
1083
- "description": "The body is `{ \"name\": string }` — non-empty, trimmed, at most 60 characters as on `role.name` — and is deliberately not a contract shape: contracts define the `role` this answers with, not this one trivial request. **The answer is a bare `role`, not an envelope**, unlike the listing beside it."
1268
+ "description": "The body is `{ \"name\": string }` — non-empty, trimmed, at most 120 characters — and is deliberately not a contract shape: contracts define the `role` this answers with, not this one trivial request. **The answer is a bare `role`, not an envelope**, unlike the listing beside it."
1084
1269
  },
1085
1270
  "get": {
1086
1271
  "operationId": "get_api_apps_id_roles",
@@ -1307,132 +1492,6 @@
1307
1492
  "description": "Built by the same builder the MCP server's own `robot_describe` uses, so the two cannot drift. It answers what the role *would* be offered and consults nothing about any user's actual MCP entitlement. A robot the role grants nothing on still appears, with an empty `exposures` — dropping it would read as \"not attached\", which is a different fact."
1308
1493
  }
1309
1494
  },
1310
- "/api/apps/{id}/roles/{roleId}": {
1311
- "patch": {
1312
- "operationId": "patch_api_apps_id_roles_roleId",
1313
- "summary": "Renames a role; its users keep it.",
1314
- "tags": [
1315
- "apps"
1316
- ],
1317
- "security": [
1318
- {
1319
- "developerSession": []
1320
- }
1321
- ],
1322
- "parameters": [
1323
- {
1324
- "name": "id",
1325
- "in": "path",
1326
- "required": true,
1327
- "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
1328
- "schema": {
1329
- "type": "string"
1330
- }
1331
- },
1332
- {
1333
- "name": "roleId",
1334
- "in": "path",
1335
- "required": true,
1336
- "description": "The role's uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`.",
1337
- "schema": {
1338
- "type": "string"
1339
- }
1340
- }
1341
- ],
1342
- "responses": {
1343
- "200": {
1344
- "description": "Success.",
1345
- "content": {
1346
- "application/json": {
1347
- "schema": {
1348
- "$ref": "#/components/schemas/role"
1349
- }
1350
- }
1351
- }
1352
- },
1353
- "default": {
1354
- "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `validation_error`, `role_name_taken`.",
1355
- "content": {
1356
- "application/json": {
1357
- "schema": {
1358
- "$ref": "#/components/schemas/api-error"
1359
- }
1360
- }
1361
- }
1362
- }
1363
- },
1364
- "description": "Names are unique per app, compared exactly as stored after trimming. Built-in roles can be renamed.",
1365
- "requestBody": {
1366
- "required": true,
1367
- "content": {
1368
- "application/json": {
1369
- "schema": {
1370
- "$ref": "#/components/schemas/role-rename-request"
1371
- }
1372
- }
1373
- }
1374
- }
1375
- },
1376
- "delete": {
1377
- "operationId": "delete_api_apps_id_roles_roleId",
1378
- "summary": "Deletes a role, moving its users, pending invitations and default-role status to another role.",
1379
- "tags": [
1380
- "apps"
1381
- ],
1382
- "security": [
1383
- {
1384
- "developerSession": []
1385
- }
1386
- ],
1387
- "parameters": [
1388
- {
1389
- "name": "id",
1390
- "in": "path",
1391
- "required": true,
1392
- "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
1393
- "schema": {
1394
- "type": "string"
1395
- }
1396
- },
1397
- {
1398
- "name": "roleId",
1399
- "in": "path",
1400
- "required": true,
1401
- "description": "The role's uuid, from `GET /api/apps/:id/roles`; a role of another app answers `404`.",
1402
- "schema": {
1403
- "type": "string"
1404
- }
1405
- },
1406
- {
1407
- "name": "move_to",
1408
- "in": "query",
1409
- "required": false,
1410
- "schema": {
1411
- "description": "Another role of the same app that takes over the deleted role's app users, pending invitations and, when it applies, the app's default. The role itself or a role of another app answers `400 validation_error`.",
1412
- "type": "string",
1413
- "format": "uuid",
1414
- "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)$"
1415
- }
1416
- }
1417
- ],
1418
- "responses": {
1419
- "204": {
1420
- "description": "Success."
1421
- },
1422
- "default": {
1423
- "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `validation_error`, `role_in_use`, `last_role`.",
1424
- "content": {
1425
- "application/json": {
1426
- "schema": {
1427
- "$ref": "#/components/schemas/api-error"
1428
- }
1429
- }
1430
- }
1431
- }
1432
- },
1433
- "description": "Without `move_to`, a role that app users or pending invitations hold, or that is the app's default, answers `409 role_in_use` with `{ users, invitations, is_default }`. With `move_to` — another role of the same app, else `400 validation_error` — one transaction moves `app_users.role_id`, pending invitations and `default_role_id`, then deletes the role and its permissions. The app's only role answers `409 last_role`. Built-in roles can be deleted like any other."
1434
- }
1435
- },
1436
1495
  "/api/apps/{id}/server-keys": {
1437
1496
  "post": {
1438
1497
  "operationId": "post_api_apps_id_server_keys",
@@ -1952,7 +2011,57 @@
1952
2011
  }
1953
2012
  },
1954
2013
  "default": {
1955
- "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `target_state_conflict`.",
2014
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`, `target_state_conflict`.",
2015
+ "content": {
2016
+ "application/json": {
2017
+ "schema": {
2018
+ "$ref": "#/components/schemas/api-error"
2019
+ }
2020
+ }
2021
+ }
2022
+ }
2023
+ },
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."
2025
+ }
2026
+ },
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.",
2031
+ "tags": [
2032
+ "apps"
2033
+ ],
2034
+ "security": [
2035
+ {
2036
+ "developerSession": []
2037
+ }
2038
+ ],
2039
+ "parameters": [
2040
+ {
2041
+ "name": "id",
2042
+ "in": "path",
2043
+ "required": true,
2044
+ "description": "The app's uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.",
2045
+ "schema": {
2046
+ "type": "string"
2047
+ }
2048
+ },
2049
+ {
2050
+ "name": "userId",
2051
+ "in": "path",
2052
+ "required": true,
2053
+ "description": "The app user's uuid, from `GET /api/apps/:id/users`; a user of another app answers `404`.",
2054
+ "schema": {
2055
+ "type": "string"
2056
+ }
2057
+ }
2058
+ ],
2059
+ "responses": {
2060
+ "204": {
2061
+ "description": "Success."
2062
+ },
2063
+ "default": {
2064
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `invalid_uuid`, `not_found`.",
1956
2065
  "content": {
1957
2066
  "application/json": {
1958
2067
  "schema": {
@@ -1962,7 +2071,7 @@
1962
2071
  }
1963
2072
  }
1964
2073
  },
1965
- "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`."
1966
2075
  }
1967
2076
  },
1968
2077
  "/api/apps/{id}/users/{userId}/mcp-grants": {
@@ -2172,7 +2281,7 @@
2172
2281
  }
2173
2282
  }
2174
2283
  },
2175
- "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.",
2176
2285
  "requestBody": {
2177
2286
  "required": true,
2178
2287
  "content": {
@@ -2569,7 +2678,7 @@
2569
2678
  "/api/apps/{id}/auth-config": {
2570
2679
  "get": {
2571
2680
  "operationId": "get_api_apps_id_auth_config",
2572
- "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.",
2573
2682
  "tags": [
2574
2683
  "apps"
2575
2684
  ],
@@ -2611,7 +2720,7 @@
2611
2720
  }
2612
2721
  }
2613
2722
  },
2614
- "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."
2615
2724
  }
2616
2725
  },
2617
2726
  "/api/apps/{id}/auth-config/registration": {
@@ -2659,7 +2768,7 @@
2659
2768
  }
2660
2769
  }
2661
2770
  },
2662
- "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.",
2663
2772
  "requestBody": {
2664
2773
  "required": true,
2665
2774
  "content": {
@@ -2672,10 +2781,68 @@
2672
2781
  }
2673
2782
  }
2674
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
+ },
2675
2842
  "/api/apps/{id}/auth-config/urls": {
2676
2843
  "put": {
2677
2844
  "operationId": "put_api_apps_id_auth_config_urls",
2678
- "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.",
2679
2846
  "tags": [
2680
2847
  "apps"
2681
2848
  ],
@@ -2717,7 +2884,7 @@
2717
2884
  }
2718
2885
  }
2719
2886
  },
2720
- "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.",
2721
2888
  "requestBody": {
2722
2889
  "required": true,
2723
2890
  "content": {
@@ -2733,7 +2900,7 @@
2733
2900
  "/api/apps/{id}/auth-config/mcp": {
2734
2901
  "put": {
2735
2902
  "operationId": "put_api_apps_id_auth_config_mcp",
2736
- "summary": "Replaces the MCP switch and its login URL together.",
2903
+ "summary": "Turns the app's MCP endpoint on or off.",
2737
2904
  "tags": [
2738
2905
  "apps"
2739
2906
  ],
@@ -2775,7 +2942,7 @@
2775
2942
  }
2776
2943
  }
2777
2944
  },
2778
- "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.",
2779
2946
  "requestBody": {
2780
2947
  "required": true,
2781
2948
  "content": {
@@ -2788,6 +2955,158 @@
2788
2955
  }
2789
2956
  }
2790
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
+ },
2791
3110
  "/api/apps/{id}/mail-templates": {
2792
3111
  "get": {
2793
3112
  "operationId": "get_api_apps_id_mail_templates",
@@ -2833,7 +3152,7 @@
2833
3152
  }
2834
3153
  }
2835
3154
  },
2836
- "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."
2837
3156
  }
2838
3157
  },
2839
3158
  "/api/apps/{id}/mail-templates/{kind}": {
@@ -3496,7 +3815,7 @@
3496
3815
  "/api/org/invitations/accept": {
3497
3816
  "post": {
3498
3817
  "operationId": "post_api_org_invitations_accept",
3499
- "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.",
3500
3819
  "tags": [
3501
3820
  "users"
3502
3821
  ],
@@ -3517,7 +3836,7 @@
3517
3836
  }
3518
3837
  }
3519
3838
  },
3520
- "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.",
3521
3840
  "requestBody": {
3522
3841
  "required": true,
3523
3842
  "content": {
@@ -3554,18 +3873,69 @@
3554
3873
  }
3555
3874
  ],
3556
3875
  "responses": {
3557
- "200": {
3558
- "description": "Success.",
3559
- "content": {
3560
- "application/json": {
3561
- "schema": {
3562
- "$ref": "#/components/schemas/fleetless-user"
3563
- }
3564
- }
3565
- }
3876
+ "200": {
3877
+ "description": "Success.",
3878
+ "content": {
3879
+ "application/json": {
3880
+ "schema": {
3881
+ "$ref": "#/components/schemas/fleetless-user"
3882
+ }
3883
+ }
3884
+ }
3885
+ },
3886
+ "default": {
3887
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `invalid_uuid`, `not_found`, `validation_error`, `last_owner`.",
3888
+ "content": {
3889
+ "application/json": {
3890
+ "schema": {
3891
+ "$ref": "#/components/schemas/api-error"
3892
+ }
3893
+ }
3894
+ }
3895
+ }
3896
+ },
3897
+ "description": "Owner tier, unconditionally — this is the route the whole owner-exclusive list is about. A uuid that is not a Fleetless user of this org answers `404 not_found`, the same as one that does not exist anywhere: the `409 target_state_conflict` documented here until the two-space cut had exactly one producer, the Org Admins membership check, and went with it. Demoting the last Owner is `409 last_owner`, decided by a row lock inside the writing transaction rather than by a read beforehand. Setting the tier already held changes nothing and writes no audit event. No session is revoked: a tier is re-read from the row on every request, so no issued token carries a stale copy of it.",
3898
+ "requestBody": {
3899
+ "required": true,
3900
+ "content": {
3901
+ "application/json": {
3902
+ "schema": {
3903
+ "$ref": "#/components/schemas/tier-change-request"
3904
+ }
3905
+ }
3906
+ }
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."
3566
3936
  },
3567
3937
  "default": {
3568
- "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `tier_required`, `invalid_uuid`, `not_found`, `validation_error`, `last_owner`.",
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`.",
3569
3939
  "content": {
3570
3940
  "application/json": {
3571
3941
  "schema": {
@@ -3575,23 +3945,13 @@
3575
3945
  }
3576
3946
  }
3577
3947
  },
3578
- "description": "Owner tier, unconditionally — this is the route the whole owner-exclusive list is about. A uuid that is not a Fleetless user of this org answers `404 not_found`, the same as one that does not exist anywhere: the `409 target_state_conflict` documented here until the two-space cut had exactly one producer, the Org Admins membership check, and went with it. Demoting the last Owner is `409 last_owner`, decided by a row lock inside the writing transaction rather than by a read beforehand. Setting the tier already held changes nothing and writes no audit event. No session is revoked: a tier is re-read from the row on every request, so no issued token carries a stale copy of it.",
3579
- "requestBody": {
3580
- "required": true,
3581
- "content": {
3582
- "application/json": {
3583
- "schema": {
3584
- "$ref": "#/components/schemas/tier-change-request"
3585
- }
3586
- }
3587
- }
3588
- }
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."
3589
3949
  }
3590
3950
  },
3591
3951
  "/api/org": {
3592
3952
  "patch": {
3593
3953
  "operationId": "patch_api_org",
3594
- "summary": "Renames the org.",
3954
+ "summary": "Renames the org, requires two-factor for its members, or both.",
3595
3955
  "tags": [
3596
3956
  "org"
3597
3957
  ],
@@ -3623,7 +3983,7 @@
3623
3983
  }
3624
3984
  }
3625
3985
  },
3626
- "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`.",
3627
3987
  "requestBody": {
3628
3988
  "required": true,
3629
3989
  "content": {
@@ -3841,7 +4201,7 @@
3841
4201
  }
3842
4202
  }
3843
4203
  },
3844
- "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."
3845
4205
  }
3846
4206
  },
3847
4207
  "/mcp/oauth/token": {
@@ -4123,7 +4483,7 @@
4123
4483
  }
4124
4484
  }
4125
4485
  },
4126
- "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."
4127
4487
  }
4128
4488
  },
4129
4489
  "/mcp/{appIdentifier}/oauth/register": {
@@ -4183,7 +4543,7 @@
4183
4543
  "/mcp/{appIdentifier}/oauth/authorize": {
4184
4544
  "get": {
4185
4545
  "operationId": "get_mcp_appIdentifier_oauth_authorize",
4186
- "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.",
4187
4547
  "tags": [
4188
4548
  "mcp"
4189
4549
  ],
@@ -4272,7 +4632,7 @@
4272
4632
  "description": "Success."
4273
4633
  },
4274
4634
  "default": {
4275
- "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`.",
4276
4636
  "content": {
4277
4637
  "application/json": {
4278
4638
  "schema": {
@@ -4282,7 +4642,7 @@
4282
4642
  }
4283
4643
  }
4284
4644
  },
4285
- "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`."
4286
4646
  }
4287
4647
  },
4288
4648
  "/mcp/{appIdentifier}/oauth/token": {
@@ -4354,13 +4714,13 @@
4354
4714
  "content": {
4355
4715
  "application/json": {
4356
4716
  "schema": {
4357
- "$ref": "#/components/schemas/session-tokens"
4717
+ "$ref": "#/components/schemas/client-sign-in-result"
4358
4718
  }
4359
4719
  }
4360
4720
  }
4361
4721
  },
4362
4722
  "default": {
4363
- "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`.",
4364
4724
  "content": {
4365
4725
  "application/json": {
4366
4726
  "schema": {
@@ -4370,7 +4730,7 @@
4370
4730
  }
4371
4731
  }
4372
4732
  },
4373
- "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.",
4374
4734
  "requestBody": {
4375
4735
  "required": true,
4376
4736
  "content": {
@@ -4383,6 +4743,87 @@
4383
4743
  }
4384
4744
  }
4385
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
+ },
4386
4827
  "/api/client/register": {
4387
4828
  "post": {
4388
4829
  "operationId": "post_api_client_register",
@@ -4407,7 +4848,7 @@
4407
4848
  }
4408
4849
  }
4409
4850
  },
4410
- "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.",
4411
4852
  "requestBody": {
4412
4853
  "required": true,
4413
4854
  "content": {
@@ -4435,7 +4876,7 @@
4435
4876
  "content": {
4436
4877
  "application/json": {
4437
4878
  "schema": {
4438
- "$ref": "#/components/schemas/session-tokens"
4879
+ "$ref": "#/components/schemas/client-sign-in-result"
4439
4880
  }
4440
4881
  }
4441
4882
  }
@@ -4451,7 +4892,7 @@
4451
4892
  }
4452
4893
  }
4453
4894
  },
4454
- "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.",
4455
4896
  "requestBody": {
4456
4897
  "required": true,
4457
4898
  "content": {
@@ -4488,34 +4929,203 @@
4488
4929
  }
4489
4930
  }
4490
4931
  },
4491
- "description": "**`202` in status, body and timing** for an address that names a `pending_verification` account, one that names an already-active account, and one that names nothing at all. A mail is sent only in the first case. This is the same discipline `POST /api/client/register` keeps, by the other door: an answer that varied here would undo it. `404 not_found` is the **app identifier** and nothing else, exactly as on `register` — the address is never the subject of a refusal. Limited per app, address and IP, so this cannot be used to mail somebody repeatedly.",
4932
+ "description": "**`202` in status, body and timing** for an address that names a `pending_verification` account, one that names an already-active account, and one that names nothing at all. A mail is sent only in the first case. This is the same discipline `POST /api/client/register` keeps, by the other door: an answer that varied here would undo it. `404 not_found` is the **app identifier** and nothing else, exactly as on `register` — the address is never the subject of a refusal. Limited per app, address and IP, so this cannot be used to mail somebody repeatedly.",
4933
+ "requestBody": {
4934
+ "required": true,
4935
+ "content": {
4936
+ "application/json": {
4937
+ "schema": {
4938
+ "$ref": "#/components/schemas/client-resend-verification-request"
4939
+ }
4940
+ }
4941
+ }
4942
+ }
4943
+ }
4944
+ },
4945
+ "/api/client/password/reset": {
4946
+ "post": {
4947
+ "operationId": "post_api_client_password_reset",
4948
+ "summary": "Mails an app user a reset link, and answers the same either way.",
4949
+ "tags": [
4950
+ "client-auth"
4951
+ ],
4952
+ "security": [],
4953
+ "parameters": [],
4954
+ "responses": {
4955
+ "202": {
4956
+ "description": "Success."
4957
+ },
4958
+ "default": {
4959
+ "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `not_found`.",
4960
+ "content": {
4961
+ "application/json": {
4962
+ "schema": {
4963
+ "$ref": "#/components/schemas/api-error"
4964
+ }
4965
+ }
4966
+ }
4967
+ }
4968
+ },
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.",
4970
+ "requestBody": {
4971
+ "required": true,
4972
+ "content": {
4973
+ "application/json": {
4974
+ "schema": {
4975
+ "$ref": "#/components/schemas/client-password-reset-request"
4976
+ }
4977
+ }
4978
+ }
4979
+ }
4980
+ }
4981
+ },
4982
+ "/api/client/password/reset/confirm": {
4983
+ "post": {
4984
+ "operationId": "post_api_client_password_reset_confirm",
4985
+ "summary": "Spends a reset token, sets the new password and answers a fresh session.",
4986
+ "tags": [
4987
+ "client-auth"
4988
+ ],
4989
+ "security": [],
4990
+ "parameters": [],
4991
+ "responses": {
4992
+ "200": {
4993
+ "description": "Success.",
4994
+ "content": {
4995
+ "application/json": {
4996
+ "schema": {
4997
+ "$ref": "#/components/schemas/client-sign-in-result"
4998
+ }
4999
+ }
5000
+ }
5001
+ },
5002
+ "default": {
5003
+ "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `token_spent`, `method_not_allowed`.",
5004
+ "content": {
5005
+ "application/json": {
5006
+ "schema": {
5007
+ "$ref": "#/components/schemas/api-error"
5008
+ }
5009
+ }
5010
+ }
5011
+ }
5012
+ },
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.",
5014
+ "requestBody": {
5015
+ "required": true,
5016
+ "content": {
5017
+ "application/json": {
5018
+ "schema": {
5019
+ "$ref": "#/components/schemas/client-password-reset-confirm-request"
5020
+ }
5021
+ }
5022
+ }
5023
+ }
5024
+ }
5025
+ },
5026
+ "/api/client/invitations/accept": {
5027
+ "post": {
5028
+ "operationId": "post_api_client_invitations_accept",
5029
+ "summary": "Spends an invitation token, creates or activates the app user and answers a session.",
5030
+ "tags": [
5031
+ "client-auth"
5032
+ ],
5033
+ "security": [],
5034
+ "parameters": [],
5035
+ "responses": {
5036
+ "200": {
5037
+ "description": "Success.",
5038
+ "content": {
5039
+ "application/json": {
5040
+ "schema": {
5041
+ "$ref": "#/components/schemas/client-sign-in-result"
5042
+ }
5043
+ }
5044
+ }
5045
+ },
5046
+ "default": {
5047
+ "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `token_spent`, `email_taken`, `target_state_conflict`, `quota_exceeded`.",
5048
+ "content": {
5049
+ "application/json": {
5050
+ "schema": {
5051
+ "$ref": "#/components/schemas/api-error"
5052
+ }
5053
+ }
5054
+ }
5055
+ }
5056
+ },
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.",
5058
+ "requestBody": {
5059
+ "required": true,
5060
+ "content": {
5061
+ "application/json": {
5062
+ "schema": {
5063
+ "$ref": "#/components/schemas/client-accept-invitation-request"
5064
+ }
5065
+ }
5066
+ }
5067
+ }
5068
+ }
5069
+ },
5070
+ "/api/client/refresh": {
5071
+ "post": {
5072
+ "operationId": "post_api_client_refresh",
5073
+ "summary": "Rotates an app-user refresh token and mints a fresh access token.",
5074
+ "tags": [
5075
+ "client-auth"
5076
+ ],
5077
+ "security": [],
5078
+ "parameters": [],
5079
+ "responses": {
5080
+ "200": {
5081
+ "description": "Success.",
5082
+ "content": {
5083
+ "application/json": {
5084
+ "schema": {
5085
+ "$ref": "#/components/schemas/session-tokens"
5086
+ }
5087
+ }
5088
+ }
5089
+ },
5090
+ "default": {
5091
+ "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `token_expired`, `token_revoked`.",
5092
+ "content": {
5093
+ "application/json": {
5094
+ "schema": {
5095
+ "$ref": "#/components/schemas/api-error"
5096
+ }
5097
+ }
5098
+ }
5099
+ }
5100
+ },
5101
+ "description": "The account is re-proved here, not just the token: refresh is where every session eventually re-proves itself, so a user who was blocked or deleted loses the family here even if the proactive revoke had not landed. A family minted from a `resource`-carrying token exchange keeps its audience across every rotation.",
4492
5102
  "requestBody": {
4493
5103
  "required": true,
4494
5104
  "content": {
4495
5105
  "application/json": {
4496
5106
  "schema": {
4497
- "$ref": "#/components/schemas/client-resend-verification-request"
5107
+ "$ref": "#/components/schemas/client-refresh-request"
4498
5108
  }
4499
5109
  }
4500
5110
  }
4501
5111
  }
4502
5112
  }
4503
5113
  },
4504
- "/api/client/password/reset": {
5114
+ "/api/client/logout": {
4505
5115
  "post": {
4506
- "operationId": "post_api_client_password_reset",
4507
- "summary": "Mails an app user a reset link, and answers the same either way.",
5116
+ "operationId": "post_api_client_logout",
5117
+ "summary": "Revokes an app-user refresh family.",
4508
5118
  "tags": [
4509
5119
  "client-auth"
4510
5120
  ],
4511
5121
  "security": [],
4512
5122
  "parameters": [],
4513
5123
  "responses": {
4514
- "202": {
5124
+ "204": {
4515
5125
  "description": "Success."
4516
5126
  },
4517
5127
  "default": {
4518
- "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `not_found`.",
5128
+ "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`.",
4519
5129
  "content": {
4520
5130
  "application/json": {
4521
5131
  "schema": {
@@ -4525,27 +5135,37 @@
4525
5135
  }
4526
5136
  }
4527
5137
  },
4528
- "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.",
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.",
4529
5139
  "requestBody": {
4530
5140
  "required": true,
4531
5141
  "content": {
4532
5142
  "application/json": {
4533
5143
  "schema": {
4534
- "$ref": "#/components/schemas/client-password-reset-request"
5144
+ "$ref": "#/components/schemas/client-logout-request"
4535
5145
  }
4536
5146
  }
4537
5147
  }
4538
5148
  }
4539
5149
  }
4540
5150
  },
4541
- "/api/client/password/reset/confirm": {
5151
+ "/api/client/password/change": {
4542
5152
  "post": {
4543
- "operationId": "post_api_client_password_reset_confirm",
4544
- "summary": "Spends a reset token, sets the new password and answers a fresh session.",
5153
+ "operationId": "post_api_client_password_change",
5154
+ "summary": "Changes an app user's own password and answers a fresh session.",
4545
5155
  "tags": [
4546
5156
  "client-auth"
4547
5157
  ],
4548
- "security": [],
5158
+ "security": [
5159
+ {
5160
+ "developerSession": []
5161
+ },
5162
+ {
5163
+ "clientToken": []
5164
+ },
5165
+ {
5166
+ "serverKey": []
5167
+ }
5168
+ ],
4549
5169
  "parameters": [],
4550
5170
  "responses": {
4551
5171
  "200": {
@@ -4559,7 +5179,7 @@
4559
5179
  }
4560
5180
  },
4561
5181
  "default": {
4562
- "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `token_spent`.",
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`.",
4563
5183
  "content": {
4564
5184
  "application/json": {
4565
5185
  "schema": {
@@ -4569,27 +5189,37 @@
4569
5189
  }
4570
5190
  }
4571
5191
  },
4572
- "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.",
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.",
4573
5193
  "requestBody": {
4574
5194
  "required": true,
4575
5195
  "content": {
4576
5196
  "application/json": {
4577
5197
  "schema": {
4578
- "$ref": "#/components/schemas/client-password-reset-confirm-request"
5198
+ "$ref": "#/components/schemas/password-change-request"
4579
5199
  }
4580
5200
  }
4581
5201
  }
4582
5202
  }
4583
5203
  }
4584
5204
  },
4585
- "/api/client/invitations/accept": {
4586
- "post": {
4587
- "operationId": "post_api_client_invitations_accept",
4588
- "summary": "Spends an invitation token, creates or activates the app user and answers a session.",
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.",
4589
5209
  "tags": [
4590
5210
  "client-auth"
4591
5211
  ],
4592
- "security": [],
5212
+ "security": [
5213
+ {
5214
+ "developerSession": []
5215
+ },
5216
+ {
5217
+ "clientToken": []
5218
+ },
5219
+ {
5220
+ "serverKey": []
5221
+ }
5222
+ ],
4593
5223
  "parameters": [],
4594
5224
  "responses": {
4595
5225
  "200": {
@@ -4597,13 +5227,13 @@
4597
5227
  "content": {
4598
5228
  "application/json": {
4599
5229
  "schema": {
4600
- "$ref": "#/components/schemas/session-tokens"
5230
+ "$ref": "#/components/schemas/client-identity"
4601
5231
  }
4602
5232
  }
4603
5233
  }
4604
5234
  },
4605
5235
  "default": {
4606
- "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `token_spent`, `email_taken`, `target_state_conflict`, `quota_exceeded`.",
5236
+ "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `forbidden`.",
4607
5237
  "content": {
4608
5238
  "application/json": {
4609
5239
  "schema": {
@@ -4613,23 +5243,13 @@
4613
5243
  }
4614
5244
  }
4615
5245
  },
4616
- "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.",
4617
- "requestBody": {
4618
- "required": true,
4619
- "content": {
4620
- "application/json": {
4621
- "schema": {
4622
- "$ref": "#/components/schemas/client-accept-invitation-request"
4623
- }
4624
- }
4625
- }
4626
- }
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."
4627
5247
  }
4628
5248
  },
4629
- "/api/client/refresh": {
5249
+ "/api/client/two-factor/verify": {
4630
5250
  "post": {
4631
- "operationId": "post_api_client_refresh",
4632
- "summary": "Rotates an app-user refresh token and mints a fresh access token.",
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.",
4633
5253
  "tags": [
4634
5254
  "client-auth"
4635
5255
  ],
@@ -4647,7 +5267,7 @@
4647
5267
  }
4648
5268
  },
4649
5269
  "default": {
4650
- "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `token_expired`, `token_revoked`.",
5270
+ "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `invalid_code`, `token_spent`.",
4651
5271
  "content": {
4652
5272
  "application/json": {
4653
5273
  "schema": {
@@ -4657,34 +5277,46 @@
4657
5277
  }
4658
5278
  }
4659
5279
  },
4660
- "description": "The account is re-proved here, not just the token: refresh is where every session eventually re-proves itself, so a user who was blocked or deleted loses the family here even if the proactive revoke had not landed. A family minted from a `resource`-carrying token exchange keeps its audience across every rotation.",
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`.",
4661
5281
  "requestBody": {
4662
5282
  "required": true,
4663
5283
  "content": {
4664
5284
  "application/json": {
4665
5285
  "schema": {
4666
- "$ref": "#/components/schemas/client-refresh-request"
5286
+ "$ref": "#/components/schemas/client-two-factor-verify-request"
4667
5287
  }
4668
5288
  }
4669
5289
  }
4670
5290
  }
4671
5291
  }
4672
5292
  },
4673
- "/api/client/logout": {
5293
+ "/api/client/two-factor/setup": {
4674
5294
  "post": {
4675
- "operationId": "post_api_client_logout",
4676
- "summary": "Revokes an app-user refresh family.",
5295
+ "operationId": "post_api_client_two_factor_setup",
5296
+ "summary": "Starts an authenticator setup and answers its secret and otpauth URL.",
4677
5297
  "tags": [
4678
5298
  "client-auth"
4679
5299
  ],
4680
- "security": [],
5300
+ "security": [
5301
+ {
5302
+ "clientToken": []
5303
+ },
5304
+ {}
5305
+ ],
4681
5306
  "parameters": [],
4682
5307
  "responses": {
4683
- "204": {
4684
- "description": "Success."
5308
+ "200": {
5309
+ "description": "Success.",
5310
+ "content": {
5311
+ "application/json": {
5312
+ "schema": {
5313
+ "$ref": "#/components/schemas/two-factor-setup-response"
5314
+ }
5315
+ }
5316
+ }
4685
5317
  },
4686
5318
  "default": {
4687
- "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`.",
5319
+ "description": "An error envelope. Codes this route is known to answer: `rate_limited`, `validation_error`, `token_spent`, `unauthorized`, `target_state_conflict`.",
4688
5320
  "content": {
4689
5321
  "application/json": {
4690
5322
  "schema": {
@@ -4694,36 +5326,31 @@
4694
5326
  }
4695
5327
  }
4696
5328
  },
4697
- "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.",
4698
5330
  "requestBody": {
4699
5331
  "required": true,
4700
5332
  "content": {
4701
5333
  "application/json": {
4702
5334
  "schema": {
4703
- "$ref": "#/components/schemas/client-logout-request"
5335
+ "$ref": "#/components/schemas/client-two-factor-setup-request"
4704
5336
  }
4705
5337
  }
4706
5338
  }
4707
5339
  }
4708
5340
  }
4709
5341
  },
4710
- "/api/client/password/change": {
5342
+ "/api/client/two-factor/setup/confirm": {
4711
5343
  "post": {
4712
- "operationId": "post_api_client_password_change",
4713
- "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.",
4714
5346
  "tags": [
4715
5347
  "client-auth"
4716
5348
  ],
4717
5349
  "security": [
4718
- {
4719
- "developerSession": []
4720
- },
4721
5350
  {
4722
5351
  "clientToken": []
4723
5352
  },
4724
- {
4725
- "serverKey": []
4726
- }
5353
+ {}
4727
5354
  ],
4728
5355
  "parameters": [],
4729
5356
  "responses": {
@@ -4732,13 +5359,13 @@
4732
5359
  "content": {
4733
5360
  "application/json": {
4734
5361
  "schema": {
4735
- "$ref": "#/components/schemas/session-tokens"
5362
+ "$ref": "#/components/schemas/client-two-factor-setup-confirm-response"
4736
5363
  }
4737
5364
  }
4738
5365
  }
4739
5366
  },
4740
5367
  "default": {
4741
- "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`.",
4742
5369
  "content": {
4743
5370
  "application/json": {
4744
5371
  "schema": {
@@ -4748,23 +5375,23 @@
4748
5375
  }
4749
5376
  }
4750
5377
  },
4751
- "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`.",
4752
5379
  "requestBody": {
4753
5380
  "required": true,
4754
5381
  "content": {
4755
5382
  "application/json": {
4756
5383
  "schema": {
4757
- "$ref": "#/components/schemas/password-change-request"
5384
+ "$ref": "#/components/schemas/client-two-factor-setup-confirm-request"
4758
5385
  }
4759
5386
  }
4760
5387
  }
4761
5388
  }
4762
5389
  }
4763
5390
  },
4764
- "/api/client/me": {
4765
- "get": {
4766
- "operationId": "get_api_client_me",
4767
- "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.",
4768
5395
  "tags": [
4769
5396
  "client-auth"
4770
5397
  ],
@@ -4781,18 +5408,11 @@
4781
5408
  ],
4782
5409
  "parameters": [],
4783
5410
  "responses": {
4784
- "200": {
4785
- "description": "Success.",
4786
- "content": {
4787
- "application/json": {
4788
- "schema": {
4789
- "$ref": "#/components/schemas/client-identity"
4790
- }
4791
- }
4792
- }
5411
+ "204": {
5412
+ "description": "Success."
4793
5413
  },
4794
5414
  "default": {
4795
- "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`.",
4796
5416
  "content": {
4797
5417
  "application/json": {
4798
5418
  "schema": {
@@ -4802,7 +5422,17 @@
4802
5422
  }
4803
5423
  }
4804
5424
  },
4805
- "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
+ }
4806
5436
  }
4807
5437
  },
4808
5438
  "/api/client/providers": {
@@ -4997,7 +5627,7 @@
4997
5627
  }
4998
5628
  }
4999
5629
  },
5000
- "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."
5001
5631
  }
5002
5632
  },
5003
5633
  "/api/client/oidc/exchange": {
@@ -8624,54 +9254,6 @@
8624
9254
  },
8625
9255
  "description": "A window longer than `USAGE_WINDOW_MAX_DAYS` is refused naming the field, not silently capped: a caller who asked for more than the platform will answer is owed a refusal, not a shorter answer they will mistake for the whole picture. `from_day <= to_day` is a cross-field rule no JSON Schema can express and is enforced here. The window is echoed back."
8626
9256
  }
8627
- },
8628
- "/api/feedback": {
8629
- "post": {
8630
- "operationId": "post_api_feedback",
8631
- "summary": "Sends a message from a developer to the people who build Fleetless.",
8632
- "tags": [
8633
- "org"
8634
- ],
8635
- "security": [
8636
- {
8637
- "developerSession": []
8638
- }
8639
- ],
8640
- "parameters": [],
8641
- "responses": {
8642
- "202": {
8643
- "description": "Success.",
8644
- "content": {
8645
- "application/json": {
8646
- "schema": {
8647
- "$ref": "#/components/schemas/feedback-response"
8648
- }
8649
- }
8650
- }
8651
- },
8652
- "default": {
8653
- "description": "An error envelope. Codes this route is known to answer: `unauthorized`, `token_expired`, `token_revoked`, `validation_error`, `rate_limited`.",
8654
- "content": {
8655
- "application/json": {
8656
- "schema": {
8657
- "$ref": "#/components/schemas/api-error"
8658
- }
8659
- }
8660
- }
8661
- }
8662
- },
8663
- "description": "The message is stored before any mail is tried, so `202` means it is kept whatever `mail` says: `sent`, `failed`, or `not_configured` when this cloud has no feedback address. At most 10 messages per developer per hour; the 11th answers `429 rate_limited` with `retry_after_ms`. Replies come by mail, to the sender's address.",
8664
- "requestBody": {
8665
- "required": true,
8666
- "content": {
8667
- "application/json": {
8668
- "schema": {
8669
- "$ref": "#/components/schemas/feedback-request"
8670
- }
8671
- }
8672
- }
8673
- }
8674
- }
8675
9257
  }
8676
9258
  },
8677
9259
  "components": {
@@ -8701,16 +9283,22 @@
8701
9283
  "minLength": 1,
8702
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."
8703
9285
  },
8704
- "password": {
8705
- "type": "string",
8706
- "minLength": 12,
8707
- "maxLength": 256,
8708
- "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
+ ]
8709
9298
  }
8710
9299
  },
8711
9300
  "required": [
8712
- "token",
8713
- "password"
9301
+ "token"
8714
9302
  ],
8715
9303
  "additionalProperties": false
8716
9304
  },
@@ -9013,43 +9601,141 @@
9013
9601
  "type": "null"
9014
9602
  }
9015
9603
  ],
9016
- "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."
9605
+ },
9606
+ "verify_url": {
9607
+ "anyOf": [
9608
+ {
9609
+ "type": "string",
9610
+ "maxLength": 500
9611
+ },
9612
+ {
9613
+ "type": "null"
9614
+ }
9615
+ ],
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."
9617
+ },
9618
+ "reset_url": {
9619
+ "anyOf": [
9620
+ {
9621
+ "type": "string",
9622
+ "maxLength": 500
9623
+ },
9624
+ {
9625
+ "type": "null"
9626
+ }
9627
+ ],
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."
9629
+ },
9630
+ "mcp_login_url": {
9631
+ "anyOf": [
9632
+ {
9633
+ "type": "string",
9634
+ "maxLength": 500
9635
+ },
9636
+ {
9637
+ "type": "null"
9638
+ }
9639
+ ],
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."
9017
9673
  },
9018
- "verify_url": {
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": {
9019
9684
  "anyOf": [
9020
9685
  {
9021
9686
  "type": "string",
9022
- "maxLength": 500
9687
+ "format": "uri"
9023
9688
  },
9024
9689
  {
9025
9690
  "type": "null"
9026
9691
  }
9027
9692
  ],
9028
- "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."
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`."
9029
9694
  },
9030
- "reset_url": {
9695
+ "hosted_accent": {
9031
9696
  "anyOf": [
9032
9697
  {
9033
9698
  "type": "string",
9034
- "maxLength": 500
9699
+ "pattern": "^#[0-9a-f]{6}$"
9035
9700
  },
9036
9701
  {
9037
9702
  "type": "null"
9038
9703
  }
9039
9704
  ],
9040
- "description": "The page that takes a new password, with `{token}` where the token goes."
9705
+ "description": "The accent colour of the hosted pages, `#rrggbb` in lowercase, or `null` for the neutral shell's own."
9041
9706
  },
9042
- "mcp_login_url": {
9043
- "anyOf": [
9044
- {
9707
+ "hosted_pages": {
9708
+ "type": "object",
9709
+ "properties": {
9710
+ "invite_url": {
9045
9711
  "type": "string",
9046
- "maxLength": 500
9712
+ "format": "uri",
9713
+ "description": "The hosted invitation page, `<portal>/app/<identifier>/invite/{token}`."
9047
9714
  },
9048
- {
9049
- "type": "null"
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}`."
9050
9729
  }
9730
+ },
9731
+ "required": [
9732
+ "invite_url",
9733
+ "verify_url",
9734
+ "reset_url",
9735
+ "mcp_login_url"
9051
9736
  ],
9052
- "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."
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."
9053
9739
  },
9054
9740
  "oidc_callback_url": {
9055
9741
  "type": "string",
@@ -9072,6 +9758,12 @@
9072
9758
  "verify_url",
9073
9759
  "reset_url",
9074
9760
  "mcp_login_url",
9761
+ "app_url",
9762
+ "sign_in_methods",
9763
+ "two_factor",
9764
+ "hosted_logo_url",
9765
+ "hosted_accent",
9766
+ "hosted_pages",
9075
9767
  "oidc_callback_url",
9076
9768
  "updated_at"
9077
9769
  ],
@@ -9171,7 +9863,7 @@
9171
9863
  "type": "null"
9172
9864
  }
9173
9865
  ],
9174
- "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."
9175
9867
  },
9176
9868
  "mail": {
9177
9869
  "type": "string",
@@ -9181,7 +9873,7 @@
9181
9873
  "not_configured",
9182
9874
  "failed"
9183
9875
  ],
9184
- "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."
9185
9877
  }
9186
9878
  },
9187
9879
  "required": [
@@ -9340,9 +10032,10 @@
9340
10032
  "enum": [
9341
10033
  "invite",
9342
10034
  "verify",
9343
- "reset"
10035
+ "reset",
10036
+ "login_code"
9344
10037
  ],
9345
- "description": "Which of the three mails this template replaces."
10038
+ "description": "Which of the four mails this template replaces."
9346
10039
  },
9347
10040
  "subject": {
9348
10041
  "type": "string",
@@ -9389,7 +10082,7 @@
9389
10082
  "type": "object",
9390
10083
  "properties": {
9391
10084
  "templates": {
9392
- "maxItems": 3,
10085
+ "maxItems": 4,
9393
10086
  "type": "array",
9394
10087
  "items": {
9395
10088
  "type": "object",
@@ -9399,9 +10092,10 @@
9399
10092
  "enum": [
9400
10093
  "invite",
9401
10094
  "verify",
9402
- "reset"
10095
+ "reset",
10096
+ "login_code"
9403
10097
  ],
9404
- "description": "Which of the three mails this template replaces."
10098
+ "description": "Which of the four mails this template replaces."
9405
10099
  },
9406
10100
  "subject": {
9407
10101
  "type": "string",
@@ -9699,6 +10393,41 @@
9699
10393
  ],
9700
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*."
9701
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
+ },
9702
10431
  "created_at": {
9703
10432
  "type": "string",
9704
10433
  "format": "date-time",
@@ -9716,6 +10445,7 @@
9716
10445
  "has_password",
9717
10446
  "providers",
9718
10447
  "last_login_at",
10448
+ "two_factor",
9719
10449
  "created_at"
9720
10450
  ],
9721
10451
  "additionalProperties": false
@@ -9801,6 +10531,41 @@
9801
10531
  ],
9802
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*."
9803
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
+ },
9804
10569
  "created_at": {
9805
10570
  "type": "string",
9806
10571
  "format": "date-time",
@@ -9818,6 +10583,7 @@
9818
10583
  "has_password",
9819
10584
  "providers",
9820
10585
  "last_login_at",
10586
+ "two_factor",
9821
10587
  "created_at"
9822
10588
  ],
9823
10589
  "additionalProperties": false
@@ -10558,6 +11324,10 @@
10558
11324
  "maxLength": 120,
10559
11325
  "description": "The organisation's display name. Free text, changed through `PATCH /api/org`."
10560
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
+ },
10561
11331
  "created_at": {
10562
11332
  "type": "string",
10563
11333
  "format": "date-time",
@@ -10568,6 +11338,7 @@
10568
11338
  "required": [
10569
11339
  "id",
10570
11340
  "name",
11341
+ "require_two_factor",
10571
11342
  "created_at"
10572
11343
  ],
10573
11344
  "additionalProperties": false
@@ -10614,6 +11385,27 @@
10614
11385
  ],
10615
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."
10616
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
+ },
10617
11409
  "created_at": {
10618
11410
  "type": "string",
10619
11411
  "format": "date-time",
@@ -10627,6 +11419,7 @@
10627
11419
  "email",
10628
11420
  "display_name",
10629
11421
  "tier",
11422
+ "two_factor",
10630
11423
  "created_at"
10631
11424
  ],
10632
11425
  "additionalProperties": false
@@ -10802,10 +11595,10 @@
10802
11595
  "description": "The opaque token from the invitation link, valid seven days. Unknown, expired, revoked and already-accepted all answer `410 token_spent`."
10803
11596
  },
10804
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.",
10805
11599
  "type": "string",
10806
11600
  "minLength": 12,
10807
- "maxLength": 256,
10808
- "description": "The password the new account will use."
11601
+ "maxLength": 256
10809
11602
  },
10810
11603
  "display_name": {
10811
11604
  "description": "An optional name, overriding whatever the invitation pre-filled.",
@@ -10822,8 +11615,7 @@
10822
11615
  }
10823
11616
  },
10824
11617
  "required": [
10825
- "token",
10826
- "password"
11618
+ "token"
10827
11619
  ],
10828
11620
  "additionalProperties": false
10829
11621
  },
@@ -10916,6 +11708,17 @@
10916
11708
  }
10917
11709
  ],
10918
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`."
10919
11722
  }
10920
11723
  },
10921
11724
  "required": [
@@ -10925,10 +11728,63 @@
10925
11728
  "server_key_id",
10926
11729
  "app_id",
10927
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",
10928
11755
  "email"
10929
11756
  ],
10930
11757
  "additionalProperties": false
10931
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."
11779
+ }
11780
+ },
11781
+ "required": [
11782
+ "app_identifier",
11783
+ "email",
11784
+ "code"
11785
+ ],
11786
+ "additionalProperties": false
11787
+ },
10932
11788
  "client-login-request": {
10933
11789
  "type": "object",
10934
11790
  "properties": {
@@ -11131,11 +11987,31 @@
11131
11987
  ],
11132
11988
  "additionalProperties": false
11133
11989
  },
11134
- "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."
11135
12010
  }
11136
12011
  },
11137
12012
  "required": [
11138
- "providers"
12013
+ "providers",
12014
+ "sign_in_methods"
11139
12015
  ],
11140
12016
  "additionalProperties": false
11141
12017
  },
@@ -11169,10 +12045,10 @@
11169
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."
11170
12046
  },
11171
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.",
11172
12049
  "type": "string",
11173
12050
  "minLength": 12,
11174
- "maxLength": 256,
11175
- "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
11176
12052
  },
11177
12053
  "display_name": {
11178
12054
  "description": "An optional human name for the account. The developer's own UI decides whether to ask for it.",
@@ -11190,8 +12066,7 @@
11190
12066
  },
11191
12067
  "required": [
11192
12068
  "app_identifier",
11193
- "email",
11194
- "password"
12069
+ "email"
11195
12070
  ],
11196
12071
  "additionalProperties": false
11197
12072
  },
@@ -11297,11 +12172,181 @@
11297
12172
  ],
11298
12173
  "additionalProperties": false
11299
12174
  },
11300
- "description": "Every robot the caller reaches, in name order with the id as the tiebreak. An app user reaches the robots their app attaches on which their role grants at least one slug or capability; a server key reaches every robot its app attaches; a developer reaches every robot of the organisation."
12175
+ "description": "Every robot the caller reaches, in name order with the id as the tiebreak. An app user reaches the robots their app attaches on which their role grants at least one slug or capability; a server key reaches every robot its app attaches; a developer reaches every robot of the organisation."
12176
+ }
12177
+ },
12178
+ "required": [
12179
+ "robots"
12180
+ ],
12181
+ "additionalProperties": false
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."
11301
12310
  }
11302
12311
  },
11303
12312
  "required": [
11304
- "robots"
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"
11305
12350
  ],
11306
12351
  "additionalProperties": false
11307
12352
  },
@@ -13635,7 +14680,7 @@
13635
14680
  },
13636
14681
  "send_mail": {
13637
14682
  "type": "boolean",
13638
- "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."
13639
14684
  }
13640
14685
  },
13641
14686
  "required": [
@@ -13783,6 +14828,113 @@
13783
14828
  ],
13784
14829
  "additionalProperties": false
13785
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": {
14915
+ "anyOf": [
14916
+ {
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
+ }
14924
+ },
14925
+ {
14926
+ "type": "null"
14927
+ }
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."
14930
+ }
14931
+ },
14932
+ "required": [
14933
+ "passkey",
14934
+ "recovery_codes"
14935
+ ],
14936
+ "additionalProperties": false
14937
+ },
13786
14938
  "create-robot-request": {
13787
14939
  "type": "object",
13788
14940
  "properties": {
@@ -13939,96 +15091,255 @@
13939
15091
  }
13940
15092
  },
13941
15093
  "required": [
13942
- "email",
13943
- "tier",
13944
- "send_mail"
15094
+ "email",
15095
+ "tier",
15096
+ "send_mail"
15097
+ ],
15098
+ "additionalProperties": false
15099
+ },
15100
+ "datapoint-list-response": {
15101
+ "type": "object",
15102
+ "properties": {
15103
+ "datapoints": {
15104
+ "type": "array",
15105
+ "items": {
15106
+ "type": "object",
15107
+ "properties": {
15108
+ "slug": {
15109
+ "type": "string",
15110
+ "minLength": 2,
15111
+ "maxLength": 63,
15112
+ "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
15113
+ "description": "The name a client reads this datapoint by."
15114
+ },
15115
+ "builtin": {
15116
+ "type": "boolean",
15117
+ "description": "`true` for the datapoints every robot has — `bridge_state` and `robot_details` — and `false` for everything the published configuration adds."
15118
+ },
15119
+ "unit": {
15120
+ "anyOf": [
15121
+ {
15122
+ "type": "string"
15123
+ },
15124
+ {
15125
+ "type": "null"
15126
+ }
15127
+ ],
15128
+ "description": "The unit the value carries **after** any scale and offset, shown beside the number so nobody has to guess whether `15` means percent, volts or minutes. `null` when the configuration names none."
15129
+ },
15130
+ "rate_throttle_hz": {
15131
+ "anyOf": [
15132
+ {
15133
+ "type": "number",
15134
+ "minimum": 0,
15135
+ "maximum": 20
15136
+ },
15137
+ {
15138
+ "type": "null"
15139
+ }
15140
+ ],
15141
+ "description": "The ceiling on how often this datapoint is sent, in hertz. `null` means no ceiling is configured, which is also the answer for every built-in. A ceiling, not a clock: a slow topic stays slow and no value is repeated to manufacture a rate."
15142
+ }
15143
+ },
15144
+ "required": [
15145
+ "slug",
15146
+ "builtin",
15147
+ "unit",
15148
+ "rate_throttle_hz"
15149
+ ],
15150
+ "additionalProperties": false
15151
+ },
15152
+ "description": "Everything a client may read on this robot: the two built-ins, plus every datapoint the published configuration exposes and the caller's role grants."
15153
+ }
15154
+ },
15155
+ "required": [
15156
+ "datapoints"
15157
+ ],
15158
+ "additionalProperties": false
15159
+ },
15160
+ "datapoint-value": {
15161
+ "type": "object",
15162
+ "properties": {
15163
+ "slug": {
15164
+ "type": "string",
15165
+ "minLength": 2,
15166
+ "maxLength": 63,
15167
+ "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
15168
+ "description": "The datapoint this value belongs to."
15169
+ },
15170
+ "value": {
15171
+ "description": "The value, shaped by the datapoint: a number, a boolean, a string, or the whole ROS message where the configuration names no field inside it. Any `scale` and `offset` the configuration declares have already been applied, at the robot."
15172
+ },
15173
+ "timestamp_ms": {
15174
+ "type": "integer",
15175
+ "minimum": 0,
15176
+ "maximum": 9007199254740991,
15177
+ "description": "When the value was captured, as a unix timestamp in milliseconds. The **bridge's capture time**, never the time the cloud received it — the one exception is the built-in `bridge_state`, which the cloud observes by construction."
15178
+ }
15179
+ },
15180
+ "required": [
15181
+ "slug",
15182
+ "value",
15183
+ "timestamp_ms"
15184
+ ],
15185
+ "additionalProperties": false
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"
13945
15239
  ],
13946
15240
  "additionalProperties": false
13947
15241
  },
13948
- "datapoint-list-response": {
15242
+ "developer-two-factor": {
13949
15243
  "type": "object",
13950
15244
  "properties": {
13951
- "datapoints": {
15245
+ "passkeys": {
13952
15246
  "type": "array",
13953
15247
  "items": {
13954
15248
  "type": "object",
13955
15249
  "properties": {
13956
- "slug": {
15250
+ "id": {
13957
15251
  "type": "string",
13958
- "minLength": 2,
13959
- "maxLength": 63,
13960
- "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
13961
- "description": "The name a client reads this datapoint by."
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`."
13962
15255
  },
13963
- "builtin": {
13964
- "type": "boolean",
13965
- "description": "`true` for the datapoints every robot has — `bridge_state` and `robot_details` — and `false` for everything the published configuration adds."
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."
13966
15261
  },
13967
- "unit": {
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": {
13968
15269
  "anyOf": [
13969
15270
  {
13970
- "type": "string"
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))$"
13971
15274
  },
13972
15275
  {
13973
15276
  "type": "null"
13974
15277
  }
13975
15278
  ],
13976
- "description": "The unit the value carries **after** any scale and offset, shown beside the number so nobody has to guess whether `15` means percent, volts or minutes. `null` when the configuration names none."
15279
+ "description": "When it last signed the person in or confirmed a sign-in, or `null` if never."
13977
15280
  },
13978
- "rate_throttle_hz": {
15281
+ "synced": {
13979
15282
  "anyOf": [
13980
15283
  {
13981
- "type": "number",
13982
- "minimum": 0,
13983
- "maximum": 20
15284
+ "type": "boolean"
13984
15285
  },
13985
15286
  {
13986
15287
  "type": "null"
13987
15288
  }
13988
15289
  ],
13989
- "description": "The ceiling on how often this datapoint is sent, in hertz. `null` means no ceiling is configured, which is also the answer for every built-in. A ceiling, not a clock: a slow topic stays slow and no value is repeated to manufacture a rate."
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."
13990
15291
  }
13991
15292
  },
13992
15293
  "required": [
13993
- "slug",
13994
- "builtin",
13995
- "unit",
13996
- "rate_throttle_hz"
15294
+ "id",
15295
+ "name",
15296
+ "created_at",
15297
+ "last_used_at",
15298
+ "synced"
13997
15299
  ],
13998
15300
  "additionalProperties": false
13999
15301
  },
14000
- "description": "Everything a client may read on this robot: the two built-ins, plus every datapoint the published configuration exposes and the caller's role grants."
14001
- }
14002
- },
14003
- "required": [
14004
- "datapoints"
14005
- ],
14006
- "additionalProperties": false
14007
- },
14008
- "datapoint-value": {
14009
- "type": "object",
14010
- "properties": {
14011
- "slug": {
14012
- "type": "string",
14013
- "minLength": 2,
14014
- "maxLength": 63,
14015
- "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
14016
- "description": "The datapoint this value belongs to."
15302
+ "description": "Every passkey the caller has registered, oldest first. Empty when none."
14017
15303
  },
14018
- "value": {
14019
- "description": "The value, shaped by the datapoint: a number, a boolean, a string, or the whole ROS message where the configuration names no field inside it. Any `scale` and `offset` the configuration declares have already been applied, at the robot."
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."
14020
15326
  },
14021
- "timestamp_ms": {
15327
+ "recovery_codes_left": {
14022
15328
  "type": "integer",
14023
15329
  "minimum": 0,
14024
- "maximum": 9007199254740991,
14025
- "description": "When the value was captured, as a unix timestamp in milliseconds. The **bridge's capture time**, never the time the cloud received it — the one exception is the built-in `bridge_state`, which the cloud observes by construction."
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."
14026
15336
  }
14027
15337
  },
14028
15338
  "required": [
14029
- "slug",
14030
- "value",
14031
- "timestamp_ms"
15339
+ "passkeys",
15340
+ "authenticator",
15341
+ "recovery_codes_left",
15342
+ "required_by_org"
14032
15343
  ],
14033
15344
  "additionalProperties": false
14034
15345
  },
@@ -14200,65 +15511,6 @@
14200
15511
  ],
14201
15512
  "additionalProperties": false
14202
15513
  },
14203
- "feedback-request": {
14204
- "type": "object",
14205
- "properties": {
14206
- "kind": {
14207
- "type": "string",
14208
- "enum": [
14209
- "idea",
14210
- "problem",
14211
- "question",
14212
- "other"
14213
- ],
14214
- "description": "What the message is: an `idea`, a `problem`, a `question` or `other`. It only sorts the inbox; it changes nothing about how the message is handled."
14215
- },
14216
- "message": {
14217
- "type": "string",
14218
- "minLength": 1,
14219
- "maxLength": 5000,
14220
- "description": "What the developer wrote, trimmed. At most 5000 characters; a message that is only whitespace is refused."
14221
- },
14222
- "page": {
14223
- "type": "string",
14224
- "maxLength": 512,
14225
- "pattern": "^\\/.*",
14226
- "description": "The console path the message was sent from, e.g. `/robots/:id/jobs` with its real id. A path, never a full URL, so no host and no query string reach the inbox by accident."
14227
- }
14228
- },
14229
- "required": [
14230
- "kind",
14231
- "message",
14232
- "page"
14233
- ],
14234
- "additionalProperties": false
14235
- },
14236
- "feedback-response": {
14237
- "type": "object",
14238
- "properties": {
14239
- "id": {
14240
- "type": "string",
14241
- "format": "uuid",
14242
- "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)$",
14243
- "description": "The stored message. It exists whatever `mail` says."
14244
- },
14245
- "mail": {
14246
- "type": "string",
14247
- "enum": [
14248
- "sent",
14249
- "not_requested",
14250
- "not_configured",
14251
- "failed"
14252
- ],
14253
- "description": "What happened to the notification mail: `sent`, `failed`, or `not_configured` when this cloud has no feedback address. The message is stored in every case, so a client shows success for all three."
14254
- }
14255
- },
14256
- "required": [
14257
- "id",
14258
- "mail"
14259
- ],
14260
- "additionalProperties": false
14261
- },
14262
15514
  "fetch-types-request": {
14263
15515
  "type": "object",
14264
15516
  "properties": {
@@ -14479,6 +15731,27 @@
14479
15731
  ],
14480
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."
14481
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
+ },
14482
15755
  "created_at": {
14483
15756
  "type": "string",
14484
15757
  "format": "date-time",
@@ -14492,6 +15765,7 @@
14492
15765
  "email",
14493
15766
  "display_name",
14494
15767
  "tier",
15768
+ "two_factor",
14495
15769
  "created_at"
14496
15770
  ],
14497
15771
  "additionalProperties": false
@@ -14543,6 +15817,27 @@
14543
15817
  ],
14544
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."
14545
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
+ },
14546
15841
  "created_at": {
14547
15842
  "type": "string",
14548
15843
  "format": "date-time",
@@ -14556,6 +15851,7 @@
14556
15851
  "email",
14557
15852
  "display_name",
14558
15853
  "tier",
15854
+ "two_factor",
14559
15855
  "created_at"
14560
15856
  ],
14561
15857
  "additionalProperties": false
@@ -15281,26 +16577,12 @@
15281
16577
  "minLength": 1,
15282
16578
  "maxLength": 200,
15283
16579
  "description": "A display name taken at invoke time — the email for a Fleetless user or an app user, the key's own name for a server key. Storing it rather than joining is the point: renaming a key afterwards does not rewrite history."
15284
- },
15285
- "name": {
15286
- "anyOf": [
15287
- {
15288
- "type": "string",
15289
- "minLength": 1,
15290
- "maxLength": 200
15291
- },
15292
- {
15293
- "type": "null"
15294
- }
15295
- ],
15296
- "description": "The person's display name when the job started: the Fleetless user's `display_name` for a developer, the app user's `display_name` for an app user. `null` for a server key, when the person had no name set, and for runs recorded before contracts 5.3.0. Show `label` when it is null."
15297
16580
  }
15298
16581
  },
15299
16582
  "required": [
15300
16583
  "kind",
15301
16584
  "id",
15302
- "label",
15303
- "name"
16585
+ "label"
15304
16586
  ],
15305
16587
  "additionalProperties": false,
15306
16588
  "description": "Who invoked the run, and what they were acting as at the time."
@@ -16479,37 +17761,6 @@
16479
17761
  "new_password"
16480
17762
  ]
16481
17763
  },
16482
- "password-reset-confirm": {
16483
- "type": "object",
16484
- "properties": {
16485
- "token": {
16486
- "type": "string",
16487
- "minLength": 1
16488
- },
16489
- "new_password": {
16490
- "type": "string",
16491
- "minLength": 12,
16492
- "maxLength": 256
16493
- }
16494
- },
16495
- "required": [
16496
- "token",
16497
- "new_password"
16498
- ]
16499
- },
16500
- "password-reset-request": {
16501
- "type": "object",
16502
- "properties": {
16503
- "email": {
16504
- "type": "string",
16505
- "format": "email",
16506
- "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
16507
- }
16508
- },
16509
- "required": [
16510
- "email"
16511
- ]
16512
- },
16513
17764
  "patch-app-oidc-provider-request": {
16514
17765
  "type": "object",
16515
17766
  "properties": {
@@ -16636,14 +17887,16 @@
16636
17887
  "type": "object",
16637
17888
  "properties": {
16638
17889
  "name": {
17890
+ "description": "The organisation's new display name. Absent leaves it alone.",
16639
17891
  "type": "string",
16640
17892
  "minLength": 1,
16641
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"
16642
17898
  }
16643
17899
  },
16644
- "required": [
16645
- "name"
16646
- ],
16647
17900
  "additionalProperties": false
16648
17901
  },
16649
17902
  "patch-org-response": {
@@ -16664,6 +17917,10 @@
16664
17917
  "maxLength": 120,
16665
17918
  "description": "The organisation's display name. Free text, changed through `PATCH /api/org`."
16666
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
+ },
16667
17924
  "created_at": {
16668
17925
  "type": "string",
16669
17926
  "format": "date-time",
@@ -16674,6 +17931,7 @@
16674
17931
  "required": [
16675
17932
  "id",
16676
17933
  "name",
17934
+ "require_two_factor",
16677
17935
  "created_at"
16678
17936
  ],
16679
17937
  "additionalProperties": false,
@@ -16865,29 +18123,37 @@
16865
18123
  "message"
16866
18124
  ]
16867
18125
  },
16868
- "put-app-auth-mcp-request": {
18126
+ "put-app-auth-look-request": {
16869
18127
  "type": "object",
16870
18128
  "properties": {
16871
- "mcp_enabled": {
16872
- "type": "boolean",
16873
- "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."
16874
- },
16875
- "mcp_login_url": {
18129
+ "hosted_accent": {
16876
18130
  "anyOf": [
16877
18131
  {
16878
18132
  "type": "string",
16879
- "maxLength": 500
18133
+ "pattern": "^#[0-9a-f]{6}$"
16880
18134
  },
16881
18135
  {
16882
18136
  "type": "null"
16883
18137
  }
16884
18138
  ],
16885
- "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."
16886
18140
  }
16887
18141
  },
16888
18142
  "required": [
16889
- "mcp_enabled",
16890
- "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"
16891
18157
  ],
16892
18158
  "additionalProperties": false
16893
18159
  },
@@ -16926,9 +18192,60 @@
16926
18192
  ],
16927
18193
  "additionalProperties": false
16928
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"
18230
+ ],
18231
+ "additionalProperties": false
18232
+ },
16929
18233
  "put-app-auth-urls-request": {
16930
18234
  "type": "object",
16931
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
+ },
16932
18249
  "invite_url": {
16933
18250
  "anyOf": [
16934
18251
  {
@@ -16939,7 +18256,7 @@
16939
18256
  "type": "null"
16940
18257
  }
16941
18258
  ],
16942
- "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."
16943
18260
  },
16944
18261
  "verify_url": {
16945
18262
  "anyOf": [
@@ -16951,7 +18268,7 @@
16951
18268
  "type": "null"
16952
18269
  }
16953
18270
  ],
16954
- "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."
16955
18272
  },
16956
18273
  "reset_url": {
16957
18274
  "anyOf": [
@@ -16963,13 +18280,27 @@
16963
18280
  "type": "null"
16964
18281
  }
16965
18282
  ],
16966
- "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."
16967
18296
  }
16968
18297
  },
16969
18298
  "required": [
18299
+ "app_url",
16970
18300
  "invite_url",
16971
18301
  "verify_url",
16972
- "reset_url"
18302
+ "reset_url",
18303
+ "mcp_login_url"
16973
18304
  ],
16974
18305
  "additionalProperties": false
16975
18306
  },
@@ -17101,6 +18432,25 @@
17101
18432
  ],
17102
18433
  "additionalProperties": false
17103
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
+ },
17104
18454
  "refresh-request": {
17105
18455
  "type": "object",
17106
18456
  "properties": {
@@ -17113,6 +18463,21 @@
17113
18463
  "refresh_token"
17114
18464
  ]
17115
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
+ },
17116
18481
  "rename-slug-request": {
17117
18482
  "type": "object",
17118
18483
  "properties": {
@@ -17906,7 +19271,7 @@
17906
19271
  },
17907
19272
  "builtin": {
17908
19273
  "type": "boolean",
17909
- "description": "`true` for the two roles every app starts with. Their **rights may be re-scoped** exactly like a custom role's, through `PUT /api/apps/:id/roles/:roleId/permissions` — the flag exists so the console can explain where they came from, not to protect them. Built-in roles can be renamed and deleted like any other; the flag only records that the cloud seeded them."
19274
+ "description": "`true` for the two roles every app starts with. Their **rights may be re-scoped** exactly like a custom role's, through `PUT /api/apps/:id/roles/:roleId/permissions` — the flag exists so the console can explain where they came from, not to protect them. It does not make them renamable or deletable — no route does that for any role."
17910
19275
  }
17911
19276
  },
17912
19277
  "required": [
@@ -17945,7 +19310,7 @@
17945
19310
  },
17946
19311
  "builtin": {
17947
19312
  "type": "boolean",
17948
- "description": "`true` for the two roles every app starts with. Their **rights may be re-scoped** exactly like a custom role's, through `PUT /api/apps/:id/roles/:roleId/permissions` — the flag exists so the console can explain where they came from, not to protect them. Built-in roles can be renamed and deleted like any other; the flag only records that the cloud seeded them."
19313
+ "description": "`true` for the two roles every app starts with. Their **rights may be re-scoped** exactly like a custom role's, through `PUT /api/apps/:id/roles/:roleId/permissions` — the flag exists so the console can explain where they came from, not to protect them. It does not make them renamable or deletable — no route does that for any role."
17949
19314
  }
17950
19315
  },
17951
19316
  "required": [
@@ -18024,21 +19389,6 @@
18024
19389
  "capabilities"
18025
19390
  ]
18026
19391
  },
18027
- "role-rename-request": {
18028
- "type": "object",
18029
- "properties": {
18030
- "name": {
18031
- "type": "string",
18032
- "minLength": 1,
18033
- "maxLength": 60,
18034
- "description": "The new name, trimmed, 1 to 60 characters. Unique per app: another role of this app with the same name answers `409 role_name_taken`. The role's users keep it under its new name."
18035
- }
18036
- },
18037
- "required": [
18038
- "name"
18039
- ],
18040
- "additionalProperties": false
18041
- },
18042
19392
  "server-key-list-response": {
18043
19393
  "type": "object",
18044
19394
  "properties": {
@@ -18129,157 +19479,6 @@
18129
19479
  ],
18130
19480
  "additionalProperties": false
18131
19481
  },
18132
- "sign-up-request": {
18133
- "type": "object",
18134
- "properties": {
18135
- "org_name": {
18136
- "type": "string",
18137
- "minLength": 1,
18138
- "maxLength": 120
18139
- },
18140
- "email": {
18141
- "type": "string",
18142
- "format": "email",
18143
- "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
18144
- },
18145
- "password": {
18146
- "type": "string",
18147
- "minLength": 12,
18148
- "maxLength": 256
18149
- }
18150
- },
18151
- "required": [
18152
- "org_name",
18153
- "email",
18154
- "password"
18155
- ]
18156
- },
18157
- "sign-up-response": {
18158
- "type": "object",
18159
- "properties": {
18160
- "org": {
18161
- "type": "object",
18162
- "properties": {
18163
- "id": {
18164
- "type": "string",
18165
- "format": "uuid",
18166
- "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)$",
18167
- "description": "The organisation. Every developer route is scoped to the caller's org already, so a client rarely has to send this anywhere."
18168
- },
18169
- "name": {
18170
- "type": "string",
18171
- "minLength": 1,
18172
- "maxLength": 120,
18173
- "description": "The organisation's display name. Free text, changed through `PATCH /api/org`."
18174
- },
18175
- "created_at": {
18176
- "type": "string",
18177
- "format": "date-time",
18178
- "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))$",
18179
- "description": "When the organisation was created, as an ISO 8601 timestamp."
18180
- }
18181
- },
18182
- "required": [
18183
- "id",
18184
- "name",
18185
- "created_at"
18186
- ],
18187
- "additionalProperties": false
18188
- },
18189
- "user": {
18190
- "type": "object",
18191
- "properties": {
18192
- "id": {
18193
- "type": "string",
18194
- "format": "uuid",
18195
- "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)$",
18196
- "description": "The Fleetless user in the API, assigned by the cloud and stable for the life of the account."
18197
- },
18198
- "org_id": {
18199
- "type": "string",
18200
- "format": "uuid",
18201
- "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)$",
18202
- "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."
18203
- },
18204
- "email": {
18205
- "type": "string",
18206
- "format": "email",
18207
- "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$",
18208
- "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."
18209
- },
18210
- "display_name": {
18211
- "anyOf": [
18212
- {
18213
- "type": "string",
18214
- "minLength": 1,
18215
- "maxLength": 120
18216
- },
18217
- {
18218
- "type": "null"
18219
- }
18220
- ],
18221
- "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."
18222
- },
18223
- "tier": {
18224
- "type": "string",
18225
- "enum": [
18226
- "owner",
18227
- "developer"
18228
- ],
18229
- "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."
18230
- },
18231
- "created_at": {
18232
- "type": "string",
18233
- "format": "date-time",
18234
- "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))$",
18235
- "description": "When the account was created, as an ISO 8601 timestamp."
18236
- }
18237
- },
18238
- "required": [
18239
- "id",
18240
- "org_id",
18241
- "email",
18242
- "display_name",
18243
- "tier",
18244
- "created_at"
18245
- ],
18246
- "additionalProperties": false
18247
- },
18248
- "tokens": {
18249
- "type": "object",
18250
- "properties": {
18251
- "access_token": {
18252
- "type": "string",
18253
- "minLength": 1,
18254
- "description": "The token to send as `Authorization: Bearer <token>` on every call. Short-lived: read `expires_in` rather than assuming a lifetime."
18255
- },
18256
- "refresh_token": {
18257
- "type": "string",
18258
- "minLength": 1,
18259
- "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."
18260
- },
18261
- "expires_in": {
18262
- "type": "integer",
18263
- "exclusiveMinimum": 0,
18264
- "maximum": 9007199254740991,
18265
- "description": "How long the access token stays valid, in **seconds** from now. Not a timestamp, and not milliseconds."
18266
- }
18267
- },
18268
- "required": [
18269
- "access_token",
18270
- "refresh_token",
18271
- "expires_in"
18272
- ],
18273
- "additionalProperties": false
18274
- }
18275
- },
18276
- "required": [
18277
- "org",
18278
- "user",
18279
- "tokens"
18280
- ],
18281
- "additionalProperties": false
18282
- },
18283
19482
  "slug-usage-response": {
18284
19483
  "type": "object",
18285
19484
  "properties": {
@@ -18467,6 +19666,66 @@
18467
19666
  ],
18468
19667
  "additionalProperties": false
18469
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
+ },
18470
19729
  "types-response": {
18471
19730
  "type": "object",
18472
19731
  "properties": {
@@ -18664,6 +19923,23 @@
18664
19923
  "required": [
18665
19924
  "email"
18666
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
18667
19943
  }
18668
19944
  }
18669
19945
  }