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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user