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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +96 -33
  2. package/artifacts/openapi.json +2049 -773
  3. package/artifacts/routes.json +1665 -314
  4. package/artifacts/schema/accept-team-invite-request.schema.json +13 -7
  5. package/artifacts/schema/app-auth-config.schema.json +108 -4
  6. package/artifacts/schema/app-hosted-pages.schema.json +33 -0
  7. package/artifacts/schema/app-invitation.schema.json +2 -2
  8. package/artifacts/schema/app-mail-template-list-response.schema.json +4 -3
  9. package/artifacts/schema/app-mail-template.schema.json +3 -2
  10. package/artifacts/schema/app-sign-in-methods.schema.json +19 -0
  11. package/artifacts/schema/app-user-list-response.schema.json +36 -0
  12. package/artifacts/schema/app-user.schema.json +36 -0
  13. package/artifacts/schema/audit-query.schema.json +0 -6
  14. package/artifacts/schema/auth-me-response.schema.json +27 -0
  15. package/artifacts/schema/auth-ok.schema.json +13 -1
  16. package/artifacts/schema/client-accept-invitation-request.schema.json +3 -4
  17. package/artifacts/schema/client-identity.schema.json +13 -1
  18. package/artifacts/schema/client-login-code-request.schema.json +24 -0
  19. package/artifacts/schema/client-login-code-verify-request.schema.json +30 -0
  20. package/artifacts/schema/client-provider-list-response.schema.json +22 -2
  21. package/artifacts/schema/client-register-request.schema.json +3 -4
  22. package/artifacts/schema/client-sign-in-result.schema.json +55 -0
  23. package/artifacts/schema/client-two-factor-disable-request.schema.json +15 -0
  24. package/artifacts/schema/client-two-factor-setup-confirm-request.schema.json +20 -0
  25. package/artifacts/schema/client-two-factor-setup-confirm-response.schema.json +49 -0
  26. package/artifacts/schema/client-two-factor-setup-request.schema.json +12 -0
  27. package/artifacts/schema/client-two-factor-verify-request.schema.json +25 -0
  28. package/artifacts/schema/create-app-invitation-request.schema.json +1 -1
  29. package/artifacts/schema/create-passkey-request.schema.json +25 -0
  30. package/artifacts/schema/create-passkey-response.schema.json +84 -0
  31. package/artifacts/schema/developer-passkey.schema.json +56 -0
  32. package/artifacts/schema/developer-two-factor.schema.json +105 -0
  33. package/artifacts/schema/fleetless-user-list-response.schema.json +22 -0
  34. package/artifacts/schema/fleetless-user.schema.json +22 -0
  35. package/artifacts/schema/invalid-code-details.schema.json +16 -0
  36. package/artifacts/schema/job-actor.schema.json +1 -15
  37. package/artifacts/schema/job-run-list-response.schema.json +1 -15
  38. package/artifacts/schema/job-run.schema.json +1 -15
  39. package/artifacts/schema/org.schema.json +5 -0
  40. package/artifacts/schema/patch-org-request.schema.json +5 -3
  41. package/artifacts/schema/patch-org-response.schema.json +5 -0
  42. package/artifacts/schema/put-app-auth-look-request.schema.json +22 -0
  43. package/artifacts/schema/put-app-auth-mcp-request.schema.json +1 -14
  44. package/artifacts/schema/put-app-auth-sign-in-request.schema.json +39 -0
  45. package/artifacts/schema/put-app-auth-urls-request.schema.json +31 -4
  46. package/artifacts/schema/recovery-codes-response.schema.json +20 -0
  47. package/artifacts/schema/{role-rename-request.schema.json → rename-passkey-request.schema.json} +2 -2
  48. package/artifacts/schema/role-list-response.schema.json +1 -1
  49. package/artifacts/schema/role.schema.json +1 -1
  50. package/artifacts/schema/totp-confirm-request.schema.json +15 -0
  51. package/artifacts/schema/totp-confirm-response.schema.json +27 -0
  52. package/artifacts/schema/two-factor-challenge.schema.json +24 -0
  53. package/artifacts/schema/two-factor-setup-response.schema.json +21 -0
  54. package/artifacts/schema/webauthn-options-response.schema.json +18 -0
  55. package/dist/app-users.d.ts +154 -34
  56. package/dist/app-users.js +177 -45
  57. package/dist/apps.d.ts +2 -38
  58. package/dist/apps.js +3 -46
  59. package/dist/audit.d.ts +0 -1
  60. package/dist/audit.js +0 -13
  61. package/dist/client-auth.d.ts +133 -24
  62. package/dist/client-auth.js +139 -28
  63. package/dist/config.d.ts +2 -2
  64. package/dist/errors.d.ts +10 -1
  65. package/dist/errors.js +34 -34
  66. package/dist/identity.d.ts +172 -123
  67. package/dist/identity.js +189 -101
  68. package/dist/index.d.ts +11 -13
  69. package/dist/index.js +12 -10
  70. package/dist/jobs.d.ts +0 -3
  71. package/dist/jobs.js +0 -16
  72. package/dist/protocol.d.ts +1 -1
  73. package/dist/realtime.d.ts +1 -0
  74. package/dist/rest.d.ts +2 -2
  75. package/dist/rest.js +17 -28
  76. package/dist/routes.d.ts +19 -0
  77. package/dist/routes.js +499 -223
  78. package/package.json +1 -1
  79. package/artifacts/schema/developer-login-request.schema.json +0 -19
  80. package/artifacts/schema/feedback-request.schema.json +0 -34
  81. package/artifacts/schema/feedback-response.schema.json +0 -27
  82. package/artifacts/schema/password-reset-confirm.schema.json +0 -19
  83. package/artifacts/schema/password-reset-request.schema.json +0 -14
  84. package/artifacts/schema/role-delete-query.schema.json +0 -13
  85. package/artifacts/schema/role-in-use-details.schema.json +0 -28
  86. package/artifacts/schema/sign-up-request.schema.json +0 -26
  87. package/artifacts/schema/sign-up-response.schema.json +0 -127
  88. package/dist/feedback.d.ts +0 -42
  89. package/dist/feedback.js +0 -35
