Skip to content

Commit 37e2b2c

Browse files
authored
fix: honor X-Forwarded-Proto in compute-first resource.url (2.5.1) (#80)
Python parity for node-commerce #72. Compute-first emitted the x402 `resource.url` from the raw request URL, so behind a TLS-terminating proxy it stayed `http://` while clients reached the endpoint over `https://`. The mppx x402 client rejects that scheme mismatch, blocking payment on every rail. The main `Checkout` path already corrected this via `_resolve_resource_url`; the compute-first path was never wired to it. - Extract the scheme correction into a shared `forwarded_proto` helper (`apply_forwarded_proto` + `read_forwarded_proto`, both-casings read for Django). - Route both compute-first emitters + the refactored main-path resolver through it. - Verified all 6 adapters (fastapi/flask/django/aiohttp/sanic/asgi) pass the full header set. Tests added; 1819 pass, coverage 95.47%.
1 parent 331f32e commit 37e2b2c

9 files changed

Lines changed: 184 additions & 16 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ pip install 'agentscore-commerce[fastapi,x402,coinbase]'
3030
| `agentscore_commerce.payment` | `networks`, `USDC`, `rails` registries; `payment_directive`, `build_payment_directive`, `www_authenticate_header`, `payment_required_header`, `alias_amount_fields` (opt-in v1↔v2 amount-field shim that adds both `amount` and `maxAmountRequired` to an entry. The 402 builders do NOT apply it by default, strict x402 v2 settlement matches the agent's echoed requirement by exact comparison, so an extra field the server's rebuilt requirement lacks breaks settle; use only when you know a client is hardcoded to read `maxAmountRequired`), `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 <base64>` `did:pkh:eip155:<chain>:<addr>` / `did:pkh:solana:<genesis>:<addr>` 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). |
3131
| `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}`. |
3232
| `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. |
33-
| `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=, static_recipients=, 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 OR a `static_recipients` match, the static address is always-accepted because the merchant owns it); on the discovery leg, mints a fresh PI for the rails NOT covered by `static_recipients`, caches the merged map, registers static addresses with `pi_cache.cache_address` so verify-leg lookups pass. `mint_multichain_recipients(...same kwargs)`, structured variant returning `MintMultichainRecipientsResult(recipients, payment_intent_id, reused_from_credential)` for the full per-rail map (typical multi-rail merchant hook). Use `static_recipients={"solana": "<wallet>"}` for low-margin endpoints where Solana per-call ATA rent (~$0.50 against MPP spec §13.6) dominates revenue, the SDK skips Stripe minting on that network, reuses the static recipient forever, and (with a one-time external pre-funding of the recipient's USDC ATA) lets every settle pay only the ~$0.001 per-tx fee. `SolanaMppRailSpec.ata_creation_required` defaults to `True` (data-only; solana method registration through `create_mppx_server` is a follow-up, merchants building the solana method directly via `pympp` should pass the flag themselves to the charge factory). `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`. |
33+
| `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=, static_recipients=, 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 OR a `static_recipients` match, the static address is always-accepted because the merchant owns it); on the discovery leg, mints a fresh PI for the rails NOT covered by `static_recipients`, caches the merged map, registers static addresses with `pi_cache.cache_address` so verify-leg lookups pass. `mint_multichain_recipients(...same kwargs)`, structured variant returning `MintMultichainRecipientsResult(recipients, payment_intent_id, reused_from_credential)` for the full per-rail map (typical multi-rail merchant hook). Solana MPP requires a static recipient: pass `static_recipients={"solana": "<wallet>"}` and pre-fund its USDC ATA once from any external wallet. Rotating (per-order, Stripe-minted) Solana recipients do not settle, since the settlement transaction does not create the recipient's token account; the SDK logs a warning when one is minted. The static recipient is reused forever, each settle pays only the ~$0.001 per-tx fee, and this also sidesteps the per-call ATA rent (~$0.50 against MPP spec §13.6). `SolanaMppRailSpec.ata_creation_required` defaults to `True` (data-only; solana method registration through `create_mppx_server` is a follow-up, merchants building the solana method directly via `pympp` should pass the flag themselves to the charge factory). `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`. |
3434
| `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. |
3535
| `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). |
3636

agentscore_commerce/checkout.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
from agentscore_commerce.challenge.respond_402 import Respond402Result, respond_402
8888
from agentscore_commerce.challenge.validation_error import build_validation_error
8989
from agentscore_commerce.errors import CheckoutValidationError
90+
from agentscore_commerce.forwarded_proto import apply_forwarded_proto, read_forwarded_proto
9091
from agentscore_commerce.payment.constants import STRIPE_MIN_CHARGE_USD
9192
from agentscore_commerce.payment.mppx_failures import classify_mppx_failure
9293
from agentscore_commerce.payment.payment_header import has_mppx_header, has_x402_header
@@ -675,17 +676,7 @@ def _resolve_resource_url(request: CheckoutRequest) -> str:
675676
Behind ALB / CloudFront the inbound ``request.url`` is ``http://``; x402 discovery
676677
requires ``https://``, so honor ``X-Forwarded-Proto`` (the proxy's original scheme).
677678
"""
678-
fwd = request.headers.get("x-forwarded-proto") or request.headers.get("X-Forwarded-Proto")
679-
if fwd:
680-
proto = fwd.split(",")[0].strip()
681-
if proto:
682-
from urllib.parse import urlparse, urlunparse
683-
684-
try:
685-
return urlunparse(urlparse(request.url)._replace(scheme=proto))
686-
except ValueError:
687-
pass
688-
return request.url
679+
return apply_forwarded_proto(request.url, read_forwarded_proto(request.headers))
689680

690681

691682
def _resolve_identity_metadata(ctx: CheckoutContext) -> dict[str, Any] | None:

agentscore_commerce/checkout_compute_first.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
)
4949
from agentscore_commerce.discovery import build_success_next_steps
5050
from agentscore_commerce.errors import CheckoutValidationError
51+
from agentscore_commerce.forwarded_proto import apply_forwarded_proto, read_forwarded_proto
5152
from agentscore_commerce.payment.amounts import format_usd_cents
5253
from agentscore_commerce.payment.constants import STRIPE_MIN_CHARGE_USD
5354
from agentscore_commerce.payment.payment_header import has_mppx_header, has_x402_header
@@ -429,7 +430,9 @@ async def _emit_402(
429430
headers = {"Content-Type": "application/json"}
430431
headers.update(mpp_challenge_headers)
431432
headers["PAYMENT-REQUIRED"] = payment_required_header(
432-
x402_version=2, accepts=accepted, resource={"url": self.url}
433+
x402_version=2,
434+
accepts=accepted,
435+
resource={"url": apply_forwarded_proto(request.url, read_forwarded_proto(request.headers))},
433436
)
434437
return 402, body_402, headers
435438

@@ -560,7 +563,7 @@ async def _bind_pay_to(addr: str) -> bool:
560563
"maxTimeoutSeconds": 300,
561564
},
562565
resource_meta={
563-
"url": request.url,
566+
"url": apply_forwarded_proto(request.url, read_forwarded_proto(request.headers)),
564567
"description": f"Agent purchase via x402-exact ({self.name})",
565568
"mimeType": "application/json",
566569
},
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
"""Scheme-correct a resource URL for TLS-terminating edge proxies."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
from urllib.parse import urlparse, urlunparse
7+
8+
if TYPE_CHECKING:
9+
from collections.abc import Mapping
10+
11+
12+
def apply_forwarded_proto(url: str, forwarded_proto: str | None) -> str:
13+
"""Rewrite a URL's scheme to the proxy's original protocol.
14+
15+
Behind a TLS-terminating edge proxy (ALB / CloudFront / nginx) the inbound
16+
request arrives as ``http://``, but x402 discovery — and the mppx client's
17+
resource-match check — require the public ``https://``. Honor
18+
``X-Forwarded-Proto`` (the scheme the client actually used) so the emitted
19+
``resource.url`` matches the URL the client fetched. ``forwarded_proto`` may
20+
carry a comma-separated proxy chain (``"https, http"``); the first hop is the
21+
client-facing scheme. A missing/blank value leaves the URL untouched (direct
22+
HTTP in local dev stays ``http://``).
23+
"""
24+
if not forwarded_proto:
25+
return url
26+
proto = forwarded_proto.split(",")[0].strip()
27+
if not proto:
28+
return url
29+
try:
30+
return urlunparse(urlparse(url)._replace(scheme=proto))
31+
except ValueError:
32+
return url
33+
34+
35+
def read_forwarded_proto(headers: Mapping[str, str]) -> str | None:
36+
"""Read ``X-Forwarded-Proto`` from a header mapping regardless of casing."""
37+
return headers.get("x-forwarded-proto") or headers.get("X-Forwarded-Proto")

agentscore_commerce/stripe_multichain/pay_to_address.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from __future__ import annotations
1818

1919
import asyncio
20+
import logging
2021
from dataclasses import dataclass
2122
from typing import TYPE_CHECKING, Any
2223

@@ -28,6 +29,15 @@
2829
if TYPE_CHECKING:
2930
from agentscore_commerce.stripe_multichain.pi_cache import PiCache
3031

32+
_logger = logging.getLogger(__name__)
33+
34+
# Stripe-minted Solana deposit addresses rotate per PaymentIntent and have no
35+
# pre-existing USDC ATA. @solana/mpp >=0.6.0 will not create the primary
36+
# recipient's ATA in the charge transaction, so the settle fails simulation
37+
# (InstructionError [2, InvalidAccountData]). Warn once when a rotating Solana
38+
# recipient is about to be minted so the merchant isn't blindsided at settle.
39+
_warned_rotating_solana_mint = False
40+
3141

3242
async def _maybe_await(value: Any) -> Any:
3343
if asyncio.iscoroutine(value) or asyncio.isfuture(value):
@@ -241,6 +251,17 @@ async def _mint_and_cache(
241251
Registers everything in the cache and returns (preferred_address, merged_map).
242252
"""
243253
stripe_networks = [n for n in networks if n not in static_recipients]
254+
global _warned_rotating_solana_mint
255+
if "solana" in stripe_networks and not _warned_rotating_solana_mint:
256+
_warned_rotating_solana_mint = True
257+
_logger.warning(
258+
"[stripe-multichain] Minting a per-PaymentIntent (rotating) Solana recipient. "
259+
"Solana MPP settle fails on @solana/mpp >=0.6.0 for rotating recipients: the "
260+
"client does not create the primary recipient ATA, so a fresh deposit address "
261+
"has no token account to receive into. Use a static Solana recipient with a "
262+
"pre-funded ATA (static_recipients={'solana': '<wallet>'}), or drop 'solana' "
263+
"from networks."
264+
)
244265
idempotency_key = f"pi-{order_id}-{amount_cents}" if order_id else None
245266
result = create_multichain_payment_intent(
246267
stripe=stripe,

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "agentscore-commerce"
7-
version = "2.5.0"
7+
version = "2.5.1"
88
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."
99
readme = "README.md"
1010
license = "MIT"

tests/test_forwarded_proto.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""X-Forwarded-Proto scheme correction for the x402 resource.url (mirrors node-commerce)."""
2+
3+
import base64
4+
import json
5+
from typing import Any
6+
7+
import pytest
8+
9+
from agentscore_commerce.checkout_compute_first import (
10+
ComputeFirstCheckout,
11+
ComputeFirstRails,
12+
ComputeFirstRequest,
13+
ComputeFirstWorkContext,
14+
WorkOutcome,
15+
)
16+
from agentscore_commerce.forwarded_proto import apply_forwarded_proto, read_forwarded_proto
17+
from agentscore_commerce.payment.rail_spec import TempoRailSpec
18+
19+
20+
def test_apply_forwarded_proto_rewrites_scheme() -> None:
21+
assert apply_forwarded_proto("http://agents.example.com/purchase", "https") == "https://agents.example.com/purchase"
22+
23+
24+
def test_apply_forwarded_proto_takes_first_proxy_hop() -> None:
25+
assert apply_forwarded_proto("http://x.com/a", "https, http") == "https://x.com/a"
26+
27+
28+
def test_apply_forwarded_proto_passthrough_when_absent() -> None:
29+
assert apply_forwarded_proto("http://localhost:3003/purchase", None) == "http://localhost:3003/purchase"
30+
assert apply_forwarded_proto("http://x.com/a", "") == "http://x.com/a"
31+
32+
33+
def test_read_forwarded_proto_both_casings() -> None:
34+
# Lowercase (fastapi/flask/aiohttp/sanic) and Title-Case (Django) both resolve.
35+
assert read_forwarded_proto({"x-forwarded-proto": "https"}) == "https"
36+
assert read_forwarded_proto({"X-Forwarded-Proto": "https"}) == "https"
37+
assert read_forwarded_proto({}) is None
38+
39+
40+
async def _run_one(_body: dict[str, Any], _ctx: ComputeFirstWorkContext) -> WorkOutcome:
41+
return WorkOutcome(result_count=1, body={"matches": ["one"], "total": 1})
42+
43+
44+
def _handler() -> ComputeFirstCheckout:
45+
return ComputeFirstCheckout(
46+
name="search",
47+
url="https://api.example.com/search",
48+
unit_price_cents=1,
49+
rails=ComputeFirstRails(tempo=TempoRailSpec(recipient="0xtempo", testnet=True)),
50+
x402_server=None,
51+
run_work=_run_one,
52+
)
53+
54+
55+
def _resource_url(headers: dict[str, str]) -> str:
56+
decoded = json.loads(base64.b64decode(headers["PAYMENT-REQUIRED"]))
57+
return decoded["resource"]["url"]
58+
59+
60+
@pytest.mark.asyncio
61+
async def test_compute_first_402_rewrites_resource_scheme_behind_proxy() -> None:
62+
req = ComputeFirstRequest(
63+
method="POST",
64+
url="http://agents.example.com/purchase",
65+
headers={"x-forwarded-proto": "https"},
66+
body={"q": "x"},
67+
)
68+
status, _body, headers = await _handler().handle(req)
69+
assert status == 402
70+
assert _resource_url(headers) == "https://agents.example.com/purchase"
71+
72+
73+
@pytest.mark.asyncio
74+
async def test_compute_first_402_leaves_http_without_proxy_header() -> None:
75+
req = ComputeFirstRequest(
76+
method="POST",
77+
url="http://localhost:3003/purchase",
78+
headers={},
79+
body={"q": "x"},
80+
)
81+
status, _body, headers = await _handler().handle(req)
82+
assert status == 402
83+
assert _resource_url(headers) == "http://localhost:3003/purchase"

0 commit comments

Comments
 (0)