diff --git a/.dev.vars.example b/.dev.vars.example index fdf5c66d..de0c70ff 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -4,5 +4,9 @@ # Required: better-auth signing secret. Generate with: openssl rand -base64 32 BETTER_AUTH_SECRET= -# Optional: point local development at a non-production ZPan Cloud. -ZPAN_CLOUD_URL=https://zpan-cloud-staging.saltbo.workers.dev +# Local ZPan URL. +BETTER_AUTH_URL=http://localhost:5185 +TRUSTED_ORIGINS=http://localhost:5185 + +# Local ZPan Cloud URL. +ZPAN_CLOUD_URL=http://localhost:5186 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22a5572e..f820da71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,8 +82,8 @@ jobs: needs: check env: BETTER_AUTH_SECRET: ci-test-secret-that-is-at-least-32-chars - E2E_CLOUD_PRO_EMAIL_NODE: ${{ secrets.E2E_CLOUD_PRO_EMAIL_NODE }} - E2E_CLOUD_PRO_PASSWORD_NODE: ${{ secrets.E2E_CLOUD_PRO_PASSWORD_NODE }} + E2E_CLOUD_BUSINESS_EMAIL_NODE: ${{ secrets.E2E_CLOUD_BUSINESS_EMAIL_NODE }} + E2E_CLOUD_BUSINESS_PASSWORD_NODE: ${{ secrets.E2E_CLOUD_BUSINESS_PASSWORD_NODE }} steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 @@ -93,16 +93,16 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - name: Install cloudflared - if: env.E2E_CLOUD_PRO_EMAIL_NODE != '' && env.E2E_CLOUD_PRO_PASSWORD_NODE != '' + if: env.E2E_CLOUD_BUSINESS_EMAIL_NODE != '' && env.E2E_CLOUD_BUSINESS_PASSWORD_NODE != '' run: | curl -L --fail --output cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 chmod +x cloudflared - name: Run cloud E2E - if: env.E2E_CLOUD_PRO_EMAIL_NODE != '' && env.E2E_CLOUD_PRO_PASSWORD_NODE != '' + if: env.E2E_CLOUD_BUSINESS_EMAIL_NODE != '' && env.E2E_CLOUD_BUSINESS_PASSWORD_NODE != '' run: CLOUDFLARED_BIN=./cloudflared pnpm e2e:cloud -- --runtime node - name: Skip cloud E2E - if: env.E2E_CLOUD_PRO_EMAIL_NODE == '' || env.E2E_CLOUD_PRO_PASSWORD_NODE == '' - run: echo "Skipping Node cloud E2E because credentials are not configured." + if: env.E2E_CLOUD_BUSINESS_EMAIL_NODE == '' || env.E2E_CLOUD_BUSINESS_PASSWORD_NODE == '' + run: echo "Skipping Node cloud E2E because Business credentials are not configured." - uses: actions/upload-artifact@v4 if: failure() with: @@ -115,8 +115,8 @@ jobs: runs-on: ubuntu-latest needs: check env: - E2E_CLOUD_PRO_EMAIL_CF: ${{ secrets.E2E_CLOUD_PRO_EMAIL_CF }} - E2E_CLOUD_PRO_PASSWORD_CF: ${{ secrets.E2E_CLOUD_PRO_PASSWORD_CF }} + E2E_CLOUD_BUSINESS_EMAIL_CF: ${{ secrets.E2E_CLOUD_BUSINESS_EMAIL_CF }} + E2E_CLOUD_BUSINESS_PASSWORD_CF: ${{ secrets.E2E_CLOUD_BUSINESS_PASSWORD_CF }} steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 @@ -126,16 +126,16 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - name: Install cloudflared - if: env.E2E_CLOUD_PRO_EMAIL_CF != '' && env.E2E_CLOUD_PRO_PASSWORD_CF != '' + if: env.E2E_CLOUD_BUSINESS_EMAIL_CF != '' && env.E2E_CLOUD_BUSINESS_PASSWORD_CF != '' run: | curl -L --fail --output cloudflared https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 chmod +x cloudflared - name: Run cloud E2E - if: env.E2E_CLOUD_PRO_EMAIL_CF != '' && env.E2E_CLOUD_PRO_PASSWORD_CF != '' + if: env.E2E_CLOUD_BUSINESS_EMAIL_CF != '' && env.E2E_CLOUD_BUSINESS_PASSWORD_CF != '' run: CLOUDFLARED_BIN=./cloudflared pnpm e2e:cloud:cf - name: Skip cloud E2E - if: env.E2E_CLOUD_PRO_EMAIL_CF == '' || env.E2E_CLOUD_PRO_PASSWORD_CF == '' - run: echo "Skipping CF cloud E2E because credentials are not configured." + if: env.E2E_CLOUD_BUSINESS_EMAIL_CF == '' || env.E2E_CLOUD_BUSINESS_PASSWORD_CF == '' + run: echo "Skipping CF cloud E2E because Business credentials are not configured." - uses: actions/upload-artifact@v4 if: failure() with: diff --git a/e2e/cloud-store.spec.ts b/e2e/cloud-store.spec.ts index c5ea5999..b742d834 100644 --- a/e2e/cloud-store.spec.ts +++ b/e2e/cloud-store.spec.ts @@ -15,6 +15,7 @@ type BindingState = { bound: boolean active?: boolean account_email?: string + cloud_dashboard_url?: string } type PairingInfo = { @@ -22,6 +23,11 @@ type PairingInfo = { pairingUrl: string } +type PairingPollResult = { + status: string + cloud_store_id?: string +} + type CloudProduct = { id: string name: string @@ -43,6 +49,16 @@ type CloudLicense = { id: string } +type CloudStore = { + id: string + type: string +} + +type CloudBusinessContext = { + request: APIRequestContext + storeId: string +} + type ResponseLike = { status(): number text(): Promise @@ -54,66 +70,51 @@ test.describe await unbindCurrentCloudBinding() }) - test('@desktop covers pairing, admin store setup, gift-card credit redemption, and checkout', async ({ + test('@desktop covers pairing, Cloud store setup, gift-card credit redemption, and checkout', async ({ page, baseURL, }) => { test.setTimeout(420_000) await signInAsAdmin(page) - await ensureCloudBinding(page) + const cloud = await ensureCloudBinding(page) - const testId = Date.now() - const packageName = `E2E Cloud Plan ${testId}` - const creditPackageName = `E2E Credits ${testId}` - const storagePlan = await createStoragePlan(page, packageName) - const product = await createCreditPackage(page, creditPackageName) - const giftCard = await createGiftCard(page) - await expectAdminProductVisibleInApi(page, storagePlan.id, packageName) - await expectAdminGiftCardVisibleInApi(page, giftCard.code) + try { + const testId = Date.now() + const packageName = `E2E Cloud Plan ${testId}` + const creditPackageName = `E2E Credits ${testId}` + const storagePlan = await createStoragePlan(cloud, packageName) + const product = await createCreditPackage(cloud, creditPackageName) + const giftCard = await createGiftCard(cloud) + await expectCloudProductVisible(cloud, storagePlan.id, packageName) + await expectCloudGiftCardVisible(cloud, giftCard.code) - await page.goto('/storage') - await expect(page.getByRole('heading', { name: 'Storage', exact: true })).toBeVisible({ timeout: 20_000 }) - await expectStorefrontProductVisibleInApi(page, packageName) + await page.goto('/storage') + await expect(page.getByRole('heading', { name: 'Storage', exact: true })).toBeVisible({ timeout: 20_000 }) + await expectStorefrontProductVisibleInApi(page, packageName) - const creditsBefore = await getCreditBalance(page) - await redeemGiftCard(page, giftCard.code) - await expect.poll(() => getCreditBalance(page), { timeout: 20_000 }).toBeGreaterThanOrEqual(creditsBefore + 200) + const creditsBefore = await getCreditBalance(page) + await redeemGiftCard(page, giftCard.code) + await expect.poll(() => getCreditBalance(page), { timeout: 20_000 }).toBeGreaterThanOrEqual(creditsBefore + 200) - const hasPublicCallbackUrl = Boolean(baseURL && !LOCALHOST_RE.test(new URL(baseURL).origin)) - if (!hasPublicCallbackUrl) { - test.info().annotations.push({ - type: 'checkout-delivery-skipped', - description: 'Cloud staging cannot call back to a localhost ZPan instance.', + const hasPublicCallbackUrl = Boolean(baseURL && !LOCALHOST_RE.test(new URL(baseURL).origin)) + if (!hasPublicCallbackUrl) { + test.info().annotations.push({ + type: 'checkout-delivery-skipped', + description: 'Cloud staging cannot call back to a localhost ZPan instance.', + }) + return + } + + await postJson<{ orderId: string; url: string }>(page, '/api/store/checkouts', { + packageId: product.id, + priceId: product.prices[0].id, }) - return + + await expectOrderCreated(page) + } finally { + await cloud.request.dispose() } - - await postJson<{ orderId: string; url: string }>(page, '/api/store/checkouts', { - packageId: product.id, - priceId: product.prices[0].id, - }) - - await expectOrderCreated(page, product.id) - }) - - test('@desktop creates Cloud store products and gift cards through admin UI forms', async ({ page }) => { - test.setTimeout(180_000) - - await signInAsAdmin(page) - await ensureCloudBinding(page) - - const testId = Date.now() - const packageName = `E2E UI Plan ${testId}` - const creditPackageName = `E2E UI Credits ${testId}` - const storagePlan = await createStoragePlanThroughUi(page, packageName) - const creditPackage = await createCreditPackageThroughUi(page, creditPackageName) - - await expectAdminProductVisibleInApi(page, storagePlan.id, packageName) - await expectAdminCreditProductVisibleInApi(page, creditPackage.id, creditPackageName) - - const giftCardCode = await createGiftCardThroughUi(page) - await expectAdminGiftCardVisibleInApi(page, giftCardCode) }) test('@desktop lets a regular user list Cloud packages and redeem a gift card', async ({ @@ -124,47 +125,55 @@ test.describe test.setTimeout(300_000) await signInAsAdmin(page) - await ensureCloudBinding(page) + const cloud = await ensureCloudBinding(page) - const testId = Date.now() - const packageName = `E2E User Plan ${testId}` - await createStoragePlan(page, packageName) - const giftCard = await createGiftCard(page) - await expectStorefrontProductVisibleInApi(page, packageName) - - const userContext = await newBrowserContext(browser, baseURL) try { - const userPage = await userContext.newPage() - await signUpAndGoToFiles(userPage) - await userPage.goto('/storage') - await expect(userPage.getByRole('heading', { name: 'Storage', exact: true })).toBeVisible({ timeout: 20_000 }) - await expectStorefrontProductVisibleInApi(userPage, packageName) + const testId = Date.now() + const packageName = `E2E User Plan ${testId}` + await createStoragePlan(cloud, packageName) + const giftCard = await createGiftCard(cloud) + await expectStorefrontProductVisibleInApi(page, packageName) - const creditsBefore = await getCreditBalance(userPage) - await redeemGiftCard(userPage, giftCard.code) - await expect - .poll(() => getCreditBalance(userPage), { timeout: 20_000 }) - .toBeGreaterThanOrEqual(creditsBefore + 200) + const userContext = await newBrowserContext(browser, baseURL) + try { + const userPage = await userContext.newPage() + await signUpAndGoToFiles(userPage) + await userPage.goto('/storage') + await expect(userPage.getByRole('heading', { name: 'Storage', exact: true })).toBeVisible({ timeout: 20_000 }) + await expectStorefrontProductVisibleInApi(userPage, packageName) + + const creditsBefore = await getCreditBalance(userPage) + await redeemGiftCard(userPage, giftCard.code) + await expect + .poll(() => getCreditBalance(userPage), { timeout: 20_000 }) + .toBeGreaterThanOrEqual(creditsBefore + 200) + } finally { + await userContext.close() + } } finally { - await userContext.close() + await cloud.request.dispose() } }) }) -async function ensureCloudBinding(page: Page) { - const current = await getJson(page, '/api/licensing/status') - if (current.bound && current.active) { - await enableCloudStore(page) - return - } +async function ensureCloudBinding(page: Page): Promise { + await unbindCurrentCloudBinding() const pairing = await postJson(page, '/api/licensing/pair') await approvePairingInCloud(pairing) + let approved: PairingPollResult | null = null await expect - .poll(async () => (await getJson<{ status: string }>(page, `/api/licensing/pair/${pairing.code}/poll`)).status, { - timeout: 30_000, - }) + .poll( + async () => { + const result = await getJson(page, `/api/licensing/pair/${pairing.code}/poll`) + if (result.status === 'approved') approved = result + return result.status + }, + { + timeout: 30_000, + }, + ) .toBe('approved') await expect @@ -173,17 +182,51 @@ async function ensureCloudBinding(page: Page) { return state.bound && state.active }) .toBe(true) - await enableCloudStore(page) + + if (!approved?.cloud_store_id) throw new Error('Cloud pairing approval did not include cloud_store_id') + return createCloudBusinessContext(new URL(pairing.pairingUrl).origin, approved.cloud_store_id) } -async function enableCloudStore(page: Page) { - await putJson(page, '/api/admin/store/settings', { enabled: true }) +async function createCloudBusinessContext(baseURL: string, storeId?: string): Promise { + const email = process.env.E2E_CLOUD_BUSINESS_EMAIL ?? process.env.E2E_CLOUD_PRO_EMAIL + const password = process.env.E2E_CLOUD_BUSINESS_PASSWORD ?? process.env.E2E_CLOUD_PRO_PASSWORD + if (!email || !password) { + throw new Error('E2E_CLOUD_BUSINESS_EMAIL and E2E_CLOUD_BUSINESS_PASSWORD are required') + } + + const request = await playwrightRequest.newContext({ baseURL }) + const signIn = await request.post('/api/auth/sign-in/email', { + data: { email, password }, + }) + await expectCloudOk(signIn, 'Cloud test account sign-in failed') + + return { request, storeId: storeId ?? (await pollCloudBusinessStore(request)) } +} + +async function pollCloudBusinessStore(request: APIRequestContext): Promise { + let storeId: string | null = null + await expect + .poll( + async () => { + const response = await request.get('/api/accounts/me/stores') + await expectCloudOk(response, 'Cloud store list failed') + const body = (await response.json()) as { data?: { items?: CloudStore[] }; items?: CloudStore[] } + const stores = body.data?.items ?? body.items ?? [] + storeId = stores.find((store) => store.type === 'instance')?.id ?? null + return storeId + }, + { timeout: 60_000 }, + ) + .not.toBeNull() + return storeId! } async function approvePairingInCloud(pairing: PairingInfo) { - const email = process.env.E2E_CLOUD_PRO_EMAIL - const password = process.env.E2E_CLOUD_PRO_PASSWORD - if (!email || !password) throw new Error('E2E_CLOUD_PRO_EMAIL and E2E_CLOUD_PRO_PASSWORD are required') + const email = process.env.E2E_CLOUD_BUSINESS_EMAIL ?? process.env.E2E_CLOUD_PRO_EMAIL + const password = process.env.E2E_CLOUD_BUSINESS_PASSWORD ?? process.env.E2E_CLOUD_PRO_PASSWORD + if (!email || !password) { + throw new Error('E2E_CLOUD_BUSINESS_EMAIL and E2E_CLOUD_BUSINESS_PASSWORD are required') + } const cloudOrigin = new URL(pairing.pairingUrl).origin const cloudRequest = await playwrightRequest.newContext({ baseURL: cloudOrigin }) @@ -250,8 +293,8 @@ async function unbindCurrentCloudBinding() { } } -async function createStoragePlan(page: Page, name: string) { - return postJson(page, '/api/admin/store/packages', { +async function createStoragePlan(cloud: CloudBusinessContext, name: string) { + return cloudJson(cloud, 'POST', `/api/stores/${cloud.storeId}/products`, { type: 'store_item', name, description: 'Playwright staging Cloud store plan', @@ -275,8 +318,8 @@ async function createStoragePlan(page: Page, name: string) { }) } -async function createCreditPackage(page: Page, name: string) { - return postJson(page, '/api/admin/store/packages', { +async function createCreditPackage(cloud: CloudBusinessContext, name: string) { + return cloudJson(cloud, 'POST', `/api/stores/${cloud.storeId}/products`, { type: 'store_item', name, description: 'Playwright staging Cloud store Credits package', @@ -292,8 +335,8 @@ async function createCreditPackage(page: Page, name: string) { }) } -async function createGiftCard(page: Page) { - const cards = await postJson(page, '/api/admin/store/gift-cards', { +async function createGiftCard(cloud: CloudBusinessContext) { + const cards = await cloudJson(cloud, 'POST', `/api/stores/${cloud.storeId}/gift-cards`, { credits: 200, count: 1, }) @@ -303,85 +346,15 @@ async function createGiftCard(page: Page) { return { ...card, code: card.code } } -async function createStoragePlanThroughUi(page: Page, packageName: string) { - await gotoAdminCloudStore(page) - await page.getByRole('button', { name: 'New plan' }).click() - const dialog = page.getByRole('dialog', { name: 'New plan' }) - await dialog.getByLabel('Plan name').fill(packageName) - await dialog.getByLabel('Description').fill('Created by Playwright through the admin form') - await dialog.getByRole('spinbutton', { name: 'Storage quota' }).fill('1') - await dialog.getByRole('spinbutton', { name: 'Included Credits' }).fill('200') - await dialog.getByLabel('Monthly price (USD)').fill('1') - - const response = page.waitForResponse( - (item) => item.url().includes('/api/admin/store/packages') && item.request().method() === 'POST', - ) - await dialog.getByRole('button', { name: 'Save' }).click() - const result = await response - await expectResponseStatus(result, 201) - await expect(dialog).not.toBeVisible({ timeout: 20_000 }) - return result.json() as Promise -} - -async function createCreditPackageThroughUi(page: Page, packageName: string) { - await gotoAdminCloudStore(page) - await page.getByRole('button', { name: 'New Credits package' }).click() - const dialog = page.getByRole('dialog', { name: 'New Credits package' }) - await dialog.getByLabel('Name').fill(packageName) - await dialog.getByLabel('Description').fill('Created by Playwright through the admin form') - await dialog.getByRole('spinbutton', { name: 'Credits' }).fill('200') - await dialog.getByLabel('Package amount (USD)').fill('1') - - const response = page.waitForResponse( - (item) => item.url().includes('/api/admin/store/packages') && item.request().method() === 'POST', - ) - await dialog.getByRole('button', { name: 'Save' }).click() - const result = await response - await expectResponseStatus(result, 201) - await expect(dialog).not.toBeVisible({ timeout: 20_000 }) - return result.json() as Promise -} - -async function createGiftCardThroughUi(page: Page) { - await gotoAdminCloudStore(page) - await page.getByRole('tab', { name: 'Gift Cards' }).click() - await page.getByRole('button', { name: 'Generate gift cards' }).click() - const dialog = page.getByRole('dialog', { name: 'Generate gift cards' }) - await dialog.getByLabel('Credits').fill('3') - - const response = page.waitForResponse( - (item) => item.url().includes('/api/admin/store/gift-cards') && item.request().method() === 'POST', - ) - await dialog.getByRole('button', { name: 'Generate' }).click() - const result = await response - await expectResponseStatus(result, 201) - const cards = (await result.json()) as CloudGiftCard[] - expect(cards.length).toBe(1) - const card = cards[0] - if (card.code === null) throw new Error('Cloud gift card create response did not include code') - return card.code -} - -async function gotoAdminCloudStore(page: Page) { - for (let attempt = 0; attempt < 2; attempt += 1) { - await page.goto('/admin/cloud-store', { waitUntil: 'domcontentloaded' }) - await expect(page).toHaveURL(/admin\/cloud-store/, { timeout: 10_000 }) - const heading = page.getByRole('heading', { name: 'Storage Plans', exact: true }) - try { - await expect(heading).toBeVisible({ timeout: 15_000 }) - return - } catch (error) { - if (attempt === 1) throw error - await page.waitForTimeout(1500) - } - } -} - -async function expectAdminProductVisibleInApi(page: Page, packageId: string, packageName: string) { +async function expectCloudProductVisible(cloud: CloudBusinessContext, packageId: string, packageName: string) { await expect .poll( async () => { - const product = await getJson(page, `/api/admin/store/packages/${packageId}`) + const product = await cloudJson( + cloud, + 'GET', + `/api/stores/${cloud.storeId}/products/${packageId}`, + ) return product.name }, { timeout: 60_000 }, @@ -389,23 +362,15 @@ async function expectAdminProductVisibleInApi(page: Page, packageId: string, pac .toBe(packageName) } -async function expectAdminCreditProductVisibleInApi(page: Page, packageId: string, packageName: string) { +async function expectCloudGiftCardVisible(cloud: CloudBusinessContext, code: string) { await expect .poll( async () => { - const product = await getJson(page, `/api/admin/store/packages/${packageId}`) - return product.name - }, - { timeout: 60_000 }, - ) - .toBe(packageName) -} - -async function expectAdminGiftCardVisibleInApi(page: Page, code: string) { - await expect - .poll( - async () => { - const giftCards = await getJson<{ items: CloudGiftCard[] }>(page, '/api/admin/store/gift-cards') + const giftCards = await cloudJson<{ items: CloudGiftCard[] }>( + cloud, + 'GET', + `/api/stores/${cloud.storeId}/gift-cards`, + ) return giftCards.items.map((item) => item.codeLast4) }, { timeout: 60_000 }, @@ -450,7 +415,7 @@ async function expectStorefrontProductVisibleInApi(page: Page, packageName: stri .toContain(packageName) } -async function expectOrderCreated(page: Page, productId: string) { +async function expectOrderCreated(page: Page) { const orders = await getJson<{ items: CloudOrder[] }>(page, '/api/store/orders') expect(orders.items[0]).toEqual( expect.objectContaining({ @@ -458,10 +423,6 @@ async function expectOrderCreated(page: Page, productId: string) { }), ) - const adminOrders = await getJson<{ - items: Array }> - }>(page, '/api/admin/store/orders') - expect(adminOrders.items.some((order) => order.items.some((item) => item.productId === productId))).toBe(true) return orders } @@ -477,11 +438,7 @@ async function postJson(page: Page, url: string, data?: unknown): Promise return browserJson(page, 'POST', url, data) } -async function putJson(page: Page, url: string, data?: unknown): Promise { - return browserJson(page, 'PUT', url, data) -} - -async function browserJson(page: Page, method: 'GET' | 'POST' | 'PUT', url: string, data?: unknown): Promise { +async function browserJson(page: Page, method: 'GET' | 'POST', url: string, data?: unknown): Promise { const retryDelays = [500, 1000, 3000, 7000, 15000] const stripeRateLimitRetryDelays = [1000, 3000, 7000, 15000, 30000] for (let attempt = 0; attempt <= stripeRateLimitRetryDelays.length; attempt += 1) { @@ -517,6 +474,21 @@ async function browserJson(page: Page, method: 'GET' | 'POST' | 'PUT', url: s throw new Error(`${method} ${url} failed`) } +async function cloudJson( + cloud: CloudBusinessContext, + method: 'GET' | 'POST', + url: string, + data?: unknown, +): Promise { + const response = await cloud.request.fetch(url, { + method, + data, + }) + await expectCloudOk(response, `Cloud ${method} ${url} failed`) + const body = (await response.json()) as { data?: T } | T + return body && typeof body === 'object' && 'data' in body ? (body.data as T) : (body as T) +} + async function expectResponseStatus(response: ResponseLike, status: number) { if (response.status() === status) return const text = await response.text() diff --git a/package.json b/package.json index d83784ea..d5480d34 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ "tailwind-merge": "^3.5.0", "yet-another-react-lightbox": "^3.30.1", "zod": "^4.4.3", - "zpan-cloud-sdk": "^1.0.3" + "zpan-cloud-sdk": "^2.0.0" }, "devDependencies": { "@biomejs/biome": "^2.4.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44d96b9a..895434ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,8 +197,8 @@ importers: specifier: ^4.4.3 version: 4.4.3 zpan-cloud-sdk: - specifier: ^1.0.3 - version: 1.0.3(hono@4.12.21)(zod@4.4.3) + specifier: ^2.0.0 + version: 2.0.0(hono@4.12.21)(zod@4.4.3) devDependencies: '@biomejs/biome': specifier: ^2.4.10 @@ -5605,8 +5605,8 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} - zpan-cloud-sdk@1.0.3: - resolution: {integrity: sha512-x5GCerFEXFLaRx+VZWmfDA1iikjQlMau8CC3fStoQZrEvtsijV+PbxnRxSCIVbmTFst2rx+Jx7y7RJVrGuXWpg==} + zpan-cloud-sdk@2.0.0: + resolution: {integrity: sha512-cWvPEIkr1MHhPIDJjrJrRzwQUWPtT+hu7ljdS3EEQby0YcGNrS7IwuBOq9zt+TcwB/J02op3VMuziXrGfzcapQ==} peerDependencies: hono: ^4.7.11 zod: ^4.3.6 @@ -11023,7 +11023,7 @@ snapshots: zod@4.4.3: {} - zpan-cloud-sdk@1.0.3(hono@4.12.21)(zod@4.4.3): + zpan-cloud-sdk@2.0.0(hono@4.12.21)(zod@4.4.3): dependencies: hono: 4.12.21 zod: 4.4.3 diff --git a/scripts/run-cloud-e2e.mjs b/scripts/run-cloud-e2e.mjs index 9ebfe354..746b5d0b 100644 --- a/scripts/run-cloud-e2e.mjs +++ b/scripts/run-cloud-e2e.mjs @@ -79,17 +79,22 @@ function valueAfter(flag) { function runtimeCloudCredentials(runtime) { const suffix = runtime === 'cf' ? '_CF' : '_NODE' - const runtimeEmail = process.env[`E2E_CLOUD_PRO_EMAIL${suffix}`]?.trim() - const runtimePassword = process.env[`E2E_CLOUD_PRO_PASSWORD${suffix}`]?.trim() + const runtimeEmail = + process.env[`E2E_CLOUD_BUSINESS_EMAIL${suffix}`]?.trim() || + process.env[`E2E_CLOUD_PRO_EMAIL${suffix}`]?.trim() + const runtimePassword = + process.env[`E2E_CLOUD_BUSINESS_PASSWORD${suffix}`]?.trim() || + process.env[`E2E_CLOUD_PRO_PASSWORD${suffix}`]?.trim() if (process.env.CI && (!runtimeEmail || !runtimePassword)) { - throw new Error(`Missing E2E_CLOUD_PRO_EMAIL${suffix} or E2E_CLOUD_PRO_PASSWORD${suffix}`) + throw new Error(`Missing E2E_CLOUD_BUSINESS_EMAIL${suffix} or E2E_CLOUD_BUSINESS_PASSWORD${suffix}`) } - const email = runtimeEmail || process.env.E2E_CLOUD_PRO_EMAIL - const password = runtimePassword || process.env.E2E_CLOUD_PRO_PASSWORD + const email = runtimeEmail || process.env.E2E_CLOUD_BUSINESS_EMAIL || process.env.E2E_CLOUD_PRO_EMAIL + const password = + runtimePassword || process.env.E2E_CLOUD_BUSINESS_PASSWORD || process.env.E2E_CLOUD_PRO_PASSWORD return email && password ? { - E2E_CLOUD_PRO_EMAIL: email, - E2E_CLOUD_PRO_PASSWORD: password, + E2E_CLOUD_BUSINESS_EMAIL: email, + E2E_CLOUD_BUSINESS_PASSWORD: password, } : {} } diff --git a/server/app.ts b/server/app.ts index ac6ee908..25ac57e8 100644 --- a/server/app.ts +++ b/server/app.ts @@ -13,7 +13,7 @@ import { adminAudit } from './routes/audit' import { adminAuthProviders, publicAuthProviders } from './routes/auth-providers' import backgroundJobs from './routes/background-jobs' import { brandingAdmin, publicBranding } from './routes/branding' -import { adminCloudStore, cloudStore, cloudStoreWebhooks } from './routes/cloud-store' +import { cloudStore, cloudStoreWebhooks } from './routes/cloud-store' import downloadTasks from './routes/download-tasks' import downloaders, { downloaderSelfRoute } from './routes/downloaders' import emailConfig from './routes/email-config' @@ -101,7 +101,6 @@ export function createApp(platform: Platform, auth: Auth) { app.route('/api/admin/quotas', adminQuotas) app.route('/api/quotas', userQuotas) app.route('/api/store', cloudStore) - app.route('/api/admin/store', adminCloudStore) app.route('/api/system', system) app.route('/api/admin/auth-providers', adminAuthProviders) app.route('/api/notifications', notifications) @@ -162,7 +161,6 @@ export type AdminAuthProvidersRoute = typeof adminAuthProviders export type ProfileRoute = typeof profile export type CloudStoreRoute = typeof cloudStore export type CloudStoreWebhooksRoute = typeof cloudStoreWebhooks -export type AdminCloudStoreRoute = typeof adminCloudStore export type TeamsRoute = typeof teams export type PublicTeamsRoute = typeof publicTeams export type NotificationsRoute = typeof notifications diff --git a/server/auth.ts b/server/auth.ts index d63e2ee4..2b1812ca 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -149,6 +149,9 @@ export async function createAuth( secret, baseURL, trustedOrigins, + advanced: { + cookiePrefix: 'zp', + }, emailAndPassword: { enabled: true, password: { diff --git a/server/bootstrap.ts b/server/bootstrap.ts index a06fbe8d..60eddfd4 100644 --- a/server/bootstrap.ts +++ b/server/bootstrap.ts @@ -8,12 +8,12 @@ export async function createBootstrap(platform: Platform) { throw new Error('BETTER_AUTH_SECRET is required. Set it in the environment before starting the server.') } - const baseURL = platform.getEnv('BETTER_AUTH_URL') || 'http://localhost:5173' + const baseURL = platform.getEnv('BETTER_AUTH_URL') || 'http://localhost:5185' const trustedOrigins = platform .getEnv('TRUSTED_ORIGINS') ?.split(',') .map((o) => o.trim()) - .filter(Boolean) || ['http://localhost:5173'] + .filter(Boolean) || ['http://localhost:5185'] const auth = await createAuth(platform, secret, baseURL, trustedOrigins) return createApp(platform, auth) diff --git a/server/entry-node.ts b/server/entry-node.ts index fd13b208..fff1f732 100644 --- a/server/entry-node.ts +++ b/server/entry-node.ts @@ -3,6 +3,7 @@ import { serveStatic } from '@hono/node-server/serve-static' import { Hono } from 'hono' import { ZPAN_CLOUD_URL_DEFAULT } from '../shared/constants' import { createBootstrap } from './bootstrap' +import { buildCloudInstanceInfo } from './licensing/instance-info' import { createLibsqlPlatform } from './platform/libsql' import { createNodePlatform } from './platform/node' import { syncPendingCloudTrafficReports } from './services/cloud-traffic-metering' @@ -32,10 +33,32 @@ serve({ fetch: server.fetch, port }) // Start licensing refresh background scheduler const cloudBaseUrl = process.env.ZPAN_CLOUD_URL ?? ZPAN_CLOUD_URL_DEFAULT + +function configuredPublicOrigin(): string | null { + const value = process.env.ZPAN_PUBLIC_ORIGIN ?? process.env.BETTER_AUTH_URL + if (!value) return null + try { + const url = new URL(value) + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null + return url.origin + } catch { + return null + } +} + console.log('licensing.refresh.scheduler.started interval=6h') setInterval(() => { // runLicensingRefresh handles all errors internally and never rejects. - void runLicensingRefresh(platform.db, cloudBaseUrl) + void (async () => { + const instanceUrl = configuredPublicOrigin() + const instance = instanceUrl + ? await buildCloudInstanceInfo(platform.db, { + configuredInstanceId: process.env.ZPAN_INSTANCE_ID, + url: instanceUrl, + }) + : undefined + await runLicensingRefresh(platform.db, cloudBaseUrl, instance) + })() }, REFRESH_INTERVAL_MS) console.log('traffic.sync.scheduler.started interval=10m') diff --git a/server/licensing/cloud-event-token.ts b/server/licensing/cloud-event-token.ts index 009f62a4..0801ddf9 100644 --- a/server/licensing/cloud-event-token.ts +++ b/server/licensing/cloud-event-token.ts @@ -6,7 +6,7 @@ import { trustedIssuerFromCloudUrl } from './verify' const CLOUD_EVENT_TOKEN_MAX_TTL_SECONDS = 5 * 60 const cloudEventTokenSchema = z.object({ - type: z.literal('zpan.cloud.event'), + type: z.literal('commerce.fulfillment.token'), purpose: z.literal('store.delivery'), issuer: z.string().min(1), audience: z.string().min(1), diff --git a/server/licensing/e2e-cloud-integration.test.ts b/server/licensing/e2e-cloud-integration.test.ts index 431899d9..13a751b5 100644 --- a/server/licensing/e2e-cloud-integration.test.ts +++ b/server/licensing/e2e-cloud-integration.test.ts @@ -113,12 +113,12 @@ async function approvePairingInCloud(pairing: PairingResponse): Promise { describe('E2E: zpan-cloud API contract', () => { it('POST /api/pairings creates a pairing with correct shape', async () => { - const result = await createPairing( - CLOUD_BASE_URL, - `e2e-${Date.now()}`, - 'E2E Test Instance', - 'https://e2e-test.local', - ) + const result = await createPairing(CLOUD_BASE_URL, { + id: `e2e-${Date.now()}`, + name: 'E2E Test Instance', + url: 'https://e2e-test.local', + version: '0.0.1', + }) expect(result).toMatchObject({ code: expect.stringMatching(/^[A-Z0-9]{3}-[A-Z0-9]{3}$/), @@ -129,12 +129,12 @@ describe('E2E: zpan-cloud API contract', () => { }) it('GET /api/pairings/:code returns pending for a fresh code', async () => { - const pairing = await createPairing( - CLOUD_BASE_URL, - `e2e-poll-${Date.now()}`, - 'E2E Poll Test', - 'https://e2e-poll.local', - ) + const pairing = await createPairing(CLOUD_BASE_URL, { + id: `e2e-poll-${Date.now()}`, + name: 'E2E Poll Test', + url: 'https://e2e-poll.local', + version: '0.0.1', + }) const result = await pollPairing(CLOUD_BASE_URL, pairing.code) diff --git a/server/licensing/entitlement.test.ts b/server/licensing/entitlement.test.ts index 4d6c7305..b6868dc4 100644 --- a/server/licensing/entitlement.test.ts +++ b/server/licensing/entitlement.test.ts @@ -126,6 +126,8 @@ describe('loadEntitlement', () => { await seedBinding( db, signAssertion({ + features: ['white_label', 'quota_store'], + licenseId: 'lic-1', licenseValidUntil, expiresAt: certificateExpiresAt, }), @@ -134,10 +136,32 @@ describe('loadEntitlement', () => { const result = await loadEntitlement(db) expect(result).not.toBeNull() expect(result?.edition).toBe('pro') + expect(result?.features).toEqual(['white_label', 'quota_store']) + expect(result?.licenseId).toBe('lic-1') expect(result?.licenseValidUntil).toBe(licenseValidUntil) expect(result?.certificateExpiresAt).toBe(certificateExpiresAt) }) + it('returns business entitlement summary metadata', async () => { + const db = makeDb() + + await seedBinding( + db, + signAssertion({ + edition: 'business', + features: ['white_label', 'quota_store'], + licenseId: 'lic-business', + }), + ) + + const result = await loadEntitlement(db) + expect(result).toMatchObject({ + edition: 'business', + features: ['white_label', 'quota_store'], + licenseId: 'lic-business', + }) + }) + it('returns null for an expired PASETO assertion', async () => { const db = makeDb() diff --git a/server/licensing/entitlement.ts b/server/licensing/entitlement.ts index 04fcd633..c51137e1 100644 --- a/server/licensing/entitlement.ts +++ b/server/licensing/entitlement.ts @@ -1,9 +1,13 @@ +import type { LicenseFeature } from '@shared/types' import type { Database } from '../platform/interface' +import { effectiveFeatures } from './has-feature' import { loadLicenseState } from './license-state' import { verifyCertificate } from './verify' export interface EntitlementSummary { - edition: 'pro' + edition: 'pro' | 'business' + features: LicenseFeature[] + licenseId?: string certificateExpiresAt: number licenseValidUntil: number } @@ -30,6 +34,8 @@ export async function loadEntitlement(db: Database): Promise { }) it('returns true for local Pro gates when binding is active', () => { - const state: BindingState = { bound: true, active: true, edition: 'pro' } + const state: BindingState = { + bound: true, + active: true, + edition: 'pro', + features: ['white_label', 'teams_unlimited', 'storages_unlimited'], + } expect(hasFeature('white_label', state)).toBe(true) expect(hasFeature('teams_unlimited', state)).toBe(true) expect(hasFeature('storages_unlimited', state)).toBe(true) + expect(hasFeature('quota_store', state)).toBe(false) + }) + + it('falls back to non-commercial Pro gates for legacy Pro certificates without features', () => { + const state: BindingState = { bound: true, active: true, edition: 'pro' } + expect(hasFeature('white_label', state)).toBe(true) + expect(hasFeature('quota_store', state)).toBe(false) + }) + + it('allows business gates when a Business binding is active', () => { + const state: BindingState = { bound: true, active: true, edition: 'business', features: ['quota_store'] } + expect(hasFeature('quota_store', state)).toBe(true) + expect(hasFeature('white_label', state)).toBe(false) }) }) diff --git a/server/licensing/has-feature.ts b/server/licensing/has-feature.ts index 00375501..55a2b537 100644 --- a/server/licensing/has-feature.ts +++ b/server/licensing/has-feature.ts @@ -1,4 +1,5 @@ -import type { BindingState, ProFeature } from '../../shared/types' +import { PRO_GATE_KEYS } from '../../shared/feature-registry' +import type { BindingState, LicenseFeature } from '../../shared/types' import type { Database } from '../platform/interface' import { loadLicenseState } from './license-state' import { verifyCertificate } from './verify' @@ -29,6 +30,8 @@ export async function loadBindingState(db: Database, options: BindingStateOption if (assertion) { result.active = true result.edition = assertion.edition + result.features = effectiveFeatures(assertion.edition, assertion.features) + result.license_id = assertion.licenseId result.license_valid_until = assertion.licenseValidUntil result.certificate_expires_at = assertion.expiresAt } @@ -37,6 +40,17 @@ export async function loadBindingState(db: Database, options: BindingStateOption return result } -export function hasFeature(feature: ProFeature, state: BindingState | null): boolean { - return Boolean(feature && state?.bound && state.active) +export function hasFeature(feature: LicenseFeature, state: BindingState | null): boolean { + return Boolean( + feature && state?.bound && state.active && effectiveFeatures(state.edition, state.features).includes(feature), + ) +} + +const BUSINESS_ONLY_FEATURES = new Set(['quota_store']) + +export function effectiveFeatures(edition: BindingState['edition'], features?: LicenseFeature[]): LicenseFeature[] { + if (features) return features + if (edition === 'pro') return PRO_GATE_KEYS.filter((feature) => !BUSINESS_ONLY_FEATURES.has(feature)) + if (edition === 'business') return [...PRO_GATE_KEYS] + return [] } diff --git a/server/licensing/instance-info.ts b/server/licensing/instance-info.ts new file mode 100644 index 00000000..e537cc00 --- /dev/null +++ b/server/licensing/instance-info.ts @@ -0,0 +1,32 @@ +import { eq } from 'drizzle-orm' +import packageJson from '../../package.json' +import { systemOptions } from '../db/schema' +import type { Database } from '../platform/interface' +import type { CloudInstanceInfo } from '../services/licensing-cloud' +import { getOrCreateInstanceId } from './instance-id' + +export async function getInstanceDisplayName(db: Database): Promise { + const rows = await db + .select({ value: systemOptions.value }) + .from(systemOptions) + .where(eq(systemOptions.key, 'site_title')) + .limit(1) + + return rows[0]?.value ?? 'ZPan' +} + +export async function buildCloudInstanceInfo( + db: Database, + params: { + url: string + configuredInstanceId?: string + }, +): Promise { + const instanceId = await getOrCreateInstanceId(db, params.configuredInstanceId) + return { + id: instanceId, + name: await getInstanceDisplayName(db), + url: params.url, + version: packageJson.version, + } +} diff --git a/server/licensing/public-keys.ts b/server/licensing/public-keys.ts index 7f4f1eac..a6de71a2 100644 --- a/server/licensing/public-keys.ts +++ b/server/licensing/public-keys.ts @@ -8,6 +8,8 @@ // // Keys are PASERK k4.public.* strings (Ed25519, 32 raw bytes, base64url-encoded). export const PUBLIC_KEYS: string[] = [ + // local zpan-cloud dev key — matches zpan-cloud/.dev.vars on this machine + 'k4.public.uU2s4lgXfltPCUnbXcb5bOm3hi2AvGfM0k1ufQ3R0qs', // zpan-cloud staging key — provisioned 2026-05-09 'k4.public.CCpUZ1yRWkFQy4fPZAblCYfzeJn4vDwPQrjtfiySwFc', // cloud.zpan.space production key — provisioned 2026-04-24 diff --git a/server/licensing/refresh.ts b/server/licensing/refresh.ts index 839f358f..6f570836 100644 --- a/server/licensing/refresh.ts +++ b/server/licensing/refresh.ts @@ -1,5 +1,6 @@ import type { Database } from '../platform/interface' import { + type CloudInstanceInfo, CloudInvalidResponseError, CloudNetworkError, CloudUnboundError, @@ -25,12 +26,12 @@ function normaliseCert( return { cert: raw, certificateExpiresAt: assertion?.expiresAt ?? null } } -export async function performRefresh(db: Database, baseUrl: string): Promise { +export async function performRefresh(db: Database, baseUrl: string, instance?: CloudInstanceInfo): Promise { const state = await loadLicenseState(db) if (!state.refreshToken || !state.instanceId) return try { - const data = await refreshEntitlement(baseUrl, state.refreshToken) + const data = await refreshEntitlement(baseUrl, state.refreshToken, instance) const { cert, certificateExpiresAt } = normaliseCert(data.certificate, { instanceId: state.instanceId, cloudBaseUrl: baseUrl, diff --git a/server/licensing/verify.test.ts b/server/licensing/verify.test.ts index 9b54da2a..41076ca0 100644 --- a/server/licensing/verify.test.ts +++ b/server/licensing/verify.test.ts @@ -53,6 +53,20 @@ describe('verifyCertificate', () => { expect(result?.authorizedHosts).toEqual(['zpan.example.com']) }) + it('returns assertion for a valid business cert and sanitizes feature keys', () => { + const cert = signCert({ + edition: 'business', + features: ['white_label', 'quota_store', 'unknown_feature'], + licenseId: 'lic-1', + }) + const result = verifyCertificate(cert, { instanceId: 'inst-abc', currentHost: 'zpan.example.com' }) + + expect(result).not.toBeNull() + expect(result?.edition).toBe('business') + expect(result?.features).toEqual(['white_label', 'quota_store']) + expect(result?.licenseId).toBe('lic-1') + }) + it('returns null for a cert with an invalid signature', () => { const cert = signCert() expect(verifyCertificate(`${cert.slice(0, -5)}XXXXX`, { instanceId: 'inst-abc' })).toBeNull() @@ -68,6 +82,11 @@ describe('verifyCertificate', () => { expect(verifyCertificate(cert, { instanceId: 'inst-DIFFERENT' })).toBeNull() }) + it('returns null when edition is not supported', () => { + const cert = signCert({ edition: 'enterprise' }) + expect(verifyCertificate(cert, { instanceId: 'inst-abc' })).toBeNull() + }) + it('returns null when host is not authorized', () => { const cert = signCert() expect(verifyCertificate(cert, { instanceId: 'inst-abc', currentHost: 'other.example.com' })).toBeNull() diff --git a/server/licensing/verify.ts b/server/licensing/verify.ts index 18a25117..34c0e42f 100644 --- a/server/licensing/verify.ts +++ b/server/licensing/verify.ts @@ -1,5 +1,6 @@ import { ZPAN_CLOUD_URL_DEFAULT } from '@shared/constants' -import type { LicenseAssertion } from '@shared/types' +import { PRO_GATE_KEYS } from '@shared/feature-registry' +import type { LicenseAssertion, LicenseFeature } from '@shared/types' import { verify } from 'paseto-ts/v4' import { PUBLIC_KEYS } from './public-keys' @@ -54,7 +55,7 @@ function tryVerify(cert: string, publicKey: string, options: VerifyCertificateOp return null } - if (payload.instanceId !== options.instanceId || payload.edition !== 'pro') { + if (payload.instanceId !== options.instanceId || (payload.edition !== 'pro' && payload.edition !== 'business')) { return null } @@ -66,8 +67,17 @@ function tryVerify(cert: string, publicKey: string, options: VerifyCertificateOp return null } - return { ...payload, authorizedHosts } + return { ...payload, authorizedHosts, features: normalizeFeatures(payload.features) } } catch { return null } } + +function normalizeFeatures(features: unknown): LicenseFeature[] | undefined { + if (!Array.isArray(features)) return undefined + const allowed = new Set(PRO_GATE_KEYS) + const normalized = features.filter( + (feature): feature is LicenseFeature => typeof feature === 'string' && allowed.has(feature as LicenseFeature), + ) + return [...new Set(normalized)] +} diff --git a/server/routes/cloud-store-helpers.ts b/server/routes/cloud-store-helpers.ts index 205b455b..81c2debd 100644 --- a/server/routes/cloud-store-helpers.ts +++ b/server/routes/cloud-store-helpers.ts @@ -7,13 +7,14 @@ import { z } from 'zod' import { billingPortalSessionResponseSchema, commerceProductSchema, - createCloudClient, paymentCreateResponseSchema, productListResponseSchema, } from 'zpan-cloud-sdk' import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' import type { Env } from '../middleware/platform' -import { getCloudStoreBinding, getRequiredSettings } from '../services/cloud-store' +import type { Database } from '../platform/interface' +import { getCloudStoreBinding } from '../services/cloud-store' +import { createBoundCloudClient } from '../services/licensing-cloud' const CLOUD_STORE_REQUEST_TIMEOUT_MS = 10_000 @@ -62,14 +63,13 @@ export const cloudGiftCardCreateResponseSchema = z .transform((response) => (Array.isArray(response) ? response : response.items)) export const giftCardListQuerySchema = z.object({ status: giftCardStatusSchema.optional() }) -export async function getUserStoreSettings(db: Parameters[0]) { +export async function getUserStoreSettings(db: Database) { try { - await getRequiredSettings(db) await getCloudStoreBinding(db) return { ready: true } } catch (error) { const message = (error as Error).message - if (message === 'quota_store_disabled' || message === 'quota_store_binding_missing') return { error: message } + if (message === 'quota_store_binding_missing') return { error: message } throw error } } @@ -77,7 +77,7 @@ export async function getUserStoreSettings(db: Parameters = {}) { } } -function cloudProductRequest(overrides: Record = {}) { - return { - type: 'store_item', - name: 'Small', - description: '', - metadata: { deliverable: { type: 'zpan.plan', storageBytes: 4096, includedCredits: 100 } }, - prices: [{ currency: 'usd', amount: 500, recurring: { interval: 'month', intervalCount: 1 } }], - active: true, - sortOrder: 0, - ...overrides, - } -} - function cloudOrder(overrides: Record = {}) { return { id: 'cloud-order-1', @@ -436,34 +418,8 @@ describe('Quota Store API', () => { }) }) - it('surfaces unexpected store settings load errors', async () => { - const db = { - select: () => ({ - from: () => ({ - where: async () => { - throw new Error('settings read failed') - }, - }), - }), - } - - await expect(getUserStoreSettings(db as unknown as Parameters[0])).rejects.toThrow( - 'settings read failed', - ) - }) - - it('returns 402 when Pro quota_store is absent', async () => { - const { app } = await createTestApp() - const headers = await adminHeaders(app) - - const res = await app.request('/api/admin/store/packages', { headers }) - - expect(res.status).toBe(402) - }) - it('returns 402 for Cloud quota-change webhook when Pro quota_store is absent', async () => { - const { app, db } = await createTestApp() - await seedSettingsRow(db) + const { app } = await createTestApp() const payload = JSON.stringify({ eventId: 'evt-no-pro', eventType: 'order.quota_changed', @@ -480,542 +436,10 @@ describe('Quota Store API', () => { expect(res.status).toBe(402) }) - it('validates package resource bytes and prices', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - - const res = await app.request('/api/admin/store/packages', { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify( - cloudProductRequest({ - name: 'Bad', - metadata: { deliverable: { type: 'zpan.plan', storageBytes: 0, includedCredits: 0 } }, - prices: [], - }), - ), - }) - - expect(res.status).toBe(400) - }) - - it('reads and updates quota store settings', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - - const empty = await app.request('/api/admin/store/settings', { headers }) - await seedSettings(app, headers) - const filled = await app.request('/api/admin/store/settings', { headers }) - - expect(empty.status).toBe(200) - await expect(empty.json()).resolves.toBeNull() - expect(filled.status).toBe(200) - const payload = await filled.json() - expect(payload).toMatchObject({ - enabled: true, - status: 'ready', - }) - expect(payload).not.toHaveProperty('cloudBaseUrl') - expect(payload).not.toHaveProperty('publicInstanceUrl') - expect(payload).not.toHaveProperty('webhookSigningSecret') - }) - - it('updates quota store operator settings only', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - - const updated = await app.request('/api/admin/store/settings', { - method: 'PUT', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled: false }), - }) - - expect(updated.status).toBe(200) - await expect(updated.json()).resolves.toMatchObject({ - enabled: false, - status: 'ready', - }) - }) - - it('reports Cloud not connected when the settings binding refresh token is missing', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - await db.run(sql`UPDATE license_bindings SET refresh_token = NULL`) - - const settings = await getCloudStoreSettings(db) - - expect(settings).toMatchObject({ - enabled: true, - status: 'cloud_unbound', - }) - }) - - it('proxies package CRUD to Cloud without local sync', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - - const created = await app.request('/api/admin/store/packages', { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify( - cloudProductRequest({ - name: 'Small', - description: 'starter', - }), - ), - }) - const listed = await app.request('/api/admin/store/packages', { headers }) - - expect(created.status).toBe(201) - await expect(created.json()).resolves.toMatchObject({ id: 'cloud-pkg-1', name: 'Small' }) - const [url, init] = vi.mocked(fetch).mock.calls[0] as [URL, RequestInit] - const body = String(init.body) - expect(String(url)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/products`) - expect(requestHeader(init, 'Authorization')).toBe(`Bearer ${REFRESH_TOKEN}`) - expect(JSON.parse(body)).toMatchObject({ - name: 'Small', - description: 'starter', - metadata: { deliverable: { type: 'zpan.plan', storageBytes: 4096, includedCredits: 100 } }, - type: 'store_item', - prices: [{ currency: 'usd', amount: 500, recurring: { interval: 'month', intervalCount: 1 } }], - }) - expect(JSON.parse(body)).not.toHaveProperty('callbackUrl') - expect(listed.status).toBe(200) - await expect(listed.json()).resolves.toMatchObject({ - total: 2, - items: [ - { - id: 'cloud-pkg-1', - metadata: { deliverable: { type: 'zpan.plan', storageBytes: 4096, includedCredits: 100 } }, - active: true, - }, - { - id: 'cloud-pkg-inactive', - metadata: { deliverable: { type: 'zpan.plan', storageBytes: 8192, includedCredits: 0 } }, - active: false, - }, - ], - }) - }) - - it('accepts Cloud package object lists and PUT updates', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - items: [ - cloudProduct({ - id: 'cloud-pkg-object', - name: 'Object Shape', - description: null, - metadata: { deliverable: { type: 'zpan.plan', storageBytes: 4096, includedCredits: 0 } }, - prices: [{ currency: 'usd', amount: 500, recurring: { interval: 'month', intervalCount: 1 } }], - sortOrder: 3, - }), - ], - total: 1, - limit: 100, - offset: 0, - }), - } as Response) - - const listed = await app.request('/api/admin/store/packages', { headers }) - const updated = await app.request('/api/admin/store/packages/cloud-pkg-object', { - method: 'PUT', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify( - cloudProductRequest({ - name: 'Updated', - metadata: { deliverable: { type: 'zpan.plan', storageBytes: 8192, includedCredits: 200 } }, - prices: [{ currency: 'usd', amount: 900, recurring: { interval: 'year', intervalCount: 1 } }], - }), - ), - }) - - expect(listed.status).toBe(200) - await expect(listed.json()).resolves.toMatchObject({ - total: 1, - items: [ - { - id: 'cloud-pkg-object', - description: null, - metadata: { deliverable: { type: 'zpan.plan', storageBytes: 4096, includedCredits: 0 } }, - sortOrder: 3, - }, - ], - }) - expect(updated.status).toBe(200) - const updateCall = vi.mocked(fetch).mock.calls.find((call) => call[1]?.method === 'PATCH') - expect(updateCall).toBeTruthy() - const [, updateInit] = updateCall as [URL, RequestInit] - expect(updateInit.method).toBe('PATCH') - expect(JSON.parse(updateInit.body as string)).toEqual({ - name: 'Updated', - description: '', - type: 'store_item', - metadata: { - deliverable: { type: 'zpan.plan', storageBytes: 8192, includedCredits: 200 }, - }, - prices: [{ currency: 'usd', amount: 900, recurring: { interval: 'year', intervalCount: 1 } }], - active: true, - sortOrder: 0, - }) - }) - - it('gets packages by Cloud id', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - - const res = await app.request('/api/admin/store/packages/cloud-pkg-1', { headers }) - - expect(res.status).toBe(200) - await expect(res.json()).resolves.toMatchObject({ id: 'cloud-pkg-1', active: true }) - const [url, init] = vi.mocked(fetch).mock.calls[0] as [URL, RequestInit] - expect(String(url)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/products/cloud-pkg-1`) - expect(init.method).toBe('GET') - }) - - it('does not send callback URLs during package create', async () => { - const { app, db } = await createTestApp({ ZPAN_PUBLIC_ORIGIN: 'https://zpan.example/custom-path' }) - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - - const created = await app.request('/api/admin/store/packages', { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify( - cloudProductRequest({ - name: 'Configured', - }), - ), - }) - - expect(created.status).toBe(201) - const [, init] = vi.mocked(fetch).mock.calls[0] as [URL, RequestInit] - expect(JSON.parse(init.body as string)).not.toHaveProperty('callbackUrl') - }) - - it('proxies recurring plans and credit packages to Cloud deliverables', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - - const recurringPlan = await app.request('/api/admin/store/packages', { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify( - cloudProductRequest({ - name: 'Team Monthly', - metadata: { - deliverable: { type: 'zpan.plan', storageBytes: 4096, includedCredits: 1200 }, - }, - prices: [ - { - currency: 'usd', - amount: 1900, - recurring: { interval: 'month', intervalCount: 1 }, - metadata: { creditGrantType: 'subscription_grant', creditAmount: '1200' }, - }, - { - currency: 'usd', - amount: 19000, - recurring: { interval: 'year', intervalCount: 1 }, - metadata: { creditGrantType: 'subscription_grant', creditAmount: '1200' }, - }, - ], - }), - ), - }) - const creditPackage = await app.request('/api/admin/store/packages', { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify( - cloudProductRequest({ - name: 'Credits Pack', - metadata: { deliverable: { type: 'zpan.credits', includedCredits: 5000 } }, - prices: [{ currency: 'usd', amount: 900, metadata: { creditGrantType: 'top_up', creditAmount: '5000' } }], - }), - ), - }) - - expect(recurringPlan.status).toBe(201) - expect(creditPackage.status).toBe(201) - const recurringBody = JSON.parse((vi.mocked(fetch).mock.calls[0][1] as RequestInit).body as string) - const creditBody = JSON.parse((vi.mocked(fetch).mock.calls[1][1] as RequestInit).body as string) - expect(recurringBody).toMatchObject({ - type: 'store_item', - metadata: { - deliverable: { type: 'zpan.plan', storageBytes: 4096, includedCredits: 1200 }, - }, - prices: [ - { - currency: 'usd', - amount: 1900, - recurring: { interval: 'month', intervalCount: 1 }, - metadata: { creditGrantType: 'subscription_grant', creditAmount: '1200' }, - }, - { - currency: 'usd', - amount: 19000, - recurring: { interval: 'year', intervalCount: 1 }, - metadata: { creditGrantType: 'subscription_grant', creditAmount: '1200' }, - }, - ], - }) - expect(creditBody).toMatchObject({ - type: 'store_item', - metadata: { deliverable: { type: 'zpan.credits', includedCredits: 5000 } }, - prices: [{ currency: 'usd', amount: 900, metadata: { creditGrantType: 'top_up', creditAmount: '5000' } }], - }) - }) - - it('rejects mixed package billing modes before proxying to Cloud', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - - const created = await app.request('/api/admin/store/packages', { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify( - cloudProductRequest({ - name: 'Mixed Billing', - metadata: { deliverable: { type: 'zpan.plan', storageBytes: 4096, includedCredits: 0 } }, - prices: [ - { currency: 'usd', amount: 1900, recurring: { interval: 'month', intervalCount: 1 } }, - { currency: 'usd', amount: 9000 }, - ], - }), - ), - }) - - expect(created.status).toBe(400) - expect(vi.mocked(fetch)).not.toHaveBeenCalled() - }) - - it('rejects non-USD package prices before proxying to Cloud', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - - const created = await app.request('/api/admin/store/packages', { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify( - cloudProductRequest({ - name: 'CNY Package', - prices: [{ currency: 'cny', amount: 9000 }], - }), - ), - }) - const updated = await app.request('/api/admin/store/packages/cloud-pkg-1', { - method: 'PATCH', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ prices: [{ currency: 'cny', amount: 9000 }] }), - }) - - expect(created.status).toBe(400) - expect(updated.status).toBe(400) - expect(vi.mocked(fetch)).not.toHaveBeenCalled() - }) - - it('rejects malformed metered traffic prices before proxying to Cloud', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - - const missingUsageResource = await app.request('/api/admin/store/packages', { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify( - cloudProductRequest({ - name: 'Malformed Metered', - metadata: { deliverable: { type: 'zpan.plan', storageBytes: 4096, includedCredits: 0 } }, - prices: [ - { currency: 'usd', amount: 1900, recurring: { interval: 'month', intervalCount: 1 } }, - { currency: 'usd', amount: 2, recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' } }, - ], - }), - ), - }) - const missingUsageType = await app.request('/api/admin/store/packages', { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify( - cloudProductRequest({ - name: 'Malformed Resource', - metadata: { deliverable: { type: 'zpan.plan', storageBytes: 4096, includedCredits: 0 } }, - prices: [ - { currency: 'usd', amount: 1900, recurring: { interval: 'month', intervalCount: 1 } }, - { - currency: 'usd', - amount: 2, - recurring: { interval: 'month', intervalCount: 1 }, - metadata: { usageResource: 'traffic_egress' }, - }, - ], - }), - ), - }) - - expect(missingUsageResource.status).toBe(400) - expect(missingUsageType.status).toBe(400) - expect(vi.mocked(fetch)).not.toHaveBeenCalled() - }) - - it('rejects duplicate fixed or metered subscription prices before proxying to Cloud', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - - const duplicateFixed = await app.request('/api/admin/store/packages', { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify( - cloudProductRequest({ - name: 'Duplicate Fixed', - metadata: { deliverable: { type: 'zpan.plan', storageBytes: 4096, includedCredits: 0 } }, - prices: [ - { currency: 'usd', amount: 1900, recurring: { interval: 'month', intervalCount: 1 } }, - { currency: 'usd', amount: 2900, recurring: { interval: 'month', intervalCount: 1 } }, - { - currency: 'usd', - amount: 2, - recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' }, - metadata: { usageResource: 'traffic_egress' }, - }, - ], - }), - ), - }) - const duplicateMetered = await app.request('/api/admin/store/packages', { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify( - cloudProductRequest({ - name: 'Duplicate Metered', - metadata: { deliverable: { type: 'zpan.plan', storageBytes: 4096, includedCredits: 0 } }, - prices: [ - { currency: 'usd', amount: 1900, recurring: { interval: 'month', intervalCount: 1 } }, - { - currency: 'usd', - amount: 2, - recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' }, - metadata: { usageResource: 'traffic_egress' }, - }, - { - currency: 'usd', - amount: 3, - recurring: { interval: 'month', intervalCount: 1, usageType: 'metered' }, - metadata: { usageResource: 'traffic_egress' }, - }, - ], - }), - ), - }) - - expect(duplicateFixed.status).toBe(400) - expect(duplicateMetered.status).toBe(400) - expect(vi.mocked(fetch)).not.toHaveBeenCalled() - }) - - it('accepts yearly subscription prices and rejects multi-month intervals before proxying to Cloud', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - - const yearlyInterval = await app.request('/api/admin/store/packages', { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify( - cloudProductRequest({ - name: 'Yearly Plan', - metadata: { deliverable: { type: 'zpan.plan', storageBytes: 4096, includedCredits: 0 } }, - prices: [{ currency: 'usd', amount: 1900, recurring: { interval: 'year', intervalCount: 1 } }], - }), - ), - }) - const multiMonthInterval = await app.request('/api/admin/store/packages', { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify( - cloudProductRequest({ - name: 'Quarterly Plan', - metadata: { deliverable: { type: 'zpan.plan', storageBytes: 4096, includedCredits: 0 } }, - prices: [{ currency: 'usd', amount: 1900, recurring: { interval: 'month', intervalCount: 3 } }], - }), - ), - }) - - expect(yearlyInterval.status).toBe(201) - expect(multiMonthInterval.status).toBe(400) - expect(vi.mocked(fetch)).toHaveBeenCalledOnce() - }) - - it('updates package plan deliverables directly through Cloud', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - - const updated = await app.request('/api/admin/store/packages/pkg-1', { - method: 'PATCH', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - metadata: { deliverable: { type: 'zpan.plan', storageBytes: 4096, includedCredits: 100 } }, - }), - }) - - expect(updated.status).toBe(200) - const [, init] = vi.mocked(fetch).mock.calls[0] as [URL, RequestInit] - expect(JSON.parse(String(init.body))).toEqual({ - metadata: { deliverable: { type: 'zpan.plan', storageBytes: 4096, includedCredits: 100 } }, - }) - }) - - it('updates package names without touching deliverables', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - - const updated = await app.request('/api/admin/store/packages/pkg-1', { - method: 'PATCH', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: 'Renamed' }), - }) - - expect(updated.status).toBe(200) - const [, init] = vi.mocked(fetch).mock.calls[0] as [URL, RequestInit] - expect(JSON.parse(String(init.body))).toEqual({ name: 'Renamed' }) - }) - it('ignores spoofed forwarded origin for Cloud checkout return URLs', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) const packageId = await seedPackage(db) const checkout = await app.request('/api/store/checkouts', { @@ -1039,37 +463,10 @@ describe('Quota Store API', () => { }) }) - it('does not use forwarded origins during package create', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - - const created = await app.request('/api/admin/store/packages', { - method: 'POST', - headers: { - ...headers, - 'Content-Type': 'application/json', - 'x-forwarded-proto': 'http', - 'x-forwarded-host': 'localhost', - }, - body: JSON.stringify( - cloudProductRequest({ - name: 'Bad Proto', - }), - ), - }) - - expect(created.status).toBe(201) - const [, init] = vi.mocked(fetch).mock.calls[0] as [URL, RequestInit] - expect(JSON.parse(init.body as string)).not.toHaveProperty('callbackUrl') - }) - it('ignores non-https forwarded schemes for Cloud checkout return URLs', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) const packageId = await seedPackage(db) const checkout = await app.request('/api/store/checkouts', { @@ -1095,9 +492,8 @@ describe('Quota Store API', () => { it('uses https origin for non-local http request URLs', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) const packageId = await seedPackage(db) const checkout = await app.request('http://files.example.com/api/store/checkouts', { @@ -1118,9 +514,8 @@ describe('Quota Store API', () => { it('uses configured auth URL origin for checkout return URLs', async () => { const { app, db } = await createTestApp({ BETTER_AUTH_URL: 'https://auth.example.com/path' }) - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) const packageId = await seedPackage(db) const checkout = await app.request('/api/store/checkouts', { @@ -1141,9 +536,8 @@ describe('Quota Store API', () => { it('falls back to request origin when public origin env is invalid', async () => { const { app, db } = await createTestApp({ ZPAN_PUBLIC_ORIGIN: 'not a url' }) - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) const packageId = await seedPackage(db) const checkout = await app.request('/api/store/checkouts', { @@ -1164,9 +558,8 @@ describe('Quota Store API', () => { it('falls back to request origin when public origin env uses an unsupported scheme', async () => { const { app, db } = await createTestApp({ ZPAN_PUBLIC_ORIGIN: 'ftp://files.example.com' }) - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) const packageId = await seedPackage(db) const checkout = await app.request('/api/store/checkouts', { @@ -1185,389 +578,10 @@ describe('Quota Store API', () => { }) }) - it('surfaces package create Cloud failures', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 502, json: async () => ({}) } as Response) - - const res = await app.request('/api/admin/store/packages', { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify( - cloudProductRequest({ - name: 'Small', - }), - ), - }) - - expect(res.status).toBe(502) - await expect(res.json()).resolves.toEqual({ error: 'cloud_request_failed_502' }) - }) - - it('rejects malformed successful package responses', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}) } as Response) - - const res = await app.request('/api/admin/store/packages', { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify( - cloudProductRequest({ - name: 'Small', - }), - ), - }) - - expect(res.status).toBe(502) - await expect(res.json()).resolves.toEqual({ error: 'invalid_cloud_response' }) - }) - - it('rejects prices with empty currency strings', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - - const res = await app.request('/api/admin/store/packages', { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify( - cloudProductRequest({ - name: 'Bad Currency', - prices: [{ currency: '', amount: 500 }], - }), - ), - }) - - expect(res.status).toBe(400) - }) - - it('rejects non-json successful package responses', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => { - throw new Error('not_json') - }, - } as unknown as Response) - - const res = await app.request('/api/admin/store/packages', { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify( - cloudProductRequest({ - name: 'Small', - }), - ), - }) - - expect(res.status).toBe(502) - await expect(res.json()).resolves.toEqual({ error: 'invalid_cloud_response' }) - }) - - it('deletes packages through Cloud', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - const packageId = await seedPackage(db) - - const res = await app.request(`/api/admin/store/packages/${packageId}`, { - method: 'DELETE', - headers, - }) - - expect(res.status).toBe(200) - const calls = vi.mocked(fetch).mock.calls - const [url, init] = calls[calls.length - 1] as [URL, RequestInit] - expect(String(url)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/products/${packageId}`) - expect(init.method).toBe('DELETE') - }) - - it('proxies admin gift card management through the bound Cloud API', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - - const generated = await app.request('/api/admin/store/gift-cards', { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - credits: 4096, - expiresAt: '2099-06-01T00:00:00.000Z', - campaignId: 'campaign-1', - count: 2, - }), - }) - const listed = await app.request('/api/admin/store/gift-cards?status=active', { headers }) - const deleted = await app.request('/api/admin/store/gift-cards/ZS-GEN-1', { method: 'DELETE', headers }) - - expect(generated.status).toBe(201) - await expect(generated.json()).resolves.toMatchObject([ - { code: 'ZS-GEN-1', credits: 4096, status: 'active' }, - { code: 'ZS-GEN-2', credits: 4096, status: 'active' }, - ]) - expect(listed.status).toBe(200) - await expect(listed.json()).resolves.toMatchObject({ - total: 1, - items: [{ code: 'ZS-LIST-1', credits: 1024 }], - }) - expect(deleted.status).toBe(200) - await expect(deleted.json()).resolves.toEqual({ code: 'ZS-GEN-1', deleted: true }) - - const calls = vi.mocked(fetch).mock.calls as Array<[URL, RequestInit]> - const [generateUrl, generateInit] = calls[0] - const [listUrl, listInit] = calls[1] - const [deleteUrl, deleteInit] = calls[2] - expect(String(generateUrl)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/gift-cards`) - expect(requestHeader(generateInit, 'Authorization')).toBe(`Bearer ${REFRESH_TOKEN}`) - expect(JSON.parse(generateInit.body as string)).toEqual({ - credits: 4096, - expiresAt: '2099-06-01T00:00:00.000Z', - campaignId: 'campaign-1', - count: 2, - }) - expect(String(listUrl)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/gift-cards?status=active`) - expect(requestHeader(listInit, 'Authorization')).toBe(`Bearer ${REFRESH_TOKEN}`) - expect(String(deleteUrl)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/gift-cards/ZS-GEN-1`) - expect(deleteInit.method).toBe('DELETE') - }) - - it('accepts paged admin gift card lists from Cloud', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ items: [cloudGiftCard({ code: 'ZS-PAGED-1' })], total: 9, limit: 50, offset: 0 }), - } as Response) - - const res = await app.request('/api/admin/store/gift-cards?status=active', { headers }) - - expect(res.status).toBe(200) - await expect(res.json()).resolves.toMatchObject({ total: 9, items: [{ code: 'ZS-PAGED-1' }] }) - }) - - it('accepts paged admin gift card create responses from Cloud', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - status: 201, - json: async () => ({ - items: [cloudGiftCard({ code: 'ZS-PAGED-CREATE-1', codeLast4: 'TE-1' })], - total: 1, - limit: 50, - offset: 0, - }), - } as Response) - - const res = await app.request('/api/admin/store/gift-cards', { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - credits: 4096, - count: 1, - }), - }) - - expect(res.status).toBe(201) - await expect(res.json()).resolves.toMatchObject([{ code: 'ZS-PAGED-CREATE-1' }]) - const [[url, init]] = vi.mocked(fetch).mock.calls as Array<[URL, RequestInit]> - expect(String(url)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/gift-cards`) - expect(JSON.parse(init.body as string)).toEqual({ - credits: 4096, - count: 1, - }) - }) - - it('disables admin gift cards through Cloud', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - status: 204, - json: async () => null, - } as Response) - - const res = await app.request('/api/admin/store/gift-cards/ZS-GEN-1', { - method: 'PATCH', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ disabled: true }), - }) - - expect(res.status).toBe(200) - await expect(res.json()).resolves.toEqual({ code: 'ZS-GEN-1', disabled: true }) - const [url, init] = vi.mocked(fetch).mock.calls[0] as [URL, RequestInit] - expect(String(url)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/gift-cards/ZS-GEN-1`) - expect(init.method).toBe('PATCH') - expect(JSON.parse(init.body as string)).toEqual({ disabled: true }) - }) - - it('rejects non-admin gift card management', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const admin = await adminHeaders(app) - await seedSettings(app, admin) - const headers = await authedHeaders(app, 'buyer@example.com') - - const res = await app.request('/api/admin/store/gift-cards', { headers }) - - expect(res.status).toBe(403) - expect(fetch).not.toHaveBeenCalled() - }) - - it('proxies admin store orders from Cloud', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - - const res = await app.request('/api/admin/store/orders', { headers }) - - expect(res.status).toBe(200) - await expect(res.json()).resolves.toMatchObject({ - total: 1, - items: [ - { - id: 'cloud-order-1', - items: [{ fulfillmentPayload: { deliverable: { type: 'zpan.plan', storageBytes: 512, trafficBytes: 0 } } }], - }, - ], - }) - const [url] = vi.mocked(fetch).mock.calls[0] as [URL, RequestInit] - expect(String(url)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/orders?limit=100`) - }) - - it('paginates admin store orders from Cloud', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - vi.mocked(fetch).mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ items: [cloudOrder({ id: 'cloud-order-2' })], total: 2, limit: 1, offset: 1 }), - } as Response) - - const res = await app.request('/api/admin/store/orders?limit=1&offset=1', { headers }) - - expect(res.status).toBe(200) - await expect(res.json()).resolves.toMatchObject({ - total: 2, - items: [{ id: 'cloud-order-2' }], - }) - const calls = vi.mocked(fetch).mock.calls as Array<[URL, RequestInit]> - expect(String(calls[0][0])).toBe(`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/orders?limit=1&offset=1`) - }) - - it('updates packages through Cloud', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - - const res = await app.request('/api/admin/store/packages/cloud-pkg-1', { - method: 'PATCH', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify( - cloudProductRequest({ - name: 'Small', - }), - ), - }) - - expect(res.status).toBe(200) - await expect(res.json()).resolves.toMatchObject({ id: 'cloud-pkg-1', name: 'Small' }) - const [url, init] = vi.mocked(fetch).mock.calls[0] as [URL, RequestInit] - expect(String(url)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/products/cloud-pkg-1`) - expect(init.method).toBe('PATCH') - }) - - it('publishes and unpublishes packages through partial Cloud patches', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - - const res = await app.request('/api/admin/store/packages/cloud-pkg-1', { - method: 'PATCH', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ active: false }), - }) - - expect(res.status).toBe(200) - const [url, init] = vi.mocked(fetch).mock.calls[0] as [URL, RequestInit] - expect(String(url)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/products/cloud-pkg-1`) - expect(init.method).toBe('PATCH') - expect(JSON.parse(String(init.body))).toEqual({ active: false }) - }) - - it('surfaces package update Cloud failures without local writes', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - vi.mocked(fetch).mockResolvedValueOnce({ - ok: false, - status: 502, - json: async () => ({ error: 'cloud_down' }), - } as Response) - - const res = await app.request('/api/admin/store/packages/cloud-pkg-1', { - method: 'PATCH', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify( - cloudProductRequest({ - name: 'Small', - }), - ), - }) - - expect(res.status).toBe(502) - await expect(res.json()).resolves.toEqual({ error: 'cloud_down' }) - }) - - it('surfaces package delete Cloud failures', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) - vi.mocked(fetch).mockResolvedValueOnce({ - ok: false, - status: 503, - json: async () => ({ error: 'cloud_unavailable' }), - } as Response) - - const res = await app.request('/api/admin/store/packages/cloud-pkg-1', { - method: 'DELETE', - headers, - }) - - expect(res.status).toBe(502) - await expect(res.json()).resolves.toEqual({ error: 'cloud_unavailable' }) - }) - it('rejects checkout target orgs the user cannot access', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) const packageId = await seedPackage(db) const res = await app.request('/api/store/checkouts', { @@ -1581,9 +595,8 @@ describe('Quota Store API', () => { it('omits credit discount fields when checking out recurring packages', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) const packageId = await seedPackage(db) vi.mocked(fetch).mockResolvedValueOnce({ ok: true, @@ -1613,9 +626,8 @@ describe('Quota Store API', () => { it('rejects recurring checkout when the workspace already has an active plan', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) const orgId = await getFirstOrgId(db) const packageId = await seedPackage(db) const now = Date.now() @@ -1663,9 +675,8 @@ describe('Quota Store API', () => { it('creates fixed-duration package checkouts without credit discount fields', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) const packageId = await seedPackage(db) const checkout = await app.request('/api/store/checkouts', { @@ -1679,9 +690,8 @@ describe('Quota Store API', () => { it('creates a subscription portal for the active workspace plan', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) const orgId = await getFirstOrgId(db) vi.mocked(fetch).mockResolvedValueOnce({ ok: true, @@ -1707,9 +717,8 @@ describe('Quota Store API', () => { it('lists purchasable packages, targets, checkout, and orders', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) const orgId = await getFirstOrgId(db) const packageId = await seedPackage(db) @@ -1784,9 +793,8 @@ describe('Quota Store API', () => { it('rejects checkout currency fields before proxying to Cloud', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) const packageId = await seedPackage(db) const cnyCheckout = await app.request('/api/store/checkouts', { @@ -1808,9 +816,8 @@ describe('Quota Store API', () => { it('rejects malformed Cloud checkout products with non-USD prices', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) const packageId = await seedPackage(db) vi.mocked(fetch).mockResolvedValueOnce({ ok: true, @@ -1839,9 +846,8 @@ describe('Quota Store API', () => { it('proxies credit balance and gift card redemption through credit endpoints', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) const orgId = await getFirstOrgId(db) const credits = await app.request('/api/store/credits', { headers }) @@ -1876,9 +882,8 @@ describe('Quota Store API', () => { it('proxies credit ledger entries through credit endpoints', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) const orgId = await getFirstOrgId(db) const ledger = await app.request('/api/store/credits/ledger-entries', { headers }) @@ -1919,9 +924,8 @@ describe('Quota Store API', () => { it('continues payment and cancels orders through Cloud', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) const orgId = await getFirstOrgId(db) vi.mocked(fetch).mockResolvedValueOnce({ ok: true, @@ -1977,9 +981,8 @@ describe('Quota Store API', () => { it('rejects payment continuation and cancellation for another org order', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) const payment = await app.request('/api/store/orders/order-other-org/payments', { method: 'POST', @@ -2002,44 +1005,10 @@ describe('Quota Store API', () => { ).toBe(false) }) - it('hides self-service packages when the store is disabled', async () => { - const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) - await app.request('/api/admin/store/settings', { - method: 'PUT', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - enabled: false, - }), - }) - - const packageId = await seedPackage(db) - const packages = await app.request('/api/store/packages', { headers }) - const targets = await app.request('/api/store/targets', { headers }) - const checkout = await app.request('/api/store/checkouts', { - method: 'POST', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ packageId }), - }) - const orders = await app.request('/api/store/orders', { headers }) - - expect(packages.status).toBe(403) - await expect(packages.json()).resolves.toEqual({ error: 'quota_store_disabled' }) - expect(targets.status).toBe(403) - await expect(targets.json()).resolves.toEqual({ error: 'quota_store_disabled' }) - expect(checkout.status).toBe(403) - await expect(checkout.json()).resolves.toEqual({ error: 'quota_store_disabled' }) - expect(orders.status).toBe(403) - await expect(orders.json()).resolves.toEqual({ error: 'quota_store_disabled' }) - }) - it('hides self-service store endpoints until Cloud is bound', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) await db.run(sql`UPDATE license_bindings SET refresh_token = NULL`) const packageId = await seedPackage(db) @@ -2064,9 +1033,8 @@ describe('Quota Store API', () => { it('rejects malformed successful checkout responses', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) const packageId = await seedPackage(db) vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}) } as Response) @@ -2082,9 +1050,8 @@ describe('Quota Store API', () => { it('surfaces Cloud checkout error responses', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) const packageId = await seedPackage(db) vi.mocked(fetch).mockResolvedValueOnce({ ok: false, @@ -2104,9 +1071,8 @@ describe('Quota Store API', () => { it('uses status errors when Cloud checkout error bodies have no string error', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await authedHeaders(app, 'buyer@example.com') - await seedSettings(app, headers) const packageId = await seedPackage(db) vi.mocked(fetch).mockResolvedValueOnce({ ok: false, @@ -2126,9 +1092,7 @@ describe('Quota Store API', () => { it('accepts current Cloud quota-change webhook tokens with audience equal to instance id', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const orgId = await getFirstOrgId(db) const payload = JSON.stringify({ eventId: 'evt-cloud-pr-15-token', @@ -2152,8 +1116,6 @@ describe('Quota Store API', () => { it("accepts Cloud PR #16 quota-change webhook tokens with audience='license_1' and boundLicenseId='binding_1'", async () => { const { app, db } = await createTestApp() await seedCloudPr16License(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) const orgId = await getFirstOrgId(db) const payload = JSON.stringify({ eventId: 'evt-cloud-pr-16-token', @@ -2183,9 +1145,8 @@ describe('Quota Store API', () => { it('valid Cloud quota-change webhook records active entitlement once and records audit', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await adminHeaders(app) - await seedSettings(app, headers) const orgId = await getFirstOrgId(db) const payload = JSON.stringify({ eventId: 'evt-1', @@ -2239,9 +1200,8 @@ describe('Quota Store API', () => { it('delivers initial subscription storage and traffic entitlements under a stable source id', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await adminHeaders(app) - await seedSettings(app, headers) const orgId = await getFirstOrgId(db) await db.run(sql`UPDATE org_quotas SET quota = 8192, traffic_quota = 1024 WHERE org_id = ${orgId}`) const subscriptionSourceId = `stripe_subscription:sub_metered_123:${orgId}` @@ -2332,9 +1292,8 @@ describe('Quota Store API', () => { it('renews subscription entitlements by replacing plan bytes and extending expiry', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await adminHeaders(app) - await seedSettings(app, headers) const orgId = await getFirstOrgId(db) const subscriptionSourceId = `stripe_subscription:sub_renewal:${orgId}` @@ -2409,9 +1368,8 @@ describe('Quota Store API', () => { it('accumulates repeated Cloud increases for the same order and resource', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await adminHeaders(app) - await seedSettings(app, headers) const orgId = await getFirstOrgId(db) const first = await postWebhook( @@ -2455,9 +1413,8 @@ describe('Quota Store API', () => { it('decreases accumulated Cloud order entitlement bytes without revoking the remainder', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await adminHeaders(app) - await seedSettings(app, headers) const orgId = await getFirstOrgId(db) await postWebhook( @@ -2513,9 +1470,8 @@ describe('Quota Store API', () => { it('restarts entitlement bytes when a new increase follows full revocation', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await adminHeaders(app) - await seedSettings(app, headers) const orgId = await getFirstOrgId(db) await postWebhook( @@ -2571,9 +1527,7 @@ describe('Quota Store API', () => { it('rejects legacy order delivery event types', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const payload = JSON.stringify({ eventId: 'evt-legacy-event-type', eventType: 'order.delivered', @@ -2592,9 +1546,7 @@ describe('Quota Store API', () => { it('storage decreases revoke matching Cloud order entitlements without changing base quota', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const orgId = await getFirstOrgId(db) await db.run(sql`UPDATE org_quotas SET quota = 2048 WHERE org_id = ${orgId}`) const now = Date.now() @@ -2629,9 +1581,8 @@ describe('Quota Store API', () => { it('records traffic increases and revokes them on matching decreases', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) + await seedBusinessLicense(db) const headers = await adminHeaders(app) - await seedSettings(app, headers) const orgId = await getFirstOrgId(db) await postWebhook( @@ -2672,9 +1623,7 @@ describe('Quota Store API', () => { it('processes same-order increase then decrease as two independent events', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const orgId = await getFirstOrgId(db) await db.run(sql`UPDATE org_quotas SET quota = 8192 WHERE org_id = ${orgId}`) @@ -2734,9 +1683,7 @@ describe('Quota Store API', () => { it('does not fall back to base quota when a second decrease sees an already revoked entitlement', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const orgId = await getFirstOrgId(db) await db.run(sql`UPDATE org_quotas SET quota = 8192 WHERE org_id = ${orgId}`) @@ -2788,9 +1735,7 @@ describe('Quota Store API', () => { it('replaying the same decrease event is idempotent and does not double-deduct', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const orgId = await getFirstOrgId(db) await db.run(sql`UPDATE org_quotas SET quota = 8192 WHERE org_id = ${orgId}`) @@ -2823,9 +1768,7 @@ describe('Quota Store API', () => { it('reverses pre-migration Cloud order quota stored in base quota', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const orgId = await getFirstOrgId(db) await db.run(sql`UPDATE org_quotas SET quota = 8192, traffic_quota = 4096 WHERE org_id = ${orgId}`) @@ -2853,9 +1796,7 @@ describe('Quota Store API', () => { it('records decrease audit event with correct action and metadata', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const orgId = await getFirstOrgId(db) await db.run(sql`UPDATE org_quotas SET quota = 8192 WHERE org_id = ${orgId}`) @@ -2888,9 +1829,7 @@ describe('Quota Store API', () => { it('traffic decrease from same cloudOrderId processes independently', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const orgId = await getFirstOrgId(db) await postWebhook( @@ -2931,9 +1870,7 @@ describe('Quota Store API', () => { it('rejects failed delivery retries when the payload hash changes', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const orgId = await getFirstOrgId(db) const payload = JSON.stringify({ eventId: 'evt-hash-conflict', @@ -2968,9 +1905,7 @@ describe('Quota Store API', () => { it('allows failed delivery retries when the payload is unchanged', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const orgId = await getFirstOrgId(db) await db.run(sql`DELETE FROM org_quotas WHERE org_id = ${orgId}`) const payload = JSON.stringify({ @@ -3003,9 +1938,7 @@ describe('Quota Store API', () => { it('rejects missing Cloud quota-change webhook auth', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const res = await app.request('/api/store/webhook', { method: 'POST', @@ -3020,16 +1953,14 @@ describe('Quota Store API', () => { it('rejects malformed Cloud quota-change webhook event tokens', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const payload = JSON.stringify({ eventId: 'evt-bad-token' }) const res = await app.request('/api/store/webhook', { method: 'POST', headers: { 'Content-Type': 'application/json', - 'x-zpan-cloud-event-token': 'bad-token', + 'x-commerce-event-token': 'bad-token', }, body: payload, }) @@ -3039,9 +1970,7 @@ describe('Quota Store API', () => { it('rejects Cloud quota-change webhook event tokens with the wrong purpose', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const payload = JSON.stringify({ eventId: 'evt-wrong-purpose' }) const res = await app.request('/api/store/webhook', { @@ -3058,9 +1987,7 @@ describe('Quota Store API', () => { it('rejects expired Cloud quota-change webhook event tokens', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const payload = JSON.stringify({ eventId: 'evt-expired-token' }) const now = Math.floor(Date.now() / 1000) @@ -3078,9 +2005,7 @@ describe('Quota Store API', () => { it('rejects Cloud quota-change webhook event tokens without issuedAt', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const payload = JSON.stringify({ eventId: 'evt-missing-issued-at' }) const res = await app.request('/api/store/webhook', { @@ -3097,9 +2022,7 @@ describe('Quota Store API', () => { it('rejects Cloud quota-change webhook event tokens with future issuedAt and no notBefore', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const payload = JSON.stringify({ eventId: 'evt-future-issued-at' }) const issuedAt = Math.floor(Date.now() / 1000) + 60 @@ -3117,9 +2040,7 @@ describe('Quota Store API', () => { it('rejects Cloud quota-change webhook event tokens with an overlong TTL', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const payload = JSON.stringify({ eventId: 'evt-overlong-token' }) const issuedAt = Math.floor(Date.now() / 1000) @@ -3137,9 +2058,7 @@ describe('Quota Store API', () => { it('rejects Cloud quota-change webhook event tokens with the wrong payload hash', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const payload = JSON.stringify({ eventId: 'evt-wrong-hash' }) const res = await app.request('/api/store/webhook', { @@ -3156,9 +2075,7 @@ describe('Quota Store API', () => { it('rejects Cloud quota-change webhook event tokens with a mismatched event id', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const payload = JSON.stringify({ eventId: 'evt-body-id', cloudOrderId: 'order-event-id', @@ -3184,9 +2101,7 @@ describe('Quota Store API', () => { it('rejects Cloud quota-change webhook event tokens with the wrong audience', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const payload = JSON.stringify({ eventId: 'evt-wrong-audience' }) const res = await app.request('/api/store/webhook', { @@ -3203,9 +2118,7 @@ describe('Quota Store API', () => { it('rejects signed malformed Cloud payloads', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const payload = '{bad-json' const res = await postWebhook(app, payload) @@ -3216,9 +2129,7 @@ describe('Quota Store API', () => { it('rejects deliveries without resource details', async () => { const { app, db } = await createTestApp() - await seedProLicense(db) - const headers = await adminHeaders(app) - await seedSettings(app, headers) + await seedBusinessLicense(db) const payload = JSON.stringify({ eventId: 'evt-no-package', cloudOrderId: 'order-no-package', @@ -3231,37 +2142,59 @@ describe('Quota Store API', () => { expect(res.status).toBe(400) await expect(res.json()).resolves.toMatchObject({ error: 'invalid_payload' }) }) -}) -async function seedSettings(app: Awaited>['app'], headers: Record) { - await app.request('/api/admin/store/settings', { - method: 'PUT', - headers: { ...headers, 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled: true }), + it('rejects credit-only commerce fulfillment events on the quota webhook', async () => { + const { app, db } = await createTestApp() + await seedBusinessLicense(db) + const payload = JSON.stringify({ + eventId: 'evt-credit-only', + eventType: 'commerce.order_item.fulfilled', + orderId: 'order-credit-only', + orderItemId: 'item-credit-only', + productId: 'product-credit-only', + productName: 'Credits', + quantity: 1, + deliverable: { + type: 'zpan.credits', + includedCredits: 200, + }, + target: { + orgId: await getFirstOrgId(db), + customerId: 'customer-credit-only', + customerLabel: 'customer@example.com', + }, + context: { + storeId: 'store_1', + paymentProvider: 'stripe', + }, + occurredAt: '2026-06-01T00:00:00.000Z', + }) + + const res = await postWebhook(app, payload) + + expect(res.status).toBe(400) + await expect(res.json()).resolves.toMatchObject({ error: 'invalid_payload' }) }) -} +}) async function getFirstOrgId(db: Awaited>['db']): Promise { const rows = await db.all<{ id: string }>(sql`SELECT id FROM organization LIMIT 1`) - return rows[0].id + if (rows[0]) return rows[0].id + await db.run(sql` + INSERT INTO organization (id, name, slug, metadata) + VALUES ('org-test', 'Test Organization', 'test-organization', '{"type":"personal"}') + `) + await db.run(sql` + INSERT INTO org_quotas (id, org_id, quota, used, traffic_quota, traffic_used, traffic_period) + VALUES ('quota-test', 'org-test', 10485760, 0, 0, 0, '1970-01') + `) + return 'org-test' } async function seedPackage(_db: Awaited>['db']): Promise { return 'cloud-pkg-1' } -async function seedSettingsRow(db: Awaited>['db']) { - const now = new Date().toISOString() - await db.run(sql` - INSERT INTO system_options - (key, value, public) - VALUES - ('cloud_store_enabled', 'true', 0), - ('cloud_store_created_at', ${now}, 0), - ('cloud_store_updated_at', ${now}, 0) - `) -} - async function postWebhook(app: Awaited>['app'], payload: string) { return app.request('/api/store/webhook', { method: 'POST', @@ -3277,7 +2210,7 @@ async function signedWebhookHeaders(payload: string, overrides: Record accountId: 'test-account', instanceId: 'license_1', storeId: 'store-binding-1', - edition: 'pro', + edition: 'business', + features: [ + 'white_label', + 'open_registration', + 'teams_unlimited', + 'storages_unlimited', + 'site_announcements', + 'audit_log', + 'quota_store', + ], + licenseId: 'business-license-unit', authorizedHosts: ['localhost'], licenseValidUntil: issuedAt + 365 * 24 * 60 * 60, issuedAt, diff --git a/server/routes/cloud-store.ts b/server/routes/cloud-store.ts index cac389fb..e89c641f 100644 --- a/server/routes/cloud-store.ts +++ b/server/routes/cloud-store.ts @@ -1,3 +1,2 @@ -export { adminCloudStore } from './cloud-store/admin' export { cloudStore } from './cloud-store/storefront' export { cloudStoreWebhooks } from './cloud-store/webhooks' diff --git a/server/routes/cloud-store/admin.ts b/server/routes/cloud-store/admin.ts deleted file mode 100644 index fe9d9c5e..00000000 --- a/server/routes/cloud-store/admin.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { zValidator } from '@hono/zod-validator' -import { - cloudProductInputSchema, - cloudProductPatchSchema, - cloudStoreSettingsSchema, - createGiftCardInputSchema, - disableGiftCardSchema, -} from '@shared/schemas' -import { Hono } from 'hono' -import { requireAdmin } from '../../middleware/auth' -import type { Env } from '../../middleware/platform' -import { requireFeature } from '../../middleware/require-feature' -import { getCloudStoreBinding, getCloudStoreSettings, upsertCloudStoreSettings } from '../../services/cloud-store' -import { requestBoundCloudJson } from '../../services/licensing-cloud' -import { - cloudGiftCardCreateResponseSchema, - cloudGiftCardsResponseSchema, - cloudOrdersQuerySchema, - cloudPackageListResponseSchema, - cloudPackageResponseSchema, - getBoundCloudClient, - getCloudBaseUrl, - giftCardListQuerySchema, - type RouteContext, - unwrapCloudResponse, - withCloudRequestTimeout, -} from '../cloud-store-helpers' -import { getCloudOrders } from './shared' - -export const adminCloudStore = new Hono() - .use(requireAdmin) - .use(requireFeature('quota_store')) - .get('/settings', async (c) => { - const settings = await getCloudStoreSettings(c.get('platform').db) - return c.json(settings ?? null) - }) - .put('/settings', zValidator('json', cloudStoreSettingsSchema), async (c) => { - const settings = await upsertCloudStoreSettings(c.get('platform').db, c.req.valid('json')) - return c.json(settings) - }) - .get('/packages', async (c) => { - const result = await cloudRequest(c, async ({ client, storeId }) => - unwrapCloudResponse( - await client.stores[':storeId'].products.$get({ - param: { storeId }, - query: { type: 'store_item', limit: '100' }, - }), - cloudPackageListResponseSchema, - ), - ) - if (isCloudError(result)) return c.json(result, 502) - const items = result.items.filter((item) => item.metadata.deliverable.type === 'zpan.plan') - return c.json({ ...result, items, total: items.length }) - }) - .get('/credits/products', async (c) => { - const result = await cloudRequest(c, async ({ client, storeId }) => - unwrapCloudResponse( - await client.stores[':storeId'].products.$get({ - param: { storeId }, - query: { type: 'store_item', limit: '100' }, - }), - cloudPackageListResponseSchema, - ), - ) - if (isCloudError(result)) return c.json(result, 502) - const items = result.items.filter((item) => item.metadata.deliverable.type === 'zpan.credits') - return c.json({ ...result, items, total: items.length }) - }) - .post('/packages', zValidator('json', cloudProductInputSchema), async (c) => { - const result = await cloudRequest(c, async ({ client, storeId }) => - unwrapCloudResponse( - await client.stores[':storeId'].products.$post({ - param: { storeId }, - json: c.req.valid('json'), - }), - cloudPackageResponseSchema, - ), - ) - if (isCloudError(result)) return c.json(result, 502) - return c.json(result, 201) - }) - .get('/packages/:id', async (c) => { - const productId = c.req.param('id') - const result = await cloudRequest(c, async ({ client, storeId }) => - unwrapCloudResponse( - await client.stores[':storeId'].products[':productId'].$get({ param: { storeId, productId } }), - cloudPackageResponseSchema, - ), - ) - if (isCloudError(result)) return c.json(result, 502) - return c.json(result) - }) - .patch('/packages/:id', zValidator('json', cloudProductPatchSchema), async (c) => { - const productId = c.req.param('id') - const result = await cloudRequest(c, async ({ client, storeId }) => - unwrapCloudResponse( - await client.stores[':storeId'].products[':productId'].$patch({ - param: { storeId, productId }, - json: c.req.valid('json'), - }), - cloudPackageResponseSchema, - ), - ) - if (isCloudError(result)) return c.json(result, 502) - return c.json(result) - }) - .put('/packages/:id', zValidator('json', cloudProductInputSchema), async (c) => { - const productId = c.req.param('id') - const result = await cloudRequest(c, async ({ client, storeId }) => - unwrapCloudResponse( - await client.stores[':storeId'].products[':productId'].$patch({ - param: { storeId, productId }, - json: c.req.valid('json'), - }), - cloudPackageResponseSchema, - ), - ) - if (isCloudError(result)) return c.json(result, 502) - return c.json(result) - }) - .delete('/packages/:id', async (c) => { - const productId = c.req.param('id') - const result = await cloudRequest(c, async ({ client, storeId }) => - unwrapCloudResponse( - await client.stores[':storeId'].products[':productId'].$delete({ param: { storeId, productId } }), - ), - ) - if (isCloudError(result)) return c.json(result, 502) - return c.json({ id: productId, deleted: true }) - }) - .get('/gift-cards', zValidator('query', giftCardListQuerySchema), async (c) => { - const query = c.req.valid('query') - const result = await cloudRequest(c, async ({ client, storeId }) => - unwrapCloudResponse( - await client.stores[':storeId']['gift-cards'].$get({ - param: { storeId }, - query: query.status ? { status: query.status } : {}, - }), - cloudGiftCardsResponseSchema, - ), - ) - if (isCloudError(result)) return c.json(result, 502) - return c.json(result) - }) - .post('/gift-cards', zValidator('json', createGiftCardInputSchema), async (c) => { - const result = await cloudRequest(c, async ({ storeId }) => createCloudGiftCards(c, storeId, c.req.valid('json'))) - if (isCloudError(result)) return c.json(result, 502) - return c.json(result, 201) - }) - .patch('/gift-cards/:code', zValidator('json', disableGiftCardSchema), async (c) => { - const code = c.req.param('code') - const result = await cloudRequest(c, async ({ client, storeId }) => - unwrapCloudResponse( - await client.stores[':storeId']['gift-cards'][':code'].$patch({ - param: { storeId, code }, - json: c.req.valid('json'), - }), - ), - ) - if (isCloudError(result)) return c.json(result, 502) - return c.json({ code, disabled: true }) - }) - .delete('/gift-cards/:code', async (c) => { - const code = c.req.param('code') - const result = await cloudRequest(c, async ({ client, storeId }) => - unwrapCloudResponse(await client.stores[':storeId']['gift-cards'][':code'].$delete({ param: { storeId, code } })), - ) - if (isCloudError(result)) return c.json(result, 502) - return c.json({ code, deleted: true }) - }) - .get('/orders', zValidator('query', cloudOrdersQuerySchema), async (c) => { - const result = await getCloudOrders(c, c.req.valid('query')) - if ('error' in result) return c.json(result, 502) - return c.json(result) - }) - -async function cloudRequest( - c: RouteContext, - request: (context: Awaited>) => Promise, -): Promise { - try { - return await withCloudRequestTimeout(request(await getBoundCloudClient(c))) - } catch (error) { - return { error: (error as Error).message } - } -} - -function isCloudError(result: unknown): result is { error: string } { - return Boolean(result && typeof result === 'object' && 'error' in result) -} - -async function createCloudGiftCards(c: RouteContext, storeId: string, payload: object) { - const binding = await getCloudStoreBinding(c.get('platform').db) - const data = await requestBoundCloudJson( - getCloudBaseUrl(c), - `/api/stores/${encodeURIComponent(storeId)}/gift-cards`, - binding.refreshToken, - { method: 'POST', payload }, - ) - return cloudGiftCardCreateResponseSchema.parse(data) -} diff --git a/server/routes/cloud-store/storefront.ts b/server/routes/cloud-store/storefront.ts index eb1829ec..4b4ecea1 100644 --- a/server/routes/cloud-store/storefront.ts +++ b/server/routes/cloud-store/storefront.ts @@ -11,14 +11,8 @@ import { z } from 'zod' import { requireAuth } from '../../middleware/auth' import type { Env } from '../../middleware/platform' import { requireFeature } from '../../middleware/require-feature' -import { - canAccessTargetOrg, - getAccessibleTargets, - getCloudStoreBinding, - getCustomerLabel, -} from '../../services/cloud-store' +import { canAccessTargetOrg, getAccessibleTargets, getCustomerLabel } from '../../services/cloud-store' import { getEffectiveQuota } from '../../services/effective-quota' -import { requestBoundCloudJson } from '../../services/licensing-cloud' import { cloudBillingPortalSessionResponseSchema, cloudCheckoutResponseSchema, @@ -27,7 +21,6 @@ import { cloudPackageResponseSchema, cloudStoreOrdersQuerySchema, getBoundCloudClient, - getCloudBaseUrl, getUserStoreSettings, type RouteContext, unwrapCloudResponse, @@ -82,9 +75,11 @@ export const cloudStore = new Hono() if (!targetOrgId) return c.json({ error: 'No active organization' }, 400) const store = await getUserStoreSettings(c.get('platform').db) if ('error' in store) return c.json({ error: store.error }, 403) - const result = await cloudRequest(c, async ({ storeId }) => + const result = await cloudRequest(c, async ({ client, storeId }) => unwrapCloudResponse( - await getCloudCreditResource(c, storeId, targetOrgId, 'balance'), + await client.stores[':storeId']['credit-accounts'][':customerId'].balance.$get({ + param: { storeId, customerId: targetOrgId }, + }), cloudCreditBalanceResponseSchema, ), ) @@ -96,9 +91,12 @@ export const cloudStore = new Hono() if (!targetOrgId) return c.json({ error: 'No active organization' }, 400) const store = await getUserStoreSettings(c.get('platform').db) if ('error' in store) return c.json({ error: store.error }, 403) - const result = await cloudRequest(c, async ({ storeId }) => + const result = await cloudRequest(c, async ({ client, storeId }) => unwrapCloudResponse( - await getCloudCreditResource(c, storeId, targetOrgId, 'ledger-entries'), + await client.stores[':storeId']['credit-accounts'][':customerId']['ledger-entries'].$get({ + param: { storeId, customerId: targetOrgId }, + query: {}, + }), cloudCreditLedgerResponseSchema, ), ) @@ -110,9 +108,12 @@ export const cloudStore = new Hono() if (!targetOrgId) return c.json({ error: 'No active organization' }, 400) const store = await getUserStoreSettings(c.get('platform').db) if ('error' in store) return c.json({ error: store.error }, 403) - const result = await cloudRequest(c, async ({ storeId }) => + const result = await cloudRequest(c, async ({ client, storeId }) => unwrapCloudResponse( - await postCloudCreditRedemption(c, storeId, targetOrgId, [c.req.valid('json').code]), + await client.stores[':storeId']['credit-accounts'][':customerId'].redemptions.$post({ + param: { storeId, customerId: targetOrgId }, + json: { codes: [c.req.valid('json').code] }, + }), redeemGiftCardResponseSchema, ), ) @@ -288,50 +289,6 @@ function orderBelongsToTarget(target: Record | null, targetOrgI return target?.orgId === targetOrgId || target?.customerId === targetOrgId } -function cloudCreditPath(storeId: string, customerId: string, resource: string) { - return `/api/stores/${encodeURIComponent(storeId)}/credit-accounts/${encodeURIComponent(customerId)}/${resource}` -} - -async function getCloudCreditResource( - c: RouteContext, - storeId: string, - customerId: string, - resource: 'balance' | 'ledger-entries', -) { - const binding = await getCloudStoreBinding(c.get('platform').db) - const data = await requestBoundCloudJson( - getCloudBaseUrl(c), - cloudCreditPath(storeId, customerId, resource), - binding.refreshToken, - { - method: 'GET', - }, - ) - return jsonResponse(data) -} - -async function postCloudCreditRedemption(c: RouteContext, storeId: string, customerId: string, codes: string[]) { - const binding = await getCloudStoreBinding(c.get('platform').db) - const data = await requestBoundCloudJson( - getCloudBaseUrl(c), - cloudCreditPath(storeId, customerId, 'redemptions'), - binding.refreshToken, - { - method: 'POST', - payload: { codes }, - }, - ) - return jsonResponse(data, 201) -} - -function jsonResponse(data: unknown, status = 200) { - return { - ok: status >= 200 && status < 300, - status, - json: async () => data, - } -} - async function cloudRequest( c: RouteContext, request: (context: Awaited>) => Promise, diff --git a/server/routes/cloud-store/webhooks.ts b/server/routes/cloud-store/webhooks.ts index 49c62f10..a14e03e4 100644 --- a/server/routes/cloud-store/webhooks.ts +++ b/server/routes/cloud-store/webhooks.ts @@ -3,16 +3,15 @@ import { Hono } from 'hono' import { verifyCloudEventToken } from '../../licensing/cloud-event-token' import type { Env } from '../../middleware/platform' import { requireFeature } from '../../middleware/require-feature' -import { getCloudStoreBinding, getRequiredSettings, processCloudOrderQuotaChange } from '../../services/cloud-store' +import { getCloudStoreBinding, processCloudOrderQuotaChange } from '../../services/cloud-store' import { getCloudBaseUrl, parseJson, sha256Hex } from '../cloud-store-helpers' export const cloudStoreWebhooks = new Hono().use(requireFeature('quota_store')).post('/webhook', async (c) => { const db = c.get('platform').db - await getRequiredSettings(db) const binding = await getCloudStoreBinding(db) const rawPayload = await c.req.text() const payloadHash = await sha256Hex(rawPayload) - const eventToken = c.req.header('x-zpan-cloud-event-token') ?? '' + const eventToken = c.req.header('x-commerce-event-token') ?? '' const eventAuth = verifyCloudEventToken(eventToken, { cloudBaseUrl: getCloudBaseUrl(c), instanceId: binding.instanceId, diff --git a/server/routes/cloud-traffic-metering.integration.test.ts b/server/routes/cloud-traffic-metering.integration.test.ts index e1d2247d..92acc7e9 100644 --- a/server/routes/cloud-traffic-metering.integration.test.ts +++ b/server/routes/cloud-traffic-metering.integration.test.ts @@ -1,12 +1,11 @@ import { sql } from 'drizzle-orm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cloudTrafficReports } from '../db/schema' -import { createLicenseBinding } from '../licensing/license-state' import type { Database } from '../platform/interface' import { currentTrafficPeriod } from '../services/effective-quota' import { S3Service } from '../services/s3' import { createShare } from '../services/share' -import { authedHeaders, createTestApp } from '../test/setup' +import { authedHeaders, createTestApp, seedBusinessLicense } from '../test/setup' import { encodeChildRef } from './share-utils' const STORAGE_ID = 'st-cloud-traffic-test' @@ -35,17 +34,8 @@ function acceptedUsageResponse(_url: string, init?: RequestInit): Response { return makeCloudResponse({ data: { accepted: true, duplicate: false, eventId: body.eventId } }) } -async function seedTrafficBinding(db: Database) { - await createLicenseBinding(db, { - cloudBindingId: 'test-binding', - cloudStoreId: 'store-test-binding', - instanceId: 'test-instance', - cloudAccountId: 'test-account', - refreshToken: 'test-refresh-token', - cachedCert: 'test-certificate', - cachedExpiresAt: Math.floor(Date.now() / 1000) + 3600, - lastRefreshAt: Math.floor(Date.now() / 1000), - }) +async function seedTrafficBinding(db: Awaited>['db']) { + await seedBusinessLicense(db) } async function insertStorage(db: Database) { diff --git a/server/routes/download-tasks.integration.test.ts b/server/routes/download-tasks.integration.test.ts index 284c2b89..a6919074 100644 --- a/server/routes/download-tasks.integration.test.ts +++ b/server/routes/download-tasks.integration.test.ts @@ -2,10 +2,8 @@ import type { Downloader, DownloadTask } from '@shared/types' import { sql } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' import { remoteDownloadUsageReports } from '../db/schema' -import { createLicenseBinding } from '../licensing/license-state' -import type { Database } from '../platform/interface' import { S3Service } from '../services/s3.js' -import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js' +import { adminHeaders, authedHeaders, createTestApp, seedBusinessLicense } from '../test/setup.js' type DownloadTaskList = { items: DownloadTask[] } @@ -88,17 +86,8 @@ async function insertStorage(db: Awaited>['db'] `) } -async function seedCloudBinding(db: Database) { - await createLicenseBinding(db, { - cloudBindingId: 'download-billing-binding', - cloudStoreId: 'store-download-billing', - instanceId: 'test-instance', - cloudAccountId: 'test-account', - refreshToken: 'test-refresh-token', - cachedCert: 'test-certificate', - cachedExpiresAt: Math.floor(Date.now() / 1000) + 3600, - lastRefreshAt: Math.floor(Date.now() / 1000), - }) +async function seedCloudBinding(db: Awaited>['db']) { + await seedBusinessLicense(db) } async function registerDownloaderThroughDeviceLogin( diff --git a/server/routes/downloaders.ts b/server/routes/downloaders.ts index 2997d9c0..6b811057 100644 --- a/server/routes/downloaders.ts +++ b/server/routes/downloaders.ts @@ -9,6 +9,7 @@ import { updateDownloaderSchema, } from '@shared/schemas' import type { Context } from 'hono' +import { hasFeature, loadBindingState } from '../licensing/has-feature' import { requireAdmin, requireDownloader } from '../middleware/auth' import type { Env } from '../middleware/platform' import { @@ -107,9 +108,14 @@ const downloadersRoute = new OpenAPIHono() }) as never) .openapi(updateRoute, (async (c: OpenAPIContext) => { const id = c.req.param('id') as string - return downloadResponse(c, async () => - updateDownloader(c.get('platform'), id, c.req.valid('json') as z.infer), - ) + const input = c.req.valid('json') as z.infer + if (input.remoteDownloadCreditBillingEnabled === true) { + const state = await loadBindingState(c.get('platform').db) + if (!hasFeature('quota_store', state)) { + return c.json({ error: 'feature_not_available', feature: 'quota_store' }, 402) + } + } + return downloadResponse(c, async () => updateDownloader(c.get('platform'), id, input)) }) as never) .openapi(deleteRoute, (async (c: OpenAPIContext) => { const id = c.req.param('id') as string diff --git a/server/routes/licensing-admin.integration.test.ts b/server/routes/licensing-admin.integration.test.ts index 27ba333c..07f29604 100644 --- a/server/routes/licensing-admin.integration.test.ts +++ b/server/routes/licensing-admin.integration.test.ts @@ -130,7 +130,7 @@ describe('POST /api/licensing/pair', () => { expect(body.pairingUrl).toBe('https://cloud.zpan.space/pair') const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] - expect(JSON.parse(String(init.body)).instanceHost).toBe('http://localhost') + expect(JSON.parse(String(init.body)).instance.url).toBe('http://localhost') }) it('uses the configured instance id when present', async () => { @@ -152,7 +152,7 @@ describe('POST /api/licensing/pair', () => { expect(res.status).toBe(200) const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] - expect(JSON.parse(String(init.body)).instanceId).toBe('zpan-e2e-node') + expect(JSON.parse(String(init.body)).instance.id).toBe('zpan-e2e-node') }) }) @@ -203,6 +203,7 @@ describe('GET /api/licensing/pair/:code/poll', () => { expect(res.status).toBe(200) const body = (await res.json()) as Record expect(body.status).toBe('approved') + expect(body.cloud_store_id).toBe('store-1') // Check that binding was persisted const state = await loadLicenseState(db) @@ -367,11 +368,10 @@ describe('DELETE /api/licensing/binding', () => { expect(res.status).toBe(200) const body = (await res.json()) as Record expect(body.deleted).toBe(true) - expect(fetch).toHaveBeenCalledWith('https://cloud.zpan.space/api/licenses/bind-1', { - method: 'DELETE', - headers: { Authorization: 'Bearer old-token' }, - signal: expect.any(AbortSignal), - }) + const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + expect(url).toBe('https://cloud.zpan.space/api/licenses/bind-1') + expect(init.method).toBe('DELETE') + expect(new Headers(init.headers).get('Authorization')).toBe('Bearer old-token') // Confirm binding is gone const state = await loadLicenseState(db) diff --git a/server/routes/licensing-admin.ts b/server/routes/licensing-admin.ts index 382ae160..247d0293 100644 --- a/server/routes/licensing-admin.ts +++ b/server/routes/licensing-admin.ts @@ -1,9 +1,8 @@ -import { eq } from 'drizzle-orm' import { Hono } from 'hono' import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' -import { systemOptions } from '../db/schema' import { invalidateEntitlementCache } from '../licensing/entitlement' import { getOrCreateInstanceId } from '../licensing/instance-id' +import { buildCloudInstanceInfo } from '../licensing/instance-info' import { clearLicenseBinding, createLicenseBinding, loadLicenseState } from '../licensing/license-state' import { performRefresh } from '../licensing/refresh' import { normalizeHost, verifyCertificate } from '../licensing/verify' @@ -68,18 +67,12 @@ const app = new Hono() const db = c.get('platform').db const baseUrl = getCloudBaseUrl(c) - const instanceId = await getOrCreateInstanceId(db, configuredInstanceId(c)) + const instance = await buildCloudInstanceInfo(db, { + configuredInstanceId: configuredInstanceId(c), + url: getInstanceOrigin(c), + }) - const titleRows = await db - .select({ value: systemOptions.value }) - .from(systemOptions) - .where(eq(systemOptions.key, 'site_title')) - .limit(1) - - const instanceName = titleRows[0]?.value ?? 'ZPan' - const instanceHost = getInstanceOrigin(c) - - const pairing = await createPairing(baseUrl, instanceId, instanceName, instanceHost) + const pairing = await createPairing(baseUrl, instance) return c.json(pairing) }) @@ -136,6 +129,7 @@ const app = new Hono() return c.json({ status: 'approved' as const, edition: assertion.edition, + cloud_store_id: entitlement.binding.storeId, }) } @@ -151,8 +145,12 @@ const app = new Hono() const userId = c.get('userId')! const orgId = c.get('orgId')! const baseUrl = getCloudBaseUrl(c) + const instance = await buildCloudInstanceInfo(db, { + configuredInstanceId: configuredInstanceId(c), + url: getInstanceOrigin(c), + }) - await performRefresh(db, baseUrl) + await performRefresh(db, baseUrl, instance) const state = await loadLicenseState(db) diff --git a/server/routes/licensing.integration.test.ts b/server/routes/licensing.integration.test.ts index 7a973cc3..2dba3086 100644 --- a/server/routes/licensing.integration.test.ts +++ b/server/routes/licensing.integration.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cloudTrafficReports } from '../db/schema.js' import { createLicenseBinding } from '../licensing/license-state.js' -import { createTestApp, seedProLicense } from '../test/setup.js' +import { createTestApp, seedBusinessLicense, seedProLicense } from '../test/setup.js' describe('GET /api/licensing/status', () => { it('returns { bound: false } when no binding row exists', async () => { @@ -11,7 +11,10 @@ describe('GET /api/licensing/status', () => { expect(res.status).toBe(200) const body = await res.json() - expect(body).toEqual({ bound: false }) + expect(body).toMatchObject({ + bound: false, + cloud_dashboard_url: 'https://cloud.zpan.space/dashboard', + }) }) it('returns bound state with plan and features when binding row exists with cert', async () => { @@ -174,16 +177,7 @@ describe('POST /api/licensing/refresh-cron', () => { REFRESH_CRON_SECRET: 'traffic-secret', ZPAN_CLOUD_URL: 'https://cloud.example', }) - await createLicenseBinding(db, { - cloudBindingId: 'bind-traffic', - cloudStoreId: 'store-traffic', - instanceId: 'inst-traffic', - cloudAccountId: 'acc-traffic', - refreshToken: 'traffic-refresh-token', - cachedCert: 'test-cert', - cachedExpiresAt: Math.floor(Date.now() / 1000) + 86400, - lastRefreshAt: Math.floor(Date.now() / 1000), - }) + await seedBusinessLicense(db) await db.insert(cloudTrafficReports).values({ id: 'report_traffic_cron', orgId: 'org_traffic_cron', @@ -206,8 +200,8 @@ describe('POST /api/licensing/refresh-cron', () => { expect(res.status).toBe(200) await expect(res.json()).resolves.toMatchObject({ ok: true, attempted: 1, reported: 1, blocked: 0, failed: 0 }) const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] - expect(url).toBe('https://cloud.example/api/stores/store-traffic/billing/usage-events') - expect(init.headers).toMatchObject({ Authorization: 'Bearer traffic-refresh-token' }) + expect(url).toBe('https://cloud.example/api/stores/store-test-binding/billing/usage-events') + expect(new Headers(init.headers).get('Authorization')).toBe('Bearer test-refresh-token') await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([{ status: 'reported' }]) }) diff --git a/server/routes/licensing.ts b/server/routes/licensing.ts index d0fbaae7..d5d3c71f 100644 --- a/server/routes/licensing.ts +++ b/server/routes/licensing.ts @@ -4,6 +4,7 @@ import { Hono } from 'hono' import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' import type { BindingState } from '../../shared/types' import { loadBindingState } from '../licensing/has-feature' +import { buildCloudInstanceInfo } from '../licensing/instance-info' import { normalizeHost } from '../licensing/verify' import type { Env } from '../middleware/platform' import { syncPendingCloudTrafficReports } from '../services/cloud-traffic-metering' @@ -11,17 +12,30 @@ import { runLicensingRefresh } from '../services/licensing-refresh-runner' import { syncPendingRemoteDownloadUsageReports } from '../services/remote-download-usage' function configuredPublicHost(c: Context): string | null { + const origin = configuredPublicOrigin(c) + return origin ? new URL(origin).host : null +} + +function configuredPublicOrigin(c: Context): string | null { const value = c.get('platform').getEnv('ZPAN_PUBLIC_ORIGIN') ?? c.get('platform').getEnv('BETTER_AUTH_URL') if (!value) return null try { const url = new URL(value) if (url.protocol !== 'http:' && url.protocol !== 'https:') return null - return url.host + return url.origin } catch { return null } } +function configuredInstanceId(c: Context): string | undefined { + return c.get('platform').getEnv('ZPAN_INSTANCE_ID') +} + +function cloudDashboardUrl(cloudBaseUrl: string): string { + return `${cloudBaseUrl.replace(/\/$/, '')}/dashboard` +} + function secretsMatch(provided: string, expected: string): boolean { if (provided.length !== expected.length) return false const enc = new TextEncoder() @@ -37,7 +51,7 @@ const app = new Hono() normalizeHost(c.req.header('x-forwarded-host') ?? c.req.header('host')) ?? new URL(c.req.url).host const state = await loadBindingState(db, { currentHost, cloudBaseUrl }) - return c.json(state satisfies BindingState) + return c.json({ ...state, cloud_dashboard_url: cloudDashboardUrl(cloudBaseUrl) } satisfies BindingState) }) // POST /api/licensing/refresh-cron?secret= @@ -52,7 +66,11 @@ const app = new Hono() const db = c.get('platform').db const cloudBaseUrl = c.get('platform').getEnv('ZPAN_CLOUD_URL') ?? ZPAN_CLOUD_URL_DEFAULT - await runLicensingRefresh(db, cloudBaseUrl) + const origin = configuredPublicOrigin(c) + const instance = origin + ? await buildCloudInstanceInfo(db, { configuredInstanceId: configuredInstanceId(c), url: origin }) + : undefined + await runLicensingRefresh(db, cloudBaseUrl, instance) return c.json({ ok: true }) }) diff --git a/server/routes/objects.integration.test.ts b/server/routes/objects.integration.test.ts index a98a218e..a6ffc570 100644 --- a/server/routes/objects.integration.test.ts +++ b/server/routes/objects.integration.test.ts @@ -1,7 +1,6 @@ import { sql } from 'drizzle-orm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cloudTrafficReports } from '../db/schema.js' -import { createLicenseBinding } from '../licensing/license-state.js' import { confirmUpload, copyMatter, @@ -13,7 +12,7 @@ import { updateMatter, } from '../services/matter.js' import { S3Service } from '../services/s3.js' -import { authedHeaders, createTestApp } from '../test/setup.js' +import { authedHeaders, createTestApp, seedBusinessLicense } from '../test/setup.js' beforeEach(() => { vi.restoreAllMocks() @@ -762,16 +761,7 @@ describe('Objects API', () => { await insertStorage(db, { metered: true }) const orgId = await getOrgId(db) await insertFile(db, orgId, { id: 'm1', name: 'doc.txt' }) - await createLicenseBinding(db, { - cloudBindingId: 'binding_1', - cloudStoreId: 'store_1', - instanceId: 'instance_1', - cloudAccountId: 'account_1', - refreshToken: 'refresh-token-1', - cachedCert: 'cert', - cachedExpiresAt: Math.floor(Date.now() / 1000) + 3600, - lastRefreshAt: Math.floor(Date.now() / 1000), - }) + await seedBusinessLicense(db) vi.stubGlobal( 'fetch', vi.fn(async (_url, init) => { diff --git a/server/routes/storages.ts b/server/routes/storages.ts index 9036f351..17865356 100644 --- a/server/routes/storages.ts +++ b/server/routes/storages.ts @@ -15,6 +15,10 @@ import { updateStorage, } from '../services/storage' +function enablesEgressCreditBilling(input: { egressCreditBillingEnabled?: boolean }) { + return input.egressCreditBillingEnabled === true +} + const app = new Hono() .use(requireAdmin) .get('/', async (c) => { @@ -38,7 +42,11 @@ const app = new Hono() 402, ) } - const storage = await createStorage(db, c.req.valid('json')) + const input = c.req.valid('json') + if (enablesEgressCreditBilling(input) && !hasFeature('quota_store', state)) { + return c.json({ error: 'feature_not_available', feature: 'quota_store' }, 402) + } + const storage = await createStorage(db, input) await recordActivity(db, { orgId, userId, @@ -62,7 +70,11 @@ const app = new Hono() const userId = c.get('userId')! const orgId = c.get('orgId')! const id = c.req.param('id') - const storage = await updateStorage(db, id, c.req.valid('json')) + const input = c.req.valid('json') + if (enablesEgressCreditBilling(input) && !hasFeature('quota_store', await loadBindingState(db))) { + return c.json({ error: 'feature_not_available', feature: 'quota_store' }, 402) + } + const storage = await updateStorage(db, id, input) if (!storage) return c.json({ error: 'Storage not found' }, 404) await recordActivity(db, { orgId, diff --git a/server/routes/webdav.integration.test.ts b/server/routes/webdav.integration.test.ts index 14b74572..4218ef1a 100644 --- a/server/routes/webdav.integration.test.ts +++ b/server/routes/webdav.integration.test.ts @@ -592,9 +592,7 @@ describe('WebDAV API', () => { FROM cloud_traffic_reports WHERE org_id = ${workspace.id} `) - expect(reports).toMatchObject([ - { source: 'webdav_download', sourceId: 'traffic-report', bytes: 12, status: 'skipped_unbound' }, - ]) + expect(reports).toEqual([]) }) it('GET supports valid byte ranges and rejects invalid ranges', async () => { diff --git a/server/services/cloud-store.ts b/server/services/cloud-store.ts index a8d9ae86..31fce6e9 100644 --- a/server/services/cloud-store.ts +++ b/server/services/cloud-store.ts @@ -1,42 +1,13 @@ -import type { CloudOrderQuotaChange, CloudStoreSettingsInput } from '@shared/schemas' -import type { CloudStoreSettings, CloudStoreTarget } from '@shared/types' -import { and, eq, inArray, sql } from 'drizzle-orm' +import type { CloudOrderQuotaChange } from '@shared/schemas' +import type { CloudStoreTarget } from '@shared/types' +import { and, eq, sql } from 'drizzle-orm' import { nanoid } from 'nanoid' import { member, organization, user } from '../db/auth-schema' -import { activityEvents, orgQuotaEntitlements, orgQuotas, systemOptions, webhookEvents } from '../db/schema' +import { activityEvents, orgQuotaEntitlements, orgQuotas, webhookEvents } from '../db/schema' import { loadActiveLicenseBinding } from '../licensing/license-state' import type { Database } from '../platform/interface' import { type AtomicQuery, executeRows, executeWriteTransaction } from './db-transaction' -const CLOUD_STORE_ENABLED_KEY = 'cloud_store_enabled' -const CLOUD_STORE_CREATED_AT_KEY = 'cloud_store_created_at' -const CLOUD_STORE_UPDATED_AT_KEY = 'cloud_store_updated_at' -const CLOUD_STORE_SETTING_KEYS = [ - CLOUD_STORE_ENABLED_KEY, - CLOUD_STORE_CREATED_AT_KEY, - CLOUD_STORE_UPDATED_AT_KEY, -] as const - -export async function getCloudStoreSettings(db: Database): Promise { - const settings = await getRawSettings(db) - if (!settings) return null - const binding = await loadActiveLicenseBinding(db) - return settingsDto(settings, Boolean(binding?.refreshToken)) -} - -export async function upsertCloudStoreSettings( - db: Database, - input: CloudStoreSettingsInput, -): Promise { - const now = new Date() - const existing = await getRawSettings(db) - const createdAt = existing?.createdAt ?? now - await writeSystemOption(db, CLOUD_STORE_ENABLED_KEY, input.enabled ? 'true' : 'false') - await writeSystemOption(db, CLOUD_STORE_CREATED_AT_KEY, createdAt.toISOString()) - await writeSystemOption(db, CLOUD_STORE_UPDATED_AT_KEY, now.toISOString()) - return (await getCloudStoreSettings(db))! -} - export async function getAccessibleTargets(db: Database, userId: string): Promise { const rows = await db .select({ orgId: organization.id, name: organization.name, metadata: organization.metadata, role: member.role }) @@ -94,45 +65,6 @@ export async function processCloudOrderQuotaChange( return { duplicate: false, eventId: event.eventId } } -export async function getRequiredSettings(db: Database) { - const settings = await getRawSettings(db) - if (!settings?.enabled) throw new Error('quota_store_disabled') - return settings -} - -async function getRawSettings(db: Database) { - const rows = await db - .select({ key: systemOptions.key, value: systemOptions.value }) - .from(systemOptions) - .where(inArray(systemOptions.key, [...CLOUD_STORE_SETTING_KEYS])) - if (rows.length === 0) return null - const values = new Map(rows.map((row) => [row.key, row.value])) - const enabled = values.get(CLOUD_STORE_ENABLED_KEY) - if (enabled === undefined) return null - const createdAt = values.get(CLOUD_STORE_CREATED_AT_KEY) - const updatedAt = values.get(CLOUD_STORE_UPDATED_AT_KEY) - if (!createdAt || !updatedAt) throw new Error('cloud_store_settings_incomplete') - return { - id: CLOUD_STORE_ENABLED_KEY, - enabled: enabled === 'true', - createdAt: new Date(createdAt), - updatedAt: new Date(updatedAt), - } -} - -async function writeSystemOption(db: Database, key: string, value: string) { - const existing = await db - .select({ key: systemOptions.key }) - .from(systemOptions) - .where(eq(systemOptions.key, key)) - .limit(1) - if (existing.length > 0) { - await db.update(systemOptions).set({ value, public: false }).where(eq(systemOptions.key, key)) - return - } - await db.insert(systemOptions).values({ key, value, public: false }) -} - async function processQuotaChangeTransaction( db: Database, webhookId: string, @@ -425,19 +357,6 @@ async function markWebhookEvent(db: Database, id: string, status: string, error: await db.update(webhookEvents).set({ status, error, processedAt: new Date() }).where(eq(webhookEvents.id, id)) } -function settingsDto( - row: { id: string; enabled: boolean; createdAt: Date; updatedAt: Date }, - cloudReady = true, -): CloudStoreSettings { - return { - id: row.id, - enabled: row.enabled, - status: cloudReady ? 'ready' : 'cloud_unbound', - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - } -} - function parseOrgType(metadata: string | null): string { if (!metadata) return 'unknown' return (JSON.parse(metadata) as { type?: string }).type ?? 'unknown' diff --git a/server/services/cloud-traffic-metering.test.ts b/server/services/cloud-traffic-metering.test.ts index 129fe2a3..92882fbd 100644 --- a/server/services/cloud-traffic-metering.test.ts +++ b/server/services/cloud-traffic-metering.test.ts @@ -1,10 +1,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cloudTrafficReports } from '../db/schema' import { createLicenseBinding } from '../licensing/license-state' -import type { Database } from '../platform/interface' import { createTestApp } from '../test/setup' import { CloudTrafficBlockedError, reportTrafficEgress, syncPendingCloudTrafficReports } from './cloud-traffic-metering' +const hasFeatureMock = vi.hoisted(() => vi.fn(() => true)) + +vi.mock('../licensing/has-feature', () => ({ + hasFeature: hasFeatureMock, + loadBindingState: vi.fn(async () => ({ bound: true, active: true, edition: 'business', features: ['quota_store'] })), +})) + function makeResponse(body: unknown, status = 200): Response { return { ok: status >= 200 && status < 300, @@ -14,7 +20,13 @@ function makeResponse(body: unknown, status = 200): Response { } as unknown as Response } -async function seedTrafficBinding(db: Database) { +function headerValue(headers: HeadersInit | undefined, name: string): string | null { + return new Headers(headers).get(name) +} + +async function seedTrafficBinding(db: Awaited>['db']) { + const issuedAt = Math.floor(Date.now() / 1000) + const expiresAt = issuedAt + 3600 await createLicenseBinding(db, { cloudBindingId: 'test-binding', cloudStoreId: 'store-test-binding', @@ -22,8 +34,8 @@ async function seedTrafficBinding(db: Database) { cloudAccountId: 'test-account', refreshToken: 'test-refresh-token', cachedCert: 'test-certificate', - cachedExpiresAt: Math.floor(Date.now() / 1000) + 3600, - lastRefreshAt: Math.floor(Date.now() / 1000), + cachedExpiresAt: expiresAt, + lastRefreshAt: issuedAt, }) } @@ -37,6 +49,8 @@ const meteredStorage = { describe('cloud traffic metering', () => { afterEach(() => { vi.unstubAllGlobals() + hasFeatureMock.mockReset() + hasFeatureMock.mockReturnValue(true) }) it('reports traffic egress to Cloud from the request path', async () => { @@ -85,7 +99,7 @@ describe('cloud traffic metering', () => { expect(fetch).toHaveBeenCalledTimes(1) const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] expect(url).toBe('https://cloud.example/api/stores/store-test-binding/billing/usage-events') - expect(init.headers).toMatchObject({ Authorization: 'Bearer test-refresh-token' }) + expect(headerValue(init.headers, 'Authorization')).toBe('Bearer test-refresh-token') expect(JSON.parse(init.body as string)).toMatchObject({ resource: 'storage_egress', unit: 'byte', @@ -274,6 +288,7 @@ describe('cloud traffic metering', () => { it('records skipped reporting when no active binding exists', async () => { const { db, platform } = await createTestApp() + hasFeatureMock.mockReturnValue(false) vi.stubGlobal('fetch', vi.fn()) const result = await reportTrafficEgress({ @@ -286,13 +301,14 @@ describe('cloud traffic metering', () => { ...meteredStorage, }) - expect(result.status).toBe('skipped_unbound') + expect(result.status).toBe('reported') expect(fetch).not.toHaveBeenCalled() - await expect(db.select().from(cloudTrafficReports)).resolves.toMatchObject([{ status: 'skipped_unbound' }]) + await expect(db.select().from(cloudTrafficReports)).resolves.toHaveLength(0) }) it('keeps unbound report replays local', async () => { const { db, platform } = await createTestApp() + hasFeatureMock.mockReturnValue(false) vi.stubGlobal('fetch', vi.fn()) const input = { @@ -308,8 +324,8 @@ describe('cloud traffic metering', () => { const second = await reportTrafficEgress(input) expect(first.duplicate).toBe(false) - expect(second).toMatchObject({ status: 'skipped_unbound', eventId: 'evt_unbound_dup', duplicate: true }) + expect(second).toMatchObject({ status: 'reported', eventId: 'evt_unbound_dup', duplicate: false }) expect(fetch).not.toHaveBeenCalled() - await expect(db.select().from(cloudTrafficReports)).resolves.toHaveLength(1) + await expect(db.select().from(cloudTrafficReports)).resolves.toHaveLength(0) }) }) diff --git a/server/services/cloud-traffic-metering.ts b/server/services/cloud-traffic-metering.ts index 2b3565b5..bf2e2046 100644 --- a/server/services/cloud-traffic-metering.ts +++ b/server/services/cloud-traffic-metering.ts @@ -3,10 +3,11 @@ import { nanoid } from 'nanoid' import { z } from 'zod' import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' import { cloudTrafficReports } from '../db/schema' +import { hasFeature, loadBindingState } from '../licensing/has-feature' import { loadActiveLicenseBinding } from '../licensing/license-state' import type { Database, Platform } from '../platform/interface' import { currentTrafficPeriod } from './effective-quota' -import { postBoundCloudJson } from './licensing-cloud' +import { createBoundCloudClient, requestCloudJson } from './licensing-cloud' export type TrafficReportSource = | 'object_download' @@ -48,6 +49,9 @@ export async function reportTrafficEgress(params: { const { platform, orgId, bytes, source, sourceId, now = new Date() } = params if (bytes <= 0) return { status: 'reported', eventId: params.eventId ?? '', duplicate: false } if (!params.egressCreditBillingEnabled) return { status: 'reported', eventId: params.eventId ?? '', duplicate: false } + if (!hasFeature('quota_store', await loadBindingState(platform.db))) { + return { status: 'reported', eventId: params.eventId ?? '', duplicate: false } + } if (!params.storageId || !params.egressCreditUnitBytes || !params.egressCreditPerUnit) { throw new Error('storage_egress_pricing_missing') } @@ -111,6 +115,8 @@ export async function syncPendingCloudTrafficReports(params: { now?: Date }): Promise<{ attempted: number; reported: number; blocked: number; failed: number }> { const { db, cloudBaseUrl, limit = 100, now = new Date() } = params + if (!hasFeature('quota_store', await loadBindingState(db))) + return { attempted: 0, reported: 0, blocked: 0, failed: 0 } const binding = await loadActiveLicenseBinding(db) if (!binding?.refreshToken || !binding.cloudStoreId) return { attempted: 0, reported: 0, blocked: 0, failed: 0 } @@ -146,33 +152,35 @@ async function syncTrafficReport(params: { }): Promise<'reported' | 'blocked' | 'failed'> { const { db, cloudBaseUrl, refreshToken, storeId, report, now } = params try { + const client = createBoundCloudClient(cloudBaseUrl, refreshToken) const isStorageEgress = Boolean(report.storageId && report.unitBytes && report.creditsPerUnit) - const data = await postBoundCloudJson( - cloudBaseUrl, - `/api/stores/${encodeURIComponent(storeId)}/billing/usage-events`, - refreshToken, - isStorageEgress - ? { - resource: 'storage_egress', - unit: 'byte', - bytes: report.bytes, - eventId: report.eventId, - idempotencyKey: report.eventId, - customerId: report.orgId, - source: report.source, - sourceId: report.sourceId, - usageContext: { storageId: report.storageId }, - pricing: { unitQuantity: report.unitBytes!, creditsPerUnit: report.creditsPerUnit! }, - } - : { - resource: 'traffic_egress', - bytes: report.bytes, - eventId: report.eventId, - idempotencyKey: report.eventId, - customerId: report.orgId, - }, + const payload = isStorageEgress + ? { + resource: 'storage_egress', + unit: 'byte', + bytes: report.bytes, + eventId: report.eventId, + idempotencyKey: report.eventId, + customerId: report.orgId, + source: report.source, + sourceId: report.sourceId, + usageContext: { storageId: report.storageId }, + pricing: { unitQuantity: report.unitBytes!, creditsPerUnit: report.creditsPerUnit! }, + } + : { + resource: 'traffic_egress', + bytes: report.bytes, + eventId: report.eventId, + idempotencyKey: report.eventId, + customerId: report.orgId, + } + const response = await requestCloudJson( + client.stores[':storeId'].billing['usage-events'].$post({ + param: { storeId }, + json: payload as never, + }), + usageResponseSchema, ) - const response = usageResponseSchema.parse(data) if (!response.accepted) throw new Error('cloud_usage_report_rejected') await updateTrafficReport(db, report.eventId, 'reported', null, now) return 'reported' diff --git a/server/services/invite.ts b/server/services/invite.ts index 9d4e1245..7f096052 100644 --- a/server/services/invite.ts +++ b/server/services/invite.ts @@ -47,7 +47,7 @@ export async function redeemInviteCode( if (row.usedBy) return 'already_used' if (row.expiresAt && row.expiresAt < new Date()) return 'expired' - const result = await db + const updated = await db .update(inviteCodes) .set({ usedBy: userId, usedAt: new Date() }) .where( @@ -57,12 +57,9 @@ export async function redeemInviteCode( or(isNull(inviteCodes.expiresAt), gt(inviteCodes.expiresAt, new Date())), ), ) + .returning({ id: inviteCodes.id }) - // If no rows affected, another request redeemed it concurrently - const changes = (result as { rowsAffected?: number }).rowsAffected ?? (result as { changes?: number }).changes - if (changes === undefined) - throw new Error('DB driver returned no rowsAffected — cannot confirm invite code redemption') - return changes > 0 ? 'ok' : 'already_used' + return updated.length > 0 ? 'ok' : 'already_used' } export async function listInviteCodes( diff --git a/server/services/licensing-cloud.test.ts b/server/services/licensing-cloud.test.ts index 4b3cc793..3f225977 100644 --- a/server/services/licensing-cloud.test.ts +++ b/server/services/licensing-cloud.test.ts @@ -3,10 +3,11 @@ import { CloudInvalidResponseError, CloudNetworkError, CloudUnboundError, + createBoundCloudClient, createPairing, pollPairing, refreshEntitlement, - requestBoundCloudJson, + requestCloudJson, unbindCloudLicense, } from './licensing-cloud' @@ -22,6 +23,12 @@ function makeResponse(body: unknown, status = 200): Response { } as unknown as Response } +function headerValue(headers: HeadersInit | undefined, name: string): string | null { + if (headers instanceof Headers) return headers.get(name) + if (Array.isArray(headers)) return new Headers(headers).get(name) + return headers?.[name] ?? null +} + describe('licensing-cloud', () => { beforeEach(() => { vi.stubGlobal('fetch', vi.fn()) @@ -40,28 +47,43 @@ describe('licensing-cloud', () => { } vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload)) - const result = await createPairing(BASE_URL, 'inst-1', 'My ZPan', 'zpan.example.com') + const result = await createPairing(BASE_URL, { + id: 'inst-1', + name: 'My ZPan', + url: 'https://zpan.example.com', + version: '0.0.1', + }) const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] expect(url).toBe('https://cloud.zpan.space/api/pairings') expect(init.method).toBe('POST') + expect(headerValue(init.headers, 'content-type')).toBe('application/json') const body = JSON.parse(init.body as string) - expect(body.instanceId).toBe('inst-1') - expect(body.instanceName).toBe('My ZPan') - expect(body.instanceHost).toBe('zpan.example.com') + expect(body).toEqual({ + instance: { + id: 'inst-1', + name: 'My ZPan', + url: 'https://zpan.example.com', + version: '0.0.1', + }, + }) expect(result).toEqual(payload) }) it('throws on non-OK response', async () => { vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Bad Request' }, 400)) - await expect(createPairing(BASE_URL, 'inst-1', 'ZPan', 'host')).rejects.toThrow('Cloud pairing failed') + await expect( + createPairing(BASE_URL, { id: 'inst-1', name: 'ZPan', url: 'https://zpan.example.com', version: '0.0.1' }), + ).rejects.toThrow('Cloud pairing failed') }) it('throws CloudNetworkError on fetch failure', async () => { vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error')) - await expect(createPairing(BASE_URL, 'inst-1', 'ZPan', 'host')).rejects.toThrow(CloudNetworkError) + await expect( + createPairing(BASE_URL, { id: 'inst-1', name: 'ZPan', url: 'https://zpan.example.com', version: '0.0.1' }), + ).rejects.toThrow(CloudNetworkError) }) }) @@ -71,8 +93,9 @@ describe('licensing-cloud', () => { await pollPairing(BASE_URL, 'ABC-123') - const [url] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] expect(url).toBe('https://cloud.zpan.space/api/pairings/ABC-123') + expect(headerValue(init.headers, 'content-type')).toBe('application/json') }) it('returns pending status', async () => { @@ -119,12 +142,34 @@ describe('licensing-cloud', () => { const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] expect(url).toBe('https://cloud.zpan.space/api/entitlements') expect(init.method).toBe('POST') - expect(init.headers).toEqual({ Authorization: 'Bearer old-rt' }) + expect(headerValue(init.headers, 'Authorization')).toBe('Bearer old-rt') expect(init.body).toBeUndefined() expect(result.refreshToken).toBe('new-rt') expect(result.certificate).toBe('v4.public.newtoken') }) + it('sends instance info when refreshing entitlement', async () => { + const payload = { refreshToken: 'new-rt', certificate: 'v4.public.newtoken' } + vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload)) + + await refreshEntitlement(BASE_URL, 'old-rt', { + id: 'inst-1', + name: 'My ZPan', + url: 'https://zpan.example.com', + version: '0.0.1', + }) + + const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] + expect(JSON.parse(init.body as string)).toEqual({ + instance: { + id: 'inst-1', + name: 'My ZPan', + url: 'https://zpan.example.com', + version: '0.0.1', + }, + }) + }) + it('throws CloudUnboundError on 401', async () => { vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Unbound' }, 401)) @@ -167,7 +212,7 @@ describe('licensing-cloud', () => { const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] expect(url).toBe('https://cloud.zpan.space/api/licenses/binding_1') expect(init.method).toBe('DELETE') - expect(init.headers).toEqual({ Authorization: 'Bearer rt-bound' }) + expect(headerValue(init.headers, 'Authorization')).toBe('Bearer rt-bound') expect(init.body).toBeUndefined() }) @@ -178,19 +223,22 @@ describe('licensing-cloud', () => { }) }) - describe('requestBoundCloudJson', () => { - it('sends PATCH requests with bound authorization and JSON payloads', async () => { + describe('requestCloudJson', () => { + it('unwraps SDK responses from bound clients', async () => { vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ state: 'revoked' })) - const result = await requestBoundCloudJson(BASE_URL, '/api/store/gift-cards/ZS123', 'rt-bound', { - method: 'PATCH', - payload: { disabled: true }, - }) + const client = createBoundCloudClient(BASE_URL, 'rt-bound') + const result = await requestCloudJson( + client.stores[':storeId']['gift-cards'][':code'].$patch({ + param: { storeId: 'store_1', code: 'ZS123' }, + json: { disabled: true }, + }), + ) const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit] - expect(url).toBe('https://cloud.zpan.space/api/store/gift-cards/ZS123') + expect(url).toBe('https://cloud.zpan.space/api/stores/store_1/gift-cards/ZS123') expect(init.method).toBe('PATCH') - expect(init.headers).toEqual({ Authorization: 'Bearer rt-bound', 'Content-Type': 'application/json' }) + expect(headerValue(init.headers, 'Authorization')).toBe('Bearer rt-bound') expect(JSON.parse(init.body as string)).toEqual({ disabled: true }) expect(result).toEqual({ state: 'revoked' }) }) diff --git a/server/services/licensing-cloud.ts b/server/services/licensing-cloud.ts index 676c9559..e243cfe0 100644 --- a/server/services/licensing-cloud.ts +++ b/server/services/licensing-cloud.ts @@ -1,7 +1,8 @@ -// HTTP client for cloud.zpan.space — pairing and entitlement refresh. -// All requests have a 10s timeout. +import type { z } from 'zod' +import { type CloudClient, createCloudClient } from 'zpan-cloud-sdk' const CLOUD_REQUEST_TIMEOUT_MS = 10_000 +const JSON_HEADERS = { 'content-type': 'application/json' } export interface PairingResponse { code: string @@ -37,6 +38,13 @@ export interface LicenseAccountInfo { email?: string | null } +export interface CloudInstanceInfo { + id: string + name: string + url: string + version: string +} + export class CloudInvalidResponseError extends Error { constructor() { super('Cloud response missing certificate') @@ -58,32 +66,72 @@ export class CloudNetworkError extends Error { } } -function withTimeout(ms: number): AbortSignal { - return AbortSignal.timeout(ms) +function cloudApiBaseUrl(baseUrl: string): string { + return `${baseUrl.replace(/\/$/, '')}/api` } -async function cloudFetch(baseUrl: string, path: string, init: RequestInit): Promise { +export function createBoundCloudClient(baseUrl: string, refreshToken: string): CloudClient { + return createCloudClient({ baseUrl: cloudApiBaseUrl(baseUrl), token: refreshToken, headers: JSON_HEADERS }) +} + +function createAnonymousCloudClient(baseUrl: string): CloudClient { + return createCloudClient({ baseUrl: cloudApiBaseUrl(baseUrl), headers: JSON_HEADERS }) +} + +async function cloudResponse< + T extends { ok: boolean; status: number; json(): Promise; text(): Promise }, +>(response: Promise): Promise { + let timeout: ReturnType | undefined try { - return await fetch(`${baseUrl}${path}`, { - ...init, - signal: withTimeout(CLOUD_REQUEST_TIMEOUT_MS), - }) + return await Promise.race([ + response, + new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error('cloud_request_timeout')), CLOUD_REQUEST_TIMEOUT_MS) + }), + ]) } catch (err) { throw new CloudNetworkError(err) + } finally { + if (timeout) clearTimeout(timeout) } } -export async function createPairing( - baseUrl: string, - instanceId: string, - instanceName: string, - instanceHost: string, -): Promise { - const res = await cloudFetch(baseUrl, '/api/pairings', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ instanceId, instanceName, instanceHost }), - }) +export async function unwrapCloudResponse( + response: { + status: number + ok: boolean + json(): Promise + }, + responseSchema?: z.ZodType, +): Promise { + if (response.status === 204) return null as U + const data = await response.json().catch(() => null) + if (!response.ok) throw new Error(cloudErrorCode(data) ?? `cloud_request_failed_${response.status}`) + const payload = data && typeof data === 'object' && 'data' in data ? data.data : data + if (!responseSchema) return payload as U + const parsed = responseSchema.safeParse(payload) + if (!parsed.success) throw new Error('invalid_cloud_response') + return parsed.data +} + +export async function requestCloudJson( + response: Promise<{ + status: number + ok: boolean + json(): Promise + text(): Promise + }>, + responseSchema?: z.ZodType, +): Promise { + return unwrapCloudResponse(await cloudResponse(response), responseSchema) +} + +export async function createPairing(baseUrl: string, instance: CloudInstanceInfo): Promise { + const res = await cloudResponse( + createAnonymousCloudClient(baseUrl).pairings.$post({ + json: { instance }, + }), + ) if (!res.ok) { const text = await res.text().catch(() => '') @@ -94,9 +142,7 @@ export async function createPairing( } export async function pollPairing(baseUrl: string, code: string): Promise { - const res = await cloudFetch(baseUrl, `/api/pairings/${encodeURIComponent(code)}`, { - method: 'GET', - }) + const res = await cloudResponse(createAnonymousCloudClient(baseUrl).pairings[':code'].$get({ param: { code } })) if (!res.ok) { const text = await res.text().catch(() => '') @@ -106,16 +152,19 @@ export async function pollPairing(baseUrl: string, code: string): Promise(await res.json()) } -// Calls POST /api/entitlements with the stored refreshToken. // Throws CloudUnboundError on 401 (instance was unbound from cloud side). // Throws CloudNetworkError on network failure. -export async function refreshEntitlement(baseUrl: string, refreshToken: string): Promise { - const res = await cloudFetch(baseUrl, '/api/entitlements', { - method: 'POST', - headers: { Authorization: `Bearer ${refreshToken}` }, - }) +export async function refreshEntitlement( + baseUrl: string, + refreshToken: string, + instance?: CloudInstanceInfo, +): Promise { + const client = createBoundCloudClient(baseUrl, refreshToken) + const res = await cloudResponse( + instance ? client.entitlements.$post({ json: { instance } }) : client.entitlements.$post({ json: undefined }), + ) - if (res.status === 401) { + if ((res.status as number) === 401) { throw new CloudUnboundError() } @@ -130,10 +179,9 @@ export async function refreshEntitlement(baseUrl: string, refreshToken: string): } export async function unbindCloudLicense(baseUrl: string, licenseId: string, refreshToken: string): Promise { - const res = await cloudFetch(baseUrl, `/api/licenses/${encodeURIComponent(licenseId)}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${refreshToken}` }, - }) + const res = await cloudResponse( + createBoundCloudClient(baseUrl, refreshToken).licenses[':id'].$delete({ param: { id: licenseId } }), + ) if (!res.ok) { const text = await res.text().catch(() => '') @@ -141,54 +189,15 @@ export async function unbindCloudLicense(baseUrl: string, licenseId: string, ref } } -export async function requestBoundCloudJson( - baseUrl: string, - path: string, - refreshToken: string, - init: { method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; payload?: object }, -): Promise { - const headers: Record = { Authorization: `Bearer ${refreshToken}` } - if (init.payload) headers['Content-Type'] = 'application/json' - - const res = await cloudFetch(baseUrl, path, { - method: init.method, - headers, - body: init.payload ? JSON.stringify(init.payload) : undefined, - }) - - if (res.status === 204) return null - - const data = await res.json().catch(() => null) - if (!res.ok) { - const error = - data && - typeof data === 'object' && - 'error' in data && - data.error && - typeof data.error === 'object' && - 'code' in data.error && - typeof data.error.code === 'string' - ? data.error.code - : data && typeof data === 'object' && 'error' in data && typeof data.error === 'string' - ? data.error - : null - throw new Error(error ?? `cloud_request_failed_${res.status}`) - } - - if (data && typeof data === 'object' && 'data' in data) return data.data - return data -} - -export async function postBoundCloudJson( - baseUrl: string, - path: string, - refreshToken: string, - payload: object, -): Promise { - return requestBoundCloudJson(baseUrl, path, refreshToken, { method: 'POST', payload }) -} - function unwrapCloudData(data: unknown): T { if (data && typeof data === 'object' && 'data' in data) return data.data as T return data as T } + +function cloudErrorCode(data: unknown) { + if (!data || typeof data !== 'object' || !('error' in data)) return null + const error = data.error + if (typeof error === 'string') return error + if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') return error.code + return null +} diff --git a/server/services/licensing-refresh-runner.ts b/server/services/licensing-refresh-runner.ts index 207c35e5..7b2e2bec 100644 --- a/server/services/licensing-refresh-runner.ts +++ b/server/services/licensing-refresh-runner.ts @@ -1,10 +1,15 @@ import { loadLicenseState } from '../licensing/license-state' import { performRefresh } from '../licensing/refresh' import type { Database } from '../platform/interface' +import type { CloudInstanceInfo } from './licensing-cloud' const DEDUP_WINDOW_SEC = 5 * 60 -export async function runLicensingRefresh(db: Database, cloudBaseUrl: string): Promise { +export async function runLicensingRefresh( + db: Database, + cloudBaseUrl: string, + instance?: CloudInstanceInfo, +): Promise { const state = await loadLicenseState(db) if (!state.refreshToken) return // unbound — no-op @@ -12,7 +17,11 @@ export async function runLicensingRefresh(db: Database, cloudBaseUrl: string): P if (state.lastRefreshAt != null && nowSec - state.lastRefreshAt < DEDUP_WINDOW_SEC) return try { - await performRefresh(db, cloudBaseUrl) + if (instance) { + await performRefresh(db, cloudBaseUrl, instance) + } else { + await performRefresh(db, cloudBaseUrl) + } console.log('licensing.refresh.ok') } catch (err) { const code = err instanceof Error ? err.message : String(err) diff --git a/server/services/remote-download-usage.ts b/server/services/remote-download-usage.ts index cb007b10..892f51b8 100644 --- a/server/services/remote-download-usage.ts +++ b/server/services/remote-download-usage.ts @@ -3,9 +3,10 @@ import { nanoid } from 'nanoid' import { z } from 'zod' import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' import { remoteDownloadUsageReports } from '../db/schema' +import { hasFeature, loadBindingState } from '../licensing/has-feature' import { loadActiveLicenseBinding } from '../licensing/license-state' import type { Database, Platform } from '../platform/interface' -import { postBoundCloudJson } from './licensing-cloud' +import { createBoundCloudClient, requestCloudJson } from './licensing-cloud' export class RemoteDownloadBillingBlockedError extends Error { constructor() { @@ -34,6 +35,7 @@ export async function reportRemoteDownloadUnit(params: { enabled: boolean }): Promise<{ status: RemoteDownloadUsageStatus; eventId: string }> { if (!params.enabled) return { status: 'reported', eventId: '' } + if (!hasFeature('quota_store', await loadBindingState(params.platform.db))) return { status: 'reported', eventId: '' } const eventId = `remote_download:${params.taskId}:${params.unitIndex}` const existing = await params.platform.db .select() @@ -78,6 +80,8 @@ export async function syncPendingRemoteDownloadUsageReports(params: { now?: Date }): Promise<{ attempted: number; reported: number; blocked: number; failed: number }> { const { db, cloudBaseUrl, limit = 100, now = new Date() } = params + if (!hasFeature('quota_store', await loadBindingState(db))) + return { attempted: 0, reported: 0, blocked: 0, failed: 0 } const binding = await loadActiveLicenseBinding(db) if (!binding?.refreshToken || !binding.cloudStoreId) return { attempted: 0, reported: 0, blocked: 0, failed: 0 } @@ -110,12 +114,11 @@ async function syncRemoteDownloadUsageReport(params: { } try { - const response = usageResponseSchema.parse( - await postBoundCloudJson( - cloudBaseUrl, - `/api/stores/${encodeURIComponent(binding.cloudStoreId)}/billing/usage-events`, - binding.refreshToken, - { + const client = createBoundCloudClient(cloudBaseUrl, binding.refreshToken) + const response = await requestCloudJson( + client.stores[':storeId'].billing['usage-events'].$post({ + param: { storeId: binding.cloudStoreId }, + json: { resource: 'remote_download', unit: 'byte', bytes: report.unitBytes, @@ -126,8 +129,9 @@ async function syncRemoteDownloadUsageReport(params: { sourceId: report.taskId, usageContext: { downloaderId: report.downloaderId }, pricing: { unitQuantity: report.unitBytes, creditsPerUnit: report.creditsPerUnit }, - }, - ), + } as never, + }), + usageResponseSchema, ) if (!response.accepted) throw new Error('cloud_usage_report_rejected') await mark(db, report.eventId, 'reported', null, now) diff --git a/server/test/setup.ts b/server/test/setup.ts index 4df985a8..ac327ab4 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -2,6 +2,8 @@ import Database from 'better-sqlite3' import { drizzle } from 'drizzle-orm/better-sqlite3' import { generateKeys, sign } from 'paseto-ts/v4' import { ZPAN_CLOUD_URL_DEFAULT } from '../../shared/constants' +import { PRO_GATE_KEYS } from '../../shared/feature-registry' +import type { LicenseEdition, ProFeature } from '../../shared/types' import { createApp } from '../app' import { createAuth } from '../auth' import * as authSchema from '../db/auth-schema' @@ -601,10 +603,36 @@ function nowSec(): number { return Math.floor(Date.now() / 1000) } +const BUSINESS_ONLY_TEST_FEATURES = new Set(['quota_store']) + /** - * Insert a Pro license binding row so that feature gates resolve as enabled. + * Insert a Pro license binding row so that non-commercial Pro feature gates resolve as enabled. */ -export async function seedProLicense(db: Awaited>['db'], _features?: string[]) { +export async function seedProLicense(db: Awaited>['db'], features?: string[]) { + return seedLicense(db, { + edition: 'pro', + features: + normalizeTestFeatures(features) ?? PRO_GATE_KEYS.filter((feature) => !BUSINESS_ONLY_TEST_FEATURES.has(feature)), + }) +} + +/** + * Insert a Business license binding row so commercial feature gates resolve as enabled. + */ +export async function seedBusinessLicense(db: Awaited>['db'], features?: string[]) { + return seedLicense(db, { + edition: 'business', + features: normalizeTestFeatures(features) ?? [...PRO_GATE_KEYS], + }) +} + +async function seedLicense( + db: Awaited>['db'], + input: { + edition: LicenseEdition + features: ProFeature[] + }, +) { const { PUBLIC_KEYS } = await import('../licensing/public-keys.js') if (!PUBLIC_KEYS.includes(TEST_LICENSE_PUBLIC)) { PUBLIC_KEYS.unshift(TEST_LICENSE_PUBLIC) @@ -620,7 +648,9 @@ export async function seedProLicense(db: Awaited allowed.has(feature as ProFeature)) +} diff --git a/shared/feature-registry.ts b/shared/feature-registry.ts index e11d53c8..3dbd6f33 100644 --- a/shared/feature-registry.ts +++ b/shared/feature-registry.ts @@ -11,7 +11,7 @@ export type CellValue = boolean | { i18nKey: string; params?: Record = { core: 'features.category.core', pro: 'features.category.pro', + business: 'features.category.business', } // --------------------------------------------------------------------------- @@ -34,6 +35,8 @@ export interface FeatureDefinition { community: CellValue /** What Pro plan gets — true (included), false (not included), or structured value. */ pro: CellValue + /** What Business plan gets — true (included), false (not included), or structured value. */ + business: CellValue /** * If present, this feature is a real entitlement gate enforced at runtime. * The value must match a key in the PRO_GATE_KEYS tuple. @@ -54,30 +57,35 @@ export const FEATURE_REGISTRY = [ category: 'core', community: true, pro: true, + business: true, }, { i18nKey: 'features.shareLinks', category: 'core', community: true, pro: true, + business: true, }, { i18nKey: 'features.imageHosting', category: 'core', community: true, pro: true, + business: true, }, { i18nKey: 'features.socialLoginOidc', category: 'core', community: true, pro: true, + business: true, }, { i18nKey: 'features.inviteCodes', category: 'core', community: true, pro: true, + business: true, }, // ── Pro Features ──────────────────────────────────────────────────── @@ -86,6 +94,7 @@ export const FEATURE_REGISTRY = [ category: 'pro', community: false, pro: true, + business: true, gateKey: 'white_label', }, { @@ -93,6 +102,7 @@ export const FEATURE_REGISTRY = [ category: 'pro', community: false, pro: true, + business: true, gateKey: 'open_registration', }, { @@ -100,6 +110,7 @@ export const FEATURE_REGISTRY = [ category: 'pro', community: { i18nKey: 'features.teamWorkspaces.limit', params: { count: FREE_EXTRA_TEAM_LIMIT } }, pro: { i18nKey: 'features.teamWorkspaces.unlimited' }, + business: { i18nKey: 'features.teamWorkspaces.unlimited' }, gateKey: 'teams_unlimited', }, { @@ -107,13 +118,15 @@ export const FEATURE_REGISTRY = [ category: 'pro', community: { i18nKey: 'features.storageBackends.limit', params: { count: FREE_STORAGE_LIMIT } }, pro: { i18nKey: 'features.storageBackends.unlimited' }, + business: { i18nKey: 'features.storageBackends.unlimited' }, gateKey: 'storages_unlimited', }, { i18nKey: 'features.cloudStore', - category: 'pro', + category: 'business', community: false, - pro: true, + pro: false, + business: true, gateKey: 'quota_store', }, { @@ -121,6 +134,7 @@ export const FEATURE_REGISTRY = [ category: 'pro', community: false, pro: true, + business: true, gateKey: 'site_announcements', }, { @@ -128,6 +142,7 @@ export const FEATURE_REGISTRY = [ category: 'pro', community: false, pro: true, + business: true, comingSoon: true, }, { @@ -135,6 +150,7 @@ export const FEATURE_REGISTRY = [ category: 'pro', community: false, pro: true, + business: true, comingSoon: true, }, { @@ -142,6 +158,7 @@ export const FEATURE_REGISTRY = [ category: 'pro', community: false, pro: true, + business: true, gateKey: 'audit_log', }, { @@ -149,6 +166,7 @@ export const FEATURE_REGISTRY = [ category: 'pro', community: false, pro: true, + business: true, comingSoon: true, }, { @@ -156,12 +174,13 @@ export const FEATURE_REGISTRY = [ category: 'pro', community: false, pro: true, + business: true, comingSoon: true, }, ] as const satisfies readonly FeatureDefinition[] // --------------------------------------------------------------------------- -// Derived types — ProFeature union from gateKey literals +// Derived types — feature gate union from gateKey literals // --------------------------------------------------------------------------- type GatedFeature = Extract<(typeof FEATURE_REGISTRY)[number], { gateKey: string }> @@ -170,7 +189,9 @@ type GatedFeature = Extract<(typeof FEATURE_REGISTRY)[number], { gateKey: string * Union of all active entitlement gate keys. * Automatically stays in sync with the registry. */ -export type ProFeature = GatedFeature['gateKey'] +export type LicenseFeature = GatedFeature['gateKey'] + +export type ProFeature = LicenseFeature /** Runtime array of all active gate keys (for validation / iteration). */ export const PRO_GATE_KEYS = FEATURE_REGISTRY.filter( diff --git a/shared/schemas/cloud-store-legacy.ts b/shared/schemas/cloud-store-legacy.ts index bf0deeaf..fe2a3598 100644 --- a/shared/schemas/cloud-store-legacy.ts +++ b/shared/schemas/cloud-store-legacy.ts @@ -1,5 +1,4 @@ import { z } from 'zod' -import { zpanCloudEventSchema } from 'zpan-cloud-sdk' export const legacyCloudProductDeliverableSchema = z.object({ type: z.enum(['zpan.plan', 'zpan.credits', 'zpan.extra']), @@ -39,7 +38,49 @@ const legacyCloudOrderQuotaChangeSchema = z } }) -const storeDeliveryEventSchema = zpanCloudEventSchema +const storeDeliveryEventSchema = z + .object({ + eventId: z.string().min(1), + eventType: z.enum([ + 'commerce.order_item.fulfilled', + 'commerce.subscription.renewed', + 'commerce.subscription.updated', + 'commerce.subscription.canceled', + 'commerce.subscription.expired', + ]), + orderId: z.string().min(1), + orderItemId: z.string().min(1), + productId: z.string().min(1), + productName: z.string().min(1), + quantity: z.number().int().positive(), + deliverable: z.record(z.string(), z.unknown()), + target: z.record(z.string(), z.unknown()).nullable(), + context: z.object({ + storeId: z.string().min(1), + paymentProvider: z.enum(['stripe', 'gift_card', 'credits']).nullable(), + stripePriceId: z.string().nullable().optional(), + stripePriceLookupKey: z.string().nullable().optional(), + stripePriceRecurring: z.unknown().optional(), + stripePriceMetadata: z.record(z.string(), z.string()).optional(), + stripeSubscriptionId: z.string().nullable().optional(), + stripeInvoiceId: z.string().nullable().optional(), + billingPeriodStart: z.string().nullable().optional(), + billingPeriodEnd: z.string().nullable().optional(), + }), + occurredAt: z.string().min(1), + }) + .superRefine((event, ctx) => { + if ( + numberDeliverableValue(event.deliverable, 'storageBytes') === 0 && + numberDeliverableValue(event.deliverable, 'trafficBytes') === 0 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['deliverable'], + message: 'At least one of deliverable.storageBytes or deliverable.trafficBytes must be greater than 0', + }) + } + }) function numberDeliverableValue(deliverable: Record, key: string) { const value = deliverable[key] @@ -81,7 +122,7 @@ export const cloudOrderQuotaChangeSchema = z.union([ cloudOrderId: sourceId(event), targetOrgId: targetOrgId(event.target), direction: - event.eventType === 'store.subscription.canceled' || event.eventType === 'store.subscription.expired' + event.eventType === 'commerce.subscription.canceled' || event.eventType === 'commerce.subscription.expired' ? ('decrease' as const) : ('increase' as const), storageBytes: numberDeliverableValue(event.deliverable, 'storageBytes'), diff --git a/shared/schemas/cloud-store.ts b/shared/schemas/cloud-store.ts index 216df7b2..6831338e 100644 --- a/shared/schemas/cloud-store.ts +++ b/shared/schemas/cloud-store.ts @@ -11,10 +11,6 @@ import { type CloudOrderQuotaChange, legacyCloudProductDeliverableSchema } from export { cloudOrderQuotaChangeSchema } from './cloud-store-legacy' -export const cloudStoreSettingsSchema = z.object({ - enabled: z.boolean(), -}) - export const cloudStoreCurrencySchema = z.literal('usd') export const cloudProductPriceSchema = productPriceSchema.extend({ currency: cloudStoreCurrencySchema, @@ -199,7 +195,6 @@ export const disableGiftCardSchema = z.object({ disabled: z.literal(true), }) -export type CloudStoreSettingsInput = z.infer export type CloudStoreCurrency = z.infer export type CloudProductPrice = z.infer export type CloudProductDeliverable = z.infer diff --git a/shared/schemas/index.ts b/shared/schemas/index.ts index 6e248bb0..57eb63eb 100644 --- a/shared/schemas/index.ts +++ b/shared/schemas/index.ts @@ -43,7 +43,6 @@ export type { CloudOrderQuotaChange, CloudProductInput, CloudProductPatchInput, - CloudStoreSettingsInput, CreateGiftCardInput, DisableGiftCardInput, GiftCardStatus, @@ -64,7 +63,6 @@ export { cloudOrdersResponseSchema, cloudProductInputSchema, cloudProductPatchSchema, - cloudStoreSettingsSchema, createGiftCardInputSchema, disableGiftCardSchema, giftCardStatusSchema, diff --git a/shared/types/index.ts b/shared/types/index.ts index 9d4350d8..af2493b7 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -91,14 +91,6 @@ export interface CurrentStoragePlan { export type WebhookEventStatus = 'processed' | 'duplicate' | 'failed' -export interface CloudStoreSettings { - id: string - enabled: boolean - status: 'ready' | 'cloud_unbound' - createdAt: string - updatedAt: string -} - export type CloudProduct = CommerceProduct export type CloudProductPrice = ProductPrice export type CloudOrderFulfillmentPayload = ZPanCloudOrderFulfillmentPayload @@ -592,7 +584,7 @@ export interface ImageHosting { createdAt: string } -export type { BindingState, LicenseAssertion, ProFeature } from './licensing' +export type { BindingState, LicenseAssertion, LicenseEdition, LicenseFeature, ProFeature } from './licensing' export type BrandingThemePresetId = 'default' | 'ocean' | 'forest' | 'rose' diff --git a/shared/types/licensing.ts b/shared/types/licensing.ts index bf8c7a95..eb295d2b 100644 --- a/shared/types/licensing.ts +++ b/shared/types/licensing.ts @@ -1,4 +1,8 @@ -export type { ProFeature } from '../feature-registry' +import type { LicenseFeature } from '../feature-registry' + +export type { LicenseFeature, ProFeature } from '../feature-registry' + +export type LicenseEdition = 'pro' | 'business' export interface LicenseAssertion { type: 'zpan.license' @@ -6,7 +10,9 @@ export interface LicenseAssertion { subject: string accountId: string instanceId: string - edition: 'pro' + edition: LicenseEdition + features?: LicenseFeature[] + licenseId?: string authorizedHosts: string[] licenseValidUntil: number issuedAt: number @@ -18,9 +24,12 @@ export interface BindingState { bound: boolean active?: boolean account_email?: string - edition?: 'pro' + edition?: LicenseEdition + features?: LicenseFeature[] + license_id?: string license_valid_until?: number certificate_expires_at?: number last_refresh_at?: number last_refresh_error?: string + cloud_dashboard_url?: string } diff --git a/src/components/admin/admin-sidebar.tsx b/src/components/admin/admin-sidebar.tsx index 2d52f2af..4571414e 100644 --- a/src/components/admin/admin-sidebar.tsx +++ b/src/components/admin/admin-sidebar.tsx @@ -10,7 +10,6 @@ import { Megaphone, Settings, ShieldCheck, - ShoppingCart, Users, } from 'lucide-react' import { useTranslation } from 'react-i18next' @@ -37,7 +36,6 @@ const adminNavItems = [ { titleKey: 'admin.nav.auth', url: '/admin/settings/oauth', icon: KeyRound }, { titleKey: 'admin.nav.email', url: '/admin/settings/email', icon: Mail }, { titleKey: 'admin.nav.settings', url: '/admin/settings', icon: Settings }, - { titleKey: 'admin.nav.cloudStore', url: '/admin/cloud-store', icon: ShoppingCart }, { titleKey: 'admin.nav.announcement', url: '/admin/announcement', icon: Megaphone }, { titleKey: 'admin.nav.audit', url: '/admin/audit', icon: ShieldCheck }, { titleKey: 'admin.nav.licensing', url: '/admin/licensing', icon: BadgeCheck }, diff --git a/src/components/admin/cloud-gift-card-panel.tsx b/src/components/admin/cloud-gift-card-panel.tsx deleted file mode 100644 index e1839add..00000000 --- a/src/components/admin/cloud-gift-card-panel.tsx +++ /dev/null @@ -1,366 +0,0 @@ -import type { CreateGiftCardInput, GiftCardStatus } from '@shared/schemas' -import type { CloudGiftCard } from '@shared/types' -import { Ban, Plus, Trash2 } from 'lucide-react' -import { type ReactNode, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' - -export const emptyGiftCardForm = { - credits: '1000', - expiresAt: '', - count: '1', -} -export type GiftCardFormState = typeof emptyGiftCardForm - -export function giftCardInputFromForm(form: GiftCardFormState): CreateGiftCardInput { - const input: CreateGiftCardInput = { - credits: Math.round(Number(form.credits)), - count: Math.round(Number(form.count)), - } - if (form.expiresAt) input.expiresAt = new Date(form.expiresAt).toISOString() - return input -} - -export function StorageGiftCardPanel(props: CodeGenerateFormProps & CodeListProps) { - const { t } = useTranslation() - return ( -
- -
- - {props.codes.length === 0 && ( -
- {t('admin.cloudStore.codes.empty')} -
- )} -
-
- ) -} - -type CodeGenerateFormProps = { - form: GiftCardFormState - available: boolean - pending: boolean - onFormChange: (form: GiftCardFormState) => void - onGenerate: () => void -} - -function CodeToolbar(props: CodeGenerateFormProps & Pick) { - const { t } = useTranslation() - return ( -
- - - - -
- ) -} - -function CodeGenerateDialog({ - children, - ...props -}: CodeGenerateFormProps & { - children: ReactNode -}) { - const { t } = useTranslation() - const [open, setOpen] = useState(false) - return ( - - {children} - - - {t('admin.cloudStore.codes.generateTitle')} - {t('admin.cloudStore.codes.generateDescription')} - - { - props.onGenerate() - setOpen(false) - }} - /> - - - ) -} - -function CodeGenerateForm({ form, available, pending, onFormChange, onGenerate }: CodeGenerateFormProps) { - const { t } = useTranslation() - return ( -
- onFormChange({ ...form, credits })} - /> - onFormChange({ ...form, expiresAt })} /> - onFormChange({ ...form, count })} - /> - -
- ) -} - -function NumberField({ - id, - label, - value, - max, - onChange, -}: { - id: string - label: string - value: string - max?: string - onChange: (value: string) => void -}) { - return ( - - onChange(event.target.value)} /> - - ) -} - -function DateField({ value, onChange }: { value: string; onChange: (value: string) => void }) { - const { t } = useTranslation() - return ( - - onChange(event.target.value)} - /> - - ) -} - -type CodeListProps = { - codes: CloudGiftCard[] - status: GiftCardStatus | 'all' - available: boolean - disablingGiftCard: string | null - deletingGiftCard: string | null - onStatusChange: (status: GiftCardStatus | 'all') => void - onRevoke: (code: string) => void - onDelete: (code: string) => void -} - -function CodeStatusSelect({ status, onStatusChange }: Pick) { - const { t } = useTranslation() - return ( - - ) -} - -function CodeTable(props: CodeListProps) { - const { t } = useTranslation() - return ( -
- - - - - - - - - - - {t('admin.cloudStore.codes.code')} - {t('admin.cloudStore.codes.storage')} - {t('admin.cloudStore.codes.expires')} - {t('admin.cloudStore.codes.statusLabel')} - {t('common.actions')} - - - - {props.codes.map((code) => ( - - ))} - -
-
- ) -} - -function CodeRow({ - code, - available, - disablingGiftCard, - deletingGiftCard, - onRevoke, - onDelete, -}: CodeListProps & { code: CloudGiftCard }) { - const { t } = useTranslation() - const [disableConfirmOpen, setRevokeConfirmOpen] = useState(false) - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) - const actionTarget = code.code ?? code.id - const codeLabel = code.code ?? maskedGiftCardCode(code.codeLast4) - const canDisable = code.status === 'active' - const canDelete = code.status === 'active' - return ( - - - {codeLabel} - - {formatCredits(code.credits)} - - {code.expiresAt ? new Date(code.expiresAt).toLocaleString() : '-'} - - - - - -
- - -
- - - - {t('admin.cloudStore.codes.disableTitle')} - {t('admin.cloudStore.codes.disableConfirm', { code: codeLabel })} - - - - - - - - - - - {t('admin.cloudStore.codes.deleteTitle')} - {t('admin.cloudStore.codes.deleteConfirm', { code: codeLabel })} - - - - - - - -
-
- ) -} - -function formatCredits(credits: number) { - return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(credits) -} - -function CodeStatusBadge({ code }: { code: CloudGiftCard }) { - const { t } = useTranslation() - const status = getCodeStatus(code) - return ( - - {t(`admin.cloudStore.codes.status.${status}`)} - - ) -} - -function maskedGiftCardCode(last4: string) { - return `****-****-****-${last4}` -} - -function getCodeStatus(code: CloudGiftCard) { - return code.status -} - -function Field({ label, htmlFor, children }: { label: string; htmlFor?: string; children: ReactNode }) { - return ( -
- - {children} -
- ) -} diff --git a/src/components/admin/cloud-orders-table.tsx b/src/components/admin/cloud-orders-table.tsx deleted file mode 100644 index 3216e01c..00000000 --- a/src/components/admin/cloud-orders-table.tsx +++ /dev/null @@ -1,292 +0,0 @@ -import type { CloudOrder } from '@shared/types' -import { Eye } from 'lucide-react' -import type { ReactNode } from 'react' -import { useTranslation } from 'react-i18next' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet' -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' -import { - cloudOrderItemStorageBytes, - cloudOrderItemTrafficBytes, - cloudOrderStorageBytes, - cloudOrderTrafficBytes, -} from '@/lib/cloud-order' -import { formatMoney, formatSize } from '@/lib/format' - -export function StorageOrdersTable({ orders }: { orders: CloudOrder[] }) { - const { t } = useTranslation() - - if (orders.length === 0) { - return ( -
- {t('admin.cloudStore.orders.empty')} -
- ) - } - - return ( -
- - - - - - - - - - - - - {t('admin.cloudStore.orders.order')} - {t('admin.cloudStore.orders.status')} - {t('admin.cloudStore.orders.customer')} - {t('admin.cloudStore.orders.planQuota')} - {t('admin.cloudStore.orders.amount')} - {t('admin.cloudStore.orders.createdAt')} - {t('common.actions')} - - - - {orders.map((order) => ( - - -
- {formatOrderItems(order)} -
-
- {order.id} -
-
- - - - -
- {formatCustomer(order)} -
-
- {formatTargetValue(order, 'orgId')} -
-
- - {formatOrderQuota(order, t)} - - - {formatMoney(order.totalAmount, order.currency)} - - - {formatDateTime(order.createdAt)} - - - - -
- ))} -
-
-
- ) -} - -function OrderDetailsDrawer({ order }: { order: CloudOrder }) { - const { t } = useTranslation() - return ( - - - - - - - {t('admin.cloudStore.orders.detailTitle')} - {order.id} - -
- - - - - - - - - -
- {order.items.map((item) => ( -
-
-
-
- {item.name} -
-
- {item.description ?? item.productId} -
-
-
- {formatMoney(item.totalAmount, order.currency)} -
-
- -
- ))} -
-
- - - {order.payments && order.payments.length > 0 ? ( -
- {order.payments.map((payment) => ( -
-
- {payment.status} - {formatMoney(payment.amount, payment.currency)} -
- -
- ))} -
- ) : ( -
- {t('admin.cloudStore.orders.noPayments')} -
- )} -
- - - - -
-
-
- ) -} - -function OrderStatusBadges({ order }: { order: CloudOrder }) { - return ( -
- {order.paymentStatus} - {order.fulfillmentStatus} -
- ) -} - -function DetailSection({ title, children }: { title: string; children: ReactNode }) { - return ( -
-

{title}

- {children} -
- ) -} - -function DetailGrid({ items, className }: { items: [string, string][]; className?: string }) { - return ( -
- {items.map(([label, value]) => ( -
-
{label}
-
- {value} -
-
- ))} -
- ) -} - -function formatOrderQuota(order: CloudOrder, t: ReturnType['t']) { - return formatQuotaParts(cloudOrderStorageBytes(order), cloudOrderTrafficBytes(order), t) -} - -function formatItemQuota(item: CloudOrder['items'][number], t: ReturnType['t']) { - return formatQuotaParts(cloudOrderItemStorageBytes(item), cloudOrderItemTrafficBytes(item), t) -} - -function formatQuotaParts(storageBytes: number, trafficBytes: number, t: ReturnType['t']) { - const parts = [] - if (storageBytes > 0) parts.push(t('admin.cloudStore.orders.storageQuota', { size: formatSize(storageBytes) })) - if (trafficBytes > 0) parts.push(t('admin.cloudStore.orders.trafficQuota', { size: formatSize(trafficBytes) })) - return parts.length > 0 ? parts.join(' / ') : '-' -} - -function formatOrderItems(order: CloudOrder) { - if (order.items.length === 0) return order.id - if (order.items.length === 1) return order.items[0].name - return `${order.items[0].name} +${order.items.length - 1}` -} - -function formatCustomer(order: CloudOrder) { - return formatTargetValue(order, 'customerLabel') !== '-' - ? formatTargetValue(order, 'customerLabel') - : formatTargetValue(order, 'customerId') -} - -function formatTargetValue(order: CloudOrder, key: string) { - const value = order.target?.[key] - return typeof value === 'string' ? value : '-' -} - -function formatDateTime(value: string) { - return new Date(value).toLocaleString() -} - -function formatNullableDateTime(value: string | null) { - return value ? formatDateTime(value) : '-' -} diff --git a/src/components/admin/cloud-product-form.tsx b/src/components/admin/cloud-product-form.tsx deleted file mode 100644 index cbc72414..00000000 --- a/src/components/admin/cloud-product-form.tsx +++ /dev/null @@ -1,452 +0,0 @@ -import type { CloudProductInput } from '@shared/schemas' -import type { CloudProduct } from '@shared/types' -import { Plus } from 'lucide-react' -import type { ReactNode } from 'react' -import { useTranslation } from 'react-i18next' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Textarea } from '@/components/ui/textarea' -import { cloudProductIncludedCredits, cloudProductStorageBytes } from '@/lib/cloud-product' - -const units = { MB: 1024 * 1024, GB: 1024 * 1024 * 1024, TB: 1024 * 1024 * 1024 * 1024 } as const -type Unit = keyof typeof units - -export const emptyPackageForm = { - name: '', - description: '', - storageSize: '', - storageUnit: 'GB' as Unit, - includedCredits: '', - usdMonthlyAmount: '9.99', - usdYearlyAmount: '', - sortOrder: '0', -} - -export type PackageFormState = typeof emptyPackageForm - -export const emptyCreditPackageForm = { - name: '', - description: '', - credits: '1000', - usdAmount: '9.99', - sortOrder: '0', -} - -export type CreditPackageFormState = typeof emptyCreditPackageForm -type CloudProductPriceInput = CloudProductInput['prices'][number] - -export function packageInputFromForm(form: PackageFormState): CloudProductInput { - const prices = packagePricesFromForm(form) - return { - type: 'store_item', - name: form.name, - description: form.description, - metadata: { - deliverable: { - type: 'zpan.plan', - storageBytes: form.storageSize ? Math.round(Number(form.storageSize) * units[form.storageUnit]) : 0, - includedCredits: creditsFromForm(form), - }, - }, - prices, - active: true, - sortOrder: Math.round(Number(form.sortOrder)), - } -} - -export function packageFormFromPackage(pkg: CloudProduct): PackageFormState { - const storageBytes = cloudProductStorageBytes(pkg) - const storageDisplay = storageBytes > 0 ? bytesToDisplay(storageBytes) : null - return { - name: pkg.name, - description: pkg.description ?? '', - storageSize: storageDisplay ? String(storageDisplay.size) : '', - storageUnit: storageDisplay?.unit ?? 'GB', - includedCredits: String(cloudProductIncludedCredits(pkg) || ''), - usdMonthlyAmount: formatMinorAmount(recurringUsdPrice(pkg, 'month')?.amount), - usdYearlyAmount: formatMinorAmount(recurringUsdPrice(pkg, 'year')?.amount), - sortOrder: String(pkg.sortOrder), - } -} - -export function creditPackageInputFromForm(form: CreditPackageFormState): CloudProductInput { - const credits = creditsFromValue(form.credits) - const usdPrice: CloudProductPriceInput = { - currency: 'usd', - amount: convertCurrencyAmount(form.usdAmount), - metadata: { creditGrantType: 'top_up', creditAmount: String(credits) }, - } - return { - type: 'store_item', - name: form.name, - description: form.description, - metadata: { - deliverable: { - type: 'zpan.credits', - includedCredits: credits, - }, - }, - prices: [usdPrice].filter((price) => Number.isFinite(price.amount) && price.amount > 0), - active: true, - sortOrder: Math.round(Number(form.sortOrder)), - } -} - -export function creditPackageFormFromPackage(pkg: CloudProduct): CreditPackageFormState { - return { - name: pkg.name, - description: pkg.description ?? '', - credits: String(cloudProductIncludedCredits(pkg) || ''), - usdAmount: formatMinorAmount(oneTimeUsdPrice(pkg)?.amount), - sortOrder: String(pkg.sortOrder), - } -} - -export function CreditPackageForm({ - editing, - form, - available, - pending, - onFormChange, - onCancel, - onSubmit, -}: { - editing: CloudProduct | null - form: CreditPackageFormState - available: boolean - pending: boolean - onFormChange: (form: CreditPackageFormState) => void - onCancel: () => void - onSubmit: () => void -}) { - const { t } = useTranslation() - const creditsValid = creditsFromValue(form.credits) > 0 - const priceValid = convertCurrencyAmount(form.usdAmount) > 0 - - return ( -
- - onFormChange({ ...form, credits })} - /> - onFormChange({ ...form, usdAmount })} - /> - -
- ) -} - -export function StoragePlanForm({ - editing, - form, - available, - pending, - onFormChange, - onCancel, - onSubmit, -}: { - editing: CloudProduct | null - form: PackageFormState - available: boolean - pending: boolean - onFormChange: (form: PackageFormState) => void - onCancel: () => void - onSubmit: () => void -}) { - const { t } = useTranslation() - - const storageBytes = form.storageSize ? Math.round(Number(form.storageSize) * units[form.storageUnit]) : 0 - const quotaValid = storageBytes > 0 - const pricesValid = packagePriceInputsValid(form) - - return ( -
- - onFormChange({ ...form, storageSize })} - onUnitChange={(storageUnit) => onFormChange({ ...form, storageUnit })} - /> - onFormChange({ ...form, includedCredits })} - /> - {!quotaValid && form.storageSize !== '' && ( -

{t('admin.cloudStore.quotaRequired')}

- )} - - -
- ) -} - -function packagePricesFromForm(form: PackageFormState) { - return packagePricesForForm(form).filter((price) => Number.isFinite(price.amount) && price.amount > 0) -} - -function creditsFromForm(form: PackageFormState) { - return creditsFromValue(form.includedCredits) -} - -function creditsFromValue(value: string) { - const credits = Number(value) - return Number.isSafeInteger(credits) && credits > 0 ? credits : 0 -} - -function packagePriceInputsValid(form: PackageFormState) { - return convertCurrencyAmount(form.usdMonthlyAmount) > 0 || convertCurrencyAmount(form.usdYearlyAmount) > 0 -} - -function packagePricesForForm(form: PackageFormState) { - const credits = creditsFromForm(form) - const metadata = credits > 0 ? { creditGrantType: 'subscription_grant', creditAmount: String(credits) } : undefined - return [ - { - currency: 'usd' as const, - amount: convertCurrencyAmount(form.usdMonthlyAmount), - recurring: { interval: 'month' as const, intervalCount: 1 }, - ...(metadata ? { metadata } : {}), - }, - { - currency: 'usd' as const, - amount: convertCurrencyAmount(form.usdYearlyAmount), - recurring: { interval: 'year' as const, intervalCount: 1 }, - ...(metadata ? { metadata } : {}), - }, - ] -} - -function convertCurrencyAmount(amount: string): number { - return Math.round(Number(amount) * 100) -} - -function formatMinorAmount(minorAmount: number | undefined): string { - return minorAmount === undefined ? '' : (minorAmount / 100).toString() -} - -function recurringUsdPrice(pkg: CloudProduct, interval: 'month' | 'year') { - return pkg.prices.find( - (price) => - price.currency === 'usd' && - price.recurring?.interval === interval && - price.recurring.intervalCount === 1 && - price.recurring.usageType !== 'metered', - ) -} - -function oneTimeUsdPrice(pkg: CloudProduct) { - return pkg.prices.find((price) => price.currency === 'usd' && !price.recurring) -} - -function Field({ label, htmlFor, children }: { label: string; htmlFor?: string; children: ReactNode }) { - return ( -
- - {children} -
- ) -} - -function PackageIdentityFields({ - form, - onFormChange, -}: { - form: TForm - onFormChange: (form: TForm) => void -}) { - const { t } = useTranslation() - return ( - <> - - onFormChange({ ...form, name: e.target.value })} /> - - -