feat(cloud-store): accept opaque store deliveries

This commit is contained in:
saltbo
2026-05-09 00:07:37 -04:00
parent 25416a5f18
commit 58d94c3df6
6 changed files with 171 additions and 20 deletions
+7 -4
View File
@@ -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
+2 -2
View File
@@ -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')
+29 -4
View File
@@ -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()}`
}
@@ -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<string, string>).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,
})
+41 -3
View File
@@ -28,6 +28,39 @@ import {
} from '../cloud-store-helpers'
import { getCloudOrders } from './shared'
function cloudProductPayload(input: ReturnType<typeof cloudProductInputSchema.parse>) {
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<typeof cloudProductPatchSchema.parse>) {
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<Env>()
.use(requireAdmin)
.use(requireFeature('quota_store'))
@@ -45,7 +78,12 @@ export const adminCloudStore = new Hono<Env>()
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<Env>()
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<Env>()
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)
+84 -1
View File
@@ -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<string, unknown>, key: string) {
const value = deliverable[key]
return typeof value === 'number' ? value : 0
}
function stringDeliverableValue(deliverable: Record<string, unknown>, key: string) {
const value = deliverable[key]
return typeof value === 'string' ? value : undefined
}
function targetOrgId(target: Record<string, unknown> | null) {
return typeof target?.orgId === 'string' ? target.orgId : ''
}
function sourceId(event: z.infer<typeof storeDeliveryEventSchema>) {
const orgId = targetOrgId(event.target)
if (event.context.stripeSubscriptionId) return `stripe_subscription:${event.context.stripeSubscriptionId}:${orgId}`
return event.orderId
}
function expiresAt(event: z.infer<typeof storeDeliveryEventSchema>) {
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<typeof cloudStoreSettingsSchema>
export type CloudStoreCurrency = z.infer<typeof cloudStoreCurrencySchema>
export type CloudProductPrice = z.infer<typeof cloudProductPriceSchema>