Files
backspace/docs/systems/admin.md
T
Jannis Braun a3a7527c9e chore: add system docs, specs, and misc updates from other sessions
- Add complete docs/systems/ reference (18 system docs)
- Add federation relay status doc and prior spec/plan docs
- Remove superseded docs/federation-dm-s2s.md (replaced by docs/systems/federation.md)
- CLAUDE.md updates
- Minor fixes in social.ts, types.ts, AddDmMemberModal, NewDmModal, UserSettings
2026-03-31 03:40:34 +02:00

21 KiB

Admin & Instance Configuration System

Source files:

  • packages/server/src/routes/admin.ts -- User management, storage management endpoints
  • packages/server/src/routes/instance.ts -- Public instance info endpoint
  • packages/server/src/routes/settings.ts -- Instance settings and streaming limits endpoints
  • packages/server/src/utils/auth.ts -- requireAdmin middleware
  • packages/server/src/utils/userDeletion.ts -- tombstoneUser() deletion logic
  • packages/server/src/utils/storageJanitor.ts -- Storage stats, orphan detection, cleanup
  • packages/web/src/stores/settingsStore.ts -- Zustand store for instance/streaming settings
  • packages/web/src/components/modals/instanceSettingsPanels/GeneralPanel.tsx -- General settings UI
  • packages/web/src/components/modals/instanceSettingsPanels/StoragePanel.tsx -- Storage management UI
  • packages/web/src/components/modals/instanceSettingsPanels/StreamingPanel.tsx -- Streaming config UI
  • packages/web/src/components/modals/instanceSettingsPanels/UsersPanel.tsx -- User management UI
  • packages/shared/src/types.ts -- Shared type interfaces
  • packages/shared/src/constants.ts -- Streaming constants (resolutions, framerates, bitrate matrix)

Authentication & Authorization

All admin endpoints use two Fastify preHandlers chained in order:

  1. authenticate -- Verifies JWT from Authorization: Bearer <token> header, sets request.userId
  2. requireAdmin -- Queries users table, verifies isAdmin === 1. Returns 403 if not admin.
auth.ts:requireAdmin()
  → db.select().from(users).where(id = request.userId)
  → if !caller || caller.isAdmin !== 1 → 403 "Only instance admins can perform this action"

The instance info endpoint (GET /api/instance/info) is fully public with no auth. The streaming limits read endpoint (GET /api/settings/streaming) requires only authenticate (any logged-in user).


Instance Settings Table

Singleton row in instance_settings (id=1). See database.md for full schema.

Settings are split into two API surfaces:

Setting Group Read Endpoint Write Endpoint
General/Admin GET /api/settings/instance (admin) PATCH /api/settings/instance (admin)
Streaming GET /api/settings/streaming (auth) PATCH /api/settings/streaming (admin)
Public info GET /api/instance/info (public) N/A (derived from settings)

General Settings Schema (InstanceAdminSettings)

Field Type DB Column Validation Notes
instanceName string instanceName 1-32 chars, trimmed Default: 'Backspace'
registrationOpen boolean registrationOpen boolean DB null = use env REGISTRATION_OPEN (default true)
discoveryEnabled boolean discoveryEnabled boolean Controls space Explore page
gifApiKey string? gifApiKey string or empty to clear Returned masked as ****{last4} for security
gifEnabled boolean? (derived) -- Read-only; true when gifApiKey is non-null
maxUploadSizeMb number maxUploadSizeBytes 1-5120 MB Stored as bytes; converted on read/write. DB null = use env MAX_UPLOAD_SIZE (default 100MB)
federationRelayEnabled boolean federationRelayEnabled boolean Default: 1 (enabled)
federationRelayTtlDays number federationRelayTtlDays integer 1-365 Default: 30 days

Streaming Settings Schema (InstanceStreamingLimits)

