Skip to content

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-tokens takes {"label": "...", "scopes": [...]}. Leaving scopes out gives ["read", "operate"], what a token could do before scopes existed short of admin. An unknown scope, an empty list, or admin for a non-admin owner answers 400 (bad-scope) rather than silently minting a weaker token.
  • List: GET /api/v1/api-tokens returns each token's effective scopes, a legacy flag and the owner (the owner's email; an admin sees every user's tokens). A legacy token (stored before scopes existed, so with no scopes field) keeps its owner's full power - read + operate, plus admin for 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, cookie swarm_legacy_tokens_ack).
  • Scopes are independent. operate does not imply read, so a write-only automation token stays write-only.
  • admin never 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, type ending in /insufficient-scope) for any token request its scopes do not cover, on every session/operator/admin route and on chat - whether or not SWARM_REQUIRE_OPERATOR_AUTH is on, so a read token 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 carries link_withheld: true instead. A browser session gets both, as before. Buckets, datasets, pools and /api/v1/me/contribution accept 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_authorization and /oauth/revoke with client_secret_basic or client_secret_post (401 invalid_client otherwise). Such apps have an owner, are never evicted and count 20 per account.
  • Dynamically registered clients are public. Registration accepts redirect URIs on https, http on localhost / 127.0.0.1 / [::1], and an app's own scheme as far as SWARM_OAUTH_NATIVE_SCHEMES allows (below). A client that asks for a secret-based token_endpoint_auth_method is registered with none instead (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; admin is offered only to an admin (enrolled in 2FA when SWARM_REQUIRE_ADMIN_2FA is on). Scopes the leader does not know are dropped; admin from 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 advertises client_id_metadata_document_supported. Nothing is registered: the leader fetches the document when someone signs in, caches it for its Cache-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 whose client_id is its own URL; at most 10 fetches per host a minute. These apps do not count against SWARM_OAUTH_MAX_CLIENTS.
  • Native apps. A loopback redirect URI (http://127.0.0.1/callback) matches on any port (RFC 8252 7.3), so swarm login and 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/token every 5 seconds; polling faster answers slow_down and 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: an https redirect 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=consent always shows it. The connected-apps lists show one row per app and user with connections, and Disconnect ends every connection of that app.
  • Step-up. An OAuth connection calling a tool its scopes do not cover gets 403 with WWW-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. admin is 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 rows oauth.client_registered and oauth.authorized record where it returns (returns=). By default only cursor and reverse-DNS schemes (com.example.app:/cb, specific to one app) are accepted: a bare name like ms-settings belongs to the operating system, and its handler is not something an unknown registration should be able to launch. javascript, data, file and 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 - flagged expired in GET /api/v1/api-tokens once 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-sessions accepts a pool_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 the POST response: the list and detail routes leave it out, and they, PATCH and DELETE only 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), /metrics and /workers/purge-pending show 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 the SWARM_RELAY_TOKEN env).
  • Enforcement: SWARM_REQUIRE_NODE_AUTH is on by default and rejects calls without a valid credential. SWARM_REQUIRE_NODE_AUTH=0 reopens 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 to POST /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 answers 410 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 the fellow_id in 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 code 4401 where the transport surfaces it). python -m silent_swarm.fellow.health_check --leader holds 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 --leader the 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 as null and 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 at GET /api/v1/pools/invites/mine and accepts with POST /api/v1/pools/invites/{invite_id}/accept (the email must match).
  • Join requests — for a public pool, POST /api/v1/pools/{id}/join either joins immediately (when the pool has open-join enabled) or files a request the owner approves at POST /api/v1/pools/{id}/join-requests/{user_id}/approve.
  • Roles & leaving — members have the member role (owner keeps owner); a member can leave and an owner/admin can remove others via DELETE /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-password issues 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/email page 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.com validates 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.py holds the structural invariants;
  • scripts/frontend_smoke.mjs runs 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.mjs drives 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:

  1. the swarm_lang cookie, set by /lang/{code} or by visiting / or /de;
  2. the request's Accept-Language header;
  3. 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.

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

on the leader host
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
.env
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.