@fleetless/contracts 5.2.0 → 6.0.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/artifacts/openapi.json +1975 -415
  3. package/artifacts/routes.json +1697 -251
  4. package/artifacts/schema/accept-team-invite-request.schema.json +13 -7
  5. package/artifacts/schema/app-auth-config.schema.json +108 -4
  6. package/artifacts/schema/app-hosted-pages.schema.json +33 -0
  7. package/artifacts/schema/app-invitation.schema.json +2 -2
  8. package/artifacts/schema/app-mail-template-list-response.schema.json +4 -3
  9. package/artifacts/schema/app-mail-template.schema.json +3 -2
  10. package/artifacts/schema/app-sign-in-methods.schema.json +19 -0
  11. package/artifacts/schema/app-user-list-response.schema.json +36 -0
  12. package/artifacts/schema/app-user.schema.json +36 -0
  13. package/artifacts/schema/auth-me-response.schema.json +27 -0
  14. package/artifacts/schema/auth-ok.schema.json +13 -1
  15. package/artifacts/schema/client-accept-invitation-request.schema.json +3 -4
  16. package/artifacts/schema/client-identity.schema.json +13 -1
  17. package/artifacts/schema/client-login-code-request.schema.json +24 -0
  18. package/artifacts/schema/client-login-code-verify-request.schema.json +30 -0
  19. package/artifacts/schema/client-provider-list-response.schema.json +22 -2
  20. package/artifacts/schema/client-register-request.schema.json +3 -4
  21. package/artifacts/schema/client-sign-in-result.schema.json +55 -0
  22. package/artifacts/schema/client-two-factor-disable-request.schema.json +15 -0
  23. package/artifacts/schema/client-two-factor-setup-confirm-request.schema.json +20 -0
  24. package/artifacts/schema/client-two-factor-setup-confirm-response.schema.json +49 -0
  25. package/artifacts/schema/client-two-factor-setup-request.schema.json +12 -0
  26. package/artifacts/schema/client-two-factor-verify-request.schema.json +25 -0
  27. package/artifacts/schema/create-app-invitation-request.schema.json +1 -1
  28. package/artifacts/schema/create-passkey-request.schema.json +25 -0
  29. package/artifacts/schema/create-passkey-response.schema.json +84 -0
  30. package/artifacts/schema/developer-passkey.schema.json +56 -0
  31. package/artifacts/schema/developer-two-factor.schema.json +105 -0
  32. package/artifacts/schema/fleetless-user-list-response.schema.json +22 -0
  33. package/artifacts/schema/fleetless-user.schema.json +22 -0
  34. package/artifacts/schema/invalid-code-details.schema.json +16 -0
  35. package/artifacts/schema/org.schema.json +5 -0
  36. package/artifacts/schema/patch-org-request.schema.json +5 -3
  37. package/artifacts/schema/patch-org-response.schema.json +5 -0
  38. package/artifacts/schema/put-app-auth-look-request.schema.json +22 -0
  39. package/artifacts/schema/put-app-auth-mcp-request.schema.json +1 -14
  40. package/artifacts/schema/put-app-auth-sign-in-request.schema.json +39 -0
  41. package/artifacts/schema/put-app-auth-urls-request.schema.json +31 -4
  42. package/artifacts/schema/recovery-codes-response.schema.json +20 -0
  43. package/artifacts/schema/rename-passkey-request.schema.json +16 -0
  44. package/artifacts/schema/totp-confirm-request.schema.json +15 -0
  45. package/artifacts/schema/totp-confirm-response.schema.json +27 -0
  46. package/artifacts/schema/two-factor-challenge.schema.json +24 -0
  47. package/artifacts/schema/two-factor-setup-response.schema.json +21 -0
  48. package/artifacts/schema/webauthn-options-response.schema.json +18 -0
  49. package/dist/app-users.d.ts +154 -34
  50. package/dist/app-users.js +177 -45
  51. package/dist/client-auth.d.ts +133 -24
  52. package/dist/client-auth.js +139 -28
  53. package/dist/config.d.ts +2 -2
  54. package/dist/errors.d.ts +10 -1
  55. package/dist/errors.js +34 -5
  56. package/dist/identity.d.ts +172 -123
  57. package/dist/identity.js +189 -101
  58. package/dist/index.d.ts +9 -9
  59. package/dist/index.js +11 -7
  60. package/dist/protocol.d.ts +1 -1
  61. package/dist/realtime.d.ts +1 -0
  62. package/dist/rest.d.ts +2 -2
  63. package/dist/rest.js +17 -28
  64. package/dist/routes.d.ts +19 -0
  65. package/dist/routes.js +497 -183
  66. package/package.json +1 -1
  67. package/artifacts/schema/developer-login-request.schema.json +0 -19
  68. package/artifacts/schema/password-reset-confirm.schema.json +0 -19
  69. package/artifacts/schema/password-reset-request.schema.json +0 -14
  70. package/artifacts/schema/sign-up-request.schema.json +0 -26
  71. package/artifacts/schema/sign-up-response.schema.json +0 -127
