@fleetless/contracts 1.0.0 → 1.0.3

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 (49) hide show
  1. package/CHANGELOG.md +97 -2
  2. package/CODE_OF_CONDUCT.md +83 -0
  3. package/CONTRIBUTING.md +136 -0
  4. package/README.md +49 -13
  5. package/SECURITY.md +55 -0
  6. package/artifacts/openapi.json +11 -11
  7. package/artifacts/routes.json +12 -12
  8. package/artifacts/schema/create-app-oidc-provider-request.schema.json +1 -1
  9. package/dist/alerts.d.ts +23 -28
  10. package/dist/alerts.js +23 -29
  11. package/dist/app-users.d.ts +18 -19
  12. package/dist/app-users.js +18 -20
  13. package/dist/apps.d.ts +21 -25
  14. package/dist/apps.js +42 -52
  15. package/dist/assets.d.ts +70 -132
  16. package/dist/assets.js +130 -223
  17. package/dist/audit.d.ts +14 -15
  18. package/dist/audit.js +28 -55
  19. package/dist/client-auth.d.ts +9 -9
  20. package/dist/client-auth.js +8 -9
  21. package/dist/common.d.ts +29 -37
  22. package/dist/common.js +28 -37
  23. package/dist/config-issues.d.ts +23 -25
  24. package/dist/config-issues.js +17 -17
  25. package/dist/config.d.ts +37 -44
  26. package/dist/config.js +145 -187
  27. package/dist/errors.d.ts +4 -3
  28. package/dist/errors.js +83 -116
  29. package/dist/identity.d.ts +24 -27
  30. package/dist/identity.js +23 -27
  31. package/dist/index.d.ts +4 -4
  32. package/dist/index.js +14 -15
  33. package/dist/introspection.d.ts +7 -6
  34. package/dist/introspection.js +6 -6
  35. package/dist/jobs.d.ts +16 -16
  36. package/dist/jobs.js +24 -29
  37. package/dist/mcp.d.ts +14 -15
  38. package/dist/mcp.js +12 -14
  39. package/dist/oauth.d.ts +21 -27
  40. package/dist/oauth.js +33 -43
  41. package/dist/protocol.d.ts +51 -62
  42. package/dist/protocol.js +107 -139
  43. package/dist/realtime.d.ts +53 -68
  44. package/dist/realtime.js +78 -104
  45. package/dist/rest.d.ts +183 -244
  46. package/dist/rest.js +305 -399
  47. package/dist/routes.d.ts +4 -3
  48. package/dist/routes.js +33 -32
  49. package/package.json +12 -7
@@ -815,7 +815,7 @@
815
815
  "quota_exceeded"
816
816
  ],
817
817
  "transport": "http",
818
- "notes": "The developer-authenticated door into the app's user table, and the one place `409 email_taken` is an honest answer about an app user: the caller is authenticated into this app already, so telling them the address is taken discloses nothing they could not read from the listing beside it. `POST /api/client/register` answers `202` to the same fact, because there the caller is a stranger. `404 not_found` is the app, or a `role_id` that is not a role of it — a role of another app is refused rather than stored, since a user holding one would carry rights nothing in this app can resolve. **The password policy answers `400 validation_error`**, not a code of its own: the twelve-character minimum is the `password` field's schema rule, and every route in this repository that takes a password refuses a short one exactly the way it refuses any other malformed field. An account created here is `active` immediately: a developer entering somebody by hand has made the decision the verification mail automates, and its address counts as proven. `409 target_state_conflict` names `default_role_id` when `role_id` is absent and the app has no default role, or its default names a role that no longer resolves — a user with no role holds rights nothing in this app can read, so nothing is created. \n\n**`409 quota_exceeded` when the org holds as many app users as `max_end_users` allows**, counted across every app of the org — the same number `GET /api/org/quotas` reports as `usage.max_end_users`, since the same address in two apps is two accounts. `details` carries `{ quota, limit }`, as every count quota's refusal does. The check is at **creation** only: an existing user signs in, is patched and is deleted at the quota exactly as under it, because a protection limit that also froze the accounts already made would be an outage rather than a limit."
818
+ "notes": "The developer-authenticated door into the app's user table, and the one place `409 email_taken` is an honest answer about an app user: the caller is authenticated into this app already, so telling them the address is taken discloses nothing they could not read from the listing beside it. `POST /api/client/register` answers `202` to the same fact, because there the caller is a stranger. `404 not_found` is the app, or a `role_id` that is not a role of it — a role of another app is refused rather than stored, since a user holding one would carry rights nothing in this app can resolve. **The password policy answers `400 validation_error`**, not a code of its own: the twelve-character minimum is the `password` field's schema rule, and every route that takes a password refuses a short one exactly the way it refuses any other malformed field. An account created here is `active` immediately: a developer entering somebody by hand has made the decision the verification mail automates, and its address counts as proven. `409 target_state_conflict` names `default_role_id` when `role_id` is absent and the app has no default role, or its default names a role that no longer resolves — a user with no role holds rights nothing in this app can read, so nothing is created. \n\n**`409 quota_exceeded` when the org holds as many app users as `max_end_users` allows**, counted across every app of the org — the same number `GET /api/org/quotas` reports as `usage.max_end_users`, since the same address in two apps is two accounts. `details` carries `{ quota, limit }`, as every count quota's refusal does. The check is at **creation** only: an existing user signs in, is patched and is deleted at the quota exactly as under it, because a protection limit that also froze the accounts already made would be an outage rather than a limit."
819
819
  },
820
820
  {
821
821
  "method": "GET",
@@ -2017,7 +2017,7 @@
2017
2017
  "token_spent"
2018
2018
  ],
2019
2019
  "transport": "http",
2020
- "notes": "The identifier-first step, with nothing left to identify: Fleetless users are password-only (design D1/D7), so **this step does not read the address at all** — it renders the password card for a known address, an unknown one and an empty one alike, and the login step below answers the same `401` for all three. That is a property of the shape rather than of two branches agreeing: there is no lookup here whose result could differ. A browser form post gets the password card; a JSON caller gets `{ \"next\" }`, which has no schema. Still rate limited per (route, ip, email), because it is an unauthenticated endpoint that renders a page."
2020
+ "notes": "The identifier-first step, with nothing left to identify: Fleetless users are password-only, so **this step does not read the address at all** — it renders the password card for a known address, an unknown one and an empty one alike, and the login step below answers the same `401` for all three. That is a property of the shape rather than of two branches agreeing: there is no lookup here whose result could differ. A browser form post gets the password card; a JSON caller gets `{ \"next\" }`, which has no schema. Still rate limited per (route, ip, email), because it is an unauthenticated endpoint that renders a page."
2021
2021
  },
2022
2022
  {
2023
2023
  "method": "POST",
@@ -2316,7 +2316,7 @@
2316
2316
  "forbidden"
2317
2317
  ],
2318
2318
  "transport": "http",