Field Type DB Column Validation Default
maxBitrateKbps number maxBitrateKbps 500-1000000 20000
minBitrateKbps number minBitrateKbps 100-1000000, must be < max 500
bitrateStepKbps number bitrateStepKbps 50-5000 500
allowedResolutions (number|'native')[] allowedResolutions Non-empty, values from STANDARD_RESOLUTIONS or 'native' [540,720,1080]
allowedFramerates number[] allowedFramerates Non-empty, values from STANDARD_FRAMERATES [30,45,60]
maxResolution number maxResolution Must be in STANDARD_RESOLUTIONS 1080
maxFramerate number maxFramerate Must be in STANDARD_FRAMERATES 60
discoveryEnabled boolean discoveryEnabled boolean true
bitrateMatrixOverrides Record<string,number>|null bitrateMatrixOverrides Keys: {res}_{fps}, values: 1-1000000 null
allowCustomBitrate boolean allowCustomBitrate boolean true

Streaming constants (packages/shared/src/constants.ts):

STANDARD_RESOLUTIONS = [540, 720, 1080, 1440, 2160]
STANDARD_FRAMERATES  = [30, 45, 60, 75, 90, 120]
HIGH_END_RESOLUTION_THRESHOLD = 1440
HIGH_END_FRAMERATE_THRESHOLD  = 75

Default bitrate matrix (kbps, VP9 screen share):

      30    45    60    75    90    120
540   1500  2000  2500  2800  3200  4000
720   3000  3500  4000  4500  5000  6000
1080  6000  7000  8000  9000  10000 12000
1440  10000 12000 14000 16000 18000 22000
2160  20000 24000 28000 32000 38000 45000

See voice.md for how clients enforce these limits at the WebRTC encoding boundary.


Serialization Details

Resolution/Framerate Storage

Stored as CSV strings in DB, parsed on read:

settings.ts:rowToLimits()
  allowedResolutions: row.allowedResolutions.split(',')
    → .map(s => s === 'native' ? 'native' : Number(s))
    → .filter(v => v === 'native' || STANDARD_RESOLUTIONS.includes(v))

  allowedFramerates: row.allowedFramerates.split(',')
    → .map(Number)
    → .filter(n => STANDARD_FRAMERATES.includes(n))

On write, numbers sorted ascending with 'native' always last:

settings.ts:PATCH /api/settings/streaming
  nums = allowedResolutions.filter(r => r !== 'native').sort(asc)
  updateData.allowedResolutions = [...nums, ...(hasNative ? ['native'] : [])].join(',')

Bitrate Matrix Overrides

Stored as JSON string in DB. Sparse representation: only cells differing from defaults.

settings.ts:rowToLimits()
  raw = row.bitrateMatrixOverrides (string|null)
  → JSON.parse → validate is non-null non-array object → return if non-empty, else null

Valid keys: {resolution}_{framerate} (e.g., "1080_60"). Validated against all combinations of STANDARD_RESOLUTIONS x STANDARD_FRAMERATES.

GIF API Key Masking

The gifApiKey is never returned in full. The GET response masks it:

settings.ts:GET /api/settings/instance
  gifApiKey: gifKey ? `****${gifKey.slice(-4)}` : undefined

On PATCH, if the client sends back a value starting with ****, it is ignored (prevents overwriting the real key with the mask). Empty string clears the key.


API Endpoints

Public Instance Info

GET /api/instance/info

No authentication. Returns:

{
  name: string;        // instanceSettings.instanceName ?? 'Backspace'
  version: string;     // Hardcoded '1.0.0' in instance.ts
  registrationOpen: boolean;  // DB setting overrides env if non-null
}

Registration resolution order: instance_settings.registrationOpen (if not null) > config.registrationOpen (from REGISTRATION_OPEN env, default true).

General Instance Settings

GET  /api/settings/instance    — admin only → InstanceAdminSettings
PATCH /api/settings/instance   — admin only → InstanceAdminSettings

See field table above for validation rules. Cross-field: discoveryEnabled changes here are also synced to streamingLimits in the frontend store (settingsStore.ts:updateInstanceSettings).

Streaming Settings

GET   /api/settings/streaming   — any authenticated user → InstanceStreamingLimits
PATCH /api/settings/streaming   — admin only → InstanceStreamingLimits

Cross-field validation on PATCH:

  • minBitrateKbps must be strictly less than maxBitrateKbps (checked against effective values after merge with current DB row).

Storage Management

