mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
feat(cloud-store): accept opaque store deliveries
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user