The sidebar's visibleChannels filter is keyed on the channelPermissions Map. Creating a channel raced two state updates: the optimistic create (added to channels with no permission entry) and the channel_created WS event (the only thing that set the permission). When the optimistic add won the race, the WS handler hit its dedup guard, skipped setChannels, and set the permission by mutating the Map in place — no new reference, so visibleChannels never recomputed and the channel stayed hidden until loadSpace rebuilt the maps (i.e. leaving and returning to the space). Centralize the logic in a new upsertChannel store action that replaces channels and channelPermissions with fresh references, used by both the create path and the channel_created handler. Also return the creator's computed myPermissions (and isPrivate) from POST so the channel renders immediately from the response, independent of WS timing. Adds spaceStore.upsertChannel.test.ts covering the reference-identity regression and the optimistic-reconcile path.
731 lines
31 KiB
Markdown
731 lines
31 KiB
Markdown
# Space & Membership System
|
|
|
|
Source files:
|
|
- `packages/server/src/routes/spaces.ts` — Space CRUD, invite, join, members, roles, bans, ownership transfer, invite preview
|
|
- `packages/server/src/routes/channels.ts` — Channel CRUD, category CRUD, channel layout reordering, channel/category permission overrides
|
|
- `packages/server/src/routes/explore.ts` — Discovery listing, public join, join request workflow
|
|
- `packages/server/src/routes/users.ts` — Space layout (sidebar folders/ordering) persistence via `PUT /api/users/@me/space-layout`
|
|
- `packages/web/src/stores/spaceStore.ts` — Client-side space state, multi-instance merge, LWW layout sync
|
|
- `packages/web/src/stores/exploreStore.ts` — Explore page state, multi-instance discovery aggregation
|
|
- `packages/web/src/components/modals/CreateSpace.tsx` — Space creation modal (icon crop, color, visibility)
|
|
- `packages/web/src/components/modals/JoinSpace.tsx` — Join-by-code modal with federation connect phases
|
|
- `packages/web/src/components/modals/InviteModal.tsx` — Invite link generation and copy
|
|
- `packages/web/src/components/modals/TransferOwnershipModal.tsx` — Ownership transfer member picker
|
|
- `packages/web/src/components/modals/SpaceSettings.tsx` — Space settings: overview, discovery, members, roles, bans
|
|
- `packages/web/src/components/JoinPage.tsx` — Public invite landing page with federation redirect
|
|
- `packages/web/src/hooks/useDragManager.ts` — Channel/category/voice-user drag-and-drop
|
|
- `packages/web/src/utils/inviteParser.ts` — Invite code/URL/qualified-code parser
|
|
|
|
Cross-references: [database.md](database.md) (table schemas), [permissions.md](permissions.md) (resolution algorithm, override tiers), [websocket.md](websocket.md) (event types), [federation.md](federation.md) (peer relay), [voice.md](voice.md) (voice channel join)
|
|
|
|
---
|
|
|
|
## Space Lifecycle
|
|
|
|
### Creation
|
|
|
|
**Endpoint:** `POST /api/spaces` (`spaces.ts:spaceRoutes`)
|
|
**Auth:** Required
|
|
**Permission:** Any authenticated user
|
|
|
|
**Request body (`CreateSpaceRequest`):**
|
|
|
|
| Field | Type | Required | Validation |
|
|
|-------|------|----------|------------|
|
|
| name | string | yes | trimmed, 1-100 chars |
|
|
| icon | string | no | Upload filename |
|
|
| banner | string | no | Upload filename |
|
|
| avatarColor | AvatarColor | no | Must be in `AVATAR_COLORS`; random if omitted |
|
|
| visibility | SpaceVisibility | no | `'public'` / `'request'` / `'private'`; defaults to `'private'` |
|
|
| description | string | no | trimmed, max 200 chars |
|
|
|
|
**AVATAR_COLORS:** `['mint', 'sky', 'lavender', 'coral', 'rose', 'teal', 'amber']`
|
|
|
|
**Atomic transaction creates:**
|
|
|
|
| Entity | ID | Details |
|
|
|--------|----|---------|
|
|
| Space | `spaceId` (snowflake) | With `inviteCode` = `crypto.randomBytes(4).toString('hex')` (8 hex chars) |
|
|
| Owner membership | `(spaceId, userId)` | Creator auto-joined |
|
|
| Category: "text-channels" | `textCategoryId` (snowflake) | position 0 |
|
|
| Category: "voice-channels" | `voiceCategoryId` (snowflake) | position 1 |
|
|
| Channel: "general" (text) | `channelId` (snowflake) | position 0, in text-channels category |
|
|
| Channel: "voice" (voice) | `voiceChannelId` (snowflake) | position 0, in voice-channels category |
|
|
| @everyone role | id = `spaceId` | `DEFAULT_EVERYONE_PERMISSIONS`, position 0, color `#b9bbbe` |
|
|
|
|
**DEFAULT_EVERYONE_PERMISSIONS bits:** VIEW_CHANNEL, SEND_MESSAGES, CREATE_INVITE, CONNECT, SPEAK, ATTACH_FILES, READ_MESSAGE_HISTORY, ADD_REACTIONS, STREAM
|
|
|
|
**Post-transaction:**
|
|
1. `connectionManager.addUserSpace(userId, spaceId)` — registers creator for WS broadcasts
|
|
2. Icon/banner attachment records cleaned up (reference now in `spaces` table)
|
|
3. Icon/banner resized via `resizeProfileImage(filePath, 'icon'|'banner')`
|
|
|
|
**Response:** 201, `Space` object
|
|
|
|
### Read
|
|
|
|
**List user's spaces:** `GET /api/spaces` — returns all spaces where user is a member. Response: `Space[]`.
|
|
|
|
**Get space detail:** `GET /api/spaces/:id` — membership required. Returns `SpaceWithChannelsAndMembers`:
|
|
- Channels filtered by `VIEW_CHANNEL` permission per-channel (computed per-user)
|
|
- Each channel includes `isPrivate` (true if @everyone has VIEW_CHANNEL deny override) and `myPermissions`
|
|
- Categories include `isPrivate` flag
|
|
- Roles include `permissions` field only if requesting user has `MANAGE_ROLES`
|
|
- `myPermissions` at space level included
|
|
|
|
### Update
|
|
|
|
**Endpoint:** `PATCH /api/spaces/:id`
|
|
**Permission:** `MANAGE_SPACE`
|
|
|
|
**Updatable fields:** name (1-100 chars), icon, banner, avatarColor (validated against AVATAR_COLORS), visibility (public/request/private), description (max 200 chars).
|
|
|
|
**Side effects:**
|
|
- Old icon/banner files deleted from disk when replaced
|
|
- New icon/banner resized via `resizeProfileImage`
|
|
- Attachment records cleaned up for newly-set images
|
|
- `space_updated` WS event broadcast to all space members
|
|
|
|
### Delete
|
|
|
|
**Endpoint:** `DELETE /api/spaces/:id`
|
|
**Permission:** Owner only (`isSpaceOwner` check)
|
|
|
|
**Transaction deletes (in order):**
|
|
1. `read_states` for all channels in the space (no FK cascade)
|
|
2. All channels (messages cascade via FK)
|
|
3. All `space_members`
|
|
4. All `space_folder_members` referencing this space
|
|
5. The space itself
|
|
|
|
**Post-transaction:** All attachment files and space icon/banner files deleted from disk.
|
|
|
|
---
|
|
|
|
## Invite System
|
|
|
|
### Invite Code Generation
|
|
|
|
**Endpoint:** `POST /api/spaces/:id/invite`
|
|
**Permission:** `CREATE_INVITE`
|
|
|
|
**Behavior:** Returns existing `inviteCode` if one exists. Only generates a new one (`crypto.randomBytes(4).toString('hex')`) if the space has no invite code. Invite codes are permanent (no expiration).
|
|
|
|
**Response:** `{ inviteCode: string }`
|
|
|
|
### Invite URL Format
|
|
|
|
Generated by `InviteModal.tsx`:
|
|
- **Web:** `{instanceOrigin}/join/{inviteCode}` (e.g., `https://nova.ddns.net/join/a3f1b2c4`)
|
|
- **Deep link (Electron):** `backspace://join/{inviteCode}` or `backspace://join/{inviteCode}@{host}` for remote instances
|
|
|
|
### Invite Code Parser (`inviteParser.ts`)
|
|
|
|
Parses three input formats into `{ code: string; origin?: string }`:
|
|
|
|
| Format | Example | Parsed |
|
|
|--------|---------|--------|
|
|
| Bare code | `a3f1b2c4` | `{ code: 'a3f1b2c4' }` |
|
|
| Full URL | `https://remote.com/join/a3f1b2c4` | `{ code: 'a3f1b2c4', origin: 'https://remote.com' }` |
|
|
| Qualified code | `a3f1b2c4@remote.com` | `{ code: 'a3f1b2c4', origin: 'https://remote.com' }` |
|
|
|
|
If the parsed origin matches `window.location.origin`, it is treated as a bare code (origin stripped).
|
|
|
|
### Direct Friend Invitation (in-app)
|
|
|
|
`POST /api/dm/space-invite` (see [dm-system.md](dm-system.md)) sends a structured invite card to a friend via DM. The card carries a snapshot of the space (name, icon, member count, description) plus the canonical identifiers (`spaceId`, `spaceInstanceOrigin`, `inviteCode`).
|
|
|
|
The endpoint lives on the **caller's home instance**, not the space's home. The caller's instance fetches the snapshot server-to-server from the space's `GET /api/spaces/invite/:code/preview` endpoint, then inserts a `type='system'` DM message with `event: 'space_invite'` content. Three-way federation (sender on X, recipient on Y, space on Z) is supported without new federation event kinds.
|
|
|
|
The friend-picker surface in `InviteModal` uses the same per-space invite code as the link-share footer — there is exactly one invite code per space at any time, and revocation (when implemented) invalidates all outstanding cards atomically.
|
|
|
|
### Invite Preview
|
|
|
|
**Endpoint:** `GET /api/spaces/invite/:code/preview` (no auth required)
|
|
|
|
**Response (`InvitePreview`):**
|
|
|
|
```typescript
|
|
{
|
|
spaceId: string;
|
|
spaceName: string;
|
|
description: string | null;
|
|
icon: string | null;
|
|
avatarColor: AvatarColor | null;
|
|
memberCount: number; // live count from space_members
|
|
instanceName: string; // from instance_settings
|
|
}
|
|
```
|
|
|
|
### Join by Invite Code
|
|
|
|
Two endpoints serve the same purpose:
|
|
|
|
| Endpoint | Use case |
|
|
|----------|----------|
|
|
| `POST /api/spaces/:id/join` | Join when spaceId is known (body: `{ inviteCode }`) |
|
|
| `POST /api/spaces/join` | Join by code only, spaceId looked up from `inviteCode` |
|
|
|
|
**Validations:** invite code match, not banned, not already a member.
|
|
|
|
**Side effects:**
|
|
1. Insert `space_members` row
|
|
2. `connectionManager.addUserSpace` for WS broadcasts
|
|
3. `member_joined` WS event broadcast to space
|
|
4. Response: `Space` object
|
|
|
|
### Join Page (`JoinPage.tsx`)
|
|
|
|
Public route at `/join/:inviteCode`. Handles five phases:
|
|
|
|
| Phase | Trigger | UI |
|
|
|-------|---------|-----|
|
|
| `preview` | Initial load | Space preview card + join button (auth) or login/register links (unauth) |
|
|
| `connect` | `NotConnectedError` on join attempt | Password prompt for federation connect |
|
|
| `fallback` | `DifferentPasswordError` on connect | Username + password for existing remote account |
|
|
| `other-instance` | User clicks "I use another instance" | Domain input for federation redirect |
|
|
| `already-member` | Join returns "already a member" | Green checkmark + auto-redirect (2s timer) |
|
|
|
|
**Federation redirect flow (other-instance):**
|
|
1. User enters their home domain (e.g., `my-instance.com`)
|
|
2. Constructs qualified invite: `{code}@{currentHost}`
|
|
3. Redirects to `https://{domain}/join/{qualifiedCode}`
|
|
4. Their home instance's JoinPage receives the qualified code, parses origin, and handles federation connect
|
|
|
|
**Preview fetching:** For remote invites, creates a temporary API client via `createApiClient(origin, () => null)` to fetch the preview without authentication.
|
|
|
|
### Join Space Modal (`JoinSpaceModal`)
|
|
|
|
Modal with three phases matching JoinPage but in-app:
|
|
- `input` — text field for invite code or URL
|
|
- `connect` — password prompt for federation
|
|
- `fallback` — different-password login for remote instance
|
|
|
|
Uses `parseInviteInput` to parse, then `joinByCode(code, origin?)` from spaceStore.
|
|
|
|
---
|
|
|
|
## Discovery System
|
|
|
|
### Space Visibility
|
|
|
|
| Value | Explore listing | Join mechanism |
|
|
|-------|----------------|----------------|
|
|
| `private` | Not listed | Invite code only |
|
|
| `request` | Listed | Submit join request, requires approval |
|
|
| `public` | Listed | Instant join, no invite needed |
|
|
|
|
### Explore Endpoint
|
|
|
|
**Endpoint:** `GET /api/spaces/explore` (`explore.ts:exploreRoutes`)
|
|
**Auth:** Required
|
|
**Query params:** `q` (search), `limit` (1-100, default 50), `offset` (default 0)
|
|
|
|
**Instance-level gate:** Checks `instance_settings.discoveryEnabled`. If false, returns `{ spaces: [], total: 0, discoveryEnabled: false }`.
|
|
|
|
**Query:** Raw SQL with LEFT JOIN on `space_members` for member count. Filters to `visibility IN ('public', 'request')`. Search matches `name` or `description` (LIKE, case-insensitive). Ordered by `member_count DESC, created_at DESC`.
|
|
|
|
**Response (`ExploreSpace[]`):**
|
|
|
|
```typescript
|
|
{
|
|
id: string;
|
|
name: string;
|
|
icon: string | null;
|
|
banner: string | null;
|
|
avatarColor: AvatarColor | null;
|
|
description: string | null;
|
|
visibility: 'public' | 'request';
|
|
memberCount: number;
|
|
createdAt: number;
|
|
joined: boolean; // true if requesting user is already a member
|
|
}
|
|
```
|
|
|
|
Also returns `total` (filtered count), `totalAll` (all discoverable), `discoveryEnabled`.
|
|
|
|
### Multi-Instance Discovery (`exploreStore.ts`)
|
|
|
|
`fetchSpaces()` queries home + all connected remote instances in parallel:
|
|
1. Waits for `instanceStore._autoConnectDone` to avoid querying with incomplete instance list
|
|
2. `Promise.allSettled` across home API + all connected instance APIs
|
|
3. Deduplicates by `spaceId:origin` key
|
|
4. Normalizes remote asset URLs via `resolveAssetUrl`
|
|
5. Merges into `TaggedExploreSpace[]` with `_instanceOrigin`
|
|
|
|
### Public Join
|
|
|
|
**Endpoint:** `POST /api/spaces/:id/public-join`
|
|
**Validation:** Space must have `visibility === 'public'`, not banned, not already member.
|
|
|
|
**Side effects:** Same as invite join (insert member, WS broadcast, add to connectionManager).
|
|
**Response:** Full `SpaceWithChannelsAndMembers` (not just `Space`), so the client can immediately populate the store without a follow-up `GET /api/spaces/:id`.
|
|
|
|
### Join Request Workflow
|
|
|
|
**Submit request:** `POST /api/spaces/:id/request-join`
|
|
- Space must have `visibility === 'request'`
|
|
- Rate limited: 5 per minute
|
|
- Message: optional, max 500 chars
|
|
- Checks for existing pending request (409 if exists)
|
|
- Creates `join_requests` row with status `'pending'`
|
|
- Sends `join_request_received` WS event to all space managers (owner + `MANAGE_SPACE` holders)
|
|
|
|
**List requests:** `GET /api/spaces/:id/join-requests?status=pending`
|
|
- Permission: owner or `MANAGE_SPACE`
|
|
- Status filter: `pending` (default), `accepted`, `declined`
|
|
- Returns `{ requests: JoinRequest[] }` with populated user data
|
|
|
|
**Decide request:** `PATCH /api/spaces/:id/join-requests/:requestId`
|
|
- Permission: owner or `MANAGE_SPACE`
|
|
- Body: `{ action: 'accept' | 'decline' }`
|
|
- Must be pending (400 if already decided)
|
|
|
|
Accept flow (atomic transaction):
|
|
1. Insert `space_members` row
|
|
2. Update request status to `'accepted'`, set `decidedBy` and `decidedAt`
|
|
3. `connectionManager.addUserSpace`
|
|
4. Broadcast `member_joined` to space
|
|
5. Build full `SpaceWithChannelsAndMembers` for accepted user
|
|
6. Send `join_request_accepted` WS event to requesting user (includes full space data)
|
|
|
|
Decline flow:
|
|
1. Update request status to `'declined'`
|
|
2. Send `join_request_declined` WS event to requesting user
|
|
|
|
**User's own requests:** `GET /api/users/@me/join-requests?status=<optional>`
|
|
- Returns all requests for the current user, optionally filtered by status
|
|
|
|
### Space Managers Resolution (`explore.ts:getSpaceManagers`)
|
|
|
|
Used to target `join_request_received` events. Iterates all space members and returns IDs where:
|
|
- `userId === space.ownerId`, OR
|
|
- `hasPermission(userId, spaceId, PermissionBits.MANAGE_SPACE)` returns true
|
|
|
|
---
|
|
|
|
## Membership
|
|
|
|
### Join
|
|
|
|
Three join paths:
|
|
1. **Invite code** — `POST /api/spaces/:id/join` or `POST /api/spaces/join`
|
|
2. **Public join** — `POST /api/spaces/:id/public-join` (visibility=public)
|
|
3. **Request accept** — `PATCH /api/spaces/:id/join-requests/:requestId` with `action: 'accept'`
|
|
|
|
All paths: insert `space_members`, register in `connectionManager`, broadcast `member_joined`.
|
|
|
|
### Leave / Kick
|
|
|
|
**Endpoint:** `DELETE /api/spaces/:id/members/:uid`
|
|
|
|
| Scenario | Condition | Permission |
|
|
|----------|-----------|------------|
|
|
| Self-leave | `uid === request.userId` | Any member (unless owner) |
|
|
| Kick | `uid !== request.userId` | `KICK_MEMBERS` |
|
|
|
|
**Owner restriction:** Owner cannot leave. Must transfer ownership or delete the space.
|
|
**Owner protection:** Cannot kick the owner.
|
|
|
|
**Cleanup on removal:**
|
|
1. Delete `space_members` row
|
|
2. Delete `voice_restrictions` for the member in this space
|
|
3. Delete `read_states` for the member in all space channels
|
|
4. Broadcast `member_left` WS event
|
|
|
|
### Ban
|
|
|
|
**Endpoint:** `POST /api/spaces/:id/bans`
|
|
**Permission:** `BAN_MEMBERS`
|
|
**Body:** `{ userId: string, reason?: string }`
|
|
|
|
**Protections:** Cannot ban owner, cannot ban self, 409 if already banned.
|
|
|
|
**Atomic transaction:**
|
|
1. Insert `bans` row (with `reason`, `bannedBy`, `createdAt`)
|
|
2. Delete `space_members`
|
|
3. Delete `member_roles`
|
|
4. Delete `read_states` for all space channels
|
|
5. Delete `voice_restrictions`
|
|
|
|
**WS events:**
|
|
- `member_left` to space (so other members update their list)
|
|
- `member_banned` to the banned user (with `reason`)
|
|
|
|
**List bans:** `GET /api/spaces/:id/bans` — requires `BAN_MEMBERS`. Returns ban records with both banned user and moderator user objects.
|
|
|
|
**Unban:** `DELETE /api/spaces/:id/bans/:uid` — requires `BAN_MEMBERS`. 404 if no ban found.
|
|
|
|
### Ownership Transfer
|
|
|
|
**Endpoint:** `PATCH /api/spaces/:id/transfer-ownership`
|
|
**Permission:** Owner only
|
|
**Body:** `{ newOwnerId: string }`
|
|
**Validation:** New owner must be a member, cannot transfer to self.
|
|
|
|
Updates `spaces.ownerId`, broadcasts `space_updated` WS event.
|
|
|
|
**Client (`TransferOwnershipModal`):** Member picker with search, two-step confirm. Shows warning "You will become a regular member." Uses toast notification on success.
|
|
|
|
### Member Role Management
|
|
|
|
**Set roles (replace):** `PATCH /api/spaces/:id/members/:uid`
|
|
- Permission: `MANAGE_ROLES`
|
|
- Body: `{ roleIds: string[] }`
|
|
- Cannot change own roles
|
|
- Cannot modify owner's roles (unless you are the owner)
|
|
- @everyone role (id=spaceId) cannot be assigned
|
|
- Atomically deletes all existing `member_roles` then inserts new ones
|
|
- Triggers `connectionManager.pushReadyPayload(uid)` to force re-sync
|
|
- Triggers `checkVoicePermissions(spaceId)` to enforce voice changes
|
|
|
|
**Add single role:** `POST /api/spaces/:id/members/:uid/roles` — body `{ roleId }`, requires `MANAGE_ROLES`
|
|
|
|
**Remove single role:** `DELETE /api/spaces/:id/members/:uid/roles/:roleId` — requires `MANAGE_ROLES`
|
|
|
|
---
|
|
|
|
## Role Management
|
|
|
|
### Create Role
|
|
|
|
**Endpoint:** `POST /api/spaces/:id/roles`
|
|
**Permission:** `MANAGE_ROLES`
|
|
**Body:** `{ name: string, color?: string, permissions?: string }`
|
|
|
|
- Name defaults to `'new role'` if empty
|
|
- Duplicate name check (case-insensitive, raw SQL COLLATE NOCASE)
|
|
- Permissions default to `DEFAULT_EVERYONE_PERMISSIONS` if not provided
|
|
- Position defaults to 0
|
|
- Color defaults to `'#b9bbbe'`
|
|
- After creation: pushes ready payload to all space members, checks voice permissions
|
|
|
|
### Update Role
|
|
|
|
**Endpoint:** `PATCH /api/spaces/:id/roles/:roleId`
|
|
**Permission:** `MANAGE_ROLES`
|
|
**Body:** `{ name?, color?, position?, permissions? }`
|
|
|
|
- Name: trimmed, non-empty, duplicate check (case-insensitive, excludes self)
|
|
- Permissions: validated as valid bigint string
|
|
- After update: pushes ready payload to all members, checks voice permissions
|
|
|
|
### Delete Role
|
|
|
|
**Endpoint:** `DELETE /api/spaces/:id/roles/:roleId`
|
|
**Permission:** `MANAGE_ROLES`
|
|
|
|
- Cannot delete @everyone role (roleId === spaceId)
|
|
- Deletes channel overrides referencing this role
|
|
- After delete: pushes ready payload to all members, checks voice permissions
|
|
|
|
---
|
|
|
|
## Channel Management
|
|
|
|
### Channel Types
|
|
|
|
| Type | Semantics |
|
|
|------|-----------|
|
|
| `text` | Message-based channel with read states, embeds, reactions |
|
|
| `voice` | Voice/video channel (LiveKit integration, see voice.md) |
|
|
|
|
### Create Channel
|
|
|
|
**Endpoint:** `POST /api/spaces/:id/channels`
|
|
**Permission:** `MANAGE_CHANNELS`
|
|
|
|
| Field | Validation |
|
|
|-------|------------|
|
|
| name | Required, trimmed, lowercased, spaces→hyphens, 1-100 chars |
|
|
| type | Required, `'text'` or `'voice'` |
|
|
| topic | Optional, trimmed |
|
|
| categoryId | Optional, validated against space's categories |
|
|
|
|
Position: `max(existing positions) + 1`.
|
|
|
|
**Response (201):** the created channel including the creator's computed `myPermissions` and `isPrivate: false` — same shape as the `channel_created` event payload — so the creating client can render it immediately without waiting for the broadcast to round-trip.
|
|
|
|
**Broadcast:** `channel_created` sent per-user (only to users with VIEW_CHANNEL on the new channel). Each user's event includes their computed `myPermissions`.
|
|
|
|
**Client reconciliation:** both the create response and the `channel_created` event are applied through the `upsertChannel` store action, which replaces `channels` and `channelPermissions` with fresh references. This is required because the sidebar's `visibleChannels` filter is keyed on `channelPermissions`; mutating that Map in place would set the value without triggering a re-render, leaving a freshly created channel hidden until the space was reopened.
|
|
|
|
### Update Channel
|
|
|
|
**Endpoint:** `PATCH /api/channels/:id`
|
|
**Permission:** `MANAGE_CHANNELS` (checked with channel-level override context)
|
|
**Body:** `{ name?, topic?, position?, categoryId? }`
|
|
|
|
- Name: same normalization as create
|
|
- Position: non-negative number
|
|
- categoryId: `null` to unassign, or valid category ID in same space
|
|
|
|
**Broadcast behavior:**
|
|
- If `categoryId` changed: calls `broadcastOverrideChange` (per-user VIEW_CHANNEL recheck, may send `channel_deleted` to users who lost access)
|
|
- Otherwise: simple `channel_updated` broadcast to channel viewers
|
|
|
|
### Delete Channel
|
|
|
|
**Endpoint:** `DELETE /api/channels/:id`
|
|
**Permission:** `MANAGE_CHANNELS`
|
|
|
|
**Cleanup sequence:**
|
|
1. Disconnect all voice participants (if voice channel)
|
|
2. Collect viewer IDs before deletion (for targeted broadcast)
|
|
3. Collect attachment filenames before cascade
|
|
4. Delete `read_states` (no FK)
|
|
5. Delete `messages` (attachments cascade)
|
|
6. Delete `channels` row
|
|
7. Delete attachment files from disk
|
|
8. Broadcast `channel_deleted` only to users who could see the channel
|
|
|
|
### Category Management
|
|
|
|
**Create:** `POST /api/spaces/:id/categories` — permission: `MANAGE_CHANNELS`, name 1-100 chars, auto-position. Broadcasts `category_created`.
|
|
|
|
**Update:** `PATCH /api/categories/:id` — permission: `MANAGE_CHANNELS`, updatable: name, position. Broadcasts `category_updated` (includes `isPrivate` flag).
|
|
|
|
**Delete:** `DELETE /api/categories/:id` — permission: `MANAGE_CHANNELS`. Transaction nulls `categoryId` on child channels, then deletes category. Broadcasts `category_deleted` then `channel_layout_updated` (per-user filtered).
|
|
|
|
### Channel Layout Reorder
|
|
|
|
**Endpoint:** `PATCH /api/spaces/:id/channel-layout`
|
|
**Permission:** `MANAGE_CHANNELS`
|
|
|
|
**Body:**
|
|
```typescript
|
|
{
|
|
channels: Array<{ id: string; position: number; categoryId: string | null }>;
|
|
categories: Array<{ id: string; position: number }>;
|
|
}
|
|
```
|
|
|
|
**Validation:**
|
|
- All channel IDs must belong to the space
|
|
- All category IDs must belong to the space
|
|
- All positions must be non-negative numbers
|
|
- Channel categoryId references must point to valid space categories
|
|
|
|
Applied atomically in a transaction. Broadcasts via `broadcastChannelLayout()` which sends `channel_layout_updated` per-user (each user sees only channels they have VIEW_CHANNEL on).
|
|
|
|
### Channel/Category Permission Overrides
|
|
|
|
OUT OF SCOPE for this document. See [permissions.md](permissions.md) for the three-tier override system (category overrides, channel overrides, member overrides) and the `computePermissions` algorithm.
|
|
|
|
Override endpoints documented here for API completeness:
|
|
|
|
| Endpoint | Permission | Notes |
|
|
|----------|------------|-------|
|
|
| `GET /api/channels/:id/overrides` | `MANAGE_ROLES` | List channel overrides |
|
|
| `PUT /api/channels/:id/overrides` | `MANAGE_ROLES` | Upsert (delete+insert in tx). Privilege escalation guard. |
|
|
| `DELETE /api/channels/:id/overrides/:targetType/:targetId` | `MANAGE_ROLES` | Remove override |
|
|
| `GET /api/categories/:id/overrides` | `MANAGE_ROLES` | List category overrides |
|
|
| `PUT /api/categories/:id/overrides` | `MANAGE_ROLES` | Upsert with escalation guard |
|
|
| `DELETE /api/categories/:id/overrides/:targetType/:targetId` | `MANAGE_ROLES` | Remove override |
|
|
|
|
All override mutations call `broadcastOverrideChange` (channel) or `broadcastCategoryOverrideChange` (category) which re-evaluates VIEW_CHANNEL per-user and sends `channel_updated` (gained access) or `channel_deleted` (lost access). Voice permission enforcement via `checkVoicePermissions` runs after every override change.
|
|
|
|
### Drag-and-Drop (`useDragManager.ts`)
|
|
|
|
Client-side hook managing three drag types:
|
|
|
|
| Type | Draggable | Drop target | Permission |
|
|
|------|-----------|-------------|------------|
|
|
| `channel` | Channel items | Before/after channels or categories | `MANAGE_CHANNELS` (`canManage`) |
|
|
| `category` | Category headers | Before/after channels or categories | `MANAGE_CHANNELS` (`canManage`) |
|
|
| `voiceUser` | Voice participant | Different voice channel | `MOVE_MEMBERS` (`canMoveMembers`) |
|
|
|
|
**Drop position normalization:** "before B" is normalized to "after A" (the preceding item in `orderedItems`) to prevent double drop-indicator rendering.
|
|
|
|
**Auto-scroll:** When dragging near top/bottom edges (40px), scrolls the sidebar container proportionally to edge distance.
|
|
|
|
**Self-drop guard:** Dropping on the same item is a no-op.
|
|
|
|
---
|
|
|
|
## Space Layout (Sidebar Ordering & Folders)
|
|
|
|
### Data Model
|
|
|
|
**Layout items (`SpaceLayoutItem`):**
|
|
```typescript
|
|
type SpaceLayoutItem =
|
|
| { t: 's'; id: string } // space reference
|
|
| { t: 'f'; id: string }; // folder reference
|
|
```
|
|
|
|
**Folders (`SpaceFolder`):**
|
|
```typescript
|
|
interface SpaceFolder {
|
|
id: string;
|
|
userId: string;
|
|
name: string | null;
|
|
color: string | null;
|
|
position: number;
|
|
spaceIds: string[]; // ordered list of spaces in the folder
|
|
}
|
|
```
|
|
|
|
### Server Persistence
|
|
|
|
**Tables (see database.md):** `user_space_layout`, `space_folders`, `space_folder_members`
|
|
|
|
**Endpoint:** `PUT /api/users/@me/space-layout` (`users.ts`)
|
|
|
|
**Request body:**
|
|
```typescript
|
|
{
|
|
items: SpaceLayoutItem[];
|
|
folders: Record<string, {
|
|
name: string | null;
|
|
color: string | null;
|
|
spaceIds: string[];
|
|
}>;
|
|
updatedAt?: number; // LWW timestamp
|
|
}
|
|
```
|
|
|
|
**Server-generated folder IDs:** Clients use `new:*` prefixed keys for new folders. The server maps each `new:*` key to a `generateSnowflake()` ID. The response returns resolved IDs so the client can update references.
|
|
|
|
**Remote folder adoption:** If a folder ID does not match an existing folder and does not start with `new:`, the server creates it with the provided ID (handles folders created on another instance being pushed to this one).
|
|
|
|
**Transaction:**
|
|
1. For each folder in request: create new / update existing / adopt remote
|
|
2. Clear and re-insert `space_folder_members` with ordered positions
|
|
3. Delete folders not in request (with their members)
|
|
4. Replace `new:*` keys in items array with resolved IDs
|
|
5. Upsert `user_space_layout` row (JSON string of items)
|
|
|
|
**WS broadcast:** `space_layout_updated` sent to the user's other connections (multi-tab sync).
|
|
|
|
**Response:** `{ items, folders, updatedAt }`
|
|
|
|
### LWW Conflict Resolution
|
|
|
|
**Server-side guard** (`users.ts`): If the request includes `updatedAt` and it is older than the stored `updatedAt`, the write is rejected and the current layout is returned without modification.
|
|
|
|
**Client-side algorithm** (`spaceStore.ts:populateFromReady`):
|
|
|
|
```
|
|
On receiving ready payload from any instance:
|
|
incomingTs = payload.layoutUpdatedAt ?? 0
|
|
currentTs = store._layoutUpdatedAt
|
|
|
|
if incomingTs >= currentTs:
|
|
Accept incoming layout (overwrite local)
|
|
_layoutUpdatedAt = incomingTs
|
|
else:
|
|
Keep local layout
|
|
Push local layout to the stale instance via pushLayoutToOrigin()
|
|
```
|
|
|
|
**`pushLayoutToOrigin(origin, layout, folders, updatedAt)`:** Calls `targetApi.spaceLayout.update()` on the specific instance that had the stale layout. This ensures all instances converge to the newest layout.
|
|
|
|
### Multi-Instance Layout Push
|
|
|
|
**`updateSpaceLayout(items, folders)`** in spaceStore:
|
|
1. Optimistically applies the layout with `Date.now()` timestamp
|
|
2. Collects all targets: home API + all connected remote instance APIs
|
|
3. `Promise.allSettled` pushes to all targets in parallel
|
|
4. Uses the first successful response to resolve `new:*` folder IDs
|
|
5. Updates store with resolved layout
|
|
|
|
---
|
|
|
|
## WS Ready Payload
|
|
|
|
The space layout and folder data are delivered in the WS `ready` event (`handler.ts:buildReadyPayload`):
|
|
|
|
```typescript
|
|
{
|
|
spaces: SpaceWithChannelsAndMembers[];
|
|
folders: SpaceFolder[];
|
|
spaceLayout: SpaceLayoutItem[] | null;
|
|
layoutUpdatedAt: number | null;
|
|
dmChannels: DmChannel[];
|
|
// ... other fields
|
|
}
|
|
```
|
|
|
|
`populateFromReady` merges incoming data by origin:
|
|
- Replaces all spaces from the incoming origin, keeps spaces from other origins
|
|
- Populates `channelToSpaceMap`, `channelOriginMap`, `channelPermissions`, `voiceChannelIds`, `categoryOriginMap`
|
|
- DM channels: removes existing DMs from this origin, appends incoming, deduplicates 1-on-1 DMs by canonical member pair (prefers home-origin copy)
|
|
- Applies LWW layout merge as described above
|
|
|
|
---
|
|
|
|
## WS Events Summary
|
|
|
|
| Event | Direction | Trigger |
|
|
|-------|-----------|---------|
|
|
| `space_updated` | S→C (space) | Space metadata changed |
|
|
| `member_joined` | S→C (space) | New member via any join path |
|
|
| `member_left` | S→C (space) | Member left, kicked, or banned |
|
|
| `member_banned` | S→C (user) | Sent to the banned user with reason |
|
|
| `join_request_received` | S→C (user) | Sent to space managers when request submitted |
|
|
| `join_request_accepted` | S→C (user) | Sent to requester with full space data |
|
|
| `join_request_declined` | S→C (user) | Sent to requester |
|
|
| `channel_created` | S→C (per-user) | New channel, per-user VIEW_CHANNEL filter |
|
|
| `channel_updated` | S→C (per-user/channel) | Channel or override changed |
|
|
| `channel_deleted` | S→C (per-user) | Channel deleted or user lost VIEW_CHANNEL |
|
|
| `category_created` | S→C (space) | New category |
|
|
| `category_updated` | S→C (space) | Category name/position/privacy changed |
|
|
| `category_deleted` | S→C (space) | Category removed |
|
|
| `channel_layout_updated` | S→C (per-user) | Batch reorder, per-user channel filtering |
|
|
| `space_layout_updated` | S→C (user) | Sidebar layout changed (multi-tab sync) |
|
|
|
|
---
|
|
|
|
## Federation Considerations
|
|
|
|
### Instance Origin Tagging
|
|
|
|
Every space in the client store has `_instanceOrigin: string`:
|
|
- `''` (empty) = home instance
|
|
- `'https://remote.com'` = remote federated instance
|
|
|
|
All store actions resolve the correct API client via `getApiForOrigin(origin)` before making HTTP requests. The resolver is registered by `instanceStore` on import (breaks circular dependency).
|
|
|
|
### User ID Resolution
|
|
|
|
`getMyUserIdForOrigin(origin)` returns the user's ID on a specific instance:
|
|
- Home (`''`): returns `authStore.user.id`
|
|
- Remote: checks `_myUserIdByOrigin` cache (populated from WS ready events), falls back to `instanceStore` resolver
|
|
|
|
Used for self-leave (`leaveSpace` calls `removeMember` with the correct user ID for the instance).
|
|
|
|
### Remote Invite Join Flow
|
|
|
|
```
|
|
1. User enters invite URL pointing to remote instance
|
|
2. parseInviteInput extracts code + origin
|
|
3. joinByCode(code, origin) called
|
|
4. If not connected → NotConnectedError thrown
|
|
5. JoinSpaceModal/JoinPage enters 'connect' phase
|
|
6. User provides password → connectToRemote(origin, password)
|
|
7. If password mismatch → DifferentPasswordError → 'fallback' phase
|
|
8. On success: joinByCode retried, space added to store with _instanceOrigin
|
|
```
|
|
|
|
### Asset URL Normalization
|
|
|
|
Remote space icons, banners, and member avatars are resolved via `resolveAssetUrl(path, origin)` when:
|
|
- `populateFromReady` processes spaces from a remote origin
|
|
- `loadSpaceDetail` loads a remote space
|
|
- `joinByCode` returns a remote space
|
|
- `exploreStore.fetchSpaces` processes remote explore results
|
|
|
|
### Client Load State (`useSpaceStore`)
|
|
|
|
Two distinct flags track per-space load progress:
|
|
|
|
- `loadingSpaceId: string | null` — non-null while a `loadSpaceDetail` call is in flight. Drives the channel-list and member-list skeletons (gated through `useDelayedLoading`).
|
|
- `loadedSpaceIds: Set<string>` — populated only on successful `loadSpaceDetail` completion. Used to differentiate "load not yet attempted" from "loaded with empty result." Required by mobile UI to gate the empty-state mascot — without it, the mascot flashes during the pre-skeleton load window because `state.channels` is overwritten on each `loadSpaceDetail` and a fresh space switch leaves `spaceChannels` momentarily filtered to `[]`.
|
|
|
|
`loadedSpaceIds` lifecycle:
|
|
- Added on `loadSpaceDetail` success (the same `set()` that replaces `channels`/`categories`/`members`).
|
|
- Pruned per-space on `deleteSpace`, `leaveSpace`, `removeSpace`, `removeInstanceSpaces`.
|
|
- Wiped entirely on `reset` (logout).
|
|
- Ephemeral — not persisted.
|