@fleetless/contracts 1.0.0

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 (287) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +17 -0
  4. package/README.md +88 -0
  5. package/artifacts/constants.json +24 -0
  6. package/artifacts/openapi.json +17219 -0
  7. package/artifacts/routes.json +4605 -0
  8. package/artifacts/schema/accept-team-invite-request.schema.json +22 -0
  9. package/artifacts/schema/action-config.schema.json +198 -0
  10. package/artifacts/schema/alert-list-response.schema.json +172 -0
  11. package/artifacts/schema/api-error.schema.json +20 -0
  12. package/artifacts/schema/app-auth-config.schema.json +106 -0
  13. package/artifacts/schema/app-invitation-list-response.schema.json +57 -0
  14. package/artifacts/schema/app-invitation.schema.json +69 -0
  15. package/artifacts/schema/app-list-response.schema.json +82 -0
  16. package/artifacts/schema/app-mail-template-list-response.schema.json +68 -0
  17. package/artifacts/schema/app-mail-template.schema.json +54 -0
  18. package/artifacts/schema/app-oidc-provider-list-response.schema.json +93 -0
  19. package/artifacts/schema/app-oidc-provider.schema.json +80 -0
  20. package/artifacts/schema/app-user-list-response.schema.json +111 -0
  21. package/artifacts/schema/app-user.schema.json +98 -0
  22. package/artifacts/schema/app.schema.json +69 -0
  23. package/artifacts/schema/apply-error.schema.json +41 -0
  24. package/artifacts/schema/asset-list-response.schema.json +288 -0
  25. package/artifacts/schema/asset-sync-request.schema.json +17 -0
  26. package/artifacts/schema/asset-sync-response.schema.json +16 -0
  27. package/artifacts/schema/asset-sync-status.schema.json +136 -0
  28. package/artifacts/schema/asset.schema.json +68 -0
  29. package/artifacts/schema/audit-actor.schema.json +32 -0
  30. package/artifacts/schema/audit-event.schema.json +119 -0
  31. package/artifacts/schema/audit-list-response.schema.json +144 -0
  32. package/artifacts/schema/audit-query.schema.json +79 -0
  33. package/artifacts/schema/auth-error.schema.json +23 -0
  34. package/artifacts/schema/auth-me-response.schema.json +99 -0
  35. package/artifacts/schema/auth-ok.schema.json +115 -0
  36. package/artifacts/schema/authorization-server-metadata.schema.json +80 -0
  37. package/artifacts/schema/bridge-asset-progress.schema.json +99 -0
  38. package/artifacts/schema/bridge-assets-available.schema.json +25 -0
  39. package/artifacts/schema/bridge-camera-state.schema.json +78 -0
  40. package/artifacts/schema/bridge-config-applied.schema.json +67 -0
  41. package/artifacts/schema/bridge-hello.schema.json +65 -0
  42. package/artifacts/schema/bridge-introspect.schema.json +114 -0
  43. package/artifacts/schema/bridge-job-lost.schema.json +22 -0
  44. package/artifacts/schema/bridge-job-update.schema.json +100 -0
  45. package/artifacts/schema/bridge-pong.schema.json +19 -0
  46. package/artifacts/schema/bridge-pressure.schema.json +292 -0
  47. package/artifacts/schema/bridge-state.schema.json +24 -0
  48. package/artifacts/schema/bridge-type-definitions.schema.json +169 -0
  49. package/artifacts/schema/busy-details.schema.json +115 -0
  50. package/artifacts/schema/camera-descriptor.schema.json +45 -0
  51. package/artifacts/schema/camera-list-response.schema.json +58 -0
  52. package/artifacts/schema/camera-source.schema.json +240 -0
  53. package/artifacts/schema/cancel-request.schema.json +20 -0
  54. package/artifacts/schema/client-accept-invitation-request.schema.json +35 -0
  55. package/artifacts/schema/client-auth.schema.json +18 -0
  56. package/artifacts/schema/client-cancel.schema.json +45 -0
  57. package/artifacts/schema/client-identity.schema.json +103 -0
  58. package/artifacts/schema/client-invoke.schema.json +45 -0
  59. package/artifacts/schema/client-login-request.schema.json +29 -0
  60. package/artifacts/schema/client-logout-request.schema.json +14 -0
  61. package/artifacts/schema/client-mcp-interaction-decision-response.schema.json +15 -0
  62. package/artifacts/schema/client-mcp-interaction.schema.json +59 -0
  63. package/artifacts/schema/client-oidc-callback-query.schema.json +28 -0
  64. package/artifacts/schema/client-oidc-exchange-request.schema.json +21 -0
  65. package/artifacts/schema/client-oidc-start-query.schema.json +36 -0
  66. package/artifacts/schema/client-password-reset-confirm-request.schema.json +22 -0
  67. package/artifacts/schema/client-password-reset-request.schema.json +24 -0
  68. package/artifacts/schema/client-provider-list-query.schema.json +17 -0
  69. package/artifacts/schema/client-provider-list-response.schema.json +34 -0
  70. package/artifacts/schema/client-publish.schema.json +40 -0
  71. package/artifacts/schema/client-refresh-request.schema.json +14 -0
  72. package/artifacts/schema/client-register-request.schema.json +44 -0
  73. package/artifacts/schema/client-resend-verification-request.schema.json +24 -0
  74. package/artifacts/schema/client-subscribe.schema.json +43 -0
  75. package/artifacts/schema/client-unsubscribe.schema.json +26 -0
  76. package/artifacts/schema/client-verify-email-request.schema.json +15 -0
  77. package/artifacts/schema/cloud-asset-request.schema.json +37 -0
  78. package/artifacts/schema/cloud-camera-start.schema.json +41 -0
  79. package/artifacts/schema/cloud-camera-stop.schema.json +26 -0
  80. package/artifacts/schema/cloud-cancel.schema.json +33 -0
  81. package/artifacts/schema/cloud-config.schema.json +1635 -0
  82. package/artifacts/schema/cloud-hello-error.schema.json +23 -0
  83. package/artifacts/schema/cloud-hello-ok.schema.json +19 -0
  84. package/artifacts/schema/cloud-introspect-request.schema.json +19 -0
  85. package/artifacts/schema/cloud-invoke.schema.json +40 -0
  86. package/artifacts/schema/cloud-ping.schema.json +19 -0
  87. package/artifacts/schema/cloud-publish.schema.json +28 -0
  88. package/artifacts/schema/cloud-type-request.schema.json +30 -0
  89. package/artifacts/schema/command-result.schema.json +175 -0
  90. package/artifacts/schema/config-draft-response.schema.json +1695 -0
  91. package/artifacts/schema/config-state.schema.json +124 -0
  92. package/artifacts/schema/config-version-response.schema.json +1641 -0
  93. package/artifacts/schema/config-versions-response.schema.json +33 -0
  94. package/artifacts/schema/create-app-invitation-request.schema.json +40 -0
  95. package/artifacts/schema/create-app-oidc-provider-request.schema.json +70 -0
  96. package/artifacts/schema/create-app-request.schema.json +30 -0
  97. package/artifacts/schema/create-app-user-request.schema.json +42 -0
  98. package/artifacts/schema/create-robot-request.schema.json +14 -0
  99. package/artifacts/schema/create-robot-response.schema.json +44 -0
  100. package/artifacts/schema/create-server-key-response.schema.json +65 -0
  101. package/artifacts/schema/create-team-invite-request.schema.json +43 -0
  102. package/artifacts/schema/datapoint-alert-row.schema.json +160 -0
  103. package/artifacts/schema/datapoint-config.schema.json +366 -0
  104. package/artifacts/schema/datapoint-display.schema.json +31 -0
  105. package/artifacts/schema/datapoint-event.schema.json +34 -0
  106. package/artifacts/schema/datapoint-frame.schema.json +28 -0
  107. package/artifacts/schema/datapoint-list-response.schema.json +61 -0
  108. package/artifacts/schema/datapoint-value.schema.json +28 -0
  109. package/artifacts/schema/developer-login-request.schema.json +19 -0
  110. package/artifacts/schema/dynamic-client-registration-request.schema.json +60 -0
  111. package/artifacts/schema/dynamic-client-registration-response.schema.json +68 -0
  112. package/artifacts/schema/error-frame.schema.json +23 -0
  113. package/artifacts/schema/exposure-counts.schema.json +39 -0
  114. package/artifacts/schema/exposure-list-response.schema.json +43 -0
  115. package/artifacts/schema/fetch-types-request.schema.json +19 -0
  116. package/artifacts/schema/fetch-types-response.schema.json +163 -0
  117. package/artifacts/schema/fleetless-user-list-response.schema.json +73 -0
  118. package/artifacts/schema/fleetless-user.schema.json +60 -0
  119. package/artifacts/schema/history-buckets-response.schema.json +79 -0
  120. package/artifacts/schema/history-query.schema.json +58 -0
  121. package/artifacts/schema/history-response.schema.json +150 -0
  122. package/artifacts/schema/history-samples-response.schema.json +68 -0
  123. package/artifacts/schema/introspection-response.schema.json +118 -0
  124. package/artifacts/schema/invoke-or-service-response.schema.json +141 -0
  125. package/artifacts/schema/invoke-request.schema.json +23 -0
  126. package/artifacts/schema/invoke-response.schema.json +125 -0
  127. package/artifacts/schema/job-actor.schema.json +34 -0
  128. package/artifacts/schema/job-event.schema.json +158 -0
  129. package/artifacts/schema/job-response.schema.json +123 -0
  130. package/artifacts/schema/job-run-list-response.schema.json +222 -0
  131. package/artifacts/schema/job-run-query.schema.json +95 -0
  132. package/artifacts/schema/job-run-summary-query.schema.json +23 -0
  133. package/artifacts/schema/job-run-summary.schema.json +33 -0
  134. package/artifacts/schema/job-run.schema.json +195 -0
  135. package/artifacts/schema/job-state.schema.json +11 -0
  136. package/artifacts/schema/job.schema.json +106 -0
  137. package/artifacts/schema/latency-bucket.schema.json +63 -0
  138. package/artifacts/schema/live-session-response.schema.json +41 -0
  139. package/artifacts/schema/mail-outcome.schema.json +20 -0
  140. package/artifacts/schema/mail-template-preview-request.schema.json +35 -0
  141. package/artifacts/schema/mail-template-preview-response.schema.json +31 -0
  142. package/artifacts/schema/mail-template-problem-details.schema.json +24 -0
  143. package/artifacts/schema/mcp-consent-grant-list-response.schema.json +52 -0
  144. package/artifacts/schema/mcp-consent-grant.schema.json +39 -0
  145. package/artifacts/schema/mcp-robot-datasheet.schema.json +115 -0
  146. package/artifacts/schema/mcp-role-preview-response.schema.json +134 -0
  147. package/artifacts/schema/missing-asset-query.schema.json +11 -0
  148. package/artifacts/schema/oauth-authorize-query.schema.json +47 -0
  149. package/artifacts/schema/oauth-redirect-response.schema.json +15 -0
  150. package/artifacts/schema/oauth-token-request.schema.json +47 -0
  151. package/artifacts/schema/oauth-token-response.schema.json +38 -0
  152. package/artifacts/schema/org-alerts-query.schema.json +15 -0
  153. package/artifacts/schema/org-event-dropped.schema.json +26 -0
  154. package/artifacts/schema/org-event-replay.schema.json +97 -0
  155. package/artifacts/schema/org-event-subscribe.schema.json +14 -0
  156. package/artifacts/schema/org-event-unsubscribe.schema.json +14 -0
  157. package/artifacts/schema/org-event.schema.json +75 -0
  158. package/artifacts/schema/org-firing-alerts-response.schema.json +178 -0
  159. package/artifacts/schema/org-health-query.schema.json +13 -0
  160. package/artifacts/schema/org-latency-query.schema.json +42 -0
  161. package/artifacts/schema/org-latency-response.schema.json +124 -0
  162. package/artifacts/schema/org-quota-usage-counts.schema.json +42 -0
  163. package/artifacts/schema/org-quota-usage.schema.json +102 -0
  164. package/artifacts/schema/org-quotas.schema.json +51 -0
  165. package/artifacts/schema/org-usage-query.schema.json +19 -0
  166. package/artifacts/schema/org-usage-response.schema.json +77 -0
  167. package/artifacts/schema/org.schema.json +30 -0
  168. package/artifacts/schema/parameter-invalid-details.schema.json +37 -0
  169. package/artifacts/schema/parameter-spec.schema.json +120 -0
  170. package/artifacts/schema/parameter-violation.schema.json +24 -0
  171. package/artifacts/schema/password-change-request.schema.json +21 -0
  172. package/artifacts/schema/password-reset-confirm.schema.json +19 -0
  173. package/artifacts/schema/password-reset-request.schema.json +14 -0
  174. package/artifacts/schema/patch-app-oidc-provider-request.schema.json +50 -0
  175. package/artifacts/schema/patch-app-user-request.schema.json +34 -0
  176. package/artifacts/schema/patch-auth-me-request.schema.json +22 -0
  177. package/artifacts/schema/patch-fleetless-user-request.schema.json +20 -0
  178. package/artifacts/schema/patch-org-request.schema.json +15 -0
  179. package/artifacts/schema/patch-org-response.schema.json +40 -0
  180. package/artifacts/schema/patch-robot-request.schema.json +15 -0
  181. package/artifacts/schema/patch-robot-response.schema.json +40 -0
  182. package/artifacts/schema/pending-team-invite-list-response.schema.json +52 -0
  183. package/artifacts/schema/pending-team-invite.schema.json +39 -0
  184. package/artifacts/schema/protected-resource-metadata.schema.json +41 -0
  185. package/artifacts/schema/publish-config-response.schema.json +21 -0
  186. package/artifacts/schema/publish-request.schema.json +17 -0
  187. package/artifacts/schema/publisher-config.schema.json +285 -0
  188. package/artifacts/schema/put-app-auth-config-request.schema.json +93 -0
  189. package/artifacts/schema/put-app-mail-template-request.schema.json +35 -0
  190. package/artifacts/schema/put-config-draft-request.schema.json +13 -0
  191. package/artifacts/schema/put-datapoint-display-request.schema.json +31 -0
  192. package/artifacts/schema/put-robot-details-request.schema.json +41 -0
  193. package/artifacts/schema/put-robot-details-response.schema.json +43 -0
  194. package/artifacts/schema/rate-limit-details.schema.json +15 -0
  195. package/artifacts/schema/refresh-request.schema.json +13 -0
  196. package/artifacts/schema/release-live-query.schema.json +13 -0
  197. package/artifacts/schema/rename-slug-request.schema.json +23 -0
  198. package/artifacts/schema/rename-slug-response.schema.json +24 -0
  199. package/artifacts/schema/resource-health-event.schema.json +72 -0
  200. package/artifacts/schema/resource-health-list-response.schema.json +80 -0
  201. package/artifacts/schema/resource-health-state.schema.json +68 -0
  202. package/artifacts/schema/robot-config-doc.schema.json +1616 -0
  203. package/artifacts/schema/robot-delete-query.schema.json +12 -0
  204. package/artifacts/schema/robot-deletion-summary.schema.json +63 -0
  205. package/artifacts/schema/robot-detail-response.schema.json +262 -0
  206. package/artifacts/schema/robot-details-doc.schema.json +33 -0
  207. package/artifacts/schema/robot-jobs-response.schema.json +119 -0
  208. package/artifacts/schema/robot-latency-series.schema.json +81 -0
  209. package/artifacts/schema/robot-list-item.schema.json +94 -0
  210. package/artifacts/schema/robot-list-response.schema.json +106 -0
  211. package/artifacts/schema/robot.schema.json +30 -0
  212. package/artifacts/schema/role-list-response.schema.json +48 -0
  213. package/artifacts/schema/role-permissions.schema.json +61 -0
  214. package/artifacts/schema/role.schema.json +35 -0
  215. package/artifacts/schema/ros-graph.schema.json +99 -0
  216. package/artifacts/schema/server-key-list-response.schema.json +64 -0
  217. package/artifacts/schema/server-key.schema.json +51 -0
  218. package/artifacts/schema/service-call-response.schema.json +13 -0
  219. package/artifacts/schema/service-config.schema.json +198 -0
  220. package/artifacts/schema/session-tokens.schema.json +28 -0
  221. package/artifacts/schema/sign-up-request.schema.json +26 -0
  222. package/artifacts/schema/sign-up-response.schema.json +127 -0
  223. package/artifacts/schema/slug-usage-response.schema.json +32 -0
  224. package/artifacts/schema/snapshot-header.schema.json +44 -0
  225. package/artifacts/schema/snapshot-meta-response.schema.json +85 -0
  226. package/artifacts/schema/subscribe-error.schema.json +31 -0
  227. package/artifacts/schema/team-invite.schema.json +57 -0
  228. package/artifacts/schema/tier-change-request.schema.json +17 -0
  229. package/artifacts/schema/type-definition.schema.json +144 -0
  230. package/artifacts/schema/types-response.schema.json +156 -0
  231. package/artifacts/schema/update-app-request.schema.json +32 -0
  232. package/artifacts/schema/urdf-completeness.schema.json +50 -0
  233. package/artifacts/schema/validation-issue.schema.json +43 -0
  234. package/artifacts/schema/waitlist-request.schema.json +15 -0
  235. package/artifacts/schema-outgoing/bridge-asset-progress.schema.json +102 -0
  236. package/artifacts/schema-outgoing/bridge-assets-available.schema.json +26 -0
  237. package/artifacts/schema-outgoing/bridge-camera-state.schema.json +80 -0
  238. package/artifacts/schema-outgoing/bridge-config-applied.schema.json +69 -0
  239. package/artifacts/schema-outgoing/bridge-hello.schema.json +68 -0
  240. package/artifacts/schema-outgoing/bridge-introspect.schema.json +119 -0
  241. package/artifacts/schema-outgoing/bridge-job-lost.schema.json +23 -0
  242. package/artifacts/schema-outgoing/bridge-job-update.schema.json +102 -0
  243. package/artifacts/schema-outgoing/bridge-pong.schema.json +20 -0
  244. package/artifacts/schema-outgoing/bridge-type-definitions.schema.json +174 -0
  245. package/artifacts/schema-outgoing/datapoint-frame.schema.json +29 -0
  246. package/artifacts/schema-outgoing/snapshot-header.schema.json +45 -0
  247. package/dist/alerts.d.ts +255 -0
  248. package/dist/alerts.js +193 -0
  249. package/dist/app-users.d.ts +606 -0
  250. package/dist/app-users.js +696 -0
  251. package/dist/apps.d.ts +175 -0
  252. package/dist/apps.js +267 -0
  253. package/dist/assets.d.ts +434 -0
  254. package/dist/assets.js +546 -0
  255. package/dist/audit.d.ts +129 -0
  256. package/dist/audit.js +238 -0
  257. package/dist/client-auth.d.ts +409 -0
  258. package/dist/client-auth.js +487 -0
  259. package/dist/common.d.ts +186 -0
  260. package/dist/common.js +199 -0
  261. package/dist/config-issues.d.ts +175 -0
  262. package/dist/config-issues.js +339 -0
  263. package/dist/config.d.ts +862 -0
  264. package/dist/config.js +1988 -0
  265. package/dist/errors.d.ts +52 -0
  266. package/dist/errors.js +786 -0
  267. package/dist/identity.d.ts +549 -0
  268. package/dist/identity.js +503 -0
  269. package/dist/index.d.ts +51 -0
  270. package/dist/index.js +51 -0
  271. package/dist/introspection.d.ts +99 -0
  272. package/dist/introspection.js +97 -0
  273. package/dist/jobs.d.ts +334 -0
  274. package/dist/jobs.js +345 -0
  275. package/dist/mcp.d.ts +239 -0
  276. package/dist/mcp.js +153 -0
  277. package/dist/oauth.d.ts +344 -0
  278. package/dist/oauth.js +488 -0
  279. package/dist/protocol.d.ts +781 -0
  280. package/dist/protocol.js +715 -0
  281. package/dist/realtime.d.ts +494 -0
  282. package/dist/realtime.js +512 -0
  283. package/dist/rest.d.ts +1989 -0
  284. package/dist/rest.js +1963 -0
  285. package/dist/routes.d.ts +94 -0
  286. package/dist/routes.js +2298 -0
  287. package/package.json +61 -0
