mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
fix(store): complete agent capacity purchase workflow (#561)
* fix(oauth): make token revocation idempotent * fix(store): complete agent capacity purchase workflow * fix(build): refresh patched dependency lock hash * fix(oauth): hide inactive token state on revoke --------- Co-authored-by: saltbo <saltbo@foxmail.com>
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
# Agent File Management Regression Log
|
||||
|
||||
This document records end-to-end regressions for Agent-driven ZPan file management across Realmroot, Restish, ZPan, ZPan Cloud, and Agent Wallet. A round only passes when the Agent can discover state, connect accounts, request workspace-scoped access, switch workspaces, and manage files through the Realmroot skill and CLI.
|
||||
This document records end-to-end regressions for Agent-driven ZPan file management across Realmroot, Restish, ZPan, ZPan Cloud, and a discovered x402 payer. A round only passes when the Agent can discover state, connect accounts, request workspace-scoped access, switch workspaces, and manage files through the Realmroot skill and CLI.
|
||||
|
||||
## Final outcome
|
||||
|
||||
- Local: repeated file-management boundary rounds, a complete x402 purchase/upload continuation, and two independent skill-only cleanroom acceptances passed.
|
||||
- Staging: two-workspace file management and sandbox-wallet x402 settlement/upload continuation passed through public APIs.
|
||||
- Staging: two-workspace file management and sandbox-payer x402 settlement/upload continuation passed through public APIs.
|
||||
- Production: two-workspace file management passed after deployment; production payment was intentionally excluded.
|
||||
- Runtime contract: an Agent can discover resources and real workspace contexts, connect or expand an account, obtain one exact workspace-bound token at a time, switch workspaces without a custom header, manage files, and recover from insufficient capacity through the advertised x402 purchase operation.
|
||||
- Cleanup: counted fixtures and shares were deleted or revoked. Controller-approved persistent cleanroom grants remain active because grant revocation is a controller-management operation, not an Agent self-service operation.
|
||||
@@ -46,9 +46,9 @@ This document records end-to-end regressions for Agent-driven ZPan file manageme
|
||||
| AFM-020 | Restish target profiles | The staging target profile contained only `base_url`; target-token issuance succeeded, but Restish reported that the profile had no `oauth2` credential binding and refused every secured target operation. | Every non-default target profile must bind its declared security scheme to `realmroot-target` with the exact selected scopes. | Fixed in local configuration and Realmroot skill guidance; staging operations passed |
|
||||
| AFM-021 | ZPan Cloud publication UI | A product that became inactive remained selectable from stale query state; saving the resource then returned only `invalid_product_price`. | Refresh or invalidate the product query before editing, exclude inactive products, and identify whether the product or price is invalid. | Fixed in Cloud UI/API; targeted tests and local active-only selector regression passed |
|
||||
| AFM-022 | Local profile isolation | Omitting `REALMROOT_PLUGIN_STATE_DIR` on one target command silently selected the default Agent's Admin workspace credential instead of the cleanroom Agent's Regression credential. | Every Realmroot and target command in an isolated run must inherit the same plugin state directory; the skill should establish it once for the shell/session. | Fixed in Realmroot skill guidance; the unintended Admin draft was aborted and the isolated paid round passed |
|
||||
| AFM-023 | Agent Wallet local profile | The local Restish profile declared only `wallet:x402:pay`, so valid `wallet:read` and `wallet:budget:request` grants were hidden from generated operations. | Local/staging Wallet profiles must declare the complete supported scope set advertised by the resource. | External profile repaired; Wallet contract regression now asserts all three scopes |
|
||||
| AFM-024 | Agent Wallet OpenAPI | A sandbox profile reused Restish's one cached production contract, so generated validation omitted Base Sepolia (`eip155:84532`) even though sandbox runtime returned and accepted it. | One semantic Wallet API with environment profiles needs a profile-stable supported-network schema; endpoint-specific enablement remains in `x-wallet-environment` and runtime policy. | Fixed, tested, deployed, and synced; sandbox help now includes production and sandbox CAIP-2 identifiers |
|
||||
| AFM-025 | Restish x402 header forwarding | Wallet authorization consumed the sandbox budget, but a caller matching `^Payment-Signature:` missed Restish's `< Payment-Signature:` verbose response line, so Cloud correctly remained quoted and no capacity was delivered. | Protocol response headers must be captured with Restish's response prefix, forwarded unchanged, and considered complete only after the merchant succeeds and Wallet confirms settlement. | Fixed in Realmroot skill guidance; the same staging attempt subsequently reached delivered/settled |
|
||||
| AFM-023 | x402 payer local profile | The local Restish profile declared only `wallet:x402:pay`, so valid `wallet:read` and `wallet:budget:request` grants were hidden from generated operations. | Local/staging payer profiles must declare the complete supported scope set advertised by the resource. | External profile repaired; the payer contract regression now asserts all three scopes |
|
||||
| AFM-024 | x402 payer OpenAPI | A sandbox profile reused Restish's one cached production contract, so generated validation omitted Base Sepolia (`eip155:84532`) even though sandbox runtime returned and accepted it. | One semantic payer API with environment profiles needs a profile-stable supported-network schema; endpoint-specific enablement remains in `x-wallet-environment` and runtime policy. | Fixed, tested, deployed, and synced; sandbox help now includes production and sandbox CAIP-2 identifiers |
|
||||
| AFM-025 | Restish x402 header forwarding | Payer authorization consumed the sandbox budget, but a caller matching `^Payment-Signature:` missed Restish's `< Payment-Signature:` verbose response line, so Cloud correctly remained quoted and no capacity was delivered. | Protocol response headers must be captured with Restish's response prefix, forwarded unchanged, and considered complete only after the merchant succeeds and the payer confirms settlement. | Fixed in Realmroot skill guidance; the same staging attempt subsequently reached delivered/settled |
|
||||
| AFM-026 | ZPan Cloud webhook idempotency | A paid delivery webhook failed once, then every retry remained pending because local D1 returned an opaque uniqueness error that ZPan's string-matching conflict detector did not recognize. | Claim webhook events atomically with `ON CONFLICT DO NOTHING ... RETURNING`; resume the stored event whenever no row was inserted. | Fixed in ZPan; async/sync/conflict unit tests and store integration tests passed |
|
||||
| AFM-027 | Realmroot account expansion | A connection update requested only the newly needed scope, so the successful OAuth callback replaced existing account scopes and revoked persistent workspace grants that were no longer covered. | Every connection expansion must request the union of the active account scopes and newly requested scopes; pending/interrupted authorization must not mutate the connection or grants. | Fixed in Realmroot; 49 use-case tests, spec verification, typecheck, and CI passed |
|
||||
| AFM-028 | Cleanroom fixture | The manually prepared workspace omitted the quota projection and free-plan entitlement rows that normal workspace creation installs, first blocking fulfillment and then downloads with zero traffic authority. | Cleanroom fixtures must be created through the normal workspace path or reproduce all required quota projections and free-plan entitlements before counted acceptance begins. | Test setup repaired; no product behavior was changed |
|
||||
@@ -202,20 +202,20 @@ Future rounds must record the exact scenario, workspace count, connection state,
|
||||
|
||||
### Paid Round 1 — quota exhaustion, x402 settlement, upload continuation
|
||||
|
||||
- Environment: local ZPan, Realmroot, ZPan Cloud, Agent Wallet Sandbox, MinIO, and a public HTTPS tunnel for the local ZPan callback.
|
||||
- Environment: local ZPan, Realmroot, ZPan Cloud, an x402 sandbox payer, MinIO, and a public HTTPS tunnel for the local ZPan callback.
|
||||
- Store readiness: one active 1 GiB capacity tier, an active verified Base Sepolia USDC receiver, and a listed `storage.capacity.purchase` resource bound to the instance's registered public origin.
|
||||
- Quota boundary: creating a 20 MiB draft in the 10 MiB Agent Regression workspace returned `CAPACITY_REQUIRED`, the current quota/usage/request size, a stable request hash, and one selectable standard capacity offer.
|
||||
- Purchase: called the advertised ZPan capacity operation without a signature, received an x402 v2 `PAYMENT-REQUIRED` object, and passed that object unmodified to the local Agent Wallet resource.
|
||||
- Wallet: requested a delegated sandbox budget, limited it to the exact merchant origin and payout address through the controller page, then authorized the 10,000-atomic-USDC payment and returned `PAYMENT-SIGNATURE`.
|
||||
- Settlement and fulfillment: retried the same purchase with the same request hash/idempotency key and signature; ZPan returned `status: delivered`. Agent Wallet subsequently verified the `PAYMENT-RESPONSE` and recorded the payment as settled.
|
||||
- Purchase: called the advertised ZPan capacity operation without a signature, received an x402 v2 `PAYMENT-REQUIRED` object, and passed that object unmodified to the discovered payer resource.
|
||||
- Payer: requested a delegated sandbox budget, limited it to the exact merchant origin and payout address through the controller page, then authorized the 10,000-atomic-USDC payment and returned `PAYMENT-SIGNATURE`.
|
||||
- Settlement and fulfillment: retried the same purchase with the same request hash/idempotency key and signature; ZPan returned `status: delivered`. The payer subsequently verified the `PAYMENT-RESPONSE` and recorded the payment as settled.
|
||||
- Upload continuation: retried the exact original create-object body, received a direct-upload workflow, uploaded 20 MiB to storage, completed the upload using the returned ETag, and fetched the object download URL.
|
||||
- Integrity: source and downloaded SHA-256 were both `cd52d81e25f372e6fa4db2c0dfceb59862c1969cab17096da352b34950c973cc`.
|
||||
- Isolation: the retried object and quota both reported `3aDEJGbtmnIhVTy1gFYsj3Zpyr81AZMh`; no workspace-selection header was used. The uploaded fixture was deleted after verification.
|
||||
- Result: passed. After store readiness and controller budget approval, the counted Agent-side payment and file flow used only Realmroot/Restish and the local Agent Wallet API; no source, database, logs, or browser diagnostics were needed.
|
||||
- Result: passed. After store readiness and controller budget approval, the counted Agent-side payment and file flow used only Realmroot/Restish and the discovered payer API; no source, database, logs, or browser diagnostics were needed.
|
||||
|
||||
### Staging Round 1 — account expansion and two-workspace file management
|
||||
|
||||
- Environment: production Realmroot identity plane, ZPan staging, ZPan Cloud staging, staging object storage, and the online Agent Wallet Sandbox resource.
|
||||
- Environment: production Realmroot identity plane, ZPan staging, ZPan Cloud staging, staging object storage, and an online x402 sandbox payer.
|
||||
- Discovery: Realmroot listed both real ZPan workspaces, including labels, identifiers, types, roles, existing grant state, and `connectionAuthorized` state.
|
||||
- Account expansion: the second workspace was discoverable but not yet connected. After fixing the connection revision wait, `access connect` stayed in the foreground while the controller updated the existing provider account and selected both workspaces; the selected item then reported `connectionAuthorized: true`.
|
||||
- Authorization: approved one persistent exact-scope grant for each workspace. Target-token commands produced zero output and no custom workspace header was used.
|
||||
@@ -229,18 +229,18 @@ Future rounds must record the exact scenario, workspace count, connection state,
|
||||
### Staging Paid Round 1 — sandbox x402 and upload continuation
|
||||
|
||||
- Quota boundary: a 20 MiB draft in the 11 MiB Agent Staging Regression workspace returned `CAPACITY_REQUIRED` with the healthy staged 10 GiB plan and stable request hash.
|
||||
- Purchase: the advertised operation returned x402 v2 for 1,000,000 atomic USDC on Base Sepolia. The online Agent Wallet Sandbox authorized it within the existing delegated budget.
|
||||
- Settlement and fulfillment: ZPan returned `status: delivered`; the wallet verified `PAYMENT-RESPONSE` and recorded transaction `0x16fa720d74baab46adaf8ca3cddeb4b857e7bb35c7b21b5b8fc29074a81d681f` as settled.
|
||||
- Purchase: the advertised operation returned x402 v2 for 1,000,000 atomic USDC on Base Sepolia. The online sandbox payer authorized it within the existing delegated budget.
|
||||
- Settlement and fulfillment: ZPan returned `status: delivered`; the payer verified `PAYMENT-RESPONSE` and recorded transaction `0x16fa720d74baab46adaf8ca3cddeb4b857e7bb35c7b21b5b8fc29074a81d681f` as settled.
|
||||
- Upload continuation: the unchanged original create body succeeded after delivery, 20 MiB uploaded directly, completion activated the object, and the download URL returned the same SHA-256 as the source.
|
||||
- Result: passed entirely through public staging APIs and the sandbox wallet. No staging database, source, logs, or non-approval browser investigation was used during the successful payment round.
|
||||
- Result: passed entirely through public staging APIs and the sandbox payer. No staging database, source, logs, or non-approval browser investigation was used during the successful payment round.
|
||||
|
||||
### Staging revalidation — persistent switching and x402 recovery
|
||||
|
||||
- Started from a fresh production-Realmroot Agent identity and used only catalog discovery to find ZPan staging, its existing account connection, both authorized workspaces, and the Wallet sandbox resource.
|
||||
- Started from a fresh production-Realmroot Agent identity and used only catalog discovery to find ZPan staging, its existing account connection, both authorized workspaces, and a sandbox payer resource.
|
||||
- Verified that the default one-target-token lifetime is intentionally consumed after issuance. Re-approved both exact workspace grants as persistent, then switched Preview Reviewer → Agent Staging Regression → Preview Reviewer with no additional approval dialog and without a workspace header.
|
||||
- Repeated file create, direct upload, completion, rename, download-integrity verification, and deletion through the staging profile.
|
||||
- Deliberately exhausted the regression fixture's effective quota and received the published 10 GiB offer. The first orchestration attempt exposed AFM-025: Wallet had signed the payment, while Cloud remained quoted because the caller had not forwarded the actual Restish response header.
|
||||
- After correcting the response-header extraction, the same Cloud attempt became delivered, its order became paid and fulfilled, Wallet recorded the Base Sepolia transaction as settled, and the unchanged original object request completed and downloaded with SHA-256 `155164370cf1dd288f29fb98401d4761528e7e23f02460a4695bde80bf16047a`.
|
||||
- Deliberately exhausted the regression fixture's effective quota and received the published 10 GiB offer. The first orchestration attempt exposed AFM-025: the payer had signed the payment, while Cloud remained quoted because the caller had not forwarded the actual Restish response header.
|
||||
- After correcting the response-header extraction, the same Cloud attempt became delivered, its order became paid and fulfilled, the payer recorded the Base Sepolia transaction as settled, and the unchanged original object request completed and downloaded with SHA-256 `155164370cf1dd288f29fb98401d4761528e7e23f02460a4695bde80bf16047a`.
|
||||
- The fixture already had an active monthly period, so Cloud correctly scheduled the second purchase for the next period instead of stacking two current plans. The temporary staging entitlement mutation used to recreate exhaustion was restored after diagnosis.
|
||||
- Result: passed. The product defect was in workflow guidance, not ZPan Cloud delivery; the successful rerun required no source or database lookup after the corrected header rule was applied.
|
||||
|
||||
@@ -259,11 +259,11 @@ Future rounds must record the exact scenario, workspace count, connection state,
|
||||
|
||||
### Final independent cleanroom — Realmroot skill only
|
||||
|
||||
- Environment: local, fresh isolated Realmroot plugin state, fresh Agent identity, local ZPan/ZPan Cloud, and Agent Wallet Sandbox. The independent Agent received only the Realmroot skill and public endpoint locations; it did not inspect source, databases, logs, server processes, prior test artifacts, or browser state.
|
||||
- Identity and connection: enrolled `ZPan Cleanroom Agent 20260803`, discovered ZPan and Wallet from Realmroot, connected the controller's ZPan account, and selected `Admin's Space` plus `Agent Cleanroom 20260803`.
|
||||
- Environment: local, fresh isolated Realmroot plugin state, fresh Agent identity, local ZPan/ZPan Cloud, and a discovered x402 sandbox payer. The independent Agent received only the Realmroot skill and public endpoint locations; it did not inspect source, databases, logs, server processes, prior test artifacts, or browser state.
|
||||
- Identity and connection: enrolled `ZPan Cleanroom Agent 20260803`, discovered ZPan and an x402 payer from Realmroot, connected the controller's ZPan account, and selected `Admin's Space` plus `Agent Cleanroom 20260803`.
|
||||
- Authorization: obtained one persistent exact-scope grant per workspace and switched Admin → Cleanroom → Admin with one target token active at a time and no workspace header.
|
||||
- Admin lifecycle: uploaded 4 KiB of real bytes, completed, downloaded with matching SHA-256, renamed, created/revoked a share, deleted, and verified absence.
|
||||
- Payment: the cleanroom create returned `CAPACITY_REQUIRED`; the Agent followed the advertised purchase operation, received an x402 v2 Base Sepolia challenge for 10,000 atomic USDC, obtained a controller-constrained Wallet budget, forwarded `Payment-Signature`, forwarded `Payment-Response`, and confirmed Wallet payment `2efdb070-7116-471b-94fc-d2ea8cfd17eb` settled in transaction `0x45092914c6495077387a1ab80b96a01a3c39f7c5078eed78d2b481697b7cf802`.
|
||||
- Payment: the cleanroom create returned `CAPACITY_REQUIRED`; the Agent followed the advertised purchase operation, received an x402 v2 Base Sepolia challenge for 10,000 atomic USDC, obtained a controller-constrained payer budget, forwarded `Payment-Signature`, forwarded `Payment-Response`, and confirmed payment `2efdb070-7116-471b-94fc-d2ea8cfd17eb` settled in transaction `0x45092914c6495077387a1ab80b96a01a3c39f7c5078eed78d2b481697b7cf802`.
|
||||
- Recovery findings: the run exposed AFM-026 and AFM-027 without diagnostic access. Parent-side maintainers fixed both products and repaired the incomplete AFM-028 fixture; the independent Agent then resumed through only the public workflow.
|
||||
- Paid upload continuation: retried the unchanged 8 KiB create request, directly uploaded and completed object `uEDeGtJOA0q_JG6FWoHXR`, downloaded SHA-256 `8a022f9acf03ba5ab4c44f70f8c4d827bd2744bf3d76cb9296f61276c038d054`, renamed, created/revoked a share, deleted, and verified the cleanroom list empty.
|
||||
- Final switching and isolation: both replacement grants remained active/persistent, the return switch required no approval, each workspace hid the other's markers and filenames, and all fixtures were cleaned up.
|
||||
|
||||
@@ -328,6 +328,9 @@ index 3896e50f99997176d2f99c934d52701cf076d11b..c3668c63eaf7dadd4f901bef218b90e5
|
||||
error: "invalid_client_metadata",
|
||||
error_description: "jwks_uri must not point to a private or reserved address"
|
||||
});
|
||||
@@ -4863 +4937 @@ async function revokeJwtAccessToken(ctx, opts, token) {
|
||||
- if (error.name === "TypeError" || error.name === "JWSInvalid") throw new APIError$1("BAD_REQUEST", {
|
||||
+ if (error.name === "TypeError" || error.name === "JWSInvalid" || error.name === "JWSSignatureVerificationFailed") throw new APIError$1("BAD_REQUEST", {
|
||||
@@ -5379,6 +5453,10 @@ const schema = {
|
||||
type: "string[]",
|
||||
required: false
|
||||
|
||||
Generated
+3
-3
@@ -30,7 +30,7 @@ overrides:
|
||||
|
||||
patchedDependencies:
|
||||
'@better-auth/oauth-provider@1.7.0-rc.2':
|
||||
hash: 4af6cc2bd9af458b82bc39f183871b53ed9d9dce47021f662447da91ea17d5e9
|
||||
hash: f935a3a4ac53781f80cd9d359509df6deee52343fef73bd1385ded320d22921d
|
||||
path: patches/@better-auth__oauth-provider@1.7.0-rc.2.patch
|
||||
|
||||
importers:
|
||||
@@ -51,7 +51,7 @@ importers:
|
||||
version: 1.7.0-rc.2(@better-auth/core@1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.7.0-rc.2(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260606.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.28.17))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.4))(better-call@1.3.7(zod@4.4.3))
|
||||
'@better-auth/oauth-provider':
|
||||
specifier: 1.7.0-rc.2
|
||||
version: 1.7.0-rc.2(patch_hash=4af6cc2bd9af458b82bc39f183871b53ed9d9dce47021f662447da91ea17d5e9)(@better-auth/core@1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.2(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260606.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.28.17))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.4))(better-call@1.3.7(zod@4.4.3))
|
||||
version: 1.7.0-rc.2(patch_hash=f935a3a4ac53781f80cd9d359509df6deee52343fef73bd1385ded320d22921d)(@better-auth/core@1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.2(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260606.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.28.17))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.4))(better-call@1.3.7(zod@4.4.3))
|
||||
'@better-captcha/react':
|
||||
specifier: ^0.7.0
|
||||
version: 0.7.0(react@19.2.5)(typescript@5.9.3)
|
||||
@@ -6390,7 +6390,7 @@ snapshots:
|
||||
'@better-auth/core': 1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
|
||||
'@better-auth/utils': 0.4.2
|
||||
|
||||
'@better-auth/oauth-provider@1.7.0-rc.2(patch_hash=4af6cc2bd9af458b82bc39f183871b53ed9d9dce47021f662447da91ea17d5e9)(@better-auth/core@1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.2(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260606.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.28.17))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.4))(better-call@1.3.7(zod@4.4.3))':
|
||||
'@better-auth/oauth-provider@1.7.0-rc.2(patch_hash=f935a3a4ac53781f80cd9d359509df6deee52343fef73bd1385ded320d22921d)(@better-auth/core@1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.7.0-rc.2(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260606.1)(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(kysely@0.28.17))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.1.4))(better-call@1.3.7(zod@4.4.3))':
|
||||
dependencies:
|
||||
'@better-auth/core': 1.7.0-rc.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@cloudflare/workers-types@4.20260606.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
|
||||
'@better-auth/utils': 0.4.2
|
||||
|
||||
+9
-3
@@ -150,9 +150,15 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
|
||||
.clone()
|
||||
.json()
|
||||
.catch(() => null)) as { error?: string } | null
|
||||
if (error?.error === 'unsupported_token_type') {
|
||||
const token = (await revokeRequest.formData()).get('token')
|
||||
if (typeof token === 'string') {
|
||||
const token = (await revokeRequest.formData()).get('token')
|
||||
if (typeof token === 'string' && token.length > 0) {
|
||||
if (error?.error === 'invalid_request') {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' },
|
||||
})
|
||||
}
|
||||
if (error?.error === 'unsupported_token_type') {
|
||||
await c.get('deps').oauth.revokeJwtAccessToken(c.get('platform').db, token)
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
|
||||
@@ -1272,7 +1272,7 @@ describe('OAuth consent guards', () => {
|
||||
expect(response.headers.get('www-authenticate')).toContain('/.well-known/oauth-protected-resource/api')
|
||||
})
|
||||
|
||||
it('issues a DPoP API token through JWT bearer and token exchange grants', async () => {
|
||||
it('issues and idempotently revokes a DPoP API token through JWT bearer and token exchange grants [spec: oauth-server/idempotent-token-revocation]', async () => {
|
||||
const ctx = await createTestApp()
|
||||
ctx.app.get('/api/test-agent-audit', async (c) => {
|
||||
const principal = c.get('principal')
|
||||
@@ -1545,6 +1545,23 @@ describe('OAuth consent guards', () => {
|
||||
})
|
||||
expect(revokeResponse.status).toBe(200)
|
||||
|
||||
const inactiveTokenResponse = await ctx.app.request('http://localhost:3000/api/auth/oauth2/revoke', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: basic, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
token: `${exchanged.access_token.slice(0, -1)}x`,
|
||||
token_type_hint: 'access_token',
|
||||
}).toString(),
|
||||
})
|
||||
expect(inactiveTokenResponse.status).toBe(200)
|
||||
|
||||
const missingTokenResponse = await ctx.app.request('http://localhost:3000/api/auth/oauth2/revoke', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: basic, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ token_type_hint: 'access_token' }).toString(),
|
||||
})
|
||||
expect(missingTokenResponse.status).toBe(400)
|
||||
|
||||
const revokedProof = await new SignJWT({
|
||||
htm: 'GET',
|
||||
htu: apiUrl,
|
||||
|
||||
+45
-4
@@ -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. 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.',
|
||||
'Creates a file draft and returns the runtime upload descriptor. If capacity is insufficient and a published plan can close the gap, createObject returns 402 with requestHash and eligible offers; invoke purchaseStorageCapacityWithX402 for one offer, authorize its payment requirement with a discovered x402 payer, invoke submitStorageCapacityPayment with the returned signature, then retry createObject. If no published plan can close the gap, createObject returns 422 without a payment offer. 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: {
|
||||
@@ -70,9 +70,9 @@ export function createArazzoDocument(origin: string) {
|
||||
},
|
||||
{
|
||||
workflowId: 'purchaseStorageCapacityWithX402',
|
||||
summary: 'Purchase workspace storage capacity',
|
||||
summary: 'Request payment terms for 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.',
|
||||
'Select an offer returned by createObject 402 and call purchaseStorageCapacity without PAYMENT-SIGNATURE. Pass the returned PAYMENT-REQUIRED header unchanged to any discovered x402 payer. Then invoke submitStorageCapacityPayment with its PAYMENT-SIGNATURE.',
|
||||
inputs: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -96,7 +96,7 @@ export function createArazzoDocument(origin: string) {
|
||||
},
|
||||
successCriteria: [{ condition: '$statusCode == 402' }],
|
||||
outputs: {
|
||||
paymentRequired: '$response.body',
|
||||
paymentRequired: '$response.header.PAYMENT-REQUIRED',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -104,6 +104,47 @@ export function createArazzoDocument(origin: string) {
|
||||
paymentRequired: '$steps.requestPaymentChallenge.outputs.paymentRequired',
|
||||
},
|
||||
},
|
||||
{
|
||||
workflowId: 'submitStorageCapacityPayment',
|
||||
summary: 'Submit payment for workspace storage capacity',
|
||||
description:
|
||||
'Retry the same capacity purchase with the PAYMENT-SIGNATURE returned by the selected x402 payer. Capture PAYMENT-RESPONSE from the successful response, pass it unchanged to that payer’s confirmation operation, and require the payment to settle. If the purchase status is pending, repeat this idempotent workflow until it is delivered. Retry the original createObject request only after delivery.',
|
||||
inputs: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
resourceId: { type: 'string', minLength: 1 },
|
||||
requestHash: { type: 'string', minLength: 1 },
|
||||
idempotencyKey: { type: 'string', minLength: 1 },
|
||||
paymentSignature: { type: 'string', minLength: 1 },
|
||||
},
|
||||
required: ['resourceId', 'requestHash', 'idempotencyKey', 'paymentSignature'],
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
stepId: 'submitPayment',
|
||||
operationId: 'purchaseStorageCapacity',
|
||||
parameters: [
|
||||
{ name: 'resourceId', in: 'path', value: '$inputs.resourceId' },
|
||||
{ name: 'payment-signature', in: 'header', value: '$inputs.paymentSignature' },
|
||||
],
|
||||
requestBody: {
|
||||
contentType: 'application/json',
|
||||
payload: {
|
||||
requestHash: '$inputs.requestHash',
|
||||
idempotencyKey: '$inputs.idempotencyKey',
|
||||
},
|
||||
},
|
||||
outputs: {
|
||||
purchase: '$response.body',
|
||||
paymentResponse: '$response.header.PAYMENT-RESPONSE',
|
||||
},
|
||||
},
|
||||
],
|
||||
outputs: {
|
||||
purchase: '$steps.submitPayment.outputs.purchase',
|
||||
paymentResponse: '$steps.submitPayment.outputs.paymentResponse',
|
||||
},
|
||||
},
|
||||
{
|
||||
workflowId: 'refreshDirectFileUploadParts',
|
||||
summary: 'Refresh expired direct-upload URLs',
|
||||
|
||||
@@ -1894,6 +1894,82 @@ describe('Objects API — quota enforcement', () => {
|
||||
)
|
||||
}
|
||||
|
||||
function stubCapacityStoreWithOffer() {
|
||||
const product = {
|
||||
id: 'capacity-pro',
|
||||
storeId: 'store-test-binding',
|
||||
type: 'store_item',
|
||||
name: 'Pro',
|
||||
description: 'More storage',
|
||||
metadata: { deliverable: { type: 'zpan.plan', storageBytes: 4096, includedCredits: 0 } },
|
||||
prices: [
|
||||
{
|
||||
id: 'price-monthly',
|
||||
currency: 'usd',
|
||||
amount: 200,
|
||||
recurring: { interval: 'month', intervalCount: 1 },
|
||||
},
|
||||
],
|
||||
active: true,
|
||||
sortOrder: 2,
|
||||
createdAt: '2026-07-30T00:00:00.000Z',
|
||||
updatedAt: '2026-07-30T00:00:00.000Z',
|
||||
}
|
||||
const resource = {
|
||||
id: 'publication-resource-pro',
|
||||
storeId: 'store-test-binding',
|
||||
resourceId: 'capacity-pro:price-monthly',
|
||||
offerId: 'pro-monthly',
|
||||
title: 'Pro',
|
||||
description: 'More storage',
|
||||
productId: product.id,
|
||||
priceId: product.prices[0].id,
|
||||
postResourceUrl: 'https://files.example/api/store/capacity-purchases/capacity-pro%3Aprice-monthly',
|
||||
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',
|
||||
}
|
||||
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: [product], total: 1, 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: [resource],
|
||||
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)
|
||||
@@ -2362,7 +2438,39 @@ describe('Objects API — quota enforcement', () => {
|
||||
expect(quotaRows[0]).toEqual({ used: 140, quota: 100 })
|
||||
})
|
||||
|
||||
it('rejects upload preparation when no capacity offer can satisfy the request', async () => {
|
||||
it('returns an eligible capacity offer before upload [spec: objects/create-capacity-offer]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
const orgId = await getOrgId(db)
|
||||
await setOrgQuota(db, orgId, 0, 90)
|
||||
await addStorageEntitlement(db, orgId, 100)
|
||||
stubCapacityStoreWithOffer()
|
||||
|
||||
const res = await createDraftResponse(app, headers, { name: 'upgrade.txt', size: 11 })
|
||||
|
||||
expect(res.status).toBe(402)
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
error: 'CAPACITY_REQUIRED',
|
||||
requestHash: expect.stringMatching(/^[0-9a-f]{64}$/),
|
||||
requestedBytes: 11,
|
||||
usedBytes: 90,
|
||||
quotaBytes: 100,
|
||||
offers: [
|
||||
{
|
||||
resourceId: 'capacity-pro:price-monthly',
|
||||
productId: 'capacity-pro',
|
||||
priceId: 'price-monthly',
|
||||
storageBytes: 4096,
|
||||
amount: 200,
|
||||
currency: 'usd',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects upload preparation when no capacity offer can satisfy the request [spec: objects/create-capacity-unavailable]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await authedHeaders(app)
|
||||
|
||||
@@ -47,6 +47,12 @@ function cloudProduct(overrides: Record<string, unknown> = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function capacityUpgradeProduct() {
|
||||
return cloudProduct({
|
||||
metadata: { deliverable: { type: 'zpan.plan', storageBytes: 20 * 1024 ** 3, includedCredits: 100 } },
|
||||
})
|
||||
}
|
||||
|
||||
function cloudOrder(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'cloud-order-1',
|
||||
@@ -759,7 +765,7 @@ describe('Quota Store API', () => {
|
||||
({ ok: status >= 200 && status < 300, status, json: async () => body }) as Response
|
||||
vi.mocked(fetch)
|
||||
.mockResolvedValueOnce(response(capacityPublication()))
|
||||
.mockResolvedValueOnce(response(cloudProduct()))
|
||||
.mockResolvedValueOnce(response(capacityUpgradeProduct()))
|
||||
.mockResolvedValueOnce(
|
||||
response({
|
||||
id: 'receiver-1',
|
||||
@@ -822,7 +828,7 @@ describe('Quota Store API', () => {
|
||||
vi.mocked(fetch)
|
||||
.mockClear()
|
||||
.mockResolvedValueOnce(response(capacityPublication()))
|
||||
.mockResolvedValueOnce(response(cloudProduct()))
|
||||
.mockResolvedValueOnce(response(capacityUpgradeProduct()))
|
||||
.mockResolvedValueOnce(
|
||||
response({
|
||||
id: 'receiver-1',
|
||||
|
||||
@@ -39,6 +39,25 @@ const capacityPurchaseInputSchema = z.object({
|
||||
requestHash: z.string().min(1).max(256),
|
||||
idempotencyKey: z.string().min(1).max(200),
|
||||
})
|
||||
const paymentResponse = <T extends z.ZodType>(schema: T, description: string) => ({
|
||||
...jsonContent(schema, description),
|
||||
headers: {
|
||||
'PAYMENT-RESPONSE': {
|
||||
description: 'Standard x402 settlement response for confirmation by the selected payer.',
|
||||
schema: { type: 'string' as const },
|
||||
},
|
||||
},
|
||||
})
|
||||
const paymentRequiredResponse = {
|
||||
...jsonContent(x402PaymentRequiredSchema, 'x402 payment required'),
|
||||
headers: {
|
||||
'PAYMENT-REQUIRED': {
|
||||
description: 'Standard x402 payment requirement to pass unchanged to a selected payer.',
|
||||
required: true,
|
||||
schema: { type: 'string' as const },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const packagesRoute = authRoute(
|
||||
{ scopes: [AuthorizationScope.STORE_READ] },
|
||||
@@ -172,7 +191,7 @@ const capacityPurchaseRoute = authRoute(
|
||||
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. An authorized caller may recover an interrupted purchase by using the same requestHash with a fresh idempotencyKey. A delivered response means the workspace capacity entitlement is active; retry the original createObject request.',
|
||||
'Select an offer returned by createObject. A new purchase is accepted only when its plan increases the workspace capacity. Call without PAYMENT-SIGNATURE to receive a standard x402 PAYMENT-REQUIRED challenge. Pay it and retry this same request with PAYMENT-SIGNATURE. An authorized caller may recover an interrupted purchase by using the same requestHash with a fresh idempotencyKey. A delivered response means the workspace capacity entitlement is active; retry the original createObject request.',
|
||||
tags: ['Store'],
|
||||
method: 'post',
|
||||
path: '/capacity-purchases/{resourceId}',
|
||||
@@ -189,12 +208,12 @@ const capacityPurchaseRoute = authRoute(
|
||||
...jsonBody(capacityPurchaseInputSchema),
|
||||
},
|
||||
responses: {
|
||||
200: jsonContent(capacityPurchaseDeliveredResultSchema, 'Capacity delivered'),
|
||||
202: jsonContent(capacityPurchasePendingResultSchema, 'Payment accepted; capacity fulfillment is pending'),
|
||||
200: paymentResponse(capacityPurchaseDeliveredResultSchema, 'Capacity delivered'),
|
||||
202: paymentResponse(capacityPurchasePendingResultSchema, 'Payment accepted; capacity fulfillment is pending'),
|
||||
400: errorResponse('Invalid capacity offer'),
|
||||
402: jsonContent(x402PaymentRequiredSchema, 'x402 payment required'),
|
||||
402: paymentRequiredResponse,
|
||||
403: errorResponse('License not bound'),
|
||||
409: errorResponse('Purchase request conflict'),
|
||||
409: errorResponse('Purchase request conflict or capacity offer is not an upgrade'),
|
||||
429: errorResponse('Too many pending capacity purchases'),
|
||||
502: errorResponse('Cloud error'),
|
||||
},
|
||||
|
||||
@@ -204,6 +204,7 @@ describe('global OpenAPI document', () => {
|
||||
expect(workflows.workflows?.map((workflow) => workflow.workflowId)).toEqual([
|
||||
'prepareDirectFileUpload',
|
||||
'purchaseStorageCapacityWithX402',
|
||||
'submitStorageCapacityPayment',
|
||||
'refreshDirectFileUploadParts',
|
||||
'completeDirectFileUpload',
|
||||
'abortDirectFileUpload',
|
||||
@@ -214,6 +215,7 @@ describe('global OpenAPI document', () => {
|
||||
expect(workflowOperationIds).toEqual([
|
||||
'createObject',
|
||||
'purchaseStorageCapacity',
|
||||
'purchaseStorageCapacity',
|
||||
'presignObjectUploadParts',
|
||||
'completeObjectUpload',
|
||||
'abortObjectUpload',
|
||||
@@ -229,6 +231,12 @@ describe('global OpenAPI document', () => {
|
||||
sessionId: '$steps.createUploadDraft.outputs.sessionId',
|
||||
upload: '$steps.createUploadDraft.outputs.upload',
|
||||
})
|
||||
expect(workflows.workflows?.[1]?.outputs).toMatchObject({
|
||||
paymentRequired: '$steps.requestPaymentChallenge.outputs.paymentRequired',
|
||||
})
|
||||
expect(workflows.workflows?.[2]?.outputs).toMatchObject({
|
||||
paymentResponse: '$steps.submitPayment.outputs.paymentResponse',
|
||||
})
|
||||
expect(document.externalDocs).toEqual({
|
||||
description: 'Machine-readable API workflows (Arazzo 1.1)',
|
||||
url: '/api/workflows.arazzo.json',
|
||||
@@ -582,6 +590,7 @@ describe('global OpenAPI document', () => {
|
||||
responses?: Record<
|
||||
string,
|
||||
{
|
||||
headers?: Record<string, unknown>
|
||||
content?: {
|
||||
'application/json'?: { schema?: { $ref?: string; allOf?: unknown[]; properties?: unknown } }
|
||||
}
|
||||
@@ -621,6 +630,12 @@ describe('global OpenAPI document', () => {
|
||||
type: 'object',
|
||||
required: ['x402Version', 'resource', 'accepts'],
|
||||
})
|
||||
expect(doc.paths['/api/store/capacity-purchases/{resourceId}']?.post?.responses?.['402']?.headers).toMatchObject({
|
||||
'PAYMENT-REQUIRED': { required: true, schema: { type: 'string' } },
|
||||
})
|
||||
expect(doc.paths['/api/store/capacity-purchases/{resourceId}']?.post?.responses?.['200']?.headers).toMatchObject({
|
||||
'PAYMENT-RESPONSE': { schema: { type: 'string' } },
|
||||
})
|
||||
expect(doc.paths['/api/objects']?.post?.security).toEqual([
|
||||
{ oauth2: [AuthorizationScope.OBJECTS_CREATE] },
|
||||
{ bearerAuth: [] },
|
||||
|
||||
@@ -617,6 +617,24 @@ describe('cloud-store usecase', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a non-increasing plan before creating an order [spec: quota-store/capacity-upgrade-only]', async () => {
|
||||
const { deps, requests } = makeDeps({
|
||||
responses: [ok(publication()), ok(pkg()), ok(receiver)],
|
||||
quota: { currentPlan: { storageBytes: 4096 } } as EffectiveQuota,
|
||||
})
|
||||
const createIntent = vi.spyOn(deps.x402CapacityPurchases, 'create')
|
||||
|
||||
const out = await purchaseCapacity(deps, CLOUD, params)
|
||||
|
||||
expectError(out, {
|
||||
httpStatus: 409,
|
||||
reason: 'CAPACITY_OFFER_NOT_UPGRADE',
|
||||
message: 'Capacity offer does not increase the workspace quota',
|
||||
})
|
||||
expect(createIntent).not.toHaveBeenCalled()
|
||||
expect(requests).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('rejects a capacity purchase when the x402 receiver is not active', async () => {
|
||||
const { deps, requests } = makeDeps({
|
||||
responses: [ok(publication()), ok(pkg()), ok({ ...receiver, status: 'pending_verification' })],
|
||||
@@ -761,7 +779,7 @@ describe('cloud-store usecase', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses the intent, verifies payment, settles, and returns the receipt', async () => {
|
||||
it('reuses the intent after its plan becomes active, then settles [spec: quota-store/capacity-recovery]', async () => {
|
||||
const quoted = attempt()
|
||||
const verifiedAttempt = attempt('verified')
|
||||
const paidPendingFulfillment = attempt('paid_pending_fulfillment')
|
||||
@@ -783,6 +801,10 @@ describe('cloud-store usecase', () => {
|
||||
],
|
||||
})
|
||||
await purchaseCapacity(deps, CLOUD, params)
|
||||
const getEffectiveQuota = vi.fn(async () => ({
|
||||
currentPlan: { storageBytes: 4096 },
|
||||
})) as unknown as QuotaRepo['getEffectiveQuota']
|
||||
deps.quota.getEffectiveQuota = getEffectiveQuota
|
||||
|
||||
const out = await purchaseCapacity(deps, CLOUD, { ...params, paymentSignature: 'signature' })
|
||||
|
||||
@@ -814,6 +836,7 @@ describe('cloud-store usecase', () => {
|
||||
path: 'stores/:storeId/orders/:orderId/x402/payment-attempts/:attemptId/fulfillment-attempts',
|
||||
input: { json: { deliveryCallbackUrl: `${params.origin}/api/store/webhook` } },
|
||||
})
|
||||
expect(getEffectiveQuota).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns an already delivered attempt after quote expiry without creating a replacement quote', async () => {
|
||||
|
||||
@@ -402,12 +402,19 @@ export async function purchaseCapacity(
|
||||
if (receiverResponse.status === 404) throw new Error('x402_receiver_not_found')
|
||||
const receiver = await unwrapCloudResponse(receiverResponse, x402ReceiverSchema)
|
||||
if (receiver.status !== 'active') throw new Error('x402_receiver_not_active')
|
||||
return { ...bound, productId, priceId, product, price, receiver }
|
||||
return { ...bound, productId, priceId, product, price, receiver, storageBytes }
|
||||
})
|
||||
if (isCloudError(context)) return { ok: false, error: capacityPurchaseCloudError(context) }
|
||||
|
||||
let intent = await deps.x402CapacityPurchases.get(params.orgId, params.resourceId, params.requestHash)
|
||||
if (!intent) {
|
||||
const quota = await deps.quota.getEffectiveQuota(params.orgId)
|
||||
if (context.storageBytes <= (quota.currentPlan?.storageBytes ?? 0)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: conflict('Capacity offer does not increase the workspace quota', 'CAPACITY_OFFER_NOT_UPGRADE'),
|
||||
}
|
||||
}
|
||||
try {
|
||||
intent = await deps.x402CapacityPurchases.create({
|
||||
orgId: params.orgId,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
Feature: OAuth server
|
||||
ZPan exposes OAuth endpoints for delegated applications and agents.
|
||||
|
||||
@oauth-server/idempotent-token-revocation @api
|
||||
Scenario: Revoking an inactive token is idempotent
|
||||
Given an authenticated OAuth client with an expired, revoked, or unknown token
|
||||
When the client submits the token to the revocation endpoint
|
||||
Then the endpoint returns success without revealing the token state
|
||||
@@ -71,6 +71,18 @@ Feature: Objects
|
||||
When a file object is created
|
||||
Then a draft with upload instructions (part size + presigned URLs) is returned
|
||||
|
||||
@objects/create-capacity-offer @api
|
||||
Scenario: Creating a file over quota offers an immediate capacity upgrade
|
||||
Given a file would exceed the workspace quota and a larger published plan is available
|
||||
When the file object is created
|
||||
Then the API responds 402 with the eligible capacity offer and a purchase request hash
|
||||
|
||||
@objects/create-capacity-unavailable @api
|
||||
Scenario: Creating a file over quota fails when no immediate capacity upgrade exists
|
||||
Given a file would exceed the workspace quota and no published plan can close the gap
|
||||
When the file object is created
|
||||
Then the API responds 422 without a payment offer
|
||||
|
||||
@objects/create-file-too-large @api
|
||||
Scenario: Creating an oversized file is rejected
|
||||
Given a file larger than the 5 TiB maximum
|
||||
|
||||
@@ -51,6 +51,19 @@ Feature: Quota Store
|
||||
When a recurring checkout is started
|
||||
Then it is rejected
|
||||
|
||||
@quota-store/capacity-upgrade-only @usecase
|
||||
Scenario: Capacity payments cannot buy a non-increasing plan
|
||||
Given a workspace already has the selected storage tier
|
||||
When a new capacity purchase is requested for that tier
|
||||
Then it is rejected before an order or payment is created
|
||||
|
||||
@quota-store/capacity-recovery @usecase
|
||||
Scenario: An existing capacity purchase remains recoverable after delivery
|
||||
Given a capacity purchase intent was created while its storage tier was an upgrade
|
||||
And that tier is now the workspace's active plan
|
||||
When the same purchase request is resumed
|
||||
Then its existing payment state is recovered without revalidating the tier
|
||||
|
||||
@quota-store/fixed-checkout @api
|
||||
Scenario: Fixed-duration packages check out without credit discounts
|
||||
Given a fixed-duration package
|
||||
|
||||
Reference in New Issue
Block a user