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.
Files changed (106) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +17 -1
  3. package/agent-spine-plugin/CHANGELOG.md +1 -1
  4. package/agent-spine-plugin/docs/host-integration.md +1 -15
  5. package/agent-spine-plugin/docs/preflight-recall.md +1 -1
  6. package/agent-spine-plugin/scripts/check-install.js +1 -6
  7. package/agent-spine-plugin/scripts/hermetic-process.js +8 -0
  8. package/agent-spine-plugin/scripts/release-check.js +2 -3
  9. package/agent-spine-plugin/scripts/run-tests-hermetic.js +8 -11
  10. package/agent-spine-plugin/src/hook.js +40 -40
  11. package/agent-spine-plugin/src/lib/briefing.js +6 -3
  12. package/agent-spine-plugin/src/lib/hook-context.js +63 -64
  13. package/agent-spine-plugin/src/lib/hook-output.js +115 -94
  14. package/agent-spine-plugin/src/lib/hook-timeline.js +20 -11
  15. package/agent-spine-plugin/src/lib/host-instruction-budget.js +8 -14
  16. package/agent-spine-plugin/src/lib/mcp-timeline-tools.js +0 -7
  17. package/agent-spine-plugin/src/lib/pre-answer-timeline-recall.js +104 -0
  18. package/agent-spine-plugin/src/lib/session-timeline-codex.js +0 -4
  19. package/agent-spine-plugin/src/lib/session-timeline-enrollment-source.js +0 -3
  20. package/agent-spine-plugin/src/lib/session-timeline-enrollment-storage.js +0 -4
  21. package/agent-spine-plugin/src/lib/session-timeline-enrollment-transport.js +0 -2
  22. package/agent-spine-plugin/src/lib/session-timeline-enrollment.js +0 -22
  23. package/agent-spine-plugin/src/lib/session-timeline-host-origin.js +0 -4
  24. package/agent-spine-plugin/src/lib/session-timeline-prior.js +42 -50
  25. package/agent-spine-plugin/src/lib/session-timeline-provider.js +0 -3
  26. package/agent-spine-plugin/src/lib/session-timeline-query.js +0 -1
  27. package/agent-spine-plugin/src/lib/session-timeline-sid-acl.js +0 -4
  28. package/agent-spine-plugin/src/lib/session-timeline-source.js +0 -3
  29. package/agent-spine-plugin/src/lib/session-timeline-transport.js +0 -8
  30. package/agent-spine-plugin/src/lib/session-timeline-windows-acl.js +0 -3
  31. package/agent-spine-plugin/src/lib/session-timeline.js +370 -408
  32. package/agent-spine-plugin/src/lib/source-roots.js +26 -16
  33. package/agent-spine-plugin/src/lib/timeline-user-feedback.js +0 -1
  34. package/bin/core-bootstrap.js +18 -10
  35. package/bin/launcher-mode.js +5 -0
  36. package/bin/launcher-runtime.js +27 -0
  37. package/bin/verify-bundled-agent-sources.cjs +28 -2
  38. package/blun.mjs +798 -143
  39. package/bundled-agent-sources.json +192 -155
  40. package/package.json +1 -1
  41. package/standard-skills/translate-native/README.md +1334 -1
  42. package/standard-skills/translate-native/SKILL.md +1 -1
  43. package/standard-skills/translate-native/VERSION +1 -1
  44. package/standard-skills/translate-native/docs/PREMORTEM.md +901 -0
  45. package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION.md +208 -41
  46. package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_API.md +1319 -77
  47. package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_EVIDENCE_HTTP.md +66 -11
  48. package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_HTTP_PROVIDER.md +19 -1
  49. package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_RECEIPT_VERIFIER_HTTP.md +24 -6
  50. package/standard-skills/translate-native/integrations/commercial_localization_profile.py +23 -0
  51. package/standard-skills/translate-native/integrations/website_localization.py +234 -10
  52. package/standard-skills/translate-native/integrations/website_localization_benchmark.py +39 -3
  53. package/standard-skills/translate-native/integrations/website_localization_cms.py +512 -10
  54. package/standard-skills/translate-native/integrations/website_localization_cms_client.py +116 -0
  55. package/standard-skills/translate-native/integrations/website_localization_cms_receiver.py +101 -11
  56. package/standard-skills/translate-native/integrations/website_localization_cms_receiver_runtime.py +12 -1
  57. package/standard-skills/translate-native/integrations/website_localization_cms_receiver_store.py +186 -24
  58. package/standard-skills/translate-native/integrations/website_localization_cms_source_client.py +64 -1
  59. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery.py +528 -5
  60. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_auth.py +918 -0
  61. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_auth_runtime.py +609 -0
  62. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_client.py +885 -0
  63. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_http.py +1244 -0
  64. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_runtime.py +220 -0
  65. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_sidecar_adapter.py +331 -0
  66. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_capabilities_http.py +444 -0
  67. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_client.py +673 -0
  68. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch.py +1317 -0
  69. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_client.py +826 -0
  70. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_http.py +1152 -0
  71. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_openapi.py +896 -0
  72. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_runtime.py +621 -0
  73. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_http.py +1180 -0
  74. package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_runtime.py +1479 -0
  75. package/standard-skills/translate-native/integrations/website_localization_cms_source_http.py +133 -17
  76. package/standard-skills/translate-native/integrations/website_localization_cms_source_runtime.py +319 -4
  77. package/standard-skills/translate-native/integrations/website_localization_cms_source_service.py +72 -2
  78. package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_http.py +13 -0
  79. package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver.py +56 -13
  80. package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver_openapi.py +335 -0
  81. package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver_runtime.py +66 -1
  82. package/standard-skills/translate-native/integrations/website_localization_cms_terminal_processing_monitor.py +73 -7
  83. package/standard-skills/translate-native/integrations/website_localization_cms_terminal_receiver_client.py +93 -9
  84. package/standard-skills/translate-native/integrations/website_localization_evidence_http.py +221 -11
  85. package/standard-skills/translate-native/integrations/website_localization_health.py +6 -0
  86. package/standard-skills/translate-native/integrations/website_localization_http_provider.py +1 -1
  87. package/standard-skills/translate-native/integrations/website_localization_quality_profiles.py +350 -0
  88. package/standard-skills/translate-native/integrations/website_localization_queue.py +126 -56
  89. package/standard-skills/translate-native/integrations/website_localization_receipt_verifier_http.py +166 -13
  90. package/standard-skills/translate-native/integrations/website_localization_release.py +423 -17
  91. package/standard-skills/translate-native/integrations/website_localization_release_coordinator.py +168 -13
  92. package/standard-skills/translate-native/integrations/website_localization_runner.py +10 -0
  93. package/standard-skills/translate-native/integrations/website_localization_worker.py +213 -9
  94. package/standard-skills/translate-native/provenance.json +2 -2
  95. package/standard-skills/translate-native/references/commercial-localization.md +96 -17
  96. package/standard-skills/translate-native/scripts/blun_language_guard.py +3 -1
  97. package/standard-skills/translate-native/scripts/check_commercial_review.py +69 -9
  98. package/standard-skills/translate-native/scripts/commercial_localization_profile.py +829 -31
  99. package/standard-tools/language-guard/VERSION +1 -1
  100. package/standard-tools/language-guard/blun_language_guard.py +3 -1
  101. package/standard-tools/language-guard/check_commercial_review.py +69 -9
  102. package/standard-tools/language-guard/commercial_localization_profile.py +829 -31
  103. package/standard-tools/language-guard/provenance.json +2 -2
  104. package/standard-tools/manifest.json +1 -1
  105. package/worker-host.mjs +215 -82
  106. package/agent-spine-plugin/scripts/check-install-king.js +0 -37
