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
+12 -1
View File
@@ -1113,7 +1113,18 @@ function handleEvent(origin: string, event: ServerEvent): void {
case 'dm_owner_updated': {
if (!isHome && !activePeerOrigins.has(origin)) break;
const { updateDmOwner } = useSpaceStore.getState();
updateDmOwner(event.dmChannelId, event.newOwnerId);
// Pass the federation routing fields so the DM's `ownerHomeInstance`
// stays in sync with the server. Without this, `getOwnerInstanceForDm`
// routes the next owner-only API call (rename, icon, kick, transfer)
// through the PREVIOUS owner's home instance and the receiving peer
// rejects it as `unauthorized_source`. Older servers omit these fields
// — the store leaves the existing values untouched in that case.
updateDmOwner(
event.dmChannelId,
event.newOwnerId,
event.newOwnerHomeUserId ?? undefined,
event.newOwnerHomeInstance ?? undefined,
);
break;
}
+19 -5
View File
@@ -129,7 +129,12 @@ interface SpaceState {
removeDmChannel: (id: string) => void;
addDmMember: (dmChannelId: string, user: User) => void;
removeDmMember: (dmChannelId: string, userId: string) => void;
updateDmOwner: (dmChannelId: string, newOwnerId: string) => void;
updateDmOwner: (
dmChannelId: string,
newOwnerId: string,
newOwnerHomeUserId?: string,
newOwnerHomeInstance?: string,
) => void;
updateDmMetadata: (dmChannelId: string, patch: { name?: string | null; icon?: string | null }) => void;
closeDm: (id: string) => Promise<void>;
leaveDm: (id: string) => Promise<void>;
@@ -321,10 +326,19 @@ export const useSpaceStore = create<SpaceState>((set, get) => ({
),
})),
updateDmOwner: (dmChannelId, newOwnerId) => set((state) => ({
dmChannels: state.dmChannels.map(dm =>
dm.id === dmChannelId ? { ...dm, ownerId: newOwnerId } : dm
),
updateDmOwner: (dmChannelId, newOwnerId, newOwnerHomeUserId, newOwnerHomeInstance) => set((state) => ({
dmChannels: state.dmChannels.map(dm => {
if (dm.id !== dmChannelId) return dm;
const next = { ...dm, ownerId: newOwnerId };
// Only overwrite the federation routing fields when the caller supplies
// them. Older servers that omit these fields must not blank out the
// existing values — the DM would otherwise lose its owner-routing data
// and `getOwnerInstanceForDm` would silently fall back to '' (home),
// re-introducing the bug this WS extension fixes.
if (newOwnerHomeUserId !== undefined) next.ownerHomeUserId = newOwnerHomeUserId;
if (newOwnerHomeInstance !== undefined) next.ownerHomeInstance = newOwnerHomeInstance;
return next;
}),
})),
// Patches the group DM's display metadata (name + icon). Idempotent: a
@@ -209,6 +209,58 @@ describe('group DM owner routing — api.dm.* (Task 5.2)', () => {
});
});
it('updateDmOwner keeps ownerHomeInstance in sync so the next owner-only op routes correctly', () => {
// Regression: the `dm_owner_updated` WS handler used to call
// updateDmOwner(channelId, newOwnerId) without the home-identity fields.
// After a manual back-and-forth transfer, `getOwnerInstanceForDm` then
// returned the PREVIOUS owner's home origin — the next owner-only call
// routed to the wrong instance and the receiver rejected the resulting
// federation event with `unauthorized_source`.
//
// The fix: WS event carries `newOwnerHomeUserId` + `newOwnerHomeInstance`
// and the store writes them. This test pins that behavior down.
const { updateDmOwner } = useSpaceStore.getState();
useSpaceStore.setState({
dmChannels: [{
...baseDm,
ownerId: 'old-owner',
ownerHomeUserId: 'old-owner-home',
ownerHomeInstance: 'https://nova.test',
}],
});
updateDmOwner('dm-1', 'new-owner', 'new-owner-home', 'https://orbit.test');
const dm = useSpaceStore.getState().dmChannels.find(d => d.id === 'dm-1');
expect(dm?.ownerId).toBe('new-owner');
expect(dm?.ownerHomeUserId).toBe('new-owner-home');
expect(dm?.ownerHomeInstance).toBe('https://orbit.test');
expect(getOwnerInstanceForDm('dm-1')).toBe('https://orbit.test');
});
it('updateDmOwner does NOT clear existing federation routing fields when called without them (legacy server)', () => {
// An older server that hasn't shipped the WS payload extension yet would
// call updateDmOwner with only (channelId, newOwnerId). The store must
// not blank out the existing home fields, or `getOwnerInstanceForDm`
// would silently fall back to '' (home) — re-introducing the bug.
const { updateDmOwner } = useSpaceStore.getState();
useSpaceStore.setState({
dmChannels: [{
...baseDm,
ownerId: 'old-owner',
ownerHomeUserId: 'old-owner-home',
ownerHomeInstance: 'https://nova.test',
}],
});
updateDmOwner('dm-1', 'new-owner');
const dm = useSpaceStore.getState().dmChannels.find(d => d.id === 'dm-1');
expect(dm?.ownerId).toBe('new-owner');
expect(dm?.ownerHomeUserId).toBe('old-owner-home');
expect(dm?.ownerHomeInstance).toBe('https://nova.test');
});
it('non-owner-only op (sendMessage) is unaffected by ownerHomeInstance', async () => {
// Owner routing is opt-in per method — sendMessage on the singleton api
// must NOT consult ownerHomeInstance. It uses the channel's pinned origin