blun-king-cli 9.1.597 → 9.1.600
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/README.md +17 -1
- package/agent-spine-plugin/CHANGELOG.md +1 -1
- package/agent-spine-plugin/docs/host-integration.md +1 -15
- package/agent-spine-plugin/docs/preflight-recall.md +1 -1
- package/agent-spine-plugin/scripts/check-install.js +1 -6
- package/agent-spine-plugin/scripts/hermetic-process.js +8 -0
- package/agent-spine-plugin/scripts/release-check.js +2 -3
- package/agent-spine-plugin/scripts/run-tests-hermetic.js +8 -11
- package/agent-spine-plugin/src/hook.js +40 -40
- package/agent-spine-plugin/src/lib/briefing.js +6 -3
- package/agent-spine-plugin/src/lib/hook-context.js +63 -64
- package/agent-spine-plugin/src/lib/hook-output.js +115 -94
- package/agent-spine-plugin/src/lib/hook-timeline.js +20 -11
- package/agent-spine-plugin/src/lib/host-instruction-budget.js +8 -14
- package/agent-spine-plugin/src/lib/mcp-timeline-tools.js +0 -7
- package/agent-spine-plugin/src/lib/pre-answer-timeline-recall.js +104 -0
- package/agent-spine-plugin/src/lib/session-timeline-codex.js +0 -4
- package/agent-spine-plugin/src/lib/session-timeline-enrollment-source.js +0 -3
- package/agent-spine-plugin/src/lib/session-timeline-enrollment-storage.js +0 -4
- package/agent-spine-plugin/src/lib/session-timeline-enrollment-transport.js +0 -2
- package/agent-spine-plugin/src/lib/session-timeline-enrollment.js +0 -22
- package/agent-spine-plugin/src/lib/session-timeline-host-origin.js +0 -4
- package/agent-spine-plugin/src/lib/session-timeline-prior.js +42 -50
- package/agent-spine-plugin/src/lib/session-timeline-provider.js +0 -3
- package/agent-spine-plugin/src/lib/session-timeline-query.js +0 -1
- package/agent-spine-plugin/src/lib/session-timeline-sid-acl.js +0 -4
- package/agent-spine-plugin/src/lib/session-timeline-source.js +0 -3
- package/agent-spine-plugin/src/lib/session-timeline-transport.js +0 -8
- package/agent-spine-plugin/src/lib/session-timeline-windows-acl.js +0 -3
- package/agent-spine-plugin/src/lib/session-timeline.js +370 -408
- package/agent-spine-plugin/src/lib/source-roots.js +26 -16
- package/agent-spine-plugin/src/lib/timeline-user-feedback.js +0 -1
- package/bin/core-bootstrap.js +18 -10
- package/bin/launcher-mode.js +5 -0
- package/bin/launcher-runtime.js +27 -0
- package/bin/verify-bundled-agent-sources.cjs +28 -2
- package/blun.mjs +798 -143
- package/bundled-agent-sources.json +192 -155
- package/package.json +1 -1
- package/standard-skills/translate-native/README.md +1334 -1
- package/standard-skills/translate-native/SKILL.md +1 -1
- package/standard-skills/translate-native/VERSION +1 -1
- package/standard-skills/translate-native/docs/PREMORTEM.md +901 -0
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION.md +208 -41
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_API.md +1319 -77
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_EVIDENCE_HTTP.md +66 -11
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_HTTP_PROVIDER.md +19 -1
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_RECEIPT_VERIFIER_HTTP.md +24 -6
- package/standard-skills/translate-native/integrations/commercial_localization_profile.py +23 -0
- package/standard-skills/translate-native/integrations/website_localization.py +234 -10
- package/standard-skills/translate-native/integrations/website_localization_benchmark.py +39 -3
- package/standard-skills/translate-native/integrations/website_localization_cms.py +512 -10
- package/standard-skills/translate-native/integrations/website_localization_cms_client.py +116 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_receiver.py +101 -11
- package/standard-skills/translate-native/integrations/website_localization_cms_receiver_runtime.py +12 -1
- package/standard-skills/translate-native/integrations/website_localization_cms_receiver_store.py +186 -24
- package/standard-skills/translate-native/integrations/website_localization_cms_source_client.py +64 -1
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery.py +528 -5
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_auth.py +918 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_auth_runtime.py +609 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_client.py +885 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_http.py +1244 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_runtime.py +220 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_sidecar_adapter.py +331 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_capabilities_http.py +444 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_client.py +673 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch.py +1317 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_client.py +826 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_http.py +1152 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_openapi.py +896 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_dispatch_runtime.py +621 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_http.py +1180 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_submission_runtime.py +1479 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_http.py +133 -17
- package/standard-skills/translate-native/integrations/website_localization_cms_source_runtime.py +319 -4
- package/standard-skills/translate-native/integrations/website_localization_cms_source_service.py +72 -2
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_http.py +13 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver.py +56 -13
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver_openapi.py +335 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver_runtime.py +66 -1
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_processing_monitor.py +73 -7
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_receiver_client.py +93 -9
- package/standard-skills/translate-native/integrations/website_localization_evidence_http.py +221 -11
- package/standard-skills/translate-native/integrations/website_localization_health.py +6 -0
- package/standard-skills/translate-native/integrations/website_localization_http_provider.py +1 -1
- package/standard-skills/translate-native/integrations/website_localization_quality_profiles.py +350 -0
- package/standard-skills/translate-native/integrations/website_localization_queue.py +126 -56
- package/standard-skills/translate-native/integrations/website_localization_receipt_verifier_http.py +166 -13
- package/standard-skills/translate-native/integrations/website_localization_release.py +423 -17
- package/standard-skills/translate-native/integrations/website_localization_release_coordinator.py +168 -13
- package/standard-skills/translate-native/integrations/website_localization_runner.py +10 -0
- package/standard-skills/translate-native/integrations/website_localization_worker.py +213 -9
- package/standard-skills/translate-native/provenance.json +2 -2
- package/standard-skills/translate-native/references/commercial-localization.md +96 -17
- package/standard-skills/translate-native/scripts/blun_language_guard.py +3 -1
- package/standard-skills/translate-native/scripts/check_commercial_review.py +69 -9
- package/standard-skills/translate-native/scripts/commercial_localization_profile.py +829 -31
- package/standard-tools/language-guard/VERSION +1 -1
- package/standard-tools/language-guard/blun_language_guard.py +3 -1
- package/standard-tools/language-guard/check_commercial_review.py +69 -9
- package/standard-tools/language-guard/commercial_localization_profile.py +829 -31
- package/standard-tools/language-guard/provenance.json +2 -2
- package/standard-tools/manifest.json +1 -1
- package/worker-host.mjs +215 -82
- package/agent-spine-plugin/scripts/check-install-king.js +0 -37
|
@@ -0,0 +1,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
|
+
)
|