@fleetless/sdk 2.0.2 → 3.0.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,114 @@ All notable changes to `@fleetless/sdk`. The format follows Keep a Changelog; th
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [3.0.0] — 2026-09-06
8
+
9
+ Fleetless shows an app user **no page**. The developer's own UI owns every
10
+ screen — login, registration, verification, invitation acceptance, password
11
+ reset, the provider buttons, the MCP consent — and this release replaces the
12
+ SDK's auth surface with the JSON API those screens call. The hosted,
13
+ app-branded login and consent pages are gone from the platform, so they are
14
+ gone from here.
15
+
16
+ ### Removed
17
+
18
+ - **`auth.beginHostedLogin` and `auth.completeHostedLogin`**, with
19
+ `BeginHostedLoginOptions`, `HostedLoginRequest` and
20
+ `CompleteHostedLoginOptions`. The `/oauth/authorize` + `/oauth/token` flow
21
+ they drove no longer runs for app users. Use `auth.beginOidcLogin` and
22
+ `auth.completeOidcLogin` for federated sign-in, or `auth.login` for a
23
+ password.
24
+ - **`client.grants`** (`GrantsApi`, `grants.list`, `grants.revoke`), with the
25
+ `ConsentGrantSummary` and `ConsentRevokeResponse` re-exports. The routes
26
+ behind it are gone. The MCP consents an app user can withdraw are now
27
+ `auth.listMcpGrants()` and `auth.revokeMcpGrant(clientId)`.
28
+ - **`auth.passwordResetUrl()`**. It pointed at a Fleetless-served page that is
29
+ now the developer console's own, for Fleetless users rather than app users.
30
+ An app user's reset is `auth.requestPasswordReset` and
31
+ `auth.confirmPasswordReset`, in your own UI.
32
+ - **`LogoutResult`**, and with it everything `logout()` used to report about
33
+ the identity provider behind a session. That apparatus belonged to the
34
+ hosted login, where Fleetless owned the browser; an app that wants to end a
35
+ provider session redirects there itself, knowing its own provider.
36
+ - **`no_hosted_login_attempt`** from `SDK_ERROR_CODES`. The case it named —
37
+ nothing persisted for this attempt — still throws, as `state_mismatch`, and
38
+ the message still says which of the two happened.
39
+ - `HttpClient.requestOAuth`, the SDK's only form-urlencoded call. Nothing
40
+ reaches `/oauth/token` from here any more.
41
+
42
+ ### Added
43
+
44
+ - **Registration and verification**: `auth.register({ email, password,
45
+ displayName? })`, `auth.verifyEmail(token)`,
46
+ `auth.resendVerification(email)`.
47
+ - **Recovery and invitations**: `auth.requestPasswordReset(email)`,
48
+ `auth.confirmPasswordReset(token, newPassword)`,
49
+ `auth.acceptInvitation({ token, password, displayName? })`. Every one of
50
+ these that spends a mailed token stores the session it answers with, so a
51
+ person is not asked to sign in again immediately after proving they can read
52
+ the mail.
53
+ - **Federated sign-in, per app**: `auth.listProviders()` for the buttons,
54
+ `auth.beginOidcLogin({ slug, redirectUri })` to build the start URL with
55
+ PKCE, `auth.completeOidcLogin({ code, state, expectedState, codeVerifier })`
56
+ to trade the one-time code for a session, and
57
+ `auth.oidcErrorFromCallback(params)` to read a failed sign-in off the
58
+ redirect back as a `FleetlessError`. The SDK builds the URL and **never
59
+ navigates**; the app does.
60
+ - **The MCP consent screen**: `auth.mcpInteraction(id)`,
61
+ `auth.approveMcpInteraction(id)`, `auth.denyMcpInteraction(id)`. Both
62
+ decisions answer where to send the browser, a denial included.
63
+ - **The app user's own standing consents**: `auth.listMcpGrants()` and
64
+ `auth.revokeMcpGrant(clientId)`.
65
+ - New option and result types: `RegisterOptions`, `AcceptInvitationOptions`,
66
+ `ProviderButton`, `BeginOidcLoginOptions`, `OidcLoginRequest`,
67
+ `CompleteOidcLoginOptions`, `McpInteractionDecision`. New wire re-exports:
68
+ `ClientMcpInteraction`, `McpConsentGrant`, `ClientOidcErrorCode`.
69
+
70
+ ### Changed
71
+
72
+ - **`ClientIdentity` renamed two fields**: `kind: 'end_user'` is now
73
+ `'app_user'`, and `end_user_id` is now `app_user_id`. A rename rather than a
74
+ kept key, deliberately — the old subject was a member of the org's one pool,
75
+ the new one is a row belonging to exactly one app, and a consumer reading
76
+ `.end_user_id` would have typechecked while meaning something subtly
77
+ different.
78
+ - **`logout()` resolves with `void`**, not a `LogoutResult`. It still never
79
+ rejects and still always clears the local store.
80
+ - **A `serverKey` client refuses session methods with a `FleetlessError` whose
81
+ code is `invalid_option`**, where it used to throw a bare `Error` a caller
82
+ could only catch by message. `me()`, `listProviders()`, `mcpInteraction()`
83
+ and `oidcErrorFromCallback()` still work on one: the first is what a server
84
+ key is for, the next two are reads the cloud answers without a credential,
85
+ and the last touches no network.
86
+ - `state_mismatch` is now thrown by `completeOidcLogin`, before any request.
87
+ - Pinned to `@fleetless/contracts` `48acd56`.
88
+
89
+ ### Verification
90
+
91
+ - The `auth` and OIDC suites now drive the SDK's **default** `fetch` against a
92
+ real `node:http` server (`test/local-api.ts`) rather than a `vi.fn()`. Every
93
+ assertion about a path, a method, a header or a body is made against bytes
94
+ that actually left the process — which is what shows, for instance, that the
95
+ two MCP decisions send no `content-type` on their bodyless `POST`, a header
96
+ a fake `fetch` records as absent exactly as convincingly when it is wrong.
97
+
98
+ ## [2.1.0] — 2026-09-04
99
+
100
+ ### Added
101
+
102
+ - `InvokeOptions` is exported — the type of `actions.invoke`'s options
103
+ argument, which a caller constructs and could not name.
104
+
105
+ ### Changed
106
+
107
+ - Every exported member carries a doc comment; the generated SDK reference
108
+ on docs.fleetless.dev is rendered from them.
109
+ - `InMemoryTokenStore` declares an explicit empty `constructor()`. It exists
110
+ only to carry a doc comment: TypeDoc reflects a synthesized constructor
111
+ for a class without one, and an undocumented member is what the reference
112
+ is built to have none of. Behaviour is unchanged — the class had an
113
+ implicit no-argument constructor before and has an explicit one now.
114
+
7
115
  ## [2.0.2] — 2026-09-04
