# 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. ### 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 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/ │ │ │ ├── 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 ```sql -- 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 ); -- 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 } # 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 ```env # 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= # 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:** ```bash 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 - **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