const API_URL = import.meta.env.VITE_API_URL // Populated by state.ts after it initializes, breaking the circular dependency let getAccount: () => unknown = () => undefined export function registerAccountGetter(fn: () => unknown) { getAccount = fn } type ApiOk = { data: T code: string } type UpdateTenantInput = { nwc_url?: string } type AuthCache = { pubkey: string value: string expiresAt: number } export class ApiError extends Error { status: number constructor(message: string, status: number) { super(message) this.name = "ApiError" this.status = status } } export type Plan = { id: string name: string amount: number stripe_price_id: string | null members: number | null blossom: boolean livekit: boolean } export type PlanId = string export type Relay = { id: string tenant: string schema: string subdomain: string plan: PlanId status: string sync_error: string stripe_subscription_item_id: string | null synced: number info_name: string info_icon: string info_description: string policy_public_join: number policy_strip_signatures: number groups_enabled: number management_enabled: number blossom_enabled: number livekit_enabled: number push_enabled: number } export type CreateRelayInput = { tenant?: string subdomain: string plan: string info_name?: string info_icon?: string info_description?: string policy_public_join?: number policy_strip_signatures?: number groups_enabled?: number management_enabled?: number blossom_enabled?: number livekit_enabled?: number push_enabled?: number } export type UpdateRelayInput = { subdomain?: string plan?: string info_name?: string info_icon?: string info_description?: string policy_public_join?: number policy_strip_signatures?: number groups_enabled?: number management_enabled?: number blossom_enabled?: number livekit_enabled?: number push_enabled?: number } export type Tenant = { pubkey: string nwc_url: string created_at: number stripe_customer_id: string stripe_subscription_id: string | null past_due_at: number | null nwc_error: string | null } export type Invoice = { id: string status: string amount_due: number currency: string hosted_invoice_url: string period_start: number period_end: number } export type Activity = { id: string tenant: string created_at: number activity_type: string resource_type: string resource_id: string } export type Identity = { pubkey: string is_admin: boolean } let authCache: AuthCache | undefined export async function makeAuth(): Promise { const current = getAccount() as { pubkey: string; signer: { signEvent: (e: unknown) => Promise<{ pubkey: string; sig: string; [k: string]: unknown }> } } | undefined if (!current) return undefined const now = Date.now() if (authCache && authCache.pubkey === current.pubkey && authCache.expiresAt > now) { return authCache.value } const event = await current.signer.signEvent({ kind: 27235, content: "", created_at: Math.floor(now / 1000), // Intentional session-style auth: sign the API base URL once, then reuse // the header briefly to avoid prompting the signer on every request. tags: [["u", API_URL]], }) const value = `Nostr ${btoa(JSON.stringify(event))}` authCache = { pubkey: current.pubkey, value, expiresAt: now + 10 * 60 * 1000, } return value } export async function callApi( method: string, path: string, body?: TRequest, ): Promise { const auth = await makeAuth() const url = new URL(path, API_URL).toString() const response = await fetch(url, { method, headers: { ...(auth ? { Authorization: auth } : {}), "Content-Type": "application/json", }, body: body === undefined ? undefined : JSON.stringify(body), }) if (!response.ok) { let message = `Request failed (${response.status})` try { const payload = await response.json() as { error?: string } if (payload.error) message = payload.error } catch { // ignore invalid/non-json body } throw new ApiError(message, response.status) } if (response.status === 204) return undefined as TResponse const payload = await response.json() as ApiOk return payload.data } export async function listPlans(): Promise { const url = new URL("/plans", API_URL).toString() const response = await fetch(url) if (!response.ok) throw new ApiError(`Failed to load plans (${response.status})`, response.status) const payload = await response.json() as { data: Plan[] } return payload.data } export function getIdentity() { return callApi("GET", "/identity") } export function createTenant() { return callApi("POST", "/tenants") } export function getPlan(id: string) { return callApi("GET", `/plans/${id}`) } export function listTenants() { return callApi("GET", "/tenants") } export function getTenant(pubkey: string) { return callApi("GET", `/tenants/${pubkey}`) } export function listTenantRelays(pubkey: string) { return callApi("GET", `/tenants/${pubkey}/relays`) } export function listTenantInvoices(pubkey: string) { return callApi("GET", `/tenants/${pubkey}/invoices`) } export function updateTenant(pubkey: string, input: UpdateTenantInput) { return callApi("PUT", `/tenants/${pubkey}`, input) } export function listRelays() { return callApi("GET", "/relays") } export function getRelay(id: string) { return callApi("GET", `/relays/${id}`) } export function listRelayMembers(id: string) { return callApi("GET", `/relays/${id}/members`) } export function listRelayActivity(id: string) { return callApi("GET", `/relays/${id}/activity`) } export function reactivateRelay(id: string) { return callApi("POST", `/relays/${id}/reactivate`) } export function createPortalSession(pubkey: string) { return callApi("GET", `/tenants/${pubkey}/stripe/session`) } export function getInvoiceBolt11(invoiceId: string) { return callApi("GET", `/invoices/${invoiceId}/bolt11`) } export function createRelay(input: CreateRelayInput) { return callApi("POST", "/relays", input) } export function updateRelay(id: string, input: UpdateRelayInput) { return callApi("PUT", `/relays/${id}`, input) } export function deactivateRelay(id: string) { return callApi("POST", `/relays/${id}/deactivate`) } export function getInvoice(id: string) { return callApi("GET", `/invoices/${id}`) }