@fleetless/contracts 1.0.4 → 1.0.6

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 (91) hide show
  1. package/CHANGELOG.md +68 -3
  2. package/CONTRIBUTING.md +100 -75
  3. package/README.md +69 -83
  4. package/SECURITY.md +24 -24
  5. package/artifacts/openapi.json +67 -67
  6. package/artifacts/routes.json +5 -5
  7. package/artifacts/schema/app-list-response.schema.json +2 -2
  8. package/artifacts/schema/app-oidc-provider-list-response.schema.json +1 -1
  9. package/artifacts/schema/app-oidc-provider.schema.json +1 -1
  10. package/artifacts/schema/app-user-list-response.schema.json +2 -2
  11. package/artifacts/schema/app-user.schema.json +2 -2
  12. package/artifacts/schema/app.schema.json +2 -2
  13. package/artifacts/schema/asset-list-response.schema.json +7 -7
  14. package/artifacts/schema/asset-sync-request.schema.json +1 -1
  15. package/artifacts/schema/asset-sync-status.schema.json +1 -1
  16. package/artifacts/schema/asset.schema.json +3 -3
  17. package/artifacts/schema/auth-me-response.schema.json +2 -2
  18. package/artifacts/schema/auth-ok.schema.json +1 -1
  19. package/artifacts/schema/authorization-server-metadata.schema.json +1 -1
  20. package/artifacts/schema/bridge-asset-progress.schema.json +1 -1
  21. package/artifacts/schema/busy-details.schema.json +3 -3
  22. package/artifacts/schema/client-identity.schema.json +1 -1
  23. package/artifacts/schema/client-login-request.schema.json +1 -1
  24. package/artifacts/schema/client-logout-request.schema.json +1 -1
  25. package/artifacts/schema/client-mcp-interaction.schema.json +1 -1
  26. package/artifacts/schema/cloud-config.schema.json +1 -1
  27. package/artifacts/schema/command-result.schema.json +3 -3
  28. package/artifacts/schema/config-draft-response.schema.json +1 -1
  29. package/artifacts/schema/config-version-response.schema.json +1 -1
  30. package/artifacts/schema/create-server-key-response.schema.json +1 -1
  31. package/artifacts/schema/datapoint-config.schema.json +1 -1
  32. package/artifacts/schema/datapoint-value.schema.json +2 -2
  33. package/artifacts/schema/dynamic-client-registration-request.schema.json +2 -2
  34. package/artifacts/schema/fleetless-user-list-response.schema.json +2 -2
  35. package/artifacts/schema/fleetless-user.schema.json +2 -2
  36. package/artifacts/schema/invoke-or-service-response.schema.json +4 -4
  37. package/artifacts/schema/invoke-response.schema.json +3 -3
  38. package/artifacts/schema/job-actor.schema.json +1 -1
  39. package/artifacts/schema/job-event.schema.json +3 -3
  40. package/artifacts/schema/job-response.schema.json +3 -3
  41. package/artifacts/schema/job-run-list-response.schema.json +2 -2
  42. package/artifacts/schema/job-run.schema.json +2 -2
  43. package/artifacts/schema/job.schema.json +3 -3
  44. package/artifacts/schema/mcp-consent-grant-list-response.schema.json +2 -2
  45. package/artifacts/schema/mcp-consent-grant.schema.json +2 -2
  46. package/artifacts/schema/oauth-authorize-query.schema.json +1 -1
  47. package/artifacts/schema/oauth-token-request.schema.json +1 -1
  48. package/artifacts/schema/patch-org-response.schema.json +1 -1
  49. package/artifacts/schema/patch-robot-response.schema.json +1 -1
  50. package/artifacts/schema/robot-config-doc.schema.json +1 -1
  51. package/artifacts/schema/robot-jobs-response.schema.json +3 -3
  52. package/artifacts/schema/role-list-response.schema.json +1 -1
  53. package/artifacts/schema/role.schema.json +1 -1
  54. package/artifacts/schema/server-key-list-response.schema.json +2 -2
  55. package/artifacts/schema/server-key.schema.json +1 -1
  56. package/artifacts/schema/service-call-response.schema.json +1 -1
  57. package/artifacts/schema/sign-up-response.schema.json +2 -2
  58. package/artifacts/schema/urdf-completeness.schema.json +2 -2
  59. package/artifacts/schema-outgoing/bridge-asset-progress.schema.json +1 -1
  60. package/dist/alerts.d.ts +16 -18
  61. package/dist/alerts.js +16 -18
  62. package/dist/app-users.d.ts +5 -6
  63. package/dist/app-users.js +9 -10
  64. package/dist/apps.d.ts +5 -6
  65. package/dist/apps.js +11 -12
  66. package/dist/assets.js +10 -13
  67. package/dist/audit.d.ts +10 -12
  68. package/dist/audit.js +14 -17
  69. package/dist/client-auth.d.ts +8 -9
  70. package/dist/client-auth.js +14 -15
  71. package/dist/config-issues.d.ts +5 -5
  72. package/dist/config-issues.js +5 -5
  73. package/dist/config.d.ts +6 -7
  74. package/dist/config.js +12 -13
  75. package/dist/errors.d.ts +4 -4
  76. package/dist/errors.js +15 -16
  77. package/dist/identity.d.ts +6 -7
  78. package/dist/identity.js +9 -10
  79. package/dist/index.d.ts +1 -1
  80. package/dist/index.js +1 -1
  81. package/dist/jobs.js +5 -5
  82. package/dist/mcp.d.ts +5 -8
  83. package/dist/mcp.js +2 -2
  84. package/dist/oauth.d.ts +8 -10
  85. package/dist/oauth.js +13 -15
  86. package/dist/protocol.d.ts +7 -8
  87. package/dist/protocol.js +20 -22
  88. package/dist/realtime.js +4 -4
  89. package/dist/rest.js +4 -4
  90. package/dist/routes.js +7 -7
  91. package/package.json +2 -1
