@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
package/CHANGELOG.md CHANGED
@@ -5,15 +5,110 @@ follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the
5
5
  project uses [semantic versioning](https://semver.org/spec/v2.0.0.html) over
6
6
  the wire shapes.
7
7
 
8
+ ## [1.0.3] — 2026-09-07
9
+
10
+ The second half of 1.0.2's sweep. **No wire shape changes**: every file under
11
+ `artifacts/schema-outgoing/` is byte-identical to 1.0.2, as are 226 of the 227
12
+ files under `artifacts/schema/` — the one that moves carries a reworded
13
+ `scopes` description. What changes is who the prose is addressed to.
14
+
15
+ ### Fixed
16
+
17
+ - **Descriptions and comments that spoke inward.** 1.0.2 removed the markers — a
18
+ ticket id, a robot's hostname, a German paragraph — and left the stance. Text
19
+ that named an internal decision label (`D2`, `D7`), pointed at a file in
20
+ another repository (`cloud/src/routes/config.ts`), said "this project" or
21
+ "this repository", cited a document a reader does not have, or explained how
22
+ somebody discovered the behaviour rather than what the behaviour is. All of it
23
+ is rewritten for a reader who has only this package: 13 descriptions and every
24
+ affected doc comment across all 20 modules.
25
+ - **The guard now covers that half too.** `test/published-prose.test.ts` gained
26
+ five patterns — an internal decision label, a path into another repository, a
27
+ reference to this project, a reference to a document the reader does not have,
28
+ and how-it-was-found prose — each with fixtures asserting both what it must
29
+ catch and what it must leave alone, because "caught by the body schema" and
30
+ "the row is found by token hash" are ordinary English and a detector that
31
+ reddens on them is one somebody deletes.
32
+ - **The German detector no longer trips on a URL.** A path segment is not prose,
33
+ and `von`, `bei`, `nach` and `wie` are all ordinary path segments. URLs are
34
+ removed before that scan; a fixture asserts a URL alone stays green and that
35
+ one beside German prose still goes red.
36
+
37
+ ## [1.0.2] — 2026-09-07
38
+
39
+ A documentation and packaging release. (1.0.1 was tagged and never published:
40
+ its `verify` job went red on a licence-header guard that swept the pipeline's
41
+ own scratch file. The tag pattern is protected and cannot be moved, so the
42
+ release carries the next number. Nothing was ever served as 1.0.1.)
43
+
44
+ **No wire shape changes**, and nothing generated from a schema changes either — `artifacts/schema/` and
45
+ `artifacts/schema-outgoing/` are byte-identical to 1.0.0. What changes is what
46
+ the package says about itself.
47
+
48
+ ### Fixed
49
+
50
+ - **The internal engineering prose is gone from the published bytes.** Doc
51
+ comments in the source were written for the people who built this and `tsc`
52
+ carries them into `dist/*.js` and `dist/*.d.ts`, so 1.0.0 shipped German
53
+ paragraphs, a reference robot's workspace paths, measured mesh sizes and
54
+ internal tracker ids to anyone who hovered a symbol in an editor. Every
55
+ comment that ships has been rewritten for a reader who has only this package.
56
+ A test now greps the built `dist/` and `artifacts/` for those markers and
57
+ fails on a hit.
58
+ - **The `artifacts/` table in the README described `schema-outgoing/`
59
+ backwards.** It said those files were the messages the cloud sends to a
60
+ bridge. They are the opposite: the frames a bridge **sends**, rendered in
61
+ output mode, and validating incoming cloud frames against them fails on every
62
+ real frame. The table now says what each directory holds and why there are
63
+ two.
64
+ - **The README claimed the robot-side bridge validates incoming frames against
65
+ `artifacts/schema/` at runtime with Python's `jsonschema`.** It does not. The
66
+ bridge does not depend on this package at all — it vendors its own copies —
67
+ and only its test harness imports `jsonschema`. There is no runtime validation
68
+ of incoming frames against these schemas.
69
+ - **The security reporting address was in nothing the package served.**
70
+ `SECURITY.md` was not in `files`, and the README linked to it relatively.
71
+ `SECURITY.md`, `CONTRIBUTING.md` and `CODE_OF_CONDUCT.md` now ship, and the
72
+ address is written out in the README under its own heading rather than behind
73
+ a link.
74
+ - **Twenty-two of the forty published files carried no SPDX header.**
75
+ Declaration emit drops a leading `//` comment and two `.js` files had theirs
76
+ elided, so more than half of what a licence scanner sees was unmarked. Every
77
+ file in `dist/` now carries `// SPDX-License-Identifier: Apache-2.0`, and the
78
+ pack check asserts it over the tarball's own bytes.
79
+ - **The CHANGELOG's account of 1.0.0 was wrong about which server it matched**
80
+ — see the corrected note below.
81
+
82
+ ### Changed
83
+
84
+ - `zod` moves from `^4.0.0` to `^4.4.3`. It stays a range on purpose, and the
85
+ README now says why: zod is a peer in everything but name, and an exact pin
86
+ forces a second copy on any consumer whose lockfile resolves a different
87
+ patch. `^4.4.3` is the floor these schemas are built, tested and exported
88
+ against, rather than a version nobody has run.
89
+ - `bugs` gains an email address, so a report has somewhere to go from the npm
90
+ page.
91
+
8
92
  ## [1.0.0] — 2026-09-07
9
93
 
10
- The first published version. Nothing about the shapes changes with it: they are
11
- exactly the shapes the Fleetless cloud serves at release 0.17.0, and they have
94
+ The first published version. Nothing about the shapes changes with it: they have
12
95
  been the contract between the cloud, the console, the SDK and the robot-side
13
96
  bridge for as long as those have existed. What changes is who can read them —
14
97
  until now this package was resolvable only from a private git URL, so nobody
15
98
  outside the project could build a Fleetless client from source.
16
99
 
100
+ **Corrected in 1.0.2:** this entry originally said these were "exactly the
101
+ shapes the Fleetless cloud serves at release 0.17.0". They are not. This package
102
+ tracks the cloud's `main` branch, not its releases, and 1.0.0 was cut from a
103
+ commit that already carried the MCP consent-withdrawal change — so the route
104
+ notes and the OpenAPI description for
105
+ `DELETE /api/client/mcp/grants/:clientId` and its developer twin state that
106
+ withdrawal ends a session at the client's very next call, which the deployed
107
+ 0.17.0 does not do. On 0.17.0 a withdrawn client keeps working for the remaining
108
+ lifetime of its access token, up to fifteen minutes. **A shape or a note here may
109
+ precede the release that serves it**; check the platform's own release notes
110
+ before treating one as an operational control.
111
+
17
112
  Consumers should pin an exact version. A range cannot express "compatible with
18
113
  the server you are talking to", and that is the only compatibility question
19
114
  these schemas answer. Any change that makes a previously valid message invalid
@@ -0,0 +1,83 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
6
+
7
+ We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
8
+
9
+ ## Our Standards
10
+
11
+ Examples of behavior that contributes to a positive environment for our community include:
12
+
13
+ * Demonstrating empathy and kindness toward other people
14
+ * Being respectful of differing opinions, viewpoints, and experiences
15
+ * Giving and gracefully accepting constructive feedback
16
+ * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
17
+ * Focusing on what is best not just for us as individuals, but for the overall community
18
+
19
+ Examples of unacceptable behavior include:
20
+
21
+ * The use of sexualized language or imagery, and sexual attention or advances of any kind
22
+ * Trolling, insulting or derogatory comments, and personal or political attacks
23
+ * Public or private harassment
24
+ * Publishing others' private information, such as a physical or email address, without their explicit permission
25
+ * Other conduct which could reasonably be considered inappropriate in a professional setting
26
+
27
+ ## Enforcement Responsibilities
28
+
29
+ Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
30
+
31
+ Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
32
+
33
+ ## Scope
34
+
35
+ This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
36
+
37
+ ## Enforcement
38
+
39
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at hello@fleetless.dev. All complaints will be reviewed and investigated promptly and fairly.
40
+
41
+ All community leaders are obligated to respect the privacy and security of the reporter of any incident.
42
+
43
+ ## Enforcement Guidelines
44
+
45
+ Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
46
+
47
+ ### 1. Correction
48
+
49
+ **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
50
+
51
+ **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
52
+
53
+ ### 2. Warning
54
+
55
+ **Community Impact**: A violation through a single incident or series of actions.
56
+
57
+ **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
58
+
59
+ ### 3. Temporary Ban
60
+
61
+ **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
62
+
63
+ **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
64
+
65
+ ### 4. Permanent Ban
66
+
67
+ **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
68
+
69
+ **Consequence**: A permanent ban from any sort of public interaction within the community.
70
+
71
+ ## Attribution
72
+
73
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
74
+
75
+ Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
76
+
77
+ For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
78
+
79
+ [homepage]: https://www.contributor-covenant.org
80
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
81
+ [Mozilla CoC]: https://github.com/mozilla/diversity
82
+ [FAQ]: https://www.contributor-covenant.org/faq
83
+ [translations]: https://www.contributor-covenant.org/translations
@@ -0,0 +1,136 @@
1
+ # Contributing to `@fleetless/contracts`
2
+
3
+ This package is the single source of wire truth for Fleetless: every shape the
4
+ API accepts or returns, and every message the robot-side bridge exchanges with
5
+ the cloud, is written here once as a zod schema and exported as JSON Schema and
6
+ OpenAPI for consumers that are not TypeScript.
7
+
8
+ That makes a change here a cross-repository change. A schema is not a detail of
9
+ one program; it is the agreement between several.
10
+
11
+ ## Setup
12
+
13
+ Node 22 and pnpm (through corepack):
14
+
15
+ ```sh
16
+ nvm use 22
17
+ corepack enable
18
+ pnpm install
19
+ ```
20
+
21
+ There are no private dependencies. `pnpm install` works from a clean checkout
22
+ with nothing but a network connection to the npm registry.
23
+
24
+ ## The checks
25
+
26
+ | Command | What it does |
27
+ |---|---|
28
+ | `pnpm typecheck` | `tsc --noEmit` over `src/`, then again over `test/` with its own project. Both halves, always — the root project is `src`-only and says nothing about test files. |
29
+ | `pnpm test` | The vitest suite. No network, no server. |
30
+ | `pnpm build` | `tsc` into `dist/`. |
31
+ | `pnpm artifacts` | Regenerates `artifacts/` from the zod schemas. |
32
+ | `pnpm run test:pack` | Packs the tarball, asserts what is and is not inside it, then installs it into a scratch project and imports it for real. |
33
+
34
+ To run one test file, use `pnpm vitest run test/<name>.test.ts`. Do **not**
35
+ use `pnpm test -- <pattern>`: it does not filter, it runs the whole suite, and
36
+ the exit code you read is the suite's.
37
+
38
+ ## Changing a schema
39
+
40
+ 1. Edit the zod schema in `src/`.
41
+ 2. Run `pnpm artifacts`. This regenerates `artifacts/`, which is **committed**.
42
+ A test fails if the committed artifacts are stale, and so does CI.
43
+ 3. Run `pnpm typecheck && pnpm test`.
44
+ 4. Commit the schema change and the regenerated artifacts together. A commit
45
+ that changes one without the other is a commit that publishes a lie to every
46
+ consumer validating against the artifact.
47
+
48
+ Every source file carries the SPDX header `// SPDX-License-Identifier:
49
+ Apache-2.0` as its first line. A test asserts this over the whole set — and the
50
+ directory list is derived from the repository rather than written down, so a new
51
+ top-level directory is swept the day it exists.
52
+
53
+ The published files carry it too, which `tsc` does not do on its own:
54
+ declaration emit drops a leading comment, so `scripts/stamp-dist.mjs` puts the
55
+ header back on every file in `dist/` and `pnpm run test:pack` asserts it over
56
+ the tarball's own bytes.
57
+
58
+ **Nothing internal reaches the published bytes.** Doc comments in `src/` are
59
+ carried into `dist/*.js` and `dist/*.d.ts` by `tsc`, and every
60
+ `.meta({ description })` is copied into the JSON Schema and OpenAPI artifacts —
61
+ so a comment written for the people who build this is a comment an editor shows
62
+ to somebody who installed it. Write a description as what a field means to a
63
+ caller, never as how the behaviour was found, on which machine, or under which
64
+ internal ticket. `test/published-prose.test.ts` greps the built output and fails
65
+ on German prose, an internal ticket id, a developer machine path or a
66
+ first-name attribution.
67
+
68
+ ## Pull requests
69
+
70
+ **Pull requests are welcome on GitHub**, at
71
+ <https://github.com/fleetless/contracts>. Open an issue first for anything that
72
+ changes an existing shape, so we can say what else has to move with it.
73
+
74
+ **CI runs on GitLab.** This repository is mirrored from an internal GitLab
75
+ instance, which is where the pipeline that verifies and publishes it lives. You
76
+ will not see a check run on your GitHub pull request; a maintainer runs the
77
+ same checks, and the result comes back as a review comment. Run `pnpm
78
+ typecheck && pnpm test && pnpm artifacts` yourself before you open the pull
79
+ request and you will have seen everything CI would tell you.
80
+
81
+ ## The CLA
82
+
83
+ The first pull request you open will ask you to sign a Contributor Licence
84
+ Agreement. It grants Dehne Robotik GmbH the right to license your contribution
85
+ under terms other than Apache-2.0 in future — that is the whole of what it is
86
+ for, and it is why the project can relicense its own code later without having
87
+ to find every past contributor. It does not take your copyright away and it
88
+ does not stop you from using your own contribution however you like.
89
+
90
+ ## Commit messages
91
+
92
+ [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/):
93
+ `feat:`, `fix:`, `docs:`, `chore:`, `ci:`, `refactor:`, `test:`. A change that
94
+ alters an existing wire shape is a breaking change; say so with a `!` and a
95
+ `BREAKING CHANGE:` footer, because it decides the next version number.
96
+
97
+ Everything in this repository is written in **English** — code, comments,
98
+ commit messages, documentation.
99
+
100
+ ## Code of conduct
101
+
102
+ By participating you agree to the [Code of Conduct](CODE_OF_CONDUCT.md).
103
+
104
+
105
+ ## Releasing (maintainers)
106
+
107
+ The pipeline publishes, and `npm publish` from a working tree is refused by a
108
+ `prepublishOnly` script — the rule has a mechanism rather than only a sentence.
109
+ (The pipeline is unaffected: it publishes the tarball `verify` packed, and npm
110
+ runs no prepare lifecycle for a tarball argument.)
111
+
112
+ 1. Update `CHANGELOG.md` and set the new version in `package.json`. **CI checks
113
+ both**, in `scripts/verify-version-tag.mjs`: `package.json` must equal the
114
+ tag without its `v`, and `CHANGELOG.md` must carry a dated heading reading
115
+ exactly `## [X.Y.Z] — YYYY-MM-DD`. `CHANGELOG.md` ships inside the tarball,
116
+ so a forgotten entry documents the wrong version to every consumer and npm
117
+ will not take a version back.
118
+
119
+ The same script refuses a release tag that would move npm's `latest`
120
+ backwards — a backported `v1.0.1` published while `latest` is `2.0.0` would
121
+ make `npm i @fleetless/contracts`, the command the README gives outsiders,
122
+ install a package a major version behind.
123
+ 2. Commit, push, and let the `verify` job go green on the branch.
124
+ 3. Tag `vX.Y.Z` (or `vX.Y.Z-beta.N` for a pre-release, which publishes to the
125
+ `next` dist-tag) and push the tag. The tag pipeline runs `verify` again and
126
+ then `publish`.
127
+
128
+ `publish` needs an `NPM_TOKEN` variable, protected and masked. Protected means
129
+ an unprotected ref receives an empty value rather than a missing one, so the
130
+ tag pattern must be protected too; the job names that case before it can fail
131
+ on it obliquely.
132
+
133
+ **If the publish job goes red after `npm publish` has run, do not press
134
+ retry.** npm refuses to republish a version, so a retry fails with a 403 that
135
+ reads like a broken pipeline rather than like a release that already happened.
136
+ Check `npm view @fleetless/contracts@<version>` first.
package/README.md CHANGED
@@ -49,15 +49,28 @@ import openapi from '@fleetless/contracts/artifacts/openapi.json' with { type: '
49
49
 
50
50
  | Path | What it is |
51
51
  |---|---|
52
- | `artifacts/openapi.json` | The OpenAPI description of the REST API. |
53
- | `artifacts/routes.json` | Every route with its request and response schema names. |
54
- | `artifacts/schema/*.json` | One JSON Schema per wire shape, named after the shape. |
55
- | `artifacts/schema-outgoing/*.json` | The messages the cloud sends to a bridge. |
52
+ | `artifacts/openapi.json` | The OpenAPI 3.1 description of the REST API, generated from the route manifest and the schemas. |
53
+ | `artifacts/routes.json` | Every route with its request and response schema names, and the notes that explain each one. |
54
+ | `artifacts/schema/*.json` | One JSON Schema per wire shape, named after the shape, rendered as **a receiver validates an incoming document**. |
55
+ | `artifacts/schema-outgoing/*.json` | Twelve of those same shapes rendered again as **a sender must produce them** — the frames a robot's bridge sends to the cloud. |
56
56
 
57
- The robot-side bridge validates against `artifacts/schema/` with Python's
58
- `jsonschema`, which is why the artifacts are generated and committed rather
59
- than produced at install time: a consumer in another language needs them
60
- without running a TypeScript build.
57
+ The two schema directories are the same shapes in zod's two rendering modes,
58
+ and the difference matters if you are writing a bridge.
59
+
60
+ `artifacts/schema/` is *input* mode: what a receiver accepts. Fleetless
61
+ receivers strip unknown keys rather than refusing them, so these documents omit
62
+ `additionalProperties: false`, and a field with a default is not marked
63
+ required.
64
+
65
+ `artifacts/schema-outgoing/` is *output* mode, for the twelve frames a bridge
66
+ **sends**. There a relaxed schema points the wrong way: a misspelled key in an
67
+ outgoing frame would pass an input-mode check and then be silently dropped by
68
+ the server. Validate what you send against `schema-outgoing/`, and what you
69
+ receive against `schema/`.
70
+
71
+ The artifacts are generated and committed rather than produced at install time
72
+ because a consumer in another language needs them without running a TypeScript
73
+ build.
61
74
 
62
75
  ## Versioning
63
76
 
@@ -69,6 +82,13 @@ client may ignore, or a description is a minor or a patch.
69
82
  Pin an exact version. These shapes describe a running server, and "compatible
70
83
  with the server you are talking to" is not something a range can express.
71
84
 
85
+ **The one dependency, `zod`, is deliberately a range and not a pin.** zod is a
86
+ peer in everything but name: a consumer that imports both this package and zod
87
+ must get one copy, or a schema from here fails an `instanceof` check against
88
+ their own. An exact pin here forces a second copy on anyone whose lockfile
89
+ resolves a different patch. The range floor is the version these schemas are
90
+ built, tested and exported against, and a zod major is a major here too.
91
+
72
92
  ## Documentation
73
93
 
74
94
  The API reference at
@@ -76,12 +96,28 @@ The API reference at
76
96
  generated from these artifacts and is the place to read what a field means.
77
97
  This package is the place to read what it *is*.
78
98
 
79
- ## Contributing and security
99
+ ## Reporting a security issue
100
+
101
+ Email **security@fleetless.dev**. Please do not open a public issue for a
102
+ security report.
103
+
104
+ **A wrong schema is a security report.** If a schema here accepts a value that
105
+ should never reach a server — an identifier that escapes its own grammar, a
106
+ missing bound, a union that admits a shape the API does not expect — that is a
107
+ vulnerability in this package, even though nothing in it executes anything. So
108
+ is a generated artifact that disagrees with the zod schema it came from, because
109
+ consumers that are not TypeScript validate against the artifact and never see
110
+ the zod.
111
+
112
+ `SECURITY.md` ships inside the published package and has the full policy: what
113
+ is in scope, what is not, and the response times we hold ourselves to.
114
+
115
+ ## Contributing
80
116
 
81
- [CONTRIBUTING.md](CONTRIBUTING.md) has the setup, the checks and the pull
82
- request process. [SECURITY.md](SECURITY.md) has the reporting address and what
83
- counts as a security report here — a schema that lets a bad value through is
84
- one, even though nothing in this package executes it.
117
+ `CONTRIBUTING.md` ships inside the published package and has the setup, the
118
+ checks and the pull request process. In short: `pnpm install`, then `pnpm
119
+ typecheck && pnpm test`; every schema change regenerates `artifacts/` with
120
+ `pnpm artifacts` and commits it in the same commit.
85
121
 
86
122
  ## Licence
87
123
 
package/SECURITY.md ADDED
@@ -0,0 +1,55 @@
1
+ # Security policy
2
+
3
+ ## Reporting a vulnerability
4
+
5
+ Email **security@fleetless.dev**. Please do not open a public GitHub issue for
6
+ a security report.
7
+
8
+ Include what you found, the schema or artifact it concerns, and — if you have
9
+ one — a value that the schema accepts and should not, or rejects and should
10
+ not. A minimal reproduction against a published version of this package is the
11
+ most useful thing you can send.
12
+
13
+ We acknowledge every report within **3 working days** and follow up with
14
+ either a fix or a written plan within **30 days**. If a report leads to a
15
+ released fix, we credit you by name unless you ask us not to.
16
+
17
+ ## What is in scope
18
+
19
+ This repository is a schema library. It defines the wire shapes of the
20
+ Fleetless API and the bridge–cloud protocol as zod schemas, and exports them
21
+ as JSON Schema and OpenAPI documents under `artifacts/`.
22
+
23
+ **A wrong schema is a security report.** If a schema accepts a value that
24
+ should never reach a server — an identifier that escapes its own grammar, a
25
+ bound that is missing, a union that admits a shape the API does not expect, a
26
+ field that a validator lets through unchecked — that is a vulnerability in
27
+ this package, even though nothing here executes it. The same is true of a
28
+ generated artifact that disagrees with the zod schema it came from, because
29
+ non-TypeScript consumers validate against the artifact and never see the zod.
30
+
31
+ So, in scope:
32
+
33
+ - the zod schemas in `src/`;
34
+ - the JSON Schema and OpenAPI artifacts in `artifacts/`, including any
35
+ disagreement between an artifact and its source schema;
36
+ - the build and export tooling in `scripts/`;
37
+ - the published npm package `@fleetless/contracts` and its contents.
38
+
39
+ ## What is not in scope
40
+
41
+ **The Fleetless cloud is not in this repository.** A server that fails to
42
+ enforce a schema, an authentication or authorisation flaw, a rate limit, a
43
+ data leak from an API endpoint — none of those live here, and none of them can
44
+ be fixed by a change to this package. Report them to the same address; say
45
+ which service you were looking at, and we will route it. What we cannot do is
46
+ treat this repository's issue tracker as the place where they are tracked.
47
+
48
+ Also out of scope here: the Fleetless console, the bridge, the SDK, the
49
+ documentation site, and any deployment of Fleetless operated by someone else.
50
+
51
+ ## Supported versions
52
+
53
+ The latest published minor of `@fleetless/contracts` receives fixes. Older
54
+ minors do not; a security fix is released as a new patch on the current minor,
55
+ and consumers pin exact versions, so upgrading is the remedy.
@@ -1490,7 +1490,7 @@
1490
1490
  }
1491
1491
  }
