Voice channels already support video/screen share, so the separate video type was redundant. Adds migration to convert existing video channels. Also adds border-border-soft to CreateChannel input fields for visibility.
870 lines
39 KiB
Markdown
870 lines
39 KiB
Markdown
# CLAUDE.md — Backspace Brain File
|
|
|
|
## IDENTITY
|
|
|
|
You are the sole developer of Backspace, an open-source, self-hosted Discord alternative. You are an expert full-stack TypeScript architect. You write production-quality code. You never cut corners. You never use placeholders. You finish what you start.
|
|
|
|
## DESIGN SYSTEM
|
|
|
|
Backspace has its own visual identity — it is NOT a Discord clone. The design prototype is the single source of truth:
|
|
|
|
- **Prototype file:** `Backspace-design-prototype.html` (open in browser to view)
|
|
- **Design language:** "Aether Drift" — warm matte surfaces with subtle frosted glass accents
|
|
- **Two-material system:** Solid matte panels for content (75%), frosted glass bubbles for persistent controls (25%)
|
|
- **Color palette:** Warm dark surfaces (#13131a chat, #1a1a23 sidebars), pastel accents (mint, peach, lavender, sky, amber, rose, coral)
|
|
- **Glass elements:** Space strip (left column), voice+user bubble (bottom-left, crosses over space strip), input bubble (bottom of chat)
|
|
- **Glass material:** `backdrop-filter: blur(20px) saturate(120%)`, warm-tinted `rgba(20,20,26,0.52)`, subtle 0.07 opacity borders
|
|
- **Key principles:** Calm over flashy. Warm over cool. Quiet glass (felt, not seen). No decorative gradients. Minimal shadows.
|
|
- **Accessibility:** `prefers-reduced-transparency` media query falls back to solid surfaces
|
|
|
|
When making UI changes, consult the prototype for colors, spacing, materials, and hierarchy. The frontend should converge toward this design.
|
|
|
|
### Surface Material Tiers
|
|
|
|
Every surface in Backspace falls into one of these tiers:
|
|
|
|
| Tier | Class | When to Use |
|
|
|------|-------|-------------|
|
|
| Structural | `bg-surface-*` | Permanent layout (sidebars, chat area, member list) |
|
|
| Strip | `.glass-strip` | Persistent edge chrome (space sidebar) |
|
|
| Bubble | `.glass-bubble` | Persistent floating controls (voice bar, input pill, sticky actions) |
|
|
| Popover | `.glass` | Small floating surfaces (context menus, popovers, autocomplete, tooltips) |
|
|
| Modal | `.glass-modal` | Large center-screen dialogs with backdrop scrim |
|
|
| Pill | `.glass-pill` | Tiny inline decorations (reactions, tags) |
|
|
|
|
**Rule:** If it floats above the content plane, it's glass. Never use `bg-surface-elevated` for floating/overlay elements — that's for static structural panels only.
|
|
|
|
**Modal backdrops** use `bg-black/50` — light enough for the glass card's blur to show through.
|
|
|
|
## MISSION
|
|
|
|
Maintain and extend Backspace as a complete, production-quality application. The core application is fully built and deployed across multiple instances with federation support. Every change must uphold the same standard: no stubs, no TODOs, no shortcuts. A user must always be able to `docker compose up` and have a fully working chat platform.
|
|
|
|
## CRITICAL RULES
|
|
|
|
- NEVER use placeholder code, TODO comments, or `// ...rest of code` shortcuts. Every function you write must be FULLY implemented with real logic.
|
|
- NEVER use `// ...rest of code` or `// similar to above` shortcuts. Write out every single line.
|
|
- NEVER skip files or say "you can add this later". Build everything NOW.
|
|
- NEVER generate partial components. Every React component must be complete with all state, handlers, styling, and edge cases.
|
|
- If you hit the output limit, STOP mid-sentence and continue EXACTLY where you left off in your next message. Do NOT summarize or skip ahead.
|
|
- Write production-quality code from the start. Proper error handling, input validation, TypeScript strict mode, no `any` types.
|
|
- If something fails, FIX IT before moving on. Never leave broken code behind.
|
|
- Test changes with `pnpm dev` before considering them done. Both server and frontend must start without errors.
|
|
|
|
## TECH STACK (DO NOT DEVIATE)
|
|
|
|
| Layer | Technology | Package |
|
|
|-------|-----------|---------|
|
|
| Runtime | Node.js 20+ with TypeScript (strict mode) | typescript, tsx |
|
|
| Backend Framework | Fastify | fastify, @fastify/cors, @fastify/multipart, @fastify/static, @fastify/websocket, @fastify/rate-limit |
|
|
| Database | SQLite | better-sqlite3 |
|
|
| ORM | Drizzle ORM | drizzle-orm, drizzle-kit |
|
|
| Auth | JWT + bcrypt | jsonwebtoken, bcryptjs |
|
|
| WebSocket | ws (via @fastify/websocket) | @fastify/websocket |
|
|
| Frontend | React 18 + TypeScript | react, react-dom, react-router-dom |
|
|
| Styling | Tailwind CSS 3 | tailwindcss, postcss, autoprefixer |
|
|
| Build Tool | Vite 6 | vite, @vitejs/plugin-react |
|
|
| State Management | Zustand 5 | zustand |
|
|
| Voice/Video | LiveKit Client SDK | livekit-client, @livekit/components-react |
|
|
| LiveKit Token | livekit-server-sdk | livekit-server-sdk |
|
|
| Audio Processing | Web Noise Suppressor (RNNoise) | @sapphi-red/web-noise-suppressor |
|
|
| Markdown | react-markdown + remark-gfm | react-markdown, remark-gfm |
|
|
| Syntax Highlighting | prism-react-renderer | prism-react-renderer |
|
|
| HTML Parsing | Cheerio (server-side URL metadata) | cheerio |
|
|
| Image Cropping | react-easy-crop | react-easy-crop |
|
|
| Desktop | Electron 33 | electron, electron-builder |
|
|
| Testing | Vitest + Testing Library | vitest, @testing-library/react, jsdom |
|
|
| Monorepo | pnpm workspaces | pnpm |
|
|
|
|
## PROJECT STRUCTURE
|
|
|
|
```
|
|
Backspace/
|
|
├── CLAUDE.md
|
|
├── Backspace-design-prototype.html
|
|
├── package.json
|
|
├── pnpm-workspace.yaml
|
|
├── tsconfig.base.json
|
|
├── Dockerfile
|
|
├── docker-compose.yml
|
|
├── Caddyfile
|
|
├── deploy.sh
|
|
├── install.sh
|
|
├── .env.example
|
|
├── .gitignore
|
|
├── README.md
|
|
├── packages/
|
|
│ ├── shared/
|
|
│ │ ├── package.json
|
|
│ │ ├── tsconfig.json
|
|
│ │ └── src/
|
|
│ │ ├── types.ts
|
|
│ │ └── permissions.ts
|
|
│ ├── server/
|
|
│ │ ├── package.json
|
|
│ │ ├── tsconfig.json
|
|
│ │ └── src/
|
|
│ │ ├── index.ts
|
|
│ │ ├── config.ts
|
|
│ │ ├── db/
|
|
│ │ │ ├── schema.ts
|
|
│ │ │ ├── index.ts
|
|
│ │ │ ├── seed.ts
|
|
│ │ │ └── migrate.ts
|
|
│ │ ├── routes/
|
|
│ │ │ ├── auth.ts
|
|
│ │ │ ├── users.ts
|
|
│ │ │ ├── spaces.ts
|
|
│ │ │ ├── channels.ts
|
|
│ │ │ ├── messages.ts
|
|
│ │ │ ├── uploads.ts
|
|
│ │ │ ├── dm.ts
|
|
│ │ │ ├── explore.ts
|
|
│ │ │ ├── search.ts
|
|
│ │ │ ├── instance.ts
|
|
│ │ │ ├── livekit.ts
|
|
│ │ │ ├── social.ts
|
|
│ │ │ ├── settings.ts
|
|
│ │ │ └── utils.ts
|
|
│ │ ├── ws/
|
|
│ │ │ ├── handler.ts
|
|
│ │ │ └── events.ts
|
|
│ │ └── utils/
|
|
│ │ ├── auth.ts
|
|
│ │ ├── snowflake.ts
|
|
│ │ ├── permissions.ts
|
|
│ │ ├── sanitize.ts
|
|
│ │ └── fileCleanup.ts
|
|
│ ├── web/
|
|
│ │ ├── package.json
|
|
│ │ ├── tsconfig.json
|
|
│ │ ├── vite.config.ts
|
|
│ │ ├── tailwind.config.js
|
|
│ │ ├── postcss.config.js
|
|
│ │ ├── index.html
|
|
│ │ └── src/
|
|
│ │ ├── main.tsx
|
|
│ │ ├── App.tsx
|
|
│ │ ├── api/
|
|
│ │ │ └── client.ts
|
|
│ │ ├── audio/
|
|
│ │ │ ├── AudioManager.ts
|
|
│ │ │ └── SpeakingDetector.ts
|
|
│ │ ├── stores/
|
|
│ │ │ ├── authStore.ts
|
|
│ │ │ ├── spaceStore.ts
|
|
│ │ │ ├── chatStore.ts
|
|
│ │ │ ├── voiceStore.ts
|
|
│ │ │ ├── socialStore.ts
|
|
│ │ │ ├── settingsStore.ts
|
|
│ │ │ ├── exploreStore.ts
|
|
│ │ │ ├── instanceStore.ts
|
|
│ │ │ └── uiStore.ts
|
|
│ │ ├── hooks/
|
|
│ │ │ ├── useWebSocket.ts
|
|
│ │ │ ├── useLiveKit.ts
|
|
│ │ │ ├── useAuth.ts
|
|
│ │ │ ├── useTrackStats.ts
|
|
│ │ │ ├── useAudioTrackPlayer.ts
|
|
│ │ │ ├── useFederationToasts.ts
|
|
│ │ │ ├── useFloatingPosition.ts
|
|
│ │ │ ├── useGridLayout.ts
|
|
│ │ │ └── useVoiceParticipantMeta.ts
|
|
│ │ ├── utils/
|
|
│ │ │ ├── permissions.ts
|
|
│ │ │ ├── livekitInternals.ts
|
|
│ │ │ ├── screenShare.ts
|
|
│ │ │ ├── assetUrls.ts
|
|
│ │ │ ├── colorExtractor.ts
|
|
│ │ │ ├── cropImage.ts
|
|
│ │ │ ├── federationOps.ts
|
|
│ │ │ ├── gradients.ts
|
|
│ │ │ ├── identity.ts
|
|
│ │ │ ├── inviteParser.ts
|
|
│ │ │ ├── mutuals.ts
|
|
│ │ │ ├── profileSync.ts
|
|
│ │ │ └── voice.ts
|
|
│ │ ├── components/
|
|
│ │ │ ├── layout/
|
|
│ │ │ │ ├── AppLayout.tsx
|
|
│ │ │ │ ├── SpaceSidebar.tsx
|
|
│ │ │ │ ├── ChannelSidebar.tsx
|
|
│ │ │ │ ├── MainContent.tsx
|
|
│ │ │ │ ├── RightPanel.tsx
|
|
│ │ │ │ ├── MemberSidebar.tsx
|
|
│ │ │ │ ├── ActivityPanel.tsx
|
|
│ │ │ │ ├── MemberListToggleButton.tsx
|
|
│ │ │ │ └── MobileNav.tsx
|
|
│ │ │ ├── chat/
|
|
│ │ │ │ ├── MessageList.tsx
|
|
│ │ │ │ ├── Message.tsx
|
|
│ │ │ │ ├── MessageInput.tsx
|
|
│ │ │ │ ├── TypingIndicator.tsx
|
|
│ │ │ │ ├── ImagePreview.tsx
|
|
│ │ │ │ ├── MarkdownRenderer.tsx
|
|
│ │ │ │ ├── MentionPopover.tsx
|
|
│ │ │ │ ├── MentionBadge.tsx
|
|
│ │ │ │ ├── Embed.tsx
|
|
│ │ │ │ ├── FriendsPage.tsx
|
|
│ │ │ │ ├── ExplorePage.tsx
|
|
│ │ │ │ └── SearchPopover.tsx
|
|
│ │ │ ├── voice/
|
|
│ │ │ │ ├── VoiceChannel.tsx
|
|
│ │ │ │ ├── VoiceChatPanel.tsx
|
|
│ │ │ │ ├── VoiceControlBar.tsx
|
|
│ │ │ │ ├── VoiceControls.tsx
|
|
│ │ │ │ ├── VoiceGrid.tsx
|
|
│ │ │ │ ├── VoiceUser.tsx
|
|
│ │ │ │ ├── StreamTile.tsx
|
|
│ │ │ │ ├── PictureInPicture.tsx
|
|
│ │ │ │ ├── IncomingCallModal.tsx
|
|
│ │ │ │ ├── GlobalAudioRenderer.tsx
|
|
│ │ │ │ ├── SoundController.tsx
|
|
│ │ │ │ ├── ConnectionInfoPopover.tsx
|
|
│ │ │ │ ├── ScreenShareSettingsPopover.tsx
|
|
│ │ │ │ ├── StreamContextMenu.tsx
|
|
│ │ │ │ └── VoiceUserContextMenu.tsx
|
|
│ │ │ ├── auth/
|
|
│ │ │ │ ├── LoginPage.tsx
|
|
│ │ │ │ └── RegisterPage.tsx
|
|
│ │ │ ├── modals/
|
|
│ │ │ │ ├── CreateSpace.tsx
|
|
│ │ │ │ ├── InviteModal.tsx
|
|
│ │ │ │ ├── CreateChannel.tsx
|
|
│ │ │ │ ├── JoinSpace.tsx
|
|
│ │ │ │ ├── UserSettings.tsx
|
|
│ │ │ │ ├── SpaceSettings.tsx
|
|
│ │ │ │ ├── ChannelSettingsModal.tsx
|
|
│ │ │ │ ├── NewDmModal.tsx
|
|
│ │ │ │ ├── AddDmMemberModal.tsx
|
|
│ │ │ │ ├── ConnectedInstances.tsx
|
|
│ │ │ │ ├── DeleteAccountModal.tsx
|
|
│ │ │ │ ├── UserProfileModal.tsx
|
|
│ │ │ │ ├── settingsPanels/
|
|
│ │ │ │ │ ├── AccountPanel.tsx
|
|
│ │ │ │ │ ├── ConnectionsPanel.tsx
|
|
│ │ │ │ │ ├── VoicePanel.tsx
|
|
│ │ │ │ │ └── InstancePanel.tsx
|
|
│ │ │ │ ├── spaceSettingsPanels/
|
|
│ │ │ │ │ ├── OverviewPanel.tsx
|
|
│ │ │ │ │ ├── RolesPanel.tsx
|
|
│ │ │ │ │ ├── MembersPanel.tsx
|
|
│ │ │ │ │ └── BansPanel.tsx
|
|
│ │ │ │ └── instanceSettingsPanels/
|
|
│ │ │ │ ├── GeneralPanel.tsx
|
|
│ │ │ │ └── StreamingPanel.tsx
|
|
│ │ │ └── ui/
|
|
│ │ │ ├── Avatar.tsx
|
|
│ │ │ ├── Modal.tsx
|
|
│ │ │ ├── Tooltip.tsx
|
|
│ │ │ ├── ContextMenu.tsx
|
|
│ │ │ ├── LoadingSpinner.tsx
|
|
│ │ │ ├── UserProfilePopout.tsx
|
|
│ │ │ ├── ConfirmDialog.tsx
|
|
│ │ │ ├── ImageCropModal.tsx
|
|
│ │ │ ├── ToastContainer.tsx
|
|
│ │ │ ├── Toggle.tsx
|
|
│ │ │ └── Username.tsx
|
|
│ │ ├── test/
|
|
│ │ │ └── setup.ts
|
|
│ │ └── styles/
|
|
│ │ └── globals.css
|
|
│ └── desktop/
|
|
│ ├── package.json
|
|
│ ├── tsconfig.json
|
|
│ ├── src/
|
|
│ │ ├── main.ts
|
|
│ │ └── preload.ts
|
|
│ └── electron-builder.yml
|
|
└── data/
|
|
├── backspace.db
|
|
└── uploads/
|
|
```
|
|
|
|
## DATABASE SCHEMA
|
|
|
|
```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
|
|
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,
|
|
filename TEXT NOT NULL,
|
|
original_name TEXT NOT NULL,
|
|
mimetype TEXT NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
|
|
-- Reactions
|
|
CREATE TABLE reactions (
|
|
id TEXT PRIMARY KEY,
|
|
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
emoji TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
|
|
-- DM Channels
|
|
CREATE TABLE dm_channels (
|
|
id TEXT PRIMARY KEY,
|
|
owner_id TEXT, -- NULL for 1-on-1, set for group DMs
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE TABLE dm_members (
|
|
dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
closed INTEGER DEFAULT 0, -- soft-close flag
|
|
PRIMARY KEY (dm_channel_id, user_id)
|
|
);
|
|
|
|
-- DM Messages
|
|
CREATE TABLE dm_messages (
|
|
id TEXT PRIMARY KEY,
|
|
dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
|
|
user_id TEXT NOT NULL REFERENCES users(id),
|
|
reply_to_id TEXT,
|
|
content TEXT,
|
|
edited_at INTEGER,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
|
|
-- DM Reactions
|
|
CREATE TABLE dm_reactions (
|
|
id TEXT PRIMARY KEY,
|
|
dm_message_id TEXT NOT NULL,
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
emoji TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
|
|
-- Friends
|
|
CREATE TABLE friends (
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
friend_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
created_at INTEGER NOT NULL,
|
|
PRIMARY KEY (user_id, friend_id)
|
|
);
|
|
|
|
-- Friend Requests
|
|
CREATE TABLE friend_requests (
|
|
id TEXT PRIMARY KEY,
|
|
from_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
to_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
status TEXT DEFAULT 'pending', -- 'pending' | 'accepted' | 'declined'
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
|
|
-- Read States
|
|
CREATE TABLE read_states (
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
channel_id TEXT NOT NULL,
|
|
last_read_message_id TEXT NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
PRIMARY KEY (user_id, channel_id)
|
|
);
|
|
|
|
-- Space Folders
|
|
CREATE TABLE space_folders (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
name TEXT,
|
|
color TEXT,
|
|
position INTEGER DEFAULT 0,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE TABLE space_folder_members (
|
|
folder_id TEXT NOT NULL REFERENCES space_folders(id) ON DELETE CASCADE,
|
|
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (folder_id, space_id)
|
|
);
|
|
|
|
-- Instance Settings (singleton row, id=1)
|
|
CREATE TABLE instance_settings (
|
|
id INTEGER PRIMARY KEY DEFAULT 1,
|
|
instance_name TEXT DEFAULT 'Backspace', -- federation instance name
|
|
worker_id INTEGER, -- Snowflake worker ID for federation
|
|
discovery_enabled INTEGER NOT NULL DEFAULT 1, -- space discovery toggle
|
|
max_bitrate_kbps INTEGER NOT NULL DEFAULT 20000,
|
|
min_bitrate_kbps INTEGER NOT NULL DEFAULT 500,
|
|
bitrate_step_kbps INTEGER NOT NULL DEFAULT 500,
|
|
allowed_resolutions TEXT NOT NULL DEFAULT '540,720,1080',
|
|
allowed_framerates TEXT NOT NULL DEFAULT '30,45,60',
|
|
max_resolution INTEGER NOT NULL DEFAULT 1080,
|
|
max_framerate INTEGER NOT NULL DEFAULT 60,
|
|
registration_open INTEGER, -- explicit registration override (null = use env)
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
|
|
-- Bans
|
|
CREATE TABLE bans (
|
|
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
reason TEXT,
|
|
banned_by TEXT REFERENCES users(id),
|
|
created_at INTEGER NOT NULL,
|
|
PRIMARY KEY (space_id, user_id)
|
|
);
|
|
|
|
-- Join Requests
|
|
CREATE TABLE join_requests (
|
|
id TEXT PRIMARY KEY,
|
|
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
message TEXT,
|
|
status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'accepted' | 'declined'
|
|
decided_by TEXT REFERENCES users(id),
|
|
created_at INTEGER NOT NULL,
|
|
decided_at INTEGER
|
|
);
|
|
|
|
-- Voice Restrictions
|
|
CREATE TABLE voice_restrictions (
|
|
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
restriction_type TEXT NOT NULL, -- 'mute' | 'deafen'
|
|
moderator_id TEXT REFERENCES users(id),
|
|
created_at INTEGER NOT NULL,
|
|
PRIMARY KEY (space_id, user_id, restriction_type)
|
|
);
|
|
```
|
|
|
|
## REST API ENDPOINTS
|
|
|
|
```
|
|
# Auth
|
|
POST /api/auth/register { username, password, displayName?, avatarColor? } → { token, user }
|
|
POST /api/auth/login { username, password } → { token, user }
|
|
GET /api/auth/check-username ?username= → { available, reason? }
|
|
|
|
# Users
|
|
GET /api/users/@me (auth) → { user }
|
|
PATCH /api/users/@me (auth) { displayName?, avatar?, banner?, accentColor?,
|
|
avatarColor?, bio?, customStatus?, status?,
|
|
replicatedInstances?, homeUserId? } → { user }
|
|
GET /api/users/:id (auth) → { user }
|
|
POST /api/users/@me/verify-password (auth) { password } → { valid }
|
|
POST /api/users/@me/change-password (auth) { currentPassword?, newPassword } → { token }
|
|
DELETE /api/users/@me (auth) { password, username } → { success }
|
|
GET /api/users/:id/mutuals (auth) ?homeUserId= → { mutualFriends[], mutualSpaces[] }
|
|
|
|
# Spaces
|
|
POST /api/spaces (auth) { name, icon?, banner?, avatarColor?, visibility?, description? } → { space }
|
|
GET /api/spaces (auth) → { spaces[] }
|
|
GET /api/spaces/:id (auth) → { space, channels[], members[], roles[] }
|
|
PATCH /api/spaces/:id (auth, MANAGE_SPACE) { name?, icon?, banner?,
|
|
avatarColor?, visibility?, description? } → { space }
|
|
DELETE /api/spaces/:id (auth, owner) → { success }
|
|
POST /api/spaces/:id/invite (auth, CREATE_INVITE) → { inviteCode }
|
|
POST /api/spaces/:id/join (auth) { inviteCode } → { space }
|
|
POST /api/spaces/join (auth) { inviteCode } → { space }
|
|
PATCH /api/spaces/:id/transfer-ownership (auth, owner) { newOwnerId } → { space }
|
|
|
|
# Members
|
|
GET /api/spaces/:id/members (auth, member) → { members[] }
|
|
PATCH /api/spaces/:id/members/:uid (auth, MANAGE_ROLES) { roleIds } → { member }
|
|
DELETE /api/spaces/:id/members/:uid (auth, KICK_MEMBERS|self) → { success }
|
|
|
|
# Bans
|
|
GET /api/spaces/:id/bans (auth, BAN_MEMBERS) → { bans[] }
|
|
POST /api/spaces/:id/bans (auth, BAN_MEMBERS) { userId, reason? } → { success }
|
|
DELETE /api/spaces/:id/bans/:uid (auth, BAN_MEMBERS) → { success }
|
|
|
|
# Roles
|
|
POST /api/spaces/:id/roles (auth, MANAGE_ROLES) { name, color?, permissions? } → { role }
|
|
PATCH /api/spaces/:id/roles/:rid (auth, MANAGE_ROLES) { name?, color?, permissions?, position? } → { role }
|
|
DELETE /api/spaces/:id/roles/:rid (auth, MANAGE_ROLES) → { success }
|
|
POST /api/spaces/:id/members/:uid/roles (auth, MANAGE_ROLES) { roleId } → { success }
|
|
DELETE /api/spaces/:id/members/:uid/roles/:rid (auth, MANAGE_ROLES) → { success }
|
|
|
|
# Channels
|
|
GET /api/spaces/:id/channels (auth, member, VIEW_CHANNEL) → { channels[] }
|
|
POST /api/spaces/:id/channels (auth, MANAGE_CHANNELS) { name, type, topic? } → { channel }
|
|
PATCH /api/channels/:id (auth, MANAGE_CHANNELS) { name?, topic?, position? } → { channel }
|
|
DELETE /api/channels/:id (auth, MANAGE_CHANNELS) → { success }
|
|
|
|
# Channel Permission Overrides
|
|
GET /api/channels/:id/overrides (auth, MANAGE_CHANNELS) → { overrides[] }
|
|
PUT /api/channels/:id/overrides (auth, MANAGE_CHANNELS) { targetType, targetId, allow, deny } → { override }
|
|
DELETE /api/channels/:id/overrides/:targetType/:targetId (auth, MANAGE_CHANNELS) → { success }
|
|
|
|
# Messages
|
|
GET /api/channels/:id/messages (auth, member) ?before=&limit=50 → { messages[] }
|
|
POST /api/channels/:id/messages (auth, SEND_MESSAGES) { content, attachments?, replyToId? } → { message }
|
|
PATCH /api/messages/:id (auth, author) { content } → { message }
|
|
DELETE /api/messages/:id (auth, author|MANAGE_MESSAGES) → { success }
|
|
|
|
# Search
|
|
GET /api/channels/:id/search (auth, member) ?q=&from=&has=&before=&after= → { results[], totalCount }
|
|
GET /api/channels/:id/messages/around (auth, member) ?messageId= → { messages[] }
|
|
GET /api/dm/:id/search (auth, member) ?q=&from=&has=&before=&after= → { results[], totalCount }
|
|
GET /api/dm/:id/messages/around (auth, member) ?messageId= → { messages[] }
|
|
|
|
# Explore / Discovery
|
|
GET /api/spaces/explore (auth) ?q=&limit=&offset= → { spaces[], total, discoveryEnabled }
|
|
POST /api/spaces/:id/public-join (auth) → { space }
|
|
POST /api/spaces/:id/request-join (auth) { message? } → { request }
|
|
GET /api/spaces/:id/join-requests (auth, MANAGE_SPACE) → { requests[] }
|
|
PATCH /api/spaces/:id/join-requests/:rid (auth, MANAGE_SPACE) { action } → { request }
|
|
GET /api/users/@me/join-requests (auth) ?status= → { requests[] }
|
|
|
|
# File Uploads
|
|
POST /api/uploads (auth) multipart file → { attachment }
|
|
GET /api/uploads/:filename (public) → file stream
|
|
|
|
# Direct Messages
|
|
GET /api/dm (auth) → { dmChannels[] }
|
|
POST /api/dm (auth) { userId } → { dmChannel }
|
|
DELETE /api/dm/:id (auth, member) → { success } (soft-close)
|
|
GET /api/dm/:id/messages (auth, member) ?before=&limit=50 → { messages[] }
|
|
POST /api/dm/:id/messages (auth, member) { content } → { message }
|
|
PATCH /api/dm/messages/:id (auth, author) { content } → { message }
|
|
DELETE /api/dm/messages/:id (auth, author) → { success }
|
|
POST /api/dm/:id/members (auth, owner) { userId } → { dmChannel } (group DM, max 10)
|
|
DELETE /api/dm/:id/members (auth, member) → { success } (leave group DM)
|
|
|
|
# Social / Friends
|
|
GET /api/social/friends (auth) → { friends[] }
|
|
GET /api/social/requests (auth) → { requests[] }
|
|
POST /api/social/requests (auth) { username } → { request }
|
|
PATCH /api/social/requests/:id (auth) { action: 'accept'|'decline' } → { request }
|
|
DELETE /api/social/requests/:id (auth) → { success } (cancel)
|
|
DELETE /api/social/friends/:id (auth) → { success }
|
|
GET /api/social/search (auth) ?q= → { users[] }
|
|
|
|
# Voice/Video
|
|
POST /api/livekit/token (auth) { channelId } → { token }
|
|
|
|
# Instance Info (public)
|
|
GET /api/instance/info (public) → { name, version, registrationOpen }
|
|
|
|
# Instance Settings (admin)
|
|
GET /api/settings/streaming (auth) → { streamingLimits }
|
|
PATCH /api/settings/streaming (auth, admin) { maxBitrateKbps?, ... } → { streamingLimits }
|
|
GET /api/settings/instance (auth, admin) → { instanceName, registrationOpen, discoveryEnabled }
|
|
PATCH /api/settings/instance (auth, admin) { instanceName?, registrationOpen?, discoveryEnabled? } → { settings }
|
|
|
|
# Utilities
|
|
GET /api/utils/metadata (auth) ?url= → { title?, description?, image?, siteName? }
|
|
GET /api/health (public) → { status: 'ok', timestamp }
|
|
```
|
|
|
|
## WEBSOCKET PROTOCOL
|
|
|
|
All WebSocket messages are JSON over `/ws`. Client authenticates by sending `{ type: 'auth', token: 'jwt...' }` as the first message. Server responds with `{ type: 'ready', ... }` containing all initial state.
|
|
|
|
### Client → Server
|
|
```
|
|
{ type: 'auth', token: string }
|
|
{ type: 'ping' }
|
|
|
|
# Server Messages
|
|
{ type: 'message_create', channelId, content, replyToId?, attachmentIds? }
|
|
{ type: 'message_edit', messageId, content }
|
|
{ type: 'message_delete', messageId }
|
|
{ type: 'typing_start', channelId }
|
|
{ type: 'reaction_add', messageId, emoji }
|
|
{ type: 'reaction_remove', messageId, emoji }
|
|
{ type: 'channel_ack', channelId, messageId }
|
|
|
|
# DM Messages
|
|
{ type: 'dm_message_create', dmChannelId, content, replyToId?, attachmentIds? }
|
|
{ type: 'dm_message_edit', messageId, content }
|
|
{ type: 'dm_message_delete', messageId }
|
|
{ type: 'dm_typing_start', dmChannelId }
|
|
|
|
# Presence
|
|
{ type: 'presence_update', status: 'online' | 'idle' | 'dnd' }
|
|
|
|
# Voice (Space Channels)
|
|
{ type: 'voice_join', channelId }
|
|
{ type: 'voice_leave' }
|
|
{ type: 'voice_status', isMuted?, isDeafened?, isCameraOn?, isScreenSharing? }
|
|
{ type: 'voice_disconnect', userId }
|
|
|
|
# Voice Moderation
|
|
{ type: 'voice_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, 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 }
|
|
```
|
|
|
|
## 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=<random-64-char-hex> # Required — generate with: openssl rand -hex 32
|
|
|
|
# LiveKit (optional — leave empty to disable voice features)
|
|
LIVEKIT_URL= # WebSocket URL to LiveKit server
|
|
LIVEKIT_API_KEY= # LiveKit API key
|
|
LIVEKIT_API_SECRET= # LiveKit API secret
|
|
|
|
# Max file upload size in bytes (default: 100MB)
|
|
MAX_UPLOAD_SIZE=104857600
|
|
|
|
# Registration
|
|
REGISTRATION_OPEN=true # Set to false to disable new user signup
|
|
|
|
# Docker Compose profile — uncomment to enable LiveKit voice service
|
|
# COMPOSE_PROFILES=voice
|
|
```
|
|
|
|
## DEPLOYMENT
|
|
|
|
**Live instances:**
|
|
- `https://nova.ddns.net` — Raspberry Pi (primary)
|
|
- `https://orbit.ddns.net` — VM (secondary)
|
|
|
|
**Infrastructure:**
|
|
- Caddy reverse proxy with automatic HTTPS (replaces OpenResty)
|
|
- `Caddyfile` — Reverse proxy config, routes `/livekit/*` to LiveKit, everything else to Backspace
|
|
- Docker Compose with three services: `backspace`, `caddy`, `livekit` (optional via `COMPOSE_PROFILES=voice`)
|
|
- Data stored in `./data/` bind mount (DB + uploads)
|
|
|
|
**Setup:**
|
|
- `./install.sh` — Interactive first-time production installer (generates `.env`, `livekit.yaml`, starts services)
|
|
- Manual: copy `.env.example` to `.env`, configure, run `docker compose up -d --build`
|
|
|
|
**Deploy commands:**
|
|
- `./deploy.sh` — Syncs code via rsync to both instances, triggers rebuild
|
|
- `./deploy.sh pi` — Deploy to Pi only (nova.ddns.net)
|
|
- `./deploy.sh vm` — Deploy to VM only (orbit.ddns.net)
|
|
- `./deploy.sh all` — Deploy to both
|
|
- `./deploy.sh --local` — Force Pi via LAN IP (192.168.1.10)
|
|
- `./deploy.sh --remote` — Force Pi via public DNS
|
|
|
|
**Development:**
|
|
```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
|