package/dist/app-users.js CHANGED
@@ -15,19 +15,22 @@ import { idpIssuer, mailStatus, password } from './identity.js';
15
15
  * So there are now **two identity spaces and nothing joins them**:
16
16
  *
17
17
  * - *Fleetless users* (`identity.ts`) — the org's team. Email globally unique,
18
- * tier `owner | developer`, Fleetless password, console access.
18
+ * tier `owner | developer`, sign-in by emailed code or passkey, console
19
+ * access.
19
20
  * - *app users* (this file) — one app each. Email unique **per app**,
20
21
  * case-insensitively. The same address may exist in several apps of one org
21
22
  * as unrelated accounts, and a Fleetless user who wants to use an app
22
23
  * registers or is invited like anybody else.
23
24
  *
24
- * **Fleetless shows an app user no page**. The developer's own UI owns
25
- * every screen and calls the JSON client-auth API (`client-auth.ts`). The one
26
- * Fleetless-rendered surface an app user can reach is the problem page for an
27
- * OIDC callback whose state no longer resolves to a redirect URI — every other
28
- * error is redirected to the app to render. That is why the four URLs on
29
- * `appAuthConfig` exist: Fleetless mails a link, and the link points into the
30
- * app.
25
+ * **The developer's own UI owns every screen it wants to own**, and calls the
26
+ * JSON client-auth API (`client-auth.ts`). Fleetless mails a link, and the
27
+ * link points into the app — at the four URLs on `appAuthConfig`. **A URL the
28
+ * app leaves unset falls back to a Fleetless-hosted page** on the auth portal
29
+ * (`hosted_pages`), carrying the app's name, its optional logo and accent
30
+ * colour, so an app works from its first minute: with no web UI of its own,
31
+ * and while its pages point nowhere yet. A set URL always wins. The hosted
32
+ * pages cover mailed links and MCP sign-in only; they are not a hosted login
33
+ * for the app's own web UI.
31
34
  */
32
35
  /** App-user display names share the Fleetless-user bound, so a rename cannot be legal in one space and refused in the other. */
33
36
  export const APP_USER_DISPLAY_NAME_MAX = 120;
