feat(client): api.dm.updateMetadata + kickMember + transferOwnership

This commit is contained in:
Jannis Braun
2026-05-10 19:41:24 +02:00
parent 22c3fd6d50
commit e77fae0402
2 changed files with 152 additions and 0 deletions
+42
View File
@@ -65,6 +65,7 @@ import type {
SpaceInviteRequest,
SpaceInviteResponse,
} from '@backspace/shared';
import { getApiForOrigin, getOwnerInstanceForDm } from '../utils/crossStoreResolvers';
export type { FederationPeer, ApprovalRequest, PeeringSubscription, PeeringNotification };
@@ -174,6 +175,23 @@ export class BackspaceApiClient {
deleteMessage: (id: string) => Promise<{ success: boolean }>;
addMember: (dmChannelId: string, data: AddDmMemberRequest) => Promise<DmChannel>;
leave: (dmChannelId: string) => Promise<{ success: boolean }>;
/**
* Owner-only: rename a group DM and/or update its icon.
* Routes via getApiForOrigin(getOwnerInstanceForDm(channelId)) so the
* federation event's sourceInstance equals the channel's ownerHomeInstance
* (required by the receiver's authority check).
*/
updateMetadata: (channelId: string, body: { name?: string | null; icon?: string | null }) => Promise<DmChannel>;
/**
* Owner-only: kick a member from a group DM.
* Routes via getApiForOrigin(getOwnerInstanceForDm(channelId)) — see updateMetadata.
*/
kickMember: (channelId: string, targetUserId: string) => Promise<{ success: boolean }>;
/**
* Owner-only: transfer group DM ownership to another member without leaving.
* Routes via getApiForOrigin(getOwnerInstanceForDm(channelId)) — see updateMetadata.
*/
transferOwnership: (channelId: string, newOwnerId: string) => Promise<DmChannel>;
spaceInvite: (body: SpaceInviteRequest) => Promise<SpaceInviteResponse>;
};
@@ -486,6 +504,30 @@ export class BackspaceApiClient {
request<DmChannel>('POST', `/dm/${dmChannelId}/members`, data),
leave: (dmChannelId: string) =>
request<{ success: boolean }>('DELETE', `/dm/${dmChannelId}/members`),
// Owner-only methods. Each first re-routes through the owner's home
// instance via getApiForOrigin(getOwnerInstanceForDm(channelId)). When
// the resolved client is `this`, we fall through to the local request
// (terminating the recursion). When it's a different client (i.e. a
// remote BackspaceApiClient), we delegate to that client's identical
// method, which will see itself as `this` and execute the request.
// This keeps the federation event's sourceInstance equal to the
// channel's current ownerHomeInstance — required by receiver authority
// checks (see docs/systems/federation.md and the kick-authority test).
updateMetadata: (channelId, body) => {
const target = getApiForOrigin(getOwnerInstanceForDm(channelId));
if (target !== this) return target.dm.updateMetadata(channelId, body);
return request<DmChannel>('PATCH', `/dm/${channelId}`, body);
},
kickMember: (channelId, targetUserId) => {
const target = getApiForOrigin(getOwnerInstanceForDm(channelId));
if (target !== this) return target.dm.kickMember(channelId, targetUserId);
return request<{ success: boolean }>('DELETE', `/dm/${channelId}/members/${targetUserId}`);
},
transferOwnership: (channelId, newOwnerId) => {
const target = getApiForOrigin(getOwnerInstanceForDm(channelId));
if (target !== this) return target.dm.transferOwnership(channelId, newOwnerId);
return request<DmChannel>('POST', `/dm/${channelId}/transfer`, { newOwnerId });
},
spaceInvite: (body) =>
request<SpaceInviteResponse>('POST', '/dm/space-invite', body),
};
@@ -32,7 +32,48 @@ vi.mock('../stores/authStore', () => ({
),
}));
// Spy-able getApiForOrigin: returns a remote-flavoured client when given a
// non-empty origin, the home stub otherwise. Used to assert which origin
// owner-only DM calls route to. We mock the resolver module directly so the
// real api/client.ts (which imports it) ends up calling our spy at runtime.
// Hoisted via vi.hoisted so the factory below — which is itself hoisted
// above the rest of the file — can reference the spies without TDZ errors.
const { remoteClient, homeClient, mockGetApiForOrigin } = vi.hoisted(() => {
const remote = {
dm: {
updateMetadata: vi.fn().mockResolvedValue({}),
kickMember: vi.fn().mockResolvedValue({}),
transferOwnership: vi.fn().mockResolvedValue({}),
sendMessage: vi.fn().mockResolvedValue({}),
},
};
const home = {
dm: {
updateMetadata: vi.fn().mockResolvedValue({}),
kickMember: vi.fn().mockResolvedValue({}),
transferOwnership: vi.fn().mockResolvedValue({}),
sendMessage: vi.fn().mockResolvedValue({}),
},
};
return {
remoteClient: remote,
homeClient: home,
mockGetApiForOrigin: vi.fn((origin: string) =>
origin ? (remote as never) : (home as never),
),
};
});
vi.mock('./crossStoreResolvers', async () => {
const actual = await vi.importActual<typeof import('./crossStoreResolvers')>('./crossStoreResolvers');
return {
...actual,
getApiForOrigin: mockGetApiForOrigin,
};
});
import { useSpaceStore, getOwnerInstanceForDm, getChannelOrigin } from '../stores/spaceStore';
import { api } from '../api/client';
const baseDm = {
id: 'dm-1',
@@ -49,6 +90,7 @@ const baseDm = {
};
beforeEach(() => {
vi.clearAllMocks();
useSpaceStore.getState().reset();
});
@@ -85,3 +127,71 @@ describe('getOwnerInstanceForDm — helper', () => {
expect(getOwnerInstanceForDm('dm-1')).toBe('https://orbit.test');
});
});
describe('group DM owner routing — api.dm.* (Task 5.2)', () => {
it('baseline (no transfer): owner-only ops route to home (empty origin)', async () => {
useSpaceStore.setState({ dmChannels: [{ ...baseDm }] });
await api.dm.updateMetadata('dm-1', { name: 'X' });
expect(mockGetApiForOrigin).toHaveBeenCalledWith('');
// Home client is returned for empty origin; the singleton delegates to it,
// and the spy on homeClient.dm.updateMetadata records the call.
expect(homeClient.dm.updateMetadata).toHaveBeenCalledWith('dm-1', { name: 'X' });
expect(remoteClient.dm.updateMetadata).not.toHaveBeenCalled();
});
it('after transfer: api.dm.updateMetadata routes to new owner instance', async () => {
useSpaceStore.setState({
dmChannels: [{ ...baseDm, ownerHomeInstance: 'https://orbit.test' }],
});
await api.dm.updateMetadata('dm-1', { name: 'Renamed' });
expect(mockGetApiForOrigin).toHaveBeenCalledWith('https://orbit.test');
expect(remoteClient.dm.updateMetadata).toHaveBeenCalledWith('dm-1', { name: 'Renamed' });
});
it('after transfer: api.dm.kickMember routes to new owner instance', async () => {
useSpaceStore.setState({
dmChannels: [{ ...baseDm, ownerHomeInstance: 'https://orbit.test' }],
});
await api.dm.kickMember('dm-1', 'target-user');
expect(mockGetApiForOrigin).toHaveBeenCalledWith('https://orbit.test');
expect(remoteClient.dm.kickMember).toHaveBeenCalledWith('dm-1', 'target-user');
});
it('after transfer: api.dm.transferOwnership routes to new owner instance', async () => {
useSpaceStore.setState({
dmChannels: [{ ...baseDm, ownerHomeInstance: 'https://orbit.test' }],
});
await api.dm.transferOwnership('dm-1', 'next-owner');
expect(mockGetApiForOrigin).toHaveBeenCalledWith('https://orbit.test');
expect(remoteClient.dm.transferOwnership).toHaveBeenCalledWith('dm-1', 'next-owner');
});
it('non-owner-only op (sendMessage) is unaffected by ownerHomeInstance', async () => {
// Owner routing is opt-in per method — sendMessage on the singleton api
// must NOT consult ownerHomeInstance. It uses the channel's pinned origin
// resolved by the caller (via getChannelOrigin), not the owner instance.
useSpaceStore.setState({
dmChannels: [{ ...baseDm, ownerHomeInstance: 'https://orbit.test' }],
});
// Mock fetch so sendMessage doesn't try a real network call.
const originalFetch = global.fetch;
const fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify({}), { status: 200, headers: { 'content-type': 'application/json' } }));
global.fetch = fetchSpy as unknown as typeof fetch;
try {
await api.dm.sendMessage('dm-1', { content: 'hi' });
} finally {
global.fetch = originalFetch;
}
expect(mockGetApiForOrigin).not.toHaveBeenCalledWith('https://orbit.test');
});
});