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
@@ -222,3 +222,28 @@ export function normalizeOriginForCompare(value: string | null | undefined): str
if (!s) return null;
return s.toLowerCase();
}
/**
* Canonicalize a homeInstance value to a full origin URL
* (e.g. `https://nova.ddns.net`) for storage on columns whose authority
* checks compare against `sourceInstance` (which is always a full URL).
*
* Accepts bare host (`nova.ddns.net`), scheme-prefixed (`https://...`), or
* `null` / empty / whitespace (returns `null`). `localhost`-style values keep
* `http://` if already present; otherwise the canonical default is `https://`.
*
* Use this at every write site that persists `dm_channels.ownerHomeInstance`
* so that S2S authority checks aren't broken by a bare-vs-full mismatch.
* Read paths should still normalize via `normalizeOriginForCompare` for
* defensive parity with legacy rows.
*/
export function canonicalizeHomeInstance(value: string | null | undefined): string | null {
if (!value) return null;
const s = value.trim();
if (!s) return null;
if (/^https?:\/\//i.test(s)) {
// Strip trailing slashes only — preserve the explicit scheme.
return s.replace(/\/+$/, '');
}
return `https://${s.replace(/\/+$/, '')}`;
}