blun-king-cli 9.1.597 → 9.1.600
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.
- package/CHANGELOG.md +39 -0
- package/README.md +17 -1
- package/agent-spine-plugin/CHANGELOG.md +1 -1
- package/agent-spine-plugin/docs/host-integration.md +1 -15
- package/agent-spine-plugin/docs/preflight-recall.md +1 -1
- package/agent-spine-plugin/scripts/check-install.js +1 -6
- package/agent-spine-plugin/scripts/hermetic-process.js +8 -0
- package/agent-spine-plugin/scripts/release-check.js +2 -3
- package/agent-spine-plugin/scripts/run-tests-hermetic.js +8 -11
- package/agent-spine-plugin/src/hook.js +40 -40
- package/agent-spine-plugin/src/lib/briefing.js +6 -3
- package/agent-spine-plugin/src/lib/hook-context.js +63 -64
- package/agent-spine-plugin/src/lib/hook-output.js +115 -94
- package/agent-spine-plugin/src/lib/hook-timeline.js +20 -11
- package/agent-spine-plugin/src/lib/host-instruction-budget.js +8 -14
- package/agent-spine-plugin/src/lib/mcp-timeline-tools.js +0 -7
- package/agent-spine-plugin/src/lib/pre-answer-timeline-recall.js +104 -0
- package/agent-spine-plugin/src/lib/session-timeline-codex.js +0 -4
- package/agent-spine-plugin/src/lib/session-timeline-enrollment-source.js +0 -3
- package/agent-spine-plugin/src/lib/session-timeline-enrollment-storage.js +0 -4
- package/agent-spine-plugin/src/lib/session-timeline-enrollment-transport.js +0 -2
- package/agent-spine-plugin/src/lib/session-timeline-enrollment.js +0 -22
- package/agent-spine-plugin/src/lib/session-timeline-host-origin.js +0 -4
- package/agent-spine-plugin/src/lib/session-timeline-prior.js +42 -50
- package/agent-spine-plugin/src/lib/session-timeline-provider.js +0 -3
- package/agent-spine-plugin/src/lib/session-timeline-query.js +0 -1
- package/agent-spine-plugin/src/lib/session-timeline-sid-acl.js +0 -4
- package/agent-spine-plugin/src/lib/session-timeline-source.js +0 -3
- package/agent-spine-plugin/src/lib/session-timeline-transport.js +0 -8
- package/agent-spine-plugin/src/lib/session-timeline-windows-acl.js +0 -3
- package/agent-spine-plugin/src/lib/session-timeline.js +370 -408
- package/agent-spine-plugin/src/lib/source-roots.js +26 -16
- package/agent-spine-plugin/src/lib/timeline-user-feedback.js +0 -1
- package/bin/core-bootstrap.js +18 -10
- package/bin/launcher-mode.js +5 -0
- package/bin/launcher-runtime.js +27 -0
- package/bin/verify-bundled-agent-sources.cjs +28 -2
- package/blun.mjs +798 -143
- package/bundled-agent-sources.json +192 -155
- package/package.json +1 -1
- package/standard-skills/translate-native/README.md +1334 -1
- package/standard-skills/translate-native/SKILL.md +1 -1
- package/standard-skills/translate-native/VERSION +1 -1
- package/standard-skills/translate-native/docs/PREMORTEM.md +901 -0
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION.md +208 -41
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_API.md +1319 -77
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_EVIDENCE_HTTP.md +66 -11
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_HTTP_PROVIDER.md +19 -1
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_RECEIPT_VERIFIER_HTTP.md +24 -6
- package/standard-skills/translate-native/integrations/commercial_localization_profile.py +23 -0
- package/standard-skills/translate-native/integrations/website_localization.py +234 -10
- package/standard-skills/translate-native/integrations/website_localization_benchmark.py +39 -3
- package/standard-skills/translate-native/integrations/website_localization_cms.py +512 -10
- package/standard-skills/translate-native/integrations/website_localization_cms_client.py +116 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_receiver.py +101 -11
- package/standard-skills/translate-native/integrations/website_localization_cms_receiver_runtime.py +12 -1
- package/standard-skills/translate-native/integrations/website_localization_cms_receiver_store.py +186 -24
- package/standard-skills/translate-native/integrations/website_localization_cms_source_client.py +64 -1
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery.py +528 -5
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_auth.py +918 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_auth_runtime.py +609 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_client.py +885 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_http.py +1244 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_runtime.py +220 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_sidecar_adapter.py +331 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_capabilities_http.py +444 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_client.py +673 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch.py +1317 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_client.py +826 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_http.py +1152 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_openapi.py +896 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_runtime.py +621 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_http.py +1180 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_runtime.py +1479 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_http.py +133 -17
- package/standard-skills/translate-native/integrations/website_localization_cms_source_runtime.py +319 -4
- package/standard-skills/translate-native/integrations/website_localization_cms_source_service.py +72 -2
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_http.py +13 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver.py +56 -13
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver_openapi.py +335 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver_runtime.py +66 -1
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_processing_monitor.py +73 -7
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_receiver_client.py +93 -9
- package/standard-skills/translate-native/integrations/website_localization_evidence_http.py +221 -11
- package/standard-skills/translate-native/integrations/website_localization_health.py +6 -0
- package/standard-skills/translate-native/integrations/website_localization_http_provider.py +1 -1
- package/standard-skills/translate-native/integrations/website_localization_quality_profiles.py +350 -0
- package/standard-skills/translate-native/integrations/website_localization_queue.py +126 -56
- package/standard-skills/translate-native/integrations/website_localization_receipt_verifier_http.py +166 -13
- package/standard-skills/translate-native/integrations/website_localization_release.py +423 -17
- package/standard-skills/translate-native/integrations/website_localization_release_coordinator.py +168 -13
- package/standard-skills/translate-native/integrations/website_localization_runner.py +10 -0
- package/standard-skills/translate-native/integrations/website_localization_worker.py +213 -9
- package/standard-skills/translate-native/provenance.json +2 -2
- package/standard-skills/translate-native/references/commercial-localization.md +96 -17
- package/standard-skills/translate-native/scripts/blun_language_guard.py +3 -1
- package/standard-skills/translate-native/scripts/check_commercial_review.py +69 -9
- package/standard-skills/translate-native/scripts/commercial_localization_profile.py +829 -31
- package/standard-tools/language-guard/VERSION +1 -1
- package/standard-tools/language-guard/blun_language_guard.py +3 -1
- package/standard-tools/language-guard/check_commercial_review.py +69 -9
- package/standard-tools/language-guard/commercial_localization_profile.py +829 -31
- package/standard-tools/language-guard/provenance.json +2 -2
- package/standard-tools/manifest.json +1 -1
- package/worker-host.mjs +215 -82
- package/agent-spine-plugin/scripts/check-install-king.js +0 -37
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Contract-pinned HTTPS client for the durable CMS submission sidecar.
|
|
3
|
+
|
|
4
|
+
The adapter lets non-Python CMS backends enqueue and inspect caller-owned
|
|
5
|
+
submission records without sharing a process or database. It discovers the
|
|
6
|
+
exact sidecar contract before every operation, performs one transport attempt
|
|
7
|
+
per request, and binds every content-free response to both the sidecar and the
|
|
8
|
+
downstream public-submission capability generations. Retry scheduling remains
|
|
9
|
+
the caller's responsibility.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import importlib.util
|
|
16
|
+
import json
|
|
17
|
+
import math
|
|
18
|
+
import re
|
|
19
|
+
import socket
|
|
20
|
+
import sys
|
|
21
|
+
import unicodedata
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.parse
|
|
24
|
+
import urllib.request
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Callable, Mapping, Protocol
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
MAX_ENDPOINT_LENGTH = 2_048
|
|
31
|
+
MAX_HEADER_VALUE_LENGTH = 4_096
|
|
32
|
+
MAX_AUTHENTICATION_HEADERS = 58
|
|
33
|
+
MAX_RESPONSE_BYTES = 4_000_000
|
|
34
|
+
SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
|
35
|
+
TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$")
|
|
36
|
+
ERROR_CODE = re.compile(r"^[a-z][a-z0-9_.-]{0,127}$")
|
|
37
|
+
AUTH_CONTEXT_SCHEMA = "blun.cms-public-submission-dispatch-client-auth-context.v4"
|
|
38
|
+
RESERVED_HEADERS = {
|
|
39
|
+
"accept", "connection", "content-length", "content-type", "host",
|
|
40
|
+
"idempotency-key", "transfer-encoding",
|
|
41
|
+
"x-localization-source-payload-sha256",
|
|
42
|
+
"x-localization-capabilities-sha256",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _load_module(name: str, path: Path):
|
|
47
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
48
|
+
if spec is None or spec.loader is None:
|
|
49
|
+
raise RuntimeError(f"cannot load submission sidecar client dependency: {path.name}")
|
|
50
|
+
module = importlib.util.module_from_spec(spec)
|
|
51
|
+
sys.modules[spec.name] = module
|
|
52
|
+
spec.loader.exec_module(module)
|
|
53
|
+
return module
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_ROOT = Path(__file__).resolve().parents[1]
|
|
57
|
+
_HTTP = _load_module(
|
|
58
|
+
"blun_website_localization_submission_dispatch_client_http",
|
|
59
|
+
_ROOT / "integrations" / "website_localization_cms_source_delivery_submission_dispatch_http.py",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class CMSSourceDeliverySubmissionDispatchClientBlocked(RuntimeError):
|
|
64
|
+
"""Stable content-free client failure with an explicit retry decision."""
|
|
65
|
+
|
|
66
|
+
cms_source_delivery_submission_dispatch_client_failure = True
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self, code: str, *, retryable: bool,
|
|
70
|
+
http_status: int | None = None, remote_error_code: str | None = None,
|
|
71
|
+
):
|
|
72
|
+
remote_valid = (
|
|
73
|
+
remote_error_code is None
|
|
74
|
+
or isinstance(remote_error_code, str)
|
|
75
|
+
and ERROR_CODE.fullmatch(remote_error_code) is not None
|
|
76
|
+
)
|
|
77
|
+
status_valid = (
|
|
78
|
+
http_status is None
|
|
79
|
+
or isinstance(http_status, int) and not isinstance(http_status, bool)
|
|
80
|
+
and 400 <= http_status <= 599
|
|
81
|
+
)
|
|
82
|
+
if (
|
|
83
|
+
ERROR_CODE.fullmatch(code) is None or not isinstance(retryable, bool)
|
|
84
|
+
or not remote_valid or not status_valid
|
|
85
|
+
or (remote_error_code is None) != (http_status is None)
|
|
86
|
+
):
|
|
87
|
+
raise ValueError("submission sidecar client failure is invalid")
|
|
88
|
+
super().__init__(code)
|
|
89
|
+
self.code = code
|
|
90
|
+
self.retryable = retryable
|
|
91
|
+
self.http_status = http_status
|
|
92
|
+
self.remote_error_code = remote_error_code
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class HTTPResult:
|
|
97
|
+
status: int
|
|
98
|
+
headers: tuple[tuple[str, str], ...]
|
|
99
|
+
body: bytes
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class HTTPTransport(Protocol):
|
|
103
|
+
def request(
|
|
104
|
+
self, method: str, url: str, headers: Mapping[str, str],
|
|
105
|
+
body: bytes | None, *, timeout: float,
|
|
106
|
+
) -> HTTPResult: ...
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
110
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class URLTransport:
|
|
115
|
+
"""Perform one bounded HTTPS request without following redirects."""
|
|
116
|
+
|
|
117
|
+
def __init__(self):
|
|
118
|
+
self._opener = urllib.request.build_opener(_NoRedirect)
|
|
119
|
+
|
|
120
|
+
def request(self, method, url, headers, body, *, timeout):
|
|
121
|
+
request = urllib.request.Request(
|
|
122
|
+
url, data=body, headers=dict(headers), method=method,
|
|
123
|
+
)
|
|
124
|
+
try:
|
|
125
|
+
response = self._opener.open(request, timeout=timeout)
|
|
126
|
+
except urllib.error.HTTPError as error:
|
|
127
|
+
response = error
|
|
128
|
+
except (urllib.error.URLError, TimeoutError, socket.timeout, OSError):
|
|
129
|
+
_fail("network", retryable=True)
|
|
130
|
+
try:
|
|
131
|
+
raw = response.read(MAX_RESPONSE_BYTES + 1)
|
|
132
|
+
return HTTPResult(
|
|
133
|
+
int(response.status), tuple(response.headers.items()), raw,
|
|
134
|
+
)
|
|
135
|
+
except (TimeoutError, socket.timeout, OSError):
|
|
136
|
+
_fail("network", retryable=True)
|
|
137
|
+
finally:
|
|
138
|
+
response.close()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _fail(
|
|
142
|
+
code: str, *, retryable: bool = False,
|
|
143
|
+
http_status: int | None = None, remote_error_code: str | None = None,
|
|
144
|
+
) -> None:
|
|
145
|
+
raise CMSSourceDeliverySubmissionDispatchClientBlocked(
|
|
146
|
+
"source_delivery_submission_dispatch_client." + code,
|
|
147
|
+
retryable=retryable,
|
|
148
|
+
http_status=http_status,
|
|
149
|
+
remote_error_code=remote_error_code,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _canonical(value: Any) -> bytes:
|
|
154
|
+
try:
|
|
155
|
+
raw = json.dumps(
|
|
156
|
+
value, ensure_ascii=False, allow_nan=False, sort_keys=True,
|
|
157
|
+
separators=(",", ":"),
|
|
158
|
+
).encode("utf-8")
|
|
159
|
+
except (TypeError, ValueError, RecursionError):
|
|
160
|
+
_fail("request_invalid")
|
|
161
|
+
if not raw or len(raw) > _HTTP.MAX_BODY_BYTES:
|
|
162
|
+
_fail("request_invalid")
|
|
163
|
+
return raw
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _pairs(items: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
167
|
+
result: dict[str, Any] = {}
|
|
168
|
+
for key, value in items:
|
|
169
|
+
if key in result:
|
|
170
|
+
raise ValueError("duplicate key")
|
|
171
|
+
result[key] = value
|
|
172
|
+
return result
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _constant(_value: str) -> None:
|
|
176
|
+
raise ValueError("non-finite number")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _sha256(value: Any) -> bool:
|
|
180
|
+
return isinstance(value, str) and SHA256.fullmatch(value) is not None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _token(value: Any) -> bool:
|
|
184
|
+
return (
|
|
185
|
+
isinstance(value, str) and TOKEN.fullmatch(value) is not None
|
|
186
|
+
and unicodedata.is_normalized("NFC", value)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _count(value: Any, *, minimum: int = 0, maximum: int = 20) -> bool:
|
|
191
|
+
return (
|
|
192
|
+
isinstance(value, int) and not isinstance(value, bool)
|
|
193
|
+
and minimum <= value <= maximum
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _timestamp(value: Any, *, nullable: bool = False) -> bool:
|
|
198
|
+
return (
|
|
199
|
+
nullable and value is None
|
|
200
|
+
or isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
201
|
+
and math.isfinite(float(value)) and float(value) >= 0
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _endpoint(value: Any, allow_loopback_http: bool) -> str:
|
|
206
|
+
if (
|
|
207
|
+
not isinstance(value, str) or value != value.strip() or not value
|
|
208
|
+
or not value.isascii() or len(value) > MAX_ENDPOINT_LENGTH
|
|
209
|
+
or any(ord(character) <= 32 or ord(character) == 127 for character in value)
|
|
210
|
+
):
|
|
211
|
+
raise ValueError("origin is invalid")
|
|
212
|
+
try:
|
|
213
|
+
parsed = urllib.parse.urlsplit(value)
|
|
214
|
+
hostname, port = parsed.hostname, parsed.port
|
|
215
|
+
except ValueError:
|
|
216
|
+
raise ValueError("origin is invalid") from None
|
|
217
|
+
secure = parsed.scheme == "https"
|
|
218
|
+
loopback = (
|
|
219
|
+
parsed.scheme == "http" and allow_loopback_http
|
|
220
|
+
and isinstance(hostname, str)
|
|
221
|
+
and hostname.lower() in {"localhost", "127.0.0.1", "::1"}
|
|
222
|
+
)
|
|
223
|
+
if (
|
|
224
|
+
not (secure or loopback) or not hostname
|
|
225
|
+
or parsed.username is not None or parsed.password is not None
|
|
226
|
+
or parsed.path or parsed.query or parsed.fragment
|
|
227
|
+
or port is not None and not 1 <= port <= 65_535
|
|
228
|
+
or value != f"{parsed.scheme}://{parsed.netloc}"
|
|
229
|
+
):
|
|
230
|
+
raise ValueError("origin must be one exact HTTPS origin")
|
|
231
|
+
return value
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _authentication_headers(provider, context) -> dict[str, str]:
|
|
235
|
+
try:
|
|
236
|
+
supplied = provider(dict(context))
|
|
237
|
+
if not isinstance(supplied, Mapping):
|
|
238
|
+
raise TypeError
|
|
239
|
+
items = tuple(supplied.items())
|
|
240
|
+
except Exception:
|
|
241
|
+
_fail("authentication")
|
|
242
|
+
if not items or len(items) > MAX_AUTHENTICATION_HEADERS:
|
|
243
|
+
_fail("authentication")
|
|
244
|
+
result: dict[str, str] = {}
|
|
245
|
+
names: set[str] = set()
|
|
246
|
+
for name, value in items:
|
|
247
|
+
normalized = name.lower() if isinstance(name, str) else ""
|
|
248
|
+
if (
|
|
249
|
+
not isinstance(name, str) or _HTTP.HEADER_NAME.fullmatch(name) is None
|
|
250
|
+
or normalized in RESERVED_HEADERS or normalized in names
|
|
251
|
+
or not isinstance(value, str) or not value
|
|
252
|
+
or len(value) > MAX_HEADER_VALUE_LENGTH
|
|
253
|
+
or "\r" in value or "\n" in value
|
|
254
|
+
or any(ord(character) < 32 or ord(character) == 127 for character in value)
|
|
255
|
+
):
|
|
256
|
+
_fail("authentication")
|
|
257
|
+
names.add(normalized)
|
|
258
|
+
result[name] = value
|
|
259
|
+
return result
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _remote_error(
|
|
263
|
+
value: Any, status: int, error_codes: Mapping[str, Any],
|
|
264
|
+
) -> None:
|
|
265
|
+
allowed = error_codes.get(str(status))
|
|
266
|
+
code = value.get("error_code") if isinstance(value, Mapping) else None
|
|
267
|
+
if (
|
|
268
|
+
not isinstance(allowed, list) or not allowed
|
|
269
|
+
or not all(isinstance(item, str) for item in allowed)
|
|
270
|
+
or not isinstance(value, Mapping)
|
|
271
|
+
or set(value) != {"schema", "status", "error_code"}
|
|
272
|
+
or value.get("schema") != _HTTP.ERROR_SCHEMA
|
|
273
|
+
or value.get("status") != "BLOCK"
|
|
274
|
+
or not isinstance(code, str) or code not in allowed
|
|
275
|
+
):
|
|
276
|
+
_fail("error_response", retryable=status >= 500)
|
|
277
|
+
_fail(
|
|
278
|
+
"http_status", retryable=status in {408, 425, 429} or status >= 500,
|
|
279
|
+
http_status=status, remote_error_code=code,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _json_response(
|
|
284
|
+
result: Any, allowed_statuses: set[int], error_codes: Mapping[str, Any],
|
|
285
|
+
) -> dict[str, Any]:
|
|
286
|
+
if (
|
|
287
|
+
not isinstance(result, HTTPResult) or isinstance(result.status, bool)
|
|
288
|
+
or not isinstance(result.status, int) or not 100 <= result.status <= 599
|
|
289
|
+
):
|
|
290
|
+
_fail("transport_invalid", retryable=True)
|
|
291
|
+
if 300 <= result.status <= 399:
|
|
292
|
+
_fail("redirect")
|
|
293
|
+
known_error = (
|
|
294
|
+
result.status not in allowed_statuses
|
|
295
|
+
and str(result.status) in error_codes
|
|
296
|
+
)
|
|
297
|
+
if result.status not in allowed_statuses and not known_error:
|
|
298
|
+
_fail(
|
|
299
|
+
"http_status",
|
|
300
|
+
retryable=result.status in {408, 425, 429} or result.status >= 500,
|
|
301
|
+
)
|
|
302
|
+
if not isinstance(result.headers, tuple):
|
|
303
|
+
_fail("transport_invalid", retryable=True)
|
|
304
|
+
headers: dict[str, str] = {}
|
|
305
|
+
for item in result.headers:
|
|
306
|
+
if (
|
|
307
|
+
not isinstance(item, tuple) or len(item) != 2
|
|
308
|
+
or not all(isinstance(part, str) for part in item)
|
|
309
|
+
):
|
|
310
|
+
_fail("transport_invalid", retryable=True)
|
|
311
|
+
name, content = item[0].lower(), item[1].strip()
|
|
312
|
+
if name in headers:
|
|
313
|
+
_fail("response_headers")
|
|
314
|
+
headers[name] = content
|
|
315
|
+
if headers.get("content-type", "").lower().replace(" ", "") not in {
|
|
316
|
+
"application/json", "application/json;charset=utf-8",
|
|
317
|
+
}:
|
|
318
|
+
_fail("response_content_type")
|
|
319
|
+
if (
|
|
320
|
+
not isinstance(result.body, bytes) or not result.body
|
|
321
|
+
or len(result.body) > MAX_RESPONSE_BYTES
|
|
322
|
+
):
|
|
323
|
+
_fail("response_size")
|
|
324
|
+
declared = headers.get("content-length")
|
|
325
|
+
if declared is not None and (
|
|
326
|
+
not declared.isascii() or not declared.isdecimal()
|
|
327
|
+
or int(declared) != len(result.body)
|
|
328
|
+
):
|
|
329
|
+
_fail("response_size")
|
|
330
|
+
try:
|
|
331
|
+
text = result.body.decode("utf-8")
|
|
332
|
+
if text.startswith("\ufeff"):
|
|
333
|
+
raise ValueError
|
|
334
|
+
value = json.loads(text, object_pairs_hook=_pairs, parse_constant=_constant)
|
|
335
|
+
except (UnicodeDecodeError, json.JSONDecodeError, ValueError, RecursionError):
|
|
336
|
+
_fail("response_json")
|
|
337
|
+
if not isinstance(value, dict):
|
|
338
|
+
_fail("response_binding")
|
|
339
|
+
if known_error:
|
|
340
|
+
_remote_error(value, result.status, error_codes)
|
|
341
|
+
return value
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _status(
|
|
345
|
+
value: Any, identity: Mapping[str, str],
|
|
346
|
+
expected_commercial_binding: Mapping[str, str],
|
|
347
|
+
) -> dict[str, Any]:
|
|
348
|
+
fields = {
|
|
349
|
+
"operation", "request_id", "event_id", "site_id", "payload_sha256",
|
|
350
|
+
"commercial_contract_binding",
|
|
351
|
+
"source_max_attempts", "delivery_max_attempts", "status", "attempts",
|
|
352
|
+
"client_max_attempts", "next_attempt_at", "lease_expires_at",
|
|
353
|
+
"lease_expired", "last_error_code", "remote_status", "remote_attempts",
|
|
354
|
+
"remote_capabilities_sha256", "remote_binding_sha256",
|
|
355
|
+
"remote_website_capability_binding", "response_sha256",
|
|
356
|
+
}
|
|
357
|
+
if not isinstance(value, Mapping) or set(value) != fields:
|
|
358
|
+
_fail("status_binding")
|
|
359
|
+
payload = dict(value)
|
|
360
|
+
commercial_binding = payload["commercial_contract_binding"]
|
|
361
|
+
if commercial_binding is not None and (
|
|
362
|
+
commercial_binding != expected_commercial_binding
|
|
363
|
+
or payload["operation"] != "change"
|
|
364
|
+
):
|
|
365
|
+
_fail("status_binding")
|
|
366
|
+
valid = (
|
|
367
|
+
payload["operation"] in _HTTP.OPERATIONS
|
|
368
|
+
and all(payload.get(name) == wanted for name, wanted in identity.items())
|
|
369
|
+
and all(_token(payload[name]) for name in ("request_id", "event_id", "site_id"))
|
|
370
|
+
and _sha256(payload["payload_sha256"])
|
|
371
|
+
and all(_count(payload[name], minimum=1) for name in (
|
|
372
|
+
"source_max_attempts", "delivery_max_attempts", "client_max_attempts",
|
|
373
|
+
))
|
|
374
|
+
and _count(payload["attempts"])
|
|
375
|
+
and payload["attempts"] <= payload["client_max_attempts"]
|
|
376
|
+
and _timestamp(payload["next_attempt_at"])
|
|
377
|
+
and _timestamp(payload["lease_expires_at"], nullable=True)
|
|
378
|
+
and isinstance(payload["lease_expired"], bool)
|
|
379
|
+
and payload["status"] in _HTTP.STATUSES
|
|
380
|
+
and (payload["status"] == "leased") == (payload["lease_expires_at"] is not None)
|
|
381
|
+
and (payload["last_error_code"] is None or (
|
|
382
|
+
isinstance(payload["last_error_code"], str)
|
|
383
|
+
and ERROR_CODE.fullmatch(payload["last_error_code"]) is not None
|
|
384
|
+
))
|
|
385
|
+
and payload["remote_status"] in {
|
|
386
|
+
None, "pending", "leased", "retry_wait", "succeeded", "failed",
|
|
387
|
+
}
|
|
388
|
+
and (payload["remote_attempts"] is None or _count(payload["remote_attempts"]))
|
|
389
|
+
and all(payload[name] is None or _sha256(payload[name]) for name in (
|
|
390
|
+
"remote_capabilities_sha256", "remote_binding_sha256", "response_sha256",
|
|
391
|
+
))
|
|
392
|
+
)
|
|
393
|
+
binding = payload["remote_website_capability_binding"]
|
|
394
|
+
if binding is not None:
|
|
395
|
+
try:
|
|
396
|
+
normalized_binding = _HTTP._website_capability_binding(binding)
|
|
397
|
+
except Exception:
|
|
398
|
+
_fail("status_binding")
|
|
399
|
+
if normalized_binding != binding:
|
|
400
|
+
_fail("status_binding")
|
|
401
|
+
binding = normalized_binding
|
|
402
|
+
remote = (
|
|
403
|
+
payload["remote_status"], payload["remote_attempts"],
|
|
404
|
+
payload["remote_capabilities_sha256"], payload["remote_binding_sha256"],
|
|
405
|
+
binding, payload["response_sha256"],
|
|
406
|
+
)
|
|
407
|
+
accepted = payload["status"] == "accepted"
|
|
408
|
+
if (
|
|
409
|
+
not valid
|
|
410
|
+
or accepted != all(item is not None for item in remote)
|
|
411
|
+
or accepted and (
|
|
412
|
+
binding["delivery_capabilities_sha256"]
|
|
413
|
+
!= payload["remote_capabilities_sha256"]
|
|
414
|
+
or hashlib.sha256(_canonical(binding)).hexdigest()
|
|
415
|
+
!= payload["remote_binding_sha256"]
|
|
416
|
+
or commercial_binding is not None
|
|
417
|
+
and binding["commercial_rendering_registry_sha256"]
|
|
418
|
+
!= commercial_binding["commercial_rendering_registry_sha256"]
|
|
419
|
+
)
|
|
420
|
+
):
|
|
421
|
+
_fail("status_binding")
|
|
422
|
+
return payload
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
class CMSSourceDeliverySubmissionDispatchHTTPClient:
|
|
426
|
+
"""Operate one durable CMS submission sidecar through its exact contract."""
|
|
427
|
+
|
|
428
|
+
def __init__(
|
|
429
|
+
self, origin: str, expected_capabilities_sha256: str,
|
|
430
|
+
expected_public_submission_capabilities_sha256: str,
|
|
431
|
+
authentication_headers: Callable[[Mapping[str, Any]], Mapping[str, str]],
|
|
432
|
+
*, transport: HTTPTransport | None = None, timeout: float | int = 30,
|
|
433
|
+
allow_loopback_http: bool = False,
|
|
434
|
+
):
|
|
435
|
+
if not isinstance(allow_loopback_http, bool):
|
|
436
|
+
raise TypeError("allow_loopback_http must be boolean")
|
|
437
|
+
self.origin = _endpoint(origin, allow_loopback_http)
|
|
438
|
+
if not _sha256(expected_capabilities_sha256) or not _sha256(
|
|
439
|
+
expected_public_submission_capabilities_sha256
|
|
440
|
+
):
|
|
441
|
+
raise ValueError("expected capability digest is invalid")
|
|
442
|
+
expected = _HTTP._capabilities_payload(
|
|
443
|
+
expected_public_submission_capabilities_sha256
|
|
444
|
+
)
|
|
445
|
+
if expected["sha256"] != expected_capabilities_sha256:
|
|
446
|
+
raise ValueError("sidecar and public capability pins are inconsistent")
|
|
447
|
+
if not callable(authentication_headers):
|
|
448
|
+
raise TypeError("authentication_headers must be callable")
|
|
449
|
+
if (
|
|
450
|
+
isinstance(timeout, bool) or not isinstance(timeout, (int, float))
|
|
451
|
+
or not math.isfinite(float(timeout)) or not 0 < float(timeout) <= 300
|
|
452
|
+
):
|
|
453
|
+
raise ValueError("timeout is outside the supported range")
|
|
454
|
+
self.expected_capabilities_sha256 = expected_capabilities_sha256
|
|
455
|
+
self.expected_public_submission_capabilities_sha256 = (
|
|
456
|
+
expected_public_submission_capabilities_sha256
|
|
457
|
+
)
|
|
458
|
+
self._expected_capabilities = expected
|
|
459
|
+
self.authentication_headers = authentication_headers
|
|
460
|
+
self.transport = URLTransport() if transport is None else transport
|
|
461
|
+
if not callable(getattr(self.transport, "request", None)):
|
|
462
|
+
raise TypeError("transport must provide request")
|
|
463
|
+
self.timeout = float(timeout)
|
|
464
|
+
|
|
465
|
+
def __repr__(self) -> str:
|
|
466
|
+
return "CMSSourceDeliverySubmissionDispatchHTTPClient(configured=True)"
|
|
467
|
+
|
|
468
|
+
def _request(
|
|
469
|
+
self, method, path, scope, body, statuses, context,
|
|
470
|
+
error_codes, headers=None,
|
|
471
|
+
):
|
|
472
|
+
authentication = {
|
|
473
|
+
"schema": AUTH_CONTEXT_SCHEMA, "method": method,
|
|
474
|
+
"origin": self.origin, "path": path, "scope": scope,
|
|
475
|
+
"body_sha256": hashlib.sha256(body or b"").hexdigest(),
|
|
476
|
+
**context,
|
|
477
|
+
}
|
|
478
|
+
if path != _HTTP.CAPABILITIES_PATH:
|
|
479
|
+
authentication["capabilities_sha256"] = (
|
|
480
|
+
self.expected_capabilities_sha256
|
|
481
|
+
)
|
|
482
|
+
request_headers = _authentication_headers(
|
|
483
|
+
self.authentication_headers, authentication,
|
|
484
|
+
)
|
|
485
|
+
request_headers["Accept"] = "application/json"
|
|
486
|
+
if body is not None:
|
|
487
|
+
request_headers["Content-Type"] = "application/json; charset=utf-8"
|
|
488
|
+
if path != _HTTP.CAPABILITIES_PATH:
|
|
489
|
+
request_headers[_HTTP.CAPABILITIES_PRECONDITION_HEADER] = (
|
|
490
|
+
self.expected_capabilities_sha256
|
|
491
|
+
)
|
|
492
|
+
if headers:
|
|
493
|
+
request_headers.update(headers)
|
|
494
|
+
try:
|
|
495
|
+
result = self.transport.request(
|
|
496
|
+
method, self.origin + path, request_headers, body,
|
|
497
|
+
timeout=self.timeout,
|
|
498
|
+
)
|
|
499
|
+
except CMSSourceDeliverySubmissionDispatchClientBlocked:
|
|
500
|
+
raise
|
|
501
|
+
except Exception:
|
|
502
|
+
_fail("network", retryable=True)
|
|
503
|
+
return _json_response(result, statuses, error_codes)
|
|
504
|
+
|
|
505
|
+
def capabilities(self) -> Mapping[str, Any]:
|
|
506
|
+
contract = self._expected_capabilities["operations"]["capabilities"]
|
|
507
|
+
response = self._request(
|
|
508
|
+
"GET", _HTTP.CAPABILITIES_PATH, _HTTP.SCOPES[_HTTP.CAPABILITIES_PATH],
|
|
509
|
+
None, {200}, {}, contract["error_codes"],
|
|
510
|
+
)
|
|
511
|
+
if (
|
|
512
|
+
set(response) != {"schema", "capabilities"}
|
|
513
|
+
or response.get("schema") != _HTTP.CAPABILITIES_RESPONSE_SCHEMA
|
|
514
|
+
or response.get("capabilities") != self._expected_capabilities
|
|
515
|
+
):
|
|
516
|
+
_fail("capabilities_binding")
|
|
517
|
+
return response
|
|
518
|
+
|
|
519
|
+
def _contract(self, name: str) -> Mapping[str, Any]:
|
|
520
|
+
return self.capabilities()["capabilities"]["operations"][name]
|
|
521
|
+
|
|
522
|
+
def enqueue(
|
|
523
|
+
self, payload: Mapping[str, Any], *, source_max_attempts: int = 5,
|
|
524
|
+
delivery_max_attempts: int = 5, client_max_attempts: int = 5,
|
|
525
|
+
) -> Mapping[str, Any]:
|
|
526
|
+
if not all(_count(value, minimum=1) for value in (
|
|
527
|
+
source_max_attempts, delivery_max_attempts, client_max_attempts,
|
|
528
|
+
)):
|
|
529
|
+
_fail("request_invalid")
|
|
530
|
+
try:
|
|
531
|
+
copied, identity, canonical = _HTTP._DISPATCH._payload(payload)
|
|
532
|
+
except Exception:
|
|
533
|
+
_fail("request_invalid")
|
|
534
|
+
contract = self._contract("enqueue")
|
|
535
|
+
request = {
|
|
536
|
+
"schema": contract["request_schema"], "payload": copied,
|
|
537
|
+
"source_max_attempts": source_max_attempts,
|
|
538
|
+
"delivery_max_attempts": delivery_max_attempts,
|
|
539
|
+
"client_max_attempts": client_max_attempts,
|
|
540
|
+
"commercial_contract_binding": (
|
|
541
|
+
self._expected_capabilities["commercial_contract_binding"]
|
|
542
|
+
if (
|
|
543
|
+
copied.get("schema") == _HTTP._DISPATCH._CLIENT._CMS.CHANGE_SCHEMA
|
|
544
|
+
and copied.get("localization", {}).get("content_type")
|
|
545
|
+
== "commercial"
|
|
546
|
+
) else None
|
|
547
|
+
),
|
|
548
|
+
}
|
|
549
|
+
body = _canonical(request)
|
|
550
|
+
response = self._request(
|
|
551
|
+
contract["method"], contract["path"], contract["scope"], body,
|
|
552
|
+
{contract["success_status"]}, identity,
|
|
553
|
+
contract["error_codes"],
|
|
554
|
+
{
|
|
555
|
+
"Idempotency-Key": identity["request_id"],
|
|
556
|
+
"X-Localization-Source-Payload-Sha256": hashlib.sha256(
|
|
557
|
+
canonical.encode("utf-8")
|
|
558
|
+
).hexdigest(),
|
|
559
|
+
},
|
|
560
|
+
)
|
|
561
|
+
if (
|
|
562
|
+
set(response) != {
|
|
563
|
+
"schema", "api_schema", "status", "capabilities_sha256",
|
|
564
|
+
"accepted_implies_publication",
|
|
565
|
+
}
|
|
566
|
+
or response.get("schema") != contract["response_schema"]
|
|
567
|
+
or response.get("api_schema") != _HTTP.API_SCHEMA
|
|
568
|
+
or response.get("capabilities_sha256") != self.expected_capabilities_sha256
|
|
569
|
+
or response.get("accepted_implies_publication") is not False
|
|
570
|
+
):
|
|
571
|
+
_fail("enqueue_binding")
|
|
572
|
+
status = _status(
|
|
573
|
+
response.get("status"), identity,
|
|
574
|
+
self._expected_capabilities["commercial_contract_binding"],
|
|
575
|
+
)
|
|
576
|
+
if (
|
|
577
|
+
status["source_max_attempts"] != source_max_attempts
|
|
578
|
+
or status["delivery_max_attempts"] != delivery_max_attempts
|
|
579
|
+
or status["client_max_attempts"] != client_max_attempts
|
|
580
|
+
or status["commercial_contract_binding"]
|
|
581
|
+
!= request["commercial_contract_binding"]
|
|
582
|
+
):
|
|
583
|
+
_fail("enqueue_binding")
|
|
584
|
+
return response
|
|
585
|
+
|
|
586
|
+
def status(
|
|
587
|
+
self, operation: str, request_id: str, event_id: str, site_id: str,
|
|
588
|
+
payload_sha256: str,
|
|
589
|
+
) -> Mapping[str, Any]:
|
|
590
|
+
identity = {
|
|
591
|
+
"operation": operation, "request_id": request_id,
|
|
592
|
+
"event_id": event_id, "site_id": site_id,
|
|
593
|
+
"payload_sha256": payload_sha256,
|
|
594
|
+
}
|
|
595
|
+
if (
|
|
596
|
+
operation not in _HTTP.OPERATIONS
|
|
597
|
+
or not all(_token(identity[name]) for name in ("request_id", "event_id", "site_id"))
|
|
598
|
+
or operation == "change" and request_id != event_id
|
|
599
|
+
or not _sha256(payload_sha256)
|
|
600
|
+
):
|
|
601
|
+
_fail("request_invalid")
|
|
602
|
+
contract = self._contract("status")
|
|
603
|
+
body = _canonical({"schema": contract["request_schema"], **identity})
|
|
604
|
+
response = self._request(
|
|
605
|
+
contract["method"], contract["path"], contract["scope"], body,
|
|
606
|
+
{contract["success_status"]}, identity, contract["error_codes"],
|
|
607
|
+
)
|
|
608
|
+
if (
|
|
609
|
+
set(response) != {
|
|
610
|
+
"schema", "api_schema", "status", "capabilities_sha256",
|
|
611
|
+
"accepted_implies_publication",
|
|
612
|
+
}
|
|
613
|
+
or response.get("schema") != contract["response_schema"]
|
|
614
|
+
or response.get("api_schema") != _HTTP.API_SCHEMA
|
|
615
|
+
or response.get("capabilities_sha256") != self.expected_capabilities_sha256
|
|
616
|
+
or response.get("accepted_implies_publication") is not False
|
|
617
|
+
):
|
|
618
|
+
_fail("status_binding")
|
|
619
|
+
_status(
|
|
620
|
+
response.get("status"), identity,
|
|
621
|
+
self._expected_capabilities["commercial_contract_binding"],
|
|
622
|
+
)
|
|
623
|
+
return response
|
|
624
|
+
|
|
625
|
+
def lifecycle(
|
|
626
|
+
self, operation: str, request_id: str, event_id: str, site_id: str,
|
|
627
|
+
payload_sha256: str,
|
|
628
|
+
) -> Mapping[str, Any]:
|
|
629
|
+
identity = {
|
|
630
|
+
"operation": operation, "request_id": request_id,
|
|
631
|
+
"event_id": event_id, "site_id": site_id,
|
|
632
|
+
"payload_sha256": payload_sha256,
|
|
633
|
+
}
|
|
634
|
+
if (
|
|
635
|
+
operation not in _HTTP.OPERATIONS
|
|
636
|
+
or not all(_token(identity[name]) for name in (
|
|
637
|
+
"request_id", "event_id", "site_id",
|
|
638
|
+
))
|
|
639
|
+
or operation == "change" and request_id != event_id
|
|
640
|
+
or not _sha256(payload_sha256)
|
|
641
|
+
):
|
|
642
|
+
_fail("request_invalid")
|
|
643
|
+
contract = self._contract("lifecycle")
|
|
644
|
+
body = _canonical({"schema": contract["request_schema"], **identity})
|
|
645
|
+
response = self._request(
|
|
646
|
+
contract["method"], contract["path"], contract["scope"], body,
|
|
647
|
+
{contract["success_status"]}, identity, contract["error_codes"],
|
|
648
|
+
)
|
|
649
|
+
if (
|
|
650
|
+
set(response) != {
|
|
651
|
+
"schema", "api_schema", "lifecycle", "capabilities_sha256",
|
|
652
|
+
"accepted_implies_publication",
|
|
653
|
+
}
|
|
654
|
+
or response.get("schema") != contract["response_schema"]
|
|
655
|
+
or response.get("api_schema") != _HTTP.API_SCHEMA
|
|
656
|
+
or response.get("capabilities_sha256")
|
|
657
|
+
!= self.expected_capabilities_sha256
|
|
658
|
+
or response.get("accepted_implies_publication") is not False
|
|
659
|
+
or not isinstance(response.get("lifecycle"), Mapping)
|
|
660
|
+
):
|
|
661
|
+
_fail("lifecycle_binding")
|
|
662
|
+
lifecycle = response["lifecycle"]
|
|
663
|
+
try:
|
|
664
|
+
normalized = _HTTP._lifecycle_payload(
|
|
665
|
+
_HTTP._DISPATCH.SubmissionDispatchLifecycle(
|
|
666
|
+
dispatch_status=_HTTP._DISPATCH.SubmissionDispatchStatus(
|
|
667
|
+
**lifecycle["dispatch_status"]
|
|
668
|
+
),
|
|
669
|
+
source_lifecycle=lifecycle["source_lifecycle"],
|
|
670
|
+
),
|
|
671
|
+
identity,
|
|
672
|
+
)
|
|
673
|
+
except Exception:
|
|
674
|
+
_fail("lifecycle_binding")
|
|
675
|
+
if normalized != lifecycle:
|
|
676
|
+
_fail("lifecycle_binding")
|
|
677
|
+
return response
|
|
678
|
+
|
|
679
|
+
def commercial_profile(self) -> Mapping[str, Any]:
|
|
680
|
+
"""Return the exact live-bound, content-free commercial contract."""
|
|
681
|
+
contract = self._contract("commercial_profile")
|
|
682
|
+
response = self._request(
|
|
683
|
+
contract["method"], contract["path"], contract["scope"], None,
|
|
684
|
+
{contract["success_status"]}, {}, contract["error_codes"],
|
|
685
|
+
)
|
|
686
|
+
if (
|
|
687
|
+
set(response) != {
|
|
688
|
+
"schema", "api_schema", "commercial_profile",
|
|
689
|
+
"commercial_rendering_registry", "website_capability_binding",
|
|
690
|
+
"capabilities_sha256", "content_free",
|
|
691
|
+
"publication_authority",
|
|
692
|
+
}
|
|
693
|
+
or response.get("schema") != contract["response_schema"]
|
|
694
|
+
or response.get("api_schema") != _HTTP.API_SCHEMA
|
|
695
|
+
or response.get("capabilities_sha256")
|
|
696
|
+
!= self.expected_capabilities_sha256
|
|
697
|
+
or response.get("content_free") is not True
|
|
698
|
+
or response.get("publication_authority") is not False
|
|
699
|
+
or response.get("commercial_profile")
|
|
700
|
+
!= self._expected_capabilities["commercial_profile"]
|
|
701
|
+
or response.get("commercial_rendering_registry")
|
|
702
|
+
!= self._expected_capabilities["commercial_rendering_registry"]
|
|
703
|
+
or not isinstance(response.get("website_capability_binding"), Mapping)
|
|
704
|
+
):
|
|
705
|
+
_fail("commercial_profile_binding")
|
|
706
|
+
try:
|
|
707
|
+
value = _HTTP._DISPATCH.SubmissionDispatchCommercialProfile(
|
|
708
|
+
commercial_profile=response["commercial_profile"],
|
|
709
|
+
commercial_rendering_registry=response[
|
|
710
|
+
"commercial_rendering_registry"
|
|
711
|
+
],
|
|
712
|
+
website_capability_binding=response[
|
|
713
|
+
"website_capability_binding"
|
|
714
|
+
],
|
|
715
|
+
)
|
|
716
|
+
normalized = _HTTP._commercial_profile_payload(value)
|
|
717
|
+
except Exception:
|
|
718
|
+
_fail("commercial_profile_binding")
|
|
719
|
+
if (
|
|
720
|
+
normalized["commercial_profile"] != response["commercial_profile"]
|
|
721
|
+
or normalized["commercial_rendering_registry"]
|
|
722
|
+
!= response["commercial_rendering_registry"]
|
|
723
|
+
or normalized["website_capability_binding"]
|
|
724
|
+
!= response["website_capability_binding"]
|
|
725
|
+
):
|
|
726
|
+
_fail("commercial_profile_binding")
|
|
727
|
+
return response
|
|
728
|
+
|
|
729
|
+
def health(self) -> Mapping[str, Any]:
|
|
730
|
+
return self._monitor("health")
|
|
731
|
+
|
|
732
|
+
def readiness(self) -> Mapping[str, Any]:
|
|
733
|
+
return self._monitor("readiness")
|
|
734
|
+
|
|
735
|
+
def openapi(self) -> Mapping[str, Any]:
|
|
736
|
+
"""Return the exact origin-free API document for this pinned deployment."""
|
|
737
|
+
contract = self._contract("openapi")
|
|
738
|
+
response = self._request(
|
|
739
|
+
contract["method"], contract["path"], contract["scope"], None,
|
|
740
|
+
{contract["success_status"]}, {}, contract["error_codes"],
|
|
741
|
+
)
|
|
742
|
+
expected = _HTTP._OPENAPI.build_document(self._expected_capabilities)
|
|
743
|
+
if (
|
|
744
|
+
set(response) != {
|
|
745
|
+
"schema", "openapi", "openapi_sha256", "capabilities_sha256",
|
|
746
|
+
}
|
|
747
|
+
or response.get("schema") != contract["response_schema"]
|
|
748
|
+
or response.get("capabilities_sha256")
|
|
749
|
+
!= self.expected_capabilities_sha256
|
|
750
|
+
or response.get("openapi") != expected
|
|
751
|
+
or response.get("openapi_sha256")
|
|
752
|
+
!= _HTTP._OPENAPI.document_sha256(expected)
|
|
753
|
+
):
|
|
754
|
+
_fail("openapi_binding")
|
|
755
|
+
return response
|
|
756
|
+
|
|
757
|
+
def _monitor(self, name: str) -> Mapping[str, Any]:
|
|
758
|
+
contract = self._contract(name)
|
|
759
|
+
response = self._request(
|
|
760
|
+
contract["method"], contract["path"], contract["scope"], None,
|
|
761
|
+
{contract["success_status"], 503}, {}, contract["error_codes"],
|
|
762
|
+
)
|
|
763
|
+
if response.get("schema") == _HTTP.ERROR_SCHEMA:
|
|
764
|
+
_remote_error(response, 503, contract["error_codes"])
|
|
765
|
+
key = name
|
|
766
|
+
if (
|
|
767
|
+
set(response) != {"schema", key, "capabilities_sha256"}
|
|
768
|
+
or response.get("schema") != contract["response_schema"]
|
|
769
|
+
or response.get("capabilities_sha256") != self.expected_capabilities_sha256
|
|
770
|
+
or not isinstance(response.get(key), Mapping)
|
|
771
|
+
):
|
|
772
|
+
_fail(name + "_binding")
|
|
773
|
+
payload = response[key]
|
|
774
|
+
if name == "health":
|
|
775
|
+
valid = (
|
|
776
|
+
set(payload) == {
|
|
777
|
+
"status", "counts", "operations", "due", "expired_leases",
|
|
778
|
+
"failed", "expected_capabilities_sha256",
|
|
779
|
+
}
|
|
780
|
+
and payload.get("status") in {"ok", "blocked"}
|
|
781
|
+
and isinstance(payload.get("counts"), Mapping)
|
|
782
|
+
and set(payload["counts"]) == _HTTP.STATUSES
|
|
783
|
+
and isinstance(payload.get("operations"), Mapping)
|
|
784
|
+
and set(payload["operations"]) == _HTTP.OPERATIONS
|
|
785
|
+
and all(_count(value, maximum=1_000_000) for value in payload["counts"].values())
|
|
786
|
+
and all(_count(value, maximum=1_000_000) for value in payload["operations"].values())
|
|
787
|
+
and all(_count(payload[name], maximum=1_000_000) for name in (
|
|
788
|
+
"due", "expired_leases", "failed",
|
|
789
|
+
))
|
|
790
|
+
and sum(payload["counts"].values()) == sum(payload["operations"].values())
|
|
791
|
+
and payload["due"] <= payload["counts"]["pending"] + payload["counts"]["retry_wait"]
|
|
792
|
+
and payload["expired_leases"] <= payload["counts"]["leased"]
|
|
793
|
+
and payload["failed"] == payload["counts"]["failed"]
|
|
794
|
+
and (payload["status"] == "ok") == (
|
|
795
|
+
payload["expired_leases"] == 0 and payload["failed"] == 0
|
|
796
|
+
)
|
|
797
|
+
and payload["expected_capabilities_sha256"]
|
|
798
|
+
== self.expected_public_submission_capabilities_sha256
|
|
799
|
+
)
|
|
800
|
+
else:
|
|
801
|
+
valid = (
|
|
802
|
+
set(payload) == {
|
|
803
|
+
"schema", "status", "worker_state", "outbox_status",
|
|
804
|
+
"error_code", "capabilities_sha256",
|
|
805
|
+
}
|
|
806
|
+
and payload.get("schema") == "blun.cms-public-submission-worker-readiness.v1"
|
|
807
|
+
and payload.get("status") in {"ready", "not_ready"}
|
|
808
|
+
and payload.get("worker_state") in _HTTP.WORKER_STATES
|
|
809
|
+
and payload.get("outbox_status") in {None, "ok", "blocked"}
|
|
810
|
+
and (payload.get("error_code") is None or (
|
|
811
|
+
isinstance(payload["error_code"], str)
|
|
812
|
+
and ERROR_CODE.fullmatch(payload["error_code"]) is not None
|
|
813
|
+
))
|
|
814
|
+
and payload.get("capabilities_sha256")
|
|
815
|
+
== self.expected_public_submission_capabilities_sha256
|
|
816
|
+
)
|
|
817
|
+
ready = (
|
|
818
|
+
payload.get("worker_state") == "running"
|
|
819
|
+
and payload.get("outbox_status") == "ok"
|
|
820
|
+
and payload.get("error_code") is None
|
|
821
|
+
)
|
|
822
|
+
valid = valid and ready == (payload.get("status") == "ready")
|
|
823
|
+
valid = valid and (ready or payload.get("error_code") is not None)
|
|
824
|
+
if not valid:
|
|
825
|
+
_fail(name + "_binding")
|
|
826
|
+
return response
|