Files

662 lines
40 KiB
Markdown

# Authentication & Session System
Source files:
- `packages/server/src/routes/auth.ts` -- Registration, login, username availability, invite-token check endpoints
- `packages/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, atomic `redeemInvite()` transaction
- `packages/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, `authenticate` preHandler, `requireAdmin`
- `packages/server/src/utils/userDeletion.ts` -- `tombstoneUser()` transactional account erasure
- `packages/server/src/utils/sanitize.ts` -- `sanitizeUser()` strips internal fields, anonymizes deleted users
- `packages/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 actions
- `packages/web/src/hooks/useAuth.ts` -- Route guard hook (redirect to `/login` when no token)
- `packages/web/src/App.tsx` -- `ProtectedRoute` and `AuthRedirect` route wrappers
- `packages/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 propagation
- `packages/server/src/config.ts` -- `jwtSecret`, `jwtExpiresIn`, `registrationOpen` config
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
```typescript
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` (env `JWT_SECRET`, minimum 32 characters -- startup crash if shorter)
- **Expiry:** `config.jwtExpiresIn` (env `JWT_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:
1. Extract `Bearer <token>` from `Authorization` header
2. `verifyJwt(token)` -- checks signature (HS256) and expiry
3. DB lookup: fetch `id`, `isDeleted`, `passwordChangedAt` from `users` table
4. Reject if user not found or `isDeleted === 1`
5. **Token revocation check:** if `passwordChangedAt` is set and `payload.iat` exists, reject if `iat < Math.floor(passwordChangedAt / 1000)` (JWT `iat` is seconds, `passwordChangedAt` is milliseconds)
6. Attach `userId` and `username` to `request` object
### 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), `passwordChangedAt` is set to `Date.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`:
1. Client connects, 10-second auth timeout starts
2. First message must be `{ type: 'auth', token: '<jwt>' }`
3. `verifyJwt(token)` validates signature and expiry
4. DB check: reject if user deleted or token revoked (same `passwordChangedAt` logic as REST)
5. On success: clears timeout, sets status to `'online'`, registers connection, sends `ready` payload, broadcasts presence
6. 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@domain` format (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):
1. `instanceSettings.registrationOpen` (DB, id=1) -- if not null, this takes priority
2. `config.registrationOpen` (env `REGISTRATION_OPEN`, default `true`) -- 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).
- `inviteToken` is **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/login` for any user. Existing federated accounts always log in regardless of `federatedRegistrationOpen`; existing local accounts always log in regardless of `registrationOpen`. Both gates affect **creation only**. This is why the Connections add-instance form keeps its submit button enabled even when the target instance has `federatedRegistrationOpen = false` (see `client-federation.md`): the request runs through `instanceStore`'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 by `federatedRegistrationOpen` and 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
```typescript
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
1. Validate inputs (username format, password length)
2. Read both registration gates from `instance_settings`
3. **Branch by request shape** (spec §1.2):
- If `homeInstance` set → reject with 403 unless `federatedRegistrationOpen === true`. `inviteToken` ignored on this path.
- Else (local) → if `registrationOpen` is false, require a valid `inviteToken`; otherwise reject with 403. Pre-flight token check rejects obvious-invalid tokens before bcrypt.
4. **Federated stub upgrade check** (if `homeInstance` is set): call `findFederatedUser` to look for an existing relay-created stub. If found and upgradeable, upgrade it instead of creating a new record (see below).
5. Check username uniqueness (exact match on lowercased username)
6. Hash password (bcrypt, 12 rounds)
7. Generate Snowflake ID
8. 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 in `ws/handler.ts`). Admin flag set if first user. **When the local-closed-with-token path is in play**, the insert runs inside `redeemInvite()`'s transaction so the user row, the `usedCount` bump, and the `invite_redemptions` row commit atomically (or all roll back).
9. Sign JWT with `{ userId, username }`
10. Return `{ token, user }` (user sanitized via `sanitizeUser(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:
- `passwordHash` is set to the new bcrypt hash (enables login)
- `username` is updated to the registration's chosen username (replaces placeholder like `291255103060533248@nova.ddns.net` with `nova@nova.ddns.net`)
- `homeUserId` is 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
1. Validate `username` and `password` are present strings
2. Look up user by `username` (trimmed, lowercased)
3. Reject if not found (generic "Invalid username or password")
4. Reject if `isDeleted === 1` ("This account has been deleted")
5. Verify password via bcrypt. **`federationHomeOrphaned === 1` no longer short-circuits here.** A federated account whose home instance was reset (a new incarnation stood up on the same domain) is now treated as **detached** — a sovereign LOCAL account whose local password hash is the sole authority (detach design §4.1). Local-hash verification proceeds normally: the correct local password logs in. The flag's only login effect is to permanently disable the self-heal path (step 7). *(Historical note: this check was previously a pre-verification freeze that blocked the local-password path too; the detach re-interpretation removed it so the real owner keeps their account instead of being locked out.)*
6. *(merged into step 5)*
7. **If password invalid AND user is federated:** if `federationHomeOrphaned === 1` (detached), reject immediately with the generic "Invalid username or password" — **no outbound request to the home domain**: there is no trusted home to consult, and re-hashing on the new incarnation's say-so would hand the account to a stranger. Otherwise attempt self-healing (see below).
8. **If password invalid AND user is local:** reject
9. Sign JWT, return `{ token, user }`. **Note:** Login does NOT mutate `users.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 for `status = 'online'`. See `docs/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:
1. Extract base username (strip `@domain` if present)
2. POST to `https://{homeInstance}/api/auth/login` with base username and provided password
3. Timeout: 10 seconds (`AbortController`)
4. **If home instance accepts (200):** run the **epoch guard** (see below) before re-hashing. If the guard passes:
- Re-hash password locally: `hashPassword(password)`
- Update local `passwordHash` -- but **do NOT set `passwordChangedAt`** (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
5. **If home instance rejects:** return "Invalid username or password"
6. **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.
#### Epoch guard (instance-epoch self-healing §6.3a)
Before re-hashing, the self-heal confirms the home instance is the **same incarnation** the trusted baseline was established with. Without this, a factory-reset home accepting a *new* same-name user's password would silently hand that stranger the established account. The guard does **not** trust the login-response body (TLS-MITM-bypassable, and a reset home would just echo its new epoch); it **reuses the authenticated `fetchPeerEpoch(peer)`** (`utils/federationEpoch.ts`, HMAC-signed request *and* response) to read the home's current epoch, then compares it to `federation_peers.peerInstanceId` for that origin (three-way):
- **No peer row / `peerInstanceId` is null** (legacy/never-tracked): allow — fall through to self-heal (no regression).
- **Baseline on record AND `fetchPeerEpoch` returns a *different* epoch:** refuse self-heal → "Invalid username or password" (the hijack case).
- **Baseline on record AND `fetchPeerEpoch` returns `null`** — epoch cannot be determined (peer too old → 404, unreachable, bad/absent response signature, **or the reset peer's desynced secret rejecting our signed request**): **fail closed — refuse self-heal.**
- **Baseline on record AND the epoch matches:** allow.
Trade-off: the separate authenticated call can fail independently of the login POST, so a transient home outage during a legitimate stale-hash login fails closed. This is security-over-availability on a rare, recoverable path (fallback: a normal password change once the home is reachable); trusting an unauthenticated body would re-open the hijack. A reset peer's `null` result also doubles as a reset signal — the guard is correct even before reset-detection has flagged the peer. This epoch guard covers the **undetected-reset window** — non-detached federated accounts whose home was reset but not yet quarantined. Once the quarantine flags an account as **detached** (`federationHomeOrphaned = 1`), the self-heal path is disabled for it entirely (step 7 of the Login Flow): the detached account is no longer a remote identity that can be self-healed at all, so the epoch comparison never runs for it — the local hash is its only authority. Re-peering the new incarnation therefore cannot re-open self-heal into a detached account.
#### Re-attach: leaving the detached state (re-attach spec §3.2)
Detach is sovereign but not permanent: the legitimate owner who re-created their account on the reset home can re-bind the detached account to the new home identity via `POST /api/users/@me/reattach` (registered in `routes/federation.ts`; see `federation.md` "Peer-Side Re-Attach"). It re-binds **only** on possession of BOTH identities — the session IS the detached account (local password authority, via `authenticate`) AND a one-time proof token minted on the home via `POST /api/auth/attach-proof` verifies with the home peer over signed S2S. Identity is never username-matched (that is the tier-2 hijack). On success the endpoint merges any pre-existing replicated stub for the new identity into the detached row, sets `home_user_id = <new homeUserId>`, **clears `federation_home_orphaned` (0)**, nulls `profile_updated_at`, and applies the current home profile. Clearing the flag automatically **re-enables** normal federated-account semantics: login self-heal resumes (the epoch guard above runs again) and the S2S binding guards stop excluding the account — live profile/presence sync from the home is restored. The local password hash is kept; a detached tombstone (`is_deleted = 1`) is never re-attachable.
**Proof-mint endpoint — `POST /api/auth/attach-proof`** (`routes/auth.ts`). The home-side half of the exchange, run against the owner's re-created **native** account on the reset home. JWT-authenticated, rate-limited 5/15min. Body `{ targetDomain }` names the peer the caller intends to re-attach on. Guards: the session user must be **native** (`homeInstance` null) — a federated/replicated session cannot mint a proof for itself. Mints a random 256-bit token (`randomBytes(32).toString('hex')`), inserts a `federation_attach_proofs` row `{ homeUserId, targetDomain, createdAt, expiresAt = now + 60s, usedAt: null }`, and returns `{ token }`. The token is **single-use, 60s TTL, and bound to `targetDomain`** — only the named peer can redeem it (verified server-side against the authenticated peer domain, not the request body, at `POST /api/federation/verify-attach-proof`). Each mint opportunistically janitors expired rows (`DELETE ... WHERE expires_at < now`); since the TTL is 60s, any spent (`used_at` set) token is swept on the next mint after it expires, so the table stays bounded without a background worker.
---
## 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, `federationHomeOrphaned !== 1`) | Not required | JWT auth is sufficient (home instance already verified the change) |
| Detached (`homeInstance` set, `federationHomeOrphaned === 1`) | Required | Follows the **local** rule — the home is gone, so nothing external verified the change; the local hash is the sole authority (detach design §4.4) |
**Steps:**
1. Validate `newPassword` is string, min 8 chars
2. Load user from DB
3. If local **or detached** (`!homeInstance || federationHomeOrphaned === 1`): require and verify `currentPassword`
4. Hash new password
5. Update `passwordHash` AND `passwordChangedAt = Date.now()` -- this invalidates all prior tokens
6. 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 (`homeInstance` must be null -- "Federated users authenticate via their home instance")
**Steps:**
1. Generate temporary password: `crypto.randomBytes(12).toString('base64url')` (16 chars)
2. Hash it and update `passwordHash` + `passwordChangedAt = Date.now()`
3. `connectionManager.forceDisconnectUser(targetId)` -- closes all WS connections, forcing re-auth
4. 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()`:
1. Changes password on home instance via API
2. Updates local token in localStorage and Zustand state
3. Calls `changePasswordOnRemotes(newPassword)` from `federationOps.ts`
`changePasswordOnRemotes()` flow:
1. Gets all connected remote instances from `instanceStore`
2. Cancels any existing retry timers for those origins
3. 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
4. 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 `pendingPasswordSync` flag on the instance (UI indicator)
**Timer management:**
- `activeRetryTimers` map tracks per-origin retry timers
- `clearPasswordSyncTimers()` 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:**
1. `username` must match stored username (confirmation safeguard)
2. Native local users **and detached accounts** (`federation_home_orphaned = 1`) must provide and verify `password` against the local hash; non-detached federated users rely on JWT auth (their home instance already vouches for them). A detached account is a sovereign local account with no home verifying anything, so it follows the LOCAL rule — the same self-destruct protection as a native account, and mirroring the change-password rule (§5, detach spec §4.4). Condition: `!user.homeInstance || user.federationHomeOrphaned === 1`. Missing password → 400; wrong password → 403.
3. Must not own any spaces (returns 400 with `ownedSpaces` list)
**Client-side flow** (`authStore.deleteAccount()`):
1. Call `deleteAccountOnRemotes()` first (best-effort, see below)
2. Call `api.users.deleteAccount()` on home instance
3. 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 spaces
- `memberRoles` -- removes all role assignments
- `friends` -- removes all friendships (both directions)
- `friendRequests` -- removes all friend requests (both directions)
- `dmMembers` -- **partitioned**: the row is KEPT for 1-on-1 DMs (`dm_channels.ownerId IS NULL`) so the thread survives as a readable anonymized "Deleted User" thread; it is deleted only for group DMs (`ownerId IS NOT NULL`). The in-function `userDmChannelIds` local captures the user's DM channel ids before partitioning.
- `readStates` -- removes all read state records
- `reactions` -- removes all message reactions
- `dmReactions` -- removes all DM reactions
- `spaceFolders` -- removes all space folders
- `bans` -- 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 user
- `voiceRestrictions.moderatorId` -- nullified
- `joinRequests.decidedBy` -- nullified
**Channel override cleanup:**
- Deletes `channelOverrides` where `targetType = 'member'` and `targetId = 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:**
- Among the user's DM channels, finds those with **zero live members**`users.isDeleted = 0`, excluding the uid being tombstoned right now (it still reads `isDeleted = 0` at scan time). A Deleted ↔ Survivor 1-on-1 is kept; a Deleted ↔ Deleted 1-on-1 is purged.
- For each: collects attachment filenames, deletes attachments, reactions, messages, and the channel
> **DM tombstone semantics** (1-on-1 survives as a read-only "Deleted User" thread, group DMs drop the member, `isDeadOneOnOne` read-only guard, dead-DM purge rule, one-time backfill, heal-path `user_updated` broadcast): see `docs/systems/dm-system.md` § "DM Tombstone Semantics".
**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:
```typescript
interface TombstoneOptions { purgeContent?: boolean }
function tombstoneUser(uid: string, options?: TombstoneOptions): string[]
```
- **`purgeContent: true`** (default / omitted): full tombstone — removes the user from spaces, friends, group DM membership, and read-states; then also deletes `reactions`, `dmReactions`, and the user's space `messages` with their attachments and embeds. 1-on-1 DM membership is kept (see below).
- **`purgeContent: false`**: soft tombstone — removes the user from spaces, friends, group DM membership, and read-states. The `purgeContent: false` flag skips only `reactions`, `dm_reactions`, and the user's space `messages` (with attachments + embeds). The DM membership partition and orphaned-DM purge always run in both modes: group-DM `dm_members` rows are deleted, 1-on-1 `dm_members` rows are KEPT (so the thread survives as an anonymized "Deleted User" thread), and any resulting zero-member DM channel is purged as unreachable garbage. 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:
```json
{ "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 `id` and `createdAt` preserved
---
## 7. Client-Side Session Lifecycle
### State (`authStore`)
```typescript
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:
1. `resetUserStores()` -- clears all user-scoped stores (chat, space, social, voice, instance, activity) and `clearSelfIds()` from identity registry
2. Saves token to localStorage
3. Sets token + user in Zustand state
4. Fires `useInstanceStore.autoConnectAll()` (fire-and-forget) for federation
### `loadUser()`
Called by `useAuth()` hook when token exists but user object is null:
1. Calls `api.users.me()` to fetch current user
2. On success: sets user, triggers `autoConnectAll()`
3. On failure: removes token from localStorage, clears state (forces redirect to login)
### `logout()`
1. Removes token from localStorage
2. Calls `resetUserStores()` (clears all stores + self IDs)
3. Sets token and user to null
### Route Guards
**`ProtectedRoute`** (in `App.tsx`):
- Reads `token` from authStore
- If no token: `<Navigate to="/login" replace />`
- Used for `/channels/:spaceId/:channelId?` and `/explore`
**`AuthRedirect`** (in `App.tsx`):
- Reads `token` from authStore
- If token present: redirects to `?redirect` param or `/channels/@me`
- Used for `/login` and `/register` routes
- 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 `?redirect` param, 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:
1. Call `api.auth.register()` -- saves token to localStorage but NOT to Zustand (prevents premature `AuthRedirect`)
2. If avatar file selected: upload file, then `api.users.update({ avatar })` (failure is non-fatal)
3. `initSession(token, finalUser)` -- activates Zustand state, triggers redirect
4. Navigate to redirect param or `/channels/@me`
**Auth-token source of truth.** During the step-2 avatar upload, the JWT lives in `localStorage` only -- `authStore.token` (Zustand) is still null because step 3 hasn't fired. Both the home `api` client (`api/client.ts`) and the home-origin branch of `setTokenForOriginResolver` in `instanceStore.ts` therefore read the home JWT from `localStorage.getItem('backspace_token')`, never from `authStore.token`. This keeps `transferStore.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.
**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:
1. Same `id` (same instance, trivial)
2. `_knownSelfIds.has(user.id)` (cross-instance via registry)
3. `user.homeInstance === window.location.host` AND base usernames match
### `canonicalUserMatch(a, b)`
Federation-safe comparison of two user-like objects. Cascading strategies:
1. Same `id` -- trivial match
2. `homeUserId` cross-matching (both have it, or one matches the other's `id`)
3. Username + home instance fallback: parse base names, derive home from `homeInstance` or 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`:
1. Loads full user from DB by `request.userId`
2. Rejects with 403 if user not found or `isAdmin !== 1`
3. Used by admin routes (user management, password reset, federation peer management)