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,1244 @@
1
+ #!/usr/bin/env python3
2
+ """Authenticated WSGI sidecar for durable website-source delivery.
3
+
4
+ The boundary lets any website backend persist one source change, cancellation,
5
+ or tombstone in the protected delivery runtime without importing Python code.
6
+ Authentication receives request metadata and the exact body digest, never a
7
+ parsed website payload. Operational responses contain only durable identities,
8
+ hashes, counters, states, and stable error codes.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import importlib.util
15
+ import json
16
+ import math
17
+ import re
18
+ import sys
19
+ import unicodedata
20
+ from dataclasses import asdict
21
+ from pathlib import Path
22
+ from typing import Any, Callable, Mapping
23
+
24
+
25
+ API_SCHEMA = "blun.cms-source-delivery-sidecar-api.v1"
26
+ ERROR_SCHEMA = "blun.cms-source-delivery-sidecar-error.v1"
27
+ AUTH_REQUEST_SCHEMA = "blun.cms-source-delivery-sidecar-auth-request.v1"
28
+ PRINCIPAL_SCHEMA = "blun.cms-source-delivery-sidecar-principal.v1"
29
+ TENANT_PRINCIPAL_SCHEMA = (
30
+ "blun.cms-source-delivery-sidecar-tenant-principal.v1"
31
+ )
32
+ CHANGE_REQUEST_SCHEMA = "blun.cms-source-delivery-change-request.v1"
33
+ REMOVAL_REQUEST_SCHEMA = "blun.cms-source-delivery-removal-request.v1"
34
+ STATUS_REQUEST_SCHEMA = "blun.cms-source-delivery-status-request.v1"
35
+ SOURCE_STATUS_REQUEST_SCHEMA = (
36
+ "blun.cms-source-delivery-source-status-request.v1"
37
+ )
38
+ QUEUE_RESPONSE_SCHEMA = "blun.cms-source-delivery-queue-response.v1"
39
+ STATUS_RESPONSE_SCHEMA = "blun.cms-source-delivery-status-response.v1"
40
+ SOURCE_STATUS_RESPONSE_SCHEMA = (
41
+ "blun.cms-source-delivery-source-status-response.v2"
42
+ )
43
+ SOURCE_READINESS_RESPONSE_SCHEMA = (
44
+ "blun.cms-source-delivery-source-readiness-response.v2"
45
+ )
46
+ SOURCE_HEALTH_RESPONSE_SCHEMA = (
47
+ "blun.cms-source-delivery-source-health-response.v2"
48
+ )
49
+ HEALTH_RESPONSE_SCHEMA = "blun.cms-source-delivery-health-response.v1"
50
+ READINESS_RESPONSE_SCHEMA = (
51
+ "blun.cms-source-delivery-readiness-response.v1"
52
+ )
53
+ CAPABILITIES_SCHEMA = "blun.cms-source-delivery-capabilities.v1"
54
+ CAPABILITIES_RESPONSE_SCHEMA = (
55
+ "blun.cms-source-delivery-capabilities-response.v1"
56
+ )
57
+
58
+ CHANGE_PATH = "/v1/localization/source-delivery/changes"
59
+ REMOVAL_PATH = "/v1/localization/source-delivery/removals"
60
+ STATUS_PATH = "/v1/localization/source-delivery/status"
61
+ SOURCE_STATUS_PATH = "/v1/localization/source-delivery/source-status"
62
+ SOURCE_READINESS_PATH = "/v1/localization/source-delivery/source-readiness"
63
+ SOURCE_HEALTH_PATH = "/v1/localization/source-delivery/source-health"
64
+ HEALTH_PATH = "/v1/localization/source-delivery/health"
65
+ READINESS_PATH = "/v1/localization/source-delivery/readiness"
66
+ CAPABILITIES_PATH = "/v1/localization/source-delivery/capabilities"
67
+
68
+ MAX_BODY_BYTES = 4_000_000
69
+ MAX_HEADERS = 64
70
+ MAX_HEADER_VALUE = 4096
71
+ TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$")
72
+ SHA256 = re.compile(r"^[0-9a-f]{64}$")
73
+ ERROR_CODE = re.compile(r"^[a-z][a-z0-9_.-]{0,127}$")
74
+ HEADER_NAME = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$")
75
+ STATUSES = ("pending", "leased", "retry_wait", "succeeded", "failed")
76
+ OPERATIONS = ("change", "cancellation", "tombstone")
77
+ WORKER_STATES = (
78
+ "unmanaged", "running", "stopping", "stopped", "failed", "closed",
79
+ )
80
+ SCOPES = {
81
+ CHANGE_PATH: "source-delivery-change:write",
82
+ REMOVAL_PATH: "source-delivery-removal:write",
83
+ STATUS_PATH: "source-delivery-status:read",
84
+ SOURCE_STATUS_PATH: "source-delivery-source-status:read",
85
+ SOURCE_READINESS_PATH: "source-delivery-source-readiness:read",
86
+ SOURCE_HEALTH_PATH: "source-delivery-source-health:read",
87
+ HEALTH_PATH: "source-delivery-health:read",
88
+ READINESS_PATH: "source-delivery-readiness:read",
89
+ CAPABILITIES_PATH: "source-delivery-capabilities:read",
90
+ }
91
+ METHODS = {
92
+ CHANGE_PATH: "POST",
93
+ REMOVAL_PATH: "POST",
94
+ STATUS_PATH: "POST",
95
+ SOURCE_STATUS_PATH: "POST",
96
+ SOURCE_READINESS_PATH: "GET",
97
+ SOURCE_HEALTH_PATH: "GET",
98
+ HEALTH_PATH: "GET",
99
+ READINESS_PATH: "GET",
100
+ CAPABILITIES_PATH: "GET",
101
+ }
102
+ TENANT_PATHS = {
103
+ CHANGE_PATH, REMOVAL_PATH, STATUS_PATH, SOURCE_STATUS_PATH,
104
+ }
105
+
106
+
107
+ def _load_module(name: str, path: Path):
108
+ spec = importlib.util.spec_from_file_location(name, path)
109
+ if spec is None or spec.loader is None:
110
+ raise RuntimeError(
111
+ f"cannot load source-delivery HTTP dependency: {path.name}"
112
+ )
113
+ module = importlib.util.module_from_spec(spec)
114
+ sys.modules[spec.name] = module
115
+ spec.loader.exec_module(module)
116
+ return module
117
+
118
+
119
+ _ROOT = Path(__file__).resolve().parents[1]
120
+ _SOURCE_HTTP = _load_module(
121
+ "blun_website_localization_cms_source_delivery_http_source",
122
+ _ROOT / "integrations" / "website_localization_cms_source_http.py",
123
+ )
124
+
125
+
126
+ class CMSSourceDeliveryHTTPBlocked(RuntimeError):
127
+ """Stable content-free sidecar failure."""
128
+
129
+ def __init__(self, code: str, status: int):
130
+ if ERROR_CODE.fullmatch(code) is None or status not in {
131
+ 400, 401, 403, 404, 405, 409, 411, 413, 415, 503,
132
+ }:
133
+ raise ValueError("invalid source delivery HTTP failure")
134
+ super().__init__(code)
135
+ self.code = code
136
+ self.status = status
137
+
138
+
139
+ def _canonical_json(value: Any) -> bytes:
140
+ try:
141
+ result = json.dumps(
142
+ value,
143
+ ensure_ascii=False,
144
+ allow_nan=False,
145
+ sort_keys=True,
146
+ separators=(",", ":"),
147
+ ).encode("utf-8")
148
+ except (TypeError, ValueError, RecursionError):
149
+ raise CMSSourceDeliveryHTTPBlocked(
150
+ "source_delivery_http.response_invalid", 503,
151
+ ) from None
152
+ if not result or len(result) > MAX_BODY_BYTES:
153
+ raise CMSSourceDeliveryHTTPBlocked(
154
+ "source_delivery_http.response_invalid", 503,
155
+ )
156
+ return result
157
+
158
+
159
+ def _pairs(items):
160
+ result = {}
161
+ for key, value in items:
162
+ if key in result:
163
+ raise ValueError
164
+ result[key] = value
165
+ return result
166
+
167
+
168
+ def _constant(_value):
169
+ raise ValueError
170
+
171
+
172
+ def _token(value: Any) -> str:
173
+ if (
174
+ not isinstance(value, str)
175
+ or TOKEN.fullmatch(value) is None
176
+ or not unicodedata.is_normalized("NFC", value)
177
+ ):
178
+ raise ValueError
179
+ return value
180
+
181
+
182
+ def _sha256(value: Any) -> str:
183
+ if not isinstance(value, str) or SHA256.fullmatch(value) is None:
184
+ raise ValueError
185
+ return value
186
+
187
+
188
+ def _count(value: Any, *, minimum: int = 0, maximum: int = 1_000_000) -> int:
189
+ if (
190
+ isinstance(value, bool)
191
+ or not isinstance(value, int)
192
+ or not minimum <= value <= maximum
193
+ ):
194
+ raise ValueError
195
+ return value
196
+
197
+
198
+ def _timestamp(value: Any) -> float:
199
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
200
+ raise ValueError
201
+ result = float(value)
202
+ if result < 0 or not math.isfinite(result):
203
+ raise ValueError
204
+ return result
205
+
206
+
207
+ def _optional_timestamp(value: Any) -> float | None:
208
+ return None if value is None else _timestamp(value)
209
+
210
+
211
+ def _optional_error(value: Any) -> str | None:
212
+ if value is None:
213
+ return None
214
+ if not isinstance(value, str) or ERROR_CODE.fullmatch(value) is None:
215
+ raise ValueError
216
+ return value
217
+
218
+
219
+ def _optional_sha256(value: Any) -> str | None:
220
+ return None if value is None else _sha256(value)
221
+
222
+
223
+ def _principal(value: Any, scope: str, *, tenant: bool) -> dict[str, str]:
224
+ fields = {
225
+ "schema", "principal_id", "credential_id", "credential_version",
226
+ "scope",
227
+ }
228
+ if tenant:
229
+ fields.add("site_id")
230
+ if not isinstance(value, Mapping) or set(value) != fields:
231
+ raise CMSSourceDeliveryHTTPBlocked(
232
+ "source_delivery_http.authentication_failed", 401,
233
+ )
234
+ try:
235
+ expected = TENANT_PRINCIPAL_SCHEMA if tenant else PRINCIPAL_SCHEMA
236
+ if value["schema"] != expected or value["scope"] != scope:
237
+ if value.get("scope") != scope:
238
+ raise CMSSourceDeliveryHTTPBlocked(
239
+ "source_delivery_http.scope_rejected", 403,
240
+ )
241
+ raise ValueError
242
+ for name in (
243
+ "principal_id", "credential_id", "credential_version", "scope",
244
+ ):
245
+ _token(value[name])
246
+ if tenant:
247
+ _token(value["site_id"])
248
+ except CMSSourceDeliveryHTTPBlocked:
249
+ raise
250
+ except (KeyError, TypeError, ValueError):
251
+ raise CMSSourceDeliveryHTTPBlocked(
252
+ "source_delivery_http.authentication_failed", 401,
253
+ ) from None
254
+ return dict(value)
255
+
256
+
257
+ def _request_identity(value: Any, *, change: bool) -> tuple[str, str, str]:
258
+ if not isinstance(value, Mapping):
259
+ raise ValueError
260
+ site_id = _token(value.get("site_id"))
261
+ event_id = _token(value.get("event_id"))
262
+ if change:
263
+ request_id = event_id
264
+ elif value.get("schema") == "blun.cms-content-cancellation.v1":
265
+ request_id = _token(value.get("cancellation_id"))
266
+ elif value.get("schema") == "blun.cms-content-tombstone.v1":
267
+ request_id = _token(value.get("tombstone_id"))
268
+ else:
269
+ raise ValueError
270
+ return site_id, event_id, request_id
271
+
272
+
273
+ def _status_payload(
274
+ value: Any,
275
+ *,
276
+ expected_operation: str | None = None,
277
+ expected_request_id: str | None = None,
278
+ expected_site_id: str | None = None,
279
+ ) -> dict[str, Any]:
280
+ try:
281
+ payload = asdict(value)
282
+ required = {
283
+ "operation", "request_id", "event_id", "site_id",
284
+ "payload_sha256", "capabilities_sha256", "status", "attempts",
285
+ "delivery_max_attempts", "source_max_attempts", "next_attempt_at",
286
+ "lease_expires_at", "lease_expired", "last_error_code",
287
+ "response_sha256",
288
+ }
289
+ if set(payload) != required:
290
+ raise ValueError
291
+ operation = payload["operation"]
292
+ if operation not in OPERATIONS:
293
+ raise ValueError
294
+ request_id = _token(payload["request_id"])
295
+ event_id = _token(payload["event_id"])
296
+ site_id = _token(payload["site_id"])
297
+ payload_hash = _sha256(payload["payload_sha256"])
298
+ remote_contract_hash = _sha256(payload["capabilities_sha256"])
299
+ status = payload["status"]
300
+ if status not in STATUSES:
301
+ raise ValueError
302
+ attempts = _count(payload["attempts"], maximum=20)
303
+ delivery_max = _count(
304
+ payload["delivery_max_attempts"], minimum=1, maximum=20,
305
+ )
306
+ source_max = _count(
307
+ payload["source_max_attempts"], minimum=1, maximum=20,
308
+ )
309
+ next_attempt = _timestamp(payload["next_attempt_at"])
310
+ lease_expires = _optional_timestamp(payload["lease_expires_at"])
311
+ if not isinstance(payload["lease_expired"], bool):
312
+ raise ValueError
313
+ error = _optional_error(payload["last_error_code"])
314
+ response_hash = _optional_sha256(payload["response_sha256"])
315
+ if attempts > delivery_max:
316
+ raise ValueError
317
+ if status == "leased" and lease_expires is None:
318
+ raise ValueError
319
+ if status != "leased" and lease_expires is not None:
320
+ raise ValueError
321
+ if status == "succeeded" and response_hash is None:
322
+ raise ValueError
323
+ if status != "succeeded" and response_hash is not None:
324
+ raise ValueError
325
+ if status in {"retry_wait", "failed"} and error is None:
326
+ raise ValueError
327
+ if expected_operation is not None and operation != expected_operation:
328
+ raise ValueError
329
+ if expected_request_id is not None and request_id != expected_request_id:
330
+ raise ValueError
331
+ if expected_site_id is not None and site_id != expected_site_id:
332
+ raise ValueError
333
+ return {
334
+ "operation": operation,
335
+ "request_id": request_id,
336
+ "event_id": event_id,
337
+ "site_id": site_id,
338
+ "payload_sha256": payload_hash,
339
+ "remote_capabilities_sha256": remote_contract_hash,
340
+ "status": status,
341
+ "attempts": attempts,
342
+ "delivery_max_attempts": delivery_max,
343
+ "source_max_attempts": source_max,
344
+ "next_attempt_at": next_attempt,
345
+ "lease_expires_at": lease_expires,
346
+ "lease_expired": payload["lease_expired"],
347
+ "last_error_code": error,
348
+ "response_sha256": response_hash,
349
+ }
350
+ except Exception:
351
+ raise CMSSourceDeliveryHTTPBlocked(
352
+ "source_delivery_http.runtime_response_invalid", 503,
353
+ ) from None
354
+
355
+
356
+ class _PayloadView:
357
+ def __init__(self, value: Mapping[str, Any]):
358
+ self.value = value
359
+
360
+ def as_payload(self) -> Mapping[str, Any]:
361
+ return self.value
362
+
363
+
364
+ def _source_status_response(
365
+ value: Any,
366
+ *,
367
+ expected_event_id: str,
368
+ expected_site_id: str,
369
+ expected_capabilities_sha256: str,
370
+ ) -> dict[str, Any]:
371
+ """Validate the complete source-service envelope without dropping fields."""
372
+
373
+ try:
374
+ if (
375
+ not isinstance(value, Mapping)
376
+ or set(value) != {
377
+ "schema", "status", "capabilities_sha256",
378
+ "capability_binding",
379
+ }
380
+ or value.get("schema") != _SOURCE_HTTP.STATUS_RESPONSE_SCHEMA
381
+ or value.get("capabilities_sha256")
382
+ != expected_capabilities_sha256
383
+ ):
384
+ raise ValueError
385
+ normalized = _SOURCE_HTTP._source_status_payload(
386
+ _PayloadView(value["status"]),
387
+ expected_event_id=expected_event_id,
388
+ expected_site_id=expected_site_id,
389
+ )
390
+ if normalized != value["status"]:
391
+ raise ValueError
392
+ binding = _SOURCE_HTTP._capability_binding_payload(
393
+ value["capability_binding"]
394
+ )
395
+ return {
396
+ "status": normalized,
397
+ "capability_binding": binding,
398
+ }
399
+ except Exception:
400
+ raise CMSSourceDeliveryHTTPBlocked(
401
+ "source_delivery_http.runtime_response_invalid", 503,
402
+ ) from None
403
+
404
+
405
+ def _source_readiness_response(
406
+ value: Any,
407
+ *,
408
+ expected_capabilities_sha256: str,
409
+ ) -> dict[str, Any]:
410
+ """Validate the source-service readiness envelope without weakening it."""
411
+
412
+ try:
413
+ if (
414
+ not isinstance(value, Mapping)
415
+ or set(value) != {
416
+ "schema", "readiness", "capabilities_sha256",
417
+ "capability_binding",
418
+ }
419
+ or value.get("schema") != _SOURCE_HTTP.READINESS_RESPONSE_SCHEMA
420
+ or value.get("capabilities_sha256")
421
+ != expected_capabilities_sha256
422
+ ):
423
+ raise ValueError
424
+ normalized = _SOURCE_HTTP._readiness_payload(value["readiness"])
425
+ if normalized != value["readiness"]:
426
+ raise ValueError
427
+ binding = _SOURCE_HTTP._capability_binding_payload(
428
+ value["capability_binding"]
429
+ )
430
+ return {
431
+ "readiness": normalized,
432
+ "capability_binding": binding,
433
+ }
434
+ except Exception:
435
+ raise CMSSourceDeliveryHTTPBlocked(
436
+ "source_delivery_http.runtime_response_invalid", 503,
437
+ ) from None
438
+
439
+
440
+ class _SourcePayloadView:
441
+ """Expose an immutable decoded payload to the source HTTP validator."""
442
+
443
+ def __init__(self, payload: Mapping[str, Any]):
444
+ self._payload = payload
445
+
446
+ def as_payload(self) -> dict[str, Any]:
447
+ return dict(self._payload)
448
+
449
+
450
+ def _source_health_response(
451
+ value: Any,
452
+ *,
453
+ expected_capabilities_sha256: str,
454
+ ) -> dict[str, Any]:
455
+ """Validate the source-service health envelope without weakening it."""
456
+
457
+ try:
458
+ if (
459
+ not isinstance(value, Mapping)
460
+ or set(value) != {
461
+ "schema", "health", "capabilities_sha256",
462
+ "capability_binding",
463
+ }
464
+ or value.get("schema") != _SOURCE_HTTP.HEALTH_RESPONSE_SCHEMA
465
+ or value.get("capabilities_sha256")
466
+ != expected_capabilities_sha256
467
+ or not isinstance(value.get("health"), Mapping)
468
+ ):
469
+ raise ValueError
470
+ normalized = _SOURCE_HTTP._health_payload(
471
+ _SourcePayloadView(value["health"])
472
+ )
473
+ if normalized != value["health"]:
474
+ raise ValueError
475
+ binding = _SOURCE_HTTP._capability_binding_payload(
476
+ value["capability_binding"]
477
+ )
478
+ return {
479
+ "health": normalized,
480
+ "capability_binding": binding,
481
+ }
482
+ except Exception:
483
+ raise CMSSourceDeliveryHTTPBlocked(
484
+ "source_delivery_http.runtime_response_invalid", 503,
485
+ ) from None
486
+
487
+
488
+ def _health_payload(value: Any) -> dict[str, Any]:
489
+ try:
490
+ payload = value.as_payload()
491
+ required = {
492
+ "schema", "status", "counts", "operations", "due",
493
+ "expired_leases", "failed", "contract_mismatches", "error_code",
494
+ }
495
+ if not isinstance(payload, Mapping) or set(payload) != required:
496
+ raise ValueError
497
+ if payload["schema"] != "blun.cms-source-delivery-health.v1":
498
+ raise ValueError
499
+ if payload["status"] not in {"ok", "blocked"}:
500
+ raise ValueError
501
+ due = _count(payload["due"])
502
+ expired = _count(payload["expired_leases"])
503
+ failed = _count(payload["failed"])
504
+ mismatches = _count(payload["contract_mismatches"])
505
+ error = _optional_error(payload["error_code"])
506
+ integrity_block = (
507
+ payload["status"] == "blocked"
508
+ and payload["counts"] == {}
509
+ and payload["operations"] == {}
510
+ and (due, expired, failed, mismatches) == (0, 0, 0, 0)
511
+ and error == "source_delivery.integrity"
512
+ )
513
+ if integrity_block:
514
+ counts = {}
515
+ operations = {}
516
+ else:
517
+ if set(payload["counts"]) != set(STATUSES):
518
+ raise ValueError
519
+ if set(payload["operations"]) != set(OPERATIONS):
520
+ raise ValueError
521
+ counts = {
522
+ name: _count(payload["counts"][name]) for name in STATUSES
523
+ }
524
+ operations = {
525
+ name: _count(payload["operations"][name])
526
+ for name in OPERATIONS
527
+ }
528
+ if (
529
+ not integrity_block
530
+ and (
531
+ sum(counts.values()) != sum(operations.values())
532
+ or due > counts["pending"] + counts["retry_wait"]
533
+ or expired > counts["leased"]
534
+ or failed != counts["failed"]
535
+ or mismatches > (
536
+ counts["pending"]
537
+ + counts["leased"]
538
+ + counts["retry_wait"]
539
+ )
540
+ or (payload["status"] == "ok")
541
+ != (
542
+ failed == 0
543
+ and expired == 0
544
+ and mismatches == 0
545
+ and error is None
546
+ )
547
+ or (payload["status"] == "blocked" and error is None)
548
+ )
549
+ ):
550
+ raise ValueError
551
+ return {
552
+ "schema": payload["schema"],
553
+ "status": payload["status"],
554
+ "counts": counts,
555
+ "operations": operations,
556
+ "due": due,
557
+ "expired_leases": expired,
558
+ "failed": failed,
559
+ "contract_mismatches": mismatches,
560
+ "error_code": error,
561
+ }
562
+ except Exception:
563
+ raise CMSSourceDeliveryHTTPBlocked(
564
+ "source_delivery_http.runtime_response_invalid", 503,
565
+ ) from None
566
+
567
+
568
+ def _readiness_payload(value: Any) -> dict[str, Any]:
569
+ try:
570
+ required = {
571
+ "schema", "status", "worker_state", "outbox_status", "error_code",
572
+ }
573
+ if not isinstance(value, Mapping) or set(value) != required:
574
+ raise ValueError
575
+ if value["schema"] != "blun.cms-source-delivery-worker-readiness.v1":
576
+ raise ValueError
577
+ if value["status"] not in {"ready", "not_ready"}:
578
+ raise ValueError
579
+ if value["worker_state"] not in WORKER_STATES:
580
+ raise ValueError
581
+ if value["outbox_status"] not in {None, "ok", "blocked"}:
582
+ raise ValueError
583
+ error = _optional_error(value["error_code"])
584
+ ready = (
585
+ value["status"] == "ready"
586
+ and value["worker_state"] == "running"
587
+ and value["outbox_status"] == "ok"
588
+ and error is None
589
+ )
590
+ if ready != (value["status"] == "ready"):
591
+ raise ValueError
592
+ if value["status"] == "not_ready" and error is None:
593
+ raise ValueError
594
+ return {
595
+ "schema": value["schema"],
596
+ "status": value["status"],
597
+ "worker_state": value["worker_state"],
598
+ "outbox_status": value["outbox_status"],
599
+ "error_code": error,
600
+ }
601
+ except Exception:
602
+ raise CMSSourceDeliveryHTTPBlocked(
603
+ "source_delivery_http.runtime_response_invalid", 503,
604
+ ) from None
605
+
606
+
607
+ def _capabilities_payload() -> dict[str, Any]:
608
+ definitions = (
609
+ (
610
+ "capabilities", "GET", CAPABILITIES_PATH,
611
+ "source-delivery-capabilities:read", PRINCIPAL_SCHEMA, None,
612
+ CAPABILITIES_RESPONSE_SCHEMA, 200,
613
+ ),
614
+ (
615
+ "change", "POST", CHANGE_PATH, "source-delivery-change:write",
616
+ TENANT_PRINCIPAL_SCHEMA, CHANGE_REQUEST_SCHEMA,
617
+ QUEUE_RESPONSE_SCHEMA, 202,
618
+ ),
619
+ (
620
+ "health", "GET", HEALTH_PATH, "source-delivery-health:read",
621
+ PRINCIPAL_SCHEMA, None, HEALTH_RESPONSE_SCHEMA, 200,
622
+ ),
623
+ (
624
+ "readiness", "GET", READINESS_PATH,
625
+ "source-delivery-readiness:read", PRINCIPAL_SCHEMA, None,
626
+ READINESS_RESPONSE_SCHEMA, 200,
627
+ ),
628
+ (
629
+ "removal", "POST", REMOVAL_PATH,
630
+ "source-delivery-removal:write", TENANT_PRINCIPAL_SCHEMA,
631
+ REMOVAL_REQUEST_SCHEMA, QUEUE_RESPONSE_SCHEMA, 202,
632
+ ),
633
+ (
634
+ "status", "POST", STATUS_PATH, "source-delivery-status:read",
635
+ TENANT_PRINCIPAL_SCHEMA, STATUS_REQUEST_SCHEMA,
636
+ STATUS_RESPONSE_SCHEMA, 200,
637
+ ),
638
+ (
639
+ "source_status", "POST", SOURCE_STATUS_PATH,
640
+ "source-delivery-source-status:read", TENANT_PRINCIPAL_SCHEMA,
641
+ SOURCE_STATUS_REQUEST_SCHEMA, SOURCE_STATUS_RESPONSE_SCHEMA, 200,
642
+ ),
643
+ (
644
+ "source_readiness", "GET", SOURCE_READINESS_PATH,
645
+ "source-delivery-source-readiness:read", PRINCIPAL_SCHEMA,
646
+ None, SOURCE_READINESS_RESPONSE_SCHEMA, 200,
647
+ ),
648
+ (
649
+ "source_health", "GET", SOURCE_HEALTH_PATH,
650
+ "source-delivery-source-health:read", PRINCIPAL_SCHEMA,
651
+ None, SOURCE_HEALTH_RESPONSE_SCHEMA, 200,
652
+ ),
653
+ )
654
+ try:
655
+ operations = {}
656
+ for (
657
+ name, method, path, scope, principal_schema, request_schema,
658
+ response_schema, success_status,
659
+ ) in definitions:
660
+ if METHODS.get(path) != method or SCOPES.get(path) != scope:
661
+ raise ValueError
662
+ operations[name] = {
663
+ "method": method,
664
+ "path": path,
665
+ "scope": scope,
666
+ "principal_schema": principal_schema,
667
+ "request_schema": request_schema,
668
+ "response_schema": response_schema,
669
+ "success_status": success_status,
670
+ }
671
+ if set(METHODS) != {item[2] for item in definitions}:
672
+ raise ValueError
673
+ if set(SCOPES) != set(METHODS):
674
+ raise ValueError
675
+ contract = {
676
+ "schema": CAPABILITIES_SCHEMA,
677
+ "api_schema": API_SCHEMA,
678
+ "operations": operations,
679
+ "limits": {
680
+ "max_body_bytes": MAX_BODY_BYTES,
681
+ "max_headers": MAX_HEADERS,
682
+ "max_header_value_bytes": MAX_HEADER_VALUE,
683
+ "max_source_attempts": 20,
684
+ "max_delivery_attempts": 20,
685
+ },
686
+ "semantics": {
687
+ "authentication_binds_exact_body_sha256": True,
688
+ "content_free_operational_reads": True,
689
+ "delivery_retries_are_durable": True,
690
+ "status_is_site_bound": True,
691
+ "source_status_requires_accepted_submission": True,
692
+ "source_readiness_is_independently_validated": True,
693
+ "source_health_is_independently_validated": True,
694
+ "source_runtime_binding_is_independently_validated": True,
695
+ "write_requires_ready_worker": True,
696
+ },
697
+ }
698
+ encoded = _canonical_json(contract)
699
+ except CMSSourceDeliveryHTTPBlocked:
700
+ raise
701
+ except Exception:
702
+ raise CMSSourceDeliveryHTTPBlocked(
703
+ "source_delivery_http.capabilities_invalid", 503,
704
+ ) from None
705
+ return {**contract, "sha256": hashlib.sha256(encoded).hexdigest()}
706
+
707
+
708
+ class CMSSourceDeliveryHTTPApplication:
709
+ """Strict authenticated WSGI adapter over one hosted delivery runtime."""
710
+
711
+ def __init__(self, runtime: Any, authenticator: Callable[[dict[str, Any]], Any]):
712
+ if not all(callable(getattr(runtime, name, None)) for name in (
713
+ "enqueue_change", "enqueue_removal", "status", "health",
714
+ "source_health", "source_readiness", "worker_readiness",
715
+ )):
716
+ raise TypeError("runtime must provide source delivery operations")
717
+ if not callable(authenticator):
718
+ raise TypeError("authenticator must be callable")
719
+ self.runtime = runtime
720
+ self.authenticator = authenticator
721
+
722
+ @staticmethod
723
+ def _headers(environ: Mapping[str, Any]) -> tuple[tuple[str, str], ...]:
724
+ headers = []
725
+ for key, value in environ.items():
726
+ if key.startswith("HTTP_"):
727
+ name = key[5:].replace("_", "-").lower()
728
+ elif key in {"CONTENT_TYPE", "CONTENT_LENGTH"}:
729
+ name = key.replace("_", "-").lower()
730
+ else:
731
+ continue
732
+ if (
733
+ HEADER_NAME.fullmatch(name) is None
734
+ or not isinstance(value, str)
735
+ or len(value) > MAX_HEADER_VALUE
736
+ or "\r" in value
737
+ or "\n" in value
738
+ ):
739
+ raise CMSSourceDeliveryHTTPBlocked(
740
+ "source_delivery_http.headers_invalid", 400,
741
+ )
742
+ headers.append((name, value))
743
+ headers.sort()
744
+ if (
745
+ len(headers) > MAX_HEADERS
746
+ or len({name for name, _ in headers}) != len(headers)
747
+ ):
748
+ raise CMSSourceDeliveryHTTPBlocked(
749
+ "source_delivery_http.headers_invalid", 400,
750
+ )
751
+ return tuple(headers)
752
+
753
+ @staticmethod
754
+ def _content_type(value: Any) -> bool:
755
+ if not isinstance(value, str):
756
+ return False
757
+ parts = [part.strip().lower() for part in value.split(";")]
758
+ return parts in (
759
+ ["application/json"], ["application/json", "charset=utf-8"],
760
+ )
761
+
762
+ @staticmethod
763
+ def _body(environ: Mapping[str, Any], *, required: bool) -> bytes:
764
+ if environ.get("HTTP_TRANSFER_ENCODING") not in {None, ""}:
765
+ raise CMSSourceDeliveryHTTPBlocked(
766
+ "source_delivery_http.transfer_encoding_rejected", 400,
767
+ )
768
+ length = environ.get("CONTENT_LENGTH")
769
+ if not required and length in {None, "", "0"}:
770
+ return b""
771
+ if (
772
+ not isinstance(length, str)
773
+ or not length.isascii()
774
+ or not length.isdecimal()
775
+ ):
776
+ raise CMSSourceDeliveryHTTPBlocked(
777
+ "source_delivery_http.content_length_required", 411,
778
+ )
779
+ size = int(length)
780
+ if size <= 0:
781
+ raise CMSSourceDeliveryHTTPBlocked(
782
+ "source_delivery_http.body_invalid", 400,
783
+ )
784
+ if size > MAX_BODY_BYTES:
785
+ raise CMSSourceDeliveryHTTPBlocked(
786
+ "source_delivery_http.body_too_large", 413,
787
+ )
788
+ stream = environ.get("wsgi.input")
789
+ try:
790
+ body = stream.read(size)
791
+ except Exception:
792
+ body = None
793
+ if not isinstance(body, bytes) or len(body) != size:
794
+ raise CMSSourceDeliveryHTTPBlocked(
795
+ "source_delivery_http.body_invalid", 400,
796
+ )
797
+ return body
798
+
799
+ def _authenticate(
800
+ self,
801
+ environ: Mapping[str, Any],
802
+ path: str,
803
+ body: bytes,
804
+ ) -> dict[str, str]:
805
+ request = {
806
+ "schema": AUTH_REQUEST_SCHEMA,
807
+ "method": environ["REQUEST_METHOD"],
808
+ "path": path,
809
+ "headers": [list(item) for item in self._headers(environ)],
810
+ "body_sha256": hashlib.sha256(body).hexdigest(),
811
+ }
812
+ try:
813
+ principal = self.authenticator(request)
814
+ except Exception:
815
+ raise CMSSourceDeliveryHTTPBlocked(
816
+ "source_delivery_http.authentication_unavailable", 503,
817
+ ) from None
818
+ return _principal(
819
+ principal, SCOPES[path], tenant=path in TENANT_PATHS,
820
+ )
821
+
822
+ @staticmethod
823
+ def _request(body: bytes) -> Any:
824
+ try:
825
+ text = body.decode("utf-8")
826
+ if text.startswith("\ufeff"):
827
+ raise ValueError
828
+ return json.loads(
829
+ text,
830
+ object_pairs_hook=_pairs,
831
+ parse_constant=_constant,
832
+ )
833
+ except (UnicodeDecodeError, json.JSONDecodeError, ValueError, RecursionError):
834
+ raise CMSSourceDeliveryHTTPBlocked(
835
+ "source_delivery_http.json_invalid", 400,
836
+ ) from None
837
+
838
+ @staticmethod
839
+ def _send(start_response, status: int, payload: Mapping[str, Any]):
840
+ body = _canonical_json(dict(payload))
841
+ phrases = {
842
+ 200: "OK", 202: "Accepted", 400: "Bad Request",
843
+ 401: "Unauthorized", 403: "Forbidden", 404: "Not Found",
844
+ 405: "Method Not Allowed", 409: "Conflict",
845
+ 411: "Length Required", 413: "Content Too Large",
846
+ 415: "Unsupported Media Type", 503: "Service Unavailable",
847
+ }
848
+ start_response(f"{status} {phrases[status]}", [
849
+ ("Content-Type", "application/json; charset=utf-8"),
850
+ ("Content-Length", str(len(body))),
851
+ ("Cache-Control", "no-store"),
852
+ ("X-Content-Type-Options", "nosniff"),
853
+ ("Referrer-Policy", "no-referrer"),
854
+ ])
855
+ return [body]
856
+
857
+ @classmethod
858
+ def _error(cls, start_response, failure: CMSSourceDeliveryHTTPBlocked):
859
+ return cls._send(start_response, failure.status, {
860
+ "schema": ERROR_SCHEMA,
861
+ "status": "BLOCK",
862
+ "error_code": failure.code,
863
+ })
864
+
865
+ @staticmethod
866
+ def _write_binding(
867
+ environ: Mapping[str, Any], payload: Mapping[str, Any], request_id: str,
868
+ ) -> str:
869
+ payload_hash = hashlib.sha256(_canonical_json(payload)).hexdigest()
870
+ if (
871
+ environ.get("HTTP_IDEMPOTENCY_KEY") != request_id
872
+ or environ.get("HTTP_X_LOCALIZATION_SOURCE_PAYLOAD_SHA256")
873
+ != payload_hash
874
+ ):
875
+ raise CMSSourceDeliveryHTTPBlocked(
876
+ "source_delivery_http.binding_invalid", 400,
877
+ )
878
+ return payload_hash
879
+
880
+ def _ready(self) -> dict[str, Any]:
881
+ try:
882
+ readiness = _readiness_payload(self.runtime.worker_readiness())
883
+ except CMSSourceDeliveryHTTPBlocked:
884
+ raise
885
+ except Exception:
886
+ raise CMSSourceDeliveryHTTPBlocked(
887
+ "source_delivery_http.runtime_blocked", 503,
888
+ ) from None
889
+ if readiness["status"] != "ready":
890
+ raise CMSSourceDeliveryHTTPBlocked(
891
+ "source_delivery_http.runtime_not_ready", 503,
892
+ )
893
+ return readiness
894
+
895
+ def __call__(self, environ: Mapping[str, Any], start_response: Callable[..., Any]):
896
+ try:
897
+ if not isinstance(environ, Mapping):
898
+ raise CMSSourceDeliveryHTTPBlocked(
899
+ "source_delivery_http.environment_invalid", 400,
900
+ )
901
+ path = environ.get("PATH_INFO")
902
+ if path not in SCOPES:
903
+ raise CMSSourceDeliveryHTTPBlocked(
904
+ "source_delivery_http.route_not_found", 404,
905
+ )
906
+ method = environ.get("REQUEST_METHOD")
907
+ if method != METHODS[path]:
908
+ raise CMSSourceDeliveryHTTPBlocked(
909
+ "source_delivery_http.method_not_allowed", 405,
910
+ )
911
+ if environ.get("wsgi.url_scheme") != "https":
912
+ raise CMSSourceDeliveryHTTPBlocked(
913
+ "source_delivery_http.https_required", 400,
914
+ )
915
+ if environ.get("QUERY_STRING") not in {None, ""}:
916
+ raise CMSSourceDeliveryHTTPBlocked(
917
+ "source_delivery_http.query_rejected", 400,
918
+ )
919
+ bodyless = path in {
920
+ HEALTH_PATH, READINESS_PATH, SOURCE_HEALTH_PATH,
921
+ SOURCE_READINESS_PATH, CAPABILITIES_PATH,
922
+ }
923
+ if bodyless:
924
+ body = self._body(environ, required=False)
925
+ if body or environ.get("CONTENT_TYPE") not in {None, ""}:
926
+ raise CMSSourceDeliveryHTTPBlocked(
927
+ "source_delivery_http.body_not_allowed", 400,
928
+ )
929
+ else:
930
+ if not self._content_type(environ.get("CONTENT_TYPE")):
931
+ raise CMSSourceDeliveryHTTPBlocked(
932
+ "source_delivery_http.content_type_invalid", 415,
933
+ )
934
+ body = self._body(environ, required=True)
935
+ principal = self._authenticate(environ, path, body)
936
+ capabilities = _capabilities_payload()
937
+
938
+ if path == CAPABILITIES_PATH:
939
+ return self._send(start_response, 200, {
940
+ "schema": CAPABILITIES_RESPONSE_SCHEMA,
941
+ "capabilities": capabilities,
942
+ })
943
+ if path == HEALTH_PATH:
944
+ try:
945
+ health = _health_payload(self.runtime.health())
946
+ except CMSSourceDeliveryHTTPBlocked:
947
+ raise
948
+ except Exception:
949
+ raise CMSSourceDeliveryHTTPBlocked(
950
+ "source_delivery_http.runtime_blocked", 503,
951
+ ) from None
952
+ status = 200 if health["status"] == "ok" else 503
953
+ return self._send(start_response, status, {
954
+ "schema": HEALTH_RESPONSE_SCHEMA,
955
+ "health": health,
956
+ "capabilities_sha256": capabilities["sha256"],
957
+ })
958
+ if path == READINESS_PATH:
959
+ try:
960
+ readiness = _readiness_payload(
961
+ self.runtime.worker_readiness(),
962
+ )
963
+ except CMSSourceDeliveryHTTPBlocked:
964
+ raise
965
+ except Exception:
966
+ raise CMSSourceDeliveryHTTPBlocked(
967
+ "source_delivery_http.runtime_blocked", 503,
968
+ ) from None
969
+ status = 200 if readiness["status"] == "ready" else 503
970
+ return self._send(start_response, status, {
971
+ "schema": READINESS_RESPONSE_SCHEMA,
972
+ "readiness": readiness,
973
+ "capabilities_sha256": capabilities["sha256"],
974
+ })
975
+ if path == SOURCE_READINESS_PATH:
976
+ try:
977
+ source_response = self.runtime.source_readiness()
978
+ except CMSSourceDeliveryHTTPBlocked:
979
+ raise
980
+ except Exception:
981
+ raise CMSSourceDeliveryHTTPBlocked(
982
+ "source_delivery_http.runtime_blocked", 503,
983
+ ) from None
984
+ source = _source_readiness_response(
985
+ source_response,
986
+ expected_capabilities_sha256=(
987
+ self.runtime.expected_capabilities_sha256
988
+ ),
989
+ )
990
+ status = (
991
+ 200 if source["readiness"]["status"] == "ready" else 503
992
+ )
993
+ return self._send(start_response, status, {
994
+ "schema": SOURCE_READINESS_RESPONSE_SCHEMA,
995
+ "source_readiness": source["readiness"],
996
+ "source_capability_binding": source[
997
+ "capability_binding"
998
+ ],
999
+ "source_capabilities_sha256": (
1000
+ self.runtime.expected_capabilities_sha256
1001
+ ),
1002
+ "capabilities_sha256": capabilities["sha256"],
1003
+ })
1004
+ if path == SOURCE_HEALTH_PATH:
1005
+ try:
1006
+ source_response = self.runtime.source_health()
1007
+ except CMSSourceDeliveryHTTPBlocked:
1008
+ raise
1009
+ except Exception:
1010
+ raise CMSSourceDeliveryHTTPBlocked(
1011
+ "source_delivery_http.runtime_blocked", 503,
1012
+ ) from None
1013
+ source = _source_health_response(
1014
+ source_response,
1015
+ expected_capabilities_sha256=(
1016
+ self.runtime.expected_capabilities_sha256
1017
+ ),
1018
+ )
1019
+ status = (
1020
+ 503 if source["health"]["status"] == "blocked" else 200
1021
+ )
1022
+ return self._send(start_response, status, {
1023
+ "schema": SOURCE_HEALTH_RESPONSE_SCHEMA,
1024
+ "source_health": source["health"],
1025
+ "source_capability_binding": source[
1026
+ "capability_binding"
1027
+ ],
1028
+ "source_capabilities_sha256": (
1029
+ self.runtime.expected_capabilities_sha256
1030
+ ),
1031
+ "capabilities_sha256": capabilities["sha256"],
1032
+ })
1033
+
1034
+ request = self._request(body)
1035
+ if path == SOURCE_STATUS_PATH:
1036
+ if (
1037
+ not isinstance(request, Mapping)
1038
+ or set(request) != {
1039
+ "schema", "event_id", "site_id", "payload_sha256",
1040
+ }
1041
+ or request.get("schema") != SOURCE_STATUS_REQUEST_SCHEMA
1042
+ ):
1043
+ raise CMSSourceDeliveryHTTPBlocked(
1044
+ "source_delivery_http.request_invalid", 400,
1045
+ )
1046
+ try:
1047
+ event_id = _token(request["event_id"])
1048
+ site_id = _token(request["site_id"])
1049
+ payload_sha256 = _sha256(request["payload_sha256"])
1050
+ except (KeyError, TypeError, ValueError):
1051
+ raise CMSSourceDeliveryHTTPBlocked(
1052
+ "source_delivery_http.request_invalid", 400,
1053
+ ) from None
1054
+ if site_id != principal["site_id"]:
1055
+ raise CMSSourceDeliveryHTTPBlocked(
1056
+ "source_delivery_http.status_not_found", 404,
1057
+ )
1058
+ try:
1059
+ source_response = self.runtime.source_status(
1060
+ event_id,
1061
+ site_id,
1062
+ payload_sha256,
1063
+ )
1064
+ except Exception as error:
1065
+ code = getattr(error, "code", None)
1066
+ if code == "source_delivery_runtime.request_invalid":
1067
+ raise CMSSourceDeliveryHTTPBlocked(
1068
+ "source_delivery_http.request_invalid", 400,
1069
+ ) from None
1070
+ if code == "source_delivery_runtime.status_not_found":
1071
+ raise CMSSourceDeliveryHTTPBlocked(
1072
+ "source_delivery_http.status_not_found", 404,
1073
+ ) from None
1074
+ if code == (
1075
+ "source_delivery_runtime.source_status_unavailable"
1076
+ ):
1077
+ raise CMSSourceDeliveryHTTPBlocked(
1078
+ "source_delivery_http.source_status_unavailable",
1079
+ 409,
1080
+ ) from None
1081
+ raise CMSSourceDeliveryHTTPBlocked(
1082
+ "source_delivery_http.runtime_blocked", 503,
1083
+ ) from None
1084
+ source = _source_status_response(
1085
+ source_response,
1086
+ expected_event_id=event_id,
1087
+ expected_site_id=site_id,
1088
+ expected_capabilities_sha256=(
1089
+ self.runtime.expected_capabilities_sha256
1090
+ ),
1091
+ )
1092
+ return self._send(start_response, 200, {
1093
+ "schema": SOURCE_STATUS_RESPONSE_SCHEMA,
1094
+ "source_status": source["status"],
1095
+ "source_capability_binding": source[
1096
+ "capability_binding"
1097
+ ],
1098
+ "source_capabilities_sha256": (
1099
+ self.runtime.expected_capabilities_sha256
1100
+ ),
1101
+ "capabilities_sha256": capabilities["sha256"],
1102
+ })
1103
+
1104
+ if path == STATUS_PATH:
1105
+ if (
1106
+ not isinstance(request, Mapping)
1107
+ or set(request) != {
1108
+ "schema", "operation", "request_id", "site_id",
1109
+ }
1110
+ or request.get("schema") != STATUS_REQUEST_SCHEMA
1111
+ or request.get("operation") not in OPERATIONS
1112
+ ):
1113
+ raise CMSSourceDeliveryHTTPBlocked(
1114
+ "source_delivery_http.request_invalid", 400,
1115
+ )
1116
+ try:
1117
+ operation = request["operation"]
1118
+ request_id = _token(request["request_id"])
1119
+ site_id = _token(request["site_id"])
1120
+ except (KeyError, TypeError, ValueError):
1121
+ raise CMSSourceDeliveryHTTPBlocked(
1122
+ "source_delivery_http.request_invalid", 400,
1123
+ ) from None
1124
+ if site_id != principal["site_id"]:
1125
+ raise CMSSourceDeliveryHTTPBlocked(
1126
+ "source_delivery_http.status_not_found", 404,
1127
+ )
1128
+ try:
1129
+ status_value = self.runtime.status(operation, request_id)
1130
+ except Exception as error:
1131
+ code = getattr(error, "code", None)
1132
+ if code == "source_delivery_runtime.request_invalid":
1133
+ raise CMSSourceDeliveryHTTPBlocked(
1134
+ "source_delivery_http.request_invalid", 400,
1135
+ ) from None
1136
+ if code == "source_delivery_runtime.status_not_found":
1137
+ raise CMSSourceDeliveryHTTPBlocked(
1138
+ "source_delivery_http.status_not_found", 404,
1139
+ ) from None
1140
+ raise CMSSourceDeliveryHTTPBlocked(
1141
+ "source_delivery_http.runtime_blocked", 503,
1142
+ ) from None
1143
+ status_payload = _status_payload(
1144
+ status_value,
1145
+ expected_operation=operation,
1146
+ expected_request_id=request_id,
1147
+ )
1148
+ if status_payload["site_id"] != site_id:
1149
+ raise CMSSourceDeliveryHTTPBlocked(
1150
+ "source_delivery_http.status_not_found", 404,
1151
+ )
1152
+ return self._send(start_response, 200, {
1153
+ "schema": STATUS_RESPONSE_SCHEMA,
1154
+ "status": status_payload,
1155
+ "capabilities_sha256": capabilities["sha256"],
1156
+ })
1157
+
1158
+ envelope_key = "change" if path == CHANGE_PATH else "removal"
1159
+ expected_schema = (
1160
+ CHANGE_REQUEST_SCHEMA if path == CHANGE_PATH
1161
+ else REMOVAL_REQUEST_SCHEMA
1162
+ )
1163
+ if (
1164
+ not isinstance(request, Mapping)
1165
+ or set(request) != {
1166
+ "schema", envelope_key, "source_max_attempts",
1167
+ "delivery_max_attempts",
1168
+ }
1169
+ or request.get("schema") != expected_schema
1170
+ ):
1171
+ raise CMSSourceDeliveryHTTPBlocked(
1172
+ "source_delivery_http.request_invalid", 400,
1173
+ )
1174
+ try:
1175
+ source_max = _count(
1176
+ request["source_max_attempts"], minimum=1, maximum=20,
1177
+ )
1178
+ delivery_max = _count(
1179
+ request["delivery_max_attempts"], minimum=1, maximum=20,
1180
+ )
1181
+ payload = request[envelope_key]
1182
+ site_id, _event_id, request_id = _request_identity(
1183
+ payload, change=path == CHANGE_PATH,
1184
+ )
1185
+ except (KeyError, TypeError, ValueError):
1186
+ raise CMSSourceDeliveryHTTPBlocked(
1187
+ "source_delivery_http.request_invalid", 400,
1188
+ ) from None
1189
+ if site_id != principal["site_id"]:
1190
+ raise CMSSourceDeliveryHTTPBlocked(
1191
+ "source_delivery_http.site_rejected", 403,
1192
+ )
1193
+ payload_hash = self._write_binding(environ, payload, request_id)
1194
+ self._ready()
1195
+ try:
1196
+ if path == CHANGE_PATH:
1197
+ queued = self.runtime.enqueue_change(
1198
+ payload,
1199
+ source_max_attempts=source_max,
1200
+ delivery_max_attempts=delivery_max,
1201
+ )
1202
+ else:
1203
+ queued = self.runtime.enqueue_removal(
1204
+ payload,
1205
+ source_max_attempts=source_max,
1206
+ delivery_max_attempts=delivery_max,
1207
+ )
1208
+ except Exception as error:
1209
+ code = getattr(error, "code", None)
1210
+ if code == "source_delivery_runtime.request_invalid":
1211
+ raise CMSSourceDeliveryHTTPBlocked(
1212
+ "source_delivery_http.request_invalid", 400,
1213
+ ) from None
1214
+ if code == "source_delivery_runtime.idempotency_collision":
1215
+ raise CMSSourceDeliveryHTTPBlocked(
1216
+ "source_delivery_http.idempotency_collision", 409,
1217
+ ) from None
1218
+ raise CMSSourceDeliveryHTTPBlocked(
1219
+ "source_delivery_http.runtime_blocked", 503,
1220
+ ) from None
1221
+ response = _status_payload(
1222
+ queued,
1223
+ expected_operation=("change" if path == CHANGE_PATH else None),
1224
+ expected_request_id=request_id,
1225
+ expected_site_id=site_id,
1226
+ )
1227
+ if response["payload_sha256"] != payload_hash:
1228
+ raise CMSSourceDeliveryHTTPBlocked(
1229
+ "source_delivery_http.runtime_response_invalid", 503,
1230
+ )
1231
+ return self._send(start_response, 202, {
1232
+ "schema": QUEUE_RESPONSE_SCHEMA,
1233
+ "queue": response,
1234
+ "capabilities_sha256": capabilities["sha256"],
1235
+ })
1236
+ except CMSSourceDeliveryHTTPBlocked as failure:
1237
+ return self._error(start_response, failure)
1238
+ except Exception:
1239
+ return self._error(
1240
+ start_response,
1241
+ CMSSourceDeliveryHTTPBlocked(
1242
+ "source_delivery_http.internal", 503,
1243
+ ),
1244
+ )