@@ -0,0 +1,673 @@
1
+ #!/usr/bin/env python3
2
+ """Contract-pinned client for the public website submission edge.
3
+
4
+ The client discovers the complete website-to-source contract before every
5
+ operation. It performs one bounded transport attempt, never follows redirects,
6
+ and validates every content-free response against the exact request identities
7
+ and capability generation. Retry scheduling remains the caller's responsibility.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import importlib.util
14
+ import json
15
+ import math
16
+ import re
17
+ import socket
18
+ import sys
19
+ import unicodedata
20
+ import urllib.error
21
+ import urllib.parse
22
+ import urllib.request
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Any, Callable, Mapping, Protocol
26
+
27
+
28
+ MAX_ENDPOINT_LENGTH = 2_048
29
+ MAX_HEADER_VALUE_LENGTH = 4_096
30
+ MAX_AUTHENTICATION_HEADERS = 58
31
+ MAX_RESPONSE_BYTES = 1_000_000
32
+ SHA256 = re.compile(r"^[a-f0-9]{64}$")
33
+ TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$")
34
+ ERROR_CODE = re.compile(r"^[a-z][a-z0-9_.-]{0,127}$")
35
+ AUTH_CONTEXT_SCHEMA = (
36
+ "blun.cms-source-delivery-submission-client-auth-context.v1"
37
+ )
38
+ RESERVED_HEADERS = {
39
+ "accept", "connection", "content-length", "content-type", "host",
40
+ "idempotency-key", "transfer-encoding",
41
+ "x-localization-source-payload-sha256",
42
+ }
43
+
44
+
45
+ def _load_module(name: str, path: Path):
46
+ spec = importlib.util.spec_from_file_location(name, path)
47
+ if spec is None or spec.loader is None:
48
+ raise RuntimeError(f"cannot load submission client dependency: {path.name}")
49
+ module = importlib.util.module_from_spec(spec)
50
+ sys.modules[spec.name] = module
51
+ spec.loader.exec_module(module)
52
+ return module
53
+
54
+
55
+ _ROOT = Path(__file__).resolve().parents[1]
56
+ _HTTP = _load_module(
57
+ "blun_website_localization_submission_client_http",
58
+ _ROOT
59
+ / "integrations"
60
+ / "website_localization_cms_source_delivery_submission_http.py",
61
+ )
62
+ _CMS = _load_module(
63
+ "blun_website_localization_submission_client_cms",
64
+ _ROOT / "integrations" / "website_localization_cms.py",
65
+ )
66
+
67
+
68
+ class CMSSourceDeliverySubmissionClientBlocked(RuntimeError):
69
+ """Stable content-free failure with an explicit retry decision."""
70
+
71
+ cms_source_delivery_submission_client_failure = True
72
+
73
+ def __init__(self, code: str, *, retryable: bool):
74
+ if ERROR_CODE.fullmatch(code) is None or not isinstance(retryable, bool):
75
+ raise ValueError("submission client failure is invalid")
76
+ super().__init__(code)
77
+ self.code = code
78
+ self.retryable = retryable
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class HTTPResult:
83
+ status: int
84
+ headers: tuple[tuple[str, str], ...]
85
+ body: bytes
86
+
87
+
88
+ class HTTPTransport(Protocol):
89
+ def request(
90
+ self,
91
+ method: str,
92
+ url: str,
93
+ headers: Mapping[str, str],
94
+ body: bytes | None,
95
+ *,
96
+ timeout: float,
97
+ ) -> HTTPResult: ...
98
+
99
+
100
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
101
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
102
+ return None
103
+
104
+
105
+ class URLTransport:
106
+ """Perform one bounded HTTPS request without following redirects."""
107
+
108
+ def __init__(self):
109
+ self._opener = urllib.request.build_opener(_NoRedirect)
110
+
111
+ def request(self, method, url, headers, body, *, timeout):
112
+ request = urllib.request.Request(
113
+ url, data=body, headers=dict(headers), method=method,
114
+ )
115
+ try:
116
+ response = self._opener.open(request, timeout=timeout)
117
+ except urllib.error.HTTPError as error:
118
+ response = error
119
+ except (urllib.error.URLError, TimeoutError, socket.timeout, OSError):
120
+ _fail("network", retryable=True)
121
+ try:
122
+ raw = response.read(MAX_RESPONSE_BYTES + 1)
123
+ return HTTPResult(
124
+ int(response.status), tuple(response.headers.items()), raw,
125
+ )
126
+ except (TimeoutError, socket.timeout, OSError):
127
+ _fail("network", retryable=True)
128
+ finally:
129
+ response.close()
130
+
131
+
132
+ def _fail(code: str, *, retryable: bool = False) -> None:
133
+ raise CMSSourceDeliverySubmissionClientBlocked(
134
+ "source_delivery_submission_client." + code, retryable=retryable,
135
+ )
136
+
137
+
138
+ def _canonical(value: Any) -> bytes:
139
+ try:
140
+ raw = json.dumps(
141
+ value, ensure_ascii=False, allow_nan=False, sort_keys=True,
142
+ separators=(",", ":"),
143
+ ).encode("utf-8")
144
+ except (TypeError, ValueError, RecursionError):
145
+ _fail("request_invalid")
146
+ if not raw or len(raw) > _HTTP.MAX_BODY_BYTES:
147
+ _fail("request_invalid")
148
+ return raw
149
+
150
+
151
+ def _pairs(items: list[tuple[str, Any]]) -> dict[str, Any]:
152
+ result: dict[str, Any] = {}
153
+ for key, value in items:
154
+ if key in result:
155
+ raise ValueError("duplicate key")
156
+ result[key] = value
157
+ return result
158
+
159
+
160
+ def _constant(_value: str) -> None:
161
+ raise ValueError("non-finite number")
162
+
163
+
164
+ def _token(value: Any) -> bool:
165
+ return (
166
+ isinstance(value, str)
167
+ and TOKEN.fullmatch(value) is not None
168
+ and unicodedata.is_normalized("NFC", value)
169
+ )
170
+
171
+
172
+ def _sha256(value: Any) -> bool:
173
+ return isinstance(value, str) and SHA256.fullmatch(value) is not None
174
+
175
+
176
+ def _count(value: Any, *, minimum: int = 0, maximum: int = 20) -> bool:
177
+ return (
178
+ isinstance(value, int)
179
+ and not isinstance(value, bool)
180
+ and minimum <= value <= maximum
181
+ )
182
+
183
+
184
+ def _endpoint(value: Any, allow_loopback_http: bool) -> str:
185
+ if (
186
+ not isinstance(value, str)
187
+ or value != value.strip()
188
+ or not value
189
+ or not value.isascii()
190
+ or len(value) > MAX_ENDPOINT_LENGTH
191
+ or any(ord(character) <= 32 or ord(character) == 127 for character in value)
192
+ ):
193
+ raise ValueError("origin is invalid")
194
+ try:
195
+ parsed = urllib.parse.urlsplit(value)
196
+ hostname = parsed.hostname
197
+ port = parsed.port
198
+ except ValueError:
199
+ raise ValueError("origin is invalid") from None
200
+ secure = parsed.scheme == "https"
201
+ loopback = (
202
+ parsed.scheme == "http"
203
+ and allow_loopback_http
204
+ and isinstance(hostname, str)
205
+ and hostname.lower() in {"localhost", "127.0.0.1", "::1"}
206
+ )
207
+ if (
208
+ not (secure or loopback)
209
+ or not hostname
210
+ or parsed.username is not None
211
+ or parsed.password is not None
212
+ or parsed.path
213
+ or parsed.query
214
+ or parsed.fragment
215
+ or port is not None and not 1 <= port <= 65_535
216
+ or value != f"{parsed.scheme}://{parsed.netloc}"
217
+ ):
218
+ raise ValueError("origin must be one exact HTTPS origin")
219
+ return value
220
+
221
+
222
+ def _authentication_headers(provider, context) -> dict[str, str]:
223
+ try:
224
+ supplied = provider(dict(context))
225
+ if not isinstance(supplied, Mapping):
226
+ raise TypeError
227
+ items = tuple(supplied.items())
228
+ except Exception:
229
+ _fail("authentication")
230
+ if len(items) > MAX_AUTHENTICATION_HEADERS:
231
+ _fail("authentication")
232
+ result: dict[str, str] = {}
233
+ names: set[str] = set()
234
+ for name, value in items:
235
+ normalized = name.lower() if isinstance(name, str) else ""
236
+ if (
237
+ not isinstance(name, str)
238
+ or _HTTP.HEADER_NAME.fullmatch(name) is None
239
+ or normalized in RESERVED_HEADERS
240
+ or normalized in names
241
+ or not isinstance(value, str)
242
+ or not value
243
+ or len(value) > MAX_HEADER_VALUE_LENGTH
244
+ or "\r" in value
245
+ or "\n" in value
246
+ or any(ord(character) < 32 or ord(character) == 127 for character in value)
247
+ ):
248
+ _fail("authentication")
249
+ names.add(normalized)
250
+ result[name] = value
251
+ if not result:
252
+ _fail("authentication")
253
+ return result
254
+
255
+
256
+ def _response_headers(value: Any) -> dict[str, str]:
257
+ if not isinstance(value, tuple):
258
+ _fail("transport_invalid", retryable=True)
259
+ result: dict[str, str] = {}
260
+ for item in value:
261
+ if (
262
+ not isinstance(item, tuple)
263
+ or len(item) != 2
264
+ or not all(isinstance(part, str) for part in item)
265
+ ):
266
+ _fail("transport_invalid", retryable=True)
267
+ name, content = item[0].lower(), item[1].strip()
268
+ if name in result:
269
+ _fail("response_headers")
270
+ result[name] = content
271
+ return result
272
+
273
+
274
+ def _json_response(result: Any, allowed_statuses: set[int]) -> dict[str, Any]:
275
+ if (
276
+ not isinstance(result, HTTPResult)
277
+ or isinstance(result.status, bool)
278
+ or not isinstance(result.status, int)
279
+ or not 100 <= result.status <= 599
280
+ ):
281
+ _fail("transport_invalid", retryable=True)
282
+ if result.status not in allowed_statuses:
283
+ if 300 <= result.status <= 399:
284
+ _fail("redirect")
285
+ retryable = result.status in {408, 425, 429} or result.status >= 500
286
+ _fail("http_status", retryable=retryable)
287
+ headers = _response_headers(result.headers)
288
+ content_type = headers.get("content-type", "").lower().replace(" ", "")
289
+ if content_type not in {"application/json", "application/json;charset=utf-8"}:
290
+ _fail("response_content_type")
291
+ if (
292
+ not isinstance(result.body, bytes)
293
+ or not result.body
294
+ or len(result.body) > MAX_RESPONSE_BYTES
295
+ ):
296
+ _fail("response_size")
297
+ declared = headers.get("content-length")
298
+ if declared is not None and (
299
+ not declared.isascii()
300
+ or not declared.isdecimal()
301
+ or int(declared) != len(result.body)
302
+ ):
303
+ _fail("response_size")
304
+ try:
305
+ text = result.body.decode("utf-8")
306
+ if text.startswith("\ufeff"):
307
+ raise ValueError
308
+ value = json.loads(
309
+ text, object_pairs_hook=_pairs, parse_constant=_constant,
310
+ )
311
+ except (UnicodeDecodeError, json.JSONDecodeError, ValueError, RecursionError):
312
+ _fail("response_json")
313
+ if not isinstance(value, dict):
314
+ _fail("response_binding")
315
+ return value
316
+
317
+
318
+ def _copy_payload(value: Any, *, change: bool) -> tuple[dict[str, Any], bytes]:
319
+ if not isinstance(value, Mapping):
320
+ _fail("request_invalid")
321
+ raw = _canonical(dict(value))
322
+ try:
323
+ copied = json.loads(raw.decode("utf-8"), object_pairs_hook=_pairs)
324
+ if change:
325
+ _CMS.WebsiteLocalizationCMSBridge._validated_event(None, copied)
326
+ elif copied.get("schema") == _CMS.CANCELLATION_SCHEMA:
327
+ _CMS.WebsiteLocalizationCMSBridge._validated_cancellation(None, copied)
328
+ elif copied.get("schema") == _CMS.TOMBSTONE_SCHEMA:
329
+ _CMS.WebsiteLocalizationCMSBridge._validated_tombstone(None, copied)
330
+ else:
331
+ raise ValueError
332
+ except Exception:
333
+ _fail("request_invalid")
334
+ return copied, raw
335
+
336
+
337
+ def _identity(payload: Mapping[str, Any], *, change: bool) -> dict[str, str]:
338
+ try:
339
+ operation, request_id, event_id, site_id = _HTTP._identity(
340
+ payload, change=change,
341
+ )
342
+ except Exception:
343
+ _fail("request_invalid")
344
+ return {
345
+ "operation": operation,
346
+ "request_id": request_id,
347
+ "event_id": event_id,
348
+ "site_id": site_id,
349
+ "payload_sha256": hashlib.sha256(_canonical(dict(payload))).hexdigest(),
350
+ }
351
+
352
+
353
+ class _PayloadView:
354
+ def __init__(self, value: Mapping[str, Any]):
355
+ self.value = value
356
+
357
+ def as_payload(self) -> Mapping[str, Any]:
358
+ return self.value
359
+
360
+
361
+ class CMSSourceDeliverySubmissionHTTPClient:
362
+ """Operate one public website edge through its freshly pinned contract."""
363
+
364
+ def __init__(
365
+ self,
366
+ origin: str,
367
+ expected_capabilities_sha256: str,
368
+ authentication_headers: Callable[[Mapping[str, Any]], Mapping[str, str]],
369
+ *,
370
+ transport: HTTPTransport | None = None,
371
+ timeout: float | int = 30,
372
+ allow_loopback_http: bool = False,
373
+ ):
374
+ if not isinstance(allow_loopback_http, bool):
375
+ raise TypeError("allow_loopback_http must be boolean")
376
+ self.origin = _endpoint(origin, allow_loopback_http)
377
+ if not _sha256(expected_capabilities_sha256):
378
+ raise ValueError("expected capability digest is invalid")
379
+ if not callable(authentication_headers):
380
+ raise TypeError("authentication_headers must be callable")
381
+ if (
382
+ isinstance(timeout, bool)
383
+ or not isinstance(timeout, (int, float))
384
+ or not math.isfinite(float(timeout))
385
+ or not 0 < float(timeout) <= 300
386
+ ):
387
+ raise ValueError("timeout is outside the supported range")
388
+ self.expected_capabilities_sha256 = expected_capabilities_sha256
389
+ self.authentication_headers = authentication_headers
390
+ self.transport = URLTransport() if transport is None else transport
391
+ if not callable(getattr(self.transport, "request", None)):
392
+ raise TypeError("transport must provide request")
393
+ self.timeout = float(timeout)
394
+
395
+ def __repr__(self) -> str:
396
+ return "CMSSourceDeliverySubmissionHTTPClient(configured=True)"
397
+
398
+ def _request(
399
+ self, method, path, scope, body, allowed_statuses, context, headers=None,
400
+ ):
401
+ authentication = {
402
+ "schema": AUTH_CONTEXT_SCHEMA,
403
+ "method": method,
404
+ "origin": self.origin,
405
+ "path": path,
406
+ "scope": scope,
407
+ "body_sha256": hashlib.sha256(body or b"").hexdigest(),
408
+ **context,
409
+ }
410
+ request_headers = _authentication_headers(
411
+ self.authentication_headers, authentication,
412
+ )
413
+ request_headers["Accept"] = "application/json"
414
+ if body is not None:
415
+ request_headers["Content-Type"] = "application/json; charset=utf-8"
416
+ if headers:
417
+ request_headers.update(headers)
418
+ try:
419
+ result = self.transport.request(
420
+ method, self.origin + path, request_headers, body,
421
+ timeout=self.timeout,
422
+ )
423
+ except CMSSourceDeliverySubmissionClientBlocked:
424
+ raise
425
+ except Exception:
426
+ _fail("network", retryable=True)
427
+ return result, _json_response(result, allowed_statuses)
428
+
429
+ def capabilities(self) -> Mapping[str, Any]:
430
+ _result, response = self._request(
431
+ "GET",
432
+ _HTTP.CAPABILITIES_PATH,
433
+ _HTTP._SUBMISSION.CAPABILITIES_HTTP_SCOPE,
434
+ None,
435
+ {200},
436
+ {},
437
+ )
438
+ if (
439
+ set(response) != {"schema", "api_schema", "capabilities"}
440
+ or response.get("schema")
441
+ != _HTTP._SUBMISSION.CAPABILITIES_HTTP_RESPONSE_SCHEMA
442
+ or response.get("api_schema") != _HTTP._CAPABILITIES.API_SCHEMA
443
+ or not isinstance(response.get("capabilities"), Mapping)
444
+ ):
445
+ _fail("capabilities_binding")
446
+ try:
447
+ normalized = _HTTP._CAPABILITIES._capabilities(
448
+ _PayloadView(response["capabilities"])
449
+ )
450
+ except Exception:
451
+ _fail("capabilities_binding")
452
+ if (
453
+ normalized != response["capabilities"]
454
+ or normalized.get("sha256") != self.expected_capabilities_sha256
455
+ ):
456
+ _fail("capabilities_binding")
457
+ return response
458
+
459
+ def _contract(self, name: str) -> tuple[Mapping[str, Any], Mapping[str, Any]]:
460
+ capabilities = self.capabilities()["capabilities"]
461
+ return capabilities["operations"][name], capabilities
462
+
463
+ def submit_change(
464
+ self,
465
+ change: Mapping[str, Any],
466
+ *,
467
+ source_max_attempts: int = 5,
468
+ delivery_max_attempts: int = 5,
469
+ ) -> Mapping[str, Any]:
470
+ payload, _raw = _copy_payload(change, change=True)
471
+ return self._submit(
472
+ payload, change=True, source_max_attempts=source_max_attempts,
473
+ delivery_max_attempts=delivery_max_attempts,
474
+ )
475
+
476
+ def submit_removal(
477
+ self,
478
+ removal: Mapping[str, Any],
479
+ *,
480
+ source_max_attempts: int = 5,
481
+ delivery_max_attempts: int = 5,
482
+ ) -> Mapping[str, Any]:
483
+ payload, _raw = _copy_payload(removal, change=False)
484
+ return self._submit(
485
+ payload, change=False, source_max_attempts=source_max_attempts,
486
+ delivery_max_attempts=delivery_max_attempts,
487
+ )
488
+
489
+ def _submit(
490
+ self, payload, *, change, source_max_attempts, delivery_max_attempts,
491
+ ):
492
+ if not _count(source_max_attempts, minimum=1):
493
+ _fail("request_invalid")
494
+ if not _count(delivery_max_attempts, minimum=1):
495
+ _fail("request_invalid")
496
+ identity = _identity(payload, change=change)
497
+ name = "enqueue_change" if change else "enqueue_removal"
498
+ contract, capabilities = self._contract(name)
499
+ key = "change" if change else "removal"
500
+ request = {
501
+ "schema": contract["request_schema"],
502
+ key: payload,
503
+ "source_max_attempts": source_max_attempts,
504
+ "delivery_max_attempts": delivery_max_attempts,
505
+ }
506
+ body = _canonical(request)
507
+ _result, response = self._request(
508
+ contract["method"], contract["path"], contract["scope"], body,
509
+ {202}, identity,
510
+ {
511
+ "Idempotency-Key": identity["request_id"],
512
+ "X-Localization-Source-Payload-Sha256": (
513
+ identity["payload_sha256"]
514
+ ),
515
+ },
516
+ )
517
+ expected_fields = {
518
+ "schema", "api_schema", "operation", "request_id", "event_id",
519
+ "site_id", "payload_sha256", "status", "attempts",
520
+ "delivery_max_attempts", "source_max_attempts",
521
+ "capabilities_sha256", "website_capability_binding",
522
+ "accepted_implies_publication",
523
+ }
524
+ binding = capabilities["website_capability_binding"]
525
+ valid = (
526
+ set(response) == expected_fields
527
+ and response.get("schema") == contract["response_schema"]
528
+ and response.get("api_schema") == _HTTP.API_SCHEMA
529
+ and all(response.get(name) == value for name, value in identity.items())
530
+ and response.get("status") in _HTTP.STATUSES
531
+ and _count(response.get("attempts"))
532
+ and response["attempts"] <= delivery_max_attempts
533
+ and response.get("delivery_max_attempts") == delivery_max_attempts
534
+ and response.get("source_max_attempts") == source_max_attempts
535
+ and response.get("capabilities_sha256")
536
+ == binding["delivery_capabilities_sha256"]
537
+ and response.get("website_capability_binding") == binding
538
+ and response.get("accepted_implies_publication") is False
539
+ )
540
+ if not valid:
541
+ _fail("submission_binding")
542
+ return response
543
+
544
+ @staticmethod
545
+ def _read_identity(
546
+ operation, request_id, event_id, site_id, payload_sha256,
547
+ ) -> dict[str, str]:
548
+ identity = {
549
+ "operation": operation,
550
+ "request_id": request_id,
551
+ "event_id": event_id,
552
+ "site_id": site_id,
553
+ "payload_sha256": payload_sha256,
554
+ }
555
+ if (
556
+ operation not in {"change", "cancellation", "tombstone"}
557
+ or not all(_token(identity[name]) for name in (
558
+ "request_id", "event_id", "site_id",
559
+ ))
560
+ or operation == "change" and request_id != event_id
561
+ or not _sha256(payload_sha256)
562
+ ):
563
+ _fail("request_invalid")
564
+ return identity
565
+
566
+ def submission_status(
567
+ self, operation, request_id, event_id, site_id, payload_sha256,
568
+ ) -> Mapping[str, Any]:
569
+ return self._read(
570
+ "submission_status", operation, request_id, event_id, site_id,
571
+ payload_sha256,
572
+ )
573
+
574
+ def submission_lifecycle(
575
+ self, operation, request_id, event_id, site_id, payload_sha256,
576
+ ) -> Mapping[str, Any]:
577
+ return self._read(
578
+ "submission_lifecycle", operation, request_id, event_id, site_id,
579
+ payload_sha256,
580
+ )
581
+
582
+ def _read(
583
+ self, name, operation, request_id, event_id, site_id, payload_sha256,
584
+ ):
585
+ identity = self._read_identity(
586
+ operation, request_id, event_id, site_id, payload_sha256,
587
+ )
588
+ contract, capabilities = self._contract(name)
589
+ request = {"schema": contract["request_schema"], **identity}
590
+ body = _canonical(request)
591
+ _result, response = self._request(
592
+ contract["method"], contract["path"], contract["scope"], body,
593
+ {200}, identity,
594
+ )
595
+ if (
596
+ set(response)
597
+ != {"schema", "api_schema", "result", "accepted_implies_publication"}
598
+ or response.get("schema") != contract["response_schema"]
599
+ or response.get("api_schema") != _HTTP.API_SCHEMA
600
+ or response.get("accepted_implies_publication") is not False
601
+ or not isinstance(response.get("result"), Mapping)
602
+ ):
603
+ _fail("status_binding" if name == "submission_status" else "lifecycle_binding")
604
+ try:
605
+ if name == "submission_status":
606
+ normalized = _HTTP._submission_status_payload(
607
+ _PayloadView(response["result"]), identity,
608
+ )
609
+ else:
610
+ normalized = _HTTP._submission_lifecycle_payload(
611
+ _PayloadView(response["result"]), identity,
612
+ )
613
+ except Exception:
614
+ _fail("status_binding" if name == "submission_status" else "lifecycle_binding")
615
+ binding = capabilities["website_capability_binding"]
616
+ if normalized != response["result"] or (
617
+ normalized.get("website_capability_binding") != binding
618
+ ):
619
+ _fail("status_binding" if name == "submission_status" else "lifecycle_binding")
620
+ if name == "submission_lifecycle" and (
621
+ normalized["sidecar_capabilities_sha256"]
622
+ != capabilities["sidecar_capabilities_sha256"]
623
+ or normalized["source_capabilities_sha256"]
624
+ != capabilities["source_capabilities_sha256"]
625
+ ):
626
+ _fail("lifecycle_binding")
627
+ return response
628
+
629
+ def pipeline_health(self) -> Mapping[str, Any]:
630
+ return self._monitor("submission_pipeline_health")
631
+
632
+ def pipeline_readiness(self) -> Mapping[str, Any]:
633
+ return self._monitor("submission_pipeline_readiness")
634
+
635
+ def _monitor(self, name: str) -> Mapping[str, Any]:
636
+ contract, capabilities = self._contract(name)
637
+ _result, response = self._request(
638
+ contract["method"], contract["path"], contract["scope"], None,
639
+ {200}, {},
640
+ )
641
+ code = "health_binding" if name.endswith("health") else "readiness_binding"
642
+ if (
643
+ set(response) != {
644
+ "schema", "api_schema", "result", "content_free",
645
+ "publication_authority",
646
+ }
647
+ or response.get("schema") != contract["response_schema"]
648
+ or response.get("api_schema") != _HTTP.API_SCHEMA
649
+ or response.get("content_free") is not True
650
+ or response.get("publication_authority") is not False
651
+ or not isinstance(response.get("result"), Mapping)
652
+ ):
653
+ _fail(code)
654
+ try:
655
+ validator = (
656
+ _HTTP._pipeline_health_payload
657
+ if name.endswith("health")
658
+ else _HTTP._pipeline_readiness_payload
659
+ )
660
+ normalized = validator(_PayloadView(response["result"]))
661
+ except Exception:
662
+ _fail(code)
663
+ if (
664
+ normalized != response["result"]
665
+ or normalized["website_capability_binding"]
666
+ != capabilities["website_capability_binding"]
667
+ or normalized["sidecar_capabilities_sha256"]
668
+ != capabilities["sidecar_capabilities_sha256"]
669
+ or normalized["source_capabilities_sha256"]
670
+ != capabilities["source_capabilities_sha256"]
671
+ ):
672
+ _fail(code)
673
+ return response