1492
1492
  },
1493
- "description": "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.",
1493
+ "description": "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.",
1494
1494
  "requestBody": {
1495
1495
  "required": true,
1496
1496
  "content": {
@@ -3563,7 +3563,7 @@
3563
3563
  }
3564
3564
  }
3565
3565
  },
3566
- "description": "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."
3566
+ "description": "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."
3567
3567
  }
3568
3568
  },
3569
3569
  "/mcp/{appIdentifier}": {
@@ -3643,7 +3643,7 @@
3643
3643
  }
3644
3644
  }
3645
3645
  },
3646
- "description": "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."
3646
+ "description": "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."
3647
3647
  },
3648
3648
  "delete": {
3649
3649
  "operationId": "delete_mcp_appIdentifier",
@@ -3770,7 +3770,7 @@
3770
3770
  }
3771
3771
  }
3772
3772
  },
3773
- "description": "RFC 8414, for the issuer `<PUBLIC_API_BASE_URL>/mcp/<identifier>` — the same path rule as the document above, and the same `404` for a switched-off app. `registration_endpoint` is present for the reason the central document states: a client that finds it registers itself and never asks a person for a `client_id`. \n\n**`issuer`, `token_endpoint` and the resource identifier are minted from the canonical public base, never from the friendly `mcp.fleetless.dev` alias or the request's `Host`**, because a client checks a minted token's `iss` and `aud` against these exact strings. \n\n**Unlike the central document, `authorization_endpoint` does not move to an auth-portal origin**, and there is nothing here for one to serve: this authorization step renders no Fleetless page at all. It redirects to the app's own `mcp_login_url` (D7), which is on the developer's origin already."
3773
+ "description": "RFC 8414, for the issuer `<PUBLIC_API_BASE_URL>/mcp/<identifier>` — the same path rule as the document above, and the same `404` for a switched-off app. `registration_endpoint` is present for the reason the central document states: a client that finds it registers itself and never asks a person for a `client_id`. \n\n**`issuer`, `token_endpoint` and the resource identifier are minted from the canonical public base, never from the friendly `mcp.fleetless.dev` alias or the request's `Host`**, because a client checks a minted token's `iss` and `aud` against these exact strings. \n\n**Unlike the central document, `authorization_endpoint` does not move to an auth-portal origin**, and there is nothing here for one to serve: this authorization step renders no Fleetless page at all. It redirects to the app's own `mcp_login_url`, which is on the developer's origin already."
3774
3774
  }
