60 KiB
DM System
Source files:
packages/server/src/routes/dm.ts-- REST endpoints for DM CRUD, group lifecycle, message send/edit/delete, federation event queueing,broadcastDmMessage()with soft-close reopen logicpackages/server/src/routes/federation.ts-- Inbound relay event processors:processMemberAddEvent,processMemberRemoveEvent,processOwnershipTransferEvent,processCreateEvent,processUpdateEvent,processDeleteEvent, reaction processors, identity resolution (resolveLocalUser,resolveOrCreateReplicatedUser,findOrCreateDmChannel)packages/server/src/utils/federationOutbox.ts--queueOutboxEvent,appendMutationLog,queueDmRelay,getDmParticipants,getGroupDmTargetOrigins,computeFederatedId,buildRelayPayloadpackages/server/src/utils/storageJanitor.ts--cleanupSoftDeletedDmChannels()(24h grace period hard-delete)packages/server/src/utils/userDeletion.ts--tombstoneUser(): DM membership partition (1-on-1 kept / group dropped) + dead-DM purge on "zero live members" (see "DM Tombstone Semantics")packages/server/src/utils/permissions.ts--isDeadOneOnOne()read-only guard for Deleted-User 1-on-1 threadspackages/server/src/db/migrate.ts-- Self-healing migration for corrupted group DM ownership;backfillOneOnOneDmMembership()restores pre-fix Deleted-User 1-on-1 threadspackages/web/src/components/chat/DmDeletedNotice.tsx-- read-only composer notice;packages/web/src/utils/dmFormatters.ts:isDeletedPartnerDm()gates itpackages/server/src/ws/handler.ts--sendToDmMembers()broadcasts (ConnectionManager method)packages/web/src/stores/spaceStore.ts-- Zustand DM state:addDmChannel,removeDmChannel,addDmMember,removeDmMember,updateDmOwner,closeDm,leaveDm,findExistingDmForUserpackages/web/src/hooks/useWebSocket.ts-- Frontend WS event handlers fordm_channel_created,dm_channel_closed,dm_member_added,dm_member_removed,dm_owner_updatedpackages/web/src/components/modals/NewDmModal.tsx-- 1-on-1 DM creation UI with user search and deduplicationpackages/web/src/components/modals/AddDmMemberModal.tsx-- Group DM member add / 1-on-1 upgrade UI
DB tables: dm_channels, dm_members, dm_messages, dm_reactions, read_states, attachments, embeds. See docs/systems/database.md for full schemas.
Related specs: docs/systems/federation.md (wire protocol, outbox worker, peer lifecycle), docs/systems/websocket.md (event wire formats), docs/systems/voice.md (DM call state machine).
Channel Type Identification
| Property | 1-on-1 DM | Group DM |
|---|---|---|
ownerId |
NULL |
Creator's local user ID (never NULL) |
federatedId format |
32-char hex (SHA-256 hash) | 36-char UUID (random) |
| Mutable membership | No (immutable pair) | Yes (any member adds, anyone leaves) |
| Max members | 2 | 10 |
| Friendship required | No | Yes (for new adds; exempt for existing DM members during 1-on-1 upgrade) |
| Soft-close | Yes (closed=1 on dm_members) |
Yes (same) |
| Leave | Not supported (use close) | Yes (DELETE /api/dm/:id/members) |
| Deletion | Never (1-on-1 DMs persist) | Soft-delete when last member leaves, hard-delete after 24h |
name / icon |
Always NULL (no metadata) | Nullable. NULL = use comma-joined / AvatarStack fallback. Owner-only writes via PATCH /api/dm/:id |
Critical invariant: ownerId must NEVER be set to NULL on a group DM. A NULL ownerId identifies the channel as 1-on-1 -- nulling it corrupts the channel's type identity and breaks membership logic.
Federated ID Algorithm
// federationOutbox.ts:computeFederatedId()
// 1-on-1: deterministic SHA-256 hash of sorted home user IDs
// Same result on any instance for the same user pair
const sorted = [homeUserIdA, homeUserIdB].sort();
const federatedId = crypto.createHash('sha256')
.update(sorted.join(':'))
.digest('hex')
.slice(0, 32); // 32-char hex string
// Group: random UUID assigned by the creating instance
const federatedId = crypto.randomUUID(); // 36-char UUID with dashes
The format difference (32-char hex vs 36-char UUID with dashes) allows detecting channel type independently of ownerId. The self-healing migration uses this: length(federated_id) = 36 AND federated_id LIKE '________-____-____-____-____________' identifies group DMs.
Re-attach re-keys the 1-on-1 federatedId (reattach-dm-reconcile spec). Because the 1-on-1 id derives from the two participants' home_user_ids, a participant's home_user_id change — the detached-account re-attach flow (POST /api/users/@me/reattach, see federation.md) — changes the federatedId of every 1-on-1 DM that participant is in. Left alone, pre-reattach history would stay under the old-identity channel while new messages compute the new id and split into a parallel channel (one conversation shown twice). Instead, existing channels are reconciled by reconcileDmChannelFederatedId: re-keyed in place when no channel yet holds the new id, or merged + deleted into the existing new-identity channel when one does (idx_dm_federated is UNIQUE, so a re-key onto an occupied id is impossible). This runs inline in the re-attach transaction for the re-attaching account and as an idempotent startup sweep (reconcileDriftedDmFederatedIds) that heals accounts re-attached before the fix shipped. Group DMs (random-UUID federatedId, member-independent) are never affected.
1-on-1 DM Creation
Endpoint: POST /api/dm -- dm.ts:dmRoutes
Request: { userId: string }
Deduplication algorithm:
- Query all
dm_membersrows whereuserId = caller - For each membership, check if
targetUserIdis also a member of that channel - If found, verify exactly 2 members in that channel (skip group DMs that happen to include the target)
- If the channel exists and is not soft-deleted: reopen if caller had
closed=1, return existing channel - If no match: create new channel atomically in a transaction
Creation transaction:
- Insert
dm_channelswithownerId = NULL, nofederatedId(assigned lazily when federation relay first fires) - Insert two
dm_membersrows (caller + target)
Post-creation:
- Send
dm_channel_createdto the target user via WebSocket - Return 201 with the
DmChannelresponse to the caller
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. Post-creation, every DM operation (message send/edit/delete, close/leave, typing, reactions, read-state acks) routes through getChannelOrigin(channelId) → getApiForOrigin(origin). If the pinned origin drops mid-session, client-side failover re-keys the DM to a connected sibling that mirrors the same federatedId — see docs/systems/client-federation.md "DM Origin Failover".
Group DM Creation
Endpoint: POST /api/dm/group -- dm.ts:dmRoutes
Request: CreateGroupDmRequest
interface CreateGroupDmRequest {
users: GroupDmUserIdentity[]; // At least 2
fromDmChannelId?: string; // Source 1-on-1 DM for upgrade
}
interface GroupDmUserIdentity {
id: string;
homeUserId?: string | null;
homeInstance?: string | null;
}
Validation:
usersarray must have at least 2 entries (minimum 3 total members including caller)- Total members (1 + users.length) capped at 10
- Each identity resolved to a local user row:
- If
homeUserId+homeInstanceprovided:resolveOrCreateReplicatedUser() - Else: direct ID lookup, falling back to
resolveLocalUser()for remote snowflake IDs
- If
- No duplicate resolved IDs
- Caller cannot include themselves
- All target users must be friends with the caller (exception: existing DM members when
fromDmChannelIdreferences a 1-on-1 DM the caller belongs to)
Creation transaction:
- Insert
dm_channelswithownerId = caller - Insert
dm_membersfor caller + all target users
Post-creation federation setup:
- If federation relay is enabled and any member has a remote
homeInstance:- Generate random UUID
federatedIdviacomputeFederatedId() - Update channel with
federatedId,ownerHomeUserId,ownerHomeInstance
- Generate random UUID
Broadcasting (local-only principle):
dm_channel_createdsent only to members whosehomeInstancematches this instance- Remote members receive the channel via federation relay bootstrap on their home instance
System messages:
- One
member_addedsystem message per target user, inserted intodm_messages - Broadcast only to local members (remote instances create their own system messages)
Federation relay (for remote members):
- For each target user with a remote
homeInstance:- Queue
member_addevent with fullgrouproster (all participants) targetOriginsincludes all participant home origins plus the new member's origin- Event
messageIdformat:member_add:{userId}:{timestamp}
- Queue
Soft-Close and Reopen
Close (Hide)
Endpoint: DELETE /api/dm/:id -- dm.ts:dmRoutes
- Verify caller is a member
- Set
dm_members.closed = 1for the caller (preserves membership) - Send
dm_channel_closedto the caller (multi-tab sync) - Channel disappears from the caller's sidebar but they remain a member
Automatic Reopen
Trigger: dm.ts:broadcastDmMessage()
When a new message arrives in a DM channel, for each member with closed = 1:
- Flip
closedback to0 - Send
dm_channel_createdwith full channel payload (including the new message aslastMessage) so their sidebar picks it up - Then send the
dm_message_createdevent
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 = 1locally,queueDmCloseRelay(channelId, userId, 'dm_close')queues adm_closeoutbox event. The receiving instance finds the channel byfederatedId, resolves the acting user viaresolveLocalUser, setsclosed = 1on the localdm_membersrow, and broadcastsdm_channel_closed. - Reopen relay: Explicit reopens (
POST /api/dmwhen reopening a closed 1-on-1 DM) queue adm_reopenevent. The receiving instance setsclosed = 0and broadcastsdm_channel_createdwith a full channel payload. - Relayed-message reopen:
processCreateEvent(inbound message relay) also checks each recipient'sclosedflag and performs the same resurface sequence (dm_channel_created→dm_message_created) — mirroringbroadcastDmMessage. 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 (nofederatedId) are unaffected.
Frontend
spaceStore.closeDm(id)callsapi.dm.close(id)then removes the channel fromdmChannelsstatedm_channel_closedWS event callsremoveDmChannel(id)which also cleans up unread/read state viachatStore.removeChannelStates()
Adding Members to an Existing Group DM
Endpoint: POST /api/dm/:id/members -- dm.ts:dmRoutes
Request: { userId: string }
Validation:
- Caller must be a member of the channel
- Channel must be a group DM (
ownerIdis not NULL) - Target user must exist
- Caller and target must be friends
- Target must not already be a member
- Current member count must be < 10
Lazy federation setup:
- If the channel lacks a
federatedIdand the new member (or any existing member) is remote:- Generate UUID
federatedId, setownerHomeUserIdandownerHomeInstance
- Generate UUID
Broadcast sequence:
dm_member_addedto all existing members (before the new one sees it)dm_channel_createdto the new member (full channel payload)- System message (
member_added) broadcast to all members viasendToDmMembers
Federation relay:
- Queue
member_addwith fullgrouproster - Target origins include the new member's home instance even if not previously in the group
Group Metadata Update
Endpoint: PATCH /api/dm/:id -- dm.ts:dmRoutes
Owner-only update of the group DM's name and/or icon. 1-on-1 DMs reject with 400; non-owners reject with 403.
Request: { name?: string | null, icon?: string | null }
Either field may be omitted (no-op for that field), null (clear), or a value. Empty/whitespace name collapses to null. icon accepts a bare attachment filename (must be owned by caller, image/*, ≤ GROUP_DM_ICON_MAX_BYTES) or an absolute http(s) URL (used by the federated rebroadcast path). When a value is provided that equals the stored value, no-op short-circuit returns 200 with no system message and no relay.
Validation (origin instance):
- 1-on-1 DM (
ownerIdis NULL) -> 400 - Caller must be a member; caller must equal
ownerId-> else 403 name(when changing): trimmed length must satisfy[GROUP_DM_NAME_MIN_LENGTH, GROUP_DM_NAME_MAX_LENGTH]icon(when changing to a local filename):attachmentsrow exists withuploaderId === request.userId,mimetypestarts withimage/,size <= GROUP_DM_ICON_MAX_BYTES. Absolute URL accepted as-is. Bare filename //api/uploads/<filename>is normalized to bare filename.
Transaction:
- Diff against current row. If neither field changed -> return 200 with current channel; emit nothing.
- Capture
metadataUpdatedAt = Date.now()inside the transaction. - Update
dm_channels.name,icon,metadataUpdatedAtin one statement. - Insert one or two
dm_messagessystem rows:name_changed({ event, oldName, newName }) and/oricon_changed({ event }). Both share a singleeventMessageIdcorrelation root with suffix scheme:${eventMessageId}:name,${eventMessageId}:icon.
Post-transaction:
- Broadcast
dm_channel_updated { dmChannelId, name, icon }viasendToDmMembers(members-only by construction;metadataUpdatedAtis intentionally omitted from the WS payload — purely a server-side version vector). - Broadcast each new system message via
dm_message_created. - Queue
group_metadata_updateoutbox event withtargetOrigins = getGroupDmTargetOrigins(channelId). - If old icon was a local file and changed/cleared:
deleteUploadFile(old) + deleteAttachmentByFilename(old)(matches avatar precedent atusers.ts:463-466).
Icon URL round-trip:
- Owner instance:
dm_channels.iconstores the bare filename. Outbound relay normalizes to${getOurOrigin()}/api/uploads/${icon}vianormalizeIconForWire(mirrors profile relay). - Receiver instance: stores either local filename (download success) or absolute URL (download failure fallback). Avatar/
<img>rendering already handles both transparently. - Receivers never re-relay
group_metadata_update. Authority invariant ensures only the owner instance emits these events. A receiver's locally-cached filename can never accidentally federate to a third peer. - Bootstrap to a new peer carries the absolute URL inside the extended
FederationGroupPayload(member_addevent); seedocs/systems/federation.mdfor the bootstrap payload shape.
Clock semantics:
metadataUpdatedAt = Date.now() is captured at the moment of the DB write inside the owner-instance transaction, not at request entry. This keeps the version vector monotonic across rapid edits on the same instance. Receivers compare strictly greater (>) — equal or stale timestamps are silently accepted.
Owner Kick
Endpoint: DELETE /api/dm/:id/members/:targetUserId -- dm.ts:dmRoutes
Owner-only removal of a single member from a group DM.
Target identification (local vs federated):
The :targetUserId path segment carries either a local user id on the
owner's instance OR a federated home user id. Optional query string
?homeInstance=<origin> signals federated resolution: when present, the
server treats the segment as a homeUserId and resolves it via
resolveOrCreateReplicatedUser(targetUserId, homeInstance). Without the
query parameter, the segment is treated as a local id (legacy form). This
mirrors the federated path on POST /api/dm/:id/transfer and is required
for any federated target, because:
- The channel-serving instance and the owner-serving instance can disagree on the local replicated user id for the same federated user.
- The client's
useCanonicalUserViewcache may surface the user's HOME view, whoseidis the home id (not this instance's local replicated id).
The client passes the federated query when the target has homeUserId +
homeInstance populated. See api.dm.kickMember's federated parameter.
Validation:
- 1-on-1 DM (
ownerIdis NULL) -> 400 - Caller must be the owner -> else 403
- Cannot kick self (use leave instead) -> 400
- Target must be a current member -> else 404
- Unresolvable target (federated id with no replicated row, or unknown local id) -> 404
Sequence: evict target from any active DM voice room (evictUserFromDmVoiceRoom), then reuse the leave path with reason: 'kick' -- emits member_removed system message (with reason: 'kick'), deletes dm_members row + read_states, broadcasts dm_member_removed, sends dm_channel_closed to the kicked user, queues member_remove outbox event with reason: 'kick'. Receiver authority for kicks is sourceInstance === ownerHomeInstance; non-owner kicks reject as unauthorized_source.
Manual Ownership Transfer
Endpoint: POST /api/dm/:id/transfer -- dm.ts:dmRoutes
Owner-only transfer of ownership without leaving the channel.
Request: TransferOwnershipRequest
interface TransferOwnershipRequest {
newOwnerId?: string; // local user id on the owner's instance
homeUserId?: string; // federated identifier (paired with homeInstance)
homeInstance?: string; // federated identifier (paired with homeUserId)
}
Target identification (local vs federated):
The endpoint accepts either a local id (newOwnerId) OR a federated
identity (homeUserId + homeInstance). Federated identification mirrors
AddDmMemberRequest and is required when the client only knows the
target's home identity — the common case for federated members surfaced
through useCanonicalUserView, whose id field is the home id, NOT this
instance's local replicated id. The server resolves via
resolveOrCreateReplicatedUser(homeUserId, homeInstance) before
validating membership.
When both forms are supplied, the federated args win — they're strictly more specific (homeUserId + homeInstance disambiguates across instances), and explicit federation arguments should override a stale local id that may have come from a cached user view.
Historical context: without the federated path, the membership check
isDmMember(id, newOwnerId) always failed for federated targets because
dm_members.userId on the owner instance is the LOCAL replicated id, not
the federated home id passed by the client. Symptom was a 400 toast on
the client: "Target user is not a member of this DM channel".
Validation:
- Body must include
newOwnerIdOR (homeUserId+homeInstance) -> else 400 - Unresolvable target (federated id with no replicated row, or unknown local id) -> 404
- 1-on-1 DM (
ownerIdis NULL) -> 400 - Caller must be the current owner -> else 403
- Resolved
newOwnerId !== ownerId(reject self-transfer) -> 400 - Target must be a current member -> else 400
Transaction (transferGroupDmOwnership): updates ownerId, ownerHomeUserId, ownerHomeInstance; inserts owner_changed system message; broadcasts dm_owner_updated; queues ownership_transfer outbox event. The receiver path is the existing processOwnershipTransferEvent -- this endpoint reuses it without modification. The outbox event's ownership.newOwner carries the resolved user's home identity, so peers see the correct homeUserId/homeInstance regardless of which form the client used.
Leaving a Group DM
Endpoint: DELETE /api/dm/:id/members -- dm.ts:dmRoutes
Preconditions:
- Caller must be a member
- Channel must be a group DM (
ownerIdis not NULL; 1-on-1 DMs return 400)
Sequence:
- If caller is in an active voice call in this DM, leave it first (auto-end call if room becomes empty)
- Capture federation target origins BEFORE member deletion (so the leaving user's peer is included)
- Insert
member_removedsystem message (while user is still a member, so broadcast includes them) - Delete
dm_membersrow - Delete
read_statesfor the departing user - Queue
member_removefederation event (reason:'leave')
Ownership transfer (if caller was owner and members remain):
- New owner = first remaining member (
remainingMembers[0]) - Update
dm_channels.ownerId - Broadcast
dm_owner_updatedto remaining members - Insert
owner_changedsystem message - Update
ownerHomeUserId/ownerHomeInstanceon the channel - Queue
ownership_transferfederation event
Last member leaves:
- Soft-delete: set
dm_channels.deletedAt = Date.now() - No ownership transfer (no remaining members)
- Storage janitor hard-deletes after 24-hour grace period
Broadcast to leaving user:
dm_channel_closedevent (removes from sidebar)
DM Deletion and Garbage Collection
Soft-Delete Trigger
A channel is soft-deleted (deletedAt set) when:
- The last member leaves a group DM (
dm.tsleave endpoint) - The last local member is removed via federation relay (
federation.ts:processMemberRemoveEvent)
Hard-Delete (GC)
Function: storageJanitor.ts:cleanupSoftDeletedDmChannels()
Grace period: 24 hours from deletedAt
Cascade (single transaction):
- Delete
dm_reactionsfor all message IDs - Delete
embedsfor all message IDs - Delete
attachments(DB rows) for all message IDs - Delete
federation_file_queueentries for all message IDs - Delete
dm_messages - Delete
dm_members(should be 0, defensive) - Delete
read_states - Delete
federation_outboxentries (bycontextId) - Delete
federation_mutation_logentries (bycontextId) - Delete the
dm_channelsrow
Post-transaction: Delete attachment files from disk (filesystem ops are idempotent)
Re-activation
If a member_add federation event arrives for a soft-deleted channel (non-null deletedAt), processMemberAddEvent cancels the soft-delete by setting deletedAt = NULL.
DM Tombstone Semantics
When a user is tombstoned (tombstoneUser() in userDeletion.ts — admin delete, self-delete, federation identity delete, or reset-heal), their DM channels are handled by channel type so that a 1-on-1 survives as a readable, read-only "Deleted User" thread while a group DM simply loses the member.
Membership partition (userDeletion.ts)
- 1-on-1 DMs (
dm_channels.ownerId IS NULL): the tombstoned user'sdm_membersrow is KEPT. The channel stores no denormalized partner identity, so the row is what keeps the thread reachable. The row is anonymized for free by the user-row tombstone (username = '!deleted:{uid}',isDeleted = 1, nulled profile fields);GET /api/dmruns every member throughsanitizeUser(noisDeletedfilter), which surfaces the partner asusername: 'Deleted User'with a nulled avatar/banner/bio. - Group DMs (
ownerId IS NOT NULL): the tombstoned user'sdm_membersrow is DELETED. If the deleted user owned the group, ownership transfers to the next remaining member (theownedGroupDmsloop). The channel survives with the remaining roster.
Implementation: the function resolves the user's DM channel ids (userDmChannelIds), partitions them by ownerId, and deletes membership rows only for the group subset. This partition runs in both purgeContent modes — full "Remove" purges the deleted user's own authored space content but never the DM message text in a surviving channel (that text is the survivor's content and stays readable).
Dead-DM purge — "zero live members (exclude-uid)"
After the partition, the orphan-purge scan deletes only channels among userDmChannelIds that now have zero live members — members whose users.isDeleted = 0, excluding the uid being tombstoned right now (its row is still isDeleted = 0 at scan time because the tombstone UPDATE runs afterward, so it is excluded explicitly via userId != uid). Effect:
- Deleted ↔ Survivor 1-on-1: the survivor is a live other → kept.
- Deleted ↔ Deleted 1-on-1: no live others → purged (messages, attachment DB rows + disk files, and
dm_reactionscleaned up, then thedm_channelsrow).
The scan is scoped to userDmChannelIds (not a global dm_channels sweep) — only this user's membership changed, so only these channels can newly become dead. Pre-existing zero-member orphans from other causes are handled by the leave-path soft-delete + storage janitor.
Read-only enforcement (isDeadOneOnOne)
A Deleted-User 1-on-1 is a read-only archive — you can never message a tombstoned (and possibly dead-incarnation) partner, and for a 1-on-1 an edit/delete relay would broadcast to all active peers (getGroupDmTargetOrigins returns undefined), risking mis-direction to a new incarnation. The server is the enforcement boundary:
- Helper:
permissions.ts:isDeadOneOnOne(dmChannelId, requesterId)→truewhen the channel is 1-on-1 (ownerId IS NULL) and every member other than the requester hasisDeleted = 1(returnsfalsefor groups and when there are no other members). - Applied to all three message-mutation endpoints in
dm.ts:POST /api/dm/:id/messages(after theisDmMembergate),PATCH /api/dm/messages/:id, andDELETE /api/dm/messages/:id. Each rejects with403 { error: "This user's account was deleted", code: 'recipient_deleted', statusCode: 403 }. - Applied to DM reactions on the WebSocket path in
ws/events.ts:handleReactionAddandhandleReactionRemovecallisDeadOneOnOne(dmMsg.dmChannelId, userId)after theisDmMembergate and silently drop the frame (WS has no response channel). Without this, a survivor could add/remove reactions on historical messages and the reaction would relay to all active peers (a 1-on-1 has no group target-origins, soqueueOutboxEvent(..., undefined)fans out) — the exact mis-directed relay the read-only invariant exists to prevent. - Client mirror (consistency, not the boundary):
components/chat/Message.tsxwithdraws the add-reaction affordances (hover button, emoji picker, context-menu "Add Reaction") and no-ops existing-pill toggles when the message's DM is a dead 1-on-1 (isDeletedPartnerDm(dm, currentUser)). Existing reactions still display read-only; only add/remove is disabled.
Live update on the heal path
healResetIncarnation (utils/federationReset.ts) — the reset-heal deletion caller — now aligns with the other three deletion callers: for each stub it collects co-member targets via collectDeletionBroadcastTargets(stub.id) before tombstoneUser (which deletes the rows that set is derived from), then re-reads the tombstoned row and broadcasts a sanitized user_updated ({ type: 'user_updated', user: sanitizeUser(deletedRow) }) to each target via connectionManager.sendToUser. Survivors' clients flip the partner to "Deleted User" live — no reload.
Client-side, no new store action was added: the user_updated handler already calls spaceStore.updateUserEverywhere (patches the matching dmChannels[].members entry) and upsertUserView, and the 1-on-1 header/composer resolve through the canonical user-view cache, so the partner flips to "Deleted User" across the sidebar, header, and open-thread messages. The composer is replaced by a read-only notice (components/chat/DmDeletedNotice.tsx), gated by dmFormatters.ts:isDeletedPartnerDm(dm, currentUser) (1-on-1 whose every other member isDeleted), in MainContent and MobileChatScreen.
One-time backfill for pre-fix threads
Users tombstoned before this fix already had their 1-on-1 dm_members row deleted, leaving those threads permanently unreachable. migrate.ts:backfillOneOnOneDmMembership(db) runs idempotently at every boot (called from db/index.ts): for each ownerId IS NULL channel with exactly one dm_members row, it re-inserts a membership row (closed = 0) for any distinct dm_messages.userId that is missing from dm_members and still exists in users (the JOIN users guard drops authors whose row is truly gone). Bounded, only inserts missing rows, safe on every re-run.
Message Operations
Send Message
Endpoint: POST /api/dm/:id/messages -- dm.ts:dmRoutes
Rate limit: 5 per 5 seconds per user
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 work across federated instances. The caller's instance hosts the LiveKit room; remote clients connect directly. Call signaling is relayed to all active federation peers via synchronous HTTP POST (not the outbox worker). Relay failures at any call state transition emit dm_call_undeliverable { phase, terminal, failures } to the originator — see docs/systems/voice.md for the full call state machine and failure surface. Federated call-start to a remote instance with no reachable recipient surfaces as dm_call_undeliverable with reason no_recipient — see voice.md for the full failure-surface table.
Validation:
- Caller must be a member (
isDmMember) - Rejected with
403 recipient_deletedifisDeadOneOnOne(id, caller)— read-only Deleted-User thread (see "DM Tombstone Semantics") - Must have content or attachments (not both empty)
- Content max length: 4000 chars (
MAX_MESSAGE_LENGTH) - Attachment ownership verified (must be unlinked and owned by caller)
Flow:
- Insert message + link attachments in a single transaction
- Hydrate full
DmMessageWithUserviagetDmMessageWithUser() - Broadcast via
broadcastDmMessage()(handles soft-close reopen) - Queue federation relay via
queueDmRelay(message, channelId, 'create') - Resolve embeds asynchronously via
setImmediate()
Edit Message
Endpoint: PATCH /api/dm/messages/:id -- dm.ts:dmRoutes
- Rejected with
403 recipient_deletedifisDeadOneOnOne(msg.dmChannelId, caller)(see "DM Tombstone Semantics") - Author-only (
msg.userId !== request.userIdreturns 403) - Update content and set
editedAt - Delete old embeds, re-resolve new embeds asynchronously
- Broadcast
dm_message_updatedto all members - Queue federation relay via
queueDmRelay(updated, channelId, 'update')
Delete Message
Endpoint: DELETE /api/dm/messages/:id -- dm.ts:dmRoutes
- Rejected with
403 recipient_deletedifisDeadOneOnOne(msg.dmChannelId, caller)(see "DM Tombstone Semantics") - Author-only
- Collect attachment filenames before deletion
- Delete attachments, reactions, and message atomically in a transaction
- Clean up files from disk
- Broadcast
dm_message_deletedto all members - Federation:
appendMutationLog()+queueOutboxEvent()witheventType='delete'
Note: Delete federation events are queued without targetOrigins -- they broadcast to ALL active peers regardless of group membership. This differs from create/update which use getGroupDmTargetOrigins() for group DMs.
Federation Relay Pipeline
This section covers the DM-specific application-level relay logic. For the wire protocol, outbox delivery, HMAC signing, and retry mechanics, see docs/systems/federation.md.
Outbound: Target Origin Resolution
Function: federationOutbox.ts:getGroupDmTargetOrigins()
Channel has ownerId?
├── No (1-on-1) → return undefined → broadcasts to ALL active peers
└── Yes (group) → query all members' homeInstances
→ normalize bare domains to full URLs
→ filter out our own origin
→ return unique peer origins
Function: federationOutbox.ts:queueDmRelay()
Single source of truth for message relay payload construction:
- Build attachment array with
sourceUrlpointing to local uploads - Fetch
getDmParticipants()for identity resolution on the receiving side - Fetch channel to check for
federatedId(included only for group DMs with an owner) - Call
appendMutationLog()+queueOutboxEvent()with the constructed payload
Outbound: Relay Payload Structure
// federationOutbox.ts:buildRelayPayload()
{
userId: localUser.id,
homeUserId: user.homeUserId || user.id,
homeInstance: user.homeInstance || getOurOrigin(),
content: message.content,
replyToId: message.replyToId ?? null,
editedAt: message.editedAt ?? null,
createdAt: message.createdAt,
}
The full event includes participants (all channel members with their federated identities and profile snapshots) and optionally federatedId (for group DMs).
Inbound: Message Create
Function: federation.ts:processCreateEvent()
Deduplication: Check sourceInstance + sourceMessageId -- reject if already exists.
Participant resolution:
- ALL participants resolved via
resolveOrCreateReplicatedUser()(auto-creates stubs for unknown remote users) - Profile data from relay event hydrated onto replicated user stubs via
hydrateReplicatedUserProfile()
Channel resolution (group vs 1-on-1):
Has federatedId? |
Path |
|---|---|
| Yes (group DM) | Lookup by federatedId. If not found, reject (channel_not_found) -- channel must exist from prior member_add bootstrap |
| No (1-on-1 DM) | Compute deterministic federatedId from the two participants' home user IDs, then findOrCreateDmChannel() |
findOrCreateDmChannel():
- Lookup by
federatedId: if found, ensure both users are members (re-add if removed) - If not found: create new channel with
ownerId = NULLand the computedfederatedId, add both users as members
Attachment handling:
- Attachment rows created immediately with
filename = sourceUrl(remote URL) - Frontend renders remote URLs directly when filename starts with
http - Background file worker downloads the file and updates the filename to the local path
- SSRF protection:
isUrlFromPeer()validates attachment URL hostname matches peer origin
Broadcast filtering:
- Skip members whose
homeInstance === sourceInstance(they already have the message from their home instance)
Inbound: Message Update
Function: federation.ts:processUpdateEvent()
- Find local message by
sourceInstance+sourceMessageId - Update content and
editedAt - Broadcast
dm_message_updatedto all local members
Inbound: Message Delete
Function: federation.ts:processDeleteEvent()
- Find local message by
sourceInstance+sourceMessageId - Delete attachments, reactions, and message atomically
- Clean up attachment files from disk
- Broadcast
dm_message_deletedto 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.
- Resolve channel by
federatedId— reject if not found - Resolve user via
resolveLocalUser— skip silently if not found - Resolve message by
messageRef(local ID orsource_instance + source_message_id) - Upsert
read_statesrow for the resolved user and message - Broadcast
channel_ackto 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
Functions: federation.ts:processReactionAddEvent(), processReactionRemoveEvent()
- Uses
resolveLocalDmMessage()for cross-instance message resolution (handles messages originating on this instance vs relayed messages) - Reaction add is idempotent (existing reaction accepted silently)
- Broadcasts
reaction_added/reaction_removedto local members
Group DM Federation Lifecycle
Bootstrap Path (Channel Does Not Exist Locally)
Trigger: processMemberAddEvent() receives a member_add event with event.group metadata for a federatedId not found locally.
Sequence:
- Resolve owner via
resolveOrCreateReplicatedUser()-- guaranteed non-null - Create
dm_channelsrow withownerId,federatedId,ownerHomeUserId,ownerHomeInstance - For each member in
event.group.members: resolve viaresolveOrCreateReplicatedUser(), insertdm_members(idempotent skip if already exists) - Set local
bootstrapped = trueflag - Build full
DmChannelpayload - Send
dm_channel_createdonly to members whose home instance is THIS instance (local-only broadcast)
Incremental Path (Channel Already Exists)
Trigger: processMemberAddEvent() finds the channel by federatedId.
Sequence:
- Validate authority:
sourceInstancemust matchchannel.ownerHomeInstance - Cancel soft-delete if channel was pending GC (
deletedAtset) - Resolve added user via
resolveOrCreateReplicatedUser() - Enforce 10-member cap
- Insert
dm_membersrow (idempotent) - Insert system message for member addition
- Broadcast
dm_message_created(system) anddm_member_addedto local members
Bootstrap vs Incremental Batching
When a group DM is created with multiple remote members, the origin instance queues one member_add event per remote member. These events arrive in a batch on the receiving instance. Only the FIRST event triggers bootstrap (channel not found). Subsequent events find the channel and take the incremental path. This is correct because the bootstrap adds ALL roster members from event.group.members, making the incremental events idempotent.
Member Remove (Inbound)
Function: federation.ts:processMemberRemoveEvent()
- Find channel by
federatedId. If not found, accept silently (idempotent). - Authority check: for kicks,
sourceInstancemust matchownerHomeInstance. For self-leave (reason === 'leave'), any instance is accepted. - Resolve user via
resolveLocalUser()(they should already exist). If not found, accept silently. - Insert
member_removedsystem message (before deletion so broadcast includes leaving user) - Delete
dm_membersrow - Delete
read_states - Broadcast
dm_member_removedto remaining local members - If zero members remain: soft-delete channel
Ownership Transfer (Inbound)
Function: federation.ts:processOwnershipTransferEvent()
- Find channel by
federatedId. If not found, accept silently. - Authority check:
sourceInstancemust matchchannel.ownerHomeInstance - Resolve new owner via
resolveOrCreateReplicatedUser()-- MUST guarantee non-null (see invariant above) - Update
dm_channels:ownerId,ownerHomeUserId,ownerHomeInstance - Broadcast
dm_owner_updatedto local members - Insert
owner_changedsystem message
System Messages
System messages (type = 'system' in dm_messages) record group lifecycle events in the chat timeline.
Event Types
| 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 |
name_changed |
{ event, oldName, newName } |
Owner who renamed |
icon_changed |
{ event } |
Owner who set/cleared the icon |
space_invite (user-initiated, federated via processCreateEvent)
Sent by POST /api/dm/space-invite (see docs/systems/spaces.md). Unlike membership-event system messages, this one is user-initiated content — the inviter authored it deliberately. It travels through the standard DM message create relay (processCreateEvent), not a dedicated event kind.
JSON content shape:
{
"event": "space_invite",
"spaceId": "<snowflake>",
"spaceInstanceOrigin": "https://z.example",
"inviteCode": "<8-hex>",
"snapshot": {
"spaceName": "...",
"icon": null,
"avatarColor": null,
"memberCount": 12,
"description": "...",
"instanceName": "..."
}
}
The spaceInstanceOrigin is the space's home instance, not the sender's. The recipient's client uses it to fetch the live preview (getApiForOrigin(spaceInstanceOrigin).spaces.invitePreview) and to call joinByCode(code, spaceInstanceOrigin) on click.
Sidebar Preview Rendering
System messages MUST NOT surface their raw JSON content in the DM sidebar preview. The sidebar uses the type field on the lastMessage payload ('user' | 'system') to dispatch:
| Event | Sidebar preview |
|---|---|
space_invite |
📨 Sent invite to {snapshot.spaceName} (or 📨 Sent a space invite if name missing) |
member_added |
{actorName} added {targetDisplayName} |
member_removed (reason='leave') |
{targetDisplayName} left the group |
member_removed (kick) |
{actorName} removed {targetDisplayName} |
owner_changed |
{newOwnerDisplayName} is now the group owner |
name_changed (newName non-null) |
{actorName} renamed the group |
name_changed (newName null) |
{actorName} cleared the group name |
icon_changed |
{actorName} updated the group icon |
| Unknown / malformed JSON | System message |
actorName is resolved from the channel members roster by lastMessage.userId, falling back to the embedded user object on DmMessageWithUser payloads (used for federation bootstrap). System messages are NEVER prefixed with ${sender}: in group DMs — the rendered text already incorporates the actor.
User messages keep the existing behavior: text/attachment formatting via formatDmPreview, with a ${senderDisplayName}: prefix in group DMs when the author is not the current user.
The single source of truth on the client is packages/web/src/utils/dmFormatters.ts:formatDmSidebarPreview(dm, currentUser). All call sites (DmListItem, MobileDmsScreen) MUST use it — never read lastMessage.content directly.
Server contract: Every code path that emits a DmLastMessagePreview (REST GET/POST /api/dm, POST /api/dm/:id/members, WS ready payload, dm_channel_created reopen) MUST include the type field copied from the dm_messages.type column. Without this, the client cannot distinguish system from user messages and falls back to rendering raw JSON.
Instance-Local Creation
System messages are NOT relayed via federation. Each instance creates its own independently:
- Origin instance: Creates in the REST endpoint, broadcasts to local members only (group DM creation) or all local members (incremental add/leave)
- Receiving instance: Creates in the federation event processor, broadcasts to local members
This avoids duplicate system messages for users connected to multiple instances.
Receiving-Side Idempotency
Membership event processors (processMemberAddEvent, processMemberRemoveEvent, processOwnershipTransferEvent) persist the federation event's (sourceInstance, event.messageId) on the system-message row they insert (dm_messages.source_instance, dm_messages.source_message_id). The unique index idx_dm_messages_source_unique enforces that this pair occurs at most once per receiving instance.
Each processor's first step is to SELECT for a matching row and accepted.push(event.messageId); return if found. This makes the entire event handler a no-op on repeat delivery. The guarantee covers:
- Outbox retries after transient network failures.
- Initial sync replays when a peer's
lastSyncedAtresets (e.g. after an admin re-approves a peering request, which recreates the peer row with the defaultlastSyncedAt = 0). - Bootstrap vs incremental races —
processMemberAddEventemits the system message in both paths so bootstrap deliveries that later re-arrive as incremental events short-circuit at the dedup check instead of inserting a second message.
The bootstrap path includes the persisted system message as lastMessage in its dm_channel_created broadcast, so sidebar preview and unread anchors use the same message ID across all instances.
Local-Only Broadcast Principle
Users connected to multiple instances must see each DM channel exactly once (from their home instance). dm_channel_created and system message broadcasts during group DM creation filter to local members:
const isLocalMember = (u: { homeInstance?: string | null }) =>
!u.homeInstance || !domainOrigin ||
u.homeInstance === domainOrigin ||
`https://${u.homeInstance}` === domainOrigin;
Applies to:
dm_channel_createdbroadcasts (both origin and receiving instance bootstrap)- System message broadcasts during group DM creation (origin instance only)
Does NOT apply to:
- Regular DM messages (
dm_message_createdfor user messages) -- these broadcast to all localdm_members dm_member_added/dm_member_removed/dm_owner_updatedstructural events
Frontend State Management
Zustand Store (spaceStore.ts)
| Action | Behavior |
|---|---|
addDmChannel(channel, origin?) |
Prepends to dmChannels, deduplicates by ID, records origin in channelOriginMap |
removeDmChannel(id) |
Filters from dmChannels, cleans up unread/read state via chatStore.removeChannelStates() |
addDmMember(dmChannelId, user) |
Appends user to channel's members array (dedup by ID) |
removeDmMember(dmChannelId, userId) |
Filters user from channel's members array (reused for kick) |
updateDmOwner(dmChannelId, newOwnerId) |
Updates ownerId on the channel (reused for manual transfer) |
updateDmMetadata(dmChannelId, { name, icon }) |
Patches name/icon on the channel; called by the dm_channel_updated WS handler |
closeDm(id) |
Calls api.dm.close(id) via origin-aware API client, removes from state |
leaveDm(id) |
Calls api.dm.leave(id) via origin-aware API client, removes from state |
findExistingDmForUser(targetUser) |
Scans dmChannels for a 2-member DM where the other member's homeUserId matches the target's homeUserId |
upsertUserView(user, deliveringOrigin) |
Inserts/updates the user-view cache under the home-wins preference rule. Called for every DM member surface (kept AND skipped channels) so render sites surface the home view even when first-wins channel dedup discarded the home payload. See client-federation.md §3 "User View Cache" |
Owner-Only Routing Helper
// spaceStore.ts (exported, paired with getChannelOrigin)
export function getOwnerInstanceForDm(channelId: string): string;
Returns the channel's ownerHomeInstance. Used by all owner-only DM operations (updateMetadata, kickMember, transferOwnership) to route requests via getApiForOrigin(getOwnerInstanceForDm(channelId)). Distinct from getChannelOrigin, which returns the channel's pinned serving origin (where the WS connection mirrors the channel) — these can diverge after a manual ownership transfer. Non-owner operations (message send, leave, close, typing, reactions, read-state acks) keep routing via getChannelOrigin. See "Historical Bugs" for the latent post-transfer routing concern this helper closes.
WebSocket Event Handlers (useWebSocket.ts)
| WS Event | Handler |
|---|---|
dm_channel_created |
Normalize remote user assets, upsert each member into userViews, call addDmChannel(channel, origin) |
dm_channel_closed |
Call removeDmChannel(dmChannelId) |
dm_channel_updated |
Call updateDmMetadata(dmChannelId, { name, icon }) |
dm_member_added |
Normalize remote user assets, upsert into userViews, call addDmMember(dmChannelId, user) |
dm_member_removed |
Call removeDmMember(dmChannelId, userId) |
dm_owner_updated |
Call updateDmOwner(dmChannelId, newOwnerId, newOwnerHomeUserId?, newOwnerHomeInstance?) — the optional home-identity fields keep the channel's federation routing cache fresh after a manual transfer |
dm_message_created / dm_message_updated |
Normalize message assets, upsert message.user and message.replyTo?.user into userViews |
New DM Modal (NewDmModal.tsx)
- User types a search query (min 2 chars, 300ms debounce)
- Calls
api.social.search()for user results - On user selection:
- Check
findExistingDmForUser()for deduplication -- navigate to existing DM if found - Otherwise call
api.dm.create({ userId })via the origin-aware API client - Add channel to state and navigate
- Check
Add DM Member Modal (AddDmMemberModal.tsx)
- Shows the caller's friends list, filtered by search query
- Excludes current DM members (shown as "Already in this DM")
- Enforces 10-member cap in the UI (
remainingSlotscalculation) - Two creation paths:
- 1-on-1 DM upgrade: If
dmChannel.ownerIdis null, callsapi.dm.createGroup()with the existing other member + selected friends +fromDmChannelId - Existing group DM: Calls
api.dm.addMember()sequentially for each selected friend
- 1-on-1 DM upgrade: If
Self-Healing Migration
Location: migrate.ts:runMigrations()
Detection: Find dm_channels where:
owner_id IS NULLfederated_id IS NOT NULLdeleted_at IS NULLlength(federated_id) = 36 AND federated_id LIKE '________-____-____-____-____________'(UUID format = group DM)
Repair: Set owner_id to the first remaining dm_members.user_id.
Root cause: A bug in processOwnershipTransferEvent (fixed in commit cd7aff0) used resolveLocalUser with a ?? null fallback. When resolution failed (even transiently), it set ownerId = NULL, converting the group DM into a 1-on-1-looking channel.
Fix: processOwnershipTransferEvent now uses resolveOrCreateReplicatedUser() which always returns a valid user, making null impossible.
Origin Normalization
Critical pitfall (origin format mismatch):
| Location | Format | Example |
|---|---|---|
users.home_instance |
Bare domain | nova.ddns.net |
federation_peers.origin |
Full URL | https://nova.ddns.net |
getOurOrigin() |
Full URL | https://orbit.ddns.net |
When comparing home instances against peer origins, always normalize:
const normalized = homeInstance.startsWith('http')
? homeInstance
: `https://${homeInstance}`;
getGroupDmTargetOrigins() performs this normalization. Failure to normalize causes queueOutboxEvent to find zero matching peers and silently drop events.
API Reference
REST Endpoints
| 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. Accepts { userId } (local) or { homeUserId, homeInstance } (federated) |
POST |
/api/dm/group |
JWT | Create group DM with multiple members |
POST |
/api/dm/space-invite |
JWT | Send a space invite card to a friend via DM (see docs/systems/spaces.md) |
PATCH |
/api/dm/:id |
JWT | Update group DM name and/or icon (owner-only). 1-on-1 DMs reject. See "Group Metadata Update" |
DELETE |
/api/dm/:id |
JWT | Soft-close DM for caller |
DELETE |
/api/dm/:id/members/:targetUserId |
JWT | Owner kicks a member from a group DM. Cannot kick self. 1-on-1 DMs reject |
POST |
/api/dm/:id/transfer |
JWT | Owner transfers ownership to another current member without leaving. Body: { newOwnerId } |
POST |
/api/dm/:id/members |
JWT | Add member to group DM (any member). 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) |
PATCH |
/api/dm/messages/:id |
JWT | Edit message (author only) |
DELETE |
/api/dm/messages/:id |
JWT | Delete message (author only) |
Pagination
GET /api/dm/:id/messages supports cursor-based pagination:
before: Message ID cursor (fetch messages before this ID)limit: 1-100, default 50- Results returned in chronological order (oldest first)
DM Channel List Sorting
GET /api/dm returns channels sorted by lastMessage.createdAt descending (newest activity first), falling back to channel.createdAt for channels with no messages.
WebSocket Events
For full wire formats, see docs/systems/websocket.md.
State-Change Events
| Event | Direction | Triggered By |
|---|---|---|
dm_channel_created |
S->C | Group DM bootstrap, new 1-on-1, soft-close reopen |
dm_channel_closed |
S->C | User closes DM, user leaves group |
dm_channel_updated |
S->C | Group metadata (name/icon) updated; payload { dmChannelId, name, icon } (no metadataUpdatedAt — server-side version vector only) |
dm_member_added |
S->C | Incremental member add (not bootstrap) |
dm_member_removed |
S->C | Member leave/kick |
dm_owner_updated |
S->C | Ownership transfer (auto on owner-leave OR manual via POST /api/dm/:id/transfer). Payload: { dmChannelId, newOwnerId, newOwnerHomeUserId?, newOwnerHomeInstance? } — the home-identity fields are populated on every new emission so the client can keep dmChannel.ownerHomeInstance (and thus getOwnerInstanceForDm routing) in sync without waiting for a ready refresh. Receivers tolerate omission for legacy senders. |
Content Events
| Event | Direction | Triggered By |
|---|---|---|
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) |
Historical Bugs
| Bug | Symptom | Root Cause | Fix |
|---|---|---|---|
| ownerId nulling | Group DM becomes 1-on-1 | processOwnershipTransferEvent used resolveLocalUser ?? null |
Use resolveOrCreateReplicatedUser (always non-null) + self-healing migration |
| Origin normalization | Federation events silently dropped | getGroupDmTargetOrigins returned bare domains vs full URL peer origins |
Normalize to full URL before comparison |
| Missing federatedId in outbox | All membership events rejected by peer | Outbox worker reconstruction omitted federatedId |
Copy parsed.federatedId during reconstruction |
| Cross-instance duplicate channels | Duplicate sidebar entries | dm_channel_created broadcast to ALL members including remote |
Local-only broadcast principle |
| Bootstrap vs incremental confusion | N/A (design note) | bootstrapped flag is function-local; batch events work correctly because bootstrap adds ALL roster members |
No fix needed -- documented as correct behavior |
| Duplicated membership system messages across restarts | 4× "Heidi added erin" in group DM, channel keeps flipping to unread after each deploy | Membership event processors inserted system messages unconditionally. Each approval-flow re-peering reset peer last_synced_at = 0, so initial sync replayed every historical member_add / member_remove / ownership_transfer on next boot. Each replay's new snowflake ID exceeded the user's read_states.last_read_message_id, flipping unread. |
Dedup by (sourceInstance, event.messageId) on the inserted system message. Both bootstrap and incremental paths in processMemberAddEvent now persist these fields so replay is a no-op. |
| Raw JSON in DM sidebar previews | DM sidebar showed {"event":"space_invite",...} / {"event":"member_added",...} as the last-message preview |
DmLastMessagePreview shape omitted type, so the client could not distinguish system from user messages and rendered lastMessage.content verbatim. |
Added type to DmLastMessagePreview, populated it from dm_messages.type in every server emission site, and routed the sidebar through a single formatDmSidebarPreview helper that renders human-readable text for each system event. |
| Owner-only requests routed to wrong instance after manual transfer (latent) | After POST /api/dm/:id/transfer moved ownership to a member whose homeInstance differed from the channel's pinned serving origin, owner-only client calls (updateMetadata, kickMember, transferOwnership) routed via getChannelOrigin would emit outbox events with sourceInstance !== ownerHomeInstance, and all peers would reject them as attribution_mismatch. Latent only because pre-polish there was no kick endpoint and no metadata edit; auto-transfer-on-leave masked the issue (the leaver IS the actor, and member_remove reason='leave' accepts any source). |
Added getOwnerInstanceForDm(channelId) exported next to getChannelOrigin. All four owner-only API client methods (updateMetadata, kickMember, transferOwnership — and any future owner-only routes) call getApiForOrigin(getOwnerInstanceForDm(channelId)) instead of channel origin. Non-owner operations are unchanged. |
|
| Kick / transfer to federated member always failed with "user not a member" | DELETE /api/dm/:id/members/:targetUserId and POST /api/dm/:id/transfer accepted only a local user id. The client passed canonical.id from useCanonicalUserView, which returns the user's HOME id when the home view is cached. After owner-routing the request to the owner instance, the owner instance's dm_members.userId (its own local replicated id) never matched the home id, so isDmMember returned false. |
Both endpoints now accept federated identification (homeUserId + homeInstance) — the transfer endpoint takes them in the body, the kick endpoint reads homeInstance from a query string and treats the URL segment as a homeUserId. Server resolves via resolveOrCreateReplicatedUser before membership check. Mirrors the addDmMember pattern. Client kickMember / transferOwnership accept an optional federated arg and pass it when the target has homeUserId + homeInstance populated. |
|
| Ownership transfer back-and-forth diverged between instances | After A→B transfer succeeded, B→A was applied locally but rejected by A's peer with unauthorized_source; ownership permanently disagreed between instances. Compounded by the client never updating its in-memory dmChannel.ownerHomeInstance from the dm_owner_updated WS event, so getOwnerInstanceForDm returned the previous owner's origin after the WS broadcast (relevant only if the same session re-attempts an owner-only op). |
Two compounding bugs: (1) transferGroupDmOwnership wrote users.homeInstance verbatim into dm_channels.ownerHomeInstance — a BARE host (orbit.ddns.net) for federated owners. (2) processOwnershipTransferEvent (and processMemberRemoveEvent for kicks) compared sourceInstance (always full URL) to channel.ownerHomeInstance with strict string equality, mis-firing on the bare-vs-full mismatch. (3) dm_owner_updated WS event omitted newOwnerHomeUserId / newOwnerHomeInstance, so the client couldn't refresh its routing cache after a successful transfer. |
(a) Both authority checks now compare via normalizeOriginForCompare. (b) Every write site that persists dm_channels.ownerHomeInstance (transferGroupDmOwnership, POST /api/dm/group post-create federation, lazy federation in POST /api/dm/:id/members, processMemberAddEvent bootstrap, processOwnershipTransferEvent receiver storage) canonicalizes through a new canonicalizeHomeInstance helper in federationAuth.ts — full URL is the canonical storage form, matching how sourceInstance always arrives. (c) dm_owner_updated WS event was extended with optional newOwnerHomeUserId and newOwnerHomeInstance fields, and the client updateDmOwner action writes them when present (guarding against legacy senders by leaving the existing values untouched if omitted). |