Files
backspace/docs/federation-dm-s2s.md
T
Jannis Braun d021ee796d docs: add federated DM S2S protocol reference
Comprehensive internal document covering the complete lifecycle of
federated DMs: data model, relay pipeline, event processing, bootstrap
path, local-only broadcast principle, system messages, and all known
pitfalls with their fixes.
2026-03-28 02:10:55 +01:00

15 KiB

Federated DM Server-to-Server (S2S) Protocol

Internal reference for agents working on Backspace federation. Covers the complete lifecycle of federated DMs: data model, relay pipeline, event processing, and known pitfalls.

Overview

Each Backspace instance maintains its own copy of DM channels and messages. Users interact with the copy on their home instance (the instance where their account was created). Federation relay synchronizes events between instances so all participants see the same conversation.

Key principle: A user should only see ONE copy of any DM channel — the one on their home instance. Cross-instance broadcasts must be filtered to local-only members.

Data Model

Tables

dm_channels
├── id                  TEXT PK     — Snowflake, instance-local
├── owner_id            TEXT NULL   — NULL = 1-on-1, non-NULL = group DM (creator's local user ID)
├── federated_id        TEXT NULL   — Cross-instance channel identity (set when any member is remote)
├── owner_home_user_id  TEXT NULL   — Owner's ID on their home instance
├── owner_home_instance TEXT NULL   — Owner's home instance origin
├── deleted_at          INTEGER NULL — Soft-delete timestamp (GC after last member leaves)
└── created_at          INTEGER NOT NULL

dm_members
├── dm_channel_id  TEXT NOT NULL → dm_channels.id
├── user_id        TEXT NOT NULL → users.id (local user ID on this instance)
├── closed         INTEGER DEFAULT 0 (soft-close, per-user)
└── PK(dm_channel_id, user_id)

dm_messages
├── id              TEXT PK
├── dm_channel_id   TEXT NOT NULL → dm_channels.id
├── user_id         TEXT NOT NULL → users.id (actor)
├── content         TEXT NULL
├── type            TEXT NOT NULL DEFAULT 'user' — 'user' | 'system'
├── reply_to_id     TEXT NULL
├── edited_at       INTEGER NULL
├── source_instance TEXT NULL — origin instance for relayed messages
├── source_message_id TEXT NULL — original message ID on source instance
└── created_at      INTEGER NOT NULL

Channel Type Identification

Field 1-on-1 DM Group DM
owner_id NULL Creator's local user ID
federated_id format Deterministic SHA-256 hash Random UUID
Mutable membership No (immutable pair) Yes (owner can add, anyone can leave)
Max members 2 10

Critical invariant: owner_id must NEVER be set to NULL on a group DM. This would make it indistinguishable from a 1-on-1 and corrupt the channel's type identity.

Federated ID Generation

// 1-on-1: deterministic from the pair's home user IDs (same result on any instance)
const sorted = [homeUserIdA, homeUserIdB].sort();
const federatedId = sha256(sorted.join(':')).slice(0, 32);  // 32-char hex

// Group: random UUID assigned by the creating instance
const federatedId = crypto.randomUUID();  // 36-char UUID with dashes

The format difference (32-char hash vs 36-char UUID) can be used to detect channel type independently of owner_id.

User Identity Resolution

Users exist on their home instance as native records (home_instance = NULL). On other instances, they appear as replicated user stubs with home_instance and home_user_id set.

Resolution Functions

Function Behavior Use When
resolveOrCreateReplicatedUser(homeUserId, homeInstance, db) Finds existing user or creates a stub. Always returns a valid user. You MUST have a valid user ID (e.g., setting ownerId, inserting system messages)
resolveLocalUser(homeUserId, db) Read-only lookup. Returns null if not found. Optional lookups where null is acceptable

Rule: Any code path that sets ownerId, creates a dm_members row, or inserts a message MUST use resolveOrCreateReplicatedUser. Using resolveLocalUser with a ?? null fallback has caused data corruption.

Origin Normalization

Critical pitfall: Two different formats exist in the database:

Location Format Example
users.home_instance Bare domain nova.ddns.net
federation_peers.origin Full URL https://nova.ddns.net
getOurOrigin() return value Full URL https://orbit.ddns.net

When comparing homeInstance against peer origins or getOurOrigin(), always normalize:

const normalized = homeInstance.startsWith('http') ? homeInstance : `https://${homeInstance}`;

Failure to normalize causes silent failures where queueOutboxEvent finds zero matching peers and drops events without error.

Relay Pipeline

Outbound Flow (Origin Instance)

1. API endpoint creates/modifies DM data
2. appendMutationLog() — permanent audit record in federation_mutation_log
3. queueOutboxEvent(messageId, contextId, eventType, payload, targetOrigins)
   ├── Fetches active peers from federation_peers
   ├── Filters to targetOrigins (normalized homeInstance → peer.origin match)
   ├── Inserts one federation_outbox row per target peer
   └── If targetOrigins produces zero peers → event is silently dropped
4. Outbox worker (10-second interval) batches pending events per peer
5. POST /api/federation/relay to each peer with signed payload
6. Peer responds with accepted/rejected arrays
7. Accepted events deleted from outbox; rejected events logged

Target Origin Resolution

For group DMs, getGroupDmTargetOrigins(channelId) determines which peers receive events:

function getGroupDmTargetOrigins(channelId: string): string[] {
  // Query all members' homeInstances
  // Normalize to full URL format
  // Filter out our own origin
  // Return unique peer origins that have members in this group
}

For 1-on-1 DMs, targetOrigins is undefined → broadcasts to ALL active peers.

Inbound Flow (Receiving Instance)

1. POST /api/federation/relay arrives with signed events array
2. Verify request signature against peer's public key
3. For each event, dispatch to type-specific processor:
   ├── create/update/delete → processCreateEvent / processUpdateEvent / processDeleteEvent
   ├── member_add          → processMemberAddEvent
   ├── member_remove       → processMemberRemoveEvent
   ├── ownership_transfer  → processOwnershipTransferEvent
   ├── reaction_add/remove → processReactionEvent
   └── friend_add/remove   → processFriendEvent
4. Return accepted/rejected arrays

Event Processing — Group DM Lifecycle

member_add (processMemberAddEvent)

Two paths:

Bootstrap path (channel doesn't exist locally):

  1. Channel not found by federatedId → create from event.group metadata
  2. Resolve owner via resolveOrCreateReplicatedUser
  3. Create dm_channels row with ownerId, federatedId, owner federation fields
  4. Add ALL roster members from event.group.members (resolve each via resolveOrCreateReplicatedUser)
  5. Send dm_channel_created to local-only members (home instance matches this server)
  6. Set bootstrapped = true to skip redundant broadcasts below
  7. Insert system message for member addition (local-only, inside !bootstrapped guard)

Incremental path (channel already exists):

  1. Channel found by federatedId
  2. Validate authority: only owner's instance can add members
  3. Resolve added user via resolveOrCreateReplicatedUser
  4. Insert dm_members row (idempotent — skip if already exists)
  5. Insert system message for the addition
  6. Send dm_member_added to local WebSocket clients

member_remove (processMemberRemoveEvent)

  1. Find channel by federatedId
  2. Validate authority: owner's instance for kicks, any instance for self-leave
  3. Resolve user via resolveLocalUser (they should already exist)
  4. Insert system message (before deletion, so broadcast includes the leaving user)
  5. Delete dm_members row
  6. Clean up read_states
  7. Send dm_member_removed to remaining local members
  8. If zero members remain → soft-delete channel (deleted_at = now)

ownership_transfer (processOwnershipTransferEvent)

  1. Find channel by federatedId
  2. Validate authority: only current owner's instance can transfer
  3. Resolve new owner via resolveOrCreateReplicatedUser (never resolveLocalUser — must guarantee valid ID)
  4. Update dm_channels: ownerId, ownerHomeUserId, ownerHomeInstance
  5. Send dm_owner_updated WebSocket event to local members
  6. Insert system message for the transfer

Event Processing — DM Messages

create (DM message relay)

Group DMs (event.federatedId present):

  1. Find channel by federatedId
  2. If not found → skip (channel should be bootstrapped by member_add first)
  3. Insert message with sourceInstance and sourceMessageId for dedup
  4. Broadcast dm_message_created to local members

1-on-1 DMs (no federatedId):

  1. Compute deterministic federatedId from sender + recipient home IDs
  2. findOrCreateDmChannel() — find by federatedId or create with ownerId = NULL
  3. Insert message, broadcast to local members

Local-Only Broadcast Principle

Users connected to multiple instances must see each DM channel exactly once (from their home instance). All dm_channel_created broadcasts and system message broadcasts filter to local members only:

const isLocalMember = (u: { homeInstance?: string | null }) =>
  !u.homeInstance || !domainOrigin ||
  u.homeInstance === domainOrigin ||
  `https://${u.homeInstance}` === domainOrigin;
  • Origin instance: broadcasts only to local members after creating the group DM
  • Receiving instance (bootstrap): broadcasts only to members whose home instance matches
  • Remote members receive notification through federation relay → bootstrap on their home instance

Does NOT apply to: Regular DM messages (dm_message_created for user messages). These broadcast to all local dm_members regardless of home instance, because the message relay ensures eventual delivery to all instances. Faster delivery to users connected to the origin is acceptable since the message ID deduplicates on the receiving instance.

System Messages

System messages (type = 'system' in dm_messages) record group lifecycle events in the chat timeline.

Events

Event Content JSON Actor (userId)
member_added {event, targetUserId, targetDisplayName} User who added them
member_removed {event, targetUserId, targetDisplayName, reason} User who left/was removed
owner_changed {event, newOwnerId, newOwnerDisplayName} Previous owner

Creation Pattern

System messages are instance-local — they are NOT relayed via federation. Each instance creates its own system messages independently when processing federation events:

  • Origin instance: Creates system messages in the REST endpoint (e.g., POST /api/dm/group), broadcasts to local members only
  • Receiving instance: Creates system messages in the federation event processor (e.g., processMemberAddEvent), broadcasts to local members

This avoids duplicate system messages for users connected to multiple instances.

Rendering

Frontend detects message.type === 'system', parses JSON content, renders as compact centered text with icons (→ added, ← left, ♛ owner change). No avatar, no context menu, no reactions.

WebSocket Events

State-change events (structural)

Event Purpose When
dm_channel_created New DM appears in sidebar Group DM bootstrap, or new 1-on-1
dm_member_added Member added to existing group Incremental member_add (not bootstrap)
dm_member_removed Member left/removed from group member_remove processing
dm_owner_updated Group ownership changed ownership_transfer processing
dm_channel_closed DM removed from sidebar for leaving user User leaves group

Content events

Event Purpose
dm_message_created New message (user or system)
dm_message_updated Message edited
dm_message_deleted Message deleted

Known Pitfalls & Historical Bugs

1. ownerId nulling (FIXED)

processOwnershipTransferEvent used resolveLocalUser with ?? null fallback. When resolution failed (even transiently), it set ownerId = NULL, converting the group DM into a 1-on-1. Fix: use resolveOrCreateReplicatedUser which always returns a valid user.

Self-healing migration in migrate.ts detects group DMs with UUID-format federated_id but NULL owner_id and restores the owner from the first remaining member.

2. Origin normalization (FIXED)

getGroupDmTargetOrigins() returned bare domains from users.home_instance, but queueOutboxEvent() compared them against federation_peers.origin (full URLs). No peers matched → events silently dropped. Fix: normalize to full URL before comparison.

3. Missing federatedId in outbox reconstruction (FIXED)

The outbox worker (federationWorker.ts) reconstructed relay events from stored payloads but never copied federatedId. Receiving instances check this field and rejected all member_add/remove/ownership_transfer events. Fix: copy parsed.federatedId during reconstruction.

4. Cross-instance duplicate channels (FIXED)

dm_channel_created was broadcast to ALL members including remote replicas. Users connected to multiple instances received the event twice (different channel IDs), creating duplicate sidebar entries. Fix: local-only broadcast principle.

5. Bootstrap vs incremental confusion

The bootstrapped flag in processMemberAddEvent is a local variable — each function invocation starts fresh. When multiple member_add events arrive in a batch (common for group creation), only the FIRST triggers bootstrap. Subsequent events see the channel exists and take the incremental path. This is correct — the bootstrap adds ALL roster members, so the incremental events are idempotent.

File Map

File Responsibility
packages/server/src/routes/dm.ts DM REST endpoints, system message creation, federation event queueing
packages/server/src/routes/federation.ts Inbound event processing: processMemberAddEvent, processMemberRemoveEvent, processOwnershipTransferEvent, resolveOrCreateReplicatedUser, resolveLocalUser
packages/server/src/utils/federationOutbox.ts queueOutboxEvent, appendMutationLog, getDmParticipants, getGroupDmTargetOrigins, computeFederatedId
packages/server/src/utils/federationWorker.ts Outbox flush worker (10s interval), event reconstruction, delivery to peers
packages/server/src/utils/federationAuth.ts getOurOrigin(), request signing, peer verification
packages/server/src/ws/handler.ts connectionManager.sendToUser(), sendToDmMembers() — WebSocket broadcast
packages/server/src/db/schema.ts Drizzle table definitions including dm_channels, dm_members, dm_messages
packages/server/src/db/migrate.ts Schema migrations + self-healing data integrity checks
packages/web/src/hooks/useWebSocket.ts Frontend WebSocket event handlers for all DM events
packages/web/src/stores/spaceStore.ts Zustand store: addDmChannel, addDmMember, removeDmMember, updateDmOwner