@fleetless/sdk 2.1.0 → 3.0.1

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
@@ -9,12 +9,27 @@ The official TypeScript SDK for apps built on [Fleetless](https://fleetless.dev)
9
9
  which exposes a ROS 2 robot as a hosted REST and realtime API — exactly the
10
10
  topics, services and actions a developer chose to publish, under stable slugs,
11
11
  behind roles. The SDK covers all of it: datapoints (read, subscribe, recorded
12
- history), actions, services, publishers, cameras, jobs, assets and URDF, the
13
- hosted login, and an end user's own consent grants. It is framework-agnostic,
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
14
  ships ESM and CJS with its own types, and runs wherever a `fetch` and a
15
15
  `WebSocket` exist — a browser, a mobile webview, Node 20 or newer, and
16
16
  server-side with a server key instead of a user session.
17
17
 
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.
27
+
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.
32
+
18
33
  ## Why
19
34
 
20
35
  - **One connection, however many subscriptions.** Every datapoint and job
@@ -40,11 +55,13 @@ server-side with a server key instead of a user session.
40
55
  `cameras.live` takes a refcounted hold: the first viewer starts the robot
41
56
  publishing, the last one leaving stops it. You get a LiveKit room URL and
42
57
  token to hand to the LiveKit client of your choice.
43
- - **Hosted login, so your app never handles a password.** OAuth 2.1
44
- Authorization Code with PKCE (S256 only). `beginHostedLogin` makes no network
45
- call at all, and `completeHostedLogin` checks `state` before it exchanges
46
- anything. Federation to a customer's identity provider happens on the hosted
47
- page, not in your code.
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.
48
65
  - **A token store you control.** Implement two methods to keep a session in
49
66
  `localStorage`, a cookie or a native keystore; the default keeps it in
50
67
  memory. Refresh is silent and single-flight, so ten concurrent calls that
@@ -56,8 +73,10 @@ server-side with a server key instead of a user session.
56
73
  npm i @fleetless/sdk
57
74
  ```
58
75
 
59
- You need an app identifier and an end user's credentials from the
60
- [Fleetless Console](https://console.fleetless.dev).
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.
61
80
 
62
81
  ```ts checked
63
82
  import { createClient } from '@fleetless/sdk'
@@ -98,45 +117,107 @@ Slugs are the names you chose in the console, in `snake_case`
98
117
  (`[a-z][a-z0-9]*(_[a-z0-9]+)*`) — never the robot's internal ROS names, which
99
118
  is what makes renaming a node on the robot invisible to your app.
100
119
 
101
- ### Hosted login
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
102
147
 
103
- Redirect the end user to the Fleetless-hosted login page instead of collecting
104
- a password yourself. Register the OAuth client in the console first, under
105
- **App Settings → OAuth clients**; its `client_id` is not your `appIdentifier`.
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.
106
150
 
107
151
  ```ts
108
- // 1. Build the URL. No network call happens here.
109
- const request = await client.auth.beginHostedLogin({
110
- clientId: 'oauth-client-id-from-the-console',
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',
111
159
  redirectUri: 'https://your-app.example.com/callback',
112
160
  })
113
-
114
- // A redirect is a fresh page load, so persist these two yourself.
115
161
  sessionStorage.setItem('fleetless_state', request.state)
116
162
  sessionStorage.setItem('fleetless_code_verifier', request.codeVerifier)
117
- window.location.href = request.url
118
-
119
- // 2. On the callback page. The callback can legitimately carry ?error=...
120
- // instead of a code, so check for that first.
121
- const params = new URLSearchParams(window.location.search)
122
- const expectedState = sessionStorage.getItem('fleetless_state')
123
- const codeVerifier = sessionStorage.getItem('fleetless_code_verifier')
124
-
125
- if (!params.has('error') && expectedState && codeVerifier) {
126
- await client.auth.completeHostedLogin({
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({
127
175
  code: params.get('code') ?? '',
128
176
  state: params.get('state') ?? '',
129
- expectedState,
130
- codeVerifier,
131
- clientId: 'oauth-client-id-from-the-console',
132
- redirectUri: 'https://your-app.example.com/callback',
177
+ expectedState: sessionStorage.getItem('fleetless_state') ?? '',
178
+ codeVerifier: sessionStorage.getItem('fleetless_code_verifier') ?? '',
133
179
  })
134
180
  // From here on me(), logout() and silent refresh behave exactly as they do
135
- // after login(). Never call completeHostedLogin twice for the same code.
181
+ // after login().
136
182
  }
137
183
  ```
138
184
 
139
- ### Running a job
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
140
221
 
141
222
  An action is a job with a lifecycle. `invoke` resolves as soon as the job
142
223
  exists; feedback, progress and the result arrive over `subscribe`.
@@ -164,6 +245,35 @@ sub.unsubscribe()
164
245
  `error.details.running`. A service is the same machinery with a plain result:
165
246
  `await client.services.call(robotId, 'set_mode', { mode: 'autonomous' })`.
166
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
+ ```
273
+
274
+ `publish` rejects `publisher_busy` while a different user holds the publisher
275
+ and has not been quiet for its configured timeout.
276
+
167
277
  ### Cameras
168
278
 
169
279
  ```ts
@@ -184,29 +294,43 @@ await room.disconnect()
184
294
  await session.release()
185
295
  ```
186
296
 
187
- ## Migrating from 1.0.0
188
-
189
- 2.0.0 is a breaking release. `CHANGELOG.md` carries the complete inventory —
190
- established by diffing the published 1.0.0 tarball's types against this one's,
191
- not read off the commit log. The breaks, one line each:
192
-
193
- - `auth.register()` and `auth.confirmRegistration()` are gone with no
194
- successor; a group grows by invitation (see [Account recovery](https://docs.fleetless.dev/reference/sdk/#account-recovery) in the SDK reference).
195
- - `auth.requestPasswordReset()` and `auth.confirmPasswordReset()` are gone.
196
- Link the user to `auth.passwordResetUrl()` instead.
197
- - The types `MailStatus` and `ClientRegisterResponse` went with them.
198
- - The error code `not_a_member` is gone — a compile break for an exhaustive
199
- `switch` on `error.code`.
200
- - `CameraDescriptor.snapshot_interval_ms` is now `snapshot_interval_seconds`.
201
- **Read that twice:** it is a rename *and* a unit change, so renaming the
202
- property and nothing else leaves you with a number a thousand times too
203
- small.
204
- - `UrdfCompleteness.missing` and `UrdfSceneResources.missing` are no longer
205
- `string[]` but `{ uri, element }[]`, so a missing texture is no longer
206
- reported as a missing mesh. For the old shape: `missing.map((m) => m.uri)`.
207
-
208
- Nothing else was removed or re-typed. `auth.logout()`'s result grew a field and
209
- gained a name (`LogoutResult`); `.revoked` is unchanged.
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.
210
334
 
211
335
  ## Documentation and help
212
336
 
@@ -216,18 +340,38 @@ gained a name (`LogoutResult`); `.revoked` is unchanged.
216
340
  robot that has never connected to a value in your app.
217
341
  - **[REST and realtime API](https://docs.fleetless.dev/reference/api/)** — the
218
342
  wire surface underneath this package.
219
- - **[Hosted login recipe](https://docs.fleetless.dev/recipes/hosted-login/)** —
220
- the full walkthrough, including every error the callback can carry.
343
+ - **[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.
346
+ - **[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.
221
349
  - **[CHANGELOG.md](CHANGELOG.md)** — what changed in each version.
222
350
  - Questions, bug reports and feature requests: hello@fleetless.dev.
223
351
 
224
352
  Fleetless is in closed beta. The waiting list is at
225
353
  <https://fleetless.dev/#waiting-list>.
226
354
 
355
+ ## Reporting a security issue
356
+
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.
361
+
362
+ ## Contributing
363
+
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.
370
+
371
+ By participating you agree to the [Code of Conduct](CODE_OF_CONDUCT.md).
372
+
227
373
  ## Maintainers
228
374
 
229
- Maintained by [Dehne Robotik GmbH](https://dehne-robotik.de). The source
230
- repository is private and takes no outside contributions; questions and bug
231
- reports go to hello@fleetless.dev.
375
+ Maintained by [Dehne Robotik GmbH](https://dehne-robotik.de).
232
376
 
233
377
  MIT licensed — see [LICENSE](LICENSE).
package/SECURITY.md ADDED
@@ -0,0 +1,100 @@
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 SDK version and runtime it happens on (a browser
9
+ name and version, or a Node version), and the smallest sequence of SDK calls
10
+ that shows it. A reproduction against a published version of this package is
11
+ the 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 the TypeScript SDK that client applications use to talk to
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.
22
+
23
+ Specifically in scope:
24
+
25
+ ### Tokens
26
+
27
+ The SDK receives an access token and a refresh token from the platform and
28
+ hands them to a `TokenStore` that **you** implement; the built-in default keeps
29
+ them in memory only and writes them nowhere. A report is in scope if the SDK:
30
+
31
+ - puts a token somewhere the caller did not ask for it to go — a global, a
32
+ URL, a query string, a log line, an error message, or a thrown object's
33
+ properties;
34
+ - sends a token to an origin other than the `apiUrl` the client was
35
+ constructed with;
36
+ - attaches a token to a request it should not have (the SDK refuses an
37
+ absolute URL it did not build — see `untrusted_absolute_url`);
38
+ - keeps a token reachable after `logout()`, or fails to clear the store;
39
+ - races its own silent refresh in a way that lets a stale or a foreign token
40
+ be used.
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.
45
+
46
+ ### PKCE and `state` in the federated sign-in flow
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
50
+ own to keep them. `completeOidcLogin` checks `state` before it exchanges
51
+ anything.
52
+
53
+ In scope: a verifier or a `state` with insufficient entropy or a predictable
54
+ source; S256 not being enforced; `completeOidcLogin` exchanging a code when
55
+ `state` does not match, is missing, or when `expectedState` is empty — that
56
+ last case is checked first and explicitly, because two empty strings compare
57
+ equal; any path that reaches the token exchange without the check.
58
+
59
+ **Where you store the verifier and the `state` between the two calls is your
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.
62
+
63
+ ### The rest of the package
64
+
65
+ - Request construction: path segments are percent-encoded (`pathSegment`), so
66
+ a slug or an identifier shaped like `../../../admin` must not escape its
67
+ position in the URL.
68
+ - The realtime channel: its authentication, its reconnection, and what it
69
+ resends after one.
70
+ - `prepareUrdfScene` and `createMeshLoader`: a robot's own URDF names the URLs
71
+ three.js is asked to fetch, so a reference the SDK does not own must not
72
+ become a network request from your users' browsers.
73
+ - Anything the SDK writes into browser-reachable state.
74
+ - The published npm package `@fleetless/sdk` and its contents, including a
75
+ dependency of it.
76
+
77
+ ## What is not in scope
78
+
79
+ **The Fleetless cloud is not in this repository.** A server that fails to
80
+ enforce a permission, an authentication or authorisation flaw in the platform,
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.
86
+
87
+ Also out of scope here: the Fleetless console, the robot-side bridge, the
88
+ `@fleetless/contracts` schemas (they have their own repository and their own
89
+ policy), the documentation site, and any deployment of Fleetless operated by
90
+ someone else.
91
+
92
+ Out of scope in your own application: where you store tokens, how you protect
93
+ your own pages, and any XSS in your app — an attacker who can run script in
94
+ your page can read anything your page can, and no SDK design prevents that.
95
+
96
+ ## Supported versions
97
+
98
+ The latest published minor of `@fleetless/sdk` receives fixes. Older minors do
99
+ not; a security fix is released as a new patch on the current minor, and
100
+ upgrading is the remedy.