Skip to content

Commit 69d8eb4

Browse files
authored
Release 2.10.0: verify session before paying, single-flight PaymentIntent creation, sweep (#142)
## Summary Release 2.10.0, parity with node-commerce 2.13.0, plus the maintenance sweep. Worked with Varun. **Verification session without a payment credential.** On an identity-gated `Checkout` the gate runs only on the settle leg, so a buyer with no identity reached the session-bearing 403 only by first sending a payment credential (a Stripe SPT buyer had to mint a token to learn they needed to verify). Now the discovery 402 carries an `identity_bootstrap` block (`header`, `value`, `instructions`) whenever the store is identity-gated and the request has no identity header, and a request carrying `X-Verification-Session: create` (case-insensitive), no identity and no payment credential runs the gate, which returns its existing session 403 with `verify_url`, `session_id`, `poll_secret` and `poll_url`. It is opt-in because discovery scanners replay the Bazaar example body on a schedule, and minting on every identity-less 402 would create a session and a pending order per probe. Gateless merchants neither advertise nor honor it. The generated `llms.txt` identity section and the Stripe/Link onboarding step name the header. **Concurrent PaymentIntent creation.** Concurrent creates under one idempotency key collide at Stripe as a resource-specific 429 that says not to retry (seen in production from the Node SDK). `create_multichain_payment_intent` is synchronous, so the race exists across the worker threads of a threaded server (Flask, Django); keyed calls now share one in-flight call, with followers waiting on the leader and a failure reaching every waiter before the key clears. Unkeyed calls are unchanged. **Sweep.** `uv lock --upgrade` (x402 2.23.0 to 2.24.0, starlette 1.7.0, uvicorn 0.54.0, sentry-sdk 2.71.0, werkzeug 3.1.9, regex, plus the ruff and ty dev bumps) and the uv pin passed to `setup-uv` in the CI, publish and security workflows moves from 0.12.17 to 0.12.20. The x402 move was checked against its wheels: every EVM `constants.py` (including the Base USDC name that the EIP-712 domain depends on) is byte-identical; the changes are in the HTTP server and middleware. ## Type of change - [ ] Bug fix (no breaking change) - [x] New feature (no breaking change) - [ ] Breaking change (existing callers must update) - [ ] Docs, tests, or internal maintenance only ## Public API Additive. New export `VERIFICATION_SESSION_HEADER` from the top-level package. Identity-gated 402 bodies gain a top-level `identity_bootstrap` object; a merchant `body_extras` key of the same name still wins. `create_multichain_payment_intent` keeps its signature. No migration needed. ## Test plan - `tests/test_checkout_verification_bootstrap.py` drives the real `handle_fastapi` adapter (the Python gate reads identity from the native request): the 402 advertises the header only on a gated store with no identity; the header returns the session 403; name and value match case-insensitively; an identity header or other value is ignored; a gateless store neither advertises nor honors it. With the new logic disabled, the three behavior tests fail and the three controls pass. - `tests/test_stripe_multichain.py`: three threads sharing a key make one Stripe call; distinct and unkeyed calls do not share; a failure reaches every waiter and the key clears for the next call. The sharing tests fail on the previous code. - Ran locally, as CI runs them: ruff check, ruff format --check, ty (package and examples), vulture, pytest (1878 passed, 4 skipped, 95.40% coverage). osv-scanner over `uv.lock`: 144 packages, no issues. No prerelease versions in the lock. ## Checklist - [x] Tests cover the new behavior, and the suite passes locally - [x] Lint, format, and type checks pass - [x] Docs and README examples updated if the public surface changed - [x] No secrets, credentials, or personal data in the diff or the tests
1 parent 906fb90 commit 69d8eb4

13 files changed

Lines changed: 598 additions & 264 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
- name: Install uv
2525
uses: astral-sh/setup-uv@v10.2.0
2626
with:
27-
version: "0.12.17"
27+
version: "0.12.20"
2828

2929
- name: Set up Python
3030
run: uv python install 3.12

‎.github/workflows/publish.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121

2222
- uses: astral-sh/setup-uv@v10.2.0
2323
with:
24-
version: "0.12.17"
24+
version: "0.12.20"
2525

2626
- name: Set version from tag
2727
run: sed -i "s/^version = .*/version = \"${GITHUB_REF_NAME#v}\"/" pyproject.toml

‎.github/workflows/security.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ jobs:
4141
- uses: useblacksmith/checkout@v1
4242
- uses: astral-sh/setup-uv@v10.2.0
4343
with:
44-
version: "0.12.17"
44+
version: "0.12.20"
4545
- uses: actions/setup-python@v7
4646
with:
4747
python-version: "3.13"

‎CLAUDE.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ Two identity types: wallet (`X-Wallet-Address`) and operator-token (`X-Operator-
8383

8484
`create_session_on_missing` auto-mints a verification session when no identity is present AND when `wallet_not_trusted` carries fixable reasons (`kyc_required` / `kyc_pending` / `kyc_failed`) — both paths rewrite the denial to `identity_verification_required` before reaching `on_denied`. When the merchant omits `create_session_on_missing` from `CheckoutGateConfig`, `Checkout` auto-defaults it from `gate.api_key` + `gate.base_url` + `gate.context` + `gate.merchant_name`. Merchants that need `on_before_session` side effects (e.g. pre-minting an order_id) supply their own config to override.
8585

86+
**Getting a verify_url before paying.** On an identity-gated `Checkout`, the gate runs only on a settle leg, so a buyer with no identity reached the session 403 only by sending a payment credential first (an SPT buyer had to mint one). The discovery 402 carries an `identity_bootstrap` block naming `X-Verification-Session: create` whenever the request has no identity header, and a request carrying that header, no identity and no payment credential runs the gate, which answers with the same session-bearing 403. It is opt-in on purpose: scanners replay the Bazaar example body on a schedule, so minting on every identity-less 402 would create a session and (on goods stores) a pending order per probe. Gateless merchants neither advertise nor honor it. Parity with node-commerce.
87+
8688
`build_verification_required_body(reason, message=?, agent_instructions=?, extra=?)` — canonical body builder for the `identity_verification_required` denial. Spreads `verify_url` / `session_id` / `poll_secret` / `poll_url` / `agent_instructions` from the gate-minted reason into a 4xx envelope with merchant-specific message + optional extras. Saves the per-merchant mapping boilerplate.
8789

8890
`get_signer_verdict(request)` (per-adapter) returns the cached `signer_match` + `signer_sanctions` verdicts the gate composed on its primary `/v1/assess` call (single round trip; merchants build a 403 with `build_signer_mismatch_body(result=verdict.signer_match)` when `kind != "pass"`).

‎agentscore_commerce/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from importlib.metadata import version as _pkg_version
1515

1616
from agentscore_commerce.checkout import (
17+
VERIFICATION_SESSION_HEADER,
1718
Checkout,
1819
CheckoutContext,
1920
CheckoutGateConfig,
@@ -168,6 +169,7 @@
168169
"AIP_A2A_EXTENSION_URI",
169170
"FIXABLE_DENIAL_REASONS",
170171
"UCP_A2A_EXTENSION_URI",
172+
"VERIFICATION_SESSION_HEADER",
171173
"A2AAgentCard",
172174
"A2AAgentCardCapabilities",
173175
"A2AAgentCardExtension",

‎agentscore_commerce/checkout.py‎

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,31 @@ def _resolve_resource_url(request: CheckoutRequest) -> str:
714714
return apply_forwarded_proto(request.url, read_forwarded_proto(request.headers))
715715

716716

717+
VERIFICATION_SESSION_HEADER = "X-Verification-Session"
718+
"""Request header that asks an identity-gated Checkout for a verification session without paying.
719+
720+
The discovery 402 advertises it; a request carrying it and no identity or payment credential runs
721+
the gate, which answers with its session-bearing 403 (verify_url + poll data). Opt-in so crawlers
722+
replaying a valid example body never mint sessions or pending orders.
723+
"""
724+
_VERIFICATION_SESSION_VALUE = "create"
725+
726+
727+
def _carries_identity(headers_lower: Mapping[str, str]) -> bool:
728+
from agentscore_commerce.aip.request import has_agent_identity_header_parts
729+
730+
return bool(
731+
headers_lower.get("x-operator-token")
732+
or headers_lower.get("x-wallet-address")
733+
or has_agent_identity_header_parts(headers_lower)
734+
)
735+
736+
737+
def _requests_verification_session(headers_lower: Mapping[str, str]) -> bool:
738+
value = headers_lower.get(VERIFICATION_SESSION_HEADER.lower()) or ""
739+
return value.strip().lower() == _VERIFICATION_SESSION_VALUE
740+
741+
717742
def _resolve_identity_metadata(ctx: CheckoutContext) -> dict[str, Any] | None:
718743
"""Compose the identity_metadata block from request + assess state.
719744
@@ -1257,7 +1282,14 @@ async def handle(self, request: CheckoutRequest) -> CheckoutResult:
12571282
# var when set; logs a warning and skips when no key is set
12581283
# (dev/testnet pattern).
12591284
has_payment_header = has_x402_header(request.headers) or has_mppx_header(request.headers)
1260-
if has_payment_header:
1285+
request_headers_lower = normalize_headers_to_lowercase(request.headers)
1286+
bootstraps_session = (
1287+
not has_payment_header
1288+
and self._has_identity_gate()
1289+
and not _carries_identity(request_headers_lower)
1290+
and _requests_verification_session(request_headers_lower)
1291+
)
1292+
if has_payment_header or bootstraps_session:
12611293
gate_result = (
12621294
await self._run_gate(ctx) if self.gate is not None else await self._run_wallet_sanctions_only(ctx)
12631295
)
@@ -2959,6 +2991,23 @@ async def _emit_402(
29592991
# wallet intent. Saves agents a round trip: they learn required_signer
29602992
# + linked_wallets at discovery instead of at the 403 on retry.
29612993
identity_metadata = _resolve_identity_metadata(ctx)
2994+
identity_bootstrap: dict[str, Any] | None = None
2995+
if self._has_identity_gate() and not _carries_identity(normalize_headers_to_lowercase(ctx.request.headers)):
2996+
identity_bootstrap = {
2997+
"header": VERIFICATION_SESSION_HEADER,
2998+
"value": _VERIFICATION_SESSION_VALUE,
2999+
"instructions": (
3000+
"This purchase requires a verified identity. Without an operator token, repeat this same "
3001+
f"request with the header {VERIFICATION_SESSION_HEADER}: {_VERIFICATION_SESSION_VALUE} and "
3002+
"no payment credential. The response is a 403 carrying verify_url, session_id, poll_secret "
3003+
"and poll_url: give verify_url to the buyer, poll poll_url for an operator_token, then pay "
3004+
"with X-Operator-Token set."
3005+
),
3006+
}
3007+
body_extra = {
3008+
**({"identity_bootstrap": identity_bootstrap} if identity_bootstrap else {}),
3009+
**(ctx.pricing.body_extras or {}),
3010+
}
29623011

29633012
# Enrich the declared Bazaar discovery extension with the request method +
29643013
# route so info.input.method (required by the v2 discovery schema) and
@@ -2994,7 +3043,7 @@ async def _emit_402(
29943043
),
29953044
),
29963045
product=ctx.pricing.product,
2997-
extra=ctx.pricing.body_extras,
3046+
extra=body_extra or None,
29983047
x402=X402PaymentRequired(
29993048
version=2,
30003049
accepts=x402_accepts,

‎agentscore_commerce/discovery/agentscore_content.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,9 @@ def build_agentscore_onboarding_steps(
123123
)
124124
stripe_fallback_step = (
125125
"If your only payment method is a Stripe / Link card (no crypto), install `@stripe/link-cli` "
126-
"instead of agentscore-pay and use it on the SPT rail. Identity gating still applies: the "
127-
"merchant's 403 with `verify_url` lets you bootstrap a Passport even with no crypto wallet involved."
126+
"instead of agentscore-pay and use it on the SPT rail. Identity gating still applies: send the "
127+
"purchase request with `X-Verification-Session: create` before minting a token, and the "
128+
"merchant's 403 with `verify_url` bootstraps a Passport even with no crypto wallet involved."
128129
)
129130
returning_user_step = (
130131
"Returning user note: if you've paid an AgentScore-gated merchant before from this wallet, "

‎agentscore_commerce/discovery/llms_txt.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ def llms_txt_identity_section(
5656
"Solana MPP). The wallet you claim must sign the payment.\n"
5757
"- **`X-Operator-Token: opc_...`**: works on every rail, including Stripe SPT. "
5858
f"Reusable across AgentScore merchants until expiry.{aip_bullet}\n"
59-
"- **Neither**: you get a 403 with `verify_url`. Complete the session flow once and "
59+
"- **Neither**: send the purchase request with `X-Verification-Session: create` and no payment "
60+
"credential, and the 403 carries `verify_url`. Complete the session flow once and "
6061
f"reuse the resulting `opc_...` everywhere.{compliance_note}"
6162
)
6263

‎agentscore_commerce/stripe_multichain/payment_intent.py‎

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
chains, returning the PI id + deposit addresses per network. Distinct from the Stripe SPT flow.
55
"""
66

7-
from dataclasses import dataclass
8-
from typing import Any, Protocol
7+
import threading
8+
from dataclasses import dataclass, field
9+
from typing import Any, Protocol, cast
910

1011
from agentscore_commerce.errors import CheckoutValidationError
1112

@@ -27,10 +28,24 @@ class MultichainPaymentIntentResult:
2728
_DEFAULT_NETWORKS: tuple[str, ...] = ("tempo", "base", "solana")
2829

2930

31+
@dataclass
32+
class _Flight:
33+
done: threading.Event = field(default_factory=threading.Event)
34+
result: MultichainPaymentIntentResult | None = None
35+
error: BaseException | None = None
36+
37+
38+
# Concurrent creates under one idempotency key collide at Stripe as a resource-specific 429
39+
# (``stripe-should-retry: false``). The call is synchronous, so the race exists across the worker
40+
# threads of a threaded server; identical requests racing there share the first call.
41+
_in_flight: dict[str, _Flight] = {}
42+
_in_flight_lock = threading.Lock()
43+
44+
3045
def create_multichain_payment_intent(
3146
*,
32-
stripe: Any, # StripeClientLike, kept loose so vendors can pass their actual `stripe.StripeClient`
33-
amount: int, # in cents (Stripe convention)
47+
stripe: Any,
48+
amount: int,
3449
currency: str = "usd",
3550
networks: list[str] | None = None,
3651
metadata: dict[str, str] | None = None,
@@ -39,7 +54,50 @@ def create_multichain_payment_intent(
3954
"""Create a Stripe PaymentIntent with multichain crypto deposit_options.
4055
4156
Returns the PI id + per-network deposit addresses. Raises if Stripe doesn't return any addresses.
57+
Concurrent calls sharing an ``idempotency_key`` share one Stripe request.
4258
"""
59+
kwargs: dict[str, Any] = {
60+
"stripe": stripe,
61+
"amount": amount,
62+
"currency": currency,
63+
"networks": networks,
64+
"metadata": metadata,
65+
"idempotency_key": idempotency_key,
66+
}
67+
if idempotency_key is None:
68+
return _create_multichain_payment_intent_once(**kwargs)
69+
with _in_flight_lock:
70+
flight = _in_flight.get(idempotency_key)
71+
leader = flight is None
72+
if flight is None:
73+
flight = _Flight()
74+
_in_flight[idempotency_key] = flight
75+
if not leader:
76+
flight.done.wait()
77+
if flight.error is not None:
78+
raise flight.error
79+
return cast("MultichainPaymentIntentResult", flight.result)
80+
try:
81+
flight.result = _create_multichain_payment_intent_once(**kwargs)
82+
return flight.result
83+
except BaseException as err:
84+
flight.error = err
85+
raise
86+
finally:
87+
with _in_flight_lock:
88+
_in_flight.pop(idempotency_key, None)
89+
flight.done.set()
90+
91+
92+
def _create_multichain_payment_intent_once(
93+
*,
94+
stripe: Any, # StripeClientLike, kept loose so vendors can pass their actual `stripe.StripeClient`
95+
amount: int, # in cents (Stripe convention)
96+
currency: str = "usd",
97+
networks: list[str] | None = None,
98+
metadata: dict[str, str] | None = None,
99+
idempotency_key: str | None = None,
100+
) -> MultichainPaymentIntentResult:
43101
resolved_networks = list(networks) if networks else list(_DEFAULT_NETWORKS)
44102
params: dict[str, Any] = {
45103
"amount": amount,

‎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.9.2"
7+
version = "2.10.0"
88
description = "Agentic commerce SDK for Python: identity middleware (FastAPI, Flask, Django, AIOHTTP, Sanic, ASGI) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agentic commerce."
99
readme = "README.md"
1010
license = "MIT"

0 commit comments

Comments
 (0)