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:
@@ -287,6 +287,40 @@ describe('processMemberRemoveEvent — kick authority', () => {
|
||||
expect(vi.mocked(connectionManager.sendToDmMembers)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts a kick when ownerHomeInstance is BARE and sourceInstance is FULL (bare-vs-full normalization)', async () => {
|
||||
// Regression: pre-fix, `processMemberRemoveEvent` compared
|
||||
// `sourceInstance` (full URL, from outbox worker) to `ownerHomeInstance`
|
||||
// verbatim. After an `ownership_transfer` to a federated user, the
|
||||
// column would be written as a bare host (`users.homeInstance`), causing
|
||||
// legitimate downstream kicks to be rejected with `unauthorized_source`.
|
||||
//
|
||||
// The fix normalizes both sides via `normalizeOriginForCompare`. This
|
||||
// test re-seeds the channel with a bare `ownerHomeInstance` to lock in
|
||||
// the new behavior. Mirrors the ownership-transfer authority test.
|
||||
seedChannelAndMembers();
|
||||
// Overwrite ownerHomeInstance to the legacy bare form.
|
||||
testDb.update(schema.dmChannels)
|
||||
.set({ ownerHomeInstance: 'owner.test' })
|
||||
.where(eq(schema.dmChannels.id, CHANNEL_ID))
|
||||
.run();
|
||||
|
||||
const fed = await import('./federation.js');
|
||||
const event = buildKickEvent('evt-kick-bare-owner');
|
||||
|
||||
const accepted: string[] = [];
|
||||
const rejected: Array<{ messageId: string; reason: string }> = [];
|
||||
fed.processMemberRemoveEvent(event, OWNER_ORIGIN, testDb, accepted, rejected);
|
||||
|
||||
expect(rejected).toEqual([]);
|
||||
expect(accepted).toEqual([event.messageId]);
|
||||
|
||||
// Victim's dm_members row was deleted (kick applied)
|
||||
const victimRow = testDb.select().from(schema.dmMembers)
|
||||
.where(and(eq(schema.dmMembers.dmChannelId, CHANNEL_ID), eq(schema.dmMembers.userId, VICTIM_USER_ID)))
|
||||
.get();
|
||||
expect(victimRow).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts a self-leave from any source instance (not the owner instance) and removes the leaver', async () => {
|
||||
seedChannelAndMembers();
|
||||
const fed = await import('./federation.js');
|
||||
|
||||
Reference in New Issue
Block a user