Files
Jannis Braun 85e1975fa5 fix(permissions): deny space permissions to non-members (invite-bypass)
computePermissions() returned the space @everyone role's permissions without
verifying the caller had joined the space. Because CREATE_INVITE is in
DEFAULT_EVERYONE_PERMISSIONS, any authenticated user could mint an invite code
for a request-only space — whose id is listed by /api/spaces/explore — and then
self-join via /api/spaces/:id/join, bypassing the join-request approval flow.
The same gap let non-members read message history and search default channels.

Root cause:
- computePermissions now returns 0n for non-members (space owner and instance
  admin still short-circuit first, so they are unaffected).

Defense in depth (request-only spaces are approval-gated, never invite-joinable):
- both invite-code join endpoints reject visibility='request' (private stays
  invite-joinable — its only entry path; public too).
- POST /api/spaces/:id/invite refuses to hand out a code for request spaces.
- POST /api/dm/space-invite refuses to card a local request space, checked by
  space id against the local table so a spoofed spaceInstanceOrigin can't slip
  past it.
- InviteModal hides the invite affordances for request spaces.

Also removes the unused computeCategoryPermissions(), which duplicated the
resolution algorithm without the membership gate.

Adds unit + route + component tests covering non-member/member/owner/admin
resolution and the request/private/public visibility matrix.

Reported-by: BadAtCaptchas (#2)
2026-07-07 19:45:51 +02:00

757 lines
33 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/ExploreSpacePreviewCard.tsx` — Compact discoverable-space card rendered inside the Join Space modal
- `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/hooks/useSpaceJoin.ts` — Shared join/request state machine over exploreStore (used by ExplorePage SpaceCard and the JoinSpace modal preview card)
- `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).
**Visibility gate:** returns `403` for `request`-visibility spaces — they are approval-gated and have no usable invite link (the join endpoints reject invite-code joins for them), so the endpoint refuses to hand one out. The client (`InviteModal`) shows an "invite by join request" notice instead of the invite UI for such spaces, and `POST /api/dm/space-invite` likewise rejects a `request`-visibility **local** space with `403 space_requires_approval` (remote request spaces are enforced by their home instance at join time).
**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, and **space visibility is not `request`**.
**Visibility gate:** invite-code joins are rejected (`403`) for `request`-visibility spaces — entry to a request-only space must go through `POST /api/spaces/:id/request-join` + manager approval, never a bearer invite code. `private` spaces remain invite-joinable (an invite is their only entry path); `public` spaces are joinable by code or via `POST /api/spaces/:id/public-join`. Combined with the permission membership gate (a non-member cannot obtain `CREATE_INVITE`, see [permissions.md](permissions.md)), this closes the invite-bypass path where a non-member could mint a code for a request-only space and self-join without approval.
**Side effects:**
1. Insert `space_members` row
2. `connectionManager.addUserSpace` for WS broadcasts — also pushes a scoped `space_voice_state` snapshot to the joining user so voice-channel occupants appear without a reload (see `docs/systems/websocket.md` → "Mid-session space join")
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`)
Opened from the space sidebar "Join a Space" action. The `input` phase is
**discovery-first**: on open it triggers `exploreStore.fetchSpaces()` +
`fetchMyRequests()` (same multi-instance discovery data as the Explore page)
and renders up to 6 unjoined public/request spaces as compact
`ExploreSpacePreviewCard`s. A "Browse all in Explore" action routes to the full
Explore page — `navigate('/explore')` on desktop, `pushMobileScreen('explore')`
on mobile (`uiStore.isMobile`). Joining a public preview card closes the modal
and navigates into the space.
States: loading (skeletons), empty / all-joined (notice), discovery disabled by
admin (`discoveryEnabled=false` → notice, invite path becomes primary), fetch
error (quiet degrade — invite path unaffected).
Below a divider, the secondary **"Have an invite code?"** section keeps the
invite-code/link flow: `parseInviteInput``joinByCode(code, origin?)`, with the
unchanged federation phases:
- `connect` — password prompt for federation
- `fallback` — different-password login for remote instance
The join/request behavior of both the compact preview card and the Explore
page's `SpaceCard` is provided by the shared `useSpaceJoin` hook, so the two
surfaces cannot drift.
The direct sidebar "Explore Spaces" button remains the 1-click path to the full
Explore page; the modal's discovery preview is an additional, in-context entry
point, not a replacement.
---
## 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.