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