Skip to content

Commit 9950b2e

Browse files
vvillait88claude
andcommitted
feat: add detect_rail_from_headers helper
Returns "mpp" when Authorization: Payment is present, "x402" when payment-signature/x-payment is present, None otherwise. Case-insensitive header lookup per RFC 7230 §3.2; auth scheme matched per RFC 7235. In practice a client constructs a request with exactly one protocol's headers; if somehow both arrive (client bug or misconfigured proxy), MPP wins by checking it first. Documented as a tiebreaker, not a feature. Tests lock 14 cross-language fixtures with the @agent-score/commerce sibling. Parametrized so multiple drifts surface independently. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 55125ea commit 9950b2e

3 files changed

Lines changed: 100 additions & 4 deletions

File tree

agentscore_commerce/payment/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
build_payment_request_blob,
1010
payment_directive,
1111
)
12-
from agentscore_commerce.payment.dispatch import dispatch_settlement_by_network
12+
from agentscore_commerce.payment.dispatch import detect_rail_from_headers, dispatch_settlement_by_network
1313
from agentscore_commerce.payment.headers import (
1414
BuildPaymentHeadersInput,
1515
PaymentHeadersRail,
@@ -124,6 +124,7 @@
124124
"coerce_resource_config",
125125
"create_mppx_server",
126126
"create_x402_server",
127+
"detect_rail_from_headers",
127128
"dispatch_settlement_by_network",
128129
"extract_payment_signer",
129130
"extract_x402_signer",

agentscore_commerce/payment/dispatch.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,44 @@
1-
"""Settlement dispatch by CAIP-2 network family (eip155→evm, solana→svm)."""
1+
"""Payment dispatch helpers.
2+
3+
* :func:`detect_rail_from_headers` — detect which payment-protocol family
4+
(x402 vs MPP) the inbound request carries, based on header presence.
5+
* :func:`dispatch_settlement_by_network` — route a settlement payload to
6+
evm vs svm handler based on the CAIP-2 network family in
7+
``payload.accepted.network``.
8+
"""
29

310
import inspect
4-
from collections.abc import Awaitable, Callable
5-
from typing import Any, TypeVar, cast
11+
from collections.abc import Awaitable, Callable, Mapping
12+
from typing import Any, Literal, TypeVar, cast
613

714
T = TypeVar("T")
815
Handler = Callable[[Any], T | Awaitable[T]]
916

1017

18+
def detect_rail_from_headers(headers: Mapping[str, str]) -> Literal["x402", "mpp"] | None:
19+
"""Detect which payment-protocol family the inbound request carries.
20+
21+
Returns ``"mpp"`` when an ``Authorization`` header starts with the ``Payment``
22+
scheme (case-insensitive per RFC 7235). Returns ``"x402"`` when a non-empty
23+
``payment-signature`` or ``x-payment`` header is present. Returns ``None``
24+
otherwise.
25+
26+
In practice a client constructs a request with exactly one protocol's headers;
27+
both arriving together is a client bug or misconfigured proxy. The helper
28+
checks MPP first so the rare degenerate case resolves to MPP. Empty header
29+
values are treated as absent. Header-name lookups are case-insensitive
30+
(RFC 7230 §3.2). The narrower rail naming (``"tempo"`` vs ``"solana"`` inside
31+
MPP) is merchant-side, derived from the credential body, not this helper.
32+
"""
33+
lower = {k.lower(): v for k, v in headers.items()}
34+
auth = lower.get("authorization") or ""
35+
if auth.lower().startswith("payment "):
36+
return "mpp"
37+
if lower.get("payment-signature") or lower.get("x-payment"):
38+
return "x402"
39+
return None
40+
41+
1142
async def dispatch_settlement_by_network(
1243
payload: Any,
1344
*,

tests/test_dispatch.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Tests for ``agentscore_commerce.payment.dispatch.detect_rail_from_headers``.
2+
3+
The fixture corpus below is locked as the cross-language contract with the
4+
Node sibling at ``node-commerce/tests/payment/detect_rail_from_headers.test.ts``.
5+
Both files reference identical header maps + expected results. A drift in either
6+
language (case-handling, empty-value treatment, scheme-prefix matching) fails
7+
that language's test against the locked value.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import pytest
13+
14+
from agentscore_commerce.payment import detect_rail_from_headers
15+
16+
# Cross-language fixtures: (label, headers_dict, expected_rail).
17+
_FIXTURES: list[tuple[str, dict[str, str], str | None]] = [
18+
("empty", {}, None),
19+
("payment_signature_only", {"payment-signature": "abc"}, "x402"),
20+
("x_payment_only", {"x-payment": "abc"}, "x402"),
21+
("authorization_payment", {"authorization": "Payment abc"}, "mpp"),
22+
("authorization_bearer", {"authorization": "Bearer xyz"}, None),
23+
("authorization_lowercase_scheme", {"authorization": "payment abc"}, "mpp"),
24+
("authorization_uppercase_name", {"Authorization": "Payment abc"}, "mpp"),
25+
("x_payment_uppercase_name", {"X-Payment": "abc"}, "x402"),
26+
("empty_values_dont_count", {"payment-signature": "", "x-payment": ""}, None),
27+
(
28+
"mpp_wins_when_both_present",
29+
{"x-payment": "abc", "authorization": "Payment xyz"},
30+
"mpp",
31+
),
32+
("payment_without_space_is_not_mpp", {"authorization": "PaymentNoSpace"}, None),
33+
("payment_with_only_space_is_mpp", {"authorization": "Payment "}, "mpp"),
34+
("mixed_case_authorization_name", {"AUTHORIZATION": "Payment abc"}, "mpp"),
35+
("authorization_uppercase_scheme", {"authorization": "PAYMENT abc"}, "mpp"),
36+
]
37+
38+
39+
@pytest.mark.parametrize(
40+
("label", "headers", "expected"),
41+
_FIXTURES,
42+
ids=[label for label, _, _ in _FIXTURES],
43+
)
44+
def test_locked_cross_language_fixture(
45+
label: str,
46+
headers: dict[str, str],
47+
expected: str | None,
48+
) -> None:
49+
del label # `label` is consumed by parametrize ids; bind locally so linters don't flag it.
50+
"""Each fixture header set maps to the locked cross-language rail value."""
51+
assert detect_rail_from_headers(headers) == expected
52+
53+
54+
def test_returns_x402_for_non_string_truthy_value() -> None:
55+
"""Any non-empty header value is treated as present (no validation of contents)."""
56+
assert detect_rail_from_headers({"x-payment": "0"}) == "x402"
57+
58+
59+
def test_does_not_mutate_input_headers() -> None:
60+
headers = {"X-Payment": "abc", "Authorization": "Payment xyz"}
61+
detect_rail_from_headers(headers)
62+
# Keys preserved verbatim; helper only reads via a lowercase projection.
63+
assert "X-Payment" in headers
64+
assert "Authorization" in headers

0 commit comments

Comments
 (0)