Skip to content

Commit f216f84

Browse files
vvillait88claude
andauthored
fix(checkout): malformed-402 re-challenge must run pre_validate -> v2.5.8 (#90)
Parity hotfix for 2.5.7 (mirrors node-commerce 2.7.1). Malformed-credential 402 re-challenge skipped pre_validate, 500ing for dynamic-pricing merchants. Fix: strip the credential + re-enter handle() (full discovery flow). Regression test added. Gate green: ruff/ty/vulture/pytest (1835, 95.48%). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4e4af87 commit f216f84

4 files changed

Lines changed: 56 additions & 31 deletions

File tree

agentscore_commerce/checkout.py

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1076,16 +1076,16 @@ async def handle(self, request: CheckoutRequest) -> CheckoutResult:
10761076
else self.compose_mppx is not None
10771077
)
10781078
if enforced and malformed is not None:
1079-
# Protocol-correct recovery: a junk credential gets a FRESH 402
1080-
# challenge (same shape as the discovery leg) so an x402/MPP
1081-
# client re-pays, instead of a dead-end 400 it cannot act on.
1082-
# Strip the malformed credential first (``_discovery_view``) so
1083-
# recipient minting + MPP compose take their fresh-mint /
1084-
# fresh-challenge path rather than binding the garbage and
1085-
# raising another 400. pre_validate and the gate/assess are
1086-
# skipped by construction here (a junk credential must never burn
1087-
# the merchant's paid probe or an identity API call).
1088-
result = await self._emit_fresh_challenge(self._discovery_view(ctx))
1079+
# A junk credential is treated as a discovery request: strip it
1080+
# and re-enter handle() so pre_validate + pricing + recipient
1081+
# minting + compose all run their fresh path exactly as for a
1082+
# no-credential request. That yields a fresh 402 the agent
1083+
# re-pays against, not a dead-end 400, and not a 500 when
1084+
# compute_pricing reads state that pre_validate populates. The
1085+
# gate/assess and settle are skipped by construction: after
1086+
# stripping there is no payment header, so no re-trigger of this
1087+
# check (max one level of recursion) and no identity call.
1088+
result = await self.handle(self._strip_payment_headers(request))
10891089
return dataclasses.replace(result, settle_phase="credential_malformed")
10901090

10911091
# Pre-validate (optional): resolve merchant-specific per-request state
@@ -2658,11 +2658,11 @@ async def _handle_mppx(self, ctx: CheckoutContext) -> CheckoutResult:
26582658
)
26592659

26602660
async def _emit_fresh_challenge(self, ctx: CheckoutContext) -> CheckoutResult:
2661-
"""Emit a fresh 402 challenge (the discovery leg).
2661+
"""Emit the discovery-leg 402.
26622662
2663-
Factored out so the malformed-credential path can reuse it. Idempotent on
2664-
already-computed pricing / resolved recipients (``_emit_402`` resolves
2665-
recipients), so the normal discovery leg pays nothing extra.
2663+
pre_validate + pricing already ran in the main flow before this is
2664+
reached; idempotent on already-computed pricing / resolved recipients
2665+
(``_emit_402`` resolves recipients), so it primes nothing twice.
26662666
"""
26672667
if ctx.pricing is None:
26682668
ctx.pricing = await _maybe_await(self.compute_pricing(ctx))
@@ -2679,24 +2679,26 @@ async def _emit_fresh_challenge(self, ctx: CheckoutContext) -> CheckoutResult:
26792679
pass
26802680
return await self._emit_402(ctx, mppx_headers=mppx_headers)
26812681

2682-
def _discovery_view(self, ctx: CheckoutContext) -> CheckoutContext:
2683-
"""Return a copy of ``ctx`` with payment-credential headers removed.
2682+
def _strip_payment_headers(self, request: CheckoutRequest) -> CheckoutRequest:
2683+
"""Return a copy of the request with payment-credential headers removed.
26842684
2685-
Recipient minting and MPP compose then take their discovery (fresh-mint,
2686-
fresh-challenge) path instead of binding the inbound credential, turning a
2687-
malformed-credential request into a clean 402 re-challenge. Pricing and
2688-
recipients are reset so they mint fresh.
2685+
Re-entering handle() with it treats the request as a discovery
2686+
(no-credential) request: pre_validate + pricing + minting + compose run
2687+
their fresh path, and the gate/assess and settle are skipped. Turns a
2688+
malformed-credential request into a clean 402 re-challenge. The raw
2689+
request is left intact; compose_mppx reads it only best-effort under a
2690+
try/except, while the stripped headers are what the shape check, gate
2691+
dispatch, and recipient minting read.
26892692
"""
26902693
headers = {
26912694
k: v
2692-
for k, v in ctx.request.headers.items()
2695+
for k, v in request.headers.items()
26932696
if not (
26942697
k.lower() in ("payment-signature", "x-payment")
26952698
or (k.lower() == "authorization" and v.startswith("Payment "))
26962699
)
26972700
}
2698-
request = dataclasses.replace(ctx.request, headers=headers)
2699-
return dataclasses.replace(ctx, request=request, pricing=None, recipients={})
2701+
return dataclasses.replace(request, headers=headers)
27002702