2319
- "notes": "JSON-RPC over MCP's Streamable HTTP, so neither the request nor the response is a shape contracts describes; the tool arguments and results are the schemas in each tool definition. **Fleetless users only** — an app's users reach their own app endpoint instead. **The bearer is verified inside the handler**, not by a route guard: the identity comes from the token and the path names none, and the refusal has to carry a `WWW-Authenticate` challenge that a guard shared with the REST surface does not send. `Origin` is checked against the cloud's own, and a foreign one is the `403 forbidden` above. **Both catalogs, unconditionally**: every caller admitted here is a Fleetless user, so the tool list has nothing left to vary with and the admin-ness check on `tools/call` is gone — a console tool that is still narrower than the catalog refuses for itself (`console_robot_delete` answers `tier_required` to a non-Owner). `403 forbidden` is also what an `mcp_session` token whose subject is an **app user** gets: this endpoint serves the team only, and such a token belongs to its own app's endpoint. The code is `forbidden` rather than `mcp_disabled` because nothing is switched off — the caller is at the wrong server — and per-app sign-in mints exactly such tokens, so the two states must not share a word. `mcp_access_denied` is gone with the per-user override and the group flag it read: every Fleetless user has MCP access here (D1). Stateless: a fresh transport per request, no session id, nothing survives the call."
2319
+ "notes": "JSON-RPC over MCP's Streamable HTTP, so neither the request nor the response is a shape contracts describes; the tool arguments and results are the schemas in each tool definition. **Fleetless users only** — an app's users reach their own app endpoint instead. **The bearer is verified inside the handler**, not by a route guard: the identity comes from the token and the path names none, and the refusal has to carry a `WWW-Authenticate` challenge that a guard shared with the REST surface does not send. `Origin` is checked against the cloud's own, and a foreign one is the `403 forbidden` above. **Both catalogs, unconditionally**: every caller admitted here is a Fleetless user, so the tool list has nothing left to vary with and the admin-ness check on `tools/call` is gone — a console tool that is still narrower than the catalog refuses for itself (`console_robot_delete` answers `tier_required` to a non-Owner). `403 forbidden` is also what an `mcp_session` token whose subject is an **app user** gets: this endpoint serves the team only, and such a token belongs to its own app's endpoint. The code is `forbidden` rather than `mcp_disabled` because nothing is switched off — the caller is at the wrong server — and per-app sign-in mints exactly such tokens, so the two states must not share a word. `mcp_access_denied` is gone with the per-user override and the group flag it read: every Fleetless user has MCP access here. Stateless: a fresh transport per request, no session id, nothing survives the call."
2320
2320
  },
2321
2321
  {
2322
2322
  "method": "POST",
@@ -2370,7 +2370,7 @@
2370
2370
  "forbidden"
2371
2371
  ],
2372
2372
  "transport": "http",
2373
- "notes": "MCP's Streamable HTTP gives this path three verbs: `POST` carries JSON-RPC, `GET` opens the server-initiated SSE stream, and `DELETE` ends a session. This server has no sessions — the argument is in `MCP_PROTOCOL_VERSION`'s own note, and W8's second cloud instance is where a per-process session map would break — so `GET` and `DELETE` answer `405`, which is what a client is built to fall back from. \n\n**The `405` is this cloud's own answer, not the SDK's**, and the difference was measured: MCP SDK 1.30.0 opens an SSE stream on `GET` (`handleGetRequest`) and answers `200` on `DELETE` (`handleDeleteRequest`), neither of which a stateless server has any business doing, so the cloud writes the `405` itself in the transport's own JSON-RPC error shape with `Allow: POST`. \n\n**The row exists so that the `405` is not a `404`.** An unregistered verb answers `404`, and at a path whose last segment is an app identifier a `404` already means *no such app* — one answer for two states, which is the failure this project keeps paying for. Registering the verb lets the endpoint say \"this app's server is here; this verb is not part of it\". The central `/mcp` registers neither verb and does not need to: its path takes no parameter, so nothing can misread its `404`. \n\n**The `405` body is the transport's JSON-RPC error object, not the `apiError` envelope.** The three codes above are the refusals that come *first* — the app, its switch, then the bearer, in the order `POST` describes — and they are `apiError` because they are answered before the transport is reached at all. If this server ever becomes stateful, this row and the `DELETE` beside it are where that lands, and the cloud's route-manifest test is what would make both repositories notice."
2373
+ "notes": "MCP's Streamable HTTP gives this path three verbs: `POST` carries JSON-RPC, `GET` opens the server-initiated SSE stream, and `DELETE` ends a session. This server has no sessions — the argument is in `MCP_PROTOCOL_VERSION`'s own note, and a per-process session map is what breaks at the second cloud instance — so `GET` and `DELETE` answer `405`, which is what a client is built to fall back from. \n\n**The `405` is this cloud's own answer, not the SDK's**, and the two differ: MCP SDK 1.30.0 opens an SSE stream on `GET` (`handleGetRequest`) and answers `200` on `DELETE` (`handleDeleteRequest`), neither of which a stateless server has any business doing, so the cloud writes the `405` itself in the transport's own JSON-RPC error shape with `Allow: POST`. \n\n**The row exists so that the `405` is not a `404`.** An unregistered verb answers `404`, and at a path whose last segment is an app identifier a `404` already means *no such app* — one answer for two states, which is one answer for two states. Registering the verb lets the endpoint say \"this app's server is here; this verb is not part of it\". The central `/mcp` registers neither verb and does not need to: its path takes no parameter, so nothing can misread its `404`. \n\n**The `405` body is the transport's JSON-RPC error object, not the `apiError` envelope.** The three codes above are the refusals that come *first* — the app, its switch, then the bearer, in the order `POST` describes — and they are `apiError` because they are answered before the transport is reached at all. If this server ever becomes stateful, this row and the `DELETE` beside it are where that lands, and the cloud's route-manifest test is what would make both repositories notice."
2374
2374
  },
2375
2375
  {
2376
2376
  "method": "DELETE",
@@ -2447,7 +2447,7 @@
2447
2447
  "not_found"
2448
2448
  ],
2449
2449
  "transport": "http",
2450
- "notes": "RFC 8414, for the issuer `<PUBLIC_API_BASE_URL>/mcp/<identifier>` — the same path rule as the document above, and the same `404` for a switched-off app. `registration_endpoint` is present for the reason the central document states: a client that finds it registers itself and never asks a person for a `client_id`. \n\n**`issuer`, `token_endpoint` and the resource identifier are minted from the canonical public base, never from the friendly `mcp.fleetless.dev` alias or the request's `Host`**, because a client checks a minted token's `iss` and `aud` against these exact strings. \n\n**Unlike the central document, `authorization_endpoint` does not move to an auth-portal origin**, and there is nothing here for one to serve: this authorization step renders no Fleetless page at all. It redirects to the app's own `mcp_login_url` (D7), which is on the developer's origin already."
2450
+ "notes": "RFC 8414, for the issuer `<PUBLIC_API_BASE_URL>/mcp/<identifier>` — the same path rule as the document above, and the same `404` for a switched-off app. `registration_endpoint` is present for the reason the central document states: a client that finds it registers itself and never asks a person for a `client_id`. \n\n**`issuer`, `token_endpoint` and the resource identifier are minted from the canonical public base, never from the friendly `mcp.fleetless.dev` alias or the request's `Host`**, because a client checks a minted token's `iss` and `aud` against these exact strings. \n\n**Unlike the central document, `authorization_endpoint` does not move to an auth-portal origin**, and there is nothing here for one to serve: this authorization step renders no Fleetless page at all. It redirects to the app's own `mcp_login_url`, which is on the developer's origin already."
2451
2451
  },
