fleetlessfleetlessdocs
Concepts/Manage Users and Roles

👥 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:

  1. Start. Your page sends the browser to /api/client/oidc/:slug/start with its own PKCE challenge, its state and its redirect_uri.
  2. Callback. Fleetless runs the flow against the provider, validates the ID token end to end, and redirects to your redirect_uri with a one-time code or an error.
  3. 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.

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_name is a claim, not an identity. The client registered itself without authenticating and typed that name about itself, and client_name_verified is the literal false. Render it as “a client calling itself …”.
  • scopes is always empty. This authorization server issues none: what an MCP session may reach is the person’s role, re-read on every call.
  • already_granted is 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.