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, CreateDmMessageRequest, Friend, FriendRequest, DiscoverUser, InstanceStreamingLimits, InstanceAdminSettings, InstanceInfoResponse, VerifyPasswordResponse, ChangePasswordRequest, ChangePasswordResponse, DeleteAccountRequest, StorageStats, OrphanedFile, CleanupResult, AdminUserListResponse, AdminUser, AdminResetPasswordResponse, ExploreSpace, JoinRequest, Role, SpaceLayoutItem, SpaceFolder, InvitePreview, GifResult, StickerPack, Sticker, } from '@backspace/shared'; 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 }>; }; 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 }[] }>; }; 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 }>; }; readonly messages: { update: (id: string, data: UpdateMessageRequest) => Promise; delete: (id: string) => Promise<{ success: boolean }>; }; readonly uploads: { upload: (file: File) => Promise; url: (filename: string) => string; }; readonly dm: { list: () => Promise; create: (data: CreateDmRequest) => 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: { userId: string }) => Promise; leave: (dmChannelId: string) => Promise<{ success: boolean }>; }; 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 stickers: { getPacks: (spaceId: string) => Promise<{ packs: StickerPack[] }>; createPack: (spaceId: string, data: { name: string; description?: string }) => Promise; updatePack: (spaceId: string, packId: string, data: { name?: string; description?: string }) => Promise; deletePack: (spaceId: string, packId: string) => Promise<{ success: boolean }>; uploadSticker: (spaceId: string, packId: string, file: File, name: string, tags?: string) => Promise; deleteSticker: (stickerId: string) => Promise<{ success: boolean }>; myStickers: () => Promise<{ packs: StickerPack[] }>; }; readonly admin: { storageStats: () => Promise; storageOrphans: () => Promise<{ orphans: OrphanedFile[] }>; storageCleanup: (dryRun?: boolean) => Promise; listUsers: (params?: { q?: string; page?: number; pageSize?: number; showDeleted?: boolean }) => Promise; setUserRole: (userId: string, isAdmin: boolean) => Promise; resetUserPassword: (userId: string) => Promise; deleteUser: (userId: string) => Promise<{ success: boolean }>; }; constructor(baseUrl: string, getToken: () => string | null) { 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 response = await fetch(`${baseUrl}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined, }); if (!response.ok) { 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 response = await fetch(`${baseUrl}/uploads`, { method: 'POST', headers, body: formData, }); if (!response.ok) { 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; } 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), }; 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}` : ''}` ); }, }; 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}`), }; 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, url: (filename: string) => `${baseUrl}/uploads/${filename}`, }; this.dm = { list: () => request('GET', '/dm'), create: (data: CreateDmRequest) => request('POST', '/dm', 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: { userId: string }) => request('POST', `/dm/${dmChannelId}/members`, data), leave: (dmChannelId: string) => request<{ success: boolean }>('DELETE', `/dm/${dmChannelId}/members`), }; 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.stickers = { getPacks: (spaceId: string) => request<{ packs: StickerPack[] }>('GET', `/spaces/${spaceId}/sticker-packs`), createPack: (spaceId: string, data: { name: string; description?: string }) => request('POST', `/spaces/${spaceId}/sticker-packs`, data), updatePack: (spaceId: string, packId: string, data: { name?: string; description?: string }) => request('PATCH', `/spaces/${spaceId}/sticker-packs/${packId}`, data), deletePack: (spaceId: string, packId: string) => request<{ success: boolean }>('DELETE', `/spaces/${spaceId}/sticker-packs/${packId}`), uploadSticker: async (spaceId: string, packId: string, file: File, name: string, tags = '') => { const formData = new FormData(); formData.append('file', file); formData.append('name', name); formData.append('tags', tags); const token = getToken(); const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; const response = await fetch(`${baseUrl}/spaces/${spaceId}/sticker-packs/${packId}/stickers`, { method: 'POST', headers, body: formData, }); if (!response.ok) { 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; }, deleteSticker: (stickerId: string) => request<{ success: boolean }>('DELETE', `/stickers/${stickerId}`), myStickers: () => request<{ packs: StickerPack[] }>('GET', '/users/@me/stickers'), }; 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 }), 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'); return request('GET', `/admin/users?${qs}`); }, 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}`), }; } } export const api = new BackspaceApiClient('/api', () => localStorage.getItem('backspace_token')); export function createApiClient(origin: string, getToken: () => string | null): BackspaceApiClient { const baseUrl = origin ? `${origin}/api` : '/api'; return new BackspaceApiClient(baseUrl, getToken); }