diff --git a/agentscore_commerce/identity/sessions.py b/agentscore_commerce/identity/sessions.py index f63b5f5..28d5a63 100644 --- a/agentscore_commerce/identity/sessions.py +++ b/agentscore_commerce/identity/sessions.py @@ -7,7 +7,7 @@ import logging from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Any, cast +from typing import Any, Literal, cast from agentscore import AgentScore, AgentScoreError @@ -46,6 +46,13 @@ class CreateSessionOnMissing: base_url: str = "https://api.agentscore.com" context: str | None = None product_name: str | None = None + # Session kind sent to POST /v1/sessions. "kyc" (the API default) runs identity + # verification; "sign_in" is registration-only (the buyer signs in with an AgentScore + # account, no identity documents) and mints a sign_in-scoped credential. Use it when the + # gate runs with an EMPTY compliance policy and only needs an account to key state on + # (a prepaid balance, say): a KYC session there asks for documents nothing will check. + # The denial's default error.message follows the kind. + kind: Literal["kyc", "sign_in"] | None = None # Per-request override of context / product_name. Receives the framework request # object; returns a dict with optional "context" and/or "product_name" keys. get_session_options: Callable[[Any], _Hookable] | None = None @@ -91,12 +98,22 @@ def _resolved_session_options(cfg: CreateSessionOnMissing, dynamic: Any) -> dict options["context"] = cfg.context if cfg.product_name is not None: options["product_name"] = cfg.product_name + if cfg.kind is not None: + options["kind"] = cfg.kind return _apply_dynamic_options(options, dynamic) +SIGN_IN_REQUIRED_MESSAGE = ( + "Sign-in is required to access this resource. Visit verify_url to sign in with an " + "AgentScore account (no identity documents), then poll poll_url for the operator token " + "and retry." +) + + def _session_denial_reason( data: dict[str, Any], extra: dict[str, Any] | None = None, + kind: Literal["kyc", "sign_in"] | None = None, ) -> DenialReason | None: # Validate required fields before trusting the response. A misbehaving (or # mocked-wrong) API could 200 without session_id/poll_secret/verify_url, which @@ -116,6 +133,9 @@ def _session_denial_reason( agent_instructions = json.dumps(next_steps) if next_steps else None return DenialReason( code="identity_verification_required", + # The per-code default message talks about KYC, which a sign_in session never runs; + # say what this session actually asks for so a merchant's default 403 is not a lie. + message=SIGN_IN_REQUIRED_MESSAGE if kind == "sign_in" else None, verify_url=data["verify_url"], session_id=data["session_id"], poll_secret=data["poll_secret"], @@ -171,7 +191,7 @@ async def try_create_session_denial_reason( except Exception as err: logger.warning("on_before_session hook failed: %s", err) - return _session_denial_reason(data, extra) + return _session_denial_reason(data, extra, cfg.kind) except Exception: return None @@ -219,6 +239,6 @@ def try_create_session_denial_reason_sync( except Exception as err: logger.warning("on_before_session hook failed: %s", err) - return _session_denial_reason(data, extra) + return _session_denial_reason(data, extra, cfg.kind) except Exception: return None diff --git a/pyproject.toml b/pyproject.toml index b9ac8fe..1f14c40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "agentscore-commerce" -version = "2.8.3" +version = "2.9.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" @@ -12,7 +12,7 @@ requires-python = ">=3.11" keywords = ["agentscore", "agent-commerce", "agentic-payments", "402", "x402", "mpp", "machine-payments-protocol", "fastapi", "starlette", "flask", "django", "aiohttp", "sanic", "middleware", "trust", "reputation", "kyc", "identity", "stripe", "tempo", "solana", "base", "ai-agent"] dependencies = [ "httpx>=0.25.0,<1.0.0", - "agentscore-py>=2.6.8", + "agentscore-py>=2.6.9", ] classifiers = [ "Development Status :: 5 - Production/Stable", diff --git a/tests/test_sessions.py b/tests/test_sessions.py index f1b8e44..6d200ad 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -81,6 +81,24 @@ def test_forwards_context_and_product_name(self): body = json.loads(route.calls[0].request.content) assert body["context"] == "purchase_flow" assert body["product_name"] == "Example Merchant" + assert "kind" not in body + + @respx.mock + def test_forwards_kind_and_swaps_the_kyc_message_for_sign_in(self): + route = respx.post(SESSIONS_URL).mock(return_value=httpx.Response(200, json=SESSION_RESPONSE)) + reason = try_create_session_denial_reason_sync( + CreateSessionOnMissing(api_key="ask_test", kind="sign_in"), + user_agent="agentscore-commerce/1.0", + ) + import json + + body = json.loads(route.calls[0].request.content) + assert body["kind"] == "sign_in" + assert reason is not None + assert reason.code == "identity_verification_required" + assert reason.message is not None + assert "sign in with an AgentScore account" in reason.message + assert "KYC" not in reason.message @respx.mock def test_omits_context_and_product_name_when_not_provided(self): diff --git a/uv.lock b/uv.lock index 130c97d..d1e1561 100644 --- a/uv.lock +++ b/uv.lock @@ -22,7 +22,7 @@ wheels = [ [[package]] name = "agentscore-commerce" -version = "2.8.3" +version = "2.9.0" source = { editable = "." } dependencies = [ { name = "agentscore-py" }, @@ -96,7 +96,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "agentscore-py", specifier = ">=2.6.8" }, + { name = "agentscore-py", specifier = ">=2.6.9" }, { name = "aiohttp", marker = "extra == 'aiohttp'", specifier = ">=3.8.0" }, { name = "cdp-sdk", marker = "extra == 'coinbase'", specifier = ">=1.0,<2" }, { name = "django", marker = "extra == 'django'", specifier = ">=4.0" }, @@ -140,14 +140,14 @@ dev = [ [[package]] name = "agentscore-py" -version = "2.6.8" +version = "2.6.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/3f/10fcd23b00dfc4ed4a701dca48f9b64d5d2c895734c42244dbdede84cb07/agentscore_py-2.6.8.tar.gz", hash = "sha256:e650bc8107c81e02f5ea056f829f4dcc6b4c0feffccd5bcff61448e77b801124", size = 67559, upload-time = "2026-09-05T00:34:10.821Z" } +sdist = { url = "https://files.pythonhosted.org/packages/79/4a/5963efd290f3f8cd2dcd5c06ffa955fccdfa955bf774540e5c640bc68ac3/agentscore_py-2.6.9.tar.gz", hash = "sha256:8606753735560062722a6910191ddc9dfd5673f2e64c0556a2be51513170e3ff", size = 67815, upload-time = "2026-09-09T00:07:49.229Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/83/05d9d74b926ea5ee6228e7b08c07e5f2b9c2690f8402c2b1468ea15adb2e/agentscore_py-2.6.8-py3-none-any.whl", hash = "sha256:0f217280fd85d8bc95847d5918e41565596af71e8c0794a1437946d8bed4ab67", size = 22379, upload-time = "2026-09-05T00:34:09.444Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1f/0ee13b46eecb4e27a6c8b0b7c91dd63df7ae31742f8e321228ee8c2eadc8/agentscore_py-2.6.9-py3-none-any.whl", hash = "sha256:9f584515742288f7dec4e0f19b869ae5b89b2e77fba97a97154c83a6d2ca140d", size = 22650, upload-time = "2026-09-09T00:07:47.931Z" }, ] [[package]]