docs: document S2S DM close/reopen relay events

Add federation.md section 8b covering dm_close/dm_reopen relay events:
payload, outbound queueing via queueDmCloseRelay, inbound processDmCloseEvent/
processDmReopenEvent handlers (lookup-only identity resolution, silent
no-ops on missing channel/user/membership), and the processCreateEvent
closed-state reopen bug fix.

Update dm-system.md soft-close section with federation behaviour: relay
to peers for both close and reopen, relayed-message reopen in
processCreateEvent, and the federatedId-only guard for legacy DMs.
This commit is contained in:
Jannis Braun
2026-04-07 22:43:04 +02:00
parent f85bb4cca3
commit 28624343d0
2 changed files with 83 additions and 0 deletions
+9
View File
@@ -159,6 +159,15 @@ When a new message arrives in a DM channel, for each member with `closed = 1`:
This ensures closed DMs resurface automatically when new activity occurs.
### Federation
Close and reopen are relayed to all peer instances that hold a copy of the DM:
- **Close relay:** After setting `closed = 1` locally, `queueDmCloseRelay(channelId, userId, 'dm_close')` queues a `dm_close` outbox event. The receiving instance finds the channel by `federatedId`, resolves the acting user via `resolveLocalUser`, sets `closed = 1` on the local `dm_members` row, and broadcasts `dm_channel_closed`.
- **Reopen relay:** Explicit reopens (`POST /api/dm/:id/reopen`) queue a `dm_reopen` event. The receiving instance sets `closed = 0` and broadcasts `dm_channel_created` with a full channel payload.
- **Relayed-message reopen:** `processCreateEvent` (inbound message relay) also checks each recipient's `closed` flag and performs the same resurface sequence (`dm_channel_created``dm_message_created`) — mirroring `broadcastDmMessage`. This ensures messages relayed from a remote instance properly reopen closed DMs on the receiving instance.
- Only fires for DMs with a `federatedId`. Legacy local-only DMs (no `federatedId`) are unaffected.
### Frontend
- `spaceStore.closeDm(id)` calls `api.dm.close(id)` then removes the channel from `dmChannels` state
+74
View File
@@ -469,6 +469,8 @@ Body limit: 10 MB. Max 50 events per batch. Rate-limited to 90 requests/min per
| `file_rejected` | `processFileRejectedEvent` | dm |
| `dm_typing_start` | `processDmTypingStartEvent` | dm (fire-and-forget, no outbox) |
| `dm_typing_stop` | `processDmTypingStopEvent` | dm (fire-and-forget, no outbox) |
| `dm_close` | `processDmCloseEvent` | dm |
| `dm_reopen` | `processDmReopenEvent` | dm |
After processing all events, the relay endpoint updates the peer's `lastSeenAt` and resets `consecutiveFailures`, then returns accepted/rejected arrays plus `maxUploadSize`.
@@ -673,6 +675,78 @@ When a user marks a DM channel as read (`channel_ack`) or marks it unread (`mark
---
## 8b. DM Close/Reopen Relay
### Overview
When a user closes or reopens a DM on their home instance, the action is relayed to all peer instances that hold a copy of the channel. This keeps the visibility state of a DM consistent across all instances that participate in it.
Only DMs with a `federatedId` are eligible. Legacy local-only DMs (created before federation was added, with no `federatedId`) are silently skipped.
### Event Types
| Event | Trigger |
|-------|---------|
| `dm_close` | User calls `DELETE /api/dm/:id` (soft-close) |
| `dm_reopen` | User calls `POST /api/dm/:id/reopen` (explicit reopen) |
### Payload
```typescript
{
eventType: 'dm_close' | 'dm_reopen',
dmChannelId: string, // local channel ID (context only)
federatedId: string, // cross-instance channel lookup key
messageId: string, // unique event ID: 'dm_close:{userId}:{ts}' or 'dm_reopen:{userId}:{ts}'
encryptionVersion: 0,
timestamp: number,
dmCloseReopen: {
homeUserId: string, // acting user's home user ID
homeInstance: string, // acting user's home instance (full URL)
}
}
```
### Outbound (`federationOutbox.ts:queueDmCloseRelay`)
Called from `dm.ts` after the local close or reopen is committed.
1. Fetch the channel's `federatedId` — if null (local-only DM), return silently
2. Fetch the acting user's `(homeUserId, homeInstance)` federation identity
3. Build `FederationRelayEvent` with `eventType` and `dmCloseReopen` payload
4. `getGroupDmTargetOrigins(dmChannelId)` resolves the delivery targets:
- 1-on-1 DMs (`ownerId = NULL`): returns `undefined` → broadcast to ALL active peers
- Group DMs: returns the set of peer origins that have at least one participant
5. Enqueue via `appendMutationLog` + `queueOutboxEvent`
### Inbound
**`processDmCloseEvent` (`federation.ts`):**
1. Look up channel by `federatedId` — if not found, accept silently (idempotent)
2. Resolve acting user via `resolveLocalUser` (lookup-only; no stub creation for close/reopen) — if not found, accept silently
3. If the user has no `dm_members` row in this channel, accept silently
4. Set `dm_members.closed = 1` for the resolved local user
5. Broadcast `dm_channel_closed` to the user's local WebSocket connections
**`processDmReopenEvent` (`federation.ts`):**
1. Look up channel by `federatedId` — if not found, accept silently
2. Resolve acting user via `resolveLocalUser` — if not found, accept silently
3. If the user has no `dm_members` row, accept silently
4. Set `dm_members.closed = 0`
5. Build full `DmChannel` payload and broadcast `dm_channel_created` to the user's local WebSocket connections (mirrors the automatic reopen path in `broadcastDmMessage`)
### Closed-State Reopen on Message Relay (Bug Fix)
`processCreateEvent` (inbound message relay) now mirrors the local `broadcastDmMessage` logic: before broadcasting `dm_message_created`, it checks each recipient's `dm_members.closed` flag. For any recipient with `closed = 1`:
1. Set `closed = 0`
2. Broadcast `dm_channel_created` (with the new message as `lastMessage`) to resurface the DM in the recipient's sidebar
3. Then broadcast `dm_message_created`
This fixes a gap where relayed messages bypassed the closed-state reopen logic, leaving the DM hidden for recipients whose `closed` flag was set on the receiving instance.
---
## 9. Friend Relay
### Event Flow (social.ts)