All admin-only.

GET  /api/admin/storage/stats         → StorageStats
GET  /api/admin/storage/orphans       → { orphans: OrphanedFile[] }
POST /api/admin/storage/cleanup       { dryRun?: boolean } → CleanupResult
POST /api/admin/storage/cleanup-media { maxAgeDays: number, dryRun?: boolean } → CleanupResult

StorageStats shape:

{
  totalFiles: number;
  totalSize: number;           // bytes
  referencedFiles: number;
  referencedSize: number;      // bytes
  orphanedFiles: number;       // Files on disk not referenced by DB
  orphanedSize: number;
  unlinkedAttachments: number; // Attachment records with no message
  unlinkedSize: number;
  danglingAttachments: number; // Attachment records pointing to missing files
  danglingSize: number;
  breakdown: { type: string; count: number; size: number }[];
}

CleanupResult shape:

{
  dryRun: boolean;
  deletedFiles: number;
  freedBytes: number;
  deletedAttachmentRecords: number;
  errors: string[];
}

Storage functions (getStorageStats, getOrphanedFiles, cleanupStorage, cleanupOldMedia) are implemented in utils/storageJanitor.ts. Out of scope here -- if an uploads.md spec is created, document there.

Cleanup flow (UI):

  1. Admin clicks "Preview Cleanup" -- calls cleanupStorage(dryRun=true) or cleanupOldMedia(days, dryRun=true)
  2. Preview result shown with count/size
  3. "Clean Up Now" / "Delete Now" button enabled only after preview completes
  4. Live cleanup calls same endpoint with dryRun=false
  5. Stats refreshed after live cleanup

Media cleanup validation: maxAgeDays must be a positive integer >= 1. Returns 400 otherwise.

User Management

All admin-only.

GET    /api/admin/users              → AdminUserListResponse
GET    /api/admin/users/instances    → { instances: string[] }
PATCH  /api/admin/users/:id/role     { isAdmin: boolean } → AdminUser
POST   /api/admin/users/:id/reset-password → AdminResetPasswordResponse
DELETE /api/admin/users/:id          → { success: boolean }

User List: Filters, Search, Sort, Pagination

Query Parameters

Param Type Default Description
q string '' Fuzzy search on username and displayName (SQL LIKE %q%)
page number 1 Page number, min 1
pageSize number 50 Results per page, clamped to 1-100
showDeleted 'true' false Include tombstoned users
homeInstance string -- 'local' for null homeInstance; otherwise exact domain match
role string -- 'admin' or 'non-admin'
joinedAfter date string -- Parsed as new Date(value).getTime()
joinedBefore date string -- Parsed as new Date(value + 'T23:59:59.999Z').getTime() (inclusive end-of-day)
sort string 'newest' One of: newest, oldest, az, za

Sort Options

Value SQL Description
newest desc(users.createdAt) Most recently created first
oldest asc(users.createdAt) Oldest first
az asc(users.username) Alphabetical A-Z
za desc(users.username) Reverse alphabetical

Filter Composition

Filters are combined with AND. When showDeleted is false (default), isDeleted = 0 is always added. Search q creates an OR condition across username LIKE and displayName LIKE.

Instances Endpoint

GET /api/admin/users/instances returns all distinct non-null homeInstance values from the users table. Used by the frontend to populate the instance filter dropdown.

AdminUser Shape

{
  id: string;
  username: string;
  displayName: string | null;
  avatar: string | null;
  avatarColor: string | null;
  status: string;         // 'online'|'idle'|'dnd'|'offline', defaults to 'offline' if null
  isAdmin: boolean;
  isDeleted: boolean;
  homeInstance: string | null;
  createdAt: number;      // epoch ms
}

Produced by admin.ts:toAdminUser() -- maps integer DB columns to booleans, coalesces null status.


Admin Actions: Safety Rules

Promote/Demote Admin (PATCH /api/admin/users/:id/role)

Request: { isAdmin: boolean }

Safety checks:

  1. isAdmin must be a boolean (400)
  2. Target user must exist (404)
  3. Target must not be deleted (400)
  4. Promote: target must NOT have a homeInstance (403 "Federated users cannot be promoted to admin")
  5. Demote: if target is currently admin, count all non-deleted admins. If count <= 1, reject (400 "Cannot demote the last admin")

