33 KiB
Authentication & Session System
Source files:
packages/server/src/routes/auth.ts-- Registration, login, username availability, invite-token check endpointspackages/server/src/routes/invites.ts-- Admin invite-link CRUD (create / list / patch / revoke / reinstate / delete / redemptions)packages/server/src/utils/inviteService.ts-- Invite token generation, status derivation, atomicredeemInvite()transactionpackages/server/src/routes/users.ts-- Password change, account deletion endpoints (lines 58-156)packages/server/src/routes/admin.ts-- Admin password reset endpoint (lines 232-265)packages/server/src/utils/auth.ts-- Password hashing, JWT sign/verify,authenticatepreHandler,requireAdminpackages/server/src/utils/userDeletion.ts--tombstoneUser()transactional account erasurepackages/server/src/utils/sanitize.ts--sanitizeUser()strips internal fields, anonymizes deleted userspackages/server/src/ws/handler.ts-- WebSocket auth handshake (lines 1282-1374)packages/web/src/stores/authStore.ts-- Client session state, login/register/logout/password/delete actionspackages/web/src/hooks/useAuth.ts-- Route guard hook (redirect to/loginwhen no token)packages/web/src/App.tsx--ProtectedRouteandAuthRedirectroute wrapperspackages/web/src/utils/identity.ts-- Federation-aware identity helpers (parseFederatedUsername,isSelf,canonicalUserMatch)packages/web/src/utils/federationOps.ts-- Cross-instance password sync and account deletion propagationpackages/server/src/config.ts--jwtSecret,jwtExpiresIn,registrationOpenconfig
DB tables: users, instanceSettings. See database.md for full schemas.
1. Password Hashing
Library: bcryptjs
Salt rounds: 12 (constant SALT_ROUNDS in auth.ts)
hashPassword(password: string): Promise<string> -- bcrypt.hash(password, 12)
verifyPassword(password: string, hash: string): Promise<boolean> -- bcrypt.compare
Federation stub marker: Replicated user stubs have passwordHash = '!federation-replicated'. Since bcrypt never produces this value, login is impossible for stubs. See federation.md for identity resolution.
2. JWT Management
Signing
interface JwtPayload {
userId: string;
username: string;
iat?: number; // Auto-set by jsonwebtoken library (seconds since epoch)
}
- Algorithm: HS256 (enforced on verify via
{ algorithms: ['HS256'] }) - Secret:
config.jwtSecret(envJWT_SECRET, minimum 32 characters -- startup crash if shorter) - Expiry:
config.jwtExpiresIn(envJWT_EXPIRES_IN, default'30d') - Library:
jsonwebtoken
signJwt(payload) creates a token with { expiresIn } option. The iat field is auto-injected by the library.
Validation (authenticate preHandler)
Applied as preHandler on all authenticated routes. Flow:
- Extract
Bearer <token>fromAuthorizationheader verifyJwt(token)-- checks signature (HS256) and expiry- DB lookup: fetch
id,isDeleted,passwordChangedAtfromuserstable - Reject if user not found or
isDeleted === 1 - Token revocation check: if
passwordChangedAtis set andpayload.iatexists, reject ifiat < Math.floor(passwordChangedAt / 1000)(JWTiatis seconds,passwordChangedAtis milliseconds) - Attach
userIdandusernametorequestobject
Token Revocation
There is no token blocklist. The only revocation mechanism is the passwordChangedAt timestamp:
- When a user changes their password (or an admin resets it),
passwordChangedAtis set toDate.now() - All tokens issued before that timestamp (
iat < passwordChangedAt/1000) are rejected - A fresh token is issued after password change
Exception: Federation password self-healing (see section 4) does NOT set passwordChangedAt -- it is a state correction, not a password change, so existing valid JWTs remain valid.
WebSocket Auth
ws/handler.ts:registerWebSocket() -- WS connection at /ws:
- Client connects, 10-second auth timeout starts
- First message must be
{ type: 'auth', token: '<jwt>' } verifyJwt(token)validates signature and expiry- DB check: reject if user deleted or token revoked (same
passwordChangedAtlogic as REST) - On success: clears timeout, sets status to
'online', registers connection, sendsreadypayload, broadcasts presence - On failure: sends error message and closes socket
3. Registration Flow
Endpoint: POST /api/auth/register
Rate limit: 10 requests / 2 minutes per IP
Auth: None
Input Validation
| Field | Rules |
|---|---|
username |
Required string. Trimmed, lowercased. |
password |
Required string. Minimum 8 characters. |
displayName |
Optional. Trimmed or null. |
avatarColor |
Optional. Must be in AVATAR_COLORS array, else random. |
homeInstance |
Optional (federation only). Max 253 chars, alphanumeric + . - _. |
homeUserId |
Optional (federation only). Stored if homeInstance is present. |
Username Validation (Two Paths)
Local registration (homeInstance absent):
- Length: 3-32 characters
- Pattern:
/^[a-z0-9_]+$/(lowercase alphanumeric + underscore) - No
@allowed
Federated/replicated registration (homeInstance present):
- MUST use
username@domainformat (plain usernames reserved for native users) - Local part: 3-32 chars,
/^[a-z0-9_]+$/ - Domain part: 1-253 chars,
/^[a-zA-Z0-9._-]+$/ - Total: max 100 characters
Registration Gate
The /api/auth/register route splits its gate by request shape (spec §1.2). There are two independent toggles plus an invite-token bypass for the local path.
Local anonymous signup (no homeInstance in body):
instanceSettings.registrationOpen(DB, id=1) -- if not null, this takes priorityconfig.registrationOpen(envREGISTRATION_OPEN, defaulttrue) -- fallback
DB value overrides env when explicitly set by admin. When closed, a valid inviteToken bypasses the gate and is atomically consumed alongside the user insert (see "Invite Tokens" below). When open, an inviteToken field is silently ignored (no validation, no consumption).
Federated identity replication (request body has homeInstance):
- Gated solely by
instanceSettings.federatedRegistrationOpen(NOT NULL DEFAULT 1). inviteTokenis ignored entirely on this path -- tokens never unlock federated creation, even when supplied.- Closed → 403
"Federated registration is closed on this instance".
Invariants (spec §1.3):
- Login-unaffected invariant. Neither toggle gates
POST /api/auth/loginfor any user. Existing federated accounts always log in regardless offederatedRegistrationOpen; existing local accounts always log in regardless ofregistrationOpen. Both gates affect creation only. This is why the Connections add-instance form keeps its submit button enabled even when the target instance hasfederatedRegistrationOpen = false(seeclient-federation.md): the request runs throughinstanceStore's register-then-login fall-through, and the login leg succeeds for users who already have a federated account on that instance. - The federated stub upgrade flow (below) is gated by
federatedRegistrationOpen, never by an invite token. Tokens only unlock the local anonymous-signup path.
Toggle matrix (spec §5.6):
registrationOpen |
federatedRegistrationOpen |
Local register | Federated register | Connections UI behavior |
|---|---|---|---|---|
| true | true | open | allowed | normal |
| true | false | open | 403 | warning banner; submit enabled (login fall-through) |
| false | true | invite-required | allowed | normal |
| false | false | invite-required | 403 | warning banner; submit enabled (login fall-through) |
| false (any) | (any) | invite bypasses | NOT bypassable by token | — |
S2S DM stub creation (relay path, never /register) is gated only by federation peering settings — neither toggle affects it.
Invite Tokens
When registrationOpen is false, the local-signup path accepts an inviteToken field on the register body. Token format: 22-char base64url (crypto.randomBytes(16).toString('base64url') — 128 bits of entropy; collision probability against the existing space is ~2^-122, with the DB UNIQUE on invite_links.token as the safety net + retry-up-to-3 in the create handler). Admin CRUD lives in inviteService.ts and routes/invites.ts -- see docs/systems/admin.md for the panel UX and the full status state machine.
Lifecycle:
create → active ──(usedCount = maxUses)─→ exhausted ┐
│ │
│ ┌──(expiresAt < now)──→ expired ──────┤
│ │ │
↓ ↓ │
revoke ──(revokedAt set)──→ revoked │ reinstate
│ ←┘ (Path A: token rotates;
│ Path B: same token)
└──→ active again
│
DELETE /admin/invites/:id
│
↓
hard-delete (CASCADE redemptions)
Status is derived at read time from (revokedAt, expiresAt, usedCount, maxUses) — no stored column. Reinstate branches on the pre-reinstate status: revoked → token rotates (tokenRotated: true); expired/exhausted → token preserved (tokenRotated: false); already-active → 409.
Audit trail (invite_redemptions): every successful redemption inserts one row with inviteId (FK CASCADE — admin hard-delete drops the audit), userId (FK SET NULL — defensive against future hard-delete; tombstone keeps it populated), registrantUsername (snapshot at registration moment, preserves forensic value when the user is later renamed or tombstoned !deleted:{uid}), and redeemedAt.
Atomic redemption (spec §2.4):
db.transaction(() => {
// 1. Re-fetch invite by token under txn (closes TOCTOU vs /check-invite)
// 2. Reject if status !== 'active' → throw InviteUnavailableError → 403
// 3. INSERT user row
// 4. UPDATE invite_links SET usedCount = usedCount + 1
// 5. INSERT invite_redemptions row (forensic audit, snapshots username)
})
If any step throws (concurrent revoke, last-slot race, username collision against the unique index), the entire transaction rolls back -- usedCount is never incremented on a failed registration. The route catches InviteUnavailableError from redeemInvite() and surfaces it as 403 "Invalid or expired invite".
- Federated stub upgrade and federated new-account paths do NOT enter
redeemInvite. They are gated only byfederatedRegistrationOpenand never consume tokens, even if a token is provided in the request body. This is the structural enforcement of the spec §1.3 invariant "tokens never unlock federated creation".
The /api/auth/check-invite debounced UX endpoint pre-validates a token from the register page; the in-txn re-derive inside redeemInvite() is the authoritative enforcement point.
First-User Admin Promotion
const userCount = db.select().from(schema.users).all().length;
const isFirstUser = userCount === 0 && !homeInstance;
The very first user registered on the instance (and only if local, not replicated) gets isAdmin = 1.
Avatar Color Assignment
const AVATAR_COLORS = ['mint', 'sky', 'lavender', 'coral', 'rose', 'teal', 'amber'] as const;
If requestedAvatarColor is provided and is in AVATAR_COLORS, use it. Otherwise, pick randomly from the array.
Registration Steps
- Validate inputs (username format, password length)
- Read both registration gates from
instance_settings - Branch by request shape (spec §1.2):
- If
homeInstanceset → reject with 403 unlessfederatedRegistrationOpen === true.inviteTokenignored on this path. - Else (local) → if
registrationOpenis false, require a validinviteToken; otherwise reject with 403. Pre-flight token check rejects obvious-invalid tokens before bcrypt.
- If
- Federated stub upgrade check (if
homeInstanceis set): callfindFederatedUserto look for an existing relay-created stub. If found and upgradeable, upgrade it instead of creating a new record (see below). - Check username uniqueness (exact match on lowercased username)
- Hash password (bcrypt, 12 rounds)
- Generate Snowflake ID
- Insert user row (status defaults to
'offline'at the schema level; it is set to'online'only when the client establishes a WebSocket via the WS auth path inws/handler.ts). Admin flag set if first user. When the local-closed-with-token path is in play, the insert runs insideredeemInvite()'s transaction so the user row, theusedCountbump, and theinvite_redemptionsrow commit atomically (or all roll back). - Sign JWT with
{ userId, username } - Return
{ token, user }(user sanitized viasanitizeUser(user, true))
Federated Stub Upgrade
When a user registers with homeInstance set (federated registration via friend-connect), the registration path checks for an existing relay-created stub using findFederatedUser. The stub-upgrade flow is always gated by federatedRegistrationOpen, never by an invite token (spec §1.3). If found and the stub has passwordHash = '!federation-replicated' (not a real account), the stub is upgraded:
passwordHashis set to the new bcrypt hash (enables login)usernameis updated to the registration's chosen username (replaces placeholder like291255103060533248@nova.ddns.netwithnova@nova.ddns.net)homeUserIdis backfilled if null- Missing profile fields (
displayName,avatarColor) are filled
The user's ID remains the same, preserving all existing FK references (DM memberships, messages, reactions, friendships). The user logs in and sees their full history. Returns HTTP 200 (not 201).
If the found user has a real password hash (already registered), the registration returns 409 and the client falls back to login.
Username Availability Check
Endpoint: GET /api/auth/check-username?username=<name>
Rate limit: 30 requests / 1 minute per IP
Auth: None
Validates format (same rules as local registration: 3-32 chars, /^[a-z0-9_]+$/), checks registration gate, then queries users table for existence. Returns { available: boolean, reason?: string }.
4. Login Flow
Endpoint: POST /api/auth/login
Rate limit: 15 requests / 2 minutes per IP
Auth: None
Steps
- Validate
usernameandpasswordare present strings - Look up user by
username(trimmed, lowercased) - Reject if not found (generic "Invalid username or password")
- Reject if
isDeleted === 1("This account has been deleted") - Verify password via bcrypt
- If password invalid AND user is federated: attempt self-healing (see below)
- If password invalid AND user is local: reject
- Sign JWT, return
{ token, user }. Note: Login does NOT mutateusers.status. A successful login does not by itself imply a live connection (the client may never establish a WebSocket due to network failure, mobile background, error path); writing'online'here would produce a permanently stuck-online row that no disconnect timer cleans up. The WebSocket auth path (ws/handler.ts) is the single source of truth forstatus = 'online'. Seedocs/systems/activity-presence.md"Boot Reset" for the mitigation that runs on server start.
Federation Password Self-Healing
When local bcrypt verification fails for a user with homeInstance set:
- Extract base username (strip
@domainif present) - POST to
https://{homeInstance}/api/auth/loginwith base username and provided password - Timeout: 10 seconds (
AbortController) - If home instance accepts (200):
- Re-hash password locally:
hashPassword(password) - Update local
passwordHash-- but do NOT setpasswordChangedAt(this is a state correction, not a password change; setting it would invalidate existing valid JWTs on this instance) - Log the self-healing event
- Continue with login success
- Re-hash password locally:
- If home instance rejects: return "Invalid username or password"
- If home instance unreachable (network error/timeout): return "Invalid username or password" (fall back to local-only rejection)
This flow ensures that when a federated user changes their password on their home instance, they can still log in on remote instances even if the remote's hash is stale.
5. Password Change
User Password Change
Endpoint: POST /api/users/@me/change-password
Rate limit: 5 requests / 15 minutes
Auth: JWT (authenticate preHandler)
Request body: { currentPassword?: string, newPassword: string }
| User type | currentPassword |
Behavior |
|---|---|---|
Local (homeInstance is null) |
Required | Verified via bcrypt against stored hash |
Federated (homeInstance set) |
Not required | JWT auth is sufficient (home instance already verified the change) |
Steps:
- Validate
newPasswordis string, min 8 chars - Load user from DB
- If local: require and verify
currentPassword - Hash new password
- Update
passwordHashANDpasswordChangedAt = Date.now()-- this invalidates all prior tokens - Sign fresh JWT, return
{ token }
Admin Password Reset
Endpoint: POST /api/admin/users/:id/reset-password
Auth: JWT + requireAdmin (instance admin only)
Guards:
- Target user must exist
- Target must not be deleted
- Target must not be federated (
homeInstancemust be null -- "Federated users authenticate via their home instance")
Steps:
- Generate temporary password:
crypto.randomBytes(12).toString('base64url')(16 chars) - Hash it and update
passwordHash+passwordChangedAt = Date.now() connectionManager.forceDisconnectUser(targetId)-- closes all WS connections, forcing re-auth- Return
{ temporaryPassword }-- admin must relay this to the user out-of-band
Cross-Instance Password Propagation (Client-Side)
When a user changes their password on their home instance, authStore.changePassword():
- Changes password on home instance via API
- Updates local token in localStorage and Zustand state
- Calls
changePasswordOnRemotes(newPassword)fromfederationOps.ts
changePasswordOnRemotes() flow:
- Gets all connected remote instances from
instanceStore - Cancels any existing retry timers for those origins
- For each connected instance, calls
inst.api.users.changePassword({ newPassword })with retry:- Initial retry:
retryWithBackoff()-- 3 attempts, exponential backoff starting at 2000ms (2s, 4s, 8s) - On success: updates cached token for that instance, clears pending sync flag
- Initial retry:
- If initial retries fail, starts
scheduleBackgroundRetry():- Schedule: 10 attempts at 30s intervals (5 min), then 12 attempts at 5min intervals (60 min)
- Each attempt looks up current instance from store (avoids stale references)
- Stops if instance disconnected or removed
- On exhaustion: sets
pendingPasswordSyncflag on the instance (UI indicator)
Timer management:
activeRetryTimersmap tracks per-origin retry timersclearPasswordSyncTimers()cancels all active retries (called on logout)- New password change cancels existing retry loops for affected origins
6. Account Deletion
Self-Deletion
Endpoint: DELETE /api/users/@me
Rate limit: 3 requests / 15 minutes
Auth: JWT (authenticate preHandler)
Request body: { password: string, username: string }
Pre-checks:
usernamemust match stored username (confirmation safeguard)- Local users must provide and verify
password; federated users rely on JWT auth - Must not own any spaces (returns 400 with
ownedSpaceslist)
Client-side flow (authStore.deleteAccount()):
- Call
deleteAccountOnRemotes()first (best-effort, see below) - Call
api.users.deleteAccount()on home instance - Clear localStorage token, reset all user-scoped stores
Federation Account Deletion
deleteAccountOnRemotes() runs before home deletion:
- For each connected remote instance, calls
inst.api.users.deleteAccount({ password: '', username: inst.username }) - Password is empty string (not needed for federated users on remotes)
- Best-effort: failures are caught and returned as
FederationOpResult[]but do not block home deletion
Tombstoning (tombstoneUser())
All cleanup runs in a single SQLite transaction:
Relationship cleanup (deletes):
spaceMembers-- removes from all spacesmemberRoles-- removes all role assignmentsfriends-- removes all friendships (both directions)friendRequests-- removes all friend requests (both directions)dmMembers-- removes from all DM channelsreadStates-- removes all read state recordsreactions-- removes all message reactionsdmReactions-- removes all DM reactionsspaceFolders-- removes all space foldersbans-- removes bans where user is target (try/catch for table existence)joinRequests-- removes join requests (try/catch)voiceRestrictions-- removes voice restrictions (try/catch)
Moderator reference cleanup (nullifies):
bans.bannedBy-- nullified where points to deleted uservoiceRestrictions.moderatorId-- nullifiedjoinRequests.decidedBy-- nullified
Channel override cleanup:
- Deletes
channelOverrideswheretargetType = 'member'andtargetId = uid
Group DM ownership transfer:
- For each group DM owned by the user, transfers to next remaining member
- If no remaining members, DM becomes orphaned
Orphaned DM cleanup:
- Finds DM channels with zero members after removal
- For each: collects attachment filenames, deletes attachments, reactions, messages, and the channel
User row anonymization:
username: '!deleted:{uid}' -- frees original username for reuse
passwordHash: crypto.randomBytes(32).toString('hex') -- random, unverifiable
displayName: null
avatar: null
banner: null
bio: null
customStatus: null
accentColor: null
avatarColor: null
replicatedInstances: '[]'
isDeleted: 1
status: 'offline'
isAdmin: 0
Return value: Array of filenames to delete from disk (avatar, banner, orphaned DM attachments). Caller handles disk cleanup.
Post-transaction (in route handler):
- Delete files from disk via
deleteUploadFile() connectionManager.forceDisconnectUser()-- closes all WS connections, leaves voice rooms, broadcasts presence
tombstoneUser() Options
tombstoneUser accepts an optional second argument:
interface TombstoneOptions { purgeContent?: boolean }
function tombstoneUser(uid: string, options?: TombstoneOptions): string[]
purgeContent: true(default / omitted): full tombstone — removes the user from spaces, friends, DM membership, and read-states; then also deletesreactions,dmReactions, and the user's spacemessageswith their attachments and embeds.purgeContent: false: soft tombstone — removes the user from spaces, friends, DM membership (dm_members), and read-states. ThepurgeContent: falseflag skips onlyreactions,dm_reactions, and the user's spacemessages(with attachments + embeds); DM membership cleanup and orphaned-DM purge always run in both modes (peruserDeletion.ts:121-126, 169-202) because zero-member DM channels are unreachable garbage regardless of authorship retention. Used by the federation identity soft-delete endpoint so remote message history is retained.
resolveOrCreateReplicatedUser and Deleted Users
resolveOrCreateReplicatedUser checks whether a user matching homeUserId + homeInstance already exists and has isDeleted = 1. If so, it returns null rather than returning or re-creating the deleted stub. This prevents zombie identities from reappearing after a federation identity deletion.
Federation Identity Deletion (Home-Side Trigger)
Endpoint: POST /api/users/@me/federation-identity/delete
Rate limit: 5 requests / 15 minutes
Auth: JWT (authenticate preHandler)
Request body: { origins: string[], mode: 'soft' | 'full' }
Fans out HMAC-signed DELETE /api/federation/identity requests to each listed remote in parallel. Returns a per-origin results map:
{ "results": { "<origin>": { "success": true } } }
On failure for a given origin the entry contains { "success": false, "error": "<message>", "ownedSpaces"?: [...] }. A 409 from a remote means the user owns spaces there that must be resolved before deletion can proceed.
sanitizeUser() for Deleted Users
When isDeleted === 1, returns an anonymized profile:
username: 'Deleted User'- All profile fields null/empty/false
status: 'offline'- Only
idandcreatedAtpreserved
7. Client-Side Session Lifecycle
State (authStore)
interface AuthState {
token: string | null; // Persisted in localStorage as 'backspace_token'
user: User | null; // Current user object
isLoading: boolean;
error: string | null;
}
Initialization: token is read from localStorage.getItem('backspace_token') on store creation.
initSession(token, user)
Called after successful login or registration:
resetUserStores()-- clears all user-scoped stores (chat, space, social, voice, instance, activity) andclearSelfIds()from identity registry- Saves token to localStorage
- Sets token + user in Zustand state
- Fires
useInstanceStore.autoConnectAll()(fire-and-forget) for federation
loadUser()
Called by useAuth() hook when token exists but user object is null:
- Calls
api.users.me()to fetch current user - On success: sets user, triggers
autoConnectAll() - On failure: removes token from localStorage, clears state (forces redirect to login)
logout()
- Removes token from localStorage
- Calls
resetUserStores()(clears all stores + self IDs) - Sets token and user to null
Route Guards
ProtectedRoute (in App.tsx):
- Reads
tokenfrom authStore - If no token:
<Navigate to="/login" replace /> - Used for
/channels/:spaceId/:channelId?and/explore
AuthRedirect (in App.tsx):
- Reads
tokenfrom authStore - If token present: redirects to
?redirectparam or/channels/@me - Used for
/loginand/registerroutes - Prevents authenticated users from seeing auth pages
useAuth() hook:
- Watches
token,user,isLoading - If no token: navigates to
/login - If token but no user and not loading: calls
loadUser() - Returns
{ user, isLoading, isAuthenticated }
Login Page
- Fields: username, password
- Redirect support: reads
?redirectparam, navigates there on success (validated: must start with/, not//) - Rate limit handling: catches
RateLimitError, shows countdown timer - Links to register page (preserves redirect param)
Registration Page (Two-Step)
Step 1 -- Credentials:
- Fields: username, password, confirm password
- Client-side validation: 3-32 chars,
/^[a-z0-9_]+$/, passwords match, min 6 chars - Debounced username availability check (500ms delay, abort on new input)
- Continue button disabled if username taken or invalid
Step 2 -- Personalization:
-
Fields: display name (optional), avatar color picker, avatar upload (with crop modal)
-
"Get Started" button: registers with personalization
-
"Skip for now" button: registers without personalization
-
Registration flow:
- Call
api.auth.register()-- saves token to localStorage but NOT to Zustand (prevents prematureAuthRedirect) - If avatar file selected: upload file, then
api.users.update({ avatar })(failure is non-fatal) initSession(token, finalUser)-- activates Zustand state, triggers redirect- Navigate to redirect param or
/channels/@me
Auth-token source of truth. During the step-2 avatar upload, the JWT lives in
localStorageonly --authStore.token(Zustand) is still null because step 3 hasn't fired. Both the homeapiclient (api/client.ts) and the home-origin branch ofsetTokenForOriginResolverininstanceStore.tstherefore read the home JWT fromlocalStorage.getItem('backspace_token'), never fromauthStore.token. This keepstransferStore.startUpload(and any other path that resolves a home-origin bearer) authenticated during the registration window. The two stores are written together everywhere else (initSession/logout), so the divergence only matters between steps 1 and 3 here. - Call
Responsive contract. Auth pages render outside MobileShell (they are pre-layout). The RegisterPage outer wrapper is a self-contained scroll container -- h-full overflow-y-auto on the outermost <div> because #root is h-full overflow-hidden (see globals.css). An inner min-h-full flex items-center justify-center wrapper centers the card vertically when content fits, and falls back to top-aligned scroll when content exceeds the viewport (as on iOS Safari with the keyboard up, where the visible viewport shrinks by ~300 px). Card width is max-w-[480px] with px-4 outer gutters, p-6 md:p-8 inner padding (smaller on mobile to reclaim 16 px content area at 360 px viewports). All <input> elements override the shared input-standard class's text-sm with text-base md:text-sm -- iOS Safari auto-zooms when an input has font-size <16 px. Primary submit buttons use py-3 md:py-2.5 to satisfy Apple HIG's ≥44 px tap-target rule on mobile. The closed-registration URL-token chip switches from inline-flex (desktop pill) to flex (mobile full-width banner) so longer error copy ("Invalid invite link -- please request a new one") wraps cleanly inside a 360 px viewport instead of forcing a single-line pill that overflows. The avatar color swatch row uses gap-2 md:gap-2.5 so the 7 swatches fit within the 360 px content area. Note: LoginPage does NOT yet apply the same scroll/iOS-zoom/tap-target treatment; this is a known follow-up since LoginPage's shorter form is less likely to clip. Update both together if revisiting.
8. Federation-Aware Identity Utilities
parseFederatedUsername(username)
Splits a potentially federated username:
"erin@nova.ddns.net" -> { baseName: "erin", domain: "nova.ddns.net" }
"erin" -> { baseName: "erin", domain: null }
Self-ID Registry
Module-level Set<string> tracking all Snowflake IDs belonging to the current user across connected instances:
registerSelfId(id: string) -- adds ID (called from WS ready events)
clearSelfIds() -- clears all (called on logout/session reset)
isSelf(user, homeUser)
Determines if a user object represents the current user. Cascading checks:
- Same
id(same instance, trivial) _knownSelfIds.has(user.id)(cross-instance via registry)user.homeInstance === window.location.hostAND base usernames match
canonicalUserMatch(a, b)
Federation-safe comparison of two user-like objects. Cascading strategies:
- Same
id-- trivial match homeUserIdcross-matching (both have it, or one matches the other'sid)- Username + home instance fallback: parse base names, derive home from
homeInstanceor domain part of username, compare
resolveDisplayIdentity(user, homeUser)
If user is a replicated alias of homeUser (via isSelf), returns homeUser for display purposes. Otherwise returns user unchanged.
9. Rate Limits Summary
| Endpoint | Max | Window |
|---|---|---|
POST /api/auth/register |
10 | 2 min |
GET /api/auth/check-username |
30 | 1 min |
GET /api/auth/check-invite |
30 | 1 min |
POST /api/auth/login |
15 | 2 min |
POST /api/users/@me/change-password |
5 | 15 min |
DELETE /api/users/@me |
3 | 15 min |
All keyed by request.ip.
10. Configuration Reference
| Config key | Env var | Default | Notes |
|---|---|---|---|
jwtSecret |
JWT_SECRET |
(required) | Min 32 chars, startup crash if shorter |
jwtExpiresIn |
JWT_EXPIRES_IN |
'30d' |
Passed to jsonwebtoken expiresIn option |
registrationOpen |
REGISTRATION_OPEN |
true |
Overridden by instanceSettings.registrationOpen in DB (null DB row = env fallback). Local anonymous signup gate. |
| (no env) | -- | true |
instanceSettings.federatedRegistrationOpen is DB-only (NOT NULL DEFAULT 1) — no env override. Federated identity replication gate. |
11. requireAdmin Guard
auth.ts:requireAdmin() -- used as preHandler alongside authenticate:
- Loads full user from DB by
request.userId - Rejects with 403 if user not found or
isAdmin !== 1 - Used by admin routes (user management, password reset, federation peer management)