package/dist/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
  });
package/dist/apps.d.ts CHANGED
@@ -151,9 +151,8 @@ export declare const createServerKeyResponse: z.ZodObject<{
151
151
  export type CreateServerKeyResponse = z.infer<typeof createServerKeyResponse>;
152
152
  /**
153
153
  * Every app starts with `observe` and `operate`; custom roles are allowed
154
- * too. `builtin` marks the two starting roles — editable, renamable and
155
- * deletable like any other; the flag only tells the console where they came
156
- * from.
154
+ * too. `builtin` marks the two starting roles — editable like any other,
155
+ * the flag only tells the console where they came from.
157
156
  */
158
157
  export declare const role: z.ZodObject<{
159
158
  id: z.ZodUUID;
@@ -172,41 +171,6 @@ export declare const roleListResponse: z.ZodObject<{
172
171
  }, z.core.$strip>>;
173
172
  }, z.core.$strip>;
174
173
  export type RoleListResponse = z.infer<typeof roleListResponse>;
175
- /**
176
- * The body of `PATCH /api/apps/:id/roles/:roleId`: the role's new name.
177
- *
178
- * The same bounds as `role.name`, trimmed. Names are unique per app, compared
179
- * exactly as stored after trimming; a clash answers `409 role_name_taken`.
180
- */
181
- export declare const roleRenameRequest: z.ZodObject<{
182
- name: z.ZodString;
183
- }, z.core.$strict>;
184
- export type RoleRenameRequest = z.infer<typeof roleRenameRequest>;
185
- /**
186
- * The query of `DELETE /api/apps/:id/roles/:roleId`.
187
- *
188
- * `move_to` is what makes a held role deletable: every app user and pending
189
- * invitation holding the role moves to it, and so does the app's default when
190
- * it pointed at the role, in the same transaction as the delete. Without it a
191
- * held role answers `409 role_in_use` with `roleInUseDetails`, so a client
192
- * can ask where they should go instead of guessing.
193
- */
194
- export declare const roleDeleteQuery: z.ZodObject<{
195
- move_to: z.ZodOptional<z.ZodUUID>;
196
- }, z.core.$strict>;
197
- export type RoleDeleteQuery = z.infer<typeof roleDeleteQuery>;
198
- /**
199
- * `details` of `409 role_in_use`: what still holds the role.
200
- *
201
- * All three are reported, zeros included, so a client renders one sentence
202
- * from one shape rather than inferring a missing key.
203
- */
204
- export declare const roleInUseDetails: z.ZodObject<{
205
- users: z.ZodNumber;
206
- invitations: z.ZodNumber;
207
- is_default: z.ZodBoolean;
208
- }, z.core.$strict>;
209
- export type RoleInUseDetails = z.infer<typeof roleInUseDetails>;
210
174
  /**
211
175
  * The rights matrix of one role: which slugs of which robot it may use, plus
212
176
  * the capabilities roles also govern. `capabilities`' own doc comment
package/dist/apps.js CHANGED
@@ -206,9 +206,8 @@ export const createServerKeyResponse = z.object({
206
206
  });
207
207
  /**
208
208
  * Every app starts with `observe` and `operate`; custom roles are allowed
209
- * too. `builtin` marks the two starting roles — editable, renamable and
210
- * deletable like any other; the flag only tells the console where they came
211
- * from.
209
+ * too. `builtin` marks the two starting roles — editable like any other,
210
+ * the flag only tells the console where they came from.
212
211
  */
213
212
  export const role = z.object({
214
213
  id: z.uuid().meta({
@@ -221,7 +220,7 @@ export const role = z.object({
221
220
  description: 'The role\'s name, shown wherever a user\'s access is chosen. The two roles every app starts with are named `observe` and `operate`.',
222
221
  }),
223
222
  builtin: z.boolean().meta({
224
- 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.',
223
+ 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.',
225
224
  }),
226
225
  });
227
226
  /** What `GET /api/apps/:id/roles` answers: the app's roles, builtin and custom alike. */
@@ -230,48 +229,6 @@ export const roleListResponse = z.object({
230
229
  description: 'The app\'s roles, built-in and custom alike, ordered by `created_at` and then by `name`. The tie-break is not cosmetic — the two built-in roles are inserted in one statement and share a creation time to the microsecond, so never read a role by position.',
231
230
  }),
232
231
  });
233
- /**
234
- * The body of `PATCH /api/apps/:id/roles/:roleId`: the role's new name.
235
- *
236
- * The same bounds as `role.name`, trimmed. Names are unique per app, compared
237
- * exactly as stored after trimming; a clash answers `409 role_name_taken`.
238
- */
239
- export const roleRenameRequest = z.object({
240
- name: z.string().trim().min(1).max(60).meta({
241
- 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.',
242
- }),
243
- }).strict();
244
- /**
245
- * The query of `DELETE /api/apps/:id/roles/:roleId`.
246
- *
247
- * `move_to` is what makes a held role deletable: every app user and pending
248
- * invitation holding the role moves to it, and so does the app's default when
249
- * it pointed at the role, in the same transaction as the delete. Without it a
250
- * held role answers `409 role_in_use` with `roleInUseDetails`, so a client
251
- * can ask where they should go instead of guessing.
252
- */
253
- export const roleDeleteQuery = z.object({
254
- move_to: z.uuid().optional().meta({
255
- 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`.',
256
- }),
257
- }).strict();
258
- /**
259
- * `details` of `409 role_in_use`: what still holds the role.
260
- *
261
- * All three are reported, zeros included, so a client renders one sentence
262
- * from one shape rather than inferring a missing key.
263
- */
264
- export const roleInUseDetails = z.object({
265
- users: z.number().int().nonnegative().meta({
266
- description: 'App users whose role this is.',
267
- }),
268
- invitations: z.number().int().nonnegative().meta({
269
- description: 'Pending invitations that would grant this role when accepted.',
270
- }),
271
- is_default: z.boolean().meta({
272
- description: '`true` when this is the app\'s `default_role_id`; the default then moves with the users to `move_to`.',
273
- }),
274
- }).strict();
275
232
  /**
276
233
  * The rights matrix of one role: which slugs of which robot it may use, plus
277
234
  * the capabilities roles also govern. `capabilities`' own doc comment
package/dist/audit.d.ts CHANGED
@@ -72,7 +72,6 @@ export declare const auditQuery: z.ZodObject<{
72
72
  action_prefix: z.ZodOptional<z.ZodString>;
73
73
  actor_id: z.ZodOptional<z.ZodUUID>;
74
74
  target_kind: z.ZodOptional<z.ZodString>;
75
- target_id: z.ZodOptional<z.ZodString>;
76
75
  from_ms: z.ZodOptional<z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<number, string | number>>, z.ZodNumber>>;
77
76
  to_ms: z.ZodOptional<z.ZodPipe<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodTransform<number, string | number>>, z.ZodNumber>>;
78
77
  }, z.core.$strict>;
package/dist/audit.js CHANGED
@@ -155,19 +155,6 @@ export const auditQuery = z.object({
155
155
  actor_id: z.uuid().optional(),
156
156
  /** Only events about this kind of target, e.g. `robot`. */
157
157
  target_kind: z.string().min(1).max(40).optional(),
158
- /**
159
- * Only events about this target — and, for a robot, also the events that
160
- * name it in `details.robot_id`, so a robot's log includes what was
161
- * started on it.
162
- *
163
- * **A string, not `z.uuid()`**, unlike `actor_id` above: `target.id` is a
164
- * string in this contract and text in the cloud's table, so a uuid rule
165
- * here would refuse ids the log can hold, and no value can fail a cast.
166
- */
167
- target_id: z.string().min(1).max(200).optional().meta({
168
- description: 'Events whose target is this id; for a robot also the events that name it in `details.robot_id` (`action.invoked`, ' +
169
- '`service.called`, …), so a robot\'s events include what was started on it.',
170
- }),
171
158
  /**
172
159
  * Absolute bounds in unix milliseconds, **half-open `[from, to)`** — the
173
160
  * same rule the history shapes follow.