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 {pubkey, signer} from "@welshman/app" 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 Relay = { 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}> const getAuthHeader = (): Maybe> => { const $pubkey = pubkey.get() const $signer = signer.get() if (!$pubkey || !$signer) return undefined 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: $pubkey, expiresAt: Date.now() + AUTH_TTL, header} } return cachedAuth.header } type HostingResponse = {data: T; error?: string} export const hostingFetch = async (method: string, path: string, body?: unknown): Promise => { const auth = await getAuthHeader() const headers: Record = {Accept: "application/json"} if (auth) headers["Authorization"] = auth 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> = 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("GET", "/plans") export const createTenant = (returnUrl: string) => hostingFetch("POST", "/tenants", {return_url: returnUrl}) export const getTenant = (pubkey: string) => hostingFetch("GET", `/tenants/${pubkey}`) export const updateTenant = (pubkey: string, input: UpdateTenantInput) => hostingFetch("PUT", `/tenants/${pubkey}`, input) export const listTenantRelays = (pubkey: string) => hostingFetch("GET", `/tenants/${pubkey}/relays`) export const listTenantInvoices = (pubkey: string) => hostingFetch("GET", `/tenants/${pubkey}/invoices`) export const reconcileTenant = (pubkey: string) => hostingFetch("POST", `/tenants/${pubkey}/reconcile`) // The backend answers with null when nothing has accrued this period. export const getDraftInvoice = async (pubkey: string): Promise> => (await hostingFetch("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("POST", "/relays", input) export const getRelay = (id: string) => hostingFetch("GET", `/relays/${id}`) export const updateRelay = (id: string, input: UpdateRelayInput) => hostingFetch("PUT", `/relays/${id}`, input) export const deactivateRelay = (id: string) => hostingFetch("POST", `/relays/${id}/deactivate`) export const reactivateRelay = (id: string) => hostingFetch("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("GET", `/invoices/${id}`) export const listInvoiceItems = (invoiceId: string) => hostingFetch("GET", `/invoices/${invoiceId}/items`) export const ensureInvoiceBolt11 = (invoiceId: string) => hostingFetch("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("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, ): 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: Relay): 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: Relay): string => `${relay.subdomain}.${relay.zooid_domain}` export const getHostedRelayUrl = (relay: Relay): 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}> export const ensureSessionTenant = async () => { const $pubkey = pubkey.get() if (!$pubkey) return 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([], set => { listPlans().then(set, () => set([])) }) export const deriveRelayMembers = (id: string) => readable([], 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>) => derived>, 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} export const deriveHostedRelay = (url: string) => derived( pubkey, ($pubkey, set) => { if (!$pubkey) return set({loading: false, relay: undefined}) set({loading: true, relay: undefined}) listTenantRelays($pubkey).then( relays => set({loading: false, relay: relays.find(r => getHostedRelayUrl(r) === url)}), () => set({loading: false, relay: undefined}), ) }, {loading: true, relay: undefined}, )