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>
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.tsand is consumed by this area, but is reused beyond it).
Key concepts
- Server — a joinable chat server. Persisted as
ServerEntity(serverstable). - Public / private —
isPrivateflag. 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 afterSERVER_INVITE_EXPIRY_MS(10 days). - Join request — a pending request on a private server (
JoinRequestEntity),pending → approved | denied. - Membership — a
ServerMembershipEntityrow, indexed byserverId+userId. - Ban — a
ServerBanEntityrow, optionallyexpiresAt. Auto-pruned on the next join attempt for the banned user. - Heartbeat — periodic
POST /:id/heartbeatfrom the server owner's client that updateslastSeenandcurrentUserson 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;404for 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, callsnotifyUser(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 byisPrivate = 0, loads relations.getServerByIdgetJoinRequestByIdgetPendingRequestsForServer
Commands (server/src/cqrs/commands/handlers/):
upsertServer— also callsreplaceServerRelationsto synctags,channels,roles,roleAssignments,channelPermissionsatomically.deleteServercreateJoinRequestupdateJoinRequestStatus— emits anotifyUserevent 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(tableservers) —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(defaultpending),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— addspasswordHashtoservers; createsserver_memberships,server_invites,server_banswith 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—ServerDirectoryApiServiceexposessearchServers,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.tsplusapplication/facades/. - Multi-endpoint awareness: Toju supports several federated signaling endpoints;
findServerAcrossActiveEndpointsqueries each and merges results.
Business rules
- Public-only listing:
GET /only returns servers withisPrivate = 0. Private servers must be reached by ID + invite. - Owner immutability: only
currentOwnerIdmatchingserver.ownerIdmay update; onlyownerIdmatchingserver.ownerIdmay delete. - Join order of checks (on
POST /:id/join): existence → ban check (auto-prune expired bans) → password check (ifpasswordHash) → invite check (if private and no invite) → membership upsert → returnsignalingUrl. - Invite expiry: 10 days (
SERVER_INVITE_EXPIRY_MS = 10 * 24 * 60 * 60 * 1000). Expired invites are pruned on access viapruneExpiredServerAccessArtifacts(). - Ban expiry: optional
expiresAt; auto-deleted on next join attempt for that user. - Join request notifications: on
PUT /requests/:id, after CQRS dispatch,notifyUserpushes the new status over WebSocket to any open connection foruserPublicKey/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:
passwordHashonly; never the cleartext. TODO: confirm the hashing algorithm (likely bcrypt / scrypt — verify inserver/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/joinfor 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 viadata/variables.json.- Per-server
maxUsers,slowModeInterval,isPrivate,passwordHashare operator-configurable viaPUT /: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.tscurrently 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_requestsmigration is undocumented (file not located by inspection); confirm during the next schema change.
Related features
- websocket-envelopes —
join_serverenvelope re-uses this area's access rules viaauthorizeWebSocketJoin.notifyUserfan-out for join-request decisions is delivered over the same WebSocket. - plugin-system —
join_serverresponses include the joined server'sPluginRequirementsSnapshot.
Changelog
| Date | Change |
|---|---|
| 2026-05-25 | Initial documentation |