27012703
async def _emit_402(
27022704
self,

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.7"
7+
version = "2.5.8"
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_credential_precheck.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def test_malformed_payment_credential_classifies_channels() -> None:
4747

4848

4949
@pytest.mark.asyncio
50-
async def test_junk_mpp_header_rechallenges_with_fresh_402_no_pre_validate() -> None:
50+
async def test_junk_mpp_header_rechallenges_with_fresh_402_discovery_flow() -> None:
5151
calls: list[str] = []
5252

5353
async def _pre_validate(_ctx: Any) -> dict[str, Any]:
@@ -69,12 +69,12 @@ async def _compose(_ctx: Any) -> MppxComposeOutcome:
6969
assert result.status == 402
7070
assert result.settle_phase == "credential_malformed"
7171
assert result.headers["www-authenticate"] == 'Payment realm="fresh"'
72-
# The junk credential must not burn the merchant's paid probe.
73-
assert "pre_validate" not in calls
72+
# Treated as a discovery request: pre_validate runs (pricing depends on its state).
73+
assert "pre_validate" in calls
7474

7575

7676
@pytest.mark.asyncio
77-
async def test_junk_x402_header_rechallenges_with_fresh_402_no_pre_validate() -> None:
77+
async def test_junk_x402_header_rechallenges_with_fresh_402_discovery_flow() -> None:
7878
calls: list[str] = []
7979

8080
async def _pre_validate(_ctx: Any) -> dict[str, Any]:
@@ -93,8 +93,31 @@ async def _pre_validate(_ctx: Any) -> dict[str, Any]:
9393
# A fresh challenge the agent can re-pay against, not a bare error body.
9494
assert result.body["accepted_methods"] is not None
9595
assert result.settle_phase == "credential_malformed"
96-
# Junk must not burn the merchant's paid probe.
97-
assert calls == []
96+
# Discovery flow: pre_validate runs.
97+
assert calls == ["pre_validate"]
98+
99+
100+
@pytest.mark.asyncio
101+
async def test_malformed_credential_runs_pre_validate_so_stateful_pricing_survives() -> None:
102+
# Regression: compute_pricing reads state that pre_validate populates (the
103+
# martin-estate shape). The malformed re-challenge must run pre_validate
104+
# first, or pricing dereferences missing state and 500s.
105+
async def _pre_validate(_ctx: Any) -> dict[str, Any]:
106+
return {"product": {"price_cents": 4800}}
107+
108+
def _compute_pricing(ctx: Any) -> PricingResult:
109+
return PricingResult(amount_usd=ctx.state["product"]["price_cents"] / 100)
110+
111+
checkout = Checkout(
112+
rails={"x402_base": X402BaseRailSpec(recipient="0x" + "00" * 19 + "dEaD")},
113+
url="https://api.example/purchase",
114+
pre_validate=_pre_validate,
115+
compute_pricing=_compute_pricing,
116+
x402_server=object(),
117+
)
118+
result = await checkout.handle(_req({"x-payment": "not-decodable"}))
119+
assert result.status == 402
120+
assert result.body["accepted_methods"] is not None
98121

99122

100123
@pytest.mark.asyncio

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)