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,1180 @@
1
+ #!/usr/bin/env python3
2
+ """Authenticated public ingress and progress API for the website edge.
3
+
4
+ The WSGI boundary accepts one exact website change or removal per request.
5
+ Authentication sees request metadata and the body digest, while the principal
6
+ is bound to the payload site before any runtime or durable outbox access.
7
+ HTTP 202 means only that the website outbox durably accepted the request; it
8
+ never implies source acceptance, localization quality, approval, or publication.
9
+ Separate site-bound reads expose acceptance and source lifecycle without
10
+ collapsing either state into publication authority.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import dataclasses
16
+ import hashlib
17
+ import importlib.util
18
+ import json
19
+ import math
20
+ import re
21
+ import sys
22
+ import unicodedata
23
+ from pathlib import Path
24
+ from typing import Any, Callable, Mapping
25
+
26
+
27
+ def _load_module(name: str, path: Path):
28
+ spec = importlib.util.spec_from_file_location(name, path)
29
+ if spec is None or spec.loader is None:
30
+ raise RuntimeError("cannot load submission HTTP dependency")
31
+ module = importlib.util.module_from_spec(spec)
32
+ sys.modules[spec.name] = module
33
+ spec.loader.exec_module(module)
34
+ return module
35
+
36
+
37
+ _ROOT = Path(__file__).resolve().parents[1]
38
+ _SUBMISSION = _load_module(
39
+ "blun_website_localization_submission_http_runtime",
40
+ _ROOT
41
+ / "integrations"
42
+ / "website_localization_cms_source_delivery_submission_runtime.py",
43
+ )
44
+ _CAPABILITIES = _load_module(
45
+ "blun_website_localization_submission_http_capabilities",
46
+ _ROOT
47
+ / "integrations"
48
+ / "website_localization_cms_source_delivery_submission_capabilities_http.py",
49
+ )
50
+
51
+ API_SCHEMA = "blun.cms-source-delivery-submission-http.v3"
52
+ ERROR_SCHEMA = "blun.cms-source-delivery-submission-http-error.v1"
53
+ AUTH_REQUEST_SCHEMA = _SUBMISSION.WRITE_HTTP_AUTH_REQUEST_SCHEMA
54
+ PRINCIPAL_SCHEMA = _SUBMISSION.WRITE_HTTP_PRINCIPAL_SCHEMA
55
+ READ_AUTH_REQUEST_SCHEMA = _SUBMISSION.READ_HTTP_AUTH_REQUEST_SCHEMA
56
+ READ_PRINCIPAL_SCHEMA = _SUBMISSION.READ_HTTP_PRINCIPAL_SCHEMA
57
+ CHANGE_REQUEST_SCHEMA = _SUBMISSION.CHANGE_HTTP_REQUEST_SCHEMA
58
+ REMOVAL_REQUEST_SCHEMA = _SUBMISSION.REMOVAL_HTTP_REQUEST_SCHEMA
59
+ CHANGE_RESPONSE_SCHEMA = _SUBMISSION.CHANGE_HTTP_RESPONSE_SCHEMA
60
+ REMOVAL_RESPONSE_SCHEMA = _SUBMISSION.REMOVAL_HTTP_RESPONSE_SCHEMA
61
+ CHANGE_PATH = _SUBMISSION.CHANGE_HTTP_PATH
62
+ REMOVAL_PATH = _SUBMISSION.REMOVAL_HTTP_PATH
63
+ STATUS_PATH = _SUBMISSION.STATUS_HTTP_PATH
64
+ LIFECYCLE_PATH = _SUBMISSION.LIFECYCLE_HTTP_PATH
65
+ PIPELINE_HEALTH_PATH = _SUBMISSION.PIPELINE_HEALTH_HTTP_PATH
66
+ PIPELINE_READINESS_PATH = _SUBMISSION.PIPELINE_READINESS_HTTP_PATH
67
+ CAPABILITIES_PATH = _SUBMISSION.CAPABILITIES_HTTP_PATH
68
+ CHANGE_SCOPE = _SUBMISSION.CHANGE_HTTP_SCOPE
69
+ REMOVAL_SCOPE = _SUBMISSION.REMOVAL_HTTP_SCOPE
70
+ STATUS_SCOPE = _SUBMISSION.STATUS_HTTP_SCOPE
71
+ LIFECYCLE_SCOPE = _SUBMISSION.LIFECYCLE_HTTP_SCOPE
72
+ PIPELINE_HEALTH_SCOPE = _SUBMISSION.PIPELINE_HEALTH_HTTP_SCOPE
73
+ PIPELINE_READINESS_SCOPE = _SUBMISSION.PIPELINE_READINESS_HTTP_SCOPE
74
+ OPERATOR_AUTH_REQUEST_SCHEMA = _SUBMISSION.OPERATOR_HTTP_AUTH_REQUEST_SCHEMA
75
+ OPERATOR_PRINCIPAL_SCHEMA = _SUBMISSION.OPERATOR_HTTP_PRINCIPAL_SCHEMA
76
+
77
+ MAX_BODY_BYTES = 4_000_000
78
+ MAX_HEADERS = 64
79
+ MAX_HEADER_VALUE = 4096
80
+ TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$")
81
+ SHA256 = re.compile(r"^[0-9a-f]{64}$")
82
+ ERROR_CODE = re.compile(r"^[a-z][a-z0-9_.-]{0,127}$")
83
+ HEADER_NAME = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$")
84
+ STATUSES = {"pending", "leased", "retry_wait", "succeeded", "failed"}
85
+ SCOPES = {
86
+ CHANGE_PATH: CHANGE_SCOPE,
87
+ REMOVAL_PATH: REMOVAL_SCOPE,
88
+ STATUS_PATH: STATUS_SCOPE,
89
+ LIFECYCLE_PATH: LIFECYCLE_SCOPE,
90
+ }
91
+ REQUEST_SCHEMAS = {
92
+ CHANGE_PATH: CHANGE_REQUEST_SCHEMA,
93
+ REMOVAL_PATH: REMOVAL_REQUEST_SCHEMA,
94
+ STATUS_PATH: _SUBMISSION.STATUS_HTTP_REQUEST_SCHEMA,
95
+ LIFECYCLE_PATH: _SUBMISSION.LIFECYCLE_HTTP_REQUEST_SCHEMA,
96
+ }
97
+ RESPONSE_SCHEMAS = {
98
+ CHANGE_PATH: CHANGE_RESPONSE_SCHEMA,
99
+ REMOVAL_PATH: REMOVAL_RESPONSE_SCHEMA,
100
+ STATUS_PATH: _SUBMISSION.STATUS_HTTP_RESPONSE_SCHEMA,
101
+ LIFECYCLE_PATH: _SUBMISSION.LIFECYCLE_HTTP_RESPONSE_SCHEMA,
102
+ }
103
+ READ_PATHS = {STATUS_PATH, LIFECYCLE_PATH}
104
+ MONITOR_PATHS = {PIPELINE_HEALTH_PATH, PIPELINE_READINESS_PATH}
105
+ MONITOR_SCOPES = {
106
+ PIPELINE_HEALTH_PATH: PIPELINE_HEALTH_SCOPE,
107
+ PIPELINE_READINESS_PATH: PIPELINE_READINESS_SCOPE,
108
+ }
109
+ MONITOR_RESPONSE_SCHEMAS = {
110
+ PIPELINE_HEALTH_PATH: _SUBMISSION.PIPELINE_HEALTH_HTTP_RESPONSE_SCHEMA,
111
+ PIPELINE_READINESS_PATH: (
112
+ _SUBMISSION.PIPELINE_READINESS_HTTP_RESPONSE_SCHEMA
113
+ ),
114
+ }
115
+ EMPTY_SHA256 = hashlib.sha256(b"").hexdigest()
116
+
117
+
118
+ class SubmissionHTTPBlocked(RuntimeError):
119
+ """Stable, content-free failure at the public website boundary."""
120
+
121
+ def __init__(self, code: str, status: int):
122
+ if ERROR_CODE.fullmatch(code) is None or status not in {
123
+ 400, 401, 403, 404, 405, 409, 411, 413, 415, 503,
124
+ }:
125
+ raise ValueError("invalid submission HTTP failure")
126
+ super().__init__(code)
127
+ self.code = code
128
+ self.status = status
129
+
130
+
131
+ def _blocked(code: str, status: int) -> SubmissionHTTPBlocked:
132
+ return SubmissionHTTPBlocked("submission_http." + code, status)
133
+
134
+
135
+ def _canonical(value: Any, *, response: bool = False) -> bytes:
136
+ try:
137
+ raw = json.dumps(
138
+ value,
139
+ ensure_ascii=False,
140
+ allow_nan=False,
141
+ sort_keys=True,
142
+ separators=(",", ":"),
143
+ ).encode("utf-8")
144
+ except (TypeError, ValueError, RecursionError):
145
+ raise _blocked(
146
+ "response_invalid" if response else "request_invalid",
147
+ 503 if response else 400,
148
+ ) from None
149
+ limit = 1_000_000 if response else MAX_BODY_BYTES
150
+ if not raw or len(raw) > limit:
151
+ raise _blocked(
152
+ "response_invalid" if response else "request_invalid",
153
+ 503 if response else 400,
154
+ )
155
+ return raw
156
+
157
+
158
+ def _pairs(items: list[tuple[str, Any]]) -> dict[str, Any]:
159
+ result: dict[str, Any] = {}
160
+ for key, value in items:
161
+ if key in result:
162
+ raise ValueError("duplicate key")
163
+ result[key] = value
164
+ return result
165
+
166
+
167
+ def _constant(_value: str) -> None:
168
+ raise ValueError("non-finite number")
169
+
170
+
171
+ def _token(value: Any) -> str:
172
+ if (
173
+ not isinstance(value, str)
174
+ or TOKEN.fullmatch(value) is None
175
+ or not unicodedata.is_normalized("NFC", value)
176
+ ):
177
+ raise ValueError
178
+ return value
179
+
180
+
181
+ def _sha256(value: Any) -> str:
182
+ if not isinstance(value, str) or SHA256.fullmatch(value) is None:
183
+ raise ValueError
184
+ return value
185
+
186
+
187
+ def _headers(environ: Mapping[str, Any]) -> tuple[tuple[str, str], ...]:
188
+ headers = []
189
+ for key, value in environ.items():
190
+ if key.startswith("HTTP_"):
191
+ name = key[5:].replace("_", "-").lower()
192
+ elif key in {"CONTENT_TYPE", "CONTENT_LENGTH"}:
193
+ name = key.replace("_", "-").lower()
194
+ else:
195
+ continue
196
+ if (
197
+ HEADER_NAME.fullmatch(name) is None
198
+ or not isinstance(value, str)
199
+ or len(value) > MAX_HEADER_VALUE
200
+ or "\r" in value
201
+ or "\n" in value
202
+ ):
203
+ raise _blocked("headers_invalid", 400)
204
+ headers.append((name, value))
205
+ headers.sort()
206
+ if (
207
+ len(headers) > MAX_HEADERS
208
+ or len({name for name, _ in headers}) != len(headers)
209
+ ):
210
+ raise _blocked("headers_invalid", 400)
211
+ return tuple(headers)
212
+
213
+
214
+ def _content_type(value: Any) -> bool:
215
+ if not isinstance(value, str):
216
+ return False
217
+ parts = [item.strip().lower() for item in value.split(";")]
218
+ return parts in (["application/json"], ["application/json", "charset=utf-8"])
219
+
220
+
221
+ def _body(environ: Mapping[str, Any]) -> bytes:
222
+ if environ.get("HTTP_TRANSFER_ENCODING") not in {None, ""}:
223
+ raise _blocked("transfer_encoding_rejected", 400)
224
+ length = environ.get("CONTENT_LENGTH")
225
+ if (
226
+ not isinstance(length, str)
227
+ or not length.isascii()
228
+ or not length.isdecimal()
229
+ ):
230
+ raise _blocked("content_length_required", 411)
231
+ size = int(length)
232
+ if size <= 0:
233
+ raise _blocked("body_invalid", 400)
234
+ if size > MAX_BODY_BYTES:
235
+ raise _blocked("body_too_large", 413)
236
+ stream = environ.get("wsgi.input")
237
+ try:
238
+ body = stream.read(size)
239
+ except Exception:
240
+ body = None
241
+ if not isinstance(body, bytes) or len(body) != size:
242
+ raise _blocked("body_invalid", 400)
243
+ return body
244
+
245
+
246
+ def _request(body: bytes) -> Mapping[str, Any]:
247
+ try:
248
+ text = body.decode("utf-8")
249
+ if text.startswith("\ufeff"):
250
+ raise ValueError
251
+ value = json.loads(
252
+ text,
253
+ object_pairs_hook=_pairs,
254
+ parse_constant=_constant,
255
+ )
256
+ except (UnicodeDecodeError, json.JSONDecodeError, ValueError, RecursionError):
257
+ raise _blocked("json_invalid", 400) from None
258
+ if not isinstance(value, Mapping):
259
+ raise _blocked("request_invalid", 400)
260
+ return value
261
+
262
+
263
+ def _principal(
264
+ value: Any, scope: str, *, schema: str = PRINCIPAL_SCHEMA,
265
+ ) -> dict[str, str]:
266
+ fields = {
267
+ "schema", "principal_id", "credential_id", "credential_version",
268
+ "scope", "site_id",
269
+ }
270
+ if not isinstance(value, Mapping) or set(value) != fields:
271
+ raise _blocked("authentication_failed", 401)
272
+ try:
273
+ if value["schema"] != schema:
274
+ raise ValueError
275
+ for name in fields - {"schema"}:
276
+ _token(value[name])
277
+ except (KeyError, TypeError, ValueError):
278
+ raise _blocked("authentication_failed", 401) from None
279
+ if value["scope"] != scope:
280
+ raise _blocked("scope_rejected", 403)
281
+ return dict(value)
282
+
283
+
284
+ def _operator_principal(value: Any, scope: str) -> dict[str, str]:
285
+ fields = {
286
+ "schema", "principal_id", "credential_id", "credential_version",
287
+ "scope",
288
+ }
289
+ if not isinstance(value, Mapping) or set(value) != fields:
290
+ raise _blocked("authentication_failed", 401)
291
+ try:
292
+ if value["schema"] != OPERATOR_PRINCIPAL_SCHEMA:
293
+ raise ValueError
294
+ for name in fields - {"schema"}:
295
+ _token(value[name])
296
+ except (KeyError, TypeError, ValueError):
297
+ raise _blocked("authentication_failed", 401) from None
298
+ if value["scope"] != scope:
299
+ raise _blocked("scope_rejected", 403)
300
+ return dict(value)
301
+
302
+
303
+ def _attempts(value: Any) -> int:
304
+ if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 20:
305
+ raise _blocked("request_invalid", 400)
306
+ return value
307
+
308
+
309
+ def _identity(payload: Any, *, change: bool) -> tuple[str, str, str, str]:
310
+ if not isinstance(payload, Mapping):
311
+ raise _blocked("request_invalid", 400)
312
+ schema = payload.get("schema")
313
+ event_id = payload.get("event_id")
314
+ site_id = payload.get("site_id")
315
+ if change and schema == _SUBMISSION._ADAPTER.CHANGE_SCHEMA:
316
+ operation, request_id = "change", event_id
317
+ elif not change and schema == _SUBMISSION._ADAPTER.CANCELLATION_SCHEMA:
318
+ operation, request_id = "cancellation", payload.get("cancellation_id")
319
+ elif not change and schema == _SUBMISSION._ADAPTER.TOMBSTONE_SCHEMA:
320
+ operation, request_id = "tombstone", payload.get("tombstone_id")
321
+ else:
322
+ raise _blocked("request_invalid", 400)
323
+ try:
324
+ for value in (request_id, event_id, site_id):
325
+ _token(value)
326
+ except ValueError:
327
+ raise _blocked("request_invalid", 400) from None
328
+ return operation, request_id, event_id, site_id
329
+
330
+
331
+ def _binding(value: Any) -> dict[str, Any]:
332
+ try:
333
+ return _CAPABILITIES._binding(value)
334
+ except Exception:
335
+ raise _blocked("runtime_response_invalid", 503) from None
336
+
337
+
338
+ def _status(
339
+ value: Any,
340
+ *,
341
+ operation: str,
342
+ request_id: str,
343
+ event_id: str,
344
+ site_id: str,
345
+ payload_sha256: str,
346
+ source_max_attempts: int,
347
+ delivery_max_attempts: int,
348
+ capabilities_sha256: str,
349
+ ) -> dict[str, Any]:
350
+ try:
351
+ fields = {
352
+ "operation", "request_id", "event_id", "site_id",
353
+ "payload_sha256", "capabilities_sha256", "status", "attempts",
354
+ "delivery_max_attempts", "source_max_attempts", "next_attempt_at",
355
+ "lease_expires_at", "lease_expired", "last_error_code",
356
+ "response_sha256",
357
+ }
358
+ payload = dataclasses.asdict(value)
359
+ if set(payload) != fields:
360
+ raise ValueError
361
+ expected = {
362
+ "operation": operation,
363
+ "request_id": request_id,
364
+ "event_id": event_id,
365
+ "site_id": site_id,
366
+ "payload_sha256": payload_sha256,
367
+ "capabilities_sha256": capabilities_sha256,
368
+ "delivery_max_attempts": delivery_max_attempts,
369
+ "source_max_attempts": source_max_attempts,
370
+ }
371
+ if any(payload[name] != expected_value for name, expected_value in expected.items()):
372
+ raise ValueError
373
+ if payload["status"] not in STATUSES:
374
+ raise ValueError
375
+ attempts = payload["attempts"]
376
+ if (
377
+ isinstance(attempts, bool)
378
+ or not isinstance(attempts, int)
379
+ or not 0 <= attempts <= delivery_max_attempts
380
+ or not isinstance(payload["lease_expired"], bool)
381
+ ):
382
+ raise ValueError
383
+ for name in ("next_attempt_at",):
384
+ number = payload[name]
385
+ if (
386
+ isinstance(number, bool)
387
+ or not isinstance(number, (int, float))
388
+ or not math.isfinite(float(number))
389
+ or float(number) < 0
390
+ ):
391
+ raise ValueError
392
+ lease = payload["lease_expires_at"]
393
+ if lease is not None and (
394
+ isinstance(lease, bool)
395
+ or not isinstance(lease, (int, float))
396
+ or not math.isfinite(float(lease))
397
+ or float(lease) < 0
398
+ ):
399
+ raise ValueError
400
+ if (payload["status"] == "leased") != (lease is not None):
401
+ raise ValueError
402
+ error = payload["last_error_code"]
403
+ if error is not None and (
404
+ not isinstance(error, str)
405
+ or ERROR_CODE.fullmatch(error) is None
406
+ ):
407
+ raise ValueError
408
+ if payload["status"] in {"retry_wait", "failed"} and error is None:
409
+ raise ValueError
410
+ response_hash = payload["response_sha256"]
411
+ if response_hash is not None:
412
+ _sha256(response_hash)
413
+ if (payload["status"] == "succeeded") != (response_hash is not None):
414
+ raise ValueError
415
+ return {
416
+ "operation": operation,
417
+ "request_id": request_id,
418
+ "event_id": event_id,
419
+ "site_id": site_id,
420
+ "payload_sha256": payload_sha256,
421
+ "status": payload["status"],
422
+ "attempts": attempts,
423
+ "delivery_max_attempts": delivery_max_attempts,
424
+ "source_max_attempts": source_max_attempts,
425
+ "capabilities_sha256": capabilities_sha256,
426
+ }
427
+ except SubmissionHTTPBlocked:
428
+ raise
429
+ except Exception:
430
+ raise _blocked("runtime_response_invalid", 503) from None
431
+
432
+
433
+ class _PayloadView:
434
+ def __init__(self, value: Mapping[str, Any]):
435
+ self.value = value
436
+
437
+ def as_payload(self) -> Mapping[str, Any]:
438
+ return self.value
439
+
440
+
441
+ def _read_identity(value: Any, *, schema: str) -> dict[str, str]:
442
+ fields = {
443
+ "schema", "operation", "request_id", "event_id", "site_id",
444
+ "payload_sha256",
445
+ }
446
+ try:
447
+ if not isinstance(value, Mapping) or set(value) != fields:
448
+ raise ValueError
449
+ if value["schema"] != schema:
450
+ raise ValueError
451
+ result = {name: _token(value[name]) for name in (
452
+ "operation", "request_id", "event_id", "site_id",
453
+ )}
454
+ if result["operation"] not in {"change", "cancellation", "tombstone"}:
455
+ raise ValueError
456
+ if result["operation"] == "change" and result["request_id"] != result["event_id"]:
457
+ raise ValueError
458
+ result["payload_sha256"] = _sha256(value["payload_sha256"])
459
+ return result
460
+ except Exception:
461
+ raise _blocked("request_invalid", 400) from None
462
+
463
+
464
+ def _submission_status_payload(
465
+ value: Any, identity: Mapping[str, str],
466
+ ) -> dict[str, Any]:
467
+ fields = {
468
+ "schema", "operation", "request_id", "event_id", "site_id",
469
+ "payload_sha256", "status", "stage", "website_status",
470
+ "website_attempts", "website_delivery_max_attempts",
471
+ "sidecar_status", "sidecar_attempts",
472
+ "sidecar_delivery_max_attempts", "source_max_attempts",
473
+ "next_attempt_at", "lease_expired", "error_code",
474
+ "website_capability_binding",
475
+ }
476
+ try:
477
+ payload = value.as_payload()
478
+ if not isinstance(payload, Mapping) or set(payload) != fields:
479
+ raise ValueError
480
+ if payload["schema"] != _SUBMISSION.STATUS_SCHEMA:
481
+ raise ValueError
482
+ if payload.get("site_id") != identity["site_id"]:
483
+ raise _blocked("site_not_found", 404)
484
+ for name in ("operation", "request_id", "event_id", "payload_sha256"):
485
+ if payload.get(name) != identity[name]:
486
+ raise ValueError
487
+ if payload["status"] not in {"pending", "accepted", "failed"}:
488
+ raise ValueError
489
+ if payload["stage"] not in {
490
+ "website_acceptance", "sidecar_delivery", "source_acceptance",
491
+ }:
492
+ raise ValueError
493
+ website_max = payload["website_delivery_max_attempts"]
494
+ sidecar_max = payload["sidecar_delivery_max_attempts"]
495
+ source_max = payload["source_max_attempts"]
496
+ for maximum in (website_max, sidecar_max, source_max):
497
+ if isinstance(maximum, bool) or not isinstance(maximum, int) or not 1 <= maximum <= 20:
498
+ raise ValueError
499
+ website_attempts = payload["website_attempts"]
500
+ if (
501
+ isinstance(website_attempts, bool)
502
+ or not isinstance(website_attempts, int)
503
+ or not 0 <= website_attempts <= website_max
504
+ or payload["website_status"] not in STATUSES
505
+ ):
506
+ raise ValueError
507
+ sidecar_attempts = payload["sidecar_attempts"]
508
+ sidecar_status = payload["sidecar_status"]
509
+ if (sidecar_status is None) != (sidecar_attempts is None):
510
+ raise ValueError
511
+ if sidecar_status is not None and (
512
+ sidecar_status not in STATUSES
513
+ or isinstance(sidecar_attempts, bool)
514
+ or not isinstance(sidecar_attempts, int)
515
+ or not 0 <= sidecar_attempts <= sidecar_max
516
+ ):
517
+ raise ValueError
518
+ next_attempt = payload["next_attempt_at"]
519
+ if (
520
+ isinstance(next_attempt, bool)
521
+ or not isinstance(next_attempt, (int, float))
522
+ or not math.isfinite(float(next_attempt))
523
+ or float(next_attempt) < 0
524
+ or not isinstance(payload["lease_expired"], bool)
525
+ ):
526
+ raise ValueError
527
+ error = payload["error_code"]
528
+ if error is not None and (
529
+ not isinstance(error, str) or ERROR_CODE.fullmatch(error) is None
530
+ ):
531
+ raise ValueError
532
+ if payload["stage"] == "website_acceptance":
533
+ if (
534
+ sidecar_status is not None
535
+ or payload["website_status"] == "succeeded"
536
+ or payload["status"]
537
+ != ("failed" if payload["website_status"] == "failed" else "pending")
538
+ ):
539
+ raise ValueError
540
+ elif payload["stage"] == "sidecar_delivery":
541
+ if (
542
+ payload["website_status"] != "succeeded"
543
+ or sidecar_status in {None, "succeeded"}
544
+ or payload["status"]
545
+ != ("failed" if sidecar_status == "failed" else "pending")
546
+ ):
547
+ raise ValueError
548
+ elif (
549
+ payload["status"] != "accepted"
550
+ or payload["website_status"] != "succeeded"
551
+ or sidecar_status != "succeeded"
552
+ ):
553
+ raise ValueError
554
+ result = dict(payload)
555
+ result["website_capability_binding"] = _binding(
556
+ payload["website_capability_binding"]
557
+ )
558
+ _canonical(result, response=True)
559
+ return result
560
+ except SubmissionHTTPBlocked:
561
+ raise
562
+ except Exception:
563
+ raise _blocked("runtime_response_invalid", 503) from None
564
+
565
+
566
+ def _submission_lifecycle_payload(
567
+ value: Any, identity: Mapping[str, str],
568
+ ) -> dict[str, Any]:
569
+ fields = {
570
+ "schema", "status", "stage", "submission", "source_status",
571
+ "source_capability_binding", "website_capability_binding",
572
+ "sidecar_capabilities_sha256", "source_capabilities_sha256",
573
+ }
574
+ try:
575
+ payload = value.as_payload()
576
+ if not isinstance(payload, Mapping) or set(payload) != fields:
577
+ raise ValueError
578
+ if payload["schema"] != _SUBMISSION.LIFECYCLE_SCHEMA:
579
+ raise ValueError
580
+ submission = _submission_status_payload(
581
+ _PayloadView(payload["submission"]), identity,
582
+ )
583
+ website_binding = _binding(payload["website_capability_binding"])
584
+ if website_binding != submission["website_capability_binding"]:
585
+ raise ValueError
586
+ sidecar_hash = _sha256(payload["sidecar_capabilities_sha256"])
587
+ source_hash = _sha256(payload["source_capabilities_sha256"])
588
+ source_status = payload["source_status"]
589
+ source_binding = payload["source_capability_binding"]
590
+ if source_status is None or source_binding is None:
591
+ if source_status is not None or source_binding is not None:
592
+ raise ValueError
593
+ if (
594
+ payload["status"] != submission["status"]
595
+ or payload["stage"] != submission["stage"]
596
+ ):
597
+ raise ValueError
598
+ else:
599
+ if submission["status"] != "accepted":
600
+ raise ValueError
601
+ normalized = _SUBMISSION._AUTH._HTTP._SOURCE_HTTP._source_status_payload(
602
+ _PayloadView(source_status),
603
+ expected_event_id=identity["event_id"],
604
+ expected_site_id=identity["site_id"],
605
+ )
606
+ if normalized != source_status:
607
+ raise ValueError
608
+ normalized_binding = (
609
+ _SUBMISSION._AUTH._HTTP._SOURCE_HTTP._capability_binding_payload(
610
+ source_binding
611
+ )
612
+ )
613
+ if normalized_binding != source_binding:
614
+ raise ValueError
615
+ if payload["stage"] not in {
616
+ "source_processing", "localization_lifecycle",
617
+ }:
618
+ raise ValueError
619
+ result = dict(payload)
620
+ result.update({
621
+ "submission": submission,
622
+ "website_capability_binding": website_binding,
623
+ "sidecar_capabilities_sha256": sidecar_hash,
624
+ "source_capabilities_sha256": source_hash,
625
+ })
626
+ _canonical(result, response=True)
627
+ return result
628
+ except SubmissionHTTPBlocked:
629
+ raise
630
+ except Exception:
631
+ raise _blocked("runtime_response_invalid", 503) from None
632
+
633
+
634
+ def _submission_health_payload(value: Any) -> dict[str, Any]:
635
+ fields = {
636
+ "schema", "status", "website_health", "sidecar_health",
637
+ "sidecar_capabilities_sha256", "source_capabilities_sha256",
638
+ "website_capability_binding",
639
+ }
640
+ try:
641
+ if not isinstance(value, Mapping) or set(value) != fields:
642
+ raise ValueError
643
+ if value["schema"] != _SUBMISSION.HEALTH_SCHEMA:
644
+ raise ValueError
645
+ website = _SUBMISSION._AUTH._HTTP._health_payload(
646
+ _PayloadView(value["website_health"])
647
+ )
648
+ sidecar = _SUBMISSION._AUTH._HTTP._health_payload(
649
+ _PayloadView(value["sidecar_health"])
650
+ )
651
+ if (
652
+ website != value["website_health"]
653
+ or sidecar != value["sidecar_health"]
654
+ ):
655
+ raise ValueError
656
+ status = (
657
+ "ok"
658
+ if website["status"] == sidecar["status"] == "ok"
659
+ else "blocked"
660
+ )
661
+ if value["status"] != status:
662
+ raise ValueError
663
+ binding = _binding(value["website_capability_binding"])
664
+ sidecar_hash = _sha256(value["sidecar_capabilities_sha256"])
665
+ source_hash = _sha256(value["source_capabilities_sha256"])
666
+ result = dict(value)
667
+ result.update({
668
+ "website_health": website,
669
+ "sidecar_health": sidecar,
670
+ "website_capability_binding": binding,
671
+ "sidecar_capabilities_sha256": sidecar_hash,
672
+ "source_capabilities_sha256": source_hash,
673
+ })
674
+ return result
675
+ except Exception:
676
+ raise _blocked("runtime_response_invalid", 503) from None
677
+
678
+
679
+ def _submission_readiness_payload(value: Any) -> dict[str, Any]:
680
+ fields = {
681
+ "schema", "status", "website_status", "website_worker_state",
682
+ "website_outbox_status", "website_error_code", "sidecar_status",
683
+ "sidecar_worker_state", "sidecar_outbox_status", "sidecar_error_code",
684
+ "sidecar_capabilities_sha256", "source_capabilities_sha256",
685
+ "website_capability_binding",
686
+ }
687
+ try:
688
+ if not isinstance(value, Mapping) or set(value) != fields:
689
+ raise ValueError
690
+ if value["schema"] != _SUBMISSION.READINESS_SCHEMA:
691
+ raise ValueError
692
+ website = _SUBMISSION._AUTH._HTTP._readiness_payload({
693
+ "schema": "blun.cms-source-delivery-worker-readiness.v1",
694
+ "status": value["website_status"],
695
+ "worker_state": value["website_worker_state"],
696
+ "outbox_status": value["website_outbox_status"],
697
+ "error_code": value["website_error_code"],
698
+ })
699
+ sidecar_values = (
700
+ value["sidecar_status"], value["sidecar_worker_state"],
701
+ value["sidecar_outbox_status"], value["sidecar_error_code"],
702
+ )
703
+ if all(item is None for item in sidecar_values):
704
+ sidecar = None
705
+ elif any(item is None for item in sidecar_values[:2]):
706
+ raise ValueError
707
+ else:
708
+ sidecar = _SUBMISSION._AUTH._HTTP._readiness_payload({
709
+ "schema": "blun.cms-source-delivery-worker-readiness.v1",
710
+ "status": sidecar_values[0],
711
+ "worker_state": sidecar_values[1],
712
+ "outbox_status": sidecar_values[2],
713
+ "error_code": sidecar_values[3],
714
+ })
715
+ ready = (
716
+ website["status"] == "ready"
717
+ and sidecar is not None
718
+ and sidecar["status"] == "ready"
719
+ )
720
+ if value["status"] != ("ready" if ready else "not_ready"):
721
+ raise ValueError
722
+ if website["status"] != "ready" and sidecar is not None:
723
+ raise ValueError
724
+ binding = _binding(value["website_capability_binding"])
725
+ sidecar_hash = _sha256(value["sidecar_capabilities_sha256"])
726
+ source_hash = _sha256(value["source_capabilities_sha256"])
727
+ result = dict(value)
728
+ result.update({
729
+ "website_capability_binding": binding,
730
+ "sidecar_capabilities_sha256": sidecar_hash,
731
+ "source_capabilities_sha256": source_hash,
732
+ })
733
+ return result
734
+ except Exception:
735
+ raise _blocked("runtime_response_invalid", 503) from None
736
+
737
+
738
+ def _validate_source_health_semantics(value: Mapping[str, Any]) -> None:
739
+ components = [value[name] for name in ("changes", "removals", "lifecycle")]
740
+ for name in ("notifications", "terminal_processing"):
741
+ if value[name]:
742
+ components.append(value[name])
743
+ for component in components:
744
+ counts = component["counts"]
745
+ blockers = (
746
+ component["failed"]
747
+ + component["expired_leases"]
748
+ + component.get("remote_failures", 0)
749
+ )
750
+ if (
751
+ component["failed"] != counts["failed"]
752
+ or component["due"]
753
+ > (
754
+ counts.get("pending", 0)
755
+ + counts.get("watching", 0)
756
+ + counts.get("retry_wait", 0)
757
+ )
758
+ or component["expired_leases"] > counts.get("leased", 0)
759
+ or (component["status"] == "ok")
760
+ != (blockers == 0)
761
+ ):
762
+ raise ValueError
763
+ if "operations" in component and (
764
+ sum(component["operations"].values()) != sum(counts.values())
765
+ ):
766
+ raise ValueError
767
+ blocked = any(item["status"] == "blocked" for item in components)
768
+ incomplete = (
769
+ value["pending_lifecycle_registrations"]
770
+ or value["pending_terminal_notifications"]
771
+ or value["pending_terminal_processing"]
772
+ )
773
+ expected_status = (
774
+ "blocked" if blocked else "degraded" if incomplete else "ok"
775
+ )
776
+ if value["status"] != expected_status:
777
+ raise ValueError
778
+ expected_error = (
779
+ "source_service.component_blocked" if blocked
780
+ else "source_service.lifecycle_registration_pending"
781
+ if value["pending_lifecycle_registrations"]
782
+ else "source_service.notification_registration_pending"
783
+ if value["pending_terminal_notifications"]
784
+ else "source_service.processing_registration_pending"
785
+ if value["pending_terminal_processing"]
786
+ else None
787
+ )
788
+ if value["error_code"] != expected_error:
789
+ raise ValueError
790
+
791
+
792
+ def _pipeline_health_payload(value: Any) -> dict[str, Any]:
793
+ fields = {
794
+ "schema", "status", "intake_health", "source_health",
795
+ "source_capability_binding", "website_capability_binding",
796
+ "sidecar_capabilities_sha256", "source_capabilities_sha256",
797
+ }
798
+ try:
799
+ payload = value.as_payload()
800
+ if not isinstance(payload, Mapping) or set(payload) != fields:
801
+ raise ValueError
802
+ if payload["schema"] != _SUBMISSION.PIPELINE_HEALTH_SCHEMA:
803
+ raise ValueError
804
+ intake = _submission_health_payload(payload["intake_health"])
805
+ binding = _binding(payload["website_capability_binding"])
806
+ sidecar_hash = _sha256(payload["sidecar_capabilities_sha256"])
807
+ source_hash = _sha256(payload["source_capabilities_sha256"])
808
+ if (
809
+ binding != intake["website_capability_binding"]
810
+ or sidecar_hash != intake["sidecar_capabilities_sha256"]
811
+ or source_hash != intake["source_capabilities_sha256"]
812
+ ):
813
+ raise ValueError
814
+ if intake["status"] != "ok":
815
+ if (
816
+ payload["status"] != "blocked"
817
+ or payload["source_health"] is not None
818
+ or payload["source_capability_binding"] is not None
819
+ ):
820
+ raise ValueError
821
+ source_health = source_binding = None
822
+ else:
823
+ normalized = _SUBMISSION._AUTH._HTTP._source_health_response({
824
+ "schema": (
825
+ _SUBMISSION._AUTH._HTTP._SOURCE_HTTP.HEALTH_RESPONSE_SCHEMA
826
+ ),
827
+ "health": payload["source_health"],
828
+ "capability_binding": payload["source_capability_binding"],
829
+ "capabilities_sha256": source_hash,
830
+ }, expected_capabilities_sha256=source_hash)
831
+ source_health = normalized["health"]
832
+ source_binding = normalized["capability_binding"]
833
+ _validate_source_health_semantics(source_health)
834
+ if (
835
+ source_health != payload["source_health"]
836
+ or source_binding != payload["source_capability_binding"]
837
+ or payload["status"] != source_health["status"]
838
+ ):
839
+ raise ValueError
840
+ result = dict(payload)
841
+ result.update({
842
+ "intake_health": intake,
843
+ "source_health": source_health,
844
+ "source_capability_binding": source_binding,
845
+ "website_capability_binding": binding,
846
+ "sidecar_capabilities_sha256": sidecar_hash,
847
+ "source_capabilities_sha256": source_hash,
848
+ })
849
+ _canonical(result, response=True)
850
+ return result
851
+ except SubmissionHTTPBlocked:
852
+ raise
853
+ except Exception:
854
+ raise _blocked("runtime_response_invalid", 503) from None
855
+
856
+
857
+ def _pipeline_readiness_payload(value: Any) -> dict[str, Any]:
858
+ fields = {
859
+ "schema", "status", "intake_readiness", "source_readiness",
860
+ "source_capability_binding", "website_capability_binding",
861
+ "sidecar_capabilities_sha256", "source_capabilities_sha256",
862
+ }
863
+ try:
864
+ payload = value.as_payload()
865
+ if not isinstance(payload, Mapping) or set(payload) != fields:
866
+ raise ValueError
867
+ if payload["schema"] != _SUBMISSION.PIPELINE_READINESS_SCHEMA:
868
+ raise ValueError
869
+ intake = _submission_readiness_payload(payload["intake_readiness"])
870
+ binding = _binding(payload["website_capability_binding"])
871
+ sidecar_hash = _sha256(payload["sidecar_capabilities_sha256"])
872
+ source_hash = _sha256(payload["source_capabilities_sha256"])
873
+ if (
874
+ binding != intake["website_capability_binding"]
875
+ or sidecar_hash != intake["sidecar_capabilities_sha256"]
876
+ or source_hash != intake["source_capabilities_sha256"]
877
+ ):
878
+ raise ValueError
879
+ if intake["status"] != "ready":
880
+ if (
881
+ payload["status"] != "not_ready"
882
+ or payload["source_readiness"] is not None
883
+ or payload["source_capability_binding"] is not None
884
+ ):
885
+ raise ValueError
886
+ source_readiness = source_binding = None
887
+ else:
888
+ normalized = _SUBMISSION._AUTH._HTTP._source_readiness_response({
889
+ "schema": (
890
+ _SUBMISSION._AUTH._HTTP._SOURCE_HTTP.READINESS_RESPONSE_SCHEMA
891
+ ),
892
+ "readiness": payload["source_readiness"],
893
+ "capability_binding": payload["source_capability_binding"],
894
+ "capabilities_sha256": source_hash,
895
+ }, expected_capabilities_sha256=source_hash)
896
+ source_readiness = normalized["readiness"]
897
+ source_binding = normalized["capability_binding"]
898
+ if (
899
+ source_readiness != payload["source_readiness"]
900
+ or source_binding != payload["source_capability_binding"]
901
+ or payload["status"] != (
902
+ "ready"
903
+ if source_readiness["status"] == "ready"
904
+ else "not_ready"
905
+ )
906
+ ):
907
+ raise ValueError
908
+ result = dict(payload)
909
+ result.update({
910
+ "intake_readiness": intake,
911
+ "source_readiness": source_readiness,
912
+ "source_capability_binding": source_binding,
913
+ "website_capability_binding": binding,
914
+ "sidecar_capabilities_sha256": sidecar_hash,
915
+ "source_capabilities_sha256": source_hash,
916
+ })
917
+ _canonical(result, response=True)
918
+ return result
919
+ except SubmissionHTTPBlocked:
920
+ raise
921
+ except Exception:
922
+ raise _blocked("runtime_response_invalid", 503) from None
923
+
924
+
925
+ class SubmissionHTTPApplication:
926
+ """Strict public WSGI adapter over one hosted website submission runtime."""
927
+
928
+ def __init__(self, runtime: Any, authenticator: Callable[[dict[str, Any]], Any]):
929
+ if not all(callable(getattr(runtime, name, None)) for name in (
930
+ "submission_capabilities", "website_capability_binding",
931
+ "worker_readiness", "enqueue_change", "enqueue_removal",
932
+ "submission_status", "submission_lifecycle",
933
+ "submission_pipeline_health", "submission_pipeline_readiness",
934
+ )):
935
+ raise TypeError("runtime must provide website submission operations")
936
+ if not callable(authenticator):
937
+ raise TypeError("authenticator must be callable")
938
+ self.runtime = runtime
939
+ self.authenticator = authenticator
940
+ self.capabilities = _CAPABILITIES.build_submission_capabilities_http(
941
+ runtime, authenticator,
942
+ )
943
+
944
+ @staticmethod
945
+ def _send(start_response, status: int, payload: Mapping[str, Any]):
946
+ body = _canonical(dict(payload), response=True)
947
+ phrases = {
948
+ 200: "OK", 202: "Accepted", 400: "Bad Request", 401: "Unauthorized",
949
+ 403: "Forbidden", 404: "Not Found", 405: "Method Not Allowed",
950
+ 409: "Conflict", 411: "Length Required", 413: "Content Too Large",
951
+ 415: "Unsupported Media Type", 503: "Service Unavailable",
952
+ }
953
+ start_response(f"{status} {phrases[status]}", [
954
+ ("Content-Type", "application/json; charset=utf-8"),
955
+ ("Content-Length", str(len(body))),
956
+ ("Cache-Control", "no-store"),
957
+ ("X-Content-Type-Options", "nosniff"),
958
+ ("Referrer-Policy", "no-referrer"),
959
+ ])
960
+ return [body]
961
+
962
+ @classmethod
963
+ def _error(cls, start_response, failure: SubmissionHTTPBlocked):
964
+ return cls._send(start_response, failure.status, {
965
+ "schema": ERROR_SCHEMA,
966
+ "status": "BLOCK",
967
+ "error_code": failure.code,
968
+ })
969
+
970
+ def __call__(self, environ: Mapping[str, Any], start_response: Callable[..., Any]):
971
+ if isinstance(environ, Mapping) and environ.get("PATH_INFO") == CAPABILITIES_PATH:
972
+ return self.capabilities(environ, start_response)
973
+ try:
974
+ if not isinstance(environ, Mapping):
975
+ raise _blocked("environment_invalid", 400)
976
+ path = environ.get("PATH_INFO")
977
+ if path in MONITOR_PATHS:
978
+ if environ.get("REQUEST_METHOD") != "GET":
979
+ raise _blocked("method_not_allowed", 405)
980
+ if environ.get("wsgi.url_scheme") != "https":
981
+ raise _blocked("https_required", 400)
982
+ if environ.get("QUERY_STRING") not in {None, ""}:
983
+ raise _blocked("query_rejected", 400)
984
+ if environ.get("HTTP_TRANSFER_ENCODING") not in {None, ""}:
985
+ raise _blocked("transfer_encoding_rejected", 400)
986
+ if environ.get("CONTENT_LENGTH") not in {None, "", "0"}:
987
+ raise _blocked("body_not_allowed", 400)
988
+ if environ.get("CONTENT_TYPE") not in {None, ""}:
989
+ raise _blocked("body_not_allowed", 400)
990
+ auth_request = {
991
+ "schema": OPERATOR_AUTH_REQUEST_SCHEMA,
992
+ "method": "GET",
993
+ "path": path,
994
+ "headers": [list(item) for item in _headers(environ)],
995
+ "body_sha256": EMPTY_SHA256,
996
+ }
997
+ try:
998
+ principal = self.authenticator(auth_request)
999
+ except Exception:
1000
+ raise _blocked("authentication_unavailable", 503) from None
1001
+ _operator_principal(principal, MONITOR_SCOPES[path])
1002
+ try:
1003
+ if path == PIPELINE_HEALTH_PATH:
1004
+ result = _pipeline_health_payload(
1005
+ self.runtime.submission_pipeline_health()
1006
+ )
1007
+ else:
1008
+ result = _pipeline_readiness_payload(
1009
+ self.runtime.submission_pipeline_readiness()
1010
+ )
1011
+ except SubmissionHTTPBlocked:
1012
+ raise
1013
+ except Exception:
1014
+ raise _blocked("runtime_blocked", 503) from None
1015
+ return self._send(start_response, 200, {
1016
+ "schema": MONITOR_RESPONSE_SCHEMAS[path],
1017
+ "api_schema": API_SCHEMA,
1018
+ "result": result,
1019
+ "content_free": True,
1020
+ "publication_authority": False,
1021
+ })
1022
+ if path not in SCOPES:
1023
+ raise _blocked("route_not_found", 404)
1024
+ if environ.get("REQUEST_METHOD") != "POST":
1025
+ raise _blocked("method_not_allowed", 405)
1026
+ if environ.get("wsgi.url_scheme") != "https":
1027
+ raise _blocked("https_required", 400)
1028
+ if environ.get("QUERY_STRING") not in {None, ""}:
1029
+ raise _blocked("query_rejected", 400)
1030
+ if not _content_type(environ.get("CONTENT_TYPE")):
1031
+ raise _blocked("content_type_invalid", 415)
1032
+ body = _body(environ)
1033
+ headers = _headers(environ)
1034
+ auth_request = {
1035
+ "schema": (
1036
+ READ_AUTH_REQUEST_SCHEMA
1037
+ if path in READ_PATHS
1038
+ else AUTH_REQUEST_SCHEMA
1039
+ ),
1040
+ "method": "POST",
1041
+ "path": path,
1042
+ "headers": [list(item) for item in headers],
1043
+ "body_sha256": hashlib.sha256(body).hexdigest(),
1044
+ }
1045
+ try:
1046
+ principal = _principal(
1047
+ self.authenticator(auth_request),
1048
+ SCOPES[path],
1049
+ schema=(
1050
+ READ_PRINCIPAL_SCHEMA
1051
+ if path in READ_PATHS
1052
+ else PRINCIPAL_SCHEMA
1053
+ ),
1054
+ )
1055
+ except SubmissionHTTPBlocked:
1056
+ raise
1057
+ except Exception:
1058
+ raise _blocked("authentication_unavailable", 503) from None
1059
+
1060
+ request = _request(body)
1061
+ if path in READ_PATHS:
1062
+ identity = _read_identity(
1063
+ request, schema=REQUEST_SCHEMAS[path],
1064
+ )
1065
+ if identity["site_id"] != principal["site_id"]:
1066
+ raise _blocked("site_not_found", 404)
1067
+ try:
1068
+ if path == STATUS_PATH:
1069
+ result = _submission_status_payload(
1070
+ self.runtime.submission_status(
1071
+ identity["operation"], identity["request_id"],
1072
+ ),
1073
+ identity,
1074
+ )
1075
+ else:
1076
+ result = _submission_lifecycle_payload(
1077
+ self.runtime.submission_lifecycle(
1078
+ identity["operation"], identity["request_id"],
1079
+ ),
1080
+ identity,
1081
+ )
1082
+ except SubmissionHTTPBlocked:
1083
+ raise
1084
+ except Exception as error:
1085
+ code = getattr(error, "code", "")
1086
+ if isinstance(code, str) and code.endswith(".status_not_found"):
1087
+ raise _blocked("submission_not_found", 404) from None
1088
+ raise _blocked("runtime_blocked", 503) from None
1089
+ return self._send(start_response, 200, {
1090
+ "schema": RESPONSE_SCHEMAS[path],
1091
+ "api_schema": API_SCHEMA,
1092
+ "result": result,
1093
+ "accepted_implies_publication": False,
1094
+ })
1095
+
1096
+ payload_key = "change" if path == CHANGE_PATH else "removal"
1097
+ if set(request) != {
1098
+ "schema", payload_key, "source_max_attempts",
1099
+ "delivery_max_attempts",
1100
+ } or request.get("schema") != REQUEST_SCHEMAS[path]:
1101
+ raise _blocked("request_invalid", 400)
1102
+ source_attempts = _attempts(request["source_max_attempts"])
1103
+ delivery_attempts = _attempts(request["delivery_max_attempts"])
1104
+ payload = request[payload_key]
1105
+ operation, request_id, event_id, site_id = _identity(
1106
+ payload, change=path == CHANGE_PATH,
1107
+ )
1108
+ if site_id != principal["site_id"]:
1109
+ raise _blocked("site_not_found", 404)
1110
+ header_map = dict(headers)
1111
+ if header_map.get("idempotency-key") != request_id:
1112
+ raise _blocked("idempotency_key_invalid", 400)
1113
+ payload_hash = hashlib.sha256(_canonical(dict(payload))).hexdigest()
1114
+ if header_map.get("x-localization-source-payload-sha256") != payload_hash:
1115
+ raise _blocked("payload_binding_invalid", 400)
1116
+
1117
+ try:
1118
+ readiness = self.runtime.worker_readiness()
1119
+ if (
1120
+ not isinstance(readiness, Mapping)
1121
+ or readiness.get("status") != "ready"
1122
+ or readiness.get("worker_state") != "running"
1123
+ ):
1124
+ raise _blocked("runtime_not_ready", 503)
1125
+ binding = _binding(self.runtime.website_capability_binding())
1126
+ if binding["delivery_capabilities_sha256"] is None:
1127
+ raise ValueError
1128
+ if path == CHANGE_PATH:
1129
+ status = self.runtime.enqueue_change(
1130
+ payload,
1131
+ source_max_attempts=source_attempts,
1132
+ delivery_max_attempts=delivery_attempts,
1133
+ )
1134
+ else:
1135
+ status = self.runtime.enqueue_removal(
1136
+ payload,
1137
+ source_max_attempts=source_attempts,
1138
+ delivery_max_attempts=delivery_attempts,
1139
+ )
1140
+ except SubmissionHTTPBlocked:
1141
+ raise
1142
+ except Exception as error:
1143
+ code = getattr(error, "code", None)
1144
+ if code == "source_delivery_runtime.request_invalid":
1145
+ raise _blocked("request_invalid", 400) from None
1146
+ if code == "source_delivery_runtime.idempotency_collision":
1147
+ raise _blocked("idempotency_collision", 409) from None
1148
+ raise _blocked("runtime_blocked", 503) from None
1149
+
1150
+ projection = _status(
1151
+ status,
1152
+ operation=operation,
1153
+ request_id=request_id,
1154
+ event_id=event_id,
1155
+ site_id=site_id,
1156
+ payload_sha256=payload_hash,
1157
+ source_max_attempts=source_attempts,
1158
+ delivery_max_attempts=delivery_attempts,
1159
+ capabilities_sha256=binding["delivery_capabilities_sha256"],
1160
+ )
1161
+ return self._send(start_response, 202, {
1162
+ "schema": RESPONSE_SCHEMAS[path],
1163
+ "api_schema": API_SCHEMA,
1164
+ **projection,
1165
+ "website_capability_binding": binding,
1166
+ "accepted_implies_publication": False,
1167
+ })
1168
+ except SubmissionHTTPBlocked as failure:
1169
+ return self._error(start_response, failure)
1170
+ except Exception:
1171
+ return self._error(start_response, _blocked("internal", 503))
1172
+
1173
+
1174
+ def build_submission_http(
1175
+ runtime: Any,
1176
+ authenticator: Callable[[dict[str, Any]], Any],
1177
+ ) -> SubmissionHTTPApplication:
1178
+ """Build the public capability, durable-write, and progress boundary."""
1179
+
1180
+ return SubmissionHTTPApplication(runtime, authenticator)