+ Cancel subscription stops billing and suspends your hub; your
+ encrypted backup is retained so you can re-subscribe or export.
+ Delete my data destroys the hub and its backup — this is
+ irreversible, and not even we can recover it (we only ever hold encrypted bytes).
+
${inner}`)
+}
diff --git a/apps/cloud/src/device-grant.ts b/apps/cloud/src/device-grant.ts
new file mode 100644
index 000000000..fe8f1a8a7
--- /dev/null
+++ b/apps/cloud/src/device-grant.ts
@@ -0,0 +1,109 @@
+/**
+ * xNet Cloud — device-grant "claim your hub" flow (RFC 8628 shaped).
+ *
+ * The non-custodial app must NOT embed WorkOS. Instead it creates its passkey DID
+ * locally and gets *claimed* by the already-authenticated dashboard (exploration
+ * 0192): the app shows a short `userCode`, the user approves it in the dashboard
+ * (proving the billing identity), and the app polls with a signed DID challenge
+ * (proving the data identity). The control plane then runs the dual-proof bind.
+ *
+ * In-memory store to start (same Phase 0/1 stance as the tenant registry); the
+ * code generator is injectable so tests are deterministic.
+ */
+
+import { randomInt } from 'node:crypto'
+
+export interface DeviceGrant {
+ /** Long opaque code the app polls with (kept secret to the app). */
+ deviceCode: string
+ /** Short human-readable code the user types into the dashboard (e.g. `ABCD-7K2P`). */
+ userCode: string
+ /** The data DID the app intends to bind. */
+ did: string
+ status: 'pending' | 'approved' | 'claimed'
+ /** WorkOS billing user that approved this device (set on approval). */
+ approvedBy?: string
+ createdAtMs: number
+}
+
+/** How long a device code is valid before the user must restart (10 minutes). */
+export const DEVICE_GRANT_TTL_MS = 10 * 60 * 1000
+
+export interface CodeGenerator {
+ deviceCode(): string
+ userCode(): string
+}
+
+// Crockford-ish alphabet: no 0/O/1/I/L/U to avoid ambiguity and accidental words.
+const USER_ALPHABET = '23456789ABCDEFGHJKMNPQRSTVWXYZ'
+
+export const cryptoCodes: CodeGenerator = {
+ deviceCode() {
+ let s = ''
+ for (let i = 0; i < 40; i++) s += USER_ALPHABET[randomInt(USER_ALPHABET.length)]
+ return s
+ },
+ userCode() {
+ const pick = (): string => USER_ALPHABET[randomInt(USER_ALPHABET.length)]
+ return `${pick()}${pick()}${pick()}${pick()}-${pick()}${pick()}${pick()}${pick()}`
+ }
+}
+
+export interface DeviceGrantStore {
+ start(did: string, nowMs: number): DeviceGrant
+ getByDeviceCode(deviceCode: string): DeviceGrant | null
+ getByUserCode(userCode: string): DeviceGrant | null
+ /** Mark a device approved by a billing identity. Returns the grant, or null if unknown. */
+ approve(userCode: string, billingUserId: string): DeviceGrant | null
+ markClaimed(deviceCode: string): void
+}
+
+export class MemoryDeviceGrantStore implements DeviceGrantStore {
+ private readonly byDevice = new Map()
+ private readonly byUser = new Map() // userCode -> deviceCode
+
+ constructor(private readonly codes: CodeGenerator = cryptoCodes) {}
+
+ start(did: string, nowMs: number): DeviceGrant {
+ const grant: DeviceGrant = {
+ deviceCode: this.codes.deviceCode(),
+ userCode: this.codes.userCode(),
+ did,
+ status: 'pending',
+ createdAtMs: nowMs
+ }
+ this.byDevice.set(grant.deviceCode, grant)
+ this.byUser.set(grant.userCode, grant.deviceCode)
+ return { ...grant }
+ }
+
+ getByDeviceCode(deviceCode: string): DeviceGrant | null {
+ const g = this.byDevice.get(deviceCode)
+ return g ? { ...g } : null
+ }
+
+ getByUserCode(userCode: string): DeviceGrant | null {
+ const code = this.byUser.get(userCode.trim().toUpperCase())
+ return code ? this.getByDeviceCode(code) : null
+ }
+
+ approve(userCode: string, billingUserId: string): DeviceGrant | null {
+ const code = this.byUser.get(userCode.trim().toUpperCase())
+ if (!code) return null
+ const g = this.byDevice.get(code)
+ if (!g) return null
+ g.status = 'approved'
+ g.approvedBy = billingUserId
+ return { ...g }
+ }
+
+ markClaimed(deviceCode: string): void {
+ const g = this.byDevice.get(deviceCode)
+ if (g) g.status = 'claimed'
+ }
+}
+
+/** True when a grant has aged past its TTL and must be restarted. */
+export function isExpired(grant: DeviceGrant, nowMs: number): boolean {
+ return nowMs - grant.createdAtMs > DEVICE_GRANT_TTL_MS
+}
diff --git a/apps/cloud/src/funnel.test.ts b/apps/cloud/src/funnel.test.ts
new file mode 100644
index 000000000..68e4dce71
--- /dev/null
+++ b/apps/cloud/src/funnel.test.ts
@@ -0,0 +1,195 @@
+import { createHmac } from 'node:crypto'
+import { MemoryBillingIdentityProvider } from '@xnetjs/cloud/identity'
+import { describe, expect, it } from 'vitest'
+import { FakeTenantBillingGateway } from './billing-gateway'
+import { createControlPlaneApp } from './server'
+import { buildControlPlane } from './index'
+
+const SESSION_SECRET = 'sess-secret'
+
+/** Build an app with the billing funnel wired (keyless fake gateway). */
+function funnelApp(opts: { webhookSecret?: string } = {}) {
+ const billing = new MemoryBillingIdentityProvider('https://auth.test/authorize')
+ billing.seed({ id: 'user_a', email: 'a@example.com', emailVerified: true }, 'code_a')
+ const { controlPlane } = buildControlPlane({ billing })
+ const app = createControlPlaneApp({
+ controlPlane,
+ billing,
+ payments: new FakeTenantBillingGateway(opts.webhookSecret),
+ sessionSecret: SESSION_SECRET,
+ baseUrl: ''
+ })
+ return { app, controlPlane }
+}
+
+/** Extract the session cookie pair from a Set-Cookie response header. */
+function cookieFrom(res: Response): string {
+ const setCookie = res.headers.get('set-cookie') ?? ''
+ return setCookie.split(';')[0]
+}
+
+async function signIn(app: ReturnType['app']): Promise {
+ const res = await app.request('/auth/callback?code=code_a&state=personal')
+ expect(res.status).toBe(302)
+ expect(res.headers.get('location')).toBe('/dashboard?plan=personal')
+ return cookieFrom(res)
+}
+
+describe('xNet Cloud signup → provision → manage funnel', () => {
+ it('seals a session on the WorkOS callback', async () => {
+ const { app } = funnelApp()
+ const cookie = await signIn(app)
+ expect(cookie).toContain('xnet_cloud_session=')
+ })
+
+ it('rejects an invalid auth code', async () => {
+ const { app } = funnelApp()
+ const res = await app.request('/auth/callback?code=nope')
+ expect(res.status).toBe(401)
+ })
+
+ it('redirects the dashboard to sign-in when unauthenticated', async () => {
+ const { app } = funnelApp()
+ const res = await app.request('/dashboard')
+ expect(res.status).toBe(302)
+ expect(res.headers.get('location')).toBe('/auth/start')
+ })
+
+ it('shows the plan picker before a hub exists', async () => {
+ const { app } = funnelApp()
+ const cookie = await signIn(app)
+ const res = await app.request('/dashboard', { headers: { cookie } })
+ expect(res.status).toBe(200)
+ const html = await res.text()
+ expect(html).toContain('Welcome to xNet Cloud')
+ expect(html).toContain('action="/checkout"')
+ })
+
+ it('runs checkout → webhook → provision → dashboard end to end', async () => {
+ const { app } = funnelApp()
+ const cookie = await signIn(app)
+
+ // Checkout redirects to the hosted (fake) checkout URL.
+ const checkout = await app.request('/checkout', {
+ method: 'POST',
+ headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' },
+ body: 'plan=personal'
+ })
+ expect(checkout.status).toBe(302)
+ expect(checkout.headers.get('location')).toContain('fake_checkout=personal')
+
+ // The provider webhook provisions the hub.
+ const hook = await app.request('/webhook', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ type: 'checkout.completed', customerRef: 'user_a', plan: 'personal' })
+ })
+ expect(hook.status).toBe(200)
+
+ // The tenant is now readable and tied to the billing user, DID not yet bound.
+ const tenant = await app.request('/tenants/t_user_a')
+ expect(tenant.status).toBe(200)
+ expect(await tenant.json()).toMatchObject({
+ plan: 'personal',
+ billingUserId: 'user_a',
+ did: '',
+ subscriptionStatus: 'active'
+ })
+
+ // The dashboard now shows the hub.
+ const dash = await app.request('/dashboard', { headers: { cookie } })
+ const html = await dash.text()
+ expect(html).toContain('Your hub')
+ expect(html).toContain('Connect your app')
+ })
+
+ it('is idempotent across replayed checkout webhooks', async () => {
+ const { app, controlPlane } = funnelApp()
+ const body = JSON.stringify({
+ type: 'checkout.completed',
+ customerRef: 'user_a',
+ plan: 'personal'
+ })
+ const post = () =>
+ app.request('/webhook', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body
+ })
+ await post()
+ await post()
+ const all = await controlPlane.getTenantForBilling('user_a')
+ expect(all?.tenantId).toBe('t_user_a')
+ // Only one tenant exists for the billing user.
+ expect((await controlPlane.getTenant('t_user_a'))?.plan).toBe('personal')
+ })
+
+ it('suspends on cancellation and retains the record', async () => {
+ const { app, controlPlane } = funnelApp()
+ await app.request('/webhook', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ type: 'checkout.completed', customerRef: 'user_a', plan: 'personal' })
+ })
+ await app.request('/webhook', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ type: 'customer.subscription.deleted', customerRef: 'user_a' })
+ })
+ const tenant = await controlPlane.getTenant('t_user_a')
+ expect(tenant).toMatchObject({ subscriptionStatus: 'canceled', dataTier: 'cold', hubUrl: '' })
+ })
+
+ it('opens the billing portal for an authenticated user', async () => {
+ const { app } = funnelApp()
+ const cookie = await signIn(app)
+ const res = await app.request('/portal', { method: 'POST', headers: { cookie } })
+ expect(res.status).toBe(302)
+ expect(res.headers.get('location')).toContain('billing.local/portal')
+ })
+
+ it('deletes data and returns to the empty dashboard', async () => {
+ const { app, controlPlane } = funnelApp()
+ const cookie = await signIn(app)
+ await app.request('/webhook', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ type: 'checkout.completed', customerRef: 'user_a', plan: 'personal' })
+ })
+ const del = await app.request('/account/delete-data', { method: 'POST', headers: { cookie } })
+ expect(del.status).toBe(302)
+ expect(await controlPlane.getTenant('t_user_a')).toBeNull()
+ })
+
+ it('guards checkout + portal behind a session', async () => {
+ const { app } = funnelApp()
+ expect((await app.request('/checkout', { method: 'POST', body: 'plan=personal' })).status).toBe(
+ 401
+ )
+ expect((await app.request('/portal', { method: 'POST' })).status).toBe(401)
+ })
+
+ it('verifies the webhook signature when a secret is configured', async () => {
+ const { app } = funnelApp({ webhookSecret: 'whsec' })
+ const body = JSON.stringify({
+ type: 'checkout.completed',
+ customerRef: 'user_a',
+ plan: 'personal'
+ })
+
+ const unsigned = await app.request('/webhook', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body
+ })
+ expect(unsigned.status).toBe(401)
+
+ const sig = createHmac('sha256', 'whsec').update(body).digest('hex')
+ const signed = await app.request('/webhook', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', 'x-xnet-signature': sig },
+ body
+ })
+ expect(signed.status).toBe(200)
+ })
+})
diff --git a/apps/cloud/src/index.ts b/apps/cloud/src/index.ts
index 7d9d3c15b..f32e1bd5e 100644
--- a/apps/cloud/src/index.ts
+++ b/apps/cloud/src/index.ts
@@ -16,6 +16,7 @@ import {
type DidChallengeVerifier
} from '@xnetjs/cloud/identity'
import { MemoryProvisioner, type Provisioner } from '@xnetjs/cloud/provisioner'
+import { FakeTenantBillingGateway, type TenantBillingGateway } from './billing-gateway'
import { ControlPlane } from './control-plane'
import { MemoryTenantStore } from './registry'
import { createControlPlaneApp } from './server'
@@ -23,6 +24,22 @@ import { createControlPlaneApp } from './server'
export { ControlPlane } from './control-plane'
export { MemoryTenantStore, type TenantRecord, type TenantStore } from './registry'
export { createControlPlaneApp, type ControlPlaneAppDeps } from './server'
+export {
+ FakeTenantBillingGateway,
+ PRICE_BY_PLAN,
+ WebhookSignatureError,
+ type TenantBillingGateway
+} from './billing-gateway'
+export { sealSession, readSession, SESSION_COOKIE, type SessionData } from './session'
+export {
+ MemoryDeviceGrantStore,
+ cryptoCodes,
+ isExpired,
+ DEVICE_GRANT_TTL_MS,
+ type DeviceGrant,
+ type DeviceGrantStore,
+ type CodeGenerator
+} from './device-grant'
/**
* Pick the billing identity provider from the environment. WorkOS AuthKit (free
@@ -71,16 +88,28 @@ export function buildControlPlane(options: BuildControlPlaneOptions = {}): {
return { controlPlane, billing }
}
+/**
+ * Pick the plan-subscription gateway. The real Stripe adapter is deferred; until
+ * then a keyless fake drives the funnel locally (and in tests). The webhook secret,
+ * when set, makes the fake require a signed webhook.
+ */
+export function resolveBillingGateway(env: NodeJS.ProcessEnv = process.env): TenantBillingGateway {
+ return new FakeTenantBillingGateway(env.XNET_CLOUD_WEBHOOK_SECRET)
+}
+
function start(): void {
const { controlPlane, billing } = buildControlPlane()
+ const env = process.env
const app = createControlPlaneApp({
controlPlane,
billing,
- ...(process.env.XNET_CLOUD_INTERNAL_SECRET
- ? { internalSecret: process.env.XNET_CLOUD_INTERNAL_SECRET }
- : {})
+ payments: resolveBillingGateway(env),
+ sessionSecret: env.XNET_CLOUD_SESSION_SECRET ?? 'dev-insecure-session-secret',
+ baseUrl: env.XNET_CLOUD_BASE_URL ?? '',
+ marketingUrl: env.XNET_CLOUD_MARKETING_URL ?? 'https://xnet.fyi/cloud',
+ ...(env.XNET_CLOUD_INTERNAL_SECRET ? { internalSecret: env.XNET_CLOUD_INTERNAL_SECRET } : {})
})
- const port = Number(process.env.PORT ?? 4455)
+ const port = Number(env.PORT ?? 4455)
serve({ fetch: app.fetch, port })
// eslint-disable-next-line no-console
console.log(`xnet-cloud control plane listening on :${port} (billing: ${billing.name})`)
diff --git a/apps/cloud/src/registry.ts b/apps/cloud/src/registry.ts
index 1222330de..9c7caed04 100644
--- a/apps/cloud/src/registry.ts
+++ b/apps/cloud/src/registry.ts
@@ -27,12 +27,20 @@ export interface TenantRecord {
lastActiveMs: number
/** `hot` = live hub; `cold` = DB lives only in R2, restored on reactivation. */
dataTier: 'hot' | 'cold'
+ /**
+ * Subscription lifecycle from the billing provider's view. `active` while paid;
+ * `canceled` after a cancel webhook (hub suspended, R2 retained until deleted).
+ * Undefined for tenants provisioned by the internal/admin route.
+ */
+ subscriptionStatus?: 'active' | 'canceled'
}
export interface TenantStore {
get(tenantId: string): Promise
put(record: TenantRecord): Promise
list(): Promise
+ /** Forget a tenant entirely (the "delete my data" path). */
+ delete(tenantId: string): Promise
}
export class MemoryTenantStore implements TenantStore {
@@ -50,4 +58,8 @@ export class MemoryTenantStore implements TenantStore {
async list(): Promise {
return [...this.records.values()].map((r) => ({ ...r }))
}
+
+ async delete(tenantId: string): Promise {
+ this.records.delete(tenantId)
+ }
}
diff --git a/apps/cloud/src/server.ts b/apps/cloud/src/server.ts
index 5f9ea16e6..b4cc6599f 100644
--- a/apps/cloud/src/server.ts
+++ b/apps/cloud/src/server.ts
@@ -1,22 +1,44 @@
/**
- * xNet Cloud — HTTP control-plane API (Hono).
+ * xNet Cloud — HTTP control-plane API + dashboard (Hono).
*
- * A thin JSON surface over the {@link ControlPlane}. The provisioning/plan/recovery
- * routes are "internal" (driven by Stripe webhooks + authenticated sessions in
- * production) and gated by a shared secret in this initial cut; `/auth/start` shows
- * the WorkOS AuthKit hand-off. Kept framework-light and synchronous-to-test:
- * exercise it with `app.request(...)` — no real socket needed.
+ * Three surfaces over the {@link ControlPlane}:
+ * - Public auth + checkout funnel: `/auth/start`, `/auth/callback`, `/checkout`,
+ * `/portal`, `/webhook` — the signup → pay → provision spine (exploration 0192).
+ * - The authenticated dashboard: `/dashboard`, `/logout`, `/account/delete-data`,
+ * served same-origin so the sealed session cookie is read without CORS.
+ * - Internal routes (`/internal/*`) driven by admin tooling, gated by a shared secret.
+ *
+ * Kept framework-light and synchronous-to-test: exercise it with `app.request(...)`.
*/
import type { ControlPlane } from './control-plane'
import type { BillingIdentityProvider, DidChallenge } from '@xnetjs/cloud/identity'
+import type { PlanId } from '@xnetjs/entitlements'
+import type { Context } from 'hono'
import { Hono } from 'hono'
+import { getCookie, setCookie, deleteCookie } from 'hono/cookie'
+import { WebhookSignatureError, type TenantBillingGateway } from './billing-gateway'
+import { renderClaimForm, renderClaimResult, renderDashboard } from './dashboard'
+import { MemoryDeviceGrantStore, isExpired, type DeviceGrantStore } from './device-grant'
+import { SESSION_COOKIE, readSession, sealSession, type SessionData } from './session'
export interface ControlPlaneAppDeps {
controlPlane: ControlPlane
billing: BillingIdentityProvider
+ /** Plan-subscription gateway (Stripe/fake). If unset, checkout/portal/webhook are 503. */
+ payments?: TenantBillingGateway
+ /** Device-grant store for the "claim your hub" flow. Defaults to in-memory. */
+ deviceGrants?: DeviceGrantStore
+ /** Secret used to sign session cookies. If unset, the dashboard + auth callback are disabled. */
+ sessionSecret?: string
+ /** Absolute origin for building checkout success/cancel URLs (e.g. https://cloud.xnet.fyi). */
+ baseUrl?: string
+ /** Where to send the user after sign-out (the marketing site). */
+ marketingUrl?: string
/** Shared secret for internal routes; if unset, internal routes are disabled. */
internalSecret?: string
+ /** Injectable clock for deterministic tests. */
+ nowMs?: () => number
}
interface ProvisionBody {
@@ -28,17 +50,35 @@ interface ProvisionBody {
region?: string
}
+/** Plans offered for self-serve checkout (free demo + contract enterprise excluded). */
+const CHECKOUT_PLANS: { id: PlanId; label: string; price: string }[] = [
+ { id: 'personal', label: 'Personal', price: '$5/mo' },
+ { id: 'family', label: 'Family', price: '$15/mo' },
+ { id: 'team', label: 'Team', price: '$12/seat/mo' }
+]
+
export function createControlPlaneApp(deps: ControlPlaneAppDeps): Hono {
const app = new Hono()
+ const now = (): number => (deps.nowMs ? deps.nowMs() : Date.now())
+ const base = deps.baseUrl ?? ''
+ const devices = deps.deviceGrants ?? new MemoryDeviceGrantStore()
+
+ /** Read + verify the session cookie, or null. */
+ const session = (c: Context): SessionData | null => {
+ if (!deps.sessionSecret) return null
+ return readSession(deps.sessionSecret, getCookie(c, SESSION_COOKIE), { nowMs: now() })
+ }
app.get('/health', (c) =>
c.json({ status: 'ok', service: 'xnet-cloud', substrate: deps.controlPlane ? 'ready' : 'init' })
)
- // Start a WorkOS AuthKit sign-in; the callback (not built here) exchanges the
- // code via billing.authenticateWithCode and seals a session.
+ // ── Auth funnel ───────────────────────────────────────────────────────────
+
+ // Start a WorkOS AuthKit sign-in. The marketing CTA passes `?plan=…`, which we
+ // round-trip through `state` so the callback can land the user on checkout.
app.get('/auth/start', (c) => {
- const state = c.req.query('state')
+ const state = c.req.query('plan') ?? c.req.query('state')
const url = deps.billing.getAuthorizationUrl({
screenHint: 'sign-in',
...(state ? { state } : {})
@@ -46,13 +86,195 @@ export function createControlPlaneApp(deps: ControlPlaneAppDeps): Hono {
return c.redirect(url)
})
+ // Exchange the WorkOS code for a user and seal a session cookie (the hole 0180
+ // flagged at server.ts:38). Lands on the dashboard, carrying any chosen plan.
+ app.get('/auth/callback', async (c) => {
+ if (!deps.sessionSecret) return c.json({ error: 'auth_not_configured' }, 503)
+ const code = c.req.query('code')
+ if (!code) return c.json({ error: 'missing_code' }, 400)
+ let user
+ try {
+ const result = await deps.billing.authenticateWithCode(code)
+ user = result.user
+ } catch {
+ return c.json({ error: 'invalid_code' }, 401)
+ }
+ const token = sealSession(deps.sessionSecret, {
+ billingUserId: user.id,
+ ...(user.email ? { email: user.email } : {}),
+ issuedAtMs: now()
+ })
+ setCookie(c, SESSION_COOKIE, token, {
+ httpOnly: true,
+ secure: true,
+ sameSite: 'Lax',
+ path: '/',
+ maxAge: 7 * 24 * 60 * 60
+ })
+ const plan = c.req.query('state')
+ return c.redirect(
+ plan ? `${base}/dashboard?plan=${encodeURIComponent(plan)}` : `${base}/dashboard`
+ )
+ })
+
+ app.get('/logout', (c) => {
+ deleteCookie(c, SESSION_COOKIE, { path: '/' })
+ return c.redirect(deps.marketingUrl ?? '/')
+ })
+
+ // ── Dashboard ───────────────────────────────────────────────────────────────
+
+ app.get('/dashboard', async (c) => {
+ const s = session(c)
+ if (!s) return c.redirect('/auth/start')
+ const tenant = await deps.controlPlane.getTenantForBilling(s.billingUserId)
+ return c.html(
+ renderDashboard({
+ billingUserId: s.billingUserId,
+ ...(s.email ? { email: s.email } : {}),
+ tenant,
+ checkoutPlans: CHECKOUT_PLANS,
+ billingEnabled: Boolean(deps.payments)
+ })
+ )
+ })
+
+ // ── Checkout + portal + webhook ──────────────────────────────────────────────
+
+ app.post('/checkout', async (c) => {
+ const s = session(c)
+ if (!s) return c.json({ error: 'unauthorized' }, 401)
+ if (!deps.payments) return c.json({ error: 'billing_not_configured' }, 503)
+ const body = await c.req.parseBody()
+ const plan = String(body.plan ?? '')
+ if (!CHECKOUT_PLANS.some((p) => p.id === plan)) return c.json({ error: 'bad_plan' }, 400)
+ const out = await deps.payments.createCheckout({
+ customerRef: s.billingUserId,
+ plan: plan as PlanId,
+ successUrl: `${base}/dashboard?provisioning=1`,
+ cancelUrl: `${base}/dashboard`,
+ ...(s.email ? { email: s.email } : {})
+ })
+ return c.redirect(out.url)
+ })
+
+ app.post('/portal', async (c) => {
+ const s = session(c)
+ if (!s) return c.json({ error: 'unauthorized' }, 401)
+ if (!deps.payments) return c.json({ error: 'billing_not_configured' }, 503)
+ const out = await deps.payments.createPortal({
+ customerRef: s.billingUserId,
+ returnUrl: `${base}/dashboard`
+ })
+ return c.redirect(out.url)
+ })
+
+ // Provider webhook — unauthenticated, verified by the gateway's signature check.
+ // `checkout.completed` provisions a hub; `subscription.canceled` suspends it.
+ app.post('/webhook', async (c) => {
+ if (!deps.payments) return c.json({ error: 'billing_not_configured' }, 503)
+ const raw = await c.req.text()
+ const headers: Record = {}
+ c.req.raw.headers.forEach((v, k) => (headers[k] = v))
+ let event
+ try {
+ event = await deps.payments.parseWebhook(raw, headers)
+ } catch (err) {
+ if (err instanceof WebhookSignatureError) return c.json({ error: 'bad_signature' }, 401)
+ return c.json({ error: 'bad_webhook' }, 400)
+ }
+ if (event.type === 'checkout.completed') {
+ await deps.controlPlane.provisionForBilling({
+ plan: event.plan,
+ billingUserId: event.customerRef
+ })
+ } else if (event.type === 'subscription.canceled') {
+ const tenant = await deps.controlPlane.getTenantForBilling(event.customerRef)
+ if (tenant) await deps.controlPlane.suspendTenant(tenant.tenantId)
+ }
+ return c.json({ received: true })
+ })
+
+ // ── Account management ────────────────────────────────────────────────────────
+
+ app.post('/account/delete-data', async (c) => {
+ const s = session(c)
+ if (!s) return c.json({ error: 'unauthorized' }, 401)
+ const tenant = await deps.controlPlane.getTenantForBilling(s.billingUserId)
+ if (tenant) await deps.controlPlane.deleteTenant(tenant.tenantId)
+ return c.redirect('/dashboard')
+ })
+
+ // ── Device-grant "claim your hub" flow (RFC 8628 shaped) ─────────────────────
+
+ // The app (no WorkOS) starts a grant with its locally-created DID, then polls.
+ app.post('/device/start', async (c) => {
+ const body = (await c.req.json().catch(() => ({}))) as { did?: string }
+ if (!body.did) return c.json({ error: 'missing_did' }, 400)
+ const grant = devices.start(body.did, now())
+ return c.json({
+ deviceCode: grant.deviceCode,
+ userCode: grant.userCode,
+ verificationUri: `${base}/claim`,
+ intervalSec: 2,
+ expiresInSec: 600
+ })
+ })
+
+ // The app polls here with a DID challenge until the user approves the code.
+ app.post('/device/token', async (c) => {
+ const body = (await c.req.json().catch(() => ({}))) as {
+ deviceCode?: string
+ challenge?: DidChallenge
+ }
+ if (!body.deviceCode || !body.challenge) return c.json({ error: 'bad_request' }, 400)
+ const grant = devices.getByDeviceCode(body.deviceCode)
+ if (!grant) return c.json({ error: 'invalid_grant' }, 400)
+ if (isExpired(grant, now())) return c.json({ error: 'expired_token' }, 400)
+ // The polled DID must be the one the grant was started with (and was shown).
+ if (body.challenge.did !== grant.did) return c.json({ error: 'did_mismatch' }, 400)
+ if (grant.status === 'pending') return c.json({ status: 'pending' })
+ if (!grant.approvedBy) return c.json({ status: 'pending' })
+ try {
+ const tenant = await deps.controlPlane.bindDataIdentity({
+ billingUserId: grant.approvedBy,
+ challenge: body.challenge
+ })
+ devices.markClaimed(grant.deviceCode)
+ return c.json({ status: 'complete', hubUrl: tenant.hubUrl })
+ } catch (err) {
+ return c.json({ error: (err as Error).message }, 422)
+ }
+ })
+
+ // The dashboard side: the signed-in user approves a device code (proves billing).
+ app.get('/claim', (c) => {
+ const s = session(c)
+ if (!s) return c.redirect('/auth/start')
+ return c.html(
+ renderClaimForm({
+ who: s.email ?? s.billingUserId,
+ ...(c.req.query('code') ? { prefill: c.req.query('code') as string } : {})
+ })
+ )
+ })
+
+ app.post('/claim', async (c) => {
+ const s = session(c)
+ if (!s) return c.redirect('/auth/start')
+ const body = await c.req.parseBody()
+ const userCode = String(body.userCode ?? '')
+ const grant = devices.approve(userCode, s.billingUserId)
+ return c.html(renderClaimResult({ who: s.email ?? s.billingUserId, ok: Boolean(grant) }))
+ })
+
app.get('/tenants/:id', async (c) => {
const record = await deps.controlPlane.getTenant(c.req.param('id'))
if (!record) return c.json({ error: 'not_found' }, 404)
return c.json(record)
})
- // ── Internal routes (Stripe webhook / admin) ─────────────────────────────
+ // ── Internal routes (admin tooling) ──────────────────────────────────────────
const requireInternal = (c: { req: { header: (k: string) => string | undefined } }): boolean =>
Boolean(deps.internalSecret) && c.req.header('x-internal-secret') === deps.internalSecret
diff --git a/apps/cloud/src/session.test.ts b/apps/cloud/src/session.test.ts
new file mode 100644
index 000000000..6e6edfc71
--- /dev/null
+++ b/apps/cloud/src/session.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest'
+import { readSession, sealSession } from './session'
+
+const SECRET = 'test-session-secret'
+
+describe('session sealing', () => {
+ it('round-trips a sealed session', () => {
+ const token = sealSession(SECRET, {
+ billingUserId: 'user_a',
+ email: 'a@x.io',
+ issuedAtMs: 1000
+ })
+ const data = readSession(SECRET, token, { nowMs: 2000 })
+ expect(data).toMatchObject({ billingUserId: 'user_a', email: 'a@x.io' })
+ })
+
+ it('rejects a token signed with a different secret', () => {
+ const token = sealSession('other-secret', { billingUserId: 'user_a', issuedAtMs: 1000 })
+ expect(readSession(SECRET, token, { nowMs: 2000 })).toBeNull()
+ })
+
+ it('rejects a tampered payload', () => {
+ const token = sealSession(SECRET, { billingUserId: 'user_a', issuedAtMs: 1000 })
+ const [, sig] = token.split('.')
+ const forged = `${Buffer.from(JSON.stringify({ billingUserId: 'admin', issuedAtMs: 1000 })).toString('base64url')}.${sig}`
+ expect(readSession(SECRET, forged, { nowMs: 2000 })).toBeNull()
+ })
+
+ it('rejects an expired session', () => {
+ const token = sealSession(SECRET, { billingUserId: 'user_a', issuedAtMs: 0 })
+ expect(readSession(SECRET, token, { nowMs: 8 * 24 * 60 * 60 * 1000 })).toBeNull()
+ })
+
+ it('returns null for missing / malformed tokens', () => {
+ expect(readSession(SECRET, undefined)).toBeNull()
+ expect(readSession(SECRET, 'no-dot')).toBeNull()
+ expect(readSession(SECRET, '.sigonly')).toBeNull()
+ })
+})
diff --git a/apps/cloud/src/session.ts b/apps/cloud/src/session.ts
new file mode 100644
index 000000000..3a75b011e
--- /dev/null
+++ b/apps/cloud/src/session.ts
@@ -0,0 +1,68 @@
+/**
+ * xNet Cloud — control-plane session sealing.
+ *
+ * After WorkOS AuthKit returns a `code`, the callback exchanges it for a billing
+ * user and seals a small session into an httpOnly cookie. We sign (HMAC-SHA256)
+ * rather than pull in a crypto-cookie dependency — the payload (a WorkOS user id)
+ * is not secret, only tamper-proof, and the cookie is httpOnly + SameSite=Lax so
+ * the dashboard can read it on same-origin requests (exploration 0192).
+ *
+ * Pure + injectable-clock so it can be unit-tested with `app.request(...)`.
+ */
+
+import { createHmac, timingSafeEqual } from 'node:crypto'
+
+/** What we remember about a signed-in billing identity. */
+export interface SessionData {
+ /** WorkOS user id — the custodial billing identity. */
+ billingUserId: string
+ email?: string
+ /** Issue time (ms); used to expire the session. */
+ issuedAtMs: number
+}
+
+/** Cookie name the dashboard reads on every authenticated request. */
+export const SESSION_COOKIE = 'xnet_cloud_session'
+
+const DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
+
+/** Seal a session into a signed `.` token. */
+export function sealSession(secret: string, data: SessionData): string {
+ const payload = Buffer.from(JSON.stringify(data)).toString('base64url')
+ const sig = createHmac('sha256', secret).update(payload).digest('base64url')
+ return `${payload}.${sig}`
+}
+
+/**
+ * Verify + decode a sealed session. Returns null on a bad signature, malformed
+ * payload, or an expired token (constant-time signature comparison).
+ */
+export function readSession(
+ secret: string,
+ token: string | undefined,
+ opts: { maxAgeMs?: number; nowMs?: number } = {}
+): SessionData | null {
+ if (!token) return null
+ const dot = token.lastIndexOf('.')
+ if (dot <= 0) return null
+ const payload = token.slice(0, dot)
+ const sig = token.slice(dot + 1)
+ const expected = createHmac('sha256', secret).update(payload).digest('base64url')
+ const got = Buffer.from(sig)
+ const want = Buffer.from(expected)
+ if (got.length !== want.length || !timingSafeEqual(got, want)) return null
+
+ let data: SessionData
+ try {
+ data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as SessionData
+ } catch {
+ return null
+ }
+ if (!data || typeof data.billingUserId !== 'string' || typeof data.issuedAtMs !== 'number') {
+ return null
+ }
+ const maxAge = opts.maxAgeMs ?? DEFAULT_MAX_AGE_MS
+ const now = opts.nowMs ?? Date.now()
+ if (now - data.issuedAtMs > maxAge) return null
+ return data
+}
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index ba6352c5b..052ec19de 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -44,6 +44,7 @@ import {
subscribeXNetStorageCorruption
} from './lib/browser-storage-reset'
import { isWorkerRuntimeEnabled } from './lib/data-runtime'
+import { persistedHubUrl } from './lib/hub-url'
import { identityManager } from './lib/identity'
import { detectBrowserFamily, getStorageBanner } from './lib/storage-banner'
import { recordDurabilityTransition, subscribeStorageStatus } from './lib/storage-durability'
@@ -76,10 +77,15 @@ declare module '@tanstack/react-router' {
const DEFAULT_HUB_URL =
import.meta.env.VITE_HUB_URL ?? (import.meta.env.DEV ? '' : 'wss://hub.xnet.fyi')
+// A hub the user connected via Settings or the xNet Cloud claim flow (persisted in
+// localStorage) wins over the build-time default — this is the read half of that
+// setting, without which "connect your cloud hub" did nothing (exploration 0192).
+const resolveConfiguredHubUrl = (): string => persistedHubUrl(DEFAULT_HUB_URL)
+
if (typeof console !== 'undefined') {
console.info(
'[xNet] hub:',
- DEFAULT_HUB_URL || '(none — local-first; set VITE_HUB_URL to connect to a hub)'
+ resolveConfiguredHubUrl() || '(none — local-first; set a hub in Settings or VITE_HUB_URL)'
)
}
@@ -126,13 +132,13 @@ function resolveHubSessionFromLocation(): { hubUrl: string; authToken: string |
stripParams('payload', 'handle')
}
if (!shareSession) {
- return { hubUrl: DEFAULT_HUB_URL, authToken: null }
+ return { hubUrl: resolveConfiguredHubUrl(), authToken: null }
}
const stored = sessionStorage.getItem(`xnet:share-session:${shareSession}`)
stripParams('shareSession')
if (!stored) {
- return { hubUrl: DEFAULT_HUB_URL, authToken: null }
+ return { hubUrl: resolveConfiguredHubUrl(), authToken: null }
}
sessionStorage.removeItem(`xnet:share-session:${shareSession}`)
@@ -146,7 +152,7 @@ function resolveHubSessionFromLocation(): { hubUrl: string; authToken: string |
!Number.isFinite(session.exp) ||
session.exp <= Date.now()
) {
- return { hubUrl: DEFAULT_HUB_URL, authToken: null }
+ return { hubUrl: resolveConfiguredHubUrl(), authToken: null }
}
return { hubUrl: session.endpoint, authToken: session.token }
diff --git a/apps/web/src/lib/cloud-claim.test.ts b/apps/web/src/lib/cloud-claim.test.ts
new file mode 100644
index 000000000..17b6a5561
--- /dev/null
+++ b/apps/web/src/lib/cloud-claim.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it, vi } from 'vitest'
+import { pollDeviceClaim, startDeviceClaim } from './cloud-claim'
+
+const CHALLENGE = { did: 'did:key:alice', nonce: 'n', signature: 's' }
+
+function jsonResponse(body: unknown, init: { status?: number } = {}): Response {
+ return new Response(JSON.stringify(body), {
+ status: init.status ?? 200,
+ headers: { 'content-type': 'application/json' }
+ })
+}
+
+describe('cloud-claim client', () => {
+ it('starts a device claim and returns the codes', async () => {
+ const fetchImpl = vi.fn().mockResolvedValue(
+ jsonResponse({
+ deviceCode: 'dc',
+ userCode: 'ABCD-7K2P',
+ verificationUri: 'https://cloud.xnet.fyi/claim',
+ intervalSec: 2,
+ expiresInSec: 600
+ })
+ )
+ const start = await startDeviceClaim('https://cloud.xnet.fyi', 'did:key:alice', fetchImpl)
+ expect(start.userCode).toBe('ABCD-7K2P')
+ expect(fetchImpl).toHaveBeenCalledWith(
+ 'https://cloud.xnet.fyi/device/start',
+ expect.objectContaining({ method: 'POST' })
+ )
+ })
+
+ it('reports pending until approval, then complete with the hub URL', async () => {
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValueOnce(jsonResponse({ status: 'pending' }))
+ .mockResolvedValueOnce(jsonResponse({ status: 'complete', hubUrl: 'wss://t.hub.xnet.fyi' }))
+
+ const first = await pollDeviceClaim('https://cloud.xnet.fyi', 'dc', CHALLENGE, fetchImpl)
+ expect(first).toEqual({ status: 'pending' })
+
+ const second = await pollDeviceClaim('https://cloud.xnet.fyi', 'dc', CHALLENGE, fetchImpl)
+ expect(second).toEqual({ status: 'complete', hubUrl: 'wss://t.hub.xnet.fyi' })
+ })
+
+ it('surfaces a control-plane error', async () => {
+ const fetchImpl = vi
+ .fn()
+ .mockResolvedValue(jsonResponse({ error: 'did_mismatch' }, { status: 400 }))
+ const res = await pollDeviceClaim('https://cloud.xnet.fyi', 'dc', CHALLENGE, fetchImpl)
+ expect(res).toEqual({ status: 'error', error: 'did_mismatch' })
+ })
+})
diff --git a/apps/web/src/lib/cloud-claim.ts b/apps/web/src/lib/cloud-claim.ts
new file mode 100644
index 000000000..8043fb60d
--- /dev/null
+++ b/apps/web/src/lib/cloud-claim.ts
@@ -0,0 +1,67 @@
+/**
+ * xNet Cloud — the app side of the device-grant "claim your hub" flow (RFC 8628).
+ *
+ * The app creates its passkey DID locally, then:
+ * 1. `startDeviceClaim` → gets a short `userCode` to show + a `deviceCode` to poll.
+ * 2. The user approves the `userCode` in the signed-in cloud dashboard.
+ * 3. `pollDeviceClaim` → once approved, the control plane binds the DID (dual proof)
+ * and returns the hub URL, which the caller persists via `setPersistedHubUrl`.
+ *
+ * The app never embeds WorkOS — it only ever talks to the control plane's device
+ * endpoints (exploration 0192). `signChallenge` is injected so this stays pure and
+ * testable without the real identity manager.
+ */
+
+export interface DeviceClaimStart {
+ deviceCode: string
+ userCode: string
+ verificationUri: string
+ intervalSec: number
+ expiresInSec: number
+}
+
+export interface DidChallenge {
+ did: string
+ nonce: string
+ signature: string
+}
+
+export type DeviceClaimPoll =
+ | { status: 'pending' }
+ | { status: 'complete'; hubUrl: string }
+ | { status: 'error'; error: string }
+
+/** Begin a device claim for a locally-created DID. */
+export async function startDeviceClaim(
+ cloudOrigin: string,
+ did: string,
+ fetchImpl: typeof fetch = fetch
+): Promise {
+ const res = await fetchImpl(`${cloudOrigin}/device/start`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ did })
+ })
+ if (!res.ok) throw new Error(`device/start failed: ${res.status}`)
+ return (await res.json()) as DeviceClaimStart
+}
+
+/** Poll once for completion. Returns `pending` until the user approves the code. */
+export async function pollDeviceClaim(
+ cloudOrigin: string,
+ deviceCode: string,
+ challenge: DidChallenge,
+ fetchImpl: typeof fetch = fetch
+): Promise {
+ const res = await fetchImpl(`${cloudOrigin}/device/token`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ deviceCode, challenge })
+ })
+ const body = (await res.json().catch(() => ({}))) as Partial & { error?: string }
+ if (!res.ok) return { status: 'error', error: body.error ?? `http_${res.status}` }
+ if (body.status === 'complete' && typeof body.hubUrl === 'string') {
+ return { status: 'complete', hubUrl: body.hubUrl }
+ }
+ return { status: 'pending' }
+}
diff --git a/apps/web/src/lib/hub-url.test.ts b/apps/web/src/lib/hub-url.test.ts
new file mode 100644
index 000000000..e16adb8f1
--- /dev/null
+++ b/apps/web/src/lib/hub-url.test.ts
@@ -0,0 +1,27 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import { HUB_URL_STORAGE_KEY, persistedHubUrl, setPersistedHubUrl } from './hub-url'
+
+afterEach(() => localStorage.clear())
+
+describe('persistedHubUrl', () => {
+ it('falls back to the build-time default when nothing is stored', () => {
+ expect(persistedHubUrl('wss://hub.xnet.fyi')).toBe('wss://hub.xnet.fyi')
+ })
+
+ it('returns the stored hub URL once configured', () => {
+ localStorage.setItem(HUB_URL_STORAGE_KEY, 'wss://t-user-a.hub.xnet.fyi')
+ expect(persistedHubUrl('wss://hub.xnet.fyi')).toBe('wss://t-user-a.hub.xnet.fyi')
+ })
+
+ it('round-trips through setPersistedHubUrl', () => {
+ setPersistedHubUrl('wss://mine.example')
+ expect(persistedHubUrl('fallback')).toBe('wss://mine.example')
+ })
+
+ it('clears the stored value when set to empty', () => {
+ setPersistedHubUrl('wss://mine.example')
+ setPersistedHubUrl('')
+ expect(localStorage.getItem(HUB_URL_STORAGE_KEY)).toBeNull()
+ expect(persistedHubUrl('fallback')).toBe('fallback')
+ })
+})
diff --git a/apps/web/src/lib/hub-url.ts b/apps/web/src/lib/hub-url.ts
new file mode 100644
index 000000000..3990c77c5
--- /dev/null
+++ b/apps/web/src/lib/hub-url.ts
@@ -0,0 +1,33 @@
+/**
+ * Hub URL resolution for the web client.
+ *
+ * The Settings → Network panel (and the xNet Cloud claim flow) persist the hub a
+ * user wants to dial under this key. App.tsx must READ it on startup — without
+ * this, the panel wrote a value nothing ever consumed, so "connect your cloud
+ * hub" silently did nothing (the bug called out in exploration 0192).
+ */
+
+export const HUB_URL_STORAGE_KEY = 'xnet:hub-url'
+
+/**
+ * The persisted hub URL if the user configured one, else `fallback` (the
+ * build-time default). A share-session endpoint, when present, still takes
+ * precedence over this — see `resolveHubSessionFromLocation` in App.tsx.
+ */
+export function persistedHubUrl(fallback: string): string {
+ try {
+ return localStorage.getItem(HUB_URL_STORAGE_KEY) || fallback
+ } catch {
+ return fallback
+ }
+}
+
+/** Persist (or clear, when empty) the hub URL the client should dial. */
+export function setPersistedHubUrl(url: string): void {
+ try {
+ if (url) localStorage.setItem(HUB_URL_STORAGE_KEY, url)
+ else localStorage.removeItem(HUB_URL_STORAGE_KEY)
+ } catch {
+ // ignore — non-persistent environments fall back to the build-time default
+ }
+}
diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx
index 526ed9b21..bda8d72bc 100644
--- a/apps/web/src/routes/settings.tsx
+++ b/apps/web/src/routes/settings.tsx
@@ -22,7 +22,8 @@ import {
User,
UserRound,
Wifi,
- ShieldCheck
+ ShieldCheck,
+ Cloud
} from 'lucide-react'
import { useState, useCallback } from 'react'
import { ProfileSettings } from '../comms/ProfileSettings'
@@ -30,8 +31,13 @@ import { ContentSafetySettings } from '../components/ContentSafetySettings'
import { PluginManager } from '../components/PluginManager'
import { SafetyCenterSettings } from '../components/SafetyCenterSettings'
import { requestXNetBrowserStorageReset } from '../lib/browser-storage-reset'
+import { persistedHubUrl, setPersistedHubUrl } from '../lib/hub-url'
import { logout } from '../lib/identity'
+/** Marketing + dashboard origins for xNet Cloud (managed hub hosting). */
+const CLOUD_MARKETING_URL = 'https://xnet.fyi/cloud'
+const CLOUD_DASHBOARD_URL = 'https://cloud.xnet.fyi/dashboard'
+
export const Route = createFileRoute('/settings')({
component: SettingsPage
})
@@ -355,21 +361,20 @@ function DataSettings() {
const DEFAULT_HUB_URL = import.meta.env.VITE_HUB_URL || 'wss://hub.xnet.fyi'
function NetworkSettings() {
- const [hubUrl, setHubUrl] = useState(() => {
- if (typeof window === 'undefined') return DEFAULT_HUB_URL
- return localStorage.getItem('xnet:hub-url') || DEFAULT_HUB_URL
- })
+ const [hubUrl, setHubUrl] = useState(() =>
+ typeof window === 'undefined' ? DEFAULT_HUB_URL : persistedHubUrl(DEFAULT_HUB_URL)
+ )
const [saved, setSaved] = useState(false)
const handleSave = useCallback(() => {
- localStorage.setItem('xnet:hub-url', hubUrl)
+ setPersistedHubUrl(hubUrl)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}, [hubUrl])
const handleReset = useCallback(() => {
setHubUrl(DEFAULT_HUB_URL)
- localStorage.removeItem('xnet:hub-url')
+ setPersistedHubUrl('')
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}, [])
@@ -420,6 +425,37 @@ function NetworkSettings() {
hub.xnet.fyi is provided for convenience, but you can run
your own signaling server.
+
+
+
+
+
+ See plans
+
+
+
+
+
+ Open dashboard
+
+
+
)
}
diff --git a/docs/explorations/0192_[_]_XNET_CLOUD_ONBOARDING_AND_UI_HOSTING.md b/docs/explorations/0192_[_]_XNET_CLOUD_ONBOARDING_AND_UI_HOSTING.md
new file mode 100644
index 000000000..8935b407f
--- /dev/null
+++ b/docs/explorations/0192_[_]_XNET_CLOUD_ONBOARDING_AND_UI_HOSTING.md
@@ -0,0 +1,626 @@
+# xNet Cloud — The Onboarding Journey, the Dashboard UI, and Where to Host It
+
+## Problem Statement
+
+xNet Cloud is **architecturally complete and code-incomplete** (exploration
+[0180](0180_[_]_XNET_CLOUD_ARCHITECTURE_AND_COMPLETION_STATUS.md)): the brain
+(plan catalog, signed entitlements, two-identity binding, billing math, the
+`ControlPlane` lifecycle) is shipped and tested, the hands (real provisioner
+adapters) are stubs, and **the face does not exist**. There is no signup, no
+pricing page, no checkout, no `/auth/callback`, no dashboard, and no in-app
+"connect my cloud hub" flow.
+
+This exploration answers the *face* question, which is really three questions
+tangled together:
+
+1. **Where do we host it, cheaply?** The marketing/pricing surface, the
+ authenticated dashboard, and the dynamic backend (auth callback, Stripe
+ webhook, provisioning) have very different hosting needs. GitHub Pages is
+ free but static. What goes where?
+2. **What is the end-to-end onboarding flow?** From "stranger reads the pricing
+ page" → "signs up with WorkOS" → "pays" → "gets a hub provisioned" → "opens
+ the app and connects to that hub" → "binds their data identity" → "manages
+ billing / cancels / deletes." Every hand-off in that chain is a design
+ decision.
+3. **How does the app *connect* to a freshly-provisioned hub** without leaking
+ the custodial billing identity into the non-custodial data plane — i.e. how
+ do the two identities (WorkOS billing ↔ data DID) get bound through a UI a
+ human can actually follow?
+
+## Executive Summary
+
+**Recommended topology — keep everything free that can be free, and put exactly
+one scale-to-zero service behind the dynamic surface:**
+
+| Surface | Host | Cost | Why |
+|---|---|---|---|
+| Marketing + pricing (`/cloud`, `/cloud/pricing`) | **GitHub Pages** (existing Astro site, `xnet.fyi`) | $0 | Static; reuse the site we already deploy |
+| The product app (web) | **GitHub Pages** (existing `xnet.fyi/app`) | $0 | Already there; gains a "Cloud" account surface |
+| Auth callback, Stripe webhook, provisioning, **dashboard** | **`xnet-cloud` Hono service on Cloud Run** (`cloud.xnet.fyi`), scale-to-zero | ~$0 at low volume | The only irreducibly-dynamic surface; same-origin sealed-cookie sessions |
+| Per-tenant hubs | Cloud Run + Litestream→R2 (the fleet) | COGS-modeled | Already designed (0177/0178) |
+
+The single most important UX insight: **the onboarding flow is a hand-off
+between two identities that must be bound, and the cleanest way to bind them in
+a UI is the OAuth 2.0 Device Authorization Grant ([RFC 8628](https://oauth.net/2/device-flow/))** —
+a "claim your hub" pairing flow. The app creates a passkey DID locally and
+displays/sends a short device code; the user approves it in the
+already-authenticated dashboard; the control plane performs the existing
+`bindIdentities` dual-proof (DID-signed challenge + WorkOS session) and hands the
+hub URL back to the app. This re-uses code that is **already shipped and tested**
+(`bindIdentities`, the `DidChallenge` dual-proof in
+`packages/cloud/src/identity/binding.ts`) and never embeds WorkOS in the
+non-custodial app.
+
+**Phasing:** ship the marketing + pricing page first (pure static, zero backend
+risk, immediately useful), then the control-plane auth/checkout/provision spine
+(Option B from 0180), then the in-app claim-hub + account-management surface.
+This is the "money path" half of 0180's recommendation, made concrete as UI.
+
+## Current State In The Repository
+
+### The site is static Astro on GitHub Pages — and that is good
+
+The marketing/docs site is **Astro 5 + Starlight**, built and published to the
+`gh-pages` branch by [.github/workflows/deploy-site.yml](.github/workflows/deploy-site.yml).
+The custom domain is `xnet.fyi` ([site/public/CNAME](site/public/CNAME),
+[site/astro.config.mjs:37](site/astro.config.mjs)). The deploy bundles two
+artifacts onto one branch:
+
+- `site/dist` → `/` (Astro marketing + docs)
+- `apps/web/dist` → `/app/` (the React PWA, built with
+ `VITE_BASE_PATH=/app/` and `VITE_USE_HASH_ROUTER=true`)
+
+The site already single-sources structured data into plain TypeScript modules
+that Astro renders at build time — [site/src/data/roadmap.ts](site/src/data/roadmap.ts)
+and [site/src/data/compare.ts](site/src/data/compare.ts) (which already has a
+`pricing` column for *competitors*). **There is a proven pattern here for a
+pricing page: a `site/src/data/pricing.ts` module rendered by an Astro page.**
+There is currently **no** `/cloud`, `/pricing`, or `/signup` route, and the
+roadmap does not mention paid hosting.
+
+The site is **purely static** — it cannot run server code, hold a secret, or
+seal a session. Anything dynamic must live elsewhere.
+
+### The control plane already has the seams — and the holes
+
+[apps/cloud/src/server.ts](apps/cloud/src/server.ts) is a Hono app with:
+
+- `GET /auth/start` ([server.ts:40](apps/cloud/src/server.ts)) — redirects to
+ WorkOS AuthKit via `billing.getAuthorizationUrl()`.
+- `GET /tenants/:id`, `POST /internal/tenants`, `POST /internal/tenants/:id/plan`,
+ `POST /internal/account/recover` — all gated by a shared `x-internal-secret`.
+
+**The holes that block a real signup:**
+
+- **No `GET /auth/callback`.** The comment at
+ [server.ts:38](apps/cloud/src/server.ts) admits the callback "is not built
+ here." Nothing exchanges the WorkOS code or seals a session.
+- **No `/checkout` / Stripe subscription path.** Provisioning is driven only by
+ the internal `POST /internal/tenants` route; there is no route that creates a
+ Stripe Checkout session for the *plan subscription* or routes a
+ `checkout.session.completed` webhook into `provisionTenant`.
+- **No dashboard.** The control plane serves JSON, not HTML; there is no
+ authenticated UI for a tenant to see their hub, change plans, or cancel.
+- **DID verification is a stub** ([index.ts:44](apps/cloud/src/index.ts),
+ `devDidVerifier` only checks shape) and the default plan secret is
+ `dev-insecure-plan-secret` ([index.ts:68](apps/cloud/src/index.ts)).
+- **All stores are in-memory** ([registry.ts](apps/cloud/src/registry.ts)).
+
+The identity primitives the signup flow needs are **shipped and tested**:
+`WorkOSAuthKitProvider` (`getAuthorizationUrl` / `authenticateWithCode` /
+`getUser`) and the dual-proof `bindIdentities` / `recoverPaidAccount` /
+`completeRebind` in `packages/cloud/src/identity/binding.ts`. The two-identity
+binding requires *both* a proven WorkOS session (`billingUserId`) *and* a fresh
+DID challenge (signature over a nonce) — exactly the two halves a UI hand-off
+must collect.
+
+### The plan catalog is a ready-made pricing data source
+
+[packages/entitlements/src/plans.ts](packages/entitlements/src/plans.ts) defines
+seven tiers and their limits — and crucially `@xnetjs/entitlements` is **MIT**,
+not the FSL `@xnetjs/cloud`, so the static site can import it without crossing
+the open-core boundary:
+
+| Plan | Isolation | Storage | Seats | AI | SLA |
+|---|---|---|---|---|---|
+| demo | pooled | 10 MiB | 1 | – | none |
+| personal | dedicated-sleep | 25 GiB | 1 | ✓ | best-effort |
+| family | dedicated-sleep | 250 GiB | 5 | ✓ | best-effort |
+| team | dedicated-warm | 100 GiB | 3 | ✓ | best-effort |
+| community | dedicated-project | 500 GiB | 10 | ✓ | 99.9 |
+| company | dedicated-project | 1 TiB | 10 | ✓ | 99.9 |
+| enterprise | region-pinned | 5 TiB | 25 | ✓ | custom |
+
+`PLAN_PRICING` and `estimateCogs` (in `@xnetjs/cloud`, FSL) hold the USD numbers;
+the public-facing prices should be mirrored into a small **MIT** site data module
+(`site/src/data/pricing.ts`) to keep the FSL package out of the static build.
+
+### The app's connect-hub flow exists — with one real bug
+
+The web app's onboarding is a state machine
+([packages/react/src/onboarding/machine.ts](packages/react/src/onboarding/machine.ts)):
+`welcome → authenticating → connecting-hub → ready → complete`. Passkey/WebAuthn
+creates an Ed25519 `did:key` locally (`packages/identity/src/did.ts`), and
+[HubConnectScreen.tsx](packages/react/src/onboarding/screens/HubConnectScreen.tsx)
+shows the hub URL while it connects (the hub is optional; it auto-advances).
+
+The hub URL today comes from `VITE_HUB_URL` or the hardcoded
+`wss://hub.xnet.fyi` ([apps/web/src/App.tsx:66](apps/web/src/App.tsx)). There is
+a **Network** settings panel
+([apps/web/src/routes/settings.tsx:355](apps/web/src/routes/settings.tsx)) that
+writes the hub URL to `localStorage['xnet:hub-url']` — **but `App.tsx` never
+reads it back on startup.** So the one piece of UI that *looks* like "point me
+at my cloud hub" is inert. **Fixing this read path is a hard prerequisite for
+any connect-your-cloud-hub experience.**
+
+Billing is half-wired: `XNetConfig.billing` exists
+([packages/react/src/context.ts:343](packages/react/src/context.ts)) and
+`useBilling()` ([packages/react/src/hooks/useBilling.ts](packages/react/src/hooks/useBilling.ts))
+already exposes `openCheckout(priceRef)` / `openPortal()` against the hub's
+billing routes — but **nothing in `apps/web` renders it.** The Account settings
+panel shows only the DID and a logout button.
+
+### Two billing surfaces, do not conflate them
+
+This is a subtle trap. There are **two** billing concepts in the repo:
+
+1. **Plan subscription** — "$5/mo for Personal." This is recurring SaaS billing.
+ `@xnetjs/billing` (MIT, PR #106) already implements it: a `PaymentProvider`
+ port with `createCheckout({ mode: 'subscription' })` and
+ `createPortalSession()`, Stripe + BTCPay adapters, local-HMAC webhook verify
+ ([packages/billing/src/provider.ts](packages/billing/src/provider.ts),
+ [packages/hub/src/routes/billing.ts](packages/hub/src/routes/billing.ts)).
+ Today it is **DID-scoped on the hub**; for cloud-tenant plans the *same
+ provider port* should be driven by the **control plane**, keyed by the WorkOS
+ customer/tenant instead of a DID.
+2. **AI usage metering** — pay-as-you-go tokens. `@xnetjs/cloud/billing`
+ (`computeChargeUsd`, the idempotent ledger, Stripe Meters). Separate surface,
+ separate UI line item.
+
+The onboarding/checkout UI is concerned with **(1)**. The dashboard's "usage"
+view surfaces **(2)**.
+
+## External Research
+
+**WorkOS AuthKit supports both the server-sealed-cookie pattern and SPA
+sessions.** The canonical flow is: configure a Redirect URI in the WorkOS
+dashboard, exchange the code server-side in a callback, and store an **encrypted
+session cookie** (the `WORKOS_COOKIE_PASSWORD` must be ≥32 chars). There is a
+framework-agnostic [`@workos/authkit-session`](https://github.com/workos/authkit-session)
+library plus first-party Next.js / React-Router integrations, and AuthKit's free
+tier covers up to ~1M MAU — so the auth layer adds no fixed cost. The
+[Session management for frontend apps](https://workos.com/blog/session-management-for-frontend-apps-with-authkit)
+post confirms SPAs are supported, but the **sealed httpOnly cookie on a
+same-origin callback** is the more secure default for a billing dashboard.
+
+**The OAuth 2.0 Device Authorization Grant (RFC 8628) is the textbook answer for
+"connect this app to my cloud account."** It exists precisely for clients that
+shouldn't run the full browser-redirect dance themselves: the device shows a
+short code (or QR), the user approves it on a second device that *is* logged in,
+and the device polls until it receives its grant. This maps almost perfectly
+onto xNet's two-identity split — the non-custodial app should *not* embed WorkOS;
+it should create its DID locally and get "claimed" by the already-authenticated
+dashboard. Okta, Microsoft, AWS Cognito, and Ping all document this flow; it is
+the standard for desktop/CLI/TV apps.
+
+**Supabase's onboarding is the friction benchmark to beat.** Across the
+[Supabase / Neon / PlanetScale comparisons](https://www.bytebase.com/blog/neon-vs-supabase/),
+Supabase "has the smoothest onboarding" because it asks for only **two inputs
+(project name + region)** before provisioning, whereas PlanetScale front-loads
+engine/cluster/size decisions. The lesson for xNet Cloud: ask for as little as
+possible before the hub exists (ideally just *plan* and *region*), and defer
+everything else (data import, plugins, seats) to after the user is in.
+
+**Neon's scale-to-zero (~150 ms cold start) is the model for the cold-tier and
+for the control plane itself.** A scale-to-zero Cloud Run service for the
+control plane means the dashboard costs effectively nothing when idle, which is
+the whole point of the "cheap" requirement.
+
+Sources:
+[RFC 8628 device flow](https://oauth.net/2/device-flow/),
+[WorkOS AuthKit sessions](https://workos.com/docs/authkit/sessions),
+[WorkOS frontend session mgmt](https://workos.com/blog/session-management-for-frontend-apps-with-authkit),
+[`@workos/authkit-session`](https://github.com/workos/authkit-session),
+[Okta device authorization grant](https://developer.okta.com/docs/guides/device-authorization-grant/main/),
+[Neon vs Supabase (Bytebase)](https://www.bytebase.com/blog/neon-vs-supabase/),
+[Supabase vs PlanetScale vs Neon (DevToolReviews)](https://www.devtoolreviews.com/reviews/supabase-vs-planetscale-vs-neon).
+
+## Key Findings
+
+1. **The hosting answer is a clean split, not a single platform.** Static
+ marketing + app stay on free GitHub Pages; only auth callback + webhook +
+ provisioning + dashboard need a server, and that is *one* scale-to-zero Cloud
+ Run service. No Vercel/Netlify/Next.js is required.
+2. **Serve the dashboard from the control plane, same-origin.** A billing
+ dashboard wants httpOnly sealed cookies. Serving the dashboard SPA from the
+ same Cloud Run origin (`cloud.xnet.fyi`) makes WorkOS sessions trivial and
+ eliminates CORS. (A static-on-Pages dashboard talking cross-origin to the API
+ is possible with `Domain=.xnet.fyi` cookies, but it trades security and
+ simplicity for no real cost saving.)
+3. **The two-identity binding is the crux of the connect-hub UX, and the device
+ grant solves it.** `bindIdentities` already wants a WorkOS session *and* a
+ signed DID challenge. RFC 8628 is the UI shape that collects both without
+ embedding WorkOS in the app.
+4. **The pricing page is the cheapest, lowest-risk first deliverable** — pure
+ static Astro reading an MIT `pricing.ts`, no backend, immediately shippable,
+ and it makes the offering legible (which the user explicitly wants).
+5. **`@xnetjs/billing` already implements plan subscriptions + customer portal;
+ reuse it in the control plane.** Don't build a second Stripe integration —
+ drive the existing `PaymentProvider` port from the control plane keyed by
+ tenant.
+6. **The `xnet:hub-url` localStorage read-path bug must be fixed** before
+ "connect your cloud hub" can work; it is a small, contained fix in
+ `App.tsx`.
+7. **Account deletion has two halves that must be visibly separated** —
+ *delete subscription* (custodial, WorkOS/Stripe, reversible-ish) vs *delete
+ data* (destroy the hub + R2 replica, irreversible, non-custodial). The UI
+ must make the asymmetry obvious because the company *cannot* recover the
+ encrypted data.
+
+## Options And Tradeoffs
+
+### Where does the authenticated dashboard live?
+
+```mermaid
+flowchart TB
+ subgraph A["Option 1 — Dashboard static on GitHub Pages"]
+ A1["xnet.fyi/cloud/app (static SPA)"] -->|"CORS + cookie Domain=.xnet.fyi"| A2["cloud.xnet.fyi API (Cloud Run)"]
+ end
+ subgraph B["Option 2 — Dashboard served by control plane ✅"]
+ B1["cloud.xnet.fyi (Cloud Run) serves SPA + API, same origin"]
+ end
+ subgraph C["Option 3 — New full-stack app (Next.js on Vercel)"]
+ C1["cloud.xnet.fyi (Vercel) SSR auth + dashboard"] --> C2["control-plane API"]
+ end
+ classDef rec fill:#efe,stroke:#0a0
+ class B1 rec
+```
+
+| Option | Cost | Session security | Complexity | Verdict |
+|---|---|---|---|---|
+| **1. Static dashboard on Pages + API on Cloud Run** | $0 hosting | Cross-origin cookies (`SameSite=None`) or bearer token in fragment | CORS, credentialed fetch, token handling | Viable, but trades security/simplicity for ~$0 saving |
+| **2. Dashboard served by the control plane (same origin)** | ~$0 (scale-to-zero) | httpOnly sealed cookie, WorkOS-documented | Lowest; no CORS | **Recommended** |
+| **3. New Next.js app on Vercel** | New vendor + bill | SSR-native | Highest; new app, new ToS, new deploy | Over-build; rejected |
+
+**Recommendation: Option 2.** The dashboard is a thin SPA (or even server-rendered
+Hono+HTML) baked into the `xnet-cloud` container and served from `cloud.xnet.fyi`,
+same origin as the API. Cost is negligible (scale-to-zero), sessions are simple
+and secure, and there is no new vendor.
+
+### How does the app connect + bind to a provisioned hub?
+
+```mermaid
+flowchart LR
+ subgraph M["Manual URL entry"]
+ M1["User copies hub URL from dashboard"] --> M2["Pastes into app Settings → Network"]
+ M2 --> M3["App signs DID challenge + user pastes a claim token"]
+ end
+ subgraph D["Device grant 'claim your hub' ✅"]
+ D1["App shows code / QR + creates DID"] --> D2["User approves in logged-in dashboard"]
+ D2 --> D3["Control plane binds DID↔tenant, returns hub URL"]
+ end
+ classDef rec fill:#efe,stroke:#0a0
+ class D1,D2,D3 rec
+```
+
+- **Manual URL + claim token:** simplest to build, but clunky (copy/paste two
+ values) and error-prone. Acceptable as a v0 fallback and for self-hosters.
+- **Device grant (RFC 8628):** the app polls; the user just approves a code in
+ the dashboard they're already signed into. Cleanest UX, standards-based, and
+ it slots straight onto the existing dual-proof `bindIdentities`. **Recommended**,
+ with manual entry kept as the self-host / power-user fallback.
+
+### Where do checkout + provisioning fire?
+
+The marketing page is static, so "Subscribe" must hand off to the control plane.
+Two sub-options:
+
+- **A. Checkout-first:** dashboard creates a Stripe Checkout (subscription) →
+ `checkout.session.completed` webhook → `provisionTenant`. Tenant has paid
+ before the hub exists. Clean billing story; ~1–2 s provisioning wait shown as
+ a progress screen.
+- **B. Provision-first (trial):** provision a `demo`/trial hub immediately on
+ sign-in, collect payment later to upgrade. Lower friction to "wow," but you
+ provision unpaid infra (abuse/cost risk) and the upgrade is a plan *flip*.
+
+**Recommendation: A for paid tiers, with the free `demo` tier as the
+provision-first trial.** A signed-in user can get a pooled `demo` hub instantly
+(no card), and upgrading to `personal+` runs Checkout → webhook → plan flip (an
+in-tier flip when possible, a migration when crossing isolation tiers, per
+`changePlan`).
+
+## Recommendation
+
+**Build the face in three shippable slices, cheapest-and-safest first.**
+
+```mermaid
+flowchart LR
+ S1["Slice 1 Pricing + marketing (static, GH Pages)"] --> S2["Slice 2 Auth → checkout → provision (control plane + dashboard)"]
+ S2 --> S3["Slice 3 In-app claim-hub + account mgmt (apps/web + dashboard)"]
+```
+
+1. **Slice 1 — Pricing & marketing (static, GitHub Pages).** A `site/src/data/pricing.ts`
+ MIT module + `site/src/pages/cloud/index.astro` and `.../pricing.astro`,
+ styled like the existing landing sections. "Get started" links to
+ `https://cloud.xnet.fyi/auth/start`. Add a roadmap entry. **Zero backend
+ risk, immediately useful.**
+2. **Slice 2 — The money + provision spine.** In `xnet-cloud`: add
+ `GET /auth/callback` (exchange code via `authenticateWithCode`, seal a WorkOS
+ cookie), `POST /checkout` (reuse `@xnetjs/billing` Stripe `createCheckout`,
+ keyed by tenant), `POST /webhook` (verify, route `checkout.session.completed`
+ → `provisionTenant`), `POST /portal` (Stripe customer portal), and serve a
+ minimal **dashboard** SPA (plan, hub status, usage, billing, danger zone).
+ Swap the four in-memory stores for durable ones and rotate the plan secret.
+ Deploy on Cloud Run at `cloud.xnet.fyi`. (This is 0180's Option B made into UI.)
+3. **Slice 3 — Connect + manage in the app.** Fix the `xnet:hub-url` read path;
+ add a **device-grant "claim your hub"** branch to the onboarding state
+ machine (`welcome → choose-sync → claim-hub → connecting-hub → ready`); render
+ `useBilling()` in Account settings (plan, manage-billing link, usage); and add
+ the **danger zone** (cancel subscription, delete data) with the
+ custodial/non-custodial asymmetry made explicit.
+
+### End-to-end onboarding journey (the whole picture)
+
+```mermaid
+sequenceDiagram
+ autonumber
+ actor U as User
+ participant Site as xnet.fyi/cloud (GH Pages, static)
+ participant CP as cloud.xnet.fyi (Control plane, Cloud Run)
+ participant WOS as WorkOS AuthKit
+ participant Stripe
+ participant Fleet as Hub fleet (Cloud Run + R2)
+ participant App as xNet app (web/desktop/mobile)
+
+ U->>Site: Read pricing, click "Get started"
+ Site->>CP: GET /auth/start
+ CP->>WOS: redirect (AuthKit)
+ WOS-->>CP: GET /auth/callback?code=…
+ CP->>WOS: authenticateWithCode(code)
+ WOS-->>CP: BillingUser + tokens
+ CP-->>U: seal session cookie → dashboard
+ U->>CP: Pick plan + region → Checkout
+ CP->>Stripe: createCheckout(subscription)
+ Stripe-->>U: hosted checkout
+ Stripe-->>CP: webhook checkout.session.completed
+ CP->>Fleet: provisionTenant(plan, signed HUB_PLAN)
+ Fleet-->>CP: hubUrl (running)
+ CP-->>U: "Your hub is ready" + Download app
+ U->>App: open app, create passkey DID
+ App->>CP: device-grant: show code / poll
+ U->>CP: approve code in dashboard (proves WorkOS session)
+ App->>CP: signed DID challenge (proves data identity)
+ CP->>CP: bindIdentities (dual proof) → return hubUrl
+ App->>Fleet: connect (WebSocket sync)
+ Note over App,Fleet: Local-first data now syncs to the managed hub
+```
+
+### Extended onboarding state machine (Slice 3)
+
+```mermaid
+stateDiagram-v2
+ [*] --> welcome
+ welcome --> authenticating: create / unlock passkey
+ authenticating --> choose_sync: identity ready
+ authenticating --> auth_error: passkey failed
+ auth_error --> authenticating: retry
+ choose_sync --> local_only: "Just my device"
+ choose_sync --> claim_hub: "Connect xNet Cloud hub"
+ choose_sync --> self_host: "Self-hosted hub URL"
+ claim_hub --> connecting_hub: device grant approved + DID bound
+ self_host --> connecting_hub: URL saved to xnet:hub-url
+ local_only --> ready
+ connecting_hub --> ready: socket open
+ connecting_hub --> ready: timeout (optional hub)
+ ready --> [*]
+```
+
+### Account management & the deletion asymmetry (Slice 3 danger zone)
+
+```mermaid
+stateDiagram-v2
+ [*] --> active
+ active --> past_due: payment fails
+ past_due --> active: payment recovered
+ active --> cancel_scheduled: cancel at period end
+ cancel_scheduled --> active: resume
+ cancel_scheduled --> suspended: period ends (hub stopped, R2 retained)
+ suspended --> active: re-subscribe (reactivate from R2)
+ suspended --> data_deleted: delete data (irreversible)
+ active --> data_deleted: delete data (irreversible)
+ data_deleted --> [*]
+ note right of data_deleted
+ Custodial: subscription/billing identity (WorkOS) — recoverable.
+ Non-custodial: encrypted hub data (R2) — company cannot recover.
+ UI must separate "cancel subscription" from "delete my data".
+ end note
+```
+
+## Example Code
+
+### Slice 1 — single-sourced pricing data the static site can import (MIT)
+
+```ts
+// site/src/data/pricing.ts — mirrors PLAN_CATALOG (MIT @xnetjs/entitlements)
+// into public-facing copy. Numbers live here; do NOT import FSL @xnetjs/cloud
+// into the static build.
+export interface PricingTier {
+ id: 'personal' | 'family' | 'team' | 'company' | 'enterprise'
+ name: string
+ priceUsdMonthly: number | 'custom'
+ billing: 'annual-default' | 'monthly'
+ storage: string
+ seats: number
+ highlights: string[]
+ cta: { label: string; href: string }
+}
+
+export const PRICING: PricingTier[] = [
+ {
+ id: 'personal', name: 'Personal', priceUsdMonthly: 5, billing: 'annual-default',
+ storage: '25 GiB', seats: 1,
+ highlights: ['Your own dedicated hub', 'Passkey identity', 'Managed AI'],
+ cta: { label: 'Get started', href: 'https://cloud.xnet.fyi/auth/start?plan=personal' }
+ },
+ // family / team / company …, enterprise → { label: 'Contact us', href: '/cloud/enterprise' }
+]
+```
+
+### Slice 2 — the missing control-plane routes (sketch)
+
+```ts
+// apps/cloud/src/server.ts (additions)
+// Seal a WorkOS session after AuthKit (the hole at server.ts:38).
+app.get('/auth/callback', async (c) => {
+ const code = c.req.query('code')
+ if (!code) return c.json({ error: 'bad_request' }, 400)
+ const { user } = await deps.billing.authenticateWithCode(code)
+ await sealSession(c, { billingUserId: user.id }) // httpOnly cookie, WORKOS_COOKIE_PASSWORD
+ return c.redirect('/dashboard')
+})
+
+// Plan-subscription checkout — reuse @xnetjs/billing's Stripe PaymentProvider,
+// keyed by the WorkOS customer/tenant (NOT the data DID).
+app.post('/checkout', requireSession, async (c) => {
+ const { plan } = await c.req.json<{ plan: PlanId }>()
+ const session = await deps.payments.createCheckout({
+ customerRef: c.get('billingUserId'),
+ priceRef: PRICE_BY_PLAN[plan], mode: 'subscription',
+ successUrl: `${BASE}/dashboard?provisioning=1`, cancelUrl: `${BASE}/cloud/pricing`
+ })
+ return c.json({ url: session.url })
+})
+
+// Stripe webhook → provision (verify signature first; then dual-proof binding is
+// completed later via the device-grant claim flow).
+app.post('/webhook', async (c) => {
+ const event = verifyWebhook(stripe, await c.req.text(), c.req.header('stripe-signature')!, SECRET)
+ if (event.type === 'checkout.session.completed') {
+ await deps.controlPlane.provisionTenant({ /* tenantId, plan, billingUserId, challenge… */ })
+ }
+ return c.json({ received: true })
+})
+```
+
+### Slice 3 — fix the inert hub-URL read path
+
+```ts
+// apps/web/src/App.tsx — read the persisted hub URL the Settings panel already writes.
+const DEFAULT_HUB_URL =
+ import.meta.env.VITE_HUB_URL ?? (import.meta.env.DEV ? '' : 'wss://hub.xnet.fyi')
+
+const hubUrl =
+ localStorage.getItem('xnet:hub-url') ?? DEFAULT_HUB_URL // ← was never consulted
+```
+
+### Slice 3 — device-grant claim (binds DID↔tenant via the existing dual-proof)
+
+```ts
+// App side: create DID locally, request a device code, poll until bound.
+const { userCode, deviceCode, verifyUrl } = await fetch(`${CLOUD}/device/start`).then(r => r.json())
+showClaimCode(userCode, verifyUrl) // "Enter ABCD-1234 at cloud.xnet.fyi/claim"
+const challenge = await identity.signChallenge(deviceCode) // proves the data DID
+const { hubUrl } = await poll(`${CLOUD}/device/token`, { deviceCode, challenge })
+localStorage.setItem('xnet:hub-url', hubUrl) // now actually read on startup
+
+// Control-plane side: the approver's WorkOS session proves billingUserId; the
+// app's signed challenge proves the DID → bindIdentities (already shipped).
+```
+
+## Risks And Open Questions
+
+- **Cookie/session topology across `xnet.fyi` (Pages) and `cloud.xnet.fyi`
+ (Cloud Run).** Serving the dashboard from the control plane (Option 2) keeps
+ sessions same-origin and sidesteps this; the marketing → dashboard hop is just
+ a top-level redirect, which is fine. Confirm the WorkOS Redirect URI is
+ `https://cloud.xnet.fyi/auth/callback`.
+- **Provisioning latency in the funnel.** A real hub takes seconds to come up;
+ the post-checkout screen must show progress and tolerate a webhook that lands
+ before/after the redirect. (Stripe webhooks are not ordered w.r.t. the
+ redirect.)
+- **Device-grant security.** Short-lived device codes, rate-limited polling, a
+ human-readable `user_code`, and binding the DID challenge to the `device_code`
+ (not just any nonce) to prevent a stolen code from binding an attacker's DID.
+- **The deletion asymmetry is a support and trust landmine.** "Delete my data"
+ destroys the hub + R2 replica irreversibly and the company *cannot* recover it
+ (non-custodial). The UI must require explicit confirmation and clearly
+ distinguish it from "cancel subscription." Consider a grace period
+ (suspended → R2-retained) before hard delete.
+- **Self-host parity.** Every cloud-only affordance (managed hub URL, billing
+ portal) must degrade gracefully for self-hosters — the self-host path stays
+ free and `HUB_PLAN`-less (the anti-lock-in invariant from 0180). The
+ "choose-sync" screen's "Self-hosted hub URL" branch preserves this.
+- **Mobile (`apps/expo`) claim flow.** The device grant + QR works well on
+ mobile, but expo currently ships a duplicated provider and a fake `did:key`
+ (per the 0185/0186 notes) — the claim flow depends on real passkey DID creation
+ landing on mobile first.
+- **Open question: server-rendered dashboard vs SPA?** A tiny Hono+HTML
+ dashboard avoids shipping a second React bundle and keeps everything in one
+ service; a React SPA reuses `@xnetjs/ui`. Lean SPA only if it meaningfully
+ reuses the settings kit.
+- **Open question: does account management live in the dashboard, the app, or
+ both?** Proposed split: *custodial* concerns (plan, payment method, invoices,
+ cancel) in the dashboard; *data/sovereign* concerns (connect hub, delete data,
+ DID) in the app — mirroring the two-identity model. Billing portal link can
+ appear in both via `useBilling().openPortal()`.
+
+## Implementation Status
+
+Implemented in PR for this exploration (all three code slices, with tests):
+the marketing/pricing pages, the control-plane signup→checkout→provision spine,
+the server-rendered dashboard, the RFC 8628 device-grant claim flow, and the
+`xnet:hub-url` read-path fix. The boxes below reflect that. Items that are pure
+infrastructure / external configuration (deploy to Cloud Run, durable stores,
+the real Stripe + `@xnetjs/identity` adapters, WorkOS/Stripe dashboard setup)
+are intentionally left unchecked — they are not implementable or verifiable in
+this repo/environment and keep their `Provisioner`-style port + keyless fake.
+
+## Implementation Checklist
+
+**Slice 1 — Pricing & marketing (static):**
+- [x] Add `site/src/data/pricing.ts` (MIT; mirror `PLAN_CATALOG` + public USD prices).
+- [x] Add `site/src/pages/cloud/index.astro` (offering) and `.../pricing.astro` (tier grid).
+- [x] Link "Get started" → `https://cloud.xnet.fyi/auth/start?plan=…`; add enterprise "Contact us".
+- [x] Add an xNet Cloud entry to `site/src/data/roadmap.ts` (cloud pages are app pages, not docs — no sidebar entry needed).
+
+**Slice 2 — Auth + checkout + provision + dashboard:**
+- [x] `GET /auth/callback`: `authenticateWithCode` + seal an httpOnly signed session cookie (`session.ts`).
+- [x] `POST /checkout`: `TenantBillingGateway.createCheckout` port keyed by the WorkOS customer (real Stripe adapter deferred; keyless fake shipped).
+- [x] `POST /webhook`: signature-verify → route `checkout.completed` → `provisionForBilling` / `subscription.canceled` → `suspendTenant` (idempotent; redirect/webhook race handled by the deterministic tenant id).
+- [x] `POST /portal`: customer-portal redirect via the gateway port.
+- [x] Serve a dashboard at `/dashboard` (plan, hub status, billing, danger zone) — server-rendered HTML, same origin.
+- [ ] Replace the four `Memory*` stores with durable implementations; stand up the control-plane DB. *(deferred — infra)*
+- [ ] Wire real DID verification (`@xnetjs/identity`) + rotate `XNET_PLAN_SECRET` / `XNET_CLOUD_INTERNAL_SECRET`. *(deferred — secrets/infra; `devDidVerifier` still in place)*
+- [ ] Deploy `xnet-cloud` on Cloud Run at `cloud.xnet.fyi`; configure WorkOS Redirect URI + Stripe webhook endpoint. *(deferred — deploy)*
+
+**Slice 3 — In-app connect + account management:**
+- [x] Fix the `xnet:hub-url` read path in `apps/web/src/App.tsx` (`lib/hub-url.ts`; App now reads what Settings writes).
+- [x] Add `POST /device/start` + `POST /device/token` (RFC 8628) to the control plane; `bindDataIdentity` (dual-proof) on approval.
+- [x] Add a "claim your hub" approval page to the dashboard (`GET/POST /claim`).
+- [x] Surface connect-a-cloud-hub in the app (Settings → Network "xNet Cloud" group + the `lib/cloud-claim.ts` client). *(integrating it into the first-run onboarding state machine is deferred to protect the passkey-gated `editor-ux` e2e.)*
+- [x] Account management UI: dashboard links for plan/billing (cloud-tenant billing lives there, **not** `useBilling()`, which is the hub's separate end-user surface).
+- [x] Danger zone: cancel subscription (portal) + delete data (`/account/delete-data` → destroy hub), with the custodial/non-custodial asymmetry shown explicitly and a JS confirm.
+
+## Validation Checklist
+
+- [x] The signup funnel works end to end **in-process**: WorkOS-callback → seal session → checkout → webhook → provision → dashboard shows the hub (`funnel.test.ts`). *(real WorkOS/Stripe + "no manual steps" needs the deploy.)*
+- [x] The post-checkout flow is correct whether the webhook lands before or after the redirect — provisioning is idempotent on a deterministic tenant id (`funnel.test.ts`).
+- [x] Approving the device code binds the DID to the tenant (dual-proof) and returns the hub URL; a mismatched/forged DID is rejected (`claim.test.ts`).
+- [x] The hub URL set via Settings (or the claim flow) is **persisted and read on startup** — the `xnet:hub-url` bug is fixed (`hub-url.test.ts` + `App.tsx`).
+- [x] "Manage billing" redirects to the portal for the authenticated user (`funnel.test.ts`). *(real Stripe portal needs the adapter.)*
+- [x] "Cancel subscription" suspends the hub and **retains** the record + R2 replica (`subscriptionStatus: 'canceled'`, `dataTier: 'cold'`) (`funnel.test.ts`).
+- [x] "Delete my data" destroys the hub and forgets the tenant, is irreversible, requires a JS confirm, and is visibly distinct from cancellation (`funnel.test.ts` + dashboard).
+- [x] A self-hoster still configures a manual hub URL with **no** WorkOS/Stripe involvement (the Settings hub-URL field; anti-lock-in preserved).
+- [ ] A control-plane restart preserves all tenants and sessions. *(deferred — needs durable stores.)*
+- [x] The dashboard, claim page, marketing, and pricing pages share the visual idiom (verified by screenshot — dark workbench-style control-plane pages, Starlight-style marketing).
+
+## References
+
+- Predecessor: [0180 — xNet Cloud Architecture and Completion Status](0180_[_]_XNET_CLOUD_ARCHITECTURE_AND_COMPLETION_STATUS.md)
+- Lineage: [0174 — Managed Hosting As Open Core](0174_[_]_MANAGED_HOSTING_AS_OPEN_CORE_IN_THE_PUBLIC_MONOREPO.md), [0175 — Managed Hub Fleet Deployment And AI Gateway](0175_[_]_MANAGED_HUB_FLEET_DEPLOYMENT_AND_AI_GATEWAY.md), [0178 — Cost-Efficient SQLite Hosting](0178_[_]_COST_EFFICIENT_SQLITE_HOSTING_NO_LIBSQL_MIGRATION.md), [0181 — Consolidate Cloud Into One Package](0181_[x]_CONSOLIDATE_CLOUD_INTO_ONE_PACKAGE.md), [0187 — Plug-and-Play Billing](0187_[x]_PLUG_AND_PLAY_BILLING_STRIPE_AND_BITCOIN.md)
+- Control plane: [apps/cloud/src/server.ts](apps/cloud/src/server.ts), [apps/cloud/src/control-plane.ts](apps/cloud/src/control-plane.ts), [apps/cloud/src/index.ts](apps/cloud/src/index.ts), [apps/cloud/src/registry.ts](apps/cloud/src/registry.ts)
+- Identity + plans: [packages/cloud/src/identity/binding.ts](packages/cloud/src/identity/binding.ts), [packages/cloud/src/identity/workos.ts](packages/cloud/src/identity/workos.ts), [packages/entitlements/src/plans.ts](packages/entitlements/src/plans.ts)
+- Billing: [packages/billing/src/provider.ts](packages/billing/src/provider.ts), [packages/hub/src/routes/billing.ts](packages/hub/src/routes/billing.ts), [packages/react/src/hooks/useBilling.ts](packages/react/src/hooks/useBilling.ts), [packages/react/src/context.ts](packages/react/src/context.ts)
+- App onboarding + settings: [packages/react/src/onboarding/machine.ts](packages/react/src/onboarding/machine.ts), [packages/react/src/onboarding/screens/HubConnectScreen.tsx](packages/react/src/onboarding/screens/HubConnectScreen.tsx), [apps/web/src/App.tsx](apps/web/src/App.tsx), [apps/web/src/routes/settings.tsx](apps/web/src/routes/settings.tsx)
+- Site + deploy: [site/astro.config.mjs](site/astro.config.mjs), [site/src/data/roadmap.ts](site/src/data/roadmap.ts), [site/src/data/compare.ts](site/src/data/compare.ts), [.github/workflows/deploy-site.yml](.github/workflows/deploy-site.yml)
+- External: [RFC 8628 device flow](https://oauth.net/2/device-flow/), [WorkOS AuthKit sessions](https://workos.com/docs/authkit/sessions), [WorkOS frontend session mgmt](https://workos.com/blog/session-management-for-frontend-apps-with-authkit), [`@workos/authkit-session`](https://github.com/workos/authkit-session), [Okta device authorization grant](https://developer.okta.com/docs/guides/device-authorization-grant/main/), [Neon vs Supabase (Bytebase)](https://www.bytebase.com/blog/neon-vs-supabase/)
diff --git a/site/src/components/sections/Nav.astro b/site/src/components/sections/Nav.astro
index fe75c18ad..5fe4b6b32 100644
--- a/site/src/components/sections/Nav.astro
+++ b/site/src/components/sections/Nav.astro
@@ -5,6 +5,7 @@ const links = [
{ href: '/#app', label: 'App' },
{ href: '/#developers', label: 'Developers' },
{ href: '/#hubs', label: 'Teams' },
+ { href: '/cloud', label: 'Cloud' },
{ href: '/#vision', label: 'Vision' },
]
---
diff --git a/site/src/data/pricing.ts b/site/src/data/pricing.ts
new file mode 100644
index 000000000..0aae64c6b
--- /dev/null
+++ b/site/src/data/pricing.ts
@@ -0,0 +1,182 @@
+/**
+ * xNet Cloud pricing — the marketing site's view of the managed-hub offering.
+ *
+ * Single source for the /cloud and /cloud/pricing pages, kept apart from the
+ * markup so a price change is a one-line edit (same pattern as roadmap.ts and
+ * compare.ts). These numbers MIRROR the real catalog — `PLAN_CATALOG` in the MIT
+ * `@xnetjs/entitlements` package and the illustrative `PLAN_PRICING` scenarios in
+ * the FSL `@xnetjs/cloud` cost model — but live here as plain data so the static
+ * site never imports the source-available `@xnetjs/cloud` package into its build.
+ *
+ * When the catalog prices change, update them here too (and the dashboard's
+ * PRICE_BY_PLAN map). See docs/explorations/0192_[_]_XNET_CLOUD_ONBOARDING_AND_UI_HOSTING.md
+ */
+
+/** Origin of the xNet Cloud control plane (auth callback, checkout, dashboard). */
+export const CLOUD_ORIGIN = 'https://cloud.xnet.fyi'
+
+/** Deep-link into the WorkOS AuthKit sign-in, carrying the chosen plan. */
+export function startUrl(plan: string): string {
+ return `${CLOUD_ORIGIN}/auth/start?plan=${encodeURIComponent(plan)}`
+}
+
+export interface PricingTier {
+ id: 'demo' | 'personal' | 'family' | 'team' | 'enterprise'
+ name: string
+ tagline: string
+ /** Display price; `null` for free, `'custom'` for contact-sales. */
+ price: { amount: number; unit: string; sub?: string } | 'free' | 'custom'
+ storage: string
+ seats: string
+ /** Tenant isolation tier (from PLAN_CATALOG) — the "what you actually get". */
+ isolation: string
+ highlights: string[]
+ cta: { label: string; href: string }
+ /** Visually emphasize this tier as the recommended default. */
+ featured?: boolean
+}
+
+export const updated = 'June 2026'
+
+/**
+ * Public-facing tiers, cheapest → richest. The full catalog also has `community`
+ * and `company` tiers (variants of team/enterprise isolation); they're available
+ * on request but kept off the public grid to keep the decision simple.
+ */
+export const PRICING: PricingTier[] = [
+ {
+ id: 'demo',
+ name: 'Free',
+ tagline: 'Kick the tires on a shared hub.',
+ price: 'free',
+ storage: '10 MiB',
+ seats: '1 person',
+ isolation: 'Pooled (shared) hub',
+ highlights: [
+ 'No card required',
+ 'Passkey identity, fully local-first',
+ 'Sync across your own devices',
+ 'Upgrade any time — your data comes with you'
+ ],
+ cta: { label: 'Start free', href: startUrl('demo') }
+ },
+ {
+ id: 'personal',
+ name: 'Personal',
+ tagline: 'Your own dedicated hub, always on call.',
+ price: { amount: 5, unit: '/mo', sub: 'billed annually ($50/yr)' },
+ storage: '25 GiB',
+ seats: '1 person',
+ isolation: 'Dedicated hub (scale-to-zero)',
+ highlights: [
+ 'A hub that is yours alone',
+ 'Managed AI gateway included',
+ 'Encrypted backup to object storage',
+ 'Full-text search & relay'
+ ],
+ cta: { label: 'Get Personal', href: startUrl('personal') },
+ featured: true
+ },
+ {
+ id: 'family',
+ name: 'Family',
+ tagline: 'Share a hub with the people you trust.',
+ price: { amount: 15, unit: '/mo' },
+ storage: '250 GiB',
+ seats: 'Up to 5 people',
+ isolation: 'Dedicated hub (scale-to-zero)',
+ highlights: [
+ 'Everything in Personal',
+ '5 seats, one bill',
+ 'Shared spaces & folders',
+ 'Generous storage for media'
+ ],
+ cta: { label: 'Get Family', href: startUrl('family') }
+ },
+ {
+ id: 'team',
+ name: 'Team',
+ tagline: 'A warm hub for collaborators who are always on.',
+ price: { amount: 12, unit: '/seat/mo', sub: 'from $36/mo (3 seats)' },
+ storage: '100 GiB',
+ seats: 'From 3 seats',
+ isolation: 'Dedicated warm hub (no cold start)',
+ highlights: [
+ 'Always-warm hub — instant sync',
+ 'Per-seat billing, add seats any time',
+ 'Roles, grants & shared workspaces',
+ '99.9% best-effort availability'
+ ],
+ cta: { label: 'Get Team', href: startUrl('team') }
+ },
+ {
+ id: 'enterprise',
+ name: 'Enterprise',
+ tagline: 'Region-pinned, SSO, and a contract.',
+ price: 'custom',
+ storage: '5 TiB+',
+ seats: '25+ seats',
+ isolation: 'Region-pinned dedicated deployment',
+ highlights: [
+ 'SSO / SCIM via WorkOS',
+ 'Data residency (region pinning)',
+ 'Custom SLA & support',
+ 'Audit logging & admin controls'
+ ],
+ cta: { label: 'Contact sales', href: '/cloud#enterprise' }
+ }
+]
+
+/** How onboarding actually works, surfaced on the /cloud page. */
+export interface OnboardingStep {
+ n: number
+ title: string
+ body: string
+}
+
+export const ONBOARDING_STEPS: OnboardingStep[] = [
+ {
+ n: 1,
+ title: 'Sign up',
+ body: 'Sign in with WorkOS AuthKit — email, social, or your company SSO. This is your billing identity, recoverable by email.'
+ },
+ {
+ n: 2,
+ title: 'Pick a plan',
+ body: 'Choose a tier and check out securely with Stripe. We provision a hub that is yours alone — no shared tenancy.'
+ },
+ {
+ n: 3,
+ title: 'Connect your app',
+ body: 'Open xNet on web, desktop, or mobile, create your passkey, and approve a short code to claim your hub. Your data identity stays on your device.'
+ },
+ {
+ n: 4,
+ title: 'Own it',
+ body: 'Manage billing, add seats, export everything, or delete your data — from one dashboard. Cancel any time; self-host with the same data whenever you like.'
+ }
+]
+
+export interface CloudFaq {
+ q: string
+ a: string
+}
+
+export const FAQS: CloudFaq[] = [
+ {
+ q: 'Can I self-host instead?',
+ a: 'Yes — xNet is local-first and the hub is open source. xNet Cloud just runs the hub for you. You can move between self-hosted and managed without losing data; the app never depends on the control plane.'
+ },
+ {
+ q: 'Who can read my data?',
+ a: 'Your data identity is a passkey-backed key that lives on your devices, separate from your billing account. We hold encrypted bytes we cannot read — the same reason "delete my data" is irreversible even for us.'
+ },
+ {
+ q: 'What happens if I cancel?',
+ a: 'Your subscription cancels at the end of the period and the hub is suspended, with your encrypted backup retained for a grace window so you can re-subscribe or export. Deleting your data is a separate, explicit, irreversible action.'
+ },
+ {
+ q: 'Do I pay for AI usage?',
+ a: 'The managed AI gateway is included on paid plans up to a budget; usage beyond that is metered transparently and shown on your dashboard. A hard budget stop prevents surprise bills.'
+ }
+]
diff --git a/site/src/data/roadmap.ts b/site/src/data/roadmap.ts
index 6b8cfd976..0314fb6dd 100644
--- a/site/src/data/roadmap.ts
+++ b/site/src/data/roadmap.ts
@@ -52,6 +52,7 @@ export const phases: RoadmapPhase[] = [
'Polished desktop experience',
'Workspace invites & sharing flows',
'Sharing UI (useCan / useGrants in app)',
+ 'Managed hub hosting — xNet Cloud (signup, pricing, connect-your-hub)',
'Push notification delivery (Web Push, Electron, mobile)',
'Query API improvements'
]
diff --git a/site/src/pages/cloud/index.astro b/site/src/pages/cloud/index.astro
new file mode 100644
index 000000000..1bcd52ca4
--- /dev/null
+++ b/site/src/pages/cloud/index.astro
@@ -0,0 +1,162 @@
+---
+import Base from '../../layouts/Base.astro'
+import Nav from '../../components/sections/Nav.astro'
+import Footer from '../../components/sections/Footer.astro'
+import { PRICING, ONBOARDING_STEPS, startUrl } from '../../data/pricing'
+
+const whatYouGet = [
+ {
+ title: 'A hub that is yours alone',
+ body: 'No shared tenancy. We provision a dedicated, isolated hub per account — encrypted backup, relay, and full-text search, managed and upgraded for you.',
+ color: 'indigo'
+ },
+ {
+ title: 'Local-first, never locked in',
+ body: 'Your data lives on your devices first and syncs to your hub. Move between self-hosted and managed any time — the app never calls home to a control plane.',
+ color: 'emerald'
+ },
+ {
+ title: 'You hold the keys',
+ body: 'Your data identity is a passkey on your device, separate from billing. We hold encrypted bytes we cannot read. Recover your account by email; your data stays yours.',
+ color: 'purple'
+ }
+]
+
+const colors: Record = {
+ indigo: { border: 'border-indigo-500/20', bg: 'bg-indigo-500/[0.03]', text: 'text-indigo-500 dark:text-indigo-400' },
+ emerald: { border: 'border-emerald-500/20', bg: 'bg-emerald-500/[0.03]', text: 'text-emerald-500 dark:text-emerald-400' },
+ purple: { border: 'border-purple-500/20', bg: 'bg-purple-500/[0.03]', text: 'text-purple-500 dark:text-purple-400' }
+}
+
+const featured = PRICING.find((t) => t.featured) ?? PRICING[1]
+---
+
+
+
+
+
+
+
+
+
+
xNet Cloud
+
+ A managed hub that is yours alone
+
+
+ xNet is local-first and self-hostable. xNet Cloud runs your hub for you —
+ dedicated, isolated, backed up, and always reachable — so your data syncs
+ everywhere without you babysitting a server.
+
+ From "never heard of it" to "syncing across all my devices" in four steps.
+
+
+ {
+ ONBOARDING_STEPS.map((step) => (
+
+
+ {step.n}
+
+
{step.title}
+
{step.body}
+
+ ))
+ }
+
+
+
+
+
+
+
+
Simple, honest pricing
+
+ Start free, then {featured.name} from
+ {typeof featured.price === 'object' ? ` $${featured.price.amount}${featured.price.unit}` : ''}.
+ Every paid plan is a dedicated hub with managed AI.
+
+ Region-pinned deployments, SSO and SCIM via WorkOS, a custom SLA, audit
+ logging, and admin controls. Bring your own identity provider and keep
+ your data where your compliance team needs it.
+
+ Also available on request: Community and
+ Company tiers with larger quotas and
+ project-grade isolation. Prices last updated {updated}.
+ See the data.
+