mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
10e16d14a2
* fix(oauth): make token revocation idempotent * fix(store): complete agent capacity purchase workflow * fix(build): refresh patched dependency lock hash * fix(oauth): hide inactive token state on revoke --------- Co-authored-by: saltbo <saltbo@foxmail.com>
445 lines
15 KiB
TypeScript
445 lines
15 KiB
TypeScript
import { OpenAPIHono, z } from '@hono/zod-openapi'
|
|
import { AuthorizationScope } from '@shared/authorization'
|
|
import {
|
|
capacityPurchaseDeliveredResultSchema,
|
|
capacityPurchasePendingResultSchema,
|
|
checkoutInputSchema,
|
|
discountQuoteInputSchema,
|
|
redeemGiftCardInputSchema,
|
|
x402PaymentRequiredSchema,
|
|
} from '@shared/schemas'
|
|
import type { Env } from '../../middleware/platform'
|
|
import { requireFeature } from '../../middleware/require-feature'
|
|
import { badGateway, badRequest, forbidden } from '../../usecases/ports'
|
|
import {
|
|
cancelOrder,
|
|
continueOrderPayment,
|
|
createBillingPortalSession,
|
|
createCheckout,
|
|
getCreditBalance,
|
|
getCreditLedger,
|
|
getDiscountQuote,
|
|
getStoreReadiness,
|
|
listCreditProducts,
|
|
listPackages,
|
|
listTargets,
|
|
purchaseCapacity,
|
|
redeemGiftCard,
|
|
} from '../../usecases/store/store'
|
|
import { authRoute, errorResponse, jsonBody, jsonContent } from '../openapi'
|
|
import { cloudStoreOrdersQuerySchema, getCloudBaseUrl } from './helpers'
|
|
import { getCloudOrders, getInstanceOrigin } from './shared'
|
|
|
|
// Storefront responses are passed through verbatim from the upstream cloud
|
|
// commerce API, whose payloads are owned by that service. They are documented as
|
|
// opaque objects rather than mirrored field-for-field here.
|
|
const cloudValue = z.unknown().openapi('CloudStoreValue')
|
|
const cloudBody = (description: string) => jsonContent(cloudValue, description)
|
|
const capacityPurchaseInputSchema = z.object({
|
|
requestHash: z.string().min(1).max(256),
|
|
idempotencyKey: z.string().min(1).max(200),
|
|
})
|
|
const paymentResponse = <T extends z.ZodType>(schema: T, description: string) => ({
|
|
...jsonContent(schema, description),
|
|
headers: {
|
|
'PAYMENT-RESPONSE': {
|
|
description: 'Standard x402 settlement response for confirmation by the selected payer.',
|
|
schema: { type: 'string' as const },
|
|
},
|
|
},
|
|
})
|
|
const paymentRequiredResponse = {
|
|
...jsonContent(x402PaymentRequiredSchema, 'x402 payment required'),
|
|
headers: {
|
|
'PAYMENT-REQUIRED': {
|
|
description: 'Standard x402 payment requirement to pass unchanged to a selected payer.',
|
|
required: true,
|
|
schema: { type: 'string' as const },
|
|
},
|
|
},
|
|
}
|
|
|
|
const packagesRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.STORE_READ] },
|
|
{
|
|
operationId: 'listStorePackages',
|
|
summary: 'List store packages',
|
|
tags: ['Store'],
|
|
method: 'get',
|
|
path: '/packages',
|
|
middleware: [requireFeature('quota_store')] as const,
|
|
responses: {
|
|
200: cloudBody('Packages'),
|
|
403: errorResponse('License not bound'),
|
|
502: errorResponse('Cloud error'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const creditProductsRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.STORE_READ] },
|
|
{
|
|
operationId: 'listCreditProducts',
|
|
summary: 'List credit products',
|
|
tags: ['Store'],
|
|
method: 'get',
|
|
path: '/credits/products',
|
|
middleware: [requireFeature('quota_store')] as const,
|
|
responses: {
|
|
200: cloudBody('Credit products'),
|
|
403: errorResponse('License not bound'),
|
|
502: errorResponse('Cloud error'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const targetsRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.STORE_READ] },
|
|
{
|
|
operationId: 'listStoreTargets',
|
|
summary: 'List store targets',
|
|
tags: ['Store'],
|
|
method: 'get',
|
|
path: '/targets',
|
|
middleware: [requireFeature('quota_store')] as const,
|
|
responses: {
|
|
200: cloudBody('Targets'),
|
|
403: errorResponse('License not bound'),
|
|
502: errorResponse('Cloud error'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const creditsRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.STORE_READ], minTeamRole: 'owner' },
|
|
{
|
|
operationId: 'getCreditBalance',
|
|
summary: 'Get credit balance',
|
|
tags: ['Store'],
|
|
method: 'get',
|
|
path: '/credits',
|
|
middleware: [requireFeature('quota_store')] as const,
|
|
responses: {
|
|
200: cloudBody('Credit balance'),
|
|
400: errorResponse('No active organization'),
|
|
403: errorResponse('License not bound'),
|
|
502: errorResponse('Cloud error'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const ledgerRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.STORE_READ], minTeamRole: 'owner' },
|
|
{
|
|
operationId: 'getCreditLedger',
|
|
summary: 'Get credit ledger',
|
|
tags: ['Store'],
|
|
method: 'get',
|
|
path: '/credits/ledger-entries',
|
|
middleware: [requireFeature('quota_store')] as const,
|
|
responses: {
|
|
200: cloudBody('Credit ledger'),
|
|
400: errorResponse('No active organization'),
|
|
403: errorResponse('License not bound'),
|
|
502: errorResponse('Cloud error'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const redeemRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.STORE_CREATE], minTeamRole: 'owner' },
|
|
{
|
|
operationId: 'redeemGiftCard',
|
|
summary: 'Redeem a gift card',
|
|
tags: ['Store'],
|
|
method: 'post',
|
|
path: '/credits/redemptions',
|
|
middleware: [requireFeature('quota_store')] as const,
|
|
request: jsonBody(redeemGiftCardInputSchema),
|
|
responses: {
|
|
200: cloudBody('Redemption result'),
|
|
400: errorResponse('No active organization'),
|
|
403: errorResponse('License not bound'),
|
|
502: errorResponse('Cloud error'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const checkoutRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.STORE_CREATE], minTeamRole: 'owner' },
|
|
{
|
|
operationId: 'createCheckout',
|
|
summary: 'Create a checkout',
|
|
tags: ['Store'],
|
|
method: 'post',
|
|
path: '/checkouts',
|
|
middleware: [requireFeature('quota_store')] as const,
|
|
request: jsonBody(checkoutInputSchema),
|
|
responses: {
|
|
200: cloudBody('Checkout session'),
|
|
400: errorResponse('Bad request'),
|
|
403: errorResponse('License not bound'),
|
|
409: errorResponse('Workspace plan already exists'),
|
|
502: errorResponse('Cloud error'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const capacityPurchaseRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.QUOTA_PURCHASE], minTeamRole: 'owner' },
|
|
{
|
|
operationId: 'purchaseStorageCapacity',
|
|
summary: 'Purchase workspace storage capacity with x402',
|
|
description:
|
|
'Select an offer returned by createObject. A new purchase is accepted only when its plan increases the workspace capacity. Call without PAYMENT-SIGNATURE to receive a standard x402 PAYMENT-REQUIRED challenge. Pay it and retry this same request with PAYMENT-SIGNATURE. An authorized caller may recover an interrupted purchase by using the same requestHash with a fresh idempotencyKey. A delivered response means the workspace capacity entitlement is active; retry the original createObject request.',
|
|
tags: ['Store'],
|
|
method: 'post',
|
|
path: '/capacity-purchases/{resourceId}',
|
|
middleware: [requireFeature('quota_store')] as const,
|
|
request: {
|
|
params: z.object({ resourceId: z.string().min(1) }),
|
|
headers: z.object({
|
|
'payment-signature': z
|
|
.string()
|
|
.min(1)
|
|
.max(64 * 1024)
|
|
.optional(),
|
|
}),
|
|
...jsonBody(capacityPurchaseInputSchema),
|
|
},
|
|
responses: {
|
|
200: paymentResponse(capacityPurchaseDeliveredResultSchema, 'Capacity delivered'),
|
|
202: paymentResponse(capacityPurchasePendingResultSchema, 'Payment accepted; capacity fulfillment is pending'),
|
|
400: errorResponse('Invalid capacity offer'),
|
|
402: paymentRequiredResponse,
|
|
403: errorResponse('License not bound'),
|
|
409: errorResponse('Purchase request conflict or capacity offer is not an upgrade'),
|
|
429: errorResponse('Too many pending capacity purchases'),
|
|
502: errorResponse('Cloud error'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const discountRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.STORE_CREATE] },
|
|
{
|
|
operationId: 'getDiscountQuote',
|
|
summary: 'Get a discount quote',
|
|
tags: ['Store'],
|
|
method: 'post',
|
|
path: '/discount-quotes',
|
|
middleware: [requireFeature('quota_store')] as const,
|
|
request: jsonBody(discountQuoteInputSchema),
|
|
responses: {
|
|
200: cloudBody('Discount quote'),
|
|
403: errorResponse('License not bound'),
|
|
502: errorResponse('Cloud error'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const billingPortalRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.STORE_CREATE], minTeamRole: 'owner' },
|
|
{
|
|
operationId: 'createBillingPortalSession',
|
|
summary: 'Create a billing portal session',
|
|
tags: ['Store'],
|
|
method: 'post',
|
|
path: '/billing-portal-sessions',
|
|
middleware: [requireFeature('quota_store')] as const,
|
|
responses: {
|
|
200: cloudBody('Billing portal session'),
|
|
400: errorResponse('No active organization'),
|
|
403: errorResponse('License not bound'),
|
|
502: errorResponse('Cloud error'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const ordersRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.STORE_READ], minTeamRole: 'owner' },
|
|
{
|
|
operationId: 'listOrders',
|
|
summary: 'List orders',
|
|
tags: ['Store'],
|
|
method: 'get',
|
|
path: '/orders',
|
|
middleware: [requireFeature('quota_store')] as const,
|
|
request: { query: cloudStoreOrdersQuerySchema },
|
|
responses: {
|
|
200: cloudBody('Orders'),
|
|
400: errorResponse('No active organization'),
|
|
403: errorResponse('Store not ready'),
|
|
502: errorResponse('Cloud error'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const continuePaymentRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.STORE_CREATE], minTeamRole: 'owner' },
|
|
{
|
|
operationId: 'continueOrderPayment',
|
|
summary: 'Continue an order payment',
|
|
tags: ['Store'],
|
|
method: 'post',
|
|
path: '/orders/{orderId}/payments',
|
|
middleware: [requireFeature('quota_store')] as const,
|
|
request: { params: z.object({ orderId: z.string() }) },
|
|
responses: {
|
|
200: cloudBody('Payment continuation'),
|
|
400: errorResponse('No active organization'),
|
|
403: errorResponse('Forbidden'),
|
|
404: errorResponse('Order not found'),
|
|
502: errorResponse('Cloud error'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const cancelOrderRoute = authRoute(
|
|
{ scopes: [AuthorizationScope.STORE_UPDATE], minTeamRole: 'owner' },
|
|
{
|
|
operationId: 'cancelOrder',
|
|
summary: 'Cancel an order',
|
|
tags: ['Store'],
|
|
method: 'put',
|
|
path: '/orders/{orderId}/status',
|
|
middleware: [requireFeature('quota_store')] as const,
|
|
request: { params: z.object({ orderId: z.string() }), ...jsonBody(z.object({ status: z.literal('canceled') })) },
|
|
responses: {
|
|
200: cloudBody('Canceled order'),
|
|
400: errorResponse('No active organization'),
|
|
403: errorResponse('Forbidden'),
|
|
404: errorResponse('Order not found'),
|
|
502: errorResponse('Cloud error'),
|
|
},
|
|
},
|
|
)
|
|
|
|
const app = new OpenAPIHono<Env>()
|
|
|
|
export const cloudStore = app
|
|
.openapi(packagesRoute, async (c) => {
|
|
const result = await listPackages(c.get('deps'), getCloudBaseUrl(c))
|
|
if (!result.ok) throw result.error
|
|
return c.json(result.value, 200)
|
|
})
|
|
.openapi(creditProductsRoute, async (c) => {
|
|
const result = await listCreditProducts(c.get('deps'), getCloudBaseUrl(c))
|
|
if (!result.ok) throw result.error
|
|
return c.json(result.value, 200)
|
|
})
|
|
.openapi(targetsRoute, async (c) => {
|
|
const result = await listTargets(c.get('deps'), c.get('userId')!)
|
|
if (!result.ok) throw result.error
|
|
return c.json(result.value, 200)
|
|
})
|
|
.openapi(creditsRoute, async (c) => {
|
|
const targetOrgId = c.get('orgId')
|
|
if (!targetOrgId) throw badRequest('No active organization')
|
|
const result = await getCreditBalance(c.get('deps'), getCloudBaseUrl(c), targetOrgId)
|
|
if (!result.ok) throw result.error
|
|
return c.json(result.value, 200)
|
|
})
|
|
.openapi(ledgerRoute, async (c) => {
|
|
const targetOrgId = c.get('orgId')
|
|
if (!targetOrgId) throw badRequest('No active organization')
|
|
const result = await getCreditLedger(c.get('deps'), getCloudBaseUrl(c), targetOrgId)
|
|
if (!result.ok) throw result.error
|
|
return c.json(result.value, 200)
|
|
})
|
|
.openapi(redeemRoute, async (c) => {
|
|
const targetOrgId = c.get('orgId')
|
|
if (!targetOrgId) throw badRequest('No active organization')
|
|
const result = await redeemGiftCard(c.get('deps'), getCloudBaseUrl(c), {
|
|
orgId: targetOrgId,
|
|
input: c.req.valid('json'),
|
|
})
|
|
if (!result.ok) throw result.error
|
|
return c.json(result.value, 200)
|
|
})
|
|
.openapi(checkoutRoute, async (c) => {
|
|
const targetOrgId = c.get('orgId')
|
|
if (!targetOrgId) throw badRequest('No active organization')
|
|
const result = await createCheckout(c.get('deps'), getCloudBaseUrl(c), {
|
|
userId: c.get('userId')!,
|
|
orgId: targetOrgId,
|
|
origin: await getInstanceOrigin(c),
|
|
input: c.req.valid('json'),
|
|
})
|
|
if (!result.ok) throw result.error
|
|
return c.json(result.value, 200)
|
|
})
|
|
.openapi(capacityPurchaseRoute, async (c) => {
|
|
const orgId = c.get('orgId')
|
|
if (!orgId) throw badRequest('No active organization')
|
|
const body = c.req.valid('json')
|
|
const result = await purchaseCapacity(c.get('deps'), getCloudBaseUrl(c), {
|
|
userId: c.get('userId')!,
|
|
orgId,
|
|
origin: await getInstanceOrigin(c),
|
|
resourceId: c.req.valid('param').resourceId,
|
|
requestHash: body.requestHash,
|
|
idempotencyKey: body.idempotencyKey,
|
|
paymentSignature: c.req.valid('header')['payment-signature'] ?? null,
|
|
})
|
|
if (!result.ok) throw result.error
|
|
if (result.kind === 'payment_required') {
|
|
c.header('PAYMENT-REQUIRED', result.paymentRequiredHeader)
|
|
return c.json(result.paymentRequired, 402)
|
|
}
|
|
if (result.paymentResponseHeader) c.header('PAYMENT-RESPONSE', result.paymentResponseHeader)
|
|
if (result.purchase.status === 'delivered') return c.json(result.purchase, 200)
|
|
return c.json(result.purchase, 202)
|
|
})
|
|
.openapi(discountRoute, async (c) => {
|
|
const result = await getDiscountQuote(c.get('deps'), getCloudBaseUrl(c), c.req.valid('json'))
|
|
if (!result.ok) throw result.error
|
|
return c.json(result.value, 200)
|
|
})
|
|
.openapi(billingPortalRoute, async (c) => {
|
|
const targetOrgId = c.get('orgId')
|
|
if (!targetOrgId) throw badRequest('No active organization')
|
|
const result = await createBillingPortalSession(c.get('deps'), getCloudBaseUrl(c), {
|
|
orgId: targetOrgId,
|
|
origin: await getInstanceOrigin(c),
|
|
})
|
|
if (!result.ok) throw result.error
|
|
return c.json(result.value, 200)
|
|
})
|
|
.openapi(ordersRoute, async (c) => {
|
|
const ready = await getStoreReadiness(c.get('deps'))
|
|
if (!ready.ready) throw forbidden(ready.error)
|
|
const targetOrgId = c.get('orgId')
|
|
if (!targetOrgId) throw badRequest('No active organization')
|
|
const query = c.req.valid('query')
|
|
const result = await getCloudOrders(c, { limit: query.limit, offset: query.offset, customerId: targetOrgId })
|
|
if ('error' in result) throw badGateway(result.error)
|
|
return c.json(result, 200)
|
|
})
|
|
.openapi(continuePaymentRoute, async (c) => {
|
|
const ready = await getStoreReadiness(c.get('deps'))
|
|
if (!ready.ready) throw forbidden(ready.error)
|
|
const targetOrgId = c.get('orgId')
|
|
if (!targetOrgId) throw badRequest('No active organization')
|
|
const result = await continueOrderPayment(c.get('deps'), getCloudBaseUrl(c), {
|
|
orgId: targetOrgId,
|
|
orderId: c.req.valid('param').orderId,
|
|
origin: await getInstanceOrigin(c),
|
|
})
|
|
if (!result.ok) throw result.error
|
|
return c.json(result.value, 200)
|
|
})
|
|
.openapi(cancelOrderRoute, async (c) => {
|
|
const ready = await getStoreReadiness(c.get('deps'))
|
|
if (!ready.ready) throw forbidden(ready.error)
|
|
const targetOrgId = c.get('orgId')
|
|
if (!targetOrgId) throw badRequest('No active organization')
|
|
const result = await cancelOrder(c.get('deps'), getCloudBaseUrl(c), {
|
|
orgId: targetOrgId,
|
|
orderId: c.req.valid('param').orderId,
|
|
status: c.req.valid('json').status,
|
|
})
|
|
if (!result.ok) throw result.error
|
|
return c.json(result.value, 200)
|
|
})
|