feat(client): getOwnerInstanceForDm — route owner-only DM ops to current owner instance
This commit is contained in:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user