forked from coracle/caravel
Update frontend api
This commit is contained in:
@@ -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
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user