Skip to main content

auth-service

Registration, login, sessions, OAuth, account management, and the admin panel's own auth. Details: Auth & Permissions.

/api/v1/auth​

MethodPathWhat it does
POST/registerCreate an account
POST/loginPassword login → access + refresh token
POST/refreshRotate a refresh token; reuse revokes the whole account's sessions
POST/logoutRevoke the current refresh token
GET/meCurrent account
POST/account/passwordChange password
PATCH/accountUpdate profile fields
GET/username-availableIs this username free? Public; Bloom-filtered
POST/forgot-passwordStart recovery → emailed | reset | unavailable
POST/reset-passwordSpend a single-use reset token

Refresh Token Lifecycle & Theft Detection​

Forgotten passwords​

/forgot-password has three answers, and which one you get is a fact about the deployment rather than about the account:

  • reset — an administrator has opened a reset window on that account, so the call mints a single-use token and hands it back; the client goes straight to a new-password form. This is how a self-hosted deployment with no mail server still has a way back in.
  • emailed — a link was sent. An account that does not exist, and a disabled one, get this same answer and no email. Telling them apart would make this endpoint a way to find out who has an account here.
  • unavailable — no SMTP server is configured, so the client says to ask an administrator.

Reset tokens are stored as SHA-256 and are single use. Minting one burns the account's previous live token, and spending one revokes every session the account had.

:::caution End-to-end encryption The identity backup is sealed with the old password, so a reset leaves it unreadable. A machine signing in for the first time after a reset mints a new device key and can read what arrives from then on — not what came before. A server that could re-seal the backup would be a server that could read it. :::

Username availability​

/username-available is answered from a Bloom filter warmed from the table at boot, so the registration form can ask on every keystroke. The filter has no false negatives: a miss is a definitive "available" with no query, and a hit is confirmed against the unique index before anybody is told a name is taken. It is a cache in front of the constraint and never a substitute for it.

Each instance holds its own bit array, but they all follow the same names: UsernameDirectory subscribes to user.created and user.updated on the event bus, which already carry the username, so a registration or a rename on one instance is in every other instance's filter at once rather than after its next restart. This also covers OAuth sign-ups, which create the account without going through the registration path. The subscription is made before the warm-up query (a name written while the query runs lands in one or the other) and is not awaited at boot, so a slow bus never holds the service up; the gap it leaves is the one the unique constraint has always closed. A malformed event is dropped rather than thrown on.

Usernames are normalised to lower case on write, which is what makes the unique index agree with signing in by username.

/api/v1/auth/oauth​

MethodPathWhat it does
GET/versionBuild/version probe
GET/providersWhich providers are enabled
GET/:provider/startBegin the provider redirect
GET/:provider/callbackProvider returns here; issues a one-time code
POST/exchangeTrade the one-time code for a session

/api/v1/admin​

Requires GlobalRole.ADMIN, checked by database lookup on every request (not a token claim — a demotion has to take effect immediately).

MethodPathWhat it does
GET/statusWhether any admin exists yet
GET/usersList accounts
PATCH/users/:idChange role / disable / enable / open a password-reset window
DELETE/users/:idDelete an account
GET/auditRead AdminAudit
GET/healthOne snapshot of the deployment (?days= sizes the bandwidth window, 30 by default)
GET/oauthRead provider configs
PUT/oauth/:providerSet a provider's credentials
GET/smtpOutgoing mail settings (never the password)
PUT/smtpConfigure the deployment's SMTP server
POST/smtp/testSend one test message

Health & storage​

GET /admin/health answers one AdminServerHealth: the dependency probes, the reporting process's runtime, database and media storage, bandwidth over a window, and the live socket counts. It is measured at the moment of the call rather than cached, so the panel polls it (30 seconds, pausable) instead of subscribing to anything.

Several fields are deliberately nullable, and a null is never a zero:

FieldNull whenThe panel says
AdminComponentHealth.latencyMsthe probe never came back"No response", not "0 ms"
AdminMediaStorage.diskBytesdriver is s3 — walking a bucket is not free"Not measurable here", with the reason
AdminMediaStorage.diskFreeBytesdriver is s3 — object storage has no volume to fillas above
AdminDatabaseStorage.version, AdminRuntimeHealth.appVersionthe server did not report one"unknown"

Rendering any of those as 0 B would be the panel inventing a measurement an operator cannot tell apart from a real one, which is the one way this screen could lie. apps/admin/src/screens/HealthScreen.check.tsx renders the view against an S3 fixture and asserts that it does not.

Call bytes are the clients' own totals from CallSession and not this host's traffic — media is peer-to-peer and never passes through it. Attachment bytes are what this deployment actually served.