@@ -103,6 +106,21 @@ export const appUser = z.object({
103
106
  last_login_at: z.iso.datetime().nullable().meta({
104
107
  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*.',
105
108
  }),
109
+ two_factor: z
110
+ .object({
111
+ enabled: z.boolean().meta({
112
+ 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.',
113
+ }),
114
+ enabled_at: z.iso.datetime().nullable().meta({
115
+ description: 'When the authenticator was confirmed, or `null` while `enabled` is `false`.',
116
+ }),
117
+ recovery_codes_left: z.number().int().min(0).max(10).meta({
118
+ description: 'How many of the ten single-use recovery codes are still unspent. `0` while `enabled` is `false`.',
119
+ }),
120
+ })
121
+ .meta({
122
+ 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`.',
123
+ }),
106
124
  created_at: z.iso.datetime().meta({
107
125
  description: 'When the account was created, as an ISO 8601 timestamp.',
108
126
  }),
@@ -183,19 +201,18 @@ export const createAppInvitationRequest = z
183
201
  description: 'An optional name to pre-fill the account with; the invitee can change it afterwards.',
184
202
  }),
185
203
  send_mail: z.boolean().meta({
186
- 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.',
204
+ 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.',
187
205
  }),
188
206
  })
189
207
  .strict();
190
208
  /**
191
209
  * The invitation as issued.
192
210
  *
193
- * **`accept_url` is nullable, and that is a policy rather than a convenience.**
194
- * The link points into the developer's app, at their configured `invite_url`.
195
- * An app that has configured none has nowhere for it to point, so there is no
196
- * link to hand back — `null` says that outright, where an absent key would be
197
- * indistinguishable from a mapper that dropped the field and a fabricated
198
- * Fleetless-hosted URL would name a page this product does not serve.
211
+ * The link points into the developer's app, at their configured `invite_url`,
212
+ * or at the Fleetless-hosted invitation page (`appAuthConfig.hosted_pages`)
213
+ * when the app has configured none. **`accept_url` stays nullable** so a
214
+ * reader written against the earlier shape keeps parsing; the cloud fills it
215
+ * in every case now that a hosted page always exists.
199
216
  */
200
217
  export const appInvitation = z.object({
201
218
  id: z.uuid().meta({ description: 'The invitation, as listed and revoked by the developer.' }),
@@ -204,10 +221,10 @@ export const appInvitation = z.object({
204
221
  role_id: z.uuid().meta({ description: 'The role the invitee holds once they accept. Resolved at creation, so a later change to the app\'s default role does not silently re-aim an outstanding invitation.' }),
205
222
  expires_at: z.iso.datetime().meta({ description: 'When the token stops working. Seven days from issue; an expired token answers exactly as an unknown one does.' }),
206
223
  accept_url: z.url().max(500).nullable().meta({
207
- 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.',
224
+ 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.',
208
225
  }),
209
226
  mail: mailStatus.meta({
210
- 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.',
227
+ 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.',
211
228
  }),
212
229
  });
213
230
  /**
@@ -435,6 +452,77 @@ export const emailDomain = z
435
452
  .min(1)
436
453
  .max(253)
437
454
  .regex(/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/, 'must be a lowercase domain name with at least two labels');
455
+ /**
456
+ * **How an app's users sign in: by password, by emailed code, or both.**
457
+ *
458
+ * At least one is on — an app with neither would have no door but its
459
+ * identity providers, and a provider can be disabled. Identity providers are
460
+ * not part of this choice; they stay on top of whichever methods are on. A
461
+ * method turned off refuses its routes with `method_not_allowed`; a stored
462
+ * password stays stored, so turning the method back on restores it.
463
+ */
464
+ export const appSignInMethods = z
465
+ .object({
466
+ password: z.boolean().meta({
467
+ 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.',
468
+ }),
469
+ email_code: z.boolean().meta({
470
+ 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.',
471
+ }),
472
+ })
473
+ .strict()
474
+ .refine((m) => m.password || m.email_code, { message: 'At least one sign-in method must be on.', path: ['password'] });
475
+ /**
476
+ * **Whether an app asks its users for a second factor**, an authenticator
477
+ * app (TOTP) with ten single-use recovery codes.
478
+ *
479
+ * - `off` — nobody is asked to set one up, and nobody can.
480
+ * - `optional` — people turn it on in the app's own account settings.
481
+ * - `required` — a person without one sets it up at their next sign-in,
482
+ * before any session exists. Nobody is signed out when it is switched on.
483
+ *
484
+ * Whatever the policy, a person who **has** a confirmed authenticator is asked
485
+ * for a code at every sign-in that yields a session. A sign-in through an
486
+ * identity provider is never asked: the provider owns that sign-in.
487
+ */
488
+ export const appTwoFactorPolicy = z.enum(['off', 'optional', 'required']);
489
+ /**
490
+ * **The app's own home page**: https, or http on `localhost`/`127.0.0.1` —
491
+ * the host rule `appUrlTemplate` keeps — and no placeholder, because nothing
492
+ * is substituted into it. The hosted pages link to it as `Open <app>` when a
493
+ * flow is done.
494
+ */
495
+ export const appHomeUrl = z
496
+ .url()
497
+ .max(500)
498
+ .refine((v) => {
499
+ try {
500
+ const u = new URL(v);
501
+ const hostOk = u.protocol === 'https:' || (u.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(u.hostname));
502
+ return hostOk && !v.includes('{');
503
+ }
504
+ catch {
505
+ return false;
506
+ }
507
+ }, { message: 'An https URL (http only on localhost) without a placeholder.' });
508
+ /** The accent colour of the hosted pages: `#rrggbb`, lowercase, the one spelling the renderer compares against. */
509
+ export const hostedAccent = z.string().regex(/^#[0-9a-f]{6}$/, 'must be a lowercase hex colour, #rrggbb');
510
+ /** The largest logo the hosted pages take, in bytes: 100 KB. */
511
+ export const HOSTED_LOGO_MAX_BYTES = 102_400;
512
+ /** The logo types the hosted pages take. An SVG is served sandboxed and embedded only as an image. */
513
+ export const HOSTED_LOGO_TYPES = ['image/png', 'image/svg+xml'];
514
+ /**
515
+ * **The Fleetless-hosted pages an unset URL falls back to**, one per URL
516
+ * field, as templates with the same placeholder the field takes. Read-only:
517
+ * they are minted by the cloud from the auth portal's base URL and the app's
518
+ * identifier.
519
+ */
520
+ export const appHostedPages = z.object({
521
+ invite_url: z.url().meta({ description: 'The hosted invitation page, `<portal>/app/<identifier>/invite/{token}`.' }),
522
+ verify_url: z.url().meta({ description: 'The hosted email-confirmation page, `<portal>/app/<identifier>/verify/{token}`.' }),
523
+ reset_url: z.url().meta({ description: 'The hosted new-password page, `<portal>/app/<identifier>/reset/{token}`.' }),
524
+ mcp_login_url: z.url().meta({ description: 'The hosted MCP sign-in, `<portal>/app/<identifier>/mcp/{interaction}`.' }),
525
+ });
438
526
  /**
439
527
  * **The app's auth settings: one row per app, configured by a Fleetless user.**
440
528
  *
@@ -444,11 +532,11 @@ export const emailDomain = z
444
532
  * a developer inviting somebody by hand has already made the decision the
445
533
  * whitelist automates.
446
534
  *
447
- * The four URLs are what makes that work: Fleetless mails a link, and the link
448
- * points into the developer's app. An app that has configured none of them
449
- * still works for password login — it simply cannot send a mail that leads
450
- * anywhere, and `send_mail` is refused rather than silently sending a dead
451
- * link.
535
+ * The four URLs point Fleetless's mails and the MCP sign-in into the
536
+ * developer's app. **Each is optional**: an unset one falls back to the
537
+ * Fleetless-hosted page in `hosted_pages`, so nothing is refused for a
538
+ * missing URL — self-registration, mailed invitations, resets and MCP sign-in
539
+ * all work before the app has a page of its own.
452
540
  */
453
541
  export const appAuthConfig = z.object({
454
542
  self_registration: z.boolean().meta({
@@ -464,16 +552,34 @@ export const appAuthConfig = z.object({
464
552
  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.',
465
553
  }),
466
554
  invite_url: appUrlTemplate('{token}').nullable().meta({
467
- 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.',
555
+ 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.',
468
556
  }),
469
557
  verify_url: appUrlTemplate('{token}').nullable().meta({
470
- 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.',
558
+ description: 'The page that confirms a new address, with `{token}` where the token goes. `null` means the hosted page in `hosted_pages` is used.',
471
559
  }),
472
560
  reset_url: appUrlTemplate('{token}').nullable().meta({
473
- description: 'The page that takes a new password, with `{token}` where the token goes.',
561
+ description: 'The page that takes a new password, with `{token}` where the token goes. `null` means the hosted page in `hosted_pages` is used.',
474
562
  }),
475
563
  mcp_login_url: appUrlTemplate('{interaction}').nullable().meta({
476
- 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.',
564
+ 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.',
565
+ }),
566
+ app_url: appHomeUrl.nullable().meta({
567
+ 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`.',
568
+ }),
569
+ sign_in_methods: appSignInMethods.meta({
570
+ 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.',
571
+ }),
572
+ two_factor: appTwoFactorPolicy.meta({
573
+ 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.',
574
+ }),
575
+ hosted_logo_url: z.url().nullable().meta({
576
+ 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`.',
577
+ }),
578
+ hosted_accent: hostedAccent.nullable().meta({
579
+ description: 'The accent colour of the hosted pages, `#rrggbb` in lowercase, or `null` for the neutral shell\'s own.',
580
+ }),
581
+ hosted_pages: appHostedPages.meta({
582
+ 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.',
477
583
  }),
478
584
  oidc_callback_url: z.url().meta({
479
585
  description: 'The one callback URL to register at every identity provider, the same for every app and every provider. **Read-only** — it is minted by the cloud from its own public base URL, and a writable version of this field would let a caller point the return leg, which carries an authorization code, at a host they own.',
@@ -484,8 +590,8 @@ export const appAuthConfig = z.object({
484
590
  * `PUT /api/apps/:id/auth-config/registration` — who may get in, and from
485
591
  * where.
486
592
  *
487
- * Three slices rather than one document, and each still a **replace** with
488
- * every field of its slice required: three screens carving up one
593
+ * Several slices rather than one document, and each still a **replace** with
594
+ * every field of its slice required: several screens carving up one
489
595
  * all-required request is how a field nobody's screen shows becomes a field
490
596
  * somebody's save clears. The slice states its own ownership, so a new field
491
597
  * lands in one schema and one screen.
@@ -496,25 +602,39 @@ export const appAuthConfig = z.object({
496
602
  export const putAppAuthRegistrationRequest = appAuthConfig
497
603
  .pick({ self_registration: true, allowed_domains: true, allowed_origins: true })
498
604
  .strict();
499
- /** `PUT /api/apps/:id/auth-config/urls` — the three pages Fleetless's mails point at. */
605
+ /** `PUT /api/apps/:id/auth-config/sign-in` — how the app's users sign in, and whether they give a second factor. */
606
+ export const putAppAuthSignInRequest = appAuthConfig
607
+ .pick({ sign_in_methods: true, two_factor: true })
608
+ .strict();
609
+ /**
610
+ * `PUT /api/apps/:id/auth-config/urls` — the app's home page and the four
611
+ * pages Fleetless's mails and the MCP sign-in point at. One slice, because
612
+ * the console's Pages section owns all five; each `null` falls back to the
613
+ * hosted page.
614
+ */
500
615
  export const putAppAuthUrlsRequest = appAuthConfig
501
- .pick({ invite_url: true, verify_url: true, reset_url: true })
616
+ .pick({ app_url: true, invite_url: true, verify_url: true, reset_url: true, mcp_login_url: true })
502
617
  .strict();
503
618
  /**
504
- * `PUT /api/apps/:id/auth-config/mcp` — the switch and the login URL, which
505
- * belong together: on without a URL refuses every sign-in, in the MCP
506
- * client's browser mid-OAuth, where no console screen ever sees it.
619
+ * `PUT /api/apps/:id/auth-config/mcp` — the switch alone. Its login URL moved
620
+ * to the `urls` slice: on without a URL no longer refuses anything, because
621
+ * the hosted MCP sign-in stands in for it.
507
622
  */
508
623
  export const putAppAuthMcpRequest = appAuthConfig
509
- .pick({ mcp_enabled: true, mcp_login_url: true })
624
+ .pick({ mcp_enabled: true })
625
+ .strict();
626
+ /** `PUT /api/apps/:id/auth-config/look` — the hosted pages' accent colour. The logo is its own write, a raw image body. */
627
+ export const putAppAuthLookRequest = appAuthConfig
628
+ .pick({ hosted_accent: true })
510
629
  .strict();
511
630
  /**
512
- * The three mails a developer may replace with their own template.
513
- * Mails to *Fleetless* users — a team invitation, a console password reset —
514
- * stay Fleetless default and are deliberately not customisable: they are
515
- * about this platform, not about the developer's product.
631
+ * The four mails a developer may replace with their own template: the
632
+ * invitation, the address confirmation, the password reset and the sign-in
633
+ * code. Mails to *Fleetless* users — a team invitation, a console sign-in
634
+ * code — stay Fleetless default and are deliberately not customisable: they
635
+ * are about this platform, not about the developer's product.
516
636
  */
517
- export const mailTemplateKind = z.enum(['invite', 'verify', 'reset']);
637
+ export const mailTemplateKind = z.enum(['invite', 'verify', 'reset', 'login_code']);
518
638
  /**
519
639
  * **Every variable a template may name, and the list is closed.**
520
640
  *
@@ -532,9 +652,11 @@ export const MAIL_TEMPLATE_VARIABLES = [
532
652
  'role.name',
533
653
  'link',
534
654
  'expires_in_hours',
655
+ 'code',
656
+ 'expires_in_minutes',
535
657
  ];
536
658
  /**
537
- * **The Fleetless default text for the three app mails.**
659
+ * **The Fleetless default text for the four app mails.**
538
660
  *
539
661
  * It lives here rather than in the cloud because two products send the same
540
662
  * words: the cloud renders these when an app has no template of its own, and
@@ -568,12 +690,15 @@ export const MAIL_TEMPLATE_VARIABLES = [
568
690
  * that one is written by an authenticated developer about somebody they
569
691
  * invited.
570
692
  *
571
- * **`expires_in_hours` is the only lifetime variable a template gets**, and
693
+ * **`expires_in_hours` is the lifetime variable of the three link mails**, and
572
694
  * the three values are 1, 24 and 168. "The next 168 hours" is not how a person
573
695
  * says a week, so each default converts: 48 and up reads in days, exactly one
574
696
  * reads "1 hour", everything else reads in hours. The conversion is in the
575
697
  * template rather than in a new variable because a custom template has the
576
- * same problem and this is the spelling it can copy.
698
+ * same problem and this is the spelling it can copy. The sign-in code mail
699
+ * carries no link: it names the `code` and its lifetime in
700
+ * `expires_in_minutes` (10), and greets nobody, since whoever asked for it
701
+ * typed the address unauthenticated.
577
702
  *
578
703
  * **What contracts does NOT assert about these.** That they compile as Liquid
579
704
  * is the cloud's business — contracts has no renderer and adding one to check
@@ -588,7 +713,7 @@ export const DEFAULT_MAIL_TEMPLATES = {
588
713
 
589
714
  {{ org.name }} has invited you to {{ app.name }} as {{ role.name }}.
590
715
 
591
- Accept the invitation and choose a password:
716
+ Accept the invitation:
592
717
  {{ link }}
593
718
 
594
719
  The link works for the next {% if expires_in_hours >= 48 %}{{ expires_in_hours | divided_by: 24 }} days{% elsif expires_in_hours == 1 %}1 hour{% else %}{{ expires_in_hours }} hours{% endif %}. If you were not expecting this invitation, ignore this mail — no account is created until you accept.
@@ -619,6 +744,13 @@ The link works for the next {% if expires_in_hours >= 48 %}{{ expires_in_hours |
619
744
  `,
620
745
  html: null,
621
746
  },
747
+ login_code: {
748
+ subject: 'Your {{ app.name }} sign-in code',
749
+ text: `Your sign-in code for {{ app.name }} is {{ code }}.
750
+
751
+ It works once, for {{ expires_in_minutes }} minutes. If you did not ask for it, ignore this mail.`,
752
+ html: null,
753
+ },
622
754
  };
623
755
  /**
624
756
  * One stored template. `html` is nullable because the mailer's HTML part is
@@ -626,7 +758,7 @@ The link works for the next {% if expires_in_hours >= 48 %}{{ expires_in_hours |
626
758
  * should not have to write the same words twice.
627
759
  */
628
760
  export const appMailTemplate = z.object({
629
- kind: mailTemplateKind.meta({ description: 'Which of the three mails this template replaces.' }),
761
+ kind: mailTemplateKind.meta({ description: 'Which of the four mails this template replaces.' }),
630
762
  subject: z.string().min(1).max(200).meta({ description: 'The subject line, a Liquid template. Bounded because a subject is rendered into a header.' }),
631
763
  text: z.string().min(1).max(20_000).meta({ description: 'The plain-text body, a Liquid template. Required even when an HTML part is given: a mail with no text part is unreadable to a client that refuses HTML.' }),
632
764
  html: z.string().min(1).max(100_000).nullable().meta({ description: 'The optional HTML body, a Liquid template. `null` means this template is text-only, which is a complete mail and not a half-configured one.' }),
@@ -634,7 +766,7 @@ export const appMailTemplate = z.object({
634
766
  });
635
767
  /** `GET /api/apps/:id/mail-templates` — **only the kinds that have a custom template.** An absent kind is one using the Fleetless default, which is a state and not a gap. */
636
768
  export const appMailTemplateListResponse = z.object({
637
- templates: z.array(appMailTemplate).max(3).meta({
769
+ templates: z.array(appMailTemplate).max(4).meta({
638
770
  description: 'The app\'s custom templates. A kind that does not appear is one using the Fleetless default text — an ordinary state, not a missing row.',
639
771
  }),
640
772
  });
@@ -3,11 +3,12 @@ import { z } from 'zod';
3
3
  /**
4
4
  * **The client auth API: the whole of what an app user's browser talks to.**
5
5
  *
6
- * Fleetless shows an app user **no page**. The developer's own UI owns
7
- * every screen — login, registration, verification, invitation acceptance,
6
+ * The developer's own UI owns every screen it wants to — login, emailed
7
+ * code, second factor, registration, verification, invitation acceptance,
8
8
  * password reset, the provider buttons, the MCP consent — and calls these
9
- * routes as JSON. The hosted, app-branded login and consent pages this file
10
- * used to describe are deleted.
9
+ * routes as JSON. **Where the app sets no URL, a Fleetless-hosted page on the
10
+ * auth portal stands in** (`appAuthConfig.hosted_pages`) and calls the same
11
+ * policy: one set of rules, whichever page drives it.
11
12
  *
12
13
  * Everything here is public: `app_identifier` travels in the body (in the
13
14
  * query for a GET), CORS is answered only for the app's `allowed_origins`, and
@@ -24,12 +25,19 @@ import { z } from 'zod';
24
25
  * the SDK and the cloud cannot drift into disagreeing about it.
25
26
  *
26
27
  * **The enumeration discipline is deliberate, not a preference:**
27
- * `register`, `resend-verification` and `password/reset` answer `202` for every
28
- * policy-allowed request whether or not the address exists, and `login` answers
29
- * the identical `invalid_credentials` for a wrong password, a `blocked` account
30
- * and a `pending_verification` one. Policy refusals are honest —
31
- * `registration_closed` and `domain_not_allowed` say what they are, because
32
- * neither reveals whether a *person* exists.
28
+ * `register`, `resend-verification`, `password/reset` and `login/code` answer
29
+ * `202` for every policy-allowed request whether or not the address exists,
30
+ * and `login` answers the identical `invalid_credentials` for a wrong
31
+ * password, a `blocked` account and a `pending_verification` one. Policy
32
+ * refusals are honest — `registration_closed`, `domain_not_allowed` and
33
+ * `method_not_allowed` say what they are, because none reveals whether a
34
+ * *person* exists.
35
+ *
36
+ * **Every step that signs somebody in answers a `clientSignInResult`**:
37
+ * session tokens, or a two-factor challenge when the person has a confirmed
38
+ * authenticator or the app requires one. No path yields a session without the
39
+ * second factor then — except a sign-in through an identity provider, which
40
+ * owns that sign-in and answers tokens as before.
33
41
  */
34
42
  export declare const clientLoginRequest: z.ZodObject<{
35
43
  app_identifier: z.ZodString;
@@ -59,6 +67,103 @@ export declare const clientLogoutRequest: z.ZodObject<{
59
67
  refresh_token: z.ZodString;
60
68
  }, z.core.$strip>;
61
69
  export type ClientLogoutRequest = z.infer<typeof clientLogoutRequest>;
70
+ /**
71
+ * **Asking for a sign-in code.** A six-digit code, mailed, valid ten minutes;
72
+ * a new request expires the previous code for the same address. The answer is
73
+ * `202` whether or not the address names an account — a decoy, like
74
+ * `resend-verification` — so this is no enumeration oracle.
75
+ */
76
+ export declare const clientLoginCodeRequest: z.ZodObject<{
77
+ app_identifier: z.ZodString;
78
+ email: z.ZodEmail;
79
+ }, z.core.$strict>;
80
+ export type ClientLoginCodeRequest = z.infer<typeof clientLoginCodeRequest>;
81
+ /** **Spending the code.** Five wrong attempts spend it; so does its tenth minute. */
82
+ export declare const clientLoginCodeVerifyRequest: z.ZodObject<{
83
+ app_identifier: z.ZodString;
84
+ email: z.ZodEmail;
85
+ code: z.ZodString;
86
+ }, z.core.$strict>;
87
+ export type ClientLoginCodeVerifyRequest = z.infer<typeof clientLoginCodeVerifyRequest>;
88
+ /**
89
+ * **A sign-in that needs its second factor first.** No session exists yet:
90
+ * the `challenge` is a short-lived handle, five minutes, that the next call
91
+ * spends.
92
+ *
93
+ * - `two_factor_required` — the person has an authenticator; send a code or
94
+ * a recovery code to `POST /api/client/two-factor/verify`.
95
+ * - `two_factor_setup_required` — the app requires two-factor and the person
96
+ * has none; set one up with `POST /api/client/two-factor/setup` and
97
+ * `…/setup/confirm`, which answers the session.
98
+ */
99
+ export declare const twoFactorChallenge: z.ZodObject<{
100
+ status: z.ZodEnum<{
101
+ two_factor_required: "two_factor_required";
102
+ two_factor_setup_required: "two_factor_setup_required";
103
+ }>;
104
+ challenge: z.ZodString;
105
+ }, z.core.$strip>;
106
+ export type TwoFactorChallenge = z.infer<typeof twoFactorChallenge>;
107
+ /**
108
+ * **What every session-minting sign-in step answers**: password login, code
109
+ * verify, and spending an invitation, verification or reset token. Session
110
+ * tokens when the sign-in is complete; a `twoFactorChallenge` when a second
111
+ * factor comes first. Tell them apart by `status`, which only the challenge
112
+ * carries.
113
+ */
114
+ export declare const clientSignInResult: z.ZodUnion<readonly [z.ZodObject<{
115
+ access_token: z.ZodString;
116
+ refresh_token: z.ZodString;
117
+ expires_in: z.ZodNumber;
118
+ }, z.core.$strip>, z.ZodObject<{
119
+ status: z.ZodEnum<{
120
+ two_factor_required: "two_factor_required";
121
+ two_factor_setup_required: "two_factor_setup_required";
122
+ }>;
123
+ challenge: z.ZodString;
124
+ }, z.core.$strip>]>;
125
+ export type ClientSignInResult = z.infer<typeof clientSignInResult>;
126
+ /** **Answering a `two_factor_required` challenge**, with the authenticator's code or one recovery code — exactly one of the two. */
127
+ export declare const clientTwoFactorVerifyRequest: z.ZodObject<{
128
+ challenge: z.ZodString;
129
+ code: z.ZodOptional<z.ZodString>;
130
+ recovery_code: z.ZodOptional<z.ZodString>;
131
+ }, z.core.$strict>;
132
+ export type ClientTwoFactorVerifyRequest = z.infer<typeof clientTwoFactorVerifyRequest>;
133
+ /**
134
+ * **Starting an authenticator setup.** Either during sign-in, with the
135
+ * `two_factor_setup_required` challenge in the body, or from the app's own
136
+ * account settings, with the app user's bearer and no challenge.
137
+ */
138
+ export declare const clientTwoFactorSetupRequest: z.ZodObject<{
139
+ challenge: z.ZodOptional<z.ZodString>;
140
+ }, z.core.$strict>;
141
+ export type ClientTwoFactorSetupRequest = z.infer<typeof clientTwoFactorSetupRequest>;
142
+ /** **Confirming the setup** with a code from the new authenticator. Only then is it on. */
143
+ export declare const clientTwoFactorSetupConfirmRequest: z.ZodObject<{
144
+ challenge: z.ZodOptional<z.ZodString>;
145
+ code: z.ZodString;
146
+ }, z.core.$strict>;
147
+ export type ClientTwoFactorSetupConfirmRequest = z.infer<typeof clientTwoFactorSetupConfirmRequest>;
148
+ /**
149
+ * **The confirmed setup**: the ten recovery codes, shown this once, and a
150
+ * session — during sign-in the first one, from account settings a fresh one,
151
+ * since every other session of the account ends.
152
+ */
153
+ export declare const clientTwoFactorSetupConfirmResponse: z.ZodObject<{
154
+ recovery_codes: z.ZodArray<z.ZodString>;
155
+ session: z.ZodObject<{
156
+ access_token: z.ZodString;
157
+ refresh_token: z.ZodString;
158
+ expires_in: z.ZodNumber;
159
+ }, z.core.$strip>;
160
+ }, z.core.$strip>;
161
+ export type ClientTwoFactorSetupConfirmResponse = z.infer<typeof clientTwoFactorSetupConfirmResponse>;
162
+ /** **Turning the authenticator off**, from the app's own account settings. A current code proves the person still holds it. */
163
+ export declare const clientTwoFactorDisableRequest: z.ZodObject<{
164
+ code: z.ZodString;
165
+ }, z.core.$strict>;
166
+ export type ClientTwoFactorDisableRequest = z.infer<typeof clientTwoFactorDisableRequest>;
62
167
  /**
63
168
  * **Self-registration** — and the account it creates cannot log in yet.
64
169
  *
@@ -81,7 +186,7 @@ export type ClientLogoutRequest = z.infer<typeof clientLogoutRequest>;
81
186
  export declare const clientRegisterRequest: z.ZodObject<{
82
187
  app_identifier: z.ZodString;
83
188
  email: z.ZodEmail;
84
- password: z.ZodString;
189
+ password: z.ZodOptional<z.ZodString>;
85
190
  display_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
86
191
  }, z.core.$strict>;
87
192
  export type ClientRegisterRequest = z.infer<typeof clientRegisterRequest>;
@@ -97,12 +202,10 @@ export declare const clientResendVerificationRequest: z.ZodObject<{
97
202
  }, z.core.$strict>;
98
203
  export type ClientResendVerificationRequest = z.infer<typeof clientResendVerificationRequest>;
99
204
  /**
100
- * Asking for a reset link **as an app user**.
101
- *
102
- * Same act as `passwordResetRequest`, different shape, because the two surfaces
103
- * identify a person differently. A Fleetless user's address is globally unique
104
- * and resolves alone; an app user's is unique only within their app, so the
105
- * pair is what names them.
205
+ * Asking for a reset link **as an app user** — the only password reset
206
+ * there is: Fleetless users hold no password. An app user's address is
207
+ * unique only within their app, so the pair of app and address is what names
208
+ * them.
106
209
  *
107
210
  * The response is identical for a known and an unknown pair — otherwise this
108
211
  * becomes the enumeration oracle the rest of the family is carefully built not
@@ -132,7 +235,7 @@ export type ClientPasswordResetConfirmRequest = z.infer<typeof clientPasswordRes
132
235
  */
133
236
  export declare const clientAcceptInvitationRequest: z.ZodObject<{
134
237
  token: z.ZodString;
135
- password: z.ZodString;
238
+ password: z.ZodOptional<z.ZodString>;
136
239
  display_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
137
240
  }, z.core.$strict>;
138
241
  export type ClientAcceptInvitationRequest = z.infer<typeof clientAcceptInvitationRequest>;
@@ -168,8 +271,8 @@ export declare const clientProviderListQuery: z.ZodObject<{
168
271
  }, z.core.$strip>;
169
272
  export type ClientProviderListQuery = z.infer<typeof clientProviderListQuery>;
170
273
  /**
171
- * What the developer's login page needs to draw its provider buttons, and
172
- * **nothing more**. This route is public and unauthenticated: the issuer, the
274
+ * What the developer's login page needs to draw its provider buttons and its
275
+ * password or code fields, and **nothing more**. This route is public and unauthenticated: the issuer, the
173
276
  * client id, the scopes and the linking policy are all management-side facts
174
277
  * that would tell a stranger how the app's federation is configured.
175
278
  *
@@ -181,6 +284,10 @@ export declare const clientProviderListResponse: z.ZodObject<{
181
284
  slug: z.ZodString;
182
285
  name: z.ZodString;
183
286
  }, z.core.$strip>>;
287
+ sign_in_methods: z.ZodObject<{
288
+ password: z.ZodBoolean;
289
+ email_code: z.ZodBoolean;
290
+ }, z.core.$strict>;
184
291
  }, z.core.$strip>;
185
292
  export type ClientProviderListResponse = z.infer<typeof clientProviderListResponse>;
186
293
  /**
@@ -291,10 +398,11 @@ export declare const clientOidcErrorCode: z.ZodEnum<{
291
398
  }>;
292
399
  export type ClientOidcErrorCode = z.infer<typeof clientOidcErrorCode>;
293
400
  /**
294
- * **A pending MCP authorization, as the app's own consent screen reads it**
295
- * Fleetless renders no page here either: `authorize` redirects to the
296
- * app's `mcp_login_url` with an interaction id, the app authenticates the user
297
- * with its normal UI, shows this, and approves or denies through the API.
401
+ * **A pending MCP authorization, as the app's own consent screen reads it.**
402
+ * `authorize` redirects to the app's `mcp_login_url` with an interaction id,
403
+ * the app authenticates the user with its normal UI, shows this, and approves
404
+ * or denies through the API. An app with no `mcp_login_url` gets the hosted
405
+ * MCP sign-in instead, which reads and decides the same interaction.
298
406
  *
299
407
  * `client_name_verified` is `z.literal(false)`, and that is the whole point of
300
408
  * the field. The name comes from an **unauthenticated** dynamic registration —
@@ -404,5 +512,6 @@ export declare const clientIdentity: z.ZodObject<{
404
512
  app_id: z.ZodNullable<z.ZodUUID>;
405
513
  role_id: z.ZodNullable<z.ZodUUID>;
406
514
  email: z.ZodNullable<z.ZodEmail>;
515
+ two_factor_enabled: z.ZodNullable<z.ZodBoolean>;
407
516
  }, z.core.$strip>;
408
517
  export type ClientIdentity = z.infer<typeof clientIdentity>;