fix(dm): owner-only group DM ops accept federated target identification

Transferring ownership or kicking a member surfaced "Target user is not a
member of this DM channel" whenever the target was a federated user.

Root cause: the client passed `canonical.id` from `useCanonicalUserView`,
which returns the user's HOME id when the home view is in the userViews
cache. After owner-routing the request to the owner instance, that
instance's `dm_members.userId` (its own local replicated id) never
matched the home id, so `isDmMember` returned false. The same failure
mode applied across any cross-instance scenario where the
channel-serving instance and the owner-serving instance disagree on the
local replicated user id for the same federated user.

Fix: both endpoints now accept federated identification, mirroring the
existing pattern on `POST /api/dm/:id/members`:

- `POST /api/dm/:id/transfer` body: `{ newOwnerId? } | { homeUserId, homeInstance }`.
  Federated args win when both are supplied (strictly more specific).
- `DELETE /api/dm/:id/members/:targetUserId` reads optional
  `?homeInstance=<origin>` query; when present, the URL segment is
  treated as a homeUserId and resolved via `resolveOrCreateReplicatedUser`.

Client `api.dm.kickMember` and `api.dm.transferOwnership` gain an
optional `federated` argument; `DmRosterPanel` and `MobileGroupDmInfo`
pass it whenever the target has `homeUserId` + `homeInstance` populated.

Adds 5 server tests (3 transfer + 2 kick) covering federated targets,
the federated-wins-over-local precedence rule, and federated-non-member
rejection. Updates 2 client routing tests and 2 DmRosterPanel test
assertions for the new signature. Updates `docs/systems/dm-system.md`
and `docs/systems/api.md`.

Server: 965 tests pass (was 960). Web: 362 tests pass (was 360).
This commit is contained in:
Jannis Braun
2026-05-10 22:09:14 +02:00
parent 9279ac78e5
commit b6842c5590
11 changed files with 442 additions and 35 deletions
+4 -4
View File
@@ -126,8 +126,8 @@ PATCH /dm/:id { name?, icon? } → { id, na
DELETE /dm/:id → { success } (soft-close)
POST /dm/:id/members { userIds[] } → { dmChannel } [owner, max 10]
DELETE /dm/:id/members → { success } (leave)
DELETE /dm/:id/members/:targetUserId → { success } [owner kick; cannot self-kick; group only]
POST /dm/:id/transfer { newOwnerId } → { success } [owner; member must be in channel; not self]
DELETE /dm/:id/members/:targetUserId ?homeInstance= → { success } [owner kick; cannot self-kick; group only; segment is homeUserId when ?homeInstance is set]
POST /dm/:id/transfer { newOwnerId? | (homeUserId+homeInstance) } → { success } [owner; resolved member must be in channel; not self]
GET /dm/:id/messages ?before=&limit=50 → { messages[] }
POST /dm/:id/messages { content, attachments?, replyToId? } → { message }
PATCH /dm/messages/:id { content } → { message } [author]
@@ -136,9 +136,9 @@ DELETE /dm/messages/:id → { succes
**`PATCH /dm/:id`** — Owner-only update of a group DM's `name` and `icon`. Either field may be omitted (no-op), null (clear), or set. Empty/whitespace name collapses to null. `icon` accepts a bare attachment filename owned by the caller (image/*, ≤ `GROUP_DM_ICON_MAX_BYTES`) or an absolute http(s) URL. No-op short-circuit when nothing actually changes — emits no system message and no federation relay. See `docs/systems/dm-system.md` "Group Metadata Update" for the full transaction, federation relay, and icon URL round-trip rules.
**`DELETE /dm/:id/members/:targetUserId`** — Owner kicks a member from a group DM. Reuses the leave path with `reason: 'kick'`; evicts the target from the DM voice room first. Sends `dm_channel_closed` to the kicked user. Receivers enforce `sourceInstance === ownerHomeInstance`; non-owner kicks reject as `unauthorized_source`.
**`DELETE /dm/:id/members/:targetUserId`** — Owner kicks a member from a group DM. The `:targetUserId` segment carries either a local user id on the owner's instance OR a federated home user id when the `?homeInstance=<origin>` query string is present (server resolves via `resolveOrCreateReplicatedUser` — same pattern as `POST /dm/:id/members`). Federated form is required for federated targets, because the client's cached user view returns the user's home id, not the owner instance's local replicated id. Reuses the leave path with `reason: 'kick'`; evicts the target from the DM voice room first. Sends `dm_channel_closed` to the kicked user. Receivers enforce `sourceInstance === ownerHomeInstance`; non-owner kicks reject as `unauthorized_source`.
**`POST /dm/:id/transfer`** — Owner transfers ownership to another current member without leaving. Updates `ownerId`, `ownerHomeUserId`, `ownerHomeInstance`; inserts an `owner_changed` system message; broadcasts `dm_owner_updated`; queues an `ownership_transfer` outbox event. Reuses the existing receiver path (`processOwnershipTransferEvent`) with no protocol changes.
**`POST /dm/:id/transfer`** — Owner transfers ownership to another current member without leaving. Body accepts either a local id (`newOwnerId`) or a federated identity (`homeUserId` + `homeInstance`). When both forms are supplied, federated args take precedence. Server resolves via `resolveOrCreateReplicatedUser` before checking membership — mirrors `POST /dm/:id/members`. Updates `ownerId`, `ownerHomeUserId`, `ownerHomeInstance`; inserts an `owner_changed` system message; broadcasts `dm_owner_updated`; queues an `ownership_transfer` outbox event. Reuses the existing receiver path (`processOwnershipTransferEvent`) with no protocol changes.
## Social (`routes/social.ts`) — auth required
```