42 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-tintedrgba(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-transparencymedia 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.
Input Tiers
Every text input, textarea, and select uses one of these CSS classes (defined in globals.css):
| Tier | Class | When to Use | Focus |
|---|---|---|---|
| Standard | .input-standard |
Form fields in modals, settings, auth pages | ring-2 primary |
| Search | .input-search |
Search bars, filter inputs, compact lookups | ring-1 primary |
| Embedded | .input-embedded |
Inside glass containers (chat input, search popover, DM search) | none |
| Danger | .input-danger |
Destructive confirmations (delete account) | ring-2 rose |
Rule: No resting border — the sunken surface-input background provides differentiation. Override padding/size with utility classes when needed (e.g. input-standard w-full py-2.5 for taller auth inputs).
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 codeshortcuts. Every function you write must be FULLY implemented with real logic. - NEVER use
// ...rest of codeor// similar to aboveshortcuts. 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
anytypes. - If something fails, FIX IT before moving on. Never leave broken code behind.
- Test changes with
pnpm devbefore 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/
│ │ │ ├── admin.ts
│ │ │ ├── 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
│ │ └── storageJanitor.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
│ │ │ │ └── StoragePanel.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
password_changed_at INTEGER, -- token revocation: tokens issued before this are rejected
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'
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,
uploader_id TEXT, -- user who uploaded (null for legacy uploads)
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
mimetype TEXT NOT NULL,
size INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
-- Embeds (resolved URL previews)
CREATE TABLE embeds (
id TEXT PRIMARY KEY,
message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
dm_message_id TEXT REFERENCES dm_messages(id) ON DELETE CASCADE,
url TEXT NOT NULL,
embed_type TEXT NOT NULL CHECK (embed_type IN ('generic', 'video', 'image', 'audio', 'rich')),
provider TEXT, -- 'youtube' | 'vimeo' | 'spotify' | null
title TEXT,
description TEXT,
image TEXT, -- thumbnail/og:image URL
embed_url TEXT, -- iframe-safe embed URL
width INTEGER,
height INTEGER,
color TEXT,
created_at INTEGER NOT NULL,
CHECK (
(message_id IS NOT NULL AND dm_message_id IS NULL) OR
(message_id IS NULL AND dm_message_id IS 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,
position INTEGER DEFAULT 0, -- ordering within folder
PRIMARY KEY (folder_id, space_id)
);
-- User Space Layout (per-user sidebar ordering)
CREATE TABLE user_space_layout (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
layout TEXT NOT NULL DEFAULT '[]', -- JSON array of {t:'s',id} | {t:'f',id} items
updated_at INTEGER NOT NULL
);
-- 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[] }
PUT /api/users/@me/space-layout (auth) { items, folders } → { items, folders }
# 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 }
# Admin
GET /api/admin/storage/stats (auth, admin) → StorageStats
GET /api/admin/storage/orphans (auth, admin) → { orphans: OrphanedFile[] }
POST /api/admin/storage/cleanup (auth, admin) { dryRun?: boolean } → CleanupResult
GET /api/admin/users (auth, admin) ?q=&page=&pageSize=&showDeleted= → AdminUserListResponse
PATCH /api/admin/users/:id/role (auth, admin) { isAdmin: boolean } → AdminUser
POST /api/admin/users/:id/reset-password (auth, admin) → { temporaryPassword }
DELETE /api/admin/users/:id (auth, admin) → { success }
# 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_space_mute', userId, muted }
{ type: 'voice_space_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, spaceLayout, 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_space_muted', userId, spaceId, muted }
{ type: 'voice_space_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 }
# Embeds
{ type: 'embeds_resolved', messageId, channelId, embeds: Embed[] }
{ type: 'dm_embeds_resolved', messageId, dmChannelId, embeds: Embed[] }
# Space Layout
{ type: 'space_layout_updated', layout: SpaceLayoutItem[], folders: SpaceFolder[] }
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 | Space-mute other members |
| 23 | DEAFEN_MEMBERS | Space-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 viaCOMPOSE_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.exampleto.env, configure, rundocker 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 and voice types with position ordering (voice channels support video/screen share)
- 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
- Inline Media: Video/audio attachment playback, YouTube/Vimeo click-to-load embeds, Spotify rich embeds, external image URL display, database-backed embed resolution
- URL Previews: Server-side metadata extraction with Cheerio (now shared utility for embed resolver)
- 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