import type { AuthResponse, RegisterRequest, LoginRequest, User, Space, SpaceWithChannelsAndMembers, Channel, ChannelCategory, MessageWithUser, MemberWithUser, Attachment, DmChannel, DmMessageWithUser, CreateSpaceRequest, UpdateSpaceRequest, CreateChannelRequest, UpdateChannelRequest, CreateMessageRequest, UpdateMessageRequest, UpdateUserRequest, JoinSpaceRequest, UpdateMemberRequest, LiveKitTokenResponse, CreateDmRequest, AddDmMemberRequest, CreateGroupDmRequest, 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, ApprovalRequest, PeeringSubscription, PeeringNotification, InviteLinkSummary, InviteRedemption, CreateInviteRequest, UpdateInviteRequest, ReinstateInviteRequest, ReinstateInviteResponse, CheckInviteResponse, SpaceInviteRequest, SpaceInviteResponse, } from '@backspace/shared'; export type { FederationPeer, 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 BackspaceApiClient { readonly auth: { register: (data: RegisterRequest) => Promise; login: (data: LoginRequest) => Promise; checkUsername: (username: string) => Promise<{ available: boolean; reason?: string }>; checkInvite: (token: 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; }; 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: { upload: (file: File) => Promise; uploadWithProgress: (file: File, onProgress: (loaded: number, total: number) => void) => Promise; 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 }>; 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 }>; }; readonly federation: { initiatePeering: (data: { remoteOrigin: string }) => Promise<{ peer: FederationPeer }>; ensurePeered: (data: { remoteOrigin: string }) => Promise<{ peeringStatus: string; peerId?: string; error?: string }>; peers: () => Promise<{ peers: FederationPeer[] }>; revokePeer: (id: string) => Promise<{ success: boolean }>; resetPeer: (id: string) => Promise<{ success: boolean }>; 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; 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 Error((error as { error: string }).error || `HTTP ${response.status}`); } return response.json() as Promise; } async function uploadFile(file: File): Promise { const formData = new FormData(); formData.append('file', file); const token = getToken(); const headers: Record = {}; if (token) { headers['Authorization'] = `Bearer ${token}`; } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 120000); let response: Response; try { response = await fetch(`${baseUrl}/uploads`, { method: 'POST', headers, body: formData, 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 && 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: 'Upload failed' })); throw new Error((error as { error: string }).error || `HTTP ${response.status}`); } return response.json() as Promise; } function uploadFileWithProgress(file: File, onProgress: (loaded: number, total: number) => void): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const formData = new FormData(); formData.append('file', file); xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) onProgress(e.loaded, e.total); }); xhr.addEventListener('load', () => { if (xhr.status >= 200 && xhr.status < 300) { try { resolve(JSON.parse(xhr.responseText)); } catch { reject(new Error('Invalid server response')); } } else if (xhr.status === 401 && onUnauthorized) { onUnauthorized(); reject(new Error('Unauthorized')); } else if (xhr.status === 413) { reject(new Error('File too large')); } else { try { const body = JSON.parse(xhr.responseText); reject(new Error(body.error || `Upload failed (${xhr.status})`)); } catch { reject(new Error(`Upload failed (${xhr.status})`)); } } }); xhr.addEventListener('error', () => reject(new Error('Upload failed — network error'))); xhr.addEventListener('timeout', () => reject(new Error('Upload timed out'))); xhr.open('POST', `${baseUrl}/uploads`); xhr.timeout = 10 * 60 * 1000; // 10 minutes for large files const token = getToken(); if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); xhr.send(formData); }); } 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), }; 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 ), }; 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 = { upload: uploadFile, uploadWithProgress: uploadFileWithProgress, 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`), 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.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}`); }, 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 }>( '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' ), revokePeer: (id: string) => request<{ success: boolean }>('DELETE', `/federation/peers/${id}`), resetPeer: (id: string) => request<{ success: boolean }>('POST', `/federation/peers/${id}/reset`), 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 }), 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); }