From c9c551ed4f4157dacb3b7b2f6764a4ef38e3074d Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Thu, 26 Mar 2026 14:43:55 -0700 Subject: [PATCH] Update frontend api --- frontend/spec/lib/api.md | 94 ++++++++++- frontend/src/lib/api.ts | 332 +++++++++++++++----------------------- frontend/src/lib/nostr.ts | 29 ++++ 3 files changed, 248 insertions(+), 207 deletions(-) diff --git a/frontend/spec/lib/api.md b/frontend/spec/lib/api.md index 8bad67c..14cf0f6 100644 --- a/frontend/spec/lib/api.md +++ b/frontend/spec/lib/api.md @@ -16,10 +16,96 @@ The api allows the frontend to access the database. Most endpoints are authentic - Uses `makeAuth` to obtain a NIP 98 authorization header. - Calls the backend api and returns the decoded json or throws an `ApiError`. -## `function get(path: string)` +## Plan methods -- Calls `callApi` with `get` +## `function listPlans()` -## `function post(path: string, body: T)` +- Calls `GET /plans` +- Returns a list of plans -- Calls `callApi` with `post` and body +## `function getPlan(id: string)` + +- Calls `GET /plans/:id` +- Returns a single plan + +## Tenant methods + +## `function listTenants()` + +- Calls `GET /tenants` +- Admin only +- Returns a list of tenants + +## `function getTenant(pubkey: string)` + +- Calls `GET /tenants/:pubkey` +- Admin or matching tenant +- Returns a single tenant + +## `function createTenant()` + +- Calls `POST /tenants` +- Requires authentication +- Creates tenant for the authorized pubkey + +## `function listTenantRelays(pubkey: string)` + +- Calls `GET /tenants/:pubkey/relays` +- Admin or matching tenant +- Returns relays for the tenant + +## `function listTenantInvoices(pubkey: string)` + +- Calls `GET /tenants/:pubkey/invoices` +- Admin or matching tenant +- Returns invoices for the tenant + +## `function updateTenantBilling(pubkey: string, billing: { nwc_url: string })` + +- Calls `PUT /tenants/:pubkey/billing` +- Admin or matching tenant +- Updates billing configuration + +## Relay methods + +## `function listRelays()` + +- Calls `GET /relays` +- Admin only +- Returns all relays + +## `function getRelay(id: string)` + +- Calls `GET /relays/:id` +- Admin or relay owner + +## `function createRelay(input: CreateRelayInput)` + +- Calls `POST /relays` +- Admin or matching tenant in payload +- Creates a relay + +## `function updateRelay(id: string, input: UpdateRelayInput)` + +- Calls `PUT /relays/:id` +- Admin or relay owner +- Updates a relay + +## `function deactivateRelay(id: string)` + +- Calls `POST /relays/:id/deactivate` +- Admin or relay owner + +## Invoice methods + +## `function listInvoices()` + +- Calls `GET /invoices` +- Admin only +- Returns all invoices + +## `function getInvoice(id: string)` + +- Calls `GET /invoices/:id` +- Admin or invoice owner +- Returns a single invoice diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5176aed..2a53d10 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,30 +1,22 @@ -import { accounts, API_URL } from "./nostr" -import { Relay as NostrRelay, RelayManagement } from "applesauce-relay" - -type NostrTag = string[] - -type UnsignedEvent = { - kind: number - content: string - created_at: number - tags: NostrTag[] -} - -type SignedEvent = UnsignedEvent & { - id: string - pubkey: string - sig: string -} - -type EventSigner = { - signEvent(event: UnsignedEvent): Promise -} +import { API_URL, getActivePubkey, getActiveSigner } from "./nostr" type ApiOk = { data: T code: string } +type BillingInput = { + nwc_url: string +} + +type AuthCache = { + pubkey: string + value: string + expiresAt: number +} + +let authCache: AuthCache | undefined + export class ApiError extends Error { status: number @@ -35,73 +27,7 @@ export class ApiError extends Error { } } -function getActiveSigner(): EventSigner { - const account = accounts.getActive() as { signer?: EventSigner } | undefined - if (!account?.signer) throw new Error("Not logged in") - return account.signer -} - -function getActivePubkey(): string { - const account = accounts.getActive() as { pubkey?: string } | undefined - if (!account?.pubkey) throw new Error("Not logged in") - return account.pubkey -} - -async function createNip98Header(url: string, method: string): Promise { - const signer = getActiveSigner() - const event = await signer.signEvent({ - kind: 27235, - content: "", - created_at: Math.floor(Date.now() / 1000), - tags: [ - ["u", url], - ["method", method.toUpperCase()], - ], - }) - - const encoded = btoa(JSON.stringify(event)) - return `Nostr ${encoded}` -} - -async function request(path: string, init?: RequestInit): Promise { - const method = (init?.method ?? "GET").toUpperCase() - const url = new URL(path, API_URL).toString() - const auth = await createNip98Header(url, method) - - const response = await fetch(url, { - ...init, - method, - headers: { - ...(init?.headers ?? {}), - Authorization: auth, - "Content-Type": "application/json", - }, - }) - - if (!response.ok) { - let message = `Request failed (${response.status})` - try { - const body = await response.json() as { error?: string } - if (body.error) message = body.error - } catch { - // ignored - } - throw new ApiError(message, response.status) - } - - if (response.status === 204) return undefined as T - const body = await response.json() as ApiOk - return body.data -} - -async function ensureTenant() { - try { - await request("/tenants", { method: "POST" }) - } catch (e) { - if (e instanceof ApiError && e.status === 422) return - throw e - } -} +export type Plan = Record export type Relay = { id: string @@ -145,12 +71,8 @@ export type Invoice = { period_end: number } -export type TenantDetail = { - tenant: Tenant - relays: Relay[] -} - export type CreateRelayInput = { + tenant?: string subdomain: string plan: string info_name?: string @@ -180,120 +102,124 @@ export type UpdateRelayInput = { push_enabled?: number } -export type AdminCheck = { - is_admin: boolean -} +export async function makeAuth(): Promise { + void (undefined as T | undefined) -export function listTenantRelays() { - return request("/relays") -} - -export async function getTenant() { const pubkey = getActivePubkey() - await ensureTenant() - return request(`/tenants/${pubkey}`) -} - -export async function createTenantRelay(input: CreateRelayInput) { - await ensureTenant() - return request("/relays", { - method: "POST", - body: JSON.stringify({ tenant: getActivePubkey(), ...input }), - }) -} - -export function getTenantRelay(id: string) { - return request(`/relays/${id}`) -} - -export function updateTenantRelay(id: string, input: UpdateRelayInput) { - return request(`/relays/${id}`, { - method: "PUT", - body: JSON.stringify(input), - }) -} - -export function updateTenantRelayPlan(id: string, plan: string) { - return updateTenantRelay(id, { plan }) -} - -export function deactivateTenantRelay(id: string) { - return request(`/relays/${id}/deactivate`, { - method: "POST", - }) -} - -export async function listTenantInvoices() { - await ensureTenant() - return request("/invoices") -} - -export function updateTenantBilling(nwc_url: string) { - return request<{ nwc_url: string }>(`/tenants/${getActivePubkey()}/billing`, { - method: "PUT", - body: JSON.stringify({ nwc_url }), - }) -} - -export function adminListTenants() { - return request("/tenants") -} - -export async function adminCheck() { - try { - await request("/tenants") - return { is_admin: true } - } catch (e) { - if (e instanceof ApiError && e.status === 403) return { is_admin: false } - throw e - } -} - -export async function adminGetTenant(pubkey: string) { - const [tenant, relays] = await Promise.all([ - request(`/tenants/${pubkey}`), - request(`/relays?tenant=${encodeURIComponent(pubkey)}`), - ]) - return { tenant, relays } -} - -export function adminUpdateTenantStatus(pubkey: string, status: string) { - void pubkey - void status - throw new Error("Tenant status updates are not supported by this API") -} - -export function adminListRelays() { - return request("/relays") -} - -export function adminGetRelay(id: string) { - return request(`/relays/${id}`) -} - -export function adminUpdateRelay(id: string, input: UpdateRelayInput) { - return request(`/relays/${id}`, { - method: "PUT", - body: JSON.stringify(input), - }) -} - -export function adminDeactivateRelay(id: string) { - return request(`/relays/${id}/deactivate`, { - method: "POST", - }) -} - -export async function getRelayMemberCount(relayUrl: string): Promise { const signer = getActiveSigner() - const management = new RelayManagement(new NostrRelay(relayUrl), signer) + if (!pubkey || !signer) return undefined - try { - const members = await management.listAllowedPubkeys() - return members.length - } catch { - // ignore and fall back to unknown member count + const now = Date.now() + if (authCache && authCache.pubkey === pubkey && authCache.expiresAt > now) { + return authCache.value } - return undefined + const event = await signer.signEvent({ + kind: 27235, + content: "", + created_at: Math.floor(now / 1000), + tags: [["u", API_URL]], + }) + + const value = `Nostr ${btoa(JSON.stringify(event))}` + authCache = { + 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 function listPlans() { + return callApi("GET", "/plans") +} + +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 createTenant() { + return callApi("POST", "/tenants") +} + +export function listTenantRelays(pubkey: string) { + return callApi("GET", `/tenants/${pubkey}/relays`) +} + +export function listTenantInvoices(pubkey: string) { + return callApi("GET", `/tenants/${pubkey}/invoices`) +} + +export function updateTenantBilling(pubkey: string, billing: BillingInput) { + return callApi("PUT", `/tenants/${pubkey}/billing`, billing) +} + +export function listRelays() { + return callApi("GET", "/relays") +} + +export function getRelay(id: string) { + return callApi("GET", `/relays/${id}`) +} + +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 listInvoices() { + return callApi("GET", "/invoices") +} + +export function getInvoice(id: string) { + return callApi("GET", `/invoices/${id}`) } diff --git a/frontend/src/lib/nostr.ts b/frontend/src/lib/nostr.ts index e431737..8313780 100644 --- a/frontend/src/lib/nostr.ts +++ b/frontend/src/lib/nostr.ts @@ -11,6 +11,25 @@ import { createEventLoaderForStore } from "applesauce-loaders/loaders" import { NostrConnectSigner } from "applesauce-signers" import { map, of } from "rxjs" +type NostrTag = string[] + +export type UnsignedEvent = { + kind: number + content: string + created_at: number + tags: NostrTag[] +} + +export type SignedEvent = UnsignedEvent & { + id: string + pubkey: string + sig: string +} + +export type EventSigner = { + signEvent(event: UnsignedEvent): Promise +} + export const API_URL = import.meta.env.VITE_API_URL export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME || "Caravel" @@ -70,6 +89,16 @@ export function useActiveAccount() { return account } +export function getActiveSigner(): EventSigner | undefined { + const account = accounts.getActive() as { signer?: EventSigner } | undefined + return account?.signer +} + +export function getActivePubkey(): string | undefined { + const account = accounts.getActive() as { pubkey?: string } | undefined + return account?.pubkey +} + export function useProfilePicture(pubkey: () => string | undefined) { const [picture, setPicture] = createSignal()