Side effects on success:

  • Updates users.isAdmin to 1 or 0
  • Sends user_updated WebSocket event to target user via connectionManager.sendToUser() so their UI reflects the change immediately

Reset Password (POST /api/admin/users/:id/reset-password)

Safety checks:

  1. Target must exist (404)
  2. Target must not be deleted (400)
  3. Target must NOT have a homeInstance (400 "Federated users authenticate via their home instance")

Process:

  1. Generate 12 random bytes, encode as base64url -- this is the temporary password
  2. Hash with bcrypt via hashPassword()
  3. Update users.passwordHash and users.passwordChangedAt = Date.now()
  4. Force-disconnect all of target's WebSocket sessions via connectionManager.forceDisconnectUser()
  5. Return { temporaryPassword } in response

The passwordChangedAt update ensures all existing JWTs for the user are invalidated (tokens issued before this timestamp are rejected by the auth middleware).

The temporary password is shown exactly once in the admin UI -- the UsersPanel displays it inline below the user row with a copy button and a "Shown once" warning.

Delete User (DELETE /api/admin/users/:id)

Safety checks:

  1. Cannot delete yourself (400 "Use account settings to delete your own account")
  2. Target must exist (404)
  3. Target must not be already deleted (400)
  4. Target must not own any spaces (400 "User owns spaces -- transfer ownership first", response includes ownedSpaces array with id/name)

Process:

  1. Call tombstoneUser(targetId) -- returns list of files to delete (avatar, banner)
    • Tombstone sets isDeleted=1, clears personal data, removes from spaces/friends/DMs/reactions/read-states/folders in a transaction
    • Transfers group DM ownership to next member
  2. Delete returned files from disk via deleteUploadFile()
  3. Force-disconnect all WebSocket sessions
  4. Return { success: true }

Frontend Architecture

Settings Store (settingsStore.ts)

Zustand store managing two data objects:

State Type Fetched Via Updated Via
streamingLimits InstanceStreamingLimits | null fetchStreamingLimits() updateStreamingLimits()
instanceSettings InstanceAdminSettings | null fetchInstanceSettings() updateInstanceSettings()
isAdmin boolean Set externally via setIsAdmin() --
gifEnabled boolean fetchGifEnabled() --

Default fallback: If streaming limits fail to fetch, the store falls back to DEFAULT_LIMITS:

{
  maxBitrateKbps: 20000,
  minBitrateKbps: 500,
  bitrateStepKbps: 500,
  allowedResolutions: [540, 720, 1080],
  allowedFramerates: [30, 45, 60],
  maxResolution: 1080,
  maxFramerate: 60,
  discoveryEnabled: true,
  bitrateMatrixOverrides: null,
  allowCustomBitrate: true,
}

Cross-field sync: When updateInstanceSettings() changes discoveryEnabled, it also patches streamingLimits.discoveryEnabled to keep the streaming panel's DiscoveryPanel warning banner in sync.

Exported helper: getStreamingLimits() returns current limits or defaults -- used by voice/streaming code outside React.

Admin UI Panels

All panels live under packages/web/src/components/modals/instanceSettingsPanels/. Each operates as a controlled form with a local draft state, detecting changes against the store's server-synced values. Unsaved changes show a sticky glass-bubble save/reset bar at the bottom.

GeneralPanel

Manages: instance name, registration toggle, discovery toggle, GIF API key, federation relay toggle/TTL, peered instances list.

  • Instance name input: max 32 chars, enforced client-side via slice(0, 32)
  • GIF key: password input, separate dirty tracking (gifKeyDirty). Only sent on save if modified. "Clear key" button sets empty string.
  • Federation peers: fetched via api.federation.peers(), displayed as a list with status badges (active/pending/unreachable), last-seen/synced times, revoke button
  • Peers with status 'revoked' are filtered out of the visible list
  • Revoke calls api.federation.revokePeer(peerId) and removes from local list

StoragePanel

