Files
Toju/agents-docs/features/presence.md
brogeby 47beed01ca 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>
2026-05-25 22:33:41 +02:00

14 KiB
Raw Blame History

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.
  • RTC negotiation, peer connection lifecycle, RNNoise — see voice-signaling.
  • Server-side account records, credentials, or the identify handshake's authentication semantics — see authentication.
  • Authorization for joining a server (membership / bans / invites) — see access-control.

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 for the full shape.

Envelopes (consumed and emitted)

Schemas live in websocket-envelopes. 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).
  • 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 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 statestatus_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.
  • AutomaticUserStatusService (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).
  • 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).
  • 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.
  • 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. 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.

  • websocket-envelopes — owns the wire shape of every envelope this area uses.
  • voice-signaling — consumes the voice_state broadcasts to drive RTC mesh.
  • authentication — owns the identify handshake and the heartbeat / reaping policy.
  • access-control — gates join_server, which precedes any presence broadcast for that server.
  • ipc-bridge — exposes powerMonitor events and process-list inspection used by status and game-activity.

Changelog

Date Change
2026-05-25 Initial documentation