Update frontend api

This commit is contained in:
Jon Staab
2026-03-26 14:43:55 -07:00
parent 2ba5d519e1
commit c9c551ed4f
3 changed files with 248 additions and 207 deletions
+90 -4
View File
@@ -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<T>(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
+129 -203
View File
@@ -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<SignedEvent>
}
import { API_URL, getActivePubkey, getActiveSigner } from "./nostr"
type ApiOk<T> = {
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<string> {
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<T>(path: string, init?: RequestInit): Promise<T> {
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<T>
return body.data
}
async function ensureTenant() {
try {
await request<Tenant>("/tenants", { method: "POST" })
} catch (e) {
if (e instanceof ApiError && e.status === 422) return
throw e
}
}
export type Plan = Record<string, unknown>
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<T = unknown>(): Promise<string | undefined> {
void (undefined as T | undefined)
export function listTenantRelays() {
return request<Relay[]>("/relays")
}
export async function getTenant() {
const pubkey = getActivePubkey()
await ensureTenant()
return request<Tenant>(`/tenants/${pubkey}`)
}
export async function createTenantRelay(input: CreateRelayInput) {
await ensureTenant()
return request<Relay>("/relays", {
method: "POST",
body: JSON.stringify({ tenant: getActivePubkey(), ...input }),
})
}
export function getTenantRelay(id: string) {
return request<Relay>(`/relays/${id}`)
}
export function updateTenantRelay(id: string, input: UpdateRelayInput) {
return request<Relay>(`/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<void>(`/relays/${id}/deactivate`, {
method: "POST",
})
}
export async function listTenantInvoices() {
await ensureTenant()
return request<Invoice[]>("/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<Tenant[]>("/tenants")
}
export async function adminCheck() {
try {
await request<Tenant[]>("/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<Tenant>(`/tenants/${pubkey}`),
request<Relay[]>(`/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<Relay[]>("/relays")
}
export function adminGetRelay(id: string) {
return request<Relay>(`/relays/${id}`)
}
export function adminUpdateRelay(id: string, input: UpdateRelayInput) {
return request<Relay>(`/relays/${id}`, {
method: "PUT",
body: JSON.stringify(input),
})
}
export function adminDeactivateRelay(id: string) {
return request<void>(`/relays/${id}/deactivate`, {
method: "POST",
})
}
export async function getRelayMemberCount(relayUrl: string): Promise<number | undefined> {
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<TRequest = unknown, TResponse = unknown>(
method: string,
path: string,
body?: TRequest,
): Promise<TResponse> {
const auth = await makeAuth<TRequest>()
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<TResponse>
return payload.data
}
export function listPlans() {
return callApi<undefined, Plan[]>("GET", "/plans")
}
export function getPlan(id: string) {
return callApi<undefined, Plan>("GET", `/plans/${id}`)
}
export function listTenants() {
return callApi<undefined, Tenant[]>("GET", "/tenants")
}
export function getTenant(pubkey: string) {
return callApi<undefined, Tenant>("GET", `/tenants/${pubkey}`)
}
export function createTenant() {
return callApi<undefined, Tenant>("POST", "/tenants")
}
export function listTenantRelays(pubkey: string) {
return callApi<undefined, Relay[]>("GET", `/tenants/${pubkey}/relays`)
}
export function listTenantInvoices(pubkey: string) {
return callApi<undefined, Invoice[]>("GET", `/tenants/${pubkey}/invoices`)
}
export function updateTenantBilling(pubkey: string, billing: BillingInput) {
return callApi<BillingInput, BillingInput>("PUT", `/tenants/${pubkey}/billing`, billing)
}
export function listRelays() {
return callApi<undefined, Relay[]>("GET", "/relays")
}
export function getRelay(id: string) {
return callApi<undefined, Relay>("GET", `/relays/${id}`)
}
export function createRelay(input: CreateRelayInput) {
return callApi<CreateRelayInput, Relay>("POST", "/relays", input)
}
export function updateRelay(id: string, input: UpdateRelayInput) {
return callApi<UpdateRelayInput, Relay>("PUT", `/relays/${id}`, input)
}
export function deactivateRelay(id: string) {
return callApi<undefined, void>("POST", `/relays/${id}/deactivate`)
}
export function listInvoices() {
return callApi<undefined, Invoice[]>("GET", "/invoices")
}
export function getInvoice(id: string) {
return callApi<undefined, Invoice>("GET", `/invoices/${id}`)
}
+29
View File
@@ -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<SignedEvent>
}
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<string | undefined>()