q-runtime-core 0.9.0__py3-none-any.whl

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.
qruntime/policy.py ADDED
@@ -0,0 +1,248 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from urllib.parse import urlsplit
5
+
6
+
7
+ @dataclass(frozen=True, slots=True)
8
+ class PolicyViolation:
9
+ code: str
10
+ message: str
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class PolicyDecision:
15
+ allowed: bool
16
+ violations: tuple[PolicyViolation, ...] = ()
17
+
18
+ @property
19
+ def reason_codes(self) -> tuple[str, ...]:
20
+ return tuple(
21
+ violation.code
22
+ for violation in self.violations
23
+ )
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class Policy:
28
+ max_amount_atomic: int | None = None
29
+ allowed_origins: tuple[str, ...] | None = None
30
+ allowed_assets: tuple[str, ...] | None = None
31
+
32
+ def __post_init__(self) -> None:
33
+ if self.max_amount_atomic is not None:
34
+ if (
35
+ isinstance(self.max_amount_atomic, bool)
36
+ or not isinstance(
37
+ self.max_amount_atomic,
38
+ int,
39
+ )
40
+ or self.max_amount_atomic <= 0
41
+ ):
42
+ raise ValueError(
43
+ "max_amount_atomic must be "
44
+ "a positive integer or None"
45
+ )
46
+
47
+ if self.allowed_origins is not None:
48
+ normalized_origins = tuple(
49
+ dict.fromkeys(
50
+ _normalize_origin(origin)
51
+ for origin in self.allowed_origins
52
+ )
53
+ )
54
+
55
+ object.__setattr__(
56
+ self,
57
+ "allowed_origins",
58
+ normalized_origins,
59
+ )
60
+
61
+ if self.allowed_assets is not None:
62
+ assets: list[str] = []
63
+
64
+ for asset in self.allowed_assets:
65
+ if not isinstance(asset, str):
66
+ raise ValueError(
67
+ "allowed_assets entries "
68
+ "must be strings"
69
+ )
70
+
71
+ value = asset.strip()
72
+
73
+ if not value:
74
+ raise ValueError(
75
+ "allowed_assets entries "
76
+ "must not be empty"
77
+ )
78
+
79
+ if value not in assets:
80
+ assets.append(value)
81
+
82
+ object.__setattr__(
83
+ self,
84
+ "allowed_assets",
85
+ tuple(assets),
86
+ )
87
+
88
+
89
+ def _normalize_origin(value: str) -> str:
90
+ if not isinstance(value, str):
91
+ raise ValueError(
92
+ "origin must be a string"
93
+ )
94
+
95
+ candidate = value.strip()
96
+
97
+ if not candidate:
98
+ raise ValueError(
99
+ "origin must not be empty"
100
+ )
101
+
102
+ parsed = urlsplit(candidate)
103
+
104
+ scheme = parsed.scheme.lower()
105
+
106
+ if scheme not in {"http", "https"}:
107
+ raise ValueError(
108
+ "origin must use http or https"
109
+ )
110
+
111
+ if parsed.hostname is None:
112
+ raise ValueError(
113
+ "origin must include a hostname"
114
+ )
115
+
116
+ if (
117
+ parsed.username is not None
118
+ or parsed.password is not None
119
+ ):
120
+ raise ValueError(
121
+ "origin must not contain credentials"
122
+ )
123
+
124
+ hostname = parsed.hostname.lower()
125
+
126
+ if ":" in hostname:
127
+ hostname = f"[{hostname}]"
128
+
129
+ try:
130
+ port = parsed.port
131
+ except ValueError as exc:
132
+ raise ValueError(
133
+ "origin contains an invalid port"
134
+ ) from exc
135
+
136
+ if (
137
+ port is None
138
+ or (scheme == "http" and port == 80)
139
+ or (scheme == "https" and port == 443)
140
+ ):
141
+ return f"{scheme}://{hostname}"
142
+
143
+ return f"{scheme}://{hostname}:{port}"
144
+
145
+
146
+ def evaluate_policy(
147
+ policy: Policy,
148
+ *,
149
+ amount_atomic: int,
150
+ origin: str | None = None,
151
+ asset: str | None = None,
152
+ ) -> PolicyDecision:
153
+ if (
154
+ isinstance(amount_atomic, bool)
155
+ or not isinstance(amount_atomic, int)
156
+ or amount_atomic <= 0
157
+ ):
158
+ raise ValueError(
159
+ "amount_atomic must be a positive integer"
160
+ )
161
+
162
+ violations: list[PolicyViolation] = []
163
+
164
+ if (
165
+ policy.max_amount_atomic is not None
166
+ and amount_atomic > policy.max_amount_atomic
167
+ ):
168
+ violations.append(
169
+ PolicyViolation(
170
+ code="amount_exceeds_max",
171
+ message=(
172
+ f"requested amount {amount_atomic} "
173
+ "exceeds policy maximum "
174
+ f"{policy.max_amount_atomic}"
175
+ ),
176
+ )
177
+ )
178
+
179
+ if policy.allowed_origins is not None:
180
+ if origin is None:
181
+ violations.append(
182
+ PolicyViolation(
183
+ code="origin_missing",
184
+ message=(
185
+ "origin is required by policy"
186
+ ),
187
+ )
188
+ )
189
+ else:
190
+ normalized_origin = _normalize_origin(
191
+ origin
192
+ )
193
+
194
+ if (
195
+ normalized_origin
196
+ not in policy.allowed_origins
197
+ ):
198
+ violations.append(
199
+ PolicyViolation(
200
+ code="origin_not_allowed",
201
+ message=(
202
+ f"origin {normalized_origin} "
203
+ "is not allowed by policy"
204
+ ),
205
+ )
206
+ )
207
+
208
+ if policy.allowed_assets is not None:
209
+ if asset is None:
210
+ violations.append(
211
+ PolicyViolation(
212
+ code="asset_missing",
213
+ message=(
214
+ "asset is required by policy"
215
+ ),
216
+ )
217
+ )
218
+ else:
219
+ if not isinstance(asset, str):
220
+ raise ValueError(
221
+ "asset must be a string"
222
+ )
223
+
224
+ candidate_asset = asset.strip()
225
+
226
+ if not candidate_asset:
227
+ raise ValueError(
228
+ "asset must not be empty"
229
+ )
230
+
231
+ if (
232
+ candidate_asset
233
+ not in policy.allowed_assets
234
+ ):
235
+ violations.append(
236
+ PolicyViolation(
237
+ code="asset_not_allowed",
238
+ message=(
239
+ f"asset {candidate_asset} "
240
+ "is not allowed by policy"
241
+ ),
242
+ )
243
+ )
244
+
245
+ return PolicyDecision(
246
+ allowed=not violations,
247
+ violations=tuple(violations),
248
+ )
File without changes
@@ -0,0 +1,4 @@
1
+ """Placeholder rail for v0.1.
2
+
3
+ The verifier operates on economic state before any real payment integration exists.
4
+ """
qruntime/rails/x402.py ADDED
@@ -0,0 +1,284 @@
1
+ """Offline x402 v2 decoder and economic normalizer for Q Runtime v0.6.
2
+
3
+ This module deliberately does not sign, verify, settle, or broadcast payments.
4
+ It only parses x402 wire objects and maps them into deterministic economic facts
5
+ that can be checked before a real rail is allowed to execute.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, asdict
10
+ import base64
11
+ import hashlib
12
+ import json
13
+ from typing import Any, Mapping
14
+
15
+
16
+ class X402DecodeError(ValueError):
17
+ pass
18
+
19
+
20
+ def _b64decode_json(value: str) -> dict[str, Any]:
21
+ raw = value.strip()
22
+ # Accept ordinary JSON too; fixtures and logs often store the decoded object.
23
+ if raw.startswith("{"):
24
+ obj = json.loads(raw)
25
+ if not isinstance(obj, dict):
26
+ raise X402DecodeError("x402 object must be a JSON object")
27
+ return obj
28
+
29
+ # x402 HTTP v2 uses Base64-encoded JSON headers. Accept URL-safe alphabet as
30
+ # a pragmatic log-decoding convenience while preserving strict JSON parsing.
31
+ padded = raw + "=" * (-len(raw) % 4)
32
+ try:
33
+ decoded = base64.b64decode(padded, altchars=b"-_", validate=False)
34
+ obj = json.loads(decoded.decode("utf-8"))
35
+ except Exception as exc: # noqa: BLE001 - convert all wire failures to one public error
36
+ raise X402DecodeError(f"invalid base64 JSON x402 header: {exc}") from exc
37
+ if not isinstance(obj, dict):
38
+ raise X402DecodeError("decoded x402 header must contain a JSON object")
39
+ return obj
40
+
41
+
42
+ def _object(value: str | Mapping[str, Any]) -> dict[str, Any]:
43
+ if isinstance(value, str):
44
+ return _b64decode_json(value)
45
+ if isinstance(value, Mapping):
46
+ return dict(value)
47
+ raise X402DecodeError(f"unsupported x402 input type: {type(value).__name__}")
48
+
49
+
50
+ def encode_header(obj: Mapping[str, Any]) -> str:
51
+ """Encode a decoded x402 v2 object exactly as an HTTP payment header payload."""
52
+ raw = json.dumps(dict(obj), separators=(",", ":"), sort_keys=True).encode("utf-8")
53
+ return base64.b64encode(raw).decode("ascii")
54
+
55
+
56
+ def _required_string(obj: Mapping[str, Any], key: str) -> str:
57
+ value = obj.get(key)
58
+ if not isinstance(value, str) or not value:
59
+ raise X402DecodeError(f"missing/invalid required field: {key}")
60
+ return value
61
+
62
+
63
+ def _required_int(obj: Mapping[str, Any], key: str) -> int:
64
+ value = obj.get(key)
65
+ if isinstance(value, bool):
66
+ raise X402DecodeError(f"missing/invalid required integer field: {key}")
67
+ try:
68
+ return int(value)
69
+ except (TypeError, ValueError) as exc:
70
+ raise X402DecodeError(f"missing/invalid required integer field: {key}") from exc
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class X402Requirement:
75
+ scheme: str
76
+ network: str
77
+ amount_atomic: int
78
+ asset: str
79
+ pay_to: str
80
+ max_timeout_seconds: int
81
+ extra_json: str = "{}"
82
+
83
+ @staticmethod
84
+ def from_dict(raw: Mapping[str, Any]) -> "X402Requirement":
85
+ amount = _required_int(raw, "amount")
86
+ timeout = _required_int(raw, "maxTimeoutSeconds")
87
+ if amount < 0:
88
+ raise X402DecodeError("amount must be non-negative")
89
+ if timeout < 0:
90
+ raise X402DecodeError("maxTimeoutSeconds must be non-negative")
91
+ extra = raw.get("extra", {})
92
+ if not isinstance(extra, Mapping):
93
+ raise X402DecodeError("extra must be an object when present")
94
+ return X402Requirement(
95
+ scheme=_required_string(raw, "scheme"),
96
+ network=_required_string(raw, "network"),
97
+ amount_atomic=amount,
98
+ asset=_required_string(raw, "asset"),
99
+ pay_to=_required_string(raw, "payTo"),
100
+ max_timeout_seconds=timeout,
101
+ extra_json=json.dumps(dict(extra), sort_keys=True, separators=(",", ":")),
102
+ )
103
+
104
+ def canonical_dict(self) -> dict[str, Any]:
105
+ return {
106
+ "scheme": self.scheme,
107
+ "network": self.network,
108
+ "amount": str(self.amount_atomic),
109
+ "asset": self.asset,
110
+ "payTo": self.pay_to,
111
+ "maxTimeoutSeconds": self.max_timeout_seconds,
112
+ "extra": json.loads(self.extra_json),
113
+ }
114
+
115
+ def fingerprint(self) -> str:
116
+ raw = json.dumps(self.canonical_dict(), sort_keys=True, separators=(",", ":")).encode()
117
+ return hashlib.sha256(raw).hexdigest()
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class X402PaymentRequired:
122
+ version: int
123
+ resource_url: str
124
+ resource_description: str
125
+ accepts: tuple[X402Requirement, ...]
126
+ error: str = ""
127
+
128
+ def selected(self, index: int = 0) -> X402Requirement:
129
+ try:
130
+ return self.accepts[index]
131
+ except IndexError as exc:
132
+ raise X402DecodeError(f"accepts index out of range: {index}") from exc
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class X402PaymentPayload:
137
+ version: int
138
+ resource_url: str
139
+ accepted: X402Requirement
140
+ payload_json: str
141
+
142
+ def payload_fingerprint(self) -> str:
143
+ raw = json.dumps(
144
+ {
145
+ "version": self.version,
146
+ "resource_url": self.resource_url,
147
+ "accepted": self.accepted.canonical_dict(),
148
+ "payload": json.loads(self.payload_json),
149
+ },
150
+ sort_keys=True,
151
+ separators=(",", ":"),
152
+ ).encode()
153
+ return hashlib.sha256(raw).hexdigest()
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class X402SettlementResponse:
158
+ success: bool
159
+ transaction: str
160
+ network: str
161
+ error_reason: str = ""
162
+ payer: str = ""
163
+ amount_atomic: int | None = None
164
+
165
+ @property
166
+ def reconciliation_required(self) -> bool:
167
+ return (not self.success) and self.error_reason == "settlement_pending"
168
+
169
+
170
+ @dataclass(frozen=True)
171
+ class QPaymentRequiredEvent:
172
+ kind: str
173
+ resource_url: str
174
+ quote_fingerprint: str
175
+ scheme: str
176
+ network: str
177
+ asset: str
178
+ amount_atomic: int
179
+ pay_to: str
180
+ max_timeout_seconds: int
181
+
182
+
183
+ def parse_payment_required(value: str | Mapping[str, Any]) -> X402PaymentRequired:
184
+ raw = _object(value)
185
+ version = _required_int(raw, "x402Version")
186
+ if version != 2:
187
+ raise X402DecodeError(f"unsupported x402Version={version}; v0.6 accepts x402 v2 only")
188
+ resource = raw.get("resource")
189
+ if not isinstance(resource, Mapping):
190
+ raise X402DecodeError("resource must be an object")
191
+ resource_url = _required_string(resource, "url")
192
+ accepts_raw = raw.get("accepts")
193
+ if not isinstance(accepts_raw, list) or not accepts_raw:
194
+ raise X402DecodeError("accepts must be a non-empty array")
195
+ accepts = tuple(X402Requirement.from_dict(item) for item in accepts_raw if isinstance(item, Mapping))
196
+ if len(accepts) != len(accepts_raw):
197
+ raise X402DecodeError("each accepts entry must be an object")
198
+ return X402PaymentRequired(
199
+ version=version,
200
+ resource_url=resource_url,
201
+ resource_description=str(resource.get("description", "")),
202
+ accepts=accepts,
203
+ error=str(raw.get("error", "")),
204
+ )
205
+
206
+
207
+ def parse_payment_payload(value: str | Mapping[str, Any]) -> X402PaymentPayload:
208
+ raw = _object(value)
209
+ version = _required_int(raw, "x402Version")
210
+ if version != 2:
211
+ raise X402DecodeError(f"unsupported x402Version={version}; v0.6 accepts x402 v2 only")
212
+ accepted_raw = raw.get("accepted")
213
+ if not isinstance(accepted_raw, Mapping):
214
+ raise X402DecodeError("accepted must be an object")
215
+ payload = raw.get("payload")
216
+ if not isinstance(payload, Mapping):
217
+ raise X402DecodeError("payload must be an object")
218
+ resource = raw.get("resource") or {}
219
+ if not isinstance(resource, Mapping):
220
+ raise X402DecodeError("resource must be an object when present")
221
+ return X402PaymentPayload(
222
+ version=version,
223
+ resource_url=str(resource.get("url", "")),
224
+ accepted=X402Requirement.from_dict(accepted_raw),
225
+ payload_json=json.dumps(dict(payload), sort_keys=True, separators=(",", ":")),
226
+ )
227
+
228
+
229
+ def parse_settlement_response(value: str | Mapping[str, Any]) -> X402SettlementResponse:
230
+ raw = _object(value)
231
+ if "success" not in raw or not isinstance(raw["success"], bool):
232
+ raise X402DecodeError("settlement response requires boolean success")
233
+ transaction = str(raw.get("transaction", ""))
234
+ network = _required_string(raw, "network")
235
+ error_reason = str(raw.get("errorReason", ""))
236
+ if error_reason == "settlement_pending" and not transaction:
237
+ raise X402DecodeError("settlement_pending requires a non-empty transaction hash")
238
+ amount = raw.get("amount")
239
+ amount_atomic = None if amount is None else _required_int(raw, "amount")
240
+ return X402SettlementResponse(
241
+ success=raw["success"],
242
+ transaction=transaction,
243
+ network=network,
244
+ error_reason=error_reason,
245
+ payer=str(raw.get("payer", "")),
246
+ amount_atomic=amount_atomic,
247
+ )
248
+
249
+
250
+ def normalize_payment_required(value: str | Mapping[str, Any], accept_index: int = 0) -> QPaymentRequiredEvent:
251
+ parsed = parse_payment_required(value)
252
+ req = parsed.selected(accept_index)
253
+ return QPaymentRequiredEvent(
254
+ kind="PAYMENT_REQUIRED",
255
+ resource_url=parsed.resource_url,
256
+ quote_fingerprint=req.fingerprint(),
257
+ scheme=req.scheme,
258
+ network=req.network,
259
+ asset=req.asset,
260
+ amount_atomic=req.amount_atomic,
261
+ pay_to=req.pay_to,
262
+ max_timeout_seconds=req.max_timeout_seconds,
263
+ )
264
+
265
+
266
+ def requirement_equal(a: X402Requirement, b: X402Requirement) -> bool:
267
+ return a.canonical_dict() == b.canonical_dict()
268
+
269
+
270
+ def requirement_diff(old: X402Requirement, new: X402Requirement) -> dict[str, tuple[Any, Any]]:
271
+ a = old.canonical_dict()
272
+ b = new.canonical_dict()
273
+ return {key: (a.get(key), b.get(key)) for key in sorted(set(a) | set(b)) if a.get(key) != b.get(key)}
274
+
275
+
276
+ def as_jsonable(obj: Any) -> Any:
277
+ if hasattr(obj, "__dataclass_fields__"):
278
+ data = asdict(obj)
279
+ if isinstance(obj, X402Requirement):
280
+ data["extra"] = json.loads(data.pop("extra_json"))
281
+ if isinstance(obj, X402PaymentPayload):
282
+ data["payload"] = json.loads(data.pop("payload_json"))
283
+ return data
284
+ return obj