3775
3775
  },
3776
3776
  "/mcp/{appIdentifier}/oauth/register": {
@@ -3814,7 +3814,7 @@
3814
3814
  }
3815
3815
  }
3816
3816
  },
3817
- "description": "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.",
3817
+ "description": "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.",
3818
3818
  "requestBody": {
3819
3819
  "required": true,
3820
3820
  "content": {
@@ -3929,7 +3929,7 @@
3929
3929
  }
3930
3930
  }
3931
3931
  },
3932
- "description": "The same query as `GET /mcp/oauth/authorize`, read the same way — parameter by parameter, because the answers differ and one parse would collapse them. \n\n**Fleetless renders no page here, and that is the whole of 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."
3932
+ "description": "The same query as `GET /mcp/oauth/authorize`, read the same way — parameter by parameter, because the answers differ and one parse would collapse them. \n\n**Fleetless renders no page here**, and that is the whole of it. The route writes an interaction — ten minutes, as the OIDC ones live — and redirects to `appAuthConfig.mcp_login_url` with `{interaction}` filled in. The app then authenticates the person with its own UI, reads `GET /api/client/mcp/interactions/:id` to show the client's claimed name and the scopes it asked for, and calls approve or deny. \n\nClient and `redirect_uri` are validated first and a failure there never redirects — the open-redirect discipline `GET /mcp/oauth/authorize` and `GET /api/client/oidc/:slug/start` both keep — and those refusals are RFC 6749's flat `oauthError`, which is why none of them appear above. `redirect_uri` is matched **exactly** against the registration, with no loopback-port wildcard: every client here registered itself minutes ago and can name the port it bound, so a wildcard would only widen where a stolen `client_id` may send a browser. \n\nThe two codes above are the `apiError` envelope because they are refusals about the **app**, decided before an OAuth parameter is looked at. **`404 not_found` covers an identifier no app carries AND an app with MCP switched off** — the same single answer the two metadata documents, `register` and the transport give. An earlier draft answered `403 mcp_disabled` here, on the argument that a client which registered while the switch was on is owed the difference between \"turned off\" and \"mistyped\"; that argument does not survive the caller being anonymous. This route takes no credential, so the extra code was readable by anyone who could type an identifier, and it handed back precisely the existence distinction every neighbouring route collapses. `mcp_disabled` survives only where the caller has already proved they belong to the app — the two decision routes under `/api/client/mcp/interactions/:id`. `409 target_state_conflict` names `mcp_login_url` with rule `not_set`: MCP is enabled and no page is configured to send the person to. It is the same code and the same shape `send_mail` answers for an unconfigured `invite_url`, and the refusal is the honest one — Fleetless has nowhere to redirect, and rendering a page of its own would contradict the rule that Fleetless shows an app user no page."
3933
3933
  }
