Files
Toju/agents-docs/features/server-directory.md
brogeby b19c39208c docs: populate initial cross-context feature docs
Add area-level documentation for the five most significant cross-context
feature areas under agents-docs/features/:

- websocket-envelopes: full envelope catalogue, lifecycle, dispatcher
- ipc-bridge: window.electronAPI surface, IPC channels, CQRS dispatch
- plugin-system: manifest contract, runtime, capabilities, plugin-support API
- server-directory: REST endpoints, CQRS, entities, business rules
- voice-signaling: mesh signaling, RNNoise pipeline, domain split

Update agents-docs/FEATURES.md index alphabetically and remove the
"no cross-context feature docs" placeholder.

Each doc records honest TODOs for verified gaps (stale signaling-contracts.ts,
window.api vs window.electronAPI mismatch, IPC error envelope drift from
CONTEXT.md, missing OpenAPI coverage for server-directory routes, no
envelope round-trip test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:36:36 +02:00

12 KiB

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).

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, voice-signaling).
  • 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 / privateisPrivate 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.tsservers, users.
  • 1000000000001-ServerAccessControl.ts — adds passwordHash to servers; creates server_memberships, server_invites, server_bans with indices.
  • 1000000000002-ServerChannels.tsserver_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.tsServerDirectoryApiService 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.
  • websocket-envelopesjoin_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-systemjoin_server responses include the joined server's PluginRequirementsSnapshot.

Changelog

Date Change
2026-05-25 Initial documentation