chitmark 0.6.2__tar.gz → 0.6.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {chitmark-0.6.2 → chitmark-0.6.4}/PKG-INFO +29 -10
- {chitmark-0.6.2 → chitmark-0.6.4}/README.md +28 -9
- {chitmark-0.6.2 → chitmark-0.6.4}/pyproject.toml +1 -1
- {chitmark-0.6.2 → chitmark-0.6.4}/src/chitmark/__init__.py +4 -1
- {chitmark-0.6.2 → chitmark-0.6.4}/src/chitmark/client.py +81 -17
- {chitmark-0.6.2 → chitmark-0.6.4}/src/chitmark/types.py +91 -10
- {chitmark-0.6.2 → chitmark-0.6.4}/src/chitmark/verify_token.py +45 -16
- {chitmark-0.6.2 → chitmark-0.6.4}/tests/test_client.py +26 -1
- {chitmark-0.6.2 → chitmark-0.6.4}/tests/test_client_extra.py +35 -2
- {chitmark-0.6.2 → chitmark-0.6.4}/tests/test_verify_token.py +48 -12
- {chitmark-0.6.2 → chitmark-0.6.4}/tests/test_verify_token_extra.py +38 -13
- {chitmark-0.6.2 → chitmark-0.6.4}/.gitignore +0 -0
- {chitmark-0.6.2 → chitmark-0.6.4}/LICENSE +0 -0
- {chitmark-0.6.2 → chitmark-0.6.4}/src/chitmark/errors.py +0 -0
- {chitmark-0.6.2 → chitmark-0.6.4}/src/chitmark/pii.py +0 -0
- {chitmark-0.6.2 → chitmark-0.6.4}/src/chitmark/py.typed +0 -0
- {chitmark-0.6.2 → chitmark-0.6.4}/tests/test_errors.py +0 -0
- {chitmark-0.6.2 → chitmark-0.6.4}/tests/test_pii.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: chitmark
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.4
|
|
4
4
|
Summary: Official Python SDK for Chitmark by Open Agent Ledger: trust decisions on agent-mediated actions (verify, feedback, challenge)
|
|
5
5
|
Project-URL: Homepage, https://chitmark.com
|
|
6
6
|
Project-URL: Documentation, https://chitmark.com/docs
|
|
@@ -80,7 +80,14 @@ with Chitmark(api_key="ck_live_...") as client:
|
|
|
80
80
|
| `client.feedback(...)` | `POST /v1/feedback` | Learn |
|
|
81
81
|
| `client.challenge(...)` | `POST /v1/challenge` | Escalate |
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
Store-outage degradation on verify is HTTP 200 with a Verdict
|
|
84
|
+
(`degraded: True`); the client deserializes it like any other Verdict.
|
|
85
|
+
Verify never returns HTTP 503. Timeouts and transport errors synthesize a
|
|
86
|
+
local degraded challenge Verdict and never raise on `verify`. HTTP 4xx /
|
|
87
|
+
429 Error responses raise `ChitmarkApiError`. Set `on_degraded` to
|
|
88
|
+
`challenge`, never allow on degraded.
|
|
89
|
+
|
|
90
|
+
Use `is_eligible_allow(verdict)` before any protected action: only an explicit non-degraded `allow` is eligible. Unknown or malformed decisions fail closed.
|
|
84
91
|
|
|
85
92
|
### Report outcomes
|
|
86
93
|
|
|
@@ -95,12 +102,12 @@ db.accounts.update(user_id, chitmark_event_id=verdict["eventId"])
|
|
|
95
102
|
client.feedback(
|
|
96
103
|
event_id=account.chitmark_event_id, # the stored join key
|
|
97
104
|
outcome="credit_burn",
|
|
98
|
-
value=87.4, #
|
|
105
|
+
value=87.4, # measured magnitude: unit rides along (default "usd"; also "credits" | "count")
|
|
99
106
|
observed_at="2026-08-06T04:00:00Z",
|
|
100
107
|
)
|
|
101
108
|
```
|
|
102
109
|
|
|
103
|
-
Exact duplicate feedback bodies derive the same warehouse id, so retrying a connector never double-counts a burned value. `unit` ships only alongside `value`.
|
|
110
|
+
Exact duplicate feedback bodies derive the same warehouse id (`eventId` + `outcome` + `value` + `unit` + `observedAt`), so retrying a connector with a stable business timestamp never double-counts a burned value. Prefer an idempotency key; omitting `observedAt` uses server time. `unit` ships only alongside `value`.
|
|
104
111
|
|
|
105
112
|
### Handle a challenge
|
|
106
113
|
|
|
@@ -129,12 +136,12 @@ if instructions["type"] == "pow":
|
|
|
129
136
|
session="sess_9f3a",
|
|
130
137
|
proof={"type": "proof_of_work", "nonce": str(nonce)},
|
|
131
138
|
)
|
|
132
|
-
# Re-verify with context={"challengeId": ...}
|
|
139
|
+
# Re-verify with context={"challengeId": ...}; completion alone does not authorize.
|
|
133
140
|
```
|
|
134
141
|
|
|
135
142
|
### Verify the receipt
|
|
136
143
|
|
|
137
|
-
Every production verdict ships a `verdictToken`: an ES256 JWT bound to session, origin, and event. Verify
|
|
144
|
+
Every production verdict ships a `verdictToken`: an ES256 JWT bound to session, origin, and event. Verify authenticity, then authorize on the decision (install the `verdict` extra for the crypto dependency):
|
|
138
145
|
|
|
139
146
|
```python
|
|
140
147
|
pip install "chitmark[verdict]"
|
|
@@ -145,13 +152,19 @@ from chitmark.verify_token import verify_verdict_token
|
|
|
145
152
|
|
|
146
153
|
claims = verify_verdict_token(
|
|
147
154
|
verdict["verdictToken"],
|
|
148
|
-
session="sess_9f3a",
|
|
149
|
-
|
|
155
|
+
session="sess_9f3a",
|
|
156
|
+
tenant_id="org_acme",
|
|
150
157
|
)
|
|
151
|
-
|
|
158
|
+
|
|
159
|
+
if claims["degraded"] or claims["decision"] != "allow":
|
|
160
|
+
raise ChallengeRequiredError() # HTTP 428 / challenge response
|
|
161
|
+
|
|
162
|
+
# Only now trust the allow.
|
|
152
163
|
```
|
|
153
164
|
|
|
154
|
-
Rejects expired tokens, unknown keys, bad signatures, and session or origin mismatches with typed error codes.
|
|
165
|
+
Rejects expired tokens, unknown keys, bad signatures, and session or origin mismatches with typed error codes. A verified token that is not a non-degraded `allow` must still fail closed.
|
|
166
|
+
|
|
167
|
+
Cache JWKS using HTTP headers: respect `Cache-Control` (`max-age=60`), and when `ETag` or `Last-Modified` are present use conditional requests (`If-None-Match` / `If-Modified-Since`) instead of a hard-coded refresh interval. Refresh immediately on unknown `kid`. Do not fetch on every verification. Every signed JWT requires `kid`. Keys are EC P-256 for ES256: require JWT `alg === "ES256"` before verifying (`wrong_algorithm` otherwise); never trust the token-requested alg. Rotation overlaps old and new keys; the old key stays until tokens under it expire (keep ≥ 5 minutes + 60 seconds). An empty JWKS (`keys: []`) means verification is impossible: fail closed; never treat an empty set as proof a token is valid (local/CI may also emit `unsigned.<eventId>`). Pass a cached `jwks=` mapping into `verify_verdict_token` to skip the network call.
|
|
155
168
|
|
|
156
169
|
### PII modes
|
|
157
170
|
|
|
@@ -161,6 +174,12 @@ Rejects expired tokens, unknown keys, bad signatures, and session or origin mism
|
|
|
161
174
|
| `none` | Derived/header-shape signals only |
|
|
162
175
|
| `raw` | Tenant opt-in only; higher compliance review |
|
|
163
176
|
|
|
177
|
+
### Timeouts and local development
|
|
178
|
+
|
|
179
|
+
The default timeout is 800ms in production. A local base URL (`localhost`,
|
|
180
|
+
`127.0.0.1`, `::1`, `0.0.0.0`, or `*.localhost`) defaults to 2000ms so a cold
|
|
181
|
+
local edge can start. Pass `timeout_ms=` to override either default.
|
|
182
|
+
|
|
164
183
|
### Dependency injection and lifecycle
|
|
165
184
|
|
|
166
185
|
The client owns its `httpx.Client` by default and closes it on context exit. Pass your own for connection pooling or tests:
|
|
@@ -46,7 +46,14 @@ with Chitmark(api_key="ck_live_...") as client:
|
|
|
46
46
|
| `client.feedback(...)` | `POST /v1/feedback` | Learn |
|
|
47
47
|
| `client.challenge(...)` | `POST /v1/challenge` | Escalate |
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
Store-outage degradation on verify is HTTP 200 with a Verdict
|
|
50
|
+
(`degraded: True`); the client deserializes it like any other Verdict.
|
|
51
|
+
Verify never returns HTTP 503. Timeouts and transport errors synthesize a
|
|
52
|
+
local degraded challenge Verdict and never raise on `verify`. HTTP 4xx /
|
|
53
|
+
429 Error responses raise `ChitmarkApiError`. Set `on_degraded` to
|
|
54
|
+
`challenge`, never allow on degraded.
|
|
55
|
+
|
|
56
|
+
Use `is_eligible_allow(verdict)` before any protected action: only an explicit non-degraded `allow` is eligible. Unknown or malformed decisions fail closed.
|
|
50
57
|
|
|
51
58
|
### Report outcomes
|
|
52
59
|
|
|
@@ -61,12 +68,12 @@ db.accounts.update(user_id, chitmark_event_id=verdict["eventId"])
|
|
|
61
68
|
client.feedback(
|
|
62
69
|
event_id=account.chitmark_event_id, # the stored join key
|
|
63
70
|
outcome="credit_burn",
|
|
64
|
-
value=87.4, #
|
|
71
|
+
value=87.4, # measured magnitude: unit rides along (default "usd"; also "credits" | "count")
|
|
65
72
|
observed_at="2026-08-06T04:00:00Z",
|
|
66
73
|
)
|
|
67
74
|
```
|
|
68
75
|
|
|
69
|
-
Exact duplicate feedback bodies derive the same warehouse id, so retrying a connector never double-counts a burned value. `unit` ships only alongside `value`.
|
|
76
|
+
Exact duplicate feedback bodies derive the same warehouse id (`eventId` + `outcome` + `value` + `unit` + `observedAt`), so retrying a connector with a stable business timestamp never double-counts a burned value. Prefer an idempotency key; omitting `observedAt` uses server time. `unit` ships only alongside `value`.
|
|
70
77
|
|
|
71
78
|
### Handle a challenge
|
|
72
79
|
|
|
@@ -95,12 +102,12 @@ if instructions["type"] == "pow":
|
|
|
95
102
|
session="sess_9f3a",
|
|
96
103
|
proof={"type": "proof_of_work", "nonce": str(nonce)},
|
|
97
104
|
)
|
|
98
|
-
# Re-verify with context={"challengeId": ...}
|
|
105
|
+
# Re-verify with context={"challengeId": ...}; completion alone does not authorize.
|
|
99
106
|
```
|
|
100
107
|
|
|
101
108
|
### Verify the receipt
|
|
102
109
|
|
|
103
|
-
Every production verdict ships a `verdictToken`: an ES256 JWT bound to session, origin, and event. Verify
|
|
110
|
+
Every production verdict ships a `verdictToken`: an ES256 JWT bound to session, origin, and event. Verify authenticity, then authorize on the decision (install the `verdict` extra for the crypto dependency):
|
|
104
111
|
|
|
105
112
|
```python
|
|
106
113
|
pip install "chitmark[verdict]"
|
|
@@ -111,13 +118,19 @@ from chitmark.verify_token import verify_verdict_token
|
|
|
111
118
|
|
|
112
119
|
claims = verify_verdict_token(
|
|
113
120
|
verdict["verdictToken"],
|
|
114
|
-
session="sess_9f3a",
|
|
115
|
-
|
|
121
|
+
session="sess_9f3a",
|
|
122
|
+
tenant_id="org_acme",
|
|
116
123
|
)
|
|
117
|
-
|
|
124
|
+
|
|
125
|
+
if claims["degraded"] or claims["decision"] != "allow":
|
|
126
|
+
raise ChallengeRequiredError() # HTTP 428 / challenge response
|
|
127
|
+
|
|
128
|
+
# Only now trust the allow.
|
|
118
129
|
```
|
|
119
130
|
|
|
120
|
-
Rejects expired tokens, unknown keys, bad signatures, and session or origin mismatches with typed error codes.
|
|
131
|
+
Rejects expired tokens, unknown keys, bad signatures, and session or origin mismatches with typed error codes. A verified token that is not a non-degraded `allow` must still fail closed.
|
|
132
|
+
|
|
133
|
+
Cache JWKS using HTTP headers: respect `Cache-Control` (`max-age=60`), and when `ETag` or `Last-Modified` are present use conditional requests (`If-None-Match` / `If-Modified-Since`) instead of a hard-coded refresh interval. Refresh immediately on unknown `kid`. Do not fetch on every verification. Every signed JWT requires `kid`. Keys are EC P-256 for ES256: require JWT `alg === "ES256"` before verifying (`wrong_algorithm` otherwise); never trust the token-requested alg. Rotation overlaps old and new keys; the old key stays until tokens under it expire (keep ≥ 5 minutes + 60 seconds). An empty JWKS (`keys: []`) means verification is impossible: fail closed; never treat an empty set as proof a token is valid (local/CI may also emit `unsigned.<eventId>`). Pass a cached `jwks=` mapping into `verify_verdict_token` to skip the network call.
|
|
121
134
|
|
|
122
135
|
### PII modes
|
|
123
136
|
|
|
@@ -127,6 +140,12 @@ Rejects expired tokens, unknown keys, bad signatures, and session or origin mism
|
|
|
127
140
|
| `none` | Derived/header-shape signals only |
|
|
128
141
|
| `raw` | Tenant opt-in only; higher compliance review |
|
|
129
142
|
|
|
143
|
+
### Timeouts and local development
|
|
144
|
+
|
|
145
|
+
The default timeout is 800ms in production. A local base URL (`localhost`,
|
|
146
|
+
`127.0.0.1`, `::1`, `0.0.0.0`, or `*.localhost`) defaults to 2000ms so a cold
|
|
147
|
+
local edge can start. Pass `timeout_ms=` to override either default.
|
|
148
|
+
|
|
130
149
|
### Dependency injection and lifecycle
|
|
131
150
|
|
|
132
151
|
The client owns its `httpx.Client` by default and closes it on context exit. Pass your own for connection pooling or tests:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "chitmark"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.4"
|
|
8
8
|
description = "Official Python SDK for Chitmark by Open Agent Ledger: trust decisions on agent-mediated actions (verify, feedback, challenge)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "Proprietary: no open-source grant. Copyright (c) 2026 Open Agent Ledger. All rights reserved." }
|
|
@@ -4,7 +4,7 @@ Trust decisions on agent-mediated actions (verify, feedback, challenge),
|
|
|
4
4
|
tuned by business outcomes.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from chitmark.client import SDK_VERSION, Chitmark
|
|
7
|
+
from chitmark.client import SDK_VERSION, Chitmark, is_eligible_allow
|
|
8
8
|
from chitmark.errors import ChitmarkApiError, ChitmarkError
|
|
9
9
|
from chitmark.pii import hash_email_sha256, truncate_ip
|
|
10
10
|
from chitmark.types import (
|
|
@@ -14,6 +14,7 @@ from chitmark.types import (
|
|
|
14
14
|
Decision,
|
|
15
15
|
DecisionCosts,
|
|
16
16
|
FeedbackResponse,
|
|
17
|
+
FeedbackSource,
|
|
17
18
|
Outcome,
|
|
18
19
|
Principal,
|
|
19
20
|
Subject,
|
|
@@ -35,6 +36,7 @@ __all__ = [
|
|
|
35
36
|
"Decision",
|
|
36
37
|
"DecisionCosts",
|
|
37
38
|
"FeedbackResponse",
|
|
39
|
+
"FeedbackSource",
|
|
38
40
|
"Outcome",
|
|
39
41
|
"Principal",
|
|
40
42
|
"Subject",
|
|
@@ -43,6 +45,7 @@ __all__ = [
|
|
|
43
45
|
"VerdictTokenError",
|
|
44
46
|
"VerifyContext",
|
|
45
47
|
"hash_email_sha256",
|
|
48
|
+
"is_eligible_allow",
|
|
46
49
|
"truncate_ip",
|
|
47
50
|
"verify_verdict_token",
|
|
48
51
|
]
|
|
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
import os
|
|
9
9
|
from collections.abc import Mapping
|
|
10
10
|
from typing import Any, cast
|
|
11
|
+
from urllib.parse import urlparse
|
|
11
12
|
|
|
12
13
|
import httpx
|
|
13
14
|
|
|
@@ -22,6 +23,8 @@ from chitmark.types import (
|
|
|
22
23
|
ChitmarkEnv,
|
|
23
24
|
Decision,
|
|
24
25
|
FeedbackResponse,
|
|
26
|
+
FeedbackSource,
|
|
27
|
+
FeedbackUnit,
|
|
25
28
|
OnDegraded,
|
|
26
29
|
Outcome,
|
|
27
30
|
PiiMode,
|
|
@@ -30,9 +33,9 @@ from chitmark.types import (
|
|
|
30
33
|
VerifyContext,
|
|
31
34
|
)
|
|
32
35
|
|
|
33
|
-
__all__ = ["Chitmark", "DEFAULT_BASE_URLS", "SDK_VERSION"]
|
|
36
|
+
__all__ = ["Chitmark", "DEFAULT_BASE_URLS", "SDK_VERSION", "is_eligible_allow"]
|
|
34
37
|
|
|
35
|
-
SDK_VERSION = "0.6.
|
|
38
|
+
SDK_VERSION = "0.6.4"
|
|
36
39
|
|
|
37
40
|
DEFAULT_BASE_URLS: dict[ChitmarkEnv, str] = {
|
|
38
41
|
"production": "https://api.chitmark.com",
|
|
@@ -41,6 +44,19 @@ DEFAULT_BASE_URLS: dict[ChitmarkEnv, str] = {
|
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
DEFAULT_TIMEOUT_MS = 800
|
|
47
|
+
LOCAL_TIMEOUT_MS = 2000
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_local_base_url(base_url: str) -> bool:
|
|
51
|
+
try:
|
|
52
|
+
host = (urlparse(base_url).hostname or "").lower()
|
|
53
|
+
except ValueError:
|
|
54
|
+
return False
|
|
55
|
+
return (
|
|
56
|
+
host == "localhost"
|
|
57
|
+
or host.endswith(".localhost")
|
|
58
|
+
or host in {"127.0.0.1", "0.0.0.0", "::1"}
|
|
59
|
+
)
|
|
44
60
|
|
|
45
61
|
|
|
46
62
|
def _degraded_challenge_verdict(reason: str, session: str | None = None) -> Verdict:
|
|
@@ -66,6 +82,15 @@ def _enforce_degraded_never_allow(verdict: Verdict) -> Verdict:
|
|
|
66
82
|
return verdict
|
|
67
83
|
|
|
68
84
|
|
|
85
|
+
def is_eligible_allow(verdict: Mapping[str, Any] | Verdict) -> bool:
|
|
86
|
+
"""True only for an explicit non-degraded allow.
|
|
87
|
+
|
|
88
|
+
Use this before any protected action so unknown or malformed decisions
|
|
89
|
+
cannot fall through. Fail closed: anything else is ineligible.
|
|
90
|
+
"""
|
|
91
|
+
return verdict.get("decision") == "allow" and not verdict.get("degraded")
|
|
92
|
+
|
|
93
|
+
|
|
69
94
|
def _is_well_formed_verdict(value: Any) -> bool:
|
|
70
95
|
"""Minimal shape guard for a 200 verify response.
|
|
71
96
|
|
|
@@ -149,7 +174,7 @@ class Chitmark:
|
|
|
149
174
|
*,
|
|
150
175
|
base_url: str | None = None,
|
|
151
176
|
env: ChitmarkEnv = "production",
|
|
152
|
-
timeout_ms: int =
|
|
177
|
+
timeout_ms: int | None = None,
|
|
153
178
|
pii_mode: PiiMode = "hashed",
|
|
154
179
|
on_degraded: OnDegraded = "challenge",
|
|
155
180
|
client_name: str | None = None,
|
|
@@ -166,7 +191,13 @@ class Chitmark:
|
|
|
166
191
|
self._base_url = (
|
|
167
192
|
base_url or os.environ.get("CHITMARK_BASE_URL") or DEFAULT_BASE_URLS[env]
|
|
168
193
|
).rstrip("/")
|
|
169
|
-
|
|
194
|
+
if timeout_ms is not None:
|
|
195
|
+
resolved_timeout_ms = timeout_ms
|
|
196
|
+
elif _is_local_base_url(self._base_url):
|
|
197
|
+
resolved_timeout_ms = LOCAL_TIMEOUT_MS
|
|
198
|
+
else:
|
|
199
|
+
resolved_timeout_ms = DEFAULT_TIMEOUT_MS
|
|
200
|
+
self._timeout = resolved_timeout_ms / 1000.0
|
|
170
201
|
self._pii_mode = pii_mode
|
|
171
202
|
self._on_degraded: OnDegraded = "challenge"
|
|
172
203
|
self._client_name = client_name or f"chitmark-python/{SDK_VERSION}"
|
|
@@ -204,13 +235,26 @@ class Chitmark:
|
|
|
204
235
|
context: VerifyContext | Mapping[str, Any] | None = None,
|
|
205
236
|
idempotency_key: str | None = None,
|
|
206
237
|
) -> Verdict:
|
|
207
|
-
"""Score + route an inbound action.
|
|
238
|
+
"""Score + route an inbound action.
|
|
239
|
+
|
|
240
|
+
Store-outage degradation arrives as HTTP 200 with a Verdict body
|
|
241
|
+
(``degraded: True``, ``decision: "challenge"``). The client
|
|
242
|
+
deserializes that like any other Verdict. Verify never returns
|
|
243
|
+
HTTP 503.
|
|
244
|
+
|
|
245
|
+
HTTP 4xx / 429 Error responses raise ``ChitmarkApiError``. Transport
|
|
246
|
+
timeout / network failure synthesizes a local degraded challenge
|
|
247
|
+
Verdict (never allow).
|
|
248
|
+
|
|
249
|
+
``session`` is a binding id you supply (prefer high-entropy,
|
|
250
|
+
server-generated); it binds challenge and ``verdictToken``.
|
|
208
251
|
|
|
209
252
|
``context`` carries per-request policy flags: ``challengeId``
|
|
210
253
|
(honor a cleared challenge once), ``challengePolicy``
|
|
211
254
|
("default" | "confirmed_tail_only"), and ``decisionCosts``
|
|
212
|
-
(falseAllow/falseChallenge/
|
|
213
|
-
allow
|
|
255
|
+
(relative, dimensionless weights for falseAllow / falseChallenge /
|
|
256
|
+
falseDeny: abusive allow, over-friction challenge on legitimate
|
|
257
|
+
traffic, legitimate deny; defaults 1:1:3).
|
|
214
258
|
"""
|
|
215
259
|
body: dict[str, Any] = {
|
|
216
260
|
"action": action,
|
|
@@ -249,15 +293,30 @@ class Chitmark:
|
|
|
249
293
|
event_id: str,
|
|
250
294
|
outcome: Outcome,
|
|
251
295
|
value: float | None = None,
|
|
252
|
-
unit:
|
|
296
|
+
unit: FeedbackUnit = "usd",
|
|
297
|
+
source: FeedbackSource | None = None,
|
|
253
298
|
observed_at: str | None = None,
|
|
254
299
|
idempotency_key: str | None = None,
|
|
255
300
|
) -> FeedbackResponse:
|
|
256
301
|
"""Report a downstream outcome joined on eventId.
|
|
257
302
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
303
|
+
Security invariant: feedback cannot create attribution. It only
|
|
304
|
+
attaches an outcome to an existing verify event for this tenant.
|
|
305
|
+
Unknown or cross-tenant ``event_id`` values return 404. Never invent
|
|
306
|
+
a join from email/IP.
|
|
307
|
+
|
|
308
|
+
Append-only (not latest-wins): multiple outcomes may attach to one
|
|
309
|
+
eventId. Exact duplicates share one feedbackId. Compatible labels
|
|
310
|
+
coexist; conflicting pairs quarantine the new observation without
|
|
311
|
+
deleting prior evidence. HTTP ``{ok: true}`` still acknowledges
|
|
312
|
+
quarantined ingest.
|
|
313
|
+
|
|
314
|
+
``value`` is the measured economic magnitude for this outcome,
|
|
315
|
+
expressed in ``unit`` (``usd`` | ``credits`` | ``count``). ``unit``
|
|
316
|
+
rides along only when ``value`` is present. Different units are not
|
|
317
|
+
converted or comparable. ``source`` is optional provenance (billing,
|
|
318
|
+
usage_metering, trust_queue, manual_review, customer_system,
|
|
319
|
+
automated_detector) and is included in warehouse identity when set.
|
|
261
320
|
``idempotency_key`` is sent as both the ``Idempotency-Key`` header
|
|
262
321
|
and body ``idempotencyKey`` so exact retries replay the original
|
|
263
322
|
acknowledgement; a reused key with a different payload conflicts.
|
|
@@ -266,6 +325,8 @@ class Chitmark:
|
|
|
266
325
|
if value is not None:
|
|
267
326
|
body["value"] = value
|
|
268
327
|
body["unit"] = unit
|
|
328
|
+
if source is not None:
|
|
329
|
+
body["source"] = source
|
|
269
330
|
if observed_at is not None:
|
|
270
331
|
body["observedAt"] = observed_at
|
|
271
332
|
if idempotency_key is not None:
|
|
@@ -290,9 +351,11 @@ class Chitmark:
|
|
|
290
351
|
) -> ChallengeResponse:
|
|
291
352
|
"""Issue an asymmetric-cost challenge.
|
|
292
353
|
|
|
293
|
-
``
|
|
294
|
-
|
|
295
|
-
|
|
354
|
+
``session`` is a binding id you supply (prefer high-entropy,
|
|
355
|
+
server-generated). ``risk_tier`` scales issued PoW difficulty
|
|
356
|
+
monotonically; difficulty is sealed in ``instructions`` and
|
|
357
|
+
completion validates that value only. ``prefer`` is ordered hints:
|
|
358
|
+
first issuable method wins.
|
|
296
359
|
"""
|
|
297
360
|
body: dict[str, Any] = {"eventId": event_id, "session": session}
|
|
298
361
|
if prefer is not None:
|
|
@@ -314,9 +377,10 @@ class Chitmark:
|
|
|
314
377
|
) -> ChallengeCompletionResponse:
|
|
315
378
|
"""Complete a challenge by submitting its solved proof.
|
|
316
379
|
|
|
317
|
-
Clears only the bound session, once
|
|
318
|
-
``context={"challengeId": challenge_id}``
|
|
319
|
-
|
|
380
|
+
Clears only the bound session, once. Does not authorize the action:
|
|
381
|
+
re-verify with ``context={"challengeId": challenge_id}`` and proceed
|
|
382
|
+
only on non-degraded ``allow`` (honored clear sets
|
|
383
|
+
``challengeCleared: True``).
|
|
320
384
|
"""
|
|
321
385
|
body: dict[str, Any] = {
|
|
322
386
|
"eventId": event_id,
|
|
@@ -19,6 +19,13 @@ Action = (
|
|
|
19
19
|
]
|
|
20
20
|
| str
|
|
21
21
|
)
|
|
22
|
+
# Outcomes reported via feedback. Prefer fast labels first
|
|
23
|
+
# (credit_burn, multi_account_cluster, abuse_confirmed).
|
|
24
|
+
# false_positive: a previously attributed abuse signal on this eventId was
|
|
25
|
+
# reviewed and determined not to represent abuse (T&S clear / overturn).
|
|
26
|
+
# Conflicts with abuse-positive labels. Not "Chitmark challenged/denied a
|
|
27
|
+
# legitimate user": that is context.decisionCosts.falseChallenge / falseDeny
|
|
28
|
+
# on verify.
|
|
22
29
|
Outcome = Literal[
|
|
23
30
|
"credit_burn",
|
|
24
31
|
"multi_account_cluster",
|
|
@@ -28,6 +35,18 @@ Outcome = Literal[
|
|
|
28
35
|
"churned",
|
|
29
36
|
"false_positive",
|
|
30
37
|
]
|
|
38
|
+
# Scale for feedback value. Allowlist only (not free-form). Values in
|
|
39
|
+
# different units are not converted or comparable.
|
|
40
|
+
FeedbackUnit = Literal["usd", "credits", "count"]
|
|
41
|
+
# Provenance of a feedback label: why you trusted this outcome.
|
|
42
|
+
FeedbackSource = Literal[
|
|
43
|
+
"billing",
|
|
44
|
+
"usage_metering",
|
|
45
|
+
"trust_queue",
|
|
46
|
+
"manual_review",
|
|
47
|
+
"customer_system",
|
|
48
|
+
"automated_detector",
|
|
49
|
+
]
|
|
31
50
|
ChallengeMethod = Literal[
|
|
32
51
|
"payment_preauth",
|
|
33
52
|
"proof_of_work",
|
|
@@ -42,19 +61,49 @@ OnDegraded = Literal["challenge"]
|
|
|
42
61
|
|
|
43
62
|
class PrincipalRequired(TypedDict):
|
|
44
63
|
"""Required principal fields from verify."""
|
|
64
|
+
|
|
45
65
|
resolved: bool
|
|
46
66
|
identityBasis: list[str]
|
|
47
67
|
|
|
48
68
|
|
|
49
69
|
class Principal(PrincipalRequired, total=False):
|
|
50
|
-
"""Principal
|
|
70
|
+
"""Principal attribution: who is acting and on whose behalf.
|
|
71
|
+
|
|
72
|
+
Not an authorization credential. ``resolved is True`` is not "this is a
|
|
73
|
+
human", not an IdP login, and not permission to unlock an action. Gate
|
|
74
|
+
on ``decision`` (plus ``degraded`` / ``enforcementMode``).
|
|
75
|
+
|
|
76
|
+
- ``resolved``: usable principal signal (today: verified Web Bot Auth
|
|
77
|
+
operator signature and/or enrichment known-good).
|
|
78
|
+
- ``operator``: bot maker from Web Bot Auth Signature-Agent when the
|
|
79
|
+
operator channel verified; does not by itself prove ``onBehalfOf``.
|
|
80
|
+
- ``onBehalfOf``: principal the agent is acting for when known
|
|
81
|
+
(organization **or** end-user identifier). Often absent at the wedge.
|
|
82
|
+
- ``identityBasis``: contributing signal sources (mixed cryptographic
|
|
83
|
+
and inferred strength).
|
|
84
|
+
"""
|
|
51
85
|
|
|
52
86
|
operator: str
|
|
53
87
|
onBehalfOf: str
|
|
54
88
|
|
|
55
|
-
|
|
56
89
|
class Verdict(TypedDict):
|
|
57
|
-
"""Verdict object: single source of truth for a verify call.
|
|
90
|
+
"""Verdict object: single source of truth for a verify call.
|
|
91
|
+
|
|
92
|
+
``confidence`` is a model confidence measure for the returned decision
|
|
93
|
+
(0..1). It is not a calibrated probability of abuse, and not the
|
|
94
|
+
threshold ``decisionCosts`` moves. Gate on ``decision`` (plus
|
|
95
|
+
``degraded`` / ``enforcementMode``); use confidence for ops triage and
|
|
96
|
+
dashboards, not as a substitute allow/deny cutoff.
|
|
97
|
+
|
|
98
|
+
``reasons`` is ``list[str]`` display copy for ops today. Do not scrape
|
|
99
|
+
reason text as a stable machine API. A future additive revision may
|
|
100
|
+
expose structured ``{code, message, category}`` entries while keeping a
|
|
101
|
+
human-readable message.
|
|
102
|
+
|
|
103
|
+
Under ``enforcementMode == "log_only"``, ``verdictToken`` is a signed
|
|
104
|
+
observation receipt and does not authorize the action. Mode is on this
|
|
105
|
+
body (not a JWT claim today): gate only when mode is ``enforce``.
|
|
106
|
+
"""
|
|
58
107
|
|
|
59
108
|
decision: Decision
|
|
60
109
|
actorType: ActorType
|
|
@@ -70,6 +119,7 @@ class Verdict(TypedDict):
|
|
|
70
119
|
class VerdictTokenClaims(TypedDict, total=False):
|
|
71
120
|
"""Decoded verdictToken JWT payload (see SDK.md §6)."""
|
|
72
121
|
|
|
122
|
+
iss: str
|
|
73
123
|
eventId: str
|
|
74
124
|
decision: Decision
|
|
75
125
|
actorType: ActorType
|
|
@@ -84,7 +134,13 @@ class VerdictTokenClaims(TypedDict, total=False):
|
|
|
84
134
|
|
|
85
135
|
|
|
86
136
|
class VerdictWithChallenge(Verdict, total=False):
|
|
87
|
-
"""Optional verdict additions for cleared challenges.
|
|
137
|
+
"""Optional verdict additions for cleared challenges.
|
|
138
|
+
|
|
139
|
+
``challengeCleared: true`` means this verify honored
|
|
140
|
+
``context.challengeId`` for the session. Guaranteed only with
|
|
141
|
+
``decision: allow`` and ``degraded: false`` (clear short-circuits
|
|
142
|
+
scoring). Under ``log_only``, still observation-only.
|
|
143
|
+
"""
|
|
88
144
|
|
|
89
145
|
challengeCleared: bool
|
|
90
146
|
|
|
@@ -103,13 +159,22 @@ class Subject(TypedDict, total=False):
|
|
|
103
159
|
|
|
104
160
|
|
|
105
161
|
class DecisionCosts(TypedDict, total=False):
|
|
106
|
-
"""
|
|
162
|
+
"""Relative, dimensionless weights for three error classes.
|
|
163
|
+
|
|
164
|
+
Not dollars and not score thresholds; only ratios matter. Repositions
|
|
165
|
+
the allow/challenge/deny boundaries for a single verify.
|
|
166
|
+
|
|
167
|
+
- ``falseAllow``: cost of allowing abusive traffic
|
|
168
|
+
- ``falseChallenge``: cost of challenging legitimate traffic when allow
|
|
169
|
+
would have been fine (a challenge can still be appropriate for
|
|
170
|
+
legitimate users; this names the over-friction case)
|
|
171
|
+
- ``falseDeny``: cost of denying legitimate traffic
|
|
107
172
|
|
|
108
173
|
Higher ``falseAllow`` challenges earlier (abuse-averse); higher
|
|
109
174
|
``falseChallenge`` lets more of the gray zone through; higher
|
|
110
175
|
``falseDeny`` reserves deny for confirmed abuse. Defaults 1:1:3 keep
|
|
111
|
-
|
|
112
|
-
|
|
176
|
+
fail-to-challenge semantics; missing/non-positive fields fall back to
|
|
177
|
+
their default.
|
|
113
178
|
"""
|
|
114
179
|
|
|
115
180
|
falseAllow: float
|
|
@@ -122,15 +187,26 @@ class VerifyContext(TypedDict, total=False):
|
|
|
122
187
|
|
|
123
188
|
Unknown keys are carried (not scored); ``decisionCosts`` repositions
|
|
124
189
|
the allow/challenge/deny boundaries for this request.
|
|
190
|
+
|
|
191
|
+
``logOnly`` is an integration-side control: set it in server code (or
|
|
192
|
+
rely on env/key log-only policy). Never derive it from client-provided
|
|
193
|
+
request data. The verdict reports the effective mode as
|
|
194
|
+
``enforcementMode``.
|
|
125
195
|
"""
|
|
126
196
|
|
|
127
197
|
challengeId: str
|
|
128
198
|
challengePolicy: Literal["default", "confirmed_tail_only"]
|
|
199
|
+
logOnly: bool
|
|
129
200
|
decisionCosts: DecisionCosts
|
|
130
201
|
|
|
131
202
|
|
|
132
203
|
class VerifyParams(TypedDict, total=False):
|
|
133
|
-
"""Parameters for Client.verify.
|
|
204
|
+
"""Parameters for Client.verify.
|
|
205
|
+
|
|
206
|
+
``session`` is supplied by you: prefer a high-entropy, server-generated
|
|
207
|
+
id (binds challenge and verdictToken). Client-generated values are
|
|
208
|
+
attacker-influenced.
|
|
209
|
+
"""
|
|
134
210
|
|
|
135
211
|
idempotency_key: str
|
|
136
212
|
action: Action
|
|
@@ -146,13 +222,18 @@ class FeedbackParams(TypedDict, total=False):
|
|
|
146
222
|
event_id: str
|
|
147
223
|
outcome: Outcome
|
|
148
224
|
value: float
|
|
149
|
-
unit:
|
|
225
|
+
unit: FeedbackUnit
|
|
226
|
+
source: FeedbackSource
|
|
150
227
|
observed_at: str
|
|
151
228
|
idempotency_key: str
|
|
152
229
|
|
|
153
230
|
|
|
154
231
|
class ChallengeParams(TypedDict, total=False):
|
|
155
|
-
"""Parameters for Client.challenge (issue mode).
|
|
232
|
+
"""Parameters for Client.challenge (issue mode).
|
|
233
|
+
|
|
234
|
+
``session`` is a binding id you supply (prefer high-entropy,
|
|
235
|
+
server-generated). Same value across issue, complete, and re-verify.
|
|
236
|
+
"""
|
|
156
237
|
|
|
157
238
|
event_id: str
|
|
158
239
|
session: str
|
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
"""Client-side verdictToken verification (MVP-016).
|
|
2
2
|
|
|
3
3
|
A verdictToken is an ES256 JWT decision receipt minted by the edge and bound
|
|
4
|
-
to session (``sub``), origin hint (``aud``), issuing tenant (``tid``), and
|
|
5
|
-
``jti`` with a short ``exp
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
``
|
|
9
|
-
``
|
|
10
|
-
|
|
4
|
+
to session (``sub``), origin hint (``aud``), issuing tenant (``tid``), and a
|
|
5
|
+
unique ``jti`` (correlation id) with a short ``exp`` (5 minutes). Every signed
|
|
6
|
+
token requires a ``kid`` in the JWT header. Signature verification proves
|
|
7
|
+
authenticity and integrity; it does not make the JWT single-use. This module
|
|
8
|
+
always enforces ``session`` and ``tenant_id`` (security boundary), validates
|
|
9
|
+
``iss`` against the Chitmark issuer, and optionally ``aud``. ``aud`` reflects
|
|
10
|
+
request headers and is attacker influenced: never enforce ``aud`` alone.
|
|
11
|
+
Rejection reasons are surfaced as ``VerdictTokenError.code``.
|
|
12
|
+
|
|
13
|
+
JWKS: respect ``Cache-Control`` and, when present, ``ETag`` /
|
|
14
|
+
``Last-Modified`` via conditional requests (instead of a hard-coded
|
|
15
|
+
refresh interval). Refresh immediately on unknown ``kid``. Do not fetch on
|
|
16
|
+
every verification; never permanently pin a single signing key. Each JWKS
|
|
17
|
+
key is EC P-256 for ES256: require JWT ``alg == "ES256"`` before verifying
|
|
18
|
+
(``wrong_algorithm`` otherwise); never trust the token-requested algorithm.
|
|
19
|
+
Rotation overlaps old and new keys; the old key stays published until tokens under
|
|
20
|
+
it expire (keep ≥ 5 minutes + 60 seconds). Pass a cached ``jwks`` mapping
|
|
21
|
+
to skip the network call. An empty JWKS (``keys: []``) means no token can
|
|
22
|
+
be cryptographically verified: fail closed; never treat an empty set as
|
|
23
|
+
evidence that a token is valid (local/CI may emit ``unsigned.<eventId>``).
|
|
11
24
|
|
|
12
25
|
Requires the optional extras ``PyJWT`` and ``cryptography``
|
|
13
26
|
(``uv sync --extra verdict``).
|
|
@@ -42,14 +55,16 @@ class VerdictTokenError(ChitmarkError):
|
|
|
42
55
|
|
|
43
56
|
DEFAULT_JWKS_URL = "https://api.chitmark.com/.well-known/jwks.json"
|
|
44
57
|
DEFAULT_LEEWAY_SECONDS = 30
|
|
58
|
+
VERDICT_TOKEN_ISSUER = "https://api.chitmark.com"
|
|
45
59
|
|
|
46
60
|
|
|
47
61
|
def verify_verdict_token(
|
|
48
62
|
token: str,
|
|
49
63
|
*,
|
|
50
|
-
session: str
|
|
51
|
-
tenant_id: str
|
|
64
|
+
session: str,
|
|
65
|
+
tenant_id: str,
|
|
52
66
|
aud: str | None = None,
|
|
67
|
+
issuer: str = VERDICT_TOKEN_ISSUER,
|
|
53
68
|
jwks: Mapping[str, Any] | None = None,
|
|
54
69
|
jwks_url: str = DEFAULT_JWKS_URL,
|
|
55
70
|
http_client: httpx.Client | None = None,
|
|
@@ -59,10 +74,19 @@ def verify_verdict_token(
|
|
|
59
74
|
|
|
60
75
|
Args:
|
|
61
76
|
token: The ES256 JWT from a verify verdict.
|
|
62
|
-
session: Expected session; token ``sub`` must equal this.
|
|
63
|
-
tenant_id: Expected issuing tenant; token ``tid`` must equal this
|
|
77
|
+
session: Expected session; token ``sub`` must equal this (required).
|
|
78
|
+
tenant_id: Expected issuing tenant; token ``tid`` must equal this
|
|
79
|
+
(required).
|
|
64
80
|
aud: Expected origin hint; token ``aud`` must equal this when provided.
|
|
65
|
-
|
|
81
|
+
issuer: Expected ``iss``; defaults to ``https://api.chitmark.com``.
|
|
82
|
+
jwks: Pre-fetched/cached JWKS mapping; skips the network fetch.
|
|
83
|
+
Prefer caching via HTTP headers (``Cache-Control``, ``ETag``,
|
|
84
|
+
and ``Last-Modified`` when present) with conditional refresh
|
|
85
|
+
and immediate refresh on unknown ``kid``, rather than fetching
|
|
86
|
+
on every verification. Never permanently pin a single signing
|
|
87
|
+
key. Rotation overlaps old+new keys; keep the retiring key
|
|
88
|
+
until tokens under it expire (≥ 5 min TTL + 60s JWKS cache
|
|
89
|
+
skew).
|
|
66
90
|
jwks_url: JWKS endpoint; defaults to the production API.
|
|
67
91
|
http_client: Reusable ``httpx.Client`` for the JWKS fetch.
|
|
68
92
|
leeway_seconds: Clock-skew tolerance.
|
|
@@ -71,8 +95,8 @@ def verify_verdict_token(
|
|
|
71
95
|
VerdictTokenError: with a machine-readable ``code``:
|
|
72
96
|
``malformed_token``, ``wrong_algorithm``, ``wrong_type``,
|
|
73
97
|
``jwks_unavailable``, ``unknown_kid``, ``invalid_signature``,
|
|
74
|
-
``expired``, ``not_yet_valid``, ``
|
|
75
|
-
``wrong_tenant``, or ``wrong_aud``.
|
|
98
|
+
``expired``, ``not_yet_valid``, ``wrong_issuer``,
|
|
99
|
+
``wrong_session``, ``wrong_tenant``, or ``wrong_aud``.
|
|
76
100
|
"""
|
|
77
101
|
if pyjwt is None: # pragma: no cover
|
|
78
102
|
raise VerdictTokenError(
|
|
@@ -140,12 +164,17 @@ def verify_verdict_token(
|
|
|
140
164
|
code="malformed_token",
|
|
141
165
|
) from None
|
|
142
166
|
|
|
143
|
-
if
|
|
167
|
+
if claims.get("iss") != issuer:
|
|
168
|
+
raise VerdictTokenError(
|
|
169
|
+
"verdictToken was not issued by Chitmark",
|
|
170
|
+
code="wrong_issuer",
|
|
171
|
+
)
|
|
172
|
+
if claims.get("sub") != session:
|
|
144
173
|
raise VerdictTokenError(
|
|
145
174
|
"verdictToken was minted for a different session",
|
|
146
175
|
code="wrong_session",
|
|
147
176
|
)
|
|
148
|
-
if
|
|
177
|
+
if claims.get("tid") != tenant_id:
|
|
149
178
|
raise VerdictTokenError(
|
|
150
179
|
"verdictToken was minted for a different tenant",
|
|
151
180
|
code="wrong_tenant",
|
|
@@ -9,7 +9,11 @@ import pytest
|
|
|
9
9
|
import respx
|
|
10
10
|
|
|
11
11
|
from chitmark import Chitmark, ChitmarkError
|
|
12
|
-
from chitmark.client import
|
|
12
|
+
from chitmark.client import (
|
|
13
|
+
_degraded_challenge_verdict,
|
|
14
|
+
_enforce_degraded_never_allow,
|
|
15
|
+
is_eligible_allow,
|
|
16
|
+
)
|
|
13
17
|
from chitmark.pii import hash_email_sha256, truncate_ip
|
|
14
18
|
from chitmark.types import Verdict
|
|
15
19
|
|
|
@@ -45,6 +49,27 @@ def test_degraded_never_allow() -> None:
|
|
|
45
49
|
assert _enforce_degraded_never_allow(bad)["decision"] == "challenge"
|
|
46
50
|
|
|
47
51
|
|
|
52
|
+
def test_is_eligible_allow() -> None:
|
|
53
|
+
allow: Verdict = {
|
|
54
|
+
"decision": "allow",
|
|
55
|
+
"actorType": "human",
|
|
56
|
+
"confidence": 0.9,
|
|
57
|
+
"reasons": ["ok"],
|
|
58
|
+
"principal": {"resolved": False, "identityBasis": []},
|
|
59
|
+
"verdictToken": "tok",
|
|
60
|
+
"eventId": "evt_ok",
|
|
61
|
+
"degraded": False,
|
|
62
|
+
"enforcementMode": "enforce",
|
|
63
|
+
}
|
|
64
|
+
assert is_eligible_allow(allow) is True
|
|
65
|
+
|
|
66
|
+
degraded_allow = {**allow, "degraded": True}
|
|
67
|
+
assert is_eligible_allow(degraded_allow) is False
|
|
68
|
+
assert is_eligible_allow({**allow, "decision": "challenge"}) is False
|
|
69
|
+
assert is_eligible_allow({**allow, "decision": "deny"}) is False
|
|
70
|
+
assert is_eligible_allow({**allow, "decision": "weird"}) is False # type: ignore[typeddict-item]
|
|
71
|
+
|
|
72
|
+
|
|
48
73
|
def test_requires_api_key() -> None:
|
|
49
74
|
with pytest.raises(ChitmarkError, match="api_key"):
|
|
50
75
|
Chitmark(api_key="")
|
|
@@ -2,15 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
|
|
5
7
|
import httpx
|
|
6
8
|
import pytest
|
|
7
9
|
import respx
|
|
8
10
|
|
|
9
|
-
from chitmark import Chitmark
|
|
10
|
-
from chitmark.client import _is_well_formed_verdict, _prepare_subject
|
|
11
|
+
from chitmark import Chitmark, __version__
|
|
12
|
+
from chitmark.client import _is_local_base_url, _is_well_formed_verdict, _prepare_subject
|
|
11
13
|
from chitmark.pii import hash_email_sha256
|
|
12
14
|
|
|
13
15
|
|
|
16
|
+
def test_version_matches_package_metadata() -> None:
|
|
17
|
+
assert __version__ == version("chitmark")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_local_base_url_detection_handles_ipv6_and_malformed_urls() -> None:
|
|
21
|
+
assert _is_local_base_url("http://[::1]:8787") is True
|
|
22
|
+
assert _is_local_base_url("http://[::1") is False
|
|
23
|
+
assert _is_local_base_url("https://api.chitmark.com") is False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_local_base_url_gets_a_longer_default_timeout() -> None:
|
|
27
|
+
with Chitmark(api_key="ck_test", base_url="http://127.0.0.1:8787") as client:
|
|
28
|
+
assert client.config["timeout_ms"] == 2000
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_production_base_url_keeps_the_800ms_default() -> None:
|
|
32
|
+
with Chitmark(api_key="ck_test") as client:
|
|
33
|
+
assert client.config["timeout_ms"] == 800
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_explicit_timeout_wins_for_a_local_base_url() -> None:
|
|
37
|
+
with Chitmark(
|
|
38
|
+
api_key="ck_test",
|
|
39
|
+
base_url="http://localhost:8787",
|
|
40
|
+
timeout_ms=400,
|
|
41
|
+
) as client:
|
|
42
|
+
assert client.config["timeout_ms"] == 400
|
|
43
|
+
|
|
44
|
+
|
|
14
45
|
def test_malformed_verdict_shapes() -> None:
|
|
15
46
|
assert _is_well_formed_verdict("allow") is False
|
|
16
47
|
assert _is_well_formed_verdict(None) is False
|
|
@@ -169,11 +200,13 @@ def test_feedback_sends_value_unit_and_observed_at() -> None:
|
|
|
169
200
|
event_id="evt_x",
|
|
170
201
|
outcome="credit_burn",
|
|
171
202
|
value=4.2,
|
|
203
|
+
source="usage_metering",
|
|
172
204
|
observed_at="2026-08-01T00:00:00Z",
|
|
173
205
|
)
|
|
174
206
|
sent = respx.calls[-1].request.content
|
|
175
207
|
assert b'"value":4.2' in sent
|
|
176
208
|
assert b'"unit":"usd"' in sent
|
|
209
|
+
assert b'"source":"usage_metering"' in sent
|
|
177
210
|
assert b'"observedAt":"2026-08-01T00:00:00Z"' in sent
|
|
178
211
|
|
|
179
212
|
|
|
@@ -92,6 +92,7 @@ def _mint(keys: dict, **overrides: object) -> str:
|
|
|
92
92
|
"decision": "allow",
|
|
93
93
|
"actorType": "human",
|
|
94
94
|
"confidence": 0.56,
|
|
95
|
+
"iss": "https://api.chitmark.com",
|
|
95
96
|
"aud": "https://app.acme.com",
|
|
96
97
|
"sub": "sess_token_a",
|
|
97
98
|
"tid": "org_unit",
|
|
@@ -117,6 +118,7 @@ def test_accepts_valid_signed_token(keys: dict) -> None:
|
|
|
117
118
|
claims = verify_verdict_token(
|
|
118
119
|
token,
|
|
119
120
|
session="sess_token_a",
|
|
121
|
+
tenant_id="org_unit",
|
|
120
122
|
aud="https://app.acme.com",
|
|
121
123
|
jwks=_jwks(keys),
|
|
122
124
|
)
|
|
@@ -133,7 +135,9 @@ def test_rejects_wrong_session(keys: dict) -> None:
|
|
|
133
135
|
token = _mint(keys, sub="sess_other")
|
|
134
136
|
|
|
135
137
|
with pytest.raises(VerdictTokenError) as exc:
|
|
136
|
-
verify_verdict_token(
|
|
138
|
+
verify_verdict_token(
|
|
139
|
+
token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
|
|
140
|
+
)
|
|
137
141
|
assert exc.value.code == "wrong_session"
|
|
138
142
|
|
|
139
143
|
|
|
@@ -141,7 +145,13 @@ def test_rejects_wrong_audience(keys: dict) -> None:
|
|
|
141
145
|
token = _mint(keys, aud="https://other.app")
|
|
142
146
|
|
|
143
147
|
with pytest.raises(VerdictTokenError) as exc:
|
|
144
|
-
verify_verdict_token(
|
|
148
|
+
verify_verdict_token(
|
|
149
|
+
token,
|
|
150
|
+
session="sess_token_a",
|
|
151
|
+
tenant_id="org_unit",
|
|
152
|
+
aud="https://app.acme.com",
|
|
153
|
+
jwks=_jwks(keys),
|
|
154
|
+
)
|
|
145
155
|
assert exc.value.code == "wrong_aud"
|
|
146
156
|
|
|
147
157
|
|
|
@@ -149,10 +159,22 @@ def test_rejects_wrong_tenant(keys: dict) -> None:
|
|
|
149
159
|
token = _mint(keys, tid="org_other")
|
|
150
160
|
|
|
151
161
|
with pytest.raises(VerdictTokenError) as exc:
|
|
152
|
-
verify_verdict_token(
|
|
162
|
+
verify_verdict_token(
|
|
163
|
+
token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
|
|
164
|
+
)
|
|
153
165
|
assert exc.value.code == "wrong_tenant"
|
|
154
166
|
|
|
155
167
|
|
|
168
|
+
def test_rejects_wrong_issuer(keys: dict) -> None:
|
|
169
|
+
token = _mint(keys, iss="https://evil.example")
|
|
170
|
+
|
|
171
|
+
with pytest.raises(VerdictTokenError) as exc:
|
|
172
|
+
verify_verdict_token(
|
|
173
|
+
token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
|
|
174
|
+
)
|
|
175
|
+
assert exc.value.code == "wrong_issuer"
|
|
176
|
+
|
|
177
|
+
|
|
156
178
|
def test_accepts_expected_tenant(keys: dict) -> None:
|
|
157
179
|
token = _mint(keys, tid="org_unit", sub="sess_token_a")
|
|
158
180
|
|
|
@@ -166,7 +188,9 @@ def test_rejects_expired_token(keys: dict) -> None:
|
|
|
166
188
|
token = _mint(keys, exp=int(time.time()) - 120)
|
|
167
189
|
|
|
168
190
|
with pytest.raises(VerdictTokenError) as exc:
|
|
169
|
-
verify_verdict_token(
|
|
191
|
+
verify_verdict_token(
|
|
192
|
+
token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
|
|
193
|
+
)
|
|
170
194
|
assert exc.value.code == "expired"
|
|
171
195
|
|
|
172
196
|
|
|
@@ -174,7 +198,9 @@ def test_rejects_unknown_kid(keys: dict) -> None:
|
|
|
174
198
|
token = _mint(keys)
|
|
175
199
|
|
|
176
200
|
with pytest.raises(VerdictTokenError) as exc:
|
|
177
|
-
verify_verdict_token(
|
|
201
|
+
verify_verdict_token(
|
|
202
|
+
token, session="sess_token_a", tenant_id="org_unit", jwks={"keys": []}
|
|
203
|
+
)
|
|
178
204
|
assert exc.value.code == "unknown_kid"
|
|
179
205
|
|
|
180
206
|
|
|
@@ -187,7 +213,9 @@ def test_rejects_tampered_signature(keys: dict) -> None:
|
|
|
187
213
|
tampered = f"{header}.{payload}.{tampered_sig}"
|
|
188
214
|
|
|
189
215
|
with pytest.raises(VerdictTokenError) as exc:
|
|
190
|
-
verify_verdict_token(
|
|
216
|
+
verify_verdict_token(
|
|
217
|
+
tampered, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
|
|
218
|
+
)
|
|
191
219
|
assert exc.value.code == "invalid_signature"
|
|
192
220
|
|
|
193
221
|
|
|
@@ -198,7 +226,9 @@ def test_rejects_wrong_algorithm(keys: dict) -> None:
|
|
|
198
226
|
wrong = f"{wrong_header}.{payload}.{signature}"
|
|
199
227
|
|
|
200
228
|
with pytest.raises(VerdictTokenError) as exc:
|
|
201
|
-
verify_verdict_token(
|
|
229
|
+
verify_verdict_token(
|
|
230
|
+
wrong, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
|
|
231
|
+
)
|
|
202
232
|
assert exc.value.code == "wrong_algorithm"
|
|
203
233
|
|
|
204
234
|
|
|
@@ -209,13 +239,17 @@ def test_rejects_missing_jwt_typ(keys: dict) -> None:
|
|
|
209
239
|
wrong = f"{wrong_header}.{payload}.{signature}"
|
|
210
240
|
|
|
211
241
|
with pytest.raises(VerdictTokenError) as exc:
|
|
212
|
-
verify_verdict_token(
|
|
242
|
+
verify_verdict_token(
|
|
243
|
+
wrong, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
|
|
244
|
+
)
|
|
213
245
|
assert exc.value.code == "wrong_type"
|
|
214
246
|
|
|
215
247
|
|
|
216
248
|
def test_rejects_malformed_token() -> None:
|
|
217
249
|
with pytest.raises(VerdictTokenError) as exc:
|
|
218
|
-
verify_verdict_token(
|
|
250
|
+
verify_verdict_token(
|
|
251
|
+
"not-a-jwt", session="sess_token_a", tenant_id="org_unit", jwks={"keys": []}
|
|
252
|
+
)
|
|
219
253
|
assert exc.value.code == "malformed_token"
|
|
220
254
|
|
|
221
255
|
|
|
@@ -226,7 +260,7 @@ def test_fetches_jwks_from_url(keys: dict) -> None:
|
|
|
226
260
|
return_value=httpx.Response(200, json=_jwks(keys)),
|
|
227
261
|
)
|
|
228
262
|
|
|
229
|
-
claims = verify_verdict_token(token, session="sess_fetch")
|
|
263
|
+
claims = verify_verdict_token(token, session="sess_fetch", tenant_id="org_unit")
|
|
230
264
|
|
|
231
265
|
assert route.called
|
|
232
266
|
assert claims["sub"] == "sess_fetch"
|
|
@@ -240,14 +274,16 @@ def test_jwks_fetch_failure_is_surfaced(keys: dict) -> None:
|
|
|
240
274
|
)
|
|
241
275
|
|
|
242
276
|
with pytest.raises(VerdictTokenError) as exc:
|
|
243
|
-
verify_verdict_token(token)
|
|
277
|
+
verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit")
|
|
244
278
|
assert exc.value.code == "jwks_unavailable"
|
|
245
279
|
|
|
246
280
|
|
|
247
281
|
def test_accepts_degraded_receipt(keys: dict) -> None:
|
|
248
282
|
token = _mint(keys, decision="challenge", degraded=True)
|
|
249
283
|
|
|
250
|
-
claims = verify_verdict_token(
|
|
284
|
+
claims = verify_verdict_token(
|
|
285
|
+
token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
|
|
286
|
+
)
|
|
251
287
|
|
|
252
288
|
assert claims["decision"] == "challenge"
|
|
253
289
|
assert claims["degraded"] is True
|
|
@@ -89,6 +89,7 @@ def _mint(keys: dict, **overrides: object) -> str:
|
|
|
89
89
|
claims = {
|
|
90
90
|
"iss": "https://api.chitmark.com",
|
|
91
91
|
"sub": "sess_token_a",
|
|
92
|
+
"tid": "org_unit",
|
|
92
93
|
"aud": "https://api.chitmark.com",
|
|
93
94
|
"iat": now,
|
|
94
95
|
"exp": now + 300,
|
|
@@ -125,38 +126,50 @@ def _jwks(keys: dict) -> dict:
|
|
|
125
126
|
def test_rejects_missing_or_empty_kid(keys: dict) -> None:
|
|
126
127
|
token = _mint(keys, _header_remove=["kid"])
|
|
127
128
|
with pytest.raises(VerdictTokenError) as excinfo:
|
|
128
|
-
verify_verdict_token(
|
|
129
|
+
verify_verdict_token(
|
|
130
|
+
token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
|
|
131
|
+
)
|
|
129
132
|
assert excinfo.value.code == "unknown_kid"
|
|
130
133
|
|
|
131
134
|
token2 = _mint(keys, _header={"kid": ""})
|
|
132
135
|
with pytest.raises(VerdictTokenError) as excinfo2:
|
|
133
|
-
verify_verdict_token(
|
|
136
|
+
verify_verdict_token(
|
|
137
|
+
token2, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
|
|
138
|
+
)
|
|
134
139
|
assert excinfo2.value.code == "unknown_kid"
|
|
135
140
|
|
|
136
141
|
token3 = _mint(keys, _header={"kid": 123})
|
|
137
142
|
with pytest.raises(VerdictTokenError) as excinfo3:
|
|
138
|
-
verify_verdict_token(
|
|
143
|
+
verify_verdict_token(
|
|
144
|
+
token3, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
|
|
145
|
+
)
|
|
139
146
|
assert excinfo3.value.code == "malformed_token"
|
|
140
147
|
|
|
141
148
|
|
|
142
149
|
def test_rejects_future_iat(keys: dict) -> None:
|
|
143
150
|
token = _mint(keys, iat=int(time.time()) + 3600)
|
|
144
151
|
with pytest.raises(VerdictTokenError) as excinfo:
|
|
145
|
-
verify_verdict_token(
|
|
152
|
+
verify_verdict_token(
|
|
153
|
+
token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
|
|
154
|
+
)
|
|
146
155
|
assert excinfo.value.code == "not_yet_valid"
|
|
147
156
|
|
|
148
157
|
|
|
149
158
|
def test_rejects_undecodable_payload(keys: dict) -> None:
|
|
150
159
|
token = _mint(keys, _raw_payload=b"hello")
|
|
151
160
|
with pytest.raises(VerdictTokenError) as excinfo:
|
|
152
|
-
verify_verdict_token(
|
|
161
|
+
verify_verdict_token(
|
|
162
|
+
token, session="sess_token_a", tenant_id="org_unit", jwks=_jwks(keys)
|
|
163
|
+
)
|
|
153
164
|
assert excinfo.value.code == "malformed_token"
|
|
154
165
|
|
|
155
166
|
|
|
156
167
|
def test_rejects_malformed_header() -> None:
|
|
157
168
|
token = "a.b.c"
|
|
158
169
|
with pytest.raises(VerdictTokenError) as excinfo:
|
|
159
|
-
verify_verdict_token(
|
|
170
|
+
verify_verdict_token(
|
|
171
|
+
token, session="sess_token_a", tenant_id="org_unit", jwks={"keys": []}
|
|
172
|
+
)
|
|
160
173
|
assert excinfo.value.code == "malformed_token"
|
|
161
174
|
|
|
162
175
|
|
|
@@ -170,7 +183,9 @@ def test_rejects_jwk_with_bad_curve_data(keys: dict) -> None:
|
|
|
170
183
|
}
|
|
171
184
|
token = _mint(keys)
|
|
172
185
|
with pytest.raises(VerdictTokenError) as excinfo:
|
|
173
|
-
verify_verdict_token(
|
|
186
|
+
verify_verdict_token(
|
|
187
|
+
token, session="sess_token_a", tenant_id="org_unit", jwks={"keys": [bad]}
|
|
188
|
+
)
|
|
174
189
|
assert excinfo.value.code == "invalid_signature"
|
|
175
190
|
|
|
176
191
|
|
|
@@ -181,7 +196,7 @@ def test_jwks_fetch_network_error(keys: dict) -> None:
|
|
|
181
196
|
)
|
|
182
197
|
token = _mint(keys)
|
|
183
198
|
with pytest.raises(VerdictTokenError) as excinfo:
|
|
184
|
-
verify_verdict_token(token)
|
|
199
|
+
verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit")
|
|
185
200
|
assert excinfo.value.code == "jwks_unavailable"
|
|
186
201
|
|
|
187
202
|
|
|
@@ -192,7 +207,7 @@ def test_jwks_fetch_non_200(keys: dict) -> None:
|
|
|
192
207
|
)
|
|
193
208
|
token = _mint(keys)
|
|
194
209
|
with pytest.raises(VerdictTokenError) as excinfo:
|
|
195
|
-
verify_verdict_token(token)
|
|
210
|
+
verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit")
|
|
196
211
|
assert excinfo.value.code == "jwks_unavailable"
|
|
197
212
|
|
|
198
213
|
|
|
@@ -203,7 +218,7 @@ def test_jwks_fetch_invalid_json(keys: dict) -> None:
|
|
|
203
218
|
)
|
|
204
219
|
token = _mint(keys)
|
|
205
220
|
with pytest.raises(VerdictTokenError) as excinfo:
|
|
206
|
-
verify_verdict_token(token)
|
|
221
|
+
verify_verdict_token(token, session="sess_token_a", tenant_id="org_unit")
|
|
207
222
|
assert excinfo.value.code == "jwks_unavailable"
|
|
208
223
|
|
|
209
224
|
|
|
@@ -214,7 +229,9 @@ def test_jwks_fetch_with_reusable_client(keys: dict) -> None:
|
|
|
214
229
|
)
|
|
215
230
|
client = httpx.Client()
|
|
216
231
|
try:
|
|
217
|
-
claims = verify_verdict_token(
|
|
232
|
+
claims = verify_verdict_token(
|
|
233
|
+
_mint(keys), session="sess_token_a", tenant_id="org_unit", http_client=client
|
|
234
|
+
)
|
|
218
235
|
assert claims["jti"] == "jwt_1"
|
|
219
236
|
assert client.is_closed is False
|
|
220
237
|
finally:
|
|
@@ -224,11 +241,18 @@ def test_jwks_fetch_with_reusable_client(keys: dict) -> None:
|
|
|
224
241
|
def test_jwks_mapping_and_keys_list_shapes(keys: dict) -> None:
|
|
225
242
|
token = _mint(keys)
|
|
226
243
|
with pytest.raises(VerdictTokenError) as excinfo:
|
|
227
|
-
verify_verdict_token(
|
|
244
|
+
verify_verdict_token(
|
|
245
|
+
token,
|
|
246
|
+
session="sess_token_a",
|
|
247
|
+
tenant_id="org_unit",
|
|
248
|
+
jwks=["not", "a", "mapping"],
|
|
249
|
+
)
|
|
228
250
|
assert excinfo.value.code == "jwks_unavailable"
|
|
229
251
|
|
|
230
252
|
with pytest.raises(VerdictTokenError) as excinfo2:
|
|
231
|
-
verify_verdict_token(
|
|
253
|
+
verify_verdict_token(
|
|
254
|
+
token, session="sess_token_a", tenant_id="org_unit", jwks={"nokeys": True}
|
|
255
|
+
)
|
|
232
256
|
assert excinfo2.value.code == "jwks_unavailable"
|
|
233
257
|
|
|
234
258
|
|
|
@@ -236,6 +260,7 @@ def test_accepts_valid_token_with_explicit_audience(keys: dict) -> None:
|
|
|
236
260
|
claims = verify_verdict_token(
|
|
237
261
|
_mint(keys),
|
|
238
262
|
session="sess_token_a",
|
|
263
|
+
tenant_id="org_unit",
|
|
239
264
|
aud="https://api.chitmark.com",
|
|
240
265
|
jwks=_jwks(keys),
|
|
241
266
|
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|