User accounts & authentication (E1)¶
The leader has a built-in user account system: register, login, browser
sessions, and per-user API tokens. Users live in the leader's state DB
(SWARM_JOIN_STATE_DB, default $SWARM_DATA_DIR/join_state.sqlite3).
First run¶
The first registered user automatically becomes an active admin — no manual
DB setup needed. Every later registrant is a plain user whose starting status
depends on the flags below.
Configuration¶
| Env var | Default | Effect |
|---|---|---|
SWARM_REQUIRE_EMAIL_VERIFICATION |
off | New users start unverified and must confirm their email before login (requires the mailer, E11). |
SWARM_REQUIRE_ADMIN_APPROVAL |
off | New users start (or move to) pending_approval; an admin must approve them before they can log in. Works without SMTP. |
SWARM_SESSION_TTL_HOURS |
336 (14 days) |
Browser session lifetime, counted from sign-in (not an idle timeout). Two minutes before it ends, the UI warns and offers Stay signed in, which gives the session a fresh full lifetime (WCAG 2.2.1; see Accessibility). |
SWARM_IMPRINT_FILE |
(unset) | Path to an HTML fragment with the provider identification (§ 5 DDG). Unset serves a visible "not configured" placeholder. |
SWARM_PRIVACY_FILE |
(unset) | Path to an HTML fragment with the privacy notice (Art. 13 GDPR). Same fallback. |
SWARM_LEGAL_LANG |
en |
BCP 47 tag the two fragments are written in, so the pages declare their language (WCAG 3.1.1). |
SWARM_COOKIE_SECURE |
1 |
Session cookies are Secure (HTTPS-only). Set 0 only for plain-HTTP local development. |
SWARM_REQUIRE_NODE_AUTH |
on | Enforce node credentials on fellow → leader calls (E5). Set 0 to reopen the pre-E5 migration grace while nodes are upgraded. |
SWARM_REQUIRE_OPERATOR_AUTH |
on | Enforce the authorization sweep (E7): API routes require a session/token/admin per their class. Set 0 for a migration window. |
SWARM_REQUIRE_CSRF |
on | Double-submit CSRF check on cookie-authenticated state changes. |
SWARM_AUTH_RATE_MAX / SWARM_AUTH_RATE_WINDOW_S |
10 / 60 |
Auth-endpoint rate limit (attempts per window, per IP). |
SWARM_TRUSTED_PROXY_HOPS |
1 |
How many reverse proxies sit in front of the leader. The client IP is taken this many entries back from the right of X-Forwarded-For; 0 ignores the header entirely. |
SWARM_LOGIN_LOCKOUT_THRESHOLD / SWARM_LOGIN_LOCKOUT_SECONDS |
5 / 300 |
Failed logins from one (account, IP) pair before that pair is locked out. |
SWARM_ACCOUNT_LOCKOUT_THRESHOLD |
8 × the above |
Failed logins against one account from any address before the account itself locks. |
The three enforcement flags are on by default. They shipped default-off as
migration grace, which meant a fresh self-hosted deployment answered the whole
operator API anonymously and accepted fellow calls with no credential. The
leader logs a SECURITY: warning at startup for each one that is switched off.
Client IP and X-Forwarded-For¶
Rate limits and the audit log identify callers by IP, so where that IP comes
from is a security decision. X-Forwarded-For is append-only: the proxy chain
adds to the right, and the left-most entry is whatever the caller sent. The
leader therefore counts SWARM_TRUSTED_PROXY_HOPS entries back from the right
(see accounts/audit.py:client_ip) instead of trusting the left-most value -
otherwise one extra request header defeats every per-IP limit and writes a
forged address into the audit log. Set the value to the number of proxies you
actually run between the client and the leader that each append an entry:
1 for the bundled nginx frontend.
Proxies in front of that nginx (Traefik, an Ingress controller, a tunnel) do not
add to the count. The frontend resolves the client itself: it believes
X-Forwarded-For only from SWARM_EDGE_TRUSTED_PROXIES (default: loopback and
the private ranges, where those proxies sit; off believes nobody), takes the
right-most address it does not trust as the client - for its own edge rate
limits too - and appends that one. So 1 stays right behind any number of
trusted proxies. Before K1 the frontend appended the proxy's address instead,
and every client behind Traefik shared one bucket. It is independent of uvicorn's --forwarded-allow-ips
(SWARM_FORWARDED_ALLOW_IPS), which only affects the scheme/host in
request.base_url.
Account states: unverified → pending_approval → active → disabled.
Login is only possible in active; the login error explains why otherwise.
Pages¶
| Route | Access | Purpose |
|---|---|---|
/ |
public | Landing page (register/login CTAs); logged-in users are redirected to /dashboard. |
/de |
public | The same landing page in German; /en is a 301 to /, which stays the English canonical. |
/robots.txt, /sitemap.xml, /llms.txt |
public | Crawler-facing files. The sitemap lists both languages with their hreflang alternates. |
/login, /register |
public | Auth forms (redirect to /dashboard when already logged in). |
/dashboard |
session | Post-login home: live pools (with plan quota), create-pool flow, tiles for Training Console, Inference, Profile. |
/pools/{owner}/{pool} |
session + pool visibility | Pool detail: live node status, per-pool join command, jobs, models, members; edit/delete for the owner. Its consoles are /pools/{owner}/{pool}/training and /inference. A pool the viewer may not see redirects to /dashboard, exactly like one that does not exist. |
/buckets/{owner}/{bucket}, /orgs/{org} |
session + visibility | A bucket, an organization (members only). See Organizations and addresses. |
/training, /inference |
session | The active pool's consoles (302 to the pool's own address). The retired /ops/join, /ops/join-v2 and /ops/inference answer 301. |
/profile |
session | Account details, password change, personal API tokens. |
/legal/imprint, /legal/privacy |
public | Legal pages; content from SWARM_IMPRINT_FILE / SWARM_PRIVACY_FILE (HTML fragments), placeholder otherwise. Set SWARM_LEGAL_LANG to the BCP 47 tag those fragments are written in (de for a German Impressum) - the page declares it on <html lang>, and an unparseable tag falls back to en. |
/legal/accessibility |
public | Erklärung zur Barrierefreiheit (BITV 2.0 § 12b). Rendered in the visitor's interface language. |
/legal/leichte-sprache |
public | The BITV 2.0 § 3 (2) explanation in Leichte Sprache. Always German. |
/lang/{code} |
public | Remembers an interface language in the swarm_lang cookie and returns to the referring page (same-origin paths only). |
The console pages (/training, /inference and each pool's
/pools/{owner}/{pool}/training|inference) are session-gated: without a
session they redirect to /login (303). With one they show the logged-in chip -
name, Dashboard link, logout.
Endpoints¶
| Route | Method | Purpose |
|---|---|---|
/api/v1/auth/register |
POST | {email, password, display_name?} — creates the account; logs it straight in when it starts active. |
/api/v1/auth/login |
POST | {email, password} — sets the session cookie. 401 bad credentials, 403 not-yet-active (with reason). |
/api/v1/auth/logout |
POST | Revokes the session, clears the cookie. |
/api/v1/auth/me |
GET | The logged-in user. |
/api/v1/auth/session/extend |
POST | "Stay signed in": gives the calling session a fresh full SWARM_SESSION_TTL_HOURS lifetime and re-issues its cookie. 401 once the session has ended. |
/api/v1/auth/password |
POST | {current_password, new_password} — changes the password and revokes all other sessions. |
Passwords are hashed with scrypt (per-user salt); session and API tokens are
stored only as SHA-256 hashes. State-changing endpoints accept JSON bodies only,
which together with SameSite=Lax cookies blocks cross-site form POSTs.
API tokens (/api/v1/api-tokens) minted while logged in belong to that user;
plain users see and revoke only their own tokens, admins see all.
API token scopes (MCP M2)¶
A token is a credential for its owner, narrowed by the scopes it was created
with (leader/accounts/token_scopes.py):
| Scope | Needed for |
|---|---|
read |
any GET/HEAD on a session-or-token route: pools, jobs, models, buckets, logs, /api/v1/auth/me |
operate |
every mutation (POST/PUT/PATCH/DELETE) and chat (/v1/chat/completions), which runs a model |
admin |
every admin-class route; only an admin owner can hold it |
- Create:
POST /api/v1/api-tokenstakes{"label": "...", "scopes": [...]}. Leavingscopesout gives["read", "operate"], what a token could do before scopes existed short of admin. An unknown scope, an empty list, oradminfor a non-admin owner answers 400 (bad-scope) rather than silently minting a weaker token. - List:
GET /api/v1/api-tokensreturns each token's effective scopes, alegacyflag and theowner(the owner's email; an admin sees every user's tokens). A legacy token (stored before scopes existed, so with noscopesfield) keeps its owner's full power -read+operate, plusadminfor an admin owner. The profile page marks these and suggests replacing them with a narrower token; for an admin it adds an Owner column and counts other users' legacy tokens separately from their own. While any legacy token exists, every admin also gets a dismissible warning above the page on any app page, linking there (dismissed per browser, cookieswarm_legacy_tokens_ack). - Scopes are independent.
operatedoes not implyread, so a write-only automation token stays write-only. adminnever outlives the role. Effective scopes are computed per request from the owner's current role: a demoted admin's admin-scoped token stops reaching admin routes at once.- Enforcement happens twice, in one response shape. The auth middleware
answers 403
insufficient-scope(RFC 7807,typeending in/insufficient-scope) for any token request its scopes do not cover, on everysession/operator/adminroute and on chat - whether or notSWARM_REQUIRE_OPERATOR_AUTHis on, so areadtoken stays read-only even on a deployment that turned the operator-auth sweep off. The route dependencies (require_user,require_admin,require_admin_principal) check the same rule behind it and refuse with the same problem. - Sessions are not scoped - the browser is the owner.
- What a token can never reach: password, 2FA, session management and the
API-token routes themselves stay
session-only, so a token cannot mint itself more power or lock its owner out. The admin user routes (/api/v1/admin/users/*) accept an admin-scoped token, but refuse it (403) a password reset, a verification link, an email or role change and a delete aimed at the token owner's own account, and never return a one-time sign-in link (invite_url/reset_url/verify_url) to a token - the answer carrieslink_withheld: trueinstead. A browser session gets both, as before. Buckets, datasets, pools and/api/v1/me/contributionaccept a session or a scoped token and hold the token to its owner's buckets and pools (another user's ids answer 404).
OAuth for MCP connectors (MCP M7)¶
claude.ai and Claude Desktop connect to /mcp with a login and a consent page
instead of a pasted token. The leader is its own OAuth 2.1 authorization
server, following the MCP authorization spec (2025-06-18); the code is
leader/mcp/oauth.py and oauth_store.py.
| Endpoint | What it does |
|---|---|
GET /.well-known/oauth-protected-resource[/mcp] |
RFC 9728 metadata for /mcp; the /mcp 401's WWW-Authenticate points here |
GET /.well-known/oauth-protected-resource/mcp/{toolsets} |
the same for a toolset URL such as /mcp/pools,admin: resource is that URL exactly, as strict clients require; a 401 from that URL points here |
GET /.well-known/oauth-authorization-server |
RFC 8414 metadata: endpoints, S256 only, public clients only |
POST /oauth/register |
RFC 7591 dynamic client registration |
GET /oauth/authorize |
the consent page; without a session it sends the browser to /login?next=... first |
POST /oauth/authorize |
the consent decision (session + CSRF) |
POST /oauth/token |
authorization_code (PKCE S256 required) and refresh_token |
POST /oauth/revoke |
RFC 7009 revocation of an access or refresh token |
POST /oauth/device_authorization |
RFC 8628: a device without a browser (swarm login --device) gets a device code and a short user code (XXXX-XXXX, ten minutes) |
GET /oauth/device |
where the user types the code (login first), then the consent page for that device; POST /oauth/device is its decision (session + CSRF). The device polls /oauth/token with the device_code grant |
GET /api/v1/me/oauth-grants |
the signed-in user's connected apps (AI agents page, browser session only) |
DELETE /api/v1/me/oauth-grants/{grant_id} |
disconnect one: its refresh and access token end at once; audited as oauth.disconnected |
GET/POST /api/v1/me/oauth-clients, DELETE .../{client_id} |
apps the user registered by hand (AI agents page): list, register (a confidential app's client_secret is in the answer only), delete - which ends every connection the app holds. Browser session only |
GET /api/v1/admin/oauth-clients, DELETE .../{client_id} |
everyone's registered apps, with their owner; an admin's browser session only |
GET /api/v1/admin/oauth-grants |
every user's connected apps, with the user's email (Admin - Connected apps; an admin's browser session only, never a token) |
DELETE /api/v1/admin/oauth-grants/{grant_id} |
an admin disconnects any user's app; audited as oauth.disconnected with by=admin |
- Apps registered by hand. For a tool that asks for an OAuth client ID
(and secret) instead of registering itself, a user registers it on the AI
agents page: a name and redirect URIs, public or confidential. A
confidential app's secret is shown once and stored hashed; it must
authenticate at
/oauth/token,/oauth/device_authorizationand/oauth/revokewithclient_secret_basicorclient_secret_post(401 invalid_clientotherwise). Such apps have an owner, are never evicted and count 20 per account. - Dynamically registered clients are public. Registration accepts redirect URIs on
https,httponlocalhost/127.0.0.1/[::1], and an app's own scheme as far asSWARM_OAUTH_NATIVE_SCHEMESallows (below). A client that asks for a secret-basedtoken_endpoint_auth_methodis registered withnoneinstead (RFC 7591 lets the server choose), and any secret it sends is ignored - PKCE is what binds the code to the app. - Scopes are the token scopes above. The default is
read operate;adminis offered only to an admin (enrolled in 2FA whenSWARM_REQUIRE_ADMIN_2FAis on). Scopes the leader does not know are dropped;adminfrom a non-admin is narrowed away with a note on the consent page, and a request for nothing the user may grant is refused. The consent page also names the MCP toolsets the app asked for (/mcp/<a,b>); those only choose which tools are listed. - Apps that publish their own description (Client ID Metadata Documents,
MCP 2025-11-25). An app may use an https URL as its
client_id, with a JSON document there naming that URL and its redirect URIs; the leader advertisesclient_id_metadata_document_supported. Nothing is registered: the leader fetches the document when someone signs in, caches it for itsCache-Control: max-age(1 minute to 1 hour, else 10 minutes), and shows the URL's host on the consent page as the app's verified site - unlike a self-chosen name, only that site can publish the document. Fetching a URL a stranger chose is guarded: https on port 443 with a host name (no IP literal, credentials or fragment) and a path; every address the name resolves to must be public, and the connection goes to one of the vetted addresses, so DNS cannot change the answer in between (the certificate is still checked for the name); no redirects, 3 seconds, 5 KB, a JSON object whoseclient_idis its own URL; at most 10 fetches per host a minute. These apps do not count againstSWARM_OAUTH_MAX_CLIENTS. - Native apps. A loopback redirect URI (
http://127.0.0.1/callback) matches on any port (RFC 8252 7.3), soswarm loginand other native apps listen wherever the system lets them; path and host must match. A client that only uses the device grant registers without redirect URIs. - Devices without a browser (RFC 8628) poll
/oauth/tokenevery 5 seconds; polling faster answersslow_downand adds 5 seconds. The device page warns that a code someone else sent is a request for your account, and an account may try 20 codes per 10 minutes. - Remembered consent. A user who has a live connection to an app gets a
new code for the same or fewer scopes without the consent page (audited
with
remembered=yes) - only when the app's identity is assured: anhttpsredirect URI or a client metadata document. Apps that return to a loopback address or their own scheme always see the page (RFC 8252 8.6: any local program could claim those).prompt=consentalways shows it. The connected-apps lists show one row per app and user withconnections, and Disconnect ends every connection of that app. - Step-up. An OAuth connection calling a tool its scopes do not cover
gets
403withWWW-Authenticate: Bearer error="insufficient_scope", scope="read operate"(MCP 2025-11-25), so the client can ask for the wider scope; an API token keeps the tool error.adminis never stepped up for a non-admin. - The login page keeps the request. When
?next=is an app's or a device's sign-in, the login and register pages say an app is waiting and carry the request through registration and two-factor sign-in. - MCP sessions follow the connection. A Streamable HTTP session opened
with an OAuth access token is bound to its connection (grant), so it
survives the hourly refresh; another connection or token still gets
404. - Desktop apps return to their own scheme. Cursor finishes its sign-in at
cursor://anysphere.cursor-mcp/...(RFC 8252 7.1). Any program on the user's machine can claim such a scheme, so the leader cannot verify the app: PKCE keeps an intercepted code useless, the consent page warns that the app is unverified, and the audit rowsoauth.client_registeredandoauth.authorizedrecord where it returns (returns=). By default onlycursorand reverse-DNS schemes (com.example.app:/cb, specific to one app) are accepted: a bare name likems-settingsbelongs to the operating system, and its handler is not something an unknown registration should be able to launch.javascript,data,fileand other schemes that run code or reach another origin are refused whatever the setting says. - No open redirects. Nothing is redirected to the app before the user is
logged in, and a malformed request goes back to the app only for a user
already connected to it; otherwise the leader shows an error page.
?next=on the login page only ever leads to a path on the leader itself. - Lifetimes. An authorization code lives 10 minutes and works once (a
reused code also revokes what it was exchanged for). The access token is a
scoped API token labelled
oauth: <app name>that expires after 1 hour. The refresh token lives 30 days from its last use, is stored hashed and is rotated on every refresh; presenting a rotated-away refresh token again revokes the whole connection. - Revoking a connection. Under Profile - API tokens each connection is
its
oauth: <app name>token. It keeps listing while the connection can still be refreshed - flaggedexpiredinGET /api/v1/api-tokensonce the hour is up - and revoking it there ends the connection: the next refresh is refused. The app can do the same through/oauth/revoke, with either token. Deactivating a user revokes all their tokens and so all their connections.
| Variable | Default | Meaning |
|---|---|---|
SWARM_OAUTH_MAX_CLIENTS |
1000 |
registered clients the leader keeps; at the cap, clients over an hour old with no live connection (refresh token) and no pending code are evicted - never-approved ones first, then the oldest - and registration answers 503 if none are |
SWARM_OAUTH_REGISTER_RATE_MAX |
20 |
registrations per client IP per window |
SWARM_OAUTH_REGISTER_RATE_WINDOW_S |
3600 |
that window, in seconds |
SWARM_OAUTH_TOKEN_RATE_MAX |
120 |
token and revoke requests per client IP per minute |
SWARM_OAUTH_APPROVALS_PER_HOUR |
20 |
apps one account may approve on the consent page per hour (429 past it) |
SWARM_OAUTH_DEVICE_RATE_MAX |
30 |
device sign-ins started per client IP per hour |
SWARM_OAUTH_NATIVE_SCHEMES |
cursor,reverse-dns |
app schemes a client may return to, comma-separated: scheme names (cursor, windsurf), reverse-dns for any scheme with a dot, * for any, off for none. Checked again on every sign-in, so turning a scheme off also stops apps registered with it |
The limits count per client IP. A hosted client such as claude.ai calls from a
few shared addresses, so every user of one leader who connects through it
shares that budget: raise SWARM_OAUTH_REGISTER_RATE_MAX on a leader with many
such users. Discovery documents are built from the request's host and are sent
Cache-Control: private with Vary: Host, so a shared cache never serves one
host's issuer to another.
Pools (admin management, E3)¶
Pools are the spaces where nodes land and connect (e.g. a public pool
thuringia for a localized deployment). Admins manage them without
restrictions at /admin/pools (admin-only page; plain users are redirected
to the dashboard):
- Create with name, slug, description, visibility (
public= listed to all users,private= members only) and an optional node limit (empty = unlimited). - Edit name/description/visibility/limit; the slug is fixed at creation.
- Delete refuses while nodes are attached unless forced (force detaches them) — no silent orphaning.
API (admin session required): GET/POST /api/v1/admin/pools,
GET/PATCH/DELETE /api/v1/admin/pools/{id} (DELETE ?force=true to detach
nodes). The pool owner is automatically its first member. Node↔pool binding is
enforced from E5 on.
User pools & quotas (E4)¶
Any logged-in user can create pools through /api/v1/pools (same verbs as
the admin surface plus GET /api/v1/pools/limits), enforced against the
role_limits table in the state DB:
| Role | Max pools | Max nodes/pool | Visibilities |
|---|---|---|---|
user (default seed) |
1 | 2 | private only |
admin |
unlimited | unlimited | public + private |
Quotas are data, not code — edit the role_limits rows to change a
deployment's plans; enforcement lives in the pool service layer and error
messages state the plan ("Your plan allows 1 pool(s) with up to 2 node(s)
each."). A user pool snapshots the plan's node limit at creation; private
pools are visible only to their members (and admins), public pools are listed
to every logged-in user. GET /api/v1/pools annotates each pool with
my_role (owner / member / null).
Pool-scoped nodes & node credentials (E5)¶
Nodes belong to pools, and everything they do is scoped to their pool:
- Join tokens are pool-scoped.
POST /api/v1/bootstrap/join-sessionsaccepts apool_id; the generated "Add Fellow" command carries that pool's token. A fellow registering with it lands in the pool (pool_nodes), subject to the pool's node limit (409 when full). No token → the deployment default pool (created lazily; migration target for pre-pool fellows). A non-admin may only mint a token for a pool they are a member of (403 otherwise) or for the default pool, where community contributions land. The token is returned once, in thePOSTresponse: the list and detail routes leave it out, and they,PATCHandDELETEonly reach sessions of the caller's pools - or sessions the caller minted, so someone who enrolled a machine into the default pool without being its member can still follow and revoke it (404 otherwise). - The cluster views are pool-scoped too.
/api/v1/cluster/nodes,/cluster/devices,/ops/join-graph(and its stream),/metricsand/workers/purge-pendingshow a non-admin only the nodes of their pools, like/api/v1/workers; an explicit?pool=outside them answers 404. The first four also show the machines the caller contributed (joined with their own token), wherever those landed. - Node credentials. Registration returns a one-time
node_token(stored hashed; re-registration re-mints it). The fellow presents it as a Bearer token on heartbeat, relay push/pop, log shipping, artifact/servable uploads, job status posts, the signal WebSocket and the inference transport — including the torch subprocesses (training relay via the transport spec, inference relay via theSWARM_RELAY_TOKENenv). - Enforcement:
SWARM_REQUIRE_NODE_AUTHis on by default and rejects calls without a valid credential.SWARM_REQUIRE_NODE_AUTH=0reopens the pre-E5 migration grace, where missing tokens pass with a debug log — but a wrong/revoked/foreign token is always rejected, in either mode. - The inference transport is scoped to the node that owns the request. A
fellow long-polls
GET /api/v1/infer/requests/{fellow_id}for work and posts frames back toPOST /api/v1/infer/responses/{request_id}. Both check the credential, and the push side checks it against the fellow the request was dispatched to (InferenceBroker.owner_of) rather than accepting any registered node: otherwise one node could write tokens into another node's generation, and the client streaming that answer would have no way to tell. A request id with no live owner answers410 Gone, which is the fellow's signal to abort the generation. - The signal WebSocket checks the credential in its handshake. ASGI runs no
HTTP middleware for a websocket scope, so
/ws/signals/{fellow_id}cannot lean on the authorization sweep below and calls the same check itself (leader/cluster/node_auth.py), refusing before accepting the socket. The token must belong to thefellow_idin the path: a valid credential is not a cluster-wide key, so one node can never claim another's control channel — a connection that could otherwise evict the real node and receive its PURGE/PAUSE signals. A refused handshake answers the upgrade with HTTP 403 (close code4401where the transport surfaces it).python -m silent_swarm.fellow.health_check --leaderholds no credential, so once enforcement is on it treats that 401/403 as a healthy leader: the refusal still proves the leader is up and the route is serving. (Without--leaderthe check is the fellow's local liveness and asks the leader nothing.) - Scheduling isolation. A job is bound to a pool
(
JobSubmitRequest.pool_id, default pool otherwise) and only ever combines that pool's nodes; logged-in non-admins can only schedule into pools they belong to and only see their pools' jobs. - Catalog scoping. Servable manifests are stamped with the training job's pool; logged-in non-admins see only their pools' models (plus pre-E5 unstamped ones), and a pool's model is only ever served on that pool's nodes.
Authorization sweep (E7)¶
Every leader route has an entry in
route_policy.py — the single
source of truth — classifying it as public, page (self-gating HTML),
node (fellow credential), chat (API token), session, operator
(session or API token — UI and CLI), or admin. A route with no entry is
unknown = default-deny: the middleware locks it to admin and
test_route_policy.py fails, so an unauthenticated route can't ship by
accident.
Enforcement of session/operator/admin on /api/* routes activates with
SWARM_REQUIRE_OPERATOR_AUTH=1 (grace-off by default, mirroring node auth).
HTML pages keep their own in-handler redirects. Auth events (login, register,
failures) and admin actions (worker release) are written to an audit log,
readable by admins at GET /api/v1/admin/audit. Login/register are rate-limited
per IP.
GET /api/v1/admin/audit returns the newest events first and takes three
optional query parameters, filtered in the database so they reach past the
newest rows:
| Parameter | Meaning |
|---|---|
limit |
1-1000 rows (default 200) |
event_prefix |
only events whose name starts with this; letters, digits and _ . : -, at most 64 characters. mcp. gives the changes agents made over MCP (event mcp.<tool>, detail via=mcp token=<label> args=...), admin. admin actions, auth. sign-ins |
q |
only events whose event, actor or detail contains this text (case-insensitive, at most 200 characters; % and _ match literally). Blank means no filter |
A value outside those bounds answers 422. The Admin > Audit page uses
q for its text box and event_prefix=mcp. for its MCP calls toggle.
Both are on by default now; a deployment mid-migration sets them to 0
temporarily, in this order: node auth back on once fellows have re-registered,
then operator auth once operators use sessions or API tokens (the CLI already
sends its token).
Scope: what "operator" actually reaches¶
operator is an access class, not a blank cheque. A session and an API token
resolve to the same person (accounts/auth.py:principal_user), and both are
held to that person's pool membership - minting a token is not a way around
your own scope:
- jobs, models, nodes and their logs/metrics are filtered to the caller's pools; a cross-pool id answers 404, not 403, because the existence of another tenant's resource is itself information;
DELETE /api/v1/jobs(purge everything, cluster-wide) is admin-only and audited - it ignores pool boundaries by definition, so it cannot be scoped;- platform admins are unscoped, as before.
Per-node telemetry (/api/v1/pools/{id}/metrics, …/events, …/metrics/history)
requires membership, not just visibility of the pool: a public pool is
listed to every logged-in user, but its machines' hostnames, GPUs and health are
not. GET /api/v1/pools/activity (the dashboard's feed of recent events across
pools) applies the same rule to every pool at once - memberships only, every
pool for an admin - and takes a session or an API token with its owner's scope.
Contribution accounting (landing E7, rung 1)¶
A node is attributed to the member whose join token admitted it:
POST /api/v1/bootstrap/join-sessions records the minting session's user, and
registration copies that onto the node's pool binding
(pool_nodes.owner_user_id). The owner is never read from a request body, so one
member cannot claim another's machine.
The metrics roll-up loop then accumulates, per member: GPU-seconds their online
machines contributed, and the tokens and requests those machines served. Each
tick reads serving over exactly the tick's window, so consecutive ticks abut
rather than overlap. GET /api/v1/me/contribution returns the caller's own
totals - there is no parameter for asking about anyone else - and reports
credits_status: "not_implemented", because this rung is accounting, not
money. Nodes that joined without a member's token stay unattributed rather
than being credited to a guess.
The one public telemetry route¶
GET /api/v1/public/stats feeds the landing page's numbers and is deliberately
anonymous, which makes it the exception that has to justify itself:
- aggregate only - counts and rounded totals, never a per-node, per-pool or per-model row, so it cannot become a back door to the member-only snapshot above;
- withheld below a floor (
SWARM_PUBLIC_STATS_MIN_NODES, default 3): with one or two machines the numbers describe an identifiable person's computer, so they are published asnulland the page says the pool is just getting started; - cached (
SWARM_PUBLIC_STATS_TTL_S, default 60s) and rounded, because an exact live counter is a liveness oracle: poll it and you learn when somebody's PC is switched on; - rate-limited at the edge in the bundled nginx config.
GPU-hours contributed is a real measured total, integrated by the metrics roll-up loop from observed uptime, not an estimate.
Pool collaboration (E8)¶
Public pools can be shared; private pools are invite-only:
- Invites — a pool owner (or admin) invites by email:
POST /api/v1/pools/{id}/invites {email}. The invitee sees pending invites atGET /api/v1/pools/invites/mineand accepts withPOST /api/v1/pools/invites/{invite_id}/accept(the email must match). - Join requests — for a public pool,
POST /api/v1/pools/{id}/joineither joins immediately (when the pool has open-join enabled) or files a request the owner approves atPOST /api/v1/pools/{id}/join-requests/{user_id}/approve. - Roles & leaving — members have the
memberrole (owner keepsowner); a member can leave and an owner/admin can remove others viaDELETE /api/v1/pools/{id}/members/{user_id}. The owner can't be removed — delete the pool instead. Invite mail is sent through the E11 mailer when configured (best-effort until then).
Admin user management (E10)¶
Admins manage every account at /admin/users (admin-only page). The table is
server-side sorted / filtered / searched / paginated via
GET /api/v1/admin/users?q=&status=&role=&sort=&order=&page=&page_size=, with
columns for status, role, pool/node counts, registration and last-login. A
pending-approval badge appears on the dashboard tile
(GET /api/v1/admin/users/pending-count).
Row actions (all audit-logged):
- Approve — the manual registration confirmation:
pending_approval/unverified→active. - Edit —
PATCH …/{id}display name, email, role — with last-admin protection (can't demote/deactivate/delete the last active admin). - Deactivate / reactivate —
POST …/{id}/status; deactivation revokes the user's sessions and API tokens. - Reset password —
POST …/{id}/reset-passwordissues a single-use, 24h-expiring reset token. With SMTP it is mailed; without, the response carries a one-time/reset-password?token=…link the admin hands over. - Resend verification — re-issues the email-verification token.
- Delete — the E9 GDPR cascade (below).
Account deletion & hardening (E9)¶
GDPR deletion. Deleting a user removes everything that grants access, in a
guarded order (last-admin refused before anything is touched): their owned
pools are deleted (nodes detached), their memberships in other pools dropped,
their API tokens revoked and their saved inference chats deleted, then the
user row, sessions and action tokens are removed. The response summarizes what
was deleted (deleted_chats among it).
Brute-force lockout. After SWARM_LOGIN_LOCKOUT_THRESHOLD (default 5)
failed logins for an email, that account is locked for
SWARM_LOGIN_LOCKOUT_SECONDS (default 300s); a success clears the counter. This
is per-account and complements the per-IP auth rate limit.
Session hygiene. Sessions are hash-stored and TTL-bounded
(SWARM_SESSION_TTL_HOURS); UserStore.purge_expired_sessions() drops
revoked/expired rows. Action tokens (verify/reset) are single-use and expire in
24h.
Backup & restore. All account, pool, job, and audit state lives in the one
SQLite file (SWARM_JOIN_STATE_DB, default /data/join_state.sqlite3 in Docker
— a bind-mounted volume). To back up, stop the leader (or use
sqlite3 <db> ".backup '<dest>'" for a hot copy) and archive the file; to
restore, put the file back before starting the leader. Users, pools and node
bindings all survive the round-trip. Keep SMTP credentials and the mesh
pre-auth key in .env (see .env.example), never in the image or the repo -
.dockerignore excludes .env and .swarm/ so a build cannot bake them in.
Transactional email (E11)¶
When SMTP is configured the leader sends lifecycle mail; without it the mailer is disabled and every feature degrades gracefully (verification skipped, approval via the admin UI, password reset falls back to a one-time link).
Configure via env (all optional): SWARM_SMTP_HOST, SWARM_SMTP_PORT,
SWARM_SMTP_USER, SWARM_SMTP_PASSWORD, SWARM_SMTP_FROM, SWARM_SMTP_TLS
(starttls | ssl | none).
- Queued, non-blocking delivery. Mails are written to a persistent outbox in the state DB and delivered by a background worker with retry/backoff (up to 5 attempts), so a slow or down SMTP server never blocks the triggering request.
- Templates: verify-email, pending-approval, account-approved, account-deactivated, password-reset, password-changed, and pool-invite — tokenized links are single-use and expire in 24h.
- Admin surface: the
/admin/emailpage shows the SMTP status (configured / disabled), recent mail and a Send test email button (GET /api/v1/admin/mail,POST /api/v1/admin/mail/test). From the leader host,swarm-leader admin send-test-mail you@example.comvalidates SMTP directly against the host's env.
Locked out? CLI escape hatch¶
On the leader host (no HTTP, operates directly on the state DB):
# Create an active admin account
swarm-leader admin create-user you@example.com # prompts for password
# Reset a password (also revokes that user's sessions)
swarm-leader admin set-password you@example.com
Both accept --db /path/to/join_state.sqlite3 when the DB isn't at the default
location. In Docker, run them inside the leader container, where the DB is at
/data/join_state.sqlite3:
docker exec -it silentswarm-leader swarm-leader admin create-user you@example.com \
--db /data/join_state.sqlite3
swarm admin ... still works and runs the same command.
Accessibility¶
The UI targets WCAG 2.2 AA: everything EN 301 549 (= WCAG 2.1 AA, the technical standard both BITV 2.0 and the BFSG point at) asks for, plus the WCAG 2.2 additions its next edition adopts. What is checked, and the manual pass each release needs, is in Accessibility: release checklist. In short:
tests/unit/leader/web/test_accessibility.pyholds the structural invariants;scripts/frontend_smoke.mjsruns axe-core (colour contrast included) over every page in both themes, and the WCAG 2.2 checks axe cannot make - target size (2.5.8), focus not hidden under a sticky or fixed element (2.4.11), and no cropped table at 320 px (1.4.10) - and scans the built docs site, which gates in CI (SMOKE_DOCS_STRICT=1);scripts/keyboard_walkthrough.mjsdrives register → pool → join command → inference → session warning with the keyboard alone.
Session length and 2.2.1 Timing Adjustable. A session ends
SWARM_SESSION_TTL_HOURS after sign-in; there is no idle timeout. The default
(14 days) is past the criterion's 20-hour exception, so in practice nobody is
signed out mid-task. A shorter, operator-chosen lifetime is the case the
criterion is about, so the UI warns two minutes ahead with a dialog that says
when, and Stay signed in extends the session by one full lifetime
(POST /api/v1/auth/session/extend; as often as needed, never past one TTL
from now). Past the end, the dialog says the session has ended instead of
letting the next click fail.
Three obligations are organisational rather than technical, and are deployment-specific because an operator who changes the UI changes the answer:
| Setting | What it does |
|---|---|
SWARM_A11Y_CONTACT |
Where accessibility barriers get reported. Shown on the statement and the Leichte Sprache page; without it, both say plainly that no contact is configured. An email address becomes a mailto: link, anything else a plain link. |
SWARM_ACCESSIBILITY_FILE |
An HTML fragment replacing the built-in statement body. Use this once you have modified the UI: the built-in text describes this codebase as audited, and a statement that no longer matches the site is worse than none. |
SWARM_DGS_VIDEO_URL |
A video explaining the site in Deutsche Gebärdensprache. When set, it is embedded on both pages; when unset, they say that it is missing rather than dropping the section. |
The statement claims partial conformance and lists the known gaps. Keep it
honest and keep STATEMENT_REVIEWED in leader/web/accessibility.py current -
BITV expects the statement reviewed whenever the site changes materially.
Interface language¶
The chrome (sidebar, drawer, account menu) is available in English and German; page bodies are English. The language is chosen in this order:
- the
swarm_langcookie, set by/lang/{code}or by visiting/or/de; - the request's
Accept-Languageheader; - English.
When the chrome ends up in a different language from the page body, its roots
are marked with their own lang so a screen reader pronounces both correctly
(WCAG 3.1.2). Add a language by extending CHROME_DE in leader/web/i18n.py
and adding its code to LANGS.
The legal pages¶
/legal/imprint and /legal/privacy render an HTML fragment you supply -
the app provides the page, the heading and the chrome. Until you configure them
both serve a visible "this deployment has not configured …" placeholder, which
is honest but is not a privacy notice.
For a public deployment in Germany neither is optional: a missing Impressum is enforceable by competitors, and Art. 13 GDPR applies from the first page view, because the server log already processes an IP address.
Start from the templates in docker/legal/:
mkdir -p "${SILENTSWARM_DATA_DIR:-data/docker}/leader/legal"
cp docker/legal/imprint.html.example "${SILENTSWARM_DATA_DIR:-data/docker}/leader/legal/imprint.html"
cp docker/legal/privacy.html.example "${SILENTSWARM_DATA_DIR:-data/docker}/leader/legal/privacy.html"
$EDITOR "${SILENTSWARM_DATA_DIR:-data/docker}"/leader/legal/*.html # fill every «...» marker
SWARM_IMPRINT_FILE=/data/legal/imprint.html
SWARM_PRIVACY_FILE=/data/legal/privacy.html
SWARM_LEGAL_LANG=de
$SILENTSWARM_DATA_DIR/leader (default ./data/docker/leader) is mounted at /data, so the paths above are the
container's view of those files. They live on the volume rather than in the
image on purpose: they are your company's text, not the software's, and a
rebuild must not be able to replace them.
The privacy template's factual sections - cookies, the aggregate view counter,
what an account and the audit log store - were written against the code and are
accurate for an unmodified deployment; the
public web surface page carries the same inventory.
What only you can supply is who you are, who processes data on your behalf, and
how long you keep it. Those are marked «...» and render highlighted, so a
half-filled fragment is obvious on the page instead of shipping quietly.
None of this is legal advice - it is an accurate description of what the software does, which is the part a lawyer would otherwise have to reverse engineer.