# 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 |