package/dist/errors.js CHANGED
@@ -18,10 +18,10 @@ export const apiError = z.object({
18
18
  * console renders a third, and each is right in its own tests.
19
19
  *
20
20
  * `field` is the **flat key exactly as the caller sent it** — the same string
21
- * as the `parameterSpec.name` it violated. That is the entire justification
22
- * for the flat parameter form: a refusal has to name something the caller can
23
- * find in what they typed, and a console can attach the error to that one
24
- * input rather than to the form.
21
+ * as the `parameterSpec.name` it violated. That is why the parameter form
22
+ * stays flat: a refusal has to name something the caller can find in what
23
+ * they typed, and a console can attach the error to that one input rather
24
+ * than to the form.
25
25
  */
26
26
  export const parameterViolation = z.object({
27
27
  field: z.string().min(1),
@@ -81,11 +81,10 @@ export const ERROR_CODES = [
81
81
  'invalid_credentials',
82
82
  'token_expired',
83
83
  'token_revoked',
84
- /* `invite_expired` and `invite_used` were removed on 2026-09-05, by the same
85
- * reasoning that removed `not_a_member` and with the same evidence: a `grep`
86
- * across contracts, cloud, sdk, console and bridge found their own entries
87
- * here and one test asserting those entries existed. Nothing has ever emitted
88
- * either.
84
+ /* `invite_expired` and `invite_used` were removed on 2026-09-05 — same
85
+ * reasoning and evidence as `not_a_member`: a `grep` across contracts, cloud,
86
+ * sdk, console and bridge found only their own entries here and one test
87
+ * asserting those entries existed. Nothing has ever emitted either.
89
88
  *
90
89
  * They were written for `POST /api/client/invitations/accept`, to tell an
91
90
  * expired invitation from an already-accepted one. That route answers `410
@@ -420,14 +419,14 @@ export const ERROR_CODES = [
420
419
  * no producer is not harmless, because a reader arriving at it takes it for
421
420
  * a live refusal. Say which it is, and say when it changes.
422
421
  *
423
- * **It has one producer already, one train early**: `POST /mcp` answers it to
424
- * an `mcp_session` token whose subject is an app user, because the central
425
- * endpoint serves the team only. No client can hold such a token yet — only
426
- * a test mints one — and the per-app train adds the
427
- * `appAuthConfig.mcp_enabled` gate this comment describes.
422
+ * **It has one producer already, ahead of the gate it describes**: `POST
423
+ * /mcp` answers it to an `mcp_session` token whose subject is an app user,
424
+ * because the central endpoint serves the team only. No client can hold such
425
+ * a token yet — only a test mints one — and the `appAuthConfig.mcp_enabled`
426
+ * gate arrives with the per-app endpoint.
428
427
  *
429
428
  * `tool_not_available` below still has no producer — `grep` finds it nowhere
430
- * in `cloud/src`. Named as unproduced, for the same reason.
429
+ * in the cloud's source. Named as unproduced, for the same reason.
431
430
  */
432
431
  'mcp_disabled',
433
432
  /**
@@ -536,7 +535,7 @@ export const ERROR_CODES = [
536
535
  // what the cloud actually sends.
537
536
  //
538
537
  // They are listed in one block, with their producer named, rather than filed
539
- // among the waves that introduced them — the honest record is *when this list
538
+ // by the release that introduced each — the honest record is *when this list
540
539
  // learned about them*, not when the cloud started sending them.
541
540
  /**
542
541
  * `409` from the configuration routes: the draft parses as YAML but its root
@@ -19,11 +19,10 @@ import { z } from 'zod';
19
19
  *
20
20
  * **What that deleted, with no successor**: groups and the Org Admins
21
21
  * group, app assignments, impersonation, the per-user MCP override and the
22
- * org-level federation policy. The 2026-08-29 model had put developers and end
23
- * users into one pool per org and connected them with all of the above; in use
24
- * it turned out to model the wrong thing — the two populations have different
25
- * lifecycles, and every mechanism joining them was cost without a product
26
- * reason.
22
+ * org-level federation policy. The 2026-08-29 model pooled developers and end
23
+ * users per org and joined them with all of the above. In use, wrong model:
24
+ * the two populations have different lifecycles, and every joining mechanism
25
+ * was cost without a product reason.
27
26
  *
28
27
  * What did **not** change: the password rules, the session and token shapes,
29
28
  * and the enumeration-oracle reasoning on password reset. None of those was
@@ -502,8 +501,8 @@ export type IdpIssuer = z.infer<typeof idpIssuer>;
502
501
  * The per-app OIDC vocabulary is `clientOidcErrorCode` in `client-auth.ts`: a
503
502
  * different list, for a different flow, redirected to the developer's own page
504
503
  * rather than rendered by Fleetless. Two callback error enums coexisting is how
505
- * the wrong one gets picked up by the train that adds per-app OIDC, which is
506
- * why this one is deleted rather than left standing for it.
504
+ * the wrong one gets picked up when per-app OIDC arrives, which is why this
505
+ * one is deleted rather than left standing for it.
507
506
  */
508
507
  /**
509
508
  * `GET /api/auth/me` — named so the console can validate it.
package/dist/identity.js CHANGED
@@ -19,11 +19,10 @@ import { z } from 'zod';
19
19
  *
20
20
  * **What that deleted, with no successor**: groups and the Org Admins
21
21
  * group, app assignments, impersonation, the per-user MCP override and the
22
- * org-level federation policy. The 2026-08-29 model had put developers and end
23
- * users into one pool per org and connected them with all of the above; in use
24
- * it turned out to model the wrong thing — the two populations have different
25
- * lifecycles, and every mechanism joining them was cost without a product
26
- * reason.
22
+ * org-level federation policy. The 2026-08-29 model pooled developers and end
23
+ * users per org and joined them with all of the above. In use, wrong model:
24
+ * the two populations have different lifecycles, and every joining mechanism
25
+ * was cost without a product reason.
27
26
  *
28
27
  * What did **not** change: the password rules, the session and token shapes,
29
28
  * and the enumeration-oracle reasoning on password reset. None of those was
@@ -78,7 +77,7 @@ export const org = z.object({
78
77
  /** What `PATCH /api/org` answers: the org as it now stands. */
79
78
  export const patchOrgResponse = z.object({
80
79
  org: org.meta({
81
- description: 'The organisation as it now stands, after the patch was applied. The whole resource comes back, not only the fields that changed.',
80
+ description: 'The organisation as it now stands, after the patch. The whole resource comes back, not only the changed fields.',
82
81
  }),
83
82
  });
84
83
  /**
@@ -104,7 +103,7 @@ export const fleetlessUser = z.object({
104
103
  description: 'The Fleetless user in the API, assigned by the cloud and stable for the life of the account.',
105
104
  }),
106
105
  org_id: z.uuid().meta({
107
- description: 'The organisation this person belongs to. Every developer route is already scoped to the caller\'s org, so this confirms what a client is looking at rather than being a filter it applies.',
106
+ description: 'The organisation this person belongs to. Every developer route is already scoped to the caller\'s org, so this confirms what a client is looking at, not a filter it applies.',
108
107
  }),
109
108
  email: z.email().meta({
110
109
  description: 'The address the account is identified by, **globally unique** across every organisation. Immutable after creation: it is what every invitation, reset link and audit line names.',
@@ -113,7 +112,7 @@ export const fleetlessUser = z.object({
113
112
  description: 'Optional human name, shown by the console instead of the address where present. Self-service through `PATCH /api/auth/me`; never used for authentication. `null` when the person never supplied one.',
114
113
  }),
115
114
  tier: orgAdminTier.meta({
116
- description: 'The console powers this person holds. **Required** — every Fleetless user is a member of the team and has a tier; the optional version of this field existed only while the org also held people with no console powers to grade, and that pool is gone.',
115
+ description: 'The console powers this person holds. **Required** — every Fleetless user has a tier; it was optional only while the org also held people with no console powers to grade, and that pool is gone.',
117
116
  }),
118
117
  created_at: z.iso.datetime().meta({
119
118
  description: 'When the account was created, as an ISO 8601 timestamp.',
@@ -479,8 +478,8 @@ export const idpIssuer = z
479
478
  * The per-app OIDC vocabulary is `clientOidcErrorCode` in `client-auth.ts`: a
480
479
  * different list, for a different flow, redirected to the developer's own page
481
480
  * rather than rendered by Fleetless. Two callback error enums coexisting is how
482
- * the wrong one gets picked up by the train that adds per-app OIDC, which is
483
- * why this one is deleted rather than left standing for it.
481
+ * the wrong one gets picked up when per-app OIDC arrives, which is why this
482
+ * one is deleted rather than left standing for it.
484
483
  */
485
484
  /**
486
485
  * `GET /api/auth/me` — named so the console can validate it.
package/dist/index.d.ts CHANGED
@@ -15,7 +15,7 @@ export type { ParameterType, ParameterSpec, ActionConfig, ServiceConfig, Publish
15
15
  /**
16
16
  * The one account of what is wrong with a configuration document, shared by
17
17
  * every layer that reports on one. See `config-issues.ts`'s header for why a
18
- * second copy of this vocabulary is a defect rather than a convenience.
18
+ * second copy of this vocabulary is a defect, not a convenience.
19
19
  */
20
20
  export { DOCUMENT_ROOT_PATH, EXPOSURE_SECTIONS, schemaIssues, formatPath, splitFormatPath, configSchemaHash, } from './config-issues.js';
21
21
  export type { SchemaIssue, ExposureSection } from './config-issues.js';
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ cameraSource, cameraCredentials, } from './config.js';
16
16
  /**
17
17
  * The one account of what is wrong with a configuration document, shared by
18
18
  * every layer that reports on one. See `config-issues.ts`'s header for why a
19
- * second copy of this vocabulary is a defect rather than a convenience.
19
+ * second copy of this vocabulary is a defect, not a convenience.
20
20
  */
21
21
  export { DOCUMENT_ROOT_PATH, EXPOSURE_SECTIONS, schemaIssues, formatPath, splitFormatPath, configSchemaHash, } from './config-issues.js';
22
22
  export { rosGraphEntry, rosGraph, typeField, typeDefinition, parameterFieldsOf } from './introspection.js';
package/dist/jobs.js CHANGED
@@ -21,17 +21,17 @@ import { slug, wireSeqCursor, wireTimestampMs } from './common.js';
21
21
  export const jobState = z.enum(['running', 'succeeded', 'failed', 'cancelled', 'lost']);
22
22
  export const job = z.object({
23
23
  id: z.uuid().meta({
24
- description: 'The job\'s id, minted by the cloud when the invocation is accepted. Informative: state is observed by slug, and this id is what a cancel names when a caller wants to stop one specific job rather than whatever is running.',
24
+ description: 'The job\'s id, minted by the cloud when the invocation is accepted. Informative — state is observed by slug; a cancel names this id to stop one specific job rather than whatever is running.',
25
25
  }),
26
26
  robot_id: z.uuid().meta({ description: 'The robot this job is running on.' }),
27
27
  slug: slug.meta({
28
28
  description: 'The action or service this job is running, as the published configuration exposes it. One slug carries one job at a time, so every observer of that slug sees the same one.',
29
29
  }),
30
30
  state: jobState.meta({
31
- description: 'Where the job stands: `running`, `succeeded`, `failed`, `cancelled` or `lost`. `lost` is a real outcome — the bridge restarted mid-job and the result is gone — and is said out loud rather than left reading `running` because nobody contradicted it.',
31
+ description: 'Where the job stands: `running`, `succeeded`, `failed`, `cancelled` or `lost`. `lost` is a real outcome — the bridge restarted mid-job and the result is gone — stated rather than left reading `running` by default.',
32
32
  }),
33
33
  started_at: z.iso.datetime().meta({
34
- description: 'When the cloud minted this job, as an ISO 8601 timestamp. For a job adopted from a reconnecting bridge it is **adoption time**, not the real start, because the cloud never minted it and has no honest alternative.',
34
+ description: 'When the cloud minted this job, as an ISO 8601 timestamp. For a job adopted from a reconnecting bridge, this is **adoption time**, not the real start — the cloud never minted it.',
35
35
  }),
36
36
  updated_at: z.iso.datetime().meta({
37
37
  description: 'When this job last changed, as an ISO 8601 timestamp.',
@@ -176,7 +176,7 @@ export const JOB_RUN_RETENTION_DAYS = 90;
176
176
  */
177
177
  export const jobActor = z.object({
178
178
  kind: z.enum(['developer', 'end_user', 'app_user', 'server_key']).meta({
179
- description: 'What the caller was acting as: a `developer` in the console, an `app_user` of one app, or a `server_key` used by server-side code. A bridge invokes nothing, so it is deliberately not a case here. `end_user` appears only on runs recorded before app users replaced the organisation-wide user pool — it is kept so a history page can still render them, and nothing writes it any more.',
179
+ description: 'What the caller was acting as: a `developer` in the console, an `app_user` of one app, or a `server_key` used by server-side code. A bridge invokes nothing, so it is deliberately not a case here. `end_user` appears only on runs recorded before app users replaced the organisation-wide user pool — kept so old runs still render; nothing writes it now.',
180
180
  }),
181
181
  id: z.uuid().meta({
182
182
  description: 'The id of the Fleetless user, app user or server key that invoked the run.',
@@ -200,7 +200,7 @@ export const jobRunKind = z.enum(['action', 'service']);
200
200
  */
201
201
  export const jobRun = z.object({
202
202
  id: z.uuid().meta({
203
- description: 'The run\'s id, which is the same id the invocation was answered with — so a caller that kept a job id can find its durable record here later.',
203
+ description: 'The run\'s id — the same id the invocation was answered with, so a caller that kept a job id can find its durable record here later.',
204
204
  }),
205
205
  robot_id: z.uuid().meta({ description: 'The robot the run happened on.' }),
206
206
  slug: slug.meta({
package/dist/mcp.d.ts CHANGED
@@ -8,8 +8,8 @@ import { z } from 'zod';
8
8
  * **This file describes the seam, not the protocol.** The MCP messages
9
9
  * themselves (`initialize`, `tools/list`, `tools/call`) are defined by the
10
10
  * Model Context Protocol and implemented with its official SDK — writing our
11
- * own zod copies of them would create a second source of truth for somebody
12
- * else's specification, which is the one thing this package exists to avoid.
11
+ * own zod copies of them would duplicate somebody else's specification, the
12
+ * one thing this package exists to avoid.
13
13
  * What lives here is what *Fleetless* decides: which revision we speak, where
14
14
  * the endpoint is, how a tool is named, and what the console is shown before
15
15
  * an end user ever connects.
@@ -94,12 +94,9 @@ export declare function mcpAppEndpointPath(appIdentifier: string): string;
94
94
  * ever fetch. Writing them here is what keeps that reading from being made
95
95
  * twice.
96
96
  *
97
- * **These are paths, not URLs.** Append them to `PUBLIC_API_BASE_URL`, for the
98
- * reason `mcpAppEndpointPath` states: the cloud mints every issuer, resource
99
- * and `aud` from the canonical base and compares a token's `aud` against that
100
- * string, never against the request's `Host`. The friendly alias is a proxy in
101
- * front of the same cloud, and a URL built on it hands a client an audience
102
- * the token endpoint will refuse.
97
+ * **These are paths, not URLs.** Append them to `PUBLIC_API_BASE_URL`, not the
98
+ * friendly `mcp.fleetless.dev` alias — see `mcpAppEndpointPath` for why: a URL
99
+ * built on the alias carries an audience the token endpoint refuses.
103
100
  *
104
101
  * Named in the shape `OAUTH_PATHS` had, and deliberately a **function** rather
105
102
  * than the object that constant was: there is one set of these per app, and a
package/dist/mcp.js CHANGED
@@ -9,8 +9,8 @@ import { slug } from './common.js';
9
9
  * **This file describes the seam, not the protocol.** The MCP messages
10
10
  * themselves (`initialize`, `tools/list`, `tools/call`) are defined by the
11
11
  * Model Context Protocol and implemented with its official SDK — writing our
12
- * own zod copies of them would create a second source of truth for somebody
13
- * else's specification, which is the one thing this package exists to avoid.
12
+ * own zod copies of them would duplicate somebody else's specification, the
13
+ * one thing this package exists to avoid.
14
14
  * What lives here is what *Fleetless* decides: which revision we speak, where
15
15
  * the endpoint is, how a tool is named, and what the console is shown before
16
16
  * an end user ever connects.
package/dist/oauth.d.ts CHANGED
@@ -14,8 +14,8 @@ import { z } from 'zod';
14
14
  * and per app for an app's users — and the console's own OAuth portal, which
15
15
  * answers `oauthRedirectResponse` at its login and sign-up steps.
16
16
  *
17
- * The client model this file was written to get right is still the important
18
- * part, and it survived the cut intact: an MCP client **registers itself**
17
+ * The client model this file was written to get right survived the cut
18
+ * intact: an MCP client **registers itself**
19
19
  * (RFC 7591) because the person only ever pastes a URL into an AI tool.
20
20
  * Nobody vetted it, its redirect URIs arrive from the client itself, and the
21
21
  * tools it will call move a physical robot. That is why consent names the
@@ -37,10 +37,9 @@ import { z } from 'zod';
37
37
  * app's auth settings — are ordinary console API and use `apiError` with
38
38
  * `ERROR_CODES` like everything else.
39
39
  *
40
- * So: **two shapes, split by audience, not by accident.** Written down here
41
- * because the natural instinct on finding two error formats in one server is
42
- * to unify them, and doing so silently removes the reason the standard one is
43
- * there.
40
+ * So: **two shapes, split by audience, not by accident.** Written down
41
+ * because two error formats in one server invite unifying them — which would
42
+ * silently erase the reason the standard one exists.
44
43
  *
45
44
  * **The split is by audience and the path prefix will mislead you.**
46
45
  * `/mcp/oauth/consent` sits under an `/oauth/` segment and is nevertheless an
@@ -198,10 +197,9 @@ export type DynamicClientRegistrationResponse = z.infer<typeof dynamicClientRegi
198
197
  * from a client we are about to trust, where an unknown key is a caller
199
198
  * assuming a feature into existence, while a token request comes from any
200
199
  * RFC-compliant client, which may legitimately send parameters this server
201
- * does not read. Refusing those would be a conformance bug. The consequence
202
- * is worth stating because it bit the test for this very schema: unknown keys
203
- * are **stripped**, so `safeParse().success` cannot tell a present field from
204
- * an absent one. Assert on the parsed value.
200
+ * does not read. Refusing those would be a conformance bug. Unknown keys are
201
+ * **stripped**, which bit the test for this schema: `safeParse().success`
202
+ * cannot tell a present field from an absent one. Assert on the parsed value.
205
203
  */
206
204
  export declare const oauthTokenRequest: z.ZodObject<{
207
205
  grant_type: z.ZodLiteral<"authorization_code">;
package/dist/oauth.js CHANGED
@@ -14,8 +14,8 @@ import { z } from 'zod';
14
14
  * and per app for an app's users — and the console's own OAuth portal, which
15
15
  * answers `oauthRedirectResponse` at its login and sign-up steps.
16
16
  *
17
- * The client model this file was written to get right is still the important
18
- * part, and it survived the cut intact: an MCP client **registers itself**
17
+ * The client model this file was written to get right survived the cut
18
+ * intact: an MCP client **registers itself**
19
19
  * (RFC 7591) because the person only ever pastes a URL into an AI tool.
20
20
  * Nobody vetted it, its redirect URIs arrive from the client itself, and the
21
21
  * tools it will call move a physical robot. That is why consent names the
@@ -37,10 +37,9 @@ import { z } from 'zod';
37
37
  * app's auth settings — are ordinary console API and use `apiError` with
38
38
  * `ERROR_CODES` like everything else.
39
39
  *
40
- * So: **two shapes, split by audience, not by accident.** Written down here
41
- * because the natural instinct on finding two error formats in one server is
42
- * to unify them, and doing so silently removes the reason the standard one is
43
- * there.
40
+ * So: **two shapes, split by audience, not by accident.** Written down
41
+ * because two error formats in one server invite unifying them — which would
42
+ * silently erase the reason the standard one exists.
44
43
  *
45
44
  * **The split is by audience and the path prefix will mislead you.**
46
45
  * `/mcp/oauth/consent` sits under an `/oauth/` segment and is nevertheless an
@@ -180,10 +179,10 @@ export const MCP_DCR_MAX_REDIRECT_URIS = 5;
180
179
  export const dynamicClientRegistrationRequest = z
181
180
  .object({
182
181
  redirect_uris: z.array(redirectUri).min(1).max(MCP_DCR_MAX_REDIRECT_URIS).meta({
183
- description: `Where the authorization code may be returned, and the one field a registration cannot omit. Each must be an \`https\` URL, or \`http\` on an explicit loopback address for a native app that cannot hold a certificate, and none may carry a fragment. There must be between \`1\` and \`${MCP_DCR_MAX_REDIRECT_URIS}\` of them; duplicates are collapsed rather than counted twice. Matched **exactly** at the authorize step against what was registered here.`,
182
+ description: `Where the authorization code may be returned, and the one field a registration cannot omit. Each must be an \`https\` URL, or \`http\` on an explicit loopback address for a native app that cannot hold a certificate, and none may carry a fragment. Between \`1\` and \`${MCP_DCR_MAX_REDIRECT_URIS}\` of them; duplicates are collapsed rather than counted twice. Matched **exactly** at the authorize step against what was registered here.`,
184
183
  }),
185
184
  client_name: z.string().min(1).max(200).optional().meta({
186
- description: 'The name the client calls itself. Optional — a registration without one is recorded under a default name, per RFC 7591\'s making every metadata field optional. It is **not** vouched for by Fleetless and must never be rendered as if it were: a self-registered client chooses this string, and one has called itself *"Fleetless Official Helper"*.',
185
+ description: 'The name the client calls itself. Optional: RFC 7591 makes every metadata field optional, so a registration without one is recorded under a default name. It is **not** vouched for by Fleetless and must never be rendered as if it were: a self-registered client chooses this string, and one has called itself *"Fleetless Official Helper"*.',
187
186
  }),
188
187
  token_endpoint_auth_method: z.enum(['none']).optional().meta({
189
188
  description: '`none`, RFC 7591\'s value for a public client, and the only value either server registers. Any other value is **refused rather than silently downgraded**: a client that believes it holds a secret and does not has a wrong mental model of its own security. There is no client secret to hold — mandatory PKCE (`S256`) is the defence.',
@@ -257,10 +256,9 @@ export const dynamicClientRegistrationResponse = z.object({
257
256
  * from a client we are about to trust, where an unknown key is a caller
258
257
  * assuming a feature into existence, while a token request comes from any
259
258
  * RFC-compliant client, which may legitimately send parameters this server
260
- * does not read. Refusing those would be a conformance bug. The consequence
261
- * is worth stating because it bit the test for this very schema: unknown keys
262
- * are **stripped**, so `safeParse().success` cannot tell a present field from
263
- * an absent one. Assert on the parsed value.
259
+ * does not read. Refusing those would be a conformance bug. Unknown keys are
260
+ * **stripped**, which bit the test for this schema: `safeParse().success`
261
+ * cannot tell a present field from an absent one. Assert on the parsed value.
264
262
  */
265
263
  export const oauthTokenRequest = z
266
264
  .object({
@@ -280,7 +278,7 @@ export const oauthTokenRequest = z
280
278
  description: 'The PKCE verifier whose `S256` hash was sent as the challenge at the authorize step. Between `43` and `128` unreserved characters, per RFC 7636 §4.1 — it is compared rather than parsed, so a length nobody checks is a length an attacker chooses. PKCE is mandatory for every client under OAuth 2.1.',
281
279
  }),
282
280
  resource: z.url().optional().meta({
283
- description: 'The resource the token is being requested for, per RFC 8707. It must match the audience the code was authorized for, or the answer is `invalid_target`; omitted, the code\'s own audience stands. It becomes the token\'s `aud`, and a resource refuses a token whose audience names something else — which is what keeps a token minted for one app out of another app\'s endpoint.',
281
+ description: 'The resource the token is requested for, per RFC 8707. It must match the audience the code was authorized for, or the answer is `invalid_target`; omitted, the code\'s own audience stands. It becomes the token\'s `aud`, and a resource refuses a token whose audience names something else — which is what keeps a token minted for one app out of another app\'s endpoint.',
284
282
  }),
285
283
  })
286
284
  .meta({
@@ -325,7 +323,7 @@ export const authorizationServerMetadata = z.object({
325
323
  description: 'The issuer identifier of this authorization server, per RFC 8414 §2. It is what a client checks a token\'s `iss` against.',
326
324
  }),
327
325
  authorization_endpoint: z.url().meta({
328
- description: 'The URL a client sends the user to in order to authorize.',
326
+ description: 'Where a client sends the user to authorize.',
329
327
  }),
330
328
  token_endpoint: z.url().meta({
331
329
  description: 'The URL where a client exchanges an authorization code, or a refresh token, for tokens.',
@@ -438,7 +436,7 @@ export const oauthAuthorizeQuery = z
438
436
  description: 'One of the client\'s registered redirect URIs, compared **exactly** — string equality against the registered list, never a prefix or a host match. Both the shape (`redirectUri`) and the registration are checked, and a failure of either is a `400 invalid_request` with no redirect.',
439
437
  }),
440
438
  code_challenge: z.string().min(1).meta({
441
- description: 'The PKCE challenge; the verifier is presented at the token endpoint. Only non-emptiness is checked here — length and alphabet are not — since the verifier is what actually has to match.',
439
+ description: 'The PKCE challenge; the verifier is presented at the token endpoint. Only non-emptiness is checked here — length and alphabet are not — since the verifier is what has to match.',
442
440
  }),
443
441
  code_challenge_method: z.literal('S256').meta({
444
442
  description: 'Only `S256`. `plain` is refused: a challenge equal to its verifier defends against nothing.',
@@ -4,8 +4,8 @@ import { z } from 'zod';
4
4
  * Bridge <-> cloud protocol, version 2.
5
5
  *
6
6
  * The version is exchanged in the hello handshake; the cloud refuses an
7
- * incompatible bridge with a clear message: `protocol_mismatch`, which names
8
- * both versions and reaches the robot's detail view as `last_hello_error`.
7
+ * incompatible bridge: `protocol_mismatch`, which names both versions and
8
+ * reaches the robot's detail view as `last_hello_error`.
9
9
  *
10
10
  * **2 (2026-08-21):** `config_applied.errors` entries gained `kind` and `code`
11
11
  * beside `message`. The check is `!==`, not a floor, so a bridge that is not
@@ -72,12 +72,11 @@ export { slug } from './common.js';
72
72
  *
73
73
  * It carries the **slug and the state**, not only the id, because the cloud's
74
74
  * reconciliation needs both and had neither. Reading `active_job_ids` as bare
75
- * uuids, a restarted cloud could answer exactly one question — "is this job
76
- * still alive?" — for jobs it already knew about. It could not name what the
77
- * robot is doing, could not tell a job that is still `running` from one that
78
- * finished while the cloud was down, and had nothing at all to say about a
79
- * job it never recorded because it crashed between minting the id and writing
80
- * the row.
75
+ * uuids, a restarted cloud could only ask "is this job still alive?" for jobs
76
+ * it already knew about — not what the robot is doing, not whether a
77
+ * `running` job had actually finished while the cloud was down, and nothing
78
+ * at all about a job it never recorded because it crashed between minting the
79
+ * id and writing the row.
81
80
  *
82
81
  * `state` is the bridge's own current answer, not a history. A bridge that
83
82
  * has a terminal result still in hand reports it here and the cloud writes it
package/dist/protocol.js CHANGED
@@ -10,8 +10,8 @@ import { rosTypeName } from './common.js';
10
10
  * Bridge <-> cloud protocol, version 2.
11
11
  *
12
12
  * The version is exchanged in the hello handshake; the cloud refuses an
13
- * incompatible bridge with a clear message: `protocol_mismatch`, which names
14
- * both versions and reaches the robot's detail view as `last_hello_error`.
13
+ * incompatible bridge: `protocol_mismatch`, which names both versions and
14
+ * reaches the robot's detail view as `last_hello_error`.
15
15
  *
16
16
  * **2 (2026-08-21):** `config_applied.errors` entries gained `kind` and `code`
17
17
  * beside `message`. The check is `!==`, not a floor, so a bridge that is not
@@ -78,12 +78,11 @@ export { slug } from './common.js';
78
78
  *
79
79
  * It carries the **slug and the state**, not only the id, because the cloud's
80
80
  * reconciliation needs both and had neither. Reading `active_job_ids` as bare
81
- * uuids, a restarted cloud could answer exactly one question — "is this job
82
- * still alive?" — for jobs it already knew about. It could not name what the
83
- * robot is doing, could not tell a job that is still `running` from one that
84
- * finished while the cloud was down, and had nothing at all to say about a
85
- * job it never recorded because it crashed between minting the id and writing
86
- * the row.
81
+ * uuids, a restarted cloud could only ask "is this job still alive?" for jobs
82
+ * it already knew about — not what the robot is doing, not whether a
83
+ * `running` job had actually finished while the cloud was down, and nothing
84
+ * at all about a job it never recorded because it crashed between minting the
85
+ * id and writing the row.
87
86
  *
88
87
  * `state` is the bridge's own current answer, not a history. A bridge that
89
88
  * has a terminal result still in hand reports it here and the cloud writes it
@@ -103,22 +102,21 @@ export const bridgeHello = z.object({
103
102
  /**
104
103
  * Every job this bridge still knows about, right now.
105
104
  *
106
- * A reconnect and a restart look **identical** on the wire otherwise: same
107
- * token, same version, same frame. But they must end differently — after a
108
- * dropped connection the running jobs are still running, after a restart
109
- * their results are gone forever. Asking the bridge to enumerate what it
110
- * still has settles it without either side guessing: the cloud marks every
111
- * job it believes is running on this robot and that is *not* named here as
112
- * `lost`.
105
+ * A reconnect and a restart look **identical** on the wire — same token,
106
+ * same version, same frame — but must end differently: after a dropped
107
+ * connection the running jobs are still running, after a restart their
108
+ * results are gone forever. Enumerating what the bridge still has settles
109
+ * it without either side guessing: the cloud marks every job it believed
110
+ * running that is *not* named here as `lost`.
113
111
  *
114
- * This deliberately needs no persistence at the bridge. A live process
115
- * lists its live jobs; a process that just started lists none, because it
116
- * has none — which is exactly the truth the cloud needs. A breadcrumb file
117
- * would only add a window in which the crash beat the write.
112
+ * Deliberately needs no persistence at the bridge: a live process lists its
113
+ * live jobs, a process that just started lists none — exactly the truth
114
+ * the cloud needs. A breadcrumb file would only add a window in which the
115
+ * crash beat the write.
118
116
  *
119
- * Defaulted, so a bridge that sends no such field still parses; a bridge
120
- * with no jobs and a bridge that does not report them both mean the cloud
121
- * has nothing to keep alive.
117
+ * Defaulted, so a bridge that sends no such field still parses; no jobs
118
+ * and no report both mean the same thing to the cloud: nothing to keep
119
+ * alive.
122
120
  */
123
121
  active_jobs: z.array(activeJob).max(500).default([]),
124
122
  });
package/dist/realtime.js CHANGED
@@ -324,10 +324,10 @@ export const liveSessionEvent = z.object({
324
324
  /**
325
325
  * **Classified text the cloud produced, never text the robot sent.**
326
326
  *
327
- * It is **not** the robot's own words. Nothing sanitises
328
- * `bridgeCameraState.error.message`, and a camera password reaches a
329
- * developer surface through exactly that route — which is why the cloud maps
330
- * a robot's diagnosis to fixed strings rather than forwarding it.
327
+ * Nothing sanitises `bridgeCameraState.error.message`, and a camera
328
+ * password reaches a developer surface through exactly that route — which
329
+ * is why the cloud maps a robot's diagnosis to fixed strings rather than
330
+ * forwarding it.
331
331
  *
332
332
  * So: `null` unless the cloud itself has something classified to say. If a
333
333
  * developer needs the robot's own diagnosis later, it arrives as a mapped
package/dist/rest.js CHANGED
@@ -23,7 +23,7 @@ export const robot = z.object({
23
23
  /** What `PATCH /api/robots/:id` answers: the robot as it now stands. */
24
24
  export const patchRobotResponse = z.object({
25
25
  robot: robot.meta({
26
- description: 'The robot as it now stands, after the patch was applied. The whole resource comes back, not only the fields that changed.',
26
+ description: 'The robot as it now stands, after the patch. The whole resource comes back, not only the changed fields.',
27
27
  }),
28
28
  });
29
29
  export const createRobotRequest = z.object({
@@ -85,10 +85,10 @@ export const robotListResponse = z.object({
85
85
  export const datapointValue = z.object({
86
86
  slug: slug.meta({ description: 'The datapoint this value belongs to.' }),
87
87
  value: z.unknown().meta({
88
- description: 'The value itself, shaped by the datapoint: a number, a boolean, a string, or the whole ROS message where the configuration names no field inside it. Any `scale` and `offset` the configuration declares have already been applied, at the robot.',
88
+ description: 'The value, shaped by the datapoint: a number, a boolean, a string, or the whole ROS message where the configuration names no field inside it. Any `scale` and `offset` the configuration declares have already been applied, at the robot.',
89
89
  }),
90
90
  timestamp_ms: z.number().int().nonnegative().meta({
91
- description: 'When the value was captured, as a unix timestamp in milliseconds. This is the **bridge\'s capture time**, never the time the cloud received it — the one exception is the built-in `bridge_state`, which the cloud observes by construction.',
91
+ description: 'When the value was captured, as a unix timestamp in milliseconds. The **bridge\'s capture time**, never the time the cloud received it — the one exception is the built-in `bridge_state`, which the cloud observes by construction.',
92
92
  }),
93
93
  });
94
94
  /* ------------------------------------------------------------------------
@@ -396,7 +396,7 @@ export const invokeResponse = z.object({
396
396
  /** A service call answers with its result directly — no job to observe. */
397
397
  export const serviceCallResponse = z.object({
398
398
  result: z.unknown().meta({
399
- description: 'What the service returned, shaped by the ROS service itself. A service call is awaited to completion, so there is no job to observe afterwards and no id to hold on to.',
399
+ description: 'What the service returned, shaped by the ROS service. A service call is awaited to completion, so there is no job to observe afterwards and no id to hold on to.',
400
400
  }),
401
401
  });
402
402
  /**
package/dist/routes.js CHANGED
@@ -509,7 +509,7 @@ export const ROUTES = [
509
509
  params: [
510
510
  { name: 'id', description: 'The app\'s uuid, as returned by `POST /api/apps` or listed by `GET /api/apps`.' },
511
511
  { name: 'userId', description: 'The app user\'s uuid, from `GET /api/apps/:id/users`; a user of another app answers `404`.' },
512
- { name: 'clientId', description: 'The MCP client, as `GET /api/apps/:id/users/:userId/mcp-grants` reports its `client_id`. Not a uuid — it is the identifier the dynamic registration issued.' },
512
+ { name: 'clientId', description: 'The MCP client, as `GET /api/apps/:id/users/:userId/mcp-grants` reports its `client_id`. Not a uuid — the identifier dynamic registration issued.' },
513
513
  ],
514
514
  query: null, request: null, response: null,
515
515
  errors: [...DEVELOPER_GUARD, 'invalid_uuid', 'not_found'], transport: 'http',
@@ -1175,8 +1175,8 @@ export const ROUTES = [
1175
1175
  '\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 ' +
1176
1176
  '`GET` (`handleGetRequest`) and answers `200` on `DELETE` (`handleDeleteRequest`), neither of which a stateless server has any ' +
1177
1177
  '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 ' +
1178
- '`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 ' +
1179
- 'one answer for two states. Registering the verb lets the endpoint say "this app\'s server is here; this verb is not ' +
1178
+ '`404`, and at a path whose last segment is an app identifier a `404` already means *no such app* — so an unregistered verb and an ' +
1179
+ 'unknown app would answer identically. Registering the verb lets the endpoint say "this app\'s server is here; this verb is not ' +
1180
1180
  'part of it". The central `/mcp` registers neither verb and does not need to: its path takes no parameter, so nothing can misread its ' +
1181
1181
  '`404`. \n\n**The `405` body is the transport\'s JSON-RPC error object, not the `apiError` envelope.** The three codes above are the ' +
1182
1182
  'refusals that come *first* — the app, its switch, then the bearer, in the order `POST` describes — and they are `apiError` because ' +
@@ -1628,7 +1628,7 @@ export const ROUTES = [
1628
1628
  method: 'DELETE', path: '/api/client/mcp/grants/:clientId', section: 'client-auth',
1629
1629
  summary: 'Withdraws the signed-in app user\'s consent to one MCP client.',
1630
1630
  audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 204,
1631
- params: [{ name: 'clientId', description: 'The MCP client, as `GET /api/client/mcp/grants` reports its `client_id`. Not a uuid — it is the identifier the dynamic registration issued.' }],
1631
+ params: [{ name: 'clientId', description: 'The MCP client, as `GET /api/client/mcp/grants` reports its `client_id`. Not a uuid — the identifier dynamic registration issued.' }],
1632
1632
  query: null, request: null, response: null, errors: [...CLIENT_GUARD], transport: 'http',
1633
1633
  notes: 'The person\'s own door, beside the developer\'s `DELETE /api/apps/:id/users/:userId/mcp-grants/:clientId`. It acts on the bearer\'s ' +
1634
1634
  'own account and on no other — the path carries a client and never a subject — so there is no user for a caller to name and none to ' +
@@ -1934,8 +1934,8 @@ export const ROUTES = [
1934
1934
  query: jobRunQuery, request: null, response: jobRunListResponse,
1935
1935
  errors: [...CLIENT_GUARD, 'invalid_uuid', 'not_found', 'capability_required', 'validation_error'], transport: 'http',
1936
1936
  notes: 'Needs the `action_history` capability, and **this route is what makes that switch mean something** — it was unkeepable while nothing ' +
1937
- 'durable recorded what had run. Two residuals worth stating rather than implying away. `history` is a syntactically valid slug and ' +
1938
- 'The router matches a static segment first, so a robot with a service literally slugged `history` can no longer be **read** through ' +
1937
+ 'durable recorded what had run. Two residuals worth stating rather than implying away. `history` is a syntactically valid slug, and ' +
1938
+ 'a static segment matches before a parameter, so a robot with a service literally slugged `history` can no longer be **read** through ' +
1939
1939
  '`GET /api/robots/:id/jobs/:slug`; invoking, cancelling and the listing are unaffected. And a run row names its actor by email address, ' +
1940
1940
  'so an end user holding this capability learns which other people have been driving the machine. `robot_id` in the query is shared with ' +
1941
1941
  'the org-wide read; a *different* one here is refused rather than quietly answered about the robot in the path.',
@@ -1946,7 +1946,7 @@ export const ROUTES = [
1946
1946
  audience: 'client', auth: 'developer_or_client', rateLimited: false, ownerTier: false, status: 202,
1947
1947
  params: [
1948
1948
  { name: 'id', description: 'The robot\'s uuid; an end user reaches it through an app that attaches it.' },
1949
- { name: 'slug', description: 'The action or service slug from the published configuration; the cloud already knows which kind it is.' },
1949
+ { name: 'slug', description: 'The action or service slug from the published configuration — the cloud already knows which kind.' },
1950
1950
  ],
1951
1951
  query: null, request: invokeRequest, response: invokeOrServiceResponse,
1952
1952
  errors: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fleetless/contracts",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Fleetless wire contracts: the bridge-cloud protocol, the REST API schemas and the error codes, as zod schemas with generated JSON Schema and OpenAPI artifacts.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dehne Robotik GmbH",
@@ -52,6 +52,7 @@
52
52
  "test": "vitest run",
53
53
  "artifacts": "tsx scripts/export-schemas.ts",
54
54
  "test:pack": "node scripts/verify-pack.mjs",
55
+ "verify:commits": "node scripts/verify-commit-messages.mjs",
55
56
  "prepublishOnly": "node scripts/refuse-manual-publish.mjs"
56
57
  },
57
58
  "dependencies": {