2452
2452
  {
2453
2453
  "method": "POST",
@@ -2473,7 +2473,7 @@
2473
2473
  "not_found"
2474
2474
  ],
2475
2475
  "transport": "http",
2476
- "notes": "RFC 7591, the same wire and the same handler as `POST /mcp/oauth/register` — `registerMcpDynamicClient`, one implementation, because a second answer to \"is this redirect URI acceptable\" would agree with the first only by luck. The request schema is what the endpoint accepts rather than what it parses, for the reason that row gives: §3.2.2 needs two distinguishable refusals and one `safeParse` failure offers one. `client_name` and `redirect_uris` are read; `grant_types`, `response_types` and `scope` are accepted and ignored, and what comes back is what was actually granted, which §3.2.1 allows — `authorization_code` only, so a client that asked for `refresh_token` is registered and told plainly that it did not get one. The registration carries a TTL. \n\n**The registration is scoped to this app.** A `client_id` minted here authorizes at this app's endpoint and nowhere else, so a client registered against one app cannot walk into another's authorize with it, and a developer who switches MCP off is not left with strangers' registrations valid somewhere adjacent. \n\nRefusals are `oauthError`; the rate limiter and `404 not_found` answer `apiError`. That `404` covers an unknown identifier **and** an app with the switch off, mirroring the two metadata documents this endpoint is discovered from — a client that could not read those has no business registering here, and giving it a third distinct answer would only tell it something the documents deliberately do not."
2476
+ "notes": "RFC 7591, the same wire and the same handler as `POST /mcp/oauth/register` — one implementation, because a second answer to \"is this redirect URI acceptable\" would agree with the first only by luck. The request schema is what the endpoint accepts rather than what it parses, for the reason that row gives: §3.2.2 needs two distinguishable refusals and one `safeParse` failure offers one. `client_name` and `redirect_uris` are read; `grant_types`, `response_types` and `scope` are accepted and ignored, and what comes back is what was actually granted, which §3.2.1 allows — `authorization_code` only, so a client that asked for `refresh_token` is registered and told plainly that it did not get one. The registration carries a TTL. \n\n**The registration is scoped to this app.** A `client_id` minted here authorizes at this app's endpoint and nowhere else, so a client registered against one app cannot walk into another's authorize with it, and a developer who switches MCP off is not left with strangers' registrations valid somewhere adjacent. \n\nRefusals are `oauthError`; the rate limiter and `404 not_found` answer `apiError`. That `404` covers an unknown identifier **and** an app with the switch off, mirroring the two metadata documents this endpoint is discovered from — a client that could not read those has no business registering here, and giving it a third distinct answer would only tell it something the documents deliberately do not."
2477
2477
  },
2478
2478
  {
2479
2479
  "method": "GET",
@@ -2499,7 +2499,7 @@
2499
2499
  "target_state_conflict"
2500
2500
  ],
2501
2501
  "transport": "http",
2502
- "notes": "The same query as `GET /mcp/oauth/authorize`, read the same way — parameter by parameter, because the answers differ and one parse would collapse them. \n\n**Fleetless renders no page here, and that is the whole of D7.** The route writes an interaction — ten minutes, as the OIDC ones live — and redirects to `appAuthConfig.mcp_login_url` with `{interaction}` filled in. The app then authenticates the person with its own UI, reads `GET /api/client/mcp/interactions/:id` to show the client's claimed name and the scopes it asked for, and calls approve or deny. \n\nClient and `redirect_uri` are validated first and a failure there never redirects — the open-redirect discipline `GET /mcp/oauth/authorize` and `GET /api/client/oidc/:slug/start` both keep — and those refusals are RFC 6749's flat `oauthError`, which is why none of them appear above. `redirect_uri` is matched **exactly** against the registration, with no loopback-port wildcard: every client here registered itself minutes ago and can name the port it bound, so a wildcard would only widen where a stolen `client_id` may send a browser. \n\nThe two codes above are the `apiError` envelope because they are refusals about the **app**, decided before an OAuth parameter is looked at. **`404 not_found` covers an identifier no app carries AND an app with MCP switched off** — the same single answer the two metadata documents, `register` and the transport give. An earlier draft answered `403 mcp_disabled` here, on the argument that a client which registered while the switch was on is owed the difference between \"turned off\" and \"mistyped\"; that argument does not survive the caller being anonymous. This route takes no credential, so the extra code was readable by anyone who could type an identifier, and it handed back precisely the existence distinction every neighbouring route collapses. `mcp_disabled` survives only where the caller has already proved they belong to the app — the two decision routes under `/api/client/mcp/interactions/:id`. `409 target_state_conflict` names `mcp_login_url` with rule `not_set`: MCP is enabled and no page is configured to send the person to. It is the same code and the same shape `send_mail` answers for an unconfigured `invite_url`, and the refusal is the honest one — Fleetless has nowhere to redirect, and rendering a page of its own instead would contradict D2."
2502
+ "notes": "The same query as `GET /mcp/oauth/authorize`, read the same way — parameter by parameter, because the answers differ and one parse would collapse them. \n\n**Fleetless renders no page here**, and that is the whole of it. The route writes an interaction — ten minutes, as the OIDC ones live — and redirects to `appAuthConfig.mcp_login_url` with `{interaction}` filled in. The app then authenticates the person with its own UI, reads `GET /api/client/mcp/interactions/:id` to show the client's claimed name and the scopes it asked for, and calls approve or deny. \n\nClient and `redirect_uri` are validated first and a failure there never redirects — the open-redirect discipline `GET /mcp/oauth/authorize` and `GET /api/client/oidc/:slug/start` both keep — and those refusals are RFC 6749's flat `oauthError`, which is why none of them appear above. `redirect_uri` is matched **exactly** against the registration, with no loopback-port wildcard: every client here registered itself minutes ago and can name the port it bound, so a wildcard would only widen where a stolen `client_id` may send a browser. \n\nThe two codes above are the `apiError` envelope because they are refusals about the **app**, decided before an OAuth parameter is looked at. **`404 not_found` covers an identifier no app carries AND an app with MCP switched off** — the same single answer the two metadata documents, `register` and the transport give. An earlier draft answered `403 mcp_disabled` here, on the argument that a client which registered while the switch was on is owed the difference between \"turned off\" and \"mistyped\"; that argument does not survive the caller being anonymous. This route takes no credential, so the extra code was readable by anyone who could type an identifier, and it handed back precisely the existence distinction every neighbouring route collapses. `mcp_disabled` survives only where the caller has already proved they belong to the app — the two decision routes under `/api/client/mcp/interactions/:id`. `409 target_state_conflict` names `mcp_login_url` with rule `not_set`: MCP is enabled and no page is configured to send the person to. It is the same code and the same shape `send_mail` answers for an unconfigured `invite_url`, and the refusal is the honest one — Fleetless has nowhere to redirect, and rendering a page of its own would contradict the rule that Fleetless shows an app user no page."
2503
2503
  },
