277 lines
6.6 KiB
TypeScript
277 lines
6.6 KiB
TypeScript
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<Response> {
|
|
const headers: Record<string, string> = {};
|
|
|
|
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<T>(
|
|
schema: z.ZodType<T>,
|
|
method: string,
|
|
path: string,
|
|
body?: unknown,
|
|
): Promise<T> {
|
|
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<void> {
|
|
await this.fetch(method, path, body);
|
|
}
|
|
|
|
// --- Auth ---
|
|
|
|
async requestCode(data: RequestCodeRequest): Promise<void> {
|
|
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<void> {
|
|
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<string> {
|
|
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<void> {
|
|
await this.requestVoid("PATCH", "/humans/me/settings", data);
|
|
}
|
|
|
|
// --- Push notification tokens ---
|
|
|
|
async registerPushToken(data: {
|
|
token: string;
|
|
platform: "ios" | "android";
|
|
app_version: string;
|
|
}): Promise<void> {
|
|
await this.requestVoid("POST", "/humans/me/push-tokens", data);
|
|
}
|
|
|
|
async unregisterPushToken(token: string): Promise<void> {
|
|
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<void> {
|
|
await this.requestVoid("POST", `/networks/${networkId}/members`, data);
|
|
}
|
|
|
|
async removeMember(networkId: string, humanId: string): Promise<void> {
|
|
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<void> {
|
|
await this.requestVoid("POST", "/invitations/accept", data);
|
|
}
|
|
|
|
async revokeInvitation(
|
|
networkId: string,
|
|
data: RevokeInvitationRequest,
|
|
): Promise<void> {
|
|
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);
|