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); } // --- 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);