docs: add cross-context feature docs for auth, presence, access-control, messaging, attachments

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>
This commit is contained in:
2026-05-25 22:33:41 +02:00
parent d5ef0b84d8
commit 47beed01ca
6 changed files with 1018 additions and 0 deletions

View File

@@ -8,8 +8,13 @@ It must stay accurate as new features are introduced, renamed, merged, or remove
## Feature list (alphabetical)
- [access-control](./features/access-control.md) — Roles, role assignments, channel permission overrides, memberships, invites, bans, and slowmode.
- [attachments](./features/attachments.md) — P2P chunked file-transfer protocol over the WebRTC chat data channel, storage decisions, auto-download rules.
- [authentication](./features/authentication.md) — User account REST surface, WebSocket `identify` handshake, heartbeat sweep, and Electron Local API tokens.
- [ipc-bridge](./features/ipc-bridge.md) — Electron preload `window.electronAPI` surface, IPC channels, and CQRS dispatch.
- [messaging](./features/messaging.md) — Server-channel chat, direct messages, inventory-sync protocol, delivery state machine.
- [plugin-system](./features/plugin-system.md) — Plugin manifest contract, renderer runtime, capability grants, and server `plugin-support` API.
- [presence](./features/presence.md) — Connection lifecycle, availability status, profile metadata propagation, voice membership, and game activity.
- [server-directory](./features/server-directory.md) — REST surface for server catalog, invites, join requests, and moderation.
- [voice-signaling](./features/voice-signaling.md) — WebRTC mesh signaling, RNNoise pipeline, and voice / direct-call / screen-share orchestration.
- [websocket-envelopes](./features/websocket-envelopes.md) — Wire-format contract for every realtime envelope between server and clients.

View File