2504
2504
  {
2505
2505
  "method": "POST",
@@ -2848,7 +2848,7 @@
2848
2848
  "rate_limited"
2849
2849
  ],
2850
2850
  "transport": "http",
2851
- "notes": "**One callback URL for every app and every provider**, and the value of `appAuthConfig.oidc_callback_url` — the string a developer registers at their IdP. `CLIENT_OIDC_CALLBACK_PATH` in `client-auth.ts` is the single spelling of this path; the URL is that path on the cloud's canonical public base, never a friendly alias, because the provider compares the redirect target against the one string it was given. \n\n**Rate limited per ip, generously.** The first draft left this route unlimited on the argument that the caller is an identity provider redirecting somebody's browser, so a limiter would drop real sign-ins on the strength of traffic none of those people sent. The cost of that argument is a `state` obtained from one `start` being replayable for the interaction's full ten minutes, unbounded and unauthenticated, with every replay driving a server-side POST to the developer's token endpoint and one audit row into their org — an amplifier against a third party. The interaction is now spent on **every** terminal outcome, refusals included, which closes the replay itself; the limiter is the ceiling on how fast the attempts may arrive at all. It is per ip and sized for a browser, so a person completing a sign-in never meets it. `429 rate_limited` is the one `apiError` this route can answer, and it is not a sign-in outcome — it is a refusal to begin the work, which is why it does not ride back to the app as an `?error=`. \n\nThe query is the **provider's** rather than a Fleetless shape, and `clientOidcCallbackQuery` describes it **without being strict**: `state` always, `code` on success, `error` and `error_description` on the provider's own refusal, and whatever else that provider adds — RFC 9207's `iss`, a `session_state`, a vendor field. Refusing those would refuse conforming providers, the trap `POST /mcp/oauth/register` documents avoiding. `state` is the required field because it is the only one Fleetless minted. \n\n**It lists `rate_limited` and no other code, because every sign-in outcome it has is a redirect.** Success and failure alike are a `302` to the app's own `redirect_uri`: `?code=…&state=…` when a session was resolved, `?error=<clientOidcErrorCode>&state=…` when it was not, so the app renders its own message and can bind either answer to the request it started. Fleetless shows an app user no page (D2). \n\n**The one exception is a `state` that resolves to no interaction** — unknown, hand-edited, or past its ten minutes. Then there is no confirmed redirect target to carry the answer to, and bouncing a browser to an unvalidated one is the hole the whole flow is arranged to avoid, so the cloud renders an HTML problem page at `400`. That is the only Fleetless-rendered surface an app user can reach. It is HTML rather than an `apiError`, which is why no code is listed: a code here would document an envelope no caller receives, and this manifest's other HTML pages (`GET /mcp/oauth/interaction/:id`, `GET /console/oauth/interaction/:id`) say their status in prose for the same reason."
2851
+ "notes": "**One callback URL for every app and every provider**, and the value of `appAuthConfig.oidc_callback_url` — the string a developer registers at their IdP. `CLIENT_OIDC_CALLBACK_PATH` in `client-auth.ts` is the single spelling of this path; the URL is that path on the cloud's canonical public base, never a friendly alias, because the provider compares the redirect target against the one string it was given. \n\n**Rate limited per ip, generously.** The first draft left this route unlimited on the argument that the caller is an identity provider redirecting somebody's browser, so a limiter would drop real sign-ins on the strength of traffic none of those people sent. The cost of that argument is a `state` obtained from one `start` being replayable for the interaction's full ten minutes, unbounded and unauthenticated, with every replay driving a server-side POST to the developer's token endpoint and one audit row into their org — an amplifier against a third party. The interaction is now spent on **every** terminal outcome, refusals included, which closes the replay itself; the limiter is the ceiling on how fast the attempts may arrive at all. It is per ip and sized for a browser, so a person completing a sign-in never meets it. `429 rate_limited` is the one `apiError` this route can answer, and it is not a sign-in outcome — it is a refusal to begin the work, which is why it does not ride back to the app as an `?error=`. \n\nThe query is the **provider's** rather than a Fleetless shape, and `clientOidcCallbackQuery` describes it **without being strict**: `state` always, `code` on success, `error` and `error_description` on the provider's own refusal, and whatever else that provider adds — RFC 9207's `iss`, a `session_state`, a vendor field. Refusing those would refuse conforming providers, the trap `POST /mcp/oauth/register` documents avoiding. `state` is the required field because it is the only one Fleetless minted. \n\n**It lists `rate_limited` and no other code, because every sign-in outcome it has is a redirect.** Success and failure alike are a `302` to the app's own `redirect_uri`: `?code=…&state=…` when a session was resolved, `?error=<clientOidcErrorCode>&state=…` when it was not, so the app renders its own message and can bind either answer to the request it started. Fleetless shows an app user no page. \n\n**The one exception is a `state` that resolves to no interaction** — unknown, hand-edited, or past its ten minutes. Then there is no confirmed redirect target to carry the answer to, and bouncing a browser to an unvalidated one is the hole the whole flow is arranged to avoid, so the cloud renders an HTML problem page at `400`. That is the only Fleetless-rendered surface an app user can reach. It is HTML rather than an `apiError`, which is why no code is listed: a code here would document an envelope no caller receives, and this manifest's other HTML pages (`GET /mcp/oauth/interaction/:id`, `GET /console/oauth/interaction/:id`) say their status in prose for the same reason."
2852
2852
  },
2853
2853
  {
2854
2854
  "method": "POST",
@@ -2926,7 +2926,7 @@
2926
2926
  "mcp_disabled"
2927
2927
  ],
2928
2928
  "transport": "http",
