docs: update specs for S2S DM unification and typing relay

This commit is contained in:
Jannis Braun
2026-04-01 12:59:45 +02:00
parent e1ece8a5b6
commit 39dce67a7f
4 changed files with 34 additions and 6 deletions
+4 -4
View File
@@ -151,6 +151,8 @@ 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.
### API Client Resolution ### API Client Resolution
```typescript ```typescript
@@ -199,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. Deduplicate 1-on-1 DMs that appear from multiple origins (prefer home) 5. Skip DM channels from remote origins — DMs are managed exclusively by the home instance
6. Last-write-wins layout merge for sidebar order 6. Last-write-wins layout merge for sidebar order
--- ---
@@ -261,7 +263,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 (messages go to the appropriate server, S2S relay distributes to peers) 4. User sends DMs — all DM writes go to the home instance. S2S relay distributes messages, reactions, and membership changes to peer instances.
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)
> **Note on DMs (2026-04-01):** DM operations currently use a dual path — the client may route DM writes to the remote instance directly (client-side) or through the home instance (S2S relay). This is being unified to S2S-only for DMs. See the S2S DM Unification project.
+5 -2
View File
@@ -79,6 +79,8 @@ The format difference (32-char hex vs 36-char UUID with dashes) allows detecting
**No federation event queued at creation time.** The `federatedId` for 1-on-1 DMs is computed on demand when the first message is relayed via `queueDmRelay()`. The receiving instance uses `findOrCreateDmChannel()` which computes the deterministic hash and creates the channel if needed. **No federation event queued at creation time.** The `federatedId` for 1-on-1 DMs is computed on demand when the first message is relayed via `queueDmRelay()`. The receiving instance uses `findOrCreateDmChannel()` which computes the deterministic hash and creates the channel if needed.
**Client routing:** DM creation always goes to the home instance. For federated users, the client passes `{ homeUserId, homeInstance }` and the server resolves the target via `resolveOrCreateReplicatedUser()`. The `federatedId` is computed at creation time when either participant has `homeInstance` set.
--- ---
## Group DM Creation ## Group DM Creation
@@ -603,10 +605,10 @@ const normalized = homeInstance.startsWith('http')
| Method | Path | Auth | Purpose | | Method | Path | Auth | Purpose |
|--------|------|------|---------| |--------|------|------|---------|
| `GET` | `/api/dm` | JWT | List caller's DM channels (excludes `closed=1` and `deleted_at` IS NOT NULL) | | `GET` | `/api/dm` | JWT | List caller's DM channels (excludes `closed=1` and `deleted_at` IS NOT NULL) |
| `POST` | `/api/dm` | JWT | Create or get existing 1-on-1 DM | | `POST` | `/api/dm` | JWT | Create or get existing 1-on-1 DM. Accepts `{ userId }` (local) or `{ homeUserId, homeInstance }` (federated) |
| `POST` | `/api/dm/group` | JWT | Create group DM with multiple members | | `POST` | `/api/dm/group` | JWT | Create group DM with multiple members |
| `DELETE` | `/api/dm/:id` | JWT | Soft-close DM for caller | | `DELETE` | `/api/dm/:id` | JWT | Soft-close DM for caller |
| `POST` | `/api/dm/:id/members` | JWT | Add member to group DM (owner only) | | `POST` | `/api/dm/:id/members` | JWT | Add member to group DM (owner only). Accepts `{ userId }` or `{ homeUserId, homeInstance }` |
| `DELETE` | `/api/dm/:id/members` | JWT | Leave group DM | | `DELETE` | `/api/dm/:id/members` | JWT | Leave group DM |
| `GET` | `/api/dm/:id/messages` | JWT | Get messages with cursor pagination | | `GET` | `/api/dm/:id/messages` | JWT | Get messages with cursor pagination |
| `POST` | `/api/dm/:id/messages` | JWT | Send message (rate-limited: 5/5s) | | `POST` | `/api/dm/:id/messages` | JWT | Send message (rate-limited: 5/5s) |
@@ -647,6 +649,7 @@ For full wire formats, see `docs/systems/websocket.md`.
| `dm_message_created` | S->C | New message (user or system) | | `dm_message_created` | S->C | New message (user or system) |
| `dm_message_updated` | S->C | Message edit | | `dm_message_updated` | S->C | Message edit |
| `dm_message_deleted` | S->C | Message delete | | `dm_message_deleted` | S->C | Message delete |
| `dm_typing_stop` | S->C | Message send (clears indicator immediately) |
--- ---
+24
View File
@@ -312,6 +312,28 @@ Every relayed message is stored with:
The `(source_instance, source_message_id)` pair is checked before insertion. Duplicates are rejected with reason `'duplicate'`. A unique partial index enforces this at the DB level: `idx_dm_messages_source_unique ON dm_messages(source_instance, source_message_id) WHERE source_instance IS NOT NULL`. The `(source_instance, source_message_id)` pair is checked before insertion. Duplicates are rejected with reason `'duplicate'`. A unique partial index enforces this at the DB level: `idx_dm_messages_source_unique ON dm_messages(source_instance, source_message_id) WHERE source_instance IS NOT NULL`.
### Typing Indicator Relay
**Event types:** `dm_typing_start`, `dm_typing_stop`
**Model:** Fire-and-forget, same as call signaling. No outbox, no retry, no mutation log. Typing is ephemeral — lost packets are acceptable.
**Channel identification:** Uses `federatedId` (not instance-local `dmChannelId`) for cross-instance channel lookup, plus `participants` for resolution context.
**Outbound (`events.ts` / `dm.ts`):**
- `handleDmTypingStart()` → after local broadcast, calls `sendTypingRelay(dmChannelId, 'dm_typing_start', userId)`
- `broadcastDmMessage()` → after local `dm_typing_stop` broadcast, calls `sendTypingRelay(dmChannelId, 'dm_typing_stop', message.userId)`
**`sendTypingRelay()` (`federationOutbox.ts`):**
- Fetches channel's `federatedId` and `getDmParticipants()` for target resolution
- Builds `FederationRelayEvent` with `typing: { homeUserId, homeInstance, username }`
- Reuses `sendCallRelay()` for the actual POST to each remote peer origin
**Inbound (`federation.ts`):**
- `processDmTypingStartEvent` → look up channel by `federatedId`, resolve user via `resolveLocalUser()` (no stub creation for ephemeral events), broadcast `dm_typing` to local members
- `processDmTypingStopEvent` → same, broadcast `dm_typing_stop` to local members
- **Implicit clear:** `processCreateEvent()` also emits `dm_typing_stop` for the message author after processing an inbound relay — primary typing clear mechanism for relayed messages
--- ---
## 5. Outbox & Relay Pipeline ## 5. Outbox & Relay Pipeline
@@ -425,6 +447,8 @@ Body limit: 10 MB. Max 50 events per batch. Rate-limited to 90 requests/min per
| `friend_add` | `processFriendAddEvent` | friend | | `friend_add` | `processFriendAddEvent` | friend |
| `friend_remove` | `processFriendRemoveEvent` | friend | | `friend_remove` | `processFriendRemoveEvent` | friend |
| `file_rejected` | `processFileRejectedEvent` | dm | | `file_rejected` | `processFileRejectedEvent` | dm |
| `dm_typing_start` | `processDmTypingStartEvent` | dm (fire-and-forget, no outbox) |
| `dm_typing_stop` | `processDmTypingStopEvent` | dm (fire-and-forget, no outbox) |
After processing all events, the relay endpoint updates the peer's `lastSeenAt` and resets `consecutiveFailures`, then returns accepted/rejected arrays plus `maxUploadSize`. After processing all events, the relay endpoint updates the peer's `lastSeenAt` and resets `consecutiveFailures`, then returns accepted/rejected arrays plus `maxUploadSize`.
+1
View File
@@ -111,6 +111,7 @@ Source: `packages/server/src/ws/handler.ts`, `packages/server/src/ws/events.ts`
| `dm_message_updated` | message: DmMessageWithUser | DM members | | `dm_message_updated` | message: DmMessageWithUser | DM members |
| `dm_message_deleted` | messageId, dmChannelId | DM members | | `dm_message_deleted` | messageId, dmChannelId | DM members |
| `dm_typing` | dmChannelId, userId, username | DM members (excludes sender) | | `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 | | `dm_embeds_resolved` | messageId, dmChannelId, embeds[] | DM members |
### Read State ### Read State