Manual ownership transfers between two federated instances diverged because `dm_channels.ownerHomeInstance` was stored as a BARE host (`orbit.ddns.net`) for federated owners — via `transferGroupDmOwnership` copying `users.homeInstance` verbatim — while `sourceInstance` always arrives as a full URL on the wire. `processOwnershipTransferEvent` and `processMemberRemoveEvent` then compared the two with strict equality and rejected legitimate inbound events as `unauthorized_source`, keeping ownership permanently divergent across peers. Live DB inspection on the two test instances confirmed both rows (nova + orbit) had a BARE `owner_home_instance`, matching the bug report exactly. Three compounding fixes: 1. Receiver authority checks now compare via `normalizeOriginForCompare` so legacy bare-vs-full rows accept legitimate transfers (and kicks). 2. New `canonicalizeHomeInstance` helper in `federationAuth.ts`; every write site that persists `ownerHomeInstance` (`transferGroupDmOwnership`, group DM creation, lazy federation in member-add, `processMemberAddEvent` bootstrap, `processOwnershipTransferEvent` receiver storage) routes through it. Full URL is the canonical storage form, matching how `sourceInstance` arrives. 3. `dm_owner_updated` WS event extended with optional `newOwnerHomeUserId` and `newOwnerHomeInstance` fields. Client `updateDmOwner` writes them when present and leaves existing values untouched otherwise (legacy-server safe). Without this, `getOwnerInstanceForDm` returned the previous owner's home after a successful WS broadcast, routing the next owner-only op to the wrong instance. Coverage: new `federation.ownershipTransfer.test.ts` (7 receiver tests including the headline bare-vs-full regression and the dedup replay guard); new bare-vs-full case in `federation.kick.test.ts`; two new client-side cases in `groupDm.ownerRouting.test.ts` covering both the extended-payload write path and the legacy-server passthrough. Tests: 1053 server + 364 web, all green. Specs updated: `dm-system.md` historical bugs + frontend handler table + WS state-change events table; `federation.md` `ownership_transfer` receiver flow; `websocket.md` event-fields table.
229 lines
11 KiB
Markdown
229 lines
11 KiB
Markdown
# WebSocket Protocol Reference
|
||
|
||
Endpoint: `GET /ws` (upgrade to WebSocket)
|
||
Transport: JSON messages over WebSocket
|
||
Source: `packages/server/src/ws/handler.ts`, `packages/server/src/ws/events.ts`
|
||
|
||
---
|
||
|
||
## Auth Flow
|
||
|
||
1. Client connects to `/ws`
|
||
2. Client sends `{ type: 'auth', token: '<jwt>' }` within 10 seconds
|
||
3. Server validates token (rejects deleted users, tokens issued before `passwordChangedAt`)
|
||
4. Server responds with `ready` event containing full client state
|
||
5. Server updates user status to `online`, broadcasts `presence_update` to friends + DM co-members + space co-members (via `collectProfileBroadcastTargetIds`); for native users, also queues a S2S `presence_update` relay to all active peers
|
||
6. Heartbeat: server pings every 30s (RFC 6455 ping frames), dead connections detected after ~65s
|
||
|
||
---
|
||
|
||
## Client → Server
|
||
|
||
### Messages
|
||
| type | fields | notes |
|
||
|------|--------|-------|
|
||
| `message_create` | channelId, content, replyToId? | SEND_MESSAGES perm |
|
||
| `message_edit` | messageId, content | author only |
|
||
| `message_delete` | messageId | author or MANAGE_MESSAGES |
|
||
| `typing_start` | channelId | 5s auto-expire |
|
||
|
||
### DM Messages
|
||
| type | fields | notes |
|
||
|------|--------|-------|
|
||
| `dm_message_create` | dmChannelId, content?, attachments?, replyToId? | member |
|
||
| `dm_message_edit` | messageId, content | author only |
|
||
| `dm_message_delete` | messageId | author only |
|
||
| `dm_typing_start` | dmChannelId | 5s auto-expire |
|
||
|
||
### Reactions (space + DM, auto-detected)
|
||
| type | fields | notes |
|
||
|------|--------|-------|
|
||
| `reaction_add` | messageId, emoji | ADD_REACTIONS perm (space) |
|
||
| `reaction_remove` | messageId, emoji | own reactions only |
|
||
|
||
### Read State
|
||
| type | fields | notes |
|
||
|------|--------|-------|
|
||
| `channel_ack` | channelId, messageId | mark read up to message |
|
||
| `mark_unread` | channelId, messageId | `'0'` to clear all |
|
||
|
||
### Presence & Activity
|
||
| type | fields | notes |
|
||
|------|--------|-------|
|
||
| `presence_update` | status: online/idle/dnd | persisted to DB |
|
||
| `activity_update` | activities: Activity[] | rate-limited 3s, respects showActivity |
|
||
|
||
### Voice (Space Channels)
|
||
| type | fields | notes |
|
||
|------|--------|-------|
|
||
| `voice_join` | channelId | one room per user enforced |
|
||
| `voice_leave` | — | |
|
||
| `voice_status` | isMuted, isDeafened, isCameraOn, isScreenSharing | server enforces space/permission mute |
|
||
|
||
### Voice Moderation
|
||
| type | fields | permission |
|
||
|------|--------|------------|
|
||
| `voice_space_mute` | userId, muted | MUTE_MEMBERS |
|
||
| `voice_space_deafen` | userId, deafened | DEAFEN_MEMBERS |
|
||
| `voice_move` | userId, targetChannelId | MOVE_MEMBERS |
|
||
| `voice_disconnect` | userId | DISCONNECT_MEMBERS |
|
||
|
||
### DM Calls
|
||
| type | fields | notes |
|
||
|------|--------|-------|
|
||
| `dm_call_start` | dmChannelId?, federatedCallId? | `dmChannelId` can be null when `federatedCallId` is provided. 60s auto-timeout if not accepted |
|
||
| `dm_call_accept` | dmChannelId?, federatedCallId? | ringing→active |
|
||
| `dm_call_reject` | dmChannelId?, federatedCallId? | |
|
||
| `dm_call_end` | dmChannelId?, federatedCallId? | |
|
||
|
||
### System
|
||
| type | fields |
|
||
|------|--------|
|
||
| `auth` | token |
|
||
| `ping` | — (gets `pong`) |
|
||
|
||
---
|
||
|
||
## Server → Client
|
||
|
||
### System
|
||
| type | fields | scope |
|
||
|------|--------|-------|
|
||
| `ready` | (see Ready Payload below) | user |
|
||
| `pong` | — | user |
|
||
| `error` | message | user |
|
||
|
||
### Messages
|
||
| type | fields | scope |
|
||
|------|--------|-------|
|
||
| `message_created` | message: MessageWithUser | channel (VIEW_CHANNEL) |
|
||
| `message_updated` | message: MessageWithUser | channel |
|
||
| `message_deleted` | messageId, channelId | channel |
|
||
| `typing` | channelId, userId, username | channel (excludes sender) |
|
||
| `reaction_added` | messageId, reaction (includes user) | channel |
|
||
| `reaction_removed` | messageId, userId, emoji | channel |
|
||
| `embeds_resolved` | messageId, channelId, embeds[] | channel |
|
||
|
||
### DM Messages
|
||
| type | fields | scope |
|
||
|------|--------|-------|
|
||
| `dm_message_created` | message: DmMessageWithUser | DM members |
|
||
| `dm_message_updated` | message: DmMessageWithUser | DM members |
|
||
| `dm_message_deleted` | messageId, dmChannelId | DM members |
|
||
| `dm_typing` | dmChannelId, userId, username | DM members (excludes sender) |
|
||
| `dm_typing_stop` | dmChannelId, userId | DM members (excludes typer) |
|
||
| `dm_embeds_resolved` | messageId, dmChannelId, embeds[] | DM members |
|
||
|
||
### Read State
|
||
| type | fields | scope |
|
||
|------|--------|-------|
|
||
| `channel_ack` | channelId, messageId | user (multi-tab sync) |
|
||
| `mark_unread` | channelId, messageId | user (multi-tab sync) |
|
||
|
||
### Presence & Activity
|
||
| type | fields | scope |
|
||
|------|--------|-------|
|
||
| `presence_update` | userId, status, activities? | friends + DM co-members + space co-members of the user (via `collectProfileBroadcastTargetIds`), plus self for multi-tab sync. For federated stubs, the local instance receives status via S2S `presence_update` relay from the home (see `federation.md` §10 — Presence Sync) and re-broadcasts to the same recipient set. |
|
||
| `user_updated` | user | user |
|
||
|
||
### Space / Channel Management
|
||
| type | fields | scope |
|
||
|------|--------|-------|
|
||
| `space_updated` | space | space |
|
||
| `member_joined` | spaceId, member: MemberWithUser | space |
|
||
| `member_left` | spaceId, userId | space |
|
||
| `member_banned` | spaceId, reason | user (banned) |
|
||
| `channel_created` | channel, spaceId | space |
|
||
| `channel_updated` | channel, spaceId | space |
|
||
| `channel_deleted` | channelId, spaceId | space |
|
||
| `category_created` | category, spaceId | space |
|
||
| `category_updated` | category, spaceId | space |
|
||
| `category_deleted` | categoryId, spaceId | space |
|
||
| `channel_layout_updated` | spaceId, channels[], categories[] | space |
|
||
| `space_layout_updated` | layout[], folders[], updatedAt? | user |
|
||
|
||
### DM Channel Management
|
||
| type | fields | scope |
|
||
|------|--------|-------|
|
||
| `dm_channel_created` | dmChannel | user |
|
||
| `dm_channel_closed` | dmChannelId | user |
|
||
| `dm_member_added` | dmChannelId, user | DM members |
|
||
| `dm_member_removed` | dmChannelId, userId | DM members |
|
||
| `dm_owner_updated` | dmChannelId, newOwnerId, newOwnerHomeUserId?, newOwnerHomeInstance? | DM members |
|
||
|
||
### Voice
|
||
| type | fields | scope |
|
||
|------|--------|-------|
|
||
| `voice_state_update` | channelId, userId, action: join/leave | space |
|
||
| `voice_status_update` | userId, channelId, isMuted, isDeafened, isCameraOn, isScreenSharing | room |
|
||
| `voice_space_muted` | userId, channelId, spaceId, muted | space |
|
||
| `voice_space_deafened` | userId, channelId, spaceId, deafened | space |
|
||
| `voice_permission_muted` | userId, spaceId, muted | space |
|
||
| `voice_moved` | userId, oldChannelId, newChannelId | user (target) |
|
||
| `voice_disconnected` | userId, channelId, reason? | user (target) |
|
||
reason: `'displaced'` (new tab) | `'session_closed'`
|
||
|
||
### DM Calls
|
||
| type | fields | scope |
|
||
|------|--------|-------|
|
||
| `dm_call_incoming` | dmChannelId?, federatedCallId, callerId, callerName, callOrigin?, livekitUrl?, livekitToken? | DM members (excludes caller). `dmChannelId` can be null for Path B federated calls (no local DM channel). `callOrigin` identifies the hosting instance for cross-instance calls. |
|
||
| `dm_call_accepted` | dmChannelId?, federatedCallId? | DM members |
|
||
| `dm_call_rejected` | dmChannelId?, federatedCallId? | DM members |
|
||
| `dm_call_ended` | dmChannelId?, federatedCallId? | DM members |
|
||
| `dm_call_undeliverable` | Sent to the originator when a call relay (start / accept / reject / end) to one or more peers fails. Includes `phase: 'start' \| 'accept' \| 'reject' \| 'end' \| 'host_unreachable'` identifying the action; `failures[]` enumerates failed peers with a `reason` (`peer_rejected` / `peer_awaiting_approval` / `peer_transient_failure` / `livekit_unavailable` / `no_recipient`). `terminal: true` means local call state should be (or has been) torn down; `terminal: false` is informational. See `docs/systems/voice.md` for the full phase × terminal matrix. | originator (caller / acceptor / rejector / ender) |
|
||
|
||
### Social
|
||
| type | fields | scope |
|
||
|------|--------|-------|
|
||
| `friend_request_received` | request | user (target) |
|
||
| `friend_request_accepted` | friend, requestId | user (requester) |
|
||
| `friend_request_declined` | requestId, userId | user (requester) |
|
||
| `friend_request_cancelled` | requestId, userId | user (target) |
|
||
| `friend_removed` | userId | user |
|
||
|
||
### Discovery
|
||
| type | fields | scope |
|
||
|------|--------|-------|
|
||
| `join_request_received` | request | space (managers) |
|
||
| `join_request_accepted` | request, space | user (requester) |
|
||
| `join_request_declined` | request | user (requester) |
|
||
|
||
### Federation
|
||
| type | fields | scope |
|
||
|------|--------|-------|
|
||
| `federation_file_rejected` | messageId, dmChannelId, attachmentId, affectedUsers[] | DM members |
|
||
| `federation_approval_request_received` | — (refetch trigger; payload: `{ type }`) | admins. Fires for **both** inbound peering requests (remote → us) AND outbound queue creation when the [Outbound Peering Gate](federation.md#outbound-peering-gate) creates a `peer_approval_requests` row in response to a user_action. Payload shape unchanged from the inbound-only behavior; only the firing surface widened. |
|
||
| `peering_subscription_changed` | — (refetch trigger; payload: `{ type }`) | the subscribing user (all of their connected sessions). Fires when a `peer_approval_subscribers` row belonging to the user is created, modified, or deleted (gate fan-in, user cancel, parent cascade). Client refetches `GET /api/federation/peering-subscriptions`. |
|
||
| `peering_notification_received` | `{ type, kind: 'approved' \| 'denied' \| 'expired' }` | the user the notification belongs to. Fires when a `peer_approval_notifications` row is created (`onPeerActivated` outbound fanout, outbound `/deny` fanout, janitor outbound expiry). Client refetches `GET /api/federation/peering-notifications` and may surface a transient toast for online users. |
|
||
|
||
**S2S relay-only event (not a direct client WS event):**
|
||
|
||
`read_state_update` — sent peer-to-peer via the federation relay when a user acknowledges a DM channel on one instance. The receiving instance processes it, upserts the `read_states` row, and then emits a standard `channel_ack` event to the user's local WebSocket connections. The relay event itself is never forwarded to clients directly.
|
||
|
||
---
|
||
|
||
## Ready Payload
|
||
|
||
```typescript
|
||
{
|
||
type: 'ready',
|
||
user: User,
|
||
spaces: SpaceWithChannelsAndMembers[],
|
||
dmChannels: DmChannel[],
|
||
folders?: SpaceFolder[],
|
||
spaceLayout?: SpaceLayoutItem[] | null,
|
||
layoutUpdatedAt?: number,
|
||
voiceStates?: Record<channelId, userId[]>,
|
||
voiceUserStates?: Record<string, { isMuted, isDeafened, isCameraOn, isScreenSharing }>,
|
||
spaceVoiceStates?: Record<string, { spaceMuted, spaceDeafened, permissionMuted }>,
|
||
readStates?: ReadState[],
|
||
activeCalls?: ActiveCallInfo[], // includes federatedCallHost?, livekitUrl?, livekitToken? for federated calls
|
||
userActivities?: Record<userId, Activity[]>,
|
||
rejectedPeerOrigins: string[], // origins with status 'rejected'; used for DM unreachable indicators
|
||
awaitingApprovalPeerOrigins: string[], // origins with status 'awaiting_approval'
|
||
pendingApprovalCount: number // count of peer_approval_requests rows; only non-zero for admins
|
||
}
|
||
```
|
||
|
||
**Federation filtering:** When the connecting user is federated (`homeInstance` is set), the server omits all DM-related data from the ready payload. `dmChannels` and `activeCalls` are sent as empty arrays, and `readStates` is filtered to only include space channel entries. Federated users receive their DM data from their home instance's ready payload instead.
|