8
116
 
9
117
  No code change; the published `dist/` is byte-identical to 2.0.0.
package/README.md CHANGED
@@ -9,8 +9,8 @@ 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.
@@ -40,11 +40,13 @@ server-side with a server key instead of a user session.
40
40
  `cameras.live` takes a refcounted hold: the first viewer starts the robot
41
41
  publishing, the last one leaving stops it. You get a LiveKit room URL and
42
42
  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.
43
+ - **The whole auth surface, and every screen stays yours.** Fleetless renders
44
+ no page for an app user: registration with email verification, invitations,
45
+ password reset, single sign-on to one of the app's identity providers, and
46
+ the MCP consent screen are all methods on `client.auth`, called from a UI you
47
+ wrote. The federated flow runs its own PKCE (S256 only) against Fleetless;
48
+ `beginOidcLogin` makes no network call, and `completeOidcLogin` checks
49
+ `state` before it exchanges anything.
48
50
  - **A token store you control.** Implement two methods to keep a session in
49
51
  `localStorage`, a cookie or a native keystore; the default keeps it in
50
52
  memory. Refresh is silent and single-flight, so ten concurrent calls that
@@ -56,8 +58,10 @@ server-side with a server key instead of a user session.
56
58
  npm i @fleetless/sdk
57
59
  ```
58
60
 
59
- You need an app identifier and an end user's credentials from the
60
- [Fleetless Console](https://console.fleetless.dev).
61
+ You need an app identifier and the credentials of an **app user** an
62
+ account inside that app, which the developer's team creates or invites from
63
+ the [Fleetless Console](https://console.fleetless.dev). A console login is a
64
+ Fleetless user, a different identity space, and will not sign in here.
61
65
 
62
66
  ```ts checked
63
67
  import { createClient } from '@fleetless/sdk'
@@ -98,44 +102,106 @@ Slugs are the names you chose in the console, in `snake_case`
98
102
  (`[a-z][a-z0-9]*(_[a-z0-9]+)*`) — never the robot's internal ROS names, which
99
103
  is what makes renaming a node on the robot invisible to your app.
100
104
 
101
- ### Hosted login
105
+ ### Your own sign-in UI
102
106
 
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`.
107
+ Every screen an app user sees belongs to your app. These are the calls behind
108
+ them; the full walkthrough, page by page, is the
109
+ [app-auth recipe](https://docs.fleetless.dev/recipes/app-auth/).
106
110
 
107
111
  ```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',
112
+ // Registration. Resolves on 202 which says a policy-allowed request was
113
+ // accepted, NOT that an account was created. An address the app already knows
114
+ // gets the identical answer with no mail sent, so the only honest thing to
115
+ // render is a sentence about the mailbox.
116
+ await client.auth.register({ email, password, displayName })
117
+
118
+ // The page your app serves at the app's configured `verify_url`. Stores a
119
+ // session, so nobody types a password right after reading their mail.
120
+ await client.auth.verifyEmail(tokenFromTheLink)
121
+
122
+ // Recovery. requestPasswordReset always resolves on 202, for a known address
123
+ // and an unknown one alike.
124
+ await client.auth.requestPasswordReset(email)
125
+ await client.auth.confirmPasswordReset(tokenFromTheLink, newPassword)
126
+
127
+ // The page your app serves at the app's configured `invite_url`.
128
+ await client.auth.acceptInvitation({ token: tokenFromTheLink, password })
129
+ ```
130
+
131
+ ### Sign-in through an identity provider
132
+
133
+ An app may carry any number of OpenID Connect providers, configured per app in
134
+ the console. Your page draws the buttons; your app never talks to the provider.
135
+
136
+ ```ts
137
+ // Draw the buttons. Only enabled providers are listed, slug and name only.
138
+ const providers = await client.auth.listProviders()
139
+
140
+ // 1. Build the URL. No network call: this generates the PKCE verifier and the
141
+ // state, and hands them back, because a redirect is a fresh page load.
142
+ const request = await client.auth.beginOidcLogin({
143
+ slug: 'azure',
111
144
  redirectUri: 'https://your-app.example.com/callback',
112
145
  })
113
-
114
- // A redirect is a fresh page load, so persist these two yourself.
115
146
  sessionStorage.setItem('fleetless_state', request.state)
