From 39dce67a7f00265cd615d20677637aabf6cc68f2 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:59:45 +0200 Subject: [PATCH] docs: update specs for S2S DM unification and typing relay --- docs/systems/client-federation.md | 8 ++++---- docs/systems/dm-system.md | 7 +++++-- docs/systems/federation.md | 24 ++++++++++++++++++++++++ docs/systems/websocket.md | 1 + 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/docs/systems/client-federation.md b/docs/systems/client-federation.md index 8fa18f56..02b5ad80 100644 --- a/docs/systems/client-federation.md +++ b/docs/systems/client-federation.md @@ -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. +> **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 ```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) 3. Build/update `channelOriginMap`, `channelToSpaceMap` 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 --- @@ -261,7 +263,5 @@ Client-side and S2S federation serve different purposes: 1. User adds a remote instance via Connections (client-side) 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) -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) - -> **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. diff --git a/docs/systems/dm-system.md b/docs/systems/dm-system.md index 661975a5..91ecf4c3 100644 --- a/docs/systems/dm-system.md +++ b/docs/systems/dm-system.md @@ -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. +**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 @@ -603,10 +605,10 @@ const normalized = homeInstance.startsWith('http') | Method | Path | Auth | Purpose | |--------|------|------|---------| | `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 | | `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 | | `GET` | `/api/dm/:id/messages` | JWT | Get messages with cursor pagination | | `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_updated` | S->C | Message edit | | `dm_message_deleted` | S->C | Message delete | +| `dm_typing_stop` | S->C | Message send (clears indicator immediately) | --- diff --git a/docs/systems/federation.md b/docs/systems/federation.md index c07e861e..82d2072e 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -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`. +### 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 @@ -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_remove` | `processFriendRemoveEvent` | friend | | `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`. diff --git a/docs/systems/websocket.md b/docs/systems/websocket.md index 1f84e8b2..a440cf3d 100644 --- a/docs/systems/websocket.md +++ b/docs/systems/websocket.md @@ -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_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