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:
@@ -759,7 +759,7 @@ Returns the channel's `ownerHomeInstance`. Used by all owner-only DM operations
|
||||
| `dm_channel_updated` | Call `updateDmMetadata(dmChannelId, { name, icon })` |
|
||||
| `dm_member_added` | Normalize remote user assets, upsert into `userViews`, call `addDmMember(dmChannelId, user)` |
|
||||
| `dm_member_removed` | Call `removeDmMember(dmChannelId, userId)` |
|
||||
| `dm_owner_updated` | Call `updateDmOwner(dmChannelId, newOwnerId)` |
|
||||
| `dm_owner_updated` | Call `updateDmOwner(dmChannelId, newOwnerId, newOwnerHomeUserId?, newOwnerHomeInstance?)` — the optional home-identity fields keep the channel's federation routing cache fresh after a manual transfer |
|
||||
| `dm_message_created` / `dm_message_updated` | Normalize message assets, upsert `message.user` and `message.replyTo?.user` into `userViews` |
|
||||
|
||||
### New DM Modal (`NewDmModal.tsx`)
|
||||
@@ -869,7 +869,7 @@ For full wire formats, see `docs/systems/websocket.md`.
|
||||
| `dm_channel_updated` | S->C | Group metadata (`name`/`icon`) updated; payload `{ dmChannelId, name, icon }` (no `metadataUpdatedAt` — server-side version vector only) |
|
||||
| `dm_member_added` | S->C | Incremental member add (not bootstrap) |
|
||||
| `dm_member_removed` | S->C | Member leave/kick |
|
||||
| `dm_owner_updated` | S->C | Ownership transfer (auto on owner-leave OR manual via `POST /api/dm/:id/transfer`) |
|
||||
| `dm_owner_updated` | S->C | Ownership transfer (auto on owner-leave OR manual via `POST /api/dm/:id/transfer`). Payload: `{ dmChannelId, newOwnerId, newOwnerHomeUserId?, newOwnerHomeInstance? }` — the home-identity fields are populated on every new emission so the client can keep `dmChannel.ownerHomeInstance` (and thus `getOwnerInstanceForDm` routing) in sync without waiting for a `ready` refresh. Receivers tolerate omission for legacy senders. |
|
||||
|
||||
### Content Events
|
||||
|
||||
@@ -895,3 +895,4 @@ For full wire formats, see `docs/systems/websocket.md`.
|
||||
| Raw JSON in DM sidebar previews | DM sidebar showed `{"event":"space_invite",...}` / `{"event":"member_added",...}` as the last-message preview | `DmLastMessagePreview` shape omitted `type`, so the client could not distinguish system from user messages and rendered `lastMessage.content` verbatim. | Added `type` to `DmLastMessagePreview`, populated it from `dm_messages.type` in every server emission site, and routed the sidebar through a single `formatDmSidebarPreview` helper that renders human-readable text for each system event. |
|
||||
| Owner-only requests routed to wrong instance after manual transfer (latent) | After `POST /api/dm/:id/transfer` moved ownership to a member whose `homeInstance` differed from the channel's pinned serving origin, owner-only client calls (`updateMetadata`, `kickMember`, `transferOwnership`) routed via `getChannelOrigin` would emit outbox events with `sourceInstance !== ownerHomeInstance`, and all peers would reject them as `attribution_mismatch`. Latent only because pre-polish there was no kick endpoint and no metadata edit; auto-transfer-on-leave masked the issue (the leaver IS the actor, and `member_remove reason='leave'` accepts any source). | Added `getOwnerInstanceForDm(channelId)` exported next to `getChannelOrigin`. All four owner-only API client methods (`updateMetadata`, `kickMember`, `transferOwnership` — and any future owner-only routes) call `getApiForOrigin(getOwnerInstanceForDm(channelId))` instead of channel origin. Non-owner operations are unchanged. |
|
||||
| Kick / transfer to federated member always failed with "user not a member" | `DELETE /api/dm/:id/members/:targetUserId` and `POST /api/dm/:id/transfer` accepted only a local user id. The client passed `canonical.id` from `useCanonicalUserView`, which returns the user's HOME id when the home view is cached. After owner-routing the request to the owner instance, the owner instance's `dm_members.userId` (its own local replicated id) never matched the home id, so `isDmMember` returned false. | Both endpoints now accept federated identification (`homeUserId` + `homeInstance`) — the transfer endpoint takes them in the body, the kick endpoint reads `homeInstance` from a query string and treats the URL segment as a homeUserId. Server resolves via `resolveOrCreateReplicatedUser` before membership check. Mirrors the `addDmMember` pattern. Client `kickMember` / `transferOwnership` accept an optional `federated` arg and pass it when the target has `homeUserId` + `homeInstance` populated. |
|
||||
| Ownership transfer back-and-forth diverged between instances | After A→B transfer succeeded, B→A was applied locally but rejected by A's peer with `unauthorized_source`; ownership permanently disagreed between instances. Compounded by the client never updating its in-memory `dmChannel.ownerHomeInstance` from the `dm_owner_updated` WS event, so `getOwnerInstanceForDm` returned the previous owner's origin after the WS broadcast (relevant only if the same session re-attempts an owner-only op). | Two compounding bugs: (1) `transferGroupDmOwnership` wrote `users.homeInstance` verbatim into `dm_channels.ownerHomeInstance` — a BARE host (`orbit.ddns.net`) for federated owners. (2) `processOwnershipTransferEvent` (and `processMemberRemoveEvent` for kicks) compared `sourceInstance` (always full URL) to `channel.ownerHomeInstance` with strict string equality, mis-firing on the bare-vs-full mismatch. (3) `dm_owner_updated` WS event omitted `newOwnerHomeUserId` / `newOwnerHomeInstance`, so the client couldn't refresh its routing cache after a successful transfer. | (a) Both authority checks now compare via `normalizeOriginForCompare`. (b) Every write site that persists `dm_channels.ownerHomeInstance` (`transferGroupDmOwnership`, `POST /api/dm/group` post-create federation, lazy federation in `POST /api/dm/:id/members`, `processMemberAddEvent` bootstrap, `processOwnershipTransferEvent` receiver storage) canonicalizes through a new `canonicalizeHomeInstance` helper in `federationAuth.ts` — full URL is the canonical storage form, matching how `sourceInstance` always arrives. (c) `dm_owner_updated` WS event was extended with optional `newOwnerHomeUserId` and `newOwnerHomeInstance` fields, and the client `updateDmOwner` action writes them when present (guarding against legacy senders by leaving the existing values untouched if omitted). |
|
||||
|
||||
Reference in New Issue
Block a user