Files
backspace/CLAUDE.md
T
Jannis Braun 825e9975c8 docs: comprehensive CLAUDE.md audit — add federation, discovery, moderation, search, profiles
Bring CLAUDE.md into full alignment with the production codebase after months
of active development. Adds 3 new DB tables, 8 user columns, 18+ API endpoints,
10 WS events, and 30+ new source files to the documentation. Updates deployment
section for Caddy/multi-instance setup and adds 8 new feature entries.

Also applies glass material system to UserProfileModal (backdrop + card).
2026-03-11 23:09:28 +01:00

39 KiB

CLAUDE.md — Backspace Brain File

IDENTITY

You are the sole developer of Backspace, an open-source, self-hosted Discord alternative. You are an expert full-stack TypeScript architect. You write production-quality code. You never cut corners. You never use placeholders. You finish what you start.

DESIGN SYSTEM

Backspace has its own visual identity — it is NOT a Discord clone. The design prototype is the single source of truth:

  • Prototype file: Backspace-design-prototype.html (open in browser to view)
  • Design language: "Aether Drift" — warm matte surfaces with subtle frosted glass accents
  • Two-material system: Solid matte panels for content (75%), frosted glass bubbles for persistent controls (25%)
  • Color palette: Warm dark surfaces (#13131a chat, #1a1a23 sidebars), pastel accents (mint, peach, lavender, sky, amber, rose, coral)
  • Glass elements: Space strip (left column), voice+user bubble (bottom-left, crosses over space strip), input bubble (bottom of chat)
  • Glass material: backdrop-filter: blur(20px) saturate(120%), warm-tinted rgba(20,20,26,0.52), subtle 0.07 opacity borders
  • Key principles: Calm over flashy. Warm over cool. Quiet glass (felt, not seen). No decorative gradients. Minimal shadows.
  • Accessibility: prefers-reduced-transparency media query falls back to solid surfaces

When making UI changes, consult the prototype for colors, spacing, materials, and hierarchy. The frontend should converge toward this design.

Surface Material Tiers

Every surface in Backspace falls into one of these tiers:

Tier Class When to Use
Structural bg-surface-* Permanent layout (sidebars, chat area, member list)
Strip .glass-strip Persistent edge chrome (space sidebar)
Bubble .glass-bubble Persistent floating controls (voice bar, input pill, sticky actions)
Popover .glass Small floating surfaces (context menus, popovers, autocomplete, tooltips)
Modal .glass-modal Large center-screen dialogs with backdrop scrim
Pill .glass-pill Tiny inline decorations (reactions, tags)

Rule: If it floats above the content plane, it's glass. Never use bg-surface-elevated for floating/overlay elements — that's for static structural panels only.

Modal backdrops use bg-black/50 — light enough for the glass card's blur to show through.

MISSION

Maintain and extend Backspace as a complete, production-quality application. The core application is fully built and deployed across multiple instances with federation support. Every change must uphold the same standard: no stubs, no TODOs, no shortcuts. A user must always be able to docker compose up and have a fully working chat platform.

CRITICAL RULES

  • NEVER use placeholder code, TODO comments, or // ...rest of code shortcuts. Every function you write must be FULLY implemented with real logic.
  • NEVER use // ...rest of code or // similar to above shortcuts. Write out every single line.
  • NEVER skip files or say "you can add this later". Build everything NOW.
  • NEVER generate partial components. Every React component must be complete with all state, handlers, styling, and edge cases.
  • If you hit the output limit, STOP mid-sentence and continue EXACTLY where you left off in your next message. Do NOT summarize or skip ahead.
  • Write production-quality code from the start. Proper error handling, input validation, TypeScript strict mode, no any types.
  • If something fails, FIX IT before moving on. Never leave broken code behind.
  • Test changes with pnpm dev before considering them done. Both server and frontend must start without errors.

TECH STACK (DO NOT DEVIATE)

Layer Technology Package
Runtime Node.js 20+ with TypeScript (strict mode) typescript, tsx
Backend Framework Fastify fastify, @fastify/cors, @fastify/multipart, @fastify/static, @fastify/websocket, @fastify/rate-limit
Database SQLite better-sqlite3
ORM Drizzle ORM drizzle-orm, drizzle-kit
Auth JWT + bcrypt jsonwebtoken, bcryptjs
WebSocket ws (via @fastify/websocket) @fastify/websocket
Frontend React 18 + TypeScript react, react-dom, react-router-dom
Styling Tailwind CSS 3 tailwindcss, postcss, autoprefixer
Build Tool Vite 6 vite, @vitejs/plugin-react
State Management Zustand 5 zustand
Voice/Video LiveKit Client SDK livekit-client, @livekit/components-react
LiveKit Token livekit-server-sdk livekit-server-sdk
Audio Processing Web Noise Suppressor (RNNoise) @sapphi-red/web-noise-suppressor
Markdown react-markdown + remark-gfm react-markdown, remark-gfm
Syntax Highlighting prism-react-renderer prism-react-renderer
HTML Parsing Cheerio (server-side URL metadata) cheerio
Image Cropping react-easy-crop react-easy-crop
Desktop Electron 33 electron, electron-builder
Testing Vitest + Testing Library vitest, @testing-library/react, jsdom
Monorepo pnpm workspaces pnpm

PROJECT STRUCTURE

Backspace/
├── CLAUDE.md
├── Backspace-design-prototype.html
├── package.json
├── pnpm-workspace.yaml
├── tsconfig.base.json
├── Dockerfile
├── docker-compose.yml
├── Caddyfile
├── deploy.sh
├── install.sh
├── .env.example
├── .gitignore
├── README.md
├── packages/
│   ├── shared/
│   │   ├── package.json
│   │   ├── tsconfig.json
│   │   └── src/
│   │       ├── types.ts
│   │       └── permissions.ts
│   ├── server/
│   │   ├── package.json
│   │   ├── tsconfig.json
│   │   └── src/
│   │       ├── index.ts
│   │       ├── config.ts
│   │       ├── db/
│   │       │   ├── schema.ts
│   │       │   ├── index.ts
│   │       │   ├── seed.ts
│   │       │   └── migrate.ts
│   │       ├── routes/
│   │       │   ├── auth.ts
│   │       │   ├── users.ts
│   │       │   ├── spaces.ts
│   │       │   ├── channels.ts
│   │       │   ├── messages.ts
│   │       │   ├── uploads.ts
│   │       │   ├── dm.ts
│   │       │   ├── explore.ts
│   │       │   ├── search.ts
│   │       │   ├── instance.ts
│   │       │   ├── livekit.ts
│   │       │   ├── social.ts
│   │       │   ├── settings.ts
│   │       │   └── utils.ts
│   │       ├── ws/
│   │       │   ├── handler.ts
│   │       │   └── events.ts
│   │       └── utils/
│   │           ├── auth.ts
│   │           ├── snowflake.ts
│   │           ├── permissions.ts
│   │           ├── sanitize.ts
│   │           └── fileCleanup.ts
│   ├── web/
│   │   ├── package.json
│   │   ├── tsconfig.json
│   │   ├── vite.config.ts
│   │   ├── tailwind.config.js
│   │   ├── postcss.config.js
│   │   ├── index.html
│   │   └── src/
│   │       ├── main.tsx
│   │       ├── App.tsx
│   │       ├── api/
│   │       │   └── client.ts
│   │       ├── audio/
│   │       │   ├── AudioManager.ts
│   │       │   └── SpeakingDetector.ts
│   │       ├── stores/
│   │       │   ├── authStore.ts
│   │       │   ├── spaceStore.ts
│   │       │   ├── chatStore.ts
│   │       │   ├── voiceStore.ts
│   │       │   ├── socialStore.ts
│   │       │   ├── settingsStore.ts
│   │       │   ├── exploreStore.ts
│   │       │   ├── instanceStore.ts
│   │       │   └── uiStore.ts
│   │       ├── hooks/
│   │       │   ├── useWebSocket.ts
│   │       │   ├── useLiveKit.ts
│   │       │   ├── useAuth.ts
│   │       │   ├── useTrackStats.ts
│   │       │   ├── useAudioTrackPlayer.ts
│   │       │   ├── useFederationToasts.ts
│   │       │   ├── useFloatingPosition.ts
│   │       │   ├── useGridLayout.ts
│   │       │   └── useVoiceParticipantMeta.ts
│   │       ├── utils/
│   │       │   ├── permissions.ts
│   │       │   ├── livekitInternals.ts
│   │       │   ├── screenShare.ts
│   │       │   ├── assetUrls.ts
│   │       │   ├── colorExtractor.ts
│   │       │   ├── cropImage.ts
│   │       │   ├── federationOps.ts
│   │       │   ├── gradients.ts
│   │       │   ├── identity.ts
│   │       │   ├── inviteParser.ts
│   │       │   ├── mutuals.ts
│   │       │   ├── profileSync.ts
│   │       │   └── voice.ts
│   │       ├── components/
│   │       │   ├── layout/
│   │       │   │   ├── AppLayout.tsx
│   │       │   │   ├── SpaceSidebar.tsx
│   │       │   │   ├── ChannelSidebar.tsx
│   │       │   │   ├── MainContent.tsx
│   │       │   │   ├── RightPanel.tsx
│   │       │   │   ├── MemberSidebar.tsx
│   │       │   │   ├── ActivityPanel.tsx
│   │       │   │   ├── MemberListToggleButton.tsx
│   │       │   │   └── MobileNav.tsx
│   │       │   ├── chat/
│   │       │   │   ├── MessageList.tsx
│   │       │   │   ├── Message.tsx
│   │       │   │   ├── MessageInput.tsx
│   │       │   │   ├── TypingIndicator.tsx
│   │       │   │   ├── ImagePreview.tsx
│   │       │   │   ├── MarkdownRenderer.tsx
│   │       │   │   ├── MentionPopover.tsx
│   │       │   │   ├── MentionBadge.tsx
│   │       │   │   ├── Embed.tsx
│   │       │   │   ├── FriendsPage.tsx
│   │       │   │   ├── ExplorePage.tsx
│   │       │   │   └── SearchPopover.tsx
│   │       │   ├── voice/
│   │       │   │   ├── VoiceChannel.tsx
│   │       │   │   ├── VoiceChatPanel.tsx
│   │       │   │   ├── VoiceControlBar.tsx
│   │       │   │   ├── VoiceControls.tsx
│   │       │   │   ├── VoiceGrid.tsx
│   │       │   │   ├── VoiceUser.tsx
│   │       │   │   ├── StreamTile.tsx
│   │       │   │   ├── PictureInPicture.tsx
│   │       │   │   ├── IncomingCallModal.tsx
│   │       │   │   ├── GlobalAudioRenderer.tsx
│   │       │   │   ├── SoundController.tsx
│   │       │   │   ├── ConnectionInfoPopover.tsx
│   │       │   │   ├── ScreenShareSettingsPopover.tsx
│   │       │   │   ├── StreamContextMenu.tsx
│   │       │   │   └── VoiceUserContextMenu.tsx
│   │       │   ├── auth/
│   │       │   │   ├── LoginPage.tsx
│   │       │   │   └── RegisterPage.tsx
│   │       │   ├── modals/
│   │       │   │   ├── CreateSpace.tsx
│   │       │   │   ├── InviteModal.tsx
│   │       │   │   ├── CreateChannel.tsx
│   │       │   │   ├── JoinSpace.tsx
│   │       │   │   ├── UserSettings.tsx
│   │       │   │   ├── SpaceSettings.tsx
│   │       │   │   ├── ChannelSettingsModal.tsx
│   │       │   │   ├── NewDmModal.tsx
│   │       │   │   ├── AddDmMemberModal.tsx
│   │       │   │   ├── ConnectedInstances.tsx
│   │       │   │   ├── DeleteAccountModal.tsx
│   │       │   │   ├── UserProfileModal.tsx
│   │       │   │   ├── settingsPanels/
│   │       │   │   │   ├── AccountPanel.tsx
│   │       │   │   │   ├── ConnectionsPanel.tsx
│   │       │   │   │   ├── VoicePanel.tsx
│   │       │   │   │   └── InstancePanel.tsx
│   │       │   │   ├── spaceSettingsPanels/
│   │       │   │   │   ├── OverviewPanel.tsx
│   │       │   │   │   ├── RolesPanel.tsx
│   │       │   │   │   ├── MembersPanel.tsx
│   │       │   │   │   └── BansPanel.tsx
│   │       │   │   └── instanceSettingsPanels/
│   │       │   │       ├── GeneralPanel.tsx
│   │       │   │       └── StreamingPanel.tsx
│   │       │   └── ui/
│   │       │       ├── Avatar.tsx
│   │       │       ├── Modal.tsx
│   │       │       ├── Tooltip.tsx
│   │       │       ├── ContextMenu.tsx
│   │       │       ├── LoadingSpinner.tsx
│   │       │       ├── UserProfilePopout.tsx
│   │       │       ├── ConfirmDialog.tsx
│   │       │       ├── ImageCropModal.tsx
│   │       │       ├── ToastContainer.tsx
│   │       │       ├── Toggle.tsx
│   │       │       └── Username.tsx
│   │       ├── test/
│   │       │   └── setup.ts
│   │       └── styles/
│   │           └── globals.css
│   └── desktop/
│       ├── package.json
│       ├── tsconfig.json
│       ├── src/
│       │   ├── main.ts
│       │   └── preload.ts
│       └── electron-builder.yml
└── data/
    ├── backspace.db
    └── uploads/

DATABASE SCHEMA

-- Users
CREATE TABLE users (
    id TEXT PRIMARY KEY,
    username TEXT UNIQUE NOT NULL,
    display_name TEXT,
    password_hash TEXT NOT NULL,
    avatar TEXT,
    status TEXT DEFAULT 'offline',
    custom_status TEXT,
    is_admin INTEGER DEFAULT 0,
    home_instance TEXT,              -- federation home instance domain
    home_user_id TEXT,               -- user ID on home instance
    replicated_instances TEXT DEFAULT '[]',  -- JSON array of federated instances
    banner TEXT,                     -- profile banner image
    accent_color TEXT,               -- profile accent color
    avatar_color TEXT,               -- avatar background color
    bio TEXT,                        -- user biography
    is_deleted INTEGER DEFAULT 0,    -- soft-delete flag
    created_at INTEGER NOT NULL
);

-- Spaces (Communities)
CREATE TABLE spaces (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    icon TEXT,
    banner TEXT,                     -- space banner image
    avatar_color TEXT,               -- space icon background color
    owner_id TEXT NOT NULL REFERENCES users(id),
    invite_code TEXT UNIQUE,
    visibility TEXT DEFAULT 'private',  -- 'public' | 'request' | 'private'
    description TEXT,                -- space description
    created_at INTEGER NOT NULL
);

-- Space Members
CREATE TABLE space_members (
    space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    nickname TEXT,
    joined_at INTEGER NOT NULL,
    PRIMARY KEY (space_id, user_id)
);

-- Roles
CREATE TABLE roles (
    id TEXT PRIMARY KEY,
    space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    color TEXT DEFAULT '#b9bbbe',
    position INTEGER DEFAULT 0,
    permissions TEXT,              -- decimal string of bigint permission bits
    created_at INTEGER NOT NULL
);

-- Member Roles (many-to-many)
CREATE TABLE member_roles (
    space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
    PRIMARY KEY (space_id, user_id, role_id)
);

-- Channels
CREATE TABLE channels (
    id TEXT PRIMARY KEY,
    space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    type TEXT NOT NULL,            -- 'text' | 'voice' | 'video'
    topic TEXT,
    position INTEGER DEFAULT 0,
    created_at INTEGER NOT NULL
);

-- Channel Permission Overrides
CREATE TABLE channel_overrides (
    channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
    target_type TEXT NOT NULL,     -- 'role' | 'member'
    target_id TEXT NOT NULL,       -- role ID or user ID
    allow TEXT NOT NULL DEFAULT '0',  -- bigint decimal string
    deny TEXT NOT NULL DEFAULT '0',   -- bigint decimal string
    PRIMARY KEY (channel_id, target_type, target_id)
);

-- Messages
CREATE TABLE messages (
    id TEXT PRIMARY KEY,
    channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
    user_id TEXT NOT NULL REFERENCES users(id),
    reply_to_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
    content TEXT,
    edited_at INTEGER,
    created_at INTEGER NOT NULL
);

-- Attachments
CREATE TABLE attachments (
    id TEXT PRIMARY KEY,
    message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
    dm_message_id TEXT,
    filename TEXT NOT NULL,
    original_name TEXT NOT NULL,
    mimetype TEXT NOT NULL,
    size INTEGER NOT NULL,
    created_at INTEGER NOT NULL
);

-- Reactions
CREATE TABLE reactions (
    id TEXT PRIMARY KEY,
    message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    emoji TEXT NOT NULL,
    created_at INTEGER NOT NULL
);

-- DM Channels
CREATE TABLE dm_channels (
    id TEXT PRIMARY KEY,
    owner_id TEXT,                 -- NULL for 1-on-1, set for group DMs
    created_at INTEGER NOT NULL
);

CREATE TABLE dm_members (
    dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    closed INTEGER DEFAULT 0,     -- soft-close flag
    PRIMARY KEY (dm_channel_id, user_id)
);

-- DM Messages
CREATE TABLE dm_messages (
    id TEXT PRIMARY KEY,
    dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
    user_id TEXT NOT NULL REFERENCES users(id),
    reply_to_id TEXT,
    content TEXT,
    edited_at INTEGER,
    created_at INTEGER NOT NULL
);

-- DM Reactions
CREATE TABLE dm_reactions (
    id TEXT PRIMARY KEY,
    dm_message_id TEXT NOT NULL,
    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    emoji TEXT NOT NULL,
    created_at INTEGER NOT NULL
);

-- Friends
CREATE TABLE friends (
    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    friend_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    created_at INTEGER NOT NULL,
    PRIMARY KEY (user_id, friend_id)
);

-- Friend Requests
CREATE TABLE friend_requests (
    id TEXT PRIMARY KEY,
    from_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    to_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    status TEXT DEFAULT 'pending', -- 'pending' | 'accepted' | 'declined'
    created_at INTEGER NOT NULL
);

-- Read States
CREATE TABLE read_states (
    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    channel_id TEXT NOT NULL,
    last_read_message_id TEXT NOT NULL,
    updated_at INTEGER NOT NULL,
    PRIMARY KEY (user_id, channel_id)
);

-- Space Folders
CREATE TABLE space_folders (
    id TEXT PRIMARY KEY,
    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    name TEXT,
    color TEXT,
    position INTEGER DEFAULT 0,
    created_at INTEGER NOT NULL
);

CREATE TABLE space_folder_members (
    folder_id TEXT NOT NULL REFERENCES space_folders(id) ON DELETE CASCADE,
    space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
    PRIMARY KEY (folder_id, space_id)
);

-- Instance Settings (singleton row, id=1)
CREATE TABLE instance_settings (
    id INTEGER PRIMARY KEY DEFAULT 1,
    instance_name TEXT DEFAULT 'Backspace',  -- federation instance name
    worker_id INTEGER,                       -- Snowflake worker ID for federation
    discovery_enabled INTEGER NOT NULL DEFAULT 1,  -- space discovery toggle
    max_bitrate_kbps INTEGER NOT NULL DEFAULT 20000,
    min_bitrate_kbps INTEGER NOT NULL DEFAULT 500,
    bitrate_step_kbps INTEGER NOT NULL DEFAULT 500,
    allowed_resolutions TEXT NOT NULL DEFAULT '540,720,1080',
    allowed_framerates TEXT NOT NULL DEFAULT '30,45,60',
    max_resolution INTEGER NOT NULL DEFAULT 1080,
    max_framerate INTEGER NOT NULL DEFAULT 60,
    registration_open INTEGER,               -- explicit registration override (null = use env)
    updated_at INTEGER NOT NULL
);

-- Bans
CREATE TABLE bans (
    space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    reason TEXT,
    banned_by TEXT REFERENCES users(id),
    created_at INTEGER NOT NULL,
    PRIMARY KEY (space_id, user_id)
);

-- Join Requests
CREATE TABLE join_requests (
    id TEXT PRIMARY KEY,
    space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    message TEXT,
    status TEXT NOT NULL DEFAULT 'pending',  -- 'pending' | 'accepted' | 'declined'
    decided_by TEXT REFERENCES users(id),
    created_at INTEGER NOT NULL,
    decided_at INTEGER
);

-- Voice Restrictions
CREATE TABLE voice_restrictions (
    space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    restriction_type TEXT NOT NULL,   -- 'mute' | 'deafen'
    moderator_id TEXT REFERENCES users(id),
    created_at INTEGER NOT NULL,
    PRIMARY KEY (space_id, user_id, restriction_type)
);

REST API ENDPOINTS

# Auth
POST   /api/auth/register          { username, password, displayName?, avatarColor? }  → { token, user }
POST   /api/auth/login             { username, password }                   → { token, user }
GET    /api/auth/check-username    ?username=                               → { available, reason? }

# Users
GET    /api/users/@me              (auth)                                   → { user }
PATCH  /api/users/@me              (auth) { displayName?, avatar?, banner?, accentColor?,
                                     avatarColor?, bio?, customStatus?, status?,
                                     replicatedInstances?, homeUserId? }    → { user }
GET    /api/users/:id              (auth)                                   → { user }
POST   /api/users/@me/verify-password (auth) { password }                   → { valid }
POST   /api/users/@me/change-password (auth) { currentPassword?, newPassword } → { token }
DELETE /api/users/@me              (auth) { password, username }             → { success }
GET    /api/users/:id/mutuals      (auth) ?homeUserId=                      → { mutualFriends[], mutualSpaces[] }

# Spaces
POST   /api/spaces                (auth) { name, icon?, banner?, avatarColor?, visibility?, description? } → { space }
GET    /api/spaces                (auth)                                   → { spaces[] }
GET    /api/spaces/:id            (auth)                                   → { space, channels[], members[], roles[] }
PATCH  /api/spaces/:id            (auth, MANAGE_SPACE) { name?, icon?, banner?,
                                     avatarColor?, visibility?, description? } → { space }
DELETE /api/spaces/:id            (auth, owner)                            → { success }
POST   /api/spaces/:id/invite     (auth, CREATE_INVITE)                   → { inviteCode }
POST   /api/spaces/:id/join       (auth) { inviteCode }                   → { space }
POST   /api/spaces/join           (auth) { inviteCode }                   → { space }
PATCH  /api/spaces/:id/transfer-ownership (auth, owner) { newOwnerId }    → { space }

# Members
GET    /api/spaces/:id/members    (auth, member)                          → { members[] }
PATCH  /api/spaces/:id/members/:uid (auth, MANAGE_ROLES) { roleIds }     → { member }
DELETE /api/spaces/:id/members/:uid (auth, KICK_MEMBERS|self)             → { success }

# Bans
GET    /api/spaces/:id/bans       (auth, BAN_MEMBERS)                     → { bans[] }
POST   /api/spaces/:id/bans       (auth, BAN_MEMBERS) { userId, reason? } → { success }
DELETE /api/spaces/:id/bans/:uid  (auth, BAN_MEMBERS)                     → { success }

# Roles
POST   /api/spaces/:id/roles      (auth, MANAGE_ROLES) { name, color?, permissions? } → { role }
PATCH  /api/spaces/:id/roles/:rid (auth, MANAGE_ROLES) { name?, color?, permissions?, position? } → { role }
DELETE /api/spaces/:id/roles/:rid (auth, MANAGE_ROLES)                    → { success }
POST   /api/spaces/:id/members/:uid/roles (auth, MANAGE_ROLES) { roleId } → { success }
DELETE /api/spaces/:id/members/:uid/roles/:rid (auth, MANAGE_ROLES)       → { success }

# Channels
GET    /api/spaces/:id/channels   (auth, member, VIEW_CHANNEL)            → { channels[] }
POST   /api/spaces/:id/channels   (auth, MANAGE_CHANNELS) { name, type, topic? } → { channel }
PATCH  /api/channels/:id           (auth, MANAGE_CHANNELS) { name?, topic?, position? } → { channel }
DELETE /api/channels/:id           (auth, MANAGE_CHANNELS)                 → { success }

# Channel Permission Overrides
GET    /api/channels/:id/overrides (auth, MANAGE_CHANNELS)                 → { overrides[] }
PUT    /api/channels/:id/overrides (auth, MANAGE_CHANNELS) { targetType, targetId, allow, deny } → { override }
DELETE /api/channels/:id/overrides/:targetType/:targetId (auth, MANAGE_CHANNELS) → { success }

# Messages
GET    /api/channels/:id/messages  (auth, member) ?before=&limit=50       → { messages[] }
POST   /api/channels/:id/messages  (auth, SEND_MESSAGES) { content, attachments?, replyToId? } → { message }
PATCH  /api/messages/:id           (auth, author) { content }              → { message }
DELETE /api/messages/:id           (auth, author|MANAGE_MESSAGES)           → { success }

# Search
GET    /api/channels/:id/search    (auth, member) ?q=&from=&has=&before=&after= → { results[], totalCount }
GET    /api/channels/:id/messages/around (auth, member) ?messageId=        → { messages[] }
GET    /api/dm/:id/search          (auth, member) ?q=&from=&has=&before=&after= → { results[], totalCount }
GET    /api/dm/:id/messages/around (auth, member) ?messageId=              → { messages[] }

# Explore / Discovery
GET    /api/spaces/explore         (auth) ?q=&limit=&offset=              → { spaces[], total, discoveryEnabled }
POST   /api/spaces/:id/public-join (auth)                                  → { space }
POST   /api/spaces/:id/request-join (auth) { message? }                    → { request }
GET    /api/spaces/:id/join-requests (auth, MANAGE_SPACE)                  → { requests[] }
PATCH  /api/spaces/:id/join-requests/:rid (auth, MANAGE_SPACE) { action }  → { request }
GET    /api/users/@me/join-requests (auth) ?status=                        → { requests[] }

# File Uploads
POST   /api/uploads                (auth) multipart file                   → { attachment }
GET    /api/uploads/:filename      (public)                                → file stream

# Direct Messages
GET    /api/dm                     (auth)                                   → { dmChannels[] }
POST   /api/dm                     (auth) { userId }                       → { dmChannel }
DELETE /api/dm/:id                 (auth, member)                           → { success } (soft-close)
GET    /api/dm/:id/messages        (auth, member) ?before=&limit=50       → { messages[] }
POST   /api/dm/:id/messages        (auth, member) { content }             → { message }
PATCH  /api/dm/messages/:id        (auth, author) { content }              → { message }
DELETE /api/dm/messages/:id        (auth, author)                           → { success }
POST   /api/dm/:id/members         (auth, owner) { userId }               → { dmChannel } (group DM, max 10)
DELETE /api/dm/:id/members         (auth, member)                           → { success } (leave group DM)

# Social / Friends
GET    /api/social/friends         (auth)                                   → { friends[] }
GET    /api/social/requests        (auth)                                   → { requests[] }
POST   /api/social/requests        (auth) { username }                     → { request }
PATCH  /api/social/requests/:id    (auth) { action: 'accept'|'decline' }   → { request }
DELETE /api/social/requests/:id    (auth)                                   → { success } (cancel)
DELETE /api/social/friends/:id     (auth)                                   → { success }
GET    /api/social/search          (auth) ?q=                              → { users[] }

# Voice/Video
POST   /api/livekit/token          (auth) { channelId }                    → { token }

# Instance Info (public)
GET    /api/instance/info          (public)                                → { name, version, registrationOpen }

# Instance Settings (admin)
GET    /api/settings/streaming     (auth)                                   → { streamingLimits }
PATCH  /api/settings/streaming     (auth, admin) { maxBitrateKbps?, ... }  → { streamingLimits }
GET    /api/settings/instance      (auth, admin)                            → { instanceName, registrationOpen, discoveryEnabled }
PATCH  /api/settings/instance      (auth, admin) { instanceName?, registrationOpen?, discoveryEnabled? } → { settings }

# Utilities
GET    /api/utils/metadata         (auth) ?url=                            → { title?, description?, image?, siteName? }
GET    /api/health                 (public)                                → { status: 'ok', timestamp }

WEBSOCKET PROTOCOL

All WebSocket messages are JSON over /ws. Client authenticates by sending { type: 'auth', token: 'jwt...' } as the first message. Server responds with { type: 'ready', ... } containing all initial state.

Client → Server

{ type: 'auth', token: string }
{ type: 'ping' }

# Server Messages
{ type: 'message_create', channelId, content, replyToId?, attachmentIds? }
{ type: 'message_edit', messageId, content }
{ type: 'message_delete', messageId }
{ type: 'typing_start', channelId }
{ type: 'reaction_add', messageId, emoji }
{ type: 'reaction_remove', messageId, emoji }
{ type: 'channel_ack', channelId, messageId }

# DM Messages
{ type: 'dm_message_create', dmChannelId, content, replyToId?, attachmentIds? }
{ type: 'dm_message_edit', messageId, content }
{ type: 'dm_message_delete', messageId }
{ type: 'dm_typing_start', dmChannelId }

# Presence
{ type: 'presence_update', status: 'online' | 'idle' | 'dnd' }

# Voice (Space Channels)
{ type: 'voice_join', channelId }
{ type: 'voice_leave' }
{ type: 'voice_status', isMuted?, isDeafened?, isCameraOn?, isScreenSharing? }
{ type: 'voice_disconnect', userId }

# Voice Moderation
{ type: 'voice_server_mute', userId, muted }
{ type: 'voice_server_deafen', userId, deafened }
{ type: 'voice_move', userId, targetChannelId }

# DM Calls
{ type: 'dm_call_start', dmChannelId }
{ type: 'dm_call_accept', dmChannelId }
{ type: 'dm_call_reject', dmChannelId }
{ type: 'dm_call_end', dmChannelId }

Server → Client

{ type: 'ready', user, spaces, dmChannels, folders, voiceStates, readStates, activeCalls }
{ type: 'pong' }

# Server Messages
{ type: 'message_created', message: MessageWithUser }
{ type: 'message_updated', message: MessageWithUser }
{ type: 'message_deleted', messageId, channelId }
{ type: 'typing', channelId, userId, username }
{ type: 'reaction_added', messageId, channelId, reaction }
{ type: 'reaction_removed', messageId, channelId, userId, emoji }

# DM Messages
{ type: 'dm_message_created', message: DmMessageWithUser }
{ type: 'dm_message_updated', message: DmMessageWithUser }
{ type: 'dm_message_deleted', messageId, dmChannelId }
{ type: 'dm_channel_created', dmChannel }
{ type: 'dm_channel_closed', dmChannelId }
{ type: 'dm_member_added', dmChannelId, user }
{ type: 'dm_member_removed', dmChannelId, userId }

# Channel/Space Updates
{ type: 'channel_created', channel }
{ type: 'channel_updated', channel }
{ type: 'channel_deleted', channelId, spaceId }
{ type: 'space_updated', space }
{ type: 'channel_ack', channelId, messageId, userId }

# Members & Presence
{ type: 'member_joined', spaceId, member: MemberWithUser }
{ type: 'member_left', spaceId, userId }
{ type: 'member_banned', spaceId, reason }
{ type: 'presence_update', userId, status }
{ type: 'user_updated', user }

# Voice
{ type: 'voice_state_update', channelId, userId, action: 'join' | 'leave' }
{ type: 'voice_status_update', userId, isMuted, isDeafened, isCameraOn, isScreenSharing }
{ type: 'voice_disconnected', userId, channelId }
{ type: 'voice_server_muted', userId, spaceId, muted }
{ type: 'voice_server_deafened', userId, spaceId, deafened }
{ type: 'voice_permission_muted', userId, spaceId, muted }
{ type: 'voice_moved', userId, oldChannelId, newChannelId }

# DM Calls
{ type: 'dm_call_incoming', dmChannelId, callerId, callerName }
{ type: 'dm_call_accepted', dmChannelId }
{ type: 'dm_call_rejected', dmChannelId }
{ type: 'dm_call_ended', dmChannelId }

# Social
{ type: 'friend_request_received', request }
{ type: 'friend_request_accepted', friend }
{ type: 'friend_removed', userId }

# Discovery
{ type: 'join_request_received', request }
{ type: 'join_request_accepted', request, space? }
{ type: 'join_request_declined', request }

PERMISSION SYSTEM

Bitwise permission engine defined in packages/shared/src/permissions.ts. Stored as decimal strings in the database (bigint is not JSON-safe).

Bit Permission Description
0 ADMINISTRATOR Full access, bypasses all checks
1 VIEW_CHANNEL See channel in list and read messages
2 MANAGE_CHANNELS Create, edit, delete channels
3 MANAGE_ROLES Create, edit, delete roles
4 MANAGE_SPACE Edit space name, icon
5 CREATE_INVITE Generate invite codes
6 KICK_MEMBERS Remove members from space
7 BAN_MEMBERS Ban members from space
10 SEND_MESSAGES Post messages in text channels
11 MANAGE_MESSAGES Delete other users' messages
12 ATTACH_FILES Upload files to messages
13 READ_MESSAGE_HISTORY View message history
14 ADD_REACTIONS Add emoji reactions
20 CONNECT Join voice channels
21 SPEAK Transmit audio in voice
22 MUTE_MEMBERS Server-mute other members
23 DEAFEN_MEMBERS Server-deafen other members
24 MOVE_MEMBERS Move members between voice channels
25 STREAM Share screen in voice channels
26 DISCONNECT_MEMBERS Disconnect members from voice channels

Resolution order: Owner → @everyone role → Assigned roles (OR'd) → ADMINISTRATOR shortcut → Channel overrides (@everyone → role overrides → member override).

ENVIRONMENT VARIABLES

# Domain (required for Caddy reverse proxy)
DOMAIN=example.com                 # Your server's public domain name

# Server
PORT=3000                          # HTTP/WS listen port
HOST=0.0.0.0                      # Bind address

# Auth
JWT_SECRET=<random-64-char-hex>   # Required — generate with: openssl rand -hex 32

# LiveKit (optional — leave empty to disable voice features)
LIVEKIT_URL=                       # WebSocket URL to LiveKit server
LIVEKIT_API_KEY=                   # LiveKit API key
LIVEKIT_API_SECRET=                # LiveKit API secret

# Max file upload size in bytes (default: 100MB)
MAX_UPLOAD_SIZE=104857600

# Registration
REGISTRATION_OPEN=true             # Set to false to disable new user signup

# Docker Compose profile — uncomment to enable LiveKit voice service
# COMPOSE_PROFILES=voice

DEPLOYMENT

Live instances:

  • https://nova.ddns.net — Raspberry Pi (primary)
  • https://orbit.ddns.net — VM (secondary)

Infrastructure:

  • Caddy reverse proxy with automatic HTTPS (replaces OpenResty)
  • Caddyfile — Reverse proxy config, routes /livekit/* to LiveKit, everything else to Backspace
  • Docker Compose with three services: backspace, caddy, livekit (optional via COMPOSE_PROFILES=voice)
  • Data stored in ./data/ bind mount (DB + uploads)

Setup:

  • ./install.sh — Interactive first-time production installer (generates .env, livekit.yaml, starts services)
  • Manual: copy .env.example to .env, configure, run docker compose up -d --build

Deploy commands:

  • ./deploy.sh — Syncs code via rsync to both instances, triggers rebuild
  • ./deploy.sh pi — Deploy to Pi only (nova.ddns.net)
  • ./deploy.sh vm — Deploy to VM only (orbit.ddns.net)
  • ./deploy.sh all — Deploy to both
  • ./deploy.sh --local — Force Pi via LAN IP (192.168.1.10)
  • ./deploy.sh --remote — Force Pi via public DNS

Development:

pnpm install
pnpm dev           # Starts server (:3005) + Vite (:5173) with proxy

FEATURE STATUS

All core features are implemented and live:

  • Auth: Registration (first user = admin), login, JWT sessions, username availability check
  • Spaces: Create, join by invite, space settings, delete, ownership transfer
  • Channels: Text, voice, video types with position ordering
  • Messaging: Send, edit, delete, replies, attachments, reactions, typing indicators, read states
  • Permissions: Full RBAC with roles, per-channel overrides, computed permissions
  • Voice/Video: LiveKit integration, mute/deafen, camera, screen share with VP9
  • Screen Share: Configurable resolution/FPS/bitrate, gaming vs text mode, instance-level limits
  • DMs: 1-on-1 and group DMs (up to 10), soft-close, message edit/delete
  • DM Calls: Ringing state machine (ring → active → ended), auto-reject timeout
  • Friends: Send/accept/decline requests, friend list, user search
  • Audio Processing: RNNoise noise suppression, echo cancellation, auto gain control, per-user volume
  • File Uploads: Multipart upload, immutable cache headers, directory traversal protection
  • URL Previews: Server-side metadata extraction with Cheerio
  • Desktop: Electron wrapper with tray, notifications, badge count
  • Docker: Multi-stage build, health checks, Caddy auto-HTTPS
  • Federation: Multi-instance user replication, home instance tracking, connected instances UI, profile sync
  • Discovery: Public/request/private space visibility, explore page, join requests with approval workflow
  • Moderation: Space bans with reason/moderator audit trail, voice restrictions (server mute/deafen), member move/disconnect
  • Search: Full-text message search with filters (from, has, before, after), jump-to-message context
  • User Profiles: Banner, bio, accent color, avatar color, mutual friends/spaces, soft-delete accounts
  • Space Profiles: Banner, avatar color, description, visibility settings
  • Account Management: Password change, account deletion with owned-space safeguards, username availability check
  • Instance Settings: Instance name, registration toggle, discovery toggle, persistent admin config