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
26 changes: 23 additions & 3 deletions agentscore_commerce/identity/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"],
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ 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"
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",
Expand Down
18 changes: 18 additions & 0 deletions tests/test_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
10 changes: 5 additions & 5 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.