diff --git a/.agents/skills/flotilla-state/SKILL.md b/.agents/skills/flotilla-state/SKILL.md index 836cd9cc..2908b0a2 100644 --- a/.agents/skills/flotilla-state/SKILL.md +++ b/.agents/skills/flotilla-state/SKILL.md @@ -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: diff --git a/.agents/skills/flotilla-views/SKILL.md b/.agents/skills/flotilla-views/SKILL.md index b597e3a0..a690ebdd 100644 --- a/.agents/skills/flotilla-views/SKILL.md +++ b/.agents/skills/flotilla-views/SKILL.md @@ -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`. diff --git a/src/app/components/HomeHosting.svelte b/src/app/components/HomeHosting.svelte index 21f80fdf..6cde8a2f 100644 --- a/src/app/components/HomeHosting.svelte +++ b/src/app/components/HomeHosting.svelte @@ -1,5 +1,4 @@ @@ -41,7 +27,7 @@ Manage {/if} {/snippet} - {#if loading} + {#if $tenantRelays.loading}
Checking your hosted spaces…
diff --git a/src/app/components/hosting/BillingPrompts.svelte b/src/app/components/hosting/BillingPrompts.svelte index 4ecfdb0d..e3346791 100644 --- a/src/app/components/hosting/BillingPrompts.svelte +++ b/src/app/components/hosting/BillingPrompts.svelte @@ -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")}), + ) }) @@ -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, diff --git a/src/app/components/hosting/CustomDomainModal.svelte b/src/app/components/hosting/CustomDomainModal.svelte index 92fc7f47..5ee70fae 100644 --- a/src/app/components/hosting/CustomDomainModal.svelte +++ b/src/app/components/hosting/CustomDomainModal.svelte @@ -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) @@ -78,7 +70,7 @@ Custom domain Point a domain you control at this relay instead of the default - {canonicalRelayHost(current)} address. + {canonicalRelayHost(relay)} address. @@ -88,7 +80,7 @@

Domain

{/snippet} {#snippet secondary()} - {#if current.custom_domain && flagToBool(current.custom_domain_verified, false)} + {#if relay.custom_domain && flagToBool(relay.custom_domain_verified, false)} Verified diff --git a/src/app/components/hosting/DataTransferModal.svelte b/src/app/components/hosting/DataTransferModal.svelte index fd59f7cc..8e45f6e7 100644 --- a/src/app/components/hosting/DataTransferModal.svelte +++ b/src/app/components/hosting/DataTransferModal.svelte @@ -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 } diff --git a/src/app/components/hosting/PaymentDialog.svelte b/src/app/components/hosting/PaymentDialog.svelte index 12f2bb03..3bcbc580 100644 --- a/src/app/components/hosting/PaymentDialog.svelte +++ b/src/app/components/hosting/PaymentDialog.svelte @@ -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 } diff --git a/src/app/components/hosting/PaymentSetup.svelte b/src/app/components/hosting/PaymentSetup.svelte index 85170335..5b9f25bf 100644 --- a/src/app/components/hosting/PaymentSetup.svelte +++ b/src/app/components/hosting/PaymentSetup.svelte @@ -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 } diff --git a/src/app/components/hosting/PlanModal.svelte b/src/app/components/hosting/PlanModal.svelte index 3495c22e..49d2729c 100644 --- a/src/app/components/hosting/PlanModal.svelte +++ b/src/app/components/hosting/PlanModal.svelte @@ -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 } } diff --git a/src/app/components/hosting/RelayCreate.svelte b/src/app/components/hosting/RelayCreate.svelte index b6ea3858..3d964561 100644 --- a/src/app/components/hosting/RelayCreate.svelte +++ b/src/app/components/hosting/RelayCreate.svelte @@ -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) } } - + diff --git a/src/app/components/hosting/RelayDetailCard.svelte b/src/app/components/hosting/RelayDetailCard.svelte index 8d719885..2aa7641d 100644 --- a/src/app/components/hosting/RelayDetailCard.svelte +++ b/src/app/components/hosting/RelayDetailCard.svelte @@ -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) +{#snippet flagToggle(title: string, flag: RelayFlag, gated = false)} + + {#snippet label()} + {title} + {/snippet} + {#snippet input()} + + {/snippet} + +{/snippet} +
- +

{relayLabel}

- - {ucFirst(current.status.replace(/_/g, " "))} + + {ucFirst(relay.status.replace(/_/g, " "))} - - {ucFirst(current.plan_id)} + + {ucFirst(relay.plan_id)}
wss://{host} - {#if current.info_description.trim()} -

{current.info_description}

+ {#if relay.info_description.trim()} +

{relay.info_description}

{/if}
@@ -280,7 +264,7 @@
  • - {#if current.status === "active"} + {#if relay.status === "active"}
  • - {#if current.sync_error} + {#if relay.sync_error}

    Provisioning error

    -

    {current.sync_error}

    +

    {relay.sync_error}

    {/if} @@ -313,9 +297,9 @@

    Custom Domain

    - {#if current.custom_domain} + {#if relay.custom_domain}
    - {current.custom_domain} + {relay.custom_domain} {#if domainVerified} @@ -338,7 +322,7 @@ {/if}
    - {#if current.custom_domain && !domainVerified} + {#if relay.custom_domain && !domainVerified}
    @@ -348,7 +332,7 @@
    - {current.custom_domain} + {relay.custom_domain} {recordType} {recordTarget} @@ -383,56 +367,11 @@

    Policy

    - - {#snippet label()} - Public read - {/snippet} - {#snippet input()} - - {/snippet} - - - {#snippet label()} - Public write - {/snippet} - {#snippet input()} - - {/snippet} - - - {#snippet label()} - Public join - {/snippet} - {#snippet input()} - - {/snippet} - - - {#snippet label()} - Members can invite others - {/snippet} - {#snippet input()} - - {/snippet} - - - {#snippet label()} - Strip signatures - {/snippet} - {#snippet input()} - - {/snippet} - + {@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")}
    @@ -441,60 +380,11 @@

    Features

    - - {#snippet label()} - Rooms - {/snippet} - {#snippet input()} - - {/snippet} - - - {#snippet label()} - Management API - {/snippet} - {#snippet input()} - - {/snippet} - - - {#snippet label()} - Push notifications - {/snippet} - {#snippet input()} - - {/snippet} - - - {#snippet label()} - Media storage - {/snippet} - {#snippet input()} - - {/snippet} - - - {#snippet label()} - LiveKit support - {/snippet} - {#snippet input()} - - {/snippet} - + {@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)}
    @@ -516,7 +406,7 @@ Member limit {/snippet} {#snippet input()} - {@const plan = $plans.find(spec({id: current.plan_id}))} + {@const plan = $plans.find(spec({id: relay.plan_id}))} {typeof plan?.members === "number" ? plan.members : "∞"} @@ -534,7 +424,7 @@ Current plan {/snippet} {#snippet input()} - {ucFirst(current.plan_id)} + {ucFirst(relay.plan_id)} {/snippet}
    diff --git a/src/app/components/hosting/RelayForm.svelte b/src/app/components/hosting/RelayForm.svelte index 7e1aebcc..d0a9b69c 100644 --- a/src/app/components/hosting/RelayForm.svelte +++ b/src/app/components/hosting/RelayForm.svelte @@ -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 } diff --git a/src/app/hosting.ts b/src/app/hosting.ts index 16c463ee..e629d334 100644 --- a/src/app/hosting.ts +++ b/src/app/hosting.ts @@ -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 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 = { + 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, ): 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, +) => { + 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([], set => { - listPlans().then(set, () => set([])) +export type TenantRelays = {loading: boolean; relays: HostedRelay[]} + +export type TenantBilling = { + tenant: Maybe + invoices: Invoice[] + draftInvoice: Maybe +} + +// 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> + + private relayState = writable({loading: true, relays: []}) + + billing = writable({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([], 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({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}>> => { + 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([], set => { listRelayMembers(id).then( @@ -442,15 +589,7 @@ export const deriveRelayActivity = (id: Readable>) => export type HostedRelayState = {loading: boolean; relay: Maybe} export const deriveHostedRelay = (url: string) => - derived( - 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, HostedRelayState>(hosting.get().relays, ({loading, relays}) => ({ + loading, + relay: relays.find(r => getHostedRelayUrl(r) === url), + })) diff --git a/src/routes/settings/hosting/+page.svelte b/src/routes/settings/hosting/+page.svelte index 4b78646f..21a58d04 100644 --- a/src/routes/settings/hosting/+page.svelte +++ b/src/routes/settings/hosting/+page.svelte @@ -1,8 +1,7 @@