Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ Two identity types: wallet (`X-Wallet-Address`) and operator-token (`X-Operator-

`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"`).

### Operator handle: what durable merchant state keys on

`get_operator_handle(request)` (per-adapter; Flask takes no argument and reads `g`; `ctx.operator_handle` inside `Checkout` hooks) returns the stable pairwise `oph_...` handle for the ACCOUNT behind the request's operator token.

**Key state on this, never on the token.** An `opc_` lives 24h and rotates silently off a 90-day refresh, so anything keyed on the token instance is stranded daily, and revoking a leaked token would forfeit a prepaid balance. The handle derives from the account, so rotation, expiry and revocation are all free. It is pairwise per consuming merchant, so the same buyer presents an unrelated handle at every store and handles never correlate across them.

It rides the gate's existing `/v1/assess` response, so reading it costs **no extra round trip and nothing extra against the merchant's quota**. That is why the accessor is synchronous like `get_signer_verdict` rather than doing a lookup of its own.

Returns `None` when the gate did not run, on wallet-authenticated paths (there is no operator token to resolve), or when the API has no handle salt configured. Available on **denied** requests too, so a merchant recording a denial against a buyer can still key it. It carries no compliance meaning: a registration-only (`sign_in`) credential resolves exactly like a KYC-backed one, so read the decision fields for policy.

Anything that is not a well-formed `oph_` string reads as absent rather than being passed through, so a half-configured API can never hand a merchant a value it would write balance rows against. `project_operator_handle(raw)` in `identity/core.py` is the single derivation both the adapters and `Checkout` call.

Captured wallets: `capture_wallet(...)` is fire-and-forget. Reads `operator_token` stashed during gating and POSTs to `/v1/credentials/wallets`. No-ops for wallet-authenticated requests.

Wallet-signer-match + signer-sanctions: the gate adapter calls `extract_payment_signer(x402_header)` pre-evaluate and passes `signer={address, network}` to the SDK's `assess`. The API returns both `signer_match` (wallet-binding) and `signer_sanctions` (OFAC SDN wallet-address) on the same response; commerce caches the raw body alongside the projected verdicts so `get_signer_verdict` is a pure cache read. **Wallet-OFAC SDN enforcement on the `signer` block is unconditional** whenever a signer is present — no `policy.require_sanctions_clear` opt-in required. An SDN hit (or `sanctions_check_unavailable`) flips `decision -> deny` and the gate returns 403 before the handler runs.
Expand Down
16 changes: 16 additions & 0 deletions agentscore_commerce/checkout.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,17 @@ class CheckoutContext:
"""Merchant-supplied per-request state, populated by :attr:`Checkout.pre_validate`.
Other hooks read from here (e.g. ``ctx.state["product"]`` after pre_validate
resolved it). Stays empty when no pre_validate is configured."""
operator_handle: str | None = None
"""Stable pairwise handle for the ACCOUNT behind this request's operator token.

Set by Checkout's internal gate from the same ``/v1/assess`` response it already
fetched, so it costs no extra round trip and nothing extra against quota.

This is what durable merchant state should key on, prepaid balances above all: it
survives the token rotating, expiring or being revoked, whereas state keyed on the token
instance is stranded every time one rotates. ``None`` when no gate is configured, on
wallet or AIT paths, on anonymous discovery legs, or when the API has no handle salt.
"""
capture_wallet: Callable[..., Any] | None = None
"""Capture the signer wallet under the operator credential the gate resolved
for this request. Set by Checkout's internal gate after a successful allow when
Expand Down Expand Up @@ -2188,6 +2199,11 @@ async def _run_gate(self, ctx: CheckoutContext) -> CheckoutResult | None:
if signer_denial is not None:
return signer_denial

# The pairwise account handle rides the same assess response the gate just used.
from agentscore_commerce.identity.core import project_operator_handle

ctx.operator_handle = project_operator_handle(ctx.request.assess)

# Stash ctx.capture_wallet so on_settled can bind the signer wallet to
# the operator credential without needing a framework-specific context.
# No-op when the request was wallet-authenticated (no operator_token).
Expand Down
27 changes: 27 additions & 0 deletions agentscore_commerce/identity/aiohttp.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,13 @@ async def _agentscore_middleware(
return await handler(request)
return _deny_response(request, DenialReason(code="api_error"))

# The pairwise account handle rides this same assess response. Stash it BEFORE the
# allow/deny branch: it is identity rather than a verdict, so a merchant recording a
# denial against the buyer needs it on the path where its handler never runs.
_handle_state = request.get(GATE_STATE_KEY)
if isinstance(_handle_state, dict):
_handle_state["operator_handle"] = client.project_operator_handle(result.raw)

if result.allow:
request["agentscore"] = result.raw
state = request.get(GATE_STATE_KEY)
Expand Down Expand Up @@ -462,3 +469,23 @@ def conditional_aip_gate_middleware(**kwargs: Any) -> Any:

kwargs["condition"] = has_agent_identity_header
return aip_gate_middleware(**kwargs)


def get_operator_handle(request: web.Request) -> str | None:
"""Read the stable pairwise operator handle for the account behind this request's token.

This is what durable merchant state (prepaid balances first) should key on: it survives
the token rotating, expiring or being revoked, whereas anything keyed on the token
instance is stranded every time one rotates.

Synchronous and free. The handle rides the gate's existing ``/v1/assess`` call, so
reading it costs no extra round trip and nothing extra against the merchant's quota.

Returns ``None`` when the gate did not run, when no operator token was presented (wallet
or AIT paths), or when the API has no handle salt configured. Available on denied
requests too, so a merchant recording a denial against a buyer can still key it.
"""
state = request.get(GATE_STATE_KEY)
if not isinstance(state, dict):
return None
return state.get("operator_handle")
38 changes: 38 additions & 0 deletions agentscore_commerce/identity/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,26 @@
DEFAULT_CACHE_SECONDS = 300


def project_operator_handle(raw: dict[str, Any] | None) -> str | None:
"""Project the stable pairwise operator handle from a raw ``/v1/assess`` response.

Pure, and the single derivation both the gate adapters and ``Checkout`` call: written
twice it would drift, and the failure is silent, since one spelling's handle simply
misses the other's rows and a buyer quietly grows a second balance.

Narrowed rather than cast. The field is absent whenever the request carried no operator
token, and absent is also what the API emits when its handle salt is unconfigured, so
anything that is not a usable ``oph_`` string must read as "no handle" instead of
becoming a state key.
"""
if not isinstance(raw, dict):
return None
value = raw.get("operator_handle")
if isinstance(value, str) and value.startswith("oph_"):
return value
return None


class AgentScoreCore:
"""Shared client for calling the AgentScore assess API.

Expand Down Expand Up @@ -525,6 +545,24 @@ def _stash_signer_raw(
):
self._last_signer_raw[normalize_address(address)] = raw

def project_operator_handle(self, raw: dict[str, Any] | None) -> str | None:
"""Project the stable pairwise operator handle from a raw ``/v1/assess`` response.

The handle is the identity durable merchant state (prepaid balances first) should
key on, because it survives the token rotating, expiring or being revoked: an
``opc_`` lives 24h and rotates silently off a 90-day refresh, so anything keyed on
the token instance is stranded daily.

It rides the assess response the gate already fetches, so reading it costs no extra
round trip and nothing extra against the merchant's quota.

Narrowed rather than cast: the field is absent whenever the request carried no
operator token, and absent is also what the API emits when its handle salt is
unconfigured. Anything that is not a usable ``oph_`` string must read as "no handle"
instead of becoming a state key.
"""
return project_operator_handle(raw)

def project_signer_verdict(self, raw: dict[str, Any] | None, claimed_address: str) -> SignerVerdict | None:
"""Project ``signer_match`` + ``signer_sanctions`` from a SPECIFIC raw assess response.

Expand Down
27 changes: 27 additions & 0 deletions agentscore_commerce/identity/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,13 @@ def __call__(self, request: HttpRequest) -> Any:
return self.get_response(request)
return self._on_denied(request, DenialReason(code="api_error"))

# The pairwise account handle rides this same assess response. Stash it BEFORE the
# allow/deny branch: it is identity rather than a verdict, so a merchant recording a
# denial against the buyer needs it on the path where its handler never runs.
_handle_state = getattr(request, "_agentscore_gate", None)
if isinstance(_handle_state, dict):
_handle_state["operator_handle"] = self._client.project_operator_handle(result.raw)

if result.allow:
setattr(request, "agentscore", result.raw) # noqa: B010 — dynamic attribute attach on HttpRequest
state = getattr(request, "_agentscore_gate", None)
Expand Down Expand Up @@ -472,3 +479,23 @@ def __init__(self, get_response: Any) -> None:

super().__init__(get_response)
self._condition = lambda request: has_agent_identity_header_parts(dict(request.headers))


def get_operator_handle(request: HttpRequest) -> str | None:
"""Read the stable pairwise operator handle for the account behind this request's token.

This is what durable merchant state (prepaid balances first) should key on: it survives
the token rotating, expiring or being revoked, whereas anything keyed on the token
instance is stranded every time one rotates.

Synchronous and free. The handle rides the gate's existing ``/v1/assess`` call, so
reading it costs no extra round trip and nothing extra against the merchant's quota.

Returns ``None`` when the gate did not run, when no operator token was presented (wallet
or AIT paths), or when the API has no handle salt configured. Available on denied
requests too, so a merchant recording a denial against a buyer can still key it.
"""
state = getattr(request, "_agentscore_gate", None)
if not isinstance(state, dict):
return None
return state.get("operator_handle")
27 changes: 27 additions & 0 deletions agentscore_commerce/identity/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,13 @@ async def __call__(self, request: Request) -> None:
return
self._deny(request, DenialReason(code="api_error"))

# The pairwise account handle rides this same assess response. Stash it BEFORE the
# allow/deny branch: it is identity rather than a verdict, so a merchant recording a
# denial against the buyer needs it on the path where its handler never runs.
_handle_state = getattr(request.state, GATE_STATE_KEY, None)
if isinstance(_handle_state, dict):
_handle_state["operator_handle"] = self._client.project_operator_handle(result.raw)

if result.allow:
setattr(request.state, ASSESS_STATE_KEY, result.raw)
state = getattr(request.state, GATE_STATE_KEY, None)
Expand Down Expand Up @@ -598,3 +605,23 @@ async def checkout(ait = Depends(get_verified_ait)):
...
"""
return getattr(request.state, AIT_STATE_KEY, None)


def get_operator_handle(request: Request) -> str | None:
"""Read the stable pairwise operator handle for the account behind this request's token.

This is what durable merchant state (prepaid balances first) should key on: it survives
the token rotating, expiring or being revoked, whereas anything keyed on the token
instance is stranded every time one rotates.

Synchronous and free. The handle rides the gate's existing ``/v1/assess`` call, so
reading it costs no extra round trip and nothing extra against the merchant's quota.

Returns ``None`` when the gate did not run, when no operator token was presented (wallet
or AIT paths), or when the API has no handle salt configured. Available on denied
requests too, so a merchant recording a denial against a buyer can still key it.
"""
state = getattr(request.state, GATE_STATE_KEY, None)
if not isinstance(state, dict):
return None
return state.get("operator_handle")
35 changes: 35 additions & 0 deletions agentscore_commerce/identity/flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,14 @@ def _agentscore_check() -> Response | tuple[Response, int] | None:
try:
result = client.check_identity(identity, chain_override, signer=signer_payload)

# The pairwise account handle rides this same assess response. Stash it BEFORE
# the allow/deny branch: it is identity rather than a verdict, so a merchant
# recording a denial against the buyer needs it on the path where its handler
# never runs.
_handle_state = getattr(g, "_agentscore_gate", None)
if isinstance(_handle_state, dict):
_handle_state["operator_handle"] = client.project_operator_handle(result.raw)

if result.allow:
g.agentscore = result.raw
state = getattr(g, "_agentscore_gate", None)
Expand Down Expand Up @@ -527,3 +535,30 @@ def _has_header(request: Request) -> bool:

kwargs["condition"] = _has_header
aip_gate(app, **kwargs)


def get_operator_handle() -> str | None:
"""Read the stable pairwise operator handle for the account behind this request's token.

This is what durable merchant state (prepaid balances first) should key on: it survives
the token rotating, expiring or being revoked, whereas anything keyed on the token
instance is stranded every time one rotates.

Synchronous and free. The handle rides the gate's existing ``/v1/assess`` call, so
reading it costs no extra round trip and nothing extra against the merchant's quota.

Returns ``None`` when the gate did not run, when no operator token was presented (wallet
or AIT paths), or when the API has no handle salt configured. Available on denied
requests too, so a merchant recording a denial against a buyer can still key it.
"""
from flask import g

try:
state = getattr(g, "_agentscore_gate", None)
except RuntimeError:
# No application context (called outside a request). Same posture as the sibling
# accessor: absent state reads as no handle, never as an error.
return None
if not isinstance(state, dict):
return None
return state.get("operator_handle")
27 changes: 27 additions & 0 deletions agentscore_commerce/identity/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,13 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
await response(scope, receive, send)
return

# The pairwise account handle rides this same assess response. Stash it BEFORE the
# allow/deny branch: it is identity rather than a verdict, so a merchant recording a
# denial against the buyer needs it on the path where its handler never runs.
_handle_state = getattr(request.state, GATE_STATE_KEY, None)
if isinstance(_handle_state, dict):
_handle_state["operator_handle"] = self._client.project_operator_handle(result.raw)

if result.allow:
scope["state"] = {**scope.get("state", {}), "agentscore": result.raw}
state = scope["state"].get(GATE_STATE_KEY)
Expand Down Expand Up @@ -513,3 +520,23 @@ class ConditionalAipGate(AipGate):
def __init__(self, app: ASGIApp, **kwargs: Any) -> None:
kwargs["condition"] = has_agent_identity_header
super().__init__(app, **kwargs)


def get_operator_handle(request: Request) -> str | None:
"""Read the stable pairwise operator handle for the account behind this request's token.

This is what durable merchant state (prepaid balances first) should key on: it survives
the token rotating, expiring or being revoked, whereas anything keyed on the token
instance is stranded every time one rotates.

Synchronous and free. The handle rides the gate's existing ``/v1/assess`` call, so
reading it costs no extra round trip and nothing extra against the merchant's quota.

Returns ``None`` when the gate did not run, when no operator token was presented (wallet
or AIT paths), or when the API has no handle salt configured. Available on denied
requests too, so a merchant recording a denial against a buyer can still key it.
"""
state = getattr(request.state, GATE_STATE_KEY, None)
if not isinstance(state, dict):
return None
return state.get("operator_handle")
28 changes: 28 additions & 0 deletions agentscore_commerce/identity/sanic.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,14 @@ async def _agentscore_check(request: Request) -> HTTPResponse | None:
try:
result = await client.acheck_identity(identity, chain_override, signer=signer_payload)

# The pairwise account handle rides this same assess response. Stash it BEFORE
# the allow/deny branch: it is identity rather than a verdict, so a merchant
# recording a denial against the buyer needs it on the path where its handler
# never runs.
_handle_state = getattr(request.ctx, GATE_STATE_ATTR, None)
if isinstance(_handle_state, dict):
_handle_state["operator_handle"] = client.project_operator_handle(result.raw)

if result.allow:
request.ctx.agentscore = result.raw
state = getattr(request.ctx, GATE_STATE_ATTR, None)
Expand Down Expand Up @@ -454,3 +462,23 @@ def conditional_aip_gate(app: Sanic, **kwargs: Any) -> None:

kwargs["condition"] = has_agent_identity_header
aip_gate(app, **kwargs)


def get_operator_handle(request: Request) -> str | None:
"""Read the stable pairwise operator handle for the account behind this request's token.

This is what durable merchant state (prepaid balances first) should key on: it survives
the token rotating, expiring or being revoked, whereas anything keyed on the token
instance is stranded every time one rotates.

Synchronous and free. The handle rides the gate's existing ``/v1/assess`` call, so
reading it costs no extra round trip and nothing extra against the merchant's quota.

Returns ``None`` when the gate did not run, when no operator token was presented (wallet
or AIT paths), or when the API has no handle salt configured. Available on denied
requests too, so a merchant recording a denial against a buyer can still key it.
"""
state = getattr(request.ctx, GATE_STATE_ATTR, None)
if not isinstance(state, dict):
return None
return state.get("operator_handle")
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "agentscore-commerce"
version = "2.5.18"
version = "2.6.0"
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."
readme = "README.md"
license = "MIT"
Expand Down
Loading