@@ -0,0 +1,696 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import { z } from 'zod';
3
+ import { idpIssuer, mailStatus, password } from './identity.js';
4
+ /**
5
+ * **App users: the per-app identity space** (spec `2026-09-05-app-user-auth`,
6
+ * D1–D7).
7
+ *
8
+ * The 2026-08-29 model put developers and end users into one pool per org,
9
+ * tied apps to groups, and let an org admin enter an app only by
10
+ * impersonation. It modelled the wrong thing: the people who configure robots
11
+ * in the console and the people who use a developer's app are different
12
+ * populations with different lifecycles, and every mechanism that connected
13
+ * them — groups, assignments, impersonation, the app-branded portal pages —
14
+ * was cost without a product reason.
15
+ *
16
+ * So there are now **two identity spaces and nothing joins them**:
17
+ *
18
+ * - *Fleetless users* (`identity.ts`) — the org's team. Email globally unique,
19
+ * tier `owner | developer`, Fleetless password, console access.
20
+ * - *app users* (this file) — one app each. Email unique **per app**,
21
+ * case-insensitively. The same address may exist in several apps of one org
22
+ * as unrelated accounts, and a Fleetless user who wants to use an app
23
+ * registers or is invited like anybody else.
24
+ *
25
+ * **Fleetless shows an app user no page** (D2). The developer's own UI owns
26
+ * every screen and calls the JSON client-auth API (`client-auth.ts`). The one
27
+ * Fleetless-rendered surface an app user can reach is the problem page for an
28
+ * OIDC callback whose state no longer resolves to a redirect URI — every other
29
+ * error is redirected to the app to render. That is why the four URLs on
30
+ * `appAuthConfig` exist: Fleetless mails a link, and the link points into the
31
+ * app.
32
+ */
33
+ /** App-user display names share the Fleetless-user bound, so a rename cannot be legal in one space and refused in the other. */
34
+ export const APP_USER_DISPLAY_NAME_MAX = 120;
35
+ /**
36
+ * **A provider slug — hyphenated, and deliberately not the ROS slug grammar.**
37
+ *
38
+ * `appIdentifier` is `slug`: lowercase and *underscore*-separated, because it
39
+ * names something that also appears in ROS. A provider slug appears in a URL
40
+ * path (`/api/client/oidc/:slug/start`) and on the developer's own sign-in
41
+ * buttons, where a hyphen is the conventional spelling — `azure-ad`, not
42
+ * `azure_ad`.
43
+ *
44
+ * The two grammars are one character apart, which is exactly why this is its
45
+ * own export with its own tests rather than a reuse: reusing the wrong one
46
+ * would be invisible until a customer typed a hyphen.
47
+ */
48
+ export const providerSlug = z
49
+ .string()
50
+ .max(40)
51
+ .regex(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/, 'a provider slug is lowercase and hyphen-separated, starting with a letter');
52
+ /**
53
+ * **The three states an app user can be in, and the order is the lifecycle.**
54
+ *
55
+ * - `pending_verification` — self-registered, mail sent, cannot log in yet
56
+ * (D6). Without this state the domain whitelist would prove nothing: anybody
57
+ * could claim any address at an allowed domain.
58
+ * - `active` — may log in.
59
+ * - `blocked` — may not, and every refusal is the same `invalid_credentials`
60
+ * a wrong password gets (§4). A block that announced itself would be an
61
+ * account-enumeration oracle with an extra step.
62
+ *
63
+ * `pending_verification` is reached exactly once and left only by spending the
64
+ * mailed token, which is why `patchAppUserRequest` cannot set it: see there.
65
+ */
66
+ export const appUserStatus = z.enum(['pending_verification', 'active', 'blocked']);
67
+ /**
68
+ * **A user of one app.** Not a user of the org: `app_id` is the whole scope,
69
+ * and the uniqueness constraint the cloud enforces is `(app_id, lower(email))`
70
+ * rather than a global one. The same person at two apps of one org is two
71
+ * unrelated rows, by design (D1).
72
+ */
73
+ export const appUser = z.object({
74
+ id: z.uuid().meta({
75
+ description: 'The app user in the API, assigned by the cloud and stable for the life of the account.',
76
+ }),
77
+ app_id: z.uuid().meta({
78
+ description: 'The app this user belongs to, and the whole of their scope. An app user of one app is nobody at another, even inside the same organisation.',
79
+ }),
80
+ email: z.email().meta({
81
+ description: 'The address the account is identified by. Unique **per app**, case-insensitively — the same address may exist as an unrelated account in another app of the same organisation.',
82
+ }),
83
+ display_name: z.string().min(1).max(APP_USER_DISPLAY_NAME_MAX).nullable().meta({
84
+ description: 'Optional human name, shown by the developer\'s own UI instead of the address where present. `null` when the user never supplied one; never used for authentication.',
85
+ }),
86
+ role_id: z.uuid().meta({
87
+ description: 'The role that decides what this user may reach. Roles are the only visibility filter: what a role does not grant does not exist for that user.',
88
+ }),
89
+ status: appUserStatus.meta({
90
+ description: 'Where the account is in its lifecycle. Only `active` may log in; `pending_verification` and `blocked` are both refused with the same `invalid_credentials` a wrong password gets, so a failed login is not an account-enumeration oracle.',
91
+ }),
92
+ /**
93
+ * The wire's only statement about the credential, one bit on purpose: a
94
+ * response that carries a hash puts it in every log that ever captured a
95
+ * response. `false` is an OIDC-only account or an invitation not yet
96
+ * accepted — it does not mean blocked and it does not mean without access.
97
+ */
98
+ has_password: z.boolean().meta({
99
+ description: 'Whether this account has a Fleetless-held password at all. `false` is an identity-provider-only account, or an invitation not yet accepted — it does not mean blocked and it does not mean without access. No hash, no algorithm and no "last changed" travels here, and nothing on the wire can say whether a password is strong or already known to somebody else.',
100
+ }),
101
+ providers: z.array(providerSlug).max(20).meta({
102
+ description: 'The slugs of the identity providers this account is linked to, empty for a password-only user. It is what lets a developer\'s user list say where an account came from without a second request.',
103
+ }),
104
+ last_login_at: z.iso.datetime().nullable().meta({
105
+ 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*.',
106
+ }),
107
+ created_at: z.iso.datetime().meta({
108
+ description: 'When the account was created, as an ISO 8601 timestamp.',
109
+ }),
110
+ });
111
+ /** `GET /api/apps/:id/users` — every app user of one app, never null: an app with no users answers an empty array. */
112
+ export const appUserListResponse = z.object({
113
+ users: z.array(appUser).meta({
114
+ description: 'Every user of this app. An app with no users answers an empty array, not an absent key.',
115
+ }),
116
+ });
117
+ /**
118
+ * **A developer creating an app user directly, password and all** — the door
119
+ * that exists so a developer can seed an account without waiting for a mail.
120
+ *
121
+ * `.strict()`: `status` is absent and cannot arrive. A user created here is
122
+ * `active`, because a developer who typed the password has already vouched for
123
+ * the address; letting the body choose would give one route two lifecycles.
124
+ */
125
+ export const createAppUserRequest = z
126
+ .object({
127
+ email: z.email().meta({
128
+ description: 'The address, unique per app case-insensitively. An address this app already knows is refused with `email_taken` — a developer-authenticated route may say so, unlike the public registration route.',
129
+ }),
130
+ password: password.meta({
131
+ description: 'The initial password. At least 12 characters: length only, because a rule a user cannot predict is a rule they work around.',
132
+ }),
133
+ display_name: z.string().min(1).max(APP_USER_DISPLAY_NAME_MAX).nullable().optional().meta({
134
+ description: 'Optional human name. Absent leaves it unset; an explicit `null` is the same end state.',
135
+ }),
136
+ role_id: z.uuid().optional().meta({
137
+ description: 'The role the new user holds. Absent means the app\'s `default_role_id`, and `409 target_state_conflict` when the app has none or its default no longer resolves; a role belonging to another app is `404 not_found`, the same refusal a role that never existed gets.',
138
+ }),
139
+ })
140
+ .strict();
141
+ /**
142
+ * `PATCH /api/apps/:id/users/:userId` — **what a developer may change, and
143
+ * what is absent rather than merely un-required.**
144
+ *
145
+ * `email` is not here: it is the identifier of the account, the value every
146
+ * invitation, reset link and audit line names, and a PATCH that could change
147
+ * it is both an account-takeover surface and a uniqueness race. Strict, so
148
+ * offering it is a refusal rather than a silently dropped field.
149
+ *
150
+ * **`status` admits only `active` and `blocked`.** `pending_verification` is
151
+ * reached once, by self-registration, and left by spending the mailed token
152
+ * (D6). A developer able to set it back could void a verified address without
153
+ * the user ever seeing a mail, and there is no route out of that state that
154
+ * does not require a token nobody re-sent. So the narrower enum is the rule,
155
+ * stated in the schema rather than left to a handler to remember.
156
+ */
157
+ export const patchAppUserRequest = z
158
+ .object({
159
+ display_name: z.string().min(1).max(APP_USER_DISPLAY_NAME_MAX).nullable().optional().meta({
160
+ description: 'The user\'s display name. Absent leaves it alone; an explicit `null` clears it.',
161
+ }),
162
+ role_id: z.uuid().optional().meta({
163
+ description: 'The role the user holds from now on. A role belonging to another app is `404 not_found`, the same refusal a role that never existed gets; re-roling closes the user\'s live subscriptions.',
164
+ }),
165
+ status: z.enum(['active', 'blocked']).meta({
166
+ description: 'Block the account or let it back in. **`pending_verification` cannot be set here**: it is reached only by self-registration and left only by spending the mailed verification token, so a developer setting it would strand the account in a state nothing re-mails them out of.',
167
+ }).optional(),
168
+ })
169
+ .strict();
170
+ /**
171
+ * **Inviting an address into an app.** The invitation carries the role, so the
172
+ * person who accepts it lands with the access the developer chose rather than
173
+ * with a default somebody has to remember to change afterwards.
174
+ */
175
+ export const createAppInvitationRequest = z
176
+ .object({
177
+ email: z.email().meta({
178
+ description: 'The address to invite. An invitation always bypasses the domain whitelist — a developer inviting somebody by hand has already made the decision the whitelist automates.',
179
+ }),
180
+ role_id: z.uuid().optional().meta({
181
+ description: 'The role the invitee gets on acceptance. Absent means the app\'s `default_role_id`.',
182
+ }),
183
+ display_name: z.string().min(1).max(APP_USER_DISPLAY_NAME_MAX).nullable().optional().meta({
184
+ description: 'An optional name to pre-fill the account with; the invitee can change it afterwards.',
185
+ }),
186
+ send_mail: z.boolean().meta({
187
+ 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.',
188
+ }),
189
+ })
190
+ .strict();
191
+ /**
192
+ * The invitation as issued.
193
+ *
194
+ * **`accept_url` is nullable, and that is a policy rather than a convenience.**
195
+ * The link points into the developer's app, at their configured `invite_url`.
196
+ * An app that has configured none has nowhere for it to point, so there is no
197
+ * link to hand back — `null` says that outright, where an absent key would be
198
+ * indistinguishable from a mapper that dropped the field and a fabricated
199
+ * Fleetless-hosted URL would name a page this product does not serve (D2).
200
+ */
201
+ export const appInvitation = z.object({
202
+ id: z.uuid().meta({ description: 'The invitation, as listed and revoked by the developer.' }),
203
+ app_id: z.uuid().meta({ description: 'The app the invitee will belong to.' }),
204
+ email: z.email().meta({ description: 'The address the invitation was addressed to.' }),
205
+ 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.' }),
206
+ 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.' }),
207
+ accept_url: z.url().max(500).nullable().meta({
208
+ 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.',
209
+ }),
210
+ mail: mailStatus.meta({
211
+ 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.',
212
+ }),
213
+ });
214
+ /**
215
+ * A pending invitation as the developer sees it in the list — **without its
216
+ * `accept_url`**, and that omission is the point.
217
+ *
218
+ * The list exists so a developer can see what is outstanding and revoke it.
219
+ * Neither needs the token, and a list that carries it turns every screenshot,
220
+ * log line and browser-history entry of that page into live credentials for
221
+ * somebody else's account. The same rule `pendingUserInvite` already keeps.
222
+ *
223
+ * `mail` is omitted for a duller reason: it described what happened at
224
+ * creation time, and re-serving it invites a reader to take it as current.
225
+ */
226
+ export const pendingAppInvitation = appInvitation.omit({ accept_url: true, mail: true });
227
+ /** `GET /api/apps/:id/invitations` — pending only. An accepted invitation is history, not something to revoke. */
228
+ export const appInvitationListResponse = z.object({
229
+ invitations: z.array(pendingAppInvitation).meta({
230
+ description: 'The app\'s outstanding invitations, without their tokens. An accepted one is history and does not appear.',
231
+ }),
232
+ });
233
+ /**
234
+ * **An app's OIDC provider, as read back** (D4). Any number per app, unlike
235
+ * the group provider this replaces — a developer serving two customers needs
236
+ * two, and the old at-most-one rule was a property of groups rather than of
237
+ * identity.
238
+ *
239
+ * **No secret, by construction.** The client secret goes in through the create
240
+ * and patch requests and never comes back out: a secret a response can carry
241
+ * is a secret in every log that ever captured a response, the same rule the
242
+ * server key and the group provider already kept.
243
+ *
244
+ * `issuer` is `idpIssuer` — http(s) only, no credentials, query or fragment.
245
+ * **This is not the SSRF defence.** It cannot tell the dev IdP
246
+ * (`http://localhost:8081/realms/…`) from `http://127.0.0.1:5432`, both being
247
+ * loopback http; the real defence refuses loopback, link-local and private
248
+ * ranges at the discovery fetch, in the cloud, and names DNS rebinding as its
249
+ * own residual.
250
+ */
251
+ export const appOidcProvider = z.object({
252
+ id: z.uuid().meta({ description: 'The provider row, as listed, patched and deleted by the developer.' }),
253
+ app_id: z.uuid().meta({ description: 'The app this provider signs users in to.' }),
254
+ slug: providerSlug.meta({
255
+ description: 'The stable handle in the sign-in URL (`/api/client/oidc/:slug/start`) and on the developer\'s own button. Unique per app, lowercase and hyphen-separated — **not** the underscore-separated grammar `identifier` uses. Immutable: linked identities are keyed by it.',
256
+ }),
257
+ name: z.string().min(1).max(80).meta({
258
+ description: 'What the developer\'s sign-in page calls this provider, e.g. "Sign in with Azure AD". Free text; the only field of this shape the public `GET /api/client/providers` reveals besides the slug.',
259
+ }),
260
+ issuer: idpIssuer.meta({
261
+ description: 'The provider\'s issuer URL, from which discovery and the JWKS are read. http(s) only, with no credentials, query or fragment — RFC 8414 §3 builds the discovery URL from the issuer\'s path, so a query there is meaningless and an `@` is a redirect trick. **This is not the SSRF defence**: it cannot tell a loopback dev provider from a loopback database, and the real check refuses loopback, link-local and private ranges at the fetch.',
262
+ }),
263
+ client_id: z.string().min(1).max(200).meta({
264
+ description: 'The OAuth client the developer registered at their provider for Fleetless.',
265
+ }),
266
+ scopes: z.array(z.string().min(1).max(60)).min(1).max(20).meta({
267
+ description: 'The scopes requested at the provider. At least one — a request that asks for nothing learns nothing — and bounded, because an unbounded array on a stored, logged and rendered shape is a size nobody chose.',
268
+ }),
269
+ link_verified_emails: z.boolean().meta({
270
+ description: 'Whether a federated login may join an **existing** app user with the same address. It needs the provider to assert `email_verified` as well: either condition alone is account takeover, since a provider that lets anyone type any address into a profile would otherwise hand over every matching account, and a developer who connects a provider for a subset of their users would otherwise silently merge strangers.',
271
+ }),
272
+ enabled: z.boolean().meta({
273
+ description: 'Whether this provider is offered at all. A disabled provider disappears from `GET /api/client/providers` and refuses a start with `provider_disabled`, without the row and its linked identities being deleted.',
274
+ }),
275
+ created_at: z.iso.datetime().meta({ description: 'When the provider was configured, as an ISO 8601 timestamp.' }),
276
+ }).strict();
277
+ /** `GET /api/apps/:id/oidc-providers` — every provider of the app, enabled or not; the public client route lists only the enabled ones. */
278
+ export const appOidcProviderListResponse = z.object({
279
+ providers: z.array(appOidcProvider).meta({
280
+ description: 'Every provider configured on this app, disabled ones included — this is the developer\'s management view, unlike the public `GET /api/client/providers`, which lists only what a user can actually press.',
281
+ }),
282
+ });
283
+ /**
284
+ * **Creating a provider** — `.strict()`, and the only place besides the patch
285
+ * that carries the client secret.
286
+ *
287
+ * The secret is **required here and optional on the patch**: a provider with
288
+ * no secret cannot exchange a code, so a create without one would store a row
289
+ * that can never work; a patch without one keeps the stored value, so a
290
+ * routine edit of the scopes does not force the secret back onto the wire.
291
+ * The minimum length refuses a trivial value — a one-character client secret
292
+ * is a misconfiguration, not a rotation.
293
+ */
294
+ export const createAppOidcProviderRequest = z
295
+ .object({
296
+ slug: providerSlug.meta({
297
+ description: 'The handle for this provider, unique per app. Immutable once created — identities are keyed by it, so a rename would orphan every linked account.',
298
+ }),
299
+ name: z.string().min(1).max(80).meta({ description: 'What the developer\'s sign-in page calls this provider.' }),
300
+ issuer: idpIssuer.meta({
301
+ description: 'The provider\'s issuer URL. Shape-checked here (http(s), no credentials, query or fragment); the authoritative SSRF defence is at the discovery fetch.',
302
+ }),
303
+ client_id: z.string().min(1).max(200).meta({ description: 'The OAuth client registered at the provider for Fleetless.' }),
304
+ client_secret: z.string().min(16).max(500).meta({
305
+ description: 'The client secret, **write-only**: it is stored encrypted and comes back through nothing — not the read, not this route\'s own answer, not an audit detail. Required on create, since a provider with no secret cannot exchange a code; the minimum length refuses a value that is a misconfiguration rather than a secret.',
306
+ }),
307
+ scopes: z.array(z.string().min(1).max(60)).min(1).max(20).default(['openid', 'email', 'profile']).meta({
308
+ description: 'The scopes to request. Defaults to `openid email profile`, which is what the linking rules in this design actually read: the subject, the address and its verified flag, and a name.',
309
+ }),
310
+ link_verified_emails: z.boolean().default(false).meta({
311
+ description: 'Whether a federated login may join an existing app user by verified address. **Defaults to off**, because relaxing later is additive and admitting duplicates now and tightening afterwards is not.',
312
+ }),
313
+ enabled: z.boolean().default(true).meta({
314
+ description: 'Whether the provider is offered immediately. Defaults to on: a developer who just typed a client secret is configuring a provider they mean to use.',
315
+ }),
316
+ })
317
+ .strict();
318
+ /**
319
+ * **Patching a provider** — every field optional, and `slug` absent.
320
+ *
321
+ * The slug is in the path and is what `app_user_identities` rows are keyed by,
322
+ * so renaming it would orphan every linked account. Strict, so offering it is
323
+ * a `400` naming the field rather than a `200` that changed nothing — the
324
+ * silence `updateAppRequest` was made strict to avoid.
325
+ */
326
+ export const patchAppOidcProviderRequest = z
327
+ .object({
328
+ name: z.string().min(1).max(80).optional().meta({ description: 'A new display name for the provider.' }),
329
+ issuer: idpIssuer.optional().meta({ description: 'A new issuer URL. Changing it re-runs discovery; identities linked under the old one keep their `(provider, subject)` key.' }),
330
+ client_id: z.string().min(1).max(200).optional().meta({ description: 'A new client id.' }),
331
+ client_secret: z.string().min(16).max(500).optional().meta({
332
+ description: 'A replacement client secret. **Absent means keep the stored one**, so a routine edit need not put the secret back on the wire; it is never echoed back by any route.',
333
+ }),
334
+ scopes: z.array(z.string().min(1).max(60)).min(1).max(20).optional().meta({ description: 'A replacement scope list. A replace, not a merge.' }),
335
+ link_verified_emails: z.boolean().optional().meta({ description: 'Whether a federated login may join an existing app user by verified address.' }),
336
+ enabled: z.boolean().optional().meta({ description: 'Turn the provider off or back on without deleting it or its linked identities.' }),
337
+ })
338
+ .strict();
339
+ /**
340
+ * **The placeholder each configurable app URL must carry, declared once.**
341
+ *
342
+ * Three of the four take a `{token}` and the fourth an `{interaction}`, and
343
+ * the difference is not cosmetic: the MCP login URL is handed an interaction
344
+ * id, not a credential. Exported so the console's help text, the cloud's
345
+ * substitution and this file's validators cannot spell them differently —
346
+ * `OAUTH_PATHS`' lesson, applied before there are five hand-written copies.
347
+ */
348
+ export const APP_URL_PLACEHOLDERS = {
349
+ invite_url: '{token}',
350
+ verify_url: '{token}',
351
+ reset_url: '{token}',
352
+ mcp_login_url: '{interaction}',
353
+ };
354
+ /**
355
+ * **An app-hosted URL template: https (or loopback http) carrying its
356
+ * placeholder exactly once.**
357
+ *
358
+ * Two rules, each with a failure it exists to prevent.
359
+ *
360
+ * *The scheme.* These links are mailed and carry a single-use credential in
361
+ * their path; over plain http on a public host that credential is readable by
362
+ * every hop. `localhost` and `127.0.0.1` are the exception because a developer
363
+ * building their app has no certificate, and a rule that made local
364
+ * development impossible would be worked around with a proxy nobody reviewed.
365
+ *
366
+ * *Exactly once.* The cloud substitutes the token with a plain string replace,
367
+ * which takes the **first** occurrence. A template naming the placeholder
368
+ * twice would therefore get one occurrence substituted and one left literal,
369
+ * and the link would 404 for the person who received the mail rather than fail
370
+ * for the developer who wrote it. Refusing at configuration time is the only
371
+ * place that mistake is cheap. A template with no placeholder is refused for
372
+ * the mirror reason: it would mail every recipient the same link.
373
+ *
374
+ * **What it cannot check**: that the URL resolves, that the app serves that
375
+ * path, or that the developer's page knows what to do with the token. Nothing
376
+ * a schema can see says any of that, and a validator that looked sufficient
377
+ * here would be read as an assurance.
378
+ */
379
+ export function appUrlTemplate(placeholder) {
380
+ return z
381
+ .string()
382
+ .max(500)
383
+ .refine((v) => {
384
+ try {
385
+ const u = new URL(v.replace(placeholder, 'x'));
386
+ const schemeOk = u.protocol === 'https:' ||
387
+ (u.protocol === 'http:' && (u.hostname === 'localhost' || u.hostname === '127.0.0.1'));
388
+ return schemeOk && v.split(placeholder).length === 2;
389
+ }
390
+ catch {
391
+ return false;
392
+ }
393
+ }, { message: `must be an https URL (or http://localhost) containing ${placeholder} exactly once` });
394
+ }
395
+ /**
396
+ * **An origin, and nothing longer than an origin.**
397
+ *
398
+ * This list is both the CORS allow-list and the redirect-URI check, and a
399
+ * browser's `Origin` header is a bare origin: scheme, host, port. An entry
400
+ * carrying a path would compare unequal forever — a rule that silently never
401
+ * matches, which is worse than one that refuses, because the developer sees
402
+ * their own app rejected with nothing naming the typo.
403
+ *
404
+ * `u.origin === v` is the whole check for that: it rejects a trailing slash, a
405
+ * path, a query and a fragment in one comparison, and it does so against the
406
+ * browser's own normalisation rather than against a regex somebody has to keep
407
+ * in step with it.
408
+ */
409
+ export const allowedOrigin = z
410
+ .string()
411
+ .max(200)
412
+ .refine((v) => {
413
+ try {
414
+ const u = new URL(v);
415
+ return (u.origin === v &&
416
+ (u.protocol === 'https:' || u.hostname === 'localhost' || u.hostname === '127.0.0.1'));
417
+ }
418
+ catch {
419
+ return false;
420
+ }
421
+ }, { message: 'must be a bare origin (scheme, host, port) over https, or over http on localhost' });
422
+ /**
423
+ * **A domain for the self-registration whitelist, in one canonical spelling.**
424
+ *
425
+ * The list is compared against the domain part of an address the cloud has
426
+ * already lowercased, so an entry carrying a capital could never match — and
427
+ * the developer who typed it would see self-registration refuse everybody with
428
+ * nothing saying why. Lowercase is therefore the rule rather than a
429
+ * normalisation applied later in one of the two places that compare.
430
+ *
431
+ * The pattern is the ordinary LDH rule: labels of letters, digits and internal
432
+ * hyphens, at least two labels, a TLD of letters. 253 characters is the DNS
433
+ * name limit.
434
+ */
435
+ export const emailDomain = z
436
+ .string()
437
+ .min(1)
438
+ .max(253)
439
+ .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');
440
+ /**
441
+ * **The app's auth settings: one row per app, configured by a Fleetless user**
442
+ * (D3).
443
+ *
444
+ * `self_registration` and `allowed_domains` are **one policy for one
445
+ * decision** — they govern registration by password and registration through
446
+ * an identity provider alike (D4). An invitation always bypasses both, because
447
+ * a developer inviting somebody by hand has already made the decision the
448
+ * whitelist automates.
449
+ *
450
+ * The four URLs are what makes D2 work: Fleetless mails a link, and the link
451
+ * points into the developer's app. An app that has configured none of them
452
+ * still works for password login — it simply cannot send a mail that leads
453
+ * anywhere, and `send_mail` is refused rather than silently sending a dead
454
+ * link.
455
+ */
456
+ export const appAuthConfig = z.object({
457
+ self_registration: z.boolean().meta({
458
+ description: 'Whether a stranger may create an account in this app. Off refuses `POST /api/client/register` with `403 registration_closed`, and refuses an unknown identity at an OIDC callback with the same reasoning — one switch for one decision, whichever door the person arrives at.',
459
+ }),
460
+ allowed_domains: z.array(emailDomain).max(50).meta({
461
+ description: 'The email domains self-registration accepts, lowercase. An empty list means no domain restriction, not "nobody" — the switch above is what closes the door. **An invitation always bypasses this**, by password and through a provider alike.',
462
+ }),
463
+ allowed_origins: z.array(allowedOrigin).max(20).meta({
464
+ description: 'The origins the client auth API answers CORS for, and the only origins an OIDC `redirect_uri` may name. Bare origins: scheme, host and port, with no path — a browser sends nothing longer, so an entry carrying one could never match.',
465
+ }),
466
+ mcp_enabled: z.boolean().meta({
467
+ 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.',
468
+ }),
469
+ invite_url: appUrlTemplate('{token}').nullable().meta({
470
+ 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.',
471
+ }),
472
+ verify_url: appUrlTemplate('{token}').nullable().meta({
473
+ 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.',
474
+ }),
475
+ reset_url: appUrlTemplate('{token}').nullable().meta({
476
+ description: 'The page that takes a new password, with `{token}` where the token goes.',
477
+ }),
478
+ mcp_login_url: appUrlTemplate('{interaction}').nullable().meta({
479
+ 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.',
480
+ }),
481
+ oidc_callback_url: z.url().meta({
482
+ 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.',
483
+ }),
484
+ updated_at: z.iso.datetime().meta({ description: 'When the configuration was last written, as an ISO 8601 timestamp.' }),
485
+ });
486
+ /**
487
+ * `PUT /api/apps/:id/auth-config` — a replace, not a merge, and `.strict()`.
488
+ *
489
+ * `oidc_callback_url` and `updated_at` are omitted because both are the
490
+ * server's: see the callback URL's own note for why a writable one would be a
491
+ * redirect-target hole rather than a convenience.
492
+ */
493
+ export const putAppAuthConfigRequest = appAuthConfig
494
+ .omit({ oidc_callback_url: true, updated_at: true })
495
+ .strict();
496
+ /**
497
+ * The three mails a developer may replace with their own template (D5).
498
+ * Mails to *Fleetless* users — a team invitation, a console password reset —
499
+ * stay Fleetless default and are deliberately not customisable: they are
500
+ * about this platform, not about the developer's product.
501
+ */
502
+ export const mailTemplateKind = z.enum(['invite', 'verify', 'reset']);
503
+ /**
504
+ * **Every variable a template may name, and the list is closed.**
505
+ *
506
+ * Liquid runs in strict mode: an unknown variable is an error at save time and
507
+ * in the preview, rather than an empty string in a mail somebody already
508
+ * received. That is only worth anything if the permitted set is written down
509
+ * where the renderer, the console's completion and the docs all read the same
510
+ * one.
511
+ */
512
+ export const MAIL_TEMPLATE_VARIABLES = [
513
+ 'app.name',
514
+ 'org.name',
515
+ 'user.email',
516
+ 'user.display_name',
517
+ 'role.name',
518
+ 'link',
519
+ 'expires_in_hours',
520
+ ];
521
+ /**
522
+ * **The Fleetless default text for the three app mails** (spec D5, §6).
523
+ *
524
+ * It lives here rather than in the cloud because two products send the same
525
+ * words: the cloud renders these when an app has no template of its own, and
526
+ * the console seeds its editor with them when a developer presses *Customise*.
527
+ * They were written twice, in different words, and a developer comparing the
528
+ * editor against a mail they had received would have found two Fleetless
529
+ * defaults that disagreed. One text, one place, and neither consumer may hold
530
+ * a copy.
531
+ *
532
+ * These are Liquid templates like any custom one — the same variables, the
533
+ * same renderer, the same bounds — so the cloud's fallback path cannot become
534
+ * a second, weaker mechanism that merely looks like the real one.
535
+ *
536
+ * **Text-only (`html: null`).** A text part is a complete mail, and a default
537
+ * that shipped markup would make every app that never opens the Mails tab send
538
+ * Fleetless-styled HTML on behalf of a product that is not Fleetless.
539
+ *
540
+ * The voice is plain and short, names the app rather than this platform, and
541
+ * says what the link does, how long it lasts, and what to do if it was not
542
+ * you.
543
+ *
544
+ * **`verify` and `reset` greet by the address, not by the display name**, and
545
+ * that is a security decision rather than a style one. `display_name` on those
546
+ * two mails comes from `POST /api/client/register`, which is unauthenticated:
547
+ * whoever typed the address also chose 120 characters of text that Fleetless
548
+ * then renders into a mail sent from the *developer's* own sender to an
549
+ * address the same caller chose. "Hello Account suspended — verify at
550
+ * https://evil.example now," is a phishing line with a real product's return
551
+ * address on it. The recipient's own address is the one value in that mail
552
+ * they can check, and it is the greeting. `invite` keeps the display name:
553
+ * that one is written by an authenticated developer about somebody they
554
+ * invited.
555
+ *
556
+ * **`expires_in_hours` is the only lifetime variable the spec offers**, and
557
+ * the three values are 1, 24 and 168. "The next 168 hours" is not how a person
558
+ * says a week, so each default converts: 48 and up reads in days, exactly one
559
+ * reads "1 hour", everything else reads in hours. The conversion is in the
560
+ * template rather than in a new variable because a custom template has the
561
+ * same problem and this is the spelling it can copy.
562
+ *
563
+ * **What contracts does NOT assert about these.** That they compile as Liquid
564
+ * is the cloud's business — contracts has no renderer and adding one to check
565
+ * its own constant would be a second, weaker copy of the thing that actually
566
+ * sends mail. Here they are pinned as a complete, non-empty set; the cloud
567
+ * asserts that the mail it sends for each kind is this exact text.
568
+ */
569
+ export const DEFAULT_MAIL_TEMPLATES = {
570
+ invite: {
571
+ subject: "You're invited to {{ app.name }}",
572
+ text: `Hello {{ user.display_name | default: user.email }},
573
+
574
+ {{ org.name }} has invited you to {{ app.name }} as {{ role.name }}.
575
+
576
+ Accept the invitation and choose a password:
577
+ {{ link }}
578
+
579
+ 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.
580
+ `,
581
+ html: null,
582
+ },
583
+ verify: {
584
+ subject: 'Confirm your email for {{ app.name }}',
585
+ text: `Hello {{ user.email }},
586
+
587
+ Confirm this address so you can sign in to {{ app.name }}:
588
+ {{ link }}
589
+
590
+ 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 did not create this account, ignore this mail — the account stays unconfirmed and cannot be used.
591
+ `,
592
+ html: null,
593
+ },
594
+ reset: {
595
+ subject: 'Reset your {{ app.name }} password',
596
+ text: `Hello {{ user.email }},
597
+
598
+ Someone asked to reset the password for this address at {{ app.name }}.
599
+
600
+ Choose a new password:
601
+ {{ link }}
602
+
603
+ 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 it was not you, ignore this mail — your current password keeps working and nothing changes.
604
+ `,
605
+ html: null,
606
+ },
607
+ };
608
+ /**
609
+ * One stored template. `html` is nullable because the mailer's HTML part is
610
+ * optional — a text-only mail is a complete mail, and an app that wants one
611
+ * should not have to write the same words twice.
612
+ */
613
+ export const appMailTemplate = z.object({
614
+ kind: mailTemplateKind.meta({ description: 'Which of the three mails this template replaces.' }),
615
+ subject: z.string().min(1).max(200).meta({ description: 'The subject line, a Liquid template. Bounded because a subject is rendered into a header.' }),
616
+ 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.' }),
617
+ 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.' }),
618
+ updated_at: z.iso.datetime().meta({ description: 'When the template was last written, as an ISO 8601 timestamp.' }),
619
+ });
620
+ /** `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. */
621
+ export const appMailTemplateListResponse = z.object({
622
+ templates: z.array(appMailTemplate).max(3).meta({
623
+ 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.',
624
+ }),
625
+ });
626
+ /**
627
+ * The body both the PUT and the preview take: `kind` is in the path and
628
+ * `updated_at` is a server fact, so neither may arrive — a body carrying
629
+ * `kind` could disagree with the path and leave the handler to choose which
630
+ * half to believe.
631
+ *
632
+ * `html` becomes `.optional()` as well as nullable here. Absent and `null` are
633
+ * the same end state on a write (text-only), and requiring the key would make
634
+ * the common case ceremony.
635
+ */
636
+ const mailTemplateBody = appMailTemplate
637
+ .omit({ kind: true, updated_at: true })
638
+ .extend({ html: z.string().min(1).max(100_000).nullable().optional() });
639
+ export const putAppMailTemplateRequest = mailTemplateBody.strict();
640
+ /**
641
+ * **The preview takes the same document the PUT does — as a second object,
642
+ * not as an alias.**
643
+ *
644
+ * The fields are defined once (`mailTemplateBody` above) and `.strict()` twice,
645
+ * so there is one definition and two values. An alias would be one value under
646
+ * two contract names, and the export registry resolves an artifact by object
647
+ * identity: it refuses a schema registered twice, because the artifact a route
648
+ * points at would otherwise be a coin toss.
649
+ */
650
+ export const mailTemplatePreviewRequest = mailTemplateBody.strict();
651
+ /** What the preview renders, with the sample data filled in. The developer reads this before anybody receives it. */
652
+ export const mailTemplatePreviewResponse = z.object({
653
+ subject: z.string().meta({ description: 'The rendered subject line.' }),
654
+ text: z.string().meta({ description: 'The rendered plain-text body.' }),
655
+ html: z.string().nullable().meta({ description: 'The rendered HTML body, or `null` when the template is text-only.' }),
656
+ });
657
+ /**
658
+ * The `details` of a `422 template_invalid`: **which part failed**, not merely
659
+ * that something did.
660
+ *
661
+ * A template has three independently-rendered parts, and an error that did not
662
+ * say which one leaves the developer re-reading all three. `message` is the
663
+ * renderer's own — it names the unknown variable or the syntax error — and
664
+ * carries nothing else: it is written into an audit event as well, where the
665
+ * rule is that no credential, token or password may appear.
666
+ */
667
+ export const mailTemplateProblemDetails = z.object({
668
+ part: z.enum(['subject', 'text', 'html']).meta({ description: 'Which of the three rendered parts failed.' }),
669
+ message: z.string().meta({ description: 'The renderer\'s own message — the unknown variable, or the syntax error and where it is.' }),
670
+ });
671
+ /**
672
+ * **What a `202` says when the only thing that happened was a mail.**
673
+ *
674
+ * Three routes do one act and answer nothing about it — re-sending a user's
675
+ * reset link, mailing an invitation, sending a test template. A bare `202`
676
+ * with an empty body would be honest about the *acceptance* and silent about
677
+ * the one fact the developer needs next, which is whether a mail actually left:
678
+ * an app with no SMTP configured looks exactly like one that mailed, and the
679
+ * developer waits for a message nobody sent.
680
+ *
681
+ * So the body is `{ "mail": mailStatus }` and nothing else. `sent` means the
682
+ * SMTP server accepted it, not that it was delivered; `not_configured` is an
683
+ * expected state on a deployment without a mailer and is not a failure;
684
+ * `failed` is the one worth somebody's attention.
685
+ *
686
+ * It is its own object rather than a reuse of `appInvitation`'s field because
687
+ * the export registry resolves an artifact by object identity — one schema
688
+ * under two contract names would make the artifact a route points at a coin
689
+ * toss, the same reason `mailTemplatePreviewRequest` is a second `.strict()`
690
+ * rather than an alias.
691
+ */
692
+ export const mailOutcome = z.object({
693
+ mail: mailStatus.meta({
694
+ description: 'What happened to the mail this call triggered. `sent` means the SMTP server accepted it, not that it was delivered; `not_requested` means none was attempted, because the caller asked for none or there was no link to carry; `not_configured` is an expected state and not a failure; `failed` is the one worth somebody\'s attention.',
695
+ }),
696
+ });