docs: update subsystem specs for cross-instance DM access

Document read_state_update relay event, lifted DM gates,
federatedId dedup, and relaxed authority check.
This commit is contained in:
Jannis Braun
2026-04-07 20:01:43 +02:00
parent 5504a34dd8
commit 626a8ded50
4 changed files with 73 additions and 14 deletions
+4 -4
View File
@@ -151,7 +151,7 @@ getChannelOrigin(channelId): string // Returns '' for home, origin URL for re
Built during `populateFromReady()` when WS ready events arrive from each instance. Built during `populateFromReady()` when WS ready events arrive from each instance.
> **DM channels** are always mapped to `''` (home origin). `channelOriginMap` is only relevant for Space channels. DM operations always route to the home instance; S2S relay handles cross-instance distribution. > **DM channels** are mapped to the origin of the instance that delivered them in the `ready` event. For 1-on-1 DMs created locally this is typically `''` (home), but federated DMs may arrive from any connected instance. DM read/write operations are routed to the channel's origin via `getApiForOrigin(getChannelOrigin(channelId))`. S2S relay then propagates changes to all other instances that have the same channel.
### API Client Resolution ### API Client Resolution
@@ -201,7 +201,7 @@ When a WS connection opens and authenticates, the server sends a `ready` event c
2. Merge into the unified space list (replacing stale data from same origin) 2. Merge into the unified space list (replacing stale data from same origin)
3. Build/update `channelOriginMap`, `channelToSpaceMap` 3. Build/update `channelOriginMap`, `channelToSpaceMap`
4. Normalize remote asset URLs to absolute paths 4. Normalize remote asset URLs to absolute paths
5. Skip DM channels from remote origins — DMs are managed exclusively by the home instance 5. Merge DM channels from all origins — DMs are accepted regardless of which instance sends the `ready` event. Channels are deduplicated by `federatedId`: if a DM channel with the same `federatedId` is already loaded (from a previous `ready` event on another connection), the second copy is skipped (first-loaded copy wins). Channels without a `federatedId` are always accepted.
6. Last-write-wins layout merge for sidebar order 6. Last-write-wins layout merge for sidebar order
--- ---
@@ -306,7 +306,7 @@ Client-side and S2S federation serve different purposes:
| Aspect | Client-Side Federation | S2S Federation | | Aspect | Client-Side Federation | S2S Federation |
|---|---|---| |---|---|---|
| **Purpose** | User interacts with multiple instances | Instances exchange data automatically | | **Purpose** | User interacts with multiple instances | Instances exchange data automatically |
| **Scope** | Spaces, friend discovery | DM relay, friend relay, file replication | | **Scope** | Spaces, DM access, friend discovery | DM relay, friend relay, file replication, read state sync |
| **Authentication** | Per-user JWT on each instance | Per-peer HMAC shared secret | | **Authentication** | Per-user JWT on each instance | Per-peer HMAC shared secret |
| **Initiated by** | User (Connections settings) | Admin (peer handshake) | | **Initiated by** | User (Connections settings) | Admin (peer handshake) |
| **Connection** | Client → each server directly | Server → server via outbox | | **Connection** | Client → each server directly | Server → server via outbox |
@@ -315,5 +315,5 @@ Client-side and S2S federation serve different purposes:
1. User adds a remote instance via Connections (client-side) 1. User adds a remote instance via Connections (client-side)
2. The client triggers S2S peering between the two servers (automatic) 2. The client triggers S2S peering between the two servers (automatic)
3. User joins Spaces on the remote instance (client-side — API calls go directly to remote) 3. User joins Spaces on the remote instance (client-side — API calls go directly to remote)
4. User sends DMs — all DM writes go to the home instance. S2S relay distributes messages, reactions, and membership changes to peer instances. 4. User sends DMs — DM writes go to whichever instance delivered the channel (determined by `channelOriginMap`). S2S relay distributes messages, reactions, read states, and membership changes to all peer instances. DM calls remain home-only (gated for federated users).
5. Friend requests and discovery work across instances (client loads friends from all connected instances, S2S relays friend events) 5. Friend requests and discovery work across instances (client loads friends from all connected instances, S2S relays friend events)
+16
View File
@@ -275,6 +275,8 @@ If a `member_add` federation event arrives for a soft-deleted channel (non-null
**Request:** `{ content?: string, attachments?: string[], replyToId?: string }` **Request:** `{ content?: string, attachments?: string[], replyToId?: string }`
**Cross-instance access:** Federated users (those with `homeInstance` set) can send messages on any DM channel where they are a member, regardless of which instance serves the request. The `requireLocalUser` gate that previously blocked federated users from DM write endpoints has been removed. DM calls (`dm_call_*` WS events) remain gated — only local (non-replicated) users can initiate or receive calls.
**Validation:** **Validation:**
- Caller must be a member (`isDmMember`) - Caller must be a member (`isDmMember`)
- Must have content or attachments (not both empty) - Must have content or attachments (not both empty)
@@ -402,6 +404,20 @@ The full event includes `participants` (all channel members with their federated
3. Clean up attachment files from disk 3. Clean up attachment files from disk
4. Broadcast `dm_message_deleted` to all local members 4. Broadcast `dm_message_deleted` to all local members
### Inbound: Read State Update
**Function:** `federation.ts:processReadStateUpdateEvent()`
Triggered by a `read_state_update` relay event sent when a user on another instance acknowledges a DM channel.
1. Resolve channel by `federatedId` — reject if not found
2. Resolve user via `resolveLocalUser` — skip silently if not found
3. Resolve message by `messageRef` (local ID or `source_instance + source_message_id`)
4. Upsert `read_states` row for the resolved user and message
5. Broadcast `channel_ack` to the user's local WebSocket connections for multi-tab sync
Not stored in the outbox or mutation log — fire-and-forget, missed deliveries are not retried.
### Inbound: Reaction Add/Remove ### Inbound: Reaction Add/Remove
**Functions:** `federation.ts:processReactionAddEvent()`, `processReactionRemoveEvent()` **Functions:** `federation.ts:processReactionAddEvent()`, `processReactionRemoveEvent()`
+49 -10
View File
@@ -460,6 +460,7 @@ Body limit: 10 MB. Max 50 events per batch. Rate-limited to 90 requests/min per
| `member_add` | `processMemberAddEvent` | dm | | `member_add` | `processMemberAddEvent` | dm |
| `member_remove` | `processMemberRemoveEvent` | dm | | `member_remove` | `processMemberRemoveEvent` | dm |
| `ownership_transfer` | `processOwnershipTransferEvent` | dm | | `ownership_transfer` | `processOwnershipTransferEvent` | dm |
| `read_state_update` | `processReadStateUpdateEvent` | dm |
| `friend_request_create` | `processFriendRequestCreateEvent` | friend | | `friend_request_create` | `processFriendRequestCreateEvent` | friend |
| `friend_request_update` | `processFriendRequestUpdateEvent` | friend | | `friend_request_update` | `processFriendRequestUpdateEvent` | friend |
| `friend_request_cancel` | `processFriendRequestCancelEvent` | friend | | `friend_request_cancel` | `processFriendRequestCancelEvent` | friend |
@@ -489,7 +490,7 @@ After processing all events, the relay endpoint updates the peer's `lastSeenAt`
5. Sets `bootstrapped = true` to skip redundant system messages and member_add broadcasts below 5. Sets `bootstrapped = true` to skip redundant system messages and member_add broadcasts below
**Incremental path** (channel already exists): **Incremental path** (channel already exists):
1. Validates authority: `sourceInstance === channel.ownerHomeInstance` (only owner's instance can add) 1. Validates authority: any HMAC-verified peer is accepted (relaxed — the `sourceInstance === channel.ownerHomeInstance` check was removed to support cross-instance access). The per-user attribution check (`verifyAttribution`) still applies.
2. Cancels soft-delete if channel was pending GC 2. Cancels soft-delete if channel was pending GC
3. Resolves added user via `resolveOrCreateReplicatedUser` 3. Resolves added user via `resolveOrCreateReplicatedUser`
4. Enforces max 10 members 4. Enforces max 10 members
@@ -628,7 +629,45 @@ Uses the same backoff schedule as outbox delivery. Max attempts: 10 (`MAX_FILE_A
--- ---
## 8. Friend Relay ## 8. Read State Relay
### `read_state_update` Event
When a user marks a DM channel as read (via `channel_ack` WS event), the read state is relayed to all peer instances so cross-instance sessions stay in sync.
**Outbound (`events.ts:handleChannelAck`):**
- Fires after writing `read_states` locally
- Only triggers for DM channels (channel ID found in `dm_members`)
- Calls `sendReadStateRelay(dmChannelId, userId, messageId)` in `federationOutbox.ts`
- Fire-and-forget — not queued via outbox, sent directly to all active peers (same pattern as typing relay)
**Event payload:**
```typescript
{
eventType: 'read_state_update',
contextType: 'dm',
federatedId: string, // DM channel's federatedId (cross-instance channel lookup)
readState: {
user: { homeUserId: string, homeInstance: string },
messageRef: { messageId: string, sourceInstance: string | null }
}
}
```
`messageRef.sourceInstance` is non-null when the acknowledged message was relayed from another instance (i.e., it has a `source_instance` in `dm_messages`). The receiving instance uses this to locate the correct local message row.
**Inbound (`processReadStateUpdateEvent`):**
1. Resolve channel by `federatedId` — reject if not found
2. Resolve user via `resolveLocalUser` — skip if not found (no-op, fire-and-forget)
3. Resolve the message by `messageRef` (local ID if `sourceInstance` is null, otherwise by `source_instance + source_message_id`)
4. Upsert `read_states` row for the resolved local user and message
5. Broadcast `channel_ack` to all of the user's local WebSocket connections (multi-tab/multi-device sync)
**Not persisted in outbox or mutation log** — read state relay is ephemeral; missed deliveries are not retried.
---
## 9. Friend Relay
### Event Flow (social.ts) ### Event Flow (social.ts)
@@ -694,7 +733,7 @@ The full event payload is stored in both `appendMutationLog` (for sync) and `que
--- ---
## 9. Profile Sync ## 10. Profile Sync
Profile sync uses **two mechanisms** that operate independently: Profile sync uses **two mechanisms** that operate independently:
@@ -733,7 +772,7 @@ Profile data is synced server-to-server. The home instance is authoritative —
--- ---
## 10. Reaction Relay ## 11. Reaction Relay
### Outbound ### Outbound
@@ -761,7 +800,7 @@ The mutation log entry for reactions stores a simpler payload (no `messageId`/`m
--- ---
## 11. Initial Sync ## 12. Initial Sync
### `runInitialSyncForNewPeers()` (`federationWorker.ts:739`) ### `runInitialSyncForNewPeers()` (`federationWorker.ts:739`)
@@ -806,7 +845,7 @@ The event processing logic is extracted into `processRelayEvents()` (exported fr
--- ---
## 12. DM Calls over Federation ## 13. DM Calls over Federation
DM calls work across federated instances. The caller's instance hosts the LiveKit room. Remote clients connect directly to the caller's LiveKit server using a token passed through S2S relay — no media is routed through the federation layer. DM calls work across federated instances. The caller's instance hosts the LiveKit room. Remote clients connect directly to the caller's LiveKit server using a token passed through S2S relay — no media is routed through the federation layer.
@@ -888,7 +927,7 @@ This registry ensures tokens and `livekitUrl` survive browser refreshes via the
--- ---
## 13. Background Workers ## 14. Background Workers
All workers are started by `startFederationWorkers()` on server boot and stopped by `stopFederationWorkers()` on shutdown. Each worker uses `setTimeout` chains (not `setInterval`) with abort controllers for graceful shutdown. All workers are started by `startFederationWorkers()` on server boot and stopped by `stopFederationWorkers()` on shutdown. Each worker uses `setTimeout` chains (not `setInterval`) with abort controllers for graceful shutdown.
@@ -914,7 +953,7 @@ DM channel hard-delete cascades: reactions, embeds, attachments (DB rows + disk
--- ---
## 14. Settings Cache ## 15. Settings Cache
`federationOutbox.ts` caches `federationRelayEnabled` and `federationRelayTtlDays` from `instance_settings` for 30 seconds (`CACHE_TTL_MS`). This prevents repeated DB reads on every message send. The cache is invalidated by TTL only -- there is no explicit cache bust on settings change. `federationOutbox.ts` caches `federationRelayEnabled` and `federationRelayTtlDays` from `instance_settings` for 30 seconds (`CACHE_TTL_MS`). This prevents repeated DB reads on every message send. The cache is invalidated by TTL only -- there is no explicit cache bust on settings change.
@@ -928,7 +967,7 @@ Relevant settings in `instance_settings`:
--- ---
## 15. Client-Side Identity Helpers (`identity.ts`) ## 16. Client-Side Identity Helpers (`identity.ts`)
The frontend needs to resolve federated identities for display purposes: The frontend needs to resolve federated identities for display purposes:
@@ -944,7 +983,7 @@ The frontend needs to resolve federated identities for display purposes:
--- ---
## 16. Self-Healing Migrations (`migrate.ts`) ## 17. Self-Healing Migrations (`migrate.ts`)
The migration system includes several data integrity checks that run on every server startup: The migration system includes several data integrity checks that run on every server startup:
+4
View File
@@ -192,6 +192,10 @@ reason: `'displaced'` (new tab) | `'session_closed'`
|------|--------|-------| |------|--------|-------|
| `federation_file_rejected` | messageId, dmChannelId, attachmentId, affectedUsers[] | DM members | | `federation_file_rejected` | messageId, dmChannelId, attachmentId, affectedUsers[] | DM members |
**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 ## Ready Payload