3934
3934
  },
3935
3935
  "/mcp/{appIdentifier}/oauth/token": {
@@ -4644,7 +4644,7 @@
4644
4644
  }
4645
4645
  }
4646
4646
  },
4647
- "description": "**One callback URL for every app and every provider**, and the value of `appAuthConfig.oidc_callback_url` — the string a developer registers at their IdP. `CLIENT_OIDC_CALLBACK_PATH` in `client-auth.ts` is the single spelling of this path; the URL is that path on the cloud's canonical public base, never a friendly alias, because the provider compares the redirect target against the one string it was given. \n\n**Rate limited per ip, generously.** The first draft left this route unlimited on the argument that the caller is an identity provider redirecting somebody's browser, so a limiter would drop real sign-ins on the strength of traffic none of those people sent. The cost of that argument is a `state` obtained from one `start` being replayable for the interaction's full ten minutes, unbounded and unauthenticated, with every replay driving a server-side POST to the developer's token endpoint and one audit row into their org — an amplifier against a third party. The interaction is now spent on **every** terminal outcome, refusals included, which closes the replay itself; the limiter is the ceiling on how fast the attempts may arrive at all. It is per ip and sized for a browser, so a person completing a sign-in never meets it. `429 rate_limited` is the one `apiError` this route can answer, and it is not a sign-in outcome — it is a refusal to begin the work, which is why it does not ride back to the app as an `?error=`. \n\nThe query is the **provider's** rather than a Fleetless shape, and `clientOidcCallbackQuery` describes it **without being strict**: `state` always, `code` on success, `error` and `error_description` on the provider's own refusal, and whatever else that provider adds — RFC 9207's `iss`, a `session_state`, a vendor field. Refusing those would refuse conforming providers, the trap `POST /mcp/oauth/register` documents avoiding. `state` is the required field because it is the only one Fleetless minted. \n\n**It lists `rate_limited` and no other code, because every sign-in outcome it has is a redirect.** Success and failure alike are a `302` to the app's own `redirect_uri`: `?code=…&state=…` when a session was resolved, `?error=<clientOidcErrorCode>&state=…` when it was not, so the app renders its own message and can bind either answer to the request it started. Fleetless shows an app user no page (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."
4647
+ "description": "**One callback URL for every app and every provider**, and the value of `appAuthConfig.oidc_callback_url` — the string a developer registers at their IdP. `CLIENT_OIDC_CALLBACK_PATH` in `client-auth.ts` is the single spelling of this path; the URL is that path on the cloud's canonical public base, never a friendly alias, because the provider compares the redirect target against the one string it was given. \n\n**Rate limited per ip, generously.** The first draft left this route unlimited on the argument that the caller is an identity provider redirecting somebody's browser, so a limiter would drop real sign-ins on the strength of traffic none of those people sent. The cost of that argument is a `state` obtained from one `start` being replayable for the interaction's full ten minutes, unbounded and unauthenticated, with every replay driving a server-side POST to the developer's token endpoint and one audit row into their org — an amplifier against a third party. The interaction is now spent on **every** terminal outcome, refusals included, which closes the replay itself; the limiter is the ceiling on how fast the attempts may arrive at all. It is per ip and sized for a browser, so a person completing a sign-in never meets it. `429 rate_limited` is the one `apiError` this route can answer, and it is not a sign-in outcome — it is a refusal to begin the work, which is why it does not ride back to the app as an `?error=`. \n\nThe query is the **provider's** rather than a Fleetless shape, and `clientOidcCallbackQuery` describes it **without being strict**: `state` always, `code` on success, `error` and `error_description` on the provider's own refusal, and whatever else that provider adds — RFC 9207's `iss`, a `session_state`, a vendor field. Refusing those would refuse conforming providers, the trap `POST /mcp/oauth/register` documents avoiding. `state` is the required field because it is the only one Fleetless minted. \n\n**It lists `rate_limited` and no other code, because every sign-in outcome it has is a redirect.** Success and failure alike are a `302` to the app's own `redirect_uri`: `?code=…&state=…` when a session was resolved, `?error=<clientOidcErrorCode>&state=…` when it was not, so the app renders its own message and can bind either answer to the request it started. Fleetless shows an app user no page. \n\n**The one exception is a `state` that resolves to no interaction** — unknown, hand-edited, or past its ten minutes. Then there is no confirmed redirect target to carry the answer to, and bouncing a browser to an unvalidated one is the hole the whole flow is arranged to avoid, so the cloud renders an HTML problem page at `400`. That is the only Fleetless-rendered surface an app user can reach. It is HTML rather than an `apiError`, which is why no code is listed: a code here would document an envelope no caller receives, and this manifest's other HTML pages (`GET /mcp/oauth/interaction/:id`, `GET /console/oauth/interaction/:id`) say their status in prose for the same reason."
4648
4648
  }
4649
4649
  },
4650
4650
  "/api/client/oidc/exchange": {
@@ -4791,7 +4791,7 @@
4791
4791
  }
4792
4792
  }
4793
4793
  },
4794
- "description": "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."
4794
+ "description": "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."
4795
4795
  }
4796
4796
  },
4797
4797
  "/api/client/mcp/interactions/{id}/deny": {
@@ -4889,7 +4889,7 @@
4889
4889
  }
4890
4890
  }
4891
4891
  },
4892
- "description": "**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."
4892
+ "description": "**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."
4893
4893
  }
4894
4894
  },
4895
4895
  "/api/client/mcp/grants/{clientId}": {
@@ -6406,7 +6406,7 @@
6406
6406
  }
6407
6407
  }
6408
6408
  },
6409
- "description": "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."
6409
+ "description": "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."
6410
6410
  }
6411
6411
  },
6412
6412
  "/api/robots/{id}/jobs/{slug}": {
@@ -12643,7 +12643,7 @@
12643
12643
  "email",
12644
12644
  "profile"
12645
12645
  ],
12646
- "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.",
12646
+ "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.",
12647
12647
  "minItems": 1,
12648
12648
  "maxItems": 20,
12649
12649
  "type": "array",