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

197 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |