Fills the five highest-value gaps under agents-docs/features/ so the index covers the system's main cross-context contracts. Each doc follows the feature-template structure and the AGENTS_FEATURES.md contract, with honest TODOs where coverage or behavior couldn't be confirmed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
15 KiB
Access Control
Area: access-control Status: Active Last updated: 2026-05-25
Overview
Access control is the permission engine under every Toju server: roles and their assignments, per-channel permission overrides, server membership, invites, bans, and slowmode. It runs in two places at once. The signaling server enforces it as the source of truth on REST mutations and on the join_server WebSocket gate. The Angular product client maintains a parallel resolution path so the UI can disable controls the user is not allowed to use, but those client-side guards are advisory — the server is authoritative.
Most concepts here were introduced over two migrations: 1000000000001-ServerAccessControl.ts added memberships, bans, and invites; 1000000000005-ServerRoleAccessControl.ts introduced first-class roles, role assignments, and per-channel overrides. The client carries a back-compat path that re-derives the legacy RoomPermissions booleans from the new role state for older UI code paths.
Responsibilities
- Define and persist roles, role assignments, and per-channel permission overrides.
- Decide whether an identity is allowed to enter a server (
authorizeWebSocketJoin) and, if not yet a member, what step is required (open join, password, invite). - Maintain memberships, bans, and invites with their lifecycles (invite expiry, ban expiry).
- Resolve the effective permission state for a (user, channel) pair via role-position precedence and channel overrides.
- Hydrate the room model on join so the client can apply the same resolution locally.
- Guard moderation actions against privilege escalation via role-position checks.
This area does not own:
- Server catalog, discoverability, search, or moderation reports → server-directory.
- Authentication or identity binding → authentication.
- The wire format of
join_server,access_denied, role/ban update envelopes → websocket-envelopes. - Online status, voice presence, game activity → presence.
Key concepts
- Role — named row in
server_rolewith aposition(higher number = higher precedence) and aPermissionStatePayloadof per-key overrides (allow | deny | inherit). - System role — bootstrap roles seeded by
1000000000005-ServerRoleAccessControl.ts:system-everyone,system-moderator,system-admin.system-everyoneapplies to all members; the others are assignable. - RoleAssignment — (server, user, role) row in
server_user_role. - ChannelPermissionOverride — (server, channel, role) row in
server_channel_permissioncarrying allow / deny / inherit per permission key on top of the role's base state. - Membership — (server, user) row in
server_membershipwithcreatedAtand optional metadata. - Invite — single-use or multi-use server invite in
server_invite; default expiry 10 days from creation. - Ban — (server, user) entry in
server_banwith optionalexpiresAtandreason. Persists across reconnects. - Slowmode — per-channel send interval (
slowModeIntervalon the channel) — currently a client-rendered hint, not enforced server-side (see TODOs).
Permission keys
Defined as a PermissionStatePayload in server/src/cqrs/types.ts. ~11 keys gate distinct capabilities:
manageServer— edit server metadata (name, icon, settings).manageChannels— create / edit / delete channels.manageRoles— create / edit / delete roles (subject to role-position guard, see below).moderateMember— kick / ban / mute / change-nick on lower-positioned members.inviteMember— create invites.viewChannel— see a channel exists.readMessages— read message history in a channel.writeMessages— send messages.manageMessages— delete / pin others' messages.connectVoice— join a voice room.speakVoice— un-mute in a voice room.
Each key may be allow, deny, or inherit on a given role. The default state on system-everyone is permissive for "read / view / write" and restrictive for "manage / moderate"; see the migration for the seed values.
Resolution algorithm
For a (user, channel) lookup:
- Collect the user's role assignments for the server, plus the implicit
system-everyone. - Order roles by
positionascending — lowest position resolved first, highest position resolved last (last-writer-wins). - For each role, apply its base
PermissionStatePayloadto a running accumulator:allowanddenyoverwrite;inheritleaves the prior value intact. - Apply the channel's per-role overrides (
server_channel_permission) on top, in the same position order. - The accumulator's final value per key is the effective permission.
Because there is no inherent deny > allow priority, a higher-positioned role's allow will override a lower-positioned role's deny on the same key. This is the intentional Discord-style "promote to override" model. Document any deviation from this when introducing a new key.
Membership state machine
Entry into a server runs through joinServerWithAccess in server/src/services/server-access.service.ts:
- Look up the server. If missing → reject.
- Look up the user's membership. If active → fast-path success.
- Look up an active ban for
(server, user). If present and not expired → reject withbanned. - If the server is public (
isPublic = true, no password, no invite required) → create membership, return success. - If the server is password-protected (
hasPassword = true) → require the supplied password to hash-match. If mismatch → reject withpassword_required/bad_password. - If the server is invite-only → require a valid (
server_invite.code) and non-expired invite. Consume the invite if single-use. - On success → insert a
server_membershiprow, return ok.
authorizeWebSocketJoin is the lighter gate used on the join_server WebSocket envelope: it short-circuits to "allowed" if a membership already exists, otherwise reports the access mode needed. Unlike joinServerWithAccess, it does not consume invites or process passwords — those flow through dedicated REST routes on the server before the client retries the WebSocket join.
handleJoinServer (server/src/websocket/handler.ts:155) is the call site: on rejection the server sends access_denied with a reason (banned, password_required, invite_required, ...); on success it broadcasts presence (user_joined) per the rules documented in presence.
Moderation actions
Moderation is gated by two helpers in server/src/services/server-permissions.service.ts:
canManageServerUpdate(actorRoles, requested)— maps the requested change (rename, icon change, permission edit, role create, invite, etc.) to the permission key it needs, then resolves the actor's effective state.canModerateServerMember(actorHighestRole, targetHighestRole)— privilege-escalation guard: the actor's highest-position role must be strictly greater than the target's highest-position role. Two moderators at the same position cannot ban each other.
Bans are written via banServerUser. The ban entity supports an optional expiresAt for time-limited bans and a reason string for moderator-facing UI.
Kick is implemented as "delete membership"; the connection is dropped via a subsequent WebSocket close on the next envelope.
Client-side hydration
When the client joins a server, the server sends the room model with normalized access-control fields:
roles: ServerRolePayload[]
roleAssignments: RoleAssignmentPayload[]
channelPermissions: ChannelPermissionPayload[]
permissions: legacy RoomPermissions bools (back-compat)
slowModeInterval: number | undefined (per-channel)
normalizeRoomAccessControl in toju-app/src/app/shared-kernel/room.models.ts is the single normalization helper. It:
- Backfills the legacy
permissionsbooleans from the role state so older NgRx selectors keep working. - Sorts roles by
positionascending. - De-duplicates assignments by
(userId, roleId).
Client-side resolution mirrors the server algorithm and lives under toju-app/src/app/domains/access-control/domain/rules/. Selectors expose canSendMessage(channelId), canManageRole(roleId), etc., which UI components consume to disable controls.
canManageRole enforces the same privilege-escalation guard as the server: a user cannot edit a role at or above their own highest position.
Invites
server_invite rows have:
code— opaque token used in invite URLs.serverId,createdById.expiresAt— default 10 days from creation.maxUses/uses— single-use or multi-use semantics.
The REST surface for invite creation, lookup, and consumption is part of server-directory. Invite consumption is transactional: a single-use invite is decremented before the membership row is created so a race cannot create two memberships off one invite.
Bans
server_ban rows carry userId, serverId, optional expiresAt, optional reason, and createdById. Active bans are matched on (serverId, userId) with the expiry filter applied at read time. A ban broadcast envelope notifies connected peers so the client can drop the banned user from the local room model — TODO: confirm whether such an envelope exists today or whether clients only learn of bans on the next server_users snapshot.
Slowmode
slowModeInterval is a per-channel hint expressed in seconds. The client renders the cooldown UI and is expected to gate the send button locally. The server does not enforce slowmode today — a non-cooperating client can ignore the interval. This is a known gap; see TODOs.
Business rules and invariants
- Server is authoritative. Client-side
canXselectors are advisory and exist to prevent UI confusion, not to gate security. - Last-writer-wins by position — there is no inherent allow/deny priority; a higher-positioned role can override a lower-positioned role's deny.
- Privilege escalation is blocked by requiring strict position-greater on the moderator. Same-position moderation is rejected on both sides.
- Invite consumption is atomic for single-use invites.
- Bans persist independently of memberships — a banned user without a membership row is still banned.
system-everyonealways applies at position 0 (or whatever the migration seeds); it cannot be removed.- Channel overrides resolve last after role base state.
Technical implementation
Server
- Services —
server/src/services/server-access.service.ts(authorizeWebSocketJoin,joinServerWithAccess,ensureServerMembership,banServerUser,leaveServerUser);server/src/services/server-permissions.service.ts(getServerRoles,getServerAssignments,resolveRolePermissionState,resolveHighestRole,canManageServerUpdate,canModerateServerMember). - Entities —
server/src/entities/ServerMembershipEntity.ts,ServerBanEntity.ts,ServerInviteEntity.ts,ServerRoleEntity.ts,ServerUserRoleEntity.ts,ServerChannelPermissionEntity.ts. - Migrations —
1000000000001-ServerAccessControl.ts,1000000000005-ServerRoleAccessControl.ts. - CQRS — payload types in
server/src/cqrs/types.ts(AccessRolePayload,PermissionStatePayload,RoleAssignmentPayload,ChannelPermissionPayload); normalization inserver/src/cqrs/relations.ts(normalizeServerRoles,normalizeServerRoleAssignments). - REST — server / role / invite / ban routes are mounted under
server/src/routes/servers.ts(catalog endpoints are documented in server-directory). - WS —
server/src/websocket/handler.ts::handleJoinServer(line 155),access_deniedemission.
Product client
- Domain —
toju-app/src/app/domains/access-control/. - Shared kernel —
toju-app/src/app/shared-kernel/access-control.models.ts,moderation.models.ts,room.models.ts(normalizeRoomAccessControl). - NgRx — access-control reducers / selectors under
toju-app/src/app/store/access-control/.
Testing
- TODO: no spec was located for
server-access.service.tsorserver-permissions.service.ts. - TODO: no spec was located for the
authorizeWebSocketJoinrejection paths. - Client-side rule specs likely exist under
toju-app/src/app/domains/access-control/domain/rules/*.spec.ts— confirm and list when filling in. - TODO: E2E coverage for invite consumption, ban enforcement, and role-position escalation.
Security considerations
- Slowmode is not server-enforced. A modified client can spam. Treat slowmode as an anti-accident measure, not abuse mitigation.
- No server-side channel-permission enforcement on message send — only role-state is checked at the join gate. TODO: verify whether per-channel overrides are applied on the message-send path.
- No audit log of moderation actions. TODO.
- Password-protected servers rely on the same SHA-256 hashing used for AuthUser passwords — see Security in authentication; the same caveats apply.
- Position-based escalation guard works only if positions are well-ordered. Position assignment is on
manageRoles-bearing users; misconfiguration can produce moderators who can demote each other arbitrarily. - Bans are not always broadcast in real time — clients may discover an ongoing ban only on the next
server_userssnapshot. TODO: confirm the live ban envelope.
Performance considerations
- Role and assignment lookups are point queries on
server_role/server_user_roleindexed byserverId. - Per-channel override resolution is O(roles × channels) at hydration time; happens once on
join_serverand is cached client-side. authorizeWebSocketJoinis O(1) for existing members (single membership lookup), O(1)–O(invites) for invite consumption.
Known issues and limitations
- No server-side slowmode enforcement.
- No dedicated ban-broadcast envelope (or unconfirmed).
- No audit log for moderation actions.
- Channel-permission overrides may not be applied on the message-send path server-side — TODO.
- Unsalted SHA-256 for password-protected server passwords — same gap as user passwords.
Related features
- server-directory — owns server catalog, discoverability, and the REST surface for invites/bans/roles.
- authentication — provides the
oderIdidentity that access-control authorizes. - presence —
user_joined/user_leftare emitted only afterauthorizeWebSocketJoinsucceeds. - websocket-envelopes — owns the wire shape of
join_server,access_denied, role/ban update envelopes.
Changelog
| Date | Change |
|---|---|
| 2026-05-25 | Initial documentation |