How the snapshot is assembled​

Every probe is timed and caught individually — Postgres (SELECT 1), Redis (PING), and a GET /health against chat-service, presence-service, call-service and remote-gateway — each on its own 2.5 s deadline. A dependency that is down turns its own card red inside a 200; it never fails the request. A page that 500s when something is broken tells an administrator only that something is broken, at the exact moment they need to know which thing. Every section (database, media, bandwidth, live) is wrapped the same way and returns its empty shape rather than propagating. overall is simply the worst state across the components, pessimistically: one down is down.

Every URL in the response goes through redactUrl first. That strips user:password@ and any query parameter whose name looks like a secret — ?password=, ?sslpassword=, ?token= — because new URL parses host:5432/db?password=hunter2 quite happily by reading host: as a scheme, and a redaction that only cleared the userinfo would hand that one straight back. Anything that does not parse at all is replaced wholesale rather than passed through.

Where the numbers come from, and what they are not:

FieldSourceCaveat
database.*raw SQL over pg_database_size, pg_stat_user_tables, pg_stat_activity, SHOW max_connections / server_versionrowEstimate is n_live_tup, the planner's estimate — an exact count(*) per table is a scan of the whole database, which is a strange thing for a health page to do to a struggling server. Top 15 tables only
media.byKindthe storage key's file extensionAttachment has no content-type column and cannot have one: the manifest naming the file is sealed inside the ciphertext. The extension comes from buildKey, so it is a hint about what was uploaded rather than a declaration
media.diskBytesa walk of LOCAL_STORAGE_PATH, fs.statfs for the free spacelocal driver only
bandwidth.*CallSession (BigInt, converted with Number() exactly as calls.service.ts does) and Attachment.sizeno new tracking and no migration — it is what was already recorded. A client closed mid-call reported nothing, so the call totals are a floor
live.onlineUsers, activeCalls, activeCallParticipantsRedis: presence:online (fresh entries only, same 90 s cutoff PresenceStore uses), presence:voice:channels, presence:voice:<channelId>a second reader of presence-service's keys; if they change, this changes with them
live.activeRemoteSessionsRemoteSession where endedAt is null—

Realtime counts, and what each one means:

  • live.onlineUsers and live.presenceAccounts are distinct accounts with a fresh entry in presence:online.
  • live.totalSockets and live.presenceSockets are sockets: live members of presence:sockets, one per device or window, so one account on three devices is three. Presence keys per device and ages a crashed instance's sockets out by heartbeat. totalSockets keeps its name and counts only the surfaces that are tracked, which today is presence.
  • /ws/presence reports presenceSockets, no longer the account count.
  • /ws/chat still reports connections: 0, meaning "not tracked". chat-service keeps its subscriptions inside the gateway process and publishes nothing about them; tracking them would mean a Redis dependency in that service. The endpoint's state still comes from its own probe.

The endpoint URLs are built from PUBLIC_API_URL with the scheme swapped for its WebSocket equivalent, never hardcoded: an administrator checking their tunnel wants the URL their clients actually dial.

Outgoing mail​

SMTP is operator configuration, not environment: it lives in SmtpSetting and is entered in the admin panel, with the password sealed under SETTINGS_SECRET exactly as an OAuth client secret is. The panel can replace it and can never read it back, and a test send reports the mail server's own refusal verbatim.

A deployment with no mail server is fully supported — every client's forgot-password screen then says to ask an administrator, and the administrator resets an account from the Users tab instead.

Opening a password-reset window​

PATCH /users/:id with { "passwordReset": true } lets that account set a new password without knowing the old one, by naming itself on the forgot-password screen. It is a window, not a flag: it expires on its own (PASSWORD_RESET_WINDOW_HOURS, a day by default), one grant is one reset, and both opening and cancelling are written to AdminAudit.

The first administrator is never created through this API — pnpm admin:create runs where the database already is, the one place that proves the operator owns the deployment.

Profile pictures​

PATCH /api/v1/auth/account carries both avatarUrl and coverUrl. For each, null is a value — it clears the picture back to the default — and an absent key means "leave it alone", so a client that only wants to change a display name does not have to resend the pictures it is not touching.

Both must match UPLOADED_PICTURE_URL: a picture stored by this deployment. They are drawn by every client that can see the account, so an arbitrary URL would be a beacon reporting who looked at whom.

They are separate columns rather than one picture scaled two ways. An avatar is a square read at 32px in a member list; a cover is a 4:1 band read at several hundred, and cropping one out of the other gives a blurred crop of somebody's face as a backdrop. The clients frame them to COVER_ASPECT (4) and COVER_MAX_WIDTH (1600) before uploading, so what reaches the server is exactly what everybody fetches.