Skip to content

Commit b6eb14e

Browse files
vvillait88claude
andcommitted
refactor(identity, payment): flatten LoadUCPSigningKeyOptions + delete dead CreateX402ServerOptions
Last remaining wrapper dataclass in the SDK. load_ucp_signing_key_from_env now takes the 5 fields directly as kwargs; the env-var-name overrides remain on the public surface so multi-env-namespace merchants still pin once via a module-level dict + spread. While auditing, CreateX402ServerOptions surfaced as a leftover wrapper that no longer matched its function (create_x402_server was already kwarg-shaped); deleted along with its barrel export. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4dec77f commit b6eb14e

9 files changed

Lines changed: 73 additions & 100 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ pip install 'agentscore-commerce[fastapi,x402,coinbase]'
2525
| Submodule | What it provides |
2626
|---|---|
2727
| `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. |
28-
| `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` (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` + `LoadUCPSigningKeyOptions` (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`); `hash_operator_token` (sha256 hex of plaintext `opc_...` — for merchants persisting `operator_token_id` to their own DB without ever storing the plaintext). |
28+
| `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` (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). |
2929
| `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 <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). |
3030
| `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), `agentscore_openapi_snippets`, `build_bazaar_discovery_payload`, `NoindexNonDiscoveryMiddleware` (ASGI middleware that emits `X-Robots-Tag: noindex` on every path except the agent-discovery surfaces; defaults cover `/openapi.json`, `/llms.txt`, `/skill.md`, `/.well-known/{mpp.json,agent-card.json,ucp,jwks.json}`, `/favicon.{png,ico}`; pure helpers `is_discovery_path` + `DEFAULT_DISCOVERY_PATHS` for non-ASGI frameworks). |
3131
| `agentscore_commerce.challenge` | `build_402_body`, `build_accepted_methods`, `build_identity_metadata`, `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), `OrderReceipt` (dataclass for the post-settlement 200 response shape); `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/identity/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@
6464
)
6565
from agentscore_commerce.identity.ucp_jwks import (
6666
GeneratedUCPKey,
67-
LoadUCPSigningKeyOptions,
6867
UCPVerificationError,
6968
build_jwks_response,
7069
generate_ucp_signing_key,
@@ -118,7 +117,6 @@ def _load_asgi_middleware() -> tuple[Any, Any]:
118117
"GateResult",
119118
"GeneratedUCPKey",
120119
"IdentityStatus",
121-
"LoadUCPSigningKeyOptions",
122120
"OperatorVerification",
123121
"PolicyBlock",
124122
"SignerSanctions",

agentscore_commerce/identity/ucp_jwks.py

Lines changed: 45 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -540,23 +540,6 @@ async def jwks():
540540
_logger = logging.getLogger(__name__)
541541

542542

543-
@dataclass(frozen=True)
544-
class LoadUCPSigningKeyOptions:
545-
"""Configuration for :func:`load_ucp_signing_key_from_env`.
546-
547-
Env-var names are overridable so a merchant can run multiple distinct signing
548-
keys from different env namespaces (e.g. ``PROD_UCP_JWK`` vs ``STAGING_UCP_JWK``).
549-
``default_kid`` and ``default_alg`` are used when the env JWK is absent or
550-
doesn't carry its own ``kid`` / can't dictate alg via kty+crv.
551-
"""
552-
553-
env_jwk_var: str = "UCP_SIGNING_KEY_JWK_PRIVATE"
554-
env_kid_var: str = "UCP_SIGNING_KEY_KID"
555-
env_alg_var: str = "UCP_SIGNING_KEY_ALG"
556-
default_kid: str = "merchant-default"
557-
default_alg: Literal["EdDSA", "ES256"] = "EdDSA"
558-
559-
560543
_env_loader_cache: dict[tuple[str, str, str, str, str], GeneratedUCPKey] = {}
561544
_env_loader_lock = threading.Lock()
562545

@@ -585,34 +568,41 @@ def _detect_alg_from_jwk(jwk: dict[str, Any]) -> Literal["EdDSA", "ES256"] | Non
585568
return None
586569

587570

588-
def _build_env_signing_key(opts: LoadUCPSigningKeyOptions) -> GeneratedUCPKey:
571+
def _build_env_signing_key(
572+
*,
573+
env_jwk_var: str,
574+
env_kid_var: str,
575+
env_alg_var: str,
576+
default_kid: str,
577+
default_alg: Literal["EdDSA", "ES256"],
578+
) -> GeneratedUCPKey:
589579
"""Load (or generate) one signing key per env state. No locking (caller wraps)."""
590-
kid_default = _read_env_trimmed(opts.env_kid_var) or opts.default_kid
591-
raw_alg = (_read_env_trimmed(opts.env_alg_var) or "").upper()
580+
kid_default = _read_env_trimmed(env_kid_var) or default_kid
581+
raw_alg = (_read_env_trimmed(env_alg_var) or "").upper()
592582
# Case-insensitive env-alg comparison: secret configs commonly carry casing
593583
# drift (``"es256"``, ``" ES256 "``, ``"eS256"``). Strict exact-match would
594584
# silently downgrade to the default and operators would publish a JWKS
595585
# containing the wrong key family.
596-
alg_fallback: Literal["EdDSA", "ES256"] = "ES256" if raw_alg == "ES256" else opts.default_alg
586+
alg_fallback: Literal["EdDSA", "ES256"] = "ES256" if raw_alg == "ES256" else default_alg
597587

598-
env_jwk = _read_env_trimmed(opts.env_jwk_var)
588+
env_jwk = _read_env_trimmed(env_jwk_var)
599589
if env_jwk:
600590
from joserfc.jwk import ECKey, OKPKey # type: ignore[import-not-found]
601591

602592
try:
603593
jwk_dict = json.loads(env_jwk)
604594
except json.JSONDecodeError as exc:
605-
msg = f"{opts.env_jwk_var} is not valid JSON: {exc}"
595+
msg = f"{env_jwk_var} is not valid JSON: {exc}"
606596
raise ValueError(msg) from exc
607597

608598
if not isinstance(jwk_dict, dict) or not jwk_dict:
609-
msg = f"{opts.env_jwk_var} must be a non-empty JWK object; got {type(jwk_dict).__name__}."
599+
msg = f"{env_jwk_var} must be a non-empty JWK object; got {type(jwk_dict).__name__}."
610600
raise ValueError(msg)
611601

612602
detected_alg = _detect_alg_from_jwk(jwk_dict)
613603
if not detected_alg:
614604
msg = (
615-
f"{opts.env_jwk_var} has unsupported kty/crv "
605+
f"{env_jwk_var} has unsupported kty/crv "
616606
f"(got kty={jwk_dict.get('kty')!r} crv={jwk_dict.get('crv')!r}); "
617607
"expected OKP+Ed25519 or EC+P-256."
618608
)
@@ -625,7 +615,7 @@ def _build_env_signing_key(opts: LoadUCPSigningKeyOptions) -> GeneratedUCPKey:
625615
# back fields of the input JWK including private key material. Surface only
626616
# the exception class so logs never carry key bytes through stderr / CloudWatch.
627617
msg = (
628-
f"{opts.env_jwk_var} has malformed key material ({type(exc).__name__}). "
618+
f"{env_jwk_var} has malformed key material ({type(exc).__name__}). "
629619
"Verify the JWK is well-formed and matches the declared kty/crv. "
630620
"Underlying details suppressed to avoid leaking key bytes."
631621
)
@@ -656,60 +646,70 @@ def _build_env_signing_key(opts: LoadUCPSigningKeyOptions) -> GeneratedUCPKey:
656646
"Loaded persistent UCP signing key kid=%s alg=%s from %s",
657647
public_jwk["kid"],
658648
detected_alg,
659-
opts.env_jwk_var,
649+
env_jwk_var,
660650
)
661651
return GeneratedUCPKey(private_key=priv, public_jwk=public_jwk)
662652

663653
_logger.error(
664654
"%s not set; generating ephemeral signing key. Verifier caches will break across restarts. "
665655
"NOT SAFE FOR PRODUCTION.",
666-
opts.env_jwk_var,
656+
env_jwk_var,
667657
)
668658
return generate_ucp_signing_key(kid=kid_default, alg=alg_fallback)
669659

670660

671-
def load_ucp_signing_key_from_env(opts: LoadUCPSigningKeyOptions | None = None) -> GeneratedUCPKey:
661+
def load_ucp_signing_key_from_env(
662+
*,
663+
env_jwk_var: str = "UCP_SIGNING_KEY_JWK_PRIVATE",
664+
env_kid_var: str = "UCP_SIGNING_KEY_KID",
665+
env_alg_var: str = "UCP_SIGNING_KEY_ALG",
666+
default_kid: str = "merchant-default",
667+
default_alg: Literal["EdDSA", "ES256"] = "EdDSA",
668+
) -> GeneratedUCPKey:
672669
"""Load the merchant's UCP signing key from env, with concurrent-safe caching.
673670
674-
On first call (per ``opts``): reads ``opts.env_jwk_var``, parses it as a JWK,
671+
On first call (per kwargs): reads ``env_jwk_var``, parses it as a JWK,
675672
validates kty/crv (OKP+Ed25519 or EC+P-256), and projects to a canonical
676673
public JWK. Falls back to an ephemeral keypair when the env var is missing
677674
or whitespace-only (dev-friendly; logs a loud warning).
678675
679-
Subsequent calls with the same ``opts`` return the cached key without
676+
Subsequent calls with the same kwargs return the cached key without
680677
re-reading env. Concurrent first-callers serialize on a lock so only one
681678
key generation runs; the rest receive the cached result.
682679
683-
Different ``opts`` values get separate cache entries: a merchant running
684-
one signing key per env namespace (e.g. prod vs staging) does not collide.
680+
Different kwargs get separate cache entries: a merchant running one signing
681+
key per env namespace (e.g. prod vs staging) does not collide. Env-var
682+
names are overridable so a merchant can run multiple distinct signing keys
683+
from different env namespaces (e.g. ``PROD_UCP_JWK`` vs ``STAGING_UCP_JWK``).
684+
``default_kid`` and ``default_alg`` are used when the env JWK is absent or
685+
doesn't carry its own ``kid`` / can't dictate alg via kty+crv.
685686
686687
Env-driven precedence:
687688
688-
* Embedded ``kid`` in the JWK wins over ``opts.env_kid_var`` env value;
689-
empty-string ``kid`` in the env JWK falls through to ``opts.default_kid``.
690-
* Structural ``kty``+``crv`` in the JWK wins over ``opts.env_alg_var`` env
689+
* Embedded ``kid`` in the JWK wins over ``env_kid_var`` env value;
690+
empty-string ``kid`` in the env JWK falls through to ``default_kid``.
691+
* Structural ``kty``+``crv`` in the JWK wins over ``env_alg_var`` env
691692
value (which is only consulted in the ephemeral fallback path).
692693
693694
Raises ``ValueError`` with a sanitized message for malformed env JWKs;
694695
raw exception detail is intentionally suppressed so key bytes can never
695696
reach logs.
696697
"""
697-
resolved = opts if opts is not None else LoadUCPSigningKeyOptions()
698-
cache_key = (
699-
resolved.env_jwk_var,
700-
resolved.env_kid_var,
701-
resolved.env_alg_var,
702-
resolved.default_kid,
703-
resolved.default_alg,
704-
)
698+
cache_key = (env_jwk_var, env_kid_var, env_alg_var, default_kid, default_alg)
705699
cached = _env_loader_cache.get(cache_key)
706700
if cached is not None:
707701
return cached
708702
with _env_loader_lock:
709703
cached = _env_loader_cache.get(cache_key)
710704
if cached is not None:
711705
return cached
712-
result = _build_env_signing_key(resolved)
706+
result = _build_env_signing_key(
707+
env_jwk_var=env_jwk_var,
708+
env_kid_var=env_kid_var,
709+
env_alg_var=env_alg_var,
710+
default_kid=default_kid,
711+
default_alg=default_alg,
712+
)
713713
_env_loader_cache[cache_key] = result
714714
return result
715715

@@ -726,7 +726,6 @@ def _reset_ucp_signing_key_cache() -> None:
726726

727727
__all__ = [
728728
"GeneratedUCPKey",
729-
"LoadUCPSigningKeyOptions",
730729
"UCPVerificationError",
731730
"build_jwks_response",
732731
"generate_ucp_signing_key",

agentscore_commerce/payment/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@
4242
)
4343
from agentscore_commerce.payment.x402 import register_x402_schemes_v1_v2
4444
from agentscore_commerce.payment.x402_server import (
45-
CreateX402ServerOptions,
4645
CustomScheme,
4746
X402FacilitatorChoice,
4847
X402SymbolicRail,
@@ -80,7 +79,6 @@
8079
"USDC",
8180
"X402_SUPPORTED_BASE_NETWORKS",
8281
"ClassifiedX402Error",
83-
"CreateX402ServerOptions",
8482
"CustomScheme",
8583
"MppxRails",
8684
"NetworkFamily",

agentscore_commerce/payment/x402_server.py

Lines changed: 1 addition & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
import importlib
2525
import os
26-
from dataclasses import dataclass, field
26+
from dataclasses import dataclass
2727
from typing import TYPE_CHECKING, Any, Literal
2828

2929
from agentscore_commerce.payment.networks import networks
@@ -51,38 +51,6 @@ class CustomScheme:
5151
scheme: Any
5252

5353

54-
@dataclass
55-
class CreateX402ServerOptions:
56-
"""Configuration for :func:`create_x402_server`."""
57-
58-
facilitator: X402FacilitatorChoice | Any = "http"
59-
"""Facilitator selection — ``"coinbase"`` (requires ``cdp-sdk`` peer dep + the
60-
``CDP_API_KEY_ID`` / ``CDP_API_KEY_SECRET`` env vars or explicit ``cdp_api_key_id``
61-
/ ``cdp_api_key_secret`` args), ``"http"`` (public testnet facilitator at
62-
``x402.org``), or any pre-built facilitator instance."""
63-
64-
rails: list[X402SymbolicRail] = field(default_factory=list)
65-
"""Symbolic rail names to register schemes for. Each gets v1+v2 dual-register
66-
applied. Requires ``x402[evm]`` peer dep installed."""
67-
68-
schemes: list[CustomScheme] = field(default_factory=list)
69-
"""Advanced: register custom (network, scheme) pairs in addition to ``rails``."""
70-
71-
bazaar: bool = False
72-
"""Register the Bazaar discovery extension. Requires the extension peer dep installed."""
73-
74-
initialize: bool = True
75-
"""Initialize the server immediately (calls facilitator). Default ``True``."""
76-
77-
cdp_api_key_id: str | None = None
78-
"""CDP API key id for the Coinbase facilitator. Falls back to
79-
``CDP_API_KEY_ID`` env var. Only consulted when ``facilitator="coinbase"``."""
80-
81-
cdp_api_key_secret: str | None = None
82-
"""CDP API key secret for the Coinbase facilitator. Falls back to
83-
``CDP_API_KEY_SECRET`` env var. Only consulted when ``facilitator="coinbase"``."""
84-
85-
8654
def _import_optional(module_name: str) -> Any | None:
8755
"""Try to import a module; return ``None`` if not installed."""
8856
try:
@@ -331,7 +299,6 @@ def build_x402_accepts_for_402(
331299

332300

333301
__all__ = [
334-
"CreateX402ServerOptions",
335302
"CustomScheme",
336303
"X402FacilitatorChoice",
337304
"X402SymbolicRail",

examples/signed_ucp_merchant.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@
3636

3737
from agentscore_commerce.identity import (
3838
AgentScoreGatePolicy,
39-
LoadUCPSigningKeyOptions,
4039
UCPServiceBinding,
4140
UCPSigningKey,
4241
UCPVerificationError,
@@ -50,20 +49,20 @@
5049

5150
logger = logging.getLogger("signed_ucp_merchant")
5251

53-
# Env-loader options pin the production kid + alg defaults for this example.
52+
# Env-loader kwargs pin the production kid + alg defaults for this example.
5453
# ``UCP_SIGNING_KEY_JWK_PRIVATE`` (env) wins when set; ``UCP_SIGNING_KEY_KID``
5554
# and ``UCP_SIGNING_KEY_ALG`` override these defaults at runtime. The helper
5655
# caches the loaded key across requests and serializes concurrent first-callers
5756
# so two threads can never publish a JWKS that disagrees with the just-signed JWS.
58-
_SIGNING_KEY_OPTS = LoadUCPSigningKeyOptions(default_kid="merchant-2026-05")
57+
_SIGNING_KEY_OPTS = {"default_kid": "merchant-2026-05"}
5958

6059

6160
app = FastAPI()
6261

6362

6463
@app.get("/.well-known/ucp")
6564
async def well_known_ucp() -> JSONResponse:
66-
key = load_ucp_signing_key_from_env(_SIGNING_KEY_OPTS)
65+
key = load_ucp_signing_key_from_env(**_SIGNING_KEY_OPTS)
6766
profile = build_ucp_profile(
6867
name="My Agent Service",
6968
services={
@@ -98,14 +97,14 @@ async def well_known_ucp() -> JSONResponse:
9897
profile.to_dict(),
9998
signing_key=key.private_key,
10099
kid=key.public_jwk["kid"],
101-
alg=key.public_jwk.get("alg", _SIGNING_KEY_OPTS.default_alg),
100+
alg=key.public_jwk.get("alg", "EdDSA"),
102101
)
103102
return JSONResponse(signed, headers={"Cache-Control": "public, max-age=60"})
104103

105104

106105
@app.get("/.well-known/jwks.json")
107106
async def well_known_jwks() -> JSONResponse:
108-
key = load_ucp_signing_key_from_env(_SIGNING_KEY_OPTS)
107+
key = load_ucp_signing_key_from_env(**_SIGNING_KEY_OPTS)
109108
return JSONResponse(
110109
build_jwks_response([key.public_jwk]),
111110
headers={

0 commit comments

Comments
 (0)