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.
|
- Uses `makeAuth` to obtain a NIP 98 authorization header.
|
||||||
- Calls the backend api and returns the decoded json or throws an `ApiError`.
|
- 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 { API_URL, getActivePubkey, getActiveSigner } 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>
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApiOk<T> = {
|
type ApiOk<T> = {
|
||||||
data: T
|
data: T
|
||||||
code: string
|
code: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BillingInput = {
|
||||||
|
nwc_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthCache = {
|
||||||
|
pubkey: string
|
||||||
|
value: string
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
let authCache: AuthCache | undefined
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number
|
status: number
|
||||||
|
|
||||||
@@ -35,73 +27,7 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActiveSigner(): EventSigner {
|
export type Plan = Record<string, unknown>
|
||||||
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 Relay = {
|
export type Relay = {
|
||||||
id: string
|
id: string
|
||||||
@@ -145,12 +71,8 @@ export type Invoice = {
|
|||||||
period_end: number
|
period_end: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TenantDetail = {
|
|
||||||
tenant: Tenant
|
|
||||||
relays: Relay[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CreateRelayInput = {
|
export type CreateRelayInput = {
|
||||||
|
tenant?: string
|
||||||
subdomain: string
|
subdomain: string
|
||||||
plan: string
|
plan: string
|
||||||
info_name?: string
|
info_name?: string
|
||||||
@@ -180,120 +102,124 @@ export type UpdateRelayInput = {
|
|||||||
push_enabled?: number
|
push_enabled?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AdminCheck = {
|
export async function makeAuth<T = unknown>(): Promise<string | undefined> {
|
||||||
is_admin: boolean
|
void (undefined as T | undefined)
|
||||||
}
|
|
||||||
|
|
||||||
export function listTenantRelays() {
|
|
||||||
return request<Relay[]>("/relays")
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTenant() {
|
|
||||||
const pubkey = getActivePubkey()
|
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 signer = getActiveSigner()
|
||||||
const management = new RelayManagement(new NostrRelay(relayUrl), signer)
|
if (!pubkey || !signer) return undefined
|
||||||
|
|
||||||
try {
|
const now = Date.now()
|
||||||
const members = await management.listAllowedPubkeys()
|
if (authCache && authCache.pubkey === pubkey && authCache.expiresAt > now) {
|
||||||
return members.length
|
return authCache.value
|
||||||
} catch {
|
|
||||||
// ignore and fall back to unknown member count
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 { NostrConnectSigner } from "applesauce-signers"
|
||||||
import { map, of } from "rxjs"
|
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 API_URL = import.meta.env.VITE_API_URL
|
||||||
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME || "Caravel"
|
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME || "Caravel"
|
||||||
|
|
||||||
@@ -70,6 +89,16 @@ export function useActiveAccount() {
|
|||||||
return account
|
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) {
|
export function useProfilePicture(pubkey: () => string | undefined) {
|
||||||
const [picture, setPicture] = createSignal<string | undefined>()
|
const [picture, setPicture] = createSignal<string | undefined>()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user