feat(client): getOwnerInstanceForDm — route owner-only DM ops to current owner instance

This commit is contained in:
Jannis Braun
2026-05-10 19:39:13 +02:00
parent 063ed2dd64
commit 22c3fd6d50
3 changed files with 131 additions and 0 deletions
+23
View File
@@ -10,6 +10,7 @@ import {
resolveUserIdFromInstances, resolveUserIdFromInstances,
getCachedUserIdForOrigin, getCachedUserIdForOrigin,
clearMyUserIdCache, clearMyUserIdCache,
setOwnerInstanceForDmResolver,
} from '../utils/crossStoreResolvers'; } from '../utils/crossStoreResolvers';
import { useAuthStore } from './authStore'; import { useAuthStore } from './authStore';
import { useChatStore } from './chatStore'; import { useChatStore } from './chatStore';
@@ -1137,6 +1138,28 @@ export function getChannelOrigin(channelId: string): string {
return useSpaceStore.getState().channelOriginMap.get(channelId) ?? ''; 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. * Resolves a raw DM channel ID to its primary `dmChannels` entry ID.
* *
@@ -39,6 +39,27 @@ export function getApiForOrigin(origin: string): BackspaceApiClient {
return _getApiForOrigin(origin); 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) ──────────────────────────────── // ─── Hostname → origin resolution (federation) ────────────────────────────────
// Registered by instanceStore on import; maps a federated user's `homeInstance` // Registered by instanceStore on import; maps a federated user's `homeInstance`
// hostname (e.g. "remote.example.com") to a full origin URL // hostname (e.g. "remote.example.com") to a full origin URL
@@ -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');
});
});