import type { AuthResponse, RegisterRequest, LoginRequest, User, Space, SpaceWithChannelsAndMembers, Channel, ChannelCategory, MessageWithUser, MemberWithUser, DmChannel, DmMessageWithUser, CreateSpaceRequest, UpdateSpaceRequest, CreateChannelRequest, UpdateChannelRequest, CreateMessageRequest, UpdateMessageRequest, UpdateUserRequest, JoinSpaceRequest, UpdateMemberRequest, LiveKitTokenResponse, CreateDmRequest, AddDmMemberRequest, CreateGroupDmRequest, TransferOwnershipRequest, CreateDmMessageRequest, Friend, FriendRequest, DiscoverUser, InstanceStreamingLimits, InstanceAdminSettings, InstanceInfoResponse, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, StorageStats, OrphanedFile, CleanupResult, AdminUserListResponse, AdminUser, AdminResetPasswordResponse, ExploreSpace, JoinRequest, Role, SpaceLayoutItem, SpaceFolder, InvitePreview, GifResult, FederationRegistryEntry, FederationIdentityDeleteRequest, FederationIdentityDeleteResponse, FederationPeer, FederationOrphanedAccount, FederationResetEvent, FederationResetEventsResponse, ApprovalRequest, PeeringSubscription, PeeringNotification, InviteLinkSummary, InviteRedemption, CreateInviteRequest, UpdateInviteRequest, ReinstateInviteRequest, ReinstateInviteResponse, CheckInviteResponse, SpaceInviteRequest, SpaceInviteResponse, AttachProofResponse, ReattachRequest, ReattachResponse, Activity, } from '@backspace/shared'; import type { AuditEvent } from '@backspace/shared/src/audit.js'; export interface SoundboardSound { id: string; spaceId: string; name: string; filename: string; uploaderId: string | null; createdAt: number; } export interface StatsLeader { userId: string; username: string; displayName: string | null; avatar: string | null; value: number; } export interface SpaceStats { days: number; since: number; voice: StatsLeader[]; messages: StatsLeader[]; totals: { voiceMs: number; messages: number }; } import { getApiForOrigin, getOwnerInstanceForDm } from '../utils/crossStoreResolvers'; export type { FederationPeer, FederationOrphanedAccount, FederationResetEvent, FederationResetEventsResponse, ApprovalRequest, PeeringSubscription, PeeringNotification }; export class RateLimitError extends Error { readonly retryAfter: number; constructor(retryAfter: number) { super('Rate limit exceeded'); this.name = 'RateLimitError'; this.retryAfter = retryAfter; } } export class HttpError extends Error { readonly status: number; readonly body?: unknown; constructor(status: number, message: string, body?: unknown) { super(message); this.name = 'HttpError'; this.status = status; this.body = body; } } export class BackspaceApiClient { readonly auth: { register: (data: RegisterRequest) => Promise; login: (data: LoginRequest) => Promise; checkUsername: (username: string) => Promise<{ available: boolean; reason?: string }>; checkInvite: (token: string) => Promise; attachProof: (targetDomain: string) => Promise; }; readonly users: { me: () => Promise; update: (data: UpdateUserRequest) => Promise; get: (id: string) => Promise; verifyPassword: (password: string) => Promise; changePassword: (data: ChangePasswordRequest) => Promise; deleteAccount: (data: DeleteAccountRequest) => Promise<{ success: boolean }>; getMutuals: (id: string, homeUserId?: string) => Promise<{ mutualFriends: User[]; mutualSpaces: { id: string; name: string; icon: string | null; avatarColor: string | null }[] }>; getFederationRegistry: () => Promise<{ registry: FederationRegistryEntry[]; updatedAt: number }>; putFederationRegistry: (data: { registry: FederationRegistryEntry[]; updatedAt: number }) => Promise<{ ok: boolean; updatedAt: number }>; deleteFederationIdentity: (data: FederationIdentityDeleteRequest) => Promise; reattach: (data: ReattachRequest) => Promise; }; readonly spaceLayout: { update: (data: { items: SpaceLayoutItem[]; folders: Record; updatedAt?: number }) => Promise<{ items: SpaceLayoutItem[]; folders: SpaceFolder[]; updatedAt?: number }>; }; readonly spaces: { list: () => Promise; get: (id: string) => Promise; create: (data: CreateSpaceRequest) => Promise; update: (id: string, data: UpdateSpaceRequest) => Promise; delete: (id: string) => Promise<{ success: boolean }>; invite: (id: string) => Promise<{ inviteCode: string }>; join: (id: string, data: JoinSpaceRequest) => Promise; joinByCode: (inviteCode: string) => Promise; members: (id: string) => Promise; updateMember: (spaceId: string, userId: string, data: UpdateMemberRequest) => Promise; removeMember: (spaceId: string, userId: string) => Promise<{ success: boolean }>; getBans: (spaceId: string) => Promise<{ spaceId: string; userId: string; reason: string | null; bannedBy: string; createdAt: number; user: any; moderator: any }[]>; ban: (spaceId: string, userId: string, reason?: string) => Promise<{ success: boolean }>; unban: (spaceId: string, userId: string) => Promise<{ success: boolean }>; transferOwnership: (spaceId: string, newOwnerId: string) => Promise; invitePreview: (code: string) => Promise; }; readonly channels: { list: (spaceId: string) => Promise; create: (spaceId: string, data: CreateChannelRequest) => Promise; update: (id: string, data: UpdateChannelRequest) => Promise; delete: (id: string) => Promise<{ success: boolean }>; messages: (id: string, before?: string, limit?: number) => Promise; messagesAround: (id: string, messageId: string) => Promise; sendMessage: (channelId: string, data: CreateMessageRequest) => Promise; getOverrides: (channelId: string) => Promise<{ channelId: string; targetType: string; targetId: string; allow: string; deny: string }[]>; putOverride: (channelId: string, data: { targetType: string; targetId: string; allow: string; deny: string }) => Promise<{ success: boolean }>; deleteOverride: (channelId: string, targetType: string, targetId: string) => Promise<{ success: boolean }>; updateLayout: (spaceId: string, data: { channels: Array<{ id: string; position: number; categoryId: string | null }>; categories: Array<{ id: string; position: number }> }) => Promise<{ success: boolean }>; }; readonly categories: { create: (spaceId: string, name: string) => Promise; update: (id: string, data: { name?: string; position?: number }) => Promise; delete: (id: string) => Promise<{ success: boolean }>; getOverrides: (categoryId: string) => Promise<{ categoryId: string; targetType: string; targetId: string; allow: string; deny: string }[]>; putOverride: (categoryId: string, data: { targetType: string; targetId: string; allow: string; deny: string }) => Promise<{ success: boolean }>; deleteOverride: (categoryId: string, targetType: string, targetId: string) => Promise<{ success: boolean }>; }; readonly messages: { update: (id: string, data: UpdateMessageRequest) => Promise; delete: (id: string) => Promise<{ success: boolean }>; }; readonly uploads: { url: (filename: string) => string; }; readonly dm: { list: () => Promise; create: (data: CreateDmRequest) => Promise; createGroup: (data: CreateGroupDmRequest) => Promise; close: (id: string) => Promise<{ success: boolean }>; messages: (id: string, before?: string, limit?: number) => Promise; messagesAround: (id: string, messageId: string) => Promise; sendMessage: (id: string, data: CreateDmMessageRequest) => Promise; updateMessage: (id: string, data: UpdateMessageRequest) => Promise; 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. The optional `federated` arg is required when the * target is a federated user: the channel-serving instance and the * owner-serving instance disagree on the local replicated user id, and * the home view surfaced through `userViews` carries the home id, not * the owner instance's local id. When `federated` is supplied, the * server resolves it via `resolveOrCreateReplicatedUser`. Without it, * `targetUserId` is treated as a local id on the owner instance. */ kickMember: ( channelId: string, targetUserId: string, federated?: { homeUserId: string; homeInstance: string }, ) => Promise<{ success: boolean }>; /** * Owner-only: transfer group DM ownership to another member without leaving. * * Routes via getApiForOrigin(getOwnerInstanceForDm(channelId)) — see * updateMetadata. The optional `federated` arg is required for * federated targets, mirroring `kickMember`. When supplied, the server * uses `resolveOrCreateReplicatedUser(homeUserId, homeInstance)` to * find the local user row. Without it, `newOwnerId` is treated as a * local id on the owner instance. */ transferOwnership: ( channelId: string, newOwnerId: string, federated?: { homeUserId: string; homeInstance: string }, ) => Promise; spaceInvite: (body: SpaceInviteRequest) => Promise; }; readonly social: { friends: () => Promise; requests: () => Promise; sendRequest: (username: string) => Promise<{ success: boolean; requestId?: string }>; updateRequest: (id: string, status: 'accepted' | 'declined') => Promise<{ success: boolean }>; removeFriend: (id: string) => Promise<{ success: boolean }>; cancelRequest: (id: string) => Promise<{ success: boolean }>; search: (q: string) => Promise; discover: (q?: string, limit?: number, offset?: number) => Promise<{ users: DiscoverUser[]; total: number }>; }; readonly livekit: { token: (channelId: string) => Promise; dmToken: (dmChannelId: string) => Promise; }; readonly settings: { getStreaming: () => Promise; updateStreaming: (data: Partial) => Promise; getInstance: () => Promise; updateInstance: (data: Partial) => Promise; }; readonly instance: { info: () => Promise; }; readonly roles: { create: (spaceId: string, data: { name: string; color?: string; permissions?: string }) => Promise; update: (spaceId: string, roleId: string, data: { name?: string; color?: string; position?: number; permissions?: string }) => Promise; delete: (spaceId: string, roleId: string) => Promise<{ success: boolean }>; }; readonly search: { channel: (channelId: string, params: { q?: string; from?: string; has?: string; before?: string; after?: string; offset?: number; limit?: number }) => Promise<{ results: MessageWithUser[]; totalCount: number }>; dm: (dmChannelId: string, params: { q?: string; from?: string; has?: string; before?: string; after?: string; offset?: number; limit?: number }) => Promise<{ results: DmMessageWithUser[]; totalCount: number }>; }; readonly explore: { list: (q?: string, limit?: number, offset?: number) => Promise<{ spaces: ExploreSpace[]; total: number; totalAll: number; discoveryEnabled: boolean }>; publicJoin: (spaceId: string) => Promise; requestJoin: (spaceId: string, message?: string) => Promise; getJoinRequests: (spaceId: string, status?: string) => Promise<{ requests: JoinRequest[] }>; decideJoinRequest: (spaceId: string, requestId: string, action: 'accept' | 'decline') => Promise; myJoinRequests: (status?: string) => Promise<{ requests: JoinRequest[] }>; }; readonly gif: { trending: (limit?: number, pos?: string) => Promise<{ results: GifResult[]; next: string }>; search: (q: string, limit?: number, pos?: string) => Promise<{ results: GifResult[]; next: string }>; enabled: () => Promise<{ enabled: boolean }>; favorites: () => Promise<{ results: GifResult[] }>; addFavorite: (gif: GifResult) => Promise; removeFavorite: (id: string) => Promise; }; readonly soundboard: { list: (spaceId: string) => Promise<{ sounds: SoundboardSound[] }>; add: (spaceId: string, name: string, filename: string) => Promise; remove: (soundId: string) => Promise; }; readonly stats: { space: (spaceId: string, days: number) => Promise; }; readonly audit: { log: (spaceId: string, before?: string) => Promise<{ events: AuditEvent[]; hasMore: boolean }>; }; readonly spotify: { status: () => Promise<{ configured: boolean; connected: boolean }>; authorizeUrl: () => Promise<{ url: string }>; nowPlaying: () => Promise<{ activity: Activity | null; connected: boolean; serverTime?: number }>; disconnect: () => Promise; }; readonly federation: { initiatePeering: (data: { remoteOrigin: string }) => Promise<{ peer: FederationPeer; verified?: boolean }>; ensurePeered: (data: { remoteOrigin: string }) => Promise<{ peeringStatus: string; peerId?: string; error?: string }>; peers: () => Promise<{ peers: FederationPeer[] }>; resetEvents: () => Promise; acknowledgeResetEvent: (origin: string) => Promise<{ success: boolean }>; revokePeer: (id: string) => Promise<{ success: boolean }>; resetPeer: (id: string) => Promise<{ success: boolean }>; recheckPeer: (id: string) => Promise<{ recovered: boolean; status: string }>; rotatePeerSecret: (id: string) => Promise<{ success: boolean; gracePeriodMs: number }>; updatePeer: (id: string, data: { autoRotateIntervalDays: number }) => Promise<{ peer: FederationPeer }>; deletePeerPermanently: (id: string) => Promise<{ success: boolean }>; approvalRequests: () => Promise<{ requests: ApprovalRequest[] }>; approveRequest: (id: string) => Promise<{ success: boolean; peer?: FederationPeer }>; denyRequest: (id: string) => Promise<{ success: boolean }>; peeringSubscriptions: () => Promise<{ subscriptions: PeeringSubscription[] }>; cancelPeeringSubscription: (id: string) => Promise<{ success: boolean }>; peeringNotifications: (unreadOnly?: boolean) => Promise<{ notifications: PeeringNotification[] }>; markPeeringNotificationRead: (id: string) => Promise<{ success: boolean }>; markAllPeeringNotificationsRead: () => Promise<{ success: boolean; count: number }>; }; readonly invites: { list: (status?: 'active' | 'archived') => Promise<{ invites: InviteLinkSummary[] }>; create: (body: CreateInviteRequest) => Promise; update: (id: string, body: UpdateInviteRequest) => Promise; revoke: (id: string) => Promise<{ invite: InviteLinkSummary }>; reinstate: (id: string, body: ReinstateInviteRequest) => Promise; delete: (id: string) => Promise<{ success: boolean }>; redemptions: (id: string) => Promise<{ redemptions: InviteRedemption[] }>; }; readonly admin: { storageStats: () => Promise; storageOrphans: () => Promise<{ orphans: OrphanedFile[] }>; storageCleanup: (dryRun?: boolean) => Promise; cleanupOldMedia: (maxAgeDays: number, dryRun?: boolean) => Promise; cleanupTusSessions: (maxAgeHours: number, dryRun?: boolean) => Promise; listUsers: (params?: { q?: string; page?: number; pageSize?: number; showDeleted?: boolean; homeInstance?: string; role?: string; joinedAfter?: string; joinedBefore?: string; sort?: string }) => Promise; listInstances: () => Promise<{ instances: string[] }>; setUserRole: (userId: string, isAdmin: boolean) => Promise; resetUserPassword: (userId: string) => Promise; deleteUser: (userId: string) => Promise<{ success: boolean }>; }; constructor(baseUrl: string, getToken: () => string | null, onUnauthorized?: () => void) { async function request( method: string, path: string, body?: unknown, requireAuth = true, ): Promise { const headers: Record = {}; if (body) { headers['Content-Type'] = 'application/json'; } if (requireAuth) { const token = getToken(); if (token) { headers['Authorization'] = `Bearer ${token}`; } } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); let response: Response; try { response = await fetch(`${baseUrl}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }); } catch (err) { clearTimeout(timeoutId); if (err instanceof DOMException && err.name === 'AbortError') { throw new Error('Request timed out'); } throw err; } clearTimeout(timeoutId); if (!response.ok) { if (response.status === 401 && requireAuth && onUnauthorized) { onUnauthorized(); } if (response.status === 429) { const body = await response.json().catch(() => ({})); const retryAfter = (body as { retryAfter?: number }).retryAfter ?? (parseInt(response.headers.get('retry-after') || '', 10) || 60); throw new RateLimitError(retryAfter); } const error = await response.json().catch(() => ({ error: 'Request failed' })); throw new HttpError(response.status, (error as { error?: string }).error || `HTTP ${response.status}`, error); } return response.json() as Promise; } this.auth = { register: (data: RegisterRequest) => request('POST', '/auth/register', data, false), login: (data: LoginRequest) => request('POST', '/auth/login', data, false), checkUsername: (username: string) => request<{ available: boolean; reason?: string }>('GET', `/auth/check-username?username=${encodeURIComponent(username)}`, undefined, false), checkInvite: (token: string) => request('GET', `/auth/check-invite?token=${encodeURIComponent(token)}`, undefined, false), attachProof: (targetDomain: string) => request('POST', '/auth/attach-proof', { targetDomain }), }; this.users = { me: () => request('GET', '/users/@me'), update: (data: UpdateUserRequest) => request('PATCH', '/users/@me', data), get: (id: string) => request('GET', `/users/${id}`), verifyPassword: (password: string) => request('POST', '/users/@me/verify-password', { password }), changePassword: (data: ChangePasswordRequest) => request('POST', '/users/@me/change-password', data), deleteAccount: (data: DeleteAccountRequest) => request<{ success: boolean }>('DELETE', '/users/@me', data), getMutuals: (id: string, homeUserId?: string) => { const params = new URLSearchParams(); if (homeUserId) params.set('homeUserId', homeUserId); const qs = params.toString(); return request<{ mutualFriends: User[]; mutualSpaces: { id: string; name: string; icon: string | null; avatarColor: string | null }[] }>( 'GET', `/users/${id}/mutuals${qs ? `?${qs}` : ''}` ); }, getFederationRegistry: () => request<{ registry: FederationRegistryEntry[]; updatedAt: number }>( 'GET', '/users/@me/federation-registry' ), putFederationRegistry: (data: { registry: FederationRegistryEntry[]; updatedAt: number }) => request<{ ok: boolean; updatedAt: number }>( 'PUT', '/users/@me/federation-registry', data ), deleteFederationIdentity: (data: FederationIdentityDeleteRequest) => request( 'POST', '/users/@me/federation-identity/delete', data ), reattach: (data: ReattachRequest) => request('POST', '/users/@me/reattach', data), }; this.spaceLayout = { update: (data) => request<{ items: SpaceLayoutItem[]; folders: SpaceFolder[]; updatedAt?: number }>('PUT', '/users/@me/space-layout', data), }; this.spaces = { list: () => request('GET', '/spaces'), get: (id: string) => request('GET', `/spaces/${id}`), create: (data: CreateSpaceRequest) => request('POST', '/spaces', data), update: (id: string, data: UpdateSpaceRequest) => request('PATCH', `/spaces/${id}`, data), delete: (id: string) => request<{ success: boolean }>('DELETE', `/spaces/${id}`), invite: (id: string) => request<{ inviteCode: string }>('POST', `/spaces/${id}/invite`), join: (id: string, data: JoinSpaceRequest) => request('POST', `/spaces/${id}/join`, data), joinByCode: (inviteCode: string) => request('POST', '/spaces/join', { inviteCode }), members: (id: string) => request('GET', `/spaces/${id}/members`), updateMember: (spaceId: string, userId: string, data: UpdateMemberRequest) => request('PATCH', `/spaces/${spaceId}/members/${userId}`, data), removeMember: (spaceId: string, userId: string) => request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/members/${userId}`), getBans: (spaceId: string) => request('GET', `/spaces/${spaceId}/bans`), ban: (spaceId: string, userId: string, reason?: string) => request<{ success: boolean }>('POST', `/spaces/${spaceId}/bans`, { userId, reason }), unban: (spaceId: string, userId: string) => request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/bans/${userId}`), transferOwnership: (spaceId: string, newOwnerId: string) => request('PATCH', `/spaces/${spaceId}/transfer-ownership`, { newOwnerId }), invitePreview: (code: string) => request('GET', `/spaces/invite/${encodeURIComponent(code)}/preview`, undefined, false), }; this.channels = { list: (spaceId: string) => request('GET', `/spaces/${spaceId}/channels`), create: (spaceId: string, data: CreateChannelRequest) => request('POST', `/spaces/${spaceId}/channels`, data), update: (id: string, data: UpdateChannelRequest) => request('PATCH', `/channels/${id}`, data), delete: (id: string) => request<{ success: boolean }>('DELETE', `/channels/${id}`), messages: (id: string, before?: string, limit = 50) => { const params = new URLSearchParams(); if (before) params.set('before', before); params.set('limit', String(limit)); return request('GET', `/channels/${id}/messages?${params}`); }, messagesAround: (id: string, messageId: string) => { const params = new URLSearchParams(); params.set('messageId', messageId); return request('GET', `/channels/${id}/messages/around?${params}`); }, sendMessage: (channelId: string, data: CreateMessageRequest) => request('POST', `/channels/${channelId}/messages`, data), getOverrides: (channelId: string) => request<{ channelId: string; targetType: string; targetId: string; allow: string; deny: string }[]>( 'GET', `/channels/${channelId}/overrides` ), putOverride: (channelId: string, data: { targetType: string; targetId: string; allow: string; deny: string }) => request<{ success: boolean }>('PUT', `/channels/${channelId}/overrides`, data), deleteOverride: (channelId: string, targetType: string, targetId: string) => request<{ success: boolean }>('DELETE', `/channels/${channelId}/overrides/${targetType}/${targetId}`), updateLayout: (spaceId: string, data: { channels: Array<{ id: string; position: number; categoryId: string | null }>; categories: Array<{ id: string; position: number }> }) => request<{ success: boolean }>('PATCH', `/spaces/${spaceId}/channel-layout`, data), }; this.categories = { create: (spaceId: string, name: string) => request('POST', `/spaces/${spaceId}/categories`, { name }), update: (id: string, data: { name?: string; position?: number }) => request('PATCH', `/categories/${id}`, data), delete: (id: string) => request<{ success: boolean }>('DELETE', `/categories/${id}`), getOverrides: (categoryId: string) => request<{ categoryId: string; targetType: string; targetId: string; allow: string; deny: string }[]>( 'GET', `/categories/${categoryId}/overrides` ), putOverride: (categoryId: string, data: { targetType: string; targetId: string; allow: string; deny: string }) => request<{ success: boolean }>('PUT', `/categories/${categoryId}/overrides`, data), deleteOverride: (categoryId: string, targetType: string, targetId: string) => request<{ success: boolean }>('DELETE', `/categories/${categoryId}/overrides/${targetType}/${targetId}`), }; this.messages = { update: (id: string, data: UpdateMessageRequest) => request('PATCH', `/messages/${id}`, data), delete: (id: string) => request<{ success: boolean }>('DELETE', `/messages/${id}`), }; this.uploads = { url: (filename: string) => `${baseUrl}/uploads/${filename}`, }; this.dm = { list: () => request('GET', '/dm'), create: (data: CreateDmRequest) => request('POST', '/dm', data), createGroup: (data: CreateGroupDmRequest) => request('POST', '/dm/group', data), close: (id: string) => request<{ success: boolean }>('DELETE', `/dm/${id}`), messages: (id: string, before?: string, limit = 50) => { const params = new URLSearchParams(); if (before) params.set('before', before); params.set('limit', String(limit)); return request('GET', `/dm/${id}/messages?${params}`); }, messagesAround: (id: string, messageId: string) => { const params = new URLSearchParams(); params.set('messageId', messageId); return request('GET', `/dm/${id}/messages/around?${params}`); }, sendMessage: (id: string, data: CreateDmMessageRequest) => request('POST', `/dm/${id}/messages`, data), updateMessage: (id: string, data: UpdateMessageRequest) => request('PATCH', `/dm/messages/${id}`, data), deleteMessage: (id: string) => request<{ success: boolean }>('DELETE', `/dm/messages/${id}`), addMember: (dmChannelId: string, data: AddDmMemberRequest) => 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, federated) => { const target = getApiForOrigin(getOwnerInstanceForDm(channelId)); if (target !== this) return target.dm.kickMember(channelId, targetUserId, federated); // For federated targets, the URL segment carries the homeUserId and // the `homeInstance` query string signals federated resolution. The // server route resolves via `resolveOrCreateReplicatedUser`. For // local targets, the URL segment is the local user id (legacy form) // and no query is appended. if (federated) { const homeId = encodeURIComponent(federated.homeUserId); const homeInst = encodeURIComponent(federated.homeInstance); return request<{ success: boolean }>( 'DELETE', `/dm/${channelId}/members/${homeId}?homeInstance=${homeInst}`, ); } return request<{ success: boolean }>('DELETE', `/dm/${channelId}/members/${targetUserId}`); }, transferOwnership: (channelId, newOwnerId, federated) => { const target = getApiForOrigin(getOwnerInstanceForDm(channelId)); if (target !== this) return target.dm.transferOwnership(channelId, newOwnerId, federated); const body: TransferOwnershipRequest = federated ? { homeUserId: federated.homeUserId, homeInstance: federated.homeInstance } : { newOwnerId }; return request('POST', `/dm/${channelId}/transfer`, body); }, spaceInvite: (body) => request('POST', '/dm/space-invite', body), }; this.social = { friends: () => request('GET', '/social/friends'), requests: () => request('GET', '/social/requests'), sendRequest: (username: string) => request<{ success: boolean; requestId?: string }>('POST', '/social/requests', { username }), updateRequest: (id: string, status: 'accepted' | 'declined') => request<{ success: boolean }>('PATCH', `/social/requests/${id}`, { status }), removeFriend: (id: string) => request<{ success: boolean }>('DELETE', `/social/friends/${id}`), cancelRequest: (id: string) => request<{ success: boolean }>('DELETE', `/social/requests/${id}`), search: (q: string) => request('GET', `/social/search?q=${encodeURIComponent(q)}`), discover: (q?: string, limit = 24, offset = 0) => { const params = new URLSearchParams(); if (q) params.set('q', q); params.set('limit', String(limit)); params.set('offset', String(offset)); return request<{ users: DiscoverUser[]; total: number }>('GET', `/social/discover?${params}`); }, }; this.livekit = { token: (channelId: string) => request('POST', '/livekit/token', { channelId }), dmToken: (dmChannelId: string) => request('POST', '/livekit/token', { dmChannelId }), }; this.settings = { getStreaming: () => request('GET', '/settings/streaming'), updateStreaming: (data: Partial) => request('PATCH', '/settings/streaming', data), getInstance: () => request('GET', '/settings/instance'), updateInstance: (data: Partial) => request('PATCH', '/settings/instance', data), }; this.instance = { info: () => request('GET', '/instance/info', undefined, false), }; this.roles = { create: (spaceId: string, data: { name: string; color?: string; permissions?: string }) => request('POST', `/spaces/${spaceId}/roles`, data), update: (spaceId: string, roleId: string, data: { name?: string; color?: string; position?: number; permissions?: string }) => request('PATCH', `/spaces/${spaceId}/roles/${roleId}`, data), delete: (spaceId: string, roleId: string) => request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/roles/${roleId}`), }; this.search = { channel: (channelId: string, params: { q?: string; from?: string; has?: string; before?: string; after?: string; offset?: number; limit?: number }) => { const qs = new URLSearchParams(); if (params.q) qs.set('q', params.q); if (params.from) qs.set('from', params.from); if (params.has) qs.set('has', params.has); if (params.before) qs.set('before', params.before); if (params.after) qs.set('after', params.after); if (params.offset !== undefined) qs.set('offset', String(params.offset)); if (params.limit !== undefined) qs.set('limit', String(params.limit)); return request<{ results: MessageWithUser[]; totalCount: number }>('GET', `/channels/${channelId}/search?${qs}`); }, dm: (dmChannelId: string, params: { q?: string; from?: string; has?: string; before?: string; after?: string; offset?: number; limit?: number }) => { const qs = new URLSearchParams(); if (params.q) qs.set('q', params.q); if (params.from) qs.set('from', params.from); if (params.has) qs.set('has', params.has); if (params.before) qs.set('before', params.before); if (params.after) qs.set('after', params.after); if (params.offset !== undefined) qs.set('offset', String(params.offset)); if (params.limit !== undefined) qs.set('limit', String(params.limit)); return request<{ results: DmMessageWithUser[]; totalCount: number }>('GET', `/dm/${dmChannelId}/search?${qs}`); }, }; this.explore = { list: (q?: string, limit = 50, offset = 0) => { const params = new URLSearchParams(); if (q) params.set('q', q); params.set('limit', String(limit)); params.set('offset', String(offset)); return request<{ spaces: ExploreSpace[]; total: number; totalAll: number; discoveryEnabled: boolean }>( 'GET', `/spaces/explore?${params}` ); }, publicJoin: (spaceId: string) => request('POST', `/spaces/${spaceId}/public-join`), requestJoin: (spaceId: string, message?: string) => request('POST', `/spaces/${spaceId}/request-join`, message ? { message } : {}), getJoinRequests: (spaceId: string, status?: string) => { const params = new URLSearchParams(); if (status) params.set('status', status); return request<{ requests: JoinRequest[] }>('GET', `/spaces/${spaceId}/join-requests?${params}`); }, decideJoinRequest: (spaceId: string, requestId: string, action: 'accept' | 'decline') => request('PATCH', `/spaces/${spaceId}/join-requests/${requestId}`, { action }), myJoinRequests: (status?: string) => { const params = new URLSearchParams(); if (status) params.set('status', status); return request<{ requests: JoinRequest[] }>('GET', `/users/@me/join-requests?${params}`); }, }; this.soundboard = { list: (spaceId: string) => request<{ sounds: SoundboardSound[] }>('GET', `/spaces/${spaceId}/sounds`), add: (spaceId: string, name: string, filename: string) => request('POST', `/spaces/${spaceId}/sounds`, { name, filename }), remove: (soundId: string) => request('DELETE', `/sounds/${soundId}`), }; this.stats = { space: (spaceId: string, days: number) => request('GET', `/spaces/${spaceId}/stats?days=${days}`), }; this.audit = { log: (spaceId: string, before?: string) => { const params = new URLSearchParams(); if (before) params.set('before', before); const qs = params.toString(); return request<{ events: AuditEvent[]; hasMore: boolean }>( 'GET', `/spaces/${spaceId}/audit-log${qs ? `?${qs}` : ''}`); }, }; this.spotify = { status: () => request<{ configured: boolean; connected: boolean }>('GET', '/connections/spotify/status'), authorizeUrl: () => request<{ url: string }>('GET', '/connections/spotify/authorize'), nowPlaying: () => request<{ activity: Activity | null; connected: boolean; serverTime?: number }>('GET', '/connections/spotify/now-playing'), disconnect: () => request('DELETE', '/connections/spotify'), }; this.gif = { trending: (limit = 30, pos?: string) => { const params = new URLSearchParams(); params.set('limit', String(limit)); if (pos) params.set('pos', pos); return request<{ results: GifResult[]; next: string }>('GET', `/gif/trending?${params}`); }, favorites: () => request<{ results: GifResult[] }>('GET', '/gif/favorites'), addFavorite: (gif: GifResult) => request('POST', '/gif/favorites', gif), removeFavorite: (id: string) => request('DELETE', `/gif/favorites/${encodeURIComponent(id)}`), search: (q: string, limit = 30, pos?: string) => { const params = new URLSearchParams(); params.set('q', q); params.set('limit', String(limit)); if (pos) params.set('pos', pos); return request<{ results: GifResult[]; next: string }>('GET', `/gif/search?${params}`); }, enabled: () => request<{ enabled: boolean }>('GET', '/gif/enabled'), }; this.federation = { initiatePeering: (data: { remoteOrigin: string }) => request<{ peer: FederationPeer; verified?: boolean }>( 'POST', '/federation/peer/initiate', data ), ensurePeered: (data: { remoteOrigin: string }) => request<{ peeringStatus: string; peerId?: string; error?: string }>( 'POST', '/federation/peer/ensure', data ), peers: () => request<{ peers: FederationPeer[] }>( 'GET', '/federation/peers' ), resetEvents: () => request('GET', '/federation/reset-events'), acknowledgeResetEvent: (origin: string) => request<{ success: boolean }>('POST', '/federation/reset-events/acknowledge', { origin }), revokePeer: (id: string) => request<{ success: boolean }>('DELETE', `/federation/peers/${id}`), resetPeer: (id: string) => request<{ success: boolean }>('POST', `/federation/peers/${id}/reset`), recheckPeer: (id: string) => request<{ recovered: boolean; status: string }>('POST', `/federation/peers/${id}/recheck`), rotatePeerSecret: (id: string) => request<{ success: boolean; gracePeriodMs: number }>('POST', `/federation/peers/${id}/rotate`), updatePeer: (id: string, data: { autoRotateIntervalDays: number }) => request<{ peer: FederationPeer }>('PATCH', `/federation/peers/${id}`, data), deletePeerPermanently: (id: string) => request<{ success: boolean }>('DELETE', `/federation/peers/${id}/permanent`), approvalRequests: () => request<{ requests: ApprovalRequest[] }>( 'GET', '/federation/approval-requests' ), approveRequest: (id: string) => request<{ success: boolean; peer?: FederationPeer }>( 'POST', `/federation/approval-requests/${id}/approve` ), denyRequest: (id: string) => request<{ success: boolean }>( 'POST', `/federation/approval-requests/${id}/deny` ), peeringSubscriptions: () => request<{ subscriptions: PeeringSubscription[] }>( 'GET', '/federation/peering-subscriptions' ), cancelPeeringSubscription: (id: string) => request<{ success: boolean }>( 'DELETE', `/federation/peering-subscriptions/${id}` ), peeringNotifications: (unreadOnly = false) => request<{ notifications: PeeringNotification[] }>( 'GET', `/federation/peering-notifications${unreadOnly ? '?unread=1' : ''}`, ), markPeeringNotificationRead: (id: string) => request<{ success: boolean }>( 'POST', `/federation/peering-notifications/${id}/read` ), markAllPeeringNotificationsRead: () => request<{ success: boolean; count: number }>( 'POST', '/federation/peering-notifications/read-all' ), }; this.invites = { list: (status: 'active' | 'archived' = 'active') => request<{ invites: InviteLinkSummary[] }>('GET', `/admin/invites?status=${status}`), create: (body: CreateInviteRequest) => request('POST', '/admin/invites', body), update: (id: string, body: UpdateInviteRequest) => request('PATCH', `/admin/invites/${id}`, body), revoke: (id: string) => request<{ invite: InviteLinkSummary }>('POST', `/admin/invites/${id}/revoke`), reinstate: (id: string, body: ReinstateInviteRequest) => request('POST', `/admin/invites/${id}/reinstate`, body), delete: (id: string) => request<{ success: boolean }>('DELETE', `/admin/invites/${id}`), redemptions: (id: string) => request<{ redemptions: InviteRedemption[] }>('GET', `/admin/invites/${id}/redemptions`), }; this.admin = { storageStats: () => request('GET', '/admin/storage/stats'), storageOrphans: () => request<{ orphans: OrphanedFile[] }>('GET', '/admin/storage/orphans'), storageCleanup: (dryRun = false) => request('POST', '/admin/storage/cleanup', { dryRun }), cleanupOldMedia: (maxAgeDays: number, dryRun = false) => request('POST', '/admin/storage/cleanup-media', { maxAgeDays, dryRun }), cleanupTusSessions: (maxAgeHours: number, dryRun = false) => request('POST', '/admin/storage/cleanup-tus', { maxAgeHours, dryRun }), listUsers: (params) => { const qs = new URLSearchParams(); if (params?.q) qs.set('q', params.q); if (params?.page !== undefined) qs.set('page', String(params.page)); if (params?.pageSize !== undefined) qs.set('pageSize', String(params.pageSize)); if (params?.showDeleted) qs.set('showDeleted', 'true'); if (params?.homeInstance) qs.set('homeInstance', params.homeInstance); if (params?.role) qs.set('role', params.role); if (params?.joinedAfter) qs.set('joinedAfter', params.joinedAfter); if (params?.joinedBefore) qs.set('joinedBefore', params.joinedBefore); if (params?.sort) qs.set('sort', params.sort); return request('GET', `/admin/users?${qs}`); }, listInstances: () => request<{ instances: string[] }>('GET', '/admin/users/instances'), setUserRole: (userId, isAdmin) => request('PATCH', `/admin/users/${userId}/role`, { isAdmin }), resetUserPassword: (userId) => request('POST', `/admin/users/${userId}/reset-password`), deleteUser: (userId) => request<{ success: boolean }>('DELETE', `/admin/users/${userId}`), }; } } function handleUnauthorized(): void { localStorage.removeItem('backspace_token'); if ( !window.location.pathname.startsWith('/login') && !window.location.pathname.startsWith('/register') ) { window.location.href = '/login'; } } export const api = new BackspaceApiClient( '/api', () => localStorage.getItem('backspace_token'), handleUnauthorized, ); export function createApiClient(origin: string, getToken: () => string | null, onUnauthorized?: () => void): BackspaceApiClient { const baseUrl = origin ? `${origin}/api` : '/api'; return new BackspaceApiClient(baseUrl, getToken, onUnauthorized); }