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 | | `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` | | `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`) | | `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: 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: `.publish()` in `RoomForm`. NIP-86 calls resolve to `{error}` instead of a thunk:
`$relayManagement.forUrl(url).createRole(...)` in `RoleCreate`. `$relayManagement.forUrl(url).createRole(...)` in `RoleCreate`.
The hosting backend isn't nostr. Toast a readable message, and `console.error` anything that The hosting backend isn't nostr. Toast `hostingErrorMessage(e, fallback)` from `hosting.ts`,
isn't a `HostingError`, as `HomeHosting` and `hosting/CustomDomainModal` do. Publishes are 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` optimistic, so rows show progress in place: `ContentActions`, `CommentActions` and `ArticleActionBar`
wrap their contents in `ThunkStatusOrDeleted`, and chat pushes a `ThunkToast`. wrap their contents in `ThunkStatusOrDeleted`, and chat pushes a `ThunkToast`.

View file

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

View file

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

View file

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

View file

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

View file

@ -16,13 +16,15 @@
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import {errorMessage} from "@lib/util"
import QRCode from "@app/components/QRCode.svelte" import QRCode from "@app/components/QRCode.svelte"
import PaymentSetup from "@app/components/hosting/PaymentSetup.svelte" import PaymentSetup from "@app/components/hosting/PaymentSetup.svelte"
import InvoiceItemsList from "@app/components/hosting/InvoiceItemsList.svelte" import InvoiceItemsList from "@app/components/hosting/InvoiceItemsList.svelte"
import {clip, pushToast} from "@app/toast" import {clip, pushToast} from "@app/toast"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import { import {
hosting,
hostingErrorMessage,
logUnexpectedError,
ensureInvoiceBolt11, ensureInvoiceBolt11,
reconcileInvoice, reconcileInvoice,
createInvoiceCheckout, createInvoiceCheckout,
@ -35,10 +37,9 @@
type Props = { type Props = {
invoice: Invoice invoice: Invoice
onPaid?: () => void
} }
const {invoice, onPaid}: Props = $props() const {invoice}: Props = $props()
type Bolt11Status = "idle" | "loading" | "ready" | "error" type Bolt11Status = "idle" | "loading" | "ready" | "error"
@ -83,7 +84,7 @@
paid = true paid = true
stopPolling() stopPolling()
onPaid?.() void $hosting.refresh()
} }
const loadBolt11 = async () => { const loadBolt11 = async () => {
@ -98,15 +99,16 @@
bolt11Status = "ready" bolt11Status = "ready"
} catch (e) { } catch (e) {
bolt11Status = "error" bolt11Status = "error"
bolt11Error = errorMessage(e) || "Failed to generate Lightning invoice" bolt11Error = hostingErrorMessage(e, "Failed to generate Lightning invoice")
} }
} }
const loadItems = async () => { const loadItems = async () => {
try { try {
items = await listInvoiceItems(invoice.id) items = await listInvoiceItems(invoice.id)
} catch { } catch (e) {
// line items are cosmetic; don't block payment // 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."}) pushToast({message: "Payment not yet confirmed. Please try again after sending."})
} }
} catch (e) { } 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 { } finally {
checking = false 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 = () => { const connectWallet = () => {
pushModal( pushModal(PaymentSetup, {initialTab: "nwc"}, {nested: true})
PaymentSetup,
{initialTab: "nwc", onSaved: () => void reconcile().catch(() => {})},
{nested: true},
)
} }
const payWithCard = async () => { const payWithCard = async () => {
@ -179,7 +177,7 @@
window.location.href = url window.location.href = url
} catch (e) { } catch (e) {
pushToast({theme: "error", message: errorMessage(e) || "Failed to open checkout"}) pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to open checkout")})
} finally { } finally {
redirecting = false redirecting = false
} }

View file

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

View file

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

View file

@ -3,70 +3,30 @@
import RelayForm, {type RelayFormValues} from "@app/components/hosting/RelayForm.svelte" import RelayForm, {type RelayFormValues} from "@app/components/hosting/RelayForm.svelte"
import PaymentDialog from "@app/components/hosting/PaymentDialog.svelte" import PaymentDialog from "@app/components/hosting/PaymentDialog.svelte"
import PaymentSetup from "@app/components/hosting/PaymentSetup.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 {navigate, pushModal} from "@app/modal"
import {makeSpacePath} from "@app/routes" import {makeSpacePath} from "@app/routes"
import {HOSTING_RELAY_DOMAIN} from "@app/env" import {createHostedRelay, getHostedRelayUrl, hosting, isPaidPlan} from "@app/hosting"
import {
autopayConfigured,
createRelay,
ensureSessionTenant,
getHostedRelayUrl,
getTenant,
listTenantInvoices,
reconcileTenant,
selectPayableInvoice,
} from "@app/hosting"
const createRelayForActiveTenant = (values: RelayFormValues) => { const submit = async (values: RelayFormValues) => {
const defaults = { const relay = await createHostedRelay(values)
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 url = getHostedRelayUrl(relay) const url = getHostedRelayUrl(relay)
$hosting.setRelay(relay)
// Join the space now, or the space layout prompts for it and displaces the payment modals. // Join the space now, or the space layout prompts for it and displaces the payment modals.
await $roomLists.addRelay(url).then(publish) 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}) await navigate(makeSpacePath(url, "admin"), {replaceState: true})
if (values.plan_id !== "free" && !autopayConfigured(tenant)) { if (due?.invoice) {
const invoice = selectPayableInvoice(await listTenantInvoices($user.pubkey)) pushModal(PaymentDialog, {invoice: due.invoice})
} else if (due) {
if (invoice) { pushModal(PaymentSetup)
pushModal(PaymentDialog, {invoice})
} else {
pushModal(PaymentSetup, {})
}
} }
} }
</script> </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 CustomDomainModal from "@app/components/hosting/CustomDomainModal.svelte"
import DataTransferModal from "@app/components/hosting/DataTransferModal.svelte" import DataTransferModal from "@app/components/hosting/DataTransferModal.svelte"
import PlanModal from "@app/components/hosting/PlanModal.svelte" import PlanModal from "@app/components/hosting/PlanModal.svelte"
import {roomLists} from "@app/core"
import RelayForm from "@app/components/hosting/RelayForm.svelte" import RelayForm from "@app/components/hosting/RelayForm.svelte"
import type {RelayFormValues} from "@app/components/hosting/RelayForm.svelte" import type {RelayFormValues} from "@app/components/hosting/RelayForm.svelte"
import {pushModal, clearModals} from "@app/modal" import {pushModal, clearModals} from "@app/modal"
import {clip, pushToast} from "@app/toast" import {clip, pushToast} from "@app/toast"
import {goToMovedSpace} from "@app/routes"
import { import {
HostingError, RELAY_FLAG_DEFAULTS,
flagToBool, flagToBool,
boolToFlag, boolToFlag,
canonicalRelayHost, canonicalRelayHost,
relayHost, relayHost,
followHostedRelay,
getHostedRelayUrl, getHostedRelayUrl,
hosting,
hostingErrorMessage,
isPaidPlan,
updateRelay, updateRelay,
getRelay, getRelay,
deactivateRelay, deactivateRelay,
reactivateRelay, reactivateRelay,
derivePlans,
deriveRelayMembers, deriveRelayMembers,
type HostedRelay, type HostedRelay,
type RelayFlag, type RelayFlag,
@ -58,96 +59,64 @@
const {relay}: Props = $props() const {relay}: Props = $props()
const PLAN_GATED_TOOLTIP = "Not available on your current plan" const plans = $hosting.plans
const plans = derivePlans()
const members = deriveRelayMembers(relay.id) const members = deriveRelayMembers(relay.id)
let current = $state(relay) const host = $derived(relayHost(relay))
let showMenu = $state(false) const relayLabel = $derived(relay.info_name || relay.subdomain)
let loading = $state(false) const domainVerified = $derived(flagToBool(relay.custom_domain_verified, false))
const recordTarget = $derived(canonicalRelayHost(relay))
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))
// DNS forbids a CNAME at a zone apex, so those use the provider's ALIAS or ANAME record. // 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 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 setFlag = (flag: RelayFlag) => async (checked: boolean) => {
const setCurrentAndFollowHost = async (next: HostedRelay) => { const previous = relay
const previousUrl = getHostedRelayUrl(current) const input: UpdateRelayInput = {[flag]: boolToFlag(checked)}
const nextUrl = getHostedRelayUrl(next)
current = next $hosting.setRelay({...previous, ...input})
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
try { try {
current = await updateRelay(previous.id, input) $hosting.setRelay(await updateRelay(previous.id, input))
} catch (e) { } catch (e) {
current = previous $hosting.setRelay(previous)
pushToast({ pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to save changes.")})
theme: "error",
message: e instanceof HostingError ? e.message : "Failed to save changes.",
})
} }
} }
const setFlag = (field: RelayFlag) => (checked: boolean) => { const openPlan = () => pushModal(PlanModal, {relay}, {size: "large"})
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 openEdit = () => { const openEdit = () => {
const previous = relay
const initialValues: RelayFormValues = { const initialValues: RelayFormValues = {
info_name: current.info_name, info_name: relay.info_name,
subdomain: current.subdomain, subdomain: relay.subdomain,
info_icon: current.info_icon, info_icon: relay.info_icon,
info_description: current.info_description, info_description: relay.info_description,
plan_id: current.plan_id, plan_id: relay.plan_id,
} }
pushModal(RelayForm, { pushModal(RelayForm, {
mode: "edit", mode: "edit",
initialValues, initialValues,
zooidDomain: current.zooid_domain, zooidDomain: relay.zooid_domain,
onSubmit: async (values: RelayFormValues) => { onSubmit: async (values: RelayFormValues) => {
const updated = await updateRelay(current.id, values) const updated = await updateRelay(previous.id, values)
clearModals() clearModals()
// The subdomain is editable here, so the host may have moved. await followHostedRelay(previous, updated)
await setCurrentAndFollowHost(updated)
}, },
}) })
} }
const openDataTransfer = () => { const openDataTransfer = () => {
pushModal(DataTransferModal, {relay: current}) pushModal(DataTransferModal, {relay})
} }
const openCustomDomain = () => { const openCustomDomain = () => {
pushModal(CustomDomainModal, {relay: current, onUpdate: setCurrentAndFollowHost}) pushModal(CustomDomainModal, {relay})
} }
const openMenu = () => { const openMenu = () => {
@ -165,7 +134,7 @@
loading = true loading = true
try { try {
const updated = await getRelay(current.id) const updated = await getRelay(relay.id)
pushToast({ pushToast({
message: flagToBool(updated.custom_domain_verified, false) message: flagToBool(updated.custom_domain_verified, false)
@ -173,12 +142,9 @@
: "Not verified yet. DNS changes can take a while to propagate.", : "Not verified yet. DNS changes can take a while to propagate.",
}) })
await setCurrentAndFollowHost(updated) await followHostedRelay(relay, updated)
} catch (e) { } catch (e) {
pushToast({ pushToast({theme: "error", message: hostingErrorMessage(e, "Verification failed.")})
theme: "error",
message: e instanceof HostingError ? e.message : "Verification failed.",
})
} finally { } finally {
loading = false 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.", "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 () => { confirm: async () => {
try { try {
await deactivateRelay(current.id) await deactivateRelay(relay.id)
current = await getRelay(current.id) $hosting.setRelay(await getRelay(relay.id))
clearModals() clearModals()
} catch (e) { } catch (e) {
pushToast({ pushToast({
theme: "error", 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.", message: "The relay will start accepting connections again.",
confirm: async () => { confirm: async () => {
try { try {
await reactivateRelay(current.id) await reactivateRelay(relay.id)
current = await getRelay(current.id) $hosting.setRelay(await getRelay(relay.id))
clearModals() clearModals()
} catch (e) { } catch (e) {
pushToast({ pushToast({
theme: "error", 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> </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="card flex flex-col gap-6">
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="flex min-w-0 items-start 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="min-w-0">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<h2 class="text-xl font-bold">{relayLabel}</h2> <h2 class="text-xl font-bold">{relayLabel}</h2>
<Badge variant={current.status === "active" ? "primary" : "neutral"}> <Badge variant={relay.status === "active" ? "primary" : "neutral"}>
{ucFirst(current.status.replace(/_/g, " "))} {ucFirst(relay.status.replace(/_/g, " "))}
</Badge> </Badge>
<Badge variant={current.plan_id === "free" ? "neutral" : "primary"}> <Badge variant={relay.plan_id === "free" ? "neutral" : "primary"}>
{ucFirst(current.plan_id)} {ucFirst(relay.plan_id)}
</Badge> </Badge>
</div> </div>
<Link external href={`https://${host}`} class="break-all text-sm text-primary"> <Link external href={`https://${host}`} class="break-all text-sm text-primary">
wss://{host} wss://{host}
</Link> </Link>
{#if current.info_description.trim()} {#if relay.info_description.trim()}
<p class="mt-2 text-sm text-content-muted">{current.info_description}</p> <p class="mt-2 text-sm text-content-muted">{relay.info_description}</p>
{/if} {/if}
</div> </div>
</div> </div>
@ -280,7 +264,7 @@
</Button> </Button>
</li> </li>
<li> <li>
{#if current.status === "active"} {#if relay.status === "active"}
<Button class="text-error" onclick={requestDeactivate}> <Button class="text-error" onclick={requestDeactivate}>
<Icon icon={Power} /> <Icon icon={Power} />
Deactivate Deactivate
@ -298,13 +282,13 @@
</div> </div>
</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"> <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"> <p class="flex items-center gap-2 text-sm font-semibold text-error">
<Icon icon={Danger} size={4} /> <Icon icon={Danger} size={4} />
Provisioning error Provisioning error
</p> </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> </div>
{/if} {/if}
@ -313,9 +297,9 @@
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<h3 class="text-sm font-semibold uppercase tracking-wider text-content-muted">Custom Domain</h3> <h3 class="text-sm font-semibold uppercase tracking-wider text-content-muted">Custom Domain</h3>
<div class="flex items-center justify-between gap-2"> <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"> <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} {#if domainVerified}
<Badge variant="primary"> <Badge variant="primary">
<Icon icon={VerifiedCheck} size={3} /> <Icon icon={VerifiedCheck} size={3} />
@ -338,7 +322,7 @@
</Button> </Button>
{/if} {/if}
</div> </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 flex-col gap-3 rounded-2xl border border-warning bg-surface-less p-4">
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<Icon icon={ShieldWarning} size={4} class="mt-0.5 shrink-0 text-warning" /> <Icon icon={ShieldWarning} size={4} class="mt-0.5 shrink-0 text-warning" />
@ -348,7 +332,7 @@
</div> </div>
<div class="flex items-center gap-2 rounded-xl border border-line bg-surface px-3 py-2"> <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"> <code class="min-w-0 flex-1 break-all font-mono text-xs text-content">
{current.custom_domain} {relay.custom_domain}
{recordType} {recordType}
{recordTarget} {recordTarget}
</code> </code>
@ -383,56 +367,11 @@
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<h3 class="text-sm font-semibold uppercase tracking-wider text-content-muted">Policy</h3> <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"> <div class="grid gap-x-8 gap-y-4 sm:grid-cols-2">
<FieldInline> {@render flagToggle("Public read", "policy_public_read")}
{#snippet label()} {@render flagToggle("Public write", "policy_public_write")}
<span>Public read</span> {@render flagToggle("Public join", "policy_public_join")}
{/snippet} {@render flagToggle("Members can invite others", "members_can_invite")}
{#snippet input()} {@render flagToggle("Strip signatures", "policy_strip_signatures")}
<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>
</div> </div>
</div> </div>
@ -441,60 +380,11 @@
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<h3 class="text-sm font-semibold uppercase tracking-wider text-content-muted">Features</h3> <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"> <div class="grid gap-x-8 gap-y-4 sm:grid-cols-2">
<FieldInline> {@render flagToggle("Rooms", "groups_enabled")}
{#snippet label()} {@render flagToggle("Management API", "management_enabled")}
<span>Rooms</span> {@render flagToggle("Push notifications", "push_enabled")}
{/snippet} {@render flagToggle("Media storage", "blossom_enabled", !paidPlan)}
{#snippet input()} {@render flagToggle("LiveKit support", "livekit_enabled", !paidPlan)}
<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>
</div> </div>
</div> </div>
@ -516,7 +406,7 @@
<span>Member limit</span> <span>Member limit</span>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
{@const plan = $plans.find(spec({id: current.plan_id}))} {@const plan = $plans.find(spec({id: relay.plan_id}))}
<span class="font-medium"> <span class="font-medium">
{typeof plan?.members === "number" ? plan.members : "∞"} {typeof plan?.members === "number" ? plan.members : "∞"}
</span> </span>
@ -534,7 +424,7 @@
<span>Current plan</span> <span>Current plan</span>
{/snippet} {/snippet}
{#snippet input()} {#snippet input()}
<span class="font-medium">{ucFirst(current.plan_id)}</span> <span class="font-medium">{ucFirst(relay.plan_id)}</span>
{/snippet} {/snippet}
</FieldInline> </FieldInline>
</div> </div>

View file

@ -25,7 +25,7 @@
import ModalTitle from "@lib/components/ModalTitle.svelte" import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import PricingTable from "@app/components/hosting/PricingTable.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 {HOSTING_RELAY_DOMAIN} from "@app/env"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {resolveImageInput} from "@app/uploads" import {resolveImageInput} from "@app/uploads"
@ -59,7 +59,7 @@
.replace(/[^a-z0-9]+/g, "-") .replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "") .replace(/^-+|-+$/g, "")
const plans = derivePlans() const plans = $hosting.plans
let loading = $state(false) let loading = $state(false)
let name = $state(initialValues.info_name ?? "") let name = $state(initialValues.info_name ?? "")
@ -139,10 +139,7 @@
info_description: description, info_description: description,
}) })
} catch (e) { } catch (e) {
pushToast({ pushToast({theme: "error", message: hostingErrorMessage(e, "Failed to save space")})
theme: "error",
message: e instanceof Error ? e.message : "Failed to save space",
})
} finally { } finally {
loading = false 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 type {Readable} from "svelte/store"
import {Capacitor} from "@capacitor/core" import {Capacitor} from "@capacitor/core"
import type {Maybe} from "@welshman/lib" 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 {makeHttpAuth, makeHttpAuthHeader, normalizeRelayUrl} from "@welshman/util"
import {user} from "@app/core" import {User} from "@welshman/app"
import {HOSTING_BACKEND_URL, PLATFORM_URL} from "@app/env" 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. // Apple doesn't allow selling hosting outside their payment system.
export const HOSTING_ENABLED = Capacitor.getPlatform() !== "ios" export const HOSTING_ENABLED = Capacitor.getPlatform() !== "ios"
@ -59,46 +62,22 @@ export type RelayFlag =
| "livekit_enabled" | "livekit_enabled"
| "push_enabled" | "push_enabled"
export type CreateRelayInput = { export type UpdateRelayInput = Partial<
tenant_pubkey?: string Pick<
subdomain: string HostedRelay,
zooid_domain: string | "subdomain"
plan_id: string | "zooid_domain"
info_name?: string | "plan_id"
info_icon?: string | "info_name"
info_description?: string | "info_icon"
policy_public_read?: number | "info_description"
policy_public_write?: number | "custom_domain"
policy_public_join?: number | RelayFlag
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 = { export type CreateRelayInput = UpdateRelayInput &
subdomain?: string Pick<HostedRelay, "tenant_pubkey" | "subdomain" | "zooid_domain" | "plan_id">
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 Tenant = { export type Tenant = {
pubkey: string 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 // Requests
const AUTH_TTL = ms(int(10, MINUTE)) 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) 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 = ( export const autopayConfigured = (
t: Pick<Tenant, "nwc_is_set" | "stripe_payment_method_id">, t: Pick<Tenant, "nwc_is_set" | "stripe_payment_method_id">,
): boolean => t.nwc_is_set || Boolean(t.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 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 // Stores
export const derivePlans = () => export type TenantRelays = {loading: boolean; relays: HostedRelay[]}
readable<Plan[]>([], set => {
listPlans().then(set, () => set([])) 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) => export const deriveRelayMembers = (id: string) =>
readable<string[]>([], set => { readable<string[]>([], set => {
listRelayMembers(id).then( listRelayMembers(id).then(
@ -442,15 +589,7 @@ export const deriveRelayActivity = (id: Readable<Maybe<string>>) =>
export type HostedRelayState = {loading: boolean; relay: Maybe<HostedRelay>} export type HostedRelayState = {loading: boolean; relay: Maybe<HostedRelay>}
export const deriveHostedRelay = (url: string) => export const deriveHostedRelay = (url: string) =>
derived<typeof user, HostedRelayState>( derived<Readable<TenantRelays>, HostedRelayState>(hosting.get().relays, ({loading, relays}) => ({
user, loading,
($user, set) => { relay: relays.find(r => getHostedRelayUrl(r) === url),
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},
)

View file

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