Manages: storage overview, file type breakdown, upload limit, orphan cleanup, media retention cleanup.

  • Stats grid: 5 cards (total files, referenced, orphaned, unlinked uploads, dangling records) with byte formatting
  • File type breakdown: List of categories (image/video/audio/document/other) with file counts and sizes
  • Upload limit: Number input (1-5120 MB) with save button, persisted via updateInstanceSettings({ maxUploadSizeMb })
  • Orphan cleanup: Two-step (preview dry-run, then live). "Clean Up Now" disabled until preview completes
  • Media retention: Age-based cleanup with configurable days input. Same two-step preview/execute pattern.
  • "Refresh Stats" link at bottom re-fetches all stats

StreamingPanel

Manages: bitrate range (min/max/step), custom bitrate toggle, resolution/framerate allowlists, bitrate matrix.

  • Bandwidth section: Range sliders + number inputs for min/max bitrate. Step size via preset pills (100, 250, 500, 1000, 2500, 5000 kbps) + custom number input.
  • Custom Bitrate toggle: Controls whether users can set their own bitrate vs using matrix defaults
  • Quality section: Toggle pills for each resolution (540p, 720p, 1080p, 1440p, 4K, Native) and framerate (30, 45, 60, 75, 90, 120 fps). At least one of each must remain enabled.
  • High-end warning: Shown when resolutions >= 1440 or framerates >= 75 are enabled. Warns about CPU/GPU/bandwidth requirements.
  • Bitrate matrix: Interactive grid of resolution x framerate cells. Displays in Mbps (stored as kbps). Click to edit. Overridden cells highlighted in accent-primary. Cells exceeding maxBitrateKbps highlighted in amber.
  • Scale slider: Multiplies all matrix values by 0.5x-2.0x. Captures snapshot on drag start, applies factor during drag, releases on pointer up.
  • Save payload: Only cells differing from defaults are sent as bitrateMatrixOverrides; identical-to-default cells are omitted (sparse representation). If no overrides, null is sent.

UsersPanel

Manages: user list with search/filter/sort/pagination, admin promotion/demotion, password reset, account deletion.

  • Search: Debounced (300ms) text input, resets to page 1 on change
  • Filters: Instance dropdown (local/specific domain), role (admin/non-admin), joined-after/joined-before date pickers, sort dropdown
  • "Clear filters" link: Visible when any filter/sort/search is active. Resets all to defaults.
  • Page size: Fixed at 50 (not user-configurable)
  • Pagination: Previous/Next buttons, "Page X of Y (N users)" label
  • User rows: Avatar, username, display name, badges (Admin amber, federated instance sky, Deleted rose), join date
  • Action buttons (per user, hidden if deleted):
    • Shield icon: promote/demote admin. Disabled for federated users. Demotion requires ConfirmDialog.
    • Key icon: reset password. Disabled for federated users. Requires ConfirmDialog. Shows temporary password inline.
    • Trash icon: delete user. Disabled for self. Requires ConfirmDialog (danger variant).
  • Temp password display: Appears inline below the user row after successful reset. Includes copy-to-clipboard button and "Shown once" notice.

Data Flow: Settings Update Lifecycle

[Admin UI Panel]
  → local draft state (useState)
  → user clicks Save
  → settingsStore.updateStreamingLimits() / updateInstanceSettings()
    → api.settings.updateStreaming() / updateInstance()
      → PATCH /api/settings/streaming or /api/settings/instance
        → Server validates each field
        → Cross-field validation (min < max for bitrates)
        → db.update(instanceSettings).set(updateData).where(id=1)
        → db.select fresh row → serialize → return
    → store.set({ streamingLimits: updated }) / set({ instanceSettings: updated })
  → UI re-renders from store, draft resets to match

Cross-References

  • Database schema: database.md -- instance_settings, users, spaces tables
  • API endpoints: api.md -- Full endpoint listing for admin, settings, instance routes
  • Federation relay: federation.md -- Relay toggle/TTL mechanics, peer management, outbox delivery
  • Voice/streaming: voice.md -- Client-side enforcement of streaming limits
  • Permissions: permissions.md -- Admin flag is separate from RBAC; isAdmin is a user-level column, not a permission bit