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,1152 @@
1
+ #!/usr/bin/env python3
2
+ """Authenticated HTTP sidecar for the caller-owned submission outbox.
3
+
4
+ Authentication sees transport metadata and the exact body digest before JSON
5
+ is parsed. Tenant writes and reads are then bound to the authenticated site,
6
+ canonical source-payload hash, request identity, and runtime capability pin.
7
+ Responses are content-free and never grant localization or publication.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import dataclasses
13
+ import hashlib
14
+ import importlib.util
15
+ import json
16
+ import math
17
+ import re
18
+ import sys
19
+ import unicodedata
20
+ from pathlib import Path
21
+ from typing import Any, Callable, Mapping
22
+
23
+
24
+ API_SCHEMA = "blun.cms-public-submission-dispatch-http.v14"
25
+ ERROR_SCHEMA = "blun.cms-public-submission-dispatch-http-error.v1"
26
+ AUTH_REQUEST_SCHEMA = "blun.cms-public-submission-dispatch-auth-request.v1"
27
+ TENANT_PRINCIPAL_SCHEMA = (
28
+ "blun.cms-public-submission-dispatch-tenant-principal.v1"
29
+ )
30
+ OPERATOR_PRINCIPAL_SCHEMA = (
31
+ "blun.cms-public-submission-dispatch-operator-principal.v1"
32
+ )
33
+ ENQUEUE_REQUEST_SCHEMA = (
34
+ "blun.cms-public-submission-dispatch-enqueue-request.v2"
35
+ )
36
+ COMMERCIAL_CONTRACT_BINDING_SCHEMA = (
37
+ "blun.cms-public-submission-dispatch-commercial-contract-binding.v1"
38
+ )
39
+ STATUS_REQUEST_SCHEMA = "blun.cms-public-submission-dispatch-status-request.v1"
40
+ LIFECYCLE_REQUEST_SCHEMA = (
41
+ "blun.cms-public-submission-dispatch-lifecycle-request.v1"
42
+ )
43
+ QUEUE_RESPONSE_SCHEMA = "blun.cms-public-submission-dispatch-queue-response.v3"
44
+ STATUS_RESPONSE_SCHEMA = "blun.cms-public-submission-dispatch-status-response.v3"
45
+ LIFECYCLE_RESPONSE_SCHEMA = (
46
+ "blun.cms-public-submission-dispatch-lifecycle-response.v2"
47
+ )
48
+ COMMERCIAL_PROFILE_RESPONSE_SCHEMA = (
49
+ "blun.cms-public-submission-dispatch-commercial-profile-response.v1"
50
+ )
51
+ HEALTH_RESPONSE_SCHEMA = "blun.cms-public-submission-dispatch-health-response.v1"
52
+ READINESS_RESPONSE_SCHEMA = (
53
+ "blun.cms-public-submission-dispatch-readiness-response.v1"
54
+ )
55
+ CAPABILITIES_SCHEMA = "blun.cms-public-submission-dispatch-capabilities.v14"
56
+ CAPABILITIES_RESPONSE_SCHEMA = (
57
+ "blun.cms-public-submission-dispatch-capabilities-response.v14"
58
+ )
59
+ OPENAPI_RESPONSE_SCHEMA = (
60
+ "blun.cms-public-submission-dispatch-openapi-response.v1"
61
+ )
62
+
63
+ ENQUEUE_PATH = "/v1/localization/cms-submission-dispatch/requests"
64
+ STATUS_PATH = "/v1/localization/cms-submission-dispatch/status"
65
+ LIFECYCLE_PATH = "/v1/localization/cms-submission-dispatch/lifecycle"
66
+ COMMERCIAL_PROFILE_PATH = (
67
+ "/v1/localization/cms-submission-dispatch/commercial-profile"
68
+ )
69
+ HEALTH_PATH = "/v1/localization/cms-submission-dispatch/health"
70
+ READINESS_PATH = "/v1/localization/cms-submission-dispatch/readiness"
71
+ CAPABILITIES_PATH = "/v1/localization/cms-submission-dispatch/capabilities"
72
+ OPENAPI_PATH = "/v1/localization/cms-submission-dispatch/openapi"
73
+ SCOPES = {
74
+ ENQUEUE_PATH: "cms-submission-dispatch:write",
75
+ STATUS_PATH: "cms-submission-dispatch-status:read",
76
+ LIFECYCLE_PATH: "cms-submission-dispatch-lifecycle:read",
77
+ COMMERCIAL_PROFILE_PATH: "cms-submission-dispatch-commercial-profile:read",
78
+ HEALTH_PATH: "cms-submission-dispatch-health:read",
79
+ READINESS_PATH: "cms-submission-dispatch-readiness:read",
80
+ CAPABILITIES_PATH: "cms-submission-dispatch-capabilities:read",
81
+ OPENAPI_PATH: "cms-submission-dispatch-openapi:read",
82
+ }
83
+ METHODS = {
84
+ ENQUEUE_PATH: "POST",
85
+ STATUS_PATH: "POST",
86
+ LIFECYCLE_PATH: "POST",
87
+ COMMERCIAL_PROFILE_PATH: "GET",
88
+ HEALTH_PATH: "GET",
89
+ READINESS_PATH: "GET",
90
+ CAPABILITIES_PATH: "GET",
91
+ OPENAPI_PATH: "GET",
92
+ }
93
+ TENANT_PATHS = {ENQUEUE_PATH, STATUS_PATH, LIFECYCLE_PATH}
94
+ BODYLESS_PATHS = {
95
+ COMMERCIAL_PROFILE_PATH, HEALTH_PATH, READINESS_PATH, CAPABILITIES_PATH,
96
+ OPENAPI_PATH,
97
+ }
98
+ CAPABILITIES_PRECONDITION_HEADER = "X-Localization-Capabilities-SHA256"
99
+ CAPABILITIES_PRECONDITION_PATHS = set(SCOPES) - {CAPABILITIES_PATH}
100
+ _AUTHENTICATION_ERRORS = {
101
+ 401: ("submission_dispatch_http.authentication_failed",),
102
+ 403: ("submission_dispatch_http.scope_rejected",),
103
+ 405: ("submission_dispatch_http.method_not_allowed",),
104
+ }
105
+ _COMMON_503_ERRORS = (
106
+ "submission_dispatch_http.authentication_unavailable",
107
+ "submission_dispatch_http.capabilities_invalid",
108
+ "submission_dispatch_http.response_invalid",
109
+ "submission_dispatch_http.runtime_blocked",
110
+ )
111
+ _BODYLESS_400_ERRORS = (
112
+ "submission_dispatch_http.body_invalid",
113
+ "submission_dispatch_http.body_not_allowed",
114
+ "submission_dispatch_http.headers_invalid",
115
+ "submission_dispatch_http.https_required",
116
+ "submission_dispatch_http.query_rejected",
117
+ "submission_dispatch_http.transfer_encoding_rejected",
118
+ )
119
+ _BODY_400_ERRORS = (
120
+ "submission_dispatch_http.body_invalid",
121
+ "submission_dispatch_http.headers_invalid",
122
+ "submission_dispatch_http.https_required",
123
+ "submission_dispatch_http.json_invalid",
124
+ "submission_dispatch_http.query_rejected",
125
+ "submission_dispatch_http.request_invalid",
126
+ "submission_dispatch_http.transfer_encoding_rejected",
127
+ )
128
+ _CAPABILITIES_PRECONDITION_ERRORS = {
129
+ 412: ("submission_dispatch_http.capabilities_precondition_failed",),
130
+ 428: ("submission_dispatch_http.capabilities_precondition_required",),
131
+ }
132
+ ERROR_CODES = {
133
+ CAPABILITIES_PATH: {
134
+ 400: _BODYLESS_400_ERRORS,
135
+ **_AUTHENTICATION_ERRORS,
136
+ 411: ("submission_dispatch_http.content_length_required",),
137
+ 413: ("submission_dispatch_http.body_too_large",),
138
+ 503: _COMMON_503_ERRORS,
139
+ },
140
+ COMMERCIAL_PROFILE_PATH: {
141
+ 400: _BODYLESS_400_ERRORS,
142
+ **_AUTHENTICATION_ERRORS,
143
+ **_CAPABILITIES_PRECONDITION_ERRORS,
144
+ 411: ("submission_dispatch_http.content_length_required",),
145
+ 413: ("submission_dispatch_http.body_too_large",),
146
+ 503: tuple(sorted((
147
+ *_COMMON_503_ERRORS,
148
+ "submission_dispatch_http.runtime_response_invalid",
149
+ ))),
150
+ },
151
+ ENQUEUE_PATH: {
152
+ 400: tuple(sorted((
153
+ *_BODY_400_ERRORS, "submission_dispatch_http.binding_invalid",
154
+ ))),
155
+ **_AUTHENTICATION_ERRORS,
156
+ **_CAPABILITIES_PRECONDITION_ERRORS,
157
+ 409: ("submission_dispatch_http.idempotency_collision",),
158
+ 411: ("submission_dispatch_http.content_length_required",),
159
+ 413: ("submission_dispatch_http.body_too_large",),
160
+ 415: ("submission_dispatch_http.content_type_invalid",),
161
+ 503: tuple(sorted((
162
+ *_COMMON_503_ERRORS,
163
+ "submission_dispatch_http.runtime_not_ready",
164
+ "submission_dispatch_http.runtime_response_invalid",
165
+ ))),
166
+ },
167
+ HEALTH_PATH: {
168
+ 400: _BODYLESS_400_ERRORS,
169
+ **_AUTHENTICATION_ERRORS,
170
+ **_CAPABILITIES_PRECONDITION_ERRORS,
171
+ 411: ("submission_dispatch_http.content_length_required",),
172
+ 413: ("submission_dispatch_http.body_too_large",),
173
+ 503: tuple(sorted((
174
+ *_COMMON_503_ERRORS,
175
+ "submission_dispatch_http.runtime_response_invalid",
176
+ ))),
177
+ },
178
+ OPENAPI_PATH: {
179
+ 400: _BODYLESS_400_ERRORS,
180
+ **_AUTHENTICATION_ERRORS,
181
+ **_CAPABILITIES_PRECONDITION_ERRORS,
182
+ 411: ("submission_dispatch_http.content_length_required",),
183
+ 413: ("submission_dispatch_http.body_too_large",),
184
+ 503: _COMMON_503_ERRORS,
185
+ },
186
+ READINESS_PATH: {
187
+ 400: _BODYLESS_400_ERRORS,
188
+ **_AUTHENTICATION_ERRORS,
189
+ **_CAPABILITIES_PRECONDITION_ERRORS,
190
+ 411: ("submission_dispatch_http.content_length_required",),
191
+ 413: ("submission_dispatch_http.body_too_large",),
192
+ 503: tuple(sorted((
193
+ *_COMMON_503_ERRORS,
194
+ "submission_dispatch_http.runtime_response_invalid",
195
+ ))),
196
+ },
197
+ STATUS_PATH: {
198
+ 400: _BODY_400_ERRORS,
199
+ **_AUTHENTICATION_ERRORS,
200
+ **_CAPABILITIES_PRECONDITION_ERRORS,
201
+ 404: ("submission_dispatch_http.submission_not_found",),
202
+ 411: ("submission_dispatch_http.content_length_required",),
203
+ 413: ("submission_dispatch_http.body_too_large",),
204
+ 415: ("submission_dispatch_http.content_type_invalid",),
205
+ 503: _COMMON_503_ERRORS,
206
+ },
207
+ LIFECYCLE_PATH: {
208
+ 400: _BODY_400_ERRORS,
209
+ **_AUTHENTICATION_ERRORS,
210
+ **_CAPABILITIES_PRECONDITION_ERRORS,
211
+ 404: ("submission_dispatch_http.submission_not_found",),
212
+ 409: ("submission_dispatch_http.lifecycle_not_accepted",),
213
+ 411: ("submission_dispatch_http.content_length_required",),
214
+ 413: ("submission_dispatch_http.body_too_large",),
215
+ 415: ("submission_dispatch_http.content_type_invalid",),
216
+ 503: tuple(sorted((
217
+ *_COMMON_503_ERRORS,
218
+ "submission_dispatch_http.runtime_response_invalid",
219
+ ))),
220
+ },
221
+ }
222
+ ERROR_STATUSES = {
223
+ path: tuple(sorted(statuses)) for path, statuses in ERROR_CODES.items()
224
+ }
225
+ RESPONSE_INVARIANTS = {
226
+ CAPABILITIES_PATH: (
227
+ "exact_capability_generation",
228
+ ),
229
+ COMMERCIAL_PROFILE_PATH: (
230
+ "exact_brand_neutral_commercial_profile",
231
+ "all_24_locale_rendering_bindings_present",
232
+ "registry_matches_live_website_capability_binding",
233
+ "project_prices_brands_and_content_absent",
234
+ "profile_grants_no_publication_authority",
235
+ ),
236
+ ENQUEUE_PATH: (
237
+ "commercial_contract_binding_matches_content_type",
238
+ "status_identity_matches_request",
239
+ "attempts_lte_client_max_attempts",
240
+ "leased_iff_lease_expires_at",
241
+ "accepted_iff_remote_website_binding_complete_and_valid",
242
+ "accepted_commercial_contract_matches_remote_registry",
243
+ ),
244
+ HEALTH_PATH: (
245
+ "queue_count_sum_matches_operation_count_sum",
246
+ "due_lte_pending_plus_retry_wait",
247
+ "expired_leases_lte_leased",
248
+ "failed_equals_failed_count",
249
+ "ok_iff_no_expired_leases_or_failures",
250
+ ),
251
+ OPENAPI_PATH: (
252
+ "exact_openapi_generation",
253
+ "capabilities_sha256_matches",
254
+ ),
255
+ READINESS_PATH: (
256
+ "ready_iff_worker_running_and_outbox_ok_and_no_error",
257
+ "not_ready_requires_error",
258
+ ),
259
+ STATUS_PATH: (
260
+ "status_identity_matches_request",
261
+ "attempts_lte_client_max_attempts",
262
+ "leased_iff_lease_expires_at",
263
+ "accepted_iff_remote_website_binding_complete_and_valid",
264
+ ),
265
+ LIFECYCLE_PATH: (
266
+ "status_identity_matches_request",
267
+ "dispatch_must_be_accepted_before_downstream_read",
268
+ "source_lifecycle_identity_matches_request",
269
+ "website_binding_matches_durable_acceptance",
270
+ "accepted_does_not_imply_publication",
271
+ ),
272
+ }
273
+ MAX_BODY_BYTES = 4_000_000
274
+ MAX_HEADERS = 64
275
+ MAX_HEADER_VALUE = 4096
276
+ TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$")
277
+ SHA256 = re.compile(r"^[0-9a-f]{64}$")
278
+ ERROR_CODE = re.compile(r"^[a-z][a-z0-9_.-]{0,127}$")
279
+ HEADER_NAME = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$")
280
+ STATUSES = {"pending", "leased", "retry_wait", "accepted", "failed"}
281
+ OPERATIONS = {"change", "cancellation", "tombstone"}
282
+ WORKER_STATES = {
283
+ "unmanaged", "running", "stopping", "stopped", "failed", "closed",
284
+ }
285
+
286
+
287
+ def _load_module(name: str, path: Path):
288
+ spec = importlib.util.spec_from_file_location(name, path)
289
+ if spec is None or spec.loader is None:
290
+ raise RuntimeError("cannot load submission dispatch HTTP dependency")
291
+ module = importlib.util.module_from_spec(spec)
292
+ sys.modules[spec.name] = module
293
+ spec.loader.exec_module(module)
294
+ return module
295
+
296
+
297
+ _ROOT = Path(__file__).resolve().parents[1]
298
+ _DISPATCH = _load_module(
299
+ "blun_website_localization_submission_dispatch_http_store",
300
+ _ROOT
301
+ / "integrations"
302
+ / "website_localization_cms_source_delivery_submission_dispatch.py",
303
+ )
304
+ _OPENAPI = _load_module(
305
+ "blun_website_localization_submission_dispatch_http_openapi",
306
+ _ROOT
307
+ / "integrations"
308
+ / "website_localization_cms_source_delivery_submission_dispatch_openapi.py",
309
+ )
310
+
311
+
312
+ class CMSSourceDeliverySubmissionDispatchHTTPBlocked(RuntimeError):
313
+ """Stable content-free HTTP failure."""
314
+
315
+ def __init__(self, code: str, status: int):
316
+ if ERROR_CODE.fullmatch(code) is None or status not in {
317
+ 400, 401, 403, 404, 405, 409, 411, 412, 413, 415, 428, 503,
318
+ }:
319
+ raise ValueError("invalid submission dispatch HTTP failure")
320
+ super().__init__(code)
321
+ self.code = code
322
+ self.status = status
323
+
324
+
325
+ def _blocked(code: str, status: int):
326
+ return CMSSourceDeliverySubmissionDispatchHTTPBlocked(
327
+ "submission_dispatch_http." + code, status,
328
+ )
329
+
330
+
331
+ def _canonical(value: Any, *, response: bool = False) -> bytes:
332
+ try:
333
+ raw = json.dumps(
334
+ value, ensure_ascii=False, allow_nan=False, sort_keys=True,
335
+ separators=(",", ":"),
336
+ ).encode("utf-8")
337
+ except (TypeError, ValueError, RecursionError):
338
+ raise _blocked(
339
+ "response_invalid" if response else "request_invalid",
340
+ 503 if response else 400,
341
+ ) from None
342
+ if not raw or len(raw) > (1_000_000 if response else MAX_BODY_BYTES):
343
+ raise _blocked(
344
+ "response_invalid" if response else "request_invalid",
345
+ 503 if response else 400,
346
+ )
347
+ return raw
348
+
349
+
350
+ def _pairs(items):
351
+ result = {}
352
+ for key, value in items:
353
+ if key in result:
354
+ raise ValueError
355
+ result[key] = value
356
+ return result
357
+
358
+
359
+ def _constant(_value):
360
+ raise ValueError
361
+
362
+
363
+ def _token(value: Any) -> str:
364
+ if (
365
+ not isinstance(value, str)
366
+ or TOKEN.fullmatch(value) is None
367
+ or not unicodedata.is_normalized("NFC", value)
368
+ ):
369
+ raise ValueError
370
+ return value
371
+
372
+
373
+ def _sha256(value: Any) -> str:
374
+ if not isinstance(value, str) or SHA256.fullmatch(value) is None:
375
+ raise ValueError
376
+ return value
377
+
378
+
379
+ def _count(value: Any, *, minimum: int = 0, maximum: int = 20) -> int:
380
+ if (
381
+ isinstance(value, bool)
382
+ or not isinstance(value, int)
383
+ or not minimum <= value <= maximum
384
+ ):
385
+ raise ValueError
386
+ return value
387
+
388
+
389
+ def _timestamp(value: Any) -> float:
390
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
391
+ raise ValueError
392
+ result = float(value)
393
+ if result < 0 or not math.isfinite(result):
394
+ raise ValueError
395
+ return result
396
+
397
+
398
+ def _optional_timestamp(value: Any) -> float | None:
399
+ return None if value is None else _timestamp(value)
400
+
401
+
402
+ def _optional(value: Any, validator: Callable[[Any], Any]) -> Any:
403
+ return None if value is None else validator(value)
404
+
405
+
406
+ def _headers(environ: Mapping[str, Any]) -> tuple[tuple[str, str], ...]:
407
+ headers = []
408
+ for key, value in environ.items():
409
+ if key.startswith("HTTP_"):
410
+ name = key[5:].replace("_", "-").lower()
411
+ elif key in {"CONTENT_TYPE", "CONTENT_LENGTH"}:
412
+ name = key.replace("_", "-").lower()
413
+ else:
414
+ continue
415
+ if (
416
+ HEADER_NAME.fullmatch(name) is None
417
+ or not isinstance(value, str)
418
+ or len(value) > MAX_HEADER_VALUE
419
+ or "\r" in value
420
+ or "\n" in value
421
+ ):
422
+ raise _blocked("headers_invalid", 400)
423
+ headers.append((name, value))
424
+ headers.sort()
425
+ if (
426
+ len(headers) > MAX_HEADERS
427
+ or len({name for name, _ in headers}) != len(headers)
428
+ ):
429
+ raise _blocked("headers_invalid", 400)
430
+ return tuple(headers)
431
+
432
+
433
+ def _body(environ: Mapping[str, Any], *, required: bool) -> bytes:
434
+ if environ.get("HTTP_TRANSFER_ENCODING") not in {None, ""}:
435
+ raise _blocked("transfer_encoding_rejected", 400)
436
+ length = environ.get("CONTENT_LENGTH")
437
+ if not required and length in {None, "", "0"}:
438
+ return b""
439
+ if (
440
+ not isinstance(length, str)
441
+ or not length.isascii()
442
+ or not length.isdecimal()
443
+ ):
444
+ raise _blocked("content_length_required", 411)
445
+ size = int(length)
446
+ if size <= 0:
447
+ raise _blocked("body_invalid", 400)
448
+ if size > MAX_BODY_BYTES:
449
+ raise _blocked("body_too_large", 413)
450
+ try:
451
+ raw = environ["wsgi.input"].read(size)
452
+ except Exception:
453
+ raw = None
454
+ if not isinstance(raw, bytes) or len(raw) != size:
455
+ raise _blocked("body_invalid", 400)
456
+ return raw
457
+
458
+
459
+ def _request(raw: bytes) -> Mapping[str, Any]:
460
+ try:
461
+ text = raw.decode("utf-8")
462
+ if text.startswith("\ufeff"):
463
+ raise ValueError
464
+ value = json.loads(
465
+ text, object_pairs_hook=_pairs, parse_constant=_constant,
466
+ )
467
+ except (UnicodeDecodeError, json.JSONDecodeError, ValueError, RecursionError):
468
+ raise _blocked("json_invalid", 400) from None
469
+ if not isinstance(value, Mapping):
470
+ raise _blocked("request_invalid", 400)
471
+ return value
472
+
473
+
474
+ def _principal(value: Any, scope: str, *, tenant: bool) -> dict[str, str]:
475
+ fields = {
476
+ "schema", "principal_id", "credential_id", "credential_version", "scope",
477
+ }
478
+ if tenant:
479
+ fields.add("site_id")
480
+ if not isinstance(value, Mapping) or set(value) != fields:
481
+ raise _blocked("authentication_failed", 401)
482
+ try:
483
+ expected = TENANT_PRINCIPAL_SCHEMA if tenant else OPERATOR_PRINCIPAL_SCHEMA
484
+ if value["scope"] != scope:
485
+ raise _blocked("scope_rejected", 403)
486
+ if value["schema"] != expected:
487
+ raise ValueError
488
+ for name in fields - {"schema"}:
489
+ _token(value[name])
490
+ except CMSSourceDeliverySubmissionDispatchHTTPBlocked:
491
+ raise
492
+ except Exception:
493
+ raise _blocked("authentication_failed", 401) from None
494
+ return dict(value)
495
+
496
+
497
+ def _status_payload(
498
+ value: Any,
499
+ *,
500
+ operation: str | None = None,
501
+ request_id: str | None = None,
502
+ event_id: str | None = None,
503
+ site_id: str | None = None,
504
+ payload_sha256: str | None = None,
505
+ ) -> dict[str, Any]:
506
+ try:
507
+ payload = dataclasses.asdict(value)
508
+ fields = {
509
+ "operation", "request_id", "event_id", "site_id", "payload_sha256",
510
+ "commercial_contract_binding",
511
+ "source_max_attempts", "delivery_max_attempts", "status", "attempts",
512
+ "client_max_attempts", "next_attempt_at", "lease_expires_at",
513
+ "lease_expired", "last_error_code", "remote_status",
514
+ "remote_attempts", "remote_capabilities_sha256",
515
+ "remote_binding_sha256", "remote_website_capability_binding",
516
+ "response_sha256",
517
+ }
518
+ if set(payload) != fields or payload["operation"] not in OPERATIONS:
519
+ raise ValueError
520
+ for name in ("request_id", "event_id", "site_id"):
521
+ _token(payload[name])
522
+ _sha256(payload["payload_sha256"])
523
+ commercial_binding = payload["commercial_contract_binding"]
524
+ if commercial_binding is not None:
525
+ if commercial_binding != _DISPATCH._commercial_contract_binding():
526
+ raise ValueError
527
+ if payload["operation"] != "change" and commercial_binding is not None:
528
+ raise ValueError
529
+ for name in (
530
+ "source_max_attempts", "delivery_max_attempts",
531
+ "client_max_attempts",
532
+ ):
533
+ _count(payload[name], minimum=1)
534
+ _count(payload["attempts"])
535
+ _timestamp(payload["next_attempt_at"])
536
+ lease = _optional_timestamp(payload["lease_expires_at"])
537
+ if not isinstance(payload["lease_expired"], bool):
538
+ raise ValueError
539
+ error = _optional(
540
+ payload["last_error_code"],
541
+ lambda item: item if ERROR_CODE.fullmatch(item) else (_ for _ in ()).throw(ValueError()),
542
+ )
543
+ remote_status = payload["remote_status"]
544
+ if remote_status not in {None, "pending", "leased", "retry_wait", "succeeded", "failed"}:
545
+ raise ValueError
546
+ remote_attempts = _optional(
547
+ payload["remote_attempts"], lambda item: _count(item),
548
+ )
549
+ for name in (
550
+ "remote_capabilities_sha256", "remote_binding_sha256",
551
+ "response_sha256",
552
+ ):
553
+ _optional(payload[name], _sha256)
554
+ remote_binding = payload["remote_website_capability_binding"]
555
+ if remote_binding is not None:
556
+ remote_binding = _website_capability_binding(remote_binding)
557
+ if remote_binding != payload["remote_website_capability_binding"]:
558
+ raise ValueError
559
+ if payload["status"] not in STATUSES:
560
+ raise ValueError
561
+ if (payload["status"] == "leased") != (lease is not None):
562
+ raise ValueError
563
+ accepted = payload["status"] == "accepted"
564
+ remote_fields = (
565
+ remote_status, remote_attempts,
566
+ payload["remote_capabilities_sha256"],
567
+ payload["remote_binding_sha256"], remote_binding,
568
+ payload["response_sha256"],
569
+ )
570
+ if accepted != all(item is not None for item in remote_fields):
571
+ raise ValueError
572
+ if accepted and (
573
+ remote_binding["delivery_capabilities_sha256"]
574
+ != payload["remote_capabilities_sha256"]
575
+ or hashlib.sha256(_canonical(remote_binding)).hexdigest()
576
+ != payload["remote_binding_sha256"]
577
+ or commercial_binding is not None
578
+ and remote_binding["commercial_rendering_registry_sha256"]
579
+ != commercial_binding["commercial_rendering_registry_sha256"]
580
+ ):
581
+ raise ValueError
582
+ expected = {
583
+ "operation": operation, "request_id": request_id,
584
+ "event_id": event_id, "site_id": site_id,
585
+ "payload_sha256": payload_sha256,
586
+ }
587
+ if any(
588
+ wanted is not None and payload[name] != wanted
589
+ for name, wanted in expected.items()
590
+ ):
591
+ raise ValueError
592
+ payload["last_error_code"] = error
593
+ return payload
594
+ except Exception:
595
+ raise _blocked("runtime_response_invalid", 503) from None
596
+
597
+
598
+ def _website_capability_binding(value: Any) -> dict[str, Any]:
599
+ return _DISPATCH._CLIENT._HTTP._binding(value)
600
+
601
+
602
+ def _lifecycle_payload(
603
+ value: Any, identity: Mapping[str, str],
604
+ ) -> dict[str, Any]:
605
+ try:
606
+ payload = dataclasses.asdict(value)
607
+ if set(payload) != {"dispatch_status", "source_lifecycle"}:
608
+ raise ValueError
609
+ dispatch_status = _status_payload(
610
+ value.dispatch_status,
611
+ operation=identity["operation"],
612
+ request_id=identity["request_id"],
613
+ event_id=identity["event_id"],
614
+ site_id=identity["site_id"],
615
+ payload_sha256=identity["payload_sha256"],
616
+ )
617
+ if dispatch_status["status"] != "accepted":
618
+ raise ValueError
619
+ source = payload["source_lifecycle"]
620
+ source_http = _DISPATCH._CLIENT._HTTP
621
+ if (
622
+ not isinstance(source, Mapping)
623
+ or set(source) != {
624
+ "schema", "api_schema", "result",
625
+ "accepted_implies_publication",
626
+ }
627
+ or source.get("schema")
628
+ != source_http._SUBMISSION.LIFECYCLE_HTTP_RESPONSE_SCHEMA
629
+ or source.get("api_schema") != source_http.API_SCHEMA
630
+ or source.get("accepted_implies_publication") is not False
631
+ or not isinstance(source.get("result"), Mapping)
632
+ ):
633
+ raise ValueError
634
+ normalized = source_http._submission_lifecycle_payload(
635
+ source_http._PayloadView(source["result"]), identity,
636
+ )
637
+ if (
638
+ normalized != source["result"]
639
+ or normalized["website_capability_binding"]
640
+ != dispatch_status["remote_website_capability_binding"]
641
+ ):
642
+ raise ValueError
643
+ return {
644
+ "dispatch_status": dispatch_status,
645
+ "source_lifecycle": dict(source),
646
+ }
647
+ except Exception:
648
+ raise _blocked("runtime_response_invalid", 503) from None
649
+
650
+
651
+ def _commercial_profile_payload(value: Any) -> dict[str, Any]:
652
+ try:
653
+ payload = dataclasses.asdict(value)
654
+ if set(payload) != {
655
+ "commercial_profile", "commercial_rendering_registry",
656
+ "website_capability_binding",
657
+ }:
658
+ raise ValueError
659
+ expected = _DISPATCH._commercial_contract()
660
+ binding = _website_capability_binding(
661
+ payload["website_capability_binding"]
662
+ )
663
+ if (
664
+ payload["commercial_profile"] != expected["commercial_profile"]
665
+ or payload["commercial_rendering_registry"]
666
+ != expected["commercial_rendering_registry"]
667
+ or binding != payload["website_capability_binding"]
668
+ or binding["commercial_rendering_registry_sha256"]
669
+ != expected["commercial_rendering_registry"]["sha256"]
670
+ ):
671
+ raise ValueError
672
+ return {
673
+ **expected,
674
+ "website_capability_binding": binding,
675
+ }
676
+ except Exception:
677
+ raise _blocked("runtime_response_invalid", 503) from None
678
+
679
+
680
+ def _health_payload(value: Any) -> dict[str, Any]:
681
+ try:
682
+ payload = dataclasses.asdict(value)
683
+ if set(payload) != {
684
+ "status", "counts", "operations", "due", "expired_leases",
685
+ "failed", "expected_capabilities_sha256",
686
+ }:
687
+ raise ValueError
688
+ if payload["status"] not in {"ok", "blocked"}:
689
+ raise ValueError
690
+ if set(payload["counts"]) != STATUSES or set(payload["operations"]) != OPERATIONS:
691
+ raise ValueError
692
+ counts = {name: _count(payload["counts"][name], maximum=1_000_000) for name in STATUSES}
693
+ operations = {
694
+ name: _count(payload["operations"][name], maximum=1_000_000)
695
+ for name in OPERATIONS
696
+ }
697
+ due = _count(payload["due"], maximum=1_000_000)
698
+ expired = _count(payload["expired_leases"], maximum=1_000_000)
699
+ failed = _count(payload["failed"], maximum=1_000_000)
700
+ digest = _sha256(payload["expected_capabilities_sha256"])
701
+ if (
702
+ sum(counts.values()) != sum(operations.values())
703
+ or due > counts["pending"] + counts["retry_wait"]
704
+ or expired > counts["leased"]
705
+ or failed != counts["failed"]
706
+ or (payload["status"] == "ok") != (expired == 0 and failed == 0)
707
+ ):
708
+ raise ValueError
709
+ return {
710
+ "status": payload["status"], "counts": counts,
711
+ "operations": operations, "due": due,
712
+ "expired_leases": expired, "failed": failed,
713
+ "expected_capabilities_sha256": digest,
714
+ }
715
+ except Exception:
716
+ raise _blocked("runtime_response_invalid", 503) from None
717
+
718
+
719
+ def _readiness_payload(value: Any, expected_digest: str) -> dict[str, Any]:
720
+ try:
721
+ fields = {
722
+ "schema", "status", "worker_state", "outbox_status",
723
+ "error_code", "capabilities_sha256",
724
+ }
725
+ if not isinstance(value, Mapping) or set(value) != fields:
726
+ raise ValueError
727
+ if value["schema"] != "blun.cms-public-submission-worker-readiness.v1":
728
+ raise ValueError
729
+ if value["status"] not in {"ready", "not_ready"}:
730
+ raise ValueError
731
+ if value["worker_state"] not in WORKER_STATES:
732
+ raise ValueError
733
+ if value["outbox_status"] not in {None, "ok", "blocked"}:
734
+ raise ValueError
735
+ error = _optional(
736
+ value["error_code"],
737
+ lambda item: item if ERROR_CODE.fullmatch(item) else (_ for _ in ()).throw(ValueError()),
738
+ )
739
+ if _sha256(value["capabilities_sha256"]) != expected_digest:
740
+ raise ValueError
741
+ ready = (
742
+ value["worker_state"] == "running"
743
+ and value["outbox_status"] == "ok"
744
+ and error is None
745
+ )
746
+ if ready != (value["status"] == "ready"):
747
+ raise ValueError
748
+ if not ready and error is None:
749
+ raise ValueError
750
+ return dict(value)
751
+ except Exception:
752
+ raise _blocked("runtime_response_invalid", 503) from None
753
+
754
+
755
+ def _capabilities_payload(runtime_digest: str) -> dict[str, Any]:
756
+ definitions = (
757
+ ("capabilities", CAPABILITIES_PATH, None, CAPABILITIES_RESPONSE_SCHEMA, 200),
758
+ ("commercial_profile", COMMERCIAL_PROFILE_PATH, None, COMMERCIAL_PROFILE_RESPONSE_SCHEMA, 200),
759
+ ("enqueue", ENQUEUE_PATH, ENQUEUE_REQUEST_SCHEMA, QUEUE_RESPONSE_SCHEMA, 202),
760
+ ("health", HEALTH_PATH, None, HEALTH_RESPONSE_SCHEMA, 200),
761
+ ("lifecycle", LIFECYCLE_PATH, LIFECYCLE_REQUEST_SCHEMA, LIFECYCLE_RESPONSE_SCHEMA, 200),
762
+ ("openapi", OPENAPI_PATH, None, OPENAPI_RESPONSE_SCHEMA, 200),
763
+ ("readiness", READINESS_PATH, None, READINESS_RESPONSE_SCHEMA, 200),
764
+ ("status", STATUS_PATH, STATUS_REQUEST_SCHEMA, STATUS_RESPONSE_SCHEMA, 200),
765
+ )
766
+ try:
767
+ commercial = _DISPATCH._commercial_contract()
768
+ commercial_binding = _DISPATCH._commercial_contract_binding()
769
+ if commercial_binding["schema"] != COMMERCIAL_CONTRACT_BINDING_SCHEMA:
770
+ raise ValueError
771
+ operations = {}
772
+ for name, path, request_schema, response_schema, status in definitions:
773
+ operations[name] = {
774
+ "method": METHODS[path], "path": path, "scope": SCOPES[path],
775
+ "principal_schema": (
776
+ TENANT_PRINCIPAL_SCHEMA if path in TENANT_PATHS
777
+ else OPERATOR_PRINCIPAL_SCHEMA
778
+ ),
779
+ "request_schema": request_schema,
780
+ "response_schema": response_schema,
781
+ "capabilities_precondition_header": (
782
+ CAPABILITIES_PRECONDITION_HEADER
783
+ if path in CAPABILITIES_PRECONDITION_PATHS else None
784
+ ),
785
+ "success_status": status,
786
+ "error_statuses": list(ERROR_STATUSES[path]),
787
+ "error_codes": {
788
+ str(error_status): list(ERROR_CODES[path][error_status])
789
+ for error_status in ERROR_STATUSES[path]
790
+ },
791
+ "response_invariants": list(RESPONSE_INVARIANTS[path]),
792
+ }
793
+ contract = {
794
+ "schema": CAPABILITIES_SCHEMA,
795
+ "api_schema": API_SCHEMA,
796
+ "operations": operations,
797
+ "limits": {
798
+ "max_body_bytes": MAX_BODY_BYTES,
799
+ "max_headers": MAX_HEADERS,
800
+ "max_header_value_bytes": MAX_HEADER_VALUE,
801
+ "max_attempts_per_stage": 20,
802
+ },
803
+ "retry_ownership": {
804
+ "client_max_attempts": "cms_to_website",
805
+ "delivery_max_attempts": "website_to_sidecar",
806
+ "source_max_attempts": "source_processing",
807
+ },
808
+ "source_payload_schemas": {
809
+ "change": _DISPATCH._CLIENT._CMS.CHANGE_SCHEMA,
810
+ "cancellation": _DISPATCH._CLIENT._CMS.CANCELLATION_SCHEMA,
811
+ "tombstone": _DISPATCH._CLIENT._CMS.TOMBSTONE_SCHEMA,
812
+ },
813
+ "commercial_profile": commercial["commercial_profile"],
814
+ "commercial_rendering_registry": (
815
+ commercial["commercial_rendering_registry"]
816
+ ),
817
+ "commercial_contract_binding": commercial_binding,
818
+ "openapi_document_schema": _OPENAPI.DOCUMENT_SCHEMA,
819
+ "semantics": {
820
+ "authentication_precedes_json_parsing": True,
821
+ "authentication_binds_exact_body_sha256": True,
822
+ "non_discovery_operations_require_exact_capability_precondition": True,
823
+ "tenant_operations_are_site_bound": True,
824
+ "write_requires_ready_managed_worker": True,
825
+ "accepted_means_website_intake_only": True,
826
+ "accepted_implies_publication": False,
827
+ "accepted_status_returns_exact_website_capability_binding": True,
828
+ "lifecycle_reads_only_after_durable_website_acceptance": True,
829
+ "lifecycle_preserves_source_and_website_generations": True,
830
+ "commercial_profile_is_brand_and_price_neutral": True,
831
+ "commercial_profile_route_verifies_live_website_generation": True,
832
+ "commercial_enqueue_requires_exact_contract_binding": True,
833
+ "commercial_contract_binding_is_durable": True,
834
+ "accepted_commercial_contract_matches_remote_registry": True,
835
+ "operational_responses_are_content_free": True,
836
+ },
837
+ "public_submission_capabilities_sha256": _sha256(runtime_digest),
838
+ }
839
+ raw = _canonical(contract, response=True)
840
+ return {**contract, "sha256": hashlib.sha256(raw).hexdigest()}
841
+ except CMSSourceDeliverySubmissionDispatchHTTPBlocked:
842
+ raise
843
+ except Exception:
844
+ raise _blocked("capabilities_invalid", 503) from None
845
+
846
+
847
+ class CMSSourceDeliverySubmissionDispatchHTTPApplication:
848
+ """Strict WSGI boundary over one supervised caller-side runtime."""
849
+
850
+ def __init__(self, runtime: Any, authenticator: Callable[[dict[str, Any]], Any]):
851
+ if not all(callable(getattr(runtime, name, None)) for name in (
852
+ "enqueue", "status", "lifecycle", "commercial_profile", "health",
853
+ "worker_readiness",
854
+ )):
855
+ raise TypeError("runtime must provide submission dispatch operations")
856
+ if not callable(authenticator):
857
+ raise TypeError("authenticator must be callable")
858
+ self.runtime = runtime
859
+ self.authenticator = authenticator
860
+
861
+ def _authenticate(
862
+ self, environ: Mapping[str, Any], path: str, raw: bytes,
863
+ ) -> dict[str, str]:
864
+ request = {
865
+ "schema": AUTH_REQUEST_SCHEMA,
866
+ "method": environ["REQUEST_METHOD"],
867
+ "path": path,
868
+ "headers": [list(item) for item in _headers(environ)],
869
+ "body_sha256": hashlib.sha256(raw).hexdigest(),
870
+ }
871
+ try:
872
+ value = self.authenticator(request)
873
+ except Exception:
874
+ raise _blocked("authentication_unavailable", 503) from None
875
+ return _principal(value, SCOPES[path], tenant=path in TENANT_PATHS)
876
+
877
+ @staticmethod
878
+ def _send(start_response, status: int, payload: Mapping[str, Any]):
879
+ raw = _canonical(dict(payload), response=True)
880
+ phrases = {
881
+ 200: "OK", 202: "Accepted", 400: "Bad Request",
882
+ 401: "Unauthorized", 403: "Forbidden", 404: "Not Found",
883
+ 405: "Method Not Allowed", 409: "Conflict",
884
+ 411: "Length Required", 412: "Precondition Failed",
885
+ 413: "Content Too Large", 428: "Precondition Required",
886
+ 415: "Unsupported Media Type", 503: "Service Unavailable",
887
+ }
888
+ start_response(f"{status} {phrases[status]}", [
889
+ ("Content-Type", "application/json; charset=utf-8"),
890
+ ("Content-Length", str(len(raw))),
891
+ ("Cache-Control", "no-store"),
892
+ ("X-Content-Type-Options", "nosniff"),
893
+ ("Referrer-Policy", "no-referrer"),
894
+ ])
895
+ return [raw]
896
+
897
+ @classmethod
898
+ def _error(cls, start_response, error, path=None):
899
+ if path in ERROR_CODES and (
900
+ error.status not in ERROR_CODES[path]
901
+ or error.code not in ERROR_CODES[path][error.status]
902
+ ):
903
+ error = _blocked("runtime_blocked", 503)
904
+ return cls._send(start_response, error.status, {
905
+ "schema": ERROR_SCHEMA, "status": "BLOCK", "error_code": error.code,
906
+ })
907
+
908
+ def __call__(self, environ: Mapping[str, Any], start_response):
909
+ path = None
910
+ try:
911
+ if not isinstance(environ, Mapping):
912
+ raise _blocked("environment_invalid", 400)
913
+ path = environ.get("PATH_INFO")
914
+ if path not in SCOPES:
915
+ raise _blocked("route_not_found", 404)
916
+ if environ.get("REQUEST_METHOD") != METHODS[path]:
917
+ raise _blocked("method_not_allowed", 405)
918
+ if environ.get("wsgi.url_scheme") != "https":
919
+ raise _blocked("https_required", 400)
920
+ if environ.get("QUERY_STRING") not in {None, ""}:
921
+ raise _blocked("query_rejected", 400)
922
+ bodyless = path in BODYLESS_PATHS
923
+ if bodyless:
924
+ raw = _body(environ, required=False)
925
+ if raw or environ.get("CONTENT_TYPE") not in {None, ""}:
926
+ raise _blocked("body_not_allowed", 400)
927
+ else:
928
+ content_type = environ.get("CONTENT_TYPE")
929
+ parts = (
930
+ [item.strip().lower() for item in content_type.split(";")]
931
+ if isinstance(content_type, str) else []
932
+ )
933
+ if parts not in (
934
+ ["application/json"],
935
+ ["application/json", "charset=utf-8"],
936
+ ):
937
+ raise _blocked("content_type_invalid", 415)
938
+ raw = _body(environ, required=True)
939
+ principal = self._authenticate(environ, path, raw)
940
+ runtime_digest = self.runtime.expected_capabilities_sha256
941
+ capabilities = _capabilities_payload(runtime_digest)
942
+ if path == CAPABILITIES_PATH:
943
+ return self._send(start_response, 200, {
944
+ "schema": CAPABILITIES_RESPONSE_SCHEMA,
945
+ "capabilities": capabilities,
946
+ })
947
+ supplied_capabilities = environ.get(
948
+ "HTTP_X_LOCALIZATION_CAPABILITIES_SHA256"
949
+ )
950
+ if supplied_capabilities is None:
951
+ raise _blocked("capabilities_precondition_required", 428)
952
+ if supplied_capabilities != capabilities["sha256"]:
953
+ raise _blocked("capabilities_precondition_failed", 412)
954
+ if path == OPENAPI_PATH:
955
+ document = _OPENAPI.build_document(capabilities)
956
+ return self._send(start_response, 200, {
957
+ "schema": OPENAPI_RESPONSE_SCHEMA,
958
+ "openapi": document,
959
+ "openapi_sha256": _OPENAPI.document_sha256(document),
960
+ "capabilities_sha256": capabilities["sha256"],
961
+ })
962
+ if path == COMMERCIAL_PROFILE_PATH:
963
+ try:
964
+ profile = _commercial_profile_payload(
965
+ self.runtime.commercial_profile()
966
+ )
967
+ except CMSSourceDeliverySubmissionDispatchHTTPBlocked:
968
+ raise
969
+ except Exception as error:
970
+ raise _blocked("runtime_blocked", 503) from error
971
+ return self._send(start_response, 200, {
972
+ "schema": COMMERCIAL_PROFILE_RESPONSE_SCHEMA,
973
+ "api_schema": API_SCHEMA,
974
+ **profile,
975
+ "capabilities_sha256": capabilities["sha256"],
976
+ "content_free": True,
977
+ "publication_authority": False,
978
+ })
979
+ if path == HEALTH_PATH:
980
+ health = _health_payload(self.runtime.health())
981
+ if health["expected_capabilities_sha256"] != runtime_digest:
982
+ raise _blocked("runtime_response_invalid", 503)
983
+ return self._send(start_response, 200 if health["status"] == "ok" else 503, {
984
+ "schema": HEALTH_RESPONSE_SCHEMA, "health": health,
985
+ "capabilities_sha256": capabilities["sha256"],
986
+ })
987
+ if path == READINESS_PATH:
988
+ readiness = _readiness_payload(
989
+ self.runtime.worker_readiness(), runtime_digest,
990
+ )
991
+ return self._send(
992
+ start_response, 200 if readiness["status"] == "ready" else 503,
993
+ {
994
+ "schema": READINESS_RESPONSE_SCHEMA,
995
+ "readiness": readiness,
996
+ "capabilities_sha256": capabilities["sha256"],
997
+ },
998
+ )
999
+ if path == ENQUEUE_PATH:
1000
+ readiness = _readiness_payload(
1001
+ self.runtime.worker_readiness(), runtime_digest,
1002
+ )
1003
+ if readiness["status"] != "ready":
1004
+ raise _blocked("runtime_not_ready", 503)
1005
+ request = _request(raw)
1006
+ if path == ENQUEUE_PATH:
1007
+ if set(request) != {
1008
+ "schema", "payload", "source_max_attempts",
1009
+ "delivery_max_attempts", "client_max_attempts",
1010
+ "commercial_contract_binding",
1011
+ } or request.get("schema") != ENQUEUE_REQUEST_SCHEMA:
1012
+ raise _blocked("request_invalid", 400)
1013
+ try:
1014
+ _copied, identity, payload_json = _DISPATCH._payload(
1015
+ request["payload"]
1016
+ )
1017
+ commercial = (
1018
+ request["payload"].get("schema")
1019
+ == _DISPATCH._CLIENT._CMS.CHANGE_SCHEMA
1020
+ and request["payload"].get("localization", {}).get(
1021
+ "content_type"
1022
+ ) == "commercial"
1023
+ )
1024
+ expected_commercial_binding = (
1025
+ capabilities["commercial_contract_binding"]
1026
+ if commercial else None
1027
+ )
1028
+ source_max = _count(request["source_max_attempts"], minimum=1)
1029
+ delivery_max = _count(
1030
+ request["delivery_max_attempts"], minimum=1,
1031
+ )
1032
+ client_max = _count(request["client_max_attempts"], minimum=1)
1033
+ except Exception:
1034
+ raise _blocked("request_invalid", 400) from None
1035
+ if (
1036
+ request["commercial_contract_binding"]
1037
+ != expected_commercial_binding
1038
+ ):
1039
+ raise _blocked("binding_invalid", 400)
1040
+ payload_hash = hashlib.sha256(payload_json.encode("utf-8")).hexdigest()
1041
+ if (
1042
+ identity["site_id"] != principal["site_id"]
1043
+ or environ.get("HTTP_IDEMPOTENCY_KEY")
1044
+ != identity["request_id"]
1045
+ or environ.get("HTTP_X_LOCALIZATION_SOURCE_PAYLOAD_SHA256")
1046
+ != payload_hash
1047
+ ):
1048
+ raise _blocked("binding_invalid", 400)
1049
+ try:
1050
+ status = self.runtime.enqueue(
1051
+ request["payload"],
1052
+ source_max_attempts=source_max,
1053
+ delivery_max_attempts=delivery_max,
1054
+ client_max_attempts=client_max,
1055
+ )
1056
+ except Exception as error:
1057
+ if getattr(error, "code", "").endswith(
1058
+ "idempotency_collision"
1059
+ ):
1060
+ raise _blocked("idempotency_collision", 409) from None
1061
+ raise _blocked("runtime_blocked", 503) from error
1062
+ normalized = _status_payload(
1063
+ status, operation=identity["operation"],
1064
+ request_id=identity["request_id"],
1065
+ event_id=identity["event_id"], site_id=identity["site_id"],
1066
+ payload_sha256=identity["payload_sha256"],
1067
+ )
1068
+ return self._send(start_response, 202, {
1069
+ "schema": QUEUE_RESPONSE_SCHEMA,
1070
+ "api_schema": API_SCHEMA,
1071
+ "status": normalized,
1072
+ "capabilities_sha256": capabilities["sha256"],
1073
+ "accepted_implies_publication": False,
1074
+ })
1075
+ expected_request_schema = (
1076
+ LIFECYCLE_REQUEST_SCHEMA
1077
+ if path == LIFECYCLE_PATH else STATUS_REQUEST_SCHEMA
1078
+ )
1079
+ if set(request) != {
1080
+ "schema", "operation", "request_id", "event_id",
1081
+ "site_id", "payload_sha256",
1082
+ } or request.get("schema") != expected_request_schema:
1083
+ raise _blocked("request_invalid", 400)
1084
+ try:
1085
+ operation = request["operation"]
1086
+ if operation not in OPERATIONS:
1087
+ raise ValueError
1088
+ request_id = _token(request["request_id"])
1089
+ event_id = _token(request["event_id"])
1090
+ site_id = _token(request["site_id"])
1091
+ payload_hash = _sha256(request["payload_sha256"])
1092
+ except Exception:
1093
+ raise _blocked("request_invalid", 400) from None
1094
+ if site_id != principal["site_id"]:
1095
+ raise _blocked("submission_not_found", 404)
1096
+ try:
1097
+ result = (
1098
+ self.runtime.lifecycle(operation, request_id)
1099
+ if path == LIFECYCLE_PATH
1100
+ else self.runtime.status(operation, request_id)
1101
+ )
1102
+ except Exception as error:
1103
+ if getattr(error, "code", "").endswith("submission_missing"):
1104
+ raise _blocked("submission_not_found", 404) from None
1105
+ if getattr(error, "code", "").endswith(
1106
+ "lifecycle_not_accepted"
1107
+ ):
1108
+ raise _blocked("lifecycle_not_accepted", 409) from None
1109
+ raise _blocked("runtime_blocked", 503) from error
1110
+ identity = {
1111
+ "operation": operation,
1112
+ "request_id": request_id,
1113
+ "event_id": event_id,
1114
+ "site_id": site_id,
1115
+ "payload_sha256": payload_hash,
1116
+ }
1117
+ if path == LIFECYCLE_PATH:
1118
+ lifecycle = _lifecycle_payload(result, identity)
1119
+ return self._send(start_response, 200, {
1120
+ "schema": LIFECYCLE_RESPONSE_SCHEMA,
1121
+ "api_schema": API_SCHEMA,
1122
+ "lifecycle": lifecycle,
1123
+ "capabilities_sha256": capabilities["sha256"],
1124
+ "accepted_implies_publication": False,
1125
+ })
1126
+ try:
1127
+ normalized = _status_payload(
1128
+ result, operation=operation, request_id=request_id,
1129
+ event_id=event_id, site_id=site_id,
1130
+ payload_sha256=payload_hash,
1131
+ )
1132
+ except CMSSourceDeliverySubmissionDispatchHTTPBlocked:
1133
+ raise _blocked("submission_not_found", 404) from None
1134
+ return self._send(start_response, 200, {
1135
+ "schema": STATUS_RESPONSE_SCHEMA,
1136
+ "api_schema": API_SCHEMA,
1137
+ "status": normalized,
1138
+ "capabilities_sha256": capabilities["sha256"],
1139
+ "accepted_implies_publication": False,
1140
+ })
1141
+ except CMSSourceDeliverySubmissionDispatchHTTPBlocked as error:
1142
+ return self._error(start_response, error, path)
1143
+ except Exception:
1144
+ return self._error(
1145
+ start_response, _blocked("runtime_blocked", 503), path,
1146
+ )
1147
+
1148
+
1149
+ def build_submission_dispatch_http(runtime: Any, authenticator):
1150
+ return CMSSourceDeliverySubmissionDispatchHTTPApplication(
1151
+ runtime, authenticator,
1152
+ )