👥 Manage Users and Roles
Who the users of your app are, what a role lets one of them reach, and every way somebody gets in.
Two identity spaces
Fleetless holds two populations, and nothing joins them.
- Fleetless users are your team: they open the console and define robots, apps and roles. One address is one Fleetless user across the whole platform, and each carries a tier — Owner (everything, organisation settings included) or Developer (everything short of that). There is no self-service way in; a person joins by invitation.
- App users belong to exactly one app. Their address is unique within that app, so the same address in two apps of one organisation is two unrelated accounts.
- The app-user limit of your plan counts across the organisation: the
app users of every app, pending invitations included. A new app user, an
invitation or a self-registration past the limit is refused with
409 plan_limit. See Plans and Limits.
A token’s kind says which space a session came from: developer,
app_user, or server_key — a per-app secret with the app’s full permission
set, for your own backend and never a client.
Fleetless shows an app user no page. Your app owns every screen they touch; Fleetless answers JSON and mails links back into your app.
Roles are the only filter 🔑
Every app starts with two roles, observe and operate, and you may define
any number of custom ones. A grant is a {robot, slug} pair. If a robot is
attached to an app then all of its exposures exist in it, so hiding one
app-wide means removing it from every role.
Three capabilities are not slugs and are granted on their own:
| Capability | What it gates |
|---|---|
action_history |
Past job runs, not only live ones. A run names the actor who started it, so this role learns who else has been driving the robot. |
presence |
Nothing. No route, SDK method or realtime frame consults it today. |
assets |
The asset store, URDF and meshes. Its own decision, because a mesh set gives away the machine’s build. |
A new role grants nothing. Sign-in works and every read answers
403 forbidden until you tick something on it.
That 403 reads the same for a slug the role does not grant and a slug that
does not exist, so a caller cannot map an app they hold no rights in. A
missing capability is the exception: 403 capability_required names the
switch that is off.
How a user gets in
Four doors, and the app’s settings decide which are open.
| Door | What it needs | The account lands as |
|---|---|---|
| A developer creates it in the console | a password, and a role or the app’s default | active at once |
| Self-registration | self_registration on, plus verify_url and default_role_id set. Answers 202 either way. |
pending_verification until the mailed token is spent |
| Invitation | a role on the invitation, or an app default. Bypasses allowed_domains. |
active on acceptance |
| Federated sign-in | an enabled provider whose ID token asserts email_verified |
active |
Only active may sign in. A developer can set only active and blocked:
pending_verification is entered by self-registration and left by spending
the mailed token. Status is re-read on every request, so blocking
bites at the next call, not the next refresh.
allowed_domains restricts self-registration by address domain, and an empty
list means no restriction, not nobody. The same switch and the same list
govern a federated sign-in: one policy for one decision, whichever door the
person arrives at.
Each mailed token is hashed at rest and single-use:
| Token | Lives |
|---|---|
| invitation | 7 days |
| verification | 24 hours |
| password reset | 1 hour |
All three answer one refusal, 410 token_spent, for a token that is unknown,
expired, revoked or already used. Telling those apart would say whether a
token had ever existed.
Two-factor
An app user’s authenticator is this account’s own, never a Fleetless user’s —
see Sign-in and Two-Factor for that side.
Whether it is asked for at all is the app’s own policy
(Create an App): off,
optional or required.
The Users list carries a Two-factor column:
| Shown | Means |
|---|---|
on |
a confirmed authenticator; every sign-in asks for it |
at next sign-in |
the policy is required and this person has none yet — asked, not blocked, and nobody is signed out for it |
by provider |
an OIDC-only user; the provider owns that sign-in, so this is never asked |
— |
the policy is off or optional and this person has not turned it on |
The same state, in full, is on the user’s own panel: when it was set up and
how many of the ten recovery codes are left, next to a Reset… action.
Resetting removes the authenticator and the recovery codes and ends every
session the user holds; they sign in again by whichever sign_in_methods
the app allows, and set a fresh authenticator up at their very next sign-in if
the policy still says required.
The refusals
POST /api/client/register refuses for policy or for what the caller typed,
never about a person.
| Code | Status | Means |
|---|---|---|
registration_closed |
403 |
the app has self-registration off |
domain_not_allowed |
403 |
the address is outside allowed_domains |
not_found |
404 |
no app carries that identifier |
plan_limit |
409 |
the organisation is at its plan’s app-user limit, counted across every app in it |
quota_exceeded |
409 |
the organisation is at the app-user protection ceiling, which sits above every plan’s limit |
target_state_conflict |
409 |
the app is not configured for this call yet |
target_state_conflict carries details.fields[0].field and
details.fields[0].rule naming what is missing: verify_url / not_set,
default_role_id / not_set, or default_role_id / unknown_role. Both live in the console; see
Create an App.
A new app has no default_role_id. Until one is chosen, registration
refuses every caller with 409 target_state_conflict before it looks at the
address, and a federated sign-in answers no_access with nothing naming the
cause.
The enumeration rule
The routes an unauthenticated stranger can reach never say whether an account exists. Any observable difference between “we sent a link” and “no such account” is an account-enumeration oracle, and one built into your own UI serves an attacker as well as one built into ours.
| Route | Always answers | So your UI may say |
|---|---|---|
register |
202 for every policy-allowed request |
“check your mail” |
resend-verification |
202 |
“check your mail” |
password/reset |
202 |
“if that address has an account, a reset link is on its way” |
login |
invalid_credentials for a wrong password, a blocked account and an unverified one alike |
“that email and password do not match” |
A 202 from register is a statement about a mail, not about an
account. An address that already exists gets the identical answer with no
mail sent, so an app rendering "welcome, your account is ready" has rebuilt
the oracle the design removes.
Mail, and what it reports
Wherever the platform reports on a mail it tried to send, it reports one of four words rather than a boolean.
mailStatus |
Means |
|---|---|
sent |
the SMTP server accepted the message. Not “delivered” — no sender can promise that. |
not_requested |
nothing was attempted: the caller asked for no mail, or there was no link for one to carry. |
not_configured |
the deployment has no SMTP at all. An expected state and not a failure; the link itself is the primary path. |
failed |
SMTP was configured, was tried, and refused or was unreachable. |
Only failed is worth somebody’s attention; rendering not_configured as an
error sends people to fix what is not broken.
rate_limited carries exactly one number, details.retry_after_ms, and the
obligation is to wait that long. A retry loop turns your own client into the
attack the limit exists to stop.
Federated sign-in
An app may carry any number of OpenID Connect providers.
GET /api/client/providers gives your login page the slug and display name of
each enabled one, and nothing else. A disabled provider is not a button
that refuses; it is a button that is not there.
Pressing a button is three steps with a browser redirect between them:
- Start. Your page sends the browser to
/api/client/oidc/:slug/startwith its own PKCE challenge, itsstateand itsredirect_uri. - Callback. Fleetless runs the flow against the provider, validates the
ID token end to end, and redirects to your
redirect_uriwith a one-time code or anerror. - Exchange. Your page trades that code for a session at
oidc/exchange.
The redirect_uri’s origin must be one of the app’s allowed_origins, and a
failure there never redirects. A URL carrying a fragment or userinfo is
refused the same way, which catches the default callback of a hash-routed
app. The message points at the allowed-origins list, which was never the
problem, so give the callback a real path.
A provider that does not assert email_verified produces
email_unverified for every user. Fleetless creates or links no account
from an unverified address, whatever the rest of the policy says.
link_verified_emails needs the assertion as well as the switch, because
either alone is account takeover.
Every refusal reaches your redirect_uri as error plus your state, in a
vocabulary your page can branch on: no_access, email_taken,
email_unverified, domain_not_allowed, registration_closed,
idp_unavailable, exchange_failed, claims_incomplete,
provider_misconfigured, provider_disabled, invalid_request,
plan_limit and quota_exceeded. The provider’s own error string is logged,
never passed through.
plan_limit means the organisation is at its plan’s app-user limit;
quota_exceeded means it is at the app-user protection ceiling, which sits
above every plan’s limit. Both are raised only when the sign-in would create
an account: a person who already has one signs in as before. The call
sequences are in the
SDK auth reference.
MCP consent
If the app serves MCP, your app draws the consent screen
too. A client that starts an authorization makes Fleetless write an
interaction and redirect the browser to your mcp_login_url with the id
substituted. The interaction lives ten minutes, and anything that is not a
live interaction of this app answers 410 interaction_expired.
Sign the person in before you read the interaction. The read takes an
optional bearer and already_granted is derived from it alone, so an
anonymous read leaves that field permanently false — a wrong answer with a
200 on it.
Three more rules for that screen:
client_nameis a claim, not an identity. The client registered itself without authenticating and typed that name about itself, andclient_name_verifiedis the literalfalse. Render it as “a client calling itself …”.scopesis always empty. This authorization server issues none: what an MCP session may reach is the person’s role, re-read on every call.already_grantedis a record of what they answered last time, and this route makes no second use of it. Approve succeeds identically for a user holding no grant at all.
Withdrawing a consent ends that one client and nothing else. The app’s MCP endpoint reads the standing grant on every request, so a withdrawn client is refused at its very next call with an unexpired token in hand.
Every screen above is already written on @fleetless/sdk in the
app starter.