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