mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
Merge pull request #545 from saltbo/feat/x402-paid-agent-uploads
feat(store): add agent x402 capacity purchases
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+5
-5
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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' } })
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'))
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "权限",
|
||||
|
||||
@@ -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`)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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`)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user