From 58d94c3df6f2489d710db979bbfd3050e643b72c Mon Sep 17 00:00:00 2001 From: saltbo Date: Sat, 9 May 2026 00:07:37 -0400 Subject: [PATCH] feat(cloud-store): accept opaque store deliveries --- server/licensing/cloud-event-token.ts | 11 ++- server/routes/cloud-store-helpers.test.ts | 4 +- server/routes/cloud-store-helpers.ts | 33 ++++++- server/routes/cloud-store.integration.test.ts | 14 +-- server/routes/cloud-store/admin.ts | 44 +++++++++- shared/schemas/cloud-store.ts | 85 ++++++++++++++++++- 6 files changed, 171 insertions(+), 20 deletions(-) diff --git a/server/licensing/cloud-event-token.ts b/server/licensing/cloud-event-token.ts index d6685fd1..85aaf440 100644 --- a/server/licensing/cloud-event-token.ts +++ b/server/licensing/cloud-event-token.ts @@ -7,12 +7,15 @@ const CLOUD_EVENT_TOKEN_MAX_TTL_SECONDS = 5 * 60 const cloudEventTokenSchema = z.object({ type: z.literal('zpan.cloud.event'), - purpose: z.literal('quota_store.delivery'), + purpose: z.enum(['quota_store.delivery', 'store.delivery']), issuer: z.string().min(1), audience: z.string().min(1), boundLicenseId: z.string().min(1), eventId: z.string().min(1), - payloadHash: z.string().regex(/^[0-9a-f]{64}$/i), + payloadHash: z + .string() + .regex(/^[0-9a-f]{64}$/i) + .optional(), issuedAt: z.number().int(), notBefore: z.number().int().optional(), expiresAt: z.number().int(), @@ -48,9 +51,9 @@ function tryVerifyCloudEventToken( const event = parsed.data const now = Math.floor(Date.now() / 1000) if (event.issuer !== trustedIssuerFromCloudUrl(options.cloudBaseUrl)) return null - if (event.audience !== options.instanceId) return null + if (event.audience !== options.instanceId && event.audience !== options.boundLicenseId) return null if (event.boundLicenseId !== options.boundLicenseId) return null - if (event.payloadHash !== options.payloadHash) return null + if (event.payloadHash && event.payloadHash !== options.payloadHash) return null if (event.issuedAt > now) return null if (event.notBefore && event.notBefore > now) return null if (event.expiresAt <= now) return null diff --git a/server/routes/cloud-store-helpers.test.ts b/server/routes/cloud-store-helpers.test.ts index 290710d7..aaf70d77 100644 --- a/server/routes/cloud-store-helpers.test.ts +++ b/server/routes/cloud-store-helpers.test.ts @@ -14,9 +14,9 @@ describe('quota store helper paths', () => { it('builds gift card and order Cloud paths', () => { expect(giftCardsPath()('store-1')).toBe('/api/stores/store-1/gift-cards') expect(giftCardsPath('active')('store-1')).toBe('/api/stores/store-1/gift-cards?status=active') - expect(packagesPath()('store-1')).toBe('/api/stores/store-1/products?type=zpan_quota&limit=100') + expect(packagesPath()('store-1')).toBe('/api/stores/store-1/products?type=store_item&limit=100') expect(packagesPath({ status: 'active' })('store-1')).toBe( - '/api/stores/store-1/products?type=zpan_quota&limit=100&status=active', + '/api/stores/store-1/products?type=store_item&limit=100&status=active', ) expect(ordersPath()('store-1')).toBe('/api/stores/store-1/orders') expect(ordersPath({ limit: 100 })('store-1')).toBe('/api/stores/store-1/orders?limit=100') diff --git a/server/routes/cloud-store-helpers.ts b/server/routes/cloud-store-helpers.ts index cced5d5a..c8937b02 100644 --- a/server/routes/cloud-store-helpers.ts +++ b/server/routes/cloud-store-helpers.ts @@ -25,7 +25,7 @@ const cloudPackagePriceSchema = z.object({ amount: cloudPackageAmountSchema, }) -const cloudPackageSchema = z +const legacyCloudPackageSchema = z .object({ id: z.string().min(1), type: z.literal('zpan_quota'), @@ -50,9 +50,34 @@ const cloudPackageSchema = z }) } }) -export const cloudPackageResponseSchema = cloudPackageSchema +const storeItemPackageSchema = z + .object({ + id: z.string().min(1), + type: z.literal('store_item'), + name: z.string().min(1), + description: z.string().nullable(), + metadata: z.object({ + deliverable: z.record(z.string(), z.unknown()), + }), + prices: z.array(cloudPackagePriceSchema).min(1), + active: z.boolean(), + sortOrder: z.number().int(), + createdAt: z.string().min(1), + updatedAt: z.string().min(1), + }) + .transform((pkg) => ({ + ...pkg, + type: 'zpan_quota' as const, + metadata: { + storageBytes: + typeof pkg.metadata.deliverable.storageBytes === 'number' ? pkg.metadata.deliverable.storageBytes : 0, + trafficBytes: + typeof pkg.metadata.deliverable.trafficBytes === 'number' ? pkg.metadata.deliverable.trafficBytes : 0, + }, + })) +export const cloudPackageResponseSchema = z.union([legacyCloudPackageSchema, storeItemPackageSchema]) export const cloudPackageListResponseSchema = z.object({ - items: z.array(cloudPackageSchema), + items: z.array(cloudPackageResponseSchema), total: z.number().int().min(0), }) const cloudOrderSchema = z.object({ @@ -230,7 +255,7 @@ export function packagesPath(options: { packageId?: string; status?: 'active' | return (storeId: string) => { const path = `/api/stores/${encodeURIComponent(storeId)}/products` if (options.packageId) return `${path}/${encodeURIComponent(options.packageId)}` - const search = new URLSearchParams({ type: 'zpan_quota', limit: '100' }) + const search = new URLSearchParams({ type: 'store_item', limit: '100' }) if (options.status) search.set('status', options.status) return `${path}?${search.toString()}` } diff --git a/server/routes/cloud-store.integration.test.ts b/server/routes/cloud-store.integration.test.ts index 6d46f1b2..fb8ebcbe 100644 --- a/server/routes/cloud-store.integration.test.ts +++ b/server/routes/cloud-store.integration.test.ts @@ -576,13 +576,13 @@ describe('Quota Store API', () => { 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?type=zpan_quota&limit=100`) + expect(String(url)).toBe(`${ZPAN_CLOUD_URL_DEFAULT}${INSTANCE_STORE_PATH}/products?type=store_item&limit=100`) expect((init.headers as Record).Authorization).toBe(`Bearer ${REFRESH_TOKEN}`) expect(JSON.parse(body)).toMatchObject({ name: 'Small', description: 'starter', - metadata: { storageBytes: 4096, trafficBytes: 0 }, - type: 'zpan_quota', + metadata: { deliverable: { type: 'zpan.extra', packageName: 'Small', storageBytes: 4096, trafficBytes: 0 } }, + type: 'store_item', prices: [{ currency: 'usd', amount: 500 }], }) expect(JSON.parse(body)).not.toHaveProperty('callbackUrl') @@ -656,8 +656,10 @@ describe('Quota Store API', () => { expect(JSON.parse(updateInit.body as string)).toEqual({ name: 'Updated', description: '', - type: 'zpan_quota', - metadata: { storageBytes: 0, trafficBytes: 8192 }, + type: 'store_item', + metadata: { + deliverable: { type: 'zpan.extra', packageName: 'Updated', storageBytes: 0, trafficBytes: 8192 }, + }, prices: [{ currency: 'cny', amount: 900 }], active: true, sortOrder: 0, @@ -2660,7 +2662,7 @@ describe('Quota Store API', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - ...(await signedWebhookHeaders(payload, { audience: 'test-binding' })), + ...(await signedWebhookHeaders(payload, { audience: 'wrong-audience' })), }, body: payload, }) diff --git a/server/routes/cloud-store/admin.ts b/server/routes/cloud-store/admin.ts index 506286eb..d419b32f 100644 --- a/server/routes/cloud-store/admin.ts +++ b/server/routes/cloud-store/admin.ts @@ -28,6 +28,39 @@ import { } from '../cloud-store-helpers' import { getCloudOrders } from './shared' +function cloudProductPayload(input: ReturnType) { + return { + ...input, + type: 'store_item', + metadata: { + deliverable: { + type: 'zpan.extra', + packageName: input.name, + storageBytes: input.metadata.storageBytes, + trafficBytes: input.metadata.trafficBytes, + }, + }, + } +} + +function cloudProductPatchPayload(input: ReturnType) { + if (!input.metadata && !input.name && input.type === undefined) return input + return { + ...input, + type: 'store_item', + metadata: input.metadata + ? { + deliverable: { + type: 'zpan.extra', + packageName: input.name, + storageBytes: input.metadata.storageBytes, + trafficBytes: input.metadata.trafficBytes, + }, + } + : undefined, + } +} + export const adminCloudStore = new Hono() .use(requireAdmin) .use(requireFeature('quota_store')) @@ -45,7 +78,12 @@ export const adminCloudStore = new Hono() return c.json(result) }) .post('/packages', zValidator('json', cloudProductInputSchema), async (c) => { - const result = await postCloudWithBinding(c, packagesPath(), c.req.valid('json'), cloudPackageResponseSchema) + const result = await postCloudWithBinding( + c, + packagesPath(), + cloudProductPayload(c.req.valid('json')), + cloudPackageResponseSchema, + ) if ('error' in result) return c.json(result, 502) return c.json(result, 201) }) @@ -58,7 +96,7 @@ export const adminCloudStore = new Hono() const result = await patchCloudWithBinding( c, packagesPath({ packageId: c.req.param('id') }), - c.req.valid('json'), + cloudProductPatchPayload(c.req.valid('json')), cloudPackageResponseSchema, ) if ('error' in result) return c.json(result, 502) @@ -68,7 +106,7 @@ export const adminCloudStore = new Hono() const result = await patchCloudWithBinding( c, packagesPath({ packageId: c.req.param('id') }), - c.req.valid('json'), + cloudProductPayload(c.req.valid('json')), cloudPackageResponseSchema, ) if ('error' in result) return c.json(result, 502) diff --git a/shared/schemas/cloud-store.ts b/shared/schemas/cloud-store.ts index a7cf4387..f736c22b 100644 --- a/shared/schemas/cloud-store.ts +++ b/shared/schemas/cloud-store.ts @@ -66,7 +66,7 @@ export const disableGiftCardSchema = z.object({ disabled: z.literal(true), }) -export const cloudOrderQuotaChangeSchema = z +const legacyCloudOrderQuotaChangeSchema = z .object({ eventId: z.string().min(1), eventType: z.literal('order.quota_changed'), @@ -94,6 +94,89 @@ export const cloudOrderQuotaChangeSchema = z } }) +const storeDeliveryEventSchema = z.object({ + eventId: z.string().min(1), + eventType: z.enum([ + 'store.order_item.fulfilled', + 'store.subscription.renewed', + 'store.subscription.updated', + 'store.subscription.canceled', + 'store.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', 'wallet']).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().datetime(), +}) + +function numberDeliverableValue(deliverable: Record, key: string) { + const value = deliverable[key] + return typeof value === 'number' ? value : 0 +} + +function stringDeliverableValue(deliverable: Record, key: string) { + const value = deliverable[key] + return typeof value === 'string' ? value : undefined +} + +function targetOrgId(target: Record | null) { + return typeof target?.orgId === 'string' ? target.orgId : '' +} + +function sourceId(event: z.infer) { + const orgId = targetOrgId(event.target) + if (event.context.stripeSubscriptionId) return `stripe_subscription:${event.context.stripeSubscriptionId}:${orgId}` + return event.orderId +} + +function expiresAt(event: z.infer) { + if (event.context.billingPeriodEnd) return event.context.billingPeriodEnd + const validityDays = numberDeliverableValue(event.deliverable, 'validityDays') + if (validityDays <= 0) return undefined + return new Date(new Date(event.occurredAt).getTime() + validityDays * 86_400_000).toISOString() +} + +export const cloudOrderQuotaChangeSchema = z.union([ + legacyCloudOrderQuotaChangeSchema, + storeDeliveryEventSchema + .transform((event) => ({ + eventId: event.eventId, + eventType: 'order.quota_changed' as const, + cloudOrderId: sourceId(event), + targetOrgId: targetOrgId(event.target), + direction: + event.eventType === 'store.subscription.canceled' || event.eventType === 'store.subscription.expired' + ? ('decrease' as const) + : ('increase' as const), + storageBytes: numberDeliverableValue(event.deliverable, 'storageBytes'), + trafficBytes: numberDeliverableValue(event.deliverable, 'trafficBytes'), + source: event.context.stripeSubscriptionId ? 'stripe_subscription' : 'stripe', + packageId: event.productId, + packageName: stringDeliverableValue(event.deliverable, 'packageName') ?? event.productName, + occurredAt: event.occurredAt, + expiresAt: expiresAt(event), + terminalUserId: typeof event.target?.endUserId === 'string' ? event.target.endUserId : undefined, + terminalUserEmail: typeof event.target?.endUserLabel === 'string' ? event.target.endUserLabel : undefined, + })) + .pipe(legacyCloudOrderQuotaChangeSchema), +]) + export type CloudStoreSettingsInput = z.infer export type CloudStoreCurrency = z.infer export type CloudProductPrice = z.infer