Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions apps/cloud/src/billing-gateway.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>): Promise<WebhookResult>
}

/**
* 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<Record<PlanId, string>> = {
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<string, string>): Promise<WebhookResult> {
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' }
}
}
147 changes: 147 additions & 0 deletions apps/cloud/src/claim.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof claimApp>['app']): Promise<string> {
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<typeof claimApp>['app']): Promise<void> {
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)
})
})
Loading
Loading