@@ -0,0 +1,232 @@
# 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](./server-directory.md).
- Authentication or identity binding → [authentication](./authentication.md).
- The wire format of `join_server`, `access_denied`, role/ban update envelopes → [websocket-envelopes](./websocket-envelopes.md).
- Online status, voice presence, game activity → [presence](./presence.md).
## Key concepts
- **Role** — named row in `server_role` with a `position` (higher number = higher precedence) and a `PermissionStatePayload` of per-key overrides (`allow | deny | inherit`).
- **System role** — bootstrap roles seeded by `1000000000005-ServerRoleAccessControl.ts`: `system-everyone`, `system-moderator`, `system-admin`. `system-everyone` applies to all members; the others are assignable.
- **RoleAssignment** — (server, user, role) row in `server_user_role`.
- **ChannelPermissionOverride** — (server, channel, role) row in `server_channel_permission` carrying allow / deny / inherit per permission key on top of the role's base state.
- **Membership** — (server, user) row in `server_membership` with `createdAt` and 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_ban` with optional `expiresAt` and `reason`. Persists across reconnects.
- **Slowmode** — per-channel send interval (`slowModeInterval` on 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:
1. Collect the user's role assignments for the server, plus the implicit `system-everyone`.
2. Order roles by `position` ascending — **lowest position resolved first**, highest position resolved last (last-writer-wins).
3. For each role, apply its base `PermissionStatePayload` to a running accumulator: `allow` and `deny` overwrite; `inherit` leaves the prior value intact.
4. Apply the channel's per-role overrides (`server_channel_permission`) on top, in the same position order.
5. 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`:
1. Look up the server. If missing → reject.
2. Look up the user's membership. If active → fast-path success.
3. Look up an active ban for `(server, user)`. If present and not expired → reject with `banned`.
4. If the server is **public** (`isPublic = true`, no password, no invite required) → create membership, return success.
5. If the server is **password-protected** (`hasPassword = true`) → require the supplied password to hash-match. If mismatch → reject with `password_required` / `bad_password`.
6. If the server is **invite-only** → require a valid (`server_invite.code`) and non-expired invite. Consume the invite if single-use.
7. On success → insert a `server_membership` row, 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](./presence.md).
---
## 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 `permissions` booleans from the role state so older NgRx selectors keep working.
- Sorts roles by `position` ascending.
- 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](./server-directory.md). 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 `canX` selectors 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-everyone` always 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 in `server/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](./server-directory.md)).
- WS — `server/src/websocket/handler.ts::handleJoinServer` (line 155), `access_denied` emission.
### 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.ts` or `server-permissions.service.ts`.
- TODO: no spec was located for the `authorizeWebSocketJoin` rejection 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](./authentication.md); 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_users` snapshot. TODO: confirm the live ban envelope.
---
## Performance considerations
- Role and assignment lookups are point queries on `server_role` / `server_user_role` indexed by `serverId`.
- Per-channel override resolution is O(roles × channels) at hydration time; happens once on `join_server` and is cached client-side.
- `authorizeWebSocketJoin` is 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](./server-directory.md)** — owns server catalog, discoverability, and the REST surface for invites/bans/roles.
- **[authentication](./authentication.md)** — provides the `oderId` identity that access-control authorizes.
- **[presence](./presence.md)** — `user_joined` / `user_left` are emitted only after `authorizeWebSocketJoin` succeeds.
- **[websocket-envelopes](./websocket-envelopes.md)** — owns the wire shape of `join_server`, `access_denied`, role/ban update envelopes.
## Changelog
| Date | Change |
|------|--------|
| 2026-05-25 | Initial documentation |

View File

@@ -0,0 +1,196 @@
# Attachments
> **Area:** attachments
> **Status:** Active
> **Last updated:** 2026-05-25
## Overview
Attachments are pure peer-to-peer in Toju. The signaling server never sees a file byte. A sender announces an attachment on the WebRTC chat data channel; a receiver requests it; the sender streams base64-encoded 64 KiB chunks back; the receiver reassembles and (on Electron) writes the result to disk under a per-conversation folder. If the original sender goes offline mid-transfer, the receiver can re-request from another peer that previously announced the same attachment. There is no inventory protocol, no integrity signature, and no server-side fallback — attachments live entirely on the participants' machines.
This area is the closest sibling of [voice-signaling](./voice-signaling.md): both are P2P protocols that ride the same RTCPeerConnection. The chat events that drive attachments are members of the `ChatEvent` union; they share the data channel with chat messages but are conceptually distinct.
## Responsibilities
- Define the file-transfer envelope set (announce / request / chunk / cancel / not-found) and its sequencing rules.
- Maintain per-transfer state on both sides — chunk index, in-flight chunk, retry/failover bookkeeping.
- Decide whether to auto-download (size + media-type heuristic).
- Decide where to persist (Electron disk vs browser memory).
- Estimate transfer speed via EWMA so the UI can render a progress bar that doesn't jitter.
- Pick a failover peer when the current sender disappears.
This area does **not** own:
- The chat message that references the attachment → [messaging](./messaging.md).
- The peer connection or data channel itself → [voice-signaling](./voice-signaling.md).
- The IPC channels used to read / write the file on Electron → [ipc-bridge](./ipc-bridge.md).
- Permission to upload — there is no formal upload gate today; access-control's `writeMessages` is the proxy. See [access-control](./access-control.md).
## Key concepts
- **Attachment** — a file announced and referenced by a chat message. Persisted independently of the message body.
- **Transfer** — the per-receiver state for a single in-flight attachment.
- **Bucket** — storage subfolder: `image | video | audio | files`. Determined by MIME type.
- **Tried-peer set** — the set of peers a receiver has already attempted for a given `${messageId}:${fileId}`; used to drive failover without re-trying the same peer in a loop.
- **`uploaderPeerId`** — the original announcer; the receiver prefers it over the tried-peer set when (re-)issuing a `file-request`.
---
## Protocol
The five events live in the `ChatEvent` union (`toju-app/src/app/shared-kernel/chat-events.ts`) and ride the WebRTC `chat` data channel. They do **not** flow through the WebSocket signaling server.
- `file-announce` — sender announces an attachment alongside a chat message. Carries `messageId`, `fileId`, `name`, `size`, `mimeType`, optional preview metadata.
- `file-request` — receiver requests the attachment from a specific peer.
- `file-chunk` — sender streams `index`, base64-encoded chunk payload, and `total` chunk count.
- `file-cancel` — either side aborts the in-flight transfer.
- `file-not-found` — sender responds when asked for an unknown `fileId`.
### Constants
Defined in the attachment domain (`toju-app/src/app/domains/attachment/`):
- `P2P_BASE64_CHUNK_SIZE_BYTES = 64 * 1024` — re-exported as `FILE_CHUNK_SIZE_BYTES`. Shared with the avatar P2P sync path.
- `MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024` — files at or under 10 MiB are auto-downloaded on receipt.
- `MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES = 50 * 1024 * 1024` — browser-mode cap on inlined media.
- **EWMA weights** — previous-weight `0.7`, current-weight `0.3` for transfer-rate smoothing.
- **Data-channel water marks** — `highWaterMark = 4 MiB`, `lowWaterMark = 1 MiB` for backpressure pacing.
### Flow
1. Sender computes attachment metadata and emits `file-announce` referencing the chat message.
2. Receiver opens a transfer state. Auto-download triggers if `size ≤ MAX_AUTO_SAVE_SIZE_BYTES` and the MIME type is in the allow-list for the bucket. Larger files require an explicit user click.
3. Receiver sends `file-request` to `uploaderPeerId`.
4. Sender streams `file-chunk` events sequentially. **Exactly one chunk is in flight per receiver at a time** — the sender awaits the per-chunk write/ack before queueing the next one. On Electron the receiver writes each chunk to disk; the protocol requires `index === receivedCount` for the next chunk or the transfer aborts.
5. Receiver reassembles. On Electron, the file lands at:
- `{appData}/server/{room}/{bucket}/{id}{.ext}` for server-channel attachments.
- `{appData}/direct-messages/{conv}/{bucket}/{id}{.ext}` for DM attachments.
- Browser mode keeps the file as a Blob in memory — lost on reload.
6. Either side may `file-cancel`; the sender returns `file-not-found` if the requested `fileId` is unknown.
### Failover
- Receiver-driven. No inventory protocol.
- Sequential — tries one peer at a time.
- The tried-peer set is keyed by `${messageId}:${fileId}`.
- `uploaderPeerId` is always preferred when reachable; the tried-peer set ensures it isn't re-attempted in a busy loop after a failure.
- If every available peer is in the tried set, the transfer ends in a not-found state and surfaces a UI prompt.
---
## Storage
### Electron
- `AttachmentEntity` — TypeORM row in the per-user local database. Carries `id`, `messageId`, `roomId` / `conversationId`, `name`, `size`, `mimeType`, `bucket`, `relativePath`, `createdAt`.
- CQRS commands — `save-attachment`, `delete-attachments-for-message`.
- CQRS queries — `get-all-attachments`, `get-attachments-for-message`.
- Filesystem IPC — `read-file-chunk`, `get-file-size`, `write-file`, `append-file`, `get-file-url`, `file-exists`, `delete-file`, `ensure-dir`, `get-app-data-path`.
The renderer never touches Node.js filesystem APIs directly; every read/write is brokered through [ipc-bridge](./ipc-bridge.md).
### Browser
When the desktop shell is not present, attachments stay in-memory as Blob URLs. Reloading the renderer loses them; this is documented behavior, not a bug.
---
## Auto-download heuristic
- Any file with `size ≤ 10 MiB` and a media MIME type (`image/*`, `video/*`, `audio/*`) is auto-downloaded on receipt so the chat UI can render it inline.
- Files above the cap or in the `files` bucket require an explicit click. The chat UI shows a "Download" affordance with the file size.
---
## Speed estimation (EWMA)
Transfer rate is exposed to the UI via an exponentially-weighted moving average:
```
rate_t = 0.7 · rate_{t-1} + 0.3 · instantaneous_t
```
Smooth enough for a stable progress display; responsive enough to surface a stalled transfer within a few seconds.
---
## Business rules and invariants
- Attachments are **pure P2P** — the signaling server never sees an attachment byte.
- **One chunk in flight per sender → receiver** (`await` per chunk). No parallelism within a single transfer.
- **Sequential chunk indices on Electron disk receive** — `index === receivedCount` is enforced; mismatches abort.
- **`PeerDeliveryService` is not on the attachment path.** Attachments use `RealtimeSessionFacade.broadcastMessage` / `sendToPeer` / `sendToPeerBuffered` directly.
- **Browser mode loses everything on reload** — no IndexedDB persistence today for attachments.
- **No integrity / signature check** on chunks; no encryption at rest beyond OS file permissions.
- **Failover is receiver-driven** and tried-peer-set deduplicated.
---
## Technical implementation
### Product client
- Domain — `toju-app/src/app/domains/attachment/`: manager, transfer state, persistence selection.
- Contracts — `toju-app/src/app/shared-kernel/attachment-contracts.ts`, `chat-events.ts` (the five envelope types).
- Realtime send paths — `RealtimeSessionFacade.broadcastMessage` / `sendToPeer` / `sendToPeerBuffered` in the realtime infrastructure tree.
### Electron
- Entity — `AttachmentEntity` in `electron/entities/`.
- CQRS handlers — under `electron/src/cqrs/` (or equivalent) for `save-attachment`, `delete-attachments-for-message`, `get-all-attachments`, `get-attachments-for-message`.
- Filesystem IPC handlers — `electron/ipc/`: `read-file-chunk`, `get-file-size`, `write-file`, `append-file`, `get-file-url`, `file-exists`, `delete-file`, `ensure-dir`, `get-app-data-path`.
### Key types
- `AttachmentEntity` — local persistence row.
- `FileChunkEvent`, `FileAnnounceEvent`, `FileRequestEvent`, `FileCancelEvent`, `FileNotFoundEvent` — member shapes of the `ChatEvent` union.
---
## Testing
- TODO: no dedicated `*.spec.ts` files under `toju-app/src/app/domains/attachment/` at time of writing.
- E2E: `e2e/tests/chat/chat-message-features.spec.ts` includes `test('syncs image and file attachments between users', ...)` which covers happy-path attachment sync.
- TODO: no E2E coverage for multi-peer failover.
- TODO: no E2E coverage for `file-cancel`.
---
## Security considerations
- **No integrity signature.** A malicious sender can corrupt a chunk; the receiver assembles whatever arrives.
- **No encryption at rest** beyond OS-level file permissions on the per-user app-data folder.
- **No MIME-type sanitation.** The receiver trusts the announced `mimeType` for bucket routing; a misleading MIME does not change the on-disk contents but does affect inline rendering. Browser-side renderers must defend against this.
- **No size cap server-side.** Caps are receiver-side and advisory: `MAX_AUTO_SAVE_SIZE_BYTES` for auto-download, `MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES` for in-memory media. A sender can announce arbitrarily large files; the receiver simply refuses them.
- **Receivers expose disk write paths** indirectly: a misbehaving peer cannot escape `{appData}/server/...` or `{appData}/direct-messages/...` because the relative path is computed by the receiver, not transmitted by the sender — but this property must be preserved in any future protocol change.
---
## Performance considerations
- **Base64 overhead.** ~33 % inflation on the wire; a 64 KiB binary chunk is ~86 KiB on the wire.
- **Single chunk in flight** per (sender, receiver) — caps single-receiver throughput at one round-trip per chunk.
- **Data-channel water marks** (4 MiB high, 1 MiB low) provide back-pressure pacing without tuning per-NIC.
- **No FEC, no parallel chunks, no resumption across browser reloads.**
---
## Known issues and limitations
- **No dedicated unit specs** for the attachment domain.
- **No resume across browser reloads** (Electron writes to disk and survives; browser does not).
- **No checksum / signed integrity** on chunks.
- **No encryption at rest** beyond OS file permissions.
- **No server-side fallback** if every peer is offline — attachments are unreachable until at least one peer with the file returns.
---
## Related features
- **[messaging](./messaging.md)** — chat messages reference attachments; attachments are persisted separately from message bodies.
- **[voice-signaling](./voice-signaling.md)** — establishes the data channel that attachments ride on.
- **[ipc-bridge](./ipc-bridge.md)** — exposes the filesystem and CQRS APIs the Electron persistence path uses.
- **[websocket-envelopes](./websocket-envelopes.md)** — for context only; attachments do not flow through the signaling server.
## Changelog
| Date | Change |
|------|--------|
| 2026-05-25 | Initial documentation |

View File

@@ -0,0 +1,194 @@
# Authentication
> **Area:** authentication
> **Status:** Active
> **Last updated:** 2026-05-25
## Overview
User identity in Toju is split into two surfaces. A small **HTTP credential surface** on the signaling server (`/api/users/register`, `/api/users/login`) registers and verifies user accounts persisted in TypeORM. A separate **WebSocket `identify` handshake** binds a *self-asserted* identity (`oderId` + display name + optional description) to a live WebSocket connection so the server can route envelopes. There is no server-issued session token: the client re-asserts identity on every reconnect, and other peers trust the claim as far as the signaling fabric does — i.e. only to the extent that subsequent authorization checks (see [access-control](./access-control.md)) accept it.
The Electron desktop shell adds a third surface — the **Local API token store** in `electron/api/auth-store.ts` — which issues short-lived bearer tokens for the in-process HTTP server that hosts the docs site and OpenAPI bundle. That surface is internal to the desktop process and is documented here only because it shares the "authentication" name.
## Responsibilities
- Register and authenticate user accounts against the signaling server's `users` table.
- Bind a connection-scoped identity to a WebSocket connection via the `identify` envelope, including profile metadata propagation.
- Detect dead WebSocket connections via ping/pong sweeps and reap stale `ConnectedUser` rows.
- Mint and validate short-lived bearer tokens for the Electron Local API server.
This area does **not** own:
- Permissions, roles, bans, or membership state → [access-control](./access-control.md).
- Online / away / busy status, voice presence, game activity → [presence](./presence.md).
- The shape of WebSocket envelopes carrying identity claims → [websocket-envelopes](./websocket-envelopes.md).
- Profile avatar bytes or per-user assets → product-client `profile-avatar` domain.
## Key concepts
- **AuthUser** — server-persisted account: `id` (uuid), `username` (unique), `passwordHash`, `displayName`, `createdAt`. Defined in `server/src/entities/AuthUserEntity.ts`.
- **oderId** — client-asserted user identifier sent on `identify`. Used as the broadcast routing key. The server **trusts** it; there is no cryptographic binding between an AuthUser row and the `oderId` claimed over WebSocket.
- **Identify handshake** — first message a client sends on a WebSocket. Carries `oderId`, `displayName`, optional `description`, optional `profileUpdatedAt`, optional `connectionScope`.
- **Connection scope** — opaque string (typically the signal URL the client connected through). Used together with `oderId` to disambiguate multiple sockets per identity so stale-connection eviction does not loop across signal URLs.
- **Local API token** — bearer token issued by the Electron desktop process, 24 h TTL, kept in-memory and pruned on access.
---
## HTTP credential surface
Mounted at `/api/users` (see `server/src/routes/index.ts:20`). All payloads are JSON.
### `POST /api/users/register`
**Request:**
```json
{
"username": "string (required, unique)",
"password": "string (required)",
"displayName": "string (optional, defaults to username)"
}
```
**Response (201):**
```json
{ "id": "uuid", "username": "string", "displayName": "string" }
```
**Errors:**
- **400** `Missing username/password` — either field absent.
- **409** `Username taken` — username already exists.
### `POST /api/users/login`
**Request:** `{ "username": "string", "password": "string" }`
**Response (200):** `{ "id": "uuid", "username": "string", "displayName": "string" }`
**Errors:**
- **401** `Invalid credentials` — no row matches, or stored hash differs.
No session cookie, JWT, or bearer token is issued — the response is purely informational. The client is expected to remember the username and re-present it via the WebSocket `identify` handshake on every reconnect.
---
## WebSocket `identify` handshake
`handleIdentify` (`server/src/websocket/handler.ts:112`) processes the first envelope a client sends. It:
1. Reads `oderId` (falls back to `connectionId` when absent).
2. Reads `connectionScope` (opaque routing key).
3. Reads / normalizes `displayName`, `description`, `profileUpdatedAt`.
4. Mutates the `ConnectedUser` row in `connectedUsers`.
5. If any of `displayName` / `description` / `profileUpdatedAt` changed, rebroadcasts `user_joined` to every server the user is currently in.
`identify` itself is unauthenticated — the server does not consult `AuthUserEntity` here. Authorization happens later, at `join_server` time, via `authorizeWebSocketJoin` (documented in [access-control](./access-control.md)).
`identify` is the canonical channel for **profile updates**. Renaming yourself or updating your description means resending `identify`; the rebroadcast pushes the new profile to peers without disconnect/reconnect.
---
## Heartbeat and dead-connection sweep
Defined in `server/src/websocket/index.ts:19``75`:
- `PING_INTERVAL_MS = 30_000` — server pings every connection every 30 s.
- `PONG_TIMEOUT_MS = 45_000` — a connection whose `lastPong` is older than 45 s is closed and removed from `connectedUsers`.
`lastPong` is bumped on any inbound frame (not just pong), so an active client cannot be reaped while sending traffic. Eviction triggers `handleLeaveServer` for every server the connection had joined, which in turn emits `user_left` if no other connection of the same `oderId` still holds the server.
---
## Electron Local API token store
`electron/api/auth-store.ts` mints opaque bearer tokens used by the Local API server (`electron/api/router.ts`) to gate calls to `/api/auth/login` and other authenticated routes. Tokens have a 24 h TTL and are kept in-memory only — they do not persist across desktop restarts. Pruning happens lazily on lookup.
This surface is **not** the same identity as the signaling server's AuthUser. It is a desktop-local affordance for the in-process HTTP server that serves docs and plugin APIs; the renderer never sees these tokens.
---
## Business rules and invariants
- Usernames are **unique** at the database level (`@Column('text', { unique: true })` on `AuthUserEntity.username`) AND pre-checked in the route handler. Comparison is **case-sensitive**.
- Passwords are hashed with **unsalted SHA-256** (`crypto.createHash('sha256')`, `routes/users.ts:8`). There is no salting, no peppering, no iteration count, no Argon2/bcrypt. This is a known weakness — see Security below.
- The signaling server never issues a session token. Identity is re-asserted on every reconnect via `identify`.
- The `identify` claim is **not verified** against `AuthUserEntity`. Two clients can claim the same `oderId`; only `(oderId, connectionScope)` is used to deduplicate eviction.
- `user_joined` is only re-broadcast on `identify` when at least one of `displayName` / `description` / `profileUpdatedAt` actually changed — duplicate identifies are silent.
- Dead-connection sweep runs continuously. A client that goes silent for ≥ 45 s is treated as disconnected.
---
## Technical implementation
### Server (signaling)
- HTTP routes: `server/src/routes/users.ts`, mounted at `/api/users` in `server/src/routes/index.ts`.
- CQRS handlers: `server/src/cqrs/commands/handlers/registerUser.ts`, `server/src/cqrs/queries/handlers/getUserByUsername.ts`, `server/src/cqrs/queries/handlers/getUserById.ts`.
- Entity: `server/src/entities/AuthUserEntity.ts` (`users` table).
- Migration: `server/src/migrations/1000000000000-InitialSchema.ts`.
- WebSocket handshake: `server/src/websocket/handler.ts::handleIdentify` (line 112).
- `ConnectedUser` shape: `server/src/websocket/types.ts`.
- Heartbeat sweep: `server/src/websocket/index.ts`.
### Product client
- Domain: `toju-app/src/app/domains/authentication/`.
- Service: `authentication.service.ts` (login / register HTTP calls).
- Components: `login.component.ts`, `register.component.ts`.
- Model: `authentication.model.ts`.
### Electron
- Local API token store: `electron/api/auth-store.ts`.
- Local API router: `electron/api/router.ts` (`/api/auth/login` endpoint).
### Key types
- `AuthUserEntity` — server account row.
- `ConnectedUser` — live WebSocket connection state, including `oderId`, `connectionScope`, `lastPong`.
---
## Testing
- E2E: `e2e/tests/auth/user-session-data-isolation.spec.ts` — verifies session-level data isolation between users.
- TODO: no unit specs were located for `server/src/routes/users.ts`, `handleRegisterUser`, `getUserByUsername`/`getUserById`, `handleIdentify`, the Electron `/api/auth/login` proxy, or the `toju-app` authentication services.
- TODO: no happy-path login/register E2E exists today.
---
## Security considerations
- **Password hashing is unsalted SHA-256.** Vulnerable to rainbow-table and parallel GPU attacks. Replacing with Argon2id or bcrypt is the obvious upgrade path and is currently a TODO.
- **No rate limiting on `/login`.** A `users` table with weak hashes is exposed to credential stuffing and online brute-force.
- **`identify` is unauthenticated.** Any WebSocket can claim any `oderId`. The real authorization gate is `authorizeWebSocketJoin` on `join_server`, which checks membership / invite / password against the access-control tables — until that gate is crossed, an unverified `oderId` cannot do anything meaningful beyond joining the public lobby.
- **No reuse-prevention on `displayName`.** Two distinct accounts may carry the same display name. UI must therefore disambiguate by `oderId` where identity actually matters.
- **Local API tokens** never leave the desktop process and have a 24 h TTL — they are not a credential primitive for the signaling server.
---
## Performance considerations
- `/register` and `/login` are O(1) lookups against a `UNIQUE` index on `username`. No caching layer.
- `identify` is O(serversJoinedByThisConnection) on profile change because of the rebroadcast loop; profile updates are rare so this is negligible.
- Dead-connection sweep is O(connections) per `PING_INTERVAL_MS`; trivially scalable for a single-process signaling server.
---
## Known issues and limitations
- **Unsalted SHA-256 password hashing.** Highest-priority hardening target.
- **No password reset, email confirmation, MFA, or account recovery.**
- **No audit log** of register/login events.
- **No binding between `AuthUserEntity.id` and the claimed `oderId`.** A future hardening pass should require the client to prove possession of an `AuthUser` credential before the server accepts an `identify` payload that names that user — likely via a signed challenge.
- **No spec coverage** for the HTTP credential surface or the identify handshake.
---
## Related features
- **[access-control](./access-control.md)** — consumes the `oderId` claimed via `identify` to authorize server joins, role lookups, and moderation actions.
- **[presence](./presence.md)** — `identify` is the canonical channel for profile-metadata updates that presence broadcasts forward.
- **[websocket-envelopes](./websocket-envelopes.md)** — owns the wire shape of `identify`, `user_joined`, and `access_denied`.
- **[ipc-bridge](./ipc-bridge.md)** — the Electron Local API token store lives behind the same IPC boundary as other privileged operations.
## Changelog
| Date | Change |
|------|--------|
| 2026-05-25 | Initial documentation |

View File

@@ -0,0 +1,195 @@
# Messaging
> **Area:** messaging
> **Status:** Active
> **Last updated:** 2026-05-25
## Overview
Messaging in Toju covers two transports that share a single domain model. Server-channel chat is broadcast by the signaling server over WebSocket — fire-and-forget, no server-side persistence today. Direct messages (1:1 and group DMs) are peer-to-peer over the WebRTC chat data channel, with a signaling-server fallback when no data channel is open and an offline queue for when neither path is available. On both transports the client maintains a **monotonic delivery state machine** per message and a **chunked inventory-sync protocol** that lets two peers reconcile missing history without flooding the link.
This document is the cross-context contract: envelope names, sync protocol, delivery states, edit/delete rules, and storage decisions. The product-client domain READMEs at `toju-app/src/app/domains/chat/README.md` and `toju-app/src/app/domains/direct-message/README.md` cover internal NgRx state and effects; the wire shapes live in [websocket-envelopes](./websocket-envelopes.md).
## Responsibilities
- Send, edit, and delete server-channel chat messages over WebSocket (`chat_message`, `edit_message`, `delete_message`).
- Send, edit, and delete direct messages over the WebRTC data channel with signaling fallback.
- Carry typing indicators on server-channel chat (`user_typing`).
- Reconcile peer history via the inventory-sync protocol (chunked, capped backfill).
- Drive a monotonic delivery state machine: `QUEUED → SENT → DELIVERED → ACKNOWLEDGED`.
- Persist direct messages locally; the server keeps no message store.
This area does **not** own:
- Attachment payloads or the chunked file-transfer protocol → [attachments](./attachments.md).
- RTC negotiation that brings the data channel up → [voice-signaling](./voice-signaling.md).
- Permission to send a message (`writeMessages`, `manageMessages`, channel overrides, slowmode hint) → [access-control](./access-control.md).
- The wire shape of every envelope used here → [websocket-envelopes](./websocket-envelopes.md).
## Key concepts
- **Server-channel message** — broadcast through the signaling server to every connected peer of a server. No server-side persistence in the current build.
- **Direct message** — point-to-point or group P2P message, persisted locally per-user via Electron CQRS (and via `localStorage` on browser fallback today — TODO confirm).
- **Conversation** — 1:1 or group DM thread. Group DMs can be created by adding a third participant to a 1:1, which spawns a new conversation while preserving the original.
- **Inventory event** — peer-to-peer announcement of "here is what I have for this conversation/channel"; the receiver replies with a request for missing pieces.
- **Sync batch** — chunked response carrying up to 200 messages per envelope, capped at 1000 messages of backfill per inventory exchange.
- **Delivery state** — monotonic enum on a direct message: `QUEUED (0) → SENT (1) → DELIVERED (2) → ACKNOWLEDGED (3)`. Defined in the chat-events shared kernel; advanced via `advanceDirectMessageStatus`.
- **Peer-delivery service** — `PeerDeliveryService` (`toju-app/src/app/domains/direct-message/application/services/peer-delivery.service.ts`) — the dispatcher that tries the data channel first, then the signaling forward, then the offline queue.
---
## Transports
### Server-channel chat (WebSocket)
- Client sends `chat_message` to the signaling server; `handleChatMessage` (`server/src/websocket/handler.ts:274`) validates the user is in the target server and broadcasts to every other connection.
- `edit_message` and `delete_message` follow the same fan-out path.
- `user_typing` (`handleTyping`, `handler.ts:309`) is broadcast as a transient signal — no persistence, no sync, no delivery state.
- The server **does not persist** these envelopes. Late joiners do not see chat history older than their join — they can request it from peers via the inventory protocol if any peer present has it stored locally.
### Direct messages (WebRTC data channel)
- DMs ride the `chat`-labelled data channel established alongside each voice peer connection (see [voice-signaling](./voice-signaling.md)).
- `PeerDeliveryService` is the dispatcher. For each outgoing event it:
1. Tries every open data channel to a `recipients`-listed peer.
2. If no data channel is available to a recipient, falls back to a signaling-server `forwardPeerMessage` envelope so the server forwards it to that peer's connection.
3. If neither path is open, enqueues the event in `OfflineMessageQueueService` and replays on `peerConnected$` / `networkRestored$`.
- The server **forwards** DM envelopes opaquely — no inspection, no persistence.
### Storage
- **Direct messages** persist via Electron CQRS — see [ipc-bridge](./ipc-bridge.md). Each user has their own local TypeORM database; messages are written via `save-direct-message` / equivalent commands.
- **Server-channel chat** persists via `DatabaseService` (Electron CQRS) when running on desktop, or in IndexedDB when running purely in browser. TODO: confirm the IndexedDB code path.
- The signaling server holds **zero** message bytes at rest today. Re-deploys lose nothing because there is nothing to lose.
---
## Inventory / sync protocol
Both transports share the same inventory shape, defined in `toju-app/src/app/shared-kernel/chat-events.ts`:
- `ChatInventoryEvent` — sender broadcasts "for conversation X I have messages with these ids and last-modified timestamps" (capped at 1000 entries).
- `ChatSyncBatchEvent` — receiver replies with a chunked batch of full message payloads, **up to 200 per envelope**, repeated until the requested set is satisfied or 1000 messages have been returned.
Rules:
- Inventory is **additive**`mergeIncomingMessage` / `upsertDirectMessage` only insert or update; a sparser peer never wipes a richer peer's history.
- Reactions and attachment-link changes are reconciled by comparing per-message `lastModifiedAt`; the higher wins.
- The 1000-message ceiling is per inventory exchange, not per conversation lifetime; an older history can be filled in piecewise across multiple inventory cycles.
The same protocol is reused for the chat domain (server channels) and the direct-message domain. The implementation lives in `toju-app/src/app/domains/chat/domain/rules/message-sync.rules.ts` and is invoked from the direct-message effects via `DirectMessageService.requestSync()`.
---
## Delivery state machine
For DMs, every outgoing message has a `status`:
| Value | Numeric | Meaning |
|-------|---------|---------|
| `QUEUED` | 0 | Composed locally; no transport attempt has succeeded yet. |
| `SENT` | 1 | At least one transport (data channel or signaling forward) has accepted the payload. |
| `DELIVERED` | 2 | At least one recipient has acknowledged receipt at the application layer. |
| `ACKNOWLEDGED` | 3 | The full recipient set has acknowledged (1:1: the one recipient; group: every participant). |
Transitions are advanced via `advanceDirectMessageStatus`, which **only advances** — a higher value is never replaced by a lower one. A retried message that succeeds after a queue replay can therefore move `QUEUED → SENT` but never `DELIVERED → SENT`.
Server-channel messages do not carry an application-level delivery state today (the server broadcast is fire-and-forget); the UI treats them as `SENT` once the WebSocket accepts the frame.
---
## Edit and delete
- `edit-message` / `delete-message` events carry the original `messageId`. On both transports, the receiver locates the existing row (by id) and applies the mutation via `applyMutation`.
- DMs use the same envelope types but ride the data channel / signaling-forward fabric.
- Edits are last-writer-wins by `editedAt`. A delete removes the message body but keeps a tombstone with `deletedAt` so peers that haven't yet seen the delete can converge on the next inventory sync.
TODO: `applyMutation` does not currently verify the mutation originated from the original author. A non-cooperating client could send `edit-message` for someone else's message and a receiver would accept it. Confirm and either harden client-side or document the trust model.
---
## Typing indicators
- Server-channel only. DMs do not have a typing indicator today.
- Sent as `user_typing`; broadcast to a server scope.
- Transient: no persistence, no sync replay, no delivery state.
---
## Business rules and invariants
- The signaling server is **not authoritative** for messaging. `handleChatMessage` only broadcasts; there is no server-side message log.
- The delivery state machine is **monotonic**`advanceDirectMessageStatus` never moves status backwards.
- DM envelopes are **ignored** unless the local user appears in `participants` / `recipients` (or has an existing local conversation matching the id).
- Inventory merges are **additive**`mergeIncomingMessage` / `upsertDirectMessage` never delete or downgrade a richer local row.
- A 1:1 → group upgrade **preserves** the original 1:1 history; the group is a new conversation.
- Edits / deletes are reconciled by `lastModifiedAt` / `editedAt` / `deletedAt`.
---
## Technical implementation
### Server
- WS handlers — `server/src/websocket/handler.ts`: `handleChatMessage` (line 274), `handleTyping` (309); DM forwarding via `forwardPeerMessage` / `forwardRtcMessage`.
- No CQRS, no entities, no migrations: server messaging is broadcast-only.
### Product client
- Chat domain — `toju-app/src/app/domains/chat/`: services, effects, sync rules at `domain/rules/message-sync.rules.ts`.
- Direct-message domain — `toju-app/src/app/domains/direct-message/`: `DirectMessageService`, `PeerDeliveryService` (`application/services/peer-delivery.service.ts`), offline queue.
- Shared kernel — `toju-app/src/app/shared-kernel/chat-events.ts` (`ChatInventoryEvent`, `ChatSyncBatchEvent`, `chat_message`, `edit-message`, `delete-message`, `direct-message-sync`, `direct-message-sync-request`).
- Persistence — Electron CQRS commands for DMs (see [ipc-bridge](./ipc-bridge.md)); `DatabaseService` for server-channel chat.
### Electron
- DM persistence — TypeORM entity (likely `MessageEntity` and a DM-specific row) + CQRS handlers. Backup / restore is part of the Electron data-management surface.
---
## Testing
- TODO: chat domain has zero `*.spec.ts` files at time of writing.
- TODO: no dedicated server-side spec for `handleChatMessage`, `handleTyping`, or `forwardRtcMessage`.
- TODO: confirm specs for `PeerDeliveryService` and `OfflineMessageQueueService`.
- E2E: `e2e/tests/chat/chat-message-features.spec.ts` covers happy-path chat and attachment sync between users.
---
## Performance considerations
- Inventory batch cap: **200 messages per envelope**.
- Inventory backfill cap: **1000 messages per inventory exchange**.
- `chat_message` broadcast is O(N) over connected peers of the server; no fan-out batching.
- DMs incur O(recipients) data-channel writes (or signaling forwards) per send; large group DMs amplify per-message cost linearly.
---
## Security considerations
- **No end-to-end encryption.** P2P traffic over the data channel is DTLS-encrypted by WebRTC; signaling-forwarded fallback is plain WebSocket; either way the local TypeORM database stores plaintext.
- **`applyMutation` does not verify authorship** on incoming `edit-message` / `delete-message` events. TODO above.
- **No server-side rate limiting** on `chat_message`. A non-cooperating client can flood a server's broadcast.
---
## Known issues and limitations
- **No server-side chat history.** Late joiners depend on peers having local history to replay via inventory sync.
- **No spec coverage** for the chat domain.
- **DM authorship is not verified** by `applyMutation`.
- **No DM typing indicator.**
- **`OfflineMessageQueueService` retry policy** is currently driven by `peerConnected$` / `networkRestored$` events only — there is no scheduled retry; a stuck queue requires one of those events to fire. TODO: confirm behavior across reconnects.
---
## Related features
- **[websocket-envelopes](./websocket-envelopes.md)** — owns the wire shape of every envelope here.
- **[attachments](./attachments.md)** — file payloads ride alongside chat events on the data channel.
- **[voice-signaling](./voice-signaling.md)** — establishes the data channel DMs ride on.
- **[ipc-bridge](./ipc-bridge.md)** — exposes the CQRS persistence DMs and server chat use.
- **[access-control](./access-control.md)** — gates write permissions and slowmode.
## Changelog
| Date | Change |
|------|--------|
| 2026-05-25 | Initial documentation |

View File

@@ -0,0 +1,196 @@
# Presence
> **Area:** presence
> **Status:** Active
> **Last updated:** 2026-05-25
## Overview
Presence in Toju covers everything that signals "who is here, where, and doing what" — connection lifecycle (`join_server`, `leave_server`), availability status (`online / away / busy / offline`), profile metadata propagation, voice-room membership, and the current-game indicator. The signaling server **forwards and deduplicates** presence over WebSocket but never persists it: every restart of the server resets all presence state to nothing, and every reconnect of a client re-derives the world from scratch via a `server_users` snapshot.
Several signals contribute. Status comes from idle detection in the renderer (Electron `powerMonitor` or browser fallback) and from explicit user choices. Voice membership is **client-derived** from observed `voice_state` broadcasts — the server never tracks who is in a voice room. Game activity is scanned in the renderer using Electron-IPC process inspection, resolved via the server's RAWG-backed `/games/match` API, and broadcast directly to peers over the WebRTC data channel — it never touches the signaling server.
## Responsibilities
- Track which users are connected to which server and broadcast joins/leaves with multi-device dedup.
- Carry profile metadata (`displayName`, `description`, `profileUpdatedAt`) so peers can render rich identity without a separate lookup.
- Propagate availability status (`online / away / busy / offline`) as users choose, idle, or wake.
- Surface voice-room membership to peers via the `voice_state` envelope.
- Surface current-game activity to peers via the chat data channel.
This area does **not** own:
- The WebSocket envelope shape — see [websocket-envelopes](./websocket-envelopes.md).
- RTC negotiation, peer connection lifecycle, RNNoise — see [voice-signaling](./voice-signaling.md).
- Server-side account records, credentials, or the `identify` handshake's authentication semantics — see [authentication](./authentication.md).
- Authorization for joining a server (membership / bans / invites) — see [access-control](./access-control.md).
## Key concepts
- **`oderId`** — client-asserted identity. The deduplication key across multiple sockets and devices.
- **`connectionScope`** — opaque string (typically the signal URL). Used together with `oderId` to coexist multi-URL sockets without an eviction loop.
- **Status** — `'online' | 'away' | 'busy' | 'offline'`. `VALID_STATUSES` lives in `server/src/websocket/handler.ts`. Client maps incoming `offline` to a local `disconnected` rendering tag.
- **Manual vs automatic status** — manual user choices override the idle-detector.
- **Voice presence** — derived client-side from `voice_state` broadcasts; the signaling server has no voice-room model.
- **Game activity** — currently-detected game; resolved via `/games/match` and announced to peers over the data channel as a `game-activity` chat event.
- **`ConnectedUser`** — server-side per-connection row (`server/src/websocket/types.ts`); see [authentication](./authentication.md) for the full shape.
---
## Envelopes (consumed and emitted)
Schemas live in [websocket-envelopes](./websocket-envelopes.md). Presence-relevant types:
- `identify` — first envelope a client sends; updates profile metadata. Re-broadcasts `user_joined` per joined server when profile fields change.
- `join_server` / `leave_server` — connection joins/leaves a specific server scope; gated by `authorizeWebSocketJoin` (see [access-control](./access-control.md)).
- `server_users` — full peer roster sent to a connection when it joins a server; the only "snapshot" envelope.
- `user_joined` — broadcast to peers on a server when a new *identity* arrives (i.e. no other connection of the same `oderId` already had this server).
- `user_left` — broadcast to peers when an identity fully releases a server; payload includes `serverIds` listing the servers the user is still in elsewhere.
- `status_update` — availability status change (`online / away / busy / offline`).
- `voice_state` — broadcast to a server when the user enters/leaves voice or toggles mute/deafen. Carries `roomId`, `voiceGateway`, mute/deafen flags. Voice membership is reconstructed from these events client-side.
- `keepalive` — bumps `lastPong` on the server to keep the connection from being reaped.
- `access_denied` — server response when authorization for `join_server` fails.
Game activity events ride the WebRTC **chat data channel** as `game-activity` chat events — not as WebSocket envelopes.
---
## Lifecycle
A connection moves through these stages:
1. **WebSocket connect** — server allocates a `ConnectedUser` row keyed by `connectionId`.
2. **`identify`** — client claims `oderId`, profile metadata, and an optional `connectionScope`. See [authentication](./authentication.md) for the handshake details.
3. **`join_server`** — gated by `authorizeWebSocketJoin`. On success, the server adds the server to `user.serverIds`, sends a private `server_users` snapshot to the joiner, and broadcasts `user_joined` to other peers on that server *only if* this is a new identity for the server (multi-device dedup).
4. **Steady state**`status_update`, `voice_state`, profile-bearing `identify` updates, and chat / RTC envelopes flow. The server bumps `lastPong` on every inbound frame.
5. **`leave_server` / disconnect** — the server removes the server from `user.serverIds`. `user_left` is broadcast to peers *only* once every connection of the same `oderId` has released the server. The payload's `serverIds` field reports which servers the identity is still in, so clients can distinguish "moved tabs" from "fully left."
6. **Dead-connection sweep** — every `PING_INTERVAL_MS = 30 000` the server sweeps; any connection with `lastPong` older than `PONG_TIMEOUT_MS = 45 000` is closed and processed as a disconnect (`server/src/websocket/index.ts`).
---
## Multi-device deduplication
`broadcastToServer` (`server/src/websocket/broadcast.ts`) deduplicates fan-out by `oderId`, so a user logged in on two devices sees each peer event once.
`handleJoinServer` (`handler.ts:155`) only emits `user_joined` when the join is a **new identity membership** — i.e. no other connection of the same `oderId` already held the server. Renaming a tab or opening a second window does not produce spurious join notifications.
Symmetrically, `handleLeaveServer` only emits `user_left` when no other connection of the same `oderId` still holds the server. The `serverIds` field on the payload lets clients see "the identity is still in these other servers" rather than treating a tab close as a full logout.
`connectionScope` keeps this stable across multi-signal-URL deployments: an identity that opens connections to two signal URLs is still one identity from the broadcast layer's perspective, but the per-scope stale-eviction does not loop.
---
## Status
Two writers update status:
- **Manual** — the user picks `online / away / busy / offline` from the UI. The chosen value is sent as a `status_update` envelope and persists locally.
- **Automatic** — `UserStatusService` (`toju-app/src/app/core/services/user-status.service.ts`) listens to Electron `powerMonitor` events (suspend, resume, lock, unlock) when running on desktop, or falls back to a 15-minute idle timer in the browser. It emits `status_update` via `RealtimeSessionFacade.sendRawMessage`.
Manual overrides automatic for the session — explicit user input prevents the idle detector from overwriting `away` back to `online`.
Server-side, `handleStatusUpdate` (`handler.ts:337`) validates the value against `VALID_STATUSES`, mutates `user.status`, and broadcasts the event to every server the user is in.
---
## Game activity
`GameActivityService` (`toju-app/src/app/domains/game-activity/application/game-activity.service.ts`) is the renderer-side scanner:
- Polls every 560 seconds (default 10 s).
- Asks Electron for active and running process names via the IPC bridge (`getActiveGameCandidate`, `getRunningProcessNames` — see [ipc-bridge](./ipc-bridge.md)).
- Resolves candidates to RAWG metadata via `POST /games/match` on the signaling server.
- Broadcasts the result to peers as a `game-activity` chat event on the WebRTC chat data channel.
The signaling server is **not** in the broadcast path for game activity — it only matches process names to RAWG entries. Once a peer connection exists, the game-activity envelope flows P2P.
---
## Business rules and invariants
- The signaling server **forwards** presence but **never persists** it. Server restart = full reset; clients rederive via `server_users`.
- Presence is **per-connection**; identity is reconstructed at broadcast time by collapsing connections that share `oderId`.
- `user_joined` is emitted **only** on a new-identity membership; `user_left` **only** on full release of a server by an `oderId`.
- `identify` is the canonical channel for profile-metadata updates. Profile-bearing `identify` envelopes rebroadcast `user_joined` only if at least one of `displayName` / `description` / `profileUpdatedAt` actually changed.
- **Voice room membership is never server-tracked.** It is client-derived from `voice_state` broadcasts.
- Manual status overrides automatic status for the session.
- Reconnection resets all presence state; the joiner's first `server_users` snapshot is authoritative for that server.
- Dead connections are reaped after 45 s of silence.
---
## Technical implementation
### Server
- WebSocket envelope handlers — `server/src/websocket/handler.ts`: `handleStatusUpdate` (line 337), `handleIdentify` (112), `handleJoinServer` (155), `handleLeaveServer` (224).
- Broadcast / dedup — `server/src/websocket/broadcast.ts`: `broadcastToServer`, `sendServerUsers`.
- Sweep / heartbeat — `server/src/websocket/index.ts` (`PING_INTERVAL_MS`, `PONG_TIMEOUT_MS`).
- `ConnectedUser` row — `server/src/websocket/types.ts`.
- Game-match HTTP — `server/src/routes/games.ts`, `server/src/services/game-matching.service.ts`.
### Product client
- Status writer — `toju-app/src/app/core/services/user-status.service.ts`.
- Game-activity scanner — `toju-app/src/app/domains/game-activity/application/game-activity.service.ts`.
- Voice presence — `toju-app/src/app/domains/voice-session/` (see [voice-signaling](./voice-signaling.md)).
- Presence sync into NgRx — `toju-app/src/app/store/rooms/room-state-sync.effects.ts`, `room-members-sync.effects.ts`.
- Presence reducers — `toju-app/src/app/store/users/`.
### Electron
- Process inspection IPC — `electron/preload.ts` (`getActiveGameCandidate`, `getRunningProcessNames`). See [ipc-bridge](./ipc-bridge.md).
- `powerMonitor` events — wired in `electron/` and surfaced to renderer via IPC events.
---
## Testing
- `server/src/websocket/handler-status.spec.ts` — status validation and broadcast.
- `toju-app/src/app/store/users/users-status.reducer.spec.ts` — status reducer.
- `toju-app/src/app/store/rooms/rooms-helpers-status.spec.ts` — room-level status aggregation.
- `toju-app/src/app/domains/game-activity/application/game-activity.service.spec.ts` — scanner + RAWG match.
- `toju-app/src/app/infrastructure/realtime/signaling/signaling-message-handler.spec.ts` — presence-relevant handling.
- TODO: no spec for multi-device `user_left` suppression.
- TODO: no spec for `identify`-triggered profile rebroadcast.
- TODO: no spec for pong-timeout reaping.
- TODO: no spec for client `offline → disconnected` rendering mapping.
- TODO: no E2E for cross-device presence dedup.
---
## Security considerations
- The `identify` claim is **not verified** — see [authentication](./authentication.md). Presence trust is inherited from that gap: a connection can claim any `oderId` and be broadcast as that user.
- Game-activity scanning surfaces local process names to the signaling server (via `/games/match`) and to peers (via the data channel). This is privacy-sensitive — there is no per-user opt-out documented today; if a user does not want process names leaving their machine, they need a `closeToTraySetting`-style toggle. TODO: confirm the current opt-out path.
- Status is self-asserted. `busy` does not enforce anything; it is purely a presence hint.
---
## Performance considerations
- `broadcastToServer` is O(N) per envelope, where N is the number of connections subscribed to the server. There is no fan-out batching.
- The 30 s ping cadence keeps idle connections cheap; the 45 s reaper keeps `connectedUsers` from leaking on TCP half-closes.
- `GameActivityService` defaults to a 10 s poll; user-configurable, clamped 560 s.
- `identify` rebroadcast is O(serversJoinedByThisConnection); negligible in practice.
---
## Known issues and limitations
- No server-side voice-room membership model means the only way to enumerate a room's occupants is to replay `voice_state` events client-side.
- Status idle detection in the browser falls back to a coarse 15-minute timer (no platform idle API).
- Game-activity opt-out and granularity (per-game blocklist) are not centrally documented; the `gameIgnoreList` setting lives in `electron/desktop-settings.ts` but is undocumented in renderer UX terms — TODO.
---
## Related features
- **[websocket-envelopes](./websocket-envelopes.md)** — owns the wire shape of every envelope this area uses.
- **[voice-signaling](./voice-signaling.md)** — consumes the `voice_state` broadcasts to drive RTC mesh.
- **[authentication](./authentication.md)** — owns the `identify` handshake and the heartbeat / reaping policy.
- **[access-control](./access-control.md)** — gates `join_server`, which precedes any presence broadcast for that server.
- **[ipc-bridge](./ipc-bridge.md)** — exposes `powerMonitor` events and process-list inspection used by status and game-activity.
## Changelog
| Date | Change |
|------|--------|
| 2026-05-25 | Initial documentation |