Merge pull request #545 from saltbo/feat/x402-paid-agent-uploads

feat(store): add agent x402 capacity purchases
This commit is contained in:
Jasper Van
2026-07-31 10:53:45 -04:00
committed by GitHub
60 changed files with 22092 additions and 167 deletions
+3
View File
@@ -7,6 +7,9 @@ BETTER_AUTH_SECRET=
# Local ZPan URL.
BETTER_AUTH_URL=http://localhost:5185
TRUSTED_ORIGINS=http://localhost:5185
# Optional comma-separated hosts accepted by the Vite development server.
# Cloudflare Quick Tunnel (*.trycloudflare.com) is already allowed in development.
ZPAN_DEV_ALLOWED_HOSTS=
# Local ZPan Cloud URL.
ZPAN_CLOUD_URL=http://localhost:5186
+293
View File
@@ -1126,6 +1126,21 @@ func (e BrandingThemePreset) Valid() bool {
}
}
// Defines values for CapacityRequiredError.
const (
CAPACITYREQUIRED CapacityRequiredError = "CAPACITY_REQUIRED"
)
// Valid indicates whether the value is a known member of the CapacityRequiredError enum.
func (e CapacityRequiredError) Valid() bool {
switch e {
case CAPACITYREQUIRED:
return true
default:
return false
}
}
// Defines values for CaptchaProvider.
const (
Captchafox CaptchaProvider = "captchafox"
@@ -2035,6 +2050,7 @@ const (
GetAgentOAuthConsentContext200JSONResponseBodyScopesObjectsDelete GetAgentOAuthConsentContext200JSONResponseBodyScopes = "objects:delete"
GetAgentOAuthConsentContext200JSONResponseBodyScopesObjectsRead GetAgentOAuthConsentContext200JSONResponseBodyScopes = "objects:read"
GetAgentOAuthConsentContext200JSONResponseBodyScopesObjectsUpdate GetAgentOAuthConsentContext200JSONResponseBodyScopes = "objects:update"
GetAgentOAuthConsentContext200JSONResponseBodyScopesQuotaPurchase GetAgentOAuthConsentContext200JSONResponseBodyScopes = "quota:purchase"
GetAgentOAuthConsentContext200JSONResponseBodyScopesQuotaRead GetAgentOAuthConsentContext200JSONResponseBodyScopes = "quota:read"
GetAgentOAuthConsentContext200JSONResponseBodyScopesSharesCreate GetAgentOAuthConsentContext200JSONResponseBodyScopes = "shares:create"
GetAgentOAuthConsentContext200JSONResponseBodyScopesSharesDelete GetAgentOAuthConsentContext200JSONResponseBodyScopes = "shares:delete"
@@ -2053,6 +2069,8 @@ func (e GetAgentOAuthConsentContext200JSONResponseBodyScopes) Valid() bool {
return true
case GetAgentOAuthConsentContext200JSONResponseBodyScopesObjectsUpdate:
return true
case GetAgentOAuthConsentContext200JSONResponseBodyScopesQuotaPurchase:
return true
case GetAgentOAuthConsentContext200JSONResponseBodyScopesQuotaRead:
return true
case GetAgentOAuthConsentContext200JSONResponseBodyScopesSharesCreate:
@@ -2074,6 +2092,7 @@ const (
ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsDelete ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "objects:delete"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsRead ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "objects:read"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsUpdate ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "objects:update"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesQuotaPurchase ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "quota:purchase"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesQuotaRead ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "quota:read"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesSharesCreate ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "shares:create"
ListAgentOAuthGrants200JSONResponseBodyItemsScopesSharesDelete ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "shares:delete"
@@ -2092,6 +2111,8 @@ func (e ListAgentOAuthGrants200JSONResponseBodyItemsScopes) Valid() bool {
return true
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsUpdate:
return true
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesQuotaPurchase:
return true
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesQuotaRead:
return true
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesSharesCreate:
@@ -5006,6 +5027,31 @@ type BrandingThemeValues struct {
SidebarAccentColor string `json:"sidebarAccentColor"`
}
// CapacityRequired defines model for CapacityRequired.
type CapacityRequired struct {
Error CapacityRequiredError `json:"error"`
Offers []struct {
Amount int `json:"amount"`
Currency string `json:"currency"`
Description *string `json:"description"`
Interval *string `json:"interval"`
IntervalCount *int `json:"intervalCount"`
Name string `json:"name"`
PriceId string `json:"priceId"`
ProductId string `json:"productId"`
PurchaseUrl string `json:"purchaseUrl"`
ResourceId string `json:"resourceId"`
StorageBytes int `json:"storageBytes"`
} `json:"offers"`
QuotaBytes int `json:"quotaBytes"`
RequestHash string `json:"requestHash"`
RequestedBytes int `json:"requestedBytes"`
UsedBytes int `json:"usedBytes"`
}
// CapacityRequiredError defines model for CapacityRequired.Error.
type CapacityRequiredError string
// CaptchaProvider defines model for CaptchaProvider.
type CaptchaProvider string
@@ -8322,6 +8368,17 @@ type ListStorageUsageItemsParamsSortDir string
// ListStorageUsageItems200JSONResponseBodyItemsSource defines parameters for ListStorageUsageItems.
type ListStorageUsageItems200JSONResponseBodyItemsSource string
// PurchaseStorageCapacityJSONBody defines parameters for PurchaseStorageCapacity.
type PurchaseStorageCapacityJSONBody struct {
IdempotencyKey string `json:"idempotencyKey"`
RequestHash string `json:"requestHash"`
}
// PurchaseStorageCapacityParams defines parameters for PurchaseStorageCapacity.
type PurchaseStorageCapacityParams struct {
PaymentSignature *string `json:"payment-signature,omitempty"`
}
// CreateCheckoutJSONBody defines parameters for CreateCheckout.
type CreateCheckoutJSONBody struct {
PackageId string `json:"packageId"`
@@ -8764,6 +8821,9 @@ type ReplaceStorageJSONRequestBody ReplaceStorageJSONBody
// UpdateStorageEgressBillingJSONRequestBody defines body for UpdateStorageEgressBilling for application/json ContentType.
type UpdateStorageEgressBillingJSONRequestBody UpdateStorageEgressBillingJSONBody
// PurchaseStorageCapacityJSONRequestBody defines body for PurchaseStorageCapacity for application/json ContentType.
type PurchaseStorageCapacityJSONRequestBody PurchaseStorageCapacityJSONBody
// CreateCheckoutJSONRequestBody defines body for CreateCheckout for application/json ContentType.
type CreateCheckoutJSONRequestBody CreateCheckoutJSONBody
@@ -11357,6 +11417,11 @@ type ClientInterface interface {
// CreateBillingPortalSession request
CreateBillingPortalSession(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
// PurchaseStorageCapacityWithBody request with any body
PurchaseStorageCapacityWithBody(ctx context.Context, resourceId string, params *PurchaseStorageCapacityParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
PurchaseStorageCapacity(ctx context.Context, resourceId string, params *PurchaseStorageCapacityParams, body PurchaseStorageCapacityJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// CreateCheckoutWithBody request with any body
CreateCheckoutWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -15532,6 +15597,30 @@ func (c *Client) CreateBillingPortalSession(ctx context.Context, reqEditors ...R
return c.Client.Do(req)
}
func (c *Client) PurchaseStorageCapacityWithBody(ctx context.Context, resourceId string, params *PurchaseStorageCapacityParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewPurchaseStorageCapacityRequestWithBody(c.Server, resourceId, params, contentType, body)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) PurchaseStorageCapacity(ctx context.Context, resourceId string, params *PurchaseStorageCapacityParams, body PurchaseStorageCapacityJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewPurchaseStorageCapacityRequest(c.Server, resourceId, params, body)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) CreateCheckoutWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewCreateCheckoutRequestWithBody(c.Server, contentType, body)
if err != nil {
@@ -26112,6 +26201,68 @@ func NewCreateBillingPortalSessionRequest(server string) (*http.Request, error)
return req, nil
}
// NewPurchaseStorageCapacityRequest calls the generic PurchaseStorageCapacity builder with application/json body
func NewPurchaseStorageCapacityRequest(server string, resourceId string, params *PurchaseStorageCapacityParams, body PurchaseStorageCapacityJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader
buf, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(buf)
return NewPurchaseStorageCapacityRequestWithBody(server, resourceId, params, "application/json", bodyReader)
}
// NewPurchaseStorageCapacityRequestWithBody generates requests for PurchaseStorageCapacity with any type of body
func NewPurchaseStorageCapacityRequestWithBody(server string, resourceId string, params *PurchaseStorageCapacityParams, contentType string, body io.Reader) (*http.Request, error) {
var err error
var pathParam0 string
pathParam0, err = runtime.StyleParamWithOptions("simple", false, "resourceId", resourceId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
if err != nil {
return nil, err
}
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/store/capacity-purchases/%s", pathParam0)
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, queryURL.String(), body)
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", contentType)
if params != nil {
if params.PaymentSignature != nil {
var headerParam0 string
headerParam0, err = runtime.StyleParamWithOptions("simple", false, "payment-signature", *params.PaymentSignature, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "string", Format: ""})
if err != nil {
return nil, err
}
req.Header.Set("payment-signature", headerParam0)
}
}
return req, nil
}
// NewCreateCheckoutRequest calls the generic CreateCheckout builder with application/json body
func NewCreateCheckoutRequest(server string, body CreateCheckoutJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader
@@ -28475,6 +28626,11 @@ type ClientWithResponsesInterface interface {
// CreateBillingPortalSessionWithResponse request
CreateBillingPortalSessionWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*CreateBillingPortalSessionResponse, error)
// PurchaseStorageCapacityWithBodyWithResponse request with any body
PurchaseStorageCapacityWithBodyWithResponse(ctx context.Context, resourceId string, params *PurchaseStorageCapacityParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PurchaseStorageCapacityResponse, error)
PurchaseStorageCapacityWithResponse(ctx context.Context, resourceId string, params *PurchaseStorageCapacityParams, body PurchaseStorageCapacityJSONRequestBody, reqEditors ...RequestEditorFn) (*PurchaseStorageCapacityResponse, error)
// CreateCheckoutWithBodyWithResponse request with any body
CreateCheckoutWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateCheckoutResponse, error)
@@ -35947,6 +36103,7 @@ type CreateObjectResponse struct {
} `json:"upload,omitempty"`
}
JSON400 *Error
JSON402 *CapacityRequired
JSON403 *Error
JSON409 *Error
JSON503 *Error
@@ -38444,6 +38601,43 @@ func (r CreateBillingPortalSessionResponse) ContentType() string {
return ""
}
type PurchaseStorageCapacityResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *CloudStoreValue
JSON202 *CloudStoreValue
JSON400 *Error
JSON402 *CloudStoreValue
JSON403 *Error
JSON409 *Error
JSON429 *Error
JSON502 *Error
}
// Status returns HTTPResponse.Status
func (r PurchaseStorageCapacityResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r PurchaseStorageCapacityResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
func (r PurchaseStorageCapacityResponse) ContentType() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Header.Get("Content-Type")
}
return ""
}
type CreateCheckoutResponse struct {
Body []byte
HTTPResponse *http.Response
@@ -42521,6 +42715,23 @@ func (c *ClientWithResponses) CreateBillingPortalSessionWithResponse(ctx context
return ParseCreateBillingPortalSessionResponse(rsp)
}
// PurchaseStorageCapacityWithBodyWithResponse request with arbitrary body returning *PurchaseStorageCapacityResponse
func (c *ClientWithResponses) PurchaseStorageCapacityWithBodyWithResponse(ctx context.Context, resourceId string, params *PurchaseStorageCapacityParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PurchaseStorageCapacityResponse, error) {
rsp, err := c.PurchaseStorageCapacityWithBody(ctx, resourceId, params, contentType, body, reqEditors...)
if err != nil {
return nil, err
}
return ParsePurchaseStorageCapacityResponse(rsp)
}
func (c *ClientWithResponses) PurchaseStorageCapacityWithResponse(ctx context.Context, resourceId string, params *PurchaseStorageCapacityParams, body PurchaseStorageCapacityJSONRequestBody, reqEditors ...RequestEditorFn) (*PurchaseStorageCapacityResponse, error) {
rsp, err := c.PurchaseStorageCapacity(ctx, resourceId, params, body, reqEditors...)
if err != nil {
return nil, err
}
return ParsePurchaseStorageCapacityResponse(rsp)
}
// CreateCheckoutWithBodyWithResponse request with arbitrary body returning *CreateCheckoutResponse
func (c *ClientWithResponses) CreateCheckoutWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateCheckoutResponse, error) {
rsp, err := c.CreateCheckoutWithBody(ctx, contentType, body, reqEditors...)
@@ -53825,6 +54036,13 @@ func ParseCreateObjectResponse(rsp *http.Response) (*CreateObjectResponse, error
}
response.JSON400 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402:
var dest CapacityRequired
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON402 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
@@ -56569,6 +56787,81 @@ func ParseCreateBillingPortalSessionResponse(rsp *http.Response) (*CreateBilling
return response, nil
}
// ParsePurchaseStorageCapacityResponse parses an HTTP response from a PurchaseStorageCapacityWithResponse call
func ParsePurchaseStorageCapacityResponse(rsp *http.Response) (*PurchaseStorageCapacityResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &PurchaseStorageCapacityResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest CloudStoreValue
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON200 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 202:
var dest CloudStoreValue
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON202 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON400 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 402:
var dest CloudStoreValue
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON402 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON403 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON409 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON429 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 502:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON502 = &dest
}
return response, nil
}
// ParseCreateCheckoutResponse parses an HTTP response from a CreateCheckoutWithResponse call
func ParseCreateCheckoutResponse(rsp *http.Response) (*CreateCheckoutResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
+17 -2
View File
@@ -138,16 +138,31 @@ catalog includes:
| `shares:create` | Create public shares |
| `shares:delete` | Revoke shares |
| `quota:read` | Inspect workspace quota |
| `quota:purchase` | Purchase storage capacity for the bound workspace through x402 |
| `storage-usage:read` | Inspect workspace storage usage |
| `tasks:read` | Inspect task state |
Administrative, billing, credential-management, WebDAV, downloader bootstrap,
and purge authority are not grantable through this catalog.
Administrative, general billing management, credential-management, WebDAV,
downloader bootstrap, and purge authority are not grantable through this
catalog. `quota:purchase` is the narrow exception for autonomous x402 capacity
purchases and cannot manage subscriptions or other billing resources.
OAuth is a credential adapter, not a business-logic fork. Middleware resolves a
protocol-neutral principal, bound workspace, scope set, and audit actor before
calling the same file use cases used by other authenticated clients.
Upgrades that add Agent scopes do not mutate OAuth tables during authentication
startup. Before deploying such an upgrade, operators run the idempotent scope
backfill in dry-run mode and then apply it:
```sh
pnpm agent-oauth-scopes:backfill -- --d1 zpan-db --remote
pnpm agent-oauth-scopes:backfill -- --d1 zpan-db --remote --apply
```
For Node/SQLite deployments, replace the D1 arguments with
`--sqlite <database-path>`.
## Self-Describing Direct Upload
File bytes continue to bypass ZPan and go directly to S3-compatible storage.
@@ -0,0 +1,18 @@
CREATE TABLE `x402_capacity_purchase_intents` (
`id` text PRIMARY KEY NOT NULL,
`org_id` text NOT NULL,
`resource_id` text NOT NULL,
`request_hash` text NOT NULL,
`idempotency_key` text NOT NULL,
`cloud_order_id` text,
`cloud_attempt_id` text,
`status` text DEFAULT 'created' NOT NULL,
`expires_at` integer,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `x402_capacity_purchase_intents_org_request_uniq` ON `x402_capacity_purchase_intents` (`org_id`,`resource_id`,`request_hash`);--> statement-breakpoint
CREATE UNIQUE INDEX `x402_capacity_purchase_intents_idempotency_uniq` ON `x402_capacity_purchase_intents` (`idempotency_key`);--> statement-breakpoint
CREATE INDEX `x402_capacity_purchase_intents_attempt_idx` ON `x402_capacity_purchase_intents` (`cloud_attempt_id`);--> statement-breakpoint
DROP INDEX `org_quota_entitlements_active_plan_uniq`;
@@ -0,0 +1,2 @@
DROP INDEX `x402_capacity_purchase_intents_idempotency_uniq`;--> statement-breakpoint
CREATE UNIQUE INDEX `x402_capacity_purchase_intents_org_idempotency_uniq` ON `x402_capacity_purchase_intents` (`org_id`,`idempotency_key`);
@@ -0,0 +1,18 @@
-- Better Auth providers can define trusted issuer semantics. Abort on unknown
-- legacy providers instead of assigning an issuer that could join the wrong identity.
SELECT CASE
WHEN EXISTS (
SELECT 1
FROM `account`
WHERE `issuer` = ''
AND `provider_id` NOT IN ('credential', 'github', 'google')
) THEN json_extract('unsupported legacy account provider', '$')
ELSE NULL
END;--> statement-breakpoint
UPDATE `account`
SET `issuer` = CASE
WHEN `provider_id` = 'credential' THEN 'local:credential'
WHEN `provider_id` = 'github' THEN 'local:oauth:github'
WHEN `provider_id` = 'google' THEN 'https://accounts.google.com'
END
WHERE `issuer` = '';
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -603,6 +603,27 @@
"when": 1785456041947,
"tag": "0086_agent-audit-identity",
"breakpoints": true
},
{
"idx": 87,
"version": "6",
"when": 1785462114956,
"tag": "0087_x402_capacity_purchases",
"breakpoints": true
},
{
"idx": 88,
"version": "6",
"when": 1785471548091,
"tag": "0088_x402_tenant_idempotency",
"breakpoints": true
},
{
"idx": 89,
"version": "6",
"when": 1785506906336,
"tag": "0089_better-auth-account-issuer-backfill",
"breakpoints": true
}
]
}
+2 -1
View File
@@ -29,6 +29,7 @@
"storage:backfill": "tsx scripts/backfill-storage-usage.ts",
"storage-status:backfill": "tsx scripts/backfill-storage-enabled-status.ts",
"api-key-scopes:backfill": "tsx scripts/backfill-api-key-scopes.ts",
"agent-oauth-scopes:backfill": "tsx scripts/backfill-agent-oauth-scopes.ts",
"typecheck": "tsc --noEmit -p server/tsconfig.json && tsc --noEmit -p src/tsconfig.json",
"test": "vitest run --project unit --project integration",
"test:cf": "vitest run --project cloudflare",
@@ -108,7 +109,7 @@
"tailwind-merge": "^3.5.0",
"yet-another-react-lightbox": "^3.30.1",
"zod": "^4.4.3",
"zpan-cloud-sdk": "^2.4.0"
"zpan-cloud-sdk": "^2.5.1"
},
"devDependencies": {
"@biomejs/biome": "^2.4.10",
+5 -5
View File
@@ -192,8 +192,8 @@ importers:
specifier: ^4.4.3
version: 4.4.3
zpan-cloud-sdk:
specifier: ^2.4.0
version: 2.4.0(hono@4.12.27)(zod@4.4.3)
specifier: ^2.5.1
version: 2.5.1(hono@4.12.27)(zod@4.4.3)
devDependencies:
'@biomejs/biome':
specifier: ^2.4.10
@@ -5902,8 +5902,8 @@ packages:
zod@4.4.3:
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
zpan-cloud-sdk@2.4.0:
resolution: {integrity: sha512-kPyX9vjZXxtZdWQRE8ZPeICJmdwq4zx+4RFw0DvfY54e9G8T5vf3U9gpWndO1YVkd37zyKKRWZpify4m1dv4Mg==}
zpan-cloud-sdk@2.5.1:
resolution: {integrity: sha512-8db7VzBYl0yuzmU8f1RCoqGRFHSaVn6cDe9v7IS10WuLs+QPwbeBZZDQxfmmpAWfoOdl8M9gxJGjlgzfBHZy6A==}
peerDependencies:
hono: ^4.7.11
zod: ^4.3.6
@@ -11637,7 +11637,7 @@ snapshots:
zod@4.4.3: {}
zpan-cloud-sdk@2.4.0(hono@4.12.27)(zod@4.4.3):
zpan-cloud-sdk@2.5.1(hono@4.12.27)(zod@4.4.3):
dependencies:
hono: 4.12.27
zod: 4.4.3
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env tsx
import { execFileSync } from 'node:child_process'
import Database from 'better-sqlite3'
import { AGENT_OAUTH_SCOPES } from '../shared/agent-oauth'
import { AuthorizationScope } from '../shared/authorization'
export type AgentOAuthScopeBackfillTarget =
| { kind: 'sqlite'; path: string }
| { kind: 'd1'; database: string; remote: boolean; env?: string }
export interface AgentOAuthScopeBackfillOptions {
apply: boolean
target: AgentOAuthScopeBackfillTarget
}
interface OAuthResourceRow {
id: string
name: string
allowedScopes: string | null
}
interface OAuthClientRow {
id: string
scopes: string | null
}
export interface AgentOAuthScopeBackfill {
resources: Array<{ id: string; scopes: string }>
clients: Array<{ id: string; scopes: string }>
}
export function buildAgentOAuthScopeBackfill(
resources: OAuthResourceRow[],
clients: OAuthClientRow[],
): AgentOAuthScopeBackfill {
const resourceScopes = JSON.stringify(AGENT_OAUTH_SCOPES)
return {
resources: resources.flatMap((resource) =>
resource.name === 'ZPan API' && resource.allowedScopes !== resourceScopes
? [{ id: resource.id, scopes: resourceScopes }]
: [],
),
clients: clients.flatMap((client) => {
const scopes = parseScopes(client.scopes)
if (!scopes.includes(AuthorizationScope.OBJECTS_CREATE) || scopes.includes(AuthorizationScope.QUOTA_PURCHASE)) {
return []
}
return [{ id: client.id, scopes: JSON.stringify([...scopes, AuthorizationScope.QUOTA_PURCHASE]) }]
}),
}
}
function parseScopes(value: string | null): string[] {
if (!value) return []
const parsed = JSON.parse(value)
if (!Array.isArray(parsed) || parsed.some((scope) => typeof scope !== 'string')) {
throw new Error('invalid_oauth_client_scopes')
}
return parsed
}
export function parseAgentOAuthScopeBackfillOptions(argv: string[]): AgentOAuthScopeBackfillOptions {
const sqliteIndex = argv.indexOf('--sqlite')
const d1Index = argv.indexOf('--d1')
if ((sqliteIndex >= 0) === (d1Index >= 0)) usage()
if (sqliteIndex >= 0) {
const path = argv[sqliteIndex + 1]
if (!path) usage()
return { apply: argv.includes('--apply'), target: { kind: 'sqlite', path } }
}
const database = argv[d1Index + 1]
if (!database) usage()
const envIndex = argv.indexOf('--env')
return {
apply: argv.includes('--apply'),
target: {
kind: 'd1',
database,
remote: argv.includes('--remote'),
env: envIndex >= 0 ? argv[envIndex + 1] : undefined,
},
}
}
function usage(): never {
throw new Error(
'Usage: pnpm agent-oauth-scopes:backfill -- (--sqlite <path> | --d1 <database> [--remote] [--env <name>]) [--apply]',
)
}
function d1Args(target: Extract<AgentOAuthScopeBackfillTarget, { kind: 'd1' }>): string[] {
return [
'exec',
'wrangler',
'd1',
'execute',
target.database,
target.remote ? '--remote' : '--local',
...(target.env ? ['--env', target.env] : []),
]
}
function executeD1(target: Extract<AgentOAuthScopeBackfillTarget, { kind: 'd1' }>, sql: string, json = false): string {
return execFileSync('pnpm', [...d1Args(target), '--command', sql, ...(json ? ['--json'] : [])], {
encoding: 'utf8',
stdio: json ? 'pipe' : 'inherit',
}) as string
}
export type AgentOAuthScopeD1Executor = typeof executeD1
function d1Rows<T>(
target: Extract<AgentOAuthScopeBackfillTarget, { kind: 'd1' }>,
sql: string,
execute: AgentOAuthScopeD1Executor,
): T[] {
const payload = JSON.parse(execute(target, sql, true)) as Array<{ results?: T[] }>
return payload.flatMap((entry) => entry.results ?? [])
}
function readRows(
target: AgentOAuthScopeBackfillTarget,
execute: AgentOAuthScopeD1Executor,
): { resources: OAuthResourceRow[]; clients: OAuthClientRow[] } {
const resourceSql = 'SELECT id, name, allowed_scopes AS allowedScopes FROM oauthResource;'
const clientSql = 'SELECT id, scopes FROM oauthClient;'
if (target.kind === 'd1') {
return {
resources: d1Rows(target, resourceSql, execute),
clients: d1Rows(target, clientSql, execute),
}
}
const db = new Database(target.path, { readonly: true })
try {
return {
resources: db.prepare(resourceSql).all() as OAuthResourceRow[],
clients: db.prepare(clientSql).all() as OAuthClientRow[],
}
} finally {
db.close()
}
}
function sqlString(value: string): string {
return `'${value.replaceAll("'", "''")}'`
}
function applyBackfill(
target: AgentOAuthScopeBackfillTarget,
changes: AgentOAuthScopeBackfill,
execute: AgentOAuthScopeD1Executor,
): void {
if (target.kind === 'd1') {
for (const resource of changes.resources) {
execute(
target,
`UPDATE oauthResource SET allowed_scopes = ${sqlString(resource.scopes)}, updated_at = cast(unixepoch('subsecond') * 1000 as integer) WHERE id = ${sqlString(resource.id)};`,
)
}
for (const client of changes.clients) {
execute(
target,
`UPDATE oauthClient SET scopes = ${sqlString(client.scopes)}, updated_at = cast(unixepoch('subsecond') * 1000 as integer) WHERE id = ${sqlString(client.id)};`,
)
}
return
}
const db = new Database(target.path)
try {
const updateResource = db.prepare('UPDATE oauthResource SET allowed_scopes = ?, updated_at = ? WHERE id = ?')
const updateClient = db.prepare('UPDATE oauthClient SET scopes = ?, updated_at = ? WHERE id = ?')
const now = Date.now()
db.transaction(() => {
for (const resource of changes.resources) updateResource.run(resource.scopes, now, resource.id)
for (const client of changes.clients) updateClient.run(client.scopes, now, client.id)
})()
} finally {
db.close()
}
}
function countChanges(changes: AgentOAuthScopeBackfill): number {
return changes.resources.length + changes.clients.length
}
export function runAgentOAuthScopeBackfill(
argv: string[],
log: (message: string) => void = console.log,
execute: AgentOAuthScopeD1Executor = executeD1,
): void {
const options = parseAgentOAuthScopeBackfillOptions(argv)
const rows = readRows(options.target, execute)
const changes = buildAgentOAuthScopeBackfill(rows.resources, rows.clients)
log(
JSON.stringify(
{
mode: options.apply ? 'apply' : 'dry-run',
resources: changes.resources.length,
clients: changes.clients.length,
},
null,
2,
),
)
if (!options.apply) return
applyBackfill(options.target, changes, execute)
const after = readRows(options.target, execute)
const remaining = buildAgentOAuthScopeBackfill(after.resources, after.clients)
if (countChanges(remaining) > 0) throw new Error(`agent_oauth_scope_backfill_failed:${countChanges(remaining)}`)
}
if (process.argv[1]?.endsWith('backfill-agent-oauth-scopes.ts')) runAgentOAuthScopeBackfill(process.argv.slice(2))
+22
View File
@@ -0,0 +1,22 @@
const QUICK_TUNNEL_502 = /trycloudflare\.com\s*\|\s*502:\s*Bad gateway/i
const CLIENT_TRANSPORT_FAILURE = /(?:\b502\b|ERR_(?:FAILED|TUNNEL_CONNECTION_FAILED)|ECONNRESET|socket hang up)/i
const TUNNEL_CONTEXT_CANCELED = /(?:Incoming request ended abruptly|Request failed)[^\n]*context canceled/i
const QUICK_TUNNEL_REQUEST = /trycloudflare\.com/i
export function isRetryableQuickTunnelFailure({ commandOutput, tunnelOutput }) {
if (QUICK_TUNNEL_502.test(commandOutput)) return true
return (
CLIENT_TRANSPORT_FAILURE.test(commandOutput) &&
TUNNEL_CONTEXT_CANCELED.test(tunnelOutput) &&
QUICK_TUNNEL_REQUEST.test(tunnelOutput)
)
}
export function cloudE2eAttemptCount(value) {
if (value === undefined) return 2
const count = Number(value)
if (!Number.isInteger(count) || count < 1) {
throw new Error('E2E_TUNNEL_RUN_ATTEMPTS must be a positive integer')
}
return count
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { cloudE2eAttemptCount, isRetryableQuickTunnelFailure } from './cloud-e2e-resilience.mjs'
describe('cloud E2E resilience', () => {
it('retries a Cloudflare Quick Tunnel gateway page', () => {
expect(
isRetryableQuickTunnelFailure({
commandOutput: '<title>trycloudflare.com | 502: Bad gateway</title>',
tunnelOutput: '',
}),
).toBe(true)
})
it('retries a client 502 corroborated by a canceled Quick Tunnel request', () => {
expect(
isRetryableQuickTunnelFailure({
commandOutput: 'expected 200, got 502',
tunnelOutput:
'Request failed error="context canceled" dest=https://fresh-tunnel.trycloudflare.com/api/events',
}),
).toBe(true)
})
it('does not retry application failures or assertions', () => {
expect(
isRetryableQuickTunnelFailure({
commandOutput: 'expected 200, got 500: checkout failed',
tunnelOutput: '',
}),
).toBe(false)
expect(
isRetryableQuickTunnelFailure({
commandOutput: 'expect(received).toEqual(expected)',
tunnelOutput:
'Incoming request ended abruptly: context canceled dest=https://fresh-tunnel.trycloudflare.com/api/events',
}),
).toBe(false)
})
it('uses two attempts by default and validates overrides', () => {
expect(cloudE2eAttemptCount()).toBe(2)
expect(cloudE2eAttemptCount('3')).toBe(3)
expect(() => cloudE2eAttemptCount('0')).toThrow('must be a positive integer')
expect(() => cloudE2eAttemptCount('nope')).toThrow('must be a positive integer')
})
})
+88 -45
View File
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { spawn } from 'node:child_process'
import { Resolver } from 'node:dns/promises'
import { createRequire } from 'node:module'
import { cloudE2eAttemptCount, isRetryableQuickTunnelFailure } from './cloud-e2e-resilience.mjs'
const args = process.argv.slice(2)
const require = createRequire(import.meta.url)
@@ -26,49 +27,60 @@ const cloudEnv = {
}
const credentialsEnv = runtimeCloudCredentials(runtime)
const tunnel = local ? null : await startTunnel(localBaseUrl)
const tunnelHost = tunnel ? new URL(tunnel.url).hostname : ''
const tunnelIp = tunnel ? await waitForPublicTunnelIp(tunnelHost) : ''
const baseUrl = tunnel?.url ?? localBaseUrl
const tunnelEnv = {
E2E_BASE_URL: baseUrl,
E2E_LOCAL_BASE_URL: localBaseUrl,
E2E_APP_PORT: String(appPort),
E2E_API_PORT: String(apiPort),
BETTER_AUTH_URL: baseUrl,
TRUSTED_ORIGINS: `${baseUrl},${localBaseUrl}`,
...(tunnel ? { E2E_CHROME_HOST_RESOLVER_RULES: `MAP ${tunnelHost} ${tunnelIp}` } : {}),
}
const e2eEnv = {
...cloudEnv,
...tunnelEnv,
...s3MockEnv(),
...credentialsEnv,
...(runtime === 'cf' ? { E2E_RUNTIME: 'cf' } : {}),
}
if (runtime === 'cf' && local) rmSync('.wrangler/state/v3/d1', { recursive: true, force: true })
if (runtime === 'cf') {
if (local) rmSync('.wrangler/state/v3/d1', { recursive: true, force: true })
writeDevVars(e2eEnv)
await run('pnpm', ['exec', 'wrangler', 'd1', 'migrations', 'apply', 'DB', '--local'], e2eEnv)
}
try {
await run(process.execPath, [require.resolve('@playwright/test/cli'), 'test', ...specs, `--project=${project}`], e2eEnv)
} finally {
if (tunnel) {
try {
tunnel.process.kill()
} catch {}
}
if (existsSync(pidFile)) {
const pid = Number(readFileSync(pidFile, 'utf8'))
if (Number.isInteger(pid)) {
try {
process.kill(pid)
} catch {}
const maxRunAttempts = local ? 1 : cloudE2eAttemptCount(process.env.E2E_TUNNEL_RUN_ATTEMPTS)
for (let attempt = 1; attempt <= maxRunAttempts; attempt += 1) {
const tunnel = local ? null : await startTunnel(localBaseUrl)
try {
const e2eEnv = await buildE2eEnv(tunnel)
if (runtime === 'cf') {
writeDevVars(e2eEnv)
await run('pnpm', ['exec', 'wrangler', 'd1', 'migrations', 'apply', 'DB', '--local'], e2eEnv)
}
rmSync(pidFile, { force: true })
try {
await run(
process.execPath,
[require.resolve('@playwright/test/cli'), 'test', ...specs, `--project=${project}`],
e2eEnv,
true,
)
break
} catch (error) {
const retryable =
error instanceof CommandError &&
tunnel &&
isRetryableQuickTunnelFailure({
commandOutput: error.output,
tunnelOutput: tunnel.output(),
})
if (!retryable || attempt === maxRunAttempts) throw error
console.warn(
`Quick Tunnel failed during cloud E2E; restarting the tunnel and test harness (${attempt + 1}/${maxRunAttempts})...`,
)
}
} finally {
stopTunnel(tunnel)
}
}
async function buildE2eEnv(tunnel) {
const tunnelHost = tunnel ? new URL(tunnel.url).hostname : ''
const tunnelIp = tunnel ? await waitForPublicTunnelIp(tunnelHost) : ''
const baseUrl = tunnel?.url ?? localBaseUrl
return {
...cloudEnv,
E2E_BASE_URL: baseUrl,
E2E_LOCAL_BASE_URL: localBaseUrl,
E2E_APP_PORT: String(appPort),
E2E_API_PORT: String(apiPort),
BETTER_AUTH_URL: baseUrl,
TRUSTED_ORIGINS: `${baseUrl},${localBaseUrl}`,
...(tunnel ? { E2E_CHROME_HOST_RESOLVER_RULES: `MAP ${tunnelHost} ${tunnelIp}` } : {}),
...s3MockEnv(),
...credentialsEnv,
...(runtime === 'cf' ? { E2E_RUNTIME: 'cf' } : {}),
}
}
@@ -137,6 +149,7 @@ function startTunnelOnce(target) {
return new Promise((resolve, reject) => {
let tunnelUrl = null
let registered = false
let output = ''
const timeout = setTimeout(() => {
child.kill()
reject(new Error('Timed out waiting for cloudflared tunnel registration'))
@@ -144,13 +157,14 @@ function startTunnelOnce(target) {
function handleOutput(chunk) {
const text = chunk.toString()
output += text
process.stdout.write(text)
const match = text.match(tunnelUrlPattern)
if (match) tunnelUrl = match[0]
if (text.includes('Registered tunnel connection')) registered = true
if (!tunnelUrl || !registered) return
clearTimeout(timeout)
resolve({ process: child, url: tunnelUrl })
resolve({ process: child, url: tunnelUrl, output: () => output })
}
child.stdout.on('data', handleOutput)
@@ -162,6 +176,18 @@ function startTunnelOnce(target) {
})
}
function stopTunnel(tunnel) {
if (tunnel) tunnel.process.kill()
if (!existsSync(pidFile)) return
const pid = Number(readFileSync(pidFile, 'utf8'))
if (Number.isInteger(pid)) {
try {
process.kill(pid)
} catch {}
}
rmSync(pidFile, { force: true })
}
function writeDevVars(env) {
const updates = {
BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET ?? 'ci-test-secret-that-is-at-least-32-chars',
@@ -201,16 +227,33 @@ async function waitForPublicTunnelIp(hostname) {
throw new Error(`Timed out waiting for public tunnel DNS: ${hostname}`)
}
function run(command, commandArgs, env = {}) {
class CommandError extends Error {
constructor(command, commandArgs, code, output) {
super(`${command} ${commandArgs.join(' ')} exited with ${code}`)
this.output = output
}
}
function run(command, commandArgs, env = {}, captureOutput = false) {
return new Promise((resolve, reject) => {
const child = spawn(command, commandArgs, {
stdio: 'inherit',
stdio: captureOutput ? ['inherit', 'pipe', 'pipe'] : 'inherit',
env: { ...process.env, ...env },
shell: process.platform === 'win32',
})
let output = ''
if (captureOutput) {
for (const stream of [child.stdout, child.stderr]) {
stream.on('data', (chunk) => {
const text = chunk.toString()
output += text
process.stdout.write(text)
})
}
}
child.on('exit', (code) => {
if (code === 0) resolve()
else reject(new Error(`${command} ${commandArgs.join(' ')} exited with ${code}`))
else reject(new CommandError(command, commandArgs, code, output))
})
})
}
+4 -2
View File
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'
import { nanoid } from 'nanoid'
export const PREVIEW_ADMIN_EMAIL = 'admin@zpan.space'
export const CREDENTIAL_ACCOUNT_ISSUER = 'local:credential'
const STAGING_D1_DB = 'zpan-db-staging'
const STAGING_ENV = 'staging'
@@ -37,12 +38,13 @@ ON CONFLICT(email) DO UPDATE SET
UPDATE account
SET password = ${passwordHash},
issuer = '${CREDENTIAL_ACCOUNT_ISSUER}',
updated_at = ${options.now}
WHERE provider_id = 'credential'
AND user_id IN (SELECT id FROM user WHERE email = ${email});
INSERT INTO account (id, account_id, provider_id, user_id, password, created_at, updated_at)
SELECT ${accountId}, u.id, 'credential', u.id, ${passwordHash}, ${options.now}, ${options.now}
INSERT INTO account (id, issuer, account_id, provider_id, user_id, password, created_at, updated_at)
SELECT ${accountId}, '${CREDENTIAL_ACCOUNT_ISSUER}', u.id, 'credential', u.id, ${passwordHash}, ${options.now}, ${options.now}
FROM user AS u
WHERE u.email = ${email}
AND NOT EXISTS (
@@ -24,9 +24,7 @@ function makeResponse(body: unknown, status = 200): Response {
}
function headerValue(headers: HeadersInit | undefined, name: string): string | null {
if (headers instanceof Headers) return headers.get(name)
if (Array.isArray(headers)) return new Headers(headers).get(name)
return headers?.[name] ?? null
return new Headers(headers).get(name)
}
describe('licensing-cloud', () => {
+24 -33
View File
@@ -98,8 +98,7 @@ async function requireTargetQuota(db: Database, orgId: string): Promise<void> {
}
function insertQuotaEntitlementQueries(db: Database, event: CloudOrderQuotaChange, now: Date): AtomicQuery[] {
return quotaEntitlementValues(event, now).flatMap((value) => [
...revokeExistingPlanQueries(db, value, now),
return quotaEntitlementValues(event, now).map((value) =>
db
.insert(orgQuotaEntitlements)
.values(value)
@@ -107,30 +106,7 @@ function insertQuotaEntitlementQueries(db: Database, event: CloudOrderQuotaChang
target: [orgQuotaEntitlements.source, orgQuotaEntitlements.sourceId, orgQuotaEntitlements.resourceType],
set: quotaEntitlementIncreaseValues(value, now),
}),
])
}
function revokeExistingPlanQueries(
db: Database,
value: typeof orgQuotaEntitlements.$inferInsert,
now: Date,
): AtomicQuery[] {
if (value.entitlementType !== 'plan') return []
return [
db
.update(orgQuotaEntitlements)
.set({ status: 'revoked', updatedAt: now })
.where(
and(
eq(orgQuotaEntitlements.orgId, value.orgId),
eq(orgQuotaEntitlements.resourceType, value.resourceType),
eq(orgQuotaEntitlements.entitlementType, 'plan'),
eq(orgQuotaEntitlements.status, 'active'),
sql`${orgQuotaEntitlements.source} <> 'free_plan'`,
sql`${orgQuotaEntitlements.sourceId} != ${value.sourceId}`,
),
),
]
)
}
function revokeQuotaEntitlementQueries(db: Database, event: CloudOrderQuotaChange, now: Date): AtomicQuery[] {
@@ -195,9 +171,10 @@ function quotaEntitlementValues(event: CloudOrderQuotaChange, now: Date): (typeo
}
function quotaEntitlementIncreaseValues(value: typeof orgQuotaEntitlements.$inferInsert, now: Date) {
const bytes = isSubscriptionSourceId(value.sourceId)
? value.bytes
: (sql`CASE
const bytes =
value.entitlementType === 'plan'
? value.bytes
: (sql`CASE
WHEN ${orgQuotaEntitlements.status} = 'active' THEN ${orgQuotaEntitlements.bytes} + ${value.bytes}
ELSE ${value.bytes}
END` as unknown as number)
@@ -231,11 +208,11 @@ function quotaEntitlementValue(
id: nanoid(),
orgId: event.targetOrgId,
resourceType,
entitlementType: isSubscriptionSourceId(event.cloudOrderId) ? 'plan' : 'grant',
entitlementType: entitlementType(event),
source: 'cloud_order',
sourceId: event.cloudOrderId,
bytes,
startsAt: now,
startsAt: eventStart(event, now),
expiresAt: event.expiresAt ? new Date(event.expiresAt) : null,
status: 'active',
metadata: JSON.stringify(quotaEntitlementMetadata(event)),
@@ -268,11 +245,25 @@ function quotaEntitlementMetadata(event: CloudOrderQuotaChange) {
expiresAt: event.expiresAt ?? null,
customerId: event.customerId ?? null,
customerEmail: event.customerEmail ?? null,
paymentProvider: eventValue(event, 'paymentProvider'),
providerTransactionId: eventValue(event, 'providerTransactionId'),
x402AuditContext: eventValue(event, 'x402AuditContext'),
}
}
function isSubscriptionSourceId(sourceId: string) {
return sourceId.startsWith('stripe_subscription:')
function entitlementType(event: CloudOrderQuotaChange): 'plan' | 'grant' {
return eventValue(event, 'entitlementType') === 'plan' || event.cloudOrderId.startsWith('stripe_subscription:')
? 'plan'
: 'grant'
}
function eventStart(event: CloudOrderQuotaChange, fallback: Date): Date {
const value = eventValue(event, 'startsAt')
return typeof value === 'string' ? new Date(value) : fallback
}
function eventValue(event: CloudOrderQuotaChange, key: string): unknown {
return key in event ? (event as unknown as Record<string, unknown>)[key] : null
}
async function beginWebhookEvent(
@@ -0,0 +1,174 @@
import { eq } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import { x402CapacityPurchaseIntents } from '../../db/schema'
import { createTestApp } from '../../test/setup'
import { createX402CapacityPurchaseRepo } from './x402-capacity-purchase'
describe('x402 capacity purchase repo', () => {
it('scopes client idempotency keys to the workspace', async () => {
const { db } = await createTestApp()
const repo = createX402CapacityPurchaseRepo(db)
const first = await repo.create({
orgId: 'org-1',
resourceId: 'resource-1',
requestHash: 'hash-1',
idempotencyKey: 'purchase-storage',
})
const second = await repo.create({
orgId: 'org-2',
resourceId: 'resource-1',
requestHash: 'hash-2',
idempotencyKey: 'purchase-storage',
})
if (!first || !second) throw new Error('Expected both workspace reservations to succeed')
expect(first.orgId).toBe('org-1')
expect(second.orgId).toBe('org-2')
})
it('rejects a reused idempotency key within the same workspace', async () => {
const { db } = await createTestApp()
const repo = createX402CapacityPurchaseRepo(db)
await repo.create({
orgId: 'org-1',
resourceId: 'resource-1',
requestHash: 'hash-1',
idempotencyKey: 'purchase-storage',
})
await expect(
repo.create({
orgId: 'org-1',
resourceId: 'resource-2',
requestHash: 'hash-2',
idempotencyKey: 'purchase-storage',
}),
).rejects.toThrow()
})
it('allows only one active order creator and recovers a stale claim', async () => {
const { db } = await createTestApp()
const repo = createX402CapacityPurchaseRepo(db)
const intent = await repo.create({
orgId: 'org-1',
resourceId: 'resource-1',
requestHash: 'hash-1',
idempotencyKey: 'purchase-storage',
})
if (!intent) throw new Error('Expected purchase reservation to succeed')
expect(await repo.claimCloudOrder(intent.id, new Date(0))).toBe(true)
expect(await repo.claimCloudOrder(intent.id, new Date(0))).toBe(false)
await repo.updateCloudState(intent.id, {
status: 'ordering',
})
expect(await repo.claimCloudOrder(intent.id, new Date(Date.now() + 1000))).toBe(true)
})
it('atomically caps pending unpaid intents per workspace', async () => {
const { db } = await createTestApp()
const repo = createX402CapacityPurchaseRepo(db)
const reservations = await Promise.all(
Array.from({ length: 10 }, (_, i) =>
repo.create({
orgId: 'org-1',
resourceId: `resource-${i}`,
requestHash: `hash-${i}`,
idempotencyKey: `purchase-${i}`,
}),
),
)
expect(reservations.filter(Boolean)).toHaveLength(5)
await expect(
repo.create({
orgId: 'org-2',
resourceId: 'resource-other-workspace',
requestHash: 'hash-other-workspace',
idempotencyKey: 'purchase-other-workspace',
}),
).resolves.not.toBeNull()
})
it('removes abandoned unpaid intents before reserving capacity', async () => {
const { db } = await createTestApp()
const repo = createX402CapacityPurchaseRepo(db)
const abandoned = await repo.create({
orgId: 'org-1',
resourceId: 'resource-abandoned',
requestHash: 'hash-abandoned',
idempotencyKey: 'purchase-abandoned',
})
expect(abandoned).not.toBeNull()
await db
.update(x402CapacityPurchaseIntents)
.set({ updatedAt: new Date(Date.now() - 25 * 60 * 60 * 1000) })
.where(eq(x402CapacityPurchaseIntents.id, abandoned!.id))
await expect(
repo.create({
orgId: 'org-1',
resourceId: 'resource-fresh',
requestHash: 'hash-fresh',
idempotencyKey: 'purchase-fresh',
}),
).resolves.not.toBeNull()
await expect(
db.select().from(x402CapacityPurchaseIntents).where(eq(x402CapacityPurchaseIntents.id, abandoned!.id)),
).resolves.toEqual([])
})
it('does not count expired quotes against the pending limit', async () => {
const { db } = await createTestApp()
const repo = createX402CapacityPurchaseRepo(db)
for (let i = 0; i < 5; i += 1) {
const intent = await repo.create({
orgId: 'org-1',
resourceId: `expired-resource-${i}`,
requestHash: `expired-hash-${i}`,
idempotencyKey: `expired-idempotency-${i}`,
})
expect(intent).not.toBeNull()
await repo.updateCloudState(intent!.id, {
status: 'quoted',
expiresAt: new Date(Date.now() - 1000),
})
}
await expect(
repo.create({
orgId: 'org-1',
resourceId: 'resource-after-expiry',
requestHash: 'hash-after-expiry',
idempotencyKey: 'purchase-after-expiry',
}),
).resolves.not.toBeNull()
})
it('caps total intent creation per workspace within the rolling hour', async () => {
const { db } = await createTestApp()
const repo = createX402CapacityPurchaseRepo(db)
for (let i = 0; i < 20; i += 1) {
const intent = await repo.create({
orgId: 'org-1',
resourceId: `resource-${i}`,
requestHash: `hash-${i}`,
idempotencyKey: `purchase-${i}`,
})
expect(intent).not.toBeNull()
await repo.updateCloudState(intent!.id, { status: 'failed' })
}
await expect(
repo.create({
orgId: 'org-1',
resourceId: 'resource-hourly-over-limit',
requestHash: 'hash-hourly-over-limit',
idempotencyKey: 'purchase-hourly-over-limit',
}),
).resolves.toBeNull()
})
})
@@ -0,0 +1,94 @@
import { and, eq, isNull, lt, or, sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { x402CapacityPurchaseIntents } from '../../db/schema'
import { executeWriteTransactionWithResults } from '../../db/transaction'
import type { Database } from '../../platform/interface'
import type { X402CapacityPurchaseRepo } from '../../usecases/ports'
const MAX_PENDING_INTENTS_PER_ORG = 5
const MAX_INTENTS_PER_HOUR_PER_ORG = 20
const INTENT_CREATION_WINDOW_MS = 60 * 60 * 1000
const PENDING_INTENT_TTL_MS = 15 * 60 * 1000
const ABANDONED_INTENT_RETENTION_MS = 24 * 60 * 60 * 1000
export function createX402CapacityPurchaseRepo(db: Database): X402CapacityPurchaseRepo {
return {
async get(orgId, resourceId, requestHash) {
const rows = await db
.select()
.from(x402CapacityPurchaseIntents)
.where(
and(
eq(x402CapacityPurchaseIntents.orgId, orgId),
eq(x402CapacityPurchaseIntents.resourceId, resourceId),
eq(x402CapacityPurchaseIntents.requestHash, requestHash),
),
)
.limit(1)
return rows[0] ?? null
},
async create(input) {
const now = new Date()
const nowMs = now.getTime()
const id = nanoid()
const abandonedBefore = new Date(nowMs - PENDING_INTENT_TTL_MS)
const creationWindowStart = new Date(nowMs - INTENT_CREATION_WINDOW_MS)
const retentionBefore = new Date(nowMs - ABANDONED_INTENT_RETENTION_MS)
const cleanup = db.delete(x402CapacityPurchaseIntents).where(sql`
${x402CapacityPurchaseIntents.updatedAt} < ${retentionBefore.getTime()}
AND ${x402CapacityPurchaseIntents.status} IN ('created', 'ordering', 'ordered', 'quoted', 'expired', 'failed', 'canceled')
`)
const insert = db
.insert(x402CapacityPurchaseIntents)
.select(sql`
SELECT
${id}, ${input.orgId}, ${input.resourceId}, ${input.requestHash}, ${input.idempotencyKey},
NULL, NULL, 'created', NULL, ${nowMs}, ${nowMs}
WHERE (
SELECT COUNT(*) FROM ${x402CapacityPurchaseIntents}
WHERE ${x402CapacityPurchaseIntents.orgId} = ${input.orgId}
AND ${x402CapacityPurchaseIntents.createdAt} >= ${creationWindowStart.getTime()}
) < ${MAX_INTENTS_PER_HOUR_PER_ORG}
AND (
SELECT COUNT(*) FROM ${x402CapacityPurchaseIntents}
WHERE ${x402CapacityPurchaseIntents.orgId} = ${input.orgId}
AND ${x402CapacityPurchaseIntents.status} IN ('created', 'ordering', 'ordered', 'quoted')
AND (
(${x402CapacityPurchaseIntents.status} = 'quoted'
AND (${x402CapacityPurchaseIntents.expiresAt} IS NULL OR ${x402CapacityPurchaseIntents.expiresAt} > ${nowMs}))
OR (${x402CapacityPurchaseIntents.status} <> 'quoted' AND ${x402CapacityPurchaseIntents.updatedAt} >= ${abandonedBefore.getTime()})
)
) < ${MAX_PENDING_INTENTS_PER_ORG}
`)
.returning()
const [, inserted] = await executeWriteTransactionWithResults(db, [cleanup, insert], [1])
return (inserted as (typeof x402CapacityPurchaseIntents.$inferSelect)[] | undefined)?.[0] ?? null
},
async claimCloudOrder(id, staleBefore) {
const rows = await db
.update(x402CapacityPurchaseIntents)
.set({ status: 'ordering', updatedAt: new Date() })
.where(
and(
eq(x402CapacityPurchaseIntents.id, id),
isNull(x402CapacityPurchaseIntents.cloudOrderId),
or(
eq(x402CapacityPurchaseIntents.status, 'created'),
and(
eq(x402CapacityPurchaseIntents.status, 'ordering'),
lt(x402CapacityPurchaseIntents.updatedAt, staleBefore),
),
),
),
)
.returning({ id: x402CapacityPurchaseIntents.id })
return rows.length === 1
},
async updateCloudState(id, input) {
await db
.update(x402CapacityPurchaseIntents)
.set({ ...input, updatedAt: new Date() })
.where(eq(x402CapacityPurchaseIntents.id, id))
},
}
}
+87 -2
View File
@@ -1,7 +1,9 @@
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { isPersonalOrgLike } from '@shared/org-slugs'
import { deriveDpopAth } from 'better-auth/oauth2'
import { eq } from 'drizzle-orm'
import { eq, sql } from 'drizzle-orm'
import { exportJWK, generateKeyPair, SignJWT } from 'jose'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createInviteRepo } from './adapters/repos/invite.js'
@@ -320,6 +322,46 @@ describe('isEmailConfigured — via emailVerification conditional', () => {
})
})
describe('Better Auth account issuer migration', () => {
it('restores email sign-in for a legacy credential account', async () => {
const ctx = await createTestApp()
const email = 'legacy-issuer@example.com'
await signUp(ctx, email)
await ctx.db
.update(authSchema.account)
.set({ issuer: '' })
.where(
eq(
authSchema.account.userId,
(await ctx.db.query.user.findFirst({ where: eq(authSchema.user.email, email) }))!.id,
),
)
const rejected = await ctx.app.request('/api/auth/sign-in/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password: 'password123456' }),
})
expect(rejected.status).toBe(401)
const migration = readFileSync(
join(process.cwd(), 'migrations/0089_better-auth-account-issuer-backfill.sql'),
'utf-8',
)
for (const statement of migration.split('--> statement-breakpoint')) {
await ctx.db.run(sql.raw(statement))
}
const restored = await ctx.app.request('/api/auth/sign-in/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password: 'password123456' }),
})
expect(restored.status).toBe(200)
expect(restored.headers.getSetCookie()).not.toHaveLength(0)
})
})
describe('dynamic email verification policy', () => {
it('requires verification immediately and resends the email on sign-in', async () => {
const { vi } = await import('vitest')
@@ -682,7 +724,7 @@ describe('loadProviderConfigs — builtin social provider resolution', () => {
expect(res.status).not.toBe(200)
})
it('createAuth initializes provider config and the two OAuth resources with three DB reads', async () => {
it('createAuth initializes provider config and OAuth resources without scanning OAuth clients', async () => {
const ctx = await createTestApp()
let selectCalls = 0
const countingDb = new Proxy(ctx.db, {
@@ -710,6 +752,49 @@ describe('loadProviderConfigs — builtin social provider resolution', () => {
})
})
describe('Cloudflare Workers preview auth origins', () => {
const configuredOrigin = 'https://zpan-staging.saltbo.workers.dev'
const commitOrigin = 'https://99dc50ae-zpan.saltbo.workers.dev'
const branchOrigin = 'https://feat-x402-paid-agent-uploads-zpan.saltbo.workers.dev'
it('accepts official commit and branch aliases on the same cached auth instance', async () => {
const ctx = await createTestApp()
const auth = await createAuth(ctx.platform, 'test-secret', configuredOrigin, [configuredOrigin])
const app = createApp(ctx.platform, auth)
const email = `preview-${Date.now()}@example.com`
const password = 'password123456'
const signUp = await app.request(`${configuredOrigin}/api/auth/sign-up/email`, {
method: 'POST',
headers: { Origin: configuredOrigin, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Preview User', email, password }),
})
expect(signUp.status).toBe(200)
for (const origin of [commitOrigin, branchOrigin]) {
const signIn = await app.request(`${origin}/api/auth/sign-in/email`, {
method: 'POST',
headers: { Origin: origin, 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, callbackURL: `${origin}/files` }),
})
expect(signIn.status, await signIn.clone().text()).toBe(200)
}
})
it('rejects unrelated workers.dev origins', async () => {
const ctx = await createTestApp()
const auth = await createAuth(ctx.platform, 'test-secret', configuredOrigin, [configuredOrigin])
const app = createApp(ctx.platform, auth)
const origin = 'https://unrelated-worker.other-account.workers.dev'
const signIn = await app.request(`${origin}/api/auth/sign-in/email`, {
method: 'POST',
headers: { Origin: origin, 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'nobody@example.com', password: 'password123456', callbackURL: `${origin}/files` }),
})
expect(signIn.status).toBe(403)
})
})
describe('Agent OAuth consent guards', () => {
it('publishes the external resource discovery contract at the exact API URL', async () => {
const ctx = await createTestApp()
+43 -1
View File
@@ -136,6 +136,42 @@ async function dynamicRegistrationOrigins(request: Request): Promise<string[]> {
}
}
export function officialWorkersPreviewOrigin(
baseURL: string | undefined,
candidate: string | null | undefined,
): string | null {
if (!baseURL || !candidate) return null
try {
const configured = new URL(baseURL)
const preview = new URL(candidate)
if (configured.protocol !== 'https:' || preview.protocol !== 'https:') return null
const configuredLabels = configured.hostname.toLowerCase().split('.')
const previewLabels = preview.hostname.toLowerCase().split('.')
if (
configuredLabels.length !== 4 ||
previewLabels.length !== 4 ||
configuredLabels[2] !== 'workers' ||
configuredLabels[3] !== 'dev' ||
previewLabels[2] !== 'workers' ||
previewLabels[3] !== 'dev' ||
configuredLabels[1] !== previewLabels[1]
) {
return null
}
const configuredWorker = configuredLabels[0]
const workerName = configuredWorker.endsWith('-staging')
? configuredWorker.slice(0, -'-staging'.length)
: configuredWorker.replace(/^[0-9a-f]{8}-/, '')
const previewWorker = previewLabels[0]
if (!workerName || (previewWorker !== workerName && !previewWorker.endsWith(`-${workerName}`))) return null
return preview.origin
} catch {
return null
}
}
// One query loads every oauth_provider_* row. Configs are snapshotted at auth
// instance creation: better-auth resolves social providers eagerly during its
// context init, so per-request dynamic loading is not possible anyway. Admin
@@ -405,7 +441,13 @@ export async function createAuth(
const origin = request?.headers.get('origin')
const list = trustedOrigins ?? []
const registrationOrigins = request ? await dynamicRegistrationOrigins(request) : []
return [...list, ...(origin && isLocalNetworkOrigin(origin) ? [origin] : []), ...registrationOrigins]
const previewOrigin = officialWorkersPreviewOrigin(baseURL, origin)
return [
...list,
...(origin && isLocalNetworkOrigin(origin) ? [origin] : []),
...(previewOrigin ? [previewOrigin] : []),
...registrationOrigins,
]
},
advanced: {
cookiePrefix: 'zp',
+2
View File
@@ -53,6 +53,7 @@ import { createTeamInviteRepo } from './adapters/repos/team-invite'
import { createUserAdminRepo } from './adapters/repos/user-admin'
import { createWebDavPathRepo } from './adapters/repos/webdav-path'
import { createWebDavStateRepo } from './adapters/repos/webdav-state'
import { createX402CapacityPurchaseRepo } from './adapters/repos/x402-capacity-purchase'
import { createZipPlanRepo } from './adapters/repos/zip'
import type { Platform } from './platform/interface'
import type { Deps } from './usecases/deps'
@@ -128,5 +129,6 @@ export function createDeps(platform: Platform, options: CreateDepsOptions = {}):
webdavState: createWebDavStateRepo(db),
zip: createZipGateway(),
zipPlan: createZipPlanRepo(db),
x402CapacityPurchases: createX402CapacityPurchaseRepo(db),
}
}
+70
View File
@@ -210,3 +210,73 @@ describe('migration 0069_storage-health-status-vocabulary.sql', () => {
}
})
})
describe('migration 0089_better-auth-account-issuer-backfill.sql', () => {
const migrationPath = join(process.cwd(), 'migrations/0089_better-auth-account-issuer-backfill.sql')
const migration = readFileSync(migrationPath, 'utf-8')
it('backfills legacy credential and OAuth issuers without overwriting explicit issuers', () => {
const db = new Database(':memory:')
try {
db.exec(`
CREATE TABLE account (
id TEXT PRIMARY KEY NOT NULL,
issuer TEXT DEFAULT '' NOT NULL,
account_id TEXT NOT NULL,
provider_id TEXT NOT NULL
);
CREATE UNIQUE INDEX account_issuer_providerAccountId_unique ON account (issuer, account_id);
INSERT INTO account (id, issuer, account_id, provider_id) VALUES
('credential', '', 'user-1', 'credential'),
('github', '', 'github-user', 'github'),
('google', '', 'google-user', 'google'),
('explicit', 'https://issuer.example.com', 'subject-1', 'external');
`)
db.exec(migration)
db.exec(migration)
expect(db.prepare('SELECT id, issuer FROM account ORDER BY id').all()).toEqual([
{ id: 'credential', issuer: 'local:credential' },
{ id: 'explicit', issuer: 'https://issuer.example.com' },
{ id: 'github', issuer: 'local:oauth:github' },
{ id: 'google', issuer: 'https://accounts.google.com' },
])
expect(() =>
db
.prepare('INSERT INTO account (id, issuer, account_id, provider_id) VALUES (?, ?, ?, ?)')
.run('duplicate', 'local:oauth:github', 'github-user', 'github'),
).toThrow(/UNIQUE constraint failed/)
} finally {
db.close()
}
})
it('fails before changing data when a legacy provider has no safe issuer mapping', () => {
const db = new Database(':memory:')
try {
db.exec(`
CREATE TABLE account (
id TEXT PRIMARY KEY NOT NULL,
issuer TEXT DEFAULT '' NOT NULL,
account_id TEXT NOT NULL,
provider_id TEXT NOT NULL
);
CREATE UNIQUE INDEX account_issuer_providerAccountId_unique ON account (issuer, account_id);
INSERT INTO account (id, issuer, account_id, provider_id) VALUES
('credential', '', 'user-1', 'credential'),
('unknown', '', 'subject-1', 'unknown-provider');
`)
expect(() => db.exec(migration)).toThrow(/malformed JSON/)
expect(db.prepare('SELECT id, issuer FROM account ORDER BY id').all()).toEqual([
{ id: 'credential', issuer: '' },
{ id: 'unknown', issuer: '' },
])
} finally {
db.close()
}
})
})
+22 -3
View File
@@ -189,9 +189,6 @@ export const orgQuotaEntitlements = sqliteTable(
(t) => [
index('org_quota_entitlements_org_resource_idx').on(t.orgId, t.resourceType, t.status),
index('org_quota_entitlements_org_type_idx').on(t.orgId, t.resourceType, t.entitlementType, t.status),
uniqueIndex('org_quota_entitlements_active_plan_uniq')
.on(t.orgId, t.resourceType, t.entitlementType)
.where(sql`status = 'active' AND entitlement_type = 'plan' AND source <> 'free_plan'`),
uniqueIndex('org_quota_entitlements_source_resource_uniq').on(t.source, t.sourceId, t.resourceType),
],
)
@@ -218,6 +215,28 @@ export const webhookEvents = sqliteTable(
],
)
export const x402CapacityPurchaseIntents = sqliteTable(
'x402_capacity_purchase_intents',
{
id: text('id').primaryKey(),
orgId: text('org_id').notNull(),
resourceId: text('resource_id').notNull(),
requestHash: text('request_hash').notNull(),
idempotencyKey: text('idempotency_key').notNull(),
cloudOrderId: text('cloud_order_id'),
cloudAttemptId: text('cloud_attempt_id'),
status: text('status').notNull().default('created'),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
},
(t) => [
uniqueIndex('x402_capacity_purchase_intents_org_request_uniq').on(t.orgId, t.resourceId, t.requestHash),
uniqueIndex('x402_capacity_purchase_intents_org_idempotency_uniq').on(t.orgId, t.idempotencyKey),
index('x402_capacity_purchase_intents_attempt_idx').on(t.cloudAttemptId),
],
)
export const inviteCodes = sqliteTable('invite_codes', {
id: text('id').primaryKey(),
code: text('code').notNull().unique(),
+37 -1
View File
@@ -24,7 +24,7 @@ export function createArazzoDocument(origin: string) {
workflowId: 'prepareDirectFileUpload',
summary: 'Prepare a direct file upload',
description:
'Creates a file draft and returns the runtime upload descriptor. PUT every local file slice identified by upload.parts[].offset and upload.parts[].length to upload.parts[].url with upload.parts[].headers. Capture each response ETag, then invoke completeDirectFileUpload. If a presigned URL expires, invoke refreshDirectFileUploadParts. File bytes are sent directly to storage, not to ZPan.',
'Creates a file draft and returns the runtime upload descriptor. If capacity is insufficient, createObject returns 402 with requestHash and eligible offers; invoke purchaseStorageCapacityWithX402 for one offer, then retry createObject. PUT every local file slice identified by upload.parts[].offset and upload.parts[].length to upload.parts[].url with upload.parts[].headers. Capture each response ETag, then invoke completeDirectFileUpload. If a presigned URL expires, invoke refreshDirectFileUploadParts. File bytes are sent directly to storage, not to ZPan.',
inputs: {
type: 'object',
properties: {
@@ -68,6 +68,42 @@ export function createArazzoDocument(origin: string) {
upload: '$steps.createUploadDraft.outputs.upload',
},
},
{
workflowId: 'purchaseStorageCapacityWithX402',
summary: 'Purchase workspace storage capacity',
description:
'Select an offer returned by createObject 402. Call purchaseStorageCapacity without PAYMENT-SIGNATURE to obtain PAYMENT-REQUIRED, pay that challenge, then retry the same operation with PAYMENT-SIGNATURE. After a delivered response, retry the original createObject request.',
inputs: {
type: 'object',
properties: {
resourceId: { type: 'string', minLength: 1 },
requestHash: { type: 'string', minLength: 1 },
idempotencyKey: { type: 'string', minLength: 1 },
},
required: ['resourceId', 'requestHash', 'idempotencyKey'],
},
steps: [
{
stepId: 'requestPaymentChallenge',
operationId: 'purchaseStorageCapacity',
parameters: [{ name: 'resourceId', in: 'path', value: '$inputs.resourceId' }],
requestBody: {
contentType: 'application/json',
payload: {
requestHash: '$inputs.requestHash',
idempotencyKey: '$inputs.idempotencyKey',
},
},
successCriteria: [{ condition: '$statusCode == 402' }],
outputs: {
paymentRequired: '$response.body',
},
},
],
outputs: {
paymentRequired: '$steps.requestPaymentChallenge.outputs.paymentRequired',
},
},
{
workflowId: 'refreshDirectFileUploadParts',
summary: 'Refresh expired direct-upload URLs',
+34
View File
@@ -40,6 +40,40 @@ describe('[CF] Auth API', () => {
expect(res.headers.get('set-cookie')).toBeTruthy()
})
it('accepts official Workers commit and branch aliases but rejects unrelated workers.dev', async () => {
const platform = createCloudflarePlatform(env)
const configuredOrigin = 'https://zpan-staging.saltbo.workers.dev'
const commitOrigin = 'https://99dc50ae-zpan.saltbo.workers.dev'
const branchOrigin = 'https://feat-x402-paid-agent-uploads-zpan.saltbo.workers.dev'
const auth = await createAuth(platform.db, env.BETTER_AUTH_SECRET, configuredOrigin, [configuredOrigin])
const app = createApp(platform, auth)
const email = `cf-preview-${Date.now()}@example.com`
const password = 'password123456'
const signUp = await app.request(`${configuredOrigin}/api/auth/sign-up/email`, {
method: 'POST',
headers: { Origin: configuredOrigin, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'CF Preview User', email, password }),
})
expect(signUp.status).toBe(200)
for (const origin of [commitOrigin, branchOrigin]) {
const signIn = await app.request(`${origin}/api/auth/sign-in/email`, {
method: 'POST',
headers: { Origin: origin, 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, callbackURL: `${origin}/files` }),
})
expect(signIn.status, await signIn.clone().text()).toBe(200)
}
const unrelatedOrigin = 'https://unrelated-worker.other-account.workers.dev'
const rejected = await app.request(`${unrelatedOrigin}/api/auth/sign-in/email`, {
method: 'POST',
headers: { Origin: unrelatedOrigin, 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, callbackURL: `${unrelatedOrigin}/files` }),
})
expect(rejected.status).toBe(403)
})
it('completes managed Agent OAuth consent on D1', async () => {
const app = await buildApp()
const signUp = await app.request('/api/auth/sign-up/email', {
@@ -1628,6 +1628,9 @@ describe('Download tasks API integration', () => {
})
expect(taskRes.status).toBe(201)
const task = (await taskRes.json()) as DownloadTask
await db.run(
sql`UPDATE org_quota_entitlements SET bytes = ${10 * 1024 * 1024 * 1024} WHERE org_id = ${task.orgId} AND resource_type = 'storage'`,
)
await claimTaskForDownloader(app, createdDownloader.token, task.id)
const tasksRes = await app.request('/api/downloads/downloaders/me/tasks?status=assigned', {
@@ -1845,6 +1848,9 @@ describe('Download tasks API integration', () => {
...(await authedHeaders(app, 'multipart-complete-user@example.com')),
'Content-Type': 'application/json',
}
await db.run(
sql`UPDATE org_quota_entitlements SET bytes = ${10 * 1024 * 1024 * 1024} WHERE resource_type = 'storage'`,
)
const createObjectRes = await app.request('/api/objects', {
method: 'POST',
+95 -16
View File
@@ -19,6 +19,7 @@ import type {
MatterListFilters,
UpdateMatterInput,
} from '../usecases/ports.js'
import { createCapacityRequestHash } from './objects.js'
type TestDbForMatter = Awaited<ReturnType<typeof createTestApp>>['db']
@@ -86,6 +87,18 @@ beforeEach(() => {
vi.spyOn(S3Service.prototype, 'completeMultipartUpload').mockResolvedValue(undefined)
})
describe('capacity purchase challenges', () => {
it('creates a fresh request hash for repeated identical uploads', async () => {
const input = { name: 'same.bin', type: 'application/octet-stream', size: 1024 }
const first = await createCapacityRequestHash('org-1', input)
const second = await createCapacityRequestHash('org-1', input)
expect(first).toMatch(/^[0-9a-f]{64}$/)
expect(second).toMatch(/^[0-9a-f]{64}$/)
expect(second).not.toBe(first)
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
@@ -1851,6 +1864,69 @@ describe('Objects API — quota enforcement', () => {
})
}
function stubCapacityStoreWithoutOffers() {
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
const url = String(input instanceof Request ? input.url : input)
if (url.includes('/products')) {
return Response.json({ items: [], total: 0, limit: 100, offset: 0 })
}
if (url.endsWith('/publication')) {
return Response.json({
storeId: 'store-test-binding',
mode: 'directory',
listingStatus: 'listed',
displayName: 'ZPan',
summary: null,
publicMetadata: {},
skillUrl: null,
termsUrl: null,
healthUrl: 'https://files.example/api/health',
healthStatus: 'healthy',
resources: [],
createdAt: '2026-07-30T00:00:00.000Z',
updatedAt: '2026-07-30T00:00:00.000Z',
})
}
throw new Error(`Unexpected Cloud request: ${url}`)
}),
)
}
it('surfaces capacity-offer lookup failures instead of reporting quota exhaustion', async () => {
const { app, db } = await createTestApp({ ZPAN_CLOUD_URL: 'https://cloud.example' })
await seedBusinessLicense(db)
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await setOrgQuota(db, orgId, 100, 90)
vi.stubGlobal(
'fetch',
vi.fn(async () => Response.json({ error: { code: 'cloud_unavailable' } }, { status: 503 })),
)
const res = await app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
dirtype: 0,
name: 'toobig.txt',
parent: '',
size: 50,
type: 'text/plain',
}),
})
expect(res.status).toBe(502)
await expect(res.json()).resolves.toMatchObject({
error: {
message: 'cloud_unavailable',
details: [{ reason: 'UNAVAILABLE' }],
},
})
})
// ─── POST /api/objects/copy — quota enforcement ──────────────────────────
describe('POST /api/objects/copy — quota enforcement', () => {
@@ -2220,14 +2296,21 @@ describe('Objects API — quota enforcement', () => {
headers: Record<string, string>,
body: { name: string; size: number; onConflict?: string },
): Promise<{ id: string; sessionId: string }> {
const res = await app.request('/api/objects', {
const res = await createDraftResponse(app, headers, body)
if (res.status !== 201) throw new Error(`create failed: ${res.status} ${await res.text()}`)
const created = (await res.json()) as { id: string; upload: { sessionId: string } }
return { id: created.id, sessionId: created.upload.sessionId }
}
function createDraftResponse(
app: Awaited<ReturnType<typeof createTestApp>>['app'],
headers: Record<string, string>,
body: { name: string; size: number; onConflict?: string },
) {
return app.request('/api/objects', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'text/plain', parent: '', dirtype: 0, ...body }),
})
if (res.status !== 201) throw new Error(`create failed: ${res.status} ${await res.text()}`)
const created = (await res.json()) as { id: string; upload: { sessionId: string } }
return { id: created.id, sessionId: created.upload.sessionId }
}
// The conflict strategy is fixed at create time (stored on the session); the
// completions body carries only the uploaded parts.
@@ -2279,7 +2362,7 @@ describe('Objects API — quota enforcement', () => {
expect(quotaRows[0]).toEqual({ used: 140, quota: 100 })
})
it('enforces storage entitlements when the legacy quota column is zero', async () => {
it('rejects upload preparation when storage entitlements are exhausted', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await authedHeaders(app)
@@ -2287,9 +2370,8 @@ describe('Objects API — quota enforcement', () => {
const orgId = await getOrgId(db)
await setOrgQuota(db, orgId, 0, 90)
await addStorageEntitlement(db, orgId, 100)
const ref = await createDraft(app, headers, { name: 'limited.txt', size: 11 })
const res = await complete(app, headers, ref)
stubCapacityStoreWithoutOffers()
const res = await createDraftResponse(app, headers, { name: 'limited.txt', size: 11 })
expect(res.status).toBe(422)
await expect(res.json()).resolves.toMatchObject({ error: { message: 'Quota exceeded' } })
})
@@ -2308,7 +2390,7 @@ describe('Objects API — quota enforcement', () => {
expect(storageRows[0].used).toBe(500)
})
it('returns 422 when finalizing upload would exceed quota', async () => {
it('returns 422 before upload when the file would exceed quota', async () => {
const { app, db } = await createTestApp()
await seedProLicense(db)
const headers = await authedHeaders(app)
@@ -2316,9 +2398,8 @@ describe('Objects API — quota enforcement', () => {
const orgId = await getOrgId(db)
// quota = 100, used = 90, file size = 50 → exceeds
await setOrgQuota(db, orgId, 100, 90)
const ref = await createDraft(app, headers, { name: 'toobig.txt', size: 50 })
const res = await complete(app, headers, ref)
stubCapacityStoreWithoutOffers()
const res = await createDraftResponse(app, headers, { name: 'toobig.txt', size: 50 })
expect(res.status).toBe(422)
const body = (await res.json()) as { error: { message: string; details: Array<{ reason: string }> } }
expect(body.error.message).toBe('Quota exceeded')
@@ -2343,16 +2424,14 @@ describe('Objects API — quota enforcement', () => {
expect(quotaRows[0].used).toBe(50)
})
it('returns 422 when no quota row or entitlement exists', async () => {
it('returns 422 before upload when no quota row or entitlement exists', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await db.delete(orgQuotaEntitlements).where(eq(orgQuotaEntitlements.orgId, orgId))
await db.delete(orgQuotas).where(eq(orgQuotas.orgId, orgId))
const ref = await createDraft(app, headers, { name: 'nolimit.txt', size: 5000 })
const res = await complete(app, headers, ref)
const res = await createDraftResponse(app, headers, { name: 'nolimit.txt', size: 5000 })
expect(res.status).toBe(422)
await expect(res.json()).resolves.toMatchObject({ error: { details: [{ reason: 'QUOTA_EXCEEDED' }] } })
})
+61 -2
View File
@@ -32,7 +32,8 @@ import {
trashObject,
updateObject,
} from '../usecases/object'
import { badRequest, forbidden, type Matter, type MatterListItem } from '../usecases/ports'
import { badRequest, forbidden, type Matter, type MatterListItem, quotaExceeded } from '../usecases/ports'
import { describeCapacityRequirement } from '../usecases/store/store'
import { recordDownloadIssued } from '../usecases/transfer-activity'
import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi'
import { decodeOptionalPageToken, directoryCursorCodec, encodeNextPageToken, pageQueryFingerprint } from './page-token'
@@ -86,6 +87,12 @@ function toMatterDTO(m: Matter): MatterDTO {
}
}
export async function createCapacityRequestHash(orgId: string, input: unknown): Promise<string> {
const bytes = new TextEncoder().encode(JSON.stringify({ orgId, input, challengeId: crypto.randomUUID() }))
const digest = await crypto.subtle.digest('SHA-256', bytes)
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')
}
const objectListItemSchema = matterSchema.extend({ hasChildren: z.boolean() }).openapi('ObjectListItem')
type ObjectListItemDTO = z.infer<typeof objectListItemSchema>
@@ -99,6 +106,30 @@ const objectPageSchema = cursorPageSchema(objectListItemSchema, 'ObjectPage')
// instructions: the server-decided part size and the presigned URLs to PUT each
// slice to (1 URL = single PutObject, N URLs = multipart).
const objectCreateResultSchema = matterSchema.extend({ upload: objectUploadInstructionsSchema.optional() })
const capacityRequiredSchema = z
.object({
error: z.literal('CAPACITY_REQUIRED'),
requestHash: z.string(),
requestedBytes: z.number().int().nonnegative(),
usedBytes: z.number().int().nonnegative(),
quotaBytes: z.number().int().nonnegative(),
offers: z.array(
z.object({
resourceId: z.string(),
productId: z.string(),
priceId: z.string(),
name: z.string(),
description: z.string().nullable(),
storageBytes: z.number().int().nonnegative(),
amount: z.number().int().positive(),
currency: z.string(),
interval: z.string().nullable(),
intervalCount: z.number().int().positive().nullable(),
purchaseUrl: z.string(),
}),
),
})
.openapi('CapacityRequired')
// GET /{id} returns the object plus, when egress is metered/allowed, a presigned
// download URL.
@@ -209,6 +240,7 @@ const createObjectRoute = authRoute(
request: jsonBody(createMatterSchema),
responses: {
201: jsonContent(objectCreateResultSchema, 'Created object (folder, or file draft with upload instructions)'),
402: jsonContent(capacityRequiredSchema, 'Additional workspace storage capacity is required'),
400: errorResponse('No active organization or file too large'),
403: errorResponse('Forbidden'),
409: errorResponse('Name conflict'),
@@ -431,7 +463,34 @@ const objects = app
if (input.storageId && c.get('userRole') !== 'admin') throw forbidden('Forbidden')
const result = await createObject(c.get('deps'), { orgId, actor: objectActor(c), input })
if (!result.ok) throw result.error
if (!result.ok) {
if ('error' in result) throw result.error
const capacity = await describeCapacityRequirement(c.get('deps'), cloudBaseUrl(c), {
orgId,
requestedBytes: result.capacityRequired.requestedBytes,
})
if (!capacity.ok) {
// Paid capacity is optional. An instance without a bound Cloud store
// still enforces its local quota using the established 422 contract.
// Once a store is bound, however, Cloud failures must remain visible so
// callers do not mistake a broken payment path for exhausted capacity.
if (capacity.error.message === 'quota_store_binding_missing') throw quotaExceeded()
throw capacity.error
}
if (capacity.value.offers.length === 0) throw quotaExceeded()
const requestHash = await createCapacityRequestHash(orgId, input)
return c.json(
{
error: 'CAPACITY_REQUIRED' as const,
requestHash,
requestedBytes: capacity.value.requestedBytes,
usedBytes: capacity.value.usedBytes,
quotaBytes: capacity.value.quotaBytes,
offers: capacity.value.offers,
},
402,
)
}
if ('upload' in result) return c.json({ ...toMatterDTO(result.matter), upload: result.upload }, 201)
return c.json(toMatterDTO(result.matter), 201)
})
+279 -1
View File
@@ -96,6 +96,131 @@ function cloudOrder(overrides: Record<string, unknown> = {}) {
}
}
function capacityPublication() {
return {
storeId: 'store-test-binding',
mode: 'directory',
listingStatus: 'listed',
displayName: 'ZPan',
summary: null,
publicMetadata: {},
skillUrl: null,
termsUrl: null,
healthUrl: 'https://files.example/api/health',
healthStatus: 'healthy',
resources: [
{
id: 'publication-resource-1',
storeId: 'store-test-binding',
resourceId: 'cloud-pkg-1:price-usd',
offerId: 'pro-monthly',
title: 'Pro',
description: null,
productId: 'cloud-pkg-1',
priceId: 'price-usd',
postResourceUrl: 'https://files.example/api/store/capacity-purchases/cloud-pkg-1%3Aprice-usd',
status: 'active',
tags: [],
capabilities: ['storage.capacity.purchase'],
publicDeliverable: { type: 'zpan.plan', storageBytes: 4096 },
productSnapshot: null,
bazaarRequestMethod: 'POST',
bazaarBodyType: 'json',
bazaarInput: null,
bazaarInputSchema: null,
bazaarOutput: null,
bazaarValidationStatus: 'unknown',
bazaarValidationDiagnostic: null,
bazaarValidatedAt: null,
createdAt: '2026-07-30T00:00:00.000Z',
updatedAt: '2026-07-30T00:00:00.000Z',
},
],
createdAt: '2026-07-30T00:00:00.000Z',
updatedAt: '2026-07-30T00:00:00.000Z',
}
}
function capacityAttempt() {
const paymentRequired = {
x402Version: 2,
resource: { url: 'https://files.example/api/store/capacity-purchases/cloud-pkg-1%3Aprice-usd' },
accepts: [
{
scheme: 'exact',
network: 'eip155:8453',
asset: '0xusdc',
amount: '500',
payTo: '0xmerchant',
maxTimeoutSeconds: 300,
extra: {},
},
],
}
return {
id: 'attempt-1',
storeId: 'store-test-binding',
orderId: 'order-cloud-1',
paymentId: null,
customerId: 'org-placeholder',
idempotencyKey: 'idem-1',
resourceId: 'cloud-pkg-1:price-usd',
offerId: 'pro-monthly',
resourceUrl: paymentRequired.resource.url,
resourceDescription: null,
requestHash: 'hash-1',
productId: 'cloud-pkg-1',
priceId: 'price-usd',
scheme: 'exact',
network: 'eip155:8453',
asset: '0xusdc',
amount: 500,
currency: 'usd',
payTo: '0xmerchant',
recurringPlan: true,
billingPeriodStart: '2026-08-01T00:00:00.000Z',
billingPeriodEnd: '2026-09-01T00:00:00.000Z',
paymentRequired,
paymentRequiredHeader: 'required-header',
paymentSignatureHeader: null,
payer: null,
paymentIdentifier: null,
authorizationHash: null,
planFamily: 'storage',
planKey: 'pro',
tierRank: 1,
billingInterval: 'month',
settlementTransaction: null,
settlementResponseHeader: null,
status: 'quoted',
lastErrorCode: null,
quotedAt: '2026-07-30T00:00:00.000Z',
expiresAt: '2099-07-30T00:05:00.000Z',
verifiedAt: null,
settlingAt: null,
settledAt: null,
canceledAt: null,
createdAt: '2026-07-30T00:00:00.000Z',
updatedAt: '2026-07-30T00:00:00.000Z',
}
}
function capacityReceiver() {
return {
id: 'receiver-1',
storeId: 'store-test-binding',
scheme: 'exact',
network: 'eip155:8453',
asset: '0xusdc',
networkFamily: 'evm',
payTo: '0xmerchant',
status: 'active',
verifiedAt: '2026-07-01T00:00:00.000Z',
createdAt: '2026-07-01T00:00:00.000Z',
updatedAt: '2026-07-01T00:00:00.000Z',
}
}
function paymentPayload() {
const call = vi.mocked(fetch).mock.calls.find(([url]) => String(url).includes('/payments')) as
| [URL, RequestInit]
@@ -612,6 +737,154 @@ describe('Quota Store API', () => {
expect(res.status).toBe(403)
})
it('rejects team capacity purchases from non-owner members', async () => {
const { app, db } = await createTestApp()
await seedBusinessLicense(db)
const { headers } = await memberInTeamOrg(app, db, 'editor')
const res = await app.request('/api/store/capacity-purchases/plan-monthly', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ requestHash: 'hash-1', idempotencyKey: 'idem-1' }),
})
expect(res.status).toBe(403)
})
it('returns the standard x402 challenge for an owner capacity purchase', async () => {
const { app, db } = await createTestApp()
await seedBusinessLicense(db)
const headers = await authedHeaders(app, 'capacity-owner@example.com')
const response = (body: unknown, status = 200) =>
({ ok: status >= 200 && status < 300, status, json: async () => body }) as Response
vi.mocked(fetch)
.mockResolvedValueOnce(response(capacityPublication()))
.mockResolvedValueOnce(response(cloudProduct()))
.mockResolvedValueOnce(
response({
id: 'receiver-1',
storeId: 'store-test-binding',
scheme: 'exact',
network: 'eip155:8453',
asset: '0xusdc',
networkFamily: 'evm',
payTo: '0xmerchant',
status: 'active',
verifiedAt: '2026-07-01T00:00:00.000Z',
createdAt: '2026-07-01T00:00:00.000Z',
updatedAt: '2026-07-01T00:00:00.000Z',
}),
)
.mockResolvedValueOnce(response(cloudOrder({ status: 'pending', paymentStatus: 'pending' }), 201))
.mockResolvedValueOnce(response({ ...capacityAttempt(), reused: false }, 201))
const res = await app.request('/api/store/capacity-purchases/cloud-pkg-1%3Aprice-usd', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ requestHash: 'hash-1', idempotencyKey: 'idem-1' }),
})
expect(res.status).toBe(402)
expect(res.headers.get('PAYMENT-REQUIRED')).toBe('required-header')
await expect(res.json()).resolves.toEqual(capacityAttempt().paymentRequired)
expect(orderPayload().idempotencyKey).toMatch(/^zpan-x402-capacity:/)
})
it('rate-limits unpaid capacity purchases before creating another Cloud order', async () => {
const { app, db, deps } = await createTestApp()
await seedBusinessLicense(db)
const headers = await authedHeaders(app, 'capacity-limited@example.com')
const orgId = await getFirstOrgId(db)
for (let i = 0; i < 5; i += 1) {
const intent = await deps.x402CapacityPurchases.create({
orgId,
resourceId: `existing-resource-${i}`,
requestHash: `existing-hash-${i}`,
idempotencyKey: `existing-idempotency-${i}`,
})
expect(intent).not.toBeNull()
}
const response = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response
vi.mocked(fetch)
.mockClear()
.mockResolvedValueOnce(response(capacityPublication()))
.mockResolvedValueOnce(response(cloudProduct()))
.mockResolvedValueOnce(
response({
id: 'receiver-1',
storeId: 'store-test-binding',
scheme: 'exact',
network: 'eip155:8453',
asset: '0xusdc',
networkFamily: 'evm',
payTo: '0xmerchant',
status: 'active',
verifiedAt: '2026-07-01T00:00:00.000Z',
createdAt: '2026-07-01T00:00:00.000Z',
updatedAt: '2026-07-01T00:00:00.000Z',
}),
)
const res = await app.request('/api/store/capacity-purchases/cloud-pkg-1%3Aprice-usd', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ requestHash: 'limited-hash', idempotencyKey: 'limited-idempotency' }),
})
expect(res.status).toBe(429)
expect(res.headers.get('Retry-After')).toBe('3600')
expect(vi.mocked(fetch).mock.calls.some(([url]) => String(url).endsWith('/orders'))).toBe(false)
})
it('returns pending and delivered x402 attempts with their transport contracts', async () => {
const { app, db, deps } = await createTestApp()
await seedBusinessLicense(db)
const headers = await authedHeaders(app, 'capacity-status@example.com')
const orgId = await getFirstOrgId(db)
const intent = await deps.x402CapacityPurchases.create({
orgId,
resourceId: 'cloud-pkg-1:price-usd',
requestHash: 'status-hash',
idempotencyKey: 'status-idempotency',
})
expect(intent).not.toBeNull()
await deps.x402CapacityPurchases.updateCloudState(intent!.id, {
cloudOrderId: 'order-cloud-1',
cloudAttemptId: 'attempt-1',
status: 'quoted',
})
const response = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response
const verified = { ...capacityAttempt(), status: 'verified', verifiedAt: '2026-07-30T00:01:00.000Z' }
const delivered = {
...capacityAttempt(),
status: 'delivered',
settlementResponseHeader: 'receipt-header',
settledAt: '2026-07-30T00:02:00.000Z',
}
vi.mocked(fetch)
.mockClear()
.mockResolvedValueOnce(response(capacityPublication()))
.mockResolvedValueOnce(response(cloudProduct()))
.mockResolvedValueOnce(response(capacityReceiver()))
.mockResolvedValueOnce(response(verified))
.mockResolvedValueOnce(response(capacityPublication()))
.mockResolvedValueOnce(response(cloudProduct()))
.mockResolvedValueOnce(response(capacityReceiver()))
.mockResolvedValueOnce(response(delivered))
const request = () =>
app.request('/api/store/capacity-purchases/cloud-pkg-1%3Aprice-usd', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ requestHash: 'status-hash', idempotencyKey: 'status-idempotency' }),
})
const pendingResponse = await request()
expect(pendingResponse.status).toBe(202)
const deliveredResponse = await request()
expect(deliveredResponse.status).toBe(200)
expect(deliveredResponse.headers.get('PAYMENT-RESPONSE')).toBe('receipt-header')
})
it('allows team checkout for the team owner and targets the team org [spec: quota-store/team-checkout]', async () => {
const { app, db } = await createTestApp()
await seedBusinessLicense(db)
@@ -2404,13 +2677,18 @@ describe('Quota Store API — storefront proxy error branches', () => {
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ packageId: 'cloud-pkg-1' }),
})
const capacity = await app.request('/api/store/capacity-purchases/plan-monthly', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ requestHash: 'hash-1', idempotencyKey: 'idem-1' }),
})
const redeem = await app.request('/api/store/credits/redemptions', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ code: 'ZS-TEST-1' }),
})
for (const res of [credits, ledger, billing, checkout, redeem]) {
for (const res of [credits, ledger, billing, checkout, capacity, redeem]) {
expect(res.status).toBe(403)
const body = (await res.json()) as { error: { message: string } }
expect(body.error.message).toBe('quota_store_binding_missing')
+61
View File
@@ -16,6 +16,7 @@ import {
listCreditProducts,
listPackages,
listTargets,
purchaseCapacity,
redeemGiftCard,
} from '../../usecases/store/store'
import { authRoute, errorResponse, jsonBody, jsonContent } from '../openapi'
@@ -27,6 +28,10 @@ import { getCloudOrders, getInstanceOrigin } from './shared'
// 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 packagesRoute = authRoute(
{ scopes: [AuthorizationScope.STORE_READ] },
@@ -154,6 +159,41 @@ const checkoutRoute = authRoute(
},
)
const capacityPurchaseRoute = authRoute(
{ scopes: [AuthorizationScope.QUOTA_PURCHASE], minTeamRole: 'owner' },
{
operationId: 'purchaseStorageCapacity',
summary: 'Purchase workspace storage capacity with x402',
description:
'Call without PAYMENT-SIGNATURE to receive a standard x402 PAYMENT-REQUIRED challenge. Pay it and retry this same request with PAYMENT-SIGNATURE. 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: cloudBody('Capacity delivered'),
202: cloudBody('Payment accepted; capacity fulfillment is pending'),
400: errorResponse('Invalid capacity offer'),
402: cloudBody('x402 payment required'),
403: errorResponse('License not bound'),
409: errorResponse('Purchase request conflict'),
429: errorResponse('Too many pending capacity purchases'),
502: errorResponse('Cloud error'),
},
},
)
const discountRoute = authRoute(
{ scopes: [AuthorizationScope.STORE_CREATE] },
{
@@ -303,6 +343,27 @@ export const cloudStore = app
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)
return c.json(result.attempt, result.kind === 'delivered' ? 200 : 202)
})
.openapi(discountRoute, async (c) => {
const result = await getDiscountQuote(c.get('deps'), getCloudBaseUrl(c), c.req.valid('json'))
if (!result.ok) throw result.error
+15
View File
@@ -94,6 +94,7 @@ describe('global OpenAPI document', () => {
})
expect(workflows.workflows?.map((workflow) => workflow.workflowId)).toEqual([
'prepareDirectFileUpload',
'purchaseStorageCapacityWithX402',
'refreshDirectFileUploadParts',
'completeDirectFileUpload',
'abortDirectFileUpload',
@@ -103,6 +104,7 @@ describe('global OpenAPI document', () => {
.map((step) => step.operationId)
expect(workflowOperationIds).toEqual([
'createObject',
'purchaseStorageCapacity',
'presignObjectUploadParts',
'completeObjectUpload',
'abortObjectUpload',
@@ -153,6 +155,7 @@ describe('global OpenAPI document', () => {
[AuthorizationScope.OBJECTS_READ]: 'List, inspect, and download objects',
[AuthorizationScope.OBJECTS_CREATE]: 'Create folders and upload objects',
[AuthorizationScope.SHARES_CREATE]: 'Create public shares',
[AuthorizationScope.QUOTA_PURCHASE]: 'Purchase workspace storage capacity',
}),
},
},
@@ -185,6 +188,10 @@ describe('global OpenAPI document', () => {
value: AuthorizationScope.OBJECTS_UPDATE,
description: 'Rename, move, and copy objects',
},
{
value: AuthorizationScope.QUOTA_PURCHASE,
description: 'Purchase workspace storage capacity',
},
]),
)
expect(document.paths['/api/oauth-resource-scopes']?.get).toMatchObject({
@@ -498,6 +505,14 @@ describe('global OpenAPI document', () => {
scopes: [AuthorizationScope.OBJECTS_CREATE],
},
})
expect(doc.paths['/api/store/capacity-purchases/{resourceId}']?.post).toMatchObject({
operationId: 'purchaseStorageCapacity',
'x-zpan-auth': {
public: false,
scopes: [AuthorizationScope.QUOTA_PURCHASE],
},
responses: { 429: expect.any(Object) },
})
expect(doc.paths['/api/objects']?.post?.security).toBeUndefined()
expect(doc.paths['/api/objects']?.post?.responses?.['201']).toBeDefined()
expect(doc.paths['/api/objects']?.post?.requestBody).toBeDefined()
@@ -0,0 +1,166 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import Database from 'better-sqlite3'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
buildAgentOAuthScopeBackfill,
parseAgentOAuthScopeBackfillOptions,
runAgentOAuthScopeBackfill,
} from '../../scripts/backfill-agent-oauth-scopes'
import { AGENT_OAUTH_SCOPES } from '../../shared/agent-oauth'
import { AuthorizationScope } from '../../shared/authorization'
const tempDirs: string[] = []
afterEach(() => {
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
function createScopeDatabase() {
const dir = mkdtempSync(join(tmpdir(), 'zpan-agent-oauth-backfill-'))
tempDirs.push(dir)
const path = join(dir, 'zpan.db')
const db = new Database(path)
db.exec(`
CREATE TABLE oauthResource (id TEXT PRIMARY KEY, name TEXT NOT NULL, allowed_scopes TEXT, updated_at INTEGER);
CREATE TABLE oauthClient (id TEXT PRIMARY KEY, scopes TEXT, updated_at INTEGER);
`)
return { db, path }
}
describe('buildAgentOAuthScopeBackfill', () => {
it('updates ZPan resources and upload clients without expanding read-only clients', () => {
const changes = buildAgentOAuthScopeBackfill(
[
{
id: 'zpan-resource',
name: 'ZPan API',
allowedScopes: JSON.stringify([AuthorizationScope.OBJECTS_CREATE]),
},
{
id: 'other-resource',
name: 'Other API',
allowedScopes: JSON.stringify([AuthorizationScope.OBJECTS_CREATE]),
},
],
[
{
id: 'upload-client',
scopes: JSON.stringify([AuthorizationScope.OBJECTS_CREATE]),
},
{
id: 'read-client',
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
},
],
)
expect(changes).toEqual({
resources: [{ id: 'zpan-resource', scopes: JSON.stringify(AGENT_OAUTH_SCOPES) }],
clients: [
{
id: 'upload-client',
scopes: JSON.stringify([AuthorizationScope.OBJECTS_CREATE, AuthorizationScope.QUOTA_PURCHASE]),
},
],
})
})
it('is idempotent after the scopes are current', () => {
const changes = buildAgentOAuthScopeBackfill(
[{ id: 'zpan-resource', name: 'ZPan API', allowedScopes: JSON.stringify(AGENT_OAUTH_SCOPES) }],
[
{
id: 'upload-client',
scopes: JSON.stringify([AuthorizationScope.OBJECTS_CREATE, AuthorizationScope.QUOTA_PURCHASE]),
},
],
)
expect(changes).toEqual({ resources: [], clients: [] })
})
it('rejects malformed client scope documents', () => {
expect(() => buildAgentOAuthScopeBackfill([], [{ id: 'bad', scopes: '{' }])).toThrow(SyntaxError)
expect(() => buildAgentOAuthScopeBackfill([], [{ id: 'bad', scopes: '["objects:create", 1]' }])).toThrow(
'invalid_oauth_client_scopes',
)
expect(buildAgentOAuthScopeBackfill([], [{ id: 'empty', scopes: null }])).toEqual({ resources: [], clients: [] })
})
it('parses sqlite and D1 targets and rejects ambiguous invocations', () => {
expect(parseAgentOAuthScopeBackfillOptions(['--sqlite', '/tmp/zpan.db', '--apply'])).toEqual({
apply: true,
target: { kind: 'sqlite', path: '/tmp/zpan.db' },
})
expect(parseAgentOAuthScopeBackfillOptions(['--d1', 'zpan-db', '--remote', '--env', 'staging'])).toEqual({
apply: false,
target: { kind: 'd1', database: 'zpan-db', remote: true, env: 'staging' },
})
expect(() => parseAgentOAuthScopeBackfillOptions([])).toThrow('Usage:')
expect(() => parseAgentOAuthScopeBackfillOptions(['--sqlite', 'a', '--d1', 'b'])).toThrow('Usage:')
expect(() => parseAgentOAuthScopeBackfillOptions(['--sqlite'])).toThrow('Usage:')
expect(() => parseAgentOAuthScopeBackfillOptions(['--d1'])).toThrow('Usage:')
})
it('dry-runs and applies the SQLite backfill end to end', () => {
const { db, path } = createScopeDatabase()
db.prepare('INSERT INTO oauthResource (id, name, allowed_scopes) VALUES (?, ?, ?)').run(
'zpan-resource',
'ZPan API',
JSON.stringify([AuthorizationScope.OBJECTS_CREATE]),
)
db.prepare('INSERT INTO oauthClient (id, scopes) VALUES (?, ?)').run(
'upload-client',
JSON.stringify([AuthorizationScope.OBJECTS_CREATE]),
)
db.close()
const logs: string[] = []
runAgentOAuthScopeBackfill(['--sqlite', path], (message) => logs.push(message))
expect(JSON.parse(logs[0])).toEqual({ mode: 'dry-run', resources: 1, clients: 1 })
runAgentOAuthScopeBackfill(['--sqlite', path, '--apply'], (message) => logs.push(message))
const after = new Database(path, { readonly: true })
expect(after.prepare('SELECT allowed_scopes FROM oauthResource WHERE id = ?').pluck().get('zpan-resource')).toBe(
JSON.stringify(AGENT_OAUTH_SCOPES),
)
expect(after.prepare('SELECT scopes FROM oauthClient WHERE id = ?').pluck().get('upload-client')).toBe(
JSON.stringify([AuthorizationScope.OBJECTS_CREATE, AuthorizationScope.QUOTA_PURCHASE]),
)
after.close()
expect(JSON.parse(logs[1])).toEqual({ mode: 'apply', resources: 1, clients: 1 })
})
it('reads and applies a remote D1 backfill with escaped identifiers', () => {
const resourceScopes = JSON.stringify(AGENT_OAUTH_SCOPES)
const clientScopes = JSON.stringify([AuthorizationScope.OBJECTS_CREATE, AuthorizationScope.QUOTA_PURCHASE])
const execute = vi
.fn()
.mockReturnValueOnce(JSON.stringify([{ results: [{ id: "resource'1", name: 'ZPan API', allowedScopes: '[]' }] }]))
.mockReturnValueOnce(
JSON.stringify([
{ results: [{ id: 'client-1', scopes: JSON.stringify([AuthorizationScope.OBJECTS_CREATE]) }] },
]),
)
.mockReturnValueOnce('')
.mockReturnValueOnce('')
.mockReturnValueOnce(
JSON.stringify([{ results: [{ id: "resource'1", name: 'ZPan API', allowedScopes: resourceScopes }] }]),
)
.mockReturnValueOnce(JSON.stringify([{ results: [{ id: 'client-1', scopes: clientScopes }] }]))
runAgentOAuthScopeBackfill(['--d1', 'zpan-db', '--remote', '--env', 'production', '--apply'], () => {}, execute)
expect(execute).toHaveBeenCalledTimes(6)
expect(execute.mock.calls[0]?.[0]).toEqual({
kind: 'd1',
database: 'zpan-db',
remote: true,
env: 'production',
})
expect(execute.mock.calls[0]?.[2]).toBe(true)
expect(execute.mock.calls[2]?.[1]).toContain("resource''1")
})
})
+8 -2
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { buildPreviewAdminSeedSql, buildWranglerArgs } from '../../scripts/seed-preview-admin'
import {
buildPreviewAdminSeedSql,
buildWranglerArgs,
CREDENTIAL_ACCOUNT_ISSUER,
} from '../../scripts/seed-preview-admin'
describe('seed-preview-admin script', () => {
it('builds idempotent SQL for the preview admin account', () => {
@@ -18,7 +22,9 @@ describe('seed-preview-admin script', () => {
expect(sql).toContain('ban_expires = NULL')
expect(sql).toContain('UPDATE account')
expect(sql).toContain("SET password = 'hash''quoted'")
expect(sql).toContain('INSERT INTO account')
expect(sql).toContain(`issuer = '${CREDENTIAL_ACCOUNT_ISSUER}'`)
expect(sql).toContain('INSERT INTO account (id, issuer, account_id, provider_id')
expect(sql).toContain(`SELECT 'account''quoted', '${CREDENTIAL_ACCOUNT_ISSUER}', u.id, 'credential'`)
expect(sql).toContain('AND NOT EXISTS')
expect(sql).not.toContain('BEGIN')
expect(sql).not.toContain('COMMIT')
+19 -3
View File
@@ -403,11 +403,27 @@ const APP_SCHEMA_SQL = `
ON org_quota_entitlements(org_id, resource_type, status);
CREATE INDEX IF NOT EXISTS org_quota_entitlements_org_type_idx
ON org_quota_entitlements(org_id, resource_type, entitlement_type, status);
CREATE UNIQUE INDEX IF NOT EXISTS org_quota_entitlements_active_plan_uniq
ON org_quota_entitlements(org_id, resource_type, entitlement_type)
WHERE status = 'active' AND entitlement_type = 'plan' AND source <> 'free_plan';
CREATE UNIQUE INDEX IF NOT EXISTS org_quota_entitlements_source_resource_uniq
ON org_quota_entitlements(source, source_id, resource_type);
CREATE TABLE IF NOT EXISTS x402_capacity_purchase_intents (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
resource_id TEXT NOT NULL,
request_hash TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
cloud_order_id TEXT,
cloud_attempt_id TEXT,
status TEXT NOT NULL DEFAULT 'created',
expires_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS x402_capacity_purchase_intents_org_request_uniq
ON x402_capacity_purchase_intents(org_id, resource_id, request_hash);
CREATE UNIQUE INDEX IF NOT EXISTS x402_capacity_purchase_intents_org_idempotency_uniq
ON x402_capacity_purchase_intents(org_id, idempotency_key);
CREATE INDEX IF NOT EXISTS x402_capacity_purchase_intents_attempt_idx
ON x402_capacity_purchase_intents(cloud_attempt_id);
CREATE TABLE IF NOT EXISTS webhook_events (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
+2
View File
@@ -50,6 +50,7 @@ import type {
UserAdminRepo,
WebDavPathRepo,
WebDavStateRepo,
X402CapacityPurchaseRepo,
ZipGateway,
ZipPlanRepo,
} from './ports'
@@ -104,4 +105,5 @@ export interface Deps {
webdavState: WebDavStateRepo
zip: ZipGateway
zipPlan: ZipPlanRepo
x402CapacityPurchases: X402CapacityPurchaseRepo
}
+88
View File
@@ -151,6 +151,7 @@ function makeDeps(
licenseBinding: {} as unknown as LicenseBindingRepo,
licensingCloud: {} as unknown as LicensingCloudGateway,
quota: {
hasQuotaForBytes: async () => true,
incrementUsageIfEffectiveQuotaAllows: async () => true,
refundTraffic: async () => {},
...overrides.quota,
@@ -293,6 +294,93 @@ describe('object usecase', () => {
})
describe('createObject', () => {
it('requires capacity before creating a draft or presigning storage', async () => {
const create = vi.fn()
const presignUpload = vi.fn()
const hasQuotaForBytes = vi.fn(async () => false)
const { deps } = makeDeps({
matter: { create },
s3: { presignUpload },
quota: { hasQuotaForBytes },
})
const out = await createObject(deps, {
orgId: 'o1',
actor: user,
input: { name: 'large.bin', size: 50, dirtype: DirType.FILE, parent: '' },
})
expect(out).toEqual({ ok: false, capacityRequired: { requestedBytes: 50 } })
expect(hasQuotaForBytes).toHaveBeenCalledWith('o1', 50)
expect(create).not.toHaveBeenCalled()
expect(presignUpload).not.toHaveBeenCalled()
})
it('checks only the net additional bytes for a replace upload', async () => {
const hasQuotaForBytes = vi.fn(async () => false)
const incumbent = file('old', { size: 40, parent: '', name: 'same.bin' })
const planConflictResolution = vi.fn(async () => ({ finalName: 'same.bin', toTrash: incumbent }))
const { deps } = makeDeps({
matter: { planConflictResolution },
quota: { hasQuotaForBytes },
})
const out = await createObject(deps, {
orgId: 'o1',
actor: user,
input: { name: 'same.bin', size: 50, dirtype: DirType.FILE, parent: '', onConflict: 'replace' },
})
expect(out).toEqual({ ok: false, capacityRequired: { requestedBytes: 10 } })
expect(planConflictResolution).toHaveBeenCalledWith('o1', '', 'same.bin', 'replace', { isFolder: false })
expect(hasQuotaForBytes).toHaveBeenCalledWith('o1', 10)
})
it('rejects a deterministic name conflict before checking quota or selecting storage', async () => {
const conflict = file('existing', { parent: '', name: 'same.bin' })
const planConflictResolution = vi.fn(async () => {
throw new (await import('./ports')).NameConflictError(conflict.name, conflict.id)
})
const select = vi.fn()
const hasQuotaForBytes = vi.fn(async () => false)
const { deps } = makeDeps({
matter: { planConflictResolution },
storages: { select },
quota: { hasQuotaForBytes },
})
await expect(
createObject(deps, {
orgId: 'o1',
actor: user,
input: { name: 'same.bin', size: 50, dirtype: DirType.FILE, parent: '' },
}),
).rejects.toMatchObject({ name: 'NameConflictError', conflictingId: 'existing' })
expect(hasQuotaForBytes).not.toHaveBeenCalled()
expect(select).not.toHaveBeenCalled()
})
it('rejects unavailable storage before checking quota', async () => {
const hasQuotaForBytes = vi.fn(async () => false)
const { deps } = makeDeps({
storages: {
select: async () => {
throw new Error('No available storage')
},
},
quota: { hasQuotaForBytes },
})
const out = await createObject(deps, {
orgId: 'o1',
actor: user,
input: { name: 'large.bin', size: 50, dirtype: DirType.FILE, parent: '', storageId: 'missing' },
})
expectError(out, 503, 'Storage is not active or has no available capacity', 'NO_STORAGE_CONFIGURED')
expect(hasQuotaForBytes).not.toHaveBeenCalled()
})
it('creates a folder without presigning an upload', async () => {
const create = vi.fn(async () => folder('f1', { name: 'My Folder' }))
const presignUpload = vi.fn()
+13 -1
View File
@@ -156,10 +156,11 @@ export const UPLOAD_PRESIGNED_URL_TTL_SECONDS = 15 * 60
export type CreateObjectOutcome =
| { ok: true; matter: Matter }
| { ok: true; matter: Matter; upload: ObjectUploadInstructions }
| { ok: false; capacityRequired: { requestedBytes: number } }
| { ok: false; error: AppError }
export async function createObject(
deps: Pick<Deps, 'matter' | 'storages' | 's3' | 'objectUploadSessions' | 'downloaders' | 'downloadTasks'>,
deps: Pick<Deps, 'matter' | 'storages' | 's3' | 'objectUploadSessions' | 'downloaders' | 'downloadTasks' | 'quota'>,
params: { orgId: string; actor: ObjectActor; input: CreateMatterInput },
): Promise<CreateObjectOutcome> {
const { orgId, actor, input } = params
@@ -187,6 +188,10 @@ export async function createObject(
return { ok: false, error: badRequest('File exceeds the 5 TiB maximum', 'FILE_TOO_LARGE') }
}
const conflictPlan = isFolder
? null
: await deps.matter.planConflictResolution(orgId, parent, name, onConflict ?? 'fail', { isFolder: false })
let storage: StorageRecord
try {
storage = await deps.storages.select(input.storageId)
@@ -200,6 +205,13 @@ export async function createObject(
throw error
}
if (!isFolder) {
const requestedBytes = Math.max(0, size - (conflictPlan?.toTrash?.size ?? 0))
if (!(await deps.quota.hasQuotaForBytes(orgId, requestedBytes))) {
return { ok: false, capacityRequired: { requestedBytes } }
}
}
const objectKey = isFolder ? '' : buildObjectKey({ uid: ownerUserId(actor), orgId, rawExt: fileExt(name) })
const matter = await deps.matter.create({
+1
View File
@@ -49,4 +49,5 @@ export * from './ports/team-invite'
export * from './ports/user'
export * from './ports/webdav-path'
export * from './ports/webdav-state'
export * from './ports/x402-capacity-purchase'
export * from './ports/zip'
@@ -0,0 +1,33 @@
export interface X402CapacityPurchaseIntent {
id: string
orgId: string
resourceId: string
requestHash: string
idempotencyKey: string
cloudOrderId: string | null
cloudAttemptId: string | null
status: string
expiresAt: Date | null
createdAt: Date
updatedAt: Date
}
export interface X402CapacityPurchaseRepo {
get(orgId: string, resourceId: string, requestHash: string): Promise<X402CapacityPurchaseIntent | null>
create(input: {
orgId: string
resourceId: string
requestHash: string
idempotencyKey: string
}): Promise<X402CapacityPurchaseIntent | null>
claimCloudOrder(id: string, staleBefore: Date): Promise<boolean>
updateCloudState(
id: string,
input: {
cloudOrderId?: string
cloudAttemptId?: string
status: string
expiresAt?: Date | null
},
): Promise<void>
}
+643 -12
View File
@@ -8,6 +8,8 @@ import {
type EffectiveQuota,
type LicensingCloudGateway,
type QuotaRepo,
type X402CapacityPurchaseIntent,
type X402CapacityPurchaseRepo,
} from '../ports'
// Asserts a failed outcome carries the expected AppError (status / reason / message).
@@ -32,10 +34,12 @@ import {
createCheckout,
getCreditBalance,
getStoreReadiness,
listCapacityOffers,
listCreditProducts,
listPackages,
listTargets,
processDeliveryWebhook,
purchaseCapacity,
redeemGiftCard,
} from './store'
@@ -60,14 +64,25 @@ const BINDING: CloudStoreBinding = {
type CloudResponse = { status: number; ok: boolean; json: () => Promise<unknown> }
function fakeCloudClient(responses: CloudResponse[]) {
let i = 0
const requests: Array<{ method: string; path: string; input: unknown }> = []
const next = () => responses[i++] ?? { status: 200, ok: true, json: async () => ({}) }
const handler: ProxyHandler<Record<string, unknown>> = {
get(_t, prop) {
if (prop === '$get' || prop === '$post' || prop === '$patch') return async () => next()
return new Proxy({}, handler)
},
}
return new Proxy({}, handler) as never
const proxy = (path: string[]): Record<string, unknown> =>
new Proxy(
{},
{
get(_target, property) {
const segment = String(property)
if (segment === '$get' || segment === '$post' || segment === '$patch') {
return async (input: unknown) => {
requests.push({ method: segment.slice(1).toUpperCase(), path: path.join('/'), input })
return next()
}
}
return proxy([...path, segment])
},
},
)
return { client: proxy([]) as never, requests }
}
const ok = (body: unknown): CloudResponse => ({ status: 200, ok: true, json: async () => body })
@@ -90,6 +105,52 @@ function pkg(overrides: Record<string, unknown> = {}) {
}
}
function publication(overrides: Record<string, unknown> = {}) {
return {
storeId: 'store-1',
mode: 'directory',
listingStatus: 'listed',
displayName: 'ZPan',
summary: null,
publicMetadata: {},
skillUrl: null,
termsUrl: null,
healthUrl: 'https://files.example/api/health',
healthStatus: 'healthy',
resources: [
{
id: 'publication-resource-1',
storeId: 'store-1',
resourceId: 'pkg-1:price-usd',
offerId: 'pro-monthly',
title: 'Pro',
description: null,
productId: 'pkg-1',
priceId: 'price-usd',
postResourceUrl: 'https://files.example/api/store/capacity-purchases/pkg-1%3Aprice-usd',
status: 'active',
tags: [],
capabilities: ['storage.capacity.purchase'],
publicDeliverable: { type: 'zpan.plan', storageBytes: 4096 },
productSnapshot: null,
bazaarRequestMethod: 'POST',
bazaarBodyType: 'json',
bazaarInput: null,
bazaarInputSchema: null,
bazaarOutput: null,
bazaarValidationStatus: 'unknown',
bazaarValidationDiagnostic: null,
bazaarValidatedAt: null,
createdAt: '2026-07-30T00:00:00.000Z',
updatedAt: '2026-07-30T00:00:00.000Z',
},
],
createdAt: '2026-07-30T00:00:00.000Z',
updatedAt: '2026-07-30T00:00:00.000Z',
...overrides,
}
}
function order(overrides: Record<string, unknown> = {}) {
return {
id: 'order-1',
@@ -114,6 +175,82 @@ function order(overrides: Record<string, unknown> = {}) {
}
const payment = { status: 'pending', paymentId: 'pay-1', orderId: 'order-1', url: 'https://cloud.example/checkout' }
const receiver = {
id: 'receiver-1',
storeId: 'store-1',
scheme: 'exact',
network: 'eip155:8453',
asset: '0xusdc',
networkFamily: 'evm',
payTo: '0xmerchant',
status: 'active',
verifiedAt: '2026-07-01T00:00:00.000Z',
createdAt: '2026-07-01T00:00:00.000Z',
updatedAt: '2026-07-01T00:00:00.000Z',
}
function attempt(status = 'quoted') {
return {
id: 'attempt-1',
storeId: 'store-1',
orderId: 'order-1',
paymentId: status === 'delivered' ? 'payment-1' : null,
customerId: 'org-1',
idempotencyKey: 'idem-1',
resourceId: 'pkg-1:price-usd',
offerId: null,
resourceUrl: 'https://files.example/api/store/capacity-purchases/pkg-1%3Aprice-usd',
resourceDescription: null,
requestHash: 'hash-1',
productId: 'pkg-1',
priceId: 'price-usd',
scheme: 'exact',
network: receiver.network,
asset: receiver.asset,
amount: 500,
currency: 'usd',
payTo: receiver.payTo,
recurringPlan: true,
billingPeriodStart: '2026-08-01T00:00:00.000Z',
billingPeriodEnd: '2026-09-01T00:00:00.000Z',
paymentRequired: {
x402Version: 2,
resource: { url: 'https://files.example/api/store/capacity-purchases/pkg-1%3Aprice-usd' },
accepts: [
{
scheme: 'exact',
network: receiver.network,
asset: receiver.asset,
amount: '500',
payTo: receiver.payTo,
maxTimeoutSeconds: 300,
extra: {},
},
],
},
paymentRequiredHeader: 'required-header',
paymentSignatureHeader: status === 'quoted' ? null : 'signature',
payer: status === 'quoted' ? null : '0xpayer',
paymentIdentifier: null,
authorizationHash: null,
planFamily: 'storage',
planKey: 'pro',
tierRank: 1,
billingInterval: 'month',
settlementTransaction: status === 'delivered' ? '0xtx' : null,
settlementResponseHeader: status === 'delivered' ? 'response-header' : null,
status,
lastErrorCode: null,
quotedAt: '2026-07-30T00:00:00.000Z',
expiresAt: '2099-07-30T00:05:00.000Z',
verifiedAt: status === 'quoted' ? null : '2026-07-30T00:01:00.000Z',
settlingAt: null,
settledAt: status === 'delivered' ? '2026-07-30T00:02:00.000Z' : null,
canceledAt: null,
createdAt: '2026-07-30T00:00:00.000Z',
updatedAt: '2026-07-30T00:00:00.000Z',
}
}
const noPlanQuota = { currentPlan: null } as EffectiveQuota
const subscribedQuota = { currentPlan: { subscription: true } } as EffectiveQuota
@@ -147,12 +284,43 @@ function makeDeps(
// every cloudRequest), so the fake must be a single instance whose response queue
// advances across calls — rebuilding it per call would reset the counter and replay
// response #0, breaking multi-call flows (checkout, continue/cancel order).
const client = fakeCloudClient(options.responses ?? [])
const createBoundCloudClient = vi.fn(() => client)
const fake = fakeCloudClient(options.responses ?? [])
const createBoundCloudClient = vi.fn(() => fake.client)
const licensingCloud = { createBoundCloudClient } as unknown as LicensingCloudGateway
const quota = { getEffectiveQuota: async () => options.quota ?? noPlanQuota } as unknown as QuotaRepo
const deps: CloudStoreDeps = { cloudStore, licensingCloud, quota }
return { deps, getCloudStoreBinding, processCloudOrderQuotaChange, createBoundCloudClient }
let purchaseIntent: X402CapacityPurchaseIntent | null = null
const x402CapacityPurchases: X402CapacityPurchaseRepo = {
get: async () => purchaseIntent,
create: async (input) => {
purchaseIntent = {
id: 'intent-1',
...input,
cloudOrderId: null,
cloudAttemptId: null,
status: 'created',
expiresAt: null,
createdAt: new Date(),
updatedAt: new Date(),
}
return purchaseIntent
},
claimCloudOrder: async () => {
if (!purchaseIntent || purchaseIntent.cloudOrderId) return false
purchaseIntent = { ...purchaseIntent, status: 'ordering', updatedAt: new Date() }
return true
},
updateCloudState: async (_id, input) => {
if (!purchaseIntent) throw new Error('missing_intent')
purchaseIntent = { ...purchaseIntent, ...input, updatedAt: new Date() }
},
}
const deps: CloudStoreDeps & { x402CapacityPurchases: X402CapacityPurchaseRepo } = {
cloudStore,
licensingCloud,
quota,
x402CapacityPurchases,
}
return { deps, getCloudStoreBinding, processCloudOrderQuotaChange, createBoundCloudClient, requests: fake.requests }
}
const CLOUD = 'https://cloud.example'
@@ -215,6 +383,58 @@ describe('cloud-store usecase', () => {
expectError(await listPackages(deps, CLOUD), { httpStatus: 502, message: 'invalid_cloud_response' })
})
it('lists every standard capacity price that closes the workspace gap', async () => {
const { deps } = makeDeps({
responses: [
ok({
items: [
pkg({
prices: [
{ id: 'monthly', currency: 'usd', amount: 500, recurring: { interval: 'month', intervalCount: 1 } },
{ id: 'yearly', currency: 'usd', amount: 5000, recurring: { interval: 'year', intervalCount: 1 } },
],
}),
pkg({
id: 'too-small',
metadata: { deliverable: { type: 'zpan.plan', storageBytes: 100 } },
}),
],
total: 2,
limit: 100,
offset: 0,
}),
ok(
publication({
resources: [
{
...publication().resources[0],
priceId: 'monthly',
resourceId: 'pro-monthly',
},
{
...publication().resources[0],
priceId: 'yearly',
resourceId: 'pro-yearly',
},
],
}),
),
],
quota: {
used: 1000,
quota: 900,
currentPlan: { storageBytes: 800 },
} as EffectiveQuota,
})
const out = await listCapacityOffers(deps, CLOUD, { orgId: 'org-1', requestedBytes: 200 })
expect(out.ok && out.value).toMatchObject([
{ resourceId: 'pro-monthly', productId: 'pkg-1', priceId: 'monthly', storageBytes: 4096 },
{ resourceId: 'pro-yearly', productId: 'pkg-1', priceId: 'yearly', storageBytes: 4096 },
])
})
it('listTargets returns the accessible targets without a cloud call', async () => {
const targets = [{ orgId: 'org-1', type: 'personal' }] as unknown as CloudStoreTarget[]
const { deps, createBoundCloudClient } = makeDeps({ targets })
@@ -330,6 +550,366 @@ describe('cloud-store usecase', () => {
})
})
describe('purchaseCapacity', () => {
const params = {
userId: 'user-1',
orgId: 'org-1',
origin: 'https://files.example',
resourceId: 'pkg-1:price-usd',
requestHash: 'hash-1',
idempotencyKey: 'idem-1',
paymentSignature: null,
}
it('creates an idempotent order and returns the standard x402 challenge', async () => {
const quote = { ...attempt(), reused: false }
const { deps, requests } = makeDeps({
responses: [ok(publication()), ok(pkg()), ok(receiver), ok(order()), ok(quote)],
})
const out = await purchaseCapacity(deps, CLOUD, params)
expect(out).toEqual({
ok: true,
kind: 'payment_required',
paymentRequired: quote.paymentRequired,
paymentRequiredHeader: quote.paymentRequiredHeader,
})
expect(requests[3]?.input).toMatchObject({
json: {
idempotencyKey: 'zpan-x402-capacity:intent-1',
},
})
expect(requests[4]).toMatchObject({
method: 'POST',
path: 'stores/:storeId/orders/:orderId/x402/payment-attempts',
input: {
header: { 'Idempotency-Key': params.idempotencyKey },
json: {
requestHash: params.requestHash,
resourceId: params.resourceId,
},
},
})
expect(requests[4]?.input).not.toMatchObject({ json: { idempotencyKey: expect.anything() } })
})
it('returns forbidden before calling Cloud when the quota store is not bound', async () => {
const { deps, createBoundCloudClient } = makeDeps({ binding: 'missing' })
const out = await purchaseCapacity(deps, CLOUD, params)
expectError(out, { httpStatus: 403, message: 'quota_store_binding_missing' })
expect(createBoundCloudClient).not.toHaveBeenCalled()
})
it('returns bad request when the published capacity offer does not exist', async () => {
const { deps } = makeDeps({
responses: [ok(publication({ resources: [] }))],
})
const out = await purchaseCapacity(deps, CLOUD, params)
expectError(out, {
httpStatus: 400,
reason: 'CAPACITY_OFFER_NOT_FOUND',
message: 'Invalid capacity offer',
})
})
it('returns a retryable conflict when another request is creating the Cloud order', async () => {
const { deps, requests } = makeDeps({
responses: [ok(publication()), ok(pkg()), ok(receiver)],
})
deps.x402CapacityPurchases.claimCloudOrder = vi.fn(async () => false)
const out = await purchaseCapacity(deps, CLOUD, params)
expectError(out, {
httpStatus: 409,
reason: 'X402_PURCHASE_IN_PROGRESS',
message: 'Purchase initialization is in progress',
})
expect(requests).toHaveLength(3)
})
it('rate-limits new unpaid purchase intents before creating a Cloud order', async () => {
const { deps, requests } = makeDeps({
responses: [ok(publication()), ok(pkg()), ok(receiver)],
})
deps.x402CapacityPurchases.create = vi.fn(async () => null)
const out = await purchaseCapacity(deps, CLOUD, params)
expectError(out, {
httpStatus: 429,
message: 'Too many pending capacity purchases',
})
expect((out as { error: AppError }).error.meta.headers).toEqual({ 'Retry-After': '3600' })
expect(requests).toHaveLength(3)
})
it('returns a purchase conflict when intent reservation fails without a concurrent winner', async () => {
const { deps, requests } = makeDeps({
responses: [ok(publication()), ok(pkg()), ok(receiver)],
})
deps.x402CapacityPurchases.create = vi.fn(async () => {
throw new Error('unique constraint')
})
const out = await purchaseCapacity(deps, CLOUD, params)
expectError(out, {
httpStatus: 409,
reason: 'X402_PURCHASE_CONFLICT',
message: 'Purchase request conflict',
})
expect(requests).toHaveLength(3)
})
it('rejects a different idempotency key for an existing purchase request', async () => {
const { deps, requests } = makeDeps({
responses: [
ok(publication()),
ok(pkg()),
ok(receiver),
ok(order()),
ok({ ...attempt(), reused: false }),
ok(publication()),
ok(pkg()),
ok(receiver),
],
})
await purchaseCapacity(deps, CLOUD, params)
const out = await purchaseCapacity(deps, CLOUD, { ...params, idempotencyKey: 'different-key' })
expectError(out, {
httpStatus: 409,
reason: 'X402_PURCHASE_CONFLICT',
message: 'Purchase request conflict',
})
expect(requests).toHaveLength(8)
})
it('releases the local order claim when Cloud order creation fails', async () => {
const { deps } = makeDeps({
responses: [ok(publication()), ok(pkg()), ok(receiver), fail(503, { error: 'cloud_down' })],
})
const updateCloudState = vi.spyOn(deps.x402CapacityPurchases, 'updateCloudState')
const out = await purchaseCapacity(deps, CLOUD, params)
expectError(out, { httpStatus: 502, message: 'cloud_down' })
expect(updateCloudState).toHaveBeenCalledWith('intent-1', { status: 'created' })
})
it('reuses the intent, verifies payment, settles, and returns the receipt', async () => {
const quoted = attempt()
const verifiedAttempt = attempt('verified')
const paidPendingFulfillment = attempt('paid_pending_fulfillment')
const delivered = attempt('delivered')
const { deps, requests } = makeDeps({
responses: [
ok(publication()),
ok(pkg()),
ok(receiver),
ok(order()),
ok({ ...quoted, reused: false }),
ok(publication()),
ok(pkg()),
ok(receiver),
ok(quoted),
ok(verifiedAttempt),
ok(paidPendingFulfillment),
ok(delivered),
],
})
await purchaseCapacity(deps, CLOUD, params)
const out = await purchaseCapacity(deps, CLOUD, { ...params, paymentSignature: 'signature' })
expect(out).toMatchObject({
ok: true,
kind: 'delivered',
paymentResponseHeader: 'response-header',
attempt: { id: 'attempt-1', status: 'delivered' },
})
expect(requests[8]).toMatchObject({
method: 'GET',
path: 'stores/:storeId/orders/:orderId/x402/payment-attempts/:attemptId',
})
expect(requests[9]).toMatchObject({
method: 'POST',
path: 'stores/:storeId/orders/:orderId/x402/payment-attempts/:attemptId/verifications',
input: {
header: { 'PAYMENT-SIGNATURE': 'signature' },
json: { requestHash: params.requestHash },
},
})
expect(requests[9]?.input).not.toMatchObject({ json: { paymentSignature: expect.anything() } })
expect(requests[10]).toMatchObject({
method: 'POST',
path: 'stores/:storeId/orders/:orderId/x402/payment-attempts/:attemptId/settlements',
})
expect(requests[11]).toMatchObject({
method: 'POST',
path: 'stores/:storeId/orders/:orderId/x402/payment-attempts/:attemptId/fulfillment-attempts',
})
})
it('returns an already delivered attempt after quote expiry without creating a replacement quote', async () => {
const quoted = attempt()
const delivered = { ...attempt('delivered'), expiresAt: '2026-07-30T00:00:00.000Z' }
const { deps, requests } = makeDeps({
responses: [
ok(publication()),
ok(pkg()),
ok(receiver),
ok(order()),
ok({ ...quoted, reused: false }),
ok(publication()),
ok(pkg()),
ok(receiver),
ok(delivered),
],
})
await purchaseCapacity(deps, CLOUD, params)
const out = await purchaseCapacity(deps, CLOUD, params)
expect(out).toMatchObject({
ok: true,
kind: 'delivered',
paymentResponseHeader: 'response-header',
attempt: { id: 'attempt-1', status: 'delivered' },
})
expect(
requests.filter((request) => request.path === 'stores/:storeId/orders/:orderId/x402/payment-attempts'),
).toHaveLength(1)
})
it('retries paid-pending fulfillment after quote expiry without creating a replacement quote', async () => {
const quoted = attempt()
const paidPending = { ...attempt('paid_pending_fulfillment'), expiresAt: '2026-07-30T00:00:00.000Z' }
const delivered = { ...attempt('delivered'), expiresAt: '2026-07-30T00:00:00.000Z' }
const { deps, requests } = makeDeps({
responses: [
ok(publication()),
ok(pkg()),
ok(receiver),
ok(order()),
ok({ ...quoted, reused: false }),
ok(publication()),
ok(pkg()),
ok(receiver),
ok(paidPending),
ok(delivered),
],
})
await purchaseCapacity(deps, CLOUD, params)
const out = await purchaseCapacity(deps, CLOUD, { ...params, paymentSignature: 'signature' })
expect(out).toMatchObject({
ok: true,
kind: 'delivered',
paymentResponseHeader: 'response-header',
attempt: { id: 'attempt-1', status: 'delivered' },
})
expect(
requests.filter((request) => request.path === 'stores/:storeId/orders/:orderId/x402/payment-attempts'),
).toHaveLength(1)
expect(requests.at(-1)).toMatchObject({
method: 'POST',
path: 'stores/:storeId/orders/:orderId/x402/payment-attempts/:attemptId/fulfillment-attempts',
})
})
it('returns a client error when Cloud rejects the payment signature', async () => {
const quoted = attempt()
const { deps } = makeDeps({
responses: [
ok(publication()),
ok(pkg()),
ok(receiver),
ok(order()),
ok({ ...quoted, reused: false }),
ok(publication()),
ok(pkg()),
ok(receiver),
ok(quoted),
fail(400, { error: 'x402_payment_proof_invalid' }),
],
})
await purchaseCapacity(deps, CLOUD, params)
const out = await purchaseCapacity(deps, CLOUD, { ...params, paymentSignature: 'invalid-signature' })
expectError(out, {
httpStatus: 400,
reason: 'X402_PAYMENT_PROOF_INVALID',
message: 'x402_payment_proof_invalid',
})
})
it('replaces an expired quote with a fresh challenge for the same purchase intent', async () => {
const quoted = attempt()
const expired = { ...attempt(), expiresAt: '2026-07-30T00:00:00.000Z' }
const replacement = {
...attempt(),
id: 'attempt-2',
idempotencyKey: 'replacement-idempotency',
paymentRequiredHeader: 'replacement-required-header',
expiresAt: '2099-07-30T00:05:00.000Z',
reused: false,
}
const { deps, requests } = makeDeps({
responses: [
ok(publication()),
ok(pkg()),
ok(receiver),
ok(order()),
ok({ ...quoted, reused: false }),
ok(publication()),
ok(pkg()),
ok(receiver),
ok(expired),
ok(replacement),
],
})
await purchaseCapacity(deps, CLOUD, params)
const out = await purchaseCapacity(deps, CLOUD, { ...params, paymentSignature: 'expired-signature' })
expect(out).toMatchObject({
ok: true,
kind: 'payment_required',
paymentRequiredHeader: 'replacement-required-header',
})
const quoteRequests = requests.filter(
(request) =>
request.method === 'POST' &&
typeof request.input === 'object' &&
request.input !== null &&
'json' in request.input &&
typeof request.input.json === 'object' &&
request.input.json !== null &&
'requestHash' in request.input.json,
)
expect(quoteRequests).toHaveLength(2)
expect(quoteRequests[1]?.input).toMatchObject({
header: {
'Idempotency-Key': expect.stringMatching(/^x402-retry:[0-9a-f]{64}$/),
},
json: {
requestHash: params.requestHash,
resourceId: params.resourceId,
},
})
})
})
describe('order actions', () => {
it('continueOrderPayment returns not_found for an empty orderId', async () => {
const { deps } = makeDeps()
@@ -384,9 +964,17 @@ describe('cloud-store usecase', () => {
describe('createBillingPortalSession', () => {
it('proxies a portal session with the org return URL', async () => {
const session = { url: 'https://billing.example', stripeSubscriptionId: 'sub_1' }
const { deps } = makeDeps({ responses: [ok(session)] })
const { deps, requests } = makeDeps({ responses: [ok(session)] })
const out = await createBillingPortalSession(deps, CLOUD, { orgId: 'org-1', origin: 'https://files.example' })
expect(out).toEqual({ ok: true, value: session })
expect(requests[0]).toMatchObject({
method: 'POST',
path: 'stores/:storeId/billing/portal-sessions',
input: {
param: { storeId: 'store-1' },
json: { customerId: 'org-1', returnUrl: 'https://files.example/storage' },
},
})
})
})
@@ -451,6 +1039,49 @@ describe('cloud-store usecase', () => {
)
})
it('accepts x402 recurring fulfillment with a provider-neutral billing period identity', async () => {
const body = {
eventId: 'evt-x402',
eventType: 'commerce.order_item.fulfilled',
orderId: 'order-x402',
orderItemId: 'item-x402',
productId: 'product-pro',
productName: 'Pro',
quantity: 1,
deliverable: { type: 'zpan.plan', storageBytes: 1024 },
target: { orgId: 'org-1', customerId: 'org-1' },
context: {
storeId: 'store-1',
paymentProvider: 'x402',
providerTransactionId: '0xtx',
x402AuditContext: { network: 'eip155:8453' },
billingPeriodStart: '2026-08-01T00:00:00.000Z',
billingPeriodEnd: '2026-09-01T00:00:00.000Z',
},
occurredAt: '2026-07-30T00:00:00.000Z',
}
verified('evt-x402')
const { deps, processCloudOrderQuotaChange } = makeDeps({
processResult: { duplicate: false, eventId: 'evt-x402' },
})
const out = await processDeliveryWebhook(deps, params(body))
expect(out).toMatchObject({ ok: true, eventId: 'evt-x402' })
expect(processCloudOrderQuotaChange).toHaveBeenCalledWith(
expect.objectContaining({
cloudOrderId: 'x402:period:product-pro:2026-08-01T00:00:00.000Z:2026-09-01T00:00:00.000Z:org-1',
entitlementType: 'plan',
startsAt: '2026-08-01T00:00:00.000Z',
expiresAt: '2026-09-01T00:00:00.000Z',
paymentProvider: 'x402',
providerTransactionId: '0xtx',
}),
JSON.stringify(body),
'hash',
)
})
it('reports duplicate=true on an idempotent replay', async () => {
verified('evt-1')
const { deps } = makeDeps({ processResult: { duplicate: true, eventId: 'evt-1' } })
+428 -3
View File
@@ -31,10 +31,21 @@ import type { z } from 'zod'
import {
billingPortalSessionResponseSchema,
type CloudClient,
type CommerceProduct,
commerceProductSchema,
createX402PaymentAttempt,
getX402PaymentAttempt,
paymentCreateResponseSchema,
productListResponseSchema,
settleX402PaymentAttempt,
storePublicationSchema,
triggerX402PaymentAttemptFulfillment,
verifyX402PaymentAttempt,
x402PaymentAttemptSchema,
x402QuoteCreateResponseSchema,
x402ReceiverSchema,
} from 'zpan-cloud-sdk'
import type { Deps } from '../deps'
import {
AppError,
badGateway,
@@ -45,6 +56,7 @@ import {
type LicensingCloudGateway,
notFound,
type QuotaRepo,
rateLimited,
} from '../ports'
import { verifyCloudEventToken } from '../site/licensing'
@@ -58,6 +70,7 @@ const cloudBillingPortalSessionResponseSchema = billingPortalSessionResponseSche
const cloudDiscountQuoteResponseSchema = discountQuoteSchema
const CLOUD_STORE_REQUEST_TIMEOUT_MS = 10_000
const X402_ORDER_CLAIM_TIMEOUT_MS = 30_000
export type CloudStoreDeps = {
cloudStore: CloudStoreRepo
@@ -69,7 +82,16 @@ export type CloudStoreDeps = {
// proxy threads this through a request callback.
export type BoundCloudClient = { client: CloudClient; storeId: string }
type CloudError = { error: string }
type CloudError = { error: string; status?: number }
class CloudResponseError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message)
}
}
// Storefront proxy outcomes. An unbound store renders 403 (`forbidden`); an
// upstream Cloud failure or malformed body renders 502 (`badGateway`). Each
@@ -132,7 +154,9 @@ export async function unwrapCloudResponse<T, U = T>(
): Promise<U> {
if (response.status === 204) return null as U
const data = await response.json().catch(() => null)
if (!response.ok) throw new Error(cloudErrorCode(data) ?? `cloud_request_failed_${response.status}`)
if (!response.ok) {
throw new CloudResponseError(response.status, cloudErrorCode(data) ?? `cloud_request_failed_${response.status}`)
}
const payload = data && typeof data === 'object' && 'data' in data ? data.data : data
if (!responseSchema) return payload as U
const parsed = responseSchema.safeParse(payload)
@@ -158,7 +182,10 @@ async function cloudRequest<T>(
try {
return await withCloudRequestTimeout(request(await buildBoundCloudClient(deps, cloudBaseUrl)))
} catch (error) {
return { error: (error as Error).message }
return {
error: (error as Error).message,
...(error instanceof CloudResponseError ? { status: error.status } : {}),
}
}
}
@@ -166,6 +193,16 @@ function isCloudError(result: unknown): result is CloudError {
return Boolean(result && typeof result === 'object' && 'error' in result)
}
function capacityPurchaseCloudError(error: CloudError): AppError {
if (error.error === 'capacity_offer_not_found') {
return badRequest('Invalid capacity offer', 'CAPACITY_OFFER_NOT_FOUND')
}
const reason = error.error.toUpperCase()
if (error.status === 400 || error.status === 422) return badRequest(error.error, reason)
if (error.status === 404 || error.status === 409) return conflict(error.error, reason)
return badGateway(error.error)
}
// ─── Storefront reads ────────────────────────────────────────────────────────
// Fetches active store_item products and keeps only those whose deliverable
@@ -191,6 +228,394 @@ async function listDeliverables(
return { ok: true, value: { ...result, items, total: items.length } }
}
export interface CapacityOffer {
resourceId: string
productId: string
priceId: string
name: string
description: string | null
storageBytes: number
amount: number
currency: string
interval: string | null
intervalCount: number | null
purchaseUrl: string
}
export async function listCapacityOffers(
deps: CloudStoreDeps,
cloudBaseUrl: string,
params: { orgId: string; requestedBytes: number },
): Promise<StorefrontReadOutcome<CapacityOffer[]>> {
const packages = await listPackages(deps, cloudBaseUrl)
if (!packages.ok) return packages
const publication = await cloudRequest(deps, cloudBaseUrl, async ({ client, storeId }) =>
unwrapCloudResponse(
await client.stores[':storeId'].publication.$get({ param: { storeId } }),
storePublicationSchema,
),
)
if (isCloudError(publication)) return { ok: false, error: badGateway(publication.error) }
const quota = await deps.quota.getEffectiveQuota(params.orgId)
const capacityOutsideCurrentPlan = Math.max(0, quota.quota - (quota.currentPlan?.storageBytes ?? 0))
const minimumStorageBytes = Math.max(1, quota.used + params.requestedBytes - capacityOutsideCurrentPlan)
const offers = (packages.value.items as CommerceProduct[]).flatMap((product) => {
const storageBytes = product.metadata.deliverable.storageBytes
if (typeof storageBytes !== 'number' || !Number.isSafeInteger(storageBytes) || storageBytes < minimumStorageBytes) {
return []
}
return product.prices.flatMap((price) => {
if (!price.id || price.recurring?.usageType === 'metered') return []
const resource = publication.resources.find(
(candidate) =>
candidate.productId === product.id &&
candidate.priceId === price.id &&
candidate.status !== 'disabled' &&
candidate.capabilities.includes('storage.capacity.purchase'),
)
if (!resource) return []
return [
{
resourceId: resource.resourceId,
productId: product.id,
priceId: price.id,
name: product.name,
description: product.description,
storageBytes,
amount: price.amount,
currency: price.currency,
interval: price.recurring?.interval ?? null,
intervalCount: price.recurring?.intervalCount ?? null,
purchaseUrl: resource.postResourceUrl,
},
]
})
})
return { ok: true, value: offers }
}
export async function describeCapacityRequirement(
deps: CloudStoreDeps,
cloudBaseUrl: string,
params: { orgId: string; requestedBytes: number },
): Promise<
StorefrontReadOutcome<{
requestedBytes: number
usedBytes: number
quotaBytes: number
offers: CapacityOffer[]
}>
> {
const offers = await listCapacityOffers(deps, cloudBaseUrl, params)
if (!offers.ok) return offers
const quota = await deps.quota.getEffectiveQuota(params.orgId)
return {
ok: true,
value: {
requestedBytes: params.requestedBytes,
usedBytes: quota.used,
quotaBytes: quota.quota,
offers: offers.value,
},
}
}
export type CapacityPurchaseOutcome =
| {
ok: true
kind: 'payment_required'
paymentRequired: unknown
paymentRequiredHeader: string
}
| {
ok: true
kind: 'pending' | 'delivered'
attempt: z.infer<typeof x402PaymentAttemptSchema>
paymentResponseHeader: string | null
}
| { ok: false; error: AppError }
async function capacityQuoteRetryKey(idempotencyKey: string, expiredAttemptId: string): Promise<string> {
const input = new TextEncoder().encode(`${idempotencyKey}:${expiredAttemptId}`)
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', input))
return `x402-retry:${Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('')}`
}
function capacityAttemptExpired(attempt: z.infer<typeof x402PaymentAttemptSchema>, now = new Date()): boolean {
return (
attempt.status === 'expired' ||
(attempt.status === 'quoted' && new Date(attempt.expiresAt).getTime() <= now.getTime())
)
}
export async function purchaseCapacity(
deps: CloudStoreDeps & Pick<Deps, 'x402CapacityPurchases'>,
cloudBaseUrl: string,
params: {
userId: string
orgId: string
origin: string
resourceId: string
requestHash: string
idempotencyKey: string
paymentSignature: string | null
},
): Promise<CapacityPurchaseOutcome> {
const ready = await getStoreReadiness(deps)
if (!ready.ready) return { ok: false, error: forbidden(ready.error) }
const context = await cloudRequest(deps, cloudBaseUrl, async (bound) => {
const publication = await unwrapCloudResponse(
await bound.client.stores[':storeId'].publication.$get({ param: { storeId: bound.storeId } }),
storePublicationSchema,
)
const resource = publication.resources.find(
(candidate) =>
candidate.resourceId === params.resourceId &&
candidate.status !== 'disabled' &&
candidate.capabilities.includes('storage.capacity.purchase'),
)
if (!resource) throw new Error('capacity_offer_not_found')
const { productId, priceId } = resource
const product = await unwrapCloudResponse(
await bound.client.stores[':storeId'].products[':productId'].$get({
param: { storeId: bound.storeId, productId },
}),
cloudPackageResponseSchema,
)
const storageBytes = product.metadata.deliverable.storageBytes
const price = product.prices.find((item) => item.id === priceId && item.recurring?.usageType !== 'metered')
if (
product.metadata.deliverable.type !== 'zpan.plan' ||
typeof storageBytes !== 'number' ||
!Number.isSafeInteger(storageBytes) ||
storageBytes <= 0 ||
!price
) {
throw new Error('capacity_offer_not_found')
}
const receiver = await unwrapCloudResponse(
await bound.client.stores[':storeId']['payment-methods'].x402.receiver.$get({
param: { storeId: bound.storeId },
}),
x402ReceiverSchema,
)
if (receiver.status !== 'active') throw new Error('x402_receiver_not_active')
return { ...bound, productId, priceId, product, price, receiver }
})
if (isCloudError(context)) return { ok: false, error: capacityPurchaseCloudError(context) }
let intent = await deps.x402CapacityPurchases.get(params.orgId, params.resourceId, params.requestHash)
if (!intent) {
try {
intent = await deps.x402CapacityPurchases.create({
orgId: params.orgId,
resourceId: params.resourceId,
requestHash: params.requestHash,
idempotencyKey: params.idempotencyKey,
})
if (!intent) {
return {
ok: false,
error: rateLimited('Too many pending capacity purchases', 3600),
}
}
} catch {
intent = await deps.x402CapacityPurchases.get(params.orgId, params.resourceId, params.requestHash)
if (!intent) {
return { ok: false, error: conflict('Purchase request conflict', 'X402_PURCHASE_CONFLICT') }
}
}
}
if (intent.idempotencyKey !== params.idempotencyKey) {
return { ok: false, error: conflict('Purchase request conflict', 'X402_PURCHASE_CONFLICT') }
}
let cloudOrderId = intent.cloudOrderId
if (!cloudOrderId) {
const claimed = await deps.x402CapacityPurchases.claimCloudOrder(
intent.id,
new Date(Date.now() - X402_ORDER_CLAIM_TIMEOUT_MS),
)
if (!claimed) {
const currentIntent = await deps.x402CapacityPurchases.get(params.orgId, params.resourceId, params.requestHash)
cloudOrderId = currentIntent?.cloudOrderId ?? null
if (!cloudOrderId) {
return {
ok: false,
error: conflict('Purchase initialization is in progress', 'X402_PURCHASE_IN_PROGRESS'),
}
}
}
}
if (!cloudOrderId) {
const customerLabel = await deps.cloudStore.getCustomerLabel(params.userId, params.orgId)
const order = await cloudRequest(deps, cloudBaseUrl, async ({ client, storeId }) =>
unwrapCloudResponse(
await client.stores[':storeId'].orders.$post({
param: { storeId },
json: {
items: [{ productId: context.productId, priceId: context.priceId, quantity: 1 }],
currency: context.price.currency,
idempotencyKey: `zpan-x402-capacity:${intent.id}`,
deliveryCallbackUrl: `${params.origin}/api/store/webhook`,
target: { orgId: params.orgId, customerId: params.orgId, customerLabel },
},
}),
cloudOrderResponseSchema,
),
)
if (isCloudError(order)) {
await deps.x402CapacityPurchases.updateCloudState(intent.id, { status: 'created' })
return { ok: false, error: capacityPurchaseCloudError(order) }
}
cloudOrderId = order.id
await deps.x402CapacityPurchases.updateCloudState(intent.id, {
cloudOrderId,
status: 'ordered',
})
}
const createQuote = async (idempotencyKey: string) =>
cloudRequest(deps, cloudBaseUrl, async ({ client, storeId }) =>
unwrapCloudResponse(
await createX402PaymentAttempt(client, {
storeId,
orderId: cloudOrderId,
idempotencyKey,
requestHash: params.requestHash,
resourceId: params.resourceId,
resourceDescription: `Add ${context.product.name} capacity to workspace`,
network: context.receiver.network,
asset: context.receiver.asset,
}),
x402QuoteCreateResponseSchema,
),
)
let attempt: z.infer<typeof x402PaymentAttemptSchema>
let quoteWasReplaced = false
if (intent.cloudAttemptId) {
const found = await cloudRequest(deps, cloudBaseUrl, async ({ client, storeId }) =>
unwrapCloudResponse(
await getX402PaymentAttempt(client, {
storeId,
orderId: cloudOrderId,
attemptId: intent.cloudAttemptId!,
}),
x402PaymentAttemptSchema,
),
)
if (isCloudError(found)) return { ok: false, error: capacityPurchaseCloudError(found) }
attempt = found
if (capacityAttemptExpired(attempt)) {
const replacement = await createQuote(await capacityQuoteRetryKey(intent.idempotencyKey, attempt.id))
if (isCloudError(replacement)) return { ok: false, error: capacityPurchaseCloudError(replacement) }
attempt = replacement
quoteWasReplaced = true
await deps.x402CapacityPurchases.updateCloudState(intent.id, {
cloudOrderId,
cloudAttemptId: attempt.id,
status: attempt.status,
expiresAt: new Date(attempt.expiresAt),
})
}
} else {
const quoted = await createQuote(params.idempotencyKey)
if (isCloudError(quoted)) return { ok: false, error: capacityPurchaseCloudError(quoted) }
attempt = quoted
await deps.x402CapacityPurchases.updateCloudState(intent.id, {
cloudOrderId,
cloudAttemptId: attempt.id,
status: attempt.status,
expiresAt: new Date(attempt.expiresAt),
})
}
if (!params.paymentSignature || quoteWasReplaced) {
if (attempt.status !== 'quoted') {
return terminalCapacityPurchaseOutcome(attempt)
}
return {
ok: true,
kind: 'payment_required',
paymentRequired: attempt.paymentRequired,
paymentRequiredHeader: attempt.paymentRequiredHeader,
}
}
if (attempt.status === 'quoted') {
const verified = await cloudRequest(deps, cloudBaseUrl, async ({ client, storeId }) =>
unwrapCloudResponse(
await verifyX402PaymentAttempt(client, {
storeId,
orderId: cloudOrderId,
attemptId: attempt.id,
paymentSignature: params.paymentSignature!,
requestHash: params.requestHash,
}),
x402PaymentAttemptSchema,
),
)
if (isCloudError(verified)) return { ok: false, error: capacityPurchaseCloudError(verified) }
attempt = verified
}
if (attempt.status === 'verified') {
const settled = await cloudRequest(deps, cloudBaseUrl, async ({ client, storeId }) =>
unwrapCloudResponse(
await settleX402PaymentAttempt(client, {
storeId,
orderId: cloudOrderId,
attemptId: attempt.id,
requestHash: params.requestHash,
}),
x402PaymentAttemptSchema,
),
)
if (isCloudError(settled)) return { ok: false, error: capacityPurchaseCloudError(settled) }
attempt = settled
}
if (attempt.status === 'paid_pending_fulfillment') {
const delivered = await cloudRequest(deps, cloudBaseUrl, async ({ client, storeId }) =>
unwrapCloudResponse(
await triggerX402PaymentAttemptFulfillment(client, {
storeId,
orderId: cloudOrderId,
attemptId: attempt.id,
}),
x402PaymentAttemptSchema,
),
)
if (isCloudError(delivered)) return { ok: false, error: capacityPurchaseCloudError(delivered) }
attempt = delivered
}
await deps.x402CapacityPurchases.updateCloudState(intent.id, {
cloudOrderId,
cloudAttemptId: attempt.id,
status: attempt.status,
expiresAt: new Date(attempt.expiresAt),
})
return terminalCapacityPurchaseOutcome(attempt)
}
function terminalCapacityPurchaseOutcome(attempt: z.infer<typeof x402PaymentAttemptSchema>): CapacityPurchaseOutcome {
if (attempt.status === 'failed' || attempt.status === 'canceled' || attempt.status === 'expired') {
return {
ok: false,
error: conflict('Payment was not completed', `X402_PAYMENT_${attempt.status.toUpperCase()}`),
}
}
return {
ok: true,
kind: attempt.status === 'delivered' ? 'delivered' : 'pending',
attempt,
paymentResponseHeader: attempt.settlementResponseHeader,
}
}
export function listPackages(deps: Pick<CloudStoreDeps, 'cloudStore' | 'licensingCloud'>, cloudBaseUrl: string) {
return listDeliverables(deps, cloudBaseUrl, 'zpan.plan')
}
+2
View File
@@ -17,6 +17,7 @@ export const AGENT_OAUTH_RESOURCE_SCOPES = [
AuthorizationScope.SHARES_CREATE,
AuthorizationScope.SHARES_DELETE,
AuthorizationScope.QUOTA_READ,
AuthorizationScope.QUOTA_PURCHASE,
AuthorizationScope.STORAGE_USAGE_READ,
] as const
export const AGENT_OAUTH_SCOPES = [...AGENT_OAUTH_STANDARD_SCOPES, ...AGENT_OAUTH_RESOURCE_SCOPES] as const
@@ -29,5 +30,6 @@ export const AGENT_OAUTH_SCOPE_DESCRIPTIONS: Record<(typeof AGENT_OAUTH_RESOURCE
[AuthorizationScope.SHARES_CREATE]: 'Create public shares',
[AuthorizationScope.SHARES_DELETE]: 'Revoke shares',
[AuthorizationScope.QUOTA_READ]: 'Inspect workspace quota',
[AuthorizationScope.QUOTA_PURCHASE]: 'Purchase workspace storage capacity',
[AuthorizationScope.STORAGE_USAGE_READ]: 'Inspect workspace storage usage',
}
+1
View File
@@ -8,6 +8,7 @@ export const AuthorizationScope = {
SHARES_CREATE: 'shares:create',
SHARES_DELETE: 'shares:delete',
QUOTA_READ: 'quota:read',
QUOTA_PURCHASE: 'quota:purchase',
STORAGE_USAGE_READ: 'storage-usage:read',
IMAGES_UPLOAD: 'images:upload',
DOWNLOAD_TASKS_READ: 'download-tasks:read',
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import { cloudOrderQuotaChangeSchema } from './cloud-store-legacy'
function deliveryEvent(context: Record<string, unknown>) {
return {
eventId: 'event-1',
eventType: 'commerce.order_item.fulfilled',
orderId: 'order-1',
orderItemId: 'item-1',
productId: 'product-1',
productName: 'Storage',
quantity: 1,
deliverable: { storageBytes: 1024 },
target: { orgId: 'org-1' },
context: {
storeId: 'store-1',
paymentProvider: 'x402',
...context,
},
occurredAt: '2026-07-31T00:00:00.000Z',
}
}
describe('cloudOrderQuotaChangeSchema', () => {
it('accepts ISO billing periods', () => {
expect(
cloudOrderQuotaChangeSchema.safeParse(
deliveryEvent({
billingPeriodStart: '2026-07-31T00:00:00.000Z',
billingPeriodEnd: '2026-08-31T00:00:00.000Z',
}),
).success,
).toBe(true)
})
it('rejects malformed billing periods', () => {
expect(
cloudOrderQuotaChangeSchema.safeParse(
deliveryEvent({
billingPeriodStart: 'not-a-date',
billingPeriodEnd: '2026-08-31T00:00:00.000Z',
}),
).success,
).toBe(false)
})
it('rejects incomplete billing periods', () => {
expect(
cloudOrderQuotaChangeSchema.safeParse(
deliveryEvent({
billingPeriodStart: '2026-07-31T00:00:00.000Z',
}),
).success,
).toBe(false)
})
})
+39 -13
View File
@@ -55,18 +55,29 @@ const storeDeliveryEventSchema = z
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', 'credits']).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(),
}),
context: z
.object({
storeId: z.string().min(1),
paymentProvider: z.enum(['stripe', 'gift_card', 'credits', 'x402']).nullable(),
providerTransactionId: z.string().nullable().optional(),
x402AuditContext: z.record(z.string(), z.unknown()).nullable().optional(),
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().datetime().nullable().optional(),
billingPeriodEnd: z.string().datetime().nullable().optional(),
})
.superRefine((context, ctx) => {
if (Boolean(context.billingPeriodStart) === Boolean(context.billingPeriodEnd)) return
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [context.billingPeriodStart ? 'billingPeriodEnd' : 'billingPeriodStart'],
message: 'Billing period start and end must be provided together',
})
}),
occurredAt: z.string().min(1),
})
.superRefine((event, ctx) => {
@@ -104,6 +115,16 @@ function targetOrgId(target: Record<string, unknown> | null) {
function sourceId(event: z.infer<typeof storeDeliveryEventSchema>) {
const orgId = targetOrgId(event.target)
if (event.context.stripeSubscriptionId) return `stripe_subscription:${event.context.stripeSubscriptionId}:${orgId}`
if (event.context.billingPeriodStart && event.context.billingPeriodEnd) {
return [
event.context.paymentProvider ?? 'commerce',
'period',
event.productId,
event.context.billingPeriodStart,
event.context.billingPeriodEnd,
orgId,
].join(':')
}
return event.orderId
}
@@ -128,7 +149,12 @@ export const cloudOrderQuotaChangeSchema = z.union([
storageBytes: numberDeliverableValue(event.deliverable, 'storageBytes'),
trafficBytes: numberDeliverableValue(event.deliverable, 'trafficBytes'),
trafficOveragePriceCents: optionalNumberDeliverableValue(event.deliverable, 'trafficOveragePriceCents'),
source: event.context.stripeSubscriptionId ? 'stripe_subscription' : 'stripe',
source: event.context.billingPeriodStart ? 'commerce_period' : (event.context.paymentProvider ?? 'commerce'),
entitlementType: event.context.billingPeriodStart ? ('plan' as const) : ('grant' as const),
startsAt: event.context.billingPeriodStart ?? event.occurredAt,
paymentProvider: event.context.paymentProvider,
providerTransactionId: event.context.providerTransactionId,
x402AuditContext: event.context.x402AuditContext,
packageId: event.productId,
packageName: stringDeliverableValue(event.deliverable, 'packageName') ?? event.productName,
occurredAt: event.occurredAt,
+1
View File
@@ -14,5 +14,6 @@ export const oauthResourceScopeLabels = {
[AuthorizationScope.SHARES_CREATE]: 'settings.agentAccess.scope.sharesCreate',
[AuthorizationScope.SHARES_DELETE]: 'settings.agentAccess.scope.sharesDelete',
[AuthorizationScope.QUOTA_READ]: 'settings.agentAccess.scope.quotaRead',
[AuthorizationScope.QUOTA_PURCHASE]: 'settings.agentAccess.scope.quotaPurchase',
[AuthorizationScope.STORAGE_USAGE_READ]: 'settings.agentAccess.scope.storageUsageRead',
} as const satisfies Record<OAuthResourceScope, string>
+8 -5
View File
@@ -2,6 +2,7 @@ import { BUILTIN_PROVIDER_IDS, OAuthProviderMeta } from '@shared/oauth-providers
import type { SiteConfig } from '@shared/schemas'
import { describe, expect, it } from 'vitest'
import { hasOAuthProviderIcon } from '@/components/oauth-provider-icon'
import { absoluteAuthCallbackURL } from '@/lib/auth-callback'
// OAuthButtons is a React rendering component. The project has no jsdom or
// @testing-library/react setup, so we cannot render it here.
@@ -91,22 +92,24 @@ describe('OAuthButtons — provider data contract', () => {
})
// ---------------------------------------------------------------------------
// OAuth sign-in handler — callbackURL defaults to '/files' and can continue
// OAuth sign-in handler — callbackURL defaults to the current origin's /files and can continue
// an authorization request supplied by the sign-in page.
// ---------------------------------------------------------------------------
const PREVIEW_ORIGIN = 'https://feat-x402-paid-agent-uploads-zpan.saltbo.workers.dev'
function buildOAuthCallbackUrl(callbackURL = '/files'): string {
return callbackURL
return absoluteAuthCallbackURL(callbackURL, PREVIEW_ORIGIN)
}
describe('OAuthButtons — OAuth callback URL', () => {
it('defaults the OAuth sign-in callback URL to "/files"', () => {
expect(buildOAuthCallbackUrl()).toBe('/files')
it('defaults the OAuth sign-in callback URL to the absolute current-origin files URL', () => {
expect(buildOAuthCallbackUrl()).toBe(`${PREVIEW_ORIGIN}/files`)
})
it('uses the supplied authorization continuation', () => {
expect(buildOAuthCallbackUrl('/api/auth/oauth2/authorize?state=oauth-state')).toBe(
'/api/auth/oauth2/authorize?state=oauth-state',
`${PREVIEW_ORIGIN}/api/auth/oauth2/authorize?state=oauth-state`,
)
})
})
+5 -1
View File
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { OAuthProviderIcon } from '@/components/oauth-provider-icon'
import { Button } from '@/components/ui/button'
import { useSiteConfig } from '@/hooks/use-site-config'
import { absoluteAuthCallbackURL } from '@/lib/auth-callback'
import { authClient } from '@/lib/auth-client'
export function useOAuthProviders() {
@@ -29,7 +30,10 @@ export function OAuthButtons({
async function handleOAuth(providerId: string) {
setError('')
onSignIn?.()
const result = await authClient.signIn.social({ provider: providerId, callbackURL })
const result = await authClient.signIn.social({
provider: providerId,
callbackURL: absoluteAuthCallbackURL(callbackURL, window.location.origin),
})
if (result.error) {
setError(result.error.message ?? t('auth.signInFailed'))
}
+1
View File
@@ -1218,6 +1218,7 @@
"settings.agentAccess.scope.sharesCreate": "Shares: create shares",
"settings.agentAccess.scope.sharesDelete": "Shares: revoke shares",
"settings.agentAccess.scope.quotaRead": "Quota: read workspace quota",
"settings.agentAccess.scope.quotaPurchase": "Quota: purchase workspace storage capacity",
"settings.agentAccess.scope.storageUsageRead": "Storage usage: read workspace usage",
"settings.agentAccess.colWorkspace": "Workspace",
"settings.agentAccess.colScopes": "Scopes",
+1
View File
@@ -1218,6 +1218,7 @@
"settings.agentAccess.scope.sharesCreate": "分享:创建分享",
"settings.agentAccess.scope.sharesDelete": "分享:撤销分享",
"settings.agentAccess.scope.quotaRead": "配额:读取工作空间配额",
"settings.agentAccess.scope.quotaPurchase": "配额:购买工作空间存储容量",
"settings.agentAccess.scope.storageUsageRead": "存储用量:读取工作空间用量",
"settings.agentAccess.colWorkspace": "工作空间",
"settings.agentAccess.colScopes": "权限",
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { absoluteAuthCallbackURL } from './auth-callback'
const PREVIEW_ORIGIN = 'https://feat-x402-paid-agent-uploads-zpan.saltbo.workers.dev'
describe('absoluteAuthCallbackURL', () => {
it('resolves a site path against the current preview origin', () => {
expect(absoluteAuthCallbackURL('/files', PREVIEW_ORIGIN)).toBe(`${PREVIEW_ORIGIN}/files`)
})
it('preserves a same-origin OAuth authorization continuation', () => {
const continuation = '/api/auth/oauth2/authorize?state=oauth-state&scope=objects%3Aread'
expect(absoluteAuthCallbackURL(continuation, PREVIEW_ORIGIN)).toBe(`${PREVIEW_ORIGIN}${continuation}`)
})
it('preserves an already absolute same-origin callback', () => {
expect(absoluteAuthCallbackURL(`${PREVIEW_ORIGIN}/files`, PREVIEW_ORIGIN)).toBe(`${PREVIEW_ORIGIN}/files`)
})
it.each([
'https://attacker.example/files',
'//attacker.example/files',
'http://[invalid',
])('falls back to the current origin for unsafe callback %s', (callbackURL) => {
expect(absoluteAuthCallbackURL(callbackURL, PREVIEW_ORIGIN)).toBe(`${PREVIEW_ORIGIN}/files`)
})
})
+11
View File
@@ -0,0 +1,11 @@
const DEFAULT_AUTH_CALLBACK_PATH = '/files'
export function absoluteAuthCallbackURL(callbackURL: string, origin: string): string {
const fallback = new URL(DEFAULT_AUTH_CALLBACK_PATH, origin).toString()
try {
const resolved = new URL(callbackURL, origin)
return resolved.origin === new URL(origin).origin ? resolved.toString() : fallback
} catch {
return fallback
}
}
+5 -3
View File
@@ -1,5 +1,6 @@
import { SignupMode } from '@shared/constants'
import { describe, expect, it } from 'vitest'
import { absoluteAuthCallbackURL } from '@/lib/auth-callback'
import { isCredentialLoginMethod } from '@/lib/last-login-method'
// SignIn is a React rendering component. The project has no jsdom or
@@ -88,11 +89,12 @@ describe('SignIn — sign-up link visibility', () => {
// Default callback URL used when no continuation is present
// ---------------------------------------------------------------------------
const DEFAULT_SIGN_IN_CALLBACK_URL = '/files'
const PREVIEW_ORIGIN = 'https://feat-x402-paid-agent-uploads-zpan.saltbo.workers.dev'
const DEFAULT_SIGN_IN_CALLBACK_URL = absoluteAuthCallbackURL('/files', PREVIEW_ORIGIN)
describe('SignIn — default callback URL', () => {
it('uses "/files" for an ordinary sign-in', () => {
expect(DEFAULT_SIGN_IN_CALLBACK_URL).toBe('/files')
it('uses the absolute current-origin files URL for an ordinary sign-in', () => {
expect(DEFAULT_SIGN_IN_CALLBACK_URL).toBe(`${PREVIEW_ORIGIN}/files`)
})
})
+2 -1
View File
@@ -10,6 +10,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { useSiteConfig } from '@/hooks/use-site-config'
import { absoluteAuthCallbackURL } from '@/lib/auth-callback'
import { authClient, signIn } from '@/lib/auth-client'
import { isCredentialLoginMethod } from '@/lib/last-login-method'
import { clearSignInRedirect, loadSignInRedirect } from '@/lib/sign-in-redirect'
@@ -24,7 +25,7 @@ function SignIn() {
const [redirectTo] = useState(() =>
loadSignInRedirect(window.location.search, window.location.origin, window.sessionStorage),
)
const callbackURL = redirectTo ?? '/files'
const callbackURL = absoluteAuthCallbackURL(redirectTo ?? '/files', window.location.origin)
const { data: siteConfig } = useSiteConfig()
const authSignupMode = siteConfig?.auth.signupMode
const captcha = siteConfig?.auth.captcha
+7 -4
View File
@@ -1,5 +1,8 @@
import { SignupMode } from '@shared/constants'
import { describe, expect, it } from 'vitest'
import { absoluteAuthCallbackURL } from '@/lib/auth-callback'
const PREVIEW_ORIGIN = 'https://feat-x402-paid-agent-uploads-zpan.saltbo.workers.dev'
// SignUp is a React rendering component. The project has no jsdom or
// @testing-library/react setup, so we cannot render it here.
@@ -86,7 +89,7 @@ function buildSignUpPayload(
name: fields.name,
email: fields.email,
password: fields.password,
callbackURL: '/files',
callbackURL: absoluteAuthCallbackURL('/files', PREVIEW_ORIGIN),
...(authSignupMode === SignupMode.INVITE_ONLY ? { inviteCode: fields.inviteCode } : {}),
...(authSignupMode === SignupMode.CLOSED && fields.siteInvitationToken
? { siteInvitationToken: fields.siteInvitationToken }
@@ -134,12 +137,12 @@ describe('SignUp — submission payload construction', () => {
expect(payload.siteInvitationToken).toBeUndefined()
})
it('always sets callbackURL to "/files"', () => {
it('always sets callbackURL to the absolute current-origin files URL', () => {
const payloadOpen = buildSignUpPayload(SignupMode.OPEN, baseFields)
const payloadInvite = buildSignUpPayload(SignupMode.INVITE_ONLY, baseFields)
expect(payloadOpen.callbackURL).toBe('/files')
expect(payloadInvite.callbackURL).toBe('/files')
expect(payloadOpen.callbackURL).toBe(`${PREVIEW_ORIGIN}/files`)
expect(payloadInvite.callbackURL).toBe(`${PREVIEW_ORIGIN}/files`)
})
it('includes all base fields in payload', () => {
+2 -1
View File
@@ -12,6 +12,7 @@ import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { useSiteConfig } from '@/hooks/use-site-config'
import { ApiError, getSiteInvitation } from '@/lib/api'
import { absoluteAuthCallbackURL } from '@/lib/auth-callback'
import { signUp } from '@/lib/auth-client'
export const Route = createFileRoute('/(auth)/sign-up')({
@@ -151,7 +152,7 @@ function SignUp() {
name: '',
email,
password,
callbackURL: '/files',
callbackURL: absoluteAuthCallbackURL('/files', window.location.origin),
fetchOptions: captcha?.enabled ? { headers: { 'x-captcha-response': captchaToken } } : undefined,
...(authSignupMode === SignupMode.INVITE_ONLY ? { inviteCode } : {}),
...(hasValidInvite && invite ? { siteInvitationToken: invite } : {}),
+10 -1
View File
@@ -10,6 +10,10 @@ const appPort = Number(process.env.E2E_APP_PORT ?? 5185)
const apiPort = Number(process.env.E2E_API_PORT ?? 8222)
const appVersion = resolveAppVersion()
const appCommit = resolveAppCommit()
const configuredDevHosts = (process.env.ZPAN_DEV_ALLOWED_HOSTS ?? '')
.split(',')
.map((host) => host.trim())
.filter(Boolean)
export default defineConfig(({ mode }) => ({
define: {
@@ -53,7 +57,12 @@ export default defineConfig(({ mode }) => ({
},
server: {
port: appPort,
allowedHosts: process.env.E2E_BASE_URL ? true : undefined,
allowedHosts:
process.env.E2E_BASE_URL
? true
: mode === 'development'
? ['.trycloudflare.com', ...configuredDevHosts]
: undefined,
...(mode === 'node'
? {
proxy: {