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