# Server Directory > **Area:** server-directory > **Status:** Active > **Last updated:** 2026-05-25 ## Overview The Server Directory is the public REST surface that lists joinable Toju chat servers, manages invites and join requests, gates membership (passwords, bans, ownership), and exposes moderation actions (kick / ban / unban). It is the only feature where the signaling server holds non-ephemeral, multi-user state: the persistent catalog of servers, their access rules, their memberships, and their pending join requests. The renderer's `server-directory` domain consumes this surface to render the "find a server" experience and to drive the join flow that eventually opens a WebSocket (see [websocket-envelopes](./websocket-envelopes.md)). ## Responsibilities - Persist the catalog of servers, their access policy (public/private, password, max users), and ownership. - Mint invites and accept invite redemptions. - Track join requests on private servers and route owner decisions back to the requester. - Track memberships and bans; enforce them on join attempts. - Provide moderation primitives: kick, ban, unban — gated by role/owner permissions. - Emit user-targeted notifications when a join request changes state. This area does **not** own: - Realtime presence, chat, or voice — those flow over the WebSocket once a user has joined (see [websocket-envelopes](./websocket-envelopes.md), [voice-signaling](./voice-signaling.md)). - Per-channel permissions logic (lives in `server/src/services/server-permissions.service.ts` and is consumed by this area, but is reused beyond it). ## Key concepts - **Server** — a joinable chat server. Persisted as `ServerEntity` (`servers` table). - **Public / private** — `isPrivate` flag. Public servers appear in directory listings; private servers do not. - **Invite** — an opaque token (`ServerInviteEntity`) that grants short-lived access to a specific server. Expires after `SERVER_INVITE_EXPIRY_MS` (10 days). - **Join request** — a pending request on a private server (`JoinRequestEntity`), `pending → approved | denied`. - **Membership** — a `ServerMembershipEntity` row, indexed by `serverId` + `userId`. - **Ban** — a `ServerBanEntity` row, optionally `expiresAt`. Auto-pruned on the next join attempt for the banned user. - **Heartbeat** — periodic `POST /:id/heartbeat` from the server owner's client that updates `lastSeen` and `currentUsers` on the directory entry. --- ## API Endpoints All HTTP routes; no auth header — caller identity is supplied per-request in the body (`ownerId`, `actorUserId`, `userId`, `requesterUserId`). Identity is whatever the client claims; authorization is enforced against persisted state. Request body validation is **manual / defensive** (no zod or class-validator). ### `server/src/routes/servers.ts` | Method | Path | Purpose | Auth | |--------|------|---------|------| | `GET` | `/` | List public servers. Query: `q`, `tags`, `limit`, `offset`. | None | | `POST` | `/` | Create a server. Required body: `name`, `ownerId`, `ownerPublicKey`. | Self-asserted | | `GET` | `/:id` | Fetch a single server. 404 if missing. | None | | `PUT` | `/:id` | Update a server. Required body: `currentOwnerId`. Permission check via `canManageServerUpdate`. | Owner / role | | `POST` | `/:id/join` | Join a server. Required body: `userId`. Optional: `password`, `inviteId`. Returns `signalingUrl`. | Self-asserted + access rules | | `POST` | `/:id/invites` | Create an invite. Required body: `requesterUserId`. Delegates to `createServerInvite`. | Role permission | | `POST` | `/:id/moderation/kick` | Kick a user. Required: `actorUserId`, `targetUserId`. Permission: `canModerateServerMember`. | Role permission | | `POST` | `/:id/moderation/ban` | Ban a user. Required: `actorUserId`, `targetUserId`. Optional: `banId`, `reason`, `expiresAt`. | Role permission | | `POST` | `/:id/moderation/unban` | Unban a user. Required: `actorUserId`. Permission: `manageBans`. | Role permission | | `POST` | `/:id/leave` | Leave a server. Required body: `userId`. | Self-asserted | | `POST` | `/:id/heartbeat` | Update `lastSeen` and `currentUsers`. Optional body: `currentUsers`. | None (TODO: confirm) | | `DELETE` | `/:id` | Delete a server. Required body: `ownerId` (must match `server.ownerId`). | Owner | | `GET` | `/:id/requests` | List pending join requests. Query: `ownerId`. | Owner | ### `server/src/routes/invites.ts` - `GET /invites/:id` (API) — fetch invite metadata; `404` for expired or unknown invite. - `GET /invites/:id` (page router) — server-rendered HTML preview of the invite (server info, owner, expiry); renders an offline state when the server is unreachable. ### `server/src/routes/join-requests.ts` - `PUT /requests/:id` — update join-request status. Body: `ownerId`, `status`. Permission: `manageServer`. On success, calls `notifyUser` (WebSocket fan-out, see below). ### Standard error codes `SERVER_NOT_FOUND`, `MISSING_USER`, `NOT_AUTHORIZED`, `BANNED`, `PASSWORD_REQUIRED`, `INVITE_EXPIRED`, plus 400 for missing required fields. --- ## CQRS handlers `server/src/cqrs/` backs every mutation; routes are thin adapters around CQRS dispatch. **Queries** (`server/src/cqrs/queries/handlers/`): - `getAllPublicServers` — filtered by `isPrivate = 0`, loads relations. - `getServerById` - `getJoinRequestById` - `getPendingRequestsForServer` **Commands** (`server/src/cqrs/commands/handlers/`): - `upsertServer` — also calls `replaceServerRelations` to sync `tags`, `channels`, `roles`, `roleAssignments`, `channelPermissions` atomically. - `deleteServer` - `createJoinRequest` - `updateJoinRequestStatus` — emits a `notifyUser` event so the requesting user's client learns the outcome over WebSocket. All handlers run inside TypeORM transactions where multi-table changes are involved. --- ## Persistence ### Entities (`server/src/entities/`) - `ServerEntity` (table `servers`) — `id`, `name`, `description`, `ownerId`, `ownerPublicKey`, `passwordHash`, `isPrivate`, `maxUsers`, `currentUsers`, `icon`, `iconUpdatedAt`, `slowModeInterval`, `createdAt`, `lastSeen`. - `ServerInviteEntity` (`server_invites`) — `id`, `serverId` (indexed), `createdBy`, `createdByDisplayName`, `createdAt`, `expiresAt` (indexed). - `JoinRequestEntity` (`join_requests`) — `id`, `serverId` (indexed), `userId`, `userPublicKey`, `displayName`, `status` (default `pending`), `createdAt`. - `ServerMembershipEntity` (`server_memberships`) — `id`, `serverId` (indexed), `userId` (indexed), `joinedAt`, `lastAccessAt`. - `ServerBanEntity` (`server_bans`) — `id`, `serverId` (indexed), `userId` (indexed), `bannedBy`, `displayName`, `reason`, `expiresAt` (nullable), `createdAt`. Related (referenced by `replaceServerRelations`): `ServerChannelEntity`, `ServerRoleEntity`, `ServerUserRoleEntity`, `ServerTagEntity`, `ServerChannelPermissionEntity`. ### Migrations (`server/src/migrations/`) - `1000000000000-InitialSchema.ts` — `servers`, `users`. - `1000000000001-ServerAccessControl.ts` — adds `passwordHash` to `servers`; creates `server_memberships`, `server_invites`, `server_bans` with indices. - `1000000000002-ServerChannels.ts` — `server_channels`. - `1000000000005-ServerRoleAccessControl.ts` — role/permission tables. - TODO: locate the migration that created `join_requests` (not obvious from filenames; likely folded into an earlier migration). --- ## Renderer side `toju-app/src/app/domains/server-directory/`: - **API client**: `infrastructure/services/server-directory-api.service.ts` — `ServerDirectoryApiService` exposes `searchServers`, `getServers`, `getServer`, `findServerAcrossActiveEndpoints`, `registerServer`, `updateServer`, `requestJoin`, `createInvite`, `getInvite`, `kickServerMember`, `banServerMember`, `unbanServerMember`, `notifyLeave`, `sendHeartbeat`. Defensive coercion (`getNumberValue` / `getStringValue` / `getBooleanValue`) is used instead of schema validation. - **State**: signal-based via `ServerEndpointStateService` (servers, active server) — not NgRx for this slice. - **Facade**: `application/services/server-directory.service.ts` plus `application/facades/`. - **Multi-endpoint awareness**: Toju supports several federated signaling endpoints; `findServerAcrossActiveEndpoints` queries each and merges results. --- ## Business rules - **Public-only listing**: `GET /` only returns servers with `isPrivate = 0`. Private servers must be reached by ID + invite. - **Owner immutability**: only `currentOwnerId` matching `server.ownerId` may update; only `ownerId` matching `server.ownerId` may delete. - **Join order of checks** (on `POST /:id/join`): existence → ban check (auto-prune expired bans) → password check (if `passwordHash`) → invite check (if private and no invite) → membership upsert → return `signalingUrl`. - **Invite expiry**: 10 days (`SERVER_INVITE_EXPIRY_MS = 10 * 24 * 60 * 60 * 1000`). Expired invites are pruned on access via `pruneExpiredServerAccessArtifacts()`. - **Ban expiry**: optional `expiresAt`; auto-deleted on next join attempt for that user. - **Join request notifications**: on `PUT /requests/:id`, after CQRS dispatch, `notifyUser` pushes the new status over WebSocket to any open connection for `userPublicKey` / `userId`. ## Security considerations - **No authentication header.** All identity is self-asserted in the request body. Authorization is enforced by checking the claimed identity against persisted role/owner state. - **Password storage**: `passwordHash` only; never the cleartext. TODO: confirm the hashing algorithm (likely bcrypt / scrypt — verify in `server/src/services/`). - **SSRF**: routes in this area do not fetch user-supplied URLs, so the SSRF guard does not apply here (it applies to link-metadata, klipy, proxy). - **No rate limiting** on directory or moderation routes — TODO: add brute-force protection on `POST /:id/join` for password attempts. - **No CSRF** (REST + JSON body, no cookies in scope), but spam protection on `POST /` (server creation) is also TODO. ## Configuration - `SERVER_INVITE_EXPIRY_MS` — currently hardcoded at 10 days. Not exposed via `data/variables.json`. - Per-server `maxUsers`, `slowModeInterval`, `isPrivate`, `passwordHash` are operator-configurable via `PUT /:id`. ## Testing - **Server-side**: no direct route specs for `servers.ts`, `invites.ts`, `join-requests.ts`. WebSocket-side handlers (`handler-status.spec.ts`, `handler-plugin.spec.ts`) cover adjacent concerns. - **Renderer-side**: `application/services/server-endpoint-state.service.spec.ts`. - **E2E**: TODO — verify whether the Playwright suite covers join / invite / moderation end-to-end. - **Gap**: routes that mutate persistent state and accept self-asserted identity should ideally have integration tests against a real DB. ## Known issues and limitations - **OpenAPI coverage is incomplete.** `server/src/routes/openapi-docs.ts` currently documents plugin-support endpoints only; server-directory endpoints are not listed. - **No structured request validation library.** Inline manual checks are error-prone; consider zod once the team is ready. - **No rate limiting / spam protection** on server creation or join attempts. - **`join_requests` migration is undocumented** (file not located by inspection); confirm during the next schema change. ## Related features - **[websocket-envelopes](./websocket-envelopes.md)** — `join_server` envelope re-uses this area's access rules via `authorizeWebSocketJoin`. `notifyUser` fan-out for join-request decisions is delivered over the same WebSocket. - **[plugin-system](./plugin-system.md)** — `join_server` responses include the joined server's `PluginRequirementsSnapshot`. ## Changelog | Date | Change | |------|--------| | 2026-05-25 | Initial documentation |