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