@fleetless/sdk 3.0.1 → 3.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.
package/README.md CHANGED
@@ -5,78 +5,49 @@
5
5
  [![license MIT](https://img.shields.io/npm/l/@fleetless/sdk)](LICENSE)
6
6
  [![node >=20](https://img.shields.io/node/v/@fleetless/sdk)](https://nodejs.org)
7
7
 
8
- The official TypeScript SDK for apps built on [Fleetless](https://fleetless.dev),
9
- which exposes a ROS 2 robot as a hosted REST and realtime API — exactly the
10
- topics, services and actions a developer chose to publish, under stable slugs,
11
- behind roles. The SDK covers all of it: datapoints (read, subscribe, recorded
12
- history), actions, services, publishers, cameras, jobs, assets and URDF, and
13
- the whole client auth API your own sign-in UI calls. It is framework-agnostic,
14
- ships ESM and CJS with its own types, and runs wherever a `fetch` and a
15
- `WebSocket` exist — a browser, a mobile webview, Node 20 or newer, and
16
- server-side with a server key instead of a user session.
8
+ **Your robot, as an API. This is the client.**
17
9
 
18
- **The mental model in one paragraph.** A robot runs the Fleetless bridge, a
19
- ROS 2 node. In the [Fleetless Console](https://console.fleetless.dev) a
20
- developer picks which topics, services and actions that robot exposes and gives
21
- each one a stable `snake_case` slug and a role that may reach it. The cloud
22
- turns those into REST and realtime endpoints. This package is the client for
23
- those endpoints: you address a robot by its id and a capability by its slug,
24
- you sign your own users in against your own app's user directory, and you never
25
- see a ROS name, a topic type or a message definition. Renaming a node on the
26
- robot is invisible to your app; changing a slug is not.
10
+ [Fleetless](https://fleetless.dev) turns a ROS 2 robot into a hosted REST and
11
+ realtime API. The robot runs the Fleetless Bridge, and in the
12
+ [Fleetless Console](https://console.fleetless.dev) a developer picks which
13
+ topics, services, actions, publishers and cameras it exposes, each under a
14
+ stable slug and behind a role. Your app talks to those slugs over HTTPS and
15
+ never learns a ROS name, a topic type or a message definition.
27
16
 
28
- New here? The [getting-started guide](https://docs.fleetless.dev/getting-started/)
29
- goes from a robot that has never connected to a value on a page, and the
30
- [app-auth recipe](https://docs.fleetless.dev/recipes/app-auth/) is the
31
- walkthrough for every sign-in screen.
17
+ This package is the official TypeScript client for that API. It handles the
18
+ sessions, the reconnects, the subscriptions and the errors, so your code can
19
+ get on with the part that is actually about your robot. Framework-agnostic,
20
+ ESM and CJS, types included.
32
21
 
33
- ## Why
34
-
35
- - **One connection, however many subscriptions.** Every datapoint and job
36
- subscription shares a single authenticated WebSocket. It reconnects with
37
- exponential backoff, re-authenticates, and resends its own subscribe frames,
38
- so a dropped network shows up as a gap in events rather than as work for you.
39
- - **Subscriptions are reference-counted per robot and slug.** Two widgets on
40
- the same battery value, or one component mounted twice under React
41
- StrictMode, share one wire subscription. Unsubscribing one never cuts off the
42
- other.
43
- - **Errors you branch on, not errors you parse.** Every refusal arrives as a
44
- `FleetlessError` carrying a stable `code` — `busy`, `forbidden`,
45
- `robot_offline`, `rate_limited` — plus the SDK's own codes for refusals that
46
- never reached the network. `rate_limited` is surfaced with its
47
- `retry_after_ms` and never retried behind your back.
48
- - **Publishers with a failsafe the robot enforces.** `publishers.publish` sends
49
- one message and nothing else. If your app stops publishing — including
50
- because it crashed — the bridge on the robot publishes the configured
51
- failsafe message itself. The safety primitive lives on the robot, not in your
52
- event loop.
53
- - **Cameras that cost nothing while nobody watches.** Snapshot bytes come with
54
- the cloud's own `age_ms`, and keep being served while the robot is offline.
55
- `cameras.live` takes a refcounted hold: the first viewer starts the robot
56
- publishing, the last one leaving stops it. You get a LiveKit room URL and
57
- token to hand to the LiveKit client of your choice.
58
- - **The whole auth surface, and every screen stays yours.** Fleetless renders
59
- no page for an app user: registration with email verification, invitations,
60
- password reset, single sign-on to one of the app's identity providers, and
61
- the MCP consent screen are all methods on `client.auth`, called from a UI you
62
- wrote. The federated flow runs its own PKCE (S256 only) against Fleetless;
63
- `beginOidcLogin` makes no network call, and `completeOidcLogin` checks
64
- `state` before it exchanges anything.
65
- - **A token store you control.** Implement two methods to keep a session in
66
- `localStorage`, a cookie or a native keystore; the default keeps it in
67
- memory. Refresh is silent and single-flight, so ten concurrent calls that
68
- meet an expired access token share one refresh instead of firing ten.
22
+ Fleetless is in closed beta. The waiting list is at
23
+ <https://fleetless.dev/#waiting-list>.
69
24
 
70
- ## Getting started
25
+ ## ✨ What you can do with it
26
+
27
+ - **Read datapoints** once, subscribe to them live over one reconnecting
28
+ WebSocket, or query their recorded history.
29
+ - **Run actions and call services** by slug, with feedback, progress and
30
+ results as they arrive.
31
+ - **Publish messages** to a topic, behind a failsafe the robot enforces
32
+ itself the moment your app goes quiet.
33
+ - **Show cameras**: a snapshot that is always there, or live video over
34
+ WebRTC that starts with the first viewer and stops with the last.
35
+ - **Follow jobs**, sync assets, and render the robot's URDF with its meshes.
36
+ - **Sign your users in** — registration, verification, invitations, password
37
+ reset, single sign-on and MCP consent — from screens that are entirely
38
+ yours. Fleetless renders no page for an app user.
39
+ - **Branch on errors** instead of parsing them: every refusal is a
40
+ `FleetlessError` with a stable `code`.
41
+
42
+ ## 🚀 Getting started
71
43
 
72
44
  ```sh
73
45
  npm i @fleetless/sdk
74
46
  ```
75
47
 
76
- You need an app identifier and the credentials of an **app user** — an
77
- account inside that app, which the developer's team creates or invites from
78
- the [Fleetless Console](https://console.fleetless.dev). A console login is a
79
- Fleetless user, a different identity space, and will not sign in here.
48
+ You need an app identifier and an **app user** of that app, both created in
49
+ the console. A console login is a Fleetless user, a different identity space,
50
+ and will not sign in here.
80
51
 
81
52
  ```ts checked
82
53
  import { createClient } from '@fleetless/sdk'
@@ -88,290 +59,65 @@ const client = createClient({
88
59
 
89
60
  await client.auth.login('user@example.com', 'correct-horse-battery')
90
61
 
91
- // Robot ids are UUIDs; the console shows the one you want.
92
- const robotId = '4f2c1a90-7b3e-4d51-9c86-0a1b2c3d4e5f'
62
+ const robotId = '4f2c1a90-7b3e-4d51-9c86-0a1b2c3d4e5f' // the console shows it
93
63
 
94
64
  // One-shot read.
95
65
  const battery = await client.datapoints.get(robotId, 'battery_percentage')
96
66
  console.log(battery.value, battery.timestamp_ms)
97
67
 
98
- // Live updates. The current value arrives immediately, then every change.
68
+ // Live updates: the current value first, then every change.
99
69
  const subscription = client.datapoints.subscribe(robotId, 'battery_percentage', {
100
- onEvent(event) {
101
- console.log(event.value, event.timestamp_ms)
102
- },
103
- onError(error) {
104
- console.error(error.code, error.message)
105
- },
70
+ onEvent: (event) => console.log(event.value, event.timestamp_ms),
71
+ onError: (error) => console.error(error.code, error.message),
106
72
  })
107
73
 
108
74
  // Later:
109
75
  subscription.unsubscribe()
110
76
  await client.auth.logout()
111
- // logout() already closes the realtime channel. Call close() yourself when a
112
- // process should exit without logging out — an open WebSocket keeps Node alive.
113
- client.close()
114
- ```
115
-
116
- Slugs are the names you chose in the console, in `snake_case`
117
- (`[a-z][a-z0-9]*(_[a-z0-9]+)*`) — never the robot's internal ROS names, which
118
- is what makes renaming a node on the robot invisible to your app.
119
-
120
- ### Your own sign-in UI
121
-
122
- Every screen an app user sees belongs to your app. These are the calls behind
123
- them; the full walkthrough, page by page, is the
124
- [app-auth recipe](https://docs.fleetless.dev/recipes/app-auth/).
125
-
126
- ```ts
127
- // Registration. Resolves on 202 — which says a policy-allowed request was
128
- // accepted, NOT that an account was created. An address the app already knows
129
- // gets the identical answer with no mail sent, so the only honest thing to
130
- // render is a sentence about the mailbox.
131
- await client.auth.register({ email, password, displayName })
132
-
133
- // The page your app serves at the app's configured `verify_url`. Stores a
134
- // session, so nobody types a password right after reading their mail.
135
- await client.auth.verifyEmail(tokenFromTheLink)
136
-
137
- // Recovery. requestPasswordReset always resolves on 202, for a known address
138
- // and an unknown one alike.
139
- await client.auth.requestPasswordReset(email)
140
- await client.auth.confirmPasswordReset(tokenFromTheLink, newPassword)
141
-
142
- // The page your app serves at the app's configured `invite_url`.
143
- await client.auth.acceptInvitation({ token: tokenFromTheLink, password })
144
- ```
145
-
146
- ### Sign-in through an identity provider
147
-
148
- An app may carry any number of OpenID Connect providers, configured per app in
149
- the console. Your page draws the buttons; your app never talks to the provider.
150
-
151
- ```ts
152
- // Draw the buttons. Only enabled providers are listed, slug and name only.
153
- const providers = await client.auth.listProviders()
154
-
155
- // 1. Build the URL. No network call: this generates the PKCE verifier and the
156
- // state, and hands them back, because a redirect is a fresh page load.
157
- const request = await client.auth.beginOidcLogin({
158
- slug: 'azure',
159
- redirectUri: 'https://your-app.example.com/callback',
160
- })
161
- sessionStorage.setItem('fleetless_state', request.state)
162
- sessionStorage.setItem('fleetless_code_verifier', request.codeVerifier)
163
- window.location.assign(request.url)
164
-
165
- // 2. On the callback page. A refused sign-in redirects here with `error`
166
- // rather than a code, so check for that first.
167
- const params = new URL(window.location.href).searchParams
168
- const failure = client.auth.oidcErrorFromCallback(params)
169
- if (failure) {
170
- // A FleetlessError carrying the documented reason: no_access, email_taken,
171
- // email_unverified, domain_not_allowed, registration_closed, and the rest.
172
- showSignInProblem(failure)
173
- } else {
174
- await client.auth.completeOidcLogin({
175
- code: params.get('code') ?? '',
176
- state: params.get('state') ?? '',
177
- expectedState: sessionStorage.getItem('fleetless_state') ?? '',
178
- codeVerifier: sessionStorage.getItem('fleetless_code_verifier') ?? '',
179
- })
180
- // From here on me(), logout() and silent refresh behave exactly as they do
181
- // after login().
182
- }
183
- ```
184
-
185
- `completeOidcLogin` refuses before any network call, with `state_mismatch`,
186
- for a mismatch, a missing `state`, **and** an empty `expectedState` — that
187
- last case is checked first and explicitly, because two empty strings compare
188
- equal.
189
-
190
- One callback URL is registered at the provider,
191
- `https://api.fleetless.dev/api/client/oidc/callback`, the same for every app
192
- and every provider. It is not your `redirectUri`, which is a page in your app.
193
-
194
- ### The MCP consent screen
195
-
196
- Only if the app serves its own MCP endpoint. An authorization redirects the
197
- browser to a page your app serves, with an interaction id; your page signs the
198
- person in, shows them what is being asked, and answers.
199
-
200
- ```ts
201
- // Sign the person in FIRST — `already_granted` is derived from the bearer,
202
- // and an anonymous read answers `false` for everybody.
203
- const interaction = await client.auth.mcpInteraction(interactionId)
204
-
205
- // `interaction.client_name` is a name the client typed about itself during an
206
- // unauthenticated registration. `client_name_verified` is the literal false;
207
- // render it as a claim, never as an identity. `scopes` is always empty: what a
208
- // session reaches is decided by the user's role, re-read on every call.
209
- const { redirectTo } = approved
210
- ? await client.auth.approveMcpInteraction(interactionId)
211
- : await client.auth.denyMcpInteraction(interactionId)
212
- window.location.assign(redirectTo) // a denial redirects too
213
-
214
- // A "connected apps" screen. Withdrawing stops the NEXT authorization; a
215
- // session already running keeps working for the rest of its 15 minutes.
216
- const grants = await client.auth.listMcpGrants()
217
- await client.auth.revokeMcpGrant(grants[0].client_id)
218
- ```
219
-
220
- ### Actions
221
-
222
- An action is a job with a lifecycle. `invoke` resolves as soon as the job
223
- exists; feedback, progress and the result arrive over `subscribe`.
224
-
225
- ```ts
226
- const job = await client.actions.invoke(robotId, 'dock', { 'target_pose.position.x': 1.0 })
227
-
228
- const sub = client.actions.subscribe(robotId, 'dock', {
229
- onJob(event) {
230
- console.log(event.job.state, event.feedback, event.progress)
231
- if (event.job.state === 'succeeded') console.log(event.job.result)
232
- },
233
- onError(error) {
234
- console.error(error.code, error.message)
235
- },
236
- })
237
-
238
- // Cancel the job you started, specifically — not whatever runs there by now.
239
- const cancelled = await client.actions.cancel(robotId, 'dock', job.id)
240
- sub.unsubscribe()
241
- ```
242
-
243
- `params` is flat, keyed by the parameter names the developer declared. A second
244
- `invoke` while one is still running is refused `busy`, with the running job on
245
- `error.details.running`. A service is the same machinery with a plain result:
246
- `await client.services.call(robotId, 'set_mode', { mode: 'autonomous' })`.
247
-
248
- ### Publishers, and no teleop helpers
249
-
250
- `publishers.publish` sends **one** message. There is no `startPublishing`, no
251
- rate helper, no joystick binding and no "hold this value" call, and that is a
252
- decision rather than a gap.
253
-
254
- The safety primitive lives on the robot. A publisher is configured with a
255
- `timeout_ms` and a failsafe message; if messages stop arriving — including
256
- because your process crashed, your tab closed, or the network went away — the
257
- bridge publishes the failsafe itself. Nothing in your event loop has to survive
258
- for that to happen.
259
-
260
- A helper here would move the appearance of that guarantee into a browser, where
261
- it is not true. So *how often* and *when* to publish is your application's
262
- decision, made where it can see the user's intent:
263
-
264
- ```ts
265
- // A control loop is yours to write, and yours to stop.
266
- const timer = setInterval(() => {
267
- void client.publishers.publish(robotId, 'cmd_vel', { 'linear.x': speed })
268
- }, 100)
269
-
270
- // Stop publishing and let the robot's own failsafe take over.
271
- clearInterval(timer)
272
77
  ```
273
78
 
274
- `publish` rejects `publisher_busy` while a different user holds the publisher
275
- and has not been quiet for its configured timeout.
79
+ `battery_percentage` is a slug you chose in the console, not a ROS topic.
80
+ Rename the node on the robot and your app never notices. Rename the slug and
81
+ it does.
276
82
 
277
- ### Cameras
83
+ The SDK runs wherever `fetch` and `WebSocket` exist: browsers, webviews,
84
+ Node 22 and newer, or server-side with a server key instead of a user session.
85
+ Node 20 does REST fine but ships no global `WebSocket`, so realtime there needs
86
+ one passed in through the `WebSocket` option.
278
87
 
279
- ```ts
280
- import { Room } from 'livekit-client' // your choice of LiveKit client
88
+ ## 📚 Documentation
281
89
 
282
- // The snapshot is always there, independent of anyone watching live.
283
- const snap = await client.cameras.snapshot(robotId, 'camera_front')
284
- if (snap.image !== null) console.log(snap.mime, `${snap.age_ms}ms old`)
90
+ Everything past this point lives at **[docs.fleetless.dev](https://docs.fleetless.dev)**.
285
91
 
286
- // Live is on demand, and it costs you a release.
287
- const room = new Room()
288
- const session = await client.cameras.live(robotId, 'camera_front')
289
- await room.connect(session.url, session.token)
290
-
291
- // When you are done watching — both calls, together. Disconnecting the Room is
292
- // what actually stops the stream; release() is the courteous fast path.
293
- await room.disconnect()
294
- await session.release()
295
- ```
296
-
297
- ## Errors
298
-
299
- Every refusal arrives as a `FleetlessError` with a stable `code`. Branch on the
300
- code; never parse the message, which is written for a developer reading a
301
- console and may change.
302
-
303
- ```ts
304
- import { FleetlessError } from '@fleetless/sdk'
305
-
306
- try {
307
- await client.actions.invoke(robotId, 'dock', {})
308
- } catch (error) {
309
- if (!(error instanceof FleetlessError)) throw error
310
- switch (error.code) {
311
- case 'busy': return showRunning(error.details?.running)
312
- case 'robot_offline': return showOffline()
313
- case 'forbidden': return showNotAllowed()
314
- case 'rate_limited': return showRetryLater(error.details?.retry_after_ms)
315
- default: return showUnexpected(error)
316
- }
317
- }
318
- ```
319
-
320
- - **`rate_limited` is surfaced, never retried behind your back.** The SDK does
321
- not sleep and re-send. `error.details.retry_after_ms` carries the wait the
322
- platform asked for, when it sent one — read it defensively, since a refusal
323
- from an intermediary may carry no `details` at all.
324
- - **The one retry the SDK does perform is `token_expired`**, and only once: it
325
- refreshes the session and re-sends the same request. That refresh is
326
- single-flight, so ten concurrent calls meeting an expired token share one.
327
- - **`command_outcome_unknown` is an honest answer, not a failure.** It means
328
- the realtime connection cycled while a command was in flight and the SDK
329
- cannot say whether the robot got it. Read the job back by slug through
330
- `actions.subscribe` to find out.
331
- - Some codes never reach the network at all — `invalid_option`,
332
- `state_mismatch`, `no_session`, `untrusted_absolute_url`. They are the SDK
333
- refusing to send something rather than the platform refusing to accept it.
334
-
335
- ## Documentation and help
336
-
337
- - **[SDK reference](https://docs.fleetless.dev/reference/sdk/)** — every method,
338
- every option, and what each one deliberately does not do.
92
+ - **[SDK reference](https://docs.fleetless.dev/reference/sdk/)** — every
93
+ method, every option, and what each one deliberately does not do.
339
94
  - **[Getting started](https://docs.fleetless.dev/getting-started/)** — from a
340
95
  robot that has never connected to a value in your app.
341
- - **[REST and realtime API](https://docs.fleetless.dev/reference/api/)** — the
342
- wire surface underneath this package.
343
96
  - **[Your own login UI](https://docs.fleetless.dev/recipes/app-auth/)** — the
344
- full walkthrough for every auth screen, and the mistakes that cost the most
345
- time.
97
+ walkthrough for every sign-in screen.
346
98
  - **[Identity](https://docs.fleetless.dev/reference/identity/)** — the two
347
- identity spaces, the federation rules, and what each refusal licenses your
348
- UI to claim.
99
+ identity spaces, and what a refusal licenses your UI to claim.
100
+ - **[REST and realtime API](https://docs.fleetless.dev/reference/api/)** —
101
+ the wire underneath this package.
349
102
  - **[CHANGELOG.md](CHANGELOG.md)** — what changed in each version.
350
- - Questions, bug reports and feature requests: hello@fleetless.dev.
351
103
 
352
- Fleetless is in closed beta. The waiting list is at
353
- <https://fleetless.dev/#waiting-list>.
104
+ Questions, bug reports and feature requests: hello@fleetless.dev.
354
105
 
355
- ## Reporting a security issue
106
+ ## 🔒 Reporting a security issue
356
107
 
357
- Email **security@fleetless.dev**. Please do not open a public issue for a
358
- security report. [SECURITY.md](SECURITY.md) says what is in scope here — how
359
- this package handles tokens, the PKCE verifier and the `state` value — and what
360
- belongs to the platform instead.
108
+ Email **security@fleetless.dev** rather than opening a public issue.
109
+ [SECURITY.md](SECURITY.md) says what is in scope for this package — tokens,
110
+ the PKCE verifier, the `state` value — and what belongs to the platform.
361
111
 
362
- ## Contributing
112
+ ## 🤝 Contributing
363
113
 
364
- Pull requests are welcome at <https://github.com/fleetless/sdk>. Read
365
- [CONTRIBUTING.md](CONTRIBUTING.md) first: it covers the setup, the checks, the
366
- Contributor Licence Agreement, and the one rule this repository is strict about
367
- — the auth suites drive the SDK's own `fetch` against a real `node:http`
368
- server rather than a double, because a mocked `fetch` cannot see the bugs that
369
- have actually shipped.
114
+ The public repository is not open yet. Until it is, send patches and questions
115
+ to <hello@fleetless.dev>, after reading [CONTRIBUTING.md](CONTRIBUTING.md) for
116
+ the setup, the checks and the Contributor Licence Agreement.
370
117
 
371
118
  By participating you agree to the [Code of Conduct](CODE_OF_CONDUCT.md).
372
119
 
373
- ## Maintainers
374
-
375
- Maintained by [Dehne Robotik GmbH](https://dehne-robotik.de).
120
+ ## 📜 Licence
376
121
 
377
- MIT licensed — see [LICENSE](LICENSE).
122
+ MIT — see [LICENSE](LICENSE). Maintained by
123
+ [Dehne Robotik GmbH](https://dehne-robotik.de).
package/SECURITY.md CHANGED
@@ -18,7 +18,7 @@ released fix, we credit you by name unless you ask us not to.
18
18
 
19
19
  This repository is the TypeScript SDK that client applications use to talk to
20
20
  Fleetless. It runs **in your users' browsers and on your servers**, and it
21
- holds credentials while it does. That is where its security surface is.
21
+ holds credentials while it does. That is its security surface.
22
22
 
23
23
  Specifically in scope:
24
24
 
@@ -39,14 +39,14 @@ them in memory only and writes them nowhere. A report is in scope if the SDK:
39
39
  - races its own silent refresh in a way that lets a stale or a foreign token
40
40
  be used.
41
41
 
42
- Choosing to persist tokens in `localStorage`, a cookie or a native keystore is
43
- your decision and its consequences are yours; a defect in how the SDK *hands*
44
- them to your store is ours.
42
+ Where you persist tokens — `localStorage`, a cookie, a native keystore — is
43
+ your decision and your risk. A defect in how the SDK *hands* them to your
44
+ store is ours.
45
45
 
46
46
  ### PKCE and `state` in the federated sign-in flow
47
47
 
48
- `beginOidcLogin` generates a PKCE verifier and a `state` value and returns them
49
- to you, because a redirect is a fresh page load and the SDK has nowhere of its
48
+ `beginOidcLogin` generates a PKCE verifier and a `state` value and hands them
49
+ back to you: a redirect is a fresh page load, and the SDK has nowhere of its
50
50
  own to keep them. `completeOidcLogin` checks `state` before it exchanges
51
51
  anything.
52
52
 
@@ -58,7 +58,7 @@ equal; any path that reaches the token exchange without the check.
58
58
 
59
59
  **Where you store the verifier and the `state` between the two calls is your
60
60
  application's decision**, and this policy cannot cover it. The README shows
61
- `sessionStorage`, which is a reasonable default and not the only correct one.
61
+ `sessionStorage`, a reasonable default and not the only correct one.
62
62
 
63
63
  ### The rest of the package
64
64
 
@@ -79,10 +79,10 @@ application's decision**, and this policy cannot cover it. The README shows
79
79
  **The Fleetless cloud is not in this repository.** A server that fails to
80
80
  enforce a permission, an authentication or authorisation flaw in the platform,
81
81
  a rate limit, a data leak from an API endpoint, anything about how a token is
82
- minted or validated — none of those live here, and none of them can be fixed by
83
- a change to this package. Report them to the same address; say which service
84
- you were looking at, and we will route it. What we cannot do is treat this
85
- repository's issue tracker as the place where they are tracked.
82
+ minted or validated — none of that lives here, and none of it can be fixed by
83
+ a change to this package. Report it to the same address, say which service you
84
+ were looking at, and we will route it. We will not treat this repository's
85
+ issue tracker as the place where it is tracked.
86
86
 
87
87
  Also out of scope here: the Fleetless console, the robot-side bridge, the
88
88
  `@fleetless/contracts` schemas (they have their own repository and their own