import { FileSystemUploadType, uploadAsync } from 'expo-file-system/legacy'; import { appConfig } from '@/config/env'; import { ApiError } from '@/lib/errors'; import type { z } from 'zod'; import { BillingStatusSchema, CheckoutSessionResponseSchema, DepotObjectSchema, FirebaseTokenResponseSchema, GetLivekitTokenResponseSchema, HumanSchema, ListInvitationsResponseSchema, ListNetworksResponseSchema, NetworkSchema, NetworkUsageSchema, PortalSessionResponseSchema, PrepareUploadResponseSchema, SignInResponseSchema, } from './types'; import type { AcceptInvitationRequest, AddMembersRequest, BillingCadence, CreateNetworkRequest, PrepareUploadRequest, RequestCodeRequest, RevokeInvitationRequest, SignInRequest, } from './types'; /** * HTTP transport for Orion. Holds the bearer token as private state — the auth * store pushes it in via {@link setToken} on sign-in / restore and clears it * on sign-out. The client itself has no opinion about what a 401 means; it * just throws, and the query-client onError handler is the single place that * turns a 401 into a session invalidation. */ class ApiClient { private token: string | null = null; constructor(private readonly baseUrl: string) {} setToken(token: string | null): void { this.token = token; } private async fetch( method: string, path: string, body?: unknown, ): Promise { const headers: Record = {}; if (body) { headers['Content-Type'] = 'application/json'; } if (this.token) { headers['Authorization'] = `Bearer ${this.token}`; } const response = await fetch(`${this.baseUrl}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined, }); if (response.status === 401) { throw new ApiError(401, 'Unauthorized'); } if (!response.ok) { const text = await response.text().catch(() => 'Unknown error'); throw new ApiError(response.status, text); } return response; } private async request( schema: z.ZodType, method: string, path: string, body?: unknown, ): Promise { const response = await this.fetch(method, path, body); const json = await response.json(); return schema.parse(json); } private async requestVoid( method: string, path: string, body?: unknown, ): Promise { await this.fetch(method, path, body); } // --- Auth --- async requestCode(data: RequestCodeRequest): Promise { await this.requestVoid('POST', '/auth/request-code', data); } async signIn(data: SignInRequest) { return this.request(SignInResponseSchema, 'POST', '/auth/sign-in', data); } async me() { return this.request(HumanSchema, 'GET', '/auth/me'); } async signOut(): Promise { await this.requestVoid('POST', '/auth/sign-out'); } async getFirebaseToken() { return this.request( FirebaseTokenResponseSchema, 'POST', '/auth/firebase-token', ); } // TODO: security: require passing in the particle id once api deprecates this async getParticleDownloadUrl(objectId: string): Promise { const response = await this.fetch('GET', `/particles/${objectId}/download`); const data = await response.json(); return data.url; } // --- Settings --- async updateSettings(data: { email_notifications_enabled?: boolean; }): Promise { await this.requestVoid('PATCH', '/humans/me/settings', data); } // --- Avatar --- /** * Upload a new profile picture. The endpoint takes the raw image bytes as * the request body (not multipart), so we stream the file directly via * expo-file-system rather than the JSON `fetch` helper. */ async uploadAvatar(fileUri: string, mimeType: string): Promise { const headers: Record = { 'Content-Type': mimeType }; if (this.token) { headers['Authorization'] = `Bearer ${this.token}`; } const result = await uploadAsync( `${this.baseUrl}/humans/me/avatar`, fileUri, { httpMethod: 'PUT', uploadType: FileSystemUploadType.BINARY_CONTENT, headers, }, ); if (result.status === 401) { throw new ApiError(401, 'Unauthorized'); } if (result.status < 200 || result.status >= 300) { throw new ApiError(result.status, result.body || 'Avatar upload failed'); } } async deleteAvatar(): Promise { await this.requestVoid('DELETE', '/humans/me/avatar'); } async getAvatarDownloadUrl(objectId: string): Promise { const response = await this.fetch('GET', `/humans/avatar/${objectId}`); const data = await response.json(); return data.url; } // --- Push notification tokens --- async registerPushToken(data: { token: string; platform: 'ios' | 'android'; app_version: string; }): Promise { await this.requestVoid('POST', '/humans/me/push-tokens', data); } async unregisterPushToken(token: string): Promise { await this.requestVoid('DELETE', '/humans/me/push-tokens', { token }); } // --- Depot --- async prepareUpload(data: PrepareUploadRequest) { return this.request( PrepareUploadResponseSchema, 'POST', '/depot/upload', data, ); } async confirmUpload(objectId: string) { return this.request( DepotObjectSchema, 'POST', `/depot/objects/${objectId}/confirm`, ); } // --- Networks --- async listNetworks() { return this.request(ListNetworksResponseSchema, 'GET', '/networks'); } async createNetwork(data: CreateNetworkRequest) { return this.request(NetworkSchema, 'POST', '/networks', data); } async getNetwork(id: string) { return this.request(NetworkSchema, 'GET', `/networks/${id}`); } async addMembers(networkId: string, data: AddMembersRequest): Promise { await this.requestVoid('POST', `/networks/${networkId}/members`, data); } async removeMember(networkId: string, humanId: string): Promise { await this.requestVoid( 'DELETE', `/networks/${networkId}/members/${humanId}`, ); } // --- Invitations --- async listNetworkInvitations(networkId: string) { return this.request( ListInvitationsResponseSchema, 'GET', `/networks/${networkId}/invitations`, ); } async listMyInvitations() { return this.request(ListInvitationsResponseSchema, 'GET', '/invitations'); } async acceptInvitation(data: AcceptInvitationRequest): Promise { await this.requestVoid('POST', '/invitations/accept', data); } async revokeInvitation( networkId: string, data: RevokeInvitationRequest, ): Promise { await this.requestVoid( 'DELETE', `/networks/${networkId}/invitations`, data, ); } // --- LiveKit --- async getLivekitToken(networkId: string, streamId: string) { return this.request( GetLivekitTokenResponseSchema, 'POST', '/livekit/token', { network_id: networkId, stream_id: streamId }, ); } // --- Billing (network admin only) --- async getNetworkBilling(networkId: string) { return this.request( BillingStatusSchema, 'GET', `/networks/${networkId}/billing`, ); } async createCheckoutSession(networkId: string, cadence: BillingCadence) { return this.request( CheckoutSessionResponseSchema, 'POST', `/networks/${networkId}/billing/checkout-session`, { cadence }, ); } async createPortalSession(networkId: string) { return this.request( PortalSessionResponseSchema, 'POST', `/networks/${networkId}/billing/portal-session`, ); } async getNetworkUsage(networkId: string) { return this.request( NetworkUsageSchema, 'GET', `/networks/${networkId}/usage`, ); } } export const apiClient = new ApiClient(appConfig.orionUrl);