116
147
  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({
148
+ window.location.assign(request.url)
149
+
150
+ // 2. On the callback page. A refused sign-in redirects here with `error`
151
+ // rather than a code, so check for that first.
152
+ const params = new URL(window.location.href).searchParams
153
+ const failure = client.auth.oidcErrorFromCallback(params)
154
+ if (failure) {
155
+ // A FleetlessError carrying the documented reason: no_access, email_taken,
156
+ // email_unverified, domain_not_allowed, registration_closed, and the rest.
157
+ showSignInProblem(failure)
158
+ } else {
159
+ await client.auth.completeOidcLogin({
127
160
  code: params.get('code') ?? '',
128
161
  state: params.get('state') ?? '',
129
- expectedState,
130
- codeVerifier,
131
- clientId: 'oauth-client-id-from-the-console',
132
- redirectUri: 'https://your-app.example.com/callback',
162
+ expectedState: sessionStorage.getItem('fleetless_state') ?? '',
163
+ codeVerifier: sessionStorage.getItem('fleetless_code_verifier') ?? '',
133
164
  })
134
165
  // From here on me(), logout() and silent refresh behave exactly as they do
135
- // after login(). Never call completeHostedLogin twice for the same code.
166
+ // after login().
136
167
  }
137
168
  ```
138
169
 
170
+ `completeOidcLogin` refuses before any network call, with `state_mismatch`,
171
+ for a mismatch, a missing `state`, **and** an empty `expectedState` — that
172
+ last case is checked first and explicitly, because two empty strings compare
173
+ equal.
174
+
175
+ One callback URL is registered at the provider,
176
+ `https://api.fleetless.dev/api/client/oidc/callback`, the same for every app
177
+ and every provider. It is not your `redirectUri`, which is a page in your app.
178
+
179
+ ### The MCP consent screen
180
+
181
+ Only if the app serves its own MCP endpoint. An authorization redirects the
182
+ browser to a page your app serves, with an interaction id; your page signs the
183
+ person in, shows them what is being asked, and answers.
184
+
185
+ ```ts
186
+ // Sign the person in FIRST — `already_granted` is derived from the bearer,
187
+ // and an anonymous read answers `false` for everybody.
188
+ const interaction = await client.auth.mcpInteraction(interactionId)
189
+
190
+ // `interaction.client_name` is a name the client typed about itself during an
191
+ // unauthenticated registration. `client_name_verified` is the literal false;
192
+ // render it as a claim, never as an identity. `scopes` is always empty: what a
193
+ // session reaches is decided by the user's role, re-read on every call.
194
+ const { redirectTo } = approved
195
+ ? await client.auth.approveMcpInteraction(interactionId)
196
+ : await client.auth.denyMcpInteraction(interactionId)
197
+ window.location.assign(redirectTo) // a denial redirects too
198
+
199
+ // A "connected apps" screen. Withdrawing stops the NEXT authorization; a
200
+ // session already running keeps working for the rest of its 15 minutes.
201
+ const grants = await client.auth.listMcpGrants()
202
+ await client.auth.revokeMcpGrant(grants[0].client_id)
203
+ ```
204
+
139
205
  ### Running a job
140
206
 
141
207
  An action is a job with a lifecycle. `invoke` resolves as soon as the job
@@ -184,30 +250,6 @@ await room.disconnect()
184
250
  await session.release()
185
251
  ```
186
252
 
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.
210
-
211
253
  ## Documentation and help
212
254
 
213
255
  - **[SDK reference](https://docs.fleetless.dev/reference/sdk/)** — every method,
@@ -216,8 +258,12 @@ gained a name (`LogoutResult`); `.revoked` is unchanged.
216
258
  robot that has never connected to a value in your app.
217
259
  - **[REST and realtime API](https://docs.fleetless.dev/reference/api/)** — the
218
260
  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.
261
+ - **[Your own login UI](https://docs.fleetless.dev/recipes/app-auth/)** — the
262
+ full walkthrough for every auth screen, and the mistakes that cost the most
263
+ time.
264
+ - **[Identity](https://docs.fleetless.dev/reference/identity/)** — the two
265
+ identity spaces, the federation rules, and what each refusal licenses your
266
+ UI to claim.
221
267
  - **[CHANGELOG.md](CHANGELOG.md)** — what changed in each version.
222
268
  - Questions, bug reports and feature requests: hello@fleetless.dev.
223
269