From 22c3fd6d5061bb6a3120d96b1d87329c74038c4d Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 10 May 2026 19:39:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(client):=20getOwnerInstanceForDm=20?= =?UTF-8?q?=E2=80=94=20route=20owner-only=20DM=20ops=20to=20current=20owne?= =?UTF-8?q?r=20instance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/web/src/stores/spaceStore.ts | 23 +++++ packages/web/src/utils/crossStoreResolvers.ts | 21 +++++ .../src/utils/groupDm.ownerRouting.test.ts | 87 +++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 packages/web/src/utils/groupDm.ownerRouting.test.ts diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index 55150c79..4af65f05 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -10,6 +10,7 @@ import { resolveUserIdFromInstances, getCachedUserIdForOrigin, clearMyUserIdCache, + setOwnerInstanceForDmResolver, } from '../utils/crossStoreResolvers'; import { useAuthStore } from './authStore'; import { useChatStore } from './chatStore'; @@ -1137,6 +1138,28 @@ export function getChannelOrigin(channelId: string): string { return useSpaceStore.getState().channelOriginMap.get(channelId) ?? ''; } +/** + * Returns the owner's home-instance origin for a group DM, or '' for the + * local home instance. Used to route owner-only API calls (rename, icon, + * kick, transfer) so the federation event's sourceInstance equals the + * channel's ownerHomeInstance — required by receiver authority checks. + * + * Distinct from getChannelOrigin: that function returns the channel's + * pinned serving origin (where the client's WS connection mirrors the + * channel), which can differ from the owner's home instance after a + * manual transfer. + * + * The resolver itself lives in `utils/crossStoreResolvers.ts` so that + * `api/client.ts` can call it without creating a value-cycle on spaceStore; + * spaceStore registers the resolver below at module load. + */ +export function getOwnerInstanceForDm(channelId: string): string { + const dm = useSpaceStore.getState().dmChannels.find(d => d.id === channelId); + return dm?.ownerHomeInstance ?? ''; +} + +setOwnerInstanceForDmResolver(getOwnerInstanceForDm); + /** * Resolves a raw DM channel ID to its primary `dmChannels` entry ID. * diff --git a/packages/web/src/utils/crossStoreResolvers.ts b/packages/web/src/utils/crossStoreResolvers.ts index 3cdbb3ab..ed37a0d9 100644 --- a/packages/web/src/utils/crossStoreResolvers.ts +++ b/packages/web/src/utils/crossStoreResolvers.ts @@ -39,6 +39,27 @@ export function getApiForOrigin(origin: string): BackspaceApiClient { return _getApiForOrigin(origin); } +// ─── Owner-instance resolution for group DMs ───────────────────────────────── +// Registered by spaceStore on import; maps a DM channel ID to the current +// owner's home-instance origin. Lives here (not in spaceStore) so that +// `api/client.ts` can route owner-only DM calls (rename, icon, kick, transfer) +// to the owner's home instance without a value-cycle on spaceStore. +// +// Returns '' (home) when the resolver is not yet registered or the channel is +// unknown — keeps owner-only calls hitting the home instance in tests/SSR. + +let _getOwnerInstanceForDm: ((channelId: string) => string) | null = null; + +export function setOwnerInstanceForDmResolver( + resolver: (channelId: string) => string, +): void { + _getOwnerInstanceForDm = resolver; +} + +export function getOwnerInstanceForDm(channelId: string): string { + return _getOwnerInstanceForDm?.(channelId) ?? ''; +} + // ─── Hostname → origin resolution (federation) ──────────────────────────────── // Registered by instanceStore on import; maps a federated user's `homeInstance` // hostname (e.g. "remote.example.com") to a full origin URL diff --git a/packages/web/src/utils/groupDm.ownerRouting.test.ts b/packages/web/src/utils/groupDm.ownerRouting.test.ts new file mode 100644 index 00000000..a0edf3e4 --- /dev/null +++ b/packages/web/src/utils/groupDm.ownerRouting.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Stub AudioManager (jsdom has no AudioWorkletNode) +vi.mock('../audio/AudioManager', () => ({ + AudioManager: { + getInstance: vi.fn().mockReturnValue({ + setOutputDevice: vi.fn(), + setVolume: vi.fn(), + }), + }, +})); + +// Stub instanceStore + authStore to avoid init-order issues +vi.mock('../stores/instanceStore', () => ({ + useInstanceStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ instances: [], _autoConnectDone: true }), + { + getState: () => ({ instances: [], _autoConnectDone: true }), + setState: vi.fn(), + subscribe: vi.fn(), + }, + ), +})); +vi.mock('../stores/authStore', () => ({ + useAuthStore: Object.assign( + (selector: (s: unknown) => unknown) => selector({ user: null, token: null }), + { + getState: () => ({ user: null, token: null }), + setState: vi.fn(), + subscribe: vi.fn(), + }, + ), +})); + +import { useSpaceStore, getOwnerInstanceForDm, getChannelOrigin } from '../stores/spaceStore'; + +const baseDm = { + id: 'dm-1', + federatedId: null, + ownerId: 'U1', + ownerHomeUserId: 'U1', + ownerHomeInstance: '' as string | null, + createdAt: 1, + members: [], + lastMessage: null, + name: null, + icon: null, + metadataUpdatedAt: 0, +}; + +beforeEach(() => { + useSpaceStore.getState().reset(); +}); + +describe('getOwnerInstanceForDm — helper', () => { + it('returns "" for an unknown channel id', () => { + expect(getOwnerInstanceForDm('does-not-exist')).toBe(''); + }); + + it('returns "" for a DM with home-instance owner (ownerHomeInstance = "")', () => { + useSpaceStore.setState({ dmChannels: [{ ...baseDm, ownerHomeInstance: '' }] }); + expect(getOwnerInstanceForDm('dm-1')).toBe(''); + }); + + it('returns "" when ownerHomeInstance is null (legacy / non-group DM)', () => { + useSpaceStore.setState({ dmChannels: [{ ...baseDm, ownerHomeInstance: null }] }); + expect(getOwnerInstanceForDm('dm-1')).toBe(''); + }); + + it('returns the remote origin after a transfer mutates ownerHomeInstance', () => { + useSpaceStore.setState({ + dmChannels: [{ ...baseDm, ownerHomeInstance: 'https://orbit.test' }], + }); + expect(getOwnerInstanceForDm('dm-1')).toBe('https://orbit.test'); + }); + + it('is distinct from getChannelOrigin (channel-pinned origin can differ)', () => { + useSpaceStore.setState({ + dmChannels: [{ ...baseDm, ownerHomeInstance: 'https://orbit.test' }], + // channelOriginMap is the channel's pinned serving origin — independent + // of ownerHomeInstance after a manual ownership transfer. + channelOriginMap: new Map([['dm-1', 'https://nova.test']]), + }); + expect(getChannelOrigin('dm-1')).toBe('https://nova.test'); + expect(getOwnerInstanceForDm('dm-1')).toBe('https://orbit.test'); + }); +});