Skip to content

Commit 4431843

Browse files
vvillait88claude
andcommitted
fix(commerce): denial body emits {error: {code, message}} (parity with node)
Mirror of node-commerce a5ad32c. denial_reason_to_body now emits {"error": {"code": ..., "message": ...}} matching the core API canonical shape, replacing the previous {"error": "<code>"} string form. Adds DenialReason.message override; per-code default messages in _response.py (_DEFAULT_MESSAGES). Bulk-updates 7 test files (FastAPI, Flask, Django, AIOHTTP, Sanic, middleware, signer_match) to read body["error"]["code"]. 483 → 485 tests pass; coverage 95.86%; ruff + ty clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2307d1b commit 4431843

9 files changed

Lines changed: 98 additions & 62 deletions

File tree

agentscore_commerce/identity/_response.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
Every adapter (ASGI, FastAPI, Flask, Django, AIOHTTP, Sanic) renders the same
44
body shape for a denial — this helper keeps them in sync and in one place.
55
Includes the wallet-signer-match fields and the agent_memory payload.
6+
7+
Body shape: ``{"error": {"code": ..., "message": ...}, ...}`` — matches the
8+
canonical AgentScore core API response shape so downstream agents see one
9+
consistent ``error.code`` + ``error.message`` pair regardless of which layer
10+
produced the denial.
611
"""
712

813
from __future__ import annotations
@@ -136,13 +141,42 @@ def build_missing_identity_reason() -> DenialReason:
136141
)
137142

138143

144+
_DEFAULT_MESSAGES: dict[str, str] = {
145+
"missing_identity": "No identity provided. Send X-Wallet-Address (wallet) or X-Operator-Token (credential).",
146+
"identity_verification_required": (
147+
"Identity verification is required to access this resource. Visit verify_url to complete KYC."
148+
),
149+
"wallet_not_trusted": "The wallet does not meet the merchant compliance policy.",
150+
"api_error": "AgentScore is unreachable. This is transient — retry in a few seconds.",
151+
"payment_required": "AgentScore tier does not support assess. Contact support.",
152+
"wallet_signer_mismatch": (
153+
"Payment signer does not match the wallet claimed via X-Wallet-Address. The signer and the "
154+
"claimed wallet must both resolve to the same AgentScore operator."
155+
),
156+
"wallet_auth_requires_wallet_signing": (
157+
"X-Wallet-Address was sent with a rail that has no wallet signature (Stripe SPT / card). "
158+
"Switch to X-Operator-Token, or use a wallet-signing rail (Tempo MPP, x402)."
159+
),
160+
"token_expired": (
161+
"The operator token is expired or revoked. A fresh verification session has been minted — "
162+
"visit verify_url to mint a new token."
163+
),
164+
"invalid_credential": (
165+
"The operator token is not recognized. Switch to a different stored token, or drop the "
166+
"header to bootstrap a fresh session."
167+
),
168+
}
169+
170+
139171
def denial_reason_to_body(reason: DenialReason) -> dict[str, Any]:
140172
"""Marshal a DenialReason dataclass into a flat dict suitable for the 403 JSON body.
141173
142174
Shared across all adapters. Omits falsy optional fields so the body stays compact.
143-
Always includes ``error`` set from ``reason.code``.
175+
Emits ``error: {code, message}`` matching the core API canonical shape; ``message``
176+
falls back to a per-code default when ``reason.message`` is None.
144177
"""
145-
body: dict[str, Any] = {"error": reason.code}
178+
message = reason.message or _DEFAULT_MESSAGES.get(reason.code, "")
179+
body: dict[str, Any] = {"error": {"code": reason.code, "message": message}}
146180
if reason.decision is not None:
147181
body["decision"] = reason.decision
148182
if reason.reasons:

agentscore_commerce/identity/types.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ class DenialReason:
6565
"""Reason a request was denied by the gate middleware."""
6666

6767
code: DenialCode
68+
# Human-readable explanation. When None, denial_reason_to_body substitutes a per-code default.
69+
message: str | None = None
6870
decision: str | None = None
6971
reasons: list[str] = field(default_factory=list)
7072
verify_url: str | None = None

tests/test_aiohttp.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ async def test_denies_untrusted_wallet(self):
8484
resp = await client.get("/", headers={"X-Wallet-Address": "0xabc"})
8585
assert resp.status == 403
8686
data = await resp.json()
87-
assert data["error"] == "wallet_not_trusted"
87+
assert data["error"]["code"] == "wallet_not_trusted"
8888
assert data["reasons"] == ["not_kyc"]
8989

9090
@pytest.mark.asyncio
@@ -94,7 +94,7 @@ async def test_missing_identity_returns_403(self):
9494
resp = await client.get("/")
9595
assert resp.status == 403
9696
data = await resp.json()
97-
assert data["error"] == "missing_identity"
97+
assert data["error"]["code"] == "missing_identity"
9898

9999
@pytest.mark.asyncio
100100
async def test_fail_open_allows_through_when_identity_missing(self):
@@ -124,7 +124,7 @@ async def test_returns_403_payment_required_on_402(self):
124124
resp = await client.get("/", headers={"X-Wallet-Address": "0xabc"})
125125
assert resp.status == 403
126126
data = await resp.json()
127-
assert data["error"] == "payment_required"
127+
assert data["error"]["code"] == "payment_required"
128128

129129
@pytest.mark.asyncio
130130
@respx.mock
@@ -135,7 +135,7 @@ async def test_returns_503_api_error_on_500(self):
135135
resp = await client.get("/", headers={"X-Wallet-Address": "0xabc"})
136136
assert resp.status == 503
137137
data = await resp.json()
138-
assert data["error"] == "api_error"
138+
assert data["error"]["code"] == "api_error"
139139

140140
@pytest.mark.asyncio
141141
@respx.mock
@@ -217,7 +217,7 @@ async def test_creates_session_and_returns_403_with_session_data(self):
217217
resp = await client.get("/")
218218
assert resp.status == 403
219219
data = await resp.json()
220-
assert data["error"] == "identity_verification_required"
220+
assert data["error"]["code"] == "identity_verification_required"
221221
assert data["session_id"] == "sess_abc123"
222222
assert data["verify_url"] == "https://agentscore.sh/verify/sess_abc123"
223223
assert data["poll_secret"] == "ps_secret"
@@ -237,7 +237,7 @@ async def test_falls_back_to_missing_identity_on_session_api_failure(self):
237237
resp = await client.get("/")
238238
assert resp.status == 403
239239
data = await resp.json()
240-
assert data["error"] == "missing_identity"
240+
assert data["error"]["code"] == "missing_identity"
241241

242242

243243
class TestCaptureWallet:
@@ -338,7 +338,7 @@ async def handler(_req):
338338
resp = await client.get("/", headers={"x-operator-token": "opc_exp"})
339339
assert resp.status == 401
340340
body = await resp.json()
341-
assert body["error"] == "token_expired"
341+
assert body["error"]["code"] == "token_expired"
342342
assert json.loads(body["agent_instructions"]) == {"action": "deliver_verify_url_and_poll"}
343343

344344

@@ -361,4 +361,4 @@ async def handler(_req):
361361
resp = await client.get("/", headers={"x-wallet-address": "0xabc"})
362362
assert resp.status == 503
363363
body = await resp.json()
364-
assert body["error"] == "api_error"
364+
assert body["error"]["code"] == "api_error"

tests/test_django.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,15 @@ def test_blocks_untrusted_wallet(self) -> None:
6767
resp = mw(request)
6868
assert resp.status_code == 403
6969
data = json.loads(resp.content)
70-
assert data["error"] == "wallet_not_trusted"
70+
assert data["error"]["code"] == "wallet_not_trusted"
7171

7272
def test_missing_wallet_returns_403(self) -> None:
7373
mw = self._make_middleware()
7474
request = self.factory.get("/")
7575
resp = mw(request)
7676
assert resp.status_code == 403
7777
data = json.loads(resp.content)
78-
assert data["error"] == "missing_identity"
78+
assert data["error"]["code"] == "missing_identity"
7979

8080
def test_missing_wallet_fail_open(self) -> None:
8181
mw = self._make_middleware(fail_open=True)
@@ -97,7 +97,7 @@ def test_api_error_fail_closed(self) -> None:
9797
resp = mw(request)
9898
assert resp.status_code == 503
9999
data = json.loads(resp.content)
100-
assert data["error"] == "api_error"
100+
assert data["error"]["code"] == "api_error"
101101

102102
def test_payment_required_fail_open(self) -> None:
103103
mw = self._make_middleware(fail_open=True)
@@ -113,7 +113,7 @@ def test_payment_required_fail_closed(self) -> None:
113113
resp = mw(request)
114114
assert resp.status_code == 403
115115
data = json.loads(resp.content)
116-
assert data["error"] == "payment_required"
116+
assert data["error"]["code"] == "payment_required"
117117

118118
def test_extract_chain_passed_to_api(self) -> None:
119119
def custom_extract_chain(_request):
@@ -189,7 +189,7 @@ def test_deny_includes_reasons_from_compliance(self) -> None:
189189
resp = mw(request)
190190
assert resp.status_code == 403
191191
data = json.loads(resp.content)
192-
assert data["error"] == "wallet_not_trusted"
192+
assert data["error"]["code"] == "wallet_not_trusted"
193193
assert "kyc_required" in data["reasons"]
194194
assert "sanctions_check_pending" in data["reasons"]
195195

@@ -223,7 +223,7 @@ def test_verify_url_available_in_raw_on_deny(self) -> None:
223223
resp = mw(request)
224224
assert resp.status_code == 403
225225
data = json.loads(resp.content)
226-
assert data["error"] == "wallet_not_trusted"
226+
assert data["error"]["code"] == "wallet_not_trusted"
227227

228228

229229
class TestDjangoCreateSessionOnMissing:
@@ -261,7 +261,7 @@ def test_creates_session_and_returns_403_with_session_data(self) -> None:
261261
resp = mw(request)
262262
assert resp.status_code == 403
263263
data = json.loads(resp.content)
264-
assert data["error"] == "identity_verification_required"
264+
assert data["error"]["code"] == "identity_verification_required"
265265
assert data["session_id"] == "sess_abc"
266266
assert data["verify_url"] == "https://agentscore.sh/verify/sess_abc"
267267
assert data["poll_secret"] == "ps_secret"
@@ -281,7 +281,7 @@ def test_falls_back_to_missing_identity_on_session_helper_failure(self) -> None:
281281
resp = mw(request)
282282
assert resp.status_code == 403
283283
data = json.loads(resp.content)
284-
assert data["error"] == "missing_identity"
284+
assert data["error"]["code"] == "missing_identity"
285285

286286

287287
class TestDjangoIdentityModel:
@@ -321,7 +321,7 @@ def test_missing_identity_returns_403(self) -> None:
321321
resp = mw(request)
322322
assert resp.status_code == 403
323323
data = json.loads(resp.content)
324-
assert data["error"] == "missing_identity"
324+
assert data["error"]["code"] == "missing_identity"
325325

326326
def test_missing_identity_fail_open(self) -> None:
327327
mw = self._make_middleware(fail_open=True)

tests/test_fastapi.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,15 +64,15 @@ def test_denies_untrusted_wallet(self):
6464
assert resp.status_code == 403
6565
body = resp.json()
6666
# FastAPI wraps HTTPException detail in {"detail": {...}}.
67-
assert body["detail"]["error"] == "wallet_not_trusted"
67+
assert body["detail"]["error"]["code"] == "wallet_not_trusted"
6868
assert body["detail"]["reasons"] == ["not_kyc"]
6969

7070
def test_missing_identity_returns_403(self):
7171
gate = AgentScoreGate(api_key="ask_test")
7272
client = TestClient(_make_app(gate))
7373
resp = client.get("/")
7474
assert resp.status_code == 403
75-
assert resp.json()["detail"]["error"] == "missing_identity"
75+
assert resp.json()["detail"]["error"]["code"] == "missing_identity"
7676

7777
def test_fail_open_allows_through_when_identity_missing(self):
7878
gate = AgentScoreGate(api_key="ask_test", fail_open=True)
@@ -96,7 +96,7 @@ def test_raises_on_402_payment_required(self):
9696
client = TestClient(_make_app(gate))
9797
resp = client.get("/", headers={"X-Wallet-Address": "0xabc"})
9898
assert resp.status_code == 403
99-
assert resp.json()["detail"]["error"] == "payment_required"
99+
assert resp.json()["detail"]["error"]["code"] == "payment_required"
100100

101101
@respx.mock
102102
def test_api_error_returns_403_api_error(self):
@@ -105,7 +105,7 @@ def test_api_error_returns_403_api_error(self):
105105
client = TestClient(_make_app(gate))
106106
resp = client.get("/", headers={"X-Wallet-Address": "0xabc"})
107107
assert resp.status_code == 503
108-
assert resp.json()["detail"]["error"] == "api_error"
108+
assert resp.json()["detail"]["error"]["code"] == "api_error"
109109

110110

111111
class TestOnDenied:
@@ -181,7 +181,7 @@ def test_creates_session_and_returns_403_with_session_data(self):
181181
resp = client.get("/")
182182
assert resp.status_code == 403
183183
detail = resp.json()["detail"]
184-
assert detail["error"] == "identity_verification_required"
184+
assert detail["error"]["code"] == "identity_verification_required"
185185
assert detail["session_id"] == "sess_abc123"
186186
assert detail["verify_url"] == "https://agentscore.sh/verify/sess_abc123"
187187
assert detail["poll_secret"] == "ps_secret"
@@ -202,7 +202,7 @@ def test_falls_back_to_missing_identity_on_session_api_failure(self):
202202
client = TestClient(_make_app(gate))
203203
resp = client.get("/")
204204
assert resp.status_code == 403
205-
assert resp.json()["detail"]["error"] == "missing_identity"
205+
assert resp.json()["detail"]["error"]["code"] == "missing_identity"
206206

207207

208208
class TestCaptureWallet:
@@ -318,7 +318,7 @@ def index():
318318
assert resp.status_code == 401
319319
# FastAPI wraps the denial body under HTTPException.detail.
320320
detail = resp.json()["detail"]
321-
assert detail["error"] == "token_expired"
321+
assert detail["error"]["code"] == "token_expired"
322322
assert json.loads(detail["agent_instructions"]) == {"action": "deliver_verify_url_and_poll"}
323323

324324

@@ -337,4 +337,4 @@ def index():
337337
client = TestClient(app, raise_server_exceptions=False)
338338
resp = client.get("/", headers={"x-wallet-address": "0xabc"})
339339
assert resp.status_code == 503
340-
assert resp.json()["detail"]["error"] == "api_error"
340+
assert resp.json()["detail"]["error"]["code"] == "api_error"

0 commit comments

Comments
 (0)