From b534392bb19c73dd4a2c237a7f88d13171a36c28 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Mon, 28 Sep 2026 22:13:44 -0700 Subject: [PATCH] Release 2.11.0: pre-payment verification path on conditional gates, shared helpers --- CLAUDE.md | 6 +- README.md | 14 +++-- agentscore_commerce/__init__.py | 12 +++- agentscore_commerce/challenge/__init__.py | 8 ++- agentscore_commerce/challenge/identity.py | 23 ++++++++ agentscore_commerce/checkout.py | 54 +++-------------- agentscore_commerce/identity/aiohttp.py | 8 ++- agentscore_commerce/identity/django.py | 8 ++- agentscore_commerce/identity/fastapi.py | 10 +++- agentscore_commerce/identity/flask.py | 8 ++- agentscore_commerce/identity/middleware.py | 8 ++- agentscore_commerce/identity/sanic.py | 8 ++- agentscore_commerce/payment/__init__.py | 10 ++++ agentscore_commerce/payment/payment_header.py | 44 ++++++++++++++ pyproject.toml | 2 +- tests/test_fastapi.py | 59 +++++++++++++++++++ tests/test_verification_session.py | 51 ++++++++++++++++ uv.lock | 2 +- 18 files changed, 264 insertions(+), 71 deletions(-) create mode 100644 tests/test_verification_session.py diff --git a/CLAUDE.md b/CLAUDE.md index 92834cb..6d53a57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ Every helper is extracted from a real consumer, not speculated. | Submodule | What it is | |---|---| -| `agentscore_commerce` (top-level) | `Checkout` orchestrator (the 2.0 high-level surface): one config object + hooks (pre_validate, compute_pricing, mint_recipients, compose_mppx, on_settled, gate), auto-derived x402+pympp servers, per-framework adapters `handle_fastapi`/`handle_flask`/`handle_django`/`handle_aiohttp`/`handle_sanic`, signed UCP routes via `mount_ucp_routes_{fastapi,flask,django,aiohttp,sanic}`, optional `discovery_probe` config for x402-crawler auto-routing. Plus `compute_first_checkout` — variable-cost pay-per-result helper (compute-first + exact-x402). Scope is exact-mode rails only (x402-exact Base, tempo/charge, solana/charge, Stripe SPT); does NOT use x402-upto (Permit2) or Settlement-Overrides — variable cost is captured by running the work pre-settle and emitting a 402 at the exact computed price. `create_quote_cache` — content-hash quote cache used by the compute-first helper (in-memory by default; pass `redis_url` for distributed deployments). `create_result_cache` — the neutral primitive under it: keyed JSON-value cache with the same body-hash key builder; use it to cache any probe-leg result a Checkout-class merchant replays on the settle leg (e.g. a paid upstream call made in `pre_validate`). `create_default_on_denied` — canonical `on_denied(reason)` factory matching `Checkout`'s gate hook (handles `wallet_signer_mismatch`, `wallet_not_trusted` unfixable fallback, `payment_required`, `token_expired`/`invalid_credential`/`api_error`); merchants pass `merchant_name` + `support_email` and override `wallet_not_trusted_message` / `payment_required_message` / `support_context` for vendor-specific copy. `has_payment_header` — discriminator that splits discovery legs (no payment credential → 402) from settle legs (`payment-signature` / `x-payment` / `Authorization: Payment `); `has_x402_header` / `has_mppx_header` — granular dispatch helpers (x402 vs MPP credential present) for routes that branch on rail. `malformed_payment_credential` — wire-shape gate for payment credentials (not base64 JSON, not token-shaped → reject); `Checkout` runs it before any merchant hook by default (`credential_pre_check=False` opts out). `default_read_only_on_denied(reason)` — canonical `on_denied` for read-only resource gates (`GET /orders/:id`): collapses every denial to 401 `unauthorized` + `Cache-Control: no-store` while still spreading `denial_reason_to_body` so `agent_instructions` / `verify_url` ride through. Returns a `DefaultOnDeniedResult(body, status, headers)` dataclass; FastAPI / Flask / aiohttp / Sanic `on_denied` callbacks accept an optional 3-tuple `(body, status, headers)` — convert with `lambda req, reason: (r := default_read_only_on_denied(reason), (r.body, r.status, r.headers or {}))[1]` or a named wrapper. Django + ASGI middleware adapters return Response objects directly; construct `JsonResponse(r.body, status=r.status, headers=r.headers)` / `JSONResponse(content=r.body, status_code=r.status, headers=r.headers)`. `extract_owner_scope(headers) -> OwnerScope` — pull canonical owner identity from `X-Wallet-Address` / `X-Operator-Token` with safe token hashing; pair with a wallet-or-token-scoped resource query so plaintext tokens never leave the request. Plus factories: `pricing_result` (cents → typed `PricingResult`), `validation_response_{fastapi,flask,django,aiohttp,sanic}` (4xx envelope per framework) | +| `agentscore_commerce` (top-level) | `Checkout` orchestrator (the 2.0 high-level surface): one config object + hooks (pre_validate, compute_pricing, mint_recipients, compose_mppx, on_settled, gate), auto-derived x402+pympp servers, per-framework adapters `handle_fastapi`/`handle_flask`/`handle_django`/`handle_aiohttp`/`handle_sanic`, signed UCP routes via `mount_ucp_routes_{fastapi,flask,django,aiohttp,sanic}`, optional `discovery_probe` config for x402-crawler auto-routing. Plus `compute_first_checkout` — variable-cost pay-per-result helper (compute-first + exact-x402). Scope is exact-mode rails only (x402-exact Base, tempo/charge, solana/charge, Stripe SPT); does NOT use x402-upto (Permit2) or Settlement-Overrides — variable cost is captured by running the work pre-settle and emitting a 402 at the exact computed price. `create_quote_cache` — content-hash quote cache used by the compute-first helper (in-memory by default; pass `redis_url` for distributed deployments). `create_result_cache` — the neutral primitive under it: keyed JSON-value cache with the same body-hash key builder; use it to cache any probe-leg result a Checkout-class merchant replays on the settle leg (e.g. a paid upstream call made in `pre_validate`). `create_default_on_denied` — canonical `on_denied(reason)` factory matching `Checkout`'s gate hook (handles `wallet_signer_mismatch`, `wallet_not_trusted` unfixable fallback, `payment_required`, `token_expired`/`invalid_credential`/`api_error`); merchants pass `merchant_name` + `support_email` and override `wallet_not_trusted_message` / `payment_required_message` / `support_context` for vendor-specific copy. `should_run_conditional_gate` / `requests_verification_session` / `has_identity_header` / `VERIFICATION_SESSION_HEADER`: the conditional-gate predicate (payment credential or an opt-in pre-payment verification-session request) and its parts; `build_identity_bootstrap`: the matching `identity_bootstrap` 402 block. `has_payment_header` — discriminator that splits discovery legs (no payment credential → 402) from settle legs (`payment-signature` / `x-payment` / `Authorization: Payment `); `has_x402_header` / `has_mppx_header` — granular dispatch helpers (x402 vs MPP credential present) for routes that branch on rail. `malformed_payment_credential` — wire-shape gate for payment credentials (not base64 JSON, not token-shaped → reject); `Checkout` runs it before any merchant hook by default (`credential_pre_check=False` opts out). `default_read_only_on_denied(reason)` — canonical `on_denied` for read-only resource gates (`GET /orders/:id`): collapses every denial to 401 `unauthorized` + `Cache-Control: no-store` while still spreading `denial_reason_to_body` so `agent_instructions` / `verify_url` ride through. Returns a `DefaultOnDeniedResult(body, status, headers)` dataclass; FastAPI / Flask / aiohttp / Sanic `on_denied` callbacks accept an optional 3-tuple `(body, status, headers)` — convert with `lambda req, reason: (r := default_read_only_on_denied(reason), (r.body, r.status, r.headers or {}))[1]` or a named wrapper. Django + ASGI middleware adapters return Response objects directly; construct `JsonResponse(r.body, status=r.status, headers=r.headers)` / `JSONResponse(content=r.body, status_code=r.status, headers=r.headers)`. `extract_owner_scope(headers) -> OwnerScope` — pull canonical owner identity from `X-Wallet-Address` / `X-Operator-Token` with safe token hashing; pair with a wallet-or-token-scoped resource query so plaintext tokens never leave the request. Plus factories: `pricing_result` (cents → typed `PricingResult`), `validation_response_{fastapi,flask,django,aiohttp,sanic}` (4xx envelope per framework) | | `agentscore_commerce.identity.{fastapi,flask,django,aiohttp,sanic,middleware}` | Trust gate middleware (KYC, age, sanctions on both account name and signer wallet, jurisdiction). Each adapter exports a conditional variant that wraps the gate so it fires only on settle legs (anonymous discovery flows through and gets a 402 with all rails): FastAPI / ASGI expose `ConditionalAgentScoreGate`, Django exposes `ConditionalAgentScoreMiddleware`, Flask + Sanic expose `conditional_agentscore_gate(app, ...)`, aiohttp exposes `conditional_agentscore_gate_middleware(...)`. Adapters export ONLY framework-specific surface (gate classes / fns, accessors, `capture_wallet`); shared helpers like `has_payment_header` / `denial_reason_to_body` import from their canonical home (`agentscore_commerce.payment` and `agentscore_commerce.identity` respectively). The Flask / Sanic `agentscore_gate(app, ...)` function accepts an optional `condition=` callable for inline gating (`AgentScoreGate.__init__` does not). | | `agentscore_commerce.identity.policy` | Per-product compliance helpers: `PolicyBlock`, `build_gate_from_policy`, `run_gate_with_enforcement`, `shipping_country_allowed`, `shipping_state_allowed`, `validate_shipping_against_policy` (one-call country+state validator that raises `CheckoutValidationError` with the canonical envelope on miss) | | `agentscore_commerce.payment` | Networks/USDC/rails registries, paymentauth.org directive builders, `create_x402_server` (wraps `x402[evm]>=2.9` + `cdp-sdk` for `facilitator="coinbase"`; install via the `coinbase` extra), `build_x402_accepts_for_402` (build the 402's `accepts[]` from the registered scheme; derives the right `extra.name` per network), `build_default_checkout_rails(tempo=, x402_base=, solana_mpp=, stripe=)` (canonical 4-rail `rails` dict factory: merchants pass per-rail overrides instead of redeclaring the recipient sentinel + network/chain_id/token boilerplate. When a caller flips `network` without pinning `token` / `chain_id`, the underlying dataclass derives them from the network: Base Sepolia → Sepolia USDC + chain_id 84532, Solana devnet → devnet USDC mint. Explicit overrides always win. Solana's `network` field accepts both CAIP-2 (`solana:5eykt4UsFv8…` / `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1`) AND the raw `@solana/mpp` form (`mainnet-beta` / `devnet` / `localnet`)), `build_mppx_compose_rails(amount_usd=, tempo_recipient=, solana_recipient=, ...)` (per-call intent factory replacing the hand-rolled `[("tempo/charge", {...}), ("solana/charge", {...}), ("stripe/charge", {...})]` list; auto-handles USD→atomic conversion for Solana; auto-drops the `stripe/charge` rail with a one-time `logging.warning` when `amount_usd < 0.50` since Stripe's fixed ~$0.30 fee makes sub-50-cent charges unprofitable — many Stripe accounts also reject PI creation below the floor with `amount_too_small`; sub-50-cent APIs pass `include_stripe=False` explicitly to silence the warning), `process_x402_settle` (verify+settle in one call), `create_mppx_server` (wraps `pympp[server,tempo,stripe]>=0.6`), `is_evm_network`/`is_solana_network` (CAIP-2 discriminators that hide the `startswith("eip155:")` / `startswith("solana:")` prefix matching), `has_payment_header` (settle-leg vs discovery-leg discriminator), `parse_did_pkh_address` (parses ``did:pkh:::`` into a `PaymentSigner`), dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header | @@ -83,7 +83,7 @@ Two identity types: wallet (`X-Wallet-Address`) and operator-token (`X-Operator- `create_session_on_missing` auto-mints a verification session when no identity is present AND when `wallet_not_trusted` carries fixable reasons (`kyc_required` / `kyc_pending` / `kyc_failed`) — both paths rewrite the denial to `identity_verification_required` before reaching `on_denied`. When the merchant omits `create_session_on_missing` from `CheckoutGateConfig`, `Checkout` auto-defaults it from `gate.api_key` + `gate.base_url` + `gate.context` + `gate.merchant_name`. Merchants that need `on_before_session` side effects (e.g. pre-minting an order_id) supply their own config to override. -**Getting a verify_url before paying.** On an identity-gated `Checkout`, the gate runs only on a settle leg, so a buyer with no identity reached the session 403 only by sending a payment credential first (an SPT buyer had to mint one). The discovery 402 carries an `identity_bootstrap` block naming `X-Verification-Session: create` whenever the request has no identity header, and a request carrying that header, no identity and no payment credential runs the gate, which answers with the same session-bearing 403. It is opt-in on purpose: scanners replay the Bazaar example body on a schedule, so minting on every identity-less 402 would create a session and (on goods stores) a pending order per probe. Gateless merchants neither advertise nor honor it. Parity with node-commerce. +**Getting a verify_url before paying.** On an identity-gated `Checkout`, the gate runs only on a settle leg, so a buyer with no identity reached the session 403 only by sending a payment credential first (an SPT buyer had to mint one). The discovery 402 carries an `identity_bootstrap` block (`build_identity_bootstrap()`) naming `X-Verification-Session: create` whenever the request has no identity header (`has_identity_header`), and a request carrying that header, no identity and no payment credential runs the gate, which answers with the same session-bearing 403. It is opt-in on purpose: scanners replay the Bazaar example body on a schedule, so minting on every identity-less 402 would create a session and (on goods stores) a pending order per probe. Gateless merchants neither advertise nor honor it. Parity with node-commerce. `build_verification_required_body(reason, message=?, agent_instructions=?, extra=?)` — canonical body builder for the `identity_verification_required` denial. Spreads `verify_url` / `session_id` / `poll_secret` / `poll_url` / `agent_instructions` from the gate-minted reason into a 4xx envelope with merchant-specific message + optional extras. Saves the per-merchant mapping boilerplate. @@ -131,7 +131,7 @@ async def gate_on_settle(request: Request) -> None: async def purchase(...): ... ``` -Anonymous POST flows through to the handler unauthenticated and gets a 402 with all rails + per-order pricing. Identity is verified at settle time on the retry leg (when the agent submits `X-Payment` / `Authorization: Payment`); `create_session_on_missing` still auto-mints a verification session there. The same wrap pattern works identically across all 6 framework adapters (fastapi, flask, django, aiohttp, sanic, middleware/ASGI). See `examples/multi_rail_merchant.py` and `examples/compliance_merchant.py`. +Anonymous POST flows through to the handler unauthenticated and gets a 402 with all rails + per-order pricing. Identity is verified at settle time on the retry leg (when the agent submits `X-Payment` / `Authorization: Payment`); `create_session_on_missing` still auto-mints a verification session there. The shared predicate is `should_run_conditional_gate` (from `agentscore_commerce.payment`): a payment credential OR `requests_verification_session` (`X-Verification-Session: create` with no identity and no payment), so a buyer can get the session 403 before paying. Every conditional adapter variant uses it; a hand-rolled wrap should too, and a merchant building its own 402 advertises the path by passing `build_identity_bootstrap()` into `build_402_body`'s `extra` as `identity_bootstrap`. Parity with node-commerce. The same wrap pattern works identically across all 6 framework adapters (fastapi, flask, django, aiohttp, sanic, middleware/ASGI). See `examples/multi_rail_merchant.py` and `examples/compliance_merchant.py`. ### `compatible_clients` field on emitted 402s diff --git a/README.md b/README.md index b9bc618..d8c7aa5 100644 --- a/README.md +++ b/README.md @@ -70,15 +70,17 @@ _gate = AgentScoreGate( ) -# Run the gate CONDITIONALLY: only when a payment credential is already attached. -# Anonymous discovery (no payment header) flows through to the handler so any spec- -# compliant x402 wallet can read the 402 challenge with rails + pricing without first -# proving identity. Identity is verified at settle time on the retry leg. -from agentscore_commerce.payment import has_payment_header +# Run the gate CONDITIONALLY: when a payment credential is attached, or when the buyer asks +# for a verification session with `X-Verification-Session: create` and no identity. Anonymous +# discovery flows through to the handler so any spec-compliant x402 wallet can read the 402 +# challenge with rails + pricing without first proving identity; identity is verified at settle +# time, or earlier on request. Advertise the early path by passing `build_identity_bootstrap()` +# into your 402 body as `identity_bootstrap` (Checkout does this for you). +from agentscore_commerce.payment import should_run_conditional_gate async def gate_on_settle(request: Request) -> None: - if not has_payment_header(request): + if not should_run_conditional_gate(request): return None return await _gate(request) diff --git a/agentscore_commerce/__init__.py b/agentscore_commerce/__init__.py index 10c98a2..09a4154 100644 --- a/agentscore_commerce/__init__.py +++ b/agentscore_commerce/__init__.py @@ -13,8 +13,8 @@ from importlib.metadata import PackageNotFoundError from importlib.metadata import version as _pkg_version +from agentscore_commerce.challenge.identity import build_identity_bootstrap from agentscore_commerce.checkout import ( - VERIFICATION_SESSION_HEADER, Checkout, CheckoutContext, CheckoutGateConfig, @@ -126,6 +126,8 @@ from agentscore_commerce.identity.sessions import CreateSessionOnMissing from agentscore_commerce.identity.types import PolicyCheck, PolicyResult from agentscore_commerce.payment import ( + VERIFICATION_SESSION_HEADER, + VERIFICATION_SESSION_VALUE, PaymentSigner, SignerNetwork, SolanaMppRailSpec, @@ -138,6 +140,7 @@ extract_payment_signer, extract_signer_for_precheck, format_usd_cents, + has_identity_header, has_mppx_header, has_payment_header, has_x402_header, @@ -145,6 +148,8 @@ is_solana_network, load_solana_fee_payer, read_x402_payment_header, + requests_verification_session, + should_run_conditional_gate, ) from agentscore_commerce.quote_cache import ( CachedQuote, @@ -170,6 +175,7 @@ "FIXABLE_DENIAL_REASONS", "UCP_A2A_EXTENSION_URI", "VERIFICATION_SESSION_HEADER", + "VERIFICATION_SESSION_VALUE", "A2AAgentCard", "A2AAgentCardCapabilities", "A2AAgentCardExtension", @@ -243,6 +249,7 @@ "build_contact_support_next_steps", "build_default_checkout_rails", "build_gate_from_policy", + "build_identity_bootstrap", "build_jwks_response", "build_mppx_compose_rails", "build_signer_mismatch_body", @@ -262,6 +269,7 @@ "format_usd_cents", "generate_ucp_signing_key", "get_identity_status", + "has_identity_header", "has_mppx_header", "has_payment_header", "has_x402_header", @@ -275,9 +283,11 @@ "mpp_payment_handler", "pricing_result", "read_x402_payment_header", + "requests_verification_session", "run_gate_with_enforcement", "shipping_country_allowed", "shipping_state_allowed", + "should_run_conditional_gate", "sign_ucp_profile", "stripe_spt_payment_handler", "ucp_a2a_extension", diff --git a/agentscore_commerce/challenge/__init__.py b/agentscore_commerce/challenge/__init__.py index 6baf386..fcd8de7 100644 --- a/agentscore_commerce/challenge/__init__.py +++ b/agentscore_commerce/challenge/__init__.py @@ -9,7 +9,12 @@ ) from agentscore_commerce.challenge.body import X402PaymentRequired, X402ResourceInfo, build_402_body from agentscore_commerce.challenge.how_to_pay import build_how_to_pay -from agentscore_commerce.challenge.identity import IdentityMode, SignerMatchResult, build_identity_metadata +from agentscore_commerce.challenge.identity import ( + IdentityMode, + SignerMatchResult, + build_identity_bootstrap, + build_identity_metadata, +) from agentscore_commerce.challenge.pricing import PricingBlock, build_pricing_block from agentscore_commerce.challenge.receipt import ( ProductInfo, @@ -37,6 +42,7 @@ "build_agent_instructions", "build_agent_memory_hint", "build_how_to_pay", + "build_identity_bootstrap", "build_identity_metadata", "build_pricing_block", "build_validation_error", diff --git a/agentscore_commerce/challenge/identity.py b/agentscore_commerce/challenge/identity.py index 45d90f3..67e15a8 100644 --- a/agentscore_commerce/challenge/identity.py +++ b/agentscore_commerce/challenge/identity.py @@ -3,6 +3,8 @@ from dataclasses import dataclass from typing import Any, Literal +from agentscore_commerce.payment.payment_header import VERIFICATION_SESSION_HEADER, VERIFICATION_SESSION_VALUE + IdentityMode = Literal["wallet", "operator_token"] @@ -38,3 +40,24 @@ def build_identity_metadata( "Payment must be signed with the claimed wallet OR any same-operator linked wallet listed in linked_wallets." ) return block + + +def build_identity_bootstrap() -> dict[str, str]: + """Build the ``identity_bootstrap`` block for an identity-gated 402. + + The block rides a 402 whose request carried no identity header. It names the + ``X-Verification-Session: create`` request that returns the gate's session-bearing 403 + (verify_url + poll data) without a payment credential. ``Checkout`` attaches it automatically; + merchants building their own 402 with ``build_402_body`` pass it in ``extra``. + """ + return { + "header": VERIFICATION_SESSION_HEADER, + "value": VERIFICATION_SESSION_VALUE, + "instructions": ( + "This purchase requires a verified identity. Without an operator token, repeat this same " + f"request with the header {VERIFICATION_SESSION_HEADER}: {VERIFICATION_SESSION_VALUE} and " + "no payment credential. The response is a 403 carrying verify_url, session_id, poll_secret " + "and poll_url: give verify_url to the buyer, poll poll_url for an operator_token, then pay " + "with X-Operator-Token set." + ), + } diff --git a/agentscore_commerce/checkout.py b/agentscore_commerce/checkout.py index 48f94c9..9f29b7c 100644 --- a/agentscore_commerce/checkout.py +++ b/agentscore_commerce/checkout.py @@ -83,6 +83,7 @@ from agentscore_commerce.challenge.agent_memory import first_encounter_agent_memory from agentscore_commerce.challenge.body import X402PaymentRequired, build_402_body from agentscore_commerce.challenge.how_to_pay import build_how_to_pay +from agentscore_commerce.challenge.identity import build_identity_bootstrap from agentscore_commerce.challenge.pricing import PricingBlock, build_pricing_block from agentscore_commerce.challenge.respond_402 import Respond402Result, respond_402 from agentscore_commerce.challenge.validation_error import build_validation_error @@ -91,9 +92,11 @@ from agentscore_commerce.payment.constants import STRIPE_MIN_CHARGE_USD from agentscore_commerce.payment.mppx_failures import classify_mppx_failure from agentscore_commerce.payment.payment_header import ( + has_identity_header, has_mppx_header, has_x402_header, malformed_payment_credential, + requests_verification_session, ) from agentscore_commerce.payment.rail_spec import ( RecipientLike, @@ -714,31 +717,6 @@ def _resolve_resource_url(request: CheckoutRequest) -> str: return apply_forwarded_proto(request.url, read_forwarded_proto(request.headers)) -VERIFICATION_SESSION_HEADER = "X-Verification-Session" -"""Request header that asks an identity-gated Checkout for a verification session without paying. - -The discovery 402 advertises it; a request carrying it and no identity or payment credential runs -the gate, which answers with its session-bearing 403 (verify_url + poll data). Opt-in so crawlers -replaying a valid example body never mint sessions or pending orders. -""" -_VERIFICATION_SESSION_VALUE = "create" - - -def _carries_identity(headers_lower: Mapping[str, str]) -> bool: - from agentscore_commerce.aip.request import has_agent_identity_header_parts - - return bool( - headers_lower.get("x-operator-token") - or headers_lower.get("x-wallet-address") - or has_agent_identity_header_parts(headers_lower) - ) - - -def _requests_verification_session(headers_lower: Mapping[str, str]) -> bool: - value = headers_lower.get(VERIFICATION_SESSION_HEADER.lower()) or "" - return value.strip().lower() == _VERIFICATION_SESSION_VALUE - - def _resolve_identity_metadata(ctx: CheckoutContext) -> dict[str, Any] | None: """Compose the identity_metadata block from request + assess state. @@ -1282,13 +1260,7 @@ async def handle(self, request: CheckoutRequest) -> CheckoutResult: # var when set; logs a warning and skips when no key is set # (dev/testnet pattern). has_payment_header = has_x402_header(request.headers) or has_mppx_header(request.headers) - request_headers_lower = normalize_headers_to_lowercase(request.headers) - bootstraps_session = ( - not has_payment_header - and self._has_identity_gate() - and not _carries_identity(request_headers_lower) - and _requests_verification_session(request_headers_lower) - ) + bootstraps_session = self._has_identity_gate() and requests_verification_session(request.headers) if has_payment_header or bootstraps_session: gate_result = ( await self._run_gate(ctx) if self.gate is not None else await self._run_wallet_sanctions_only(ctx) @@ -2991,19 +2963,11 @@ async def _emit_402( # wallet intent. Saves agents a round trip: they learn required_signer # + linked_wallets at discovery instead of at the 403 on retry. identity_metadata = _resolve_identity_metadata(ctx) - identity_bootstrap: dict[str, Any] | None = None - if self._has_identity_gate() and not _carries_identity(normalize_headers_to_lowercase(ctx.request.headers)): - identity_bootstrap = { - "header": VERIFICATION_SESSION_HEADER, - "value": _VERIFICATION_SESSION_VALUE, - "instructions": ( - "This purchase requires a verified identity. Without an operator token, repeat this same " - f"request with the header {VERIFICATION_SESSION_HEADER}: {_VERIFICATION_SESSION_VALUE} and " - "no payment credential. The response is a 403 carrying verify_url, session_id, poll_secret " - "and poll_url: give verify_url to the buyer, poll poll_url for an operator_token, then pay " - "with X-Operator-Token set." - ), - } + identity_bootstrap = ( + build_identity_bootstrap() + if self._has_identity_gate() and not has_identity_header(ctx.request.headers) + else None + ) body_extra = { **({"identity_bootstrap": identity_bootstrap} if identity_bootstrap else {}), **(ctx.pricing.body_extras or {}), diff --git a/agentscore_commerce/identity/aiohttp.py b/agentscore_commerce/identity/aiohttp.py index d62846a..1306218 100644 --- a/agentscore_commerce/identity/aiohttp.py +++ b/agentscore_commerce/identity/aiohttp.py @@ -367,10 +367,14 @@ def conditional_agentscore_gate_middleware(**kwargs: Any) -> Any: flow through; settle legs trigger the full gate. Accepts the same kwargs as :func:`agentscore_gate_middleware`. + + It also fires on an ``X-Verification-Session: create`` request with no identity and no + payment (:func:`~agentscore_commerce.payment.requests_verification_session`), so a buyer can + get a verify_url before paying. """ - from agentscore_commerce.payment.payment_header import has_payment_header + from agentscore_commerce.payment.payment_header import should_run_conditional_gate - kwargs["condition"] = has_payment_header + kwargs["condition"] = should_run_conditional_gate return agentscore_gate_middleware(**kwargs) diff --git a/agentscore_commerce/identity/django.py b/agentscore_commerce/identity/django.py index 67cdede..087b7dd 100644 --- a/agentscore_commerce/identity/django.py +++ b/agentscore_commerce/identity/django.py @@ -345,13 +345,17 @@ class ConditionalAgentScoreMiddleware(AgentScoreMiddleware): Settings shape is identical to :class:`AgentScoreMiddleware` — the ``AGENTSCORE_GATE`` dict's ``condition`` key is overwritten with the payment-header check. + + It also fires on an ``X-Verification-Session: create`` request with no identity and no + payment (:func:`~agentscore_commerce.payment.requests_verification_session`), so a buyer can + get a verify_url before paying. """ def __init__(self, get_response: Any) -> None: - from agentscore_commerce.payment.payment_header import has_payment_header + from agentscore_commerce.payment.payment_header import should_run_conditional_gate super().__init__(get_response) - self._condition = has_payment_header + self._condition = should_run_conditional_gate # --------------------------------------------------------------------------- diff --git a/agentscore_commerce/identity/fastapi.py b/agentscore_commerce/identity/fastapi.py index 1cb72d9..7449de7 100644 --- a/agentscore_commerce/identity/fastapi.py +++ b/agentscore_commerce/identity/fastapi.py @@ -472,16 +472,20 @@ class ConditionalAgentScoreGate: @app.post("/purchase", dependencies=[Depends(gate)]) async def purchase(request: Request): ... + + It also fires on an ``X-Verification-Session: create`` request with no identity and no + payment (:func:`~agentscore_commerce.payment.requests_verification_session`), so a buyer can + get a verify_url before paying. """ def __init__(self, *args: Any, **kwargs: Any) -> None: - from agentscore_commerce.payment.payment_header import has_payment_header + from agentscore_commerce.payment.payment_header import should_run_conditional_gate self._inner = AgentScoreGate(*args, **kwargs) - self._has_payment_header = has_payment_header + self._should_run_gate = should_run_conditional_gate async def __call__(self, request: Request) -> None: - if not self._has_payment_header(request): + if not self._should_run_gate(request): return await self._inner(request) diff --git a/agentscore_commerce/identity/flask.py b/agentscore_commerce/identity/flask.py index 6137745..3da90ee 100644 --- a/agentscore_commerce/identity/flask.py +++ b/agentscore_commerce/identity/flask.py @@ -411,10 +411,14 @@ def conditional_agentscore_gate(app: Flask, **kwargs: Any) -> None: Accepts the same kwargs as :func:`agentscore_gate`; any ``condition`` kwarg passed in is replaced with the payment-header check. + + It also fires on an ``X-Verification-Session: create`` request with no identity and no + payment (:func:`~agentscore_commerce.payment.requests_verification_session`), so a buyer can + get a verify_url before paying. """ - from agentscore_commerce.payment.payment_header import has_payment_header + from agentscore_commerce.payment.payment_header import should_run_conditional_gate - kwargs["condition"] = has_payment_header + kwargs["condition"] = should_run_conditional_gate agentscore_gate(app, **kwargs) diff --git a/agentscore_commerce/identity/middleware.py b/agentscore_commerce/identity/middleware.py index 37c4cb4..77f8dbd 100644 --- a/agentscore_commerce/identity/middleware.py +++ b/agentscore_commerce/identity/middleware.py @@ -411,12 +411,16 @@ class ConditionalAgentScoreGate(AgentScoreGate): Accepts the same kwargs as :class:`AgentScoreGate`; any ``condition`` kwarg is replaced with the payment-header check. + + It also fires on an ``X-Verification-Session: create`` request with no identity and no + payment (:func:`~agentscore_commerce.payment.requests_verification_session`), so a buyer can + get a verify_url before paying. """ def __init__(self, app: Any, **kwargs: Any) -> None: - from agentscore_commerce.payment.payment_header import has_payment_header + from agentscore_commerce.payment.payment_header import should_run_conditional_gate - kwargs["condition"] = has_payment_header + kwargs["condition"] = should_run_conditional_gate super().__init__(app, **kwargs) diff --git a/agentscore_commerce/identity/sanic.py b/agentscore_commerce/identity/sanic.py index aa4eef9..c90b651 100644 --- a/agentscore_commerce/identity/sanic.py +++ b/agentscore_commerce/identity/sanic.py @@ -364,10 +364,14 @@ def conditional_agentscore_gate(app: Sanic, **kwargs: Any) -> None: Discovery legs flow through to the handler unauthenticated; settle legs trigger the full gate. + + It also fires on an ``X-Verification-Session: create`` request with no identity and no + payment (:func:`~agentscore_commerce.payment.requests_verification_session`), so a buyer can + get a verify_url before paying. """ - from agentscore_commerce.payment.payment_header import has_payment_header + from agentscore_commerce.payment.payment_header import should_run_conditional_gate - kwargs["condition"] = has_payment_header + kwargs["condition"] = should_run_conditional_gate agentscore_gate(app, **kwargs) diff --git a/agentscore_commerce/payment/__init__.py b/agentscore_commerce/payment/__init__.py index d5ab506..0c427f0 100644 --- a/agentscore_commerce/payment/__init__.py +++ b/agentscore_commerce/payment/__init__.py @@ -21,11 +21,16 @@ from agentscore_commerce.payment.network_kind import is_evm_network, is_solana_network from agentscore_commerce.payment.networks import NetworkFamily, network_family, networks from agentscore_commerce.payment.payment_header import ( + VERIFICATION_SESSION_HEADER, + VERIFICATION_SESSION_VALUE, MalformedPaymentCredential, + has_identity_header, has_mppx_header, has_payment_header, has_x402_header, malformed_payment_credential, + requests_verification_session, + should_run_conditional_gate, ) from agentscore_commerce.payment.rail_spec import ( RecipientLike, @@ -95,6 +100,8 @@ __all__ = [ "SETTLEMENT_OVERRIDES_HEADER", "USDC", + "VERIFICATION_SESSION_HEADER", + "VERIFICATION_SESSION_VALUE", "X402_SUPPORTED_BASE_NETWORKS", "ClassifiedX402Error", "CustomScheme", @@ -143,6 +150,7 @@ "extract_signer_for_precheck", "extract_x402_signer", "format_usd_cents", + "has_identity_header", "has_mppx_header", "has_payment_header", "has_x402_header", @@ -162,9 +170,11 @@ "rails", "read_x402_payment_header", "register_x402_schemes_v1_v2", + "requests_verification_session", "resolve_recipient", "settle_result_to_json_bytes", "settlement_override_header", + "should_run_conditional_gate", "strip_unsigned_x402_payload_fields", "usd_to_atomic", "validate_x402_network_config", diff --git a/agentscore_commerce/payment/payment_header.py b/agentscore_commerce/payment/payment_header.py index 577d02d..62a0335 100644 --- a/agentscore_commerce/payment/payment_header.py +++ b/agentscore_commerce/payment/payment_header.py @@ -80,6 +80,50 @@ def has_payment_header(request_or_headers: Any) -> bool: return bool(isinstance(auth, str) and auth.startswith("Payment ")) +VERIFICATION_SESSION_HEADER = "X-Verification-Session" +"""Request header that asks an identity gate for a verification session without paying first. + +Opt-in so crawlers replaying a valid example body never mint sessions or pending orders. +""" +VERIFICATION_SESSION_VALUE = "create" + + +def has_identity_header(request_or_headers: Any) -> bool: + """True when the request carries an identity header. + + That is an operator token, a wallet address, or an AIP ``Agent-Identity`` token. + """ + headers = _unwrap_headers(request_or_headers) + if _read_header(headers, "x-operator-token") or _read_header(headers, "x-wallet-address"): + return True + agent_identity = _read_header(headers, "agent-identity") or "" + return any(part.strip() for part in agent_identity.split(",")) + + +def requests_verification_session(request_or_headers: Any) -> bool: + """True when the request asks for a verification session before paying. + + That is ``X-Verification-Session: create`` (case-insensitive) with no identity and no payment + credential. + """ + headers = _unwrap_headers(request_or_headers) + value = _read_header(headers, VERIFICATION_SESSION_HEADER) or "" + return ( + value.strip().lower() == VERIFICATION_SESSION_VALUE + and not has_identity_header(headers) + and not has_payment_header(headers) + ) + + +def should_run_conditional_gate(request_or_headers: Any) -> bool: + """Whether a conditional (settle-leg) identity gate should run. + + True when a payment credential is attached, or when the request asks for a verification session + before paying. + """ + return has_payment_header(request_or_headers) or requests_verification_session(request_or_headers) + + def has_x402_header(request_or_headers: Any) -> bool: """True when the request carries an x402 payment credential. diff --git a/pyproject.toml b/pyproject.toml index cb8aca7..8544246 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-commerce" -version = "2.10.0" +version = "2.11.0" description = "Agentic commerce SDK for Python: identity middleware (FastAPI, Flask, Django, AIOHTTP, Sanic, ASGI) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agentic commerce." readme = "README.md" license = "MIT" diff --git a/tests/test_fastapi.py b/tests/test_fastapi.py index 8999210..32960b4 100644 --- a/tests/test_fastapi.py +++ b/tests/test_fastapi.py @@ -751,6 +751,65 @@ def _root(req: Request): class TestConditionalGate: + @respx.mock + def test_verification_session_header_runs_gate_and_returns_session_403(self): + """X-Verification-Session: create with no identity runs the gate before any payment.""" + from agentscore_commerce.identity.fastapi import ConditionalAgentScoreGate + + assess = _mock_assess("allow") + respx.post(SESSIONS_URL).mock( + return_value=httpx.Response( + 200, + json={ + "session_id": "sess_boot", + "verify_url": "https://www.agentscore.com/verify?session=sess_boot", + "poll_secret": "poll_boot", + "poll_url": "https://api.agentscore.com/v1/sessions/sess_boot", + }, + ) + ) + gate = ConditionalAgentScoreGate( + api_key="ask_test", + require_kyc=True, + create_session_on_missing=CreateSessionOnMissing(api_key="ask_session"), + ) + app = FastAPI() + + @app.post("/purchase", dependencies=[Depends(gate)]) + async def purchase(): + return {"ok": True} + + resp = TestClient(app).post("/purchase", headers={"X-Verification-Session": "create"}) + assert resp.status_code == 403 + body = resp.json() + assert body["verify_url"] == "https://www.agentscore.com/verify?session=sess_boot" + assert body["session_id"] == "sess_boot" + assert assess.call_count == 0 + + @respx.mock + def test_verification_session_header_with_identity_flows_through(self): + """An identity header turns the session request back into an ordinary discovery leg.""" + from agentscore_commerce.identity.fastapi import ConditionalAgentScoreGate + + assess = _mock_assess("allow") + sessions = respx.post(SESSIONS_URL).mock(return_value=httpx.Response(500)) + gate = ConditionalAgentScoreGate( + api_key="ask_test", + create_session_on_missing=CreateSessionOnMissing(api_key="ask_session"), + ) + app = FastAPI() + + @app.post("/purchase", dependencies=[Depends(gate)]) + async def purchase(): + return {"ok": True} + + resp = TestClient(app).post( + "/purchase", headers={"X-Verification-Session": "create", "X-Operator-Token": "opc_x"} + ) + assert resp.status_code == 200 + assert assess.call_count == 0 + assert sessions.call_count == 0 + @respx.mock def test_discovery_leg_flows_through_unauthenticated(self): """ConditionalAgentScoreGate lets a no-credential discovery leg through without diff --git a/tests/test_verification_session.py b/tests/test_verification_session.py new file mode 100644 index 0000000..5db576e --- /dev/null +++ b/tests/test_verification_session.py @@ -0,0 +1,51 @@ +"""Verification-session request helpers. Ports node-commerce ``tests/verification_session.test.ts``.""" + +from __future__ import annotations + +from agentscore_commerce import ( + VERIFICATION_SESSION_HEADER, + build_identity_bootstrap, + has_identity_header, + requests_verification_session, + should_run_conditional_gate, +) + + +def test_recognizes_the_header_in_any_case_trimmed() -> None: + assert requests_verification_session({"x-verification-session": "create"}) + assert requests_verification_session({VERIFICATION_SESSION_HEADER: " CREATE "}) + + +def test_rejects_other_values_and_a_missing_header() -> None: + assert not requests_verification_session({"x-verification-session": "yes"}) + assert not requests_verification_session({}) + + +def test_no_session_request_when_identity_or_payment_is_present() -> None: + base = {"x-verification-session": "create"} + assert not requests_verification_session({**base, "x-operator-token": "opc_x"}) + assert not requests_verification_session({**base, "x-wallet-address": "0xabc"}) + assert not requests_verification_session({**base, "agent-identity": "eyJ.e30.sig"}) + assert not requests_verification_session({**base, "authorization": "Payment abc"}) + assert not requests_verification_session({**base, "x-payment": "abc"}) + + +def test_empty_agent_identity_is_no_identity() -> None: + assert not has_identity_header({"agent-identity": " , "}) + assert has_identity_header({"agent-identity": "eyJ.e30.sig"}) + + +def test_conditional_gate_runs_on_payment_or_session_request_only() -> None: + assert should_run_conditional_gate({"authorization": "Payment abc"}) + assert should_run_conditional_gate({"payment-signature": "abc"}) + assert should_run_conditional_gate({"x-verification-session": "create"}) + assert not should_run_conditional_gate({}) + assert not should_run_conditional_gate({"x-operator-token": "opc_x"}) + + +def test_builds_the_identity_bootstrap_block() -> None: + block = build_identity_bootstrap() + assert block["header"] == VERIFICATION_SESSION_HEADER + assert block["value"] == "create" + assert f"{VERIFICATION_SESSION_HEADER}: create" in block["instructions"] + assert "verify_url" in block["instructions"] diff --git a/uv.lock b/uv.lock index bed66e7..229d10b 100644 --- a/uv.lock +++ b/uv.lock @@ -22,7 +22,7 @@ wheels = [ [[package]] name = "agentscore-commerce" -version = "2.10.0" +version = "2.11.0" source = { editable = "." } dependencies = [ { name = "agentscore-py" },