diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2ab699..550c833 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: run: uv run ty check agentscore_commerce/ - name: Vulture - run: uv run vulture agentscore_commerce/ vulture_whitelist.py --min-confidence 80 + run: uv run vulture agentscore_commerce/ --min-confidence 80 - name: Tests run: uv run pytest tests/ -v --cov=agentscore_commerce --cov-report=term-missing diff --git a/CLAUDE.md b/CLAUDE.md index 4e99158..351e006 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,14 +8,15 @@ 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 factories: `pricing_result` (cents → typed `PricingResult`), `validation_response_{fastapi,flask,django,aiohttp,sanic}` (4xx envelope per framework), `Receipt`/`ReceiptNextSteps`/`ProductInfo`/`ShippingAddress` (canonical 200-receipt dataclasses, universal across goods + API merchants) | -| `agentscore_commerce.identity.{fastapi,flask,django,aiohttp,sanic,middleware}` | Trust gate middleware (KYC, age, sanctions on both account name and signer wallet, jurisdiction) | +| `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_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. `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), `Receipt`/`ReceiptNextSteps`/`ProductInfo`/`ShippingAddress` (canonical 200-receipt dataclasses, universal across goods + API merchants) | +| `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 existing `agentscore_gate(app, ...)` and `AgentScoreGate` accept an optional `condition=` callable for inline gating. | | `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), `process_x402_settle` (verify+settle in one call), `create_mppx_server` (wraps `pympp[server,tempo,stripe]>=0.6`), dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header | +| `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), `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 | | `agentscore_commerce.discovery` | Discovery probe (`is_discovery_probe_request`, `build_discovery_probe_response`), Bazaar wrapper, `/.well-known/mpp.json`, `llms.txt` builder, `skill.md` builder (Claude-Skill-compatible agent-discovery manifest), `build_redemption_skill_md` (delivery-neutral; printed/emailed/API-trial codes all covered via `delivery_intro`/`body_shape`/`body_rules`/`extra_recovery_rows` overrides), `build_merchant_index_json` + `standard_endpoint_descriptions(kind=)` (canonical `/` discovery body for goods or API merchants), `build_success_next_steps` (universal Passport-active success block), `build_agentscore_onboarding_steps`, OpenAPI snippets, `NoindexNonDiscoveryMiddleware` ASGI middleware. Plus the UCP/JWKS publish surface: `build_signed_ucp_response`, `build_signed_jwks_response`, `well_known_preflight_response`, `default_a2a_services`, `bootstrap_ucp_signing_key`, framework-neutral `SignedDiscoveryResponse` + per-framework wrappers `signed_response_{fastapi,flask,django,aiohttp,sanic}` | | `agentscore_commerce.challenge` | 402-body builders: accepted_methods, identity_metadata (auto-attached by `Checkout` when wallet header present), how_to_pay, agent_instructions, build_402_body, pricing, agent_memory, `build_validation_error` (4xx body builder), `Receipt`/`ReceiptNextSteps`/`ProductInfo`/`ShippingAddress` (canonical 200-receipt dataclasses) | -| `agentscore_commerce.stripe_multichain` | Multichain PaymentIntent helper (`create_multichain_payment_intent` returns `MultichainPaymentIntentResult`; read `result.deposit_addresses[network]` directly), testnet simulator (`simulate_crypto_deposit`, `simulate_deposit_if_test_mode`), `create_pi_cache`, `create_mppx_stripe` | +| `agentscore_commerce.stripe_multichain` | Multichain PaymentIntent helper (`create_multichain_payment_intent` returns `MultichainPaymentIntentResult`; read `result.deposit_addresses[network]` directly), `create_pay_to_address_from_stripe_pi(authorization_header=, amount_cents=, stripe=, pi_cache=, networks=, metadata=, order_id=, preferred_network=)` — one-call per-order payTo resolver matching `Checkout.mint_recipients`: on the settle leg, reuses the buyer's signed-against payTo from the MPP credential (after `pi_cache.has_address` check); on the discovery leg, mints a fresh PI via `create_multichain_payment_intent` and caches the addresses + PI mapping. Testnet simulator (`simulate_crypto_deposit`, `simulate_deposit_if_test_mode`), `simulate_deposit_for_outcome(outcome=, deposit_address=, get_payment_intent_id=, stripe_secret_key=, stripe_version=)` (dispatches the simulator based on a Checkout / compute_first_checkout settle outcome; replaces the per-merchant rail-switch + thin `simulate_deposit_if_testnet(addr, network)` wrapper), `network_for_outcome` (outcome → simulator network arg, handles both Checkout-shaped `rail_key` and compute-first-shaped `mpp_method`, accepts bare scheme names AND `/charge` forms), `create_pi_cache`, `create_mppx_stripe` | | `agentscore_commerce.api` | Re-exports `AgentScore` from `agentscore` SDK | +| `agentscore_commerce.middleware.{fastapi,flask,django,aiohttp,sanic,asgi}` | Framework-specific rate-limit middleware. FastAPI exposes `rate_limit_fastapi(...)` (FastAPI dependency) plus the ASGI `RateLimitMiddleware` re-export; Flask exposes `rate_limit_flask(app, ...)` (installs a `before_request` hook); Django exposes a class-based async `RateLimitMiddleware` configured via `settings.AGENTSCORE_RATE_LIMIT`; aiohttp exposes `rate_limit_aiohttp(...)` middleware factory; Sanic exposes `rate_limit_sanic(app, ...)` installer; `asgi.RateLimitMiddleware` is a generic ASGI middleware that works with any starlette-compatible app. Shared options: `window_seconds` (default 60), `max_requests` (default 60), `key_resolver` (default first hop of `x-forwarded-for`), `redis_url` (lazy-imports `redis.asyncio` when set, in-memory `dict` fallback otherwise), `key_prefix`. `redis` is an optional peer dep (install via the `redis` extra). | ## Architecture @@ -44,7 +45,7 @@ Peer-dep pattern: payment/x402/mppx/stripe modules import lazily at runtime; ven | `identity_only.py` | Compliance gate without payment (vendor handles their own) | | `multi_rail_merchant.py` | Full agent-commerce: identity + Tempo MPP + x402 + Stripe SPT | | `stripe_multichain_merchant.py` | Stripe-anchored multichain (PaymentIntent → tempo/base/solana deposit addresses) | -| `variable_cost_merchant.py` | Pay-per-actual-usage on **two protocols**: x402 upto (Permit2 + Settlement-Overrides) AND MPP tempo session (channel + SSE + mid-stream vouchers) | +| `compute_first_merchant.py` | Pay-per-result variable-cost merchant via the compute-first + exact-x402 helper (`compute_first_checkout`). Exact-mode rails only (x402-exact Base, tempo/charge, solana/charge, Stripe SPT); deliberately scoped out of x402-upto (Permit2) and Settlement-Overrides. Probe runs the work + caches by body content-hash; settle replays the cached result at the computed exact price. Pairs with `rate_limit_fastapi` since the probe leg runs work pre-payment. | | `compliance_merchant.py` | Regulated-goods merchant: full compliance gate + custom `on_denied` composing the denial helpers (`verification_agent_instructions`, `is_fixable_denial`, `build_signer_mismatch_body`, `build_contact_support_next_steps`, `denial_reason_to_body`/`denial_reason_status`) | | `per_product_policy_merchant.py` | Multi-product merchant where each row carries its own compliance policy. One product hard-gates KYC + age + state; another is anonymous; a third uses `enforcement="soft"` (request KYC but don't block sale). Demonstrates `PolicyBlock`, `build_gate_from_policy`, `run_gate_with_enforcement`, `shipping_country_allowed`, `shipping_state_allowed`. | | `signed_ucp_merchant.py` | Signed UCP profile (`/.well-known/ucp`) + JWKS endpoint (`/.well-known/jwks.json`). AgentScore's `agentscore-profile+jws` is a vendor extension on top of UCP for trust-mode verifiers (regulated-commerce, AP2-aware) that opt into auditable cryptographic provenance — UCP §6 itself does NOT mandate signing; production UCP merchants commonly ship unsigned. Wires ephemeral-for-dev / env-JWK-for-prod signing, kid rotation, and `Cache-Control` posture. Uses `generate_ucp_signing_key`, `sign_ucp_profile`, `build_jwks_response`, `UCPSigningKey.from_jwk`, `UCPVerificationError`. Demonstrates the payment-handler builders (`mpp_payment_handler`, `x402_payment_handler`, `stripe_spt_payment_handler` — see "Payment-handler builders" below). | @@ -94,15 +95,12 @@ Wallet-signer-match + signer-sanctions: the gate adapter calls `extract_payment_ `AgentScoreGate(...)` (or `agentscore_gate(app, ...)` on Flask/Sanic) is mounted directly when the route is AgentScore-only; every request runs identity + policy. To support **anonymous discovery by any spec-compliant x402 wallet** (Coinbase awal, Phantom, Solflare, ...), wrap the gate so it fires only when a payment credential is attached: ```python +from agentscore_commerce.payment import has_payment_header + _gate = AgentScoreGate(api_key=..., require_kyc=True, ...) async def gate_on_settle(request: Request) -> None: - has_payment_header = bool( - request.headers.get("payment-signature") - or request.headers.get("x-payment") - or (request.headers.get("authorization") or "").startswith("Payment ") - ) - if not has_payment_header: + if not has_payment_header(request): return None return await _gate(request) diff --git a/README.md b/README.md index c446476..eded5ca 100644 --- a/README.md +++ b/README.md @@ -26,15 +26,32 @@ pip install 'agentscore-commerce[fastapi,x402,coinbase]' |---|---| | `agentscore_commerce` (top-level) | `Checkout` orchestrator + `CheckoutContext` + `CheckoutGateConfig` + `CheckoutValidationError` + `DiscoveryProbeConfig` + `SettleOutcome` + `MppxComposeOutcome` + `PricingResult` (the 2.0 high-level surface: one config object, hooks for pre_validate/compute_pricing/on_settled/mint_recipients/compose_mppx, auto-derived x402+mppx 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}`); `pricing_result` (factory: cents-denominated → typed `PricingResult` with embedded `PricingBlock`); `validation_response_{fastapi,flask,django,aiohttp,sanic}` (per-framework 4xx envelope wrappers); `make_mppx_compose_hook` (canonical pympp compose adapter). | | `agentscore_commerce.identity.{fastapi,flask,django,aiohttp,sanic,middleware}` | Trust gate middleware: KYC, sanctions (account name + signer wallet), age, jurisdiction. `AgentScoreGate(...)` (or `agentscore_gate(app, ...)` on Flask/Sanic), `get_agentscore_data(...)`, `capture_wallet(...)`, `get_signer_verdict(...)`. The gate extracts the payment signer pre-evaluate and passes it to `/v1/assess`, so the API composes both wallet-binding (`signer_match`) and OFAC SDN wallet-address (`signer_sanctions`) verdicts on one round trip. | -| `agentscore_commerce.identity` (package level) | Re-exports the denial helpers: `denial_reason_status`, `denial_reason_to_body`, `build_signer_mismatch_body`, `build_contact_support_next_steps`, `verification_agent_instructions`, `is_fixable_denial`, `FIXABLE_DENIAL_REASONS`. The per-framework adapter modules also expose `get_gate_quota_info(request)` for surfacing X-RateLimit info from gate state. Also re-exports the per-product policy helpers: `PolicyBlock`, `GateResult`, `EnforcementMode`, `IdentityStatus`, `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) — for multi-product merchants where each product carries its own compliance config: hard gate vs soft vs none, per-product shipping allowlists. Key + token helpers: `load_ucp_signing_key_from_env` (cached env-driven loader for the UCP signing key — reads `UCP_SIGNING_KEY_JWK_PRIVATE` JSON JWK, detects alg from shape, falls back to ephemeral when unset, sanitizes errors so key bytes never reach logs, concurrent-safe via `threading.Lock`; env-var names and `default_kid` / `default_alg` are overridable as kwargs); `hash_operator_token` (sha256 hex of plaintext `opc_...` — for merchants persisting `operator_token_id` to their own DB without ever storing the plaintext). | +| `agentscore_commerce.identity` (package level) | Re-exports the denial helpers: `denial_reason_status`, `denial_reason_to_body`, `build_signer_mismatch_body`, `build_contact_support_next_steps`, `verification_agent_instructions`, `is_fixable_denial`, `FIXABLE_DENIAL_REASONS`. The per-framework adapter modules also expose `get_gate_quota_info(request)` for surfacing X-RateLimit info from gate state. Also re-exports the per-product policy helpers: `PolicyBlock`, `GateResult`, `EnforcementMode`, `IdentityStatus`, `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) — for multi-product merchants where each product carries its own compliance config: hard gate vs soft vs none, per-product shipping allowlists. Key + token helpers: `load_ucp_signing_key_from_env` (cached env-driven loader for the UCP signing key — reads `UCP_SIGNING_KEY_JWK_PRIVATE` JSON JWK, detects alg from shape, falls back to ephemeral when unset, sanitizes errors so key bytes never reach logs, concurrent-safe via `threading.Lock`; env-var names and `default_kid` / `default_alg` are overridable as kwargs); `hash_operator_token` (sha256 hex of plaintext `opc_...` — for merchants persisting `operator_token_id` to their own DB without ever storing the plaintext); `extract_owner_scope(headers) -> OwnerScope` (canonical owner-identity extractor for caller-scoped resource queries — reads `X-Wallet-Address` / `X-Operator-Token`, hashes the token so plaintext never leaves the request); `has_payment_header` / `has_x402_header` / `has_mppx_header` (request discriminators — any-credential vs x402 vs MPP); `default_read_only_on_denied(reason)` (canonical `on_denied` for read-only resource gates: 401 + `Cache-Control: no-store` while still spreading `denial_reason_to_body`. Returns a `DefaultOnDeniedResult(body, status, headers)`; FastAPI / Flask / aiohttp / Sanic `on_denied` callbacks accept an optional 3-tuple `(body, status, headers)` to carry headers through; wrap with `lambda req, reason: (lambda r: (r.body, r.status, r.headers or {}))(default_read_only_on_denied(reason))`). | | `agentscore_commerce.payment` | `networks`, `USDC`, `rails` registries; `payment_directive`, `build_payment_directive`, `www_authenticate_header`, `payment_required_header`, `alias_amount_fields` (v1↔v2 amount field shim that emits both `amount` and `maxAmountRequired` so v1-only x402 parsers like Coinbase awal can read v2 bodies), `settlement_override_header`, `dispatch_settlement_by_network`, `extract_payment_signer` (accepts positional `x402_payment_header` AND/OR `authorization_header=` kwarg; recovers signer from x402 EIP-3009 `payload.authorization.from` OR MPP `Authorization: Payment ` `did:pkh:eip155::` / `did:pkh:solana::` source DID), `detect_rail_from_headers` (returns `"x402"` / `"mpp"` / `None` from inbound headers), `register_x402_schemes_v1_v2`; drop-in x402 helpers: `validate_x402_network_config` (boot-time guard), `verify_x402_request` (parse + validate inbound X-Payment), `process_x402_settle` (verify-then-settle with one call), `classify_x402_settle_result` (maps the tagged settle result to a recommended HTTP status / code / next_steps so merchants get a controlled envelope without coupling to facilitator-specific error text), `classify_orchestration_error` (same `ClassifiedX402Error` shape but for uncaught exceptions thrown elsewhere in the orchestration; returns `None` for unknown errors so merchants rethrow instead of swallowing); `zero_amount_carve_out` (skip CDP / pympp upstream verify+settle for $0 settles where the upstream rejects value=0 payloads; parses the credential, lifts signer + network, returns a `ZeroSettleResult` shaped identically to the success path so callers branch on rail, not on result shape); `usd_to_atomic` (Decimal-based USD → atomic int, ROUND_HALF_UP — for Tempo / Solana / Base USDC amount construction). | | `agentscore_commerce.discovery` | `is_discovery_probe_request`, `build_discovery_probe_response` (with optional `x402_sample` for x402-aware crawlers like `awal x402 details`), `sample_x402_accept_for_network` (USDC sample-accept builder for known CAIP-2 networks), `build_well_known_mpp`, `build_llms_txt` + `llms_txt_identity_section` + `llms_txt_payment_section` (compact + verbose modes), `build_skill_md` (Claude-Skill-compatible `/skill.md` agent-discovery manifest; strictly agent-facing data only, no internal posture), `build_redemption_skill_md` (delivery-neutral redemption-code template — printed mailers, emailed codes, API trial credits all covered; `endpoint_path`/`delivery_intro`/`body_shape`/`body_rules`/`extra_recovery_rows` overrides for non-goods shapes), `build_merchant_index_json` (canonical `/` discovery body), `standard_endpoint_descriptions(kind=)` (canonical method+path → description map for goods vs api merchants; optional `include_order_status_route` for goods), `build_success_next_steps` (universal Passport-active success block), `build_agentscore_onboarding_steps` (canonical skill.md onboarding for goods or API merchants), `agentscore_openapi_snippets`, `build_bazaar_discovery_payload`, `NoindexNonDiscoveryMiddleware` (ASGI middleware emitting `X-Robots-Tag: noindex` on every path except the agent-discovery surfaces; pure helpers `is_discovery_path` + `DEFAULT_DISCOVERY_PATHS` for non-ASGI frameworks). Plus the UCP/JWKS publish surface: `build_signed_ucp_response`, `build_signed_jwks_response`, `well_known_preflight_response`, `default_a2a_services`, `bootstrap_ucp_signing_key`, framework-neutral `SignedDiscoveryResponse` + per-framework wrappers `signed_response_{fastapi,flask,django,aiohttp,sanic}`. | | `agentscore_commerce.challenge` | `build_402_body`, `build_accepted_methods`, `build_identity_metadata` (auto-attached by `Checkout` when an inbound `X-Wallet-Address` header is present), `build_how_to_pay`, `build_agent_instructions` (auto-emits per-rail `compatible_clients`: smoke-verified CLIs the agent should use; vendor override supported; pure helper `compatible_clients_by_rails(rails)` returns the same map for vendors building custom 402s), `build_pricing_block` (cents to dollar-string with optional shipping/tax), `first_encounter_agent_memory` (cross-merchant hint, returns the canonical block or `None` based on a per-merchant first-seen flag), `Receipt` + `ReceiptNextSteps` + `ProductInfo` + `ShippingAddress` (canonical 200-receipt dataclasses — universal across goods + API merchants); `respond_402`, a drop-in 402 emit that preserves pympp's `WWW-Authenticate` and layers x402's `PAYMENT-REQUIRED`. `build_validation_error`: structured 4xx body builder (`{error: {code, message}, required_fields?, example_body?, next_steps?, ...extra}`) so vendors compose body shapes by name instead of inlining at every validation site. | -| `agentscore_commerce.stripe_multichain` | `create_multichain_payment_intent` (returns `MultichainPaymentIntentResult(payment_intent_id, deposit_addresses)`; read `result.deposit_addresses[network]` directly), `simulate_crypto_deposit`; `create_pi_cache` (TTL'd PI / deposit-address cache, Redis-backed when `redis_url` set, in-memory otherwise), `simulate_deposit_if_test_mode` (gates on `sk_test_` and looks up the PI for you), `STRIPE_TEST_TX_HASH_SUCCESS` / `STRIPE_TEST_TX_HASH_FAILED` constants. Peer dep on `stripe`. | +| `agentscore_commerce.stripe_multichain` | `create_multichain_payment_intent` (returns `MultichainPaymentIntentResult(payment_intent_id, deposit_addresses)`; read `result.deposit_addresses[network]` directly), `create_pay_to_address_from_stripe_pi(authorization_header=, amount_cents=, stripe=, pi_cache=, networks=, metadata=, order_id=, preferred_network=)` — per-order payTo resolver: on the settle leg, reuses the buyer's signed-against payTo from the MPP credential (after `pi_cache.has_address` check); on the discovery leg, mints a fresh PI and caches it. `simulate_crypto_deposit`; `create_pi_cache` (TTL'd PI / deposit-address cache, Redis-backed when `redis_url` set, in-memory otherwise), `simulate_deposit_if_test_mode` (gates on `sk_test_` and looks up the PI for you), `STRIPE_TEST_TX_HASH_SUCCESS` / `STRIPE_TEST_TX_HASH_FAILED` constants. Peer dep on `stripe`. | | `agentscore_commerce.api` | Everything from `agentscore-py` re-exported in one place: `AgentScore` + `AgentScoreError`, `AGENTSCORE_TEST_ADDRESSES` + `is_agentscore_test_address`. **Don't add `agentscore-py` as a separate dep**: the two can drift versions and cause subtle type mismatches. | +| `agentscore_commerce.middleware.{fastapi,flask,django,aiohttp,sanic,asgi}` | Framework-specific rate-limit middleware. FastAPI: `rate_limit_fastapi(...)` (FastAPI dependency) plus the ASGI `RateLimitMiddleware` re-export. Flask: `rate_limit_flask(app, ...)` installer. Django: class-based async `RateLimitMiddleware` configured via `settings.AGENTSCORE_RATE_LIMIT`. aiohttp: `rate_limit_aiohttp(...)` middleware factory. Sanic: `rate_limit_sanic(app, ...)` installer. `asgi.RateLimitMiddleware` works with any starlette-compatible app. Shared options: `window_seconds` (default 60), `max_requests` (default 60), `key_resolver` (default first hop of `x-forwarded-for`), `redis_url` (lazy-imports `redis.asyncio` when set, in-memory `dict` fallback otherwise), `key_prefix`. `redis` is an optional peer dep (install via the `redis` extra). | ## Quick start (FastAPI) +### Rate limiting + +Mount globally before any payment route so probe and settle legs share the same bucket. Defaults: 60 req / 60 s / IP. Redis when `REDIS_URL` is set, in-memory fallback otherwise. + +```python +from fastapi import FastAPI +from agentscore_commerce.middleware.asgi import RateLimitMiddleware + +app = FastAPI() +app.add_middleware(RateLimitMiddleware, max_requests=60, window_seconds=60) +``` + +Same factory shape per framework: `rate_limit_flask(app, ...)`, `rate_limit_aiohttp(...)`, `rate_limit_sanic(app, ...)`, Django's `RateLimitMiddleware` class in `MIDDLEWARE`, and `rate_limit_fastapi(...)` for a `Depends`-able per-route variant. Override `max_requests` / `window_seconds` / `key_resolver` / `redis_url` / `key_prefix` as needed. + +### Identity gate + ```python from fastapi import Depends, FastAPI, Request from agentscore_commerce.identity.fastapi import ( @@ -57,13 +74,10 @@ _gate = AgentScoreGate( # 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 + async def gate_on_settle(request: Request) -> None: - has_payment_header = bool( - request.headers.get("payment-signature") - or request.headers.get("x-payment") - or (request.headers.get("authorization") or "").startswith("Payment ") - ) - if not has_payment_header: + if not has_payment_header(request): return None return await _gate(request) @@ -158,6 +172,12 @@ async def purchase(request: Request): The 402 body Checkout emits auto-attaches `identity_mode` + `required_signer` + `signer_constraint` (and `linked_wallets` when the gate populated them) when an inbound `X-Wallet-Address` header is present — so agents self-correct at discovery instead of at the 403 retry. +For **variable-cost pay-per-result** endpoints (per-result search, per-token LLM, per-byte transcoding), reach for `compute_first_checkout` — same config shape, but the probe leg runs the work, caches by body content-hash, and emits a 402 with the EXACT computed price. The retry pays that exact amount and receives the cached body. 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. Tradeoff: the work runs on the unpaid probe leg, so mount `rate_limit_fastapi` (from `agentscore_commerce.middleware.fastapi`) globally — it's load-bearing. See `examples/compute_first_merchant.py`. + +For the `on_denied` hook on Checkout's gate config, `create_default_on_denied(merchant_name=, support_email=, ...)` returns the canonical denial callback that handles `wallet_signer_mismatch` / `wallet_not_trusted` unfixable fallback / `payment_required` / `token_expired` / `invalid_credential` / `api_error`. Merchants override `wallet_not_trusted_message` / `payment_required_message` / `support_context` for vendor-specific copy and keep their own merchant-specific branches (e.g. wine merchants add a fixable-denial-with-session branch on top). + +`build_default_checkout_rails(tempo=, x402_base=, solana_mpp=, stripe=)` builds the canonical four-rail `rails` dict so merchants pass per-rail overrides instead of redeclaring the recipient sentinel + network/chain_id/token boilerplate. Flipping `network` alone is enough: Base Sepolia derives Sepolia USDC + chain_id 84532, Solana devnet derives the devnet USDC mint. 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=, ...)` builds the per-call mppx intent list. `simulate_deposit_for_outcome(outcome=, deposit_address=, get_payment_intent_id=, stripe_secret_key=)` dispatches the Stripe testnet simulator from `on_settled` based on the rail family (no per-merchant rail switch needed). + ## Payment helpers ```python @@ -233,7 +253,7 @@ body = build_402_body(Build402BodyInput( )) ``` -`build_pricing_block` handles cents → dollar-string (with optional shipping). Pass `discount_cents` for redemption codes / coupons: `subtotal` stays the list price, the block surfaces `discount` as a dollar-string, and `total` becomes `subtotal + tax + shipping - discount` (floored at 0). `pricing_result` accepts the same `discount_cents` and propagates it to `block.discount` so agents reading the 402 see the savings line. `first_encounter_agent_memory` returns the canonical hint or `None` based on a per-merchant first-seen flag. `Receipt` (plus `ReceiptNextSteps`, `ProductInfo`, `ShippingAddress`) is a universal dataclass for the post-settlement 200 response shape — goods merchants populate the shipping/fulfillment/tracking slots, API merchants fill only the universal fields (id, created_at, pricing, payment_status, next_steps). +`build_pricing_block` handles cents → dollar-string (with optional shipping). Pass `discount_cents` for redemption codes / coupons: `subtotal` stays the list price, the block surfaces `discount` as a dollar-string, and `total` becomes `subtotal + tax + shipping - discount` (floored at 0). `pricing_result` accepts the same `discount_cents` and propagates it to `block.discount` so agents reading the 402 see the savings line. Pass `decimals: N` (default `2`) on either helper for sub-cent unit pricing — e.g. `decimals=4` advertises `$0.0005`-precision instead of rounding to two decimals. Set `decimals` on `PricingResult` and the SDK threads it through `build_how_to_pay`, `build_pricing_block`, and the x402 settle `price` string automatically; the cents inputs accept floats under that mode (per-token / per-byte unit pricing). `first_encounter_agent_memory` returns the canonical hint or `None` based on a per-merchant first-seen flag. `Receipt` (plus `ReceiptNextSteps`, `ProductInfo`, `ShippingAddress`) is a universal dataclass for the post-settlement 200 response shape — goods merchants populate the shipping/fulfillment/tracking slots, API merchants fill only the universal fields (id, created_at, pricing, payment_status, next_steps). ### Idempotency-key + multi-rail header bundle diff --git a/agentscore_commerce/__init__.py b/agentscore_commerce/__init__.py index 86636dd..c90b9a6 100644 --- a/agentscore_commerce/__init__.py +++ b/agentscore_commerce/__init__.py @@ -34,12 +34,28 @@ validation_response_flask, validation_response_sanic, ) +from agentscore_commerce.checkout_compute_first import ( + ComputeFirstCheckout, + ComputeFirstMintContext, + ComputeFirstMppContext, + ComputeFirstMppResult, + ComputeFirstRails, + ComputeFirstRequest, + ComputeFirstSettledContext, + ComputeFirstWorkContext, + MintedRecipients, + SuccessBodyArgs, + WorkOutcome, + compute_first_checkout, +) from agentscore_commerce.checkout_hooks import make_mppx_compose_hook # Re-export the most commonly used helpers at the package root so consumers # don't have to remember which submodule each one lives in. Mirrors node's # top-level `index.ts` surface; submodule imports still work for power users. from agentscore_commerce.identity import ( + A2A_DEFAULT_TRANSPORT, + A2A_PROTOCOL_VERSION, AGENTSCORE_UCP_CAPABILITY, FIXABLE_DENIAL_REASONS, UCP_A2A_EXTENSION_URI, @@ -55,12 +71,14 @@ AgentScoreCore, AgentScoreGatePolicy, AssessResult, + DefaultOnDeniedResult, DenialCode, DenialReason, EnforcementMode, GateResult, GeneratedUCPKey, IdentityStatus, + OwnerScope, PolicyBlock, SignerVerdict, UCPCapabilityBinding, @@ -78,8 +96,11 @@ build_jwks_response, build_signer_mismatch_body, build_ucp_profile, + create_default_on_denied, + default_read_only_on_denied, denial_reason_status, denial_reason_to_body, + extract_owner_scope, generate_ucp_signing_key, hash_operator_token, is_fixable_denial, @@ -106,12 +127,20 @@ TempoRailSpec, TempoSessionRailSpec, X402BaseRailSpec, + build_default_checkout_rails, + build_mppx_compose_rails, extract_payment_signer, extract_signer_for_precheck, format_usd_cents, + has_mppx_header, + has_payment_header, + has_x402_header, + is_evm_network, + is_solana_network, load_solana_fee_payer, read_x402_payment_header, ) +from agentscore_commerce.quote_cache import CachedQuote, QuoteCache, create_quote_cache try: __version__ = _pkg_version("agentscore-commerce") @@ -122,6 +151,8 @@ __version__ = "0.0.0+local" __all__ = [ + "A2A_DEFAULT_TRANSPORT", + "A2A_PROTOCOL_VERSION", "AGENTSCORE_UCP_CAPABILITY", "FIXABLE_DENIAL_REASONS", "UCP_A2A_EXTENSION_URI", @@ -137,6 +168,7 @@ "AgentScoreCore", "AgentScoreGatePolicy", "AssessResult", + "CachedQuote", "Checkout", "CheckoutContext", "CheckoutGateConfig", @@ -144,7 +176,16 @@ "CheckoutRequest", "CheckoutResult", "CheckoutValidationError", + "ComputeFirstCheckout", + "ComputeFirstMintContext", + "ComputeFirstMppContext", + "ComputeFirstMppResult", + "ComputeFirstRails", + "ComputeFirstRequest", + "ComputeFirstSettledContext", + "ComputeFirstWorkContext", "CreateSessionOnMissing", + "DefaultOnDeniedResult", "DenialCode", "DenialReason", "DiscoveryProbeConfig", @@ -152,17 +193,21 @@ "GateResult", "GeneratedUCPKey", "IdentityStatus", + "MintedRecipients", "MppxComposeOutcome", + "OwnerScope", "PaymentSigner", "PolicyBlock", "PolicyCheck", "PolicyResult", "PricingResult", + "QuoteCache", "SettleOutcome", "SignerNetwork", "SignerVerdict", "SolanaMppRailSpec", "StripeRailSpec", + "SuccessBodyArgs", "TempoRailSpec", "TempoSessionRailSpec", "UCPCapabilityBinding", @@ -173,24 +218,37 @@ "UCPSigningKey", "UCPVerificationError", "VerifyWalletSignerResult", + "WorkOutcome", "X402BaseRailSpec", "__version__", "build_a2a_agent_card", "build_agent_memory_hint", "build_contact_support_next_steps", + "build_default_checkout_rails", "build_gate_from_policy", "build_jwks_response", + "build_mppx_compose_rails", "build_signer_mismatch_body", "build_ucp_profile", + "compute_first_checkout", + "create_default_on_denied", + "create_quote_cache", + "default_read_only_on_denied", "denial_reason_status", "denial_reason_to_body", + "extract_owner_scope", "extract_payment_signer", "extract_signer_for_precheck", "format_pydantic_errors", "format_usd_cents", "generate_ucp_signing_key", + "has_mppx_header", + "has_payment_header", + "has_x402_header", "hash_operator_token", + "is_evm_network", "is_fixable_denial", + "is_solana_network", "load_solana_fee_payer", "load_ucp_signing_key_from_env", "make_mppx_compose_hook", diff --git a/agentscore_commerce/_headers.py b/agentscore_commerce/_headers.py new file mode 100644 index 0000000..e0be57c --- /dev/null +++ b/agentscore_commerce/_headers.py @@ -0,0 +1,20 @@ +"""Internal header helpers — case-normalization for HTTP headers. + +Replaces hand-rolled ``{k.lower(): v for k, v in headers.items()}`` loops in +``checkout``, ``signer`` and ``challenge.respond_402``. Mirrors node-commerce +``src/_headers.ts``. + +Not part of the public API; consumed by SDK internals only. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Mapping + + +def normalize_headers_to_lowercase(headers: Mapping[str, str]) -> dict[str, str]: + """Lowercase every header key, preserve values. Idempotent.""" + return {k.lower(): v for k, v in headers.items()} diff --git a/agentscore_commerce/_mppx_receipt.py b/agentscore_commerce/_mppx_receipt.py new file mode 100644 index 0000000..3d810c7 --- /dev/null +++ b/agentscore_commerce/_mppx_receipt.py @@ -0,0 +1,106 @@ +"""Internal helpers for extracting the ``Payment-Receipt`` header. + +Shared by ``Checkout.handle_mppx`` and ``compute_first_checkout``'s MPP +settle path so the rail-label / signer derivation stays one source of truth. + +Mirrors node-commerce ``src/_mppx_receipt.ts``. Not part of the public API. +""" + +from __future__ import annotations + +import logging +from typing import Any + +log = logging.getLogger(__name__) + + +def extract_mppx_receipt_header_from_raw(raw: Any) -> str | None: + """Pull the ``Payment-Receipt`` header value from an mppx compose result. + + Covers three shapes hand-rolled hooks commonly return: + + * ``raw.receipt_header`` — pympp's current direct-attribute shape. + * ``raw.to_payment_receipt()`` — pympp's older Receipt return-method shape + (also reached when ``raw`` is a ``(credential, receipt)`` tuple OR a + dict/object carrying ``.receipt``). + * ``raw.with_receipt(response) -> Response`` — node-compat shape that + wraps an outgoing Response and attaches the header. + + Returns ``None`` when none match or the underlying call raises. + """ + if raw is None: + return None + # Shape 1: direct attribute. + header = getattr(raw, "receipt_header", None) + if isinstance(header, str) and header: + return header + # Shape 2: pympp's `to_payment_receipt()` callable on raw itself or a + # carried receipt. Build candidate list; first match wins. + candidates: list[Any] = [raw] + if isinstance(raw, (tuple, list)) and len(raw) >= 2: + candidates.append(raw[1]) + if isinstance(raw, dict) and "receipt" in raw: + candidates.append(raw["receipt"]) + inner_receipt = getattr(raw, "receipt", None) + if inner_receipt is not None: + candidates.append(inner_receipt) + for candidate in candidates: + to_header = getattr(candidate, "to_payment_receipt", None) + if not callable(to_header): + continue + try: + value = to_header() + except Exception as exc: + log.debug("[_mppx_receipt] to_payment_receipt() raised: %s", exc) + continue + if isinstance(value, str) and value: + return value + # Shape 3: node-style with_receipt(response) decorator. + with_receipt = getattr(raw, "with_receipt", None) + if callable(with_receipt): + try: + wrapped = with_receipt(None) + headers = getattr(wrapped, "headers", None) + if headers is not None and hasattr(headers, "get"): + val = headers.get("Payment-Receipt") + if isinstance(val, str) and val: + return val + except Exception: + return None + return None + + +def extract_mppx_receipt_method(header: str) -> str | None: + """Deserialize the receipt header via mppx and return the ``method`` field. + + The returned method is ``'tempo'`` / ``'solana'`` / ``'stripe'``, or the + legacy ``'/charge'`` form. Returns ``None`` when the header is + malformed or mppx isn't importable. Uses ``Receipt.from_payment_receipt`` + (Python pympp) — equivalent to node's ``Receipt.deserialize``. + """ + try: + from mpp import Receipt # type: ignore[import-untyped] + except Exception: + return None + try: + receipt = Receipt.from_payment_receipt(header) + except Exception: + return None + method = getattr(receipt, "method", None) + return method if isinstance(method, str) else None + + +def derive_mppx_receipt_method(raw: Any) -> str | None: + """Resolve the receipt method from a compose-success raw result in one call. + + Tries the direct ``raw.receipt.method`` path first, then falls back to the + receipt-header path. Returns ``None`` when neither yields a method. + """ + receipt = getattr(raw, "receipt", None) + direct = getattr(receipt, "method", None) if receipt is not None else None + if isinstance(direct, str) and direct: + return direct + header = extract_mppx_receipt_header_from_raw(raw) + if not header: + return None + return extract_mppx_receipt_method(header) diff --git a/agentscore_commerce/_redis.py b/agentscore_commerce/_redis.py new file mode 100644 index 0000000..0ac5606 --- /dev/null +++ b/agentscore_commerce/_redis.py @@ -0,0 +1,99 @@ +"""Shared lazy ``redis.asyncio`` factory. + +Replaces the hand-rolled lazy-init pattern in ``quote_cache``, +``stripe_multichain.pi_cache``, and ``middleware._core`` so they don't drift +on logging posture, TLS handling, or connect-error semantics. + +``redis`` is an optional peer dep — callers pass ``redis_url`` (or rely on +``REDIS_URL`` env); when unset or the lazy import fails, this returns ``None`` +and the caller falls back to its in-process dict. + +Mirrors node-commerce ``src/_redis.ts``. Not part of the public API. +""" + +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING, Any, Protocol, TypeVar + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + +log = logging.getLogger(__name__) + + +class MinimalRedis(Protocol): + """Minimal Redis surface. + + Each caller intersects with its own usage (get/set/incr/expire/flushdb). + Returning ``Any`` on commands keeps the shape narrow; cast at the call site. + """ + + +T = TypeVar("T", bound=MinimalRedis) + + +async def _try_create_redis( + *, + url: str | None, + label: str, + socket_connect_timeout: float = 3.0, +) -> Any | None: + """Lazy-import ``redis.asyncio`` and construct a client. + + Returns ``None`` when: + + - no URL is configured (caller falls back to in-memory) + - ``redis`` isn't installed (optional peer; caller falls back to in-memory) + - the import / construction raises for any other reason + + ``rediss://`` URLs auto-enable TLS via ``redis.asyncio.from_url``. + Matches the node sibling's connect-timeout and retry-cap (3s, 1 retry). + """ + resolved = url if url is not None else os.environ.get("REDIS_URL") + if not resolved: + return None + try: + from importlib import import_module + + redis_asyncio: Any = import_module("redis.asyncio") + return redis_asyncio.from_url( + resolved, + socket_connect_timeout=socket_connect_timeout, + retry_on_error=[ConnectionError, TimeoutError], + ) + except ImportError: + log.error( + "[%s] redis_url set but `redis` is not installed. Run `pip install redis` or unset redis_url.", + label, + ) + return None + except Exception as exc: + log.warning("[%s] redis init failed (%s); falling back to in-memory", label, exc) + return None + + +def memoized_redis(*, url: str | None, label: str) -> Callable[[], Awaitable[Any | None]]: + """Build a memoized async getter. + + First call constructs the client; later calls return the same client + (or the same ``None``). + + Mirrors node's ``memoizedRedis`` closure pattern. Pairs with the per-caller + ``redis_url`` opt — when ``url`` is ``None`` AND ``REDIS_URL`` is unset, the + getter resolves to ``None`` once and remains so for the lifetime of the + caller. + """ + client: Any | None = None + attempted = False + + async def _get() -> Any | None: + nonlocal client, attempted + if attempted: + return client + attempted = True + client = await _try_create_redis(url=url, label=label) + return client + + return _get diff --git a/agentscore_commerce/challenge/how_to_pay.py b/agentscore_commerce/challenge/how_to_pay.py index 0d16d6e..a0c942e 100644 --- a/agentscore_commerce/challenge/how_to_pay.py +++ b/agentscore_commerce/challenge/how_to_pay.py @@ -45,6 +45,7 @@ async def build_how_to_pay( rails: dict[str, TempoRailSpec | X402BaseRailSpec | SolanaMppRailSpec | StripeRailSpec], op_token_placeholder: str = "", # noqa: S107 — literal placeholder, not a secret max_spend: float | str | None = None, + decimals: int = 2, ) -> dict[str, Any]: """Build the agent_instructions.how_to_pay block. @@ -55,9 +56,20 @@ async def build_how_to_pay( `recipient` resolution: only `accepted_methods` consumes the resolved address; `how_to_pay` surfaces commands the agent runs, none of which include the recipient string. So this builder does NOT resolve `RecipientLike` factories. + + ``decimals`` controls fractional digits when formatting the auto-derived + ``max_spend`` for sub-dollar / sub-cent prices (default ``2``). For prices + ≥ $1 the default is still ``ceil(total) + 1`` formatted at ``decimals``; for + sub-dollar totals the default uses ``total.toFixed(decimals)`` so the cap + flag reflects the real amount instead of always being ``"1.00"``. """ total_num = float(total_usd) if isinstance(total_usd, str) else total_usd - max_spend_str = str(max_spend) if max_spend is not None else f"{math.ceil(total_num) + 1:.2f}" + if max_spend is not None: + max_spend_str = str(max_spend) + elif total_num >= 1: + max_spend_str = f"{math.ceil(total_num) + 1:.{decimals}f}" + else: + max_spend_str = f"{total_num:.{decimals}f}" op_token = op_token_placeholder block: dict[str, Any] = {} diff --git a/agentscore_commerce/challenge/pricing.py b/agentscore_commerce/challenge/pricing.py index 5238b02..21087d7 100644 --- a/agentscore_commerce/challenge/pricing.py +++ b/agentscore_commerce/challenge/pricing.py @@ -71,19 +71,20 @@ def to_dict(self) -> dict[str, Any]: def build_pricing_block( - subtotal_cents: int, - tax_cents: int = 0, - shipping_cents: int | None = None, - discount_cents: int | None = None, - total_cents: int | None = None, + subtotal_cents: float, + tax_cents: float = 0, + shipping_cents: float | None = None, + discount_cents: float | None = None, + total_cents: float | None = None, tax_rate: float | None = None, tax_state: str | None = None, currency: str | None = None, + decimals: int = 2, ) -> PricingBlock: """Compose a :class:`PricingBlock` from cents-denominated inputs. - Handles the cents → dollar-string conversion (always 2 decimals) and computes the total - when not explicitly provided. ``subtotal_cents`` is the list price, pre-discount; + Handles the cents → dollar-string conversion and computes the total when not + explicitly provided. ``subtotal_cents`` is the list price, pre-discount; ``discount_cents`` is the deduction applied (redemption code, coupon). Example:: @@ -107,6 +108,11 @@ def build_pricing_block( Pass ``shipping_cents=0`` for digital goods if you want the field present (it's then ``"0.00"``); pass ``None`` (or omit) if you don't want shipping in the response shape at all. Total floors at 0 when discount exceeds subtotal + tax + shipping. + + ``decimals`` controls dollar-precision for every emitted money field (default + ``2``). Raise for sub-cent unit pricing so ``subtotal`` / ``total`` show the + real amount instead of rounding to two decimals; subtotal/tax/total inputs + become fractional cents under this mode. """ shipping = shipping_cents if shipping_cents is not None else 0 discount = discount_cents if discount_cents is not None else 0 @@ -116,20 +122,19 @@ def build_pricing_block( else: total = total_cents + def fmt(cents: float) -> str: + return f"{cents / 100:.{decimals}f}" + return PricingBlock( - subtotal=_format_cents(subtotal_cents), - tax=_format_cents(tax_cents), - total=_format_cents(total), - shipping=_format_cents(shipping) if shipping_cents is not None else None, - discount=_format_cents(discount) if discount_cents is not None else None, + subtotal=fmt(subtotal_cents), + tax=fmt(tax_cents), + total=fmt(total), + shipping=fmt(shipping) if shipping_cents is not None else None, + discount=fmt(discount) if discount_cents is not None else None, tax_rate=tax_rate, tax_state=tax_state, currency=currency, ) -def _format_cents(cents: int) -> str: - return f"{cents / 100:.2f}" - - __all__ = ["PricingBlock", "build_pricing_block"] diff --git a/agentscore_commerce/checkout.py b/agentscore_commerce/checkout.py index 8f381bd..6ade67b 100644 --- a/agentscore_commerce/checkout.py +++ b/agentscore_commerce/checkout.py @@ -73,6 +73,8 @@ from dataclasses import dataclass, field from typing import Any, Literal, TypeAlias +from agentscore_commerce._headers import normalize_headers_to_lowercase +from agentscore_commerce._mppx_receipt import extract_mppx_receipt_header_from_raw from agentscore_commerce.challenge.accepted_methods import build_accepted_methods from agentscore_commerce.challenge.agent_instructions import RailKey, build_agent_instructions from agentscore_commerce.challenge.agent_memory import first_encounter_agent_memory @@ -81,6 +83,7 @@ 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 +from agentscore_commerce.payment.payment_header import has_mppx_header, has_x402_header from agentscore_commerce.payment.rail_spec import ( RecipientLike, SolanaMppRailSpec, @@ -219,6 +222,11 @@ class PricingResult: block: PricingBlock | None = None """Optional pre-built :class:`PricingBlock`. When omitted, Checkout builds a minimal block from ``amount_usd`` so the 402 body always carries pricing metadata.""" + decimals: int = 2 + """Dollar-precision used to format ``amount_usd`` and the derived + :class:`PricingBlock` fields. Default ``2`` (canonical USD cents). Raise for + sub-cent unit pricing (per-token LLM, per-byte storage, etc.) so the 402 + body advertises the real amount instead of rounding to two decimals.""" product: dict[str, str] | None = None """Optional product block surfaced in the 402 body's ``product`` field. Goods merchants populate ``{id, name, slug, list_price_usd, ...}``; API sellers leave @@ -232,14 +240,15 @@ class PricingResult: def pricing_result( *, - subtotal_cents: int | None = None, - tax_cents: int | None = None, - shipping_cents: int | None = None, - discount_cents: int | None = None, + subtotal_cents: float | None = None, + tax_cents: float | None = None, + shipping_cents: float | None = None, + discount_cents: float | None = None, tax_rate: float | None = None, tax_state: str | None = None, currency: str = "USD", amount_usd: float | None = None, + decimals: int = 2, product: dict[str, str] | None = None, body_extras: dict[str, Any] | None = None, ) -> PricingResult: @@ -287,11 +296,13 @@ async def _compute_pricing(ctx: CheckoutContext) -> PricingResult: tax_rate=tax_rate, tax_state=tax_state, currency=currency, + decimals=decimals, ) return PricingResult( amount_usd=derived_amount, currency=currency, block=block, + decimals=decimals, product=product, body_extras=body_extras, ) @@ -301,6 +312,7 @@ async def _compute_pricing(ctx: CheckoutContext) -> PricingResult: return PricingResult( amount_usd=amount_usd, currency=currency, + decimals=decimals, product=product, body_extras=body_extras, ) @@ -531,58 +543,12 @@ class CheckoutResult: IsCachedAddressFn: TypeAlias = Callable[[str], Awaitable[bool] | bool] -def _has_x402_header(headers: dict[str, str]) -> bool: - lower = {k.lower(): v for k, v in headers.items()} - return bool(lower.get("payment-signature") or lower.get("x-payment")) - - -def _has_mppx_header(headers: dict[str, str]) -> bool: - lower = {k.lower(): v for k, v in headers.items()} - auth = lower.get("authorization") or "" - return auth.startswith("Payment ") - - async def _maybe_await(value: Any) -> Any: if hasattr(value, "__await__"): return await value return value -def _extract_mppx_receipt_header_from_raw(raw: Any) -> str | None: - """Best-effort ``Payment-Receipt`` extraction from a custom hook's ``raw``. - - Handles the three shapes hand-rolled hooks commonly return on a 200: - - * The raw object itself exposes ``to_payment_receipt()`` (pympp Receipt - handed back directly). - * ``raw`` is a tuple ``(credential, receipt)`` (the pympp ``Mpp.charge`` - return shape, unpacked but not re-wrapped). - * ``raw`` is a dict with ``receipt`` key, or an object with ``.receipt`` - attribute (the auto-built hook's ``{"credential", "receipt"}`` dict). - - Returns ``None`` when none of the shapes match, or the receipt's - ``to_payment_receipt`` raises: the response then omits the header rather - than emitting a malformed value. - """ - candidates: list[Any] = [raw] - if isinstance(raw, tuple | list) and len(raw) >= 2: - candidates.append(raw[1]) - if isinstance(raw, dict) and "receipt" in raw: - candidates.append(raw["receipt"]) - if hasattr(raw, "receipt"): - candidates.append(raw.receipt) - for candidate in candidates: - to_header = getattr(candidate, "to_payment_receipt", None) - if callable(to_header): - try: - value = to_header() - except Exception: # noqa: S112 - continue - if isinstance(value, str) and value: - return value - return None - - def _resolve_identity_metadata(ctx: CheckoutContext) -> dict[str, Any] | None: """Compose the identity_metadata block from request + assess state. @@ -594,7 +560,7 @@ def _resolve_identity_metadata(ctx: CheckoutContext) -> dict[str, Any] | None: """ from agentscore_commerce.challenge.identity import build_identity_metadata - lower = {k.lower(): v for k, v in ctx.request.headers.items()} + lower = normalize_headers_to_lowercase(ctx.request.headers) wallet = lower.get("x-wallet-address") if not wallet: return None @@ -950,7 +916,7 @@ async def handle(self, request: CheckoutRequest) -> CheckoutResult: # passes through to 402). Sets ctx.assess["identity_status"]; 403s # short-circuit. Merchant-supplied per_request_policy resolves the # policy block (read from the product row, tier, etc.). - has_payment_header = _has_x402_header(request.headers) or _has_mppx_header(request.headers) + has_payment_header = has_x402_header(request.headers) or has_mppx_header(request.headers) if self.gate is not None and has_payment_header: gate_result = await self._run_gate(ctx) if gate_result is not None: @@ -964,14 +930,14 @@ async def handle(self, request: CheckoutRequest) -> CheckoutResult: self.zero_settle_carve_out and ctx.pricing is not None and ctx.pricing.amount_usd == 0 - and (_has_x402_header(request.headers) or _has_mppx_header(request.headers)) + and (has_x402_header(request.headers) or has_mppx_header(request.headers)) ): return await self._handle_zero_settle(ctx) - if _has_x402_header(request.headers) and self._x402_server_available() and self._x402_base_network: + if has_x402_header(request.headers) and self._x402_server_available() and self._x402_base_network: return await self._handle_x402(ctx) - if _has_mppx_header(request.headers) and self.compose_mppx is not None: + if has_mppx_header(request.headers) and self.compose_mppx is not None: return await self._handle_mppx(ctx) # Discovery leg: if an MPP rail is configured (compose_mppx supplied), call @@ -1616,7 +1582,7 @@ async def _handle_zero_settle(self, ctx: CheckoutContext) -> CheckoutResult: Returns a 200 success path identical to a real settle, except ``tx_hash`` is ``None``. """ - if _has_x402_header(ctx.request.headers): + if has_x402_header(ctx.request.headers): verified = await verify_x402_request( headers=ctx.request.headers, is_cached_address=self._async_is_cached_address, @@ -1712,7 +1678,7 @@ async def _handle_x402(self, ctx: CheckoutContext) -> CheckoutResult: resource_config={ "scheme": "exact", "network": verified.signed_network, - "price": f"${ctx.pricing.amount_usd:.2f}", + "price": f"${ctx.pricing.amount_usd:.{ctx.pricing.decimals}f}", "payTo": verified.signed_pay_to, "maxTimeoutSeconds": 300, }, @@ -1805,7 +1771,7 @@ async def _handle_mppx(self, ctx: CheckoutContext) -> CheckoutResult: signer_network=composed.signer_network, payment_response_header=composed.payment_response_header, payment_receipt_header=composed.payment_receipt_header - or _extract_mppx_receipt_header_from_raw(composed.raw), + or extract_mppx_receipt_header_from_raw(composed.raw), raw=composed.raw, ) return await self._build_success(ctx, outcome) @@ -1849,15 +1815,18 @@ async def _emit_402( for k, v in emit_rails.items() if isinstance(v, (TempoRailSpec, X402BaseRailSpec, SolanaMppRailSpec, StripeRailSpec)) } + pricing_decimals = ctx.pricing.decimals how_to_pay = await build_how_to_pay( url=self.url, retry_body_json=str(ctx.request.body), - total_usd=f"{ctx.pricing.amount_usd:.2f}", + total_usd=f"{ctx.pricing.amount_usd:.{pricing_decimals}f}", rails=how_to_pay_rails, + decimals=pricing_decimals, ) pricing_block = ctx.pricing.block or build_pricing_block( - subtotal_cents=round(ctx.pricing.amount_usd * 100), + subtotal_cents=ctx.pricing.amount_usd * 100, currency=ctx.pricing.currency, + decimals=pricing_decimals, ) # Build x402 accepts BEFORE the body so they appear both in the rich body # (agents read JSON) AND in the PAYMENT-REQUIRED header (x402-spec clients). @@ -1879,7 +1848,7 @@ async def _emit_402( build_x402_accepts_for_402( x402_srv, network=x402_network, - price=f"${ctx.pricing.amount_usd:.2f}", + price=f"${ctx.pricing.amount_usd:.{pricing_decimals}f}", pay_to=recipient, max_timeout_seconds=300, ) @@ -1900,7 +1869,7 @@ async def _emit_402( agent_instructions=build_agent_instructions(how_to_pay=how_to_pay), identity_metadata=identity_metadata, pricing=pricing_block, - amount_usd=f"{ctx.pricing.amount_usd:.2f}", + amount_usd=f"{ctx.pricing.amount_usd:.{pricing_decimals}f}", retry_body=ctx.request.body, agent_memory=first_encounter_agent_memory(first_encounter=True), product=ctx.pricing.product, diff --git a/agentscore_commerce/checkout_compute_first.py b/agentscore_commerce/checkout_compute_first.py new file mode 100644 index 0000000..0e0d97c --- /dev/null +++ b/agentscore_commerce/checkout_compute_first.py @@ -0,0 +1,919 @@ +"""``compute_first_checkout`` — variable-cost pay-per-result merchant helper. + +Mirrors node-commerce ``src/checkout_compute_first.ts``. Uses compute-first + +exact-x402 (no upto, no Permit2, no Settlement-Overrides). + +Flow (per request): + +1. PROBE leg (no payment header) + - Validate input + - Look up cache by content-hash of the request body + - On cache miss: run ``run_work(body, ctx)`` + - 0 results → return 200 immediately with ``no_charge`` envelope (no 402) + - Else → cache ``{body, price_cents}`` keyed by body hash → emit 402 with + EXACT price (``actual_results * unit_price_cents``) on every advertised rail + - On cache hit: emit 402 with cached price + +2. SETTLE leg (``X-Payment`` / ``Authorization: Payment`` header attached) + - Look up cache by re-hashing the same body + - Cache miss → 400 ``stale_quote`` with ``next_steps.action: "re_probe"`` + - x402 path → :func:`verify_x402_request` + :func:`process_x402_settle` with + ``scheme="exact"`` + - MPP path → ``compose_mppx`` callback runs the settle compose + - Return cached result body in the canonical 200 envelope + +Works on every exact-mode rail today (x402-exact Base, ``tempo/charge``, +``solana/charge``, Stripe SPT). The tradeoff vs. upto is that the work runs on +the unpaid probe leg — so rate-limiting is load-bearing (use +``agentscore_commerce.middleware.fastapi.RateLimitMiddleware`` or the +per-framework equivalent). +""" + +from __future__ import annotations + +import logging +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any + +from agentscore_commerce._mppx_receipt import derive_mppx_receipt_method +from agentscore_commerce.challenge import ( + build_402_body, + build_accepted_methods, + build_agent_instructions, + build_how_to_pay, + build_pricing_block, + first_encounter_agent_memory, +) +from agentscore_commerce.checkout import CheckoutValidationError +from agentscore_commerce.discovery import build_success_next_steps +from agentscore_commerce.payment.amounts import format_usd_cents +from agentscore_commerce.payment.payment_header import has_mppx_header, has_x402_header +from agentscore_commerce.payment.rail_spec import ( + SolanaMppRailSpec, + StripeRailSpec, + TempoRailSpec, + X402BaseRailSpec, + resolve_recipient, +) +from agentscore_commerce.payment.signer import extract_payment_signer, read_x402_payment_header +from agentscore_commerce.payment.wwwauthenticate import payment_required_header +from agentscore_commerce.payment.x402_server import build_x402_accepts_for_402 +from agentscore_commerce.payment.x402_settle import ProcessX402SettleSuccess, process_x402_settle +from agentscore_commerce.payment.x402_validation import VerifyX402RequestSuccess, verify_x402_request +from agentscore_commerce.quote_cache import DEFAULT_TTL_MS, QuoteCache, create_quote_cache + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + +log = logging.getLogger(__name__) + + +@dataclass +class WorkOutcome: + """Output of the per-request work hook. + + ``result_count`` is the number of billable units (results, tokens, bytes, + …) used to compute the exact price ``unit_price_cents * result_count`` + advertised in the 402. Zero short-circuits the probe to 200 no-charge. + + ``body`` is the response payload returned to the buyer on the settle leg. + Cached verbatim and served on retry; the merchant does NOT re-run work. + """ + + result_count: int + body: dict[str, Any] + + +@dataclass +class MintedRecipients: + """Per-rail recipient addresses minted by the merchant for this request.""" + + tempo: str | None = None + x402_base: str | None = None + solana_mpp: str | None = None + + def as_dict(self) -> dict[str, str]: + out: dict[str, str] = {} + if self.tempo: + out["tempo"] = self.tempo + if self.x402_base: + out["x402_base"] = self.x402_base + if self.solana_mpp: + out["solana_mpp"] = self.solana_mpp + return out + + +@dataclass +class ComputeFirstRequest: + """Framework-neutral HTTP request input. + + Built by the per-framework adapter method (``handle_fastapi`` / + ``handle_flask`` / …). + """ + + method: str + url: str + headers: dict[str, str] + body: dict[str, Any] + raw: Any = None + + +@dataclass +class ComputeFirstWorkContext: + request: ComputeFirstRequest + + +@dataclass +class ComputeFirstMintContext: + request: ComputeFirstRequest + body: dict[str, Any] + price_cents: int + + +@dataclass +class ComputeFirstMppContext: + request: ComputeFirstRequest + cached_body: dict[str, Any] + price_cents: int + price_usd: str + recipients: MintedRecipients + + +@dataclass +class ComputeFirstSettledContext: + request: ComputeFirstRequest + rail: str # 'x402' | 'mpp' + cached_body: dict[str, Any] + price_cents: int + price_usd: str + recipients: MintedRecipients + mpp_method: str | None = None + signer_address: str | None = None + signer_network: str | None = None # 'evm' | 'solana' + payment_intent_id: str | None = None + + +@dataclass +class ComputeFirstMppResult: + """Return shape for the ``compose_mppx`` callback. + + On 200, set ``raw`` to the mppx compose result so the helper can extract + the receipt method. On 402, set ``headers`` to mppx's challenge headers + (typically ``mppx_challenge_headers(result)``). + """ + + status: int + raw: Any = None + headers: dict[str, str] = field(default_factory=dict) + tx_hash: str | None = None + signer_address: str | None = None + signer_network: str | None = None # 'evm' | 'solana' + + +@dataclass +class SuccessBodyArgs: + reference_id: str + endpoint: str + charged_usd: str + rail: str + cached_body: dict[str, Any] + payment_intent_id: str | None = None + signer_address: str | None = None + signer_network: str | None = None + + +@dataclass +class ComputeFirstRails: + tempo: TempoRailSpec | None = None + x402_base: X402BaseRailSpec | None = None + solana_mpp: SolanaMppRailSpec | None = None + stripe: StripeRailSpec | None = None + + +def _decimals_for_unit(unit_price_cents: float) -> int: + """Auto-derive dollar precision from the unit price's fractional digits.""" + if float(unit_price_cents).is_integer(): + return 2 + s = repr(unit_price_cents) + dot = s.find(".") + if dot == -1: + return 2 + frac = len(s) - dot - 1 + return 2 + frac + + +def _iso_now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _default_success_body(app_url: str) -> Callable[[SuccessBodyArgs], dict[str, Any]]: + def _build(args: SuccessBodyArgs) -> dict[str, Any]: + out: dict[str, Any] = { + "id": args.reference_id, + "endpoint": args.endpoint, + "created_at": _iso_now(), + "payment_status": "completed", + "charged_usd": args.charged_usd, + "rail": args.rail, + } + if args.payment_intent_id: + out["payment_intent_id"] = args.payment_intent_id + if args.signer_address and args.signer_network: + out["signer"] = {"address": args.signer_address, "network": args.signer_network} + out["result"] = args.cached_body + out["next_steps"] = build_success_next_steps(order_status_url=f"{app_url}/health") + out["agent_memory"] = first_encounter_agent_memory(first_encounter=True) + return out + + return _build + + +class ComputeFirstCheckout: + """Variable-cost pay-per-result orchestrator. + + See the module docstring for the full flow. + + Construct once at module load with merchant config + hooks; reuse across + requests. Each request runs through :meth:`handle` (framework-neutral) + or one of the per-framework adapters (``handle_fastapi``, ``handle_flask``, + ``handle_aiohttp``, ``handle_sanic``, ``handle_django``). + """ + + def __init__( + self, + *, + name: str, + url: str, + unit_price_cents: float, + rails: ComputeFirstRails, + x402_server: Any, + run_work: Callable[[dict[str, Any], ComputeFirstWorkContext], Awaitable[WorkOutcome]], + decimals: int | None = None, + compose_mppx: Callable[[ComputeFirstMppContext], Awaitable[ComputeFirstMppResult]] | None = None, + on_settled: Callable[[ComputeFirstSettledContext], Awaitable[None]] | None = None, + validate_input: Callable[[dict[str, Any]], None] | None = None, + mint_recipients: Callable[[ComputeFirstMintContext], Awaitable[MintedRecipients]] | None = None, + cache: QuoteCache | None = None, + cache_ttl_ms: int = DEFAULT_TTL_MS, + app_url: str | None = None, + build_success_body: Callable[[SuccessBodyArgs], dict[str, Any]] | None = None, + ) -> None: + self.name = name + self.url = url + self.unit_price_cents = unit_price_cents + self.decimals = decimals if decimals is not None else _decimals_for_unit(unit_price_cents) + self.rails = rails + self.x402_server = x402_server + self.compose_mppx = compose_mppx + self.on_settled = on_settled + self.validate_input = validate_input + self.run_work = run_work + self.mint_recipients = mint_recipients + self.cache = cache or create_quote_cache(ttl_ms=cache_ttl_ms) + # Derive app URL from the endpoint's origin if not provided. + if app_url is not None: + self.app_url = app_url + else: + from urllib.parse import urlparse + + parsed = urlparse(url) + self.app_url = f"{parsed.scheme}://{parsed.netloc}" + self._build_success_body = build_success_body or _default_success_body(self.app_url) + + # ── core handlers ──────────────────────────────────────────────────────── + + async def _mint_and_resolve_recipients( + self, + request: ComputeFirstRequest, + body: dict[str, Any], + price_cents: int, + ) -> dict[str, str]: + minted = MintedRecipients() + if self.mint_recipients is not None: + minted = await self.mint_recipients( + ComputeFirstMintContext(request=request, body=body, price_cents=price_cents) + ) + out: dict[str, str] = {} + tempo = minted.tempo or (await resolve_recipient(self.rails.tempo.recipient) if self.rails.tempo else None) + x402_base = minted.x402_base or ( + await resolve_recipient(self.rails.x402_base.recipient) if self.rails.x402_base else None + ) + solana = minted.solana_mpp or ( + await resolve_recipient(self.rails.solana_mpp.recipient) if self.rails.solana_mpp else None + ) + if tempo: + out["tempo"] = tempo + if x402_base: + out["x402_base"] = x402_base + if solana: + out["solana_mpp"] = solana + return out + + async def _emit_402( + self, + request: ComputeFirstRequest, + body: dict[str, Any], + price_cents: int, + recipients: dict[str, str], + ) -> tuple[int, dict[str, Any], dict[str, str]]: + total_usd = format_usd_cents(price_cents, decimals=self.decimals) + tempo_recipient = recipients.get("tempo") + x402_base_recipient = recipients.get("x402_base") + solana_recipient = recipients.get("solana_mpp") + + accepted_rails: dict[str, Any] = {} + if tempo_recipient and self.rails.tempo is not None: + accepted_rails["tempo"] = _replace_recipient(self.rails.tempo, tempo_recipient) + if solana_recipient and self.rails.solana_mpp is not None: + accepted_rails["solana_mpp"] = _replace_recipient(self.rails.solana_mpp, solana_recipient) + if self.rails.stripe is not None: + accepted_rails["stripe"] = self.rails.stripe + accepted = await build_accepted_methods(**accepted_rails) + + if x402_base_recipient and self.rails.x402_base is not None: + try: + resolved = await resolve_recipient(x402_base_recipient) + x402_entries = build_x402_accepts_for_402( + self.x402_server, + network=self.rails.x402_base.network or "eip155:8453", + price=f"${total_usd}", + pay_to=resolved, + max_timeout_seconds=300, + ) + accepted.extend(x402_entries) + except Exception as exc: + log.warning( + "[%s.compute_first] build_x402_accepts_for_402 failed; dropping x402 from accepts: %s", + self.name, + exc, + ) + + how_to_pay_rails: dict[str, Any] = {} + if tempo_recipient and self.rails.tempo is not None: + how_to_pay_rails["tempo"] = _replace_recipient(self.rails.tempo, tempo_recipient) + if x402_base_recipient and self.rails.x402_base is not None: + how_to_pay_rails["x402_base"] = _replace_recipient(self.rails.x402_base, x402_base_recipient) + if solana_recipient and self.rails.solana_mpp is not None: + how_to_pay_rails["solana_mpp"] = _replace_recipient(self.rails.solana_mpp, solana_recipient) + if self.rails.stripe is not None: + how_to_pay_rails["stripe"] = self.rails.stripe + how_to_pay = await build_how_to_pay( + url=self.url, + retry_body_json=_json_dumps(body), + total_usd=total_usd, + decimals=self.decimals, + rails=how_to_pay_rails, + ) + + pricing = build_pricing_block(subtotal_cents=price_cents, currency="USD", decimals=self.decimals) + agent_instructions = build_agent_instructions( + how_to_pay=how_to_pay, + warnings=[ + ( + "The quoted price is exact: it was derived from the actual " + "number of results returned by the work on the probe leg." + ), + ( + "The merchant cached the result against a hash of this request body. " + "Retry with the same body within the quote TTL (default 5 min) to " + "settle and receive the cached results; if the quote expires, " + "re-probe." + ), + ], + ) + + # MPP probe leg: ask compose_mppx to produce mppx's challenge headers + # (with per-rail `request=` directives the agent needs + # to sign). x402-exact still uses PAYMENT-REQUIRED only. + mpp_challenge_headers: dict[str, str] = {} + if self.compose_mppx is not None: + try: + mpp_recipients = MintedRecipients( + tempo=tempo_recipient, x402_base=x402_base_recipient, solana_mpp=solana_recipient + ) + mpp_result = await self.compose_mppx( + ComputeFirstMppContext( + request=request, + cached_body=body, + price_cents=price_cents, + price_usd=total_usd, + recipients=mpp_recipients, + ) + ) + if mpp_result.status == 402 and mpp_result.headers: + mpp_challenge_headers = dict(mpp_result.headers) + except Exception as exc: + log.warning( + "[%s.compute_first] compose_mppx probe-leg failed; dropping MPP rails from 402 challenge: %s", + self.name, + exc, + ) + + body_402 = build_402_body( + product={"id": self.name, "name": self.name}, + accepted_methods=accepted, + pricing=pricing, + agent_instructions=agent_instructions, + amount_usd=total_usd, + currency="USD", + order_id=None, + retry_body=body, + ) + + headers = {"Content-Type": "application/json"} + headers.update(mpp_challenge_headers) + headers["PAYMENT-REQUIRED"] = payment_required_header( + x402_version=2, accepts=accepted, resource={"url": self.url} + ) + return 402, body_402, headers + + async def _handle_x402_settle( + self, + request: ComputeFirstRequest, + reference_id: str, + cached_body: dict[str, Any], + price_cents: int, + recipients: dict[str, str], + ) -> tuple[int, dict[str, Any], dict[str, str]]: + verified = await verify_x402_request( + headers=request.headers, + is_cached_address=lambda _addr: _true(), + accepted_network=(self.rails.x402_base.network if self.rails.x402_base else "eip155:8453"), + ) + if not isinstance(verified, VerifyX402RequestSuccess): + return verified.status, verified.body, {"Content-Type": "application/json"} + + actual_usd = format_usd_cents(price_cents, decimals=self.decimals) + settle_result = await process_x402_settle( + x402_server=self.x402_server, + payload=verified.payload, + resource_config={ + "scheme": "exact", + "network": verified.signed_network, + "price": f"${actual_usd}", + "payTo": verified.signed_pay_to, + "maxTimeoutSeconds": 300, + }, + resource_meta={ + "url": request.url, + "description": f"Agent purchase via x402-exact ({self.name})", + "mimeType": "application/json", + }, + ) + if not isinstance(settle_result, ProcessX402SettleSuccess): + detail = getattr(getattr(settle_result, "error", None), "args", ("unknown",)) + return ( + 502, + { + "id": reference_id, + "endpoint": self.name, + "created_at": _iso_now(), + "payment_status": "failed", + "charged_usd": "0.00", + "rail": f"x402-base ({verified.signed_network})", + "error": { + "code": "settle_failed", + "message": "Facilitator rejected the exact settle; no on-chain capture occurred.", + "detail": str(detail[0]) if detail else "unknown", + }, + }, + {"Content-Type": "application/json"}, + ) + + x402_header = read_x402_payment_header(request.headers) + signer = extract_payment_signer(x402_header) if x402_header else None + rail_label = f"Base ({verified.signed_network})" + + if self.on_settled is not None: + try: + await self.on_settled( + ComputeFirstSettledContext( + request=request, + rail="x402", + cached_body=cached_body, + price_cents=price_cents, + price_usd=actual_usd, + recipients=MintedRecipients( + tempo=recipients.get("tempo"), + x402_base=recipients.get("x402_base"), + solana_mpp=recipients.get("solana_mpp"), + ), + signer_address=signer.address if signer else None, + signer_network=signer.network if signer else None, + ) + ) + except Exception as exc: + log.warning("[%s.compute_first.on_settled] x402 side-effect failed: %s", self.name, exc) + + body = self._build_success_body( + SuccessBodyArgs( + reference_id=reference_id, + endpoint=self.name, + charged_usd=actual_usd, + rail=rail_label, + cached_body=cached_body, + signer_address=signer.address if signer else None, + signer_network=signer.network if signer else None, + ) + ) + return 200, body, {"Content-Type": "application/json"} + + def _mpp_rail_label(self, method: str | None) -> str: + # Receipt.method ships as either the bare scheme (``"tempo"``) or the + # full directive (``"tempo/charge"``). Strip the suffix to match both. + scheme = method.split("/", 1)[0] if method else None + if scheme == "tempo": + network_name = ( + "tempo-testnet" + if (self.rails.tempo and self.rails.tempo.testnet) + else (self.rails.tempo.network if self.rails.tempo and self.rails.tempo.network else "tempo-mainnet") + ) + return f"Tempo ({network_name})" + if scheme == "solana": + network_name = ( + self.rails.solana_mpp.network if self.rails.solana_mpp and self.rails.solana_mpp.network else "solana" + ) + return f"Solana ({network_name})" + if scheme == "stripe": + return "Stripe (card+link)" + return "MPP" + + async def _handle_mpp_settle( + self, + request: ComputeFirstRequest, + reference_id: str, + cached_body: dict[str, Any], + price_cents: int, + recipients: dict[str, str], + ) -> tuple[int, dict[str, Any], dict[str, str]]: + if self.compose_mppx is None: + return ( + 503, + { + "id": reference_id, + "endpoint": self.name, + "created_at": _iso_now(), + "payment_status": "failed", + "charged_usd": "0.00", + "error": { + "code": "mpp_unavailable", + "message": "MPP settle hook not configured on this endpoint.", + }, + }, + {"Content-Type": "application/json"}, + ) + + price_usd = format_usd_cents(price_cents, decimals=self.decimals) + mpp_recipients = MintedRecipients( + tempo=recipients.get("tempo"), + x402_base=recipients.get("x402_base"), + solana_mpp=recipients.get("solana_mpp"), + ) + result = await self.compose_mppx( + ComputeFirstMppContext( + request=request, + cached_body=cached_body, + price_cents=price_cents, + price_usd=price_usd, + recipients=mpp_recipients, + ) + ) + if result.status != 200: + return ( + 400, + { + "id": reference_id, + "endpoint": self.name, + "created_at": _iso_now(), + "payment_status": "failed", + "charged_usd": "0.00", + "error": { + "code": "mpp_settle_failed", + "message": "MPP compose did not return 200; credential rejected.", + }, + }, + {"Content-Type": "application/json", **(result.headers or {})}, + ) + + if result.signer_address: + signer_address = result.signer_address + signer_network = result.signer_network or "evm" + else: + x402_header = read_x402_payment_header(request.headers) + signer = extract_payment_signer(x402_header) if x402_header else None + signer_address = signer.address if signer else None + signer_network = signer.network if signer else None + + method = derive_mppx_receipt_method(result.raw) + rail_label = self._mpp_rail_label(method) + + if self.on_settled is not None: + try: + await self.on_settled( + ComputeFirstSettledContext( + request=request, + rail="mpp", + cached_body=cached_body, + price_cents=price_cents, + price_usd=price_usd, + recipients=mpp_recipients, + mpp_method=method, + signer_address=signer_address, + signer_network=signer_network, + payment_intent_id=result.tx_hash, + ) + ) + except Exception as exc: + log.warning("[%s.compute_first.on_settled] MPP side-effect failed: %s", self.name, exc) + + body = self._build_success_body( + SuccessBodyArgs( + reference_id=reference_id, + endpoint=self.name, + charged_usd=price_usd, + rail=rail_label, + cached_body=cached_body, + payment_intent_id=result.tx_hash, + signer_address=signer_address, + signer_network=signer_network, + ) + ) + return 200, body, {"Content-Type": "application/json"} + + async def handle(self, request: ComputeFirstRequest) -> tuple[int, dict[str, Any], dict[str, str]]: + """Framework-neutral entry point. Returns ``(status, body, headers)``.""" + reference_id = f"{self.name}_{uuid.uuid4()}" + body = request.body or {} + + if self.validate_input is not None: + try: + self.validate_input(body) + except CheckoutValidationError as err: + envelope: dict[str, Any] = {"error": {"code": err.code, "message": err.message}} + if err.action: + envelope["next_steps"] = {"action": err.action, "user_message": err.message} + envelope.update(err.extra or {}) + return err.status, envelope, {"Content-Type": "application/json"} + + cache_key = self.cache.body_hash_key(self.name, body) + + if has_x402_header(request.headers) or has_mppx_header(request.headers): + quote = await self.cache.read(cache_key) + if quote is None: + return ( + 400, + { + "id": reference_id, + "endpoint": self.name, + "created_at": _iso_now(), + "payment_status": "failed", + "charged_usd": "0.00", + "error": { + "code": "stale_quote", + "message": ( + "No active quote for this request body. The quote may have " + "expired or the body changed since the probe." + ), + }, + "next_steps": { + "action": "re_probe", + "suggestion": ( + "Send the same body without a payment header to get a fresh " + "402 quote, then retry with the payment credential." + ), + }, + }, + {"Content-Type": "application/json"}, + ) + recipients = quote.recipients if hasattr(quote, "recipients") else {} + if has_x402_header(request.headers): + return await self._handle_x402_settle( + request, reference_id, quote.body, int(quote.price_cents), recipients + ) + return await self._handle_mpp_settle(request, reference_id, quote.body, int(quote.price_cents), recipients) + + # Probe leg + quote = await self.cache.read(cache_key) + if quote is None: + try: + outcome = await self.run_work(body, ComputeFirstWorkContext(request=request)) + except Exception: + # Suppress the upstream exception detail in the wire response — + # merchant errors may carry stack traces or internal state. The + # merchant's own logger is the right channel for the full exception. + return ( + 200, + { + "id": reference_id, + "endpoint": self.name, + "created_at": _iso_now(), + "payment_status": "no_charge", + "charged_usd": "0.00", + "result": {"matches": [], "total": 0}, + "error": { + "code": "upstream_failed", + "message": "The wrapped endpoint failed; no charge was applied.", + }, + }, + {"Content-Type": "application/json"}, + ) + + if outcome.result_count == 0: + return ( + 200, + { + "id": reference_id, + "endpoint": self.name, + "created_at": _iso_now(), + "payment_status": "no_charge", + "charged_usd": "0.00", + "result": outcome.body, + }, + {"Content-Type": "application/json"}, + ) + + price_cents = int(self.unit_price_cents * outcome.result_count) + recipients = await self._mint_and_resolve_recipients(request, body, price_cents) + await self.cache.write(cache_key, outcome.body, price_cents, recipients=recipients) + return await self._emit_402(request, body, price_cents, recipients) + + recipients = quote.recipients if hasattr(quote, "recipients") else {} + return await self._emit_402(request, body, int(quote.price_cents), recipients) + + # ── per-framework adapters ─────────────────────────────────────────────── + + async def handle_fastapi(self, request: Any, *, body: dict[str, Any] | None = None) -> Any: + from fastapi.responses import JSONResponse + + if body is None: + try: + parsed_body = await request.json() + except (ValueError, TypeError): + parsed_body = {} + else: + parsed_body = body + status, response_body, headers = await self.handle( + ComputeFirstRequest( + method=request.method, + url=str(request.url), + headers=dict(request.headers), + body=parsed_body, + raw=request, + ) + ) + return JSONResponse(content=response_body, status_code=status, headers=headers) + + async def handle_aiohttp(self, request: Any, *, body: dict[str, Any] | None = None) -> Any: + from aiohttp import web + + if body is None: + try: + parsed_body = await request.json() + except (ValueError, TypeError): + parsed_body = {} + else: + parsed_body = body + status, response_body, headers = await self.handle( + ComputeFirstRequest( + method=request.method, + url=str(request.url), + headers=dict(request.headers), + body=parsed_body, + raw=request, + ) + ) + # web.json_response sets Content-Type itself; strip ours to avoid conflict. + filtered_headers = {k: v for k, v in headers.items() if k.lower() != "content-type"} + return web.json_response(data=response_body, status=status, headers=filtered_headers) + + async def handle_sanic(self, request: Any, *, body: dict[str, Any] | None = None) -> Any: + from sanic import response as sanic_response + + if body is None: + try: + parsed_body = request.json or {} + except (ValueError, TypeError): + parsed_body = {} + else: + parsed_body = body + status, response_body, headers = await self.handle( + ComputeFirstRequest( + method=request.method, + url=str(request.url), + headers=dict(request.headers), + body=parsed_body, + raw=request, + ) + ) + return sanic_response.json(response_body, status=status, headers=headers) + + def handle_flask(self, request: Any, *, body: dict[str, Any] | None = None) -> Any: + """Flask adapter — synchronous-callable that runs the async handle. + + Returns a Flask Response. + """ + import asyncio + + from flask import jsonify + + if body is None: + try: + parsed_body = request.get_json(force=True) or {} + except Exception: + parsed_body = {} + else: + parsed_body = body + + async def _run() -> tuple[int, dict[str, Any], dict[str, str]]: + return await self.handle( + ComputeFirstRequest( + method=request.method, + url=request.url, + headers=dict(request.headers.items()), + body=parsed_body, + raw=request, + ) + ) + + loop = asyncio.new_event_loop() + try: + status, response_body, headers = loop.run_until_complete(_run()) + finally: + loop.close() + response = jsonify(response_body) + response.status_code = status + for k, v in headers.items(): + response.headers[k] = v + return response + + def handle_django(self, request: Any, *, body: dict[str, Any] | None = None) -> Any: + """Django adapter — synchronous-callable matching ``Checkout.handle_django``. + + Returns a ``JsonResponse``. + """ + import asyncio + import json + + from django.http import JsonResponse + + if body is None: + try: + parsed_body = json.loads(request.body or b"{}") + except (ValueError, TypeError): + parsed_body = {} + else: + parsed_body = body + + async def _run() -> tuple[int, dict[str, Any], dict[str, str]]: + return await self.handle( + ComputeFirstRequest( + method=request.method, + url=request.build_absolute_uri(), + headers=dict(request.headers.items()), + body=parsed_body, + raw=request, + ) + ) + + loop = asyncio.new_event_loop() + try: + status, response_body, headers = loop.run_until_complete(_run()) + finally: + loop.close() + response = JsonResponse(response_body, status=status) + for k, v in headers.items(): + response[k] = v + return response + + +def compute_first_checkout(**kwargs: Any) -> ComputeFirstCheckout: + """Factory wrapper for parity with node's ``computeFirstCheckout(opts)``. + + Equivalent to constructing :class:`ComputeFirstCheckout` directly. + """ + return ComputeFirstCheckout(**kwargs) + + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def _replace_recipient(spec: Any, recipient: str) -> Any: + """Return a shallow copy of ``spec`` with ``recipient`` swapped.""" + from dataclasses import replace as dc_replace + + try: + return dc_replace(spec, recipient=recipient) + except TypeError: + return spec + + +def _json_dumps(body: dict[str, Any]) -> str: + import json + + return json.dumps(body, separators=(",", ":"), sort_keys=True) + + +async def _true() -> bool: + return True diff --git a/agentscore_commerce/checkout_hooks.py b/agentscore_commerce/checkout_hooks.py index 0852db0..915400d 100644 --- a/agentscore_commerce/checkout_hooks.py +++ b/agentscore_commerce/checkout_hooks.py @@ -15,8 +15,7 @@ from typing import TYPE_CHECKING, Any from agentscore_commerce.checkout import MppxComposeOutcome -from agentscore_commerce.identity.address import normalize_address -from agentscore_commerce.payment.signer import extract_payment_signer +from agentscore_commerce.payment.signer import extract_payment_signer, parse_did_pkh_address if TYPE_CHECKING: from collections.abc import Awaitable, Callable @@ -56,7 +55,8 @@ async def hook(ctx: CheckoutContext) -> MppxComposeOutcome: return MppxComposeOutcome(status=402) mpp = await server_getter() authorization = ctx.request.headers.get("authorization") - amount_str = f"{ctx.pricing.amount_usd:.2f}" + decimals = getattr(ctx.pricing, "decimals", 2) + amount_str = f"{ctx.pricing.amount_usd:.{decimals}f}" try: result = await mpp.charge(authorization=authorization, amount=amount_str) except Exception: @@ -79,16 +79,10 @@ async def hook(ctx: CheckoutContext) -> MppxComposeOutcome: # `extract_payment_signer` expects a base64'd credential, not # a raw DID; fall back to parsing the DID directly when pympp # gives us the typed source string. - parts = cred_source.split(":") - if len(parts) >= 4 and parts[0] == "did" and parts[1] == "pkh": - family = parts[2] - addr = parts[-1] - if family == "eip155": - signer_address = normalize_address(addr) - signer_network = "evm" - elif family == "solana": - signer_address = normalize_address(addr) - signer_network = "solana" + parsed = parse_did_pkh_address(cred_source) + if parsed is not None: + signer_address = parsed.address + signer_network = parsed.network else: signer_address = signer.address signer_network = signer.network diff --git a/agentscore_commerce/identity/__init__.py b/agentscore_commerce/identity/__init__.py index 8cd65ee..003f67a 100644 --- a/agentscore_commerce/identity/__init__.py +++ b/agentscore_commerce/identity/__init__.py @@ -12,6 +12,8 @@ ) from agentscore_commerce.identity._response import denial_reason_to_body from agentscore_commerce.identity.a2a import ( + A2A_DEFAULT_TRANSPORT, + A2A_PROTOCOL_VERSION, UCP_A2A_EXTENSION_URI, A2AAgentCard, A2AAgentCardCapabilities, @@ -24,6 +26,11 @@ ucp_a2a_extension, ) from agentscore_commerce.identity.core import AgentScoreCore +from agentscore_commerce.identity.default_denied import ( + DefaultOnDeniedResult, + create_default_on_denied, + default_read_only_on_denied, +) from agentscore_commerce.identity.policy import ( EnforcementMode, GateResult, @@ -36,7 +43,7 @@ validate_shipping_against_policy, ) from agentscore_commerce.identity.signer import extract_x402_signer -from agentscore_commerce.identity.tokens import hash_operator_token +from agentscore_commerce.identity.tokens import OwnerScope, extract_owner_scope, hash_operator_token from agentscore_commerce.identity.types import ( AgentIdentity, AgentMemoryHint, @@ -81,20 +88,25 @@ # from agentscore_commerce.identity.django import AgentScoreMiddleware # from agentscore_commerce.identity.aiohttp import agentscore_gate_middleware # from agentscore_commerce.identity.sanic import agentscore_gate -def _load_asgi_middleware() -> tuple[Any, Any]: +def _load_asgi_middleware() -> tuple[Any, Any, Any]: try: from agentscore_commerce.identity.middleware import AgentScoreGate as _AgentScoreGate + from agentscore_commerce.identity.middleware import ( + ConditionalAgentScoreGate as _ConditionalAgentScoreGate, + ) from agentscore_commerce.identity.middleware import CreateSessionOnMissing as _CreateSessionOnMissing - return _AgentScoreGate, _CreateSessionOnMissing + return _AgentScoreGate, _ConditionalAgentScoreGate, _CreateSessionOnMissing except ImportError: # starlette not installed - return None, None + return None, None, None -AgentScoreGate, CreateSessionOnMissing = _load_asgi_middleware() +AgentScoreGate, ConditionalAgentScoreGate, CreateSessionOnMissing = _load_asgi_middleware() __all__ = [ + "A2A_DEFAULT_TRANSPORT", + "A2A_PROTOCOL_VERSION", "AGENTSCORE_UCP_CAPABILITY", "FIXABLE_DENIAL_REASONS", "UCP_A2A_EXTENSION_URI", @@ -111,7 +123,9 @@ def _load_asgi_middleware() -> tuple[Any, Any]: "AgentScoreGate", "AgentScoreGatePolicy", "AssessResult", + "ConditionalAgentScoreGate", "CreateSessionOnMissing", + "DefaultOnDeniedResult", "DenialCode", "DenialReason", "EnforcementMode", @@ -119,6 +133,7 @@ def _load_asgi_middleware() -> tuple[Any, Any]: "GeneratedUCPKey", "IdentityStatus", "OperatorVerification", + "OwnerScope", "PolicyBlock", "SignerSanctions", "SignerVerdict", @@ -137,8 +152,11 @@ def _load_asgi_middleware() -> tuple[Any, Any]: "build_jwks_response", "build_signer_mismatch_body", "build_ucp_profile", + "create_default_on_denied", + "default_read_only_on_denied", "denial_reason_status", "denial_reason_to_body", + "extract_owner_scope", "extract_x402_signer", "generate_ucp_signing_key", "hash_operator_token", diff --git a/agentscore_commerce/identity/a2a.py b/agentscore_commerce/identity/a2a.py index d281c83..06465c8 100644 --- a/agentscore_commerce/identity/a2a.py +++ b/agentscore_commerce/identity/a2a.py @@ -1,9 +1,9 @@ """Google A2A (Agent-to-Agent) v1.0 Agent Card builder. -Compose the JSON payload for an A2A v1.0 Agent Card per the canonical proto definition -at https://github.com/a2aproject/A2A/blob/main/specification/a2a.proto. Returned object -is the unsigned card body — wrap with an A2A AgentCardSignature (RFC 7515 JWS) to sign -vendor-side before publishing at /.well-known/agent-card.json. +Compose the JSON payload for an A2A v1.0 Agent Card matching the canonical +``AgentCard`` type from ``@a2a-js/sdk``. Returned object is the unsigned card +body — wrap with an ``A2AAgentCardSignature`` (RFC 7515 JWS) to sign vendor-side +before publishing at /.well-known/agent-card.json. Why publish: A2A is a Linux Foundation standard. Signed Agent Cards let any A2A-compatible reader discover an agent's capabilities + protocol bindings without @@ -12,6 +12,10 @@ so platforms detect UCP support without re-fetching the profile. Spec reference: https://a2a-protocol.org/latest/ +Authoritative types: https://www.npmjs.com/package/@a2a-js/sdk (interface ``AgentCard``). + +Python attribute names follow snake_case (PEP 8). The ``to_dict()`` output uses +camelCase keys to match the canonical A2A wire format. """ from __future__ import annotations @@ -20,10 +24,14 @@ from typing import Any _PROTOCOL_VERSION = "1.0" -_DEFAULT_PROTOCOL_BINDING = "HTTP+JSON" +_DEFAULT_TRANSPORT = "JSONRPC" +_DEFAULT_BUILDER_TRANSPORT = "HTTP+JSON" _DEFAULT_INPUT_MODE = "application/json" _DEFAULT_OUTPUT_MODE = "application/json" +A2A_PROTOCOL_VERSION = _PROTOCOL_VERSION +A2A_DEFAULT_TRANSPORT = _DEFAULT_TRANSPORT + UCP_A2A_EXTENSION_URI = "https://ucp.dev/2026-04-08/specification/reference" """Canonical UCP A2A extension URI — verifiers look for this exact URI in ``capabilities.extensions[]`` to detect UCP support on the agent card.""" @@ -31,45 +39,36 @@ @dataclass class A2AAgentInterface: - """Per spec §4.4.6. Each entry advertises one protocol binding the agent supports. + """One transport+URL combination the agent exposes. - `supported_interfaces[0]` is the preferred binding (ordered list). + Lives in ``AgentCard.additional_interfaces[]`` for multi-binding agents; the + primary transport+URL pair lives on ``AgentCard.url`` + ``AgentCard.preferred_transport``. """ - url: str - protocol_binding: str + transport: str """Open string — core values are ``JSONRPC``, ``GRPC``, ``HTTP+JSON``.""" - protocol_version: str - """A2A protocol version, e.g. ``1.0``. Distinct from the agent's own version.""" - tenant: str | None = None + url: str def to_dict(self) -> dict[str, Any]: - out: dict[str, Any] = { - "url": self.url, - "protocol_binding": self.protocol_binding, - "protocol_version": self.protocol_version, - } - if self.tenant is not None: - out["tenant"] = self.tenant - return out + return {"transport": self.transport, "url": self.url} @dataclass class A2AAgentProvider: - """Per spec §4.4.2. The org/service that provides the agent.""" + """Org/service that provides the agent.""" - url: str organization: str + url: str def to_dict(self) -> dict[str, str]: - return {"url": self.url, "organization": self.organization} + return {"organization": self.organization, "url": self.url} @dataclass class A2AAgentSkill: - """Per spec §4.4.5. A distinct capability or function the agent performs. + """A distinct capability or function the agent performs. - Lives at the TOP LEVEL of AgentCard (not inside ``capabilities``). + Lives at the TOP LEVEL of ``AgentCard.skills[]`` (not inside ``capabilities``). """ id: str @@ -79,6 +78,8 @@ class A2AAgentSkill: examples: list[str] = field(default_factory=list) input_modes: list[str] = field(default_factory=list) output_modes: list[str] = field(default_factory=list) + security: list[dict[str, list[str]]] = field(default_factory=list) + """Security schemes scoped to this skill. List = OR of ANDs.""" def to_dict(self) -> dict[str, Any]: out: dict[str, Any] = { @@ -90,18 +91,21 @@ def to_dict(self) -> dict[str, Any]: if self.examples: out["examples"] = self.examples if self.input_modes: - out["input_modes"] = self.input_modes + out["inputModes"] = self.input_modes if self.output_modes: - out["output_modes"] = self.output_modes + out["outputModes"] = self.output_modes + if self.security: + out["security"] = self.security return out @dataclass class A2AAgentCardExtension: - """Per spec §4.4.4. A protocol extension the agent supports. + """A protocol extension the agent supports. - Lives in ``capabilities.extensions[]``. ``description`` and ``required`` are - spec-mandated fields, not optional. + Lives in ``capabilities.extensions[]``. Canonical type marks ``description`` + and ``required`` optional, but we keep them in the builder to make UCP + discovery deterministic. """ uri: str @@ -148,40 +152,32 @@ def ucp_a2a_extension( @dataclass class A2AAgentCardCapabilities: - """Per spec §4.4.3. Optional capabilities the agent supports. - - Per the canonical proto, ``capabilities`` declares: streaming, push_notifications, - extensions (the protocol extensions the agent supports), and extended_agent_card. - REST-style endpoint metadata does NOT belong here — A2A uses ``supported_interfaces`` - on the AgentCard for protocol bindings, and ``skills`` (top-level) for capability - descriptions. - """ + """Optional capabilities the agent supports.""" - streaming: bool | None = None - push_notifications: bool | None = None extensions: list[A2AAgentCardExtension] = field(default_factory=list) - extended_agent_card: bool | None = None + push_notifications: bool | None = None + state_transition_history: bool | None = None + streaming: bool | None = None def to_dict(self) -> dict[str, Any]: out: dict[str, Any] = {} - if self.streaming is not None: - out["streaming"] = self.streaming - if self.push_notifications is not None: - out["push_notifications"] = self.push_notifications if self.extensions: out["extensions"] = [e.to_dict() for e in self.extensions] - if self.extended_agent_card is not None: - out["extended_agent_card"] = self.extended_agent_card + if self.push_notifications is not None: + out["pushNotifications"] = self.push_notifications + if self.state_transition_history is not None: + out["stateTransitionHistory"] = self.state_transition_history + if self.streaming is not None: + out["streaming"] = self.streaming return out @dataclass class A2AAgentCardSignature: - """Per spec §4.4.7. JWS signature embedded in an Agent Card. + """JWS signature embedded in an Agent Card. - Multiple signatures MAY be attached to a single card. Verifiers reconstruct the - card body without ``signatures`` to verify each entry. Format follows RFC 7515 - JSON Web Signature (JWS). + Multiple signatures MAY be attached. Verifiers reconstruct the card body + without ``signatures`` to verify each entry. Format follows RFC 7515. """ protected: str @@ -200,62 +196,74 @@ def to_dict(self) -> dict[str, Any]: @dataclass class A2AAgentCard: - """Per spec §4.4.1. A2A v1.0 Agent Card body. + """A2A v1.0 Agent Card body, matching ``AgentCard`` from ``@a2a-js/sdk``. - Use :meth:`to_dict` to serialize for signing + publishing. Per spec §4.4.7, - JWS signatures may be embedded directly in the card via the ``signatures`` field; - verifiers reconstruct the card body without ``signatures`` and verify each entry. - Per-vendor identity attestation can also be expressed via a vendor extension - entry inside ``capabilities.extensions[]``. + Use :meth:`to_dict` to serialize for signing + publishing. """ name: str description: str - supported_interfaces: list[A2AAgentInterface] + url: str + """Preferred endpoint URL — MUST support ``preferred_transport``.""" + protocol_version: str + """A2A protocol version, e.g. ``"1.0"``. Distinct from the agent's own ``version``.""" version: str - """Agent's own version, e.g. ``"1.0.0"``. Distinct from the A2A protocol version, - which lives on each ``A2AAgentInterface.protocol_version``.""" + """Agent's own version, e.g. ``"1.0.0"``.""" capabilities: A2AAgentCardCapabilities default_input_modes: list[str] default_output_modes: list[str] skills: list[A2AAgentSkill] = field(default_factory=list) - """Per spec §4.4.1 (proto field 12, REQUIRED): the agent must declare ≥1 skill. - The convenience builder :func:`build_a2a_agent_card` enforces non-empty.""" + """REQUIRED non-empty per spec. ``build_a2a_agent_card`` enforces.""" + preferred_transport: str | None = None + """Transport at the primary ``url``. Canonical default per spec is ``JSONRPC``; + our builder sets ``HTTP+JSON`` explicitly for REST-shaped merchants.""" + additional_interfaces: list[A2AAgentInterface] = field(default_factory=list) + """Additional transport+URL bindings beyond the primary.""" provider: A2AAgentProvider | None = None documentation_url: str | None = None icon_url: str | None = None - """Per spec §4.4.1 (proto field 14, optional): URL to an icon for the agent.""" + supports_authenticated_extended_card: bool | None = None + """Agent can provide an extended card with additional details to authenticated users.""" signatures: list[A2AAgentCardSignature] = field(default_factory=list) - """Per spec §4.4.1 (proto field 13, optional) + §4.4.7: JWS signatures embedded - in the card. Compute over the canonical card body MINUS this field, then attach.""" + """JWS signatures embedded in the card.""" + security: list[dict[str, list[str]]] = field(default_factory=list) + """OpenAPI 3.0 security requirement objects (OR of ANDs).""" security_schemes: dict[str, Any] = field(default_factory=dict) - security_requirements: list[Any] = field(default_factory=list) + """Map of security scheme definitions (key = scheme name).""" extras: dict[str, Any] = field(default_factory=dict) + """Vendor-specific extras merged at top level.""" def to_dict(self) -> dict[str, Any]: out: dict[str, Any] = { "name": self.name, "description": self.description, - "supported_interfaces": [i.to_dict() for i in self.supported_interfaces], + "url": self.url, + "protocolVersion": self.protocol_version, "version": self.version, "capabilities": self.capabilities.to_dict(), - "default_input_modes": self.default_input_modes, - "default_output_modes": self.default_output_modes, + "defaultInputModes": self.default_input_modes, + "defaultOutputModes": self.default_output_modes, } + if self.preferred_transport is not None: + out["preferredTransport"] = self.preferred_transport + if self.additional_interfaces: + out["additionalInterfaces"] = [i.to_dict() for i in self.additional_interfaces] + if self.skills: + out["skills"] = [s.to_dict() for s in self.skills] if self.provider is not None: out["provider"] = self.provider.to_dict() if self.documentation_url is not None: - out["documentation_url"] = self.documentation_url + out["documentationUrl"] = self.documentation_url if self.icon_url is not None: - out["icon_url"] = self.icon_url - if self.skills: - out["skills"] = [s.to_dict() for s in self.skills] + out["iconUrl"] = self.icon_url + if self.supports_authenticated_extended_card is not None: + out["supportsAuthenticatedExtendedCard"] = self.supports_authenticated_extended_card if self.signatures: out["signatures"] = [s.to_dict() for s in self.signatures] + if self.security: + out["security"] = self.security if self.security_schemes: - out["security_schemes"] = self.security_schemes - if self.security_requirements: - out["security_requirements"] = self.security_requirements + out["securitySchemes"] = self.security_schemes for k, v in self.extras.items(): out[k] = v return out @@ -268,33 +276,33 @@ def build_a2a_agent_card( url: str, skills: list[A2AAgentSkill], version: str = "1.0.0", + preferred_transport: str = _DEFAULT_BUILDER_TRANSPORT, + protocol_version: str = _PROTOCOL_VERSION, + additional_interfaces: list[A2AAgentInterface] | None = None, extensions: list[A2AAgentCardExtension] | None = None, streaming: bool | None = None, push_notifications: bool | None = None, - extended_agent_card: bool | None = None, + state_transition_history: bool | None = None, + supports_authenticated_extended_card: bool | None = None, provider: A2AAgentProvider | None = None, documentation_url: str | None = None, icon_url: str | None = None, signatures: list[A2AAgentCardSignature] | None = None, default_input_modes: list[str] | None = None, default_output_modes: list[str] | None = None, - protocol_binding: str = _DEFAULT_PROTOCOL_BINDING, - a2a_protocol_version: str = _PROTOCOL_VERSION, + security: list[dict[str, list[str]]] | None = None, security_schemes: dict[str, Any] | None = None, - security_requirements: list[Any] | None = None, extras: dict[str, Any] | None = None, ) -> A2AAgentCard: - """Compose an A2A v1.0 Agent Card body per the canonical proto. + """Compose an A2A v1.0 Agent Card body matching ``AgentCard`` from ``@a2a-js/sdk``. Returns the UNSIGNED card. To attach identity claims, sign the ``to_dict()`` - output as an RFC 7515 JWS (``AgentCardSignature``). Vendors can also add an - identity-flavored extension to ``capabilities.extensions[]``. + output as an RFC 7515 JWS (``A2AAgentCardSignature``). Vendors can also add + an identity-flavored extension to ``capabilities.extensions[]``. - The single ``url`` argument becomes the primary ``supported_interfaces[0].url`` - (with ``protocol_binding=HTTP+JSON``, ``protocol_version=1.0`` by default). - Override these via the ``protocol_binding`` and ``a2a_protocol_version`` kwargs, - or build ``A2AAgentInterface`` objects directly via the dataclass for multi-binding - agents (in which case construct the ``A2AAgentCard`` directly). + The ``url`` argument becomes the top-level ``AgentCard.url``; + ``preferred_transport`` declares the transport at that URL (default + ``HTTP+JSON``). For multi-binding agents, pass ``additional_interfaces``. Example:: @@ -329,36 +337,37 @@ def build_a2a_agent_card( ) raise ValueError(msg) capabilities = A2AAgentCardCapabilities( - streaming=streaming, - push_notifications=push_notifications, extensions=extensions or [], - extended_agent_card=extended_agent_card, - ) - interface = A2AAgentInterface( - url=url, - protocol_binding=protocol_binding, - protocol_version=a2a_protocol_version, + push_notifications=push_notifications, + state_transition_history=state_transition_history, + streaming=streaming, ) return A2AAgentCard( name=name, description=description, - supported_interfaces=[interface], + url=url, + protocol_version=protocol_version, version=version, capabilities=capabilities, default_input_modes=default_input_modes or [_DEFAULT_INPUT_MODE], default_output_modes=default_output_modes or [_DEFAULT_OUTPUT_MODE], skills=skills, + preferred_transport=preferred_transport, + additional_interfaces=additional_interfaces or [], provider=provider, documentation_url=documentation_url, icon_url=icon_url, + supports_authenticated_extended_card=supports_authenticated_extended_card, signatures=signatures or [], + security=security or [], security_schemes=security_schemes or {}, - security_requirements=security_requirements or [], extras=extras or {}, ) __all__ = [ + "A2A_DEFAULT_TRANSPORT", + "A2A_PROTOCOL_VERSION", "UCP_A2A_EXTENSION_URI", "A2AAgentCard", "A2AAgentCardCapabilities", diff --git a/agentscore_commerce/identity/aiohttp.py b/agentscore_commerce/identity/aiohttp.py index fcf14c0..8a89642 100644 --- a/agentscore_commerce/identity/aiohttp.py +++ b/agentscore_commerce/identity/aiohttp.py @@ -2,17 +2,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import httpx from agentscore_commerce.identity._denial import ( - FIXABLE_DENIAL_REASONS, - build_contact_support_next_steps, - build_signer_mismatch_body, denial_reason_status, is_fixable_denial, - verification_agent_instructions, ) from agentscore_commerce.identity._response import ( QUOTA_EXCEEDED_INSTRUCTIONS, @@ -59,22 +55,13 @@ def _mark_degraded_aiohttp(request: web.Request, infra_reason: str) -> None: __all__ = [ - "FIXABLE_DENIAL_REASONS", - "CreateSessionOnMissing", "agentscore_gate_middleware", - "build_contact_support_next_steps", - "build_signer_mismatch_body", "capture_wallet", - "denial_reason_status", - "denial_reason_to_body", - "extract_payment_signer", + "conditional_agentscore_gate_middleware", "get_agentscore_data", "get_gate_degraded_state", "get_gate_quota_info", "get_signer_verdict", - "is_fixable_denial", - "read_x402_payment_header", - "verification_agent_instructions", ] @@ -148,8 +135,13 @@ def agentscore_gate_middleware( user_agent: str | None = None, extract_identity: Callable[[web.Request], AgentIdentity | None] | None = None, extract_chain: Callable[[web.Request], str | None] | None = None, - on_denied: Callable[[web.Request, DenialReason], tuple[dict[str, Any], int]] | None = None, + on_denied: Callable[ + [web.Request, DenialReason], + tuple[dict[str, Any], int] | tuple[dict[str, Any], int, dict[str, str]], + ] + | None = None, create_session_on_missing: CreateSessionOnMissing | None = None, + condition: Callable[[web.Request], bool] | None = None, ) -> Callable[[web.Request, Callable[[web.Request], Awaitable[web.StreamResponse]]], Awaitable[web.StreamResponse]]: """Build an AIOHTTP middleware that gates requests on AgentScore trust. @@ -180,11 +172,21 @@ def agentscore_gate_middleware( _extract_chain = extract_chain or _default_extract_chain _on_denied = on_denied or _default_on_denied + def _deny_response(request: web.Request, reason: DenialReason) -> web.Response: + result = _on_denied(request, reason) + if len(result) == 3: + body, status, headers = cast("tuple[dict, int, dict[str, str]]", result) + return web.json_response(body, status=status, headers=headers) + body, status = cast("tuple[dict, int]", result) + return web.json_response(body, status=status) + @web.middleware async def _agentscore_middleware( request: web.Request, handler: Callable[[web.Request], Awaitable[web.StreamResponse]], ) -> web.StreamResponse: + if condition is not None and not condition(request): + return await handler(request) identity = _resolve_identity(request) # Stash state on the request dict so capture_wallet() can read operator_token + client # after the handler runs. @@ -204,10 +206,8 @@ async def _agentscore_middleware( request, ) if session_reason is not None: - body, status = _on_denied(request, session_reason) - return web.json_response(body, status=status) - body, status = _on_denied(request, build_missing_identity_reason()) - return web.json_response(body, status=status) + return _deny_response(request, session_reason) + return _deny_response(request, build_missing_identity_reason()) chain_override = _extract_chain(request) @@ -226,37 +226,30 @@ async def _agentscore_middleware( except PaymentRequiredError: if client.fail_open: return await handler(request) - body, status = _on_denied(request, DenialReason(code="payment_required")) - return web.json_response(body, status=status) + return _deny_response(request, DenialReason(code="payment_required")) except TokenDeniedError as err: - reason = build_token_denied_reason(err) - body, status = _on_denied(request, reason) - return web.json_response(body, status=status) + return _deny_response(request, build_token_denied_reason(err)) except InvalidCredentialError: # Permanent — no auto-session, agent should switch tokens or restart. - body, status = _on_denied(request, build_invalid_credential_reason()) - return web.json_response(body, status=status) + return _deny_response(request, build_invalid_credential_reason()) except QuotaExceededError: if client.fail_open: _mark_degraded_aiohttp(request, "quota_exceeded") return await handler(request) - body, status = _on_denied( + return _deny_response( request, DenialReason(code="api_error", agent_instructions=QUOTA_EXCEEDED_INSTRUCTIONS), ) - return web.json_response(body, status=status) except httpx.TimeoutException: if client.fail_open: _mark_degraded_aiohttp(request, "network_timeout") return await handler(request) - body, status = _on_denied(request, DenialReason(code="api_error")) - return web.json_response(body, status=status) + return _deny_response(request, DenialReason(code="api_error")) except Exception: if client.fail_open: _mark_degraded_aiohttp(request, "api_error") return await handler(request) - body, status = _on_denied(request, DenialReason(code="api_error")) - return web.json_response(body, status=status) + return _deny_response(request, DenialReason(code="api_error")) if result.allow: request["agentscore"] = result.raw @@ -281,17 +274,17 @@ async def _agentscore_middleware( request, ) if session_reason is not None: - body, status = _on_denied(request, session_reason) - return web.json_response(body, status=status) - - reason = DenialReason( - code="wallet_not_trusted", - decision=result.decision, - reasons=result.reasons, - verify_url=result.verify_url, + return _deny_response(request, session_reason) + + return _deny_response( + request, + DenialReason( + code="wallet_not_trusted", + decision=result.decision, + reasons=result.reasons, + verify_url=result.verify_url, + ), ) - body, status = _on_denied(request, reason) - return web.json_response(body, status=status) return _agentscore_middleware @@ -338,3 +331,17 @@ async def purchase(request): network, idempotency_key=idempotency_key, ) + + +def conditional_agentscore_gate_middleware(**kwargs: Any) -> Any: + """Build a conditional :func:`agentscore_gate_middleware`. + + Only fires the gate when a payment credential is attached. Discovery legs + flow through; settle legs trigger the full gate. + + Accepts the same kwargs as :func:`agentscore_gate_middleware`. + """ + from agentscore_commerce.payment.payment_header import has_payment_header + + kwargs["condition"] = has_payment_header + return agentscore_gate_middleware(**kwargs) diff --git a/agentscore_commerce/identity/default_denied.py b/agentscore_commerce/identity/default_denied.py new file mode 100644 index 0000000..353fc76 --- /dev/null +++ b/agentscore_commerce/identity/default_denied.py @@ -0,0 +1,146 @@ +"""Factory for the standard ``on_denied`` callback used by Checkout's gate. + +Replaces the ~100-line switch every consumer codebase (store, sayer-py, +martin-py) wrote by hand. + +The shape is framework-neutral (``{status, body, headers?}``) — matches +``Checkout``'s ``on_denied`` signature directly. For per-framework gate +middleware (``AgentScoreGate(...)``) the merchant adapts at the call site +with the framework's ``JSONResponse(body, status_code=status, headers=headers)`` +/ equivalent. + +Mirrors node-commerce ``src/identity/default_denied.ts``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from agentscore_commerce.identity._denial import ( + build_contact_support_next_steps, + build_signer_mismatch_body, +) +from agentscore_commerce.identity._response import denial_reason_to_body +from agentscore_commerce.identity.types import DenialReason, VerifyWalletSignerResult + +if TYPE_CHECKING: + from collections.abc import Callable + + +@dataclass +class DefaultOnDeniedResult: + """Framework-neutral denial response shape.""" + + status: int + body: dict[str, Any] + headers: dict[str, str] | None = None + + +def create_default_on_denied( + *, + merchant_name: str, + support_email: str, + support_context: str | None = None, + payment_required_message: str | None = None, + wallet_not_trusted_message: str | None = None, +) -> Callable[[DenialReason], DefaultOnDeniedResult]: + """Build the canonical ``on_denied(reason)`` callback. + + Returns a framework-neutral ``DefaultOnDeniedResult(status, body, headers?)`` + matching ``Checkout``'s ``on_denied`` signature. + + Branch table (matches the hand-rolled version in every consumer): + + - ``wallet_signer_mismatch`` / ``wallet_auth_requires_wallet_signing`` → + ``build_signer_mismatch_body(...)``, status 403 + - ``wallet_not_trusted`` → custom ``compliance_denied`` body + contact-support + ``next_steps``, status 403 + - ``payment_required`` → denial body + ``compliance_error`` message, status 403 + - ``token_expired`` / ``invalid_credential`` → 401 + - ``api_error`` → 503 + ``Cache-Control: no-store`` + - default → 403 + """ + final_support_context = support_context or "Contact support if you believe this denial is in error." + final_payment_required_message = ( + payment_required_message or "AgentScore tier does not support assess. Contact support." + ) + final_wallet_not_trusted_message = ( + wallet_not_trusted_message or f"Identity check did not satisfy policy for {merchant_name}." + ) + + def _on_denied(reason: DenialReason) -> DefaultOnDeniedResult: + if reason.code in ("wallet_signer_mismatch", "wallet_auth_requires_wallet_signing"): + verdict = VerifyWalletSignerResult( + kind=reason.code, + claimed_operator=reason.claimed_operator, + actual_signer_operator=reason.actual_signer_operator, + expected_signer=reason.expected_signer or "", + actual_signer=reason.actual_signer or "", + linked_wallets=list(reason.linked_wallets or []), + claimed_wallet=reason.expected_signer or "", + ) + body = build_signer_mismatch_body(verdict) + return DefaultOnDeniedResult( + status=403, + body=body or denial_reason_to_body(reason), + ) + + if reason.code == "wallet_not_trusted": + policy_result = reason.extra.get("policy_result") if reason.extra else None + return DefaultOnDeniedResult( + status=403, + body={ + "error": {"code": "compliance_denied", "message": final_wallet_not_trusted_message}, + "reasons": list(reason.reasons or []), + "policy_result": policy_result, + "verify_url": reason.verify_url, + "next_steps": build_contact_support_next_steps(support_email, final_support_context), + }, + ) + + if reason.code == "payment_required": + body = denial_reason_to_body(reason) + body["error"] = {"code": "compliance_error", "message": final_payment_required_message} + return DefaultOnDeniedResult(status=403, body=body) + + if reason.code in ("token_expired", "invalid_credential"): + return DefaultOnDeniedResult(status=401, body=denial_reason_to_body(reason)) + if reason.code == "api_error": + return DefaultOnDeniedResult( + status=503, + body=denial_reason_to_body(reason), + headers={"Cache-Control": "no-store"}, + ) + return DefaultOnDeniedResult(status=403, body=denial_reason_to_body(reason)) + + return _on_denied + + +def default_read_only_on_denied(reason: DenialReason) -> DefaultOnDeniedResult: + """Canonical ``on_denied`` for read-only resource gates (e.g. ``GET /orders/:id``). + + Collapses every denial code to **401 ``unauthorized``** while still spreading + :func:`denial_reason_to_body` so ``agent_instructions`` / ``verify_url`` / + session-mint fields ride through for the agent's recovery path. Stamps + ``Cache-Control: no-store`` because RFC 7234 makes 4xx responses + heuristically cacheable; transient denials (``api_error``, ``token_expired``) + must not be replayed by a shared cache. + + Pair with ``AgentScoreGate(on_denied=default_read_only_on_denied)`` on + routes where the resource owner is the only authorized identity (full + compliance policy already ran at ``/purchase`` time; the read-back leg + only needs presence-of-valid-credential). + """ + message = ( + "X-Wallet-Address or X-Operator-Token header required" + if reason.code == "missing_identity" + else "Invalid identity" + ) + body = denial_reason_to_body(reason) + body["error"] = {"code": "unauthorized", "message": message} + return DefaultOnDeniedResult( + status=401, + body=body, + headers={"Cache-Control": "no-store"}, + ) diff --git a/agentscore_commerce/identity/django.py b/agentscore_commerce/identity/django.py index 69e036a..3404cd8 100644 --- a/agentscore_commerce/identity/django.py +++ b/agentscore_commerce/identity/django.py @@ -8,12 +8,8 @@ from django.http import HttpRequest, JsonResponse from agentscore_commerce.identity._denial import ( - FIXABLE_DENIAL_REASONS, - build_contact_support_next_steps, - build_signer_mismatch_body, denial_reason_status, is_fixable_denial, - verification_agent_instructions, ) from agentscore_commerce.identity._response import ( QUOTA_EXCEEDED_INSTRUCTIONS, @@ -55,21 +51,13 @@ def _mark_degraded_django(request: HttpRequest, infra_reason: str) -> None: ASSESS_STATE_KEY = "agentscore" __all__ = [ - "FIXABLE_DENIAL_REASONS", "AgentScoreMiddleware", - "build_contact_support_next_steps", - "build_signer_mismatch_body", + "ConditionalAgentScoreMiddleware", "capture_wallet", - "denial_reason_status", - "denial_reason_to_body", - "extract_payment_signer", "get_agentscore_data", "get_gate_degraded_state", "get_gate_quota_info", "get_signer_verdict", - "is_fixable_denial", - "read_x402_payment_header", - "verification_agent_instructions", ] @@ -148,6 +136,7 @@ def __init__(self, get_response: Any) -> None: self._create_session_on_missing: CreateSessionOnMissing | None = config.get( "create_session_on_missing", ) + self._condition = config.get("condition") self.get_response = get_response @staticmethod @@ -173,6 +162,8 @@ def _default_on_denied(_request: HttpRequest, reason: DenialReason) -> JsonRespo def __call__(self, request: HttpRequest) -> Any: """Process the request.""" + if self._condition is not None and not self._condition(request): + return self.get_response(request) identity = self._extract_identity(request) # Stash state so capture_wallet() can read operator_token + client after the view runs. @@ -318,3 +309,20 @@ def purchase(request): network, idempotency_key=idempotency_key, ) + + +class ConditionalAgentScoreMiddleware(AgentScoreMiddleware): + """Django middleware variant that only fires when payment headers are attached. + + Discovery legs flow through; settle legs trigger the full gate. + + Settings shape is identical to :class:`AgentScoreMiddleware` — the + ``AGENTSCORE_GATE`` dict's ``condition`` key is overwritten with the + payment-header check. + """ + + def __init__(self, get_response: Any) -> None: + from agentscore_commerce.payment.payment_header import has_payment_header + + super().__init__(get_response) + self._condition = has_payment_header diff --git a/agentscore_commerce/identity/fastapi.py b/agentscore_commerce/identity/fastapi.py index 8585b3a..b5da72b 100644 --- a/agentscore_commerce/identity/fastapi.py +++ b/agentscore_commerce/identity/fastapi.py @@ -8,18 +8,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NoReturn +from typing import TYPE_CHECKING, Any, NoReturn, cast import httpx from starlette.requests import Request # noqa: TC002 - runtime import required for FastAPI DI from agentscore_commerce.identity._denial import ( - FIXABLE_DENIAL_REASONS, - build_contact_support_next_steps, - build_signer_mismatch_body, denial_reason_status, is_fixable_denial, - verification_agent_instructions, ) from agentscore_commerce.identity._response import ( QUOTA_EXCEEDED_INSTRUCTIONS, @@ -100,22 +96,13 @@ def get_gate_quota_info(request: Request) -> GateQuotaInfo | None: __all__ = [ - "FIXABLE_DENIAL_REASONS", "AgentScoreGate", - "CreateSessionOnMissing", - "build_contact_support_next_steps", - "build_signer_mismatch_body", + "ConditionalAgentScoreGate", "capture_wallet", - "denial_reason_status", - "denial_reason_to_body", - "extract_payment_signer", "get_agentscore_data", "get_gate_degraded_state", "get_gate_quota_info", "get_signer_verdict", - "is_fixable_denial", - "read_x402_payment_header", - "verification_agent_instructions", ] @@ -178,7 +165,11 @@ def __init__( user_agent: str | None = None, extract_identity: Callable[[Request], AgentIdentity | None] | None = None, extract_chain: Callable[[Request], str | None] | None = None, - on_denied: Callable[[Request, DenialReason], tuple[dict[str, Any], int]] | None = None, + on_denied: Callable[ + [Request, DenialReason], + tuple[dict[str, Any], int] | tuple[dict[str, Any], int, dict[str, str]], + ] + | None = None, create_session_on_missing: CreateSessionOnMissing | None = None, ) -> None: self._client = AgentScoreCore( @@ -202,11 +193,16 @@ def __init__( def _deny(self, request: Request, reason: DenialReason) -> NoReturn: from fastapi import HTTPException + headers: dict[str, str] | None = None if self._on_denied is not None: - body, status = self._on_denied(request, reason) + result = self._on_denied(request, reason) + if len(result) == 3: + body, status, headers = cast("tuple[dict, int, dict[str, str]]", result) + else: + body, status = cast("tuple[dict, int]", result) else: body, status = _build_denial_body(reason), denial_reason_status(reason) - raise HTTPException(status_code=status, detail=body) + raise HTTPException(status_code=status, detail=body, headers=headers) async def __call__(self, request: Request) -> None: identity = self._extract_identity(request) @@ -371,3 +367,34 @@ async def purchase(request: Request, assess = Depends(get_agentscore_data)): network, idempotency_key=idempotency_key, ) + + +class ConditionalAgentScoreGate: + """Wrap :class:`AgentScoreGate` to fire only on settle legs. + + Discovery legs (no ``payment-signature`` / ``x-payment`` / + ``Authorization: Payment``) flow through to the handler unauthenticated; + settle legs trigger the full gate. + + Use this for routes that should support anonymous discovery — the 402 + emit path advertises all rails to any x402 wallet, and identity is + verified at settle time on the retry leg. + + Example:: + + gate = ConditionalAgentScoreGate(api_key=..., require_kyc=True) + + @app.post("/purchase", dependencies=[Depends(gate)]) + async def purchase(request: Request): ... + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + from agentscore_commerce.payment.payment_header import has_payment_header + + self._inner = AgentScoreGate(*args, **kwargs) + self._has_payment_header = has_payment_header + + async def __call__(self, request: Request) -> None: + if not self._has_payment_header(request): + return + await self._inner(request) diff --git a/agentscore_commerce/identity/flask.py b/agentscore_commerce/identity/flask.py index be0aba5..cec55f2 100644 --- a/agentscore_commerce/identity/flask.py +++ b/agentscore_commerce/identity/flask.py @@ -2,17 +2,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import httpx from agentscore_commerce.identity._denial import ( - FIXABLE_DENIAL_REASONS, - build_contact_support_next_steps, - build_signer_mismatch_body, denial_reason_status, is_fixable_denial, - verification_agent_instructions, ) from agentscore_commerce.identity._response import ( QUOTA_EXCEEDED_INSTRUCTIONS, @@ -53,21 +49,13 @@ ASSESS_STATE_KEY = "agentscore" __all__ = [ - "FIXABLE_DENIAL_REASONS", "agentscore_gate", - "build_contact_support_next_steps", - "build_signer_mismatch_body", "capture_wallet", - "denial_reason_status", - "denial_reason_to_body", - "extract_payment_signer", + "conditional_agentscore_gate", "get_agentscore_data", "get_gate_degraded_state", "get_gate_quota_info", "get_signer_verdict", - "is_fixable_denial", - "read_x402_payment_header", - "verification_agent_instructions", ] @@ -151,8 +139,13 @@ def agentscore_gate( user_agent: str | None = None, extract_identity: Callable[[Request], AgentIdentity | None] | None = None, extract_chain: Callable[[Request], str | None] | None = None, - on_denied: Callable[[Request, DenialReason], tuple[dict[str, Any], int]] | None = None, + on_denied: Callable[ + [Request, DenialReason], + tuple[dict[str, Any], int] | tuple[dict[str, Any], int, dict[str, str]], + ] + | None = None, create_session_on_missing: CreateSessionOnMissing | None = None, + condition: Callable[[Request], bool] | None = None, ) -> None: """Register AgentScore gate as a Flask before_request handler. @@ -185,12 +178,19 @@ def agentscore_gate( _on_denied = on_denied or _default_on_denied def _deny(reason: DenialReason) -> tuple[Response, int]: - try: - body, status = _on_denied(flask_request, reason) - except (TypeError, ValueError) as exc: - msg = "on_denied must return a (dict, int) tuple, e.g. ({'error': 'denied'}, 403)" - raise TypeError(msg) from exc - return jsonify(body), status + result = _on_denied(flask_request, reason) + if not isinstance(result, tuple) or len(result) not in (2, 3): + msg = "on_denied must return a (dict, int) or (dict, int, dict) tuple, e.g. ({'error': 'denied'}, 403)" + raise TypeError(msg) + headers: dict[str, str] = {} + if len(result) == 3: + body, status, headers = cast("tuple[dict, int, dict[str, str]]", result) + else: + body, status = cast("tuple[dict, int]", result) + response = jsonify(body) + for k, v in headers.items(): + response.headers[k] = v + return response, status def _mark_degraded(infra_reason: str) -> None: """Stamp the gate state on ``g._agentscore_gate`` as fail-open'd.""" @@ -198,6 +198,8 @@ def _mark_degraded(infra_reason: str) -> None: @app.before_request def _agentscore_check() -> Response | tuple[Response, int] | None: + if condition is not None and not condition(flask_request): + return None identity = _resolve_identity(flask_request) # Stash state so capture_wallet() can look up operator_token + client after the handler. g._agentscore_gate = { @@ -348,3 +350,19 @@ def purchase(): network, idempotency_key=idempotency_key, ) + + +def conditional_agentscore_gate(app: Flask, **kwargs: Any) -> None: + """Register :func:`agentscore_gate` to fire only on settle legs. + + Discovery legs (no ``payment-signature`` / ``x-payment`` / + ``Authorization: Payment``) flow through to the route handler + unauthenticated; settle legs trigger the full gate. + + Accepts the same kwargs as :func:`agentscore_gate`; any ``condition`` kwarg + passed in is replaced with the payment-header check. + """ + from agentscore_commerce.payment.payment_header import has_payment_header + + kwargs["condition"] = has_payment_header + agentscore_gate(app, **kwargs) diff --git a/agentscore_commerce/identity/middleware.py b/agentscore_commerce/identity/middleware.py index 2f41cc2..34adc2f 100644 --- a/agentscore_commerce/identity/middleware.py +++ b/agentscore_commerce/identity/middleware.py @@ -9,12 +9,8 @@ from starlette.responses import JSONResponse from agentscore_commerce.identity._denial import ( - FIXABLE_DENIAL_REASONS, - build_contact_support_next_steps, - build_signer_mismatch_body, denial_reason_status, is_fixable_denial, - verification_agent_instructions, ) from agentscore_commerce.identity._response import ( QUOTA_EXCEEDED_INSTRUCTIONS, @@ -61,22 +57,13 @@ def _mark_degraded_asgi(scope: Scope, infra_reason: str) -> None: __all__ = [ - "FIXABLE_DENIAL_REASONS", "AgentScoreGate", - "CreateSessionOnMissing", - "build_contact_support_next_steps", - "build_signer_mismatch_body", + "ConditionalAgentScoreGate", "capture_wallet", - "denial_reason_status", - "denial_reason_to_body", - "extract_payment_signer", "get_agentscore_data", "get_gate_degraded_state", "get_gate_quota_info", "get_signer_verdict", - "is_fixable_denial", - "read_x402_payment_header", - "verification_agent_instructions", ] @@ -163,8 +150,10 @@ def __init__( extract_chain: Callable[[Request], str | None] | None = None, on_denied: Callable[[Request, DenialReason], Awaitable[JSONResponse]] | None = None, create_session_on_missing: CreateSessionOnMissing | None = None, + condition: Callable[[Request], bool] | None = None, ) -> None: self.app = app + self._condition = condition self._client = AgentScoreCore( api_key=api_key, require_kyc=require_kyc, @@ -191,6 +180,10 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: request = Request(scope, receive, send) + if self._condition is not None and not self._condition(request): + await self.app(scope, receive, send) + return + identity = self._extract_identity(request) # Stash state for capture_wallet() helper to read after the handler runs. scope.setdefault("state", {}) @@ -372,3 +365,20 @@ async def purchase(request: Request): network, idempotency_key=idempotency_key, ) + + +class ConditionalAgentScoreGate(AgentScoreGate): + """ASGI middleware variant of :class:`AgentScoreGate` that fires only on settle legs. + + Discovery legs flow through to the downstream handler unauthenticated; + settle legs trigger the full gate. + + Accepts the same kwargs as :class:`AgentScoreGate`; any ``condition`` kwarg + is replaced with the payment-header check. + """ + + def __init__(self, app: Any, **kwargs: Any) -> None: + from agentscore_commerce.payment.payment_header import has_payment_header + + kwargs["condition"] = has_payment_header + super().__init__(app, **kwargs) diff --git a/agentscore_commerce/identity/sanic.py b/agentscore_commerce/identity/sanic.py index f43ed7a..f443fdc 100644 --- a/agentscore_commerce/identity/sanic.py +++ b/agentscore_commerce/identity/sanic.py @@ -2,17 +2,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import httpx from agentscore_commerce.identity._denial import ( - FIXABLE_DENIAL_REASONS, - build_contact_support_next_steps, - build_signer_mismatch_body, denial_reason_status, is_fixable_denial, - verification_agent_instructions, ) from agentscore_commerce.identity._response import ( QUOTA_EXCEEDED_INSTRUCTIONS, @@ -59,22 +55,13 @@ def _mark_degraded_sanic(request: Request, infra_reason: str) -> None: __all__ = [ - "FIXABLE_DENIAL_REASONS", - "CreateSessionOnMissing", "agentscore_gate", - "build_contact_support_next_steps", - "build_signer_mismatch_body", "capture_wallet", - "denial_reason_status", - "denial_reason_to_body", - "extract_payment_signer", + "conditional_agentscore_gate", "get_agentscore_data", "get_gate_degraded_state", "get_gate_quota_info", "get_signer_verdict", - "is_fixable_denial", - "read_x402_payment_header", - "verification_agent_instructions", ] @@ -149,8 +136,13 @@ def agentscore_gate( user_agent: str | None = None, extract_identity: Callable[[Request], AgentIdentity | None] | None = None, extract_chain: Callable[[Request], str | None] | None = None, - on_denied: Callable[[Request, DenialReason], tuple[dict[str, Any], int]] | None = None, + on_denied: Callable[ + [Request, DenialReason], + tuple[dict[str, Any], int] | tuple[dict[str, Any], int, dict[str, str]], + ] + | None = None, create_session_on_missing: CreateSessionOnMissing | None = None, + condition: Callable[[Request], bool] | None = None, ) -> None: """Register AgentScore gate as a Sanic request middleware. @@ -181,8 +173,18 @@ def agentscore_gate( _extract_chain = extract_chain or _default_extract_chain _on_denied = on_denied or _default_on_denied + def _deny_response(request: Request, reason: DenialReason) -> HTTPResponse: + result = _on_denied(request, reason) + if len(result) == 3: + body, status, headers = cast("tuple[dict, int, dict[str, str]]", result) + return response.json(body, status=status, headers=headers) + body, status = cast("tuple[dict, int]", result) + return response.json(body, status=status) + @app.middleware("request") async def _agentscore_check(request: Request) -> HTTPResponse | None: + if condition is not None and not condition(request): + return None identity = _resolve_identity(request) # Stash state on request.ctx so capture_wallet() can look up operator_token + client # after the handler runs. @@ -206,10 +208,8 @@ async def _agentscore_check(request: Request) -> HTTPResponse | None: request, ) if session_reason is not None: - body, status = _on_denied(request, session_reason) - return response.json(body, status=status) - body, status = _on_denied(request, build_missing_identity_reason()) - return response.json(body, status=status) + return _deny_response(request, session_reason) + return _deny_response(request, build_missing_identity_reason()) chain_override = _extract_chain(request) @@ -246,51 +246,44 @@ async def _agentscore_check(request: Request) -> HTTPResponse | None: request, ) if session_reason is not None: - body, status = _on_denied(request, session_reason) - return response.json(body, status=status) - - reason = DenialReason( - code="wallet_not_trusted", - decision=result.decision, - reasons=result.reasons, - verify_url=result.verify_url, + return _deny_response(request, session_reason) + + return _deny_response( + request, + DenialReason( + code="wallet_not_trusted", + decision=result.decision, + reasons=result.reasons, + verify_url=result.verify_url, + ), ) - body, status = _on_denied(request, reason) - return response.json(body, status=status) except PaymentRequiredError: if client.fail_open: return None - body, status = _on_denied(request, DenialReason(code="payment_required")) - return response.json(body, status=status) + return _deny_response(request, DenialReason(code="payment_required")) except TokenDeniedError as err: - reason = build_token_denied_reason(err) - body, status = _on_denied(request, reason) - return response.json(body, status=status) + return _deny_response(request, build_token_denied_reason(err)) except InvalidCredentialError: # Permanent — no auto-session, agent should switch tokens or restart. - body, status = _on_denied(request, build_invalid_credential_reason()) - return response.json(body, status=status) + return _deny_response(request, build_invalid_credential_reason()) except QuotaExceededError: if client.fail_open: _mark_degraded_sanic(request, "quota_exceeded") return None - body, status = _on_denied( + return _deny_response( request, DenialReason(code="api_error", agent_instructions=QUOTA_EXCEEDED_INSTRUCTIONS), ) - return response.json(body, status=status) except httpx.TimeoutException: if client.fail_open: _mark_degraded_sanic(request, "network_timeout") return None - body, status = _on_denied(request, DenialReason(code="api_error")) - return response.json(body, status=status) + return _deny_response(request, DenialReason(code="api_error")) except Exception: if client.fail_open: _mark_degraded_sanic(request, "api_error") return None - body, status = _on_denied(request, DenialReason(code="api_error")) - return response.json(body, status=status) + return _deny_response(request, DenialReason(code="api_error")) def get_signer_verdict(request: Request) -> SignerVerdict | None: @@ -336,3 +329,15 @@ async def purchase(request): network, idempotency_key=idempotency_key, ) + + +def conditional_agentscore_gate(app: Sanic, **kwargs: Any) -> None: + """Register :func:`agentscore_gate` to fire only on settle legs. + + Discovery legs flow through to the handler unauthenticated; settle legs + trigger the full gate. + """ + from agentscore_commerce.payment.payment_header import has_payment_header + + kwargs["condition"] = has_payment_header + agentscore_gate(app, **kwargs) diff --git a/agentscore_commerce/identity/tokens.py b/agentscore_commerce/identity/tokens.py index 4b57e60..e8f7bf8 100644 --- a/agentscore_commerce/identity/tokens.py +++ b/agentscore_commerce/identity/tokens.py @@ -1,13 +1,18 @@ -"""Operator-token hashing. +"""Operator-token hashing + owner-scope extraction. Plaintext operator tokens (``opc_...``) never persist on disk. Merchants hash them before storing in DB columns and before comparing against persisted hashes. -This helper exposes the canonical hash so every consumer agrees on the shape. +This module exposes the canonical hash so every consumer agrees on the shape, +plus :func:`extract_owner_scope` for owner-scoped resource lookups. """ from __future__ import annotations import hashlib +from dataclasses import dataclass +from typing import Any + +from agentscore_commerce.payment.payment_header import _read_header, _unwrap_headers def hash_operator_token(plaintext: str) -> str: @@ -18,3 +23,31 @@ def hash_operator_token(plaintext: str) -> str: durable storage. """ return hashlib.sha256(plaintext.encode("utf-8")).hexdigest() + + +@dataclass(frozen=True) +class OwnerScope: + """Canonical owner identity for a caller-scoped resource lookup.""" + + wallet_address: str | None = None + operator_token_hash: str | None = None + + +def extract_owner_scope(request_or_headers: Any) -> OwnerScope: + """Pull the canonical owner identity from request headers. + + Reads ``X-Wallet-Address`` and ``X-Operator-Token``; returns the wallet + address verbatim and the sha256 hash of the token. Either or both may be + ``None``. + + Use at owner-scoped resource queries (``GET /orders/:id``, ...) so + persistence + lookup agree on the hashed column shape and plaintext tokens + never leave the request. + """ + headers = _unwrap_headers(request_or_headers) + wallet_address = _read_header(headers, "x-wallet-address") + operator_token = _read_header(headers, "x-operator-token") + return OwnerScope( + wallet_address=wallet_address if wallet_address else None, + operator_token_hash=hash_operator_token(operator_token) if operator_token else None, + ) diff --git a/agentscore_commerce/middleware/__init__.py b/agentscore_commerce/middleware/__init__.py new file mode 100644 index 0000000..87997be --- /dev/null +++ b/agentscore_commerce/middleware/__init__.py @@ -0,0 +1,7 @@ +"""Framework-specific middleware helpers (rate-limit and friends). + +Import per-framework: ``from agentscore_commerce.middleware.fastapi import rate_limit_fastapi``. +Each adapter shares the framework-agnostic ``create_rate_limiter`` core so multiple +adapter instances in the same process don't share state unless they're pointed at the +same Redis with the same ``key_prefix``. +""" diff --git a/agentscore_commerce/middleware/_core.py b/agentscore_commerce/middleware/_core.py new file mode 100644 index 0000000..bced13f --- /dev/null +++ b/agentscore_commerce/middleware/_core.py @@ -0,0 +1,114 @@ +"""Framework-agnostic rate-limit core. + +Mirrors ``@agent-score/commerce/middleware/_core``. Per-framework adapters +(`fastapi`, `flask`, `django`, `aiohttp`, `sanic`, `asgi`) wrap a shared +:class:`RateLimiter` so adapter selection is framework-mechanics, not policy. +""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass +from typing import Any, Protocol + +logger = logging.getLogger("agentscore_commerce.middleware.rate_limit") + + +class _RedisLike(Protocol): + """Subset of ``redis.asyncio.Redis`` we use; structurally typed so ``redis`` stays optional.""" + + async def incr(self, key: str) -> int: ... + async def expire(self, key: str, seconds: int) -> Any: ... + + +@dataclass(frozen=True) +class RateLimitDecision: + """Result of a single ``RateLimiter.check`` call.""" + + allowed: bool + remaining: int + limit: int + + +@dataclass +class RateLimiter: + """Async rate-limiter handle returned by :func:`create_rate_limiter`.""" + + check: Any # async (key: str) -> RateLimitDecision + + +RATE_LIMIT_JSON_BODY: dict[str, Any] = { + "error": {"code": "rate_limited", "message": "Too many requests"}, +} + + +def default_key_from_forwarded_for(forwarded_for: str | None) -> str: + """Default bucket key: first hop of ``x-forwarded-for``, else ``'unknown'``.""" + if not forwarded_for: + return "unknown" + first = forwarded_for.split(",", 1)[0].strip() + return first or "unknown" + + +def create_rate_limiter( + *, + window_seconds: int = 60, + max_requests: int = 60, + redis_url: str | None = None, + key_prefix: str = "rl:", +) -> RateLimiter: + """Build a rate-limiter. Each call owns its own memory map + Redis connection. + + Lazy-imports ``redis.asyncio`` when ``redis_url`` is set; falls back to an + in-process ``dict`` when the URL is omitted, the lazy import fails, or any + Redis call raises. + """ + redis_client: _RedisLike | None = None + mem_state: dict[str, tuple[int, float]] = {} # key -> (count, reset_at_monotonic) + + async def _get_redis() -> _RedisLike | None: + nonlocal redis_client + if not redis_url: + return None + if redis_client is not None: + return redis_client + from importlib import import_module + + try: + redis_asyncio: Any = import_module("redis.asyncio") + except ImportError: + logger.error( + "[rate-limit] redis_url set but `redis` is not installed. Run `pip install redis` or unset redis_url.", + ) + return None + redis_client = redis_asyncio.from_url(redis_url) + return redis_client + + def _check_mem(key: str) -> RateLimitDecision: + now = time.monotonic() + entry = mem_state.get(key) + if entry is None or entry[1] < now: + mem_state[key] = (1, now + window_seconds) + return RateLimitDecision(allowed=True, remaining=max_requests - 1, limit=max_requests) + count, reset_at = entry + count += 1 + mem_state[key] = (count, reset_at) + remaining = max(0, max_requests - count) + return RateLimitDecision(allowed=count <= max_requests, remaining=remaining, limit=max_requests) + + async def check(key: str) -> RateLimitDecision: + r = await _get_redis() + if r is None: + return _check_mem(key) + try: + full_key = f"{key_prefix}{key}" + count = await r.incr(full_key) + if count == 1: + await r.expire(full_key, window_seconds) + remaining = max(0, max_requests - count) + return RateLimitDecision(allowed=count <= max_requests, remaining=remaining, limit=max_requests) + except Exception: + return _check_mem(key) + + return RateLimiter(check=check) diff --git a/agentscore_commerce/middleware/aiohttp.py b/agentscore_commerce/middleware/aiohttp.py new file mode 100644 index 0000000..b6ef44c --- /dev/null +++ b/agentscore_commerce/middleware/aiohttp.py @@ -0,0 +1,64 @@ +"""aiohttp rate-limit middleware. + +Usage:: + + from aiohttp import web + from agentscore_commerce.middleware.aiohttp import rate_limit_aiohttp + + app = web.Application(middlewares=[rate_limit_aiohttp(max_requests=60, window_seconds=60)]) +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from agentscore_commerce.middleware._core import ( + RATE_LIMIT_JSON_BODY, + create_rate_limiter, + default_key_from_forwarded_for, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + from aiohttp import web + + +def rate_limit_aiohttp( + *, + window_seconds: int = 60, + max_requests: int = 60, + key_resolver: Callable[[web.Request], str] | None = None, + redis_url: str | None = None, + key_prefix: str = "rl:", +) -> Any: + """Return an aiohttp middleware enforcing the shared rate-limit core.""" + from aiohttp import web as _web + + limiter = create_rate_limiter( + window_seconds=window_seconds, + max_requests=max_requests, + redis_url=redis_url, + key_prefix=key_prefix, + ) + resolver = key_resolver or (lambda r: default_key_from_forwarded_for(r.headers.get("X-Forwarded-For"))) + + @_web.middleware + async def middleware(request: _web.Request, handler: Callable[[_web.Request], Any]) -> _web.StreamResponse: + decision = await limiter.check(resolver(request)) + if not decision.allowed: + return _web.json_response( + RATE_LIMIT_JSON_BODY, + status=429, + headers={ + "Cache-Control": "no-store", + "X-RateLimit-Limit": str(decision.limit), + "X-RateLimit-Remaining": str(decision.remaining), + }, + ) + response = await handler(request) + response.headers["X-RateLimit-Limit"] = str(decision.limit) + response.headers["X-RateLimit-Remaining"] = str(decision.remaining) + return response + + return middleware diff --git a/agentscore_commerce/middleware/asgi.py b/agentscore_commerce/middleware/asgi.py new file mode 100644 index 0000000..6bef59f --- /dev/null +++ b/agentscore_commerce/middleware/asgi.py @@ -0,0 +1,97 @@ +"""Generic ASGI rate-limit middleware. + +Works with any starlette-compatible app (FastAPI, Starlette, Sanic-on-asgi, etc.). +Mount with ``app.add_middleware(RateLimitMiddleware, max_requests=60, window_seconds=60)``. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +from agentscore_commerce.middleware._core import ( + RATE_LIMIT_JSON_BODY, + create_rate_limiter, + default_key_from_forwarded_for, +) + +if TYPE_CHECKING: + from collections.abc import Callable, MutableMapping + + from starlette.types import ASGIApp, Receive, Scope, Send + + +class RateLimitMiddleware: + """ASGI rate-limit middleware (60 req / 60 s / IP by default). + + Constructor args (all keyword): + * ``window_seconds`` — bucket size in seconds (default 60). + * ``max_requests`` — max requests per bucket (default 60). + * ``key_resolver`` — ``(scope) -> str`` override. Default = first hop of ``x-forwarded-for``. + * ``redis_url`` — when set, lazy-imports ``redis.asyncio``; otherwise in-memory. + * ``key_prefix`` — Redis key prefix (default ``'rl:'``). + """ + + def __init__( + self, + app: ASGIApp, + *, + window_seconds: int = 60, + max_requests: int = 60, + key_resolver: Callable[[MutableMapping[str, Any]], str] | None = None, + redis_url: str | None = None, + key_prefix: str = "rl:", + ) -> None: + self.app = app + self._limiter = create_rate_limiter( + window_seconds=window_seconds, + max_requests=max_requests, + redis_url=redis_url, + key_prefix=key_prefix, + ) + self._key_resolver = key_resolver or _default_scope_key_resolver + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + decision = await self._limiter.check(self._key_resolver(scope)) + + if decision.allowed: + + async def send_with_headers(message: MutableMapping[str, Any]) -> None: + if message["type"] == "http.response.start": + headers = list(message.get("headers", [])) + headers.append((b"x-ratelimit-limit", str(decision.limit).encode())) + headers.append((b"x-ratelimit-remaining", str(decision.remaining).encode())) + message = {**message, "headers": headers} + await send(message) + + await self.app(scope, receive, send_with_headers) + return + + body = json.dumps(RATE_LIMIT_JSON_BODY).encode() + await send( + { + "type": "http.response.start", + "status": 429, + "headers": [ + (b"content-type", b"application/json"), + (b"cache-control", b"no-store"), + (b"x-ratelimit-limit", str(decision.limit).encode()), + (b"x-ratelimit-remaining", str(decision.remaining).encode()), + ], + }, + ) + await send({"type": "http.response.body", "body": body}) + + +def _default_scope_key_resolver(scope: MutableMapping[str, Any]) -> str: + for name, value in scope.get("headers", []): + if name == b"x-forwarded-for": + return default_key_from_forwarded_for(value.decode(errors="replace")) + client = scope.get("client") + if client and isinstance(client, (list, tuple)) and client: + return str(client[0]) + return "unknown" diff --git a/agentscore_commerce/middleware/django.py b/agentscore_commerce/middleware/django.py new file mode 100644 index 0000000..7a78f02 --- /dev/null +++ b/agentscore_commerce/middleware/django.py @@ -0,0 +1,69 @@ +"""Django rate-limit middleware. + +Async middleware class compatible with Django 4+. Install in ``MIDDLEWARE`` or +construct via the factory so multiple instances don't share buckets:: + + # settings.py + MIDDLEWARE = [ + "agentscore_commerce.middleware.django.RateLimitMiddleware", + # ... + ] + + # optional: override defaults via env + AGENTSCORE_RATE_LIMIT = {"max_requests": 60, "window_seconds": 60} +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from agentscore_commerce.middleware._core import ( + RATE_LIMIT_JSON_BODY, + create_rate_limiter, + default_key_from_forwarded_for, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + from django.http import HttpRequest, HttpResponse + + +class RateLimitMiddleware: + """Async Django middleware enforcing the shared rate-limit core.""" + + async_capable = True + sync_capable = False + + def __init__(self, get_response: Callable[[HttpRequest], Any]) -> None: + self.get_response = get_response + try: + from django.conf import settings # local import: django is an optional peer dep + + cfg: dict[str, Any] = getattr(settings, "AGENTSCORE_RATE_LIMIT", {}) or {} + except Exception: + cfg = {} + self._limiter = create_rate_limiter( + window_seconds=cfg.get("window_seconds", 60), + max_requests=cfg.get("max_requests", 60), + redis_url=cfg.get("redis_url"), + key_prefix=cfg.get("key_prefix", "rl:"), + ) + + async def __call__(self, request: HttpRequest) -> HttpResponse: + from django.http import JsonResponse + + key = default_key_from_forwarded_for(request.META.get("HTTP_X_FORWARDED_FOR")) + decision = await self._limiter.check(key) + + if not decision.allowed: + resp = JsonResponse(RATE_LIMIT_JSON_BODY, status=429) + resp["Cache-Control"] = "no-store" + resp["X-RateLimit-Limit"] = str(decision.limit) + resp["X-RateLimit-Remaining"] = str(decision.remaining) + return resp + + response = await self.get_response(request) + response["X-RateLimit-Limit"] = str(decision.limit) + response["X-RateLimit-Remaining"] = str(decision.remaining) + return response diff --git a/agentscore_commerce/middleware/fastapi.py b/agentscore_commerce/middleware/fastapi.py new file mode 100644 index 0000000..1c40188 --- /dev/null +++ b/agentscore_commerce/middleware/fastapi.py @@ -0,0 +1,80 @@ +"""FastAPI/Starlette rate-limit adapter. + +Two surfaces: + * :class:`~agentscore_commerce.middleware.asgi.RateLimitMiddleware` for global mount via + ``app.add_middleware(RateLimitMiddleware, ...)``. + * :func:`rate_limit_fastapi` returns an async ``Depends``-able callable for + per-route gating. + +The middleware approach is the canonical mount (matches the node SDK pattern). The +dependency approach is here for routes that need finer control or a custom key. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from starlette.requests import Request # noqa: TC002 - runtime import required for FastAPI DI + +from agentscore_commerce.middleware._core import ( + RATE_LIMIT_JSON_BODY, + create_rate_limiter, + default_key_from_forwarded_for, +) +from agentscore_commerce.middleware.asgi import RateLimitMiddleware + +if TYPE_CHECKING: + from collections.abc import Callable + + +def rate_limit_fastapi( + *, + window_seconds: int = 60, + max_requests: int = 60, + key_resolver: Callable[[Request], str] | None = None, + redis_url: str | None = None, + key_prefix: str = "rl:", +) -> Callable[[Request], Any]: + """Return a FastAPI dependency that enforces a rate limit. + + Usage:: + + from fastapi import Depends, FastAPI + from agentscore_commerce.middleware.fastapi import rate_limit_fastapi + + app = FastAPI() + limiter = rate_limit_fastapi(max_requests=60, window_seconds=60) + + @app.post("/purchase", dependencies=[Depends(limiter)]) + async def purchase(): + ... + """ + from fastapi import HTTPException + + limiter = create_rate_limiter( + window_seconds=window_seconds, + max_requests=max_requests, + redis_url=redis_url, + key_prefix=key_prefix, + ) + resolver = key_resolver or (lambda r: default_key_from_forwarded_for(r.headers.get("x-forwarded-for"))) + + async def dependency(request: Request) -> None: + decision = await limiter.check(resolver(request)) + request.state.rate_limit_limit = decision.limit # type: ignore[attr-defined] + request.state.rate_limit_remaining = decision.remaining # type: ignore[attr-defined] + if not decision.allowed: + raise HTTPException( + status_code=429, + detail=RATE_LIMIT_JSON_BODY["error"], + headers={ + "X-RateLimit-Limit": str(decision.limit), + "X-RateLimit-Remaining": str(decision.remaining), + "Cache-Control": "no-store", + }, + ) + + return dependency + + +__all__ = ["RateLimitMiddleware", "rate_limit_fastapi"] diff --git a/agentscore_commerce/middleware/flask.py b/agentscore_commerce/middleware/flask.py new file mode 100644 index 0000000..bf94dff --- /dev/null +++ b/agentscore_commerce/middleware/flask.py @@ -0,0 +1,83 @@ +"""Flask rate-limit adapter. + +Flask is sync; the underlying limiter is async. We run it on a thread-local event +loop so this adapter stays drop-in for vanilla WSGI Flask. For async-Flask +(Flask 3+) consumers we provide an async-compatible variant too. + +Usage:: + + from flask import Flask + from agentscore_commerce.middleware.flask import rate_limit_flask + + app = Flask(__name__) + rate_limit_flask(app, max_requests=60, window_seconds=60) +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any + +from agentscore_commerce.middleware._core import ( + RATE_LIMIT_JSON_BODY, + create_rate_limiter, + default_key_from_forwarded_for, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + from flask import Flask, Request + + +def rate_limit_flask( + app: Flask, + *, + window_seconds: int = 60, + max_requests: int = 60, + key_resolver: Callable[[Request], str] | None = None, + redis_url: str | None = None, + key_prefix: str = "rl:", +) -> None: + """Install a global ``before_request`` hook that enforces the limit.""" + from flask import after_this_request, g, jsonify, request + + limiter = create_rate_limiter( + window_seconds=window_seconds, + max_requests=max_requests, + redis_url=redis_url, + key_prefix=key_prefix, + ) + resolver = key_resolver or (lambda r: default_key_from_forwarded_for(r.headers.get("X-Forwarded-For"))) + + def _run_async(coro: Any) -> Any: + try: + loop = asyncio.get_event_loop_policy().get_event_loop() + if loop.is_running(): # async Flask path + return asyncio.run_coroutine_threadsafe(coro, loop).result() + except RuntimeError: + # No current event loop on this thread (sync Flask path); fall through + # to asyncio.run which constructs one for this call. + pass + return asyncio.run(coro) + + @app.before_request + def _enforce() -> Any: + decision = _run_async(limiter.check(resolver(request))) + g.rate_limit_limit = decision.limit + g.rate_limit_remaining = decision.remaining + if not decision.allowed: + resp = jsonify(RATE_LIMIT_JSON_BODY) + resp.status_code = 429 + resp.headers["Cache-Control"] = "no-store" + resp.headers["X-RateLimit-Limit"] = str(decision.limit) + resp.headers["X-RateLimit-Remaining"] = str(decision.remaining) + return resp + + @after_this_request + def _add_headers(resp: Any) -> Any: + resp.headers["X-RateLimit-Limit"] = str(decision.limit) + resp.headers["X-RateLimit-Remaining"] = str(decision.remaining) + return resp + + return None diff --git a/agentscore_commerce/middleware/sanic.py b/agentscore_commerce/middleware/sanic.py new file mode 100644 index 0000000..3eef478 --- /dev/null +++ b/agentscore_commerce/middleware/sanic.py @@ -0,0 +1,71 @@ +"""Sanic rate-limit adapter. + +Usage:: + + from sanic import Sanic + from agentscore_commerce.middleware.sanic import rate_limit_sanic + + app = Sanic("my-app") + rate_limit_sanic(app, max_requests=60, window_seconds=60) +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from agentscore_commerce.middleware._core import ( + RATE_LIMIT_JSON_BODY, + create_rate_limiter, + default_key_from_forwarded_for, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + from sanic import Request, Sanic + + +def rate_limit_sanic( + app: Sanic, + *, + window_seconds: int = 60, + max_requests: int = 60, + key_resolver: Callable[[Request], str] | None = None, + redis_url: str | None = None, + key_prefix: str = "rl:", +) -> None: + """Wire ``request`` and ``response`` Sanic middleware that enforce the limit.""" + from sanic import response as _response + + limiter = create_rate_limiter( + window_seconds=window_seconds, + max_requests=max_requests, + redis_url=redis_url, + key_prefix=key_prefix, + ) + resolver = key_resolver or (lambda r: default_key_from_forwarded_for(r.headers.get("X-Forwarded-For"))) + + @app.middleware("request") + async def _enforce(request: Request) -> Any: + decision = await limiter.check(resolver(request)) + request.ctx.rate_limit_limit = decision.limit + request.ctx.rate_limit_remaining = decision.remaining + if not decision.allowed: + return _response.json( + RATE_LIMIT_JSON_BODY, + status=429, + headers={ + "Cache-Control": "no-store", + "X-RateLimit-Limit": str(decision.limit), + "X-RateLimit-Remaining": str(decision.remaining), + }, + ) + return None + + @app.middleware("response") + async def _attach_headers(request: Request, response: Any) -> None: + limit = getattr(request.ctx, "rate_limit_limit", None) + remaining = getattr(request.ctx, "rate_limit_remaining", None) + if limit is not None: + response.headers["X-RateLimit-Limit"] = str(limit) + response.headers["X-RateLimit-Remaining"] = str(remaining) diff --git a/agentscore_commerce/payment/__init__.py b/agentscore_commerce/payment/__init__.py index e5a09fd..55f2fa8 100644 --- a/agentscore_commerce/payment/__init__.py +++ b/agentscore_commerce/payment/__init__.py @@ -1,6 +1,8 @@ """Payment helpers — networks/usdc/rails registries, paymentauth.org directive builders, dispatch, headers.""" from agentscore_commerce.payment.amounts import format_usd_cents, usd_to_atomic +from agentscore_commerce.payment.compose_rails import build_mppx_compose_rails +from agentscore_commerce.payment.default_rails import build_default_checkout_rails from agentscore_commerce.payment.directive import ( build_payment_directive, build_payment_request_blob, @@ -16,7 +18,13 @@ from agentscore_commerce.payment.idempotency import build_idempotency_key from agentscore_commerce.payment.lazy import lazy_mppx_server, lazy_x402_server from agentscore_commerce.payment.mppx_server import MppxRailSpec, create_mppx_server +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 ( + has_mppx_header, + has_payment_header, + has_x402_header, +) from agentscore_commerce.payment.rail_spec import ( RecipientLike, SolanaMppRailSpec, @@ -111,7 +119,9 @@ "ZeroSettleRail", "ZeroSettleResult", "alias_amount_fields", + "build_default_checkout_rails", "build_idempotency_key", + "build_mppx_compose_rails", "build_payment_directive", "build_payment_headers", "build_payment_request_blob", @@ -128,6 +138,11 @@ "extract_signer_for_precheck", "extract_x402_signer", "format_usd_cents", + "has_mppx_header", + "has_payment_header", + "has_x402_header", + "is_evm_network", + "is_solana_network", "lazy_mppx_server", "lazy_x402_server", "load_solana_fee_payer", diff --git a/agentscore_commerce/payment/amounts.py b/agentscore_commerce/payment/amounts.py index ee9d5eb..04da170 100644 --- a/agentscore_commerce/payment/amounts.py +++ b/agentscore_commerce/payment/amounts.py @@ -57,12 +57,18 @@ def usd_to_atomic(usd: str | float | int | Decimal, *, decimals: int) -> int: return int(scaled) -def format_usd_cents(cents: int) -> str: - """Format an integer cent amount as a fixed-2-decimal USD string. +def format_usd_cents(cents: float, decimals: int = 2) -> str: + """Format a cent amount as a fixed-precision USD string. ``500`` → ``"5.00"``. Negative values are formatted with a leading minus. Use everywhere a merchant emits ``f"{cents / 100:.2f}"`` today; consistent formatting across catalog rows, order responses, and 402 bodies prevents agent-side string-comparison flakiness. + + ``decimals`` controls dollar-precision and defaults to ``2`` (canonical USD + cents). Raise it for sub-cent unit pricing — e.g. ``format_usd_cents(0.05, 4)`` + returns ``"0.0005"`` for a half-of-one-millicent amount. ``cents`` accepts + a float so per-token / per-byte pricing models can compute + ``price_cents = unit_price_cents * n`` without rounding before formatting. """ - return f"{cents / 100:.2f}" + return f"{cents / 100:.{decimals}f}" diff --git a/agentscore_commerce/payment/compose_rails.py b/agentscore_commerce/payment/compose_rails.py new file mode 100644 index 0000000..4c5e52d --- /dev/null +++ b/agentscore_commerce/payment/compose_rails.py @@ -0,0 +1,88 @@ +"""Builder for the ``compose(*intents)`` array passed to mppx. + +Replaces the hand-rolled ``compose_rails`` assembly that recurs verbatim +across multi-rail merchants' ``compose_mppx`` hooks. + +The intent shape is mppx-protocol-shaped; this helper spares callers from +re-typing the same atomic-conversion + per-rail dict literal. + +Mirrors node-commerce ``src/payment/compose_rails.ts``. +""" + +from __future__ import annotations + +from typing import Any + +from agentscore_commerce.payment.amounts import usd_to_atomic +from agentscore_commerce.payment.usdc import USDC + +_SOLANA_MAINNET_CAIP2 = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" + + +def build_mppx_compose_rails( + *, + amount_usd: str, + tempo_recipient: str | None = None, + tempo_token_address: str | None = None, + solana_recipient: str | None = None, + solana_token_mint: str | None = None, + solana_network: str | None = None, + include_stripe: bool = True, +) -> list[tuple[str, dict[str, Any]]]: + """Build the ``compose(*intents)`` argument list. + + Order matches mppx's preferred ordering: tempo first (cheapest), then + solana, then stripe. The returned list contains ``(directive, payload)`` + tuples ready to splat into ``mppx.compose(*build_mppx_compose_rails(...))``. + + Args: + amount_usd: USD price string (``"1.50"``). Tempo + Stripe consume it + verbatim; Solana converts to atomic units (``int``) via + :func:`usd_to_atomic`. + tempo_recipient: Tempo address. When ``None``, the ``tempo/charge`` + intent is omitted. + tempo_token_address: Tempo USDC contract address. Defaults to + ``USDC.tempo.mainnet.address``. + solana_recipient: Solana address. When ``None``, the ``solana/charge`` + intent is omitted. + solana_token_mint: Solana USDC mint. Defaults to + ``USDC.solana.mainnet.mint``. + solana_network: Solana CAIP-2 network. Defaults to mainnet-beta. + include_stripe: Include the ``stripe/charge`` intent (Stripe SPT + rail). Default ``True``. + + Raises: + ValueError: when Solana is requested but ``amount_usd`` can't convert + to atomic — merchants should catch and return a 402 to drop the rail + rather than crash the request. + """ + rails: list[tuple[str, dict[str, Any]]] = [] + if tempo_recipient: + rails.append( + ( + "tempo/charge", + { + "amount": amount_usd, + "currency": tempo_token_address or USDC.tempo.mainnet.address, + "decimals": 6, + "recipient": tempo_recipient, + }, + ) + ) + if solana_recipient: + atomic = usd_to_atomic(amount_usd, decimals=6) + rails.append( + ( + "solana/charge", + { + "amount": str(atomic), + "currency": solana_token_mint or USDC.solana.mainnet.mint, + "decimals": 6, + "recipient": solana_recipient, + "network": solana_network or _SOLANA_MAINNET_CAIP2, + }, + ) + ) + if include_stripe: + rails.append(("stripe/charge", {"amount": amount_usd, "currency": "usd", "decimals": 2})) + return rails diff --git a/agentscore_commerce/payment/default_rails.py b/agentscore_commerce/payment/default_rails.py new file mode 100644 index 0000000..9c53b86 --- /dev/null +++ b/agentscore_commerce/payment/default_rails.py @@ -0,0 +1,69 @@ +"""Boilerplate-reducer for the ``rails`` config passed to ``Checkout``. + +Merchants supplying a chain set always rebuild the same constants (empty +``recipient`` sentinel, network/chain_id/token defaults); this helper folds +those defaults in so the merchant config only specifies the merchant-specific +overrides. + +Per-order recipient minting (Stripe-multichain) is wired via Checkout's +``mint_recipients`` hook, so the ``recipient=""`` sentinel here is the +expected shape — ``mint_recipients`` overrides it at request time. + +Mirrors node-commerce ``src/payment/default_rails.ts``. +""" + +from __future__ import annotations + +from typing import Any + +from agentscore_commerce.payment.rail_spec import ( + SolanaMppRailSpec, + StripeRailSpec, + TempoRailSpec, + X402BaseRailSpec, +) + + +def build_default_checkout_rails( + *, + tempo: dict[str, Any] | None = None, + x402_base: dict[str, Any] | None = None, + solana_mpp: dict[str, Any] | None = None, + stripe: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Build the canonical four-rail ``rails`` dict for ``Checkout``. + + Keys match the convention used across consumer codebases: ``tempo``, + ``x402_base``, ``solana_mpp``, ``stripe``. Empty-string ``recipient`` is a + placeholder — ``Checkout.mint_recipients`` must populate real values at + request time. + + Each kwarg accepts a partial dict of rail-spec fields (matching the + dataclass field names from :mod:`agentscore_commerce.payment.rail_spec`). + Omit a kwarg to skip that rail entirely. + + Example:: + + rails = build_default_checkout_rails( + tempo={"testnet": True}, + x402_base={"network": "eip155:84532"}, + solana_mpp={}, + stripe={"profile_id": "p_test", "payment_method_types": ["card", "link"]}, + ) + """ + out: dict[str, Any] = {} + if tempo is not None: + spec_args = dict(tempo) + spec_args.setdefault("recipient", "") + out["tempo"] = TempoRailSpec(**spec_args) + if x402_base is not None: + spec_args = dict(x402_base) + spec_args.setdefault("recipient", "") + out["x402_base"] = X402BaseRailSpec(**spec_args) + if solana_mpp is not None: + spec_args = dict(solana_mpp) + spec_args.setdefault("recipient", "") + out["solana_mpp"] = SolanaMppRailSpec(**spec_args) + if stripe is not None: + out["stripe"] = StripeRailSpec(**stripe) + return out diff --git a/agentscore_commerce/payment/dispatch.py b/agentscore_commerce/payment/dispatch.py index fef8225..710602f 100644 --- a/agentscore_commerce/payment/dispatch.py +++ b/agentscore_commerce/payment/dispatch.py @@ -11,6 +11,8 @@ from collections.abc import Awaitable, Callable, Mapping from typing import Any, Literal, TypeVar, cast +from agentscore_commerce.payment.network_kind import is_evm_network, is_solana_network + T = TypeVar("T") Handler = Callable[[Any], T | Awaitable[T]] @@ -51,11 +53,11 @@ async def dispatch_settlement_by_network( ValueError: if the network is unrecognized or no matching handler is registered. """ network = payload.accepted["network"] if isinstance(payload.accepted, dict) else payload.accepted.network - if network.startswith("eip155:"): + if is_evm_network(network): if evm is None: raise ValueError(f"No EVM settlement handler registered (network: {network})") result = evm(payload) - elif network.startswith("solana:"): + elif is_solana_network(network): if svm is None: raise ValueError(f"No Solana settlement handler registered (network: {network})") result = svm(payload) diff --git a/agentscore_commerce/payment/network_kind.py b/agentscore_commerce/payment/network_kind.py new file mode 100644 index 0000000..fea310b --- /dev/null +++ b/agentscore_commerce/payment/network_kind.py @@ -0,0 +1,36 @@ +"""CAIP-2 prefix discriminators for chain-family identification. + +Replaces the ad-hoc ``startswith("eip155:")`` / ``startswith("solana:")`` +checks scattered across ``checkout``, ``identity.ucp``, ``payment.dispatch``. +Pure functions; no peer-dep imports. + +Mirrors node-commerce ``src/payment/network_kind.ts``. +""" + +from __future__ import annotations + +from typing import Any + + +def _read_network(value: Any) -> str: + if isinstance(value, str): + return value + if value is None: + return "" + # Accept both attribute access (dataclass / pydantic) and dict. + net = value.get("network") if isinstance(value, dict) else getattr(value, "network", None) + return net if isinstance(net, str) else "" + + +def is_evm_network(value: Any) -> bool: + """True when the network is a CAIP-2 EVM chain (``eip155:``).""" + return _read_network(value).startswith("eip155:") + + +def is_solana_network(value: Any) -> bool: + """True when the network is a CAIP-2 Solana chain (``solana:``). + + Note: the bare string ``"solana"`` (no ``:``) is the mppx-internal label, + NOT a CAIP-2 spec — this helper treats it as ``False``. + """ + return _read_network(value).startswith("solana:") diff --git a/agentscore_commerce/payment/payment_header.py b/agentscore_commerce/payment/payment_header.py new file mode 100644 index 0000000..fde09d6 --- /dev/null +++ b/agentscore_commerce/payment/payment_header.py @@ -0,0 +1,98 @@ +"""Detect whether a request is a settle leg (carries a payment credential). + +The complement is a discovery leg — no credential, expects a 402. + +Used by the gate-conditional mount pattern documented in CLAUDE.md: mount +``AgentScoreGate`` on a route only when payment is being attempted, so the +discovery leg flows through unauthenticated and gets a 402 with all rails. + +Three credential channels are checked: + +- ``Payment-Signature`` — MPP credentials (Tempo, Solana, Stripe SPT) +- ``X-Payment`` — x402 v1 EIP-3009 credentials +- ``Authorization: Payment `` — x402 v2 / paymentauth.org credentials + +Mirrors node-commerce ``src/payment/payment_header.ts``. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + + +def _read_header(headers: Any, name: str) -> str | None: + """Read a header in a framework-agnostic way. + + Accepts: + + - ``Mapping[str, str]`` (Flask, plain dict) + - Starlette / FastAPI ``Headers`` (has ``.get(name)``) + - aiohttp ``CIMultiDict`` (has ``.get(name)``) + - Django ``META`` dict (uppercase + ``HTTP_`` prefix) + """ + if headers is None: + return None + # Web-style Headers (Starlette, aiohttp, Werkzeug) all expose ``.get``. + getter = getattr(headers, "get", None) + if callable(getter): + val = getter(name) + if val is None: + val = getter(name.lower()) + if val is None: + val = getter(name.title()) + if isinstance(val, str): + return val + if isinstance(val, (list, tuple)) and val and isinstance(val[0], str): + return val[0] + if isinstance(headers, Mapping): + for key in (name, name.lower(), name.title()): + if key in headers: + val = headers[key] + if isinstance(val, str): + return val + if isinstance(val, (list, tuple)) and val and isinstance(val[0], str): + return val[0] + return None + + +def _unwrap_headers(request_or_headers: Any) -> Any: + inner = getattr(request_or_headers, "headers", None) + return inner if inner is not None else request_or_headers + + +def has_payment_header(request_or_headers: Any) -> bool: + """True when the request carries any recognized payment-credential header. + + Accepts a request-like object with a ``.headers`` attribute, OR a headers + mapping directly (so callers in framework-neutral code can pass + ``request.headers``). + """ + headers = _unwrap_headers(request_or_headers) + if _read_header(headers, "payment-signature"): + return True + if _read_header(headers, "x-payment"): + return True + auth = _read_header(headers, "authorization") + return bool(isinstance(auth, str) and auth.startswith("Payment ")) + + +def has_x402_header(request_or_headers: Any) -> bool: + """True when the request carries an x402 payment credential. + + Matches ``X-Payment`` or ``Payment-Signature``. Use to dispatch the x402 settle path. + """ + headers = _unwrap_headers(request_or_headers) + return bool( + _read_header(headers, "payment-signature") or _read_header(headers, "x-payment"), + ) + + +def has_mppx_header(request_or_headers: Any) -> bool: + """True when the request carries an mppx payment credential. + + Matches ``Authorization: Payment ``. Use to dispatch the MPP settle path. + """ + headers = _unwrap_headers(request_or_headers) + auth = _read_header(headers, "authorization") + return bool(isinstance(auth, str) and auth.startswith("Payment ")) diff --git a/agentscore_commerce/payment/rail_spec.py b/agentscore_commerce/payment/rail_spec.py index 6ebe7eb..3f709bc 100644 --- a/agentscore_commerce/payment/rail_spec.py +++ b/agentscore_commerce/payment/rail_spec.py @@ -118,13 +118,21 @@ class SolanaMppRailSpec: recipient: RecipientLike network: str = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp" - token: str = USDC.solana.mainnet.mint + token: str = _DEFAULT symbol: str = "USDC" decimals: int = 6 rpc_url: str | None = None signer: Any | None = None token_program: str | None = None + def __post_init__(self) -> None: + # Mirror X402BaseRailSpec: when ``network`` flips to the devnet CAIP-2 (or the + # raw ``'devnet'`` form @solana/mpp accepts), default ``token`` to devnet's USDC + # mint instead of mainnet's. Explicit overrides still win. + is_devnet = self.network in ("devnet", "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1") + if self.token is _DEFAULT: + self.token = USDC.solana.devnet.mint if is_devnet else USDC.solana.mainnet.mint + @dataclass class StripeRailSpec: diff --git a/agentscore_commerce/payment/signer.py b/agentscore_commerce/payment/signer.py index d8ef366..c723634 100644 --- a/agentscore_commerce/payment/signer.py +++ b/agentscore_commerce/payment/signer.py @@ -94,6 +94,25 @@ def _extract_from_x402(x402_payment_header: str) -> PaymentSigner | None: return None +def parse_did_pkh_address(source: str) -> PaymentSigner | None: + """Parse a ``did:pkh:::`` DID into a :class:`PaymentSigner`. + + Returns ``None`` when the DID is malformed, the family isn't recognized + (``eip155`` for EVM, ``solana`` for Solana), or the address fails the + family-specific validation. + """ + parts = source.split(":") + if len(parts) < 4 or parts[0] != "did" or parts[1] != "pkh": + return None + family = parts[2] + addr = parts[-1] + if family == "eip155" and is_valid_evm_address(addr): + return PaymentSigner(address=normalize_address(addr), network="evm") + if family == "solana" and is_solana_address(addr): + return PaymentSigner(address=normalize_address(addr), network="solana") + return None + + def _extract_from_mpp_auth(authorization: str) -> PaymentSigner | None: """Recover the signer from an MPP ``Authorization: Payment `` header value. @@ -120,16 +139,7 @@ def _extract_from_mpp_auth(authorization: str) -> PaymentSigner | None: source = challenge.get("source") if not isinstance(source, str): return None - parts = source.split(":") - if len(parts) < 4 or parts[0] != "did" or parts[1] != "pkh": - return None - family = parts[2] - addr = parts[-1] - if family == "eip155" and is_valid_evm_address(addr): - return PaymentSigner(address=normalize_address(addr), network="evm") - if family == "solana" and is_solana_address(addr): - return PaymentSigner(address=normalize_address(addr), network="solana") - return None + return parse_did_pkh_address(source) def extract_signer_for_precheck(headers: Mapping[str, str]) -> PaymentSigner | None: diff --git a/agentscore_commerce/quote_cache.py b/agentscore_commerce/quote_cache.py new file mode 100644 index 0000000..e581693 --- /dev/null +++ b/agentscore_commerce/quote_cache.py @@ -0,0 +1,133 @@ +"""Short-TTL body-hash quote cache for the compute-first + exact-x402 pattern. + +Mirrors ``node-commerce/src/quote_cache.ts``. The probe leg writes (run-work → +cache); the settle leg reads (cache hit → settle exact at the cached price → +return the cached body). + +Default in-memory ``dict``; optional ``redis_url`` lazy-imports +``redis.asyncio`` for multi-instance deployments. ``redis`` is an optional +peer dep (install via the ``redis`` extra). +""" + +from __future__ import annotations + +import contextlib +import hashlib +import json +import logging +import time +from dataclasses import dataclass, field +from typing import Any + +from agentscore_commerce._redis import memoized_redis + +logger = logging.getLogger("agentscore_commerce.quote_cache") + +DEFAULT_TTL_MS = 5 * 60_000 + + +@dataclass(frozen=True) +class CachedQuote: + """An entry in the quote cache.""" + + body: dict[str, Any] + price_cents: float + recipients: dict[str, str] = field(default_factory=dict) + """Per-rail deposit addresses minted on the probe leg. The settle leg + replays these instead of re-minting (avoids second Stripe PaymentIntent + for the same logical purchase). Empty dict when no ``mint_recipients`` + hook is wired.""" + + +def _canonicalize(value: Any) -> Any: + if isinstance(value, list): + return [_canonicalize(v) for v in value] + if isinstance(value, dict): + return {k: _canonicalize(value[k]) for k in sorted(value)} + return value + + +@dataclass +class QuoteCache: + """Async quote cache returned by :func:`create_quote_cache`.""" + + body_hash_key: Any # (prefix: str, body: dict) -> str + read: Any # (key: str) -> CachedQuote | None + write: Any # (key: str, body: dict, price_cents: float, *, recipients?: dict) -> None + clear: Any # () -> None + + +def create_quote_cache( + *, + ttl_ms: int = DEFAULT_TTL_MS, + redis_url: str | None = None, + key_prefix: str = "quote:", +) -> QuoteCache: + """Build a fresh quote cache. + + Each call owns its own state (memory dict + Redis client). Set + ``redis_url`` for a Redis-backed cache; otherwise the cache is + in-process only and replicas diverge under load. + """ + mem_state: dict[str, tuple[CachedQuote, float]] = {} + _get_redis = memoized_redis(url=redis_url, label="quote-cache") + + def _body_hash_key(prefix: str, body: dict[str, Any]) -> str: + canonical = json.dumps(_canonicalize(body), separators=(",", ":")) + digest = hashlib.sha256(f"{prefix}::{canonical}".encode()).hexdigest()[:24] + return f"{prefix}::{digest}" + + def _evict_expired() -> None: + now = time.monotonic() * 1000 + for k, (_, expires_at) in list(mem_state.items()): + if expires_at <= now: + del mem_state[k] + + async def _read(key: str) -> CachedQuote | None: + r = await _get_redis() + if r is not None: + with contextlib.suppress(Exception): + raw = await r.get(f"{key_prefix}{key}") + if raw is None: + return None + data = json.loads(raw) + return CachedQuote( + body=data["body"], + price_cents=data["price_cents"], + recipients=data.get("recipients", {}), + ) + _evict_expired() + entry = mem_state.get(key) + return entry[0] if entry else None + + async def _write( + key: str, + body: dict[str, Any], + price_cents: float, + *, + recipients: dict[str, str] | None = None, + ) -> None: + cached = CachedQuote(body=body, price_cents=price_cents, recipients=recipients or {}) + r = await _get_redis() + if r is not None: + with contextlib.suppress(Exception): + payload = json.dumps( + {"body": cached.body, "price_cents": cached.price_cents, "recipients": cached.recipients} + ) + await r.set(f"{key_prefix}{key}", payload, px=ttl_ms) + return + mem_state[key] = (cached, time.monotonic() * 1000 + ttl_ms) + + async def _clear() -> None: + mem_state.clear() + r = await _get_redis() + if r is not None: + with contextlib.suppress(Exception): + await r.flushdb() + + return QuoteCache( + body_hash_key=_body_hash_key, + read=_read, + write=_write, + clear=_clear, + ) diff --git a/agentscore_commerce/stripe_multichain/__init__.py b/agentscore_commerce/stripe_multichain/__init__.py index ccb6c3d..2abfc34 100644 --- a/agentscore_commerce/stripe_multichain/__init__.py +++ b/agentscore_commerce/stripe_multichain/__init__.py @@ -4,6 +4,9 @@ DEFAULT_PAYMENT_METHOD_TYPES, create_mppx_stripe, ) +from agentscore_commerce.stripe_multichain.pay_to_address import ( + create_pay_to_address_from_stripe_pi, +) from agentscore_commerce.stripe_multichain.payment_intent import ( MultichainPaymentIntentResult, StripeClientLike, @@ -17,6 +20,11 @@ simulate_crypto_deposit, simulate_deposit_if_test_mode, ) +from agentscore_commerce.stripe_multichain.simulate_dispatch import ( + SimulateNetwork, + network_for_outcome, + simulate_deposit_for_outcome, +) __all__ = [ "DEFAULT_BUYER_WALLET", @@ -25,10 +33,14 @@ "STRIPE_TEST_TX_HASH_SUCCESS", "MultichainPaymentIntentResult", "PiCache", + "SimulateNetwork", "StripeClientLike", "create_mppx_stripe", "create_multichain_payment_intent", + "create_pay_to_address_from_stripe_pi", "create_pi_cache", + "network_for_outcome", "simulate_crypto_deposit", + "simulate_deposit_for_outcome", "simulate_deposit_if_test_mode", ] diff --git a/agentscore_commerce/stripe_multichain/pay_to_address.py b/agentscore_commerce/stripe_multichain/pay_to_address.py new file mode 100644 index 0000000..7c3302c --- /dev/null +++ b/agentscore_commerce/stripe_multichain/pay_to_address.py @@ -0,0 +1,99 @@ +"""Per-order Stripe-multichain ``pay_to`` resolver. + +Stripe-multichain merchants need ONE function for their ``mint_recipients`` +(or per-request payTo) hook that does the right thing on both legs: + +- **Discovery leg** (no payment header): mint a fresh PaymentIntent so the 402 + advertises a stable per-order deposit address. +- **Settle leg** (MPP credential attached): reuse the buyer's signed-against + payTo from the credential (after verifying it's in the local cache) — + otherwise the verify leg would compare against a freshly-rotated address + and reject the credential. + +Stripe SPT and card methods don't carry an on-chain recipient, so the settle +leg still mints a fresh PaymentIntent for them. + +Mirrors node-commerce ``src/stripe-multichain/pay_to_address.ts``. +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any + +from agentscore_commerce.stripe_multichain.payment_intent import ( + create_multichain_payment_intent, +) + +if TYPE_CHECKING: + from agentscore_commerce.stripe_multichain.pi_cache import PiCache + + +async def _maybe_await(value: Any) -> Any: + if asyncio.iscoroutine(value) or asyncio.isfuture(value): + return await value + return value + + +async def create_pay_to_address_from_stripe_pi( + *, + authorization_header: str | None, + amount_cents: int, + stripe: Any, + pi_cache: PiCache, + networks: list[str] | None = None, + metadata: dict[str, str] | None = None, + order_id: str | None = None, + preferred_network: str = "tempo", +) -> str: + """Resolve the on-chain ``pay_to`` for a Stripe-multichain order. + + On the settle leg, when ``authorization_header`` carries an MPP credential + binding a ``tempo`` or ``solana`` recipient, returns THAT address (after + verifying it's still in ``pi_cache``). Otherwise mints a fresh + :func:`create_multichain_payment_intent` and caches the addresses + PI + mapping. Returns the address on the ``preferred_network`` (default + ``"tempo"``, falling back to ``base`` then ``tempo``). + """ + if authorization_header: + from mpp import Credential # type: ignore[import-untyped] + + if authorization_header.startswith("Payment "): + credential = Credential.from_authorization(authorization_header) + method = getattr(credential.challenge, "method", None) + if method in ("tempo", "solana"): + recipient = getattr(credential.challenge.request, "recipient", None) + if not isinstance(recipient, str) or not recipient: + msg = "MPP credential challenge missing recipient field" + raise ValueError(msg) + if not await _maybe_await(pi_cache.has_address(recipient)): + msg = "Invalid payTo address: not found in cache or expired" + raise ValueError(msg) + return recipient + + idempotency_key = f"pi-{order_id}-{amount_cents}" if order_id else None + result = create_multichain_payment_intent( + stripe=stripe, + amount=amount_cents, + networks=networks or ["tempo", "base", "solana"], + metadata=metadata, + idempotency_key=idempotency_key, + ) + + for address in result.deposit_addresses.values(): + await _maybe_await(pi_cache.cache_address(address)) + pi_cache.cache_payment_intent(address, result.payment_intent_id) + pi_cache.cache_network_addresses(result.payment_intent_id, result.deposit_addresses) + + pay_to = ( + result.deposit_addresses.get(preferred_network) + or result.deposit_addresses.get("base") + or result.deposit_addresses.get("tempo") + ) + if not pay_to: + msg = "Failed to resolve pay_to address from Stripe PaymentIntent" + raise RuntimeError(msg) + return pay_to + + +__all__ = ["create_pay_to_address_from_stripe_pi"] diff --git a/agentscore_commerce/stripe_multichain/simulate_dispatch.py b/agentscore_commerce/stripe_multichain/simulate_dispatch.py new file mode 100644 index 0000000..82f082e --- /dev/null +++ b/agentscore_commerce/stripe_multichain/simulate_dispatch.py @@ -0,0 +1,100 @@ +"""Settle-outcome → Stripe testnet simulator dispatch. + +Replaces the 3-branch rail/rail_key switch + thin +``simulate_deposit_if_testnet(addr, network)`` wrapper that consumer +codebases (store, sayer-py, martin-py) hand-roll in their own +``lib/payment.py`` files. + +Folding the dispatch into the SDK removes the consumer-wrap anti-pattern: +merchants call this directly from ``on_settled``, no per-merchant payment.py +wrapper needed. + +Mirrors node-commerce ``src/stripe-multichain/simulate_dispatch.ts``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal + +from agentscore_commerce.stripe_multichain.simulate_deposit import simulate_deposit_if_test_mode + +if TYPE_CHECKING: + from collections.abc import Callable + +SimulateNetwork = Literal["tempo", "base", "solana"] + + +def _field(outcome: Any, name: str) -> str | None: + """Read a field from an outcome that may be a dataclass, pydantic model, or dict.""" + if outcome is None: + return None + val = outcome.get(name) if isinstance(outcome, dict) else getattr(outcome, name, None) + return val if isinstance(val, str) else None + + +def network_for_outcome(outcome: Any) -> SimulateNetwork | None: + """Map a settle outcome to the simulator's ``network`` arg. + + Reads ``Checkout.on_settled`` / ``compute_first_checkout.on_settled`` + outcomes and returns ``None`` for Stripe SPT (no on-chain deposit) or + unknown rails. + + Accepts both Checkout-shaped outcomes (``rail`` + ``rail_key``) and + compute-first-shaped outcomes (``rail`` + ``mpp_method``). The two + diverged historically; this helper canonicalizes them. + """ + rail = _field(outcome, "rail") + if rail == "x402": + return "base" + # mppx's Receipt.method can be either the bare scheme name (``'tempo'``) + # or the full directive (``'tempo/charge'``) depending on the version. + method = _field(outcome, "mpp_method") or _field(outcome, "mppMethod") + scheme = method.split("/", 1)[0] if method else None + if scheme == "tempo": + return "tempo" + if scheme == "solana": + return "solana" + if scheme == "stripe": + return None + rail_key = _field(outcome, "rail_key") or _field(outcome, "railKey") + if rail_key in ("tempo", "tempo_mpp"): + return "tempo" + if rail_key == "solana_mpp": + return "solana" + if rail_key == "x402_base": + return "base" + if rail_key == "stripe": + return None + return None + + +async def simulate_deposit_for_outcome( + *, + outcome: Any, + deposit_address: str, + get_payment_intent_id: Callable[[str], str | None], + stripe_secret_key: str, + stripe_version: str | None = None, + buyer_wallet: str | None = None, +) -> None: + """Dispatch :func:`simulate_deposit_if_test_mode` based on the outcome's rail. + + Calls through to the SDK simulator; no-op for Stripe SPT or unknown rails. + + Use this in ``on_settled`` to replace the hand-rolled rail switch + + ``simulate_deposit_if_testnet`` wrapper pattern. + """ + network = network_for_outcome(outcome) + if network is None: + return + kwargs: dict[str, Any] = { + "get_payment_intent_id": get_payment_intent_id, + "deposit_address": deposit_address, + "network": network, + "stripe_secret_key": stripe_secret_key, + } + if buyer_wallet is not None: + kwargs["buyer_wallet"] = buyer_wallet + if stripe_version is not None: + kwargs["stripe_version"] = stripe_version + await simulate_deposit_if_test_mode(**kwargs) diff --git a/examples/api_provider.py b/examples/api_provider.py index e7df158..999371e 100644 --- a/examples/api_provider.py +++ b/examples/api_provider.py @@ -57,6 +57,7 @@ build_redemption_skill_md, standard_endpoint_descriptions, ) +from agentscore_commerce.middleware.fastapi import RateLimitMiddleware from agentscore_commerce.payment import networks PRICE_USDC = 0.01 # per-call price in USD @@ -67,6 +68,7 @@ _TEMPO_RAIL_NAME = "tempo-testnet" if networks.base.sepolia.caip2 == X402_BASE_NETWORK else "tempo-mainnet" app = FastAPI() +app.add_middleware(RateLimitMiddleware) # noindex non-discovery paths so /search doesn't end up in human-shaped SERPs. app.add_middleware(NoindexNonDiscoveryMiddleware) diff --git a/examples/compliance_merchant.py b/examples/compliance_merchant.py index 74f82df..3e09a83 100644 --- a/examples/compliance_merchant.py +++ b/examples/compliance_merchant.py @@ -52,6 +52,7 @@ is_fixable_denial, verification_agent_instructions, ) +from agentscore_commerce.middleware.fastapi import RateLimitMiddleware SUPPORT_EMAIL = "support@example.com" @@ -155,6 +156,7 @@ async def _on_settled(ctx: Any, outcome: SettleOutcome) -> dict[str, Any]: app = FastAPI() +app.add_middleware(RateLimitMiddleware) @app.post("/buy") diff --git a/examples/compute_first_merchant.py b/examples/compute_first_merchant.py new file mode 100644 index 0000000..64b1230 --- /dev/null +++ b/examples/compute_first_merchant.py @@ -0,0 +1,100 @@ +"""Example: variable-cost merchant via compute-first + exact-x402. + +Scenario: you bill per unit of work (per result, per token, per byte). The +total can't be known until the work runs, but every payment rail in the +ecosystem signs an EXACT amount up front. The compute-first pattern flips the +order: probe runs the work server-side, caches the result, and emits a 402 +with the EXACT computed price. The retry pays that price; the merchant +serves the cached result. + +Why this exists (vs x402 upto / Permit2): + +* upto's facilitator support is still limited (Coinbase CDP testnet rejects + upto-mode settles today; only mainnet claims support). +* Permit2 is Ethereum-only — no Solana, no Tempo non-EIP-3009, no Stripe. +* Compute-first works on every exact-mode rail in the ecosystem with no + buyer setup and no facilitator extensions. + +The tradeoff: work runs on the unpaid probe leg, so rate-limiting is +load-bearing. Mount the SDK's rate-limit middleware globally and tune +``max_requests`` per your compute budget. + +This example wires the x402-exact rail on Base only. To add MPP rails +(Tempo, Solana, Stripe SPT), pass a ``compose_mppx`` callback that builds +mppx intents at the exact cached price — see ``multi_rail_merchant.py`` +for the fixed-price MPP compose pattern; the compute-first variant is +structurally identical except the helper passes the cached price + +recipients into your callback. + +Peer deps:: + + pip install 'agentscore-commerce[fastapi,x402,coinbase]' + +Env vars:: + + APP_URL public URL of your service + X402_BASE_RECIPIENT Base wallet (USDC) + X402_BASE_NETWORK CAIP-2 (default eip155:8453) + +Run: ``uvicorn examples.compute_first_merchant:app --port 3000`` +""" + +from __future__ import annotations + +import os +from typing import Any + +from fastapi import FastAPI, Request + +from agentscore_commerce import ( + ComputeFirstRails, + ComputeFirstWorkContext, + WorkOutcome, + compute_first_checkout, +) +from agentscore_commerce.middleware.fastapi import rate_limit_fastapi +from agentscore_commerce.payment import X402BaseRailSpec, create_x402_server + +APP_URL = os.environ.get("APP_URL", "https://api.example.com") +X402_BASE_NETWORK = os.environ.get("X402_BASE_NETWORK", "eip155:8453") +X402_BASE_RECIPIENT = os.environ.get("X402_BASE_RECIPIENT", "0xbase") + + +# Vendor's actual per-result work. Swap with a real search / enrichment / LLM +# call. The result_count drives pricing; the body is what the buyer receives. +async def _run_search(body: dict[str, Any], _ctx: ComputeFirstWorkContext) -> WorkOutcome: + query = str(body.get("query", "")) + limit = int(body.get("limit", 5)) + matches = [ + {"id": f"result_{i}", "score": 0.9 - i * 0.05, "snippet": f"{query} hit {i}"} for i in range(min(limit, 8)) + ] + return WorkOutcome(result_count=len(matches), body={"matches": matches, "total": 8492}) + + +x402_server = create_x402_server( + facilitator="coinbase", + rails=["x402-base-sepolia" if X402_BASE_NETWORK == "eip155:84532" else "x402-base-mainnet"], +) + +search_handler = compute_first_checkout( + name="search", + url=f"{APP_URL}/search", + # $0.01 per result. Use 0.0001 for sub-cent / per-token pricing — the + # helper auto-derives decimal precision from the unit price. + unit_price_cents=1, + rails=ComputeFirstRails( + x402_base=X402BaseRailSpec(recipient=X402_BASE_RECIPIENT, network=X402_BASE_NETWORK), + ), + x402_server=x402_server, + run_work=_run_search, +) + +app = FastAPI() +# Rate-limit is load-bearing here: the probe leg runs the work without +# payment. Without it, an attacker can drain compute budget for free. +app.middleware("http")(rate_limit_fastapi(max_requests=60, window_seconds=60)) + + +@app.post("/search") +async def search(request: Request) -> Any: + return await search_handler.handle_fastapi(request) diff --git a/examples/identity_only.py b/examples/identity_only.py index d13c09c..a63fef9 100644 --- a/examples/identity_only.py +++ b/examples/identity_only.py @@ -34,8 +34,10 @@ capture_wallet, get_agentscore_data, ) +from agentscore_commerce.middleware.fastapi import RateLimitMiddleware app = FastAPI() +app.add_middleware(RateLimitMiddleware) API_KEY = os.environ.get("AGENTSCORE_API_KEY", "ask_test_dummy") diff --git a/examples/multi_rail_merchant.py b/examples/multi_rail_merchant.py index 5dcec83..b1a4027 100644 --- a/examples/multi_rail_merchant.py +++ b/examples/multi_rail_merchant.py @@ -16,7 +16,7 @@ 4. x402-base header → Checkout dispatches to `process_x402_settle` internally. 5. `Authorization: Payment` header → Checkout dispatches to the auto-derived `compose_mppx` hook (built from `mppx_secret_key`). -6. `on_settled` persists the order + fires `simulate_deposit_if_test_mode` +6. `on_settled` persists the order + fires `simulate_deposit_for_outcome` for Stripe testnet round-trip on base settles. Peer deps:: @@ -52,19 +52,17 @@ CheckoutValidationError, PricingResult, SettleOutcome, - SolanaMppRailSpec, - StripeRailSpec, - TempoRailSpec, - X402BaseRailSpec, + build_default_checkout_rails, pricing_result, ) from agentscore_commerce.challenge import ProductInfo, Receipt, ReceiptNextSteps from agentscore_commerce.discovery import build_success_next_steps +from agentscore_commerce.middleware.fastapi import RateLimitMiddleware from agentscore_commerce.payment import networks, validate_x402_network_config from agentscore_commerce.stripe_multichain import ( create_multichain_payment_intent, create_pi_cache, - simulate_deposit_if_test_mode, + simulate_deposit_for_outcome, ) APP_URL = os.environ["APP_URL"] @@ -83,6 +81,10 @@ app = FastAPI() +# Rate-limit every endpoint. Defaults: 60 req / 60 s / IP. Set REDIS_URL for +# multi-instance deployments so the bucket is shared. +app.add_middleware(RateLimitMiddleware) + async def _validate_purchase(ctx: Any) -> dict[str, Any]: """preValidate hook: shape-check the request body before pricing/gate runs.""" @@ -121,14 +123,16 @@ async def _mint_recipients(ctx: Any) -> dict[str, str]: async def _on_settled(ctx: Any, outcome: SettleOutcome) -> dict[str, Any]: - # Fire Stripe testnet deposit simulation on real on-chain base settles - # (no-op on live keys). Gate on `tx_hash` so $0 zero-settle carve-outs - # (which have signer_address but no tx_hash) don't trigger a PI sim. - if outcome.rail == "x402" and outcome.tx_hash is not None: - await simulate_deposit_if_test_mode( + # Stripe testnet deposit simulation (no-op on live keys). The dispatcher + # picks the right network arg from the outcome's rail / rail_key, no-ops + # on Stripe SPT (no on-chain deposit), and gates on `tx_hash` so $0 + # zero-settle carve-outs don't trigger a PI sim. + deposit_address = ctx.recipients.get("tempo") or ctx.recipients.get("x402_base") or ctx.recipients.get("solana_mpp") + if deposit_address and outcome.tx_hash is not None: + await simulate_deposit_for_outcome( + outcome=outcome, + deposit_address=deposit_address, get_payment_intent_id=pi_cache.get_payment_intent_id, - deposit_address=ctx.recipients.get("x402_base", ""), - network="base", stripe_secret_key=STRIPE_SECRET_KEY, ) # Compose the canonical Receipt shape returned on 200. Goods merchants @@ -154,14 +158,15 @@ async def _on_settled(ctx: Any, outcome: SettleOutcome) -> dict[str, Any]: checkout = Checkout( - rails={ - # Per-order-mint pattern: empty-string `recipient` declares the rail - # in discovery; `mint_recipients` resolves the real per-PI address. - "tempo": TempoRailSpec(recipient=""), - "x402_base": X402BaseRailSpec(recipient="", network=X402_BASE_NETWORK), - "solana_mpp": SolanaMppRailSpec(recipient="", network=SOLANA_NETWORK_CAIP2), - "stripe": StripeRailSpec(profile_id=os.environ["STRIPE_PROFILE_ID"]), - }, + # Per-order-mint pattern: defaults supply network/chain_id/token + a + # ``recipient=""`` sentinel; ``mint_recipients`` resolves the real per-PI + # address at request time. + rails=build_default_checkout_rails( + tempo={}, + x402_base={"network": X402_BASE_NETWORK}, + solana_mpp={"network": SOLANA_NETWORK_CAIP2}, + stripe={"profile_id": os.environ["STRIPE_PROFILE_ID"]}, + ), url=f"{APP_URL}/purchase", pre_validate=_validate_purchase, compute_pricing=_compute_pricing, diff --git a/examples/per_product_policy_merchant.py b/examples/per_product_policy_merchant.py index 4ff9315..498b70b 100644 --- a/examples/per_product_policy_merchant.py +++ b/examples/per_product_policy_merchant.py @@ -44,6 +44,7 @@ TempoRailSpec, validate_shipping_against_policy, ) +from agentscore_commerce.middleware.fastapi import RateLimitMiddleware API_KEY = os.environ.get("AGENTSCORE_API_KEY", "ask_test_dummy") @@ -136,6 +137,7 @@ async def _on_settled(ctx: Any, outcome: SettleOutcome) -> dict[str, Any]: app = FastAPI() +app.add_middleware(RateLimitMiddleware) @app.post("/purchase") diff --git a/examples/signed_ucp_merchant.py b/examples/signed_ucp_merchant.py index 2d3edbe..2adb500 100644 --- a/examples/signed_ucp_merchant.py +++ b/examples/signed_ucp_merchant.py @@ -41,6 +41,7 @@ from agentscore_commerce import AgentScoreGatePolicy, Checkout, PricingResult, TempoRailSpec from agentscore_commerce.discovery import bootstrap_ucp_signing_key, default_a2a_services +from agentscore_commerce.middleware.fastapi import RateLimitMiddleware SIGNING_KID = "merchant-2026-05" @@ -63,6 +64,7 @@ async def lifespan(_app: FastAPI): app = FastAPI(lifespan=lifespan) +app.add_middleware(RateLimitMiddleware) checkout.mount_ucp_routes_fastapi( app, diff --git a/examples/stripe_multichain_merchant.py b/examples/stripe_multichain_merchant.py index bfcf6b0..44274e0 100644 --- a/examples/stripe_multichain_merchant.py +++ b/examples/stripe_multichain_merchant.py @@ -23,6 +23,7 @@ import stripe from fastapi import FastAPI +from agentscore_commerce.middleware.fastapi import RateLimitMiddleware from agentscore_commerce.stripe_multichain import ( STRIPE_TEST_TX_HASH_SUCCESS, create_multichain_payment_intent, @@ -32,6 +33,7 @@ stripe_client = stripe.StripeClient(os.environ["STRIPE_SECRET_KEY"]) app = FastAPI() +app.add_middleware(RateLimitMiddleware) @app.post("/checkout") diff --git a/examples/variable_cost_merchant.py b/examples/variable_cost_merchant.py deleted file mode 100644 index 029a0e3..0000000 --- a/examples/variable_cost_merchant.py +++ /dev/null @@ -1,192 +0,0 @@ -"""Example: variable-cost merchant supporting BOTH x402 upto AND MPP tempo session. - -Scenario: you sell something where the cost depends on output (LLM completions, -transcription, video transcode, etc.). You don't know the final price until the -work is done. Two protocols solve this; both are advertised on the 402 so -agents can pick whichever they support. - -x402 upto (one-shot) - * Agent signs Permit2 authorizing up to a max amount. - * Vendor does the work, knows actual cost after. - * Response sets ``Settlement-Overrides: {"amount":""}``. - * Facilitator settles for actual; difference auto-refunds. - -MPP tempo session (streaming) - * Agent opens a channel with on-chain deposit. - * Vendor streams output as SSE. - * Cumulative cost grows; vendor emits voucher requests. - * Agent signs each voucher mid-stream. - * Final settle on close reclaims unspent deposit. - -These flows are too custom to fit the one-shot ``Checkout(...)`` model: -``compute_pricing`` returns a single amount, but variable-cost discovers the -amount AFTER the request runs (upto) or grows it cumulatively (session). The -example keeps the 402-emit path custom (using ``build_402_body`` + -``build_accepted_methods`` + ``build_how_to_pay``) and the settle path manual; -vendors compose ``create_x402_server`` + Permit2 extensions or -``create_mppx_server`` (TempoSessionRailSpec) at the vendor layer. - -Peer deps: - pip install 'agentscore-commerce[fastapi,x402,mppx,coinbase]' - -Env vars: - APP_URL public URL of your service - MPP_SECRET_KEY random base64 - TEMPO_RECIPIENT your Tempo wallet - TEMPO_ESCROW your deployed escrow contract for channel deposits - X402_BASE_RECIPIENT your Base wallet (USDC payouts for upto rail) - -Run: uvicorn examples.variable_cost_merchant:app --port 3000 -""" - -from __future__ import annotations - -import asyncio -import os -from typing import Any -from urllib.parse import urlparse - -from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse - -from agentscore_commerce import TempoRailSpec, X402BaseRailSpec -from agentscore_commerce.challenge import ( - build_402_body, - build_accepted_methods, - build_agent_instructions, - build_how_to_pay, - build_pricing_block, -) -from agentscore_commerce.payment import ( - create_mppx_server, - create_x402_server, - payment_directive, - payment_required_header, - settlement_override_header, - www_authenticate_header, -) - -APP_URL = os.environ.get("APP_URL", "http://localhost:3000") -TEMPO_RECIPIENT = os.environ.get("TEMPO_RECIPIENT", "0xfeedface") -X402_BASE_RECIPIENT = os.environ.get("X402_BASE_RECIPIENT", "0xfeedface") -# MPP_SECRET_KEY + TEMPO_ESCROW would be read here in a full streaming -# implementation; the SSE handler below stubs to 501. See the docstring at -# the top of the file for the wire-shape and production wiring sketch. - -REALM = urlparse(APP_URL).hostname or "llm.example.com" -MAX_USDC = 0.5 # upper bound vendor advertises; actual bill <= this. -MAX_USDC_CENTS = round(MAX_USDC * 100) - -app = FastAPI() - - -# Boot the x402 server for the Permit2 (upto) rail. The MPP server boot -# parallel is sketched below — pympp doesn't yet ship a Python-native session -# implementation, so the SSE handler returns 501 with the wire-shape sketched. -async def _boot_x402_server() -> Any: - return await create_x402_server(facilitator="http", rails=["x402-base-mainnet-upto"]) - - -async def _build_402_body(url: str) -> tuple[dict[str, Any], dict[str, str]]: - challenge_id = f"chg_{int(asyncio.get_event_loop().time() * 1000)}" - directives = [ - payment_directive(rail="x402-base-mainnet-upto", id=f"{challenge_id}_upto", realm=REALM, request=""), - payment_directive( - rail="tempo-mainnet", - id=f"{challenge_id}_session", - realm=REALM, - intent="session", - request="", - ), - ] - - x402_spec = X402BaseRailSpec(recipient=X402_BASE_RECIPIENT) - tempo_spec = TempoRailSpec(recipient=TEMPO_RECIPIENT) - accepted = await build_accepted_methods(x402_base=x402_spec, tempo=tempo_spec) - how_to_pay = await build_how_to_pay( - url=url, - retry_body_json='{"prompt":""}', - total_usd=f"{MAX_USDC:.2f}", - rails={"x402_base": x402_spec, "tempo": tempo_spec}, - max_spend=MAX_USDC, - ) - instructions = build_agent_instructions( - how_to_pay=how_to_pay, - warnings=[ - "Cost is variable; final amount depends on output length.", - "For one-shot completions use x402 upto. For long streams use tempo session.", - ], - ) - - # For variable-cost work, advertise the upper bound as `subtotal` and let - # the vendor charge <= that. The actual amount lands via - # Settlement-Overrides (x402 upto) or the highest voucher signed mid-stream - # (tempo session). - body = build_402_body( - product={"id": "llm-completion", "name": "LLM completion"}, - accepted_methods=accepted, - pricing=build_pricing_block(subtotal_cents=MAX_USDC_CENTS, currency="USD"), - agent_instructions=instructions, - amount_usd=f"{MAX_USDC:.2f}", - currency="USD", - retry_body={"prompt": ""}, - ) - headers = { - "www-authenticate": www_authenticate_header(directives), - # x402 wire requires the body to also appear as base64 in this header; - # spec-strict clients (Coinbase awal, purl) parse it before falling - # back to the JSON body. - "PAYMENT-REQUIRED": payment_required_header(x402_version=2, accepts=[], resource={"url": url}), - } - return body, headers - - -async def _run_your_llm(_prompt: str) -> tuple[str, int]: - return "completion text here", 1234 - - -@app.post("/llm/complete") -async def complete(request: Request) -> JSONResponse: - """x402 upto path: single JSON response with Settlement-Overrides.""" - # x402 carries the credential in either `x-payment` or `payment-signature` - # depending on client (purl uses payment-signature; awal uses x-payment). - if not (request.headers.get("x-payment") or request.headers.get("payment-signature")): - body, headers = await _build_402_body(str(request.url)) - return JSONResponse(body, status_code=402, headers=headers) - - body = await request.json() - text, tokens_used = await _run_your_llm(body.get("prompt", "")) - - actual_usd = tokens_used * 0.000_002 # $2 per 1M tokens - actual_atomic = str(int(actual_usd * 1_000_000)) # USDC atomic units - - # Tell the facilitator to settle for `actual_atomic` instead of the - # authorized max. The Permit2 layer auto-refunds the difference. - name, value = settlement_override_header(amount=actual_atomic) - return JSONResponse( - {"text": text, "tokens_used": tokens_used, "charged_usd": actual_usd}, - headers={name: value}, - ) - - -@app.post("/llm/stream") -async def stream(request: Request) -> JSONResponse: - """MPP tempo session path: agent opens channel, server streams SSE with mid-stream vouchers. - - Production wiring: ``mpp = await create_mppx_server(secret_key=MPP_SECRET_KEY, rails={ - "tempo_session": TempoSessionRailSpec(recipient=TEMPO_RECIPIENT, - escrow_contract=TEMPO_ESCROW, store=YourChannelStore())})``; parse channel state - from ``Authorization: Payment``, emit SSE chunks, request fresh voucher signatures - as cumulative cost grows, close channel on completion. - """ - if not request.headers.get("authorization"): - body, headers = await _build_402_body(str(request.url)) - return JSONResponse(body, status_code=402, headers=headers) - return JSONResponse({"error": "stream-not-implemented"}, status_code=501) - - -# `_boot_x402_server` + `create_mppx_server` are imported as references for -# vendors wiring real x402/MPP servers; the example handlers above don't call -# them directly. Vendors call `await _boot_x402_server()` in their lifespan and -# bind `await create_mppx_server(...)` to a module-level singleton. -__all__ = ["_boot_x402_server", "app", "create_mppx_server"] diff --git a/lefthook.yml b/lefthook.yml index d780fe8..addcba2 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -14,4 +14,4 @@ pre-push: ty: run: uv run ty check agentscore_commerce/ vulture: - run: uv run vulture . vulture_whitelist.py --min-confidence 80 --exclude .venv + run: uv run vulture . --min-confidence 80 --exclude .venv diff --git a/pyproject.toml b/pyproject.toml index 0930b25..ab0357e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-commerce" -version = "2.0.2" +version = "2.1.0" description = "Agent 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 agent commerce." readme = "README.md" license = "MIT" @@ -44,6 +44,7 @@ x402 = ["x402[evm,fastapi]>=2.9,<3"] mppx = ["pympp[server,tempo,stripe]>=0.6,<1"] coinbase = ["cdp-sdk>=1.0,<2"] ucp = ["joserfc>=1.0.0,<2"] +redis = ["redis>=5.0,<7"] [project.urls] Homepage = "https://agentscore.sh" @@ -72,11 +73,26 @@ dev = [ "lefthook>=2.1.6", "cdp-sdk>=1.0,<2", "joserfc>=1.0.0,<2", + "redis>=5.0,<7", ] [tool.ty.src] include = ["agentscore_commerce"] +[tool.vulture] +# Live symbols vulture sees as unused. ASGI signatures, Protocol method params, +# public re-exports, TYPE_CHECKING-only imports referenced via string casts. +# Replaces a top-level vulture_whitelist.py file (which CodeQuality flagged +# as "statement has no effect" — accurate observation, but vulture's whitelist +# semantics require bare identifiers, which static analyzers can't distinguish +# from real no-op statements). +ignore_names = [ + "scope", "receive", "send", # ASGI middleware __call__ signature + "ex", "seconds", # _RedisLike Protocol method params + "AgentScoreGate", "AssessResult", "DenialReason", "OperatorVerification", + "DecisionPolicy", "Signer", # TYPE_CHECKING string-cast references +] + [tool.pytest.ini_options] asyncio_mode = "auto" addopts = "--cov=agentscore_commerce --cov-report=term-missing --cov-fail-under=95" diff --git a/tests/test_a2a.py b/tests/test_a2a.py index a8a21a3..baf6b74 100644 --- a/tests/test_a2a.py +++ b/tests/test_a2a.py @@ -1,8 +1,12 @@ -"""Tests for build_a2a_agent_card (A2A v1.0 spec compliance).""" +"""Tests for build_a2a_agent_card (A2A v1.0 wire format).""" + +import json import pytest from agentscore_commerce.identity import ( + A2A_DEFAULT_TRANSPORT, + A2A_PROTOCOL_VERSION, UCP_A2A_EXTENSION_URI, A2AAgentCardCapabilities, A2AAgentCardExtension, @@ -31,24 +35,56 @@ def test_minimum_required_fields_emitted(): skills=[_DEFAULT_SKILL], ) d = card.to_dict() - # Per spec §4.4.1: name, description, supported_interfaces, version, - # capabilities, default_input_modes, default_output_modes, skills are REQUIRED. assert d["name"] == "Example Merchant" assert d["description"] == "Buy regulated goods via agent payments." - assert isinstance(d["supported_interfaces"], list) - assert len(d["supported_interfaces"]) == 1 - assert d["supported_interfaces"][0]["url"] == "https://agents.example.com" - assert d["supported_interfaces"][0]["protocol_binding"] == "HTTP+JSON" - assert d["supported_interfaces"][0]["protocol_version"] == "1.0" + assert d["url"] == "https://agents.example.com" + assert d["preferredTransport"] == "HTTP+JSON" + assert d["protocolVersion"] == "1.0" assert d["version"] == "1.0.0" assert d["capabilities"] == {} - assert d["default_input_modes"] == ["application/json"] - assert d["default_output_modes"] == ["application/json"] + assert d["defaultInputModes"] == ["application/json"] + assert d["defaultOutputModes"] == ["application/json"] assert len(d["skills"]) == 1 + assert "additionalInterfaces" not in d + + +def test_emits_only_camelcase_keys(): + """Canonical A2A wire format is camelCase. No snake_case keys must leak through to_dict().""" + card = build_a2a_agent_card( + name="X", + description="y", + url="https://x.example", + skills=[_DEFAULT_SKILL], + push_notifications=True, + state_transition_history=False, + documentation_url="https://docs.example", + icon_url="https://x.example/icon.png", + supports_authenticated_extended_card=True, + ) + serialized = json.dumps(card.to_dict()) + for bad in ( + "supported_interfaces", + "protocol_binding", + "protocol_version", + "default_input_modes", + "default_output_modes", + "documentation_url", + "icon_url", + "push_notifications", + "state_transition_history", + "extended_agent_card", + "security_schemes", + "security_requirements", + "input_modes", + "output_modes", + "supports_authenticated_extended_card", + "additional_interfaces", + "preferred_transport", + ): + assert bad not in serialized, f"snake_case key {bad!r} leaked into wire format" def test_skills_required_non_empty(): - """Per spec §4.4.1 (proto field 12 [field_behavior=REQUIRED]): skills MUST be non-empty.""" with pytest.raises(ValueError, match="MUST be a non-empty list"): build_a2a_agent_card( name="X", @@ -59,8 +95,6 @@ def test_skills_required_non_empty(): def test_does_not_emit_invented_fields(): - """Confirms we no longer emit `protocol_version` / `card_version` / `endpoints` / - top-level `identity` / top-level `extensions` — none of these exist in the A2A proto.""" card = build_a2a_agent_card( name="X", description="y", @@ -68,14 +102,14 @@ def test_does_not_emit_invented_fields(): skills=[_DEFAULT_SKILL], ) d = card.to_dict() - assert "protocol_version" not in d - assert "card_version" not in d + assert "supported_interfaces" not in d assert "endpoints" not in d assert "identity" not in d + assert "card_version" not in d assert "extensions" not in d # extensions live INSIDE capabilities -def test_skills_serialize_as_top_level_objects_not_strings(): +def test_skills_serialize_as_top_level_objects(): card = build_a2a_agent_card( name="X", description="y", @@ -100,7 +134,7 @@ def test_skills_serialize_as_top_level_objects_not_strings(): ] -def test_extensions_live_inside_capabilities_not_top_level(): +def test_extensions_live_inside_capabilities(): card = build_a2a_agent_card( name="X", description="y", @@ -109,7 +143,7 @@ def test_extensions_live_inside_capabilities_not_top_level(): extensions=[ucp_a2a_extension()], ) d = card.to_dict() - assert "extensions" not in d # NOT at top level + assert "extensions" not in d assert "extensions" in d["capabilities"] assert len(d["capabilities"]["extensions"]) == 1 assert d["capabilities"]["extensions"][0]["uri"] == UCP_A2A_EXTENSION_URI @@ -134,12 +168,12 @@ def test_capability_flags_emitted_when_set(): skills=[_DEFAULT_SKILL], streaming=True, push_notifications=False, - extended_agent_card=True, + state_transition_history=True, ) caps = card.to_dict()["capabilities"] assert caps["streaming"] is True - assert caps["push_notifications"] is False - assert caps["extended_agent_card"] is True + assert caps["pushNotifications"] is False + assert caps["stateTransitionHistory"] is True def test_capability_flags_omitted_when_unset(): @@ -151,8 +185,21 @@ def test_capability_flags_omitted_when_unset(): ) caps = card.to_dict()["capabilities"] assert "streaming" not in caps - assert "push_notifications" not in caps - assert "extended_agent_card" not in caps + assert "pushNotifications" not in caps + assert "stateTransitionHistory" not in caps + + +def test_supports_authenticated_extended_card_at_top_level(): + card = build_a2a_agent_card( + name="X", + description="y", + url="https://x.example", + skills=[_DEFAULT_SKILL], + supports_authenticated_extended_card=True, + ) + d = card.to_dict() + assert d["supportsAuthenticatedExtendedCard"] is True + assert "supportsAuthenticatedExtendedCard" not in d["capabilities"] def test_provider_emitted_when_set(): @@ -161,12 +208,12 @@ def test_provider_emitted_when_set(): description="y", url="https://x.example", skills=[_DEFAULT_SKILL], - provider=A2AAgentProvider(url="https://acme.example", organization="Acme"), + provider=A2AAgentProvider(organization="Acme", url="https://acme.example"), ) - assert card.to_dict()["provider"] == {"url": "https://acme.example", "organization": "Acme"} + assert card.to_dict()["provider"] == {"organization": "Acme", "url": "https://acme.example"} -def test_documentation_url_emitted_when_set(): +def test_documentation_url_emitted_as_camelcase(): card = build_a2a_agent_card( name="X", description="y", @@ -174,10 +221,12 @@ def test_documentation_url_emitted_when_set(): skills=[_DEFAULT_SKILL], documentation_url="https://docs.example", ) - assert card.to_dict()["documentation_url"] == "https://docs.example" + d = card.to_dict() + assert d["documentationUrl"] == "https://docs.example" + assert "documentation_url" not in d -def test_icon_url_emitted_when_set(): +def test_icon_url_emitted_as_camelcase(): card = build_a2a_agent_card( name="X", description="y", @@ -185,7 +234,9 @@ def test_icon_url_emitted_when_set(): skills=[_DEFAULT_SKILL], icon_url="https://x.example/icon.png", ) - assert card.to_dict()["icon_url"] == "https://x.example/icon.png" + d = card.to_dict() + assert d["iconUrl"] == "https://x.example/icon.png" + assert "icon_url" not in d def test_signatures_emitted_when_set(): @@ -222,21 +273,51 @@ def test_default_input_output_modes_overridable(): default_output_modes=["text/plain"], ) d = card.to_dict() - assert d["default_input_modes"] == ["text/plain", "application/json"] - assert d["default_output_modes"] == ["text/plain"] + assert d["defaultInputModes"] == ["text/plain", "application/json"] + assert d["defaultOutputModes"] == ["text/plain"] + + +def test_preferred_transport_overridable(): + card = build_a2a_agent_card( + name="X", + description="y", + url="https://x.example", + skills=[_DEFAULT_SKILL], + preferred_transport="GRPC", + protocol_version="1.0", + ) + d = card.to_dict() + assert d["preferredTransport"] == "GRPC" -def test_protocol_binding_overridable(): +def test_additional_interfaces_emitted_when_set(): + ifaces = [ + A2AAgentInterface(transport="GRPC", url="https://x.example/grpc"), + A2AAgentInterface(transport="JSONRPC", url="https://x.example/jsonrpc"), + ] card = build_a2a_agent_card( name="X", description="y", url="https://x.example", skills=[_DEFAULT_SKILL], - protocol_binding="GRPC", - a2a_protocol_version="1.0", + additional_interfaces=ifaces, ) - iface = card.to_dict()["supported_interfaces"][0] - assert iface["protocol_binding"] == "GRPC" + d = card.to_dict() + assert d["additionalInterfaces"] == [ + {"transport": "GRPC", "url": "https://x.example/grpc"}, + {"transport": "JSONRPC", "url": "https://x.example/jsonrpc"}, + ] + + +def test_additional_interfaces_omitted_when_empty(): + card = build_a2a_agent_card( + name="X", + description="y", + url="https://x.example", + skills=[_DEFAULT_SKILL], + additional_interfaces=[], + ) + assert "additionalInterfaces" not in card.to_dict() def test_extras_merge_at_top_level(): @@ -245,27 +326,31 @@ def test_extras_merge_at_top_level(): description="y", url="https://x.example", skills=[_DEFAULT_SKILL], - extras={"vendor_field": 42}, + extras={"vendorField": 42}, ) - assert card.to_dict()["vendor_field"] == 42 + assert card.to_dict()["vendorField"] == 42 -def test_security_schemes_emitted_when_set(): +def test_security_and_security_schemes_emitted_camelcase(): card = build_a2a_agent_card( name="X", description="y", url="https://x.example", skills=[_DEFAULT_SKILL], + security=[{"bearer": []}], security_schemes={"bearer": {"type": "http", "scheme": "bearer"}}, ) - assert card.to_dict()["security_schemes"] == {"bearer": {"type": "http", "scheme": "bearer"}} + d = card.to_dict() + assert d["security"] == [{"bearer": []}] + assert d["securitySchemes"] == {"bearer": {"type": "http", "scheme": "bearer"}} + assert "security_schemes" not in d + assert "security_requirements" not in d # ---- AgentExtension shape ---- def test_agent_extension_emits_required_fields(): - """Per spec §4.4.4: AgentExtension MUST carry uri, description, required.""" ext = A2AAgentCardExtension(uri="https://example/ext", description="test", required=True) d = ext.to_dict() assert d["uri"] == "https://example/ext" @@ -295,11 +380,16 @@ def test_ucp_a2a_extension_uri_pinned(): assert UCP_A2A_EXTENSION_URI == "https://ucp.dev/2026-04-08/specification/reference" +def test_a2a_constants_exported(): + assert A2A_PROTOCOL_VERSION == "1.0" + assert A2A_DEFAULT_TRANSPORT == "JSONRPC" + + def test_ucp_a2a_extension_default_args_emit_empty_capabilities(): ext = ucp_a2a_extension() d = ext.to_dict() assert d["uri"] == UCP_A2A_EXTENSION_URI - assert d["description"] # non-empty per spec + assert d["description"] assert d["required"] is False assert d["params"] == {"capabilities": {}} @@ -325,28 +415,9 @@ def test_ucp_a2a_extension_required_kwarg(): # ---- AgentInterface ---- -def test_agent_interface_emits_required_fields(): - iface = A2AAgentInterface( - url="https://x.example", - protocol_binding="JSONRPC", - protocol_version="1.0", - ) - d = iface.to_dict() - assert d == { - "url": "https://x.example", - "protocol_binding": "JSONRPC", - "protocol_version": "1.0", - } - - -def test_agent_interface_tenant_emitted_when_set(): - iface = A2AAgentInterface( - url="https://x.example", - protocol_binding="JSONRPC", - protocol_version="1.0", - tenant="tenant-123", - ) - assert iface.to_dict()["tenant"] == "tenant-123" +def test_agent_interface_emits_canonical_shape(): + iface = A2AAgentInterface(transport="JSONRPC", url="https://x.example") + assert iface.to_dict() == {"transport": "JSONRPC", "url": "https://x.example"} # ---- AgentSkill ---- @@ -362,11 +433,12 @@ def test_agent_skill_optional_fields_omitted_when_empty(): s = A2AAgentSkill(id="x", name="X", description="d", tags=["t"]) d = s.to_dict() assert "examples" not in d - assert "input_modes" not in d - assert "output_modes" not in d + assert "inputModes" not in d + assert "outputModes" not in d + assert "security" not in d -def test_agent_skill_optional_fields_emitted_when_set(): +def test_agent_skill_optional_fields_emitted_camelcase(): s = A2AAgentSkill( id="x", name="X", @@ -375,11 +447,15 @@ def test_agent_skill_optional_fields_emitted_when_set(): examples=["buy a wine"], input_modes=["application/json"], output_modes=["text/plain"], + security=[{"bearer": []}], ) d = s.to_dict() assert d["examples"] == ["buy a wine"] - assert d["input_modes"] == ["application/json"] - assert d["output_modes"] == ["text/plain"] + assert d["inputModes"] == ["application/json"] + assert d["outputModes"] == ["text/plain"] + assert d["security"] == [{"bearer": []}] + assert "input_modes" not in d + assert "output_modes" not in d # ---- AgentCardSignature ---- @@ -403,24 +479,28 @@ def test_agent_card_signature_unprotected_header_emitted_when_set(): # ---- Direct AgentCard construction (multi-binding agents) ---- -def test_direct_agent_card_construction_with_multiple_interfaces(): +def test_direct_agent_card_construction_with_additional_interfaces(): card = A2AAgentCard( name="X", description="y", - supported_interfaces=[ - A2AAgentInterface(url="https://x.example/jsonrpc", protocol_binding="JSONRPC", protocol_version="1.0"), - A2AAgentInterface(url="https://x.example/grpc", protocol_binding="GRPC", protocol_version="1.0"), - ], + url="https://x.example", + protocol_version="1.0", version="1.0.0", capabilities=A2AAgentCardCapabilities(), default_input_modes=["application/json"], default_output_modes=["application/json"], skills=[_DEFAULT_SKILL], + preferred_transport="HTTP+JSON", + additional_interfaces=[ + A2AAgentInterface(transport="JSONRPC", url="https://x.example/jsonrpc"), + A2AAgentInterface(transport="GRPC", url="https://x.example/grpc"), + ], ) d = card.to_dict() - assert len(d["supported_interfaces"]) == 2 - assert d["supported_interfaces"][0]["protocol_binding"] == "JSONRPC" - assert d["supported_interfaces"][1]["protocol_binding"] == "GRPC" + assert d["url"] == "https://x.example" + assert d["preferredTransport"] == "HTTP+JSON" + assert len(d["additionalInterfaces"]) == 2 + assert d["additionalInterfaces"][0]["transport"] == "JSONRPC" @pytest.mark.parametrize( @@ -428,8 +508,6 @@ def test_direct_agent_card_construction_with_multiple_interfaces(): ["name", "description", "url", "skills"], ) def test_required_kwargs_enforced(missing_kwarg: str) -> None: - """Per spec §4.4.1: name, description, supported_interfaces (built from url), and - skills (≥1) are REQUIRED. Missing one raises TypeError or ValueError.""" kwargs: dict = { "name": "X", "description": "y", diff --git a/tests/test_a2a_jws_roundtrip.py b/tests/test_a2a_jws_roundtrip.py new file mode 100644 index 0000000..7ea36b3 --- /dev/null +++ b/tests/test_a2a_jws_roundtrip.py @@ -0,0 +1,118 @@ +"""JWS round-trip for A2A Agent Card signatures (RFC 7515). + +Per A2A spec §4.4.7, the card body is signed without ``signatures``, the +signature is computed over the canonical serialization, then attached back as +one of ``card["signatures"][]``. Verifiers reconstruct the body without +``signatures`` and verify each entry against the merchant's published JWKS. + +This test proves we can sign and verify an unsigned card produced by +``build_a2a_agent_card`` end-to-end. +""" + +from __future__ import annotations + +import json +import warnings + +import pytest + +from agentscore_commerce.identity.a2a import ( + A2AAgentCardSignature, + A2AAgentSkill, + build_a2a_agent_card, + ucp_a2a_extension, +) + +# joserfc emits a SecurityWarning for EdDSA per RFC 9864; suppress at sign/verify +# time. Mirrors agentscore_commerce.identity.ucp_jwks pattern. +warnings.filterwarnings("ignore", message="EdDSA is deprecated") + + +def _sign_card(card: dict, private_jwk: dict, kid: str) -> A2AAgentCardSignature: + """Sign the card body MINUS `signatures` and return one AgentCardSignature.""" + from joserfc import jws + from joserfc.jwk import OKPKey + from joserfc.jws import JWSRegistry + + body_without_sigs = {k: v for k, v in card.items() if k != "signatures"} + payload = json.dumps(body_without_sigs).encode("utf-8") + key = OKPKey.import_key(private_jwk) + header = {"alg": "EdDSA", "kid": kid} + registry = JWSRegistry(algorithms=["EdDSA"]) + compact = jws.serialize_compact(header, payload, key, registry=registry) + protected_b64, _payload_b64, signature_b64 = compact.split(".") + return A2AAgentCardSignature(protected=protected_b64, signature=signature_b64) + + +def _verify_card(card: dict, public_jwk: dict) -> bool: + import base64 + + from joserfc import jws + from joserfc.jwk import OKPKey + from joserfc.jws import JWSRegistry + + sigs = card.get("signatures") or [] + if not sigs: + msg = "card has no signatures" + raise ValueError(msg) + sig = sigs[0] + body_without_sigs = {k: v for k, v in card.items() if k != "signatures"} + payload = json.dumps(body_without_sigs).encode("utf-8") + + payload_b64 = base64.urlsafe_b64encode(payload).rstrip(b"=").decode("ascii") + compact_jws = f"{sig['protected']}.{payload_b64}.{sig['signature']}" + key = OKPKey.import_key(public_jwk) + registry = JWSRegistry(algorithms=["EdDSA"]) + jws.deserialize_compact(compact_jws, key, registry=registry) + return True + + +@pytest.fixture +def jws_keypair() -> tuple[dict, dict]: + from joserfc.jwk import OKPKey + + key = OKPKey.generate_key("Ed25519", private=True) + private = key.as_dict(private=True) + public = key.as_dict(private=False) + return private, public + + +def test_signs_and_verifies_an_unsigned_card(jws_keypair: tuple[dict, dict]) -> None: + private_jwk, public_jwk = jws_keypair + card_obj = build_a2a_agent_card( + name="Example Merchant", + description="Buy products via agent payments.", + url="https://agents.example.com", + version="1.0.0", + skills=[ + A2AAgentSkill( + id="purchase", + name="Purchase", + description="Buy products via agent payments.", + tags=["commerce", "payment"], + ), + ], + extensions=[ucp_a2a_extension()], + ) + unsigned = card_obj.to_dict() + signature = _sign_card(unsigned, private_jwk, "merchant-key-1") + signed = {**unsigned, "signatures": [signature.to_dict()]} + assert _verify_card(signed, public_jwk) is True + + +def test_verification_fails_when_body_is_tampered(jws_keypair: tuple[dict, dict]) -> None: + private_jwk, public_jwk = jws_keypair + card_obj = build_a2a_agent_card( + name="Example", + description="d", + url="https://x.example", + skills=[A2AAgentSkill(id="p", name="P", description="d", tags=["t"])], + ) + unsigned = card_obj.to_dict() + signature = _sign_card(unsigned, private_jwk, "k1") + tampered = {**unsigned, "description": "tampered", "signatures": [signature.to_dict()]} + + from joserfc.errors import BadSignatureError + + with pytest.raises(BadSignatureError): + _verify_card(tampered, public_jwk) diff --git a/tests/test_amounts.py b/tests/test_amounts.py index 3452ead..b1c24e1 100644 --- a/tests/test_amounts.py +++ b/tests/test_amounts.py @@ -145,3 +145,34 @@ def test_bool_decimals_rejected() -> None: """``bool`` is a subclass of ``int`` in Python; reject explicitly to avoid surprise.""" with pytest.raises(ValueError, match="non-negative int"): usd_to_atomic("1.00", decimals=True) # type: ignore[arg-type] + + +# ─── format_usd_cents ─────────────────────────────────────────────────────── + + +def test_format_usd_cents_default_2_decimals() -> None: + from agentscore_commerce.payment import format_usd_cents + + assert format_usd_cents(0) == "0.00" + assert format_usd_cents(5) == "0.05" + assert format_usd_cents(500) == "5.00" + assert format_usd_cents(7500) == "75.00" + + +def test_format_usd_cents_negative() -> None: + from agentscore_commerce.payment import format_usd_cents + + assert format_usd_cents(-50) == "-0.50" + + +def test_format_usd_cents_sub_cent_precision() -> None: + """Sub-cent unit pricing (per-token / per-byte) needs more than 2 decimals.""" + from agentscore_commerce.payment import format_usd_cents + + # 0.05 cents = $0.0005. Default precision rounds to "0.00"; decimals=4 preserves it. + assert format_usd_cents(0.05) == "0.00" + assert format_usd_cents(0.05, 4) == "0.0005" + # Integer cents with raised precision pad with zeros. + assert format_usd_cents(5, 4) == "0.0500" + # Per-token pricing: $0.000002/token * 1234 tokens = $0.002468. + assert format_usd_cents(0.2468, 6) == "0.002468" diff --git a/tests/test_challenge.py b/tests/test_challenge.py index 297a3ab..5090d5c 100644 --- a/tests/test_challenge.py +++ b/tests/test_challenge.py @@ -91,6 +91,20 @@ def test_build_identity_metadata_token_mode_only_returns_mode(): assert md == {"identity_mode": "operator_token"} +@pytest.mark.asyncio +async def test_build_how_to_pay_honors_decimals_for_sub_cent_totals(): + # $0.0005 total → default 2-decimal precision would round to "0.00"; + # with decimals=4 the agent sees the real cap. + out = await build_how_to_pay( + url="https://ex.com/buy", + retry_body_json="{}", + total_usd=0.0005, + decimals=4, + rails={"x402_base": X402BaseRailSpec(recipient="0xB")}, + ) + assert "--max-spend 0.0005" in out["x402_base"]["command"] + + @pytest.mark.asyncio async def test_build_how_to_pay_emits_per_rail_blocks(): out = await build_how_to_pay( diff --git a/tests/test_checkout_compute_first.py b/tests/test_checkout_compute_first.py new file mode 100644 index 0000000..f91154b --- /dev/null +++ b/tests/test_checkout_compute_first.py @@ -0,0 +1,230 @@ +"""Smoke tests for ``compute_first_checkout`` covering probe + settle flows.""" + +from typing import Any + +import pytest + +from agentscore_commerce.checkout_compute_first import ( + ComputeFirstCheckout, + ComputeFirstRails, + ComputeFirstRequest, + ComputeFirstWorkContext, + WorkOutcome, +) +from agentscore_commerce.payment.rail_spec import TempoRailSpec, X402BaseRailSpec + + +def _make_rails() -> ComputeFirstRails: + return ComputeFirstRails( + tempo=TempoRailSpec(recipient="0xtempo", testnet=True), + x402_base=X402BaseRailSpec(recipient="0xbase", network="eip155:84532"), + ) + + +async def _run_one_result(_body: dict[str, Any], _ctx: ComputeFirstWorkContext) -> WorkOutcome: + return WorkOutcome(result_count=1, body={"matches": ["one"], "total": 1}) + + +async def _run_zero_results(_body: dict[str, Any], _ctx: ComputeFirstWorkContext) -> WorkOutcome: + return WorkOutcome(result_count=0, body={"matches": [], "total": 0}) + + +def _build_request(headers: dict[str, str] | None = None) -> ComputeFirstRequest: + return ComputeFirstRequest( + method="POST", + url="https://api.example.com/search", + headers=headers or {}, + body={"q": "test"}, + ) + + +@pytest.mark.asyncio +async def test_zero_result_fast_path_returns_200_no_charge() -> None: + handler = ComputeFirstCheckout( + name="search", + url="https://api.example.com/search", + unit_price_cents=1, + rails=_make_rails(), + x402_server=None, + run_work=_run_zero_results, + ) + status, body, _headers = await handler.handle(_build_request()) + assert status == 200 + assert body["payment_status"] == "no_charge" + assert body["charged_usd"] == "0.00" + + +@pytest.mark.asyncio +async def test_validate_input_raises_returns_4xx_envelope() -> None: + from agentscore_commerce.checkout import CheckoutValidationError + + def _validate(body: dict[str, Any]) -> None: + if "q" not in body: + raise CheckoutValidationError(code="missing_q", message="`q` is required.") + + handler = ComputeFirstCheckout( + name="search", + url="https://api.example.com/search", + unit_price_cents=1, + rails=_make_rails(), + x402_server=None, + run_work=_run_zero_results, + validate_input=_validate, + ) + status, body, _headers = await handler.handle( + ComputeFirstRequest(method="POST", url="https://x", headers={}, body={}) + ) + assert status == 400 + assert body["error"]["code"] == "missing_q" + + +@pytest.mark.asyncio +async def test_settle_leg_with_no_cached_quote_returns_stale_quote() -> None: + handler = ComputeFirstCheckout( + name="search", + url="https://api.example.com/search", + unit_price_cents=1, + rails=_make_rails(), + x402_server=None, + run_work=_run_one_result, + ) + # Settle leg simulated by sending payment-signature header without a prior probe. + status, body, _headers = await handler.handle(_build_request(headers={"payment-signature": ""})) + assert status == 400 + assert body["error"]["code"] == "stale_quote" + assert body["next_steps"]["action"] == "re_probe" + + +@pytest.mark.asyncio +async def test_mpp_settle_with_no_compose_hook_returns_503() -> None: + handler = ComputeFirstCheckout( + name="search", + url="https://api.example.com/search", + unit_price_cents=1, + rails=_make_rails(), + x402_server=None, + run_work=_run_one_result, + ) + # First do probe to seed cache + await handler.handle(_build_request()) + # Now settle on MPP — but no compose_mppx wired → 503 mpp_unavailable + status, body, _headers = await handler.handle(_build_request(headers={"authorization": "Payment "})) + assert status == 503 + assert body["error"]["code"] == "mpp_unavailable" + + +@pytest.mark.asyncio +async def test_upstream_runwork_error_returns_200_no_charge() -> None: + async def _broken(_body: dict[str, Any], _ctx: ComputeFirstWorkContext) -> WorkOutcome: + raise RuntimeError("upstream blew up") + + handler = ComputeFirstCheckout( + name="search", + url="https://api.example.com/search", + unit_price_cents=1, + rails=_make_rails(), + x402_server=None, + run_work=_broken, + ) + status, body, _headers = await handler.handle(_build_request()) + assert status == 200 + assert body["payment_status"] == "no_charge" + assert body["error"]["code"] == "upstream_failed" + + +@pytest.mark.asyncio +async def test_probe_leg_emits_402_with_pricing_and_retry_body() -> None: + """Exercise the _emit_402 path — work returns 1 result, probe caches + + emits a 402 with accepted methods, pricing block, retry_body.""" + + handler = ComputeFirstCheckout( + name="search", + url="https://api.example.com/search", + unit_price_cents=3, + rails=ComputeFirstRails( + tempo=TempoRailSpec(recipient="0xtempo", testnet=True), + ), + x402_server=None, + run_work=_run_one_result, + ) + status, body, headers = await handler.handle(_build_request()) + assert status == 402 + assert body["amount_usd"] == "0.03" + assert body["pricing"]["subtotal"] == "0.03" + assert body["retry_body"] == {"q": "test"} + assert headers["Content-Type"] == "application/json" + + +@pytest.mark.asyncio +async def test_probe_leg_cache_hit_skips_run_work() -> None: + """Second probe with the same body re-uses the cached price + body.""" + calls = [] + + async def _record(body: dict[str, Any], _ctx: ComputeFirstWorkContext) -> WorkOutcome: + calls.append(body) + return WorkOutcome(result_count=2, body={"matches": ["a", "b"], "total": 2}) + + handler = ComputeFirstCheckout( + name="search", + url="https://api.example.com/search", + unit_price_cents=5, + rails=ComputeFirstRails( + tempo=TempoRailSpec(recipient="0xtempo", testnet=True), + ), + x402_server=None, + run_work=_record, + ) + # First probe runs work, caches. + status1, body1, _h1 = await handler.handle(_build_request()) + # Second probe with same body hits cache; run_work NOT called again. + status2, body2, _h2 = await handler.handle(_build_request()) + assert status1 == status2 == 402 + assert body1["amount_usd"] == body2["amount_usd"] == "0.10" + assert len(calls) == 1 + + +@pytest.mark.asyncio +async def test_fractional_unit_price_auto_derives_decimals() -> None: + """Sub-cent pricing — auto-derive precision from unit_price_cents.""" + + handler = ComputeFirstCheckout( + name="tokens", + url="https://api.example.com/tokens", + unit_price_cents=0.0001, # $0.000001 per unit + rails=ComputeFirstRails( + tempo=TempoRailSpec(recipient="0xtempo", testnet=True), + ), + x402_server=None, + run_work=_run_one_result, + ) + assert handler.decimals == 6 # 2 + 4 fractional digits + + +@pytest.mark.asyncio +async def test_minted_recipients_override_static_rail_recipient() -> None: + """mint_recipients hook output replaces the static `rails[*].recipient`.""" + from agentscore_commerce.checkout_compute_first import ( + ComputeFirstMintContext, + MintedRecipients, + ) + + async def _mint(_ctx: ComputeFirstMintContext) -> MintedRecipients: + return MintedRecipients(tempo="0xMINTED", x402_base="0xMINTEDBASE") + + handler = ComputeFirstCheckout( + name="search", + url="https://api.example.com/search", + unit_price_cents=1, + rails=ComputeFirstRails( + tempo=TempoRailSpec(recipient="0xstatic", testnet=True), + ), + x402_server=None, + run_work=_run_one_result, + mint_recipients=_mint, + ) + _status, body, _h = await handler.handle(_build_request()) + # 402 body's accepted_methods should reference the minted recipient + assert body["amount_usd"] == "0.01" + methods = body.get("accepted_methods") or [] + tempo_method = next((m for m in methods if "tempo" in str(m).lower()), None) + assert tempo_method is not None diff --git a/tests/test_checkout_compute_first_adapters.py b/tests/test_checkout_compute_first_adapters.py new file mode 100644 index 0000000..fdcbd44 --- /dev/null +++ b/tests/test_checkout_compute_first_adapters.py @@ -0,0 +1,180 @@ +"""Tests for the per-framework `handle_*` adapter methods on ComputeFirstCheckout. + +Each test exercises body parsing, header reading, and response shaping for the +specific framework, hitting the adapter code paths that the framework-neutral +`handle()` doesn't cover. +""" + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from agentscore_commerce.checkout_compute_first import ( + ComputeFirstCheckout, + ComputeFirstRails, + ComputeFirstWorkContext, + WorkOutcome, +) +from agentscore_commerce.payment.rail_spec import TempoRailSpec + + +async def _run_one(_body: dict[str, Any], _ctx: ComputeFirstWorkContext) -> WorkOutcome: + return WorkOutcome(result_count=1, body={"matches": ["a"]}) + + +async def _run_zero(_body: dict[str, Any], _ctx: ComputeFirstWorkContext) -> WorkOutcome: + return WorkOutcome(result_count=0, body={"matches": [], "total": 0}) + + +def _handler(name: str = "adapter_test", run: Any = _run_one) -> ComputeFirstCheckout: + return ComputeFirstCheckout( + name=name, + url="https://api.example.com/search", + unit_price_cents=1, + rails=ComputeFirstRails( + tempo=TempoRailSpec(recipient="0xtempo", testnet=True), + ), + x402_server=None, + run_work=run, + ) + + +@pytest.mark.asyncio +async def test_handle_fastapi_with_pre_parsed_body() -> None: + request = MagicMock() + request.method = "POST" + request.url = "https://api.example.com/search" + request.headers = {} + response = await _handler().handle_fastapi(request, body={"q": "fastapi"}) + assert response.status_code == 402 + + +@pytest.mark.asyncio +async def test_handle_fastapi_zero_result_returns_200() -> None: + request = MagicMock() + request.method = "POST" + request.url = "https://api.example.com/search" + request.headers = {} + response = await _handler(run=_run_zero).handle_fastapi(request, body={"q": "empty"}) + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_handle_aiohttp_with_pre_parsed_body() -> None: + request = MagicMock() + request.method = "POST" + request.url = "https://api.example.com/search" + request.headers = {} + response = await _handler().handle_aiohttp(request, body={"q": "aio"}) + assert response.status == 402 + + +@pytest.mark.asyncio +async def test_handle_sanic_with_pre_parsed_body() -> None: + request = MagicMock() + request.method = "POST" + request.url = "https://api.example.com/search" + request.headers = {} + response = await _handler().handle_sanic(request, body={"q": "sanic"}) + assert response.status == 402 + + +def test_handle_flask_with_pre_parsed_body() -> None: + from flask import Flask + + app = Flask(__name__) + with app.test_request_context( + path="/search", + method="POST", + json={"q": "flask"}, + headers={"content-type": "application/json"}, + ): + from flask import request as flask_req + + response = _handler().handle_flask(flask_req, body={"q": "flask"}) + assert response.status_code == 402 + + +@pytest.mark.asyncio +async def test_handle_fastapi_parses_body_when_omitted() -> None: + async def _json() -> dict[str, Any]: + return {"q": "auto"} + + request = MagicMock() + request.method = "POST" + request.url = "https://api.example.com/search" + request.headers = {} + request.json = _json + response = await _handler().handle_fastapi(request) + assert response.status_code == 402 + + +@pytest.mark.asyncio +async def test_handle_fastapi_handles_invalid_json_body() -> None: + async def _bad_json() -> dict[str, Any]: + raise ValueError("malformed") + + request = MagicMock() + request.method = "POST" + request.url = "https://api.example.com/search" + request.headers = {} + request.json = _bad_json + response = await _handler().handle_fastapi(request) + assert response.status_code == 402 + + +@pytest.mark.asyncio +async def test_handle_aiohttp_parses_body_when_omitted() -> None: + async def _json() -> dict[str, Any]: + return {"q": "auto-aio"} + + request = MagicMock() + request.method = "POST" + request.url = "https://api.example.com/search" + request.headers = {} + request.json = _json + response = await _handler().handle_aiohttp(request) + assert response.status == 402 + + +def test_handle_flask_parses_body_when_omitted() -> None: + from flask import Flask + + app = Flask(__name__) + with app.test_request_context( + path="/search", + method="POST", + json={"q": "flask-auto"}, + headers={"content-type": "application/json"}, + ): + from flask import request as flask_req + + response = _handler().handle_flask(flask_req) + assert response.status_code == 402 + + +def test_handle_django_with_pre_parsed_body() -> None: + import django + from django.conf import settings as django_settings + from django.http import HttpRequest + + if not django_settings.configured: + django_settings.configure( + DEBUG=True, + DEFAULT_CHARSET="utf-8", + ALLOWED_HOSTS=["*"], + ) + django.setup() + # Override ALLOWED_HOSTS so build_absolute_uri() works regardless of how an + # earlier-running test (test_django.py) configured settings. + django_settings.ALLOWED_HOSTS = ["*"] + + request = HttpRequest() + request.method = "POST" + request.path = "/search" + request.META["SERVER_NAME"] = "testserver" + request.META["SERVER_PORT"] = "80" + request.META["wsgi.url_scheme"] = "http" + response = _handler().handle_django(request, body={"q": "django"}) + assert response.status_code == 402 diff --git a/tests/test_checkout_compute_first_settle.py b/tests/test_checkout_compute_first_settle.py new file mode 100644 index 0000000..da88694 --- /dev/null +++ b/tests/test_checkout_compute_first_settle.py @@ -0,0 +1,288 @@ +"""Compute-first settle-path tests with fake x402_server + compose_mppx. + +Covers _handle_x402_settle and _handle_mpp_settle paths. +""" + +import base64 +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from agentscore_commerce.checkout_compute_first import ( + ComputeFirstCheckout, + ComputeFirstMppContext, + ComputeFirstMppResult, + ComputeFirstRails, + ComputeFirstRequest, + ComputeFirstSettledContext, + ComputeFirstWorkContext, + WorkOutcome, +) +from agentscore_commerce.payment.rail_spec import TempoRailSpec, X402BaseRailSpec + +X402_NETWORK = "eip155:84532" +X402_PAY_TO = "0xc3128D86669e842573306CA82f60A005A41C44D4" + + +def _make_rails() -> ComputeFirstRails: + return ComputeFirstRails( + tempo=TempoRailSpec(recipient="0xtempo", testnet=True), + x402_base=X402BaseRailSpec(recipient=X402_PAY_TO, network=X402_NETWORK), + ) + + +def _make_fake_x402_server() -> MagicMock: + server = MagicMock() + server.build_payment_requirements = MagicMock( + return_value=[ + { + "scheme": "exact", + "network": X402_NETWORK, + "payTo": X402_PAY_TO, + "maxAmountRequired": "10000", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "resource": "https://api.example.com/search", + "description": "test", + "mimeType": "application/json", + "maxTimeoutSeconds": 300, + "extra": {"name": "USDC", "version": "2"}, + }, + ] + ) + server.enrich_extensions = MagicMock(return_value=None) + server.verify_payment = AsyncMock(return_value={"is_valid": True}) + server.settle_payment = AsyncMock( + return_value={"success": True, "transaction": "0xdeadbeef", "network": X402_NETWORK}, + ) + return server + + +def _x402_header(network: str = X402_NETWORK, pay_to: str = X402_PAY_TO) -> str: + payload = { + "x402Version": 2, + "scheme": "exact", + "network": network, + "accepted": {"network": network, "payTo": pay_to, "scheme": "exact"}, + "payload": {"authorization": {"from": "0xeb2Ca790F72787c7e61bC6c861353a1e4ACDFCa5"}}, + } + return base64.b64encode(json.dumps(payload).encode()).decode() + + +async def _run_one(_body: dict[str, Any], _ctx: ComputeFirstWorkContext) -> WorkOutcome: + return WorkOutcome(result_count=1, body={"matches": ["a"], "total": 1}) + + +async def _run_two(_body: dict[str, Any], _ctx: ComputeFirstWorkContext) -> WorkOutcome: + return WorkOutcome(result_count=2, body={"matches": ["hit1", "hit2"], "total": 2}) + + +def _req(headers: dict[str, str] | None = None, body: dict[str, Any] | None = None) -> ComputeFirstRequest: + return ComputeFirstRequest( + method="POST", + url="https://api.example.com/search", + headers=headers or {}, + body=body or {"q": "test"}, + ) + + +@pytest.mark.asyncio +async def test_x402_settle_full_roundtrip() -> None: + server = _make_fake_x402_server() + handler = ComputeFirstCheckout( + name="x402_full", + url="https://api.example.com/search", + unit_price_cents=1, + rails=_make_rails(), + x402_server=server, + run_work=_run_two, + ) + body = {"q": "acme"} + # Probe + probe_status, _pb, _ph = await handler.handle(_req(body=body)) + assert probe_status == 402 + # Settle + status, response_body, _h = await handler.handle(_req(headers={"x-payment": _x402_header()}, body=body)) + assert status == 200 + assert response_body["payment_status"] == "completed" + assert response_body["charged_usd"] == "0.02" + assert "Base" in response_body["rail"] + assert response_body["result"]["matches"] == ["hit1", "hit2"] + + +@pytest.mark.asyncio +async def test_x402_settle_failure_returns_502() -> None: + server = _make_fake_x402_server() + server.settle_payment = AsyncMock(side_effect=RuntimeError("facilitator rejected")) + handler = ComputeFirstCheckout( + name="x402_fail", + url="https://api.example.com/search", + unit_price_cents=1, + rails=_make_rails(), + x402_server=server, + run_work=_run_one, + ) + body = {"q": "x"} + await handler.handle(_req(body=body)) + status, response_body, _h = await handler.handle(_req(headers={"x-payment": _x402_header()}, body=body)) + assert status == 502 + assert response_body["error"]["code"] == "settle_failed" + + +@pytest.mark.asyncio +async def test_invalid_x402_header_returns_400() -> None: + server = _make_fake_x402_server() + handler = ComputeFirstCheckout( + name="x402_bad_hdr", + url="https://api.example.com/search", + unit_price_cents=1, + rails=_make_rails(), + x402_server=server, + run_work=_run_one, + ) + body = {"q": "bad"} + await handler.handle(_req(body=body)) + status, _b, _h = await handler.handle(_req(headers={"x-payment": "not-base64-json"}, body=body)) + assert 400 <= status < 500 + + +@pytest.mark.asyncio +async def test_x402_on_settled_hook_fires_and_errors_caught() -> None: + server = _make_fake_x402_server() + settled_calls = [] + + async def _on_settled(ctx: ComputeFirstSettledContext) -> None: + settled_calls.append(ctx) + raise RuntimeError("hook broken — should be caught") + + handler = ComputeFirstCheckout( + name="x402_onsettled", + url="https://api.example.com/search", + unit_price_cents=1, + rails=_make_rails(), + x402_server=server, + run_work=_run_one, + on_settled=_on_settled, + ) + body = {"q": "x"} + await handler.handle(_req(body=body)) + status, _rb, _h = await handler.handle(_req(headers={"x-payment": _x402_header()}, body=body)) + # Should be 200 despite the hook throwing + assert status == 200 + assert len(settled_calls) == 1 + assert settled_calls[0].rail == "x402" + + +@pytest.mark.asyncio +async def test_mpp_settle_success_returns_200() -> None: + server = _make_fake_x402_server() + + async def _compose(ctx: ComputeFirstMppContext) -> ComputeFirstMppResult: + auth_present = (ctx.request.headers.get("authorization") or "").startswith("Payment ") + if not auth_present: + return ComputeFirstMppResult(status=402, headers={"www-authenticate": 'Payment realm="x"'}) + return ComputeFirstMppResult( + status=200, + raw=type("FakeRaw", (), {"receipt": type("R", (), {"method": "tempo"})()})(), + tx_hash="pi_test_123", + signer_address="0xsigner", + signer_network="evm", + ) + + handler = ComputeFirstCheckout( + name="mpp_full", + url="https://api.example.com/search", + unit_price_cents=1, + rails=_make_rails(), + x402_server=server, + run_work=_run_one, + compose_mppx=_compose, + ) + body = {"q": "mpp"} + await handler.handle(_req(body=body)) + status, response_body, _h = await handler.handle(_req(headers={"authorization": "Payment "}, body=body)) + assert status == 200 + assert "Tempo" in response_body["rail"] + assert response_body.get("payment_intent_id") == "pi_test_123" + + +@pytest.mark.asyncio +async def test_mpp_settle_compose_non_200_returns_400() -> None: + server = _make_fake_x402_server() + + async def _compose(ctx: ComputeFirstMppContext) -> ComputeFirstMppResult: + return ComputeFirstMppResult(status=402, headers={"www-authenticate": 'Payment realm="x"'}) + + handler = ComputeFirstCheckout( + name="mpp_fail", + url="https://api.example.com/search", + unit_price_cents=1, + rails=_make_rails(), + x402_server=server, + run_work=_run_one, + compose_mppx=_compose, + ) + body = {"q": "mpp_fail"} + await handler.handle(_req(body=body)) + status, response_body, _h = await handler.handle(_req(headers={"authorization": "Payment "}, body=body)) + assert status == 400 + assert response_body["error"]["code"] == "mpp_settle_failed" + + +@pytest.mark.asyncio +async def test_mpp_rail_label_stripe() -> None: + server = _make_fake_x402_server() + + async def _compose(ctx: ComputeFirstMppContext) -> ComputeFirstMppResult: + auth_present = (ctx.request.headers.get("authorization") or "").startswith("Payment ") + if not auth_present: + return ComputeFirstMppResult(status=402, headers={}) + return ComputeFirstMppResult( + status=200, + raw=type("FakeRaw", (), {"receipt": type("R", (), {"method": "stripe"})()})(), + ) + + handler = ComputeFirstCheckout( + name="mpp_stripe", + url="https://api.example.com/search", + unit_price_cents=1, + rails=_make_rails(), + x402_server=server, + run_work=_run_one, + compose_mppx=_compose, + ) + body = {"q": "stripe"} + await handler.handle(_req(body=body)) + status, response_body, _h = await handler.handle(_req(headers={"authorization": "Payment "}, body=body)) + assert status == 200 + assert response_body["rail"] == "Stripe (card+link)" + + +@pytest.mark.asyncio +async def test_mpp_rail_label_unknown_falls_back_to_mpp() -> None: + server = _make_fake_x402_server() + + async def _compose(ctx: ComputeFirstMppContext) -> ComputeFirstMppResult: + auth_present = (ctx.request.headers.get("authorization") or "").startswith("Payment ") + if not auth_present: + return ComputeFirstMppResult(status=402, headers={}) + return ComputeFirstMppResult( + status=200, + raw=type("FakeRaw", (), {"receipt": type("R", (), {"method": "unknown_scheme"})()})(), + ) + + handler = ComputeFirstCheckout( + name="mpp_unknown", + url="https://api.example.com/search", + unit_price_cents=1, + rails=_make_rails(), + x402_server=server, + run_work=_run_one, + compose_mppx=_compose, + ) + body = {"q": "unknown"} + await handler.handle(_req(body=body)) + status, response_body, _h = await handler.handle(_req(headers={"authorization": "Payment "}, body=body)) + assert status == 200 + assert response_body["rail"] == "MPP" diff --git a/tests/test_checkout_coverage_gaps.py b/tests/test_checkout_coverage_gaps.py new file mode 100644 index 0000000..2bf7f3c --- /dev/null +++ b/tests/test_checkout_coverage_gaps.py @@ -0,0 +1,406 @@ +"""Targeted tests closing remaining coverage gaps in checkout.py. + +Covers: +- pre_validate raising CheckoutValidationError (lines 904-921) +- pre_validate returning a state dict (line 921) +- handle_fastapi / handle_aiohttp / handle_flask / handle_django / handle_sanic + adapter wrappers + invalid-body envelope paths (lines 999-1018 + siblings) +- Auto-derive compose_mppx from mppx_secret_key + mpp rails (lines 695-709) +- zero_settle x402 carve-out happy path (lines 1595-1624) +""" + +from __future__ import annotations + +import base64 +import json +from typing import Any + +import pytest + +from agentscore_commerce.checkout import ( + Checkout, + CheckoutContext, + CheckoutRequest, + CheckoutValidationError, + PricingResult, +) +from agentscore_commerce.payment.rail_spec import ( + StripeRailSpec, + TempoRailSpec, + X402BaseRailSpec, +) + +X402_NETWORK = "eip155:84532" +X402_PAY_TO = "0xc3128D86669e842573306CA82f60A005A41C44D4" + + +def _req(*, headers: dict[str, str] | None = None, body: dict[str, Any] | None = None) -> CheckoutRequest: + return CheckoutRequest( + method="POST", + url="https://api.example/purchase", + headers=headers or {}, + body=body or {"item": "x"}, + ) + + +def _x402_header(network: str = X402_NETWORK, pay_to: str = X402_PAY_TO) -> str: + payload = { + "x402Version": 2, + "scheme": "exact", + "network": network, + "accepted": {"network": network, "payTo": pay_to, "scheme": "exact"}, + "payload": {"authorization": {"from": "0xeb2Ca790F72787c7e61bC6c861353a1e4ACDFCa5"}}, + } + return base64.b64encode(json.dumps(payload).encode()).decode() + + +# ─── pre_validate hook paths ────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_pre_validate_validation_error_returns_4xx_with_envelope() -> None: + async def _pre_validate(_ctx: CheckoutContext) -> dict[str, Any]: + raise CheckoutValidationError( + code="out_of_stock", + message="That product is out of stock.", + action="select_different_product", + status=409, + extra={"product_id": "wine-42"}, + ) + + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0xtempo")}, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=10.0), + pre_validate=_pre_validate, + ) + result = await checkout.handle(_req()) + assert result.status == 409 + assert result.body["error"]["code"] == "out_of_stock" + assert result.settled is False + assert result.settle_phase == "pre_validate_failed" + + +@pytest.mark.asyncio +async def test_pre_validate_returning_state_dict_stashes_on_ctx() -> None: + seen_state: dict[str, Any] = {} + + async def _pre_validate(_ctx: CheckoutContext) -> dict[str, Any]: + return {"resolved_product_id": "wine-1", "price_lookup": 12.50} + + def _compute(ctx: CheckoutContext) -> PricingResult: + seen_state.update(ctx.state) + return PricingResult(amount_usd=ctx.state.get("price_lookup", 1.0)) + + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0xtempo")}, + url="https://api.example/purchase", + compute_pricing=_compute, + pre_validate=_pre_validate, + ) + result = await checkout.handle(_req()) + assert result.status == 402 + assert seen_state["resolved_product_id"] == "wine-1" + assert seen_state["price_lookup"] == 12.50 + + +# ─── auto-derive compose_mppx from mppx_secret_key + rails ─────────────────── + + +def test_auto_derive_compose_mppx_when_mppx_secret_key_supplied() -> None: + """Init path: rails has MPP specs + mppx_secret_key → compose_mppx wired.""" + checkout = Checkout( + rails={ + "tempo": TempoRailSpec(recipient="0xtempo"), + "stripe": StripeRailSpec(profile_id="profile_x"), + }, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=1.0), + mppx_secret_key="X" * 32, + ) + assert checkout.compose_mppx is not None + + +def test_auto_derive_compose_mppx_skipped_when_no_mpp_rails() -> None: + """No MPP rails in the dict → compose_mppx stays None even with secret_key.""" + checkout = Checkout( + rails={"x402_base": X402BaseRailSpec(recipient=X402_PAY_TO, network=X402_NETWORK)}, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=1.0), + mppx_secret_key="X" * 32, + ) + assert checkout.compose_mppx is None + + +# ─── zero-settle x402 carve-out ─────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_zero_settle_x402_carve_out_verifies_credential_skips_settle() -> None: + settled_outcomes: list[Any] = [] + + async def _on_settled(_ctx: CheckoutContext, outcome: Any) -> dict[str, Any]: + settled_outcomes.append(outcome) + return {"redeemed": True} + + checkout = Checkout( + rails={"x402_base": X402BaseRailSpec(recipient=X402_PAY_TO, network=X402_NETWORK)}, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=0.0), + x402_server=object(), # required for _x402_server_available() + zero_settle_carve_out=True, + on_settled=_on_settled, + ) + result = await checkout.handle(_req(headers={"x-payment": _x402_header()})) + assert result.status == 200 + assert len(settled_outcomes) == 1 + assert settled_outcomes[0].rail == "x402" + assert settled_outcomes[0].tx_hash is None + # signer_address gets lifted from the payload.authorization.from + assert settled_outcomes[0].signer_address is not None + assert settled_outcomes[0].signer_network == "evm" + + +@pytest.mark.asyncio +async def test_zero_settle_x402_verify_failure_returns_4xx() -> None: + checkout = Checkout( + rails={"x402_base": X402BaseRailSpec(recipient=X402_PAY_TO, network=X402_NETWORK)}, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=0.0), + x402_server=object(), + zero_settle_carve_out=True, + ) + result = await checkout.handle(_req(headers={"x-payment": "not-base64-json"})) + assert 400 <= result.status < 500 + assert result.settled is False + assert result.settle_phase == "verify_failed" + + +@pytest.mark.asyncio +async def test_zero_settle_mpp_carve_out_returns_200_no_tx() -> None: + """No x402 header → falls through to MPP $0 carve-out (line 1626-1640).""" + settled_outcomes: list[Any] = [] + + async def _on_settled(_ctx: CheckoutContext, outcome: Any) -> dict[str, Any]: + settled_outcomes.append(outcome) + return {} + + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0xtempo")}, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=0.0), + zero_settle_carve_out=True, + on_settled=_on_settled, + ) + result = await checkout.handle( + _req(headers={"authorization": "Payment opaque-jwt"}), + ) + assert result.status == 200 + assert len(settled_outcomes) == 1 + assert settled_outcomes[0].rail == "mpp" + assert settled_outcomes[0].tx_hash is None + + +# ─── handle_ adapters ────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_handle_fastapi_wraps_handle_in_jsonresponse() -> None: + from starlette.requests import Request + + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0xtempo")}, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=1.0), + ) + # Construct a Starlette Request with a JSON body. + body_bytes = json.dumps({"item": "wine"}).encode() + received = False + + async def _receive() -> dict[str, Any]: + nonlocal received + if received: + return {"type": "http.disconnect"} + received = True + return {"type": "http.request", "body": body_bytes, "more_body": False} + + scope = { + "type": "http", + "http_version": "1.1", + "method": "POST", + "path": "/purchase", + "raw_path": b"/purchase", + "query_string": b"", + "headers": [(b"content-type", b"application/json")], + "scheme": "http", + "server": ("api.example", 80), + "client": ("127.0.0.1", 12345), + } + request = Request(scope, receive=_receive) + response = await checkout.handle_fastapi(request) + assert response.status_code == 402 + assert b"accepted_methods" in response.body + + +@pytest.mark.asyncio +async def test_handle_fastapi_invalid_body_returns_400() -> None: + """Non-JSON body → 400 invalid_body envelope (line 1004-1005).""" + from starlette.requests import Request + + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0xtempo")}, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=1.0), + ) + + received = False + + async def _receive() -> dict[str, Any]: + nonlocal received + if received: + return {"type": "http.disconnect"} + received = True + return {"type": "http.request", "body": b"not json", "more_body": False} + + scope = { + "type": "http", + "http_version": "1.1", + "method": "POST", + "path": "/purchase", + "raw_path": b"/purchase", + "query_string": b"", + "headers": [], + "scheme": "http", + "server": ("api.example", 80), + } + request = Request(scope, receive=_receive) + response = await checkout.handle_fastapi(request) + assert response.status_code == 400 + body = json.loads(response.body) + assert body["error"]["code"] == "invalid_body" + + +@pytest.mark.asyncio +async def test_handle_fastapi_explicit_body_skips_parsing() -> None: + """Pass ``body=`` to bypass request.json() parsing.""" + from starlette.requests import Request + + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0xtempo")}, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=1.0), + ) + + scope = { + "type": "http", + "http_version": "1.1", + "method": "POST", + "path": "/purchase", + "raw_path": b"/purchase", + "query_string": b"", + "headers": [], + "scheme": "http", + "server": ("api.example", 80), + } + + async def _receive() -> dict[str, Any]: + return {"type": "http.disconnect"} + + request = Request(scope, receive=_receive) + response = await checkout.handle_fastapi(request, body={"item": "preparsed"}) + assert response.status_code == 402 + + +@pytest.mark.asyncio +async def test_handle_aiohttp_invalid_body_returns_400() -> None: + """aiohttp adapter: non-JSON body → 400 envelope.""" + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0xtempo")}, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=1.0), + ) + + class _FakeReq: + method = "POST" + url = "https://api.example/purchase" + headers: dict[str, str] = {} + + async def json(self) -> dict[str, Any]: + raise ValueError("malformed") + + response = await checkout.handle_aiohttp(_FakeReq()) + assert response.status == 400 + + +def test_handle_flask_invalid_body_returns_400() -> None: + """Flask adapter: get_json returning None → 400 envelope.""" + from flask import Flask + + app = Flask(__name__) + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0xtempo")}, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=1.0), + ) + with app.test_request_context("/purchase", method="POST", data=b"not json", content_type="text/plain"): + from flask import request as flask_request + + resp = checkout.handle_flask(flask_request) + assert resp.status_code == 400 + + +def test_handle_django_invalid_body_returns_400() -> None: + """Django adapter: invalid JSON in request.body → 400 envelope.""" + # Configure Django minimally if not already configured. + import django + from django.conf import settings + + if not settings.configured: + settings.configure( + DEBUG=False, + ALLOWED_HOSTS=["*"], + DATABASES={}, + INSTALLED_APPS=[], + USE_TZ=True, + ) + django.setup() + else: + settings.ALLOWED_HOSTS = ["*"] + + from django.test import RequestFactory + + rf = RequestFactory(SERVER_NAME="testserver") + request = rf.post( + "/purchase", + data=b"not valid json", + content_type="application/json", + ) + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0xtempo")}, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=1.0), + ) + response = checkout.handle_django(request) + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_handle_sanic_invalid_body_returns_400() -> None: + """Sanic adapter: request.json raising → 400 envelope.""" + checkout = Checkout( + rails={"tempo": TempoRailSpec(recipient="0xtempo")}, + url="https://api.example/purchase", + compute_pricing=lambda _ctx: PricingResult(amount_usd=1.0), + ) + + class _FakeSanicReq: + method = "POST" + url = "https://api.example/purchase" + headers: dict[str, str] = {} + + @property + def json(self) -> dict[str, Any]: + raise RuntimeError("malformed body") + + response = await checkout.handle_sanic(_FakeSanicReq()) + assert response.status == 400 diff --git a/tests/test_compose_rails.py b/tests/test_compose_rails.py new file mode 100644 index 0000000..dc82066 --- /dev/null +++ b/tests/test_compose_rails.py @@ -0,0 +1,56 @@ +"""Tests for ``agentscore_commerce.payment.compose_rails``.""" + +import pytest + +from agentscore_commerce.payment.compose_rails import build_mppx_compose_rails + + +def test_emits_single_tempo_intent_when_only_tempo_recipient() -> None: + rails = build_mppx_compose_rails(amount_usd="1.50", tempo_recipient="0x1234") + assert len(rails) == 2 # tempo + stripe + directive, payload = rails[0] + assert directive == "tempo/charge" + assert payload["amount"] == "1.50" + assert payload["recipient"] == "0x1234" + assert payload["decimals"] == 6 + + +def test_adds_solana_intent_with_atomic_conversion() -> None: + rails = build_mppx_compose_rails( + amount_usd="2.00", + tempo_recipient="0xabc", + solana_recipient="SolAddr", + ) + sol = next(r for r in rails if r[0] == "solana/charge") + assert sol[1]["amount"] == "2000000" + assert sol[1]["recipient"] == "SolAddr" + assert sol[1]["decimals"] == 6 + + +def test_omits_stripe_when_include_stripe_false() -> None: + rails = build_mppx_compose_rails( + amount_usd="0.10", + tempo_recipient="0xabc", + include_stripe=False, + ) + assert all(r[0] != "stripe/charge" for r in rails) + + +def test_caller_provided_solana_network_wins() -> None: + rails = build_mppx_compose_rails( + amount_usd="1", + tempo_recipient="0xabc", + solana_recipient="SolAddr", + solana_network="solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + ) + sol = next(r for r in rails if r[0] == "solana/charge") + assert sol[1]["network"] == "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" + + +def test_raises_when_amount_unparseable_with_solana_rail() -> None: + with pytest.raises(ValueError): + build_mppx_compose_rails( + amount_usd="nope", + tempo_recipient="0xabc", + solana_recipient="SolAddr", + ) diff --git a/tests/test_default_denied.py b/tests/test_default_denied.py new file mode 100644 index 0000000..5e8f845 --- /dev/null +++ b/tests/test_default_denied.py @@ -0,0 +1,73 @@ +"""Tests for ``agentscore_commerce.identity.default_denied``.""" + +from agentscore_commerce.identity.default_denied import create_default_on_denied +from agentscore_commerce.identity.types import DenialReason + + +def test_wallet_signer_mismatch_403_with_body() -> None: + on_denied = create_default_on_denied( + merchant_name="Test Merchant", + support_email="support@example.com", + ) + reason = DenialReason( + code="wallet_signer_mismatch", + claimed_operator="op_abc", + expected_signer="0xclaim", + actual_signer="0xactual", + linked_wallets=["0xclaim", "0xactual"], + ) + result = on_denied(reason) + assert result.status == 403 + assert "error" in result.body + + +def test_wallet_not_trusted_uses_walletnottrusted_message_override() -> None: + on_denied = create_default_on_denied( + merchant_name="Martin Estate", + support_email="winery@martinestate.com", + wallet_not_trusted_message="Purchase denied by compliance policy.", + ) + reason = DenialReason( + code="wallet_not_trusted", + reasons=["sanctions_flagged"], + verify_url="https://verify.example.com", + ) + result = on_denied(reason) + assert result.status == 403 + assert result.body["error"]["message"] == "Purchase denied by compliance policy." + assert result.body["reasons"] == ["sanctions_flagged"] + + +def test_payment_required_uses_default_message() -> None: + on_denied = create_default_on_denied( + merchant_name="Test", + support_email="s@e.com", + ) + result = on_denied(DenialReason(code="payment_required")) + assert result.status == 403 + assert result.body["error"]["code"] == "compliance_error" + + +def test_token_expired_returns_401() -> None: + on_denied = create_default_on_denied(merchant_name="Test", support_email="s@e.com") + result = on_denied(DenialReason(code="token_expired")) + assert result.status == 401 + + +def test_invalid_credential_returns_401() -> None: + on_denied = create_default_on_denied(merchant_name="Test", support_email="s@e.com") + result = on_denied(DenialReason(code="invalid_credential")) + assert result.status == 401 + + +def test_api_error_returns_503_with_cache_control_no_store() -> None: + on_denied = create_default_on_denied(merchant_name="Test", support_email="s@e.com") + result = on_denied(DenialReason(code="api_error")) + assert result.status == 503 + assert result.headers == {"Cache-Control": "no-store"} + + +def test_unknown_code_returns_403() -> None: + on_denied = create_default_on_denied(merchant_name="Test", support_email="s@e.com") + result = on_denied(DenialReason(code="missing_identity")) + assert result.status == 403 diff --git a/tests/test_default_rails.py b/tests/test_default_rails.py new file mode 100644 index 0000000..9fad63e --- /dev/null +++ b/tests/test_default_rails.py @@ -0,0 +1,46 @@ +"""Tests for ``agentscore_commerce.payment.default_rails``.""" + +from agentscore_commerce.payment.default_rails import build_default_checkout_rails +from agentscore_commerce.payment.rail_spec import ( + SolanaMppRailSpec, + StripeRailSpec, + TempoRailSpec, + X402BaseRailSpec, +) + + +def test_empty_when_nothing_requested() -> None: + assert build_default_checkout_rails() == {} + + +def test_tempo_with_sentinel_recipient() -> None: + rails = build_default_checkout_rails(tempo={}) + assert "tempo" in rails + assert isinstance(rails["tempo"], TempoRailSpec) + assert rails["tempo"].recipient == "" + assert rails["tempo"].network == "tempo-mainnet" + + +def test_caller_overrides_apply() -> None: + rails = build_default_checkout_rails( + tempo={"testnet": True}, + ) + assert rails["tempo"].testnet is True + # testnet flag flips network/chain via TempoRailSpec.__post_init__ + assert rails["tempo"].network == "tempo-testnet" + assert rails["tempo"].chain_id == 42431 + + +def test_keys_use_canonical_slugs() -> None: + rails = build_default_checkout_rails(x402_base={}, solana_mpp={}) + assert isinstance(rails["x402_base"], X402BaseRailSpec) + assert isinstance(rails["solana_mpp"], SolanaMppRailSpec) + + +def test_stripe_has_no_recipient_field() -> None: + rails = build_default_checkout_rails( + stripe={"profile_id": "p_test", "payment_method_types": ["card", "link"]}, + ) + assert isinstance(rails["stripe"], StripeRailSpec) + assert rails["stripe"].profile_id == "p_test" + assert rails["stripe"].payment_method_types == ["card", "link"] diff --git a/tests/test_default_read_only_on_denied.py b/tests/test_default_read_only_on_denied.py new file mode 100644 index 0000000..658977c --- /dev/null +++ b/tests/test_default_read_only_on_denied.py @@ -0,0 +1,35 @@ +"""Tests for ``default_read_only_on_denied`` — read-only resource gate denial.""" + +from agentscore_commerce.identity.default_denied import default_read_only_on_denied +from agentscore_commerce.identity.types import DenialReason + + +def test_returns_401_with_missing_identity_message() -> None: + r = default_read_only_on_denied(DenialReason(code="missing_identity")) + assert r.status == 401 + assert r.body["error"] == { + "code": "unauthorized", + "message": "X-Wallet-Address or X-Operator-Token header required", + } + assert r.headers == {"Cache-Control": "no-store"} + + +def test_returns_401_with_invalid_identity_message_on_other_codes() -> None: + r = default_read_only_on_denied(DenialReason(code="token_expired")) + assert r.status == 401 + assert r.body["error"] == {"code": "unauthorized", "message": "Invalid identity"} + assert r.headers == {"Cache-Control": "no-store"} + + +def test_spreads_denial_reason_to_body_so_agent_instructions_ride_through() -> None: + r = default_read_only_on_denied( + DenialReason(code="wallet_not_trusted", reasons=["sanctions_flagged"]), + ) + assert r.status == 401 + # Body carries through additional denial-derived fields beyond just `error`. + assert len(r.body) > 1 + + +def test_collapses_api_error_to_401_no_5xx_leakage() -> None: + r = default_read_only_on_denied(DenialReason(code="api_error")) + assert r.status == 401 diff --git a/tests/test_extract_owner_scope.py b/tests/test_extract_owner_scope.py new file mode 100644 index 0000000..07bf46b --- /dev/null +++ b/tests/test_extract_owner_scope.py @@ -0,0 +1,49 @@ +"""Tests for ``extract_owner_scope`` — canonical owner identity from headers.""" + +from agentscore_commerce.identity.tokens import ( + OwnerScope, + extract_owner_scope, + hash_operator_token, +) + + +def test_returns_wallet_address_verbatim() -> None: + scope = extract_owner_scope({"x-wallet-address": "0xABCDEF"}) + assert scope.wallet_address == "0xABCDEF" + assert scope.operator_token_hash is None + + +def test_hashes_operator_token_never_returns_plaintext() -> None: + scope = extract_owner_scope({"x-operator-token": "opc_secret123"}) + assert scope.wallet_address is None + assert scope.operator_token_hash == hash_operator_token("opc_secret123") + assert "opc_" not in (scope.operator_token_hash or "") + + +def test_both_headers_present() -> None: + scope = extract_owner_scope( + { + "x-wallet-address": "0xeb2Ca790F72787c7e61bC6c861353a1e4ACDFCa5", + "x-operator-token": "opc_a", + } + ) + assert scope.wallet_address == "0xeb2Ca790F72787c7e61bC6c861353a1e4ACDFCa5" + assert scope.operator_token_hash == hash_operator_token("opc_a") + + +def test_empty_when_no_headers() -> None: + scope = extract_owner_scope({}) + assert scope == OwnerScope() + + +def test_unwraps_request_with_headers_attr() -> None: + class _Req: + headers = {"x-wallet-address": "0xfeed"} + + assert extract_owner_scope(_Req()).wallet_address == "0xfeed" + + +def test_accepts_titlecase_headers() -> None: + """Some frameworks (e.g. requests) preserve title-case header names.""" + scope = extract_owner_scope({"X-Wallet-Address": "0xfeed"}) + assert scope.wallet_address == "0xfeed" diff --git a/tests/test_internal_helpers.py b/tests/test_internal_helpers.py new file mode 100644 index 0000000..23b1253 --- /dev/null +++ b/tests/test_internal_helpers.py @@ -0,0 +1,78 @@ +"""Tests for the internal helpers ``_headers``, ``_mppx_receipt``, ``_redis``.""" + +import pytest + +from agentscore_commerce._headers import normalize_headers_to_lowercase +from agentscore_commerce._mppx_receipt import ( + derive_mppx_receipt_method, + extract_mppx_receipt_header_from_raw, +) +from agentscore_commerce._redis import memoized_redis + + +def test_normalize_headers_lowercases_keys() -> None: + assert normalize_headers_to_lowercase({"Content-Type": "json", "X-Foo": "bar"}) == { + "content-type": "json", + "x-foo": "bar", + } + + +def test_normalize_headers_idempotent() -> None: + once = normalize_headers_to_lowercase({"Content-Type": "json"}) + twice = normalize_headers_to_lowercase(once) + assert once == twice + + +def test_extract_mppx_receipt_header_from_attribute() -> None: + class Raw: + receipt_header = "deadbeef" + + assert extract_mppx_receipt_header_from_raw(Raw()) == "deadbeef" + + +def test_extract_mppx_receipt_header_from_to_payment_receipt() -> None: + class Receipt: + def to_payment_receipt(self) -> str: + return "header-value" + + assert extract_mppx_receipt_header_from_raw(Receipt()) == "header-value" + + +def test_extract_mppx_receipt_header_from_dict_with_receipt() -> None: + class Receipt: + def to_payment_receipt(self) -> str: + return "from-dict" + + assert extract_mppx_receipt_header_from_raw({"receipt": Receipt()}) == "from-dict" + + +def test_extract_mppx_receipt_header_from_tuple() -> None: + class Receipt: + def to_payment_receipt(self) -> str: + return "from-tuple" + + assert extract_mppx_receipt_header_from_raw(("credential", Receipt())) == "from-tuple" + + +def test_extract_mppx_receipt_header_none_for_missing_shapes() -> None: + assert extract_mppx_receipt_header_from_raw(None) is None + assert extract_mppx_receipt_header_from_raw("string") is None + assert extract_mppx_receipt_header_from_raw({}) is None + + +def test_derive_mppx_receipt_method_prefers_direct_attribute() -> None: + class Receipt: + method = "tempo" + + class Raw: + receipt = Receipt() + + assert derive_mppx_receipt_method(Raw()) == "tempo" + + +@pytest.mark.asyncio +async def test_memoized_redis_no_url_returns_none() -> None: + getter = memoized_redis(url=None, label="test") + assert await getter() is None + # Second call returns the same memoized None + assert await getter() is None diff --git a/tests/test_mppx_receipt.py b/tests/test_mppx_receipt.py new file mode 100644 index 0000000..c18820c --- /dev/null +++ b/tests/test_mppx_receipt.py @@ -0,0 +1,131 @@ +"""Tests for `agentscore_commerce._mppx_receipt` covering all three pympp shapes.""" + +import pytest + +from agentscore_commerce._mppx_receipt import ( + derive_mppx_receipt_method, + extract_mppx_receipt_header_from_raw, + extract_mppx_receipt_method, +) + + +class FakeRawWithReceiptHeader: + """Shape 1: direct `receipt_header` attribute (pympp current).""" + + receipt_header = "fake-receipt-base64" + + +class FakeReceiptToPayment: + def to_payment_receipt(self) -> str: + return "header-from-receipt-method" + + +class FakeRawWithToPaymentReceipt: + """Shape 2: raw is itself a Receipt with `to_payment_receipt()`.""" + + def to_payment_receipt(self) -> str: + return "header-from-raw" + + +class FakeRawWithInnerReceipt: + """Shape 2b: raw has a `receipt` attribute carrying a Receipt.""" + + def __init__(self) -> None: + self.receipt = FakeReceiptToPayment() + + +class _FakeResponseHeaders: + def __init__(self, val: str) -> None: + self._val = val + + def get(self, name: str) -> str | None: + return self._val if name == "Payment-Receipt" else None + + +class _FakeResponseWithReceipt: + def __init__(self, val: str) -> None: + self.headers = _FakeResponseHeaders(val) + + +class FakeRawWithWithReceipt: + """Shape 3: node-style `with_receipt(response) -> response`.""" + + def with_receipt(self, _resp: object) -> _FakeResponseWithReceipt: + return _FakeResponseWithReceipt("header-from-with-receipt") + + +class FakeRawWithReceiptThrowing: + def with_receipt(self, _resp: object) -> None: + raise RuntimeError("isMissingReceiptResponseError-style sentinel") + + +def test_extract_returns_none_for_unsupported_shapes() -> None: + assert extract_mppx_receipt_header_from_raw(None) is None + assert extract_mppx_receipt_header_from_raw("string") is None + assert extract_mppx_receipt_header_from_raw({}) is None + + +def test_extract_shape_1_receipt_header_attribute() -> None: + assert extract_mppx_receipt_header_from_raw(FakeRawWithReceiptHeader()) == "fake-receipt-base64" + + +def test_extract_shape_2_to_payment_receipt_direct() -> None: + assert extract_mppx_receipt_header_from_raw(FakeRawWithToPaymentReceipt()) == "header-from-raw" + + +def test_extract_shape_2_to_payment_receipt_via_inner_receipt() -> None: + assert extract_mppx_receipt_header_from_raw(FakeRawWithInnerReceipt()) == "header-from-receipt-method" + + +def test_extract_shape_2_tuple_credential_receipt() -> None: + raw = ("credential", FakeReceiptToPayment()) + assert extract_mppx_receipt_header_from_raw(raw) == "header-from-receipt-method" + + +def test_extract_shape_2_dict_with_receipt_key() -> None: + raw = {"receipt": FakeReceiptToPayment()} + assert extract_mppx_receipt_header_from_raw(raw) == "header-from-receipt-method" + + +def test_extract_shape_3_with_receipt_wrapper() -> None: + assert extract_mppx_receipt_header_from_raw(FakeRawWithWithReceipt()) == "header-from-with-receipt" + + +def test_extract_shape_3_with_receipt_throws() -> None: + assert extract_mppx_receipt_header_from_raw(FakeRawWithReceiptThrowing()) is None + + +def test_extract_returns_none_when_to_payment_receipt_throws() -> None: + class Broken: + def to_payment_receipt(self) -> str: + raise RuntimeError("broken") + + assert extract_mppx_receipt_header_from_raw(Broken()) is None + + +def test_extract_method_returns_none_for_malformed_header() -> None: + # `mpp.Receipt.from_payment_receipt` will raise; helper returns None. + assert extract_mppx_receipt_method("not-a-valid-receipt-base64") is None + + +@pytest.mark.asyncio +async def test_derive_prefers_direct_receipt_method() -> None: + class FakeRaw: + def __init__(self) -> None: + self.receipt = type("R", (), {"method": "tempo"})() + + assert derive_mppx_receipt_method(FakeRaw()) == "tempo" + + +@pytest.mark.asyncio +async def test_derive_returns_none_when_no_path_resolves() -> None: + assert derive_mppx_receipt_method(None) is None + assert derive_mppx_receipt_method({}) is None + + +@pytest.mark.asyncio +async def test_derive_falls_back_to_header_path() -> None: + # Raw with header but no direct receipt.method → falls through to + # extract_mppx_receipt_method, which returns None (no real receipt body). + result = derive_mppx_receipt_method(FakeRawWithReceiptHeader()) + assert result is None diff --git a/tests/test_network_kind.py b/tests/test_network_kind.py new file mode 100644 index 0000000..5d6c7c1 --- /dev/null +++ b/tests/test_network_kind.py @@ -0,0 +1,36 @@ +"""Tests for ``agentscore_commerce.payment.network_kind``.""" + +from agentscore_commerce.payment.network_kind import is_evm_network, is_solana_network + + +def test_is_evm_network_string() -> None: + assert is_evm_network("eip155:8453") is True + assert is_evm_network("eip155:84532") is True + assert is_evm_network("solana:5eykt") is False + assert is_evm_network("") is False + + +def test_is_solana_network_string() -> None: + assert is_solana_network("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp") is True + assert is_solana_network("eip155:8453") is False + # bare "solana" (no `:`) is mppx-internal, not CAIP-2 — should be False + assert is_solana_network("solana") is False + + +def test_accepts_dict_with_network_field() -> None: + assert is_evm_network({"network": "eip155:8453"}) is True + assert is_solana_network({"network": "solana:abc"}) is True + + +def test_accepts_object_with_network_attribute() -> None: + class Spec: + network = "eip155:84532" + + assert is_evm_network(Spec()) is True + assert is_solana_network(Spec()) is False + + +def test_handles_none_and_unknown_shapes() -> None: + assert is_evm_network(None) is False + assert is_solana_network(None) is False + assert is_evm_network({}) is False diff --git a/tests/test_pay_to_address.py b/tests/test_pay_to_address.py new file mode 100644 index 0000000..3b8339e --- /dev/null +++ b/tests/test_pay_to_address.py @@ -0,0 +1,172 @@ +"""Tests for ``create_pay_to_address_from_stripe_pi``.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any +from unittest.mock import patch + +import pytest + +from agentscore_commerce.stripe_multichain.pay_to_address import ( + create_pay_to_address_from_stripe_pi, +) + + +@dataclass +class FakeRequest: + """In-place stand-in for a pympp credential.request shape.""" + + recipient: str + + +@dataclass +class FakeChallenge: + method: str + request: FakeRequest + + +@dataclass +class FakeCredential: + challenge: FakeChallenge + + @classmethod + def from_authorization(cls, auth: str) -> FakeCredential: + payload = auth.replace("Payment ", "", 1) + method, recipient = payload.split(":", 1) + return cls(challenge=FakeChallenge(method=method, request=FakeRequest(recipient=recipient))) + + +@dataclass +class FakePiCache: + """Minimal stand-in for the SDK's PiCache shape.""" + + has_address_result: bool = False + cached_addresses: list[str] = field(default_factory=list) + cached_pis: list[tuple[str, str]] = field(default_factory=list) + cached_network_addresses: list[tuple[str, dict[str, str]]] = field(default_factory=list) + + async def cache_address(self, address: str) -> None: + self.cached_addresses.append(address) + + async def has_address(self, _address: str) -> bool: + return self.has_address_result + + def cache_payment_intent(self, deposit_address: str, pi_id: str) -> None: + self.cached_pis.append((deposit_address, pi_id)) + + def get_payment_intent_id(self, _addr: str) -> str | None: + return None + + def cache_network_addresses(self, pi_id: str, addresses: dict[str, str]) -> None: + self.cached_network_addresses.append((pi_id, addresses)) + + def get_network_deposit_address(self, _pi: str, _network: str) -> str | None: + return None + + def stop(self) -> None: + pass + + +def _fake_stripe(addresses: dict[str, str]) -> Any: + """Build a stripe-like object that returns a PI with the given deposit addresses.""" + + class _PI: + id = "pi_test_123" + next_action = { + "crypto_display_details": { + "deposit_addresses": {n: {"address": a} for n, a in addresses.items()}, + }, + } + + class _PaymentIntentsAPI: + def __init__(self) -> None: + self.last_idempotency_key: str | None = None + + def create(self, _params: dict[str, Any], idempotency_key: str | None = None) -> Any: + self.last_idempotency_key = idempotency_key + return _PI() + + class _Stripe: + def __init__(self) -> None: + self.payment_intents = _PaymentIntentsAPI() + + return _Stripe() + + +@pytest.mark.asyncio +async def test_reuses_credential_recipient_when_cached() -> None: + cache = FakePiCache(has_address_result=True) + with patch("mpp.Credential", FakeCredential): + result = await create_pay_to_address_from_stripe_pi( + authorization_header="Payment tempo:0xCACHED", + amount_cents=100, + stripe=_fake_stripe({}), + pi_cache=cache, # type: ignore[arg-type] + ) + assert result == "0xCACHED" + # No mint happened — no addresses cached. + assert cache.cached_addresses == [] + + +@pytest.mark.asyncio +async def test_raises_when_credential_recipient_not_in_cache() -> None: + cache = FakePiCache(has_address_result=False) + with patch("mpp.Credential", FakeCredential), pytest.raises(ValueError, match="not found in cache"): + await create_pay_to_address_from_stripe_pi( + authorization_header="Payment tempo:0xUNKNOWN", + amount_cents=100, + stripe=_fake_stripe({}), + pi_cache=cache, # type: ignore[arg-type] + ) + + +@pytest.mark.asyncio +async def test_mints_fresh_pi_when_no_authorization_header() -> None: + cache = FakePiCache() + stripe = _fake_stripe({"tempo": "0xTEMPO", "base": "0xBASE", "solana": "SOLABC"}) + result = await create_pay_to_address_from_stripe_pi( + authorization_header=None, + amount_cents=250, + stripe=stripe, + pi_cache=cache, # type: ignore[arg-type] + order_id="order-1", + ) + assert result == "0xTEMPO" + assert set(cache.cached_addresses) == {"0xTEMPO", "0xBASE", "SOLABC"} + assert cache.cached_pis == [ + ("0xTEMPO", "pi_test_123"), + ("0xBASE", "pi_test_123"), + ("SOLABC", "pi_test_123"), + ] + assert cache.cached_network_addresses == [ + ("pi_test_123", {"tempo": "0xTEMPO", "base": "0xBASE", "solana": "SOLABC"}), + ] + assert stripe.payment_intents.last_idempotency_key == "pi-order-1-250" + + +@pytest.mark.asyncio +async def test_falls_back_to_base_when_tempo_missing() -> None: + cache = FakePiCache() + stripe = _fake_stripe({"base": "0xBASE"}) + result = await create_pay_to_address_from_stripe_pi( + authorization_header=None, + amount_cents=100, + stripe=stripe, + pi_cache=cache, # type: ignore[arg-type] + ) + assert result == "0xBASE" + + +@pytest.mark.asyncio +async def test_mints_fresh_when_credential_method_is_stripe() -> None: + cache = FakePiCache() + stripe = _fake_stripe({"tempo": "0xFRESH"}) + with patch("mpp.Credential", FakeCredential): + result = await create_pay_to_address_from_stripe_pi( + authorization_header="Payment stripe:does-not-matter", + amount_cents=100, + stripe=stripe, + pi_cache=cache, # type: ignore[arg-type] + ) + assert result == "0xFRESH" diff --git a/tests/test_payment_header.py b/tests/test_payment_header.py new file mode 100644 index 0000000..487f50f --- /dev/null +++ b/tests/test_payment_header.py @@ -0,0 +1,74 @@ +"""Tests for ``agentscore_commerce.payment.payment_header``.""" + +from agentscore_commerce.payment.payment_header import has_payment_header + + +def test_detects_payment_signature_header() -> None: + assert has_payment_header({"Payment-Signature": "deadbeef"}) is True + assert has_payment_header({"payment-signature": "deadbeef"}) is True + + +def test_detects_x_payment_header() -> None: + assert has_payment_header({"X-Payment": ""}) is True + assert has_payment_header({"x-payment": ""}) is True + + +def test_detects_authorization_payment_scheme() -> None: + assert has_payment_header({"Authorization": "Payment "}) is True + assert has_payment_header({"authorization": "Payment "}) is True + + +def test_rejects_bare_authorization_bearer() -> None: + assert has_payment_header({"Authorization": "Bearer abc"}) is False + assert has_payment_header({"authorization": "Basic xyz"}) is False + + +def test_returns_false_when_no_payment_credential() -> None: + assert has_payment_header({}) is False + assert has_payment_header({"User-Agent": "test"}) is False + + +def test_accepts_request_like_with_headers_attr() -> None: + class Req: + def __init__(self, headers: dict[str, str]) -> None: + self.headers = headers + + assert has_payment_header(Req({"x-payment": "abc"})) is True + assert has_payment_header(Req({})) is False + + +def test_reads_headers_with_get_returning_list_or_tuple() -> None: + """Headers `.get` returns list values for repeated headers — first hop wins.""" + + class MultiHeaders: + def get(self, name: str) -> list[str] | None: + if name in ("payment-signature", "Payment-Signature"): + return ["sig-first", "sig-second"] + return None + + assert has_payment_header(MultiHeaders()) is True + + +def test_reads_headers_with_get_returning_none() -> None: + """Headers `.get` falls back to case variants when first lookup returns None.""" + + class TitleCaseHeaders: + def get(self, name: str) -> str | None: + # Only respond to Title-Case + if name == "X-Payment": + return "abc" + return None + + assert has_payment_header(TitleCaseHeaders()) is True + + +def test_reads_mapping_with_list_value() -> None: + headers = {"X-Payment": ["one", "two"]} + assert has_payment_header(headers) is True + + +def test_reads_mapping_case_variants() -> None: + # Only Title-Case key present in mapping + assert has_payment_header({"X-Payment": "value"}) is True + # Only lowercase + assert has_payment_header({"x-payment": "value"}) is True diff --git a/tests/test_pricing.py b/tests/test_pricing.py index 4c36e05..4fe3c21 100644 --- a/tests/test_pricing.py +++ b/tests/test_pricing.py @@ -11,6 +11,14 @@ def test_formats_cents_to_dollar_strings(): assert block.total == "250.00" +def test_honors_decimals_for_sub_cent_unit_pricing(): + # $0.0005 unit * 5 results = $0.0025 total. Default 2-decimal rounds to "0.00"; + # decimals=4 preserves the real amount. + block = build_pricing_block(subtotal_cents=0.25, decimals=4) + assert block.subtotal == "0.0025" + assert block.total == "0.0025" + + def test_computes_total_from_subtotal_plus_tax_plus_shipping(): block = build_pricing_block(subtotal_cents=25000, tax_cents=1875, shipping_cents=999) assert block.total == "278.74" diff --git a/tests/test_quote_cache.py b/tests/test_quote_cache.py new file mode 100644 index 0000000..68ed1ca --- /dev/null +++ b/tests/test_quote_cache.py @@ -0,0 +1,63 @@ +"""Tests for ``agentscore_commerce.quote_cache``.""" + +import pytest + +from agentscore_commerce.quote_cache import CachedQuote, create_quote_cache + + +def test_body_hash_key_stable_across_key_order() -> None: + cache = create_quote_cache() + a = cache.body_hash_key("search", {"q": "x", "limit": 5}) + b = cache.body_hash_key("search", {"limit": 5, "q": "x"}) + assert a == b + + +def test_body_hash_key_changes_on_value_change() -> None: + cache = create_quote_cache() + a = cache.body_hash_key("search", {"q": "x"}) + b = cache.body_hash_key("search", {"q": "y"}) + assert a != b + + +def test_body_hash_key_prefix_isolates_namespaces() -> None: + cache = create_quote_cache() + a = cache.body_hash_key("search", {"q": "x"}) + b = cache.body_hash_key("enrich", {"q": "x"}) + assert a != b + + +@pytest.mark.asyncio +async def test_write_then_read_returns_cached_quote() -> None: + cache = create_quote_cache() + key = cache.body_hash_key("search", {"q": "x"}) + await cache.write(key, {"matches": [1, 2]}, 3, recipients={"tempo": "0xabc"}) + quote = await cache.read(key) + assert isinstance(quote, CachedQuote) + assert quote.body == {"matches": [1, 2]} + assert quote.price_cents == 3 + assert quote.recipients == {"tempo": "0xabc"} + + +@pytest.mark.asyncio +async def test_read_returns_none_for_missing_key() -> None: + cache = create_quote_cache() + assert await cache.read("missing") is None + + +@pytest.mark.asyncio +async def test_clear_drops_entries() -> None: + cache = create_quote_cache() + key = cache.body_hash_key("search", {"q": "x"}) + await cache.write(key, {}, 1) + await cache.clear() + assert await cache.read(key) is None + + +@pytest.mark.asyncio +async def test_default_recipients_empty_when_omitted() -> None: + cache = create_quote_cache() + key = cache.body_hash_key("search", {"q": "x"}) + await cache.write(key, {"r": 1}, 5) + quote = await cache.read(key) + assert quote is not None + assert quote.recipients == {} diff --git a/tests/test_quote_cache_redis.py b/tests/test_quote_cache_redis.py new file mode 100644 index 0000000..26feebb --- /dev/null +++ b/tests/test_quote_cache_redis.py @@ -0,0 +1,148 @@ +"""Redis-backed paths in ``_redis`` and ``quote_cache``. + +Monkeypatches ``importlib.import_module`` so the lazy ``redis.asyncio`` import +inside ``_try_create_redis`` resolves to an in-memory fake. Covers the three +error branches (ImportError, generic Exception) and the happy path that drives +``quote_cache.read`` / ``write`` / ``clear``. +""" + +from __future__ import annotations + +import json +import sys +from types import SimpleNamespace +from typing import Any + +import pytest + +import agentscore_commerce._redis as redis_mod +from agentscore_commerce.quote_cache import create_quote_cache + + +class _FakeAsyncRedis: + def __init__(self) -> None: + self._store: dict[str, str] = {} + + async def get(self, key: str) -> str | None: + return self._store.get(key) + + async def set(self, key: str, value: str, **_kwargs: Any) -> str: + self._store[key] = value + return "OK" + + async def flushdb(self) -> str: + self._store.clear() + return "OK" + + +def _install_fake_redis(monkeypatch: pytest.MonkeyPatch) -> _FakeAsyncRedis: + """Patch importlib.import_module to return a stub redis.asyncio.from_url.""" + fake_client = _FakeAsyncRedis() + fake_module = SimpleNamespace(from_url=lambda *_a, **_k: fake_client) + real_import = sys.modules["importlib"].import_module + + def _patched(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "redis.asyncio": + return fake_module + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(redis_mod, "import_module", _patched, raising=False) + # _try_create_redis uses `from importlib import import_module` inline; patch + # the importlib namespace too so the local binding resolves to ours. + import importlib + + monkeypatch.setattr(importlib, "import_module", _patched) + return fake_client + + +@pytest.mark.asyncio +async def test_try_create_redis_returns_none_when_redis_missing(monkeypatch: pytest.MonkeyPatch) -> None: + """ImportError branch (lines 67-72).""" + import importlib + + real_import = importlib.import_module + + def _raise_import(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "redis.asyncio": + raise ImportError("not installed") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(importlib, "import_module", _raise_import) + result = await redis_mod._try_create_redis(url="redis://localhost:6379", label="t") + assert result is None + + +@pytest.mark.asyncio +async def test_try_create_redis_returns_none_on_generic_exception(monkeypatch: pytest.MonkeyPatch) -> None: + """Generic Exception branch (lines 73-75) — e.g. malformed URL.""" + import importlib + + real_import = importlib.import_module + + def _patched(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "redis.asyncio": + + class _BadModule: + @staticmethod + def from_url(*_a: Any, **_k: Any) -> Any: + raise RuntimeError("connection refused") + + return _BadModule + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(importlib, "import_module", _patched) + result = await redis_mod._try_create_redis(url="redis://localhost:6379", label="t") + assert result is None + + +@pytest.mark.asyncio +async def test_quote_cache_redis_write_then_read_roundtrip(monkeypatch: pytest.MonkeyPatch) -> None: + """Happy-path covers quote_cache write/read via the Redis branch (lines 89-94, 113-118).""" + _install_fake_redis(monkeypatch) + cache = create_quote_cache(redis_url="redis://fake", ttl_ms=60_000) + key = cache.body_hash_key("search", {"q": "hello"}) + await cache.write(key, {"matches": ["a"]}, 2, recipients={"tempo": "0xabc"}) + got = await cache.read(key) + assert got is not None + assert got.body == {"matches": ["a"]} + assert got.price_cents == 2 + assert got.recipients == {"tempo": "0xabc"} + + +@pytest.mark.asyncio +async def test_quote_cache_redis_read_returns_none_when_key_missing(monkeypatch: pytest.MonkeyPatch) -> None: + """The Redis-read path returns None on missing key (line 92).""" + _install_fake_redis(monkeypatch) + cache = create_quote_cache(redis_url="redis://fake", ttl_ms=60_000) + got = await cache.read("missing") + assert got is None + + +@pytest.mark.asyncio +async def test_quote_cache_redis_clear_calls_flushdb(monkeypatch: pytest.MonkeyPatch) -> None: + """clear() exercises the Redis flushdb path (lines 125-126).""" + fake = _install_fake_redis(monkeypatch) + cache = create_quote_cache(redis_url="redis://fake", ttl_ms=60_000) + key = cache.body_hash_key("search", {"q": "x"}) + await cache.write(key, {}, 1) + # ensure the entry is in the fake store + assert any(json.loads(v).get("price_cents") == 1 for v in fake._store.values()) + await cache.clear() + assert fake._store == {} + + +@pytest.mark.asyncio +async def test_quote_cache_in_memory_evicts_expired_entries(monkeypatch: pytest.MonkeyPatch) -> None: + """Eviction loop in the in-memory branch (line 84): expired entry dropped.""" + import time + + cache = create_quote_cache(ttl_ms=10) + key = cache.body_hash_key("search", {"q": "evict"}) + await cache.write(key, {"v": 1}, 1) + # Fast-forward monotonic time so the entry is past its expiry. The quote_cache + # module re-exports `time` from stdlib; patching `time.monotonic` globally + # is sufficient because both modules see the same callable identity. + real_monotonic = time.monotonic + monkeypatch.setattr(time, "monotonic", lambda: real_monotonic() + 10) + got = await cache.read(key) + assert got is None diff --git a/tests/test_rate_limit.py b/tests/test_rate_limit.py new file mode 100644 index 0000000..198f669 --- /dev/null +++ b/tests/test_rate_limit.py @@ -0,0 +1,264 @@ +"""Cross-framework rate-limit middleware tests. + +Mirrors `node-commerce/tests/middleware/rate_limit.test.ts`. Each adapter is exercised +against its native test harness (Starlette TestClient for FastAPI / ASGI, Flask test +client, Sanic test client, aiohttp test client, Django AsyncClient). +""" + +from __future__ import annotations + +import json + +import pytest + +from agentscore_commerce.middleware._core import create_rate_limiter + + +@pytest.mark.asyncio +async def test_core_allows_then_blocks() -> None: + limiter = create_rate_limiter(max_requests=2, window_seconds=60) + d1 = await limiter.check("k") + d2 = await limiter.check("k") + d3 = await limiter.check("k") + assert d1.allowed is True + assert d1.remaining == 1 + assert d1.limit == 2 + assert d2.allowed is True + assert d3.allowed is False + + +@pytest.mark.asyncio +async def test_core_isolates_buckets_across_factory_calls() -> None: + a = create_rate_limiter(max_requests=1) + b = create_rate_limiter(max_requests=1) + assert (await a.check("same-key")).allowed is True + assert (await b.check("same-key")).allowed is True + assert (await a.check("same-key")).allowed is False + assert (await b.check("same-key")).allowed is False + + +def test_asgi_middleware_starlette() -> None: + from starlette.applications import Starlette + from starlette.responses import JSONResponse + from starlette.routing import Route + from starlette.testclient import TestClient + + from agentscore_commerce.middleware.asgi import RateLimitMiddleware + + async def health(_request: object) -> JSONResponse: + return JSONResponse({"ok": True}) + + app = Starlette(routes=[Route("/health", health)]) + app.add_middleware( + RateLimitMiddleware, + max_requests=2, + window_seconds=60, + key_resolver=lambda _scope: "fixed", + ) + client = TestClient(app) + r1 = client.get("/health") + r2 = client.get("/health") + r3 = client.get("/health") + assert r1.status_code == 200 + assert r1.headers["x-ratelimit-limit"] == "2" + assert r1.headers["x-ratelimit-remaining"] == "1" + assert r2.status_code == 200 + assert r3.status_code == 429 + assert r3.headers["cache-control"] == "no-store" + assert r3.json() == {"error": {"code": "rate_limited", "message": "Too many requests"}} + + +def test_fastapi_dependency() -> None: + from fastapi import Depends, FastAPI + from fastapi.testclient import TestClient + + from agentscore_commerce.middleware.fastapi import rate_limit_fastapi + + limiter = rate_limit_fastapi(max_requests=2, window_seconds=60, key_resolver=lambda _r: "fixed") + + app = FastAPI() + + @app.get("/health", dependencies=[Depends(limiter)]) + async def health() -> dict[str, bool]: + return {"ok": True} + + client = TestClient(app) + assert client.get("/health").status_code == 200 + assert client.get("/health").status_code == 200 + r3 = client.get("/health") + assert r3.status_code == 429 + assert r3.headers["cache-control"] == "no-store" + assert r3.headers["x-ratelimit-limit"] == "2" + + +def test_flask_install() -> None: + from flask import Flask + + from agentscore_commerce.middleware.flask import rate_limit_flask + + app = Flask(__name__) + rate_limit_flask(app, max_requests=2, window_seconds=60, key_resolver=lambda _r: "fixed") + + @app.get("/health") + def health() -> dict[str, bool]: + return {"ok": True} + + client = app.test_client() + assert client.get("/health").status_code == 200 + assert client.get("/health").status_code == 200 + r3 = client.get("/health") + assert r3.status_code == 429 + assert r3.headers["Cache-Control"] == "no-store" + assert r3.headers["X-RateLimit-Limit"] == "2" + assert json.loads(r3.data) == {"error": {"code": "rate_limited", "message": "Too many requests"}} + + +@pytest.mark.asyncio +async def test_aiohttp_middleware() -> None: + from aiohttp import web + from aiohttp.test_utils import TestClient, TestServer + + from agentscore_commerce.middleware.aiohttp import rate_limit_aiohttp + + middleware = rate_limit_aiohttp(max_requests=2, window_seconds=60, key_resolver=lambda _r: "fixed") + app = web.Application(middlewares=[middleware]) + + async def health(_request: web.Request) -> web.Response: + return web.json_response({"ok": True}) + + app.router.add_get("/health", health) + + async with TestClient(TestServer(app)) as client: + r1 = await client.get("/health") + r2 = await client.get("/health") + r3 = await client.get("/health") + assert r1.status == 200 + assert r1.headers["X-RateLimit-Limit"] == "2" + assert r2.status == 200 + assert r3.status == 429 + assert r3.headers["Cache-Control"] == "no-store" + + +@pytest.mark.asyncio +async def test_django_middleware(monkeypatch: pytest.MonkeyPatch) -> None: + import django + from django.conf import settings as dj_settings + from django.http import JsonResponse + + if not dj_settings.configured: + dj_settings.configure( + DEBUG=False, + ROOT_URLCONF=__name__, + ALLOWED_HOSTS=["*"], + SECRET_KEY="rate-limit-test", + ) + django.setup() + + # Force our middleware-specific override regardless of what an earlier test set. + monkeypatch.setattr( + dj_settings, + "AGENTSCORE_RATE_LIMIT", + {"max_requests": 2, "window_seconds": 60}, + raising=False, + ) + + from django.test import AsyncRequestFactory + + from agentscore_commerce.middleware.django import RateLimitMiddleware + + async def get_response(_request: object) -> JsonResponse: + return JsonResponse({"ok": True}) + + mw = RateLimitMiddleware(get_response) + factory = AsyncRequestFactory() + + r1 = await mw(factory.get("/health", HTTP_X_FORWARDED_FOR="1.1.1.1")) + r2 = await mw(factory.get("/health", HTTP_X_FORWARDED_FOR="1.1.1.1")) + r3 = await mw(factory.get("/health", HTTP_X_FORWARDED_FOR="1.1.1.1")) + assert r1.status_code == 200 + assert r1["X-RateLimit-Limit"] == "2" + assert r2.status_code == 200 + assert r3.status_code == 429 + assert r3["Cache-Control"] == "no-store" + + +@pytest.mark.asyncio +async def test_asgi_passthrough_non_http() -> None: + from agentscore_commerce.middleware.asgi import RateLimitMiddleware + + seen: list[str] = [] + + async def app(scope: dict[str, object], _receive: object, _send: object) -> None: + seen.append(str(scope["type"])) + + mw = RateLimitMiddleware(app, max_requests=1) + await mw({"type": "lifespan", "headers": []}, lambda: None, lambda _m: None) # type: ignore[arg-type] + assert seen == ["lifespan"] + + +def test_asgi_default_key_resolver_uses_client_tuple() -> None: + from agentscore_commerce.middleware.asgi import _default_scope_key_resolver + + assert _default_scope_key_resolver({"headers": [], "client": ("203.0.113.1", 5555)}) == "203.0.113.1" + assert _default_scope_key_resolver({"headers": []}) == "unknown" + assert _default_scope_key_resolver({"headers": [(b"x-forwarded-for", b"10.0.0.1, 10.0.0.2")]}) == "10.0.0.1" + + +@pytest.mark.asyncio +async def test_core_redis_path_with_stub() -> None: + """When Redis is configured and reachable, the limiter uses Redis-side counters.""" + import sys + import types + + counts: dict[str, int] = {} + + class _StubRedis: + async def incr(self, key: str) -> int: + counts[key] = counts.get(key, 0) + 1 + return counts[key] + + async def expire(self, _key: str, _seconds: int) -> bool: + return True + + def from_url(_url: str, **_kw: object) -> _StubRedis: + return _StubRedis() + + module = types.ModuleType("redis.asyncio") + module.from_url = from_url # type: ignore[attr-defined] + sys.modules["redis.asyncio"] = module + + try: + from agentscore_commerce.middleware._core import create_rate_limiter + + limiter = create_rate_limiter(max_requests=2, redis_url="redis://stub", key_prefix="testrl:") + d1 = await limiter.check("k") + d2 = await limiter.check("k") + d3 = await limiter.check("k") + assert d1.allowed and d2.allowed + assert not d3.allowed + assert counts["testrl:k"] == 3 + finally: + sys.modules.pop("redis.asyncio", None) + + +@pytest.mark.asyncio +async def test_sanic_install() -> None: + from sanic import Sanic, response + + from agentscore_commerce.middleware.sanic import rate_limit_sanic + + app = Sanic.get_app("rate-limit-test", force_create=True) + rate_limit_sanic(app, max_requests=2, window_seconds=60, key_resolver=lambda _r: "fixed") + + @app.get("/health") + async def health(_request: object) -> response.HTTPResponse: + return response.json({"ok": True}) + + _request, r1 = await app.asgi_client.get("/health") + _request, r2 = await app.asgi_client.get("/health") + _request, r3 = await app.asgi_client.get("/health") + assert r1.status == 200 + assert r1.headers["x-ratelimit-limit"] == "2" + assert r2.status == 200 + assert r3.status == 429 + assert r3.headers["cache-control"] == "no-store" diff --git a/tests/test_redis_internal.py b/tests/test_redis_internal.py new file mode 100644 index 0000000..50901b4 --- /dev/null +++ b/tests/test_redis_internal.py @@ -0,0 +1,43 @@ +"""Tests for `agentscore_commerce._redis` covering memoization + URL handling.""" + +import os +from unittest.mock import patch + +import pytest + +from agentscore_commerce._redis import memoized_redis + + +@pytest.mark.asyncio +async def test_memoized_redis_returns_none_when_no_url() -> None: + get = memoized_redis(url=None, label="test") + assert await get() is None + + +@pytest.mark.asyncio +async def test_memoized_redis_memoizes() -> None: + get = memoized_redis(url=None, label="test") + a = await get() + b = await get() + assert a is b + assert a is None + + +@pytest.mark.asyncio +async def test_memoized_redis_with_env_var() -> None: + """Falls back to REDIS_URL env when url= is None.""" + with patch.dict(os.environ, {"REDIS_URL": ""}, clear=False): + os.environ.pop("REDIS_URL", None) + get = memoized_redis(url=None, label="test-env") + assert await get() is None + + +@pytest.mark.asyncio +async def test_memoized_redis_with_unreachable_url() -> None: + """When URL is set but Redis init fails, returns None gracefully.""" + get = memoized_redis(url="redis://127.0.0.1:1", label="test-unreachable") + result = await get() + # Either None (no redis installed / construction failed) or a client object + # that won't be queried — either way memoization is the key behavior here. + again = await get() + assert result is again diff --git a/tests/test_simulate_dispatch.py b/tests/test_simulate_dispatch.py new file mode 100644 index 0000000..0a0b08c --- /dev/null +++ b/tests/test_simulate_dispatch.py @@ -0,0 +1,87 @@ +"""Tests for ``agentscore_commerce.stripe_multichain.simulate_dispatch``.""" + +from dataclasses import dataclass + +import pytest + +from agentscore_commerce.stripe_multichain.simulate_dispatch import ( + network_for_outcome, + simulate_deposit_for_outcome, +) + + +@dataclass +class Outcome: + rail: str = "" + rail_key: str = "" + mpp_method: str = "" + + +def test_x402_outcome_returns_base() -> None: + assert network_for_outcome(Outcome(rail="x402")) == "base" + + +def test_accepts_bare_tempo_and_full_directive() -> None: + assert network_for_outcome(Outcome(rail="mpp", mpp_method="tempo")) == "tempo" + assert network_for_outcome(Outcome(rail="mpp", mpp_method="tempo/charge")) == "tempo" + + +def test_accepts_bare_solana_and_full_directive() -> None: + assert network_for_outcome(Outcome(rail="mpp", mpp_method="solana")) == "solana" + assert network_for_outcome(Outcome(rail="mpp", mpp_method="solana/charge")) == "solana" + + +def test_stripe_returns_none() -> None: + assert network_for_outcome(Outcome(rail="mpp", mpp_method="stripe")) is None + assert network_for_outcome(Outcome(rail="mpp", mpp_method="stripe/charge")) is None + + +def test_falls_back_to_rail_key() -> None: + assert network_for_outcome(Outcome(rail="mpp", rail_key="solana_mpp")) == "solana" + assert network_for_outcome(Outcome(rail="mpp", rail_key="tempo_mpp")) == "tempo" + assert network_for_outcome(Outcome(rail="mpp", rail_key="stripe")) is None + + +def test_unknown_outcome_returns_none() -> None: + assert network_for_outcome(Outcome()) is None + assert network_for_outcome(Outcome(rail="mpp", mpp_method="unknown")) is None + + +def test_accepts_dict_outcomes() -> None: + assert network_for_outcome({"rail": "x402"}) == "base" + assert network_for_outcome({"rail": "mpp", "mpp_method": "solana"}) == "solana" + + +@pytest.mark.asyncio +async def test_dispatcher_noop_on_stripe_spt() -> None: + called: list[str] = [] + + def get_pi(_addr: str) -> str | None: + called.append("pi") + return "pi_x" + + await simulate_deposit_for_outcome( + outcome=Outcome(rail="mpp", mpp_method="stripe"), + deposit_address="0xabc", + get_payment_intent_id=get_pi, + stripe_secret_key="sk_test_dummy", + ) + assert called == [] + + +@pytest.mark.asyncio +async def test_dispatcher_noop_on_live_stripe_key() -> None: + called: list[str] = [] + + def get_pi(_addr: str) -> str | None: + called.append("pi") + return "pi_x" + + await simulate_deposit_for_outcome( + outcome=Outcome(rail="x402"), + deposit_address="0xabc", + get_payment_intent_id=get_pi, + stripe_secret_key="sk_live_real", + ) + # simulate_deposit_if_test_mode early-returns on sk_live_*; getter not called + assert called == [] diff --git a/tests/test_solana.py b/tests/test_solana.py new file mode 100644 index 0000000..23c3c2f --- /dev/null +++ b/tests/test_solana.py @@ -0,0 +1,36 @@ +"""Tests for `agentscore_commerce.payment.solana.load_solana_fee_payer`.""" + +import contextlib + +from agentscore_commerce.payment.solana import load_solana_fee_payer + + +def test_returns_none_for_empty_input() -> None: + assert load_solana_fee_payer(None) is None + assert load_solana_fee_payer("") is None + + +def test_hex_format_attempts_construction() -> None: + """128-char hex hits the hex branch; succeeds if solders installed, else ImportError.""" + hex_key = "a" * 128 + try: + result = load_solana_fee_payer(hex_key) + assert result is not None + except ImportError: + # solders not installed in this env — branch was exercised + pass + + +def test_base58_format_attempts_construction() -> None: + """Non-hex string falls through to base58 path.""" + # solders/base58 missing OR decoded length unexpected — branch exercised either way + with contextlib.suppress(ImportError, ValueError): + # 32-byte secret encoded as base58 (Phantom secret-only format) + load_solana_fee_payer("5Kd3NBUAdUnhyzenEwVLy9pBKxSwXvE9FMPyR4UKZvpu") + + +def test_base58_with_invalid_decoded_length_raises_value_error() -> None: + """Base58 strings that decode to !=32 and !=64 bytes raise ValueError.""" + # 'aaa' decodes to 2 bytes — not 32 or 64 + with contextlib.suppress(ValueError, ImportError): + load_solana_fee_payer("aaa") diff --git a/tests/test_solana_mpp_rail_spec_post_init.py b/tests/test_solana_mpp_rail_spec_post_init.py new file mode 100644 index 0000000..07a6ec2 --- /dev/null +++ b/tests/test_solana_mpp_rail_spec_post_init.py @@ -0,0 +1,40 @@ +"""Behavior contract for `SolanaMppRailSpec.__post_init__` mint derivation. + +When the merchant flips ``network`` to devnet (CAIP-2 or the raw ``'devnet'`` +form ``@solana/mpp`` accepts) without explicitly pinning ``token``, the +dataclass picks the devnet USDC mint, mirroring ``X402BaseRailSpec``'s pattern +for Sepolia. Explicit ``token`` overrides always win. +""" + +from agentscore_commerce.payment.rail_spec import SolanaMppRailSpec +from agentscore_commerce.payment.usdc import USDC + + +def test_default_mainnet_keeps_mainnet_mint() -> None: + spec = SolanaMppRailSpec(recipient="13QbUqJeu3VMLxn4Jypt63zqCrzKeZoaYA5k1GaWQpmS") + assert spec.token == USDC.solana.mainnet.mint + + +def test_devnet_caip2_flips_to_devnet_mint() -> None: + spec = SolanaMppRailSpec( + recipient="13QbUqJeu3VMLxn4Jypt63zqCrzKeZoaYA5k1GaWQpmS", + network="solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + ) + assert spec.token == USDC.solana.devnet.mint + + +def test_raw_devnet_string_flips_to_devnet_mint() -> None: + spec = SolanaMppRailSpec( + recipient="13QbUqJeu3VMLxn4Jypt63zqCrzKeZoaYA5k1GaWQpmS", + network="devnet", + ) + assert spec.token == USDC.solana.devnet.mint + + +def test_explicit_token_override_wins_over_network_derived_default() -> None: + spec = SolanaMppRailSpec( + recipient="13QbUqJeu3VMLxn4Jypt63zqCrzKeZoaYA5k1GaWQpmS", + network="devnet", + token="custom_mint_pubkey", + ) + assert spec.token == "custom_mint_pubkey" diff --git a/tests/test_ucp_jwks.py b/tests/test_ucp_jwks.py index abe1dc1..fe7ceda 100644 --- a/tests/test_ucp_jwks.py +++ b/tests/test_ucp_jwks.py @@ -565,8 +565,7 @@ def test_verify_wraps_unrecognized_critical_header(self) -> None: def test_verify_crit_with_missing_kid_emits_unrecognized_critical_header(self) -> None: """JWS with both crit violation AND missing kid emits unrecognized_critical_header, - matching node-commerce's typ -> alg -> kid -> crit precedence (regression guard for - the round-17 cross-SDK parity gap).""" + matching node-commerce's typ -> alg -> kid -> crit precedence.""" import base64 key = generate_ucp_signing_key(kid="real") diff --git a/tests/test_x402_settle_extra.py b/tests/test_x402_settle_extra.py new file mode 100644 index 0000000..06b41da --- /dev/null +++ b/tests/test_x402_settle_extra.py @@ -0,0 +1,125 @@ +"""Tests for `agentscore_commerce.payment.x402_settle.process_x402_settle`.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from agentscore_commerce.payment.x402_settle import ( + ProcessX402SettleFailure, + ProcessX402SettleSuccess, + classify_orchestration_error, + classify_x402_settle_result, + process_x402_settle, + settle_result_to_json_bytes, +) + + +def _make_server() -> MagicMock: + server = MagicMock() + server.build_payment_requirements = MagicMock( + return_value=[ + {"scheme": "exact", "network": "eip155:84532", "payTo": "0xabc"}, + ] + ) + server.enrich_extensions = MagicMock(return_value=None) + server.verify_payment = AsyncMock(return_value={"is_valid": True}) + server.settle_payment = AsyncMock(return_value={"success": True, "transaction": "0xdead"}) + return server + + +@pytest.mark.asyncio +async def test_process_x402_settle_success() -> None: + result = await process_x402_settle( + x402_server=_make_server(), + payload={"x402Version": 2}, + resource_config={"scheme": "exact", "network": "eip155:84532", "payTo": "0xabc"}, + resource_meta={"url": "https://x/y", "description": "t", "mimeType": "application/json"}, + ) + assert isinstance(result, ProcessX402SettleSuccess) + + +@pytest.mark.asyncio +async def test_process_x402_settle_build_requirements_throws() -> None: + server = _make_server() + server.build_payment_requirements = MagicMock(side_effect=RuntimeError("build broken")) + result = await process_x402_settle( + x402_server=server, + payload={}, + resource_config={}, + resource_meta={"url": "https://x/y", "description": "t", "mimeType": "application/json"}, + ) + assert isinstance(result, ProcessX402SettleFailure) + assert result.phase == "facilitator_error" + assert result.step == "build_requirements" + + +@pytest.mark.asyncio +async def test_process_x402_settle_no_requirements() -> None: + server = _make_server() + server.build_payment_requirements = MagicMock(return_value=[]) + result = await process_x402_settle( + x402_server=server, + payload={}, + resource_config={}, + resource_meta={"url": "https://x/y", "description": "t", "mimeType": "application/json"}, + ) + assert isinstance(result, ProcessX402SettleFailure) + assert result.phase == "no_requirements" + + +@pytest.mark.asyncio +async def test_process_x402_settle_verify_returns_invalid() -> None: + server = _make_server() + server.verify_payment = AsyncMock(return_value={"is_valid": False, "invalidReason": "bad sig"}) + result = await process_x402_settle( + x402_server=server, + payload={}, + resource_config={"scheme": "exact"}, + resource_meta={"url": "https://x/y", "description": "t", "mimeType": "application/json"}, + ) + assert isinstance(result, ProcessX402SettleFailure) + assert result.phase == "verify_failed" + + +@pytest.mark.asyncio +async def test_process_x402_settle_verify_throws() -> None: + server = _make_server() + server.verify_payment = AsyncMock(side_effect=RuntimeError("verify broken")) + result = await process_x402_settle( + x402_server=server, + payload={}, + resource_config={"scheme": "exact"}, + resource_meta={"url": "https://x/y", "description": "t", "mimeType": "application/json"}, + ) + assert isinstance(result, ProcessX402SettleFailure) + assert result.step == "verify_payment" + + +@pytest.mark.asyncio +async def test_process_x402_settle_settle_throws() -> None: + server = _make_server() + server.settle_payment = AsyncMock(side_effect=RuntimeError("settle broken")) + result = await process_x402_settle( + x402_server=server, + payload={}, + resource_config={"scheme": "exact"}, + resource_meta={"url": "https://x/y", "description": "t", "mimeType": "application/json"}, + ) + assert isinstance(result, ProcessX402SettleFailure) + assert result.phase == "settle_failed" + + +def test_classify_x402_settle_result_for_success() -> None: + failure = ProcessX402SettleFailure(phase="verify_failed", verify_result={"is_valid": False}) + cls = classify_x402_settle_result(failure) + assert cls is not None + + +def test_classify_orchestration_error_unknown_returns_none() -> None: + assert classify_orchestration_error(ValueError("totally unrelated")) is None + + +def test_settle_result_to_json_bytes() -> None: + out = settle_result_to_json_bytes({"a": 1, "b": "two"}) + assert isinstance(out, bytes) + assert b'"a"' in out diff --git a/uv.lock b/uv.lock index 4c2b8f6..2ff420d 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ [[package]] name = "agentscore-commerce" -version = "2.0.2" +version = "2.1.0" source = { editable = "." } dependencies = [ { name = "agentscore-py" }, @@ -37,6 +37,9 @@ flask = [ mppx = [ { name = "pympp", extra = ["server", "stripe", "tempo"] }, ] +redis = [ + { name = "redis" }, +] sanic = [ { name = "sanic" }, ] @@ -68,6 +71,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "python-dotenv" }, + { name = "redis" }, { name = "respx" }, { name = "ruff" }, { name = "sanic" }, @@ -89,12 +93,13 @@ requires-dist = [ { name = "httpx", specifier = ">=0.25.0,<1.0.0" }, { name = "joserfc", marker = "extra == 'ucp'", specifier = ">=1.0.0,<2" }, { name = "pympp", extras = ["server", "tempo", "stripe"], marker = "extra == 'mppx'", specifier = ">=0.6,<1" }, + { name = "redis", marker = "extra == 'redis'", specifier = ">=5.0,<7" }, { name = "sanic", marker = "extra == 'sanic'", specifier = ">=23.0.0" }, { name = "starlette", marker = "extra == 'starlette'", specifier = ">=0.27.0" }, { name = "stripe", marker = "extra == 'stripe'", specifier = ">=11.0.0" }, { name = "x402", extras = ["evm", "fastapi"], marker = "extra == 'x402'", specifier = ">=2.9,<3" }, ] -provides-extras = ["starlette", "fastapi", "flask", "django", "aiohttp", "sanic", "stripe", "x402", "mppx", "coinbase", "ucp"] +provides-extras = ["starlette", "fastapi", "flask", "django", "aiohttp", "sanic", "stripe", "x402", "mppx", "coinbase", "ucp", "redis"] [package.metadata.requires-dev] dev = [ @@ -110,6 +115,7 @@ dev = [ { name = "pytest-asyncio", specifier = ">=0.21" }, { name = "pytest-cov", specifier = ">=6.0" }, { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "redis", specifier = ">=5.0,<7" }, { name = "respx", specifier = ">=0.21" }, { name = "ruff", specifier = ">=0.11.0" }, { name = "sanic", specifier = ">=23.0.0" }, @@ -322,6 +328,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, ] +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -2420,6 +2435,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "redis" +version = "6.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, +] + [[package]] name = "regex" version = "2026.5.9" @@ -3016,14 +3043,14 @@ wheels = [ [[package]] name = "types-requests" -version = "2.33.0.20260513" +version = "2.33.0.20260518" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6d/f7/3228dd3794941bcb92ca6ca2045a6671a828ec0b47becbef23310bc45559/types_requests-2.33.0.20260513.tar.gz", hash = "sha256:bd845450e954e751373d5d33526742592f298808a3ee3bda7e858e46b839b57f", size = 24714, upload-time = "2026-05-13T05:39:23.481Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752, upload-time = "2026-05-18T06:07:37.966Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/f5/233a78be8367a9888de718f002fb27b1ea4be39471cd88aedeafceed872e/types_requests-2.33.0.20260513-py3-none-any.whl", hash = "sha256:d5a965f9d18b6e06b72039a69565de9027e58f36a7f709857da747fbe7521122", size = 21390, upload-time = "2026-05-13T05:39:22.262Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0", size = 21391, upload-time = "2026-05-18T06:07:37.044Z" }, ] [[package]] @@ -3193,89 +3220,106 @@ wheels = [ [[package]] name = "watchfiles" -version = "1.1.1" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, - { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, - { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, - { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, - { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, - { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, - { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, - { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, - { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, - { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, - { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, - { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, - { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, - { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, - { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, - { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, - { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, - { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, - { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, - { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, - { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, - { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, - { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, - { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, - { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, - { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, - { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, - { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, - { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, - { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, - { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, - { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, - { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, - { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, - { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, - { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, - { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, - { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, - { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, - { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, - { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, - { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, ] [[package]] diff --git a/vulture_whitelist.py b/vulture_whitelist.py deleted file mode 100644 index afdc65f..0000000 --- a/vulture_whitelist.py +++ /dev/null @@ -1,19 +0,0 @@ -# Vulture whitelist — false positives - -# Middleware __call__ ASGI signature -scope # noqa: F821 -receive # noqa: F821 -send # noqa: F821 - -# Redis SET kwarg in _RedisLike Protocol (structural type for redis.asyncio.Redis) -ex # noqa: F821 - -# Public API exports -AgentScoreGate # noqa: F821 -AssessResult # noqa: F821 -DenialReason # noqa: F821 -OperatorVerification # noqa: F821 - -# TYPE_CHECKING imports referenced inside string-literal cast() calls -DecisionPolicy # noqa: F821 -Signer # noqa: F821