flotilla/src/app/hosting.ts
2026-07-29 15:52:13 -07:00

412 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {derived, readable} from "svelte/store"
import type {Readable} from "svelte/store"
import {Capacitor} from "@capacitor/core"
import type {Maybe} from "@welshman/lib"
import {sortBy, int, MINUTE, ms} from "@welshman/lib"
import {makeHttpAuth, makeHttpAuthHeader, normalizeRelayUrl} from "@welshman/util"
import {user} from "@app/core"
import {HOSTING_BACKEND_URL, PLATFORM_URL} from "@app/env"
// Apple doesn't allow selling hosting outside their payment system, so on iOS we
// point people at the platform's website instead.
export const HOSTING_ENABLED = Capacitor.getPlatform() !== "ios"
export type Plan = {
id: string
name: string
amount: number
hidden: boolean
members: number | null
blossom: boolean
livekit: boolean
}
export type HostedRelay = {
id: string
tenant_pubkey: string
subdomain: string
zooid_domain: string
plan_id: string
status: string
sync_error: string
synced: number
custom_domain: string
custom_domain_verified: number
info_name: string
info_icon: string
info_description: string
policy_public_read: number
policy_public_write: number
policy_public_join: number
policy_strip_signatures: number
groups_enabled: number
management_enabled: number
blossom_enabled: number
livekit_enabled: number
push_enabled: number
}
// The relay's boolean settings, which the backend models as 0/1 columns.
export type RelayFlag =
| "policy_public_read"
| "policy_public_write"
| "policy_public_join"
| "policy_strip_signatures"
| "groups_enabled"
| "management_enabled"
| "blossom_enabled"
| "livekit_enabled"
| "push_enabled"
export type CreateRelayInput = {
tenant_pubkey?: string
subdomain: string
zooid_domain: string
plan_id: string
info_name?: string
info_icon?: string
info_description?: string
policy_public_read?: number
policy_public_write?: number
policy_public_join?: number
policy_strip_signatures?: number
groups_enabled?: number
management_enabled?: number
blossom_enabled?: number
livekit_enabled?: number
push_enabled?: number
custom_domain?: string
}
export type UpdateRelayInput = {
subdomain?: string
zooid_domain?: string
plan_id?: string
info_name?: string
info_icon?: string
info_description?: string
policy_public_read?: number
policy_public_write?: number
policy_public_join?: number
policy_strip_signatures?: number
groups_enabled?: number
management_enabled?: number
blossom_enabled?: number
livekit_enabled?: number
push_enabled?: number
custom_domain?: string
}
export type Tenant = {
pubkey: string
return_url: string
nwc_is_set: boolean
created_at: number
billing_anchor: number | null
stripe_customer_id: string
stripe_payment_method_id: string | null
nwc_error: string | null
stripe_error: string | null
churned_at: number | null
}
export type UpdateTenantInput = {
nwc_url?: string
}
export type InvoiceMethod = "nwc" | "stripe" | "oob"
export type Invoice = {
id: string
tenant_pubkey: string
amount: number
period_start: number
period_end: number
created_at: number
paid_at: number | null
voided_at: number | null
method: InvoiceMethod | null
}
export type InvoiceItem = {
id: string
invoice_id: string | null
activity_id: string | null
tenant_pubkey: string
relay_id: string
plan_id: string
amount: number
description: string
created_at: number
voided_at: number | null
}
export type Bolt11 = {
id: string
invoice_id: string
lnbc: string
msats: number
created_at: number
expires_at: number
settled_at: number | null
}
export type Activity = {
id: string
tenant_pubkey: string
created_at: number
activity_type: string
resource_type: string
resource_id: string
}
export class HostingError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.name = "HostingError"
this.status = status
}
}
// Requests
const AUTH_TTL = ms(int(10, MINUTE))
// The backend checks the signature and `u` tag but not freshness, so one token
// per pubkey is reusable. Cache the promise rather than the value so a burst of
// concurrent requests shares a single signature, and key it on the pubkey so
// switching accounts doesn't keep signing as the old one.
let cachedAuth: Maybe<{pubkey: string; expiresAt: number; header: Promise<string>}>
const getAuthHeader = (): Promise<string> => {
const {pubkey, signer} = user.get()
if (cachedAuth?.pubkey !== pubkey || cachedAuth.expiresAt < Date.now()) {
const header = makeHttpAuth(HOSTING_BACKEND_URL, "GET")
.then(template => signer.sign(template))
.then(makeHttpAuthHeader)
// A declined signature shouldn't be cached, or the user can't retry.
header.catch(() => {
if (cachedAuth?.header === header) cachedAuth = undefined
})
cachedAuth = {pubkey, expiresAt: Date.now() + AUTH_TTL, header}
}
return cachedAuth.header
}
type HostingResponse<T> = {data: T; error?: string}
export const hostingFetch = async <T>(method: string, path: string, body?: unknown): Promise<T> => {
const headers: Record<string, string> = {
Accept: "application/json",
Authorization: await getAuthHeader(),
}
if (body !== undefined) headers["Content-Type"] = "application/json"
const response = await fetch(new URL(path, HOSTING_BACKEND_URL).toString(), {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
})
const payload: Maybe<HostingResponse<T>> = await response.json().catch(() => undefined)
if (!response.ok || !payload) {
throw new HostingError(payload?.error || `Request failed (${response.status})`, response.status)
}
return payload.data
}
export const listPlans = () => hostingFetch<Plan[]>("GET", "/plans")
export const createTenant = (returnUrl: string) =>
hostingFetch<Tenant>("POST", "/tenants", {return_url: returnUrl})
export const getTenant = (pubkey: string) => hostingFetch<Tenant>("GET", `/tenants/${pubkey}`)
export const updateTenant = (pubkey: string, input: UpdateTenantInput) =>
hostingFetch<Tenant>("PUT", `/tenants/${pubkey}`, input)
export const listTenantRelays = (pubkey: string) =>
hostingFetch<HostedRelay[]>("GET", `/tenants/${pubkey}/relays`)
export const listTenantInvoices = (pubkey: string) =>
hostingFetch<Invoice[]>("GET", `/tenants/${pubkey}/invoices`)
export const reconcileTenant = (pubkey: string) =>
hostingFetch<Tenant>("POST", `/tenants/${pubkey}/reconcile`)
// The backend answers with null when nothing has accrued this period.
export const getDraftInvoice = async (pubkey: string): Promise<Maybe<Invoice>> =>
(await hostingFetch<Invoice | null>("GET", `/tenants/${pubkey}/invoices/draft`)) ?? undefined
export const createPortalSession = (pubkey: string, returnUrl: string = hostingReturnUrl()) =>
hostingFetch<{url: string}>(
"GET",
`/tenants/${pubkey}/stripe/session?return_url=${encodeURIComponent(returnUrl)}`,
)
export const createRelay = (input: CreateRelayInput) =>
hostingFetch<HostedRelay>("POST", "/relays", input)
export const getRelay = (id: string) => hostingFetch<HostedRelay>("GET", `/relays/${id}`)
export const updateRelay = (id: string, input: UpdateRelayInput) =>
hostingFetch<HostedRelay>("PUT", `/relays/${id}`, input)
export const deactivateRelay = (id: string) =>
hostingFetch<void>("POST", `/relays/${id}/deactivate`)
export const reactivateRelay = (id: string) =>
hostingFetch<void>("POST", `/relays/${id}/reactivate`)
export const listRelayMembers = (id: string) =>
hostingFetch<{members: string[]}>("GET", `/relays/${id}/members`)
export const listRelayActivity = (id: string) =>
hostingFetch<{activity: Activity[]}>("GET", `/relays/${id}/activity`)
export const getInvoice = (id: string) => hostingFetch<Invoice>("GET", `/invoices/${id}`)
export const listInvoiceItems = (invoiceId: string) =>
hostingFetch<InvoiceItem[]>("GET", `/invoices/${invoiceId}/items`)
export const ensureInvoiceBolt11 = (invoiceId: string) =>
hostingFetch<Bolt11>("POST", `/invoices/${invoiceId}/bolt11`)
export const createInvoiceCheckout = (invoiceId: string) => {
const returnUrl = hostingReturnUrl()
const queryString = `?return_url=${encodeURIComponent(returnUrl)}`
return hostingFetch<{url: string}>("POST", `/invoices/${invoiceId}/checkout${queryString}`)
}
export const reconcileInvoice = (invoiceId: string) =>
hostingFetch<Invoice>("POST", `/invoices/${invoiceId}/reconcile`)
// API utils
export const flagToBool = (value: number | undefined, fallback: boolean): boolean => {
if (value === 0) return false
if (value === 1) return true
return fallback
}
export const boolToFlag = (value: boolean): 0 | 1 => (value ? 1 : 0)
export const autopayConfigured = (
t: Pick<Tenant, "nwc_is_set" | "stripe_payment_method_id">,
): boolean => t.nwc_is_set || Boolean(t.stripe_payment_method_id)
// The oldest open positive invoice, matching the backend's dunning order. The
// backend models the lifecycle as timestamps, not a status field.
export const selectPayableInvoice = (invoices: Invoice[]): Invoice | undefined =>
sortBy(invoice => invoice.created_at, invoices).find(
invoice => !invoice.paid_at && !invoice.voided_at && invoice.amount > 0,
)
export const formatUsd = (cents: number) => `$${(cents / 100).toFixed(2)}`
export const formatPeriod = (startSecs?: number, endSecs?: number) =>
!startSecs || !endSecs
? ""
: `${new Date(ms(startSecs)).toLocaleDateString()} – ${new Date(ms(endSecs)).toLocaleDateString()}`
// A verified custom domain when present, otherwise the platform subdomain.
export const relayHost = (relay: HostedRelay): string =>
relay.custom_domain_verified && relay.custom_domain
? relay.custom_domain
: canonicalRelayHost(relay)
// The host a custom domain must CNAME to.
export const canonicalRelayHost = (relay: HostedRelay): string =>
`${relay.subdomain}.${relay.zooid_domain}`
export const getHostedRelayUrl = (relay: HostedRelay): string =>
normalizeRelayUrl("wss://" + relayHost(relay))
// On native, redirects can't round-trip through location.origin; use the
// platform's hosted url.
export const hostingReturnUrl = (path = "/settings/hosting"): string =>
Capacitor.isNativePlatform() ? PLATFORM_URL + path : location.origin + path
// Provisioning is idempotent, so this runs once per login. The shared promise
// dedupes concurrent callers, and a failure drops the cache so the next call
// retries.
let tenantPromise: Maybe<{pubkey: string; promise: Promise<Tenant>}>
export const ensureSessionTenant = async () => {
const $pubkey = user.get().pubkey
if (tenantPromise?.pubkey !== $pubkey) {
const promise = createTenant(hostingReturnUrl())
promise.catch(() => {
if (tenantPromise?.promise === promise) tenantPromise = undefined
})
tenantPromise = {pubkey: $pubkey, promise}
}
await tenantPromise.promise
}
// Stores
export const derivePlans = () =>
readable<Plan[]>([], set => {
listPlans().then(set, () => set([]))
})
export const deriveRelayMembers = (id: string) =>
readable<string[]>([], set => {
listRelayMembers(id).then(
({members}) => set(members),
() => set([]),
)
})
export type RelayActivityState = {loading: boolean; activity: Activity[]}
// Takes a store because the id comes from the hosted relay lookup, which hasn't
// resolved when the page mounts.
export const deriveRelayActivity = (id: Readable<Maybe<string>>) =>
derived<Readable<Maybe<string>>, RelayActivityState>(
id,
($id, set) => {
if (!$id) return set({loading: false, activity: []})
set({loading: true, activity: []})
listRelayActivity($id).then(
({activity}) => set({loading: false, activity}),
() => set({loading: false, activity: []}),
)
},
{loading: false, activity: []},
)
// Loading is distinct from "not hosted here" — both leave `relay` undefined, but
// only one of them should keep the caller waiting.
export type HostedRelayState = {loading: boolean; relay: Maybe<HostedRelay>}
export const deriveHostedRelay = (url: string) =>
derived<typeof user, HostedRelayState>(
user,
($user, set) => {
set({loading: true, relay: undefined})
listTenantRelays($user.pubkey).then(
relays => set({loading: false, relay: relays.find(r => getHostedRelayUrl(r) === url)}),
() => set({loading: false, relay: undefined}),
)
},
{loading: true, relay: undefined},
)