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)
33 KiB
Space & Membership System
Source files:
packages/server/src/routes/spaces.ts— Space CRUD, invite, join, members, roles, bans, ownership transfer, invite previewpackages/server/src/routes/channels.ts— Channel CRUD, category CRUD, channel layout reordering, channel/category permission overridespackages/server/src/routes/explore.ts— Discovery listing, public join, join request workflowpackages/server/src/routes/users.ts— Space layout (sidebar folders/ordering) persistence viaPUT /api/users/@me/space-layoutpackages/web/src/stores/spaceStore.ts— Client-side space state, multi-instance merge, LWW layout syncpackages/web/src/stores/exploreStore.ts— Explore page state, multi-instance discovery aggregationpackages/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 phasespackages/web/src/components/modals/ExploreSpacePreviewCard.tsx— Compact discoverable-space card rendered inside the Join Space modalpackages/web/src/components/modals/InviteModal.tsx— Invite link generation and copypackages/web/src/components/modals/TransferOwnershipModal.tsx— Ownership transfer member pickerpackages/web/src/components/modals/SpaceSettings.tsx— Space settings: overview, discovery, members, roles, banspackages/web/src/components/JoinPage.tsx— Public invite landing page with federation redirectpackages/web/src/hooks/useDragManager.ts— Channel/category/voice-user drag-and-droppackages/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 (table schemas), permissions.md (resolution algorithm, override tiers), websocket.md (event types), federation.md (peer relay), 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:
connectionManager.addUserSpace(userId, spaceId)— registers creator for WS broadcasts- Icon/banner attachment records cleaned up (reference now in
spacestable) - 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_CHANNELpermission per-channel (computed per-user) - Each channel includes
isPrivate(true if @everyone has VIEW_CHANNEL deny override) andmyPermissions - Categories include
isPrivateflag - Roles include
permissionsfield only if requesting user hasMANAGE_ROLES myPermissionsat 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_updatedWS event broadcast to all space members
Delete
Endpoint: DELETE /api/spaces/:id
Permission: Owner only (isSpaceOwner check)
Transaction deletes (in order):
read_statesfor all channels in the space (no FK cascade)- All channels (messages cascade via FK)
- All
space_members - All
space_folder_membersreferencing this space - 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}orbackspace://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) 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):
{
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), 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:
- Insert
space_membersrow connectionManager.addUserSpacefor WS broadcasts — also pushes a scopedspace_voice_statesnapshot to the joining user so voice-channel occupants appear without a reload (seedocs/systems/websocket.md→ "Mid-session space join")member_joinedWS event broadcast to space- Response:
Spaceobject
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):
- User enters their home domain (e.g.,
my-instance.com) - Constructs qualified invite:
{code}@{currentHost} - Redirects to
https://{domain}/join/{qualifiedCode} - 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
ExploreSpacePreviewCards. 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 federationfallback— 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[]):
{
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:
- Waits for
instanceStore._autoConnectDoneto avoid querying with incomplete instance list Promise.allSettledacross home API + all connected instance APIs- Deduplicates by
spaceId:originkey - Normalizes remote asset URLs via
resolveAssetUrl - 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_requestsrow with status'pending' - Sends
join_request_receivedWS event to all space managers (owner +MANAGE_SPACEholders)
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):
- Insert
space_membersrow - Update request status to
'accepted', setdecidedByanddecidedAt connectionManager.addUserSpace- Broadcast
member_joinedto space - Build full
SpaceWithChannelsAndMembersfor accepted user - Send
join_request_acceptedWS event to requesting user (includes full space data)
Decline flow:
- Update request status to
'declined' - Send
join_request_declinedWS 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, ORhasPermission(userId, spaceId, PermissionBits.MANAGE_SPACE)returns true
Membership
Join
Three join paths:
- Invite code —
POST /api/spaces/:id/joinorPOST /api/spaces/join - Public join —
POST /api/spaces/:id/public-join(visibility=public) - Request accept —
PATCH /api/spaces/:id/join-requests/:requestIdwithaction: '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:
- Delete
space_membersrow - Delete
voice_restrictionsfor the member in this space - Delete
read_statesfor the member in all space channels - Broadcast
member_leftWS 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:
- Insert
bansrow (withreason,bannedBy,createdAt) - Delete
space_members - Delete
member_roles - Delete
read_statesfor all space channels - Delete
voice_restrictions
WS events:
member_leftto space (so other members update their list)member_bannedto the banned user (withreason)
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_rolesthen 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_PERMISSIONSif 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:
nullto unassign, or valid category ID in same space
Broadcast behavior:
- If
categoryIdchanged: callsbroadcastOverrideChange(per-user VIEW_CHANNEL recheck, may sendchannel_deletedto users who lost access) - Otherwise: simple
channel_updatedbroadcast to channel viewers
Delete Channel
Endpoint: DELETE /api/channels/:id
Permission: MANAGE_CHANNELS
Cleanup sequence:
- Disconnect all voice participants (if voice channel)
- Collect viewer IDs before deletion (for targeted broadcast)
- Collect attachment filenames before cascade
- Delete
read_states(no FK) - Delete
messages(attachments cascade) - Delete
channelsrow - Delete attachment files from disk
- Broadcast
channel_deletedonly 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:
{
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 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):
type SpaceLayoutItem =
| { t: 's'; id: string } // space reference
| { t: 'f'; id: string }; // folder reference
Folders (SpaceFolder):
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:
{
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:
- For each folder in request: create new / update existing / adopt remote
- Clear and re-insert
space_folder_memberswith ordered positions - Delete folders not in request (with their members)
- Replace
new:*keys in items array with resolved IDs - Upsert
user_space_layoutrow (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:
- Optimistically applies the layout with
Date.now()timestamp - Collects all targets: home API + all connected remote instance APIs
Promise.allSettledpushes to all targets in parallel- Uses the first successful response to resolve
new:*folder IDs - Updates store with resolved layout
WS Ready Payload
The space layout and folder data are delivered in the WS ready event (handler.ts:buildReadyPayload):
{
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 (
''): returnsauthStore.user.id - Remote: checks
_myUserIdByOrigincache (populated from WS ready events), falls back toinstanceStoreresolver
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:
populateFromReadyprocesses spaces from a remote originloadSpaceDetailloads a remote spacejoinByCodereturns a remote spaceexploreStore.fetchSpacesprocesses remote explore results
Client Load State (useSpaceStore)
Two distinct flags track per-space load progress:
loadingSpaceId: string | null— non-null while aloadSpaceDetailcall is in flight. Drives the channel-list and member-list skeletons (gated throughuseDelayedLoading).loadedSpaceIds: Set<string>— populated only on successfulloadSpaceDetailcompletion. 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 becausestate.channelsis overwritten on eachloadSpaceDetailand a fresh space switch leavesspaceChannelsmomentarily filtered to[].
loadedSpaceIds lifecycle:
- Added on
loadSpaceDetailsuccess (the sameset()that replaceschannels/categories/members). - Pruned per-space on
deleteSpace,leaveSpace,removeSpace,removeInstanceSpaces. - Wiped entirely on
reset(logout). - Ephemeral — not persisted.