Share hosting plans, relays and billing through a per-app Hosting plugin and route hosting errors through one helper

This commit is contained in:
Jon Staab 2026-09-25 21:31:17 -07:00
parent 8a8ca65d5d
commit 8f9ff3a366
14 changed files with 401 additions and 520 deletions

View file

@ -121,6 +121,7 @@ they cache is itself a rebinding store. `commandsByUrl` holds `fromApp` stores.
| `SpaceManagement` (`management.ts`) | none | NIP-86 supported methods (5-minute TTL), admins and bans per URL |
| `Chats` (`chats.ts`) | none | DM conversations by chat id (`index`, `one`), and a fuzzy `search` |
| `LatestEvents` (`repository.ts`) | none | the most recent event held from an author (`forPubkey`) |
| `Hosting` (`hosting.ts`) | none | the hosting backend's plans, the tenant's relays and billing, shared by every hosting view |
Each is exposed with `usePlugin`. `Statuses` is the minimal shape:

View file

@ -414,8 +414,8 @@ return a `Command` as well: `$deletes.deleteEvent(event, w => w.setProtected(pro
`.publish()` in `RoomForm`. NIP-86 calls resolve to `{error}` instead of a thunk:
`$relayManagement.forUrl(url).createRole(...)` in `RoleCreate`.
The hosting backend isn't nostr. Toast a readable message, and `console.error` anything that
isn't a `HostingError`, as `HomeHosting` and `hosting/CustomDomainModal` do. Publishes are
The hosting backend isn't nostr. Toast `hostingErrorMessage(e, fallback)` from `hosting.ts`,
which returns a `HostingError`'s own message and logs anything else before falling back. Publishes are
optimistic, so rows show progress in place: `ContentActions`, `CommentActions` and `ArticleActionBar`
wrap their contents in `ThunkStatusOrDeleted`, and chat pushes a `ThunkToast`.

View file

@ -1,5 +1,4 @@
<script lang="ts">
import {onMount} from "svelte"
import Add from "@assets/icons/add.svg?dataurl"
import Server from "@assets/icons/server.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
@ -9,30 +8,17 @@
import HomeSection from "@app/components/HomeSection.svelte"
import RelayListItem from "@app/components/hosting/RelayListItem.svelte"
import RelayCreate from "@app/components/hosting/RelayCreate.svelte"
import {user} from "@app/core"
import {pushModal} from "@app/modal"
import {HostingError, listTenantRelays, type HostedRelay} from "@app/hosting"
import {hosting} from "@app/hosting"
import {PLATFORM_NAME} from "@app/env"
const {class: className = ""}: {class?: string} = $props()
let relays = $state<HostedRelay[]>([])
let loading = $state(true)
const tenantRelays = $hosting.relays
const relays = $derived($tenantRelays.relays)
const openCreate = () => pushModal(RelayCreate)
// A pubkey with no tenant answers with a HostingError.
onMount(async () => {
try {
relays = await listTenantRelays($user.pubkey)
} catch (e) {
if (!(e instanceof HostingError)) {
console.error(e)
}
} finally {
loading = false
}
})
</script>
<HomeSection title="Hosting" icon={Server} class={className}>
@ -41,7 +27,7 @@
<Link href="/settings/hosting" class="button button-neutral button-xs">Manage</Link>
{/if}
{/snippet}
{#if loading}
{#if $tenantRelays.loading}
<div class="flex justify-center px-4 pb-8">
<Spinner>Checking your hosted spaces…</Spinner>
</div>

View file

@ -8,40 +8,31 @@
import Close from "@assets/icons/close.svg?dataurl"
import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte"
import {errorMessage} from "@lib/util"
import PaymentDialog from "@app/components/hosting/PaymentDialog.svelte"
import PaymentSetup from "@app/components/hosting/PaymentSetup.svelte"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
import {autopayConfigured, getInvoice, type Invoice, type Tenant} from "@app/hosting"
import {
autopayConfigured,
getInvoice,
hostingErrorMessage,
type Invoice,
type Tenant,
} from "@app/hosting"
type Props = {
tenant: Tenant | undefined
openInvoice: Invoice | undefined
hasPaidSubscription: boolean
suppressInline?: boolean
onRefresh?: () => void
}
const {
tenant,
openInvoice,
hasPaidSubscription,
suppressInline = false,
onRefresh,
}: Props = $props()
const {tenant, openInvoice, hasPaidSubscription}: Props = $props()
let dismissedAutopay = $state(false)
const openPayInvoice = (invoice: Invoice) =>
pushModal(PaymentDialog, {invoice, onPaid: () => onRefresh?.()})
const openPayInvoice = (invoice: Invoice) => pushModal(PaymentDialog, {invoice})
const openSetup = (initialTab: "nwc" | "card") =>
pushModal(PaymentSetup, {
initialTab,
nwcConnected: Boolean(tenant?.nwc_is_set),
onSaved: () => onRefresh?.(),
})
const openSetup = (initialTab: "nwc" | "card") => pushModal(PaymentSetup, {initialTab})
const setUpNwc = () => openSetup("nwc")
@ -69,7 +60,9 @@
getInvoice(id)
.then(openPayInvoice)
.catch(e => pushToast({theme: "error", message: errorMessage(e) || "Failed to load invoice"}))
.catch(e =>
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to load invoice")}),
)
})
</script>
@ -136,7 +129,7 @@
"Your account is past due and some relays are paused. Pay your balance or update your payment method to restore service.",
churnedActions,
)}
{:else if openInvoice && !suppressInline}
{:else if openInvoice}
{@render card(
"card-secondary",
DangerCircle,
@ -157,7 +150,7 @@
"Your card couldn't be charged. Update your payment method.",
updateMethodActions,
)}
{:else if hasPaidSubscription && !autopayConfigured(tenant) && !suppressInline && !dismissedAutopay}
{:else if hasPaidSubscription && !autopayConfigured(tenant) && !dismissedAutopay}
{@render card(
"card-primary",
InfoCircle,

View file

@ -20,17 +20,17 @@
import {
canonicalRelayHost,
flagToBool,
followHostedRelay,
hostingErrorMessage,
updateRelay,
HostingError,
type HostedRelay,
} from "@app/hosting"
type Props = {
relay: HostedRelay
onUpdate?: (relay: HostedRelay) => void
}
const {relay, onUpdate}: Props = $props()
const {relay}: Props = $props()
const back = () => history.back()
@ -43,21 +43,15 @@
loading = true
try {
const updated = await updateRelay(current.id, {custom_domain: value})
const updated = await updateRelay(relay.id, {custom_domain: value})
current = updated
domain = updated.custom_domain ?? ""
onUpdate?.(updated)
pushToast({message: value ? "Custom domain saved." : "Custom domain removed."})
back()
// Removing a verified domain moves the relay back to its subdomain.
await followHostedRelay(relay, updated)
} catch (e) {
const isHostingError = e instanceof HostingError
pushToast({theme: "error", message: isHostingError ? e.message : "Failed to save domain."})
if (!isHostingError) {
console.error(e)
}
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to save domain.")})
} finally {
loading = false
}
@ -66,8 +60,6 @@
const onSubmit = () => submit(domain.trim())
// Local copy so the verified state updates in place after save/verify.
let current = $state(relay)
let domain = $state(relay.custom_domain ?? "")
let loading = $state(false)
</script>
@ -78,7 +70,7 @@
<ModalTitle>Custom domain</ModalTitle>
<ModalSubtitle>
Point a domain you control at this relay instead of the default
<span class="text-primary">{canonicalRelayHost(current)}</span> address.
<span class="text-primary">{canonicalRelayHost(relay)}</span> address.
</ModalSubtitle>
</ModalHeader>
@ -88,7 +80,7 @@
<p>Domain</p>
{/snippet}
{#snippet secondary()}
{#if current.custom_domain && flagToBool(current.custom_domain_verified, false)}
{#if relay.custom_domain && flagToBool(relay.custom_domain_verified, false)}
<Badge variant="primary">
<Icon icon={VerifiedCheck} size={3} />
Verified

View file

@ -19,7 +19,7 @@
import {
exportRelayData,
importRelayData,
HostingError,
hostingErrorMessage,
type HostedRelay,
type ImportResult,
} from "@app/hosting"
@ -47,10 +47,7 @@
try {
await downloadText(`${relay.subdomain}.jsonl`, await exportRelayData(relay.id))
} catch (e) {
pushToast({
theme: "error",
message: e instanceof HostingError ? e.message : "Failed to export relay data.",
})
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to export relay data.")})
} finally {
exporting = false
}
@ -63,10 +60,7 @@
try {
result = await importRelayData(relay.id, file)
} catch (e) {
pushToast({
theme: "error",
message: e instanceof HostingError ? e.message : "Failed to import relay data.",
})
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to import relay data.")})
} finally {
importing = false
}

View file

@ -16,13 +16,15 @@
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {errorMessage} from "@lib/util"
import QRCode from "@app/components/QRCode.svelte"
import PaymentSetup from "@app/components/hosting/PaymentSetup.svelte"
import InvoiceItemsList from "@app/components/hosting/InvoiceItemsList.svelte"
import {clip, pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
import {
hosting,
hostingErrorMessage,
logUnexpectedError,
ensureInvoiceBolt11,
reconcileInvoice,
createInvoiceCheckout,
@ -35,10 +37,9 @@
type Props = {
invoice: Invoice
onPaid?: () => void
}
const {invoice, onPaid}: Props = $props()
const {invoice}: Props = $props()
type Bolt11Status = "idle" | "loading" | "ready" | "error"
@ -83,7 +84,7 @@
paid = true
stopPolling()
onPaid?.()
void $hosting.refresh()
}
const loadBolt11 = async () => {
@ -98,15 +99,16 @@
bolt11Status = "ready"
} catch (e) {
bolt11Status = "error"
bolt11Error = errorMessage(e) || "Failed to generate Lightning invoice"
bolt11Error = hostingErrorMessage(e, "Failed to generate Lightning invoice")
}
}
const loadItems = async () => {
try {
items = await listInvoiceItems(invoice.id)
} catch {
// line items are cosmetic; don't block payment
} catch (e) {
// Line items are cosmetic, so a failure doesn't block payment.
logUnexpectedError(e)
}
}
@ -152,19 +154,15 @@
pushToast({message: "Payment not yet confirmed. Please try again after sending."})
}
} catch (e) {
pushToast({theme: "error", message: errorMessage(e) || "Failed to check payment status"})
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to check payment status")})
} finally {
checking = false
}
}
// Nested so this dialog stays mounted, with onSaved collecting and the poll as the backstop.
// Nested so this dialog stays mounted, and its poll collects once the wallet is saved.
const connectWallet = () => {
pushModal(
PaymentSetup,
{initialTab: "nwc", onSaved: () => void reconcile().catch(() => {})},
{nested: true},
)
pushModal(PaymentSetup, {initialTab: "nwc"}, {nested: true})
}
const payWithCard = async () => {
@ -179,7 +177,7 @@
window.location.href = url
} catch (e) {
pushToast({theme: "error", message: errorMessage(e) || "Failed to open checkout"})
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to open checkout")})
} finally {
redirecting = false
}

View file

@ -19,17 +19,19 @@
import {user} from "@app/core"
import {pushToast} from "@app/toast"
import {getNwcClient, wallet} from "@app/lightning"
import {updateTenant, createPortalSession} from "@app/hosting"
import {hosting, hostingErrorMessage, updateTenant, createPortalSession} from "@app/hosting"
type Tab = "nwc" | "card"
type Props = {
onSaved?: () => void
initialTab?: Tab
nwcConnected?: boolean
}
const {onSaved, initialTab, nwcConnected = false}: Props = $props()
const {initialTab}: Props = $props()
const billing = $hosting.billing
const nwcConnected = $derived(Boolean($billing.tenant?.nwc_is_set))
const back = () => history.back()
@ -63,11 +65,11 @@
try {
await updateTenant($user.pubkey, {nwc_url: url})
saved = true
onSaved?.()
void $hosting.refresh()
} catch (e) {
pushToast({
theme: "error",
message: e instanceof Error ? e.message : "Failed to save wallet connection",
message: hostingErrorMessage(e, "Failed to save wallet connection"),
})
} finally {
saving = false
@ -87,14 +89,11 @@
try {
await updateTenant($user.pubkey, {nwc_url: ""})
onSaved?.()
void $hosting.refresh()
pushToast({message: "Lightning wallet disconnected."})
back()
} catch (e) {
pushToast({
theme: "error",
message: e instanceof Error ? e.message : "Failed to disconnect wallet",
})
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to disconnect wallet")})
} finally {
saving = false
}
@ -108,10 +107,7 @@
window.location.href = url
} catch (e) {
pushToast({
theme: "error",
message: e instanceof Error ? e.message : "Failed to open billing portal",
})
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to open billing portal")})
} finally {
redirecting = false
}

View file

@ -13,29 +13,23 @@
import PricingTable from "@app/components/hosting/PricingTable.svelte"
import PaymentDialog from "@app/components/hosting/PaymentDialog.svelte"
import PaymentSetup from "@app/components/hosting/PaymentSetup.svelte"
import {user} from "@app/core"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
import {
autopayConfigured,
derivePlans,
getTenant,
HostingError,
listTenantInvoices,
reconcileTenant,
selectPayableInvoice,
hosting,
hostingErrorMessage,
isPaidPlan,
updateRelay,
type HostedRelay,
} from "@app/hosting"
type Props = {
relay: HostedRelay
onUpdate?: (relay: HostedRelay) => void
}
const {relay, onUpdate}: Props = $props()
const {relay}: Props = $props()
const plans = derivePlans()
const plans = $hosting.plans
let selected = $state(relay.plan_id)
let saving = $state(false)
@ -49,51 +43,35 @@
}
const save = async () => {
if (!canSave) {
return
}
if (canSave) {
saving = true
saving = true
try {
// Leaving the paid plans turns off the features only they include.
const updated = await updateRelay(
relay.id,
isPaidPlan(selected)
? {plan_id: selected}
: {plan_id: selected, blossom_enabled: 0, livekit_enabled: 0},
)
try {
// Downgrading to free turns off the features that plan doesn't include.
const updated = await updateRelay(
relay.id,
selected === "free"
? {plan_id: selected, blossom_enabled: 0, livekit_enabled: 0}
: {plan_id: selected},
)
$hosting.setRelay(updated)
pushToast({message: "Plan updated."})
onUpdate?.(updated)
pushToast({message: "Plan updated."})
const due = isPaidPlan(selected) ? await $hosting.findPaymentDue() : undefined
if (selected === "free") {
back()
return
}
await reconcileTenant($user.pubkey)
const tenant = await getTenant($user.pubkey)
if (autopayConfigured(tenant)) {
back()
} else {
const invoice = selectPayableInvoice(await listTenantInvoices($user.pubkey))
if (invoice) {
pushModal(PaymentDialog, {invoice}, {replaceState: true})
} else {
if (due?.invoice) {
pushModal(PaymentDialog, {invoice: due.invoice}, {replaceState: true})
} else if (due) {
pushModal(PaymentSetup, {}, {replaceState: true})
} else {
back()
}
} catch (e) {
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to update plan.")})
} finally {
saving = false
}
} catch (e) {
pushToast({
theme: "error",
message: e instanceof HostingError ? e.message : "Failed to update plan.",
})
} finally {
saving = false
}
}
</script>

View file

@ -3,70 +3,30 @@
import RelayForm, {type RelayFormValues} from "@app/components/hosting/RelayForm.svelte"
import PaymentDialog from "@app/components/hosting/PaymentDialog.svelte"
import PaymentSetup from "@app/components/hosting/PaymentSetup.svelte"
import {roomLists, user} from "@app/core"
import {roomLists} from "@app/core"
import {navigate, pushModal} from "@app/modal"
import {makeSpacePath} from "@app/routes"
import {HOSTING_RELAY_DOMAIN} from "@app/env"
import {
autopayConfigured,
createRelay,
ensureSessionTenant,
getHostedRelayUrl,
getTenant,
listTenantInvoices,
reconcileTenant,
selectPayableInvoice,
} from "@app/hosting"
import {createHostedRelay, getHostedRelayUrl, hosting, isPaidPlan} from "@app/hosting"
const createRelayForActiveTenant = (values: RelayFormValues) => {
const defaults = {
info_name: "",
info_icon: "",
info_description: "",
policy_public_read: 0,
policy_public_write: 0,
policy_public_join: 0,
policy_strip_signatures: 0,
groups_enabled: 1,
management_enabled: 1,
members_can_invite: 1,
push_enabled: 1,
}
const overrides = {
tenant_pubkey: $user.pubkey,
zooid_domain: HOSTING_RELAY_DOMAIN,
blossom_enabled: values.plan_id === "free" ? 0 : 1,
livekit_enabled: values.plan_id === "free" ? 0 : 1,
}
return createRelay({...defaults, ...values, ...overrides})
}
const handleSubmit = async (values: RelayFormValues) => {
await ensureSessionTenant()
const relay = await createRelayForActiveTenant(values)
const submit = async (values: RelayFormValues) => {
const relay = await createHostedRelay(values)
const url = getHostedRelayUrl(relay)
$hosting.setRelay(relay)
// Join the space now, or the space layout prompts for it and displaces the payment modals.
await $roomLists.addRelay(url).then(publish)
await reconcileTenant($user.pubkey)
const tenant = await getTenant($user.pubkey)
const due = isPaidPlan(values.plan_id) ? await $hosting.findPaymentDue() : undefined
await navigate(makeSpacePath(url, "admin"), {replaceState: true})
if (values.plan_id !== "free" && !autopayConfigured(tenant)) {
const invoice = selectPayableInvoice(await listTenantInvoices($user.pubkey))
if (invoice) {
pushModal(PaymentDialog, {invoice})
} else {
pushModal(PaymentSetup, {})
}
if (due?.invoice) {
pushModal(PaymentDialog, {invoice: due.invoice})
} else if (due) {
pushModal(PaymentSetup)
}
}
</script>
<RelayForm mode="create" submitLabel="Create Space" onSubmit={handleSubmit} />
<RelayForm mode="create" submitLabel="Create Space" onSubmit={submit} />

View file

@ -28,24 +28,25 @@
import CustomDomainModal from "@app/components/hosting/CustomDomainModal.svelte"
import DataTransferModal from "@app/components/hosting/DataTransferModal.svelte"
import PlanModal from "@app/components/hosting/PlanModal.svelte"
import {roomLists} from "@app/core"
import RelayForm from "@app/components/hosting/RelayForm.svelte"
import type {RelayFormValues} from "@app/components/hosting/RelayForm.svelte"
import {pushModal, clearModals} from "@app/modal"
import {clip, pushToast} from "@app/toast"
import {goToMovedSpace} from "@app/routes"
import {
HostingError,
RELAY_FLAG_DEFAULTS,
flagToBool,
boolToFlag,
canonicalRelayHost,
relayHost,
followHostedRelay,
getHostedRelayUrl,
hosting,
hostingErrorMessage,
isPaidPlan,
updateRelay,
getRelay,
deactivateRelay,
reactivateRelay,
derivePlans,
deriveRelayMembers,
type HostedRelay,
type RelayFlag,
@ -58,96 +59,64 @@
const {relay}: Props = $props()
const PLAN_GATED_TOOLTIP = "Not available on your current plan"
const plans = derivePlans()
const plans = $hosting.plans
const members = deriveRelayMembers(relay.id)
let current = $state(relay)
let showMenu = $state(false)
let loading = $state(false)
const host = $derived(relayHost(current))
const relayLabel = $derived(current.info_name || current.subdomain)
const domainVerified = $derived(flagToBool(current.custom_domain_verified, false))
const recordTarget = $derived(canonicalRelayHost(current))
const host = $derived(relayHost(relay))
const relayLabel = $derived(relay.info_name || relay.subdomain)
const domainVerified = $derived(flagToBool(relay.custom_domain_verified, false))
const recordTarget = $derived(canonicalRelayHost(relay))
// DNS forbids a CNAME at a zone apex, so those use the provider's ALIAS or ANAME record.
const isApex = $derived(current.custom_domain.split(".").length === 2)
const isApex = $derived(relay.custom_domain.split(".").length === 2)
const recordType = $derived(isApex ? "ALIAS" : "CNAME")
const isPaidPlan = $derived(current.plan_id !== "free")
const paidPlan = $derived(isPaidPlan(relay.plan_id))
// A custom domain moves the relay's host, so migrate the groups list and follow the space.
const setCurrentAndFollowHost = async (next: HostedRelay) => {
const previousUrl = getHostedRelayUrl(current)
const nextUrl = getHostedRelayUrl(next)
const setFlag = (flag: RelayFlag) => async (checked: boolean) => {
const previous = relay
const input: UpdateRelayInput = {[flag]: boolToFlag(checked)}
current = next
if (previousUrl !== nextUrl) {
await $roomLists.migrateRelay(previousUrl, nextUrl).then(command => command?.publish())
await goToMovedSpace(previousUrl, nextUrl)
}
}
const persist = async (next: HostedRelay, input: UpdateRelayInput) => {
const previous = current
current = next
$hosting.setRelay({...previous, ...input})
try {
current = await updateRelay(previous.id, input)
$hosting.setRelay(await updateRelay(previous.id, input))
} catch (e) {
current = previous
pushToast({
theme: "error",
message: e instanceof HostingError ? e.message : "Failed to save changes.",
})
$hosting.setRelay(previous)
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to save changes.")})
}
}
const setFlag = (field: RelayFlag) => (checked: boolean) => {
const input: UpdateRelayInput = {[field]: boolToFlag(checked)}
persist({...current, ...input}, input)
}
const setCurrent = (next: HostedRelay) => {
current = next
}
const openPlan = () =>
pushModal(PlanModal, {relay: current, onUpdate: setCurrent}, {size: "large"})
const openPlan = () => pushModal(PlanModal, {relay}, {size: "large"})
const openEdit = () => {
const previous = relay
const initialValues: RelayFormValues = {
info_name: current.info_name,
subdomain: current.subdomain,
info_icon: current.info_icon,
info_description: current.info_description,
plan_id: current.plan_id,
info_name: relay.info_name,
subdomain: relay.subdomain,
info_icon: relay.info_icon,
info_description: relay.info_description,
plan_id: relay.plan_id,
}
pushModal(RelayForm, {
mode: "edit",
initialValues,
zooidDomain: current.zooid_domain,
zooidDomain: relay.zooid_domain,
onSubmit: async (values: RelayFormValues) => {
const updated = await updateRelay(current.id, values)
const updated = await updateRelay(previous.id, values)
clearModals()
// The subdomain is editable here, so the host may have moved.
await setCurrentAndFollowHost(updated)
await followHostedRelay(previous, updated)
},
})
}
const openDataTransfer = () => {
pushModal(DataTransferModal, {relay: current})
pushModal(DataTransferModal, {relay})
}
const openCustomDomain = () => {
pushModal(CustomDomainModal, {relay: current, onUpdate: setCurrentAndFollowHost})
pushModal(CustomDomainModal, {relay})
}
const openMenu = () => {
@ -165,7 +134,7 @@
loading = true
try {
const updated = await getRelay(current.id)
const updated = await getRelay(relay.id)
pushToast({
message: flagToBool(updated.custom_domain_verified, false)
@ -173,12 +142,9 @@
: "Not verified yet. DNS changes can take a while to propagate.",
})
await setCurrentAndFollowHost(updated)
await followHostedRelay(relay, updated)
} catch (e) {
pushToast({
theme: "error",
message: e instanceof HostingError ? e.message : "Verification failed.",
})
pushToast({theme: "error", message: hostingErrorMessage(e, "Verification failed.")})
} finally {
loading = false
}
@ -192,13 +158,13 @@
"All client connections are dropped and members can't read or write, but all data and settings are preserved. You can reactivate at any time.",
confirm: async () => {
try {
await deactivateRelay(current.id)
current = await getRelay(current.id)
await deactivateRelay(relay.id)
$hosting.setRelay(await getRelay(relay.id))
clearModals()
} catch (e) {
pushToast({
theme: "error",
message: e instanceof HostingError ? e.message : "Failed to deactivate relay.",
message: hostingErrorMessage(e, "Failed to deactivate relay."),
})
}
},
@ -212,39 +178,57 @@
message: "The relay will start accepting connections again.",
confirm: async () => {
try {
await reactivateRelay(current.id)
current = await getRelay(current.id)
await reactivateRelay(relay.id)
$hosting.setRelay(await getRelay(relay.id))
clearModals()
} catch (e) {
pushToast({
theme: "error",
message: e instanceof HostingError ? e.message : "Failed to reactivate relay.",
message: hostingErrorMessage(e, "Failed to reactivate relay."),
})
}
},
})
}
let showMenu = $state(false)
let loading = $state(false)
</script>
{#snippet flagToggle(title: string, flag: RelayFlag, gated = false)}
<FieldInline class={cx({"opacity-50": gated})}>
{#snippet label()}
<span>{title}</span>
{/snippet}
{#snippet input()}
<ToggleInput
checked={flagToBool(relay[flag], Boolean(RELAY_FLAG_DEFAULTS[flag]))}
disabled={gated}
tooltip={gated ? "Not available on your current plan" : undefined}
onchange={setFlag(flag)} />
{/snippet}
</FieldInline>
{/snippet}
<div class="card flex flex-col gap-6">
<div class="flex items-start justify-between gap-4">
<div class="flex min-w-0 items-start gap-4">
<RelayIcon url={getHostedRelayUrl(current)} icon={current.info_icon} size={12} />
<RelayIcon url={getHostedRelayUrl(relay)} icon={relay.info_icon} size={12} />
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-xl font-bold">{relayLabel}</h2>
<Badge variant={current.status === "active" ? "primary" : "neutral"}>
{ucFirst(current.status.replace(/_/g, " "))}
<Badge variant={relay.status === "active" ? "primary" : "neutral"}>
{ucFirst(relay.status.replace(/_/g, " "))}
</Badge>
<Badge variant={current.plan_id === "free" ? "neutral" : "primary"}>
{ucFirst(current.plan_id)}
<Badge variant={relay.plan_id === "free" ? "neutral" : "primary"}>
{ucFirst(relay.plan_id)}
</Badge>
</div>
<Link external href={`https://${host}`} class="break-all text-sm text-primary">
wss://{host}
</Link>
{#if current.info_description.trim()}
<p class="mt-2 text-sm text-content-muted">{current.info_description}</p>
{#if relay.info_description.trim()}
<p class="mt-2 text-sm text-content-muted">{relay.info_description}</p>
{/if}
</div>
</div>
@ -280,7 +264,7 @@
</Button>
</li>
<li>
{#if current.status === "active"}
{#if relay.status === "active"}
<Button class="text-error" onclick={requestDeactivate}>
<Icon icon={Power} />
Deactivate
@ -298,13 +282,13 @@
</div>
</div>
{#if current.sync_error}
{#if relay.sync_error}
<div class="flex flex-col gap-1 rounded-2xl border border-error bg-surface-less p-4">
<p class="flex items-center gap-2 text-sm font-semibold text-error">
<Icon icon={Danger} size={4} />
Provisioning error
</p>
<p class="break-all font-mono text-sm text-error">{current.sync_error}</p>
<p class="break-all font-mono text-sm text-error">{relay.sync_error}</p>
</div>
{/if}
@ -313,9 +297,9 @@
<div class="flex flex-col gap-3">
<h3 class="text-sm font-semibold uppercase tracking-wider text-content-muted">Custom Domain</h3>
<div class="flex items-center justify-between gap-2">
{#if current.custom_domain}
{#if relay.custom_domain}
<div class="flex min-w-0 items-center gap-2">
<span class="truncate font-medium">{current.custom_domain}</span>
<span class="truncate font-medium">{relay.custom_domain}</span>
{#if domainVerified}
<Badge variant="primary">
<Icon icon={VerifiedCheck} size={3} />
@ -338,7 +322,7 @@
</Button>
{/if}
</div>
{#if current.custom_domain && !domainVerified}
{#if relay.custom_domain && !domainVerified}
<div class="flex flex-col gap-3 rounded-2xl border border-warning bg-surface-less p-4">
<div class="flex items-start gap-2">
<Icon icon={ShieldWarning} size={4} class="mt-0.5 shrink-0 text-warning" />
@ -348,7 +332,7 @@
</div>
<div class="flex items-center gap-2 rounded-xl border border-line bg-surface px-3 py-2">
<code class="min-w-0 flex-1 break-all font-mono text-xs text-content">
{current.custom_domain}
{relay.custom_domain}
{recordType}
{recordTarget}
</code>
@ -383,56 +367,11 @@
<div class="flex flex-col gap-4">
<h3 class="text-sm font-semibold uppercase tracking-wider text-content-muted">Policy</h3>
<div class="grid gap-x-8 gap-y-4 sm:grid-cols-2">
<FieldInline>
{#snippet label()}
<span>Public read</span>
{/snippet}
{#snippet input()}
<ToggleInput
checked={flagToBool(current.policy_public_read, false)}
onchange={setFlag("policy_public_read")} />
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<span>Public write</span>
{/snippet}
{#snippet input()}
<ToggleInput
checked={flagToBool(current.policy_public_write, false)}
onchange={setFlag("policy_public_write")} />
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<span>Public join</span>
{/snippet}
{#snippet input()}
<ToggleInput
checked={flagToBool(current.policy_public_join, false)}
onchange={setFlag("policy_public_join")} />
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<span>Members can invite others</span>
{/snippet}
{#snippet input()}
<ToggleInput
checked={flagToBool(current.members_can_invite, true)}
onchange={setFlag("members_can_invite")} />
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<span>Strip signatures</span>
{/snippet}
{#snippet input()}
<ToggleInput
checked={flagToBool(current.policy_strip_signatures, false)}
onchange={setFlag("policy_strip_signatures")} />
{/snippet}
</FieldInline>
{@render flagToggle("Public read", "policy_public_read")}
{@render flagToggle("Public write", "policy_public_write")}
{@render flagToggle("Public join", "policy_public_join")}
{@render flagToggle("Members can invite others", "members_can_invite")}
{@render flagToggle("Strip signatures", "policy_strip_signatures")}
</div>
</div>
@ -441,60 +380,11 @@
<div class="flex flex-col gap-4">
<h3 class="text-sm font-semibold uppercase tracking-wider text-content-muted">Features</h3>
<div class="grid gap-x-8 gap-y-4 sm:grid-cols-2">
<FieldInline>
{#snippet label()}
<span>Rooms</span>
{/snippet}
{#snippet input()}
<ToggleInput
checked={flagToBool(current.groups_enabled, true)}
onchange={setFlag("groups_enabled")} />
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<span>Management API</span>
{/snippet}
{#snippet input()}
<ToggleInput
checked={flagToBool(current.management_enabled, true)}
onchange={setFlag("management_enabled")} />
{/snippet}
</FieldInline>
<FieldInline>
{#snippet label()}
<span>Push notifications</span>
{/snippet}
{#snippet input()}
<ToggleInput
checked={flagToBool(current.push_enabled, true)}
onchange={setFlag("push_enabled")} />
{/snippet}
</FieldInline>
<FieldInline class={cx({"opacity-50": !isPaidPlan})}>
{#snippet label()}
<span>Media storage</span>
{/snippet}
{#snippet input()}
<ToggleInput
checked={flagToBool(current.blossom_enabled, false)}
disabled={!isPaidPlan}
tooltip={isPaidPlan ? undefined : PLAN_GATED_TOOLTIP}
onchange={setFlag("blossom_enabled")} />
{/snippet}
</FieldInline>
<FieldInline class={cx({"opacity-50": !isPaidPlan})}>
{#snippet label()}
<span>LiveKit support</span>
{/snippet}
{#snippet input()}
<ToggleInput
checked={flagToBool(current.livekit_enabled, false)}
disabled={!isPaidPlan}
tooltip={isPaidPlan ? undefined : PLAN_GATED_TOOLTIP}
onchange={setFlag("livekit_enabled")} />
{/snippet}
</FieldInline>
{@render flagToggle("Rooms", "groups_enabled")}
{@render flagToggle("Management API", "management_enabled")}
{@render flagToggle("Push notifications", "push_enabled")}
{@render flagToggle("Media storage", "blossom_enabled", !paidPlan)}
{@render flagToggle("LiveKit support", "livekit_enabled", !paidPlan)}
</div>
</div>
@ -516,7 +406,7 @@
<span>Member limit</span>
{/snippet}
{#snippet input()}
{@const plan = $plans.find(spec({id: current.plan_id}))}
{@const plan = $plans.find(spec({id: relay.plan_id}))}
<span class="font-medium">
{typeof plan?.members === "number" ? plan.members : "∞"}
</span>
@ -534,7 +424,7 @@
<span>Current plan</span>
{/snippet}
{#snippet input()}
<span class="font-medium">{ucFirst(current.plan_id)}</span>
<span class="font-medium">{ucFirst(relay.plan_id)}</span>
{/snippet}
</FieldInline>
</div>

View file

@ -25,7 +25,7 @@
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import PricingTable from "@app/components/hosting/PricingTable.svelte"
import {derivePlans} from "@app/hosting"
import {hosting, hostingErrorMessage} from "@app/hosting"
import {HOSTING_RELAY_DOMAIN} from "@app/env"
import {pushToast} from "@app/toast"
import {resolveImageInput} from "@app/uploads"
@ -59,7 +59,7 @@
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
const plans = derivePlans()
const plans = $hosting.plans
let loading = $state(false)
let name = $state(initialValues.info_name ?? "")
@ -139,10 +139,7 @@
info_description: description,
})
} catch (e) {
pushToast({
theme: "error",
message: e instanceof Error ? e.message : "Failed to save space",
})
pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to save space")})
} finally {
loading = false
}

View file

@ -1,11 +1,14 @@
import {derived, readable} from "svelte/store"
import {derived, readable, writable} 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 {sortBy, spec, 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"
import {User} from "@welshman/app"
import type {IApp} from "@welshman/app"
import {roomLists, usePlugin, user} from "@app/core"
import {HOSTING_BACKEND_URL, HOSTING_RELAY_DOMAIN, PLATFORM_URL} from "@app/env"
import {goToMovedSpace} from "@app/routes"
// Apple doesn't allow selling hosting outside their payment system.
export const HOSTING_ENABLED = Capacitor.getPlatform() !== "ios"
@ -59,46 +62,22 @@ export type RelayFlag =
| "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
members_can_invite?: number
blossom_enabled?: number
livekit_enabled?: number
push_enabled?: number
custom_domain?: string
}
export type UpdateRelayInput = Partial<
Pick<
HostedRelay,
| "subdomain"
| "zooid_domain"
| "plan_id"
| "info_name"
| "info_icon"
| "info_description"
| "custom_domain"
| RelayFlag
>
>
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
members_can_invite?: number
blossom_enabled?: number
livekit_enabled?: number
push_enabled?: number
custom_domain?: string
}
export type CreateRelayInput = UpdateRelayInput &
Pick<HostedRelay, "tenant_pubkey" | "subdomain" | "zooid_domain" | "plan_id">
export type Tenant = {
pubkey: string
@ -186,6 +165,19 @@ export class HostingError extends Error {
}
}
export const logUnexpectedError = (e: unknown) => {
if (!(e instanceof HostingError)) {
console.error(e)
}
}
// A HostingError carries the backend's own explanation; anything else is a bug worth logging.
export const hostingErrorMessage = (e: unknown, fallback: string) => {
logUnexpectedError(e)
return e instanceof HostingError ? e.message : fallback
}
// Requests
const AUTH_TTL = ms(int(10, MINUTE))
@ -347,6 +339,23 @@ export const flagToBool = (value: number | undefined, fallback: boolean): boolea
export const boolToFlag = (value: boolean): 0 | 1 => (value ? 1 : 0)
// What a new relay starts with, and what a toggle shows when the backend leaves a flag unset.
export const RELAY_FLAG_DEFAULTS: Pick<HostedRelay, RelayFlag> = {
policy_public_read: 0,
policy_public_write: 0,
policy_public_join: 0,
policy_strip_signatures: 0,
groups_enabled: 1,
management_enabled: 1,
members_can_invite: 1,
blossom_enabled: 0,
livekit_enabled: 0,
push_enabled: 1,
}
// Paid plans are the ones that bill, and the only ones with media storage and livekit.
export const isPaidPlan = (planId: string) => planId !== "free"
export const autopayConfigured = (
t: Pick<Tenant, "nwc_is_set" | "stripe_payment_method_id">,
): boolean => t.nwc_is_set || Boolean(t.stripe_payment_method_id)
@ -402,13 +411,151 @@ export const ensureSessionTenant = async () => {
await tenantPromise.promise
}
export const createHostedRelay = async (
values: Omit<CreateRelayInput, "tenant_pubkey" | "zooid_domain">,
) => {
await ensureSessionTenant()
const features = boolToFlag(isPaidPlan(values.plan_id))
return createRelay({
...RELAY_FLAG_DEFAULTS,
...values,
tenant_pubkey: user.get().pubkey,
zooid_domain: HOSTING_RELAY_DOMAIN,
blossom_enabled: features,
livekit_enabled: features,
})
}
// Stores
export const derivePlans = () =>
readable<Plan[]>([], set => {
listPlans().then(set, () => set([]))
export type TenantRelays = {loading: boolean; relays: HostedRelay[]}
export type TenantBilling = {
tenant: Maybe<Tenant>
invoices: Invoice[]
draftInvoice: Maybe<Invoice>
}
// The tenant is the signed-in pubkey, so what's loaded for it lives on the app and goes with a login.
export class Hosting {
private plansRequest: Maybe<Promise<Plan[]>>
private relayState = writable<TenantRelays>({loading: true, relays: []})
billing = writable<TenantBilling>({tenant: undefined, invoices: [], draftInvoice: undefined})
constructor(private readonly app: IApp) {}
loadPlans = () => {
this.plansRequest ??= listPlans().catch((e): Plan[] => {
this.plansRequest = undefined
logUnexpectedError(e)
return []
})
return this.plansRequest
}
plans = readable<Plan[]>([], set => {
this.loadPlans().then(set)
})
loadRelays = async () => {
this.relayState.update($state => ({...$state, loading: true}))
try {
const relays = await listTenantRelays(User.require(this.app).pubkey)
this.relayState.set({loading: false, relays})
} catch (e) {
this.relayState.update($state => ({...$state, loading: false}))
throw e
}
}
// A pubkey with no tenant answers with a HostingError, which leaves the list empty.
relays = readable<TenantRelays>({loading: true, relays: []}, set => {
this.loadRelays().catch(logUnexpectedError)
return this.relayState.subscribe(set)
})
setRelay = (relay: HostedRelay) =>
this.relayState.update($state => ({
...$state,
relays: $state.relays.some(spec({id: relay.id}))
? $state.relays.map(r => (r.id === relay.id ? relay : r))
: [...$state.relays, relay],
}))
// Each request lands on its own, so one failure doesn't blank what the others loaded.
refresh = async () => {
const {pubkey} = User.require(this.app)
const results = await Promise.allSettled([
getTenant(pubkey),
listTenantInvoices(pubkey),
getDraftInvoice(pubkey),
this.loadRelays(),
])
const [tenant, invoices, draftInvoice] = results
this.billing.update($billing => ({
tenant: tenant.status === "fulfilled" ? tenant.value : $billing.tenant,
invoices:
invoices.status === "fulfilled"
? sortBy(invoice => -invoice.created_at, invoices.value)
: $billing.invoices,
draftInvoice:
draftInvoice.status === "fulfilled" ? draftInvoice.value : $billing.draftInvoice,
}))
for (const result of results) {
if (result.status === "rejected") {
logUnexpectedError(result.reason)
}
}
return results.every(spec({status: "fulfilled"}))
}
// Settles the balance, then says what a tenant without autopay still owes: an open invoice, or a method.
findPaymentDue = async (): Promise<Maybe<{invoice: Maybe<Invoice>}>> => {
const {pubkey} = User.require(this.app)
await reconcileTenant(pubkey)
const tenant = await getTenant(pubkey)
this.billing.update($billing => ({...$billing, tenant}))
if (!autopayConfigured(tenant)) {
return {invoice: selectPayableInvoice(await listTenantInvoices(pubkey))}
}
}
}
export const hosting = usePlugin(Hosting)
// A new subdomain or a verified custom domain moves the relay, so the room list and the page follow it.
export const followHostedRelay = async (previous: HostedRelay, next: HostedRelay) => {
const previousUrl = getHostedRelayUrl(previous)
const nextUrl = getHostedRelayUrl(next)
if (previousUrl !== nextUrl) {
await roomLists
.get()
.migrateRelay(previousUrl, nextUrl)
.then(command => command?.publish())
await goToMovedSpace(previousUrl, nextUrl)
}
hosting.get().setRelay(next)
}
export const deriveRelayMembers = (id: string) =>
readable<string[]>([], set => {
listRelayMembers(id).then(
@ -442,15 +589,7 @@ export const deriveRelayActivity = (id: Readable<Maybe<string>>) =>
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},
)
derived<Readable<TenantRelays>, HostedRelayState>(hosting.get().relays, ({loading, relays}) => ({
loading,
relay: relays.find(r => getHostedRelayUrl(r) === url),
}))

View file

@ -1,8 +1,7 @@
<script lang="ts">
import {onMount} from "svelte"
import {App} from "@capacitor/app"
import type {Maybe} from "@welshman/lib"
import {indexBy, sortBy, spec} from "@welshman/lib"
import {indexBy} from "@welshman/lib"
import Add from "@assets/icons/add.svg?dataurl"
import Server from "@assets/icons/server.svg?dataurl"
import Bolt from "@assets/icons/bolt.svg?dataurl"
@ -15,7 +14,6 @@
import Button from "@lib/components/Button.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import PageContent from "@lib/components/PageContent.svelte"
import {errorMessage} from "@lib/util"
import RelayListItem from "@app/components/hosting/RelayListItem.svelte"
import RelayCreate from "@app/components/hosting/RelayCreate.svelte"
import BillingPrompts from "@app/components/hosting/BillingPrompts.svelte"
@ -28,32 +26,29 @@
import {
autopayConfigured,
ensureSessionTenant,
getDraftInvoice,
getTenant,
listTenantInvoices,
listTenantRelays,
derivePlans,
hosting,
hostingErrorMessage,
reconcileTenant,
selectPayableInvoice,
type HostedRelay,
type Invoice,
type Tenant,
} from "@app/hosting"
type PaymentMethodState = {kind: "not_set_up"} | {kind: "ok"} | {kind: "error"; message: string}
const INVOICE_PAGE_SIZE = 10
const plans = derivePlans()
const plans = $hosting.plans
const tenantRelays = $hosting.relays
const billing = $hosting.billing
let tenant = $state<Maybe<Tenant>>()
let invoices = $state<Invoice[]>([])
let relays = $state<HostedRelay[]>([])
let draftInvoice = $state<Maybe<Invoice>>()
let loading = $state(true)
let error = $state("")
let showAllInvoices = $state(false)
const tenant = $derived($billing.tenant)
const invoices = $derived($billing.invoices)
const draftInvoice = $derived($billing.draftInvoice)
const relays = $derived($tenantRelays.relays)
const openInvoice = $derived(selectPayableInvoice(invoices))
// Whether any active relay is on a paid plan.
@ -107,39 +102,6 @@
showAllInvoices ? invoices : invoices.slice(0, INVOICE_PAGE_SIZE),
)
const refetch = async () => {
const pk = user.get().pubkey
if (!pk) {
return
}
const results = await Promise.allSettled([
getTenant(pk),
listTenantInvoices(pk),
listTenantRelays(pk),
getDraftInvoice(pk),
])
const [t, inv, rel, draft] = results
if (t.status === "fulfilled") {
tenant = t.value
}
if (inv.status === "fulfilled") {
invoices = sortBy(invoice => -invoice.created_at, inv.value)
}
if (rel.status === "fulfilled") {
relays = rel.value
}
if (draft.status === "fulfilled") {
draftInvoice = draft.value
}
if (results.some(spec({status: "rejected"}))) {
pushToast({theme: "error", message: "Failed to refresh billing data"})
}
}
// Guard so the mount effect and the foreground listener don't race two reconciles.
let reconciling = false
@ -160,7 +122,7 @@
await ensureSessionTenant()
await reconcileTenant(pk)
} catch (e) {
const message = errorMessage(e) || "Failed to update billing"
const message = hostingErrorMessage(e, "Failed to update billing")
if (initialized) {
pushToast({theme: "error", message})
@ -168,21 +130,18 @@
error = message
}
} finally {
await refetch()
if (!(await $hosting.refresh())) {
pushToast({theme: "error", message: "Failed to refresh billing data"})
}
reconciling = false
initialized = true
}
}
const openPayInvoice = (invoice: Invoice) =>
pushModal(PaymentDialog, {invoice, onPaid: () => void refetch()})
const openPayInvoice = (invoice: Invoice) => pushModal(PaymentDialog, {invoice})
const openSetup = (initialTab: "nwc" | "card") =>
pushModal(PaymentSetup, {
initialTab,
nwcConnected: Boolean(tenant?.nwc_is_set),
onSaved: () => void refetch(),
})
const openSetup = (initialTab: "nwc" | "card") => pushModal(PaymentSetup, {initialTab})
const openNwcSetup = () => openSetup("nwc")
@ -190,8 +149,6 @@
const openCreateRelay = () => pushModal(RelayCreate)
const refresh = () => void refetch()
const toggleAllInvoices = () => {
showAllInvoices = !showAllInvoices
}
@ -255,7 +212,7 @@
</div>
{:else}
{#if tenant}
<BillingPrompts {tenant} {openInvoice} {hasPaidSubscription} onRefresh={refresh} />
<BillingPrompts {tenant} {openInvoice} {hasPaidSubscription} />
{/if}
{#if relays.length === 0}