diff --git a/server/routes/quota-store.integration.test.ts b/server/routes/quota-store.integration.test.ts index 9e380a44..8b6870b7 100644 --- a/server/routes/quota-store.integration.test.ts +++ b/server/routes/quota-store.integration.test.ts @@ -80,7 +80,7 @@ describe('Quota Store API', () => { await expect(filled.json()).resolves.toMatchObject({ enabled: true, cloudBaseUrl: 'https://cloud.example', - publicInstanceUrl: 'https://zpan.example', + publicInstanceUrl: 'https://zpan.example//', webhookSigningSecretSet: true, }) }) @@ -402,6 +402,8 @@ describe('Quota Store API', () => { amount: 500, currency: 'usd', bytes: 4096, + successUrl: 'https://zpan.example/store', + cancelUrl: 'https://zpan.example/store', }) expect(redemptionBody.code).toBe('CODE-OK') await expect(decodeSession(redemptionBody.session)).resolves.toMatchObject({ @@ -412,6 +414,92 @@ describe('Quota Store API', () => { await expect(grants.json()).resolves.toMatchObject({ total: 1, items: [{ orgId, bytes: 512 }] }) }) + 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/quota-store/settings', { + method: 'PUT', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: false, + cloudBaseUrl: 'https://cloud.example', + publicInstanceUrl: 'https://zpan.example/', + webhookSigningSecret: SECRET, + }), + }) + + const orgId = await getFirstOrgId(db) + const packageId = await seedPackage(db) + const packages = await app.request('/api/quota-store/packages', { headers }) + const targets = await app.request('/api/quota-store/targets', { headers }) + const checkout = await app.request('/api/quota-store/checkout', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ packageId, targetOrgId: orgId }), + }) + const redemption = await app.request('/api/quota-store/redemptions', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: 'CODE-OK', targetOrgId: orgId }), + }) + const grants = await app.request('/api/quota-store/grants', { 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(redemption.status).toBe(403) + await expect(redemption.json()).resolves.toEqual({ error: 'quota_store_disabled' }) + expect(grants.status).toBe(403) + await expect(grants.json()).resolves.toEqual({ error: 'quota_store_disabled' }) + }) + + it('hides self-service store endpoints until webhook signing is configured', async () => { + const { app, db } = await createTestApp() + await seedProLicense(db) + const headers = await authedHeaders(app, 'buyer@example.com') + await app.request('/api/admin/quota-store/settings', { + method: 'PUT', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: true, + cloudBaseUrl: 'https://cloud.example', + publicInstanceUrl: 'https://zpan.example', + }), + }) + + const orgId = await getFirstOrgId(db) + const packageId = await seedPackage(db) + const packages = await app.request('/api/quota-store/packages', { headers }) + const targets = await app.request('/api/quota-store/targets', { headers }) + const checkout = await app.request('/api/quota-store/checkout', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ packageId, targetOrgId: orgId }), + }) + const redemption = await app.request('/api/quota-store/redemptions', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: 'CODE-OK', targetOrgId: orgId }), + }) + const grants = await app.request('/api/quota-store/grants', { headers }) + + expect(packages.status).toBe(403) + await expect(packages.json()).resolves.toEqual({ error: 'quota_store_webhook_secret_missing' }) + expect(targets.status).toBe(403) + await expect(targets.json()).resolves.toEqual({ error: 'quota_store_webhook_secret_missing' }) + expect(checkout.status).toBe(403) + await expect(checkout.json()).resolves.toEqual({ error: 'quota_store_webhook_secret_missing' }) + expect(redemption.status).toBe(403) + await expect(redemption.json()).resolves.toEqual({ error: 'quota_store_webhook_secret_missing' }) + expect(grants.status).toBe(403) + await expect(grants.json()).resolves.toEqual({ error: 'quota_store_webhook_secret_missing' }) + }) + it('rejects malformed successful checkout responses', async () => { const { app, db } = await createTestApp() await seedProLicense(db) @@ -489,7 +577,7 @@ describe('Quota Store API', () => { eventId: 'evt-1', cloudOrderId: 'order-1', targetOrgId: orgId, - packageId, + packageId: 'cloud-pkg-1', source: 'stripe', bytes: 4096, }) @@ -570,7 +658,7 @@ describe('Quota Store API', () => { eventId: 'evt-invalid-package', cloudOrderId: 'order-invalid-package', targetOrgId: orgId, - packageId, + packageId: 'cloud-pkg-1', source: 'stripe', bytes: 8192, }) @@ -594,6 +682,72 @@ describe('Quota Store API', () => { await expect(retry.json()).resolves.toMatchObject({ success: true, duplicate: false }) }) + it('rejects ambiguous delivery package identifiers', async () => { + const { app, db } = await createTestApp() + await seedProLicense(db) + const headers = await adminHeaders(app) + await seedSettings(app, headers) + const orgId = await getFirstOrgId(db) + const now = Date.now() + await db.run(sql` + INSERT INTO quota_store_packages + (id, name, description, bytes, amount, currency, active, sort_order, cloud_package_id, sync_status, created_at, updated_at) + VALUES + ('pkg-cloud-a', 'Cloud package A', '', 4096, 500, 'usd', 1, 1, 'cloud-pkg-ambiguous', 'synced', ${now}, ${now}), + ('pkg-cloud-b', 'Cloud package B', '', 4096, 500, 'usd', 1, 2, 'cloud-pkg-ambiguous', 'synced', ${now}, ${now}) + `) + + const res = await postWebhook( + app, + JSON.stringify({ + eventId: 'evt-ambiguous-package', + cloudOrderId: 'order-ambiguous-package', + targetOrgId: orgId, + packageId: 'cloud-pkg-ambiguous', + source: 'stripe', + bytes: 4096, + }), + ) + + expect(res.status).toBe(400) + await expect(res.json()).resolves.toEqual({ error: 'invalid_package_delivery' }) + }) + + it('uses exact local package identifiers before Cloud package identifiers', async () => { + const { app, db } = await createTestApp() + await seedProLicense(db) + const headers = await adminHeaders(app) + await seedSettings(app, headers) + const orgId = await getFirstOrgId(db) + const now = Date.now() + await db.run(sql` + INSERT INTO quota_store_packages + (id, name, description, bytes, amount, currency, active, sort_order, cloud_package_id, sync_status, created_at, updated_at) + VALUES + ('pkg-colliding-id', 'Local package', '', 4096, 500, 'usd', 1, 1, 'cloud-local', 'synced', ${now}, ${now}), + ('pkg-cloud-owner', 'Cloud package', '', 4096, 500, 'usd', 1, 2, 'pkg-colliding-id', 'synced', ${now}, ${now}) + `) + + const res = await postWebhook( + app, + JSON.stringify({ + eventId: 'evt-package-id-collision', + cloudOrderId: 'order-package-id-collision', + targetOrgId: orgId, + packageId: 'pkg-colliding-id', + source: 'stripe', + bytes: 4096, + }), + ) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toMatchObject({ success: true, duplicate: false }) + const grants = await db.all<{ count: number }>( + sql`SELECT COUNT(*) AS count FROM quota_grants WHERE package_snapshot LIKE '%pkg-colliding-id%'`, + ) + expect(grants[0].count).toBe(1) + }) + it('marks delivery events failed when grant insertion fails', async () => { const { app, db } = await createTestApp() await seedProLicense(db) @@ -797,7 +951,7 @@ async function seedSettings(app: Awaited>['app' body: JSON.stringify({ enabled: true, cloudBaseUrl: 'https://cloud.example', - publicInstanceUrl: 'https://zpan.example', + publicInstanceUrl: 'https://zpan.example//', webhookSigningSecret: SECRET, }), }) @@ -813,8 +967,8 @@ async function seedPackage(db: Awaited>['db']): const now = Date.now() await db.run(sql` INSERT INTO quota_store_packages - (id, name, description, bytes, amount, currency, active, sort_order, sync_status, created_at, updated_at) - VALUES (${id}, 'Small', '', 4096, 500, 'usd', 1, 0, 'synced', ${now}, ${now}) + (id, name, description, bytes, amount, currency, active, sort_order, cloud_package_id, sync_status, created_at, updated_at) + VALUES (${id}, 'Small', '', 4096, 500, 'usd', 1, 0, 'cloud-pkg-1', 'synced', ${now}, ${now}) `) return id } diff --git a/server/routes/quota-store.ts b/server/routes/quota-store.ts index 3e687980..d04c451b 100644 --- a/server/routes/quota-store.ts +++ b/server/routes/quota-store.ts @@ -85,11 +85,17 @@ const quotaStore = new Hono() .use(requireAuth) .use(requireFeature('quota_store')) .get('/packages', async (c) => { - const items = await listQuotaStorePackages(c.get('platform').db, true) + const db = c.get('platform').db + const store = await getUserStoreSettings(db) + if ('error' in store) return c.json({ error: store.error }, 403) + const items = await listQuotaStorePackages(db, true) return c.json({ items, total: items.length }) }) .get('/targets', async (c) => { - const items = await getAccessibleTargets(c.get('platform').db, c.get('userId')!) + const db = c.get('platform').db + const store = await getUserStoreSettings(db) + if ('error' in store) return c.json({ error: store.error }, 403) + const items = await getAccessibleTargets(db, c.get('userId')!) return c.json({ items, total: items.length }) }) .post('/checkout', zValidator('json', checkoutInputSchema), async (c) => { @@ -99,7 +105,9 @@ const quotaStore = new Hono() return c.json({ error: 'Forbidden' }, 403) } - const settings = await getRequiredSettings(db) + const store = await getUserStoreSettings(db) + if ('error' in store) return c.json({ error: store.error }, 403) + const settings = store.settings const pkg = await getActiveQuotaStorePackage(db, body.packageId) if (!pkg) return c.json({ error: 'Package not found' }, 404) const result = await postUserCloud( @@ -118,7 +126,9 @@ const quotaStore = new Hono() return c.json({ error: 'Forbidden' }, 403) } - const settings = await getRequiredSettings(db) + const store = await getUserStoreSettings(db) + if ('error' in store) return c.json({ error: store.error }, 403) + const settings = store.settings const result = await postUserCloud( settings, '/api/store/redemptions', @@ -132,7 +142,10 @@ const quotaStore = new Hono() return c.json(result) }) .get('/grants', async (c) => { - const items = await listGrantsForUser(c.get('platform').db, c.get('userId')!) + const db = c.get('platform').db + const store = await getUserStoreSettings(db) + if ('error' in store) return c.json({ error: store.error }, 403) + const items = await listGrantsForUser(db, c.get('userId')!) return c.json({ items, total: items.length }) }) @@ -162,6 +175,18 @@ const quotaStoreWebhooks = new Hono().use(requireFeature('quota_store')).po export { adminQuotaStore, quotaStore, quotaStoreWebhooks } +async function getUserStoreSettings(db: Parameters[0]) { + try { + return { settings: await getRequiredSettings(db) } + } catch (error) { + const message = (error as Error).message + if (message === 'quota_store_disabled' || message === 'quota_store_webhook_secret_missing') { + return { error: message } + } + throw error + } +} + async function syncPackages(db: Parameters[0], packageId: string) { try { const result = await syncCatalog(db) @@ -183,7 +208,7 @@ async function syncCatalog(db: Parameters[0], excludin binding.sharedSecret, { boundLicenseId: binding.boundLicenseId, - callbackUrl: `${settings.publicInstanceUrl}/api/quota-store/webhooks/cloud`, + callbackUrl: `${publicInstanceUrl(settings)}/api/quota-store/webhooks/cloud`, packages: packages.map(cloudPackagePayload), }, cloudPackageSyncResponseSchema, @@ -309,8 +334,8 @@ async function createCheckoutSession( amount: pkg.amount, currency: pkg.currency, bytes: pkg.bytes, - successUrl: `${settings.publicInstanceUrl}/quota-store/checkout/success`, - cancelUrl: `${settings.publicInstanceUrl}/quota-store/checkout/cancel`, + successUrl: `${publicInstanceUrl(settings)}/store`, + cancelUrl: `${publicInstanceUrl(settings)}/store`, expiresAt: sessionExpiry(), }, binding.sharedSecret, @@ -344,6 +369,10 @@ function sessionExpiry(): string { return new Date(Date.now() + 15 * 60 * 1000).toISOString() } +function publicInstanceUrl(settings: Awaited>): string { + return settings.publicInstanceUrl.replace(/\/+$/, '') +} + function base64Url(value: string): string { const bytes = new TextEncoder().encode(value) let binary = '' diff --git a/server/services/quota-store.ts b/server/services/quota-store.ts index 19268dbd..1e12d78b 100644 --- a/server/services/quota-store.ts +++ b/server/services/quota-store.ts @@ -1,6 +1,6 @@ import type { CloudDeliveryEvent, QuotaStorePackageInput, QuotaStoreSettingsInput } from '@shared/schemas' import type { QuotaGrant, QuotaStorePackage, QuotaStoreSettings, QuotaTarget } from '@shared/types' -import { and, eq, inArray, sql } from 'drizzle-orm' +import { and, eq, inArray, isNotNull, sql } from 'drizzle-orm' import { nanoid } from 'nanoid' import { member, organization, user } from '../db/auth-schema' import { quotaDeliveryEvents, quotaGrants, quotaStorePackages, quotaStoreSettings } from '../db/schema' @@ -44,13 +44,21 @@ export async function upsertQuotaStoreSettings( export async function listQuotaStorePackages(db: Database, activeOnly = false): Promise { const query = db.select().from(quotaStorePackages) const rows = activeOnly - ? await query - .where(eq(quotaStorePackages.active, true)) - .orderBy(quotaStorePackages.sortOrder, quotaStorePackages.name) + ? await query.where(purchasablePackageCondition()).orderBy(quotaStorePackages.sortOrder, quotaStorePackages.name) : await query.orderBy(quotaStorePackages.sortOrder, quotaStorePackages.name) return rows.map(packageDto) } +function purchasablePackageCondition(packageId?: string) { + const conditions = [ + eq(quotaStorePackages.active, true), + eq(quotaStorePackages.syncStatus, 'synced'), + isNotNull(quotaStorePackages.cloudPackageId), + ] + if (packageId) conditions.push(eq(quotaStorePackages.id, packageId)) + return and(...conditions) +} + export async function createQuotaStorePackage(db: Database, input: QuotaStorePackageInput): Promise { const now = new Date() const row = { @@ -94,11 +102,7 @@ export async function getQuotaStorePackage(db: Database, id: string): Promise { - const rows = await db - .select() - .from(quotaStorePackages) - .where(and(eq(quotaStorePackages.id, id), eq(quotaStorePackages.active, true))) - .limit(1) + const rows = await db.select().from(quotaStorePackages).where(purchasablePackageCondition(id)).limit(1) return rows[0] ? packageDto(rows[0]) : null } @@ -222,14 +226,26 @@ async function getRawSettings(db: Database) { async function validatePackageBytes(db: Database, event: CloudDeliveryEvent): Promise { if (event.source === 'stripe' && !event.packageId) throw new Error('package_required') if (!event.packageId) return null - const rows = await db.select().from(quotaStorePackages).where(eq(quotaStorePackages.id, event.packageId)).limit(1) - const pkg = rows[0] + const pkg = await findDeliveryPackage(db, event.packageId) if (!pkg || pkg.bytes !== event.bytes || (event.package && event.package.bytes !== pkg.bytes)) { throw new Error('invalid_package_delivery') } return JSON.stringify(event.package ?? packageDto(pkg)) } +async function findDeliveryPackage(db: Database, packageId: string) { + const localRows = await db.select().from(quotaStorePackages).where(eq(quotaStorePackages.id, packageId)).limit(1) + if (localRows[0]) return localRows[0] + + const cloudRows = await db + .select() + .from(quotaStorePackages) + .where(eq(quotaStorePackages.cloudPackageId, packageId)) + .limit(2) + if (cloudRows.length !== 1) return null + return cloudRows[0] +} + async function validateDeliveryPackage( db: Database, eventId: string, diff --git a/src/components/UpgradeHint.tsx b/src/components/UpgradeHint.tsx index ed09030a..de29cace 100644 --- a/src/components/UpgradeHint.tsx +++ b/src/components/UpgradeHint.tsx @@ -8,8 +8,8 @@ const FEATURE_LABELS: Record = { open_registration: 'open registration', teams_unlimited: 'unlimited teams', storages_unlimited: 'unlimited storages', - quota_store: 'quota store', audit_log: 'audit logs', + quota_store: 'storage quota store', } interface UpgradeHintProps { diff --git a/src/components/admin/admin-sidebar.tsx b/src/components/admin/admin-sidebar.tsx index 8f907e62..1e206446 100644 --- a/src/components/admin/admin-sidebar.tsx +++ b/src/components/admin/admin-sidebar.tsx @@ -9,6 +9,7 @@ import { Megaphone, Settings, ShieldCheck, + ShoppingCart, Users, } from 'lucide-react' import { useTranslation } from 'react-i18next' @@ -33,6 +34,7 @@ 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.quotaStore', url: '/admin/quota-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/quota-store-package-form.tsx b/src/components/admin/quota-store-package-form.tsx new file mode 100644 index 00000000..50b28021 --- /dev/null +++ b/src/components/admin/quota-store-package-form.tsx @@ -0,0 +1,187 @@ +import type { QuotaStorePackageInput } from '@shared/schemas' +import type { QuotaStorePackage } 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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' + +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: '', + size: '100', + unit: 'GB' as Unit, + amount: '999', + currency: 'usd' as 'usd' | 'cny', + active: true, + sortOrder: '0', +} + +export type PackageFormState = typeof emptyPackageForm + +export function packageInputFromForm(form: PackageFormState): QuotaStorePackageInput { + return { + name: form.name, + description: form.description, + bytes: Math.round(Number(form.size) * units[form.unit]), + amount: Math.round(Number(form.amount)), + currency: form.currency, + active: form.active, + sortOrder: Math.round(Number(form.sortOrder)), + } +} + +export function packageFormFromPackage(pkg: QuotaStorePackage): PackageFormState { + const display = bytesToDisplay(pkg.bytes) + return { + name: pkg.name, + description: pkg.description, + size: String(display.size), + unit: display.unit, + amount: String(pkg.amount), + currency: pkg.currency === 'cny' ? 'cny' : 'usd', + active: pkg.active, + sortOrder: String(pkg.sortOrder), + } +} + +export function QuotaStorePackageForm({ + editing, + form, + available, + pending, + onFormChange, + onCancel, + onSubmit, +}: { + editing: QuotaStorePackage | null + form: PackageFormState + available: boolean + pending: boolean + onFormChange: (form: PackageFormState) => void + onCancel: () => void + onSubmit: () => void +}) { + const { t } = useTranslation() + + return ( + + + {editing ? t('admin.quotaStore.editPackage') : t('admin.quotaStore.newPackage')} + + + + onFormChange({ ...form, name: e.target.value })} /> + + +