diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 27436060..a72d5f95 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -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; 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; + /** + * 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; spaceInvite: (body: SpaceInviteRequest) => Promise; }; @@ -486,6 +504,30 @@ export class BackspaceApiClient { request('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('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('POST', `/dm/${channelId}/transfer`, { newOwnerId }); + }, spaceInvite: (body) => request('POST', '/dm/space-invite', body), }; diff --git a/packages/web/src/utils/groupDm.ownerRouting.test.ts b/packages/web/src/utils/groupDm.ownerRouting.test.ts index a0edf3e4..bc6e0bd3 100644 --- a/packages/web/src/utils/groupDm.ownerRouting.test.ts +++ b/packages/web/src/utils/groupDm.ownerRouting.test.ts @@ -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('./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'); + }); +});