fix(dm): ownership transfer divergence after back-and-forth — canonicalize ownerHomeInstance + normalize authority checks

Manual ownership transfers between two federated instances diverged because
`dm_channels.ownerHomeInstance` was stored as a BARE host (`orbit.ddns.net`)
for federated owners — via `transferGroupDmOwnership` copying `users.homeInstance`
verbatim — while `sourceInstance` always arrives as a full URL on the wire.
`processOwnershipTransferEvent` and `processMemberRemoveEvent` then compared the
two with strict equality and rejected legitimate inbound events as
`unauthorized_source`, keeping ownership permanently divergent across peers.
Live DB inspection on the two test instances confirmed both rows (nova + orbit)
had a BARE `owner_home_instance`, matching the bug report exactly.

Three compounding fixes:

1. Receiver authority checks now compare via `normalizeOriginForCompare` so
   legacy bare-vs-full rows accept legitimate transfers (and kicks).
2. New `canonicalizeHomeInstance` helper in `federationAuth.ts`; every write
   site that persists `ownerHomeInstance` (`transferGroupDmOwnership`, group DM
   creation, lazy federation in member-add, `processMemberAddEvent` bootstrap,
   `processOwnershipTransferEvent` receiver storage) routes through it. Full URL
   is the canonical storage form, matching how `sourceInstance` arrives.
3. `dm_owner_updated` WS event extended with optional `newOwnerHomeUserId` and
   `newOwnerHomeInstance` fields. Client `updateDmOwner` writes them when
   present and leaves existing values untouched otherwise (legacy-server safe).
   Without this, `getOwnerInstanceForDm` returned the previous owner's home
   after a successful WS broadcast, routing the next owner-only op to the wrong
   instance.

Coverage: new `federation.ownershipTransfer.test.ts` (7 receiver tests including
the headline bare-vs-full regression and the dedup replay guard); new bare-vs-full
case in `federation.kick.test.ts`; two new client-side cases in
`groupDm.ownerRouting.test.ts` covering both the extended-payload write path and
the legacy-server passthrough. Tests: 1053 server + 364 web, all green.

Specs updated: `dm-system.md` historical bugs + frontend handler table + WS
state-change events table; `federation.md` `ownership_transfer` receiver flow;
`websocket.md` event-fields table.
This commit is contained in:
Jannis Braun
2026-05-10 22:38:03 +02:00
parent b6842c5590
commit 3c7bb02901
12 changed files with 585 additions and 29 deletions
+31 -8
View File
@@ -46,7 +46,7 @@ import {
sendTypingRelay,
normalizeIconForWire,
} from '../utils/federationOutbox.js';
import { getOurOrigin } from '../utils/federationAuth.js';
import { getOurOrigin, canonicalizeHomeInstance } from '../utils/federationAuth.js';
import type { FederationRelayEvent } from '@backspace/shared';
import { resolveLocalUser, resolveOrCreateReplicatedUser } from './federation.js';
@@ -510,7 +510,16 @@ function transferGroupDmOwnership(
const domainOrigin = isFederationRelayEnabled() ? getOurOrigin() : null;
const newOwnerHomeUserId = newOwnerRow?.homeUserId || newOwnerId;
const newOwnerHomeInstance = newOwnerRow?.homeInstance || domainOrigin || '';
// Canonicalize to a full origin URL. `users.homeInstance` is stored as a
// bare host (e.g. `orbit.ddns.net`) for federated users, but
// `dm_channels.ownerHomeInstance` is compared against `sourceInstance`
// (always a full URL) in S2S authority checks. Storing the bare form here
// caused legitimate `ownership_transfer` events to be rejected with
// `unauthorized_source` after back-and-forth transfers — see the historical
// bug entry in `docs/systems/dm-system.md`.
const newOwnerHomeInstance = canonicalizeHomeInstance(
newOwnerRow?.homeInstance || domainOrigin || '',
);
const ownerSysMsgId = generateSnowflake();
const ownerNow = Date.now();
@@ -520,6 +529,11 @@ function transferGroupDmOwnership(
newOwnerDisplayName,
});
// Wire homeInstance values are canonicalized to full URLs so receivers store
// the canonical form too. Future authority checks then compare full-URL to
// full-URL without needing defensive normalization at every site.
const previousOwnerHomeInstanceWire =
canonicalizeHomeInstance(previousOwnerRow?.homeInstance || domainOrigin || '') ?? '';
const transferPayload: FederationRelayEvent | null = federationActive
? {
eventType: 'ownership_transfer',
@@ -531,11 +545,11 @@ function transferGroupDmOwnership(
ownership: {
newOwner: {
homeUserId: newOwnerHomeUserId,
homeInstance: newOwnerHomeInstance || (domainOrigin ?? ''),
homeInstance: newOwnerHomeInstance ?? (domainOrigin ?? ''),
},
previousOwner: {
homeUserId: previousOwnerRow?.homeUserId || previousOwnerId,
homeInstance: previousOwnerRow?.homeInstance || (domainOrigin ?? ''),
homeInstance: previousOwnerHomeInstanceWire,
},
},
}
@@ -584,12 +598,17 @@ function transferGroupDmOwnership(
.where(eq(schema.dmMembers.dmChannelId, channelId))
.all();
// Broadcast dm_owner_updated to local members.
// Broadcast dm_owner_updated to local members. Include the new owner's
// home identity so receiving clients can update `dm.ownerHomeInstance`
// (and thus keep `getOwnerInstanceForDm` correct for the next owner-only
// request) without waiting for a fresh `ready` payload on reconnect.
for (const member of members) {
connectionManager.sendToUser(member.userId, {
type: 'dm_owner_updated',
dmChannelId: channelId,
newOwnerId,
newOwnerHomeUserId,
newOwnerHomeInstance: newOwnerHomeInstance ?? null,
});
}
@@ -1211,7 +1230,9 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
.set({
federatedId,
ownerHomeUserId: callerUser?.homeUserId || request.userId,
ownerHomeInstance: callerUser?.homeInstance || domainOrigin,
// Canonicalize for federation-authority parity (see
// `transferGroupDmOwnership` for the full rationale).
ownerHomeInstance: canonicalizeHomeInstance(callerUser?.homeInstance || domainOrigin),
})
.where(eq(schema.dmChannels.id, dmChannelId))
.run();
@@ -1754,7 +1775,9 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
.set({
federatedId: newFederatedId,
ownerHomeUserId: ownerUser?.homeUserId || dmChannel.ownerId!,
ownerHomeInstance: ownerUser?.homeInstance || domainOrigin,
// Canonicalize for federation-authority parity (see
// `transferGroupDmOwnership` for the full rationale).
ownerHomeInstance: canonicalizeHomeInstance(ownerUser?.homeInstance || domainOrigin),
})
.where(eq(schema.dmChannels.id, id))
.run();
@@ -1763,7 +1786,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
...dmChannel,
federatedId: newFederatedId,
ownerHomeUserId: ownerUser?.homeUserId || dmChannel.ownerId!,
ownerHomeInstance: ownerUser?.homeInstance || domainOrigin,
ownerHomeInstance: canonicalizeHomeInstance(ownerUser?.homeInstance || domainOrigin),
};
}
}