Remove the unused USE_VOICE_ACTIVITY permission bit (was bit 25) and shift STREAM to bit 25, DISCONNECT_MEMBERS to bit 26. Add a database migration to remap stored permission values. Gate camera and screen share buttons in VoiceControlBar behind canSpeak/canStream, matching VoiceControls behavior.
29 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.
MISSION
Maintain and extend Backspace as a complete, production-quality application. The core application is fully built and deployed. 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 |
| 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
├── deploy.sh
├── Makefile
├── .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
│ │ │ ├── livekit.ts
│ │ │ ├── social.ts
│ │ │ ├── settings.ts
│ │ │ └── utils.ts
│ │ ├── ws/
│ │ │ ├── handler.ts
│ │ │ └── events.ts
│ │ └── utils/
│ │ ├── auth.ts
│ │ ├── snowflake.ts
│ │ └── permissions.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
│ │ │ └── uiStore.ts
│ │ ├── hooks/
│ │ │ ├── useWebSocket.ts
│ │ │ ├── useLiveKit.ts
│ │ │ ├── useAuth.ts
│ │ │ ├── useTrackStats.ts
│ │ │ └── useAudioTrackPlayer.ts
│ │ ├── utils/
│ │ │ ├── permissions.ts
│ │ │ ├── livekitInternals.ts
│ │ │ └── screenShare.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
│ │ │ ├── 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
│ │ │ ├── auth/
│ │ │ │ ├── LoginPage.tsx
│ │ │ │ └── RegisterPage.tsx
│ │ │ ├── modals/
│ │ │ │ ├── CreateSpace.tsx
│ │ │ │ ├── InviteModal.tsx
│ │ │ │ ├── CreateChannel.tsx
│ │ │ │ ├── JoinSpace.tsx
│ │ │ │ ├── UserSettings.tsx
│ │ │ │ ├── SpaceSettings.tsx
│ │ │ │ ├── ChannelSettingsModal.tsx
│ │ │ │ ├── NewDmModal.tsx
│ │ │ │ └── AddDmMemberModal.tsx
│ │ │ └── ui/
│ │ │ ├── Avatar.tsx
│ │ │ ├── Modal.tsx
│ │ │ ├── Tooltip.tsx
│ │ │ ├── ContextMenu.tsx
│ │ │ ├── LoadingSpinner.tsx
│ │ │ └── UserProfilePopout.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,
created_at INTEGER NOT NULL
);
-- Spaces (Communities)
CREATE TABLE spaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT,
owner_id TEXT NOT NULL REFERENCES users(id),
invite_code TEXT UNIQUE,
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,
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,
updated_at INTEGER NOT NULL
);
REST API ENDPOINTS
# Auth
POST /api/auth/register { username, password, displayName? } → { token, user }
POST /api/auth/login { username, password } → { token, user }
# Users
GET /api/users/@me (auth) → { user }
PATCH /api/users/@me (auth) { displayName?, avatar?, customStatus?, status? } → { user }
GET /api/users/:id (auth) → { user }
# Spaces
POST /api/spaces (auth) { name, icon? } → { space }
GET /api/spaces (auth) → { spaces[] }
GET /api/spaces/:id (auth) → { space, channels[], members[], roles[] }
PATCH /api/spaces/:id (auth, MANAGE_SPACE) { name?, icon? } → { 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 }
# Members
GET /api/spaces/:id/members (auth, member) → { members[] }
PATCH /api/spaces/:id/members/:uid (auth, MANAGE_ROLES) { roles } → { member }
DELETE /api/spaces/:id/members/:uid (auth, KICK_MEMBERS|self) → { 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 }
# 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 Settings (admin)
GET /api/settings/streaming (auth) → { streamingLimits }
PATCH /api/settings/streaming (auth, admin) { maxBitrateKbps?, ... } → { streamingLimits }
# 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 }
# 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: 'presence_update', userId, status }
# Voice
{ type: 'voice_state_update', channelId, userId, action: 'join' | 'leave' }
{ type: 'voice_status_update', userId, isMuted, isDeafened, isCameraOn, isScreenSharing }
{ type: 'voice_disconnected', userId, channelId }
# 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 }
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
# 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
# Storage
UPLOAD_DIR=./data/uploads # File upload directory
DB_PATH=./data/backspace.db # SQLite database path
MAX_UPLOAD_SIZE=104857600 # Max upload size in bytes (100MB)
# Registration
REGISTRATION_OPEN=true # Set to false to disable new user signup
DEPLOYMENT
Live instance: https://nova.ddns.net — Raspberry Pi behind OpenResty reverse proxy with HSTS.
Infrastructure:
- Docker container (
backspace) on port 3000 - External Docker volume:
backspace-data(mounted at/app/data) - External Docker network:
backspace-net(shared with reverse proxy) - LiveKit server at
wss://nova.ddns.net/livekit
Deploy commands:
./deploy.sh— Syncs code via rsync to Pi, triggersdocker compose up -d --build./deploy.sh --local— Use local IP (192.168.1.10)./deploy.sh --remote— Use remote DNS (nova.ddns.net)make deploy— Same as deploy.shmake logs— Watch Docker logs on Pimake shell— SSH into running containermake status— Check container status
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
- Spaces: Create, join by invite, space settings, delete
- 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
- Admin Panel: Instance-level streaming limits (bitrate, resolution, framerate bounds)
- URL Previews: Server-side metadata extraction with Cheerio
- Desktop: Electron wrapper with tray, notifications, badge count
- Docker: Multi-stage build, health checks, persistent volume