diff --git a/apps/cloud/src/billing-gateway.ts b/apps/cloud/src/billing-gateway.ts new file mode 100644 index 000000000..e09b926d2 --- /dev/null +++ b/apps/cloud/src/billing-gateway.ts @@ -0,0 +1,124 @@ +/** + * xNet Cloud — tenant-billing gateway (plan subscriptions). + * + * This is the **plan-subscription** surface ("$5/mo Personal"), keyed by the + * WorkOS billing identity — deliberately separate from `@xnetjs/cloud/billing` + * (AI usage metering) and from `@xnetjs/billing` (the hub's DID-scoped end-user + * billing). Conflating them was called out as a trap in exploration 0192. + * + * The control plane talks only to this port, so the real adapter (Stripe Checkout + * + Customer Portal, server-side secret key) is swappable and the fake is keyless- + * testable (exploration 0176). The production adapter wraps Stripe (or reuses + * `@xnetjs/billing`'s Stripe `PaymentProvider` keyed by the tenant) — deferred. + */ + +import type { PlanId } from '@xnetjs/entitlements' +import { createHmac, timingSafeEqual } from 'node:crypto' + +export interface CheckoutArgs { + /** WorkOS billing user id this subscription belongs to (server-set from session). */ + customerRef: string + plan: PlanId + successUrl: string + cancelUrl: string + email?: string +} + +export interface PortalArgs { + customerRef: string + returnUrl: string +} + +/** A verified, parsed provider webhook reduced to a control-plane action. */ +export type WebhookResult = + | { type: 'checkout.completed'; customerRef: string; plan: PlanId } + | { type: 'subscription.canceled'; customerRef: string } + | { type: 'ignored' } + +/** Thrown when a webhook fails signature verification (route → 401). */ +export class WebhookSignatureError extends Error { + constructor(message = 'Invalid webhook signature') { + super(message) + this.name = 'WebhookSignatureError' + } +} + +export interface TenantBillingGateway { + /** Telemetry/display label, e.g. `stripe` or `fake`. */ + readonly id: string + /** Create a hosted checkout for a plan subscription; returns the URL to redirect to. */ + createCheckout(args: CheckoutArgs): Promise<{ url: string; externalRef: string }> + /** Create a hosted customer portal session for managing/canceling the subscription. */ + createPortal(args: PortalArgs): Promise<{ url: string }> + /** Verify + parse a provider webhook. Throws `WebhookSignatureError` on a bad signature. */ + parseWebhook(rawBody: string, headers: Record): Promise +} + +/** + * Stripe price ids per plan. Mirrors the public prices in + * `site/src/data/pricing.ts`; overridden from the environment in production. + * `demo` is free (no checkout) and `enterprise` is contract-sales (no self-serve). + */ +export const PRICE_BY_PLAN: Partial> = { + personal: 'price_personal', + family: 'price_family', + team: 'price_team' +} + +const HEADER = 'x-xnet-signature' + +/** + * Keyless in-memory gateway for local dev + tests. `createCheckout` echoes a + * marker onto the success URL (so the dashboard can show "provisioning…"), and + * `parseWebhook` accepts a JSON body `{ type, customerRef, plan }` — optionally + * gated by an HMAC signature when a secret is configured. + */ +export class FakeTenantBillingGateway implements TenantBillingGateway { + readonly id = 'fake' + constructor(private readonly secret?: string) {} + + async createCheckout(args: CheckoutArgs): Promise<{ url: string; externalRef: string }> { + const sep = args.successUrl.includes('?') ? '&' : '?' + return { + url: `${args.successUrl}${sep}fake_checkout=${encodeURIComponent(args.plan)}`, + externalRef: `fake_sub_${args.customerRef}` + } + } + + async createPortal(args: PortalArgs): Promise<{ url: string }> { + return { url: `https://billing.local/portal?return=${encodeURIComponent(args.returnUrl)}` } + } + + async parseWebhook(rawBody: string, headers: Record): Promise { + if (this.secret) { + const sig = headers[HEADER] ?? headers[HEADER.toUpperCase()] + const expected = createHmac('sha256', this.secret).update(rawBody).digest('hex') + const got = Buffer.from(sig ?? '') + const want = Buffer.from(expected) + if (got.length !== want.length || !timingSafeEqual(got, want)) { + throw new WebhookSignatureError() + } + } + let body: { type?: string; customerRef?: string; plan?: string } + try { + body = JSON.parse(rawBody) + } catch { + return { type: 'ignored' } + } + if ( + (body.type === 'checkout.session.completed' || body.type === 'checkout.completed') && + body.customerRef && + body.plan + ) { + return { + type: 'checkout.completed', + customerRef: body.customerRef, + plan: body.plan as PlanId + } + } + if (body.type === 'customer.subscription.deleted' && body.customerRef) { + return { type: 'subscription.canceled', customerRef: body.customerRef } + } + return { type: 'ignored' } + } +} diff --git a/apps/cloud/src/claim.test.ts b/apps/cloud/src/claim.test.ts new file mode 100644 index 000000000..6340a3839 --- /dev/null +++ b/apps/cloud/src/claim.test.ts @@ -0,0 +1,147 @@ +import { MemoryBillingIdentityProvider } from '@xnetjs/cloud/identity' +import { describe, expect, it } from 'vitest' +import { FakeTenantBillingGateway } from './billing-gateway' +import { MemoryDeviceGrantStore, isExpired, type CodeGenerator } from './device-grant' +import { createControlPlaneApp } from './server' +import { buildControlPlane } from './index' + +/** Deterministic codes so the test can drive both sides of the grant. */ +const fixedCodes: CodeGenerator = { + deviceCode: () => 'DEVICE_CODE_FIXED', + userCode: () => 'ABCD-7K2P' +} + +function claimApp() { + 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(), + deviceGrants: new MemoryDeviceGrantStore(fixedCodes), + sessionSecret: 'sess-secret', + baseUrl: '' + }) + return { app, controlPlane } +} + +async function signIn(app: ReturnType['app']): Promise { + const res = await app.request('/auth/callback?code=code_a') + return (res.headers.get('set-cookie') ?? '').split(';')[0] +} + +const CHALLENGE = { did: 'did:key:alice', nonce: 'n1', signature: 'sig1' } + +async function provisionFor(app: ReturnType['app']): Promise { + await app.request('/webhook', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'checkout.completed', customerRef: 'user_a', plan: 'personal' }) + }) +} + +describe('device-grant claim flow', () => { + it('binds the DID after the user approves the device code', async () => { + const { app, controlPlane } = claimApp() + await provisionFor(app) + const cookie = await signIn(app) + + // App starts a grant with its local DID. + const start = await app.request('/device/start', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ did: CHALLENGE.did }) + }) + expect(start.status).toBe(200) + const { deviceCode, userCode } = (await start.json()) as { + deviceCode: string + userCode: string + } + expect(userCode).toBe('ABCD-7K2P') + + // While pending, polling returns pending (no binding yet). + const pending = await app.request('/device/token', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ deviceCode, challenge: CHALLENGE }) + }) + expect(await pending.json()).toEqual({ status: 'pending' }) + + // The signed-in user approves the code in the dashboard. + const approve = await app.request('/claim', { + method: 'POST', + headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }, + body: `userCode=${encodeURIComponent(userCode)}` + }) + expect(approve.status).toBe(200) + expect(await approve.text()).toContain('Device approved') + + // The next poll completes: the DID is bound and the hub URL returned. + const done = await app.request('/device/token', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ deviceCode, challenge: CHALLENGE }) + }) + const result = (await done.json()) as { status: string; hubUrl: string } + expect(result.status).toBe('complete') + expect(result.hubUrl).toContain('hub') + + const tenant = await controlPlane.getTenant('t_user_a') + expect(tenant?.did).toBe('did:key:alice') + }) + + it('rejects a polled DID that differs from the one shown', async () => { + const { app } = claimApp() + await provisionFor(app) + const start = await app.request('/device/start', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ did: 'did:key:alice' }) + }) + const { deviceCode } = (await start.json()) as { deviceCode: string } + const res = await app.request('/device/token', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ deviceCode, challenge: { ...CHALLENGE, did: 'did:key:mallory' } }) + }) + expect(res.status).toBe(400) + expect((await res.json()).error).toBe('did_mismatch') + }) + + it('rejects an unknown device code', async () => { + const { app } = claimApp() + const res = await app.request('/device/token', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ deviceCode: 'nope', challenge: CHALLENGE }) + }) + expect(res.status).toBe(400) + expect((await res.json()).error).toBe('invalid_grant') + }) + + it('requires a session to approve a device', async () => { + const { app } = claimApp() + const res = await app.request('/claim', { method: 'POST', body: 'userCode=ABCD-7K2P' }) + expect(res.status).toBe(302) + expect(res.headers.get('location')).toBe('/auth/start') + }) + + it('reports an unknown user code on the claim page', async () => { + const { app } = claimApp() + const cookie = await signIn(app) + const res = await app.request('/claim', { + method: 'POST', + headers: { cookie, 'content-type': 'application/x-www-form-urlencoded' }, + body: 'userCode=ZZZZ-ZZZZ' + }) + expect(await res.text()).toContain('Code not found') + }) + + it('expires a grant past its TTL', () => { + const store = new MemoryDeviceGrantStore(fixedCodes) + const grant = store.start('did:key:alice', 0) + expect(isExpired(grant, 5 * 60 * 1000)).toBe(false) + expect(isExpired(grant, 11 * 60 * 1000)).toBe(true) + }) +}) diff --git a/apps/cloud/src/control-plane.ts b/apps/cloud/src/control-plane.ts index f50b398e1..62ccb7ecf 100644 --- a/apps/cloud/src/control-plane.ts +++ b/apps/cloud/src/control-plane.ts @@ -58,6 +58,11 @@ export type PlanChangeResult = | { kind: 'flipped'; tenant: TenantRecord } | { kind: 'migration-required'; from: PlanEntitlements; to: PlanEntitlements } +/** Deterministic tenant id for a billing identity (so a replayed webhook is idempotent). */ +export function tenantIdForBilling(billingUserId: string): string { + return `t_${billingUserId.replace(/[^a-zA-Z0-9_-]/g, '')}` +} + export class ControlPlane { constructor(private readonly deps: ControlPlaneDeps) {} @@ -113,6 +118,120 @@ export class ControlPlane { return record } + /** + * Provision a hub for a paid billing identity whose DATA identity is not yet + * known — the Stripe `checkout.session.completed` path. The hub comes up with an + * empty `did`; the data DID is bound later, when the user opens the app and + * approves the device-grant claim flow (exploration 0192). Idempotent: a replayed + * webhook for the same billing user returns the existing record. + */ + async provisionForBilling(args: { + plan: PlanId + billingUserId: string + region?: string + overrides?: Partial> + }): Promise { + const tenantId = tenantIdForBilling(args.billingUserId) + const existing = await this.deps.tenants.get(tenantId) + if (existing) { + // Re-subscribe / replay: reactivate a suspended hub, otherwise return as-is. + if (existing.dataTier === 'cold') return this.reactivate(tenantId) + if (existing.subscriptionStatus === 'canceled') { + const reactivated: TenantRecord = { ...existing, subscriptionStatus: 'active' } + await this.deps.tenants.put(reactivated) + return reactivated + } + return existing + } + + const entitlements = resolveEntitlements(args.plan, args.overrides) + const handle = await this.deps.provisioner.provision({ + tenantId, + entitlements, + targetVersion: this.deps.defaultTargetVersion, + region: args.region, + env: this.hubEnv(entitlements) + }) + const record: TenantRecord = { + tenantId, + plan: args.plan, + entitlements, + billingUserId: args.billingUserId, + did: '', // bound later via the claim flow (exploration 0192) + hubUrl: handle.hubUrl, + substrateRef: handle.substrateRef, + region: handle.region, + targetVersion: handle.targetVersion, + createdAt: this.now(), + lastActiveMs: this.now(), + dataTier: 'hot', + subscriptionStatus: 'active' + } + await this.deps.tenants.put(record) + return record + } + + /** Find the tenant owned by a billing identity (dashboard + claim lookup). */ + async getTenantForBilling(billingUserId: string): Promise { + const all = await this.deps.tenants.list() + return all.find((t) => t.billingUserId === billingUserId) ?? null + } + + /** + * Bind a data DID to a billing identity's tenant — the second half of the + * device-grant "claim your hub" flow (exploration 0192). Dual proof: the + * billing session was proven when the user approved the device code; the DID is + * proven now by the signed challenge. Stamps the DID onto the tenant record. + */ + async bindDataIdentity(args: { + billingUserId: string + challenge: DidChallenge + }): Promise { + const tenant = await this.getTenantForBilling(args.billingUserId) + if (!tenant) throw new Error(`No tenant for billing user: ${args.billingUserId}`) + await bindIdentities(this.deps.bindings, this.deps.verifyDid, { + tenantId: tenant.tenantId, + billingUserId: args.billingUserId, + challenge: args.challenge, + nowMs: this.now() + }) + const updated: TenantRecord = { ...tenant, did: args.challenge.did } + await this.deps.tenants.put(updated) + return updated + } + + /** + * Suspend a tenant on subscription cancellation: tear down the live machine but + * keep the record and the R2 replica so a re-subscribe can reactivate it. The + * encrypted data is retained until the user explicitly deletes it. + */ + async suspendTenant(tenantId: string): Promise { + const record = await this.deps.tenants.get(tenantId) + if (!record) return null + if (record.substrateRef) await this.deps.provisioner.destroy(record.substrateRef) + const updated: TenantRecord = { + ...record, + subscriptionStatus: 'canceled', + dataTier: 'cold', + substrateRef: '', + hubUrl: '' + } + await this.deps.tenants.put(updated) + return updated + } + + /** + * Destroy a tenant's hub and forget it — the "delete my data" path. Irreversible: + * the encrypted DB/replica is the user's, and the company cannot recover it. + */ + async deleteTenant(tenantId: string): Promise<{ deleted: boolean }> { + const record = await this.deps.tenants.get(tenantId) + if (!record) return { deleted: false } + if (record.substrateRef) await this.deps.provisioner.destroy(record.substrateRef) + await this.deps.tenants.delete(tenantId) + return { deleted: true } + } + /** * Change a tenant's plan or capacity. A change that stays within the same * isolation tier (e.g. more storage/seats/concurrency) is a live entitlement diff --git a/apps/cloud/src/dashboard.ts b/apps/cloud/src/dashboard.ts new file mode 100644 index 000000000..66383cbfb --- /dev/null +++ b/apps/cloud/src/dashboard.ts @@ -0,0 +1,237 @@ +/** + * xNet Cloud — the authenticated dashboard (server-rendered HTML). + * + * Served same-origin by the control plane so the sealed session cookie is read + * without CORS (exploration 0192's Option 2). Deliberately a small HTML string, + * not a second React bundle — it manages the *custodial* side (plan, hub status, + * billing, danger zone); the *data/sovereign* side (connect hub, delete data DID) + * lives in the app. + */ + +import type { TenantRecord } from './registry' +import type { PlanId } from '@xnetjs/entitlements' + +export interface DashboardView { + billingUserId: string + email?: string + tenant: TenantRecord | null + /** Plans offered for self-serve checkout (excludes free demo + contract enterprise). */ + checkoutPlans: { id: PlanId; label: string; price: string }[] + /** Whether checkout/portal are wired (a billing gateway is configured). */ + billingEnabled: boolean +} + +const esc = (s: string): string => + s.replace(/[&<>"']/g, (c) => + c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : c === '"' ? '"' : ''' + ) + +const GiB = 1024 * 1024 * 1024 +const MiB = 1024 * 1024 +const fmtBytes = (n: number): string => + n >= GiB ? `${Math.round(n / GiB)} GiB` : `${Math.round(n / MiB)} MiB` + +function planPicker(view: DashboardView): string { + if (!view.billingEnabled) { + return `

Billing isn't configured on this control plane yet.

` + } + const cards = view.checkoutPlans + .map( + (p) => ` +
+ +
${esc(p.label)}
+
${esc(p.price)}
+ +
` + ) + .join('') + return `
${cards}
` +} + +function hubCard(tenant: TenantRecord): string { + const status = + tenant.subscriptionStatus === 'canceled' + ? `Canceled — suspended` + : tenant.dataTier === 'cold' + ? `Sleeping` + : `Active` + const reach = tenant.hubUrl + ? `${esc(tenant.hubUrl)}` + : `suspended — re-subscribe to wake it` + const e = tenant.entitlements + return ` +
+

Your hub ${status}

+
+
Plan
${esc(tenant.plan)}
+
Endpoint
${reach}
+
Region
${esc(tenant.region || 'auto')}
+
Storage
${fmtBytes(e.quotaBytes)}
+
Seats
${e.seats}
+
Data identity
${ + tenant.did + ? `${esc(tenant.did)}` + : `not yet connected` + }
+
+
` +} + +function connectCard(tenant: TenantRecord): string { + if (tenant.did) { + return ` +
+

Connected

+

Your app is connected to this hub. Open xNet on any device and sign in with your passkey.

+ Open the app +
` + } + return ` +
+

Connect your app

+
    +
  1. Open xNet on web, desktop, or mobile.
  2. +
  3. Create your passkey (this is your data identity — it never leaves your device).
  4. +
  5. Choose Connect xNet Cloud hub and approve the code here.
  6. +
+ Approve a device +
` +} + +function billingCard(view: DashboardView, tenant: TenantRecord): string { + if (!view.billingEnabled) return '' + const portal = + tenant.subscriptionStatus === 'canceled' + ? `

Your subscription is canceled.

` + : `
` + return ` +
+

Billing

+ ${portal} +
` +} + +function dangerZone(): string { + return ` +
+

Danger zone

+

+ 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). +

+
+
+
+ +
+
+
` +} + +const STYLE = ` + :root { color-scheme: dark; } + * { box-sizing: border-box; } + body { margin: 0; background: #0a0a0f; color: #e5e7eb; font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } + .wrap { max-width: 880px; margin: 0 auto; padding: 32px 24px 80px; } + header { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 28px; } + header .brand { font-weight: 700; font-size: 20px; letter-spacing: -0.02em; } + header .who { color: #9ca3af; font-size: 13px; } + h1 { font-size: 26px; margin: 0 0 6px; } + h2 { font-size: 16px; margin: 0 0 14px; } + .lead { color: #9ca3af; margin: 0 0 28px; } + .card { border: 1px solid #23232b; background: #121218; border-radius: 14px; padding: 22px; margin-bottom: 18px; } + .card.danger { border-color: rgba(239,68,68,0.3); } + dl { margin: 0; display: grid; gap: 10px; } + dl > div { display: flex; justify-content: space-between; gap: 12px; } + dt { color: #9ca3af; } dd { margin: 0; text-align: right; font-weight: 500; } + code { background: #1c1c24; padding: 2px 6px; border-radius: 6px; font-size: 13px; word-break: break-all; } + .muted { color: #9ca3af; font-size: 14px; } + .badge { font-size: 12px; padding: 2px 8px; border-radius: 999px; font-weight: 600; } + .badge-ok { background: rgba(16,185,129,0.15); color: #34d399; } + .badge-warn { background: rgba(245,158,11,0.15); color: #fbbf24; } + button, .btn { display: inline-block; background: #4f46e5; color: #fff; border: 0; border-radius: 9px; padding: 9px 16px; font-size: 14px; font-weight: 500; cursor: pointer; text-decoration: none; } + button:hover, .btn:hover { background: #6366f1; } + button.ghost { background: transparent; border: 1px solid #3a3a44; color: #c7c7d1; } + button.destructive { background: #b91c1c; } + button.destructive:hover { background: #dc2626; } + .plans { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; } + .plan { border: 1px solid #23232b; border-radius: 12px; padding: 16px; text-align: center; } + .plan-name { font-weight: 600; } .plan-price { color: #9ca3af; font-size: 13px; margin: 4px 0 12px; } + .danger-actions { display: flex; gap: 12px; flex-wrap: wrap; } + ol { margin: 0 0 16px; padding-left: 20px; } ol li { margin-bottom: 6px; } +` + +/** Wrap inner HTML in the shared dark-themed document chrome. */ +function page(title: string, who: string, inner: string): string { + return ` + + + +${esc(title)} + +
+
+ xNet Cloud + ${esc(who)} · Sign out +
+ ${inner} +
` +} + +/** Render the full dashboard HTML document. */ +export function renderDashboard(view: DashboardView): string { + const who = view.email ?? view.billingUserId + const body = view.tenant + ? `${hubCard(view.tenant)}${connectCard(view.tenant)}${billingCard(view, view.tenant)}${dangerZone()}` + : `
+

Welcome to xNet Cloud

+

Pick a plan to spin up your dedicated hub. You can change or cancel any time.

+ ${planPicker(view)} +
` + return page( + 'xNet Cloud — Dashboard', + who, + `

Dashboard

+

Manage your managed hub, billing, and data.

+ ${body}` + ) +} + +/** Render the device-grant approval form (the dashboard side of "claim your hub"). */ +export function renderClaimForm(opts: { who: string; prefill?: string; error?: string }): string { + const err = opts.error ? `

${esc(opts.error)}

` : '' + return page( + 'xNet Cloud — Connect a device', + opts.who, + `

Connect a device

+

Enter the code shown in your xNet app to link it to your hub.

+
+ ${err} +
+ + + +
+
` + ) +} + +/** Render the result after approving (or failing to find) a device code. */ +export function renderClaimResult(opts: { who: string; ok: boolean }): string { + const inner = opts.ok + ? `
+

Device approved Done

+

Return to your xNet app — it will finish connecting to your hub automatically.

+ Back to dashboard +
` + : `
+

Code not found

+

That code is unknown or has expired. Restart the connection from your app and try again.

+ Try again +
` + return page('xNet Cloud — Connect a device', opts.who, `

Connect a device

${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] +--- + + +