.agents/rules/realtime.md
src/BuildingBlocks/Web/Realtime/ + Sse/. For the frontend side see frontend/shared.md + frontend/dashboard.md.
AppHub)[Authorize] AppHub mapped at /api/v1/realtime/hub. Groups: user:{userId}, tenant:{tenantId}, channel:{channelId}.
OnConnectedAsync auto-joins user:{id}, tenant:{id}, and every channel:{id} the user is already a member of. A channel that becomes relevant after the socket is live (a new DM, or being added to a channel) is not auto-joined — the client must call the membership-gated JoinChannel(channelId) hub method (the dashboard does this on channel open + reconnect). Without it, group broadcasts silently miss that connection until a page reload re-runs OnConnectedAsync. New-DM creation pushes ChatChannelAdded to each other participant's user:{id} group so their channel list refreshes.Context.User, NOT ICurrentUser. ICurrentUser flows through IHttpContextAccessor, but the negotiate HttpContext isn't pinned to subsequent hub invocations → ICurrentUser returns nulls inside the hub. Use Context.User (the hub's GetUserId()/GetTenantId() helpers).tenant:{id}, user:{id}, channel:{id}), never Clients.All. PresenceChanged goes to the tenant group.CachingOptions:Redis is set (channel prefix fsh-signalr) — required for multi-replica.IHubContext<AppHub> to group user:{userId} (e.g. Notifications' "NotificationCreated").IPresenceTracker (in-memory, per-host — single-replica only for presence). Modules supply IChannelMembershipChecker/IUserChannelLookup adapters so the shared hub can authorize channel groups without depending on Chat.Web/Sse/) — two-step tokenEventSource can't send Authorization, so SSE uses a token handshake:
POST /api/v1/sse/token (authorized) → opaque Guid, single-use, 30s TTL in IDistributedCache.GET /api/v1/sse/stream?token={guid} (anonymous, consumes the token) → text/event-stream, X-Accel-Buffering: no, 15s heartbeat.SseConnectionManager (singleton, ConcurrentDictionary, bounded channel cap 100 DropOldest): TrySend(userId) (all tabs), Broadcast(tenantId), BroadcastAll().
SignalR hub tests force long-polling (TestServer has no WebSocket). See integration-testing.md.