2929
- "notes": "The person is already signed in **at the app**, by whatever means that app uses, and this is the app telling Fleetless what they decided. Fleetless never sees that sign-in, which is D7 in one sentence. \n\nThe guard admits all three caller kinds and the handler takes one: a developer bearer or a server key reaching this is `401 unauthorized`, because a consent is a person's and a server key is not a person — the same shape `POST /api/client/password/change` has. `403 mcp_disabled` is the app's switch, re-read here as it is on every request — and it is the one refusal on this surface that names the switch, because reaching it needs an app-user session of that very app. \n\n**An interaction of ANOTHER app answers `410 interaction_expired`, not `403`.** An interaction of one app cannot be decided with a session from another — that is what stops a developer running two apps from letting one speak for the other — but saying so with a distinct code would tell any bearer holder that the id names a real, live interaction somewhere else, which is the existence answer the shared `410` exists to withhold. Unknown, expired, already decided, an interaction of the central flow, and one belonging to a different app are one status and one body. Approve and deny spend an interaction alike, so the second call gets it whichever route made the first. \n\n**Rate limited per app user, unlike the read.** The read is a public document about a request the server already holds; this one spends something, and a decision is the one thing a leaked interaction id would be worth hammering for. The limit is on the signed-in account rather than on the ip, because that is what the caller has had to prove. \n\n**The answer is a redirect target, not a redirect.** `redirect_to` is the MCP client's own callback carrying the authorization code, and the app's page sends the browser there. The app is holding that browser and Fleetless is answering its JSON call, so a `302` here would be a redirect on the wrong request. Approving records the grant for this user and this client, which is what a later `already_granted` reads back."
2929
+ "notes": "The person is already signed in **at the app**, by whatever means that app uses, and this is the app telling Fleetless what they decided. Fleetless never sees that sign-in. \n\nThe guard admits all three caller kinds and the handler takes one: a developer bearer or a server key reaching this is `401 unauthorized`, because a consent is a person's and a server key is not a person — the same shape `POST /api/client/password/change` has. `403 mcp_disabled` is the app's switch, re-read here as it is on every request — and it is the one refusal on this surface that names the switch, because reaching it needs an app-user session of that very app. \n\n**An interaction of ANOTHER app answers `410 interaction_expired`, not `403`.** An interaction of one app cannot be decided with a session from another — that is what stops a developer running two apps from letting one speak for the other — but saying so with a distinct code would tell any bearer holder that the id names a real, live interaction somewhere else, which is the existence answer the shared `410` exists to withhold. Unknown, expired, already decided, an interaction of the central flow, and one belonging to a different app are one status and one body. Approve and deny spend an interaction alike, so the second call gets it whichever route made the first. \n\n**Rate limited per app user, unlike the read.** The read is a public document about a request the server already holds; this one spends something, and a decision is the one thing a leaked interaction id would be worth hammering for. The limit is on the signed-in account rather than on the ip, because that is what the caller has had to prove. \n\n**The answer is a redirect target, not a redirect.** `redirect_to` is the MCP client's own callback carrying the authorization code, and the app's page sends the browser there. The app is holding that browser and Fleetless is answering its JSON call, so a `302` here would be a redirect on the wrong request. Approving records the grant for this user and this client, which is what a later `already_granted` reads back."
2930
2930
  },
2931
2931
  {
2932
2932
  "method": "POST",
@@ -2980,7 +2980,7 @@
2980
2980
  "forbidden"
2981
2981
  ],
2982
2982
  "transport": "http",
2983
- "notes": "**So the developer's app can offer a \"connected apps\" screen of its own**, which is the only place an end user could ever be shown this: Fleetless renders no page for an app's users (D2), and the console is the developer's tool rather than their customers'. \n\nThe answer is about the bearer's own account and takes no user id — there is no id to pass and therefore nothing to pass the wrong one. The guard admits all three caller kinds because it is shared, and the handler takes one: a developer bearer or a server key is `401 unauthorized`, the shape `POST /api/client/password/change` has, because a consent is a person's and a server key is not a person. \n\n**Every `client_name` is unverified**, on every row: dynamic registration takes no credential, so the name is text the client chose about itself and `client_name_verified` is the literal `false`. A screen that renders it as an identity is showing somebody a string an attacker picked, and this list is read long after the moment of approval, when nobody remembers what they clicked. **Withdrawn grants are absent**, not listed as withdrawn. \n\n**Not rate limited and not gated on the app's MCP switch.** It reads one small table for one account, and a person must be able to see and end what they agreed to even after a developer switches MCP off — a withdrawal door that closes with the feature is a door that is shut exactly when somebody wants it."
2983
+ "notes": "**So the developer's app can offer a \"connected apps\" screen of its own**, which is the only place an end user could ever be shown this: Fleetless renders no page for an app's users, and the console is the developer's tool rather than their customers'. \n\nThe answer is about the bearer's own account and takes no user id — there is no id to pass and therefore nothing to pass the wrong one. The guard admits all three caller kinds because it is shared, and the handler takes one: a developer bearer or a server key is `401 unauthorized`, the shape `POST /api/client/password/change` has, because a consent is a person's and a server key is not a person. \n\n**Every `client_name` is unverified**, on every row: dynamic registration takes no credential, so the name is text the client chose about itself and `client_name_verified` is the literal `false`. A screen that renders it as an identity is showing somebody a string an attacker picked, and this list is read long after the moment of approval, when nobody remembers what they clicked. **Withdrawn grants are absent**, not listed as withdrawn. \n\n**Not rate limited and not gated on the app's MCP switch.** It reads one small table for one account, and a person must be able to see and end what they agreed to even after a developer switches MCP off — a withdrawal door that closes with the feature is a door that is shut exactly when somebody wants it."
2984
2984
  },
2985
2985
  {
2986
2986
  "method": "DELETE",
@@ -3788,7 +3788,7 @@
3788
3788
  "validation_error"
3789
3789
  ],
3790
3790
  "transport": "http",
3791
- "notes": "Needs the `action_history` capability, and **this route is what makes that switch mean something** — it was unkeepable while nothing durable recorded what had run. Two residuals worth stating rather than implying away. `history` is a syntactically valid slug and Fastify matches a static segment first, so a robot with a service literally slugged `history` can no longer be **read** through `GET /api/robots/:id/jobs/:slug`; invoking, cancelling and the listing are unaffected. And a run row names its actor by email address, so an end user holding this capability learns which other people have been driving the machine. `robot_id` in the query is shared with the org-wide read; a *different* one here is refused rather than quietly answered about the robot in the path."
3791
+ "notes": "Needs the `action_history` capability, and **this route is what makes that switch mean something** — it was unkeepable while nothing durable recorded what had run. Two residuals worth stating rather than implying away. `history` is a syntactically valid slug and The router matches a static segment first, so a robot with a service literally slugged `history` can no longer be **read** through `GET /api/robots/:id/jobs/:slug`; invoking, cancelling and the listing are unaffected. And a run row names its actor by email address, so an end user holding this capability learns which other people have been driving the machine. `robot_id` in the query is shared with the org-wide read; a *different* one here is refused rather than quietly answered about the robot in the path."
3792
3792
  },
3793
3793
  {
3794
3794
  "method": "POST",
@@ -4558,7 +4558,7 @@
4558
4558
  "bad_request"
4559
4559
  ],
4560
4560
  "transport": "http",
4561
- "notes": "The body is the **raw file bytes**, not JSON, so it has no request schema; everything about the file — its kind, its name, its sync id and its announced size — rides in the `x-fleetless-asset-*` headers `ASSET_UPLOAD_HEADERS` names. The credential is a short-lived upload token minted by `POST /api/robots/:id/assets/sync`, verified in a `preParsing` hook so a refusal precedes the work rather than following it: a `preHandler` would already have buffered the whole file. The announced size is refused there too, before a single byte is read — it is an announcement and not a proof, so it only ever rejects early and never accepts early, and a body that lies small is still caught by the real length check. Past both, Fastify's own body limit answers a bare `413 bad_request` with neither ceiling nor size in it. Rate limited per robot inside that same hook, which is why `rateLimited` is `false`: there is no rate-limiting preHandler registered on this route."
4561
+ "notes": "The body is the **raw file bytes**, not JSON, so it has no request schema; everything about the file — its kind, its name, its sync id and its announced size — rides in the `x-fleetless-asset-*` headers `ASSET_UPLOAD_HEADERS` names. The credential is a short-lived upload token minted by `POST /api/robots/:id/assets/sync`, verified in a `preParsing` hook so a refusal precedes the work rather than following it: a `preHandler` would already have buffered the whole file. The announced size is refused there too, before a single byte is read — it is an announcement and not a proof, so it only ever rejects early and never accepts early, and a body that lies small is still caught by the real length check. Past both, the server's own body limit answers a bare `413 bad_request` with neither ceiling nor size in it. Rate limited per robot inside that same hook, which is why `rateLimited` is `false`: there is no rate-limiting preHandler registered on this route."
4562
4562
  },
4563
4563
  {
4564
4564
  "method": "GET",
@@ -38,7 +38,7 @@
38
38
  "email",
39
39
  "profile"
40
40
  ],
41
- "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.",
41
+ "description": "The scopes to request. Defaults to `openid email profile`, which is what account linking actually reads: the subject, the address and its verified flag, and a name.",
42
42
  "minItems": 1,
43
43
  "maxItems": 20,
44
44
  "type": "array",
package/dist/alerts.d.ts CHANGED
@@ -1,28 +1,24 @@
1
+ // SPDX-License-Identifier: Apache-2.0
1
2
  import { z } from 'zod';
2
3
  /**
3
- * Datapoint alerts and per-datapoint chart display config (spec
4
- * `2026-08-28-alerts-and-datapoint-modal-design`, D1/D2/D5).
4
+ /**
5
+ * Datapoint alerts and per-datapoint chart display config.
5
6
  *
6
- * An alert is a **state machine** (`ok ⇄ firing`), not a fire-once event —
7
- * the definition (this file's request/entity shapes) and the runtime state
8
- * (`state`, `state_since`, `last_value`) share one row, evaluated by the
9
- * cloud at ingest.
7
+ * An alert is a **state machine** (`ok ⇄ firing`), not a fire-once event. The
8
+ * definition and the runtime state (`state`, `state_since`, `last_value`) are
9
+ * read together, and the cloud evaluates the alert at ingest.
10
10
  *
11
- * **Both tables moved into `robotConfigDoc` in FL-002.** The alert definition
12
- * is `config.ts`'s `datapointAlert`, nested under the datapoint it watches;
13
- * the chart bounds are `datapointChart`. They therefore take effect on
14
- * publish rather than immediately, and in exchange every change to them is
15
- * versioned, comparable and revertible. The runtime state stays wherever the
16
- * definition goes: it belongs in the database and has no business in a
17
- * versioned document.
11
+ * **The definitions live in the configuration document.** The alert definition
12
+ * is `config.ts`'s `datapointAlert`, nested under the datapoint it watches; the
13
+ * chart bounds are `datapointChart`. They therefore take effect on publish
14
+ * rather than immediately, and in exchange every change to them is versioned,
15
+ * comparable and revertible. The runtime state stays in the database: it has no
16
+ * business in a versioned document.
18
17
  *
19
- * **What is left here is the read surface**, which FL-002 wave 4 kept rather
20
- * than deleted: `GET /api/robots/:id/alerts` and `GET /api/org/alerts` still
21
- * answer with the definition joined to its state, and the shapes below are
22
- * what they answer with. What wave 4 did remove is the mail path — the fields
23
- * `cooldown_minutes`, `recipients` and `notify_on_resolve`, and the two
24
- * bounds that guarded them. No alert can send mail, so nothing here describes
25
- * one.
18
+ * **What is here is the read surface.** `GET /api/robots/:id/alerts` and
19
+ * `GET /api/org/alerts` answer with the definition joined to its state, and the
20
+ * shapes below are what they answer with. No alert sends mail, so nothing here
21
+ * describes one.
26
22
  */
27
23
  /**
28
24
  * `above`/`below` compare the numeric sample value (already scale/offset
@@ -86,7 +82,7 @@ export declare const alertSeverity: z.ZodEnum<{
86
82
  warning: "warning";
87
83
  }>;
88
84
  export type AlertSeverity = z.infer<typeof alertSeverity>;
89
- /** The two states of the alert state machine. There is no third state — an alert is never "unknown" or "pending"; it holds its last state across non-comparable samples (D2). */
85
+ /** The two states of the alert state machine. There is no third state — an alert is never "unknown" or "pending"; it holds its last state across non-comparable samples. */
90
86
  export declare const alertState: z.ZodEnum<{
91
87
  ok: "ok";
92
88
  firing: "firing";
@@ -104,10 +100,9 @@ export type AlertState = z.infer<typeof alertState>;
104
100
  * document and the runtime state out of `datapoint_alert_state`, and the
105
101
  * cloud joins the two per request (`routes/alerts.ts`'s `toWire`).
106
102
  *
107
- * **It carried three mail settings — `cooldown_minutes`, `recipients` and
108
- * `notify_on_resolve` — and FL-002 wave 4 removed them with the mail path.**
109
- * The format has no mail fields, so no alert could be configured to send one;
110
- * the three had nothing behind them well before they were deleted.
103
+ * **There are no mail settings here.** The configuration format has no mail
104
+ * fields, so no alert can be configured to send one, and a shape describing
105
+ * recipients would describe a delivery path that does not exist.
111
106
  */
112
107
  export declare const datapointAlertRow: z.ZodObject<{
113
108
  id: z.ZodUUID;
@@ -176,7 +171,7 @@ export declare const alertListResponse: z.ZodObject<{
176
171
  export type AlertListResponse = z.infer<typeof alertListResponse>;
177
172
  /**
178
173
  * `GET /api/org/alerts?state=firing` — feeds the overview's "open issues"
179
- * tile and the fleet grid's per-robot badge (D3). Org-scoped and
174
+ * tile and the fleet grid's per-robot badge. Org-scoped and
180
175
  * cross-robot, so each entry carries `robot_name` alongside the alert: the
181
176
  * overview has no robot context of its own to join against.
182
177
  */
@@ -228,7 +223,7 @@ export declare const orgAlertsQuery: z.ZodObject<{
228
223
  export type OrgAlertsQuery = z.infer<typeof orgAlertsQuery>;
229
224
  /**
230
225
  * `GET /api/robots/:id/datapoints/:slug/display` — chart display config from
231
- * the modal's Chart tab (D1, D4). `robot_id`/`slug` live in the path, not
226
+ * the modal's Chart tab. `robot_id`/`slug` live in the path, not
232
227
  * the body; there is exactly one row per `(robot_id, slug)`, so there is
233
228
  * nothing to list or identify beyond the path itself.
234
229
  *
@@ -243,7 +238,7 @@ export declare const datapointDisplay: z.ZodObject<{
243
238
  export type DatapointDisplay = z.infer<typeof datapointDisplay>;
244
239
  /**
245
240
  * `PUT /api/robots/:id/datapoints/:slug/display` — applies immediately,
246
- * never published, never sent to the bridge (D1). Same shape as
241
+ * never published, never sent to the bridge. Same shape as
247
242
  * `datapointDisplay`, kept as its own type per house convention (`put*Request`
248
243
  * beside the entity it writes) so the two can diverge if the read side ever
249
244
  * grows a field the write side should not accept.
package/dist/alerts.js CHANGED
@@ -2,29 +2,24 @@
2
2
  import { z } from 'zod';
3
3
  import { slug } from './common.js';
4
4
  /**
5
- * Datapoint alerts and per-datapoint chart display config (spec
6
- * `2026-08-28-alerts-and-datapoint-modal-design`, D1/D2/D5).
5
+ /**
6
+ * Datapoint alerts and per-datapoint chart display config.
7
7
  *
8
- * An alert is a **state machine** (`ok ⇄ firing`), not a fire-once event —
9
- * the definition (this file's request/entity shapes) and the runtime state
10
- * (`state`, `state_since`, `last_value`) share one row, evaluated by the
11
- * cloud at ingest.
8
+ * An alert is a **state machine** (`ok ⇄ firing`), not a fire-once event. The
9
+ * definition and the runtime state (`state`, `state_since`, `last_value`) are
10
+ * read together, and the cloud evaluates the alert at ingest.
12
11
  *
13
- * **Both tables moved into `robotConfigDoc` in FL-002.** The alert definition
14
- * is `config.ts`'s `datapointAlert`, nested under the datapoint it watches;
15
- * the chart bounds are `datapointChart`. They therefore take effect on
16
- * publish rather than immediately, and in exchange every change to them is
17
- * versioned, comparable and revertible. The runtime state stays wherever the
18
- * definition goes: it belongs in the database and has no business in a
19
- * versioned document.
12
+ * **The definitions live in the configuration document.** The alert definition
13
+ * is `config.ts`'s `datapointAlert`, nested under the datapoint it watches; the
14
+ * chart bounds are `datapointChart`. They therefore take effect on publish
15
+ * rather than immediately, and in exchange every change to them is versioned,
16
+ * comparable and revertible. The runtime state stays in the database: it has no
17
+ * business in a versioned document.
20
18
  *
21
- * **What is left here is the read surface**, which FL-002 wave 4 kept rather
22
- * than deleted: `GET /api/robots/:id/alerts` and `GET /api/org/alerts` still
23
- * answer with the definition joined to its state, and the shapes below are
24
- * what they answer with. What wave 4 did remove is the mail path — the fields
25
- * `cooldown_minutes`, `recipients` and `notify_on_resolve`, and the two
26
- * bounds that guarded them. No alert can send mail, so nothing here describes
27
- * one.
19
+ * **What is here is the read surface.** `GET /api/robots/:id/alerts` and
20
+ * `GET /api/org/alerts` answer with the definition joined to its state, and the
21
+ * shapes below are what they answer with. No alert sends mail, so nothing here
22
+ * describes one.
28
23
  */
29
24
  /**
30
25
  * `above`/`below` compare the numeric sample value (already scale/offset
@@ -88,7 +83,7 @@ export const alertRowCondition = z.discriminatedUnion('kind', [
88
83
  * `orgEventKind`'s doc comment in `realtime.ts`).
89
84
  */
90
85
  export const alertSeverity = z.enum(['warning', 'error']);
91
- /** The two states of the alert state machine. There is no third state — an alert is never "unknown" or "pending"; it holds its last state across non-comparable samples (D2). */
86
+ /** The two states of the alert state machine. There is no third state — an alert is never "unknown" or "pending"; it holds its last state across non-comparable samples. */
92
87
  export const alertState = z.enum(['ok', 'firing']);
93
88
  /**
94
89
  * One alert row, definition and runtime state together — the runtime fields
@@ -102,10 +97,9 @@ export const alertState = z.enum(['ok', 'firing']);
102
97
  * document and the runtime state out of `datapoint_alert_state`, and the
103
98
  * cloud joins the two per request (`routes/alerts.ts`'s `toWire`).
104
99
  *
105
- * **It carried three mail settings — `cooldown_minutes`, `recipients` and
106
- * `notify_on_resolve` — and FL-002 wave 4 removed them with the mail path.**
107
- * The format has no mail fields, so no alert could be configured to send one;
108
- * the three had nothing behind them well before they were deleted.
100
+ * **There are no mail settings here.** The configuration format has no mail
101
+ * fields, so no alert can be configured to send one, and a shape describing
102
+ * recipients would describe a delivery path that does not exist.
109
103
  */
110
104
  export const datapointAlertRow = z.object({
111
105
  id: z.uuid(),
@@ -116,7 +110,7 @@ export const datapointAlertRow = z.object({
116
110
  severity: alertSeverity,
117
111
  condition: alertRowCondition,
118
112
  state: alertState,
119
- /** `null` only until the first evaluation writes a state; every alert is created `ok` (D2), so in practice this is set from creation onward. */
113
+ /** `null` only until the first evaluation writes a state; every alert is created `ok`, so in practice this is set from creation onward. */
120
114
  state_since: z.iso.datetime().nullable(),
121
115
  /**
122
116
  * The value at the alert's last state transition — written only when the
@@ -142,7 +136,7 @@ export const alertListResponse = z.object({
142
136
  });
143
137
  /**
144
138
  * `GET /api/org/alerts?state=firing` — feeds the overview's "open issues"
145
- * tile and the fleet grid's per-robot badge (D3). Org-scoped and
139
+ * tile and the fleet grid's per-robot badge. Org-scoped and
146
140
  * cross-robot, so each entry carries `robot_name` alongside the alert: the
147
141
  * overview has no robot context of its own to join against.
148
142
  */
@@ -166,7 +160,7 @@ export const orgAlertsQuery = z
166
160
  .meta({ description: 'The query of `GET /api/org/alerts`. One required parameter with one accepted value.' });
167
161
  /**
168
162
  * `GET /api/robots/:id/datapoints/:slug/display` — chart display config from
169
- * the modal's Chart tab (D1, D4). `robot_id`/`slug` live in the path, not
163
+ * the modal's Chart tab. `robot_id`/`slug` live in the path, not
170
164
  * the body; there is exactly one row per `(robot_id, slug)`, so there is
171
165
  * nothing to list or identify beyond the path itself.
172
166
  *
@@ -180,7 +174,7 @@ export const datapointDisplay = z.object({
180
174
  });
181
175
  /**
182
176
  * `PUT /api/robots/:id/datapoints/:slug/display` — applies immediately,
183
- * never published, never sent to the bridge (D1). Same shape as
177
+ * never published, never sent to the bridge. Same shape as
184
178
  * `datapointDisplay`, kept as its own type per house convention (`put*Request`
185
179
  * beside the entity it writes) so the two can diverge if the read side ever
186
180
  * grows a field the write side should not accept.
@@ -1,7 +1,7 @@
1
+ // SPDX-License-Identifier: Apache-2.0
1
2
  import { z } from 'zod';
2
3
  /**
3
- * **App users: the per-app identity space** (spec `2026-09-05-app-user-auth`,
4
- * D1–D7).
4
+ * **App users: the per-app identity space.**
5
5
  *
6
6
  * The 2026-08-29 model put developers and end users into one pool per org,
7
7
  * tied apps to groups, and let an org admin enter an app only by
@@ -20,7 +20,7 @@ import { z } from 'zod';
20
20
  * as unrelated accounts, and a Fleetless user who wants to use an app
21
21
  * registers or is invited like anybody else.
22
22
  *
23
- * **Fleetless shows an app user no page** (D2). The developer's own UI owns
23
+ * **Fleetless shows an app user no page**. The developer's own UI owns
24
24
  * every screen and calls the JSON client-auth API (`client-auth.ts`). The one
25
25
  * Fleetless-rendered surface an app user can reach is the problem page for an
26
26
  * OIDC callback whose state no longer resolves to a redirect URI — every other
@@ -47,12 +47,12 @@ export declare const providerSlug: z.ZodString;
47
47
  /**
48
48
  * **The three states an app user can be in, and the order is the lifecycle.**
49
49
  *
50
- * - `pending_verification` — self-registered, mail sent, cannot log in yet
51
- * (D6). Without this state the domain whitelist would prove nothing: anybody
52
- * could claim any address at an allowed domain.
50
+ * - `pending_verification` — self-registered, mail sent, cannot log in yet.
51
+ * Without this state a domain allow-list would prove nothing: anybody could
52
+ * claim any address at an allowed domain.
53
53
  * - `active` — may log in.
54
54
  * - `blocked` — may not, and every refusal is the same `invalid_credentials`
55
- * a wrong password gets (§4). A block that announced itself would be an
55
+ * a wrong password gets. A block that announced itself would be an
56
56
  * account-enumeration oracle with an extra step.
57
57
  *
58
58
  * `pending_verification` is reached exactly once and left only by spending the
@@ -68,7 +68,7 @@ export type AppUserStatus = z.infer<typeof appUserStatus>;
68
68
  * **A user of one app.** Not a user of the org: `app_id` is the whole scope,
69
69
  * and the uniqueness constraint the cloud enforces is `(app_id, lower(email))`
70
70
  * rather than a global one. The same person at two apps of one org is two
71
- * unrelated rows, by design (D1).
71
+ * unrelated rows, by design.
72
72
  */
73
73
  export declare const appUser: z.ZodObject<{
74
74
  id: z.ZodUUID;
@@ -132,8 +132,8 @@ export type CreateAppUserRequest = z.infer<typeof createAppUserRequest>;
132
132
  * offering it is a refusal rather than a silently dropped field.
133
133
  *
134
134
  * **`status` admits only `active` and `blocked`.** `pending_verification` is
135
- * reached once, by self-registration, and left by spending the mailed token
136
- * (D6). A developer able to set it back could void a verified address without
135
+ * reached once, by self-registration, and left by spending the mailed token.
136
+ * A developer able to set it back could void a verified address without
137
137
  * the user ever seeing a mail, and there is no route out of that state that
138
138
  * does not require a token nobody re-sent. So the narrower enum is the rule,
139
139
  * stated in the schema rather than left to a handler to remember.
@@ -167,7 +167,7 @@ export type CreateAppInvitationRequest = z.infer<typeof createAppInvitationReque
167
167
  * An app that has configured none has nowhere for it to point, so there is no
168
168
  * link to hand back — `null` says that outright, where an absent key would be
169
169
  * indistinguishable from a mapper that dropped the field and a fabricated
170
- * Fleetless-hosted URL would name a page this product does not serve (D2).
170
+ * Fleetless-hosted URL would name a page this product does not serve.
171
171
  */
172
172
  export declare const appInvitation: z.ZodObject<{
173
173
  id: z.ZodUUID;
@@ -216,7 +216,7 @@ export declare const appInvitationListResponse: z.ZodObject<{
216
216
  }, z.core.$strip>;
217
217
  export type AppInvitationListResponse = z.infer<typeof appInvitationListResponse>;
218
218
  /**
219
- * **An app's OIDC provider, as read back** (D4). Any number per app, unlike
219
+ * **An app's OIDC provider, as read back**. Any number per app, unlike
220
220
  * the group provider this replaces — a developer serving two customers needs
221
221
  * two, and the old at-most-one rule was a property of groups rather than of
222
222
  * identity.
@@ -373,16 +373,15 @@ export declare const allowedOrigin: z.ZodString;
373
373
  */
374
374
  export declare const emailDomain: z.ZodString;
375
375
  /**
376
- * **The app's auth settings: one row per app, configured by a Fleetless user**
377
- * (D3).
376
+ * **The app's auth settings: one row per app, configured by a Fleetless user.**
378
377
  *
379
378
  * `self_registration` and `allowed_domains` are **one policy for one
380
379
  * decision** — they govern registration by password and registration through
381
- * an identity provider alike (D4). An invitation always bypasses both, because
380
+ * an identity provider alike. An invitation always bypasses both, because
382
381
  * a developer inviting somebody by hand has already made the decision the
383
382
  * whitelist automates.
384
383
  *
385
- * The four URLs are what makes D2 work: Fleetless mails a link, and the link
384
+ * The four URLs are what makes that work: Fleetless mails a link, and the link
386
385
  * points into the developer's app. An app that has configured none of them
387
386
  * still works for password login — it simply cannot send a mail that leads
388
387
  * anywhere, and `send_mail` is refused rather than silently sending a dead
@@ -420,7 +419,7 @@ export declare const putAppAuthConfigRequest: z.ZodObject<{
420
419
  }, z.core.$strict>;
421
420
  export type PutAppAuthConfigRequest = z.infer<typeof putAppAuthConfigRequest>;
422
421
  /**
423
- * The three mails a developer may replace with their own template (D5).
422
+ * The three mails a developer may replace with their own template.
424
423
  * Mails to *Fleetless* users — a team invitation, a console password reset —
425
424
  * stay Fleetless default and are deliberately not customisable: they are
426
425
  * about this platform, not about the developer's product.
@@ -442,7 +441,7 @@ export type MailTemplateKind = z.infer<typeof mailTemplateKind>;
442
441
  */
443
442
  export declare const MAIL_TEMPLATE_VARIABLES: readonly ["app.name", "org.name", "user.email", "user.display_name", "role.name", "link", "expires_in_hours"];
444
443
  /**
445
- * **The Fleetless default text for the three app mails** (spec D5, §6).
444
+ * **The Fleetless default text for the three app mails.**
446
445
  *
447
446
  * It lives here rather than in the cloud because two products send the same
448
447
  * words: the cloud renders these when an app has no template of its own, and
@@ -476,7 +475,7 @@ export declare const MAIL_TEMPLATE_VARIABLES: readonly ["app.name", "org.name",
476
475
  * that one is written by an authenticated developer about somebody they
477
476
  * invited.
478
477
  *
479
- * **`expires_in_hours` is the only lifetime variable the spec offers**, and
478
+ * **`expires_in_hours` is the only lifetime variable a template gets**, and
480
479
  * the three values are 1, 24 and 168. "The next 168 hours" is not how a person
481
480
  * says a week, so each default converts: 48 and up reads in days, exactly one
482
481
  * reads "1 hour", everything else reads in hours. The conversion is in the