blun-king-cli 9.1.587 → 9.1.588
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 +11 -185
- package/LIESMICH.txt +51 -13
- package/README.md +44 -47
- package/agent-spine-plugin/.codex-plugin/plugin.json +16 -4
- package/agent-spine-plugin/CHANGELOG.md +37 -5
- package/agent-spine-plugin/README.md +3 -3
- package/agent-spine-plugin/blun.plugin.json +45 -10
- package/agent-spine-plugin/docs/artifact-evaluation.md +93 -0
- package/agent-spine-plugin/docs/host-integration.md +42 -27
- package/agent-spine-plugin/docs/preflight-recall.md +4 -2
- package/agent-spine-plugin/docs/session-timeline.md +97 -236
- package/agent-spine-plugin/docs/world-model.md +25 -0
- package/agent-spine-plugin/hooks/codex.json +1 -1
- package/agent-spine-plugin/hooks/hooks.json +1 -1
- package/agent-spine-plugin/package.json +1 -3
- package/agent-spine-plugin/scripts/check-hosts.js +3 -3
- package/agent-spine-plugin/scripts/release-check.js +10 -5
- package/agent-spine-plugin/scripts/run-checks.js +4 -1
- package/agent-spine-plugin/scripts/run-tests-hermetic.js +32 -6
- package/agent-spine-plugin/src/cli-learning.js +15 -0
- package/agent-spine-plugin/src/cli.js +2 -0
- package/agent-spine-plugin/src/hook.js +32 -32
- package/agent-spine-plugin/src/lib/action-lesson-recall.js +73 -8
- package/agent-spine-plugin/src/lib/briefing.js +146 -36
- package/agent-spine-plugin/src/lib/channel-continuity.js +19 -0
- package/agent-spine-plugin/src/lib/delivery-agent-usage.js +14 -7
- package/agent-spine-plugin/src/lib/gateway-group-response.js +128 -0
- package/agent-spine-plugin/src/lib/gateway-runs.js +24 -15
- package/agent-spine-plugin/src/lib/hook-briefing-use.js +13 -3
- package/agent-spine-plugin/src/lib/hook-context.js +16 -3
- package/agent-spine-plugin/src/lib/hook-output.js +129 -5
- package/agent-spine-plugin/src/lib/hook-timeline.js +5 -3
- package/agent-spine-plugin/src/lib/indexed-memory.js +2 -2
- package/agent-spine-plugin/src/lib/learning-artifact-evaluator.js +114 -0
- package/agent-spine-plugin/src/lib/learning-context.js +11 -4
- package/agent-spine-plugin/src/lib/learning-measurements.js +2 -2
- package/agent-spine-plugin/src/lib/mcp-runtime.js +89 -3
- package/agent-spine-plugin/src/lib/mcp-source-context.js +12 -2
- package/agent-spine-plugin/src/lib/mcp-timeline-tools.js +91 -8
- package/agent-spine-plugin/src/lib/mcp-world-tools.js +2 -2
- package/agent-spine-plugin/src/lib/owned-file-lock.js +20 -1
- package/agent-spine-plugin/src/lib/persona-runtime.js +2 -2
- package/agent-spine-plugin/src/lib/preflight-delivery-id.js +27 -0
- package/agent-spine-plugin/src/lib/preflight.js +4 -4
- package/agent-spine-plugin/src/lib/session-timeline-codex.js +15 -0
- package/agent-spine-plugin/src/lib/session-timeline-contract.js +12 -4
- package/agent-spine-plugin/src/lib/session-timeline-event-extract.js +36 -7
- package/agent-spine-plugin/src/lib/session-timeline-host-origin.js +13 -10
- package/agent-spine-plugin/src/lib/session-timeline-invocation.js +1 -1
- package/agent-spine-plugin/src/lib/session-timeline-king.js +14 -0
- package/agent-spine-plugin/src/lib/session-timeline-prior.js +18 -12
- package/agent-spine-plugin/src/lib/session-timeline-provider.js +5 -0
- package/agent-spine-plugin/src/lib/session-timeline-query.js +2 -0
- package/agent-spine-plugin/src/lib/session-timeline-results.js +35 -10
- package/agent-spine-plugin/src/lib/session-timeline-source-open.js +30 -0
- package/agent-spine-plugin/src/lib/session-timeline.js +122 -75
- package/agent-spine-plugin/src/lib/source-roots.js +3 -2
- package/agent-spine-plugin/src/lib/task-knowledge-context.js +22 -1
- package/agent-spine-plugin/src/lib/timeline-continuation-update.js +100 -0
- package/agent-spine-plugin/src/lib/timeline-tool-guard.js +30 -7
- package/agent-spine-plugin/src/lib/timeline-user-feedback.js +217 -0
- package/agent-spine-plugin/src/lib/timeline-world-capture.js +233 -0
- package/agent-spine-plugin/src/lib/world-knowledge.js +59 -2
- package/agent-spine-plugin/src/lib/world-model.js +64 -9
- package/agent-spine-plugin/src/worker.js +13 -1
- package/bin/blun.js +43 -28
- package/bin/core-bootstrap.js +5 -4
- package/bin/king.js +43 -28
- package/bin/launcher-mode.js +1 -10
- package/bin/launcher-runtime.js +128 -295
- package/bin/managed-node.js +0 -0
- package/bin/managed-plugin-selection.cjs +0 -1
- package/bin/native-module-repair.js +0 -0
- package/bin/node-runtime.js +0 -0
- package/bin/node-version.js +0 -0
- package/bin/plugin-bootstrap.js +56 -120
- package/bin/private-paths.js +11 -34
- package/bin/standard-tools-bootstrap.js +34 -114
- package/bin/turn-thinking-policy.cjs +3 -11
- package/bin/update-copy.js +200 -0
- package/bin/update-lease.js +0 -0
- package/bin/update-notice.js +136 -289
- package/bin/verify-agent-behavior.cjs +122 -0
- package/bin/verify-agent-components.cjs +104 -0
- package/bin/verify-bundled-agent-sources.cjs +57 -0
- package/blun.mjs +143076 -135288
- package/bundled-agent-sources.json +701 -0
- package/package.json +12 -15
- package/standard-skills/translate-native/README.md +1293 -0
- package/standard-skills/translate-native/SKILL.md +172 -22
- package/standard-skills/translate-native/VERSION +1 -1
- package/standard-skills/translate-native/agents/openai.yaml +18 -0
- package/standard-skills/translate-native/assets/icon.svg +8 -0
- package/standard-skills/translate-native/docs/BLUN_CODE_INTEGRATION.md +76 -0
- package/standard-skills/translate-native/docs/PREMORTEM.md +489 -0
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION.md +2035 -0
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_API.md +1302 -0
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_EVIDENCE_HTTP.md +136 -0
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_HEALTH_HTTP.md +130 -0
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_HTTP_PROVIDER.md +175 -0
- package/standard-skills/translate-native/docs/WEBSITE_LOCALIZATION_RECEIPT_VERIFIER_HTTP.md +86 -0
- package/standard-skills/translate-native/integrations/AGENT_RULES.md +32 -0
- package/standard-skills/translate-native/integrations/adapters/blun-code-language-guard.js +514 -0
- package/standard-skills/translate-native/integrations/adapters/node-language-guard.js +230 -0
- package/standard-skills/translate-native/integrations/audit_log.py +327 -0
- package/standard-skills/translate-native/integrations/claude_language_hook.js +1536 -0
- package/standard-skills/translate-native/integrations/commercial_localization_profile.py +42 -0
- package/standard-skills/translate-native/integrations/delivery-policy.example.json +28 -0
- package/standard-skills/translate-native/integrations/enforced_delivery.py +543 -0
- package/standard-skills/translate-native/integrations/guard_service.py +435 -0
- package/standard-skills/translate-native/integrations/language_gateway.py +67 -0
- package/standard-skills/translate-native/integrations/mcp_auth_headers.py +198 -0
- package/standard-skills/translate-native/integrations/mcp_http_gateway.py +429 -0
- package/standard-skills/translate-native/integrations/non_language_html_entities.js +1485 -0
- package/standard-skills/translate-native/integrations/pre_output_guard.py +65 -0
- package/standard-skills/translate-native/integrations/task_router.py +101 -0
- package/standard-skills/translate-native/integrations/website_localization.py +401 -0
- package/standard-skills/translate-native/integrations/website_localization_api.py +581 -0
- package/standard-skills/translate-native/integrations/website_localization_benchmark.py +1885 -0
- package/standard-skills/translate-native/integrations/website_localization_benchmark_campaign.py +1772 -0
- package/standard-skills/translate-native/integrations/website_localization_benchmark_candidate.py +506 -0
- package/standard-skills/translate-native/integrations/website_localization_benchmark_http.py +400 -0
- package/standard-skills/translate-native/integrations/website_localization_benchmark_review_store.py +781 -0
- package/standard-skills/translate-native/integrations/website_localization_benchmark_reviewer_http.py +500 -0
- package/standard-skills/translate-native/integrations/website_localization_benchmark_runtime.py +1107 -0
- package/standard-skills/translate-native/integrations/website_localization_benchmark_suite.py +463 -0
- package/standard-skills/translate-native/integrations/website_localization_cms.py +2835 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_client.py +875 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_dispatch.py +805 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_http.py +588 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_lifecycle_monitor.py +991 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_receiver.py +1441 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_receiver_runtime.py +414 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_receiver_store.py +1073 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_removal_dispatch.py +865 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_client.py +583 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery.py +964 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_delivery_runtime.py +665 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_http.py +1153 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_runtime.py +675 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_source_service.py +1125 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification.py +674 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_http.py +444 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver.py +1469 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_notification_receiver_runtime.py +1142 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_processing_monitor.py +634 -0
- package/standard-skills/translate-native/integrations/website_localization_cms_terminal_receiver_client.py +804 -0
- package/standard-skills/translate-native/integrations/website_localization_deepl_baseline.py +922 -0
- package/standard-skills/translate-native/integrations/website_localization_evidence_http.py +482 -0
- package/standard-skills/translate-native/integrations/website_localization_health.py +1541 -0
- package/standard-skills/translate-native/integrations/website_localization_health_http.py +372 -0
- package/standard-skills/translate-native/integrations/website_localization_http_provider.py +297 -0
- package/standard-skills/translate-native/integrations/website_localization_native_reference_http.py +479 -0
- package/standard-skills/translate-native/integrations/website_localization_native_reference_intake.py +363 -0
- package/standard-skills/translate-native/integrations/website_localization_native_reference_queue.py +1449 -0
- package/standard-skills/translate-native/integrations/website_localization_native_reference_store.py +420 -0
- package/standard-skills/translate-native/integrations/website_localization_quality_profiles.py +235 -0
- package/standard-skills/translate-native/integrations/website_localization_queue.py +671 -0
- package/standard-skills/translate-native/integrations/website_localization_receipt_verifier_http.py +516 -0
- package/standard-skills/translate-native/integrations/website_localization_release.py +928 -0
- package/standard-skills/translate-native/integrations/website_localization_release_coordinator.py +1008 -0
- package/standard-skills/translate-native/integrations/website_localization_runner.py +276 -0
- package/standard-skills/translate-native/integrations/website_localization_runtime.py +862 -0
- package/standard-skills/translate-native/integrations/website_localization_service.py +350 -0
- package/standard-skills/translate-native/integrations/website_localization_supervisor.py +511 -0
- package/standard-skills/translate-native/integrations/website_localization_worker.py +663 -0
- package/standard-skills/translate-native/provenance.json +3 -4
- package/standard-skills/translate-native/references/commercial-localization.md +177 -0
- package/standard-skills/translate-native/scripts/blun_language_guard.py +7 -1
- package/standard-skills/translate-native/scripts/check_commercial_review.py +80 -0
- package/standard-skills/translate-native/scripts/commercial_localization_profile.py +333 -0
- package/standard-tools/language-guard/LICENSE +21 -0
- package/standard-tools/language-guard/VERSION +1 -0
- package/standard-tools/language-guard/blun_language_guard.py +7 -1
- package/standard-tools/language-guard/check_commercial_review.py +80 -0
- package/standard-tools/language-guard/commercial_localization_profile.py +333 -0
- package/standard-tools/language-guard/language_gateway.py +62 -0
- package/standard-tools/language-guard/pre_output_guard.py +64 -0
- package/standard-tools/language-guard/provenance.json +4 -11
- package/standard-tools/manifest.json +34 -11
- package/telegram-plugin/commands/access.md +2 -10
- package/telegram-plugin/dist/bridge.mjs +64041 -687
- package/telegram-plugin/dist/mcp-server.mjs +72810 -9027
- package/telegram-plugin/dist/noise.mjs +28 -63511
- package/agent-spine-plugin/CONTRIBUTING.md +0 -52
- package/agent-spine-plugin/SECURITY.md +0 -47
- package/agent-spine-plugin/docs/assignment-continuation.md +0 -48
- package/agent-spine-plugin/docs/releasing.md +0 -85
- package/agent-spine-plugin/docs/structured-completion.md +0 -67
- package/bin/abort-listener-policy.cjs +0 -43
- package/bin/active-steer-priority-policy.cjs +0 -24
- package/bin/agent-api-http-adapter.mjs +0 -446
- package/bin/agent-api-private-http-server.mjs +0 -288
- package/bin/agent-api-runtime.mjs +0 -252
- package/bin/agent-api-service-environment.mjs +0 -236
- package/bin/agent-api-service-host.mjs +0 -209
- package/bin/agent-api-service-process.mjs +0 -171
- package/bin/agent-api-session-registry.mjs +0 -428
- package/bin/agent-api-tool-broker.cjs +0 -248
- package/bin/agent-api-turn-controller.mjs +0 -461
- package/bin/agent-api-usage-journal.cjs +0 -259
- package/bin/agent-resume-snapshot.cjs +0 -241
- package/bin/agentspine-king-goal-inbox.mjs +0 -111
- package/bin/agentspine-king-goal-intake.mjs +0 -106
- package/bin/approval-rejection-stop.cjs +0 -15
- package/bin/assistant-message-offload-policy.cjs +0 -284
- package/bin/baseline-skill-performance-policy.cjs +0 -39
- package/bin/bash-search-scope-policy.cjs +0 -49
- package/bin/codebase-search-runtime.cjs +0 -23
- package/bin/cognitive-action-checkpoint.cjs +0 -1104
- package/bin/cognitive-attention-delivery.cjs +0 -76
- package/bin/cognitive-attention-policy.cjs +0 -143
- package/bin/cognitive-attention-runtime.cjs +0 -91
- package/bin/cognitive-context-projection.cjs +0 -73
- package/bin/cognitive-cross-portal-acceptance.cjs +0 -443
- package/bin/cognitive-effective-view.cjs +0 -77
- package/bin/cognitive-focus-projection.cjs +0 -206
- package/bin/cognitive-focus-scope.cjs +0 -37
- package/bin/cognitive-goal-autostart-policy.cjs +0 -72
- package/bin/cognitive-goal-time-trigger-controller.cjs +0 -146
- package/bin/cognitive-memory-adapter.cjs +0 -282
- package/bin/cognitive-memory-command.cjs +0 -293
- package/bin/cognitive-memory-provider.cjs +0 -92
- package/bin/cognitive-salience-policy.cjs +0 -159
- package/bin/cognitive-state-store.cjs +0 -508
- package/bin/cognitive-turn-lifecycle.cjs +0 -624
- package/bin/cognitive-work-focus.cjs +0 -180
- package/bin/compaction-history-archive.cjs +0 -166
- package/bin/compaction-history-startup.cjs +0 -50
- package/bin/compaction-model-policy.cjs +0 -31
- package/bin/compaction-stage-policy.cjs +0 -21
- package/bin/compaction-transaction-policy.cjs +0 -122
- package/bin/config-write-dedup-policy.cjs +0 -27
- package/bin/context-budget-ledger.cjs +0 -31
- package/bin/context-doctor-policy.cjs +0 -70
- package/bin/context-insight-policy.cjs +0 -36
- package/bin/context-performance-policy.cjs +0 -19
- package/bin/context-pressure-policy.cjs +0 -20
- package/bin/cron-run-output.cjs +0 -45
- package/bin/cron-run-store.cjs +0 -145
- package/bin/curiosity-scout-policy.cjs +0 -49
- package/bin/default-model-output-budget-policy.cjs +0 -28
- package/bin/durable-task-resume-policy.cjs +0 -130
- package/bin/durable-task-resume-runtime.cjs +0 -117
- package/bin/durable-task-resume-store.cjs +0 -88
- package/bin/editable-tool-approval-policy.cjs +0 -540
- package/bin/editable-tool-approval-runtime.cjs +0 -99
- package/bin/effective-system-prompt-cache-policy.cjs +0 -33
- package/bin/error-memory-performance-policy.cjs +0 -113
- package/bin/file-observation-policy.cjs +0 -133
- package/bin/foreground-output-capture-policy.cjs +0 -41
- package/bin/generated-source-health.cjs +0 -142
- package/bin/glob-pattern-policy.cjs +0 -13
- package/bin/goal-completion-evidence-policy.cjs +0 -120
- package/bin/grep-output-limit-policy.cjs +0 -39
- package/bin/historical-media-projection-policy.cjs +0 -48
- package/bin/history-offload-pressure-policy.cjs +0 -33
- package/bin/html-to-research-markdown.cjs +0 -147
- package/bin/identity-context-policy.cjs +0 -764
- package/bin/identity-journal-policy.cjs +0 -107
- package/bin/input-draft-persistence.cjs +0 -77
- package/bin/king-tui-function-contract.json +0 -33
- package/bin/launcher-restart-policy.cjs +0 -150
- package/bin/live-response-repetition-guard.cjs +0 -196
- package/bin/llm-config-log-dedup-policy.cjs +0 -76
- package/bin/loop-event-record-policy.cjs +0 -174
- package/bin/managed-context-startup-policy.cjs +0 -27
- package/bin/media-activity-layout-policy.cjs +0 -34
- package/bin/media-auto-retrieval-policy.cjs +0 -90
- package/bin/media-result-policy.cjs +0 -59
- package/bin/micro-compaction-policy.cjs +0 -145
- package/bin/mistake-relevance-policy.cjs +0 -319
- package/bin/model-retry-progress-policy.cjs +0 -46
- package/bin/native-large-file-io.cjs +0 -42
- package/bin/native-runtime-cache.cjs +0 -76
- package/bin/natural-presence-policy.cjs +0 -28
- package/bin/noninteractive-shell-env-policy.cjs +0 -19
- package/bin/observer-hooks.cjs +0 -14
- package/bin/outbound-claim-provenance.cjs +0 -150
- package/bin/oversized-context-offload-policy.cjs +0 -86
- package/bin/pending-media-policy.cjs +0 -182
- package/bin/pending-token-estimate-policy.cjs +0 -41
- package/bin/personal-memory-consent-policy.cjs +0 -72
- package/bin/personal-memory-performance-policy.cjs +0 -12
- package/bin/personality-choice-policy.cjs +0 -101
- package/bin/personality-memory-adapter.cjs +0 -379
- package/bin/personality-mode.cjs +0 -46
- package/bin/personality-setup-policy.cjs +0 -197
- package/bin/proactive-compaction-policy.cjs +0 -25
- package/bin/profile-identity-resolution.cjs +0 -136
- package/bin/profile-runtime.cjs +0 -318
- package/bin/profile-tool-exclusion-policy.cjs +0 -37
- package/bin/programmatic-context-isolation.cjs +0 -25
- package/bin/programmatic-tool-runtime.mjs +0 -627
- package/bin/provider-idle-timeout-policy.cjs +0 -14
- package/bin/provider-model-refresh-deadline.cjs +0 -53
- package/bin/provider-model-refresh-policy.cjs +0 -107
- package/bin/rate-limit-recovery-policy.cjs +0 -47
- package/bin/read-batch-policy.cjs +0 -32
- package/bin/read-continuation-policy.cjs +0 -59
- package/bin/recurring-cron-history-policy.cjs +0 -124
- package/bin/relationship-continuity-policy.cjs +0 -143
- package/bin/relationship-curiosity-policy.cjs +0 -107
- package/bin/relationship-learning-policy.cjs +0 -168
- package/bin/release-artifact-freeze-policy.cjs +0 -30
- package/bin/reload-plugin-bootstrap.cjs +0 -18
- package/bin/reload-queue-policy.cjs +0 -38
- package/bin/repeated-assistant-response-policy.cjs +0 -232
- package/bin/repeated-injection-projection.cjs +0 -107
- package/bin/repeated-user-message-projection.cjs +0 -8
- package/bin/research-page-result.cjs +0 -74
- package/bin/retry-checkpoint-policy.cjs +0 -13
- package/bin/runtime-exit-ledger.cjs +0 -144
- package/bin/scoped-cron-run-policy.cjs +0 -358
- package/bin/session-checkpoint-policy.cjs +0 -25
- package/bin/session-compaction-policy.cjs +0 -84
- package/bin/session-replay-policy.cjs +0 -20
- package/bin/session-replay-window-policy.cjs +0 -40
- package/bin/session-resume-checkpoint.cjs +0 -254
- package/bin/session-scrollback-archive.cjs +0 -229
- package/bin/skill-activation-performance-policy.cjs +0 -69
- package/bin/skill-listing-performance-policy.cjs +0 -92
- package/bin/soul-organization-policy.cjs +0 -78
- package/bin/soul-preservation-policy.cjs +0 -20
- package/bin/startup-preferences.cjs +0 -131
- package/bin/streaming-flush-performance-policy.cjs +0 -28
- package/bin/structured-agent-swarm-output.cjs +0 -325
- package/bin/structured-subagent-output.cjs +0 -252
- package/bin/subagent-context-fork-policy.cjs +0 -155
- package/bin/subagent-max-tokens-handoff-policy.cjs +0 -69
- package/bin/subagent-parent-responsiveness.cjs +0 -19
- package/bin/subagent-skill-policy.cjs +0 -206
- package/bin/subagent-timeout-policy.cjs +0 -182
- package/bin/subagent-tool-policy.cjs +0 -60
- package/bin/subagent-usage-rollup-policy.cjs +0 -29
- package/bin/system-prompt-context-policy.cjs +0 -124
- package/bin/system-prompt-token-cache-policy.cjs +0 -60
- package/bin/telegram-addressed-focus.cjs +0 -55
- package/bin/telegram-addressed-priority.cjs +0 -12
- package/bin/telegram-approval-relay.cjs +0 -290
- package/bin/telegram-bot-priority.cjs +0 -17
- package/bin/telegram-console-status-policy.cjs +0 -174
- package/bin/telegram-context-projection-policy.cjs +0 -141
- package/bin/telegram-delivery-lifecycle.cjs +0 -125
- package/bin/telegram-direct-focus-policy.cjs +0 -273
- package/bin/telegram-mcp-compatibility.cjs +0 -49
- package/bin/telegram-media-delivery-policy.cjs +0 -42
- package/bin/telegram-private-conversation-policy.cjs +0 -185
- package/bin/telegram-queue-handoff-policy.cjs +0 -73
- package/bin/telegram-remote-status-policy.cjs +0 -120
- package/bin/telegram-session-queue-runtime.mjs +0 -306
- package/bin/telegram-text-chunk-policy.cjs +0 -63
- package/bin/telegram-truncated-reply-policy.cjs +0 -37
- package/bin/telegram-urgent-policy.cjs +0 -45
- package/bin/telemetry-spool-policy.cjs +0 -57
- package/bin/thinking-activity-status-policy.cjs +0 -132
- package/bin/thinking-only-guard.cjs +0 -80
- package/bin/todo-list-turn-policy.cjs +0 -131
- package/bin/tool-call-loop-policy.cjs +0 -51
- package/bin/tool-file-persistence.cjs +0 -141
- package/bin/tool-result-offload-policy.cjs +0 -359
- package/bin/tool-result-offload-telemetry.cjs +0 -12
- package/bin/tool-schema-token-cache-policy.cjs +0 -41
- package/bin/tool-stream-preview-policy.cjs +0 -9
- package/bin/tui-functional-contract.cjs +0 -55
- package/bin/turn-tool-performance-policy.cjs +0 -486
- package/bin/usage-cache-efficiency-policy.cjs +0 -26
- package/bin/user-home-path-policy.cjs +0 -13
- package/bin/user-message-offload-policy.cjs +0 -103
- package/bin/user-prompt-hook-origin-policy.cjs +0 -34
- package/bin/user-tool-record-policy.cjs +0 -7
- package/bin/validated-learning-insight-policy.cjs +0 -58
- package/bin/validated-learning-outcome-trace.cjs +0 -107
- package/bin/validated-learning-performance-policy.cjs +0 -53
- package/bin/validated-learning-signal.cjs +0 -463
- package/bin/windows-bash-dialect-policy.cjs +0 -25
- package/bin/windows-node-crash-dump.cjs +0 -110
- package/bin/write-continuation-policy.cjs +0 -69
- package/codebase-index/README.md +0 -82
- package/codebase-index/codebase_index.py +0 -470
- package/standard-skills/agent-browser/SKILL.md +0 -19
- package/standard-skills/agent-browser/references/runtime.md +0 -8
- package/standard-skills/blun-session-inspector/SKILL.md +0 -41
- package/standard-skills/blun-session-inspector/scripts/inspect-session.cjs +0 -437
- package/standard-skills/design-taste-frontend/SKILL.md +0 -1206
- package/standard-skills/full-output-enforcement/SKILL.md +0 -49
- package/standard-skills/high-end-visual-design/SKILL.md +0 -98
- package/standard-skills/image-to-code/SKILL.md +0 -1228
- package/standard-skills/industrial-brutalist-ui/SKILL.md +0 -92
- package/standard-skills/minimalist-ui/SKILL.md +0 -85
- package/standard-skills/motion-design-taste/SKILL.md +0 -74
- package/standard-skills/playwright-testing/SKILL.md +0 -19
- package/standard-skills/playwright-testing/references/runtime.md +0 -7
- package/standard-skills/premortem/SKILL.md +0 -148
- package/standard-skills/redesign-existing-projects/SKILL.md +0 -178
- package/standard-skills/research-evidence/SKILL.md +0 -39
- package/standard-skills/research-evidence/references/evidence-format.md +0 -104
- package/standard-skills/research-evidence/scripts/evidence-collection.cjs +0 -260
- package/standard-skills/research-evidence/scripts/score-report.cjs +0 -130
- package/standard-skills/screenshot-lesen/SKILL.md +0 -52
- package/standard-skills/stitch-design-taste/DESIGN.md +0 -121
- package/standard-skills/stitch-design-taste/SKILL.md +0 -184
- package/standard-skills/telegram-channel/SKILL.md +0 -18
- package/standard-skills/telegram-channel/references/runtime.md +0 -7
- package/standard-skills/venture-flywheel/SKILL.md +0 -32
- package/standard-skills/venture-flywheel/identity/project-identity.cjs +0 -146
- package/standard-skills/venture-flywheel/policy/capability-engine.cjs +0 -114
- package/standard-skills/venture-flywheel/policy/repository-trust.cjs +0 -229
- package/standard-skills/venture-flywheel/references/BEISPIELE-phase0.md +0 -146
- package/standard-skills/venture-flywheel/references/CAPABILITY-MAP.md +0 -34
- package/standard-skills/venture-flywheel/references/SPEC-phase0-identity-trust.md +0 -77
- package/standard-skills/venture-flywheel/references/SPEC-phase0-state-events.md +0 -93
- package/standard-skills/venture-flywheel/schemas/capability-decision.schema.json +0 -13
- package/standard-skills/venture-flywheel/schemas/execution-event.schema.json +0 -44
- package/standard-skills/venture-flywheel/schemas/project-identity.schema.json +0 -32
- package/standard-skills/venture-flywheel/schemas/repository-trust.schema.json +0 -57
- package/standard-skills/venture-flywheel/schemas/run-transition.schema.json +0 -59
- package/standard-skills/venture-flywheel/state/execution-event.cjs +0 -191
- package/standard-skills/venture-flywheel/state/task-state-machine.cjs +0 -190
- package/standard-skills/web-lesen/SKILL.md +0 -73
- package/standard-skills/web-lesen/scripts/crawl_public.py +0 -379
- package/standard-skills/windows-mcp/SKILL.md +0 -19
- package/standard-skills/windows-mcp/references/runtime.md +0 -9
- package/telegram-plugin/DELIVERY.md +0 -36
- package/telegram-plugin/bin/telegram-approval-relay.cjs +0 -290
- package/telegram-plugin/bin/telegram-console-status-policy.cjs +0 -175
- package/telegram-plugin/bin/telegram-delivery-lifecycle.cjs +0 -125
- package/telegram-plugin/bin/telegram-direct-reply-policy.cjs +0 -48
- package/telegram-plugin/bin/telegram-launcher-status-queue.cjs +0 -122
- package/telegram-plugin/bin/telegram-private-conversation-policy.cjs +0 -186
- package/telegram-plugin/bin/telegram-remote-status-policy.cjs +0 -121
- package/telegram-plugin/bin/telegram-reply-parts.cjs +0 -149
- package/telegram-plugin/bin/telegram-text-chunk-policy.cjs +0 -63
- package/telegram-plugin/bin/telegram-typing-keepalive.cjs +0 -89
- package/telegram-plugin/compat/mcp-server-fa511cd1.mjs +0 -73825
- /package/{bin → scripts}/fix-node-pty-perms.js +0 -0
|
@@ -0,0 +1,2835 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Signed CMS ingress and all-locales publication outbox.
|
|
3
|
+
|
|
4
|
+
The host owns the SQLite connections, signature authorities, and transport.
|
|
5
|
+
This module accepts one authenticated content-change event, resumes its
|
|
6
|
+
idempotent queue insertion, and publishes only a complete signed locale bundle.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import importlib.util
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import secrets
|
|
16
|
+
import sqlite3
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
import unicodedata
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from dataclasses import dataclass, fields, is_dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Callable, Iterator, Mapping, Protocol
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
SCHEMA_VERSION = 4
|
|
27
|
+
CHANGE_SCHEMA = "blun.cms-content-change.v2"
|
|
28
|
+
CANCELLATION_SCHEMA = "blun.cms-content-cancellation.v1"
|
|
29
|
+
TOMBSTONE_SCHEMA = "blun.cms-content-tombstone.v1"
|
|
30
|
+
PUBLICATION_SCHEMA = "blun.cms-localization-publication.v3"
|
|
31
|
+
ACK_SCHEMA = "blun.cms-localization-publication-ack.v1"
|
|
32
|
+
TOMBSTONE_DELIVERY_SCHEMA = "blun.cms-localization-tombstone.v1"
|
|
33
|
+
TOMBSTONE_ACK_SCHEMA = "blun.cms-localization-tombstone-ack.v1"
|
|
34
|
+
CAPABILITIES_SCHEMA = "blun.website-localization-capabilities.v3"
|
|
35
|
+
PUBLICATION_HTTP_CONTRACT_SCHEMA = (
|
|
36
|
+
"blun.cms-localization-publication-http-capabilities.v2"
|
|
37
|
+
)
|
|
38
|
+
PUBLICATION_HTTP_REQUEST_SCHEMA = "blun.cms-localization-publication-http.v1"
|
|
39
|
+
PUBLICATION_HTTP_RESPONSE_SCHEMA = "blun.cms-localization-publication-http-ack.v1"
|
|
40
|
+
TOMBSTONE_HTTP_REQUEST_SCHEMA = "blun.cms-localization-tombstone-http.v1"
|
|
41
|
+
TOMBSTONE_HTTP_RESPONSE_SCHEMA = "blun.cms-localization-tombstone-http-ack.v1"
|
|
42
|
+
PUBLICATION_HEALTH_SCHEMA = "blun.cms-localization-publication-health.v1"
|
|
43
|
+
PUBLICATION_HEALTH_ACK_SCHEMA = "blun.cms-localization-publication-health-ack.v1"
|
|
44
|
+
PUBLICATION_HEALTH_HTTP_REQUEST_SCHEMA = (
|
|
45
|
+
"blun.cms-localization-publication-health-http.v1"
|
|
46
|
+
)
|
|
47
|
+
PUBLICATION_HEALTH_HTTP_RESPONSE_SCHEMA = (
|
|
48
|
+
"blun.cms-localization-publication-health-http-ack.v1"
|
|
49
|
+
)
|
|
50
|
+
PUBLICATION_HTTP_BINDING_HEADERS = (
|
|
51
|
+
("Idempotency-Key", "delivery_id"),
|
|
52
|
+
("X-Localization-Delivery-Id", "delivery_id"),
|
|
53
|
+
("X-Localization-Payload-Sha256", "payload_sha256"),
|
|
54
|
+
)
|
|
55
|
+
PUBLICATION_HEALTH_HTTP_BINDING_HEADERS = (
|
|
56
|
+
("Idempotency-Key", "probe_id"),
|
|
57
|
+
("X-Localization-Probe-Id", "probe_id"),
|
|
58
|
+
("X-Localization-Contract-Sha256", "contract_sha256"),
|
|
59
|
+
)
|
|
60
|
+
MAX_MESSAGE_BYTES = 4_000_000
|
|
61
|
+
MAX_ATTEMPTS = 20
|
|
62
|
+
MAX_LEASE_SECONDS = 86_400.0
|
|
63
|
+
TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$")
|
|
64
|
+
SIGNATURE_VALUE = re.compile(r"^[A-Za-z0-9_.:/+=-]{1,4096}$")
|
|
65
|
+
ERROR_CODE = re.compile(r"^[a-z][a-z0-9_.-]{0,127}$")
|
|
66
|
+
SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
67
|
+
_EVENT_COLUMNS = (
|
|
68
|
+
"event_id", "event_sha256", "event_json", "signature_algorithm", "key_id",
|
|
69
|
+
"signature", "plan_id", "status", "created_at", "updated_at",
|
|
70
|
+
)
|
|
71
|
+
_DELIVERY_COLUMNS = (
|
|
72
|
+
"delivery_id", "event_id", "plan_id", "payload_json", "payload_sha256",
|
|
73
|
+
"signature_algorithm", "key_id", "signature", "status", "attempts",
|
|
74
|
+
"max_attempts", "next_attempt_at", "lease_owner", "lease_token",
|
|
75
|
+
"lease_expires_at", "last_error_code", "last_error_detail_hash",
|
|
76
|
+
"created_at", "updated_at",
|
|
77
|
+
)
|
|
78
|
+
_EVENT_TOPIC_COLUMNS = (
|
|
79
|
+
"event_id", "site_id", "source_id", "generation", "created_at",
|
|
80
|
+
)
|
|
81
|
+
_SUPERSESSION_COLUMNS = (
|
|
82
|
+
"event_id", "superseded_by_event_id", "created_at",
|
|
83
|
+
)
|
|
84
|
+
_CANCELLATION_COLUMNS = (
|
|
85
|
+
"cancellation_id", "event_id", "cancellation_sha256", "cancellation_json",
|
|
86
|
+
"signature_algorithm", "key_id", "signature", "created_at",
|
|
87
|
+
)
|
|
88
|
+
_TOMBSTONE_COLUMNS = (
|
|
89
|
+
"tombstone_id", "event_id", "tombstone_sha256", "tombstone_json",
|
|
90
|
+
"request_signature_algorithm", "request_key_id", "request_signature",
|
|
91
|
+
"delivery_id", "plan_id", "payload_json", "payload_sha256",
|
|
92
|
+
"signature_algorithm", "key_id", "signature", "status", "attempts",
|
|
93
|
+
"max_attempts", "next_attempt_at", "lease_owner", "lease_token",
|
|
94
|
+
"lease_expires_at", "last_error_code", "last_error_detail_hash",
|
|
95
|
+
"created_at", "updated_at",
|
|
96
|
+
)
|
|
97
|
+
_LOCALE_PROFILE_FIELDS = (
|
|
98
|
+
"locale", "eu_code", "language", "native_name", "script",
|
|
99
|
+
"quality_profile_version", "quality_profile_sha256", "direction",
|
|
100
|
+
)
|
|
101
|
+
_EXPECTED_CONTENT_TYPES = frozenset({
|
|
102
|
+
"headline", "cta", "marketing", "ui", "documentation", "seo", "legal",
|
|
103
|
+
"commercial",
|
|
104
|
+
})
|
|
105
|
+
_EXPECTED_COMMERCIAL_DIMENSIONS = (
|
|
106
|
+
"amount_currency", "discount_basis", "qualifiers", "tax_status",
|
|
107
|
+
"billing_interval", "commitment", "renewal", "cancellation",
|
|
108
|
+
"conditions", "offer_assignment",
|
|
109
|
+
)
|
|
110
|
+
_DEFAULT_TARGET_POLICY = "all-eu-official-locales-except-source-language"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _load_module(name: str, path: Path):
|
|
114
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
115
|
+
if spec is None or spec.loader is None:
|
|
116
|
+
raise RuntimeError(f"cannot load required CMS dependency: {path.name}")
|
|
117
|
+
module = importlib.util.module_from_spec(spec)
|
|
118
|
+
sys.modules[spec.name] = module
|
|
119
|
+
spec.loader.exec_module(module)
|
|
120
|
+
return module
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
_ROOT = Path(__file__).resolve().parents[1]
|
|
124
|
+
_PLANNER = _load_module(
|
|
125
|
+
"blun_website_localization_cms_planner",
|
|
126
|
+
_ROOT / "integrations" / "website_localization.py",
|
|
127
|
+
)
|
|
128
|
+
_COMMERCIAL = _load_module(
|
|
129
|
+
"blun_website_localization_cms_commercial_profile",
|
|
130
|
+
_ROOT / "integrations" / "commercial_localization_profile.py",
|
|
131
|
+
)
|
|
132
|
+
_RELEASE = _load_module(
|
|
133
|
+
"blun_website_localization_cms_release",
|
|
134
|
+
_ROOT / "integrations" / "website_localization_release.py",
|
|
135
|
+
)
|
|
136
|
+
_QUEUE = _RELEASE._QUEUE
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class CMSBridgeBlocked(RuntimeError):
|
|
140
|
+
"""Stable, content-free CMS bridge failure."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, code: str):
|
|
143
|
+
if not isinstance(code, str) or ERROR_CODE.fullmatch(code) is None:
|
|
144
|
+
raise ValueError("CMS failure code is invalid")
|
|
145
|
+
super().__init__(code)
|
|
146
|
+
self.code = code
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass(frozen=True)
|
|
150
|
+
class CMSMessageSignature:
|
|
151
|
+
algorithm: str
|
|
152
|
+
key_id: str
|
|
153
|
+
signature: str
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class CMSMessageAuthority(Protocol):
|
|
157
|
+
def sign(self, payload: bytes) -> CMSMessageSignature: ...
|
|
158
|
+
def verify(self, payload: bytes, signature: CMSMessageSignature) -> bool: ...
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class CMSPublisher(Protocol):
|
|
162
|
+
def publish(self, request: "CMSPublicationRequest") -> Mapping[str, Any]: ...
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class CMSPublishFailed(RuntimeError):
|
|
166
|
+
"""Provider-neutral transport failure with an explicit retry decision."""
|
|
167
|
+
|
|
168
|
+
def __init__(self, code: str, *, retryable: bool, detail: str | None = None):
|
|
169
|
+
if not isinstance(code, str) or ERROR_CODE.fullmatch(code) is None:
|
|
170
|
+
raise ValueError("publisher failure code is invalid")
|
|
171
|
+
if not isinstance(retryable, bool):
|
|
172
|
+
raise ValueError("retryable must be boolean")
|
|
173
|
+
if detail is not None and not isinstance(detail, str):
|
|
174
|
+
raise ValueError("publisher failure detail must be text")
|
|
175
|
+
super().__init__(code)
|
|
176
|
+
self.code = code
|
|
177
|
+
self.retryable = retryable
|
|
178
|
+
self.detail = detail
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _declared_publish_failure(error: Exception) -> CMSPublishFailed | None:
|
|
182
|
+
"""Normalize an adapter failure without depending on module class identity."""
|
|
183
|
+
|
|
184
|
+
if isinstance(error, CMSPublishFailed):
|
|
185
|
+
return error
|
|
186
|
+
try:
|
|
187
|
+
declared = error.cms_publish_failure is True
|
|
188
|
+
code = error.code
|
|
189
|
+
retryable = error.retryable
|
|
190
|
+
except Exception:
|
|
191
|
+
return None
|
|
192
|
+
if (
|
|
193
|
+
not declared
|
|
194
|
+
or not isinstance(code, str)
|
|
195
|
+
or ERROR_CODE.fullmatch(code) is None
|
|
196
|
+
or not isinstance(retryable, bool)
|
|
197
|
+
):
|
|
198
|
+
return None
|
|
199
|
+
full_code = "publisher." + code
|
|
200
|
+
if ERROR_CODE.fullmatch(full_code) is None:
|
|
201
|
+
full_code = "publisher.failure"
|
|
202
|
+
return CMSPublishFailed(full_code, retryable=retryable)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(frozen=True)
|
|
206
|
+
class IngestedChange:
|
|
207
|
+
event_id: str
|
|
208
|
+
plan_id: str
|
|
209
|
+
job_count: int
|
|
210
|
+
inserted_jobs: int
|
|
211
|
+
status: str
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass(frozen=True)
|
|
215
|
+
class CancelledChange:
|
|
216
|
+
cancellation_id: str
|
|
217
|
+
event_id: str
|
|
218
|
+
status: str
|
|
219
|
+
newly_cancelled: bool
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass(frozen=True)
|
|
223
|
+
class TombstoneAccepted:
|
|
224
|
+
tombstone_id: str
|
|
225
|
+
event_id: str
|
|
226
|
+
delivery_id: str
|
|
227
|
+
status: str
|
|
228
|
+
newly_requested: bool
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@dataclass(frozen=True)
|
|
232
|
+
class CMSPublicationRequest:
|
|
233
|
+
delivery_id: str
|
|
234
|
+
payload: dict[str, Any]
|
|
235
|
+
payload_sha256: str
|
|
236
|
+
signature: CMSMessageSignature
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass(frozen=True)
|
|
240
|
+
class CMSTombstoneRequest:
|
|
241
|
+
delivery_id: str
|
|
242
|
+
payload: dict[str, Any]
|
|
243
|
+
payload_sha256: str
|
|
244
|
+
signature: CMSMessageSignature
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@dataclass(frozen=True)
|
|
248
|
+
class ClaimedDelivery:
|
|
249
|
+
request: CMSPublicationRequest
|
|
250
|
+
attempt: int
|
|
251
|
+
max_attempts: int
|
|
252
|
+
lease_owner: str
|
|
253
|
+
lease_token: str
|
|
254
|
+
lease_expires_at: float
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass(frozen=True)
|
|
258
|
+
class ClaimedTombstone:
|
|
259
|
+
request: CMSTombstoneRequest
|
|
260
|
+
attempt: int
|
|
261
|
+
max_attempts: int
|
|
262
|
+
lease_owner: str
|
|
263
|
+
lease_token: str
|
|
264
|
+
lease_expires_at: float
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@dataclass(frozen=True)
|
|
268
|
+
class DeliveryStatus:
|
|
269
|
+
delivery_id: str
|
|
270
|
+
event_id: str
|
|
271
|
+
plan_id: str
|
|
272
|
+
status: str
|
|
273
|
+
attempts: int
|
|
274
|
+
max_attempts: int
|
|
275
|
+
next_attempt_at: float
|
|
276
|
+
lease_expires_at: float | None
|
|
277
|
+
last_error_code: str | None
|
|
278
|
+
last_error_detail_hash: str | None
|
|
279
|
+
payload_sha256: str
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclass(frozen=True)
|
|
283
|
+
class DeliveryOutcome:
|
|
284
|
+
status: str
|
|
285
|
+
delivery_id: str | None = None
|
|
286
|
+
attempt: int | None = None
|
|
287
|
+
error_code: str | None = None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@dataclass(frozen=True)
|
|
291
|
+
class LocaleProgress:
|
|
292
|
+
job_id: str
|
|
293
|
+
target_locale: str
|
|
294
|
+
status: str
|
|
295
|
+
attempts: int
|
|
296
|
+
max_attempts: int
|
|
297
|
+
next_attempt_at: float
|
|
298
|
+
lease_expires_at: float | None
|
|
299
|
+
lease_expired: bool
|
|
300
|
+
last_error_code: str | None
|
|
301
|
+
last_error_detail_hash: str | None
|
|
302
|
+
result_sha256: str | None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@dataclass(frozen=True)
|
|
306
|
+
class ChangeProgress:
|
|
307
|
+
event_id: str
|
|
308
|
+
site_id: str
|
|
309
|
+
plan_id: str
|
|
310
|
+
website_version: str
|
|
311
|
+
source_sequence: int
|
|
312
|
+
job_count: int
|
|
313
|
+
counts: dict[str, int]
|
|
314
|
+
locales: tuple[LocaleProgress, ...]
|
|
315
|
+
cancelled: bool
|
|
316
|
+
queue_recovery_pending: bool
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
@dataclass(frozen=True)
|
|
320
|
+
class ChangeLifecycle:
|
|
321
|
+
event_id: str
|
|
322
|
+
site_id: str
|
|
323
|
+
plan_id: str
|
|
324
|
+
website_version: str
|
|
325
|
+
source_sequence: int
|
|
326
|
+
status: str
|
|
327
|
+
required_locales: tuple[str, ...]
|
|
328
|
+
approved_locales: tuple[str, ...]
|
|
329
|
+
blocked_locales: tuple[tuple[str, str], ...]
|
|
330
|
+
queue_counts: dict[str, int]
|
|
331
|
+
delivery: dict[str, Any] | None
|
|
332
|
+
tombstone: dict[str, Any] | None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _canonical_json(value: Any) -> str:
|
|
336
|
+
try:
|
|
337
|
+
encoded = json.dumps(
|
|
338
|
+
value,
|
|
339
|
+
ensure_ascii=False,
|
|
340
|
+
allow_nan=False,
|
|
341
|
+
sort_keys=True,
|
|
342
|
+
separators=(",", ":"),
|
|
343
|
+
)
|
|
344
|
+
except (TypeError, ValueError) as error:
|
|
345
|
+
raise CMSBridgeBlocked("cms.json.invalid") from error
|
|
346
|
+
if len(encoded.encode("utf-8")) > MAX_MESSAGE_BYTES:
|
|
347
|
+
raise CMSBridgeBlocked("cms.message.too_large")
|
|
348
|
+
return encoded
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _hash(value: str) -> str:
|
|
352
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _text(value: Any, code: str, *, limit: int = 256) -> str:
|
|
356
|
+
if not isinstance(value, str) or not value or value != value.strip():
|
|
357
|
+
raise CMSBridgeBlocked(code)
|
|
358
|
+
if len(value) > limit or "\x00" in value or not unicodedata.is_normalized("NFC", value):
|
|
359
|
+
raise CMSBridgeBlocked(code)
|
|
360
|
+
return value
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _token(value: Any, code: str) -> str:
|
|
364
|
+
value = _text(value, code)
|
|
365
|
+
if TOKEN.fullmatch(value) is None:
|
|
366
|
+
raise CMSBridgeBlocked(code)
|
|
367
|
+
return value
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _timestamp(value: Any, code: str) -> float:
|
|
371
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
372
|
+
raise CMSBridgeBlocked(code)
|
|
373
|
+
value = float(value)
|
|
374
|
+
if value < 0 or value != value or value in {float("inf"), float("-inf")}:
|
|
375
|
+
raise CMSBridgeBlocked(code)
|
|
376
|
+
return value
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _valid_release_evidence(
|
|
380
|
+
value: Any,
|
|
381
|
+
*,
|
|
382
|
+
locale: Any,
|
|
383
|
+
target_sha256: Any,
|
|
384
|
+
approval_id: Any,
|
|
385
|
+
) -> bool:
|
|
386
|
+
try:
|
|
387
|
+
evidence = _RELEASE.validate_publication_evidence(value)
|
|
388
|
+
except _RELEASE.LocalizationReleaseBlocked:
|
|
389
|
+
return False
|
|
390
|
+
return (
|
|
391
|
+
evidence["target_locale"] == locale
|
|
392
|
+
and evidence["target_sha256"] == target_sha256
|
|
393
|
+
and evidence["approval_id"] == approval_id
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _positive_integer(value: Any, code: str) -> int:
|
|
398
|
+
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value < 2**63:
|
|
399
|
+
raise CMSBridgeBlocked(code)
|
|
400
|
+
return value
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _duration(value: Any, code: str, *, maximum: float = MAX_LEASE_SECONDS) -> float:
|
|
404
|
+
value = _timestamp(value, code)
|
|
405
|
+
if value <= 0 or value > maximum:
|
|
406
|
+
raise CMSBridgeBlocked(code)
|
|
407
|
+
return value
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _signature(value: Any, code: str = "cms.signature.invalid") -> CMSMessageSignature:
|
|
411
|
+
if not isinstance(value, CMSMessageSignature):
|
|
412
|
+
expected = tuple(field.name for field in fields(CMSMessageSignature))
|
|
413
|
+
try:
|
|
414
|
+
actual = tuple(field.name for field in fields(value))
|
|
415
|
+
parameters = type(value).__dataclass_params__
|
|
416
|
+
if (
|
|
417
|
+
not is_dataclass(value)
|
|
418
|
+
or isinstance(value, type)
|
|
419
|
+
or type(value).__name__ != CMSMessageSignature.__name__
|
|
420
|
+
or parameters.frozen is not True
|
|
421
|
+
or actual != expected
|
|
422
|
+
):
|
|
423
|
+
raise TypeError("incompatible signature")
|
|
424
|
+
value = CMSMessageSignature(**{
|
|
425
|
+
field: getattr(value, field) for field in expected
|
|
426
|
+
})
|
|
427
|
+
except Exception:
|
|
428
|
+
raise CMSBridgeBlocked(code) from None
|
|
429
|
+
for part in (value.algorithm, value.key_id):
|
|
430
|
+
if not isinstance(part, str) or TOKEN.fullmatch(part) is None:
|
|
431
|
+
raise CMSBridgeBlocked(code)
|
|
432
|
+
if not isinstance(value.signature, str) or SIGNATURE_VALUE.fullmatch(value.signature) is None:
|
|
433
|
+
raise CMSBridgeBlocked(code)
|
|
434
|
+
return value
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _verify(authority: Any, payload: bytes, signature: CMSMessageSignature, code: str) -> None:
|
|
438
|
+
verify = getattr(authority, "verify", None)
|
|
439
|
+
try:
|
|
440
|
+
accepted = callable(verify) and verify(payload, signature) is True
|
|
441
|
+
except Exception:
|
|
442
|
+
accepted = False
|
|
443
|
+
if not accepted:
|
|
444
|
+
raise CMSBridgeBlocked(code)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@contextmanager
|
|
448
|
+
def _transaction(connection: sqlite3.Connection) -> Iterator[None]:
|
|
449
|
+
if connection.in_transaction:
|
|
450
|
+
raise CMSBridgeBlocked("cms.transaction.external")
|
|
451
|
+
try:
|
|
452
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
453
|
+
yield
|
|
454
|
+
except Exception:
|
|
455
|
+
connection.rollback()
|
|
456
|
+
raise
|
|
457
|
+
else:
|
|
458
|
+
connection.commit()
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class WebsiteLocalizationCMSBridge:
|
|
462
|
+
"""Authenticated CMS change ingestion and complete-bundle delivery."""
|
|
463
|
+
|
|
464
|
+
def __init__(self, connection: sqlite3.Connection, queue: Any, release_store: Any):
|
|
465
|
+
if not isinstance(connection, sqlite3.Connection):
|
|
466
|
+
raise CMSBridgeBlocked("cms.connection.invalid")
|
|
467
|
+
if not isinstance(queue, _QUEUE.LocalizationQueue):
|
|
468
|
+
raise CMSBridgeBlocked("cms.queue.invalid")
|
|
469
|
+
if not isinstance(release_store, _RELEASE.LocalizationReleaseStore):
|
|
470
|
+
raise CMSBridgeBlocked("cms.release_store.invalid")
|
|
471
|
+
if release_store.queue is not queue:
|
|
472
|
+
raise CMSBridgeBlocked("cms.queue_mismatch")
|
|
473
|
+
self.connection = connection
|
|
474
|
+
self.connection.row_factory = sqlite3.Row
|
|
475
|
+
self.connection.execute("PRAGMA foreign_keys = ON")
|
|
476
|
+
self.connection.execute("PRAGMA busy_timeout = 5000")
|
|
477
|
+
self.queue = queue
|
|
478
|
+
self.release_store = release_store
|
|
479
|
+
version = int(connection.execute("PRAGMA user_version").fetchone()[0])
|
|
480
|
+
if version not in {0, 1, 2, 3, SCHEMA_VERSION}:
|
|
481
|
+
raise CMSBridgeBlocked("cms.schema.unsupported")
|
|
482
|
+
if version == 0:
|
|
483
|
+
self._create_schema()
|
|
484
|
+
elif version == 1:
|
|
485
|
+
self._migrate_v1()
|
|
486
|
+
elif version == 2:
|
|
487
|
+
self._migrate_v2()
|
|
488
|
+
elif version == 3:
|
|
489
|
+
self._migrate_v3()
|
|
490
|
+
self._verify_schema()
|
|
491
|
+
|
|
492
|
+
def localization_capabilities(self) -> dict[str, Any]:
|
|
493
|
+
"""Return the canonical, content-free planner contract without mutation."""
|
|
494
|
+
try:
|
|
495
|
+
profiles = _PLANNER.EU_OFFICIAL_LOCALES
|
|
496
|
+
content_types = _PLANNER.CONTENT_TYPES
|
|
497
|
+
quality_passes = _PLANNER.QUALITY_PASSES
|
|
498
|
+
if (
|
|
499
|
+
not isinstance(profiles, tuple)
|
|
500
|
+
or len(profiles) != 24
|
|
501
|
+
or not isinstance(content_types, frozenset)
|
|
502
|
+
or content_types != _EXPECTED_CONTENT_TYPES
|
|
503
|
+
or quality_passes != ("target_native", "source_fidelity")
|
|
504
|
+
or _PLANNER.EU_LANGUAGE_SOURCE
|
|
505
|
+
!= "https://european-union.europa.eu/principles-countries-history/languages_en"
|
|
506
|
+
):
|
|
507
|
+
raise CMSBridgeBlocked("cms.capabilities.registry_invalid")
|
|
508
|
+
|
|
509
|
+
commercial = _COMMERCIAL.public_profile(_PLANNER.COMMERCIAL_PROFILE)
|
|
510
|
+
review_summary_contract = _COMMERCIAL.public_review_summary_contract(
|
|
511
|
+
_PLANNER.COMMERCIAL_PROFILE,
|
|
512
|
+
)
|
|
513
|
+
if (
|
|
514
|
+
not isinstance(commercial, dict)
|
|
515
|
+
or set(commercial) != {
|
|
516
|
+
"schema", "profile", "applies_to", "dimensions",
|
|
517
|
+
"preservation", "rendering", "verification",
|
|
518
|
+
"protected_terms", "review_summary_schema",
|
|
519
|
+
"review_summary_contract", "sha256",
|
|
520
|
+
}
|
|
521
|
+
or commercial["schema"] != _COMMERCIAL.PUBLIC_PROFILE_SCHEMA
|
|
522
|
+
or commercial["profile"] != _PLANNER.COMMERCIAL_PROFILE
|
|
523
|
+
or commercial["review_summary_schema"]
|
|
524
|
+
!= _COMMERCIAL.REVIEW_SUMMARY_SCHEMA
|
|
525
|
+
or commercial["review_summary_contract"] != review_summary_contract
|
|
526
|
+
or commercial["applies_to"] != {
|
|
527
|
+
"content_type": "commercial",
|
|
528
|
+
"locales": "all-supported-target-locales",
|
|
529
|
+
}
|
|
530
|
+
or commercial["protected_terms"] != "project-configuration-only"
|
|
531
|
+
or [item.get("name") for item in commercial["dimensions"]]
|
|
532
|
+
!= list(_EXPECTED_COMMERCIAL_DIMENSIONS)
|
|
533
|
+
or tuple(_COMMERCIAL.DIMENSIONS) != _EXPECTED_COMMERCIAL_DIMENSIONS
|
|
534
|
+
):
|
|
535
|
+
raise CMSBridgeBlocked("cms.capabilities.registry_invalid")
|
|
536
|
+
unsigned_review_summary_contract = dict(review_summary_contract)
|
|
537
|
+
review_summary_digest = unsigned_review_summary_contract.pop(
|
|
538
|
+
"sha256", None,
|
|
539
|
+
)
|
|
540
|
+
if (
|
|
541
|
+
set(review_summary_contract) != {
|
|
542
|
+
"schema", "result_schema", "profile", "required_fields",
|
|
543
|
+
"statuses", "review_required_dimensions", "evidence_sha256",
|
|
544
|
+
"content_policy", "sha256",
|
|
545
|
+
}
|
|
546
|
+
or review_summary_contract["schema"]
|
|
547
|
+
!= _COMMERCIAL.REVIEW_SUMMARY_CAPABILITIES_SCHEMA
|
|
548
|
+
or review_summary_contract["result_schema"]
|
|
549
|
+
!= _COMMERCIAL.REVIEW_SUMMARY_SCHEMA
|
|
550
|
+
or review_summary_contract["profile"]
|
|
551
|
+
!= _PLANNER.COMMERCIAL_PROFILE
|
|
552
|
+
or review_summary_contract["required_fields"] != [
|
|
553
|
+
"schema", "profile", "status", "review_required_dimensions",
|
|
554
|
+
"evidence_sha256",
|
|
555
|
+
]
|
|
556
|
+
or review_summary_contract["statuses"] != {
|
|
557
|
+
"verified": {"review_required_dimensions": "empty"},
|
|
558
|
+
"review_required": {
|
|
559
|
+
"review_required_dimensions": "one-or-more",
|
|
560
|
+
"requires_independent_review": True,
|
|
561
|
+
},
|
|
562
|
+
}
|
|
563
|
+
or review_summary_contract["review_required_dimensions"] != {
|
|
564
|
+
"allowed": list(_EXPECTED_COMMERCIAL_DIMENSIONS),
|
|
565
|
+
"order": list(_EXPECTED_COMMERCIAL_DIMENSIONS),
|
|
566
|
+
"unique": True,
|
|
567
|
+
}
|
|
568
|
+
or review_summary_contract["evidence_sha256"] != {
|
|
569
|
+
"algorithm": "sha-256",
|
|
570
|
+
"canonicalization": (
|
|
571
|
+
"utf-8-json-sort-keys-no-insignificant-whitespace"
|
|
572
|
+
),
|
|
573
|
+
"covers": "complete-commercial-review-evidence",
|
|
574
|
+
}
|
|
575
|
+
or review_summary_contract["content_policy"] != {
|
|
576
|
+
"source_text": False,
|
|
577
|
+
"target_text": False,
|
|
578
|
+
"source_spans": False,
|
|
579
|
+
"target_spans": False,
|
|
580
|
+
"reviewer_prose": False,
|
|
581
|
+
"project_prices": False,
|
|
582
|
+
"project_brands": False,
|
|
583
|
+
}
|
|
584
|
+
or not isinstance(review_summary_digest, str)
|
|
585
|
+
or SHA256.fullmatch(review_summary_digest) is None
|
|
586
|
+
or review_summary_digest
|
|
587
|
+
!= _hash(_canonical_json(unsigned_review_summary_contract))
|
|
588
|
+
):
|
|
589
|
+
raise CMSBridgeBlocked("cms.capabilities.registry_invalid")
|
|
590
|
+
unsigned_commercial = dict(commercial)
|
|
591
|
+
commercial_digest = unsigned_commercial.pop("sha256", None)
|
|
592
|
+
if (
|
|
593
|
+
not isinstance(commercial_digest, str)
|
|
594
|
+
or SHA256.fullmatch(commercial_digest) is None
|
|
595
|
+
or commercial_digest != _hash(_canonical_json(unsigned_commercial))
|
|
596
|
+
):
|
|
597
|
+
raise CMSBridgeBlocked("cms.capabilities.registry_invalid")
|
|
598
|
+
|
|
599
|
+
locales = []
|
|
600
|
+
seen_locales: set[str] = set()
|
|
601
|
+
seen_languages: set[str] = set()
|
|
602
|
+
seen_eu_codes: set[str] = set()
|
|
603
|
+
for profile in profiles:
|
|
604
|
+
if (
|
|
605
|
+
not is_dataclass(profile)
|
|
606
|
+
or isinstance(profile, type)
|
|
607
|
+
or type(profile).__name__ != "LocaleProfile"
|
|
608
|
+
or type(profile).__dataclass_params__.frozen is not True
|
|
609
|
+
or tuple(field.name for field in fields(profile))
|
|
610
|
+
!= _LOCALE_PROFILE_FIELDS
|
|
611
|
+
):
|
|
612
|
+
raise CMSBridgeBlocked("cms.capabilities.registry_invalid")
|
|
613
|
+
item = {
|
|
614
|
+
field: getattr(profile, field) for field in _LOCALE_PROFILE_FIELDS
|
|
615
|
+
}
|
|
616
|
+
locale = _token(item["locale"], "cms.capabilities.registry_invalid")
|
|
617
|
+
eu_code = _token(item["eu_code"], "cms.capabilities.registry_invalid")
|
|
618
|
+
language = _token(item["language"], "cms.capabilities.registry_invalid")
|
|
619
|
+
native_name = _text(
|
|
620
|
+
item["native_name"], "cms.capabilities.registry_invalid",
|
|
621
|
+
)
|
|
622
|
+
script = _token(item["script"], "cms.capabilities.registry_invalid")
|
|
623
|
+
version = _token(
|
|
624
|
+
item["quality_profile_version"],
|
|
625
|
+
"cms.capabilities.registry_invalid",
|
|
626
|
+
)
|
|
627
|
+
digest = item["quality_profile_sha256"]
|
|
628
|
+
direction = item["direction"]
|
|
629
|
+
if (
|
|
630
|
+
_PLANNER.canonicalize_locale(locale) != locale
|
|
631
|
+
or locale.split("-", 1)[0] != language
|
|
632
|
+
or not isinstance(digest, str)
|
|
633
|
+
or SHA256.fullmatch(digest) is None
|
|
634
|
+
or direction not in {"ltr", "rtl"}
|
|
635
|
+
or locale in seen_locales
|
|
636
|
+
or language in seen_languages
|
|
637
|
+
or eu_code in seen_eu_codes
|
|
638
|
+
):
|
|
639
|
+
raise CMSBridgeBlocked("cms.capabilities.registry_invalid")
|
|
640
|
+
quality = _PLANNER.quality_profile_for(locale)
|
|
641
|
+
if (
|
|
642
|
+
not isinstance(quality, dict)
|
|
643
|
+
or quality.get("locale") != locale
|
|
644
|
+
or quality.get("version") != version
|
|
645
|
+
or quality.get("sha256") != digest
|
|
646
|
+
):
|
|
647
|
+
raise CMSBridgeBlocked("cms.capabilities.registry_invalid")
|
|
648
|
+
seen_locales.add(locale)
|
|
649
|
+
seen_languages.add(language)
|
|
650
|
+
seen_eu_codes.add(eu_code)
|
|
651
|
+
locales.append({
|
|
652
|
+
"locale": locale,
|
|
653
|
+
"eu_code": eu_code,
|
|
654
|
+
"language": language,
|
|
655
|
+
"native_name": native_name,
|
|
656
|
+
"script": script,
|
|
657
|
+
"direction": direction,
|
|
658
|
+
"quality_profile_version": version,
|
|
659
|
+
"quality_profile_sha256": digest,
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
body = {
|
|
663
|
+
"schema": CAPABILITIES_SCHEMA,
|
|
664
|
+
"change_schema": CHANGE_SCHEMA,
|
|
665
|
+
"cancellation_schema": CANCELLATION_SCHEMA,
|
|
666
|
+
"tombstone_schema": TOMBSTONE_SCHEMA,
|
|
667
|
+
"publication_schema": PUBLICATION_SCHEMA,
|
|
668
|
+
"tombstone_delivery_schema": TOMBSTONE_DELIVERY_SCHEMA,
|
|
669
|
+
"plan_schema": _token(
|
|
670
|
+
_PLANNER.SCHEMA, "cms.capabilities.registry_invalid",
|
|
671
|
+
),
|
|
672
|
+
"job_schema": _token(
|
|
673
|
+
_PLANNER.JOB_SCHEMA, "cms.capabilities.registry_invalid",
|
|
674
|
+
),
|
|
675
|
+
"eu_language_source": _PLANNER.EU_LANGUAGE_SOURCE,
|
|
676
|
+
"default_target_policy": _DEFAULT_TARGET_POLICY,
|
|
677
|
+
"content_types": sorted(content_types),
|
|
678
|
+
"quality_passes": list(quality_passes),
|
|
679
|
+
"commercial_profile": commercial,
|
|
680
|
+
"publication_http": self._publication_http_capabilities(),
|
|
681
|
+
"locales": locales,
|
|
682
|
+
}
|
|
683
|
+
canonical = _canonical_json(body)
|
|
684
|
+
return {**body, "sha256": _hash(canonical)}
|
|
685
|
+
except CMSBridgeBlocked:
|
|
686
|
+
raise
|
|
687
|
+
except Exception:
|
|
688
|
+
raise CMSBridgeBlocked("cms.capabilities.registry_invalid") from None
|
|
689
|
+
|
|
690
|
+
@staticmethod
|
|
691
|
+
def _publication_http_capabilities() -> dict[str, Any]:
|
|
692
|
+
"""Describe the built-in outbound adapter without deployment secrets."""
|
|
693
|
+
try:
|
|
694
|
+
operations = [
|
|
695
|
+
{
|
|
696
|
+
"name": "publication",
|
|
697
|
+
"payload_schema": _token(
|
|
698
|
+
PUBLICATION_SCHEMA, "cms.capabilities.registry_invalid",
|
|
699
|
+
),
|
|
700
|
+
"request_schema": _token(
|
|
701
|
+
PUBLICATION_HTTP_REQUEST_SCHEMA,
|
|
702
|
+
"cms.capabilities.registry_invalid",
|
|
703
|
+
),
|
|
704
|
+
"acknowledgement_schema": _token(
|
|
705
|
+
ACK_SCHEMA, "cms.capabilities.registry_invalid",
|
|
706
|
+
),
|
|
707
|
+
"response_schema": _token(
|
|
708
|
+
PUBLICATION_HTTP_RESPONSE_SCHEMA,
|
|
709
|
+
"cms.capabilities.registry_invalid",
|
|
710
|
+
),
|
|
711
|
+
"acknowledgement_status": "accepted",
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
"name": "tombstone",
|
|
715
|
+
"payload_schema": _token(
|
|
716
|
+
TOMBSTONE_DELIVERY_SCHEMA,
|
|
717
|
+
"cms.capabilities.registry_invalid",
|
|
718
|
+
),
|
|
719
|
+
"request_schema": _token(
|
|
720
|
+
TOMBSTONE_HTTP_REQUEST_SCHEMA,
|
|
721
|
+
"cms.capabilities.registry_invalid",
|
|
722
|
+
),
|
|
723
|
+
"acknowledgement_schema": _token(
|
|
724
|
+
TOMBSTONE_ACK_SCHEMA, "cms.capabilities.registry_invalid",
|
|
725
|
+
),
|
|
726
|
+
"response_schema": _token(
|
|
727
|
+
TOMBSTONE_HTTP_RESPONSE_SCHEMA,
|
|
728
|
+
"cms.capabilities.registry_invalid",
|
|
729
|
+
),
|
|
730
|
+
"acknowledgement_status": "deleted",
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
"name": "health",
|
|
734
|
+
"payload_schema": _token(
|
|
735
|
+
PUBLICATION_HEALTH_SCHEMA,
|
|
736
|
+
"cms.capabilities.registry_invalid",
|
|
737
|
+
),
|
|
738
|
+
"request_schema": _token(
|
|
739
|
+
PUBLICATION_HEALTH_HTTP_REQUEST_SCHEMA,
|
|
740
|
+
"cms.capabilities.registry_invalid",
|
|
741
|
+
),
|
|
742
|
+
"acknowledgement_schema": _token(
|
|
743
|
+
PUBLICATION_HEALTH_ACK_SCHEMA,
|
|
744
|
+
"cms.capabilities.registry_invalid",
|
|
745
|
+
),
|
|
746
|
+
"response_schema": _token(
|
|
747
|
+
PUBLICATION_HEALTH_HTTP_RESPONSE_SCHEMA,
|
|
748
|
+
"cms.capabilities.registry_invalid",
|
|
749
|
+
),
|
|
750
|
+
"acknowledgement_status": "healthy",
|
|
751
|
+
},
|
|
752
|
+
]
|
|
753
|
+
headers = []
|
|
754
|
+
seen_headers: set[str] = set()
|
|
755
|
+
for name, binding in PUBLICATION_HTTP_BINDING_HEADERS:
|
|
756
|
+
if (
|
|
757
|
+
not isinstance(name, str)
|
|
758
|
+
or not name.isascii()
|
|
759
|
+
or not re.fullmatch(r"[A-Za-z0-9-]{1,128}", name)
|
|
760
|
+
or binding not in {"delivery_id", "payload_sha256"}
|
|
761
|
+
or name.lower() in seen_headers
|
|
762
|
+
):
|
|
763
|
+
raise ValueError
|
|
764
|
+
seen_headers.add(name.lower())
|
|
765
|
+
headers.append({
|
|
766
|
+
"name": name,
|
|
767
|
+
"binding": _token(
|
|
768
|
+
binding, "cms.capabilities.registry_invalid",
|
|
769
|
+
),
|
|
770
|
+
})
|
|
771
|
+
body = {
|
|
772
|
+
"schema": _token(
|
|
773
|
+
PUBLICATION_HTTP_CONTRACT_SCHEMA,
|
|
774
|
+
"cms.capabilities.registry_invalid",
|
|
775
|
+
),
|
|
776
|
+
"method": "POST",
|
|
777
|
+
"request_content_type": "application/json; charset=utf-8",
|
|
778
|
+
"response_content_types": [
|
|
779
|
+
"application/json", "application/json; charset=utf-8",
|
|
780
|
+
],
|
|
781
|
+
"delivery_semantics": "at-least-once",
|
|
782
|
+
"release_evidence_schema": _token(
|
|
783
|
+
_RELEASE.PUBLICATION_EVIDENCE_SCHEMA,
|
|
784
|
+
"cms.capabilities.registry_invalid",
|
|
785
|
+
),
|
|
786
|
+
"binding_headers": headers,
|
|
787
|
+
"health_binding_headers": [
|
|
788
|
+
{
|
|
789
|
+
"name": _token(
|
|
790
|
+
name, "cms.capabilities.registry_invalid",
|
|
791
|
+
),
|
|
792
|
+
"binding": _token(
|
|
793
|
+
binding, "cms.capabilities.registry_invalid",
|
|
794
|
+
),
|
|
795
|
+
}
|
|
796
|
+
for name, binding in PUBLICATION_HEALTH_HTTP_BINDING_HEADERS
|
|
797
|
+
],
|
|
798
|
+
"operations": operations,
|
|
799
|
+
}
|
|
800
|
+
health_headers = body["health_binding_headers"]
|
|
801
|
+
if (
|
|
802
|
+
len(health_headers) != len(PUBLICATION_HEALTH_HTTP_BINDING_HEADERS)
|
|
803
|
+
or len({item["name"].lower() for item in health_headers})
|
|
804
|
+
!= len(health_headers)
|
|
805
|
+
or any(
|
|
806
|
+
item["binding"] not in {"probe_id", "contract_sha256"}
|
|
807
|
+
or not item["name"].isascii()
|
|
808
|
+
or re.fullmatch(r"[A-Za-z0-9-]{1,128}", item["name"]) is None
|
|
809
|
+
for item in health_headers
|
|
810
|
+
)
|
|
811
|
+
):
|
|
812
|
+
raise ValueError
|
|
813
|
+
return {**body, "sha256": _hash(_canonical_json(body))}
|
|
814
|
+
except CMSBridgeBlocked:
|
|
815
|
+
raise
|
|
816
|
+
except Exception:
|
|
817
|
+
raise CMSBridgeBlocked("cms.capabilities.registry_invalid") from None
|
|
818
|
+
|
|
819
|
+
def _create_revision_schema(self) -> None:
|
|
820
|
+
self.connection.execute("""
|
|
821
|
+
CREATE TABLE cms_event_topics (
|
|
822
|
+
event_id TEXT PRIMARY KEY,
|
|
823
|
+
site_id TEXT NOT NULL,
|
|
824
|
+
source_id TEXT NOT NULL,
|
|
825
|
+
generation INTEGER NOT NULL CHECK (generation > 0),
|
|
826
|
+
created_at REAL NOT NULL,
|
|
827
|
+
FOREIGN KEY (event_id) REFERENCES cms_change_events (event_id),
|
|
828
|
+
UNIQUE (site_id, source_id, generation)
|
|
829
|
+
)
|
|
830
|
+
""")
|
|
831
|
+
self.connection.execute("""
|
|
832
|
+
CREATE INDEX cms_event_topic_order
|
|
833
|
+
ON cms_event_topics (site_id, source_id, generation, event_id)
|
|
834
|
+
""")
|
|
835
|
+
self.connection.execute("""
|
|
836
|
+
CREATE TABLE cms_event_supersessions (
|
|
837
|
+
event_id TEXT PRIMARY KEY,
|
|
838
|
+
superseded_by_event_id TEXT NOT NULL,
|
|
839
|
+
created_at REAL NOT NULL,
|
|
840
|
+
CHECK (event_id <> superseded_by_event_id),
|
|
841
|
+
FOREIGN KEY (event_id) REFERENCES cms_change_events (event_id),
|
|
842
|
+
FOREIGN KEY (superseded_by_event_id) REFERENCES cms_change_events (event_id)
|
|
843
|
+
)
|
|
844
|
+
""")
|
|
845
|
+
self._create_cancellation_schema()
|
|
846
|
+
|
|
847
|
+
def _create_cancellation_schema(self) -> None:
|
|
848
|
+
self.connection.execute("""
|
|
849
|
+
CREATE TABLE IF NOT EXISTS cms_event_cancellations (
|
|
850
|
+
cancellation_id TEXT PRIMARY KEY,
|
|
851
|
+
event_id TEXT NOT NULL UNIQUE,
|
|
852
|
+
cancellation_sha256 TEXT NOT NULL,
|
|
853
|
+
cancellation_json TEXT NOT NULL,
|
|
854
|
+
signature_algorithm TEXT NOT NULL,
|
|
855
|
+
key_id TEXT NOT NULL,
|
|
856
|
+
signature TEXT NOT NULL,
|
|
857
|
+
created_at REAL NOT NULL,
|
|
858
|
+
FOREIGN KEY (event_id) REFERENCES cms_change_events (event_id)
|
|
859
|
+
)
|
|
860
|
+
""")
|
|
861
|
+
|
|
862
|
+
def _create_tombstone_schema(self) -> None:
|
|
863
|
+
self.connection.execute(f"""
|
|
864
|
+
CREATE TABLE IF NOT EXISTS cms_tombstone_deliveries (
|
|
865
|
+
tombstone_id TEXT PRIMARY KEY,
|
|
866
|
+
event_id TEXT NOT NULL UNIQUE,
|
|
867
|
+
tombstone_sha256 TEXT NOT NULL,
|
|
868
|
+
tombstone_json TEXT NOT NULL,
|
|
869
|
+
request_signature_algorithm TEXT NOT NULL,
|
|
870
|
+
request_key_id TEXT NOT NULL,
|
|
871
|
+
request_signature TEXT NOT NULL,
|
|
872
|
+
delivery_id TEXT NOT NULL UNIQUE,
|
|
873
|
+
plan_id TEXT NOT NULL,
|
|
874
|
+
payload_json TEXT NOT NULL,
|
|
875
|
+
payload_sha256 TEXT NOT NULL,
|
|
876
|
+
signature_algorithm TEXT NOT NULL,
|
|
877
|
+
key_id TEXT NOT NULL,
|
|
878
|
+
signature TEXT NOT NULL,
|
|
879
|
+
status TEXT NOT NULL CHECK (
|
|
880
|
+
status IN ('pending', 'leased', 'retry_wait', 'succeeded', 'failed')
|
|
881
|
+
),
|
|
882
|
+
attempts INTEGER NOT NULL CHECK (attempts >= 0),
|
|
883
|
+
max_attempts INTEGER NOT NULL CHECK (
|
|
884
|
+
max_attempts BETWEEN 1 AND {MAX_ATTEMPTS}
|
|
885
|
+
),
|
|
886
|
+
next_attempt_at REAL NOT NULL,
|
|
887
|
+
lease_owner TEXT,
|
|
888
|
+
lease_token TEXT,
|
|
889
|
+
lease_expires_at REAL,
|
|
890
|
+
last_error_code TEXT,
|
|
891
|
+
last_error_detail_hash TEXT,
|
|
892
|
+
created_at REAL NOT NULL,
|
|
893
|
+
updated_at REAL NOT NULL,
|
|
894
|
+
FOREIGN KEY (event_id) REFERENCES cms_change_events (event_id)
|
|
895
|
+
)
|
|
896
|
+
""")
|
|
897
|
+
self.connection.execute("""
|
|
898
|
+
CREATE INDEX IF NOT EXISTS cms_tombstone_ready
|
|
899
|
+
ON cms_tombstone_deliveries (status, next_attempt_at, created_at, delivery_id)
|
|
900
|
+
""")
|
|
901
|
+
|
|
902
|
+
def _create_schema(self) -> None:
|
|
903
|
+
with _transaction(self.connection):
|
|
904
|
+
self.connection.execute("""
|
|
905
|
+
CREATE TABLE cms_change_events (
|
|
906
|
+
event_id TEXT PRIMARY KEY,
|
|
907
|
+
event_sha256 TEXT NOT NULL,
|
|
908
|
+
event_json TEXT NOT NULL,
|
|
909
|
+
signature_algorithm TEXT NOT NULL,
|
|
910
|
+
key_id TEXT NOT NULL,
|
|
911
|
+
signature TEXT NOT NULL,
|
|
912
|
+
plan_id TEXT NOT NULL,
|
|
913
|
+
status TEXT NOT NULL CHECK (status IN ('accepted', 'enqueued')),
|
|
914
|
+
created_at REAL NOT NULL,
|
|
915
|
+
updated_at REAL NOT NULL
|
|
916
|
+
)
|
|
917
|
+
""")
|
|
918
|
+
self.connection.execute(f"""
|
|
919
|
+
CREATE TABLE cms_publication_deliveries (
|
|
920
|
+
delivery_id TEXT PRIMARY KEY,
|
|
921
|
+
event_id TEXT NOT NULL UNIQUE,
|
|
922
|
+
plan_id TEXT NOT NULL,
|
|
923
|
+
payload_json TEXT NOT NULL,
|
|
924
|
+
payload_sha256 TEXT NOT NULL,
|
|
925
|
+
signature_algorithm TEXT NOT NULL,
|
|
926
|
+
key_id TEXT NOT NULL,
|
|
927
|
+
signature TEXT NOT NULL,
|
|
928
|
+
status TEXT NOT NULL CHECK (
|
|
929
|
+
status IN ('pending', 'leased', 'retry_wait', 'succeeded', 'failed')
|
|
930
|
+
),
|
|
931
|
+
attempts INTEGER NOT NULL CHECK (attempts >= 0),
|
|
932
|
+
max_attempts INTEGER NOT NULL CHECK (
|
|
933
|
+
max_attempts BETWEEN 1 AND {MAX_ATTEMPTS}
|
|
934
|
+
),
|
|
935
|
+
next_attempt_at REAL NOT NULL,
|
|
936
|
+
lease_owner TEXT,
|
|
937
|
+
lease_token TEXT,
|
|
938
|
+
lease_expires_at REAL,
|
|
939
|
+
last_error_code TEXT,
|
|
940
|
+
last_error_detail_hash TEXT,
|
|
941
|
+
created_at REAL NOT NULL,
|
|
942
|
+
updated_at REAL NOT NULL,
|
|
943
|
+
FOREIGN KEY (event_id) REFERENCES cms_change_events (event_id)
|
|
944
|
+
)
|
|
945
|
+
""")
|
|
946
|
+
self.connection.execute("""
|
|
947
|
+
CREATE INDEX cms_publication_ready
|
|
948
|
+
ON cms_publication_deliveries (status, next_attempt_at, created_at, delivery_id)
|
|
949
|
+
""")
|
|
950
|
+
self._create_revision_schema()
|
|
951
|
+
self._create_tombstone_schema()
|
|
952
|
+
self.connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
|
953
|
+
|
|
954
|
+
def _migrate_v1(self) -> None:
|
|
955
|
+
try:
|
|
956
|
+
with _transaction(self.connection):
|
|
957
|
+
event_columns = tuple(
|
|
958
|
+
row["name"]
|
|
959
|
+
for row in self.connection.execute("PRAGMA table_info(cms_change_events)")
|
|
960
|
+
)
|
|
961
|
+
delivery_columns = tuple(
|
|
962
|
+
row["name"]
|
|
963
|
+
for row in self.connection.execute(
|
|
964
|
+
"PRAGMA table_info(cms_publication_deliveries)"
|
|
965
|
+
)
|
|
966
|
+
)
|
|
967
|
+
if event_columns != _EVENT_COLUMNS or delivery_columns != _DELIVERY_COLUMNS:
|
|
968
|
+
raise CMSBridgeBlocked("cms.schema.altered")
|
|
969
|
+
self._create_revision_schema()
|
|
970
|
+
generations: dict[tuple[str, str], int] = {}
|
|
971
|
+
last_created_at: dict[tuple[str, str], float] = {}
|
|
972
|
+
rows = self.connection.execute(
|
|
973
|
+
"SELECT * FROM cms_change_events ORDER BY created_at, event_id"
|
|
974
|
+
).fetchall()
|
|
975
|
+
for row in rows:
|
|
976
|
+
if _hash(row["event_json"]) != row["event_sha256"]:
|
|
977
|
+
raise CMSBridgeBlocked("cms.migration.event_invalid")
|
|
978
|
+
try:
|
|
979
|
+
event = json.loads(row["event_json"])
|
|
980
|
+
except json.JSONDecodeError:
|
|
981
|
+
raise CMSBridgeBlocked("cms.migration.event_invalid") from None
|
|
982
|
+
if _canonical_json(event) != row["event_json"]:
|
|
983
|
+
raise CMSBridgeBlocked("cms.migration.event_invalid")
|
|
984
|
+
event, plan = self._validated_event(event, allow_legacy=True)
|
|
985
|
+
if plan.plan_id != row["plan_id"]:
|
|
986
|
+
raise CMSBridgeBlocked("cms.migration.event_invalid")
|
|
987
|
+
topic = (event["site_id"], event["localization"]["source_id"])
|
|
988
|
+
if event.get("schema") == CHANGE_SCHEMA:
|
|
989
|
+
generation = event["source_sequence"]
|
|
990
|
+
else:
|
|
991
|
+
if last_created_at.get(topic) == float(row["created_at"]):
|
|
992
|
+
raise CMSBridgeBlocked("cms.migration.order_ambiguous")
|
|
993
|
+
generation = generations.get(topic, 0) + 1
|
|
994
|
+
generations[topic] = max(generations.get(topic, 0), generation)
|
|
995
|
+
last_created_at[topic] = float(row["created_at"])
|
|
996
|
+
self.connection.execute(
|
|
997
|
+
"INSERT INTO cms_event_topics VALUES (?, ?, ?, ?, ?)",
|
|
998
|
+
(row["event_id"], *topic, generation, row["created_at"]),
|
|
999
|
+
)
|
|
1000
|
+
self.connection.execute("""
|
|
1001
|
+
INSERT INTO cms_event_supersessions
|
|
1002
|
+
SELECT older.event_id, newest.event_id, newest.created_at
|
|
1003
|
+
FROM cms_event_topics AS older
|
|
1004
|
+
JOIN cms_change_events AS older_event
|
|
1005
|
+
ON older_event.event_id = older.event_id
|
|
1006
|
+
JOIN cms_event_topics AS newest
|
|
1007
|
+
ON newest.site_id = older.site_id
|
|
1008
|
+
AND newest.source_id = older.source_id
|
|
1009
|
+
JOIN cms_change_events AS newest_event
|
|
1010
|
+
ON newest_event.event_id = newest.event_id
|
|
1011
|
+
LEFT JOIN cms_publication_deliveries AS delivery
|
|
1012
|
+
ON delivery.event_id = older.event_id
|
|
1013
|
+
AND delivery.status = 'succeeded'
|
|
1014
|
+
WHERE older_event.status = 'enqueued'
|
|
1015
|
+
AND newest_event.status = 'enqueued'
|
|
1016
|
+
AND newest.generation = (
|
|
1017
|
+
SELECT MAX(candidate.generation)
|
|
1018
|
+
FROM cms_event_topics AS candidate
|
|
1019
|
+
JOIN cms_change_events AS candidate_event
|
|
1020
|
+
ON candidate_event.event_id = candidate.event_id
|
|
1021
|
+
WHERE candidate.site_id = older.site_id
|
|
1022
|
+
AND candidate.source_id = older.source_id
|
|
1023
|
+
AND candidate_event.status = 'enqueued'
|
|
1024
|
+
)
|
|
1025
|
+
AND older.generation < newest.generation
|
|
1026
|
+
AND delivery.event_id IS NULL
|
|
1027
|
+
""")
|
|
1028
|
+
self.connection.execute("""
|
|
1029
|
+
UPDATE cms_publication_deliveries
|
|
1030
|
+
SET status = 'failed', lease_owner = NULL, lease_token = NULL,
|
|
1031
|
+
lease_expires_at = NULL, last_error_code = 'event_superseded',
|
|
1032
|
+
last_error_detail_hash = NULL, updated_at = (
|
|
1033
|
+
SELECT created_at FROM cms_event_supersessions
|
|
1034
|
+
WHERE cms_event_supersessions.event_id =
|
|
1035
|
+
cms_publication_deliveries.event_id
|
|
1036
|
+
)
|
|
1037
|
+
WHERE event_id IN (SELECT event_id FROM cms_event_supersessions)
|
|
1038
|
+
AND status <> 'succeeded'
|
|
1039
|
+
""")
|
|
1040
|
+
self._create_tombstone_schema()
|
|
1041
|
+
self.connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
|
1042
|
+
except CMSBridgeBlocked:
|
|
1043
|
+
raise
|
|
1044
|
+
except Exception:
|
|
1045
|
+
raise CMSBridgeBlocked("cms.migration.failed") from None
|
|
1046
|
+
|
|
1047
|
+
def _migrate_v2(self) -> None:
|
|
1048
|
+
try:
|
|
1049
|
+
with _transaction(self.connection):
|
|
1050
|
+
event_columns = tuple(
|
|
1051
|
+
row["name"]
|
|
1052
|
+
for row in self.connection.execute("PRAGMA table_info(cms_change_events)")
|
|
1053
|
+
)
|
|
1054
|
+
delivery_columns = tuple(
|
|
1055
|
+
row["name"] for row in self.connection.execute(
|
|
1056
|
+
"PRAGMA table_info(cms_publication_deliveries)"
|
|
1057
|
+
)
|
|
1058
|
+
)
|
|
1059
|
+
topic_columns = tuple(
|
|
1060
|
+
row["name"]
|
|
1061
|
+
for row in self.connection.execute("PRAGMA table_info(cms_event_topics)")
|
|
1062
|
+
)
|
|
1063
|
+
supersession_columns = tuple(
|
|
1064
|
+
row["name"] for row in self.connection.execute(
|
|
1065
|
+
"PRAGMA table_info(cms_event_supersessions)"
|
|
1066
|
+
)
|
|
1067
|
+
)
|
|
1068
|
+
if (
|
|
1069
|
+
event_columns != _EVENT_COLUMNS
|
|
1070
|
+
or delivery_columns != _DELIVERY_COLUMNS
|
|
1071
|
+
or topic_columns != _EVENT_TOPIC_COLUMNS
|
|
1072
|
+
or supersession_columns != _SUPERSESSION_COLUMNS
|
|
1073
|
+
):
|
|
1074
|
+
raise CMSBridgeBlocked("cms.schema.altered")
|
|
1075
|
+
self._create_cancellation_schema()
|
|
1076
|
+
self._create_tombstone_schema()
|
|
1077
|
+
self.connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
|
1078
|
+
except CMSBridgeBlocked:
|
|
1079
|
+
raise
|
|
1080
|
+
except Exception:
|
|
1081
|
+
raise CMSBridgeBlocked("cms.migration.failed") from None
|
|
1082
|
+
|
|
1083
|
+
def _migrate_v3(self) -> None:
|
|
1084
|
+
try:
|
|
1085
|
+
with _transaction(self.connection):
|
|
1086
|
+
self._create_tombstone_schema()
|
|
1087
|
+
self.connection.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")
|
|
1088
|
+
except CMSBridgeBlocked:
|
|
1089
|
+
raise
|
|
1090
|
+
except Exception:
|
|
1091
|
+
raise CMSBridgeBlocked("cms.migration.failed") from None
|
|
1092
|
+
|
|
1093
|
+
def _verify_schema(self) -> None:
|
|
1094
|
+
event_columns = tuple(
|
|
1095
|
+
row["name"] for row in self.connection.execute("PRAGMA table_info(cms_change_events)")
|
|
1096
|
+
)
|
|
1097
|
+
delivery_columns = tuple(
|
|
1098
|
+
row["name"]
|
|
1099
|
+
for row in self.connection.execute("PRAGMA table_info(cms_publication_deliveries)")
|
|
1100
|
+
)
|
|
1101
|
+
topic_columns = tuple(
|
|
1102
|
+
row["name"] for row in self.connection.execute("PRAGMA table_info(cms_event_topics)")
|
|
1103
|
+
)
|
|
1104
|
+
supersession_columns = tuple(
|
|
1105
|
+
row["name"]
|
|
1106
|
+
for row in self.connection.execute("PRAGMA table_info(cms_event_supersessions)")
|
|
1107
|
+
)
|
|
1108
|
+
cancellation_columns = tuple(
|
|
1109
|
+
row["name"]
|
|
1110
|
+
for row in self.connection.execute("PRAGMA table_info(cms_event_cancellations)")
|
|
1111
|
+
)
|
|
1112
|
+
tombstone_columns = tuple(
|
|
1113
|
+
row["name"]
|
|
1114
|
+
for row in self.connection.execute("PRAGMA table_info(cms_tombstone_deliveries)")
|
|
1115
|
+
)
|
|
1116
|
+
version = int(self.connection.execute("PRAGMA user_version").fetchone()[0])
|
|
1117
|
+
if (
|
|
1118
|
+
version != SCHEMA_VERSION
|
|
1119
|
+
or event_columns != _EVENT_COLUMNS
|
|
1120
|
+
or delivery_columns != _DELIVERY_COLUMNS
|
|
1121
|
+
or topic_columns != _EVENT_TOPIC_COLUMNS
|
|
1122
|
+
or supersession_columns != _SUPERSESSION_COLUMNS
|
|
1123
|
+
or cancellation_columns != _CANCELLATION_COLUMNS
|
|
1124
|
+
or tombstone_columns != _TOMBSTONE_COLUMNS
|
|
1125
|
+
):
|
|
1126
|
+
raise CMSBridgeBlocked("cms.schema.altered")
|
|
1127
|
+
|
|
1128
|
+
def _validated_event(
|
|
1129
|
+
self,
|
|
1130
|
+
event: Any,
|
|
1131
|
+
*,
|
|
1132
|
+
allow_legacy: bool = False,
|
|
1133
|
+
) -> tuple[dict[str, Any], Any]:
|
|
1134
|
+
if not isinstance(event, dict):
|
|
1135
|
+
raise CMSBridgeBlocked("cms.event.invalid")
|
|
1136
|
+
if not isinstance(allow_legacy, bool):
|
|
1137
|
+
raise CMSBridgeBlocked("cms.event.legacy_mode_invalid")
|
|
1138
|
+
expected = {
|
|
1139
|
+
"schema", "event_id", "site_id", "website_version",
|
|
1140
|
+
"source_sequence", "localization",
|
|
1141
|
+
}
|
|
1142
|
+
legacy = event.get("schema") == "blun.cms-content-change.v1"
|
|
1143
|
+
if legacy:
|
|
1144
|
+
expected.remove("source_sequence")
|
|
1145
|
+
if (
|
|
1146
|
+
set(event) != expected
|
|
1147
|
+
or (event.get("schema") != CHANGE_SCHEMA and not (allow_legacy and legacy))
|
|
1148
|
+
):
|
|
1149
|
+
raise CMSBridgeBlocked("cms.event.invalid")
|
|
1150
|
+
_token(event.get("event_id"), "cms.event_id.invalid")
|
|
1151
|
+
_token(event.get("site_id"), "cms.site_id.invalid")
|
|
1152
|
+
_token(event.get("website_version"), "cms.website_version.invalid")
|
|
1153
|
+
if not legacy:
|
|
1154
|
+
_positive_integer(event.get("source_sequence"), "cms.source_sequence.invalid")
|
|
1155
|
+
localization = event.get("localization")
|
|
1156
|
+
try:
|
|
1157
|
+
plan = _PLANNER.plan_from_mapping(localization)
|
|
1158
|
+
except _PLANNER.LocalizationPlanBlocked:
|
|
1159
|
+
raise CMSBridgeBlocked("cms.localization.invalid") from None
|
|
1160
|
+
return event, plan
|
|
1161
|
+
|
|
1162
|
+
def _validated_cancellation(self, cancellation: Any) -> dict[str, Any]:
|
|
1163
|
+
expected = {
|
|
1164
|
+
"schema", "cancellation_id", "event_id", "site_id",
|
|
1165
|
+
"website_version", "source_id", "source_sequence",
|
|
1166
|
+
}
|
|
1167
|
+
if (
|
|
1168
|
+
not isinstance(cancellation, dict)
|
|
1169
|
+
or set(cancellation) != expected
|
|
1170
|
+
or cancellation.get("schema") != CANCELLATION_SCHEMA
|
|
1171
|
+
):
|
|
1172
|
+
raise CMSBridgeBlocked("cms.cancellation.invalid")
|
|
1173
|
+
for field in (
|
|
1174
|
+
"cancellation_id", "event_id", "site_id", "website_version", "source_id",
|
|
1175
|
+
):
|
|
1176
|
+
_token(cancellation.get(field), f"cms.cancellation.{field}_invalid")
|
|
1177
|
+
_positive_integer(
|
|
1178
|
+
cancellation.get("source_sequence"),
|
|
1179
|
+
"cms.cancellation.source_sequence_invalid",
|
|
1180
|
+
)
|
|
1181
|
+
return cancellation
|
|
1182
|
+
|
|
1183
|
+
def _validated_tombstone(self, tombstone: Any) -> dict[str, Any]:
|
|
1184
|
+
expected = {
|
|
1185
|
+
"schema", "tombstone_id", "event_id", "site_id",
|
|
1186
|
+
"website_version", "source_id", "source_sequence",
|
|
1187
|
+
}
|
|
1188
|
+
if (
|
|
1189
|
+
not isinstance(tombstone, dict)
|
|
1190
|
+
or set(tombstone) != expected
|
|
1191
|
+
or tombstone.get("schema") != TOMBSTONE_SCHEMA
|
|
1192
|
+
):
|
|
1193
|
+
raise CMSBridgeBlocked("cms.tombstone.invalid")
|
|
1194
|
+
for field in (
|
|
1195
|
+
"tombstone_id", "event_id", "site_id", "website_version", "source_id",
|
|
1196
|
+
):
|
|
1197
|
+
_token(tombstone.get(field), f"cms.tombstone.{field}_invalid")
|
|
1198
|
+
_positive_integer(
|
|
1199
|
+
tombstone.get("source_sequence"),
|
|
1200
|
+
"cms.tombstone.source_sequence_invalid",
|
|
1201
|
+
)
|
|
1202
|
+
return tombstone
|
|
1203
|
+
|
|
1204
|
+
def _verify_stored_cancellation(
|
|
1205
|
+
self,
|
|
1206
|
+
row: sqlite3.Row,
|
|
1207
|
+
event: dict[str, Any],
|
|
1208
|
+
topic: sqlite3.Row,
|
|
1209
|
+
event_key_id: str,
|
|
1210
|
+
verifier: CMSMessageAuthority,
|
|
1211
|
+
) -> dict[str, Any]:
|
|
1212
|
+
if _hash(row["cancellation_json"]) != row["cancellation_sha256"]:
|
|
1213
|
+
raise CMSBridgeBlocked("cms.cancellation.tampered")
|
|
1214
|
+
try:
|
|
1215
|
+
cancellation = json.loads(row["cancellation_json"])
|
|
1216
|
+
except json.JSONDecodeError:
|
|
1217
|
+
raise CMSBridgeBlocked("cms.cancellation.tampered") from None
|
|
1218
|
+
if _canonical_json(cancellation) != row["cancellation_json"]:
|
|
1219
|
+
raise CMSBridgeBlocked("cms.cancellation.tampered")
|
|
1220
|
+
cancellation = self._validated_cancellation(cancellation)
|
|
1221
|
+
signature = _signature(CMSMessageSignature(
|
|
1222
|
+
row["signature_algorithm"], row["key_id"], row["signature"],
|
|
1223
|
+
))
|
|
1224
|
+
_verify(
|
|
1225
|
+
verifier,
|
|
1226
|
+
row["cancellation_json"].encode("utf-8"),
|
|
1227
|
+
signature,
|
|
1228
|
+
"cms.cancellation.signature_rejected",
|
|
1229
|
+
)
|
|
1230
|
+
if (
|
|
1231
|
+
cancellation["cancellation_id"] != row["cancellation_id"]
|
|
1232
|
+
or cancellation["event_id"] != row["event_id"]
|
|
1233
|
+
or cancellation["event_id"] != event["event_id"]
|
|
1234
|
+
or cancellation["site_id"] != event["site_id"]
|
|
1235
|
+
or cancellation["website_version"] != event["website_version"]
|
|
1236
|
+
or cancellation["source_id"] != event["localization"]["source_id"]
|
|
1237
|
+
or cancellation["source_sequence"] != int(topic["generation"])
|
|
1238
|
+
or row["key_id"] != event_key_id
|
|
1239
|
+
):
|
|
1240
|
+
raise CMSBridgeBlocked("cms.cancellation.binding_invalid")
|
|
1241
|
+
return cancellation
|
|
1242
|
+
|
|
1243
|
+
def cancel_change(
|
|
1244
|
+
self,
|
|
1245
|
+
cancellation: Any,
|
|
1246
|
+
signature: CMSMessageSignature,
|
|
1247
|
+
verifier: CMSMessageAuthority,
|
|
1248
|
+
*,
|
|
1249
|
+
now: float | int,
|
|
1250
|
+
) -> CancelledChange:
|
|
1251
|
+
cancellation = self._validated_cancellation(cancellation)
|
|
1252
|
+
signature = _signature(signature)
|
|
1253
|
+
now = _timestamp(now, "cms.time.invalid")
|
|
1254
|
+
cancellation_json = _canonical_json(cancellation)
|
|
1255
|
+
cancellation_hash = _hash(cancellation_json)
|
|
1256
|
+
_verify(
|
|
1257
|
+
verifier,
|
|
1258
|
+
cancellation_json.encode("utf-8"),
|
|
1259
|
+
signature,
|
|
1260
|
+
"cms.cancellation.signature_rejected",
|
|
1261
|
+
)
|
|
1262
|
+
event, _ = self._load_event(
|
|
1263
|
+
cancellation["event_id"],
|
|
1264
|
+
verifier,
|
|
1265
|
+
allow_superseded=True,
|
|
1266
|
+
allow_cancelled=True,
|
|
1267
|
+
allow_accepted=True,
|
|
1268
|
+
)
|
|
1269
|
+
topic = self.connection.execute(
|
|
1270
|
+
"SELECT * FROM cms_event_topics WHERE event_id = ?",
|
|
1271
|
+
(event["event_id"],),
|
|
1272
|
+
).fetchone()
|
|
1273
|
+
event_row = self.connection.execute(
|
|
1274
|
+
"SELECT key_id FROM cms_change_events WHERE event_id = ?",
|
|
1275
|
+
(event["event_id"],),
|
|
1276
|
+
).fetchone()
|
|
1277
|
+
if topic is None or event_row is None:
|
|
1278
|
+
raise CMSBridgeBlocked("cms.event.topic_invalid")
|
|
1279
|
+
if signature.key_id != event_row["key_id"]:
|
|
1280
|
+
raise CMSBridgeBlocked("cms.cancellation.scope_rejected")
|
|
1281
|
+
expected = {
|
|
1282
|
+
"event_id": event["event_id"],
|
|
1283
|
+
"site_id": event["site_id"],
|
|
1284
|
+
"website_version": event["website_version"],
|
|
1285
|
+
"source_id": event["localization"]["source_id"],
|
|
1286
|
+
"source_sequence": int(topic["generation"]),
|
|
1287
|
+
}
|
|
1288
|
+
if any(cancellation[field] != value for field, value in expected.items()):
|
|
1289
|
+
raise CMSBridgeBlocked("cms.cancellation.binding_invalid")
|
|
1290
|
+
|
|
1291
|
+
with _transaction(self.connection):
|
|
1292
|
+
delivery = self.connection.execute("""
|
|
1293
|
+
SELECT status FROM cms_publication_deliveries WHERE event_id = ?
|
|
1294
|
+
""", (event["event_id"],)).fetchone()
|
|
1295
|
+
if delivery is not None and delivery["status"] == "succeeded":
|
|
1296
|
+
raise CMSBridgeBlocked("cms.cancellation.already_published")
|
|
1297
|
+
if delivery is not None and delivery["status"] == "leased":
|
|
1298
|
+
raise CMSBridgeBlocked("cms.cancellation.delivery_in_flight")
|
|
1299
|
+
by_id = self.connection.execute("""
|
|
1300
|
+
SELECT event_id, cancellation_sha256
|
|
1301
|
+
FROM cms_event_cancellations WHERE cancellation_id = ?
|
|
1302
|
+
""", (cancellation["cancellation_id"],)).fetchone()
|
|
1303
|
+
by_event = self.connection.execute("""
|
|
1304
|
+
SELECT cancellation_id, cancellation_sha256
|
|
1305
|
+
FROM cms_event_cancellations WHERE event_id = ?
|
|
1306
|
+
""", (event["event_id"],)).fetchone()
|
|
1307
|
+
if by_id is not None and (
|
|
1308
|
+
by_id["event_id"] != event["event_id"]
|
|
1309
|
+
or by_id["cancellation_sha256"] != cancellation_hash
|
|
1310
|
+
):
|
|
1311
|
+
raise CMSBridgeBlocked("cms.cancellation.idempotency_collision")
|
|
1312
|
+
if by_event is not None and (
|
|
1313
|
+
by_event["cancellation_id"] != cancellation["cancellation_id"]
|
|
1314
|
+
or by_event["cancellation_sha256"] != cancellation_hash
|
|
1315
|
+
):
|
|
1316
|
+
raise CMSBridgeBlocked("cms.cancellation.idempotency_collision")
|
|
1317
|
+
newly_cancelled = by_id is None and by_event is None
|
|
1318
|
+
if newly_cancelled:
|
|
1319
|
+
self.connection.execute("""
|
|
1320
|
+
INSERT INTO cms_event_cancellations VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1321
|
+
""", (
|
|
1322
|
+
cancellation["cancellation_id"], event["event_id"],
|
|
1323
|
+
cancellation_hash, cancellation_json, signature.algorithm,
|
|
1324
|
+
signature.key_id, signature.signature, now,
|
|
1325
|
+
))
|
|
1326
|
+
self.connection.execute("""
|
|
1327
|
+
UPDATE cms_publication_deliveries
|
|
1328
|
+
SET status = 'failed', lease_owner = NULL, lease_token = NULL,
|
|
1329
|
+
lease_expires_at = NULL, last_error_code = 'event_cancelled',
|
|
1330
|
+
last_error_detail_hash = NULL, updated_at = ?
|
|
1331
|
+
WHERE event_id = ? AND status <> 'succeeded'
|
|
1332
|
+
""", (now, event["event_id"]))
|
|
1333
|
+
return CancelledChange(
|
|
1334
|
+
cancellation["cancellation_id"], event["event_id"], "cancelled",
|
|
1335
|
+
newly_cancelled,
|
|
1336
|
+
)
|
|
1337
|
+
|
|
1338
|
+
def request_tombstone(
|
|
1339
|
+
self,
|
|
1340
|
+
tombstone: Any,
|
|
1341
|
+
signature: CMSMessageSignature,
|
|
1342
|
+
event_verifier: CMSMessageAuthority,
|
|
1343
|
+
publication_authority: CMSMessageAuthority,
|
|
1344
|
+
*,
|
|
1345
|
+
now: float | int,
|
|
1346
|
+
max_attempts: int = 5,
|
|
1347
|
+
) -> TombstoneAccepted:
|
|
1348
|
+
"""Accept and sign deletion only for an exactly confirmed publication."""
|
|
1349
|
+
tombstone = self._validated_tombstone(tombstone)
|
|
1350
|
+
signature = _signature(signature)
|
|
1351
|
+
now = _timestamp(now, "cms.time.invalid")
|
|
1352
|
+
if (
|
|
1353
|
+
isinstance(max_attempts, bool)
|
|
1354
|
+
or not isinstance(max_attempts, int)
|
|
1355
|
+
or not 1 <= max_attempts <= MAX_ATTEMPTS
|
|
1356
|
+
):
|
|
1357
|
+
raise CMSBridgeBlocked("cms.max_attempts.invalid")
|
|
1358
|
+
tombstone_json = _canonical_json(tombstone)
|
|
1359
|
+
tombstone_hash = _hash(tombstone_json)
|
|
1360
|
+
_verify(
|
|
1361
|
+
event_verifier,
|
|
1362
|
+
tombstone_json.encode("utf-8"),
|
|
1363
|
+
signature,
|
|
1364
|
+
"cms.tombstone.signature_rejected",
|
|
1365
|
+
)
|
|
1366
|
+
event, plan = self._load_event(
|
|
1367
|
+
tombstone["event_id"], event_verifier, allow_superseded=True,
|
|
1368
|
+
)
|
|
1369
|
+
topic = self.connection.execute(
|
|
1370
|
+
"SELECT generation FROM cms_event_topics WHERE event_id = ?",
|
|
1371
|
+
(event["event_id"],),
|
|
1372
|
+
).fetchone()
|
|
1373
|
+
event_row = self.connection.execute(
|
|
1374
|
+
"SELECT key_id FROM cms_change_events WHERE event_id = ?",
|
|
1375
|
+
(event["event_id"],),
|
|
1376
|
+
).fetchone()
|
|
1377
|
+
if topic is None or event_row is None:
|
|
1378
|
+
raise CMSBridgeBlocked("cms.event.topic_invalid")
|
|
1379
|
+
if signature.key_id != event_row["key_id"]:
|
|
1380
|
+
raise CMSBridgeBlocked("cms.tombstone.scope_rejected")
|
|
1381
|
+
expected = {
|
|
1382
|
+
"event_id": event["event_id"],
|
|
1383
|
+
"site_id": event["site_id"],
|
|
1384
|
+
"website_version": event["website_version"],
|
|
1385
|
+
"source_id": event["localization"]["source_id"],
|
|
1386
|
+
"source_sequence": int(topic["generation"]),
|
|
1387
|
+
}
|
|
1388
|
+
if any(tombstone[field] != value for field, value in expected.items()):
|
|
1389
|
+
raise CMSBridgeBlocked("cms.tombstone.binding_invalid")
|
|
1390
|
+
if self.connection.execute(
|
|
1391
|
+
"SELECT 1 FROM cms_event_cancellations WHERE event_id = ?",
|
|
1392
|
+
(event["event_id"],),
|
|
1393
|
+
).fetchone() is not None:
|
|
1394
|
+
raise CMSBridgeBlocked("cms.tombstone.not_published")
|
|
1395
|
+
publication_row = self.connection.execute(
|
|
1396
|
+
"SELECT * FROM cms_publication_deliveries WHERE event_id = ?",
|
|
1397
|
+
(event["event_id"],),
|
|
1398
|
+
).fetchone()
|
|
1399
|
+
if publication_row is None or publication_row["status"] != "succeeded":
|
|
1400
|
+
raise CMSBridgeBlocked("cms.tombstone.not_published")
|
|
1401
|
+
publication = self._request_from_row(
|
|
1402
|
+
publication_row, publication_authority, now,
|
|
1403
|
+
require_current_approvals=False,
|
|
1404
|
+
)
|
|
1405
|
+
|
|
1406
|
+
existing = self.connection.execute(
|
|
1407
|
+
"SELECT * FROM cms_tombstone_deliveries WHERE tombstone_id = ? OR event_id = ?",
|
|
1408
|
+
(tombstone["tombstone_id"], event["event_id"]),
|
|
1409
|
+
).fetchone()
|
|
1410
|
+
if existing is not None:
|
|
1411
|
+
if (
|
|
1412
|
+
existing["tombstone_id"] != tombstone["tombstone_id"]
|
|
1413
|
+
or existing["event_id"] != event["event_id"]
|
|
1414
|
+
or existing["tombstone_sha256"] != tombstone_hash
|
|
1415
|
+
or existing["request_key_id"] != signature.key_id
|
|
1416
|
+
):
|
|
1417
|
+
raise CMSBridgeBlocked("cms.tombstone.idempotency_collision")
|
|
1418
|
+
request = self._tombstone_request_from_row(
|
|
1419
|
+
existing, event_verifier, publication_authority,
|
|
1420
|
+
)
|
|
1421
|
+
return TombstoneAccepted(
|
|
1422
|
+
tombstone["tombstone_id"], event["event_id"],
|
|
1423
|
+
request.delivery_id, existing["status"], False,
|
|
1424
|
+
)
|
|
1425
|
+
|
|
1426
|
+
locales = sorted(
|
|
1427
|
+
item["locale"] for item in publication.payload["localizations"]
|
|
1428
|
+
)
|
|
1429
|
+
unsigned = {
|
|
1430
|
+
"schema": TOMBSTONE_DELIVERY_SCHEMA,
|
|
1431
|
+
"tombstone_id": tombstone["tombstone_id"],
|
|
1432
|
+
"event_id": event["event_id"],
|
|
1433
|
+
"site_id": event["site_id"],
|
|
1434
|
+
"website_version": event["website_version"],
|
|
1435
|
+
"plan_id": plan.plan_id,
|
|
1436
|
+
"source_id": event["localization"]["source_id"],
|
|
1437
|
+
"source_sequence": int(topic["generation"]),
|
|
1438
|
+
"publication_delivery_id": publication.delivery_id,
|
|
1439
|
+
"publication_payload_sha256": publication.payload_sha256,
|
|
1440
|
+
"locales": locales,
|
|
1441
|
+
}
|
|
1442
|
+
delivery_id = "blun-cms-tombstone-" + _hash(_canonical_json(unsigned))
|
|
1443
|
+
payload = {**unsigned, "delivery_id": delivery_id}
|
|
1444
|
+
payload_json = _canonical_json(payload)
|
|
1445
|
+
payload_hash = _hash(payload_json)
|
|
1446
|
+
sign = getattr(publication_authority, "sign", None)
|
|
1447
|
+
try:
|
|
1448
|
+
delivery_signature = _signature(
|
|
1449
|
+
sign(payload_json.encode("utf-8")) if callable(sign) else None,
|
|
1450
|
+
)
|
|
1451
|
+
except CMSBridgeBlocked:
|
|
1452
|
+
raise
|
|
1453
|
+
except Exception:
|
|
1454
|
+
raise CMSBridgeBlocked("cms.tombstone.signing_failed") from None
|
|
1455
|
+
_verify(
|
|
1456
|
+
publication_authority,
|
|
1457
|
+
payload_json.encode("utf-8"),
|
|
1458
|
+
delivery_signature,
|
|
1459
|
+
"cms.tombstone.signature_rejected",
|
|
1460
|
+
)
|
|
1461
|
+
with _transaction(self.connection):
|
|
1462
|
+
concurrent = self.connection.execute(
|
|
1463
|
+
"SELECT * FROM cms_tombstone_deliveries WHERE tombstone_id = ? OR event_id = ?",
|
|
1464
|
+
(tombstone["tombstone_id"], event["event_id"]),
|
|
1465
|
+
).fetchone()
|
|
1466
|
+
if concurrent is not None:
|
|
1467
|
+
if (
|
|
1468
|
+
concurrent["tombstone_id"] != tombstone["tombstone_id"]
|
|
1469
|
+
or concurrent["event_id"] != event["event_id"]
|
|
1470
|
+
or concurrent["tombstone_sha256"] != tombstone_hash
|
|
1471
|
+
or concurrent["request_key_id"] != signature.key_id
|
|
1472
|
+
):
|
|
1473
|
+
raise CMSBridgeBlocked("cms.tombstone.idempotency_collision")
|
|
1474
|
+
request = self._tombstone_request_from_row(
|
|
1475
|
+
concurrent, event_verifier, publication_authority,
|
|
1476
|
+
)
|
|
1477
|
+
return TombstoneAccepted(
|
|
1478
|
+
tombstone["tombstone_id"], event["event_id"],
|
|
1479
|
+
request.delivery_id, concurrent["status"], False,
|
|
1480
|
+
)
|
|
1481
|
+
live_publication = self.connection.execute(
|
|
1482
|
+
"SELECT status, payload_sha256 FROM cms_publication_deliveries WHERE event_id = ?",
|
|
1483
|
+
(event["event_id"],),
|
|
1484
|
+
).fetchone()
|
|
1485
|
+
if (
|
|
1486
|
+
live_publication is None
|
|
1487
|
+
or live_publication["status"] != "succeeded"
|
|
1488
|
+
or live_publication["payload_sha256"] != publication.payload_sha256
|
|
1489
|
+
):
|
|
1490
|
+
raise CMSBridgeBlocked("cms.tombstone.not_published")
|
|
1491
|
+
self.connection.execute("""
|
|
1492
|
+
INSERT INTO cms_tombstone_deliveries (
|
|
1493
|
+
tombstone_id, event_id, tombstone_sha256, tombstone_json,
|
|
1494
|
+
request_signature_algorithm, request_key_id, request_signature,
|
|
1495
|
+
delivery_id, plan_id, payload_json, payload_sha256,
|
|
1496
|
+
signature_algorithm, key_id, signature, status, attempts,
|
|
1497
|
+
max_attempts, next_attempt_at, created_at, updated_at
|
|
1498
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
1499
|
+
'pending', 0, ?, ?, ?, ?)
|
|
1500
|
+
""", (
|
|
1501
|
+
tombstone["tombstone_id"], event["event_id"], tombstone_hash,
|
|
1502
|
+
tombstone_json, signature.algorithm, signature.key_id,
|
|
1503
|
+
signature.signature, delivery_id, plan.plan_id, payload_json,
|
|
1504
|
+
payload_hash, delivery_signature.algorithm,
|
|
1505
|
+
delivery_signature.key_id, delivery_signature.signature,
|
|
1506
|
+
max_attempts, now, now, now,
|
|
1507
|
+
))
|
|
1508
|
+
return TombstoneAccepted(
|
|
1509
|
+
tombstone["tombstone_id"], event["event_id"], delivery_id,
|
|
1510
|
+
"pending", True,
|
|
1511
|
+
)
|
|
1512
|
+
|
|
1513
|
+
def ingest_change(
|
|
1514
|
+
self,
|
|
1515
|
+
event: Any,
|
|
1516
|
+
signature: CMSMessageSignature,
|
|
1517
|
+
verifier: CMSMessageAuthority,
|
|
1518
|
+
*,
|
|
1519
|
+
max_attempts: int = 3,
|
|
1520
|
+
now: float | int,
|
|
1521
|
+
) -> IngestedChange:
|
|
1522
|
+
event, plan = self._validated_event(event, allow_legacy=True)
|
|
1523
|
+
signature = _signature(signature)
|
|
1524
|
+
now = _timestamp(now, "cms.time.invalid")
|
|
1525
|
+
event_json = _canonical_json(event)
|
|
1526
|
+
event_hash = _hash(event_json)
|
|
1527
|
+
_verify(verifier, event_json.encode("utf-8"), signature, "cms.event.signature_rejected")
|
|
1528
|
+
event_id = event["event_id"]
|
|
1529
|
+
site_id = event["site_id"]
|
|
1530
|
+
source_id = event["localization"]["source_id"]
|
|
1531
|
+
|
|
1532
|
+
with _transaction(self.connection):
|
|
1533
|
+
row = self.connection.execute(
|
|
1534
|
+
"SELECT event_sha256, plan_id FROM cms_change_events WHERE event_id = ?",
|
|
1535
|
+
(event_id,),
|
|
1536
|
+
).fetchone()
|
|
1537
|
+
if row is None:
|
|
1538
|
+
if event.get("schema") != CHANGE_SCHEMA:
|
|
1539
|
+
raise CMSBridgeBlocked("cms.event.legacy_replay_only")
|
|
1540
|
+
generation = event["source_sequence"]
|
|
1541
|
+
collision = self.connection.execute("""
|
|
1542
|
+
SELECT 1 FROM cms_event_topics
|
|
1543
|
+
WHERE site_id = ? AND source_id = ? AND generation = ?
|
|
1544
|
+
""", (site_id, source_id, generation)).fetchone()
|
|
1545
|
+
if collision is not None:
|
|
1546
|
+
raise CMSBridgeBlocked("cms.event.sequence_collision")
|
|
1547
|
+
self.connection.execute("""
|
|
1548
|
+
INSERT INTO cms_change_events VALUES (?, ?, ?, ?, ?, ?, ?, 'accepted', ?, ?)
|
|
1549
|
+
""", (
|
|
1550
|
+
event_id, event_hash, event_json, signature.algorithm, signature.key_id,
|
|
1551
|
+
signature.signature, plan.plan_id, now, now,
|
|
1552
|
+
))
|
|
1553
|
+
self.connection.execute(
|
|
1554
|
+
"INSERT INTO cms_event_topics VALUES (?, ?, ?, ?, ?)",
|
|
1555
|
+
(event_id, site_id, source_id, generation, now),
|
|
1556
|
+
)
|
|
1557
|
+
elif row["event_sha256"] != event_hash or row["plan_id"] != plan.plan_id:
|
|
1558
|
+
raise CMSBridgeBlocked("cms.event.idempotency_collision")
|
|
1559
|
+
else:
|
|
1560
|
+
topic = self.connection.execute(
|
|
1561
|
+
"SELECT site_id, source_id FROM cms_event_topics WHERE event_id = ?",
|
|
1562
|
+
(event_id,),
|
|
1563
|
+
).fetchone()
|
|
1564
|
+
if topic is None or (topic["site_id"], topic["source_id"]) != (site_id, source_id):
|
|
1565
|
+
raise CMSBridgeBlocked("cms.event.topic_invalid")
|
|
1566
|
+
|
|
1567
|
+
cancelled = self.connection.execute(
|
|
1568
|
+
"SELECT 1 FROM cms_event_cancellations WHERE event_id = ?",
|
|
1569
|
+
(event_id,),
|
|
1570
|
+
).fetchone() is not None
|
|
1571
|
+
|
|
1572
|
+
if cancelled:
|
|
1573
|
+
self._load_event(
|
|
1574
|
+
event_id,
|
|
1575
|
+
verifier,
|
|
1576
|
+
allow_superseded=True,
|
|
1577
|
+
allow_cancelled=True,
|
|
1578
|
+
allow_accepted=True,
|
|
1579
|
+
)
|
|
1580
|
+
return IngestedChange(event_id, plan.plan_id, len(plan.jobs), 0, "cancelled")
|
|
1581
|
+
|
|
1582
|
+
try:
|
|
1583
|
+
inserted = self.queue.enqueue_plan(plan, max_attempts=max_attempts, now=now)
|
|
1584
|
+
except _QUEUE.LocalizationQueueBlocked:
|
|
1585
|
+
raise CMSBridgeBlocked("cms.queue.rejected") from None
|
|
1586
|
+
with _transaction(self.connection):
|
|
1587
|
+
cancelled = self.connection.execute(
|
|
1588
|
+
"SELECT 1 FROM cms_event_cancellations WHERE event_id = ?",
|
|
1589
|
+
(event_id,),
|
|
1590
|
+
).fetchone() is not None
|
|
1591
|
+
if cancelled:
|
|
1592
|
+
self._load_event(
|
|
1593
|
+
event_id,
|
|
1594
|
+
verifier,
|
|
1595
|
+
allow_superseded=True,
|
|
1596
|
+
allow_cancelled=True,
|
|
1597
|
+
allow_accepted=True,
|
|
1598
|
+
)
|
|
1599
|
+
else:
|
|
1600
|
+
updated = self.connection.execute("""
|
|
1601
|
+
UPDATE cms_change_events SET status = 'enqueued', updated_at = ?
|
|
1602
|
+
WHERE event_id = ? AND event_sha256 = ? AND plan_id = ?
|
|
1603
|
+
""", (now, event_id, event_hash, plan.plan_id))
|
|
1604
|
+
if updated.rowcount != 1:
|
|
1605
|
+
raise CMSBridgeBlocked("cms.event.identity_lost")
|
|
1606
|
+
newest = self.connection.execute("""
|
|
1607
|
+
SELECT topic.event_id, topic.generation
|
|
1608
|
+
FROM cms_event_topics AS topic
|
|
1609
|
+
JOIN cms_change_events AS event ON event.event_id = topic.event_id
|
|
1610
|
+
WHERE topic.site_id = ? AND topic.source_id = ?
|
|
1611
|
+
AND event.status = 'enqueued'
|
|
1612
|
+
ORDER BY topic.generation DESC LIMIT 1
|
|
1613
|
+
""", (site_id, source_id)).fetchone()
|
|
1614
|
+
if newest is None:
|
|
1615
|
+
raise CMSBridgeBlocked("cms.event.topic_invalid")
|
|
1616
|
+
older = self.connection.execute("""
|
|
1617
|
+
SELECT older.event_id
|
|
1618
|
+
FROM cms_event_topics AS older
|
|
1619
|
+
JOIN cms_change_events AS events ON events.event_id = older.event_id
|
|
1620
|
+
LEFT JOIN cms_publication_deliveries AS delivery
|
|
1621
|
+
ON delivery.event_id = older.event_id AND delivery.status = 'succeeded'
|
|
1622
|
+
WHERE older.site_id = ? AND older.source_id = ?
|
|
1623
|
+
AND older.generation < ? AND events.status = 'enqueued'
|
|
1624
|
+
AND delivery.event_id IS NULL
|
|
1625
|
+
ORDER BY older.generation, older.event_id
|
|
1626
|
+
""", (site_id, source_id, newest["generation"])).fetchall()
|
|
1627
|
+
for prior in older:
|
|
1628
|
+
self.connection.execute("""
|
|
1629
|
+
INSERT OR IGNORE INTO cms_event_supersessions
|
|
1630
|
+
VALUES (?, ?, ?)
|
|
1631
|
+
""", (prior["event_id"], newest["event_id"], now))
|
|
1632
|
+
self.connection.execute("""
|
|
1633
|
+
UPDATE cms_publication_deliveries
|
|
1634
|
+
SET status = 'failed', lease_owner = NULL, lease_token = NULL,
|
|
1635
|
+
lease_expires_at = NULL, last_error_code = 'event_superseded',
|
|
1636
|
+
last_error_detail_hash = NULL, updated_at = ?
|
|
1637
|
+
WHERE event_id IN (
|
|
1638
|
+
SELECT supersession.event_id FROM cms_event_supersessions AS supersession
|
|
1639
|
+
JOIN cms_event_topics AS topic ON topic.event_id = supersession.event_id
|
|
1640
|
+
WHERE topic.site_id = ? AND topic.source_id = ?
|
|
1641
|
+
) AND status <> 'succeeded'
|
|
1642
|
+
""", (now, site_id, source_id))
|
|
1643
|
+
superseded = self.connection.execute(
|
|
1644
|
+
"SELECT 1 FROM cms_event_supersessions WHERE event_id = ?",
|
|
1645
|
+
(event_id,),
|
|
1646
|
+
).fetchone() is not None
|
|
1647
|
+
if cancelled:
|
|
1648
|
+
return IngestedChange(
|
|
1649
|
+
event_id, plan.plan_id, len(plan.jobs), inserted, "cancelled",
|
|
1650
|
+
)
|
|
1651
|
+
return IngestedChange(
|
|
1652
|
+
event_id, plan.plan_id, len(plan.jobs), inserted,
|
|
1653
|
+
"superseded" if superseded else "enqueued",
|
|
1654
|
+
)
|
|
1655
|
+
|
|
1656
|
+
def resume_accepted_change(
|
|
1657
|
+
self,
|
|
1658
|
+
event_id: Any,
|
|
1659
|
+
verifier: CMSMessageAuthority,
|
|
1660
|
+
*,
|
|
1661
|
+
max_attempts: int = 3,
|
|
1662
|
+
now: float | int,
|
|
1663
|
+
) -> IngestedChange | None:
|
|
1664
|
+
"""Resume one signed event persisted before its queue transaction."""
|
|
1665
|
+
event_id = _token(event_id, "cms.event_id.invalid")
|
|
1666
|
+
row = self.connection.execute(
|
|
1667
|
+
"SELECT * FROM cms_change_events WHERE event_id = ?",
|
|
1668
|
+
(event_id,),
|
|
1669
|
+
).fetchone()
|
|
1670
|
+
if row is None:
|
|
1671
|
+
raise CMSBridgeBlocked("cms.event.not_enqueued")
|
|
1672
|
+
if row["status"] != "accepted":
|
|
1673
|
+
return None
|
|
1674
|
+
|
|
1675
|
+
event, _ = self._load_event(
|
|
1676
|
+
event_id,
|
|
1677
|
+
verifier,
|
|
1678
|
+
allow_cancelled=True,
|
|
1679
|
+
allow_accepted=True,
|
|
1680
|
+
)
|
|
1681
|
+
signature = _signature(CMSMessageSignature(
|
|
1682
|
+
row["signature_algorithm"], row["key_id"], row["signature"],
|
|
1683
|
+
))
|
|
1684
|
+
return self.ingest_change(
|
|
1685
|
+
event,
|
|
1686
|
+
signature,
|
|
1687
|
+
verifier,
|
|
1688
|
+
max_attempts=max_attempts,
|
|
1689
|
+
now=now,
|
|
1690
|
+
)
|
|
1691
|
+
|
|
1692
|
+
def change_progress(
|
|
1693
|
+
self,
|
|
1694
|
+
event_id: Any,
|
|
1695
|
+
event_verifier: CMSMessageAuthority,
|
|
1696
|
+
*,
|
|
1697
|
+
site_id: Any,
|
|
1698
|
+
requester_key_id: Any,
|
|
1699
|
+
now: float | int,
|
|
1700
|
+
) -> ChangeProgress:
|
|
1701
|
+
"""Return content-free progress only to the event's exact site credential."""
|
|
1702
|
+
event_id = _token(event_id, "cms.event_id.invalid")
|
|
1703
|
+
site_id = _token(site_id, "cms.site_id.invalid")
|
|
1704
|
+
requester_key_id = _token(
|
|
1705
|
+
requester_key_id, "cms.status.requester_key_id.invalid",
|
|
1706
|
+
)
|
|
1707
|
+
now = _timestamp(now, "cms.time.invalid")
|
|
1708
|
+
topic = self.connection.execute(
|
|
1709
|
+
"SELECT site_id, generation FROM cms_event_topics WHERE event_id = ?",
|
|
1710
|
+
(event_id,),
|
|
1711
|
+
).fetchone()
|
|
1712
|
+
event_row = self.connection.execute(
|
|
1713
|
+
"SELECT key_id, status FROM cms_change_events WHERE event_id = ?",
|
|
1714
|
+
(event_id,),
|
|
1715
|
+
).fetchone()
|
|
1716
|
+
if (
|
|
1717
|
+
topic is None
|
|
1718
|
+
or event_row is None
|
|
1719
|
+
or topic["site_id"] != site_id
|
|
1720
|
+
or event_row["key_id"] != requester_key_id
|
|
1721
|
+
):
|
|
1722
|
+
raise CMSBridgeBlocked("cms.status.scope_rejected")
|
|
1723
|
+
|
|
1724
|
+
cancelled = self.connection.execute(
|
|
1725
|
+
"SELECT 1 FROM cms_event_cancellations WHERE event_id = ?",
|
|
1726
|
+
(event_id,),
|
|
1727
|
+
).fetchone() is not None
|
|
1728
|
+
queue_recovery_pending = (
|
|
1729
|
+
event_row["status"] == "accepted" and not cancelled
|
|
1730
|
+
)
|
|
1731
|
+
event, plan = self._load_event(
|
|
1732
|
+
event_id,
|
|
1733
|
+
event_verifier,
|
|
1734
|
+
allow_cancelled=cancelled,
|
|
1735
|
+
allow_accepted=cancelled or queue_recovery_pending,
|
|
1736
|
+
)
|
|
1737
|
+
if event["site_id"] != site_id:
|
|
1738
|
+
raise CMSBridgeBlocked("cms.status.scope_rejected")
|
|
1739
|
+
if cancelled and event_row["status"] == "accepted":
|
|
1740
|
+
counts = {status: 0 for status in _QUEUE._STATUSES}
|
|
1741
|
+
counts["cancelled"] = len(plan.jobs)
|
|
1742
|
+
locales = tuple(LocaleProgress(
|
|
1743
|
+
job_id=job.job_id,
|
|
1744
|
+
target_locale=job.target.locale,
|
|
1745
|
+
status="cancelled",
|
|
1746
|
+
attempts=0,
|
|
1747
|
+
max_attempts=0,
|
|
1748
|
+
next_attempt_at=0.0,
|
|
1749
|
+
lease_expires_at=None,
|
|
1750
|
+
lease_expired=False,
|
|
1751
|
+
last_error_code="event_cancelled",
|
|
1752
|
+
last_error_detail_hash=None,
|
|
1753
|
+
result_sha256=None,
|
|
1754
|
+
) for job in sorted(plan.jobs, key=lambda item: item.target.locale))
|
|
1755
|
+
return ChangeProgress(
|
|
1756
|
+
event_id=event["event_id"],
|
|
1757
|
+
site_id=event["site_id"],
|
|
1758
|
+
plan_id=plan.plan_id,
|
|
1759
|
+
website_version=event["website_version"],
|
|
1760
|
+
source_sequence=int(topic["generation"]),
|
|
1761
|
+
job_count=len(plan.jobs),
|
|
1762
|
+
counts=counts,
|
|
1763
|
+
locales=locales,
|
|
1764
|
+
cancelled=True,
|
|
1765
|
+
queue_recovery_pending=False,
|
|
1766
|
+
)
|
|
1767
|
+
if queue_recovery_pending:
|
|
1768
|
+
counts = {status: 0 for status in _QUEUE._STATUSES}
|
|
1769
|
+
locales = tuple(LocaleProgress(
|
|
1770
|
+
job_id=job.job_id,
|
|
1771
|
+
target_locale=job.target.locale,
|
|
1772
|
+
status="awaiting_queue_resume",
|
|
1773
|
+
attempts=0,
|
|
1774
|
+
max_attempts=0,
|
|
1775
|
+
next_attempt_at=0.0,
|
|
1776
|
+
lease_expires_at=None,
|
|
1777
|
+
lease_expired=False,
|
|
1778
|
+
last_error_code="cms.event.awaiting_queue_resume",
|
|
1779
|
+
last_error_detail_hash=None,
|
|
1780
|
+
result_sha256=None,
|
|
1781
|
+
) for job in sorted(plan.jobs, key=lambda item: item.target.locale))
|
|
1782
|
+
return ChangeProgress(
|
|
1783
|
+
event_id=event["event_id"],
|
|
1784
|
+
site_id=event["site_id"],
|
|
1785
|
+
plan_id=plan.plan_id,
|
|
1786
|
+
website_version=event["website_version"],
|
|
1787
|
+
source_sequence=int(topic["generation"]),
|
|
1788
|
+
job_count=len(plan.jobs),
|
|
1789
|
+
counts=counts,
|
|
1790
|
+
locales=locales,
|
|
1791
|
+
cancelled=False,
|
|
1792
|
+
queue_recovery_pending=True,
|
|
1793
|
+
)
|
|
1794
|
+
locales = []
|
|
1795
|
+
try:
|
|
1796
|
+
for job in plan.jobs:
|
|
1797
|
+
status = self.queue.status(job.job_id)
|
|
1798
|
+
if (
|
|
1799
|
+
status.target_locale != job.target.locale
|
|
1800
|
+
or plan.plan_id not in status.plan_ids
|
|
1801
|
+
):
|
|
1802
|
+
raise CMSBridgeBlocked("cms.queue.identity_lost")
|
|
1803
|
+
if status.status == "succeeded":
|
|
1804
|
+
self.queue.result(job.job_id)
|
|
1805
|
+
locales.append(LocaleProgress(
|
|
1806
|
+
job_id=status.job_id,
|
|
1807
|
+
target_locale=status.target_locale,
|
|
1808
|
+
status=status.status,
|
|
1809
|
+
attempts=status.attempts,
|
|
1810
|
+
max_attempts=status.max_attempts,
|
|
1811
|
+
next_attempt_at=status.next_attempt_at,
|
|
1812
|
+
lease_expires_at=status.lease_expires_at,
|
|
1813
|
+
lease_expired=(
|
|
1814
|
+
status.status == "leased"
|
|
1815
|
+
and status.lease_expires_at is not None
|
|
1816
|
+
and status.lease_expires_at <= now
|
|
1817
|
+
),
|
|
1818
|
+
last_error_code=status.last_error_code,
|
|
1819
|
+
last_error_detail_hash=status.last_error_detail_hash,
|
|
1820
|
+
result_sha256=status.result_sha256,
|
|
1821
|
+
))
|
|
1822
|
+
counts = self.queue.plan_counts(plan.plan_id)
|
|
1823
|
+
except _QUEUE.LocalizationQueueBlocked:
|
|
1824
|
+
raise CMSBridgeBlocked("cms.queue.integrity_failed") from None
|
|
1825
|
+
if sum(counts.values()) != len(plan.jobs) or len(locales) != len(plan.jobs):
|
|
1826
|
+
raise CMSBridgeBlocked("cms.queue.identity_lost")
|
|
1827
|
+
return ChangeProgress(
|
|
1828
|
+
event_id=event["event_id"],
|
|
1829
|
+
site_id=event["site_id"],
|
|
1830
|
+
plan_id=plan.plan_id,
|
|
1831
|
+
website_version=event["website_version"],
|
|
1832
|
+
source_sequence=int(topic["generation"]),
|
|
1833
|
+
job_count=len(plan.jobs),
|
|
1834
|
+
counts=counts,
|
|
1835
|
+
locales=tuple(sorted(locales, key=lambda item: item.target_locale)),
|
|
1836
|
+
cancelled=cancelled,
|
|
1837
|
+
queue_recovery_pending=False,
|
|
1838
|
+
)
|
|
1839
|
+
|
|
1840
|
+
def change_lifecycle(
|
|
1841
|
+
self,
|
|
1842
|
+
event_id: Any,
|
|
1843
|
+
event_verifier: CMSMessageAuthority,
|
|
1844
|
+
approval_authority: Any,
|
|
1845
|
+
publication_authority: CMSMessageAuthority,
|
|
1846
|
+
*,
|
|
1847
|
+
site_id: Any,
|
|
1848
|
+
requester_key_id: Any,
|
|
1849
|
+
now: float | int,
|
|
1850
|
+
) -> ChangeLifecycle:
|
|
1851
|
+
"""Return the verified end-to-end state for one tenant-scoped event."""
|
|
1852
|
+
now = _timestamp(now, "cms.time.invalid")
|
|
1853
|
+
progress = self.change_progress(
|
|
1854
|
+
event_id,
|
|
1855
|
+
event_verifier,
|
|
1856
|
+
site_id=site_id,
|
|
1857
|
+
requester_key_id=requester_key_id,
|
|
1858
|
+
now=now,
|
|
1859
|
+
)
|
|
1860
|
+
event, plan = self._load_event(
|
|
1861
|
+
progress.event_id,
|
|
1862
|
+
event_verifier,
|
|
1863
|
+
allow_cancelled=progress.cancelled,
|
|
1864
|
+
allow_accepted=(
|
|
1865
|
+
progress.cancelled or progress.queue_recovery_pending
|
|
1866
|
+
),
|
|
1867
|
+
)
|
|
1868
|
+
if progress.queue_recovery_pending:
|
|
1869
|
+
required_locales = tuple(sorted(
|
|
1870
|
+
job.target.locale for job in plan.jobs
|
|
1871
|
+
))
|
|
1872
|
+
return ChangeLifecycle(
|
|
1873
|
+
event_id=progress.event_id,
|
|
1874
|
+
site_id=progress.site_id,
|
|
1875
|
+
plan_id=progress.plan_id,
|
|
1876
|
+
website_version=progress.website_version,
|
|
1877
|
+
source_sequence=progress.source_sequence,
|
|
1878
|
+
status="queue_recovery",
|
|
1879
|
+
required_locales=required_locales,
|
|
1880
|
+
approved_locales=(),
|
|
1881
|
+
blocked_locales=tuple(
|
|
1882
|
+
(locale, "queue.awaiting_resume")
|
|
1883
|
+
for locale in required_locales
|
|
1884
|
+
),
|
|
1885
|
+
queue_counts=progress.counts,
|
|
1886
|
+
delivery=None,
|
|
1887
|
+
tombstone=None,
|
|
1888
|
+
)
|
|
1889
|
+
try:
|
|
1890
|
+
readiness = self.release_store.readiness(
|
|
1891
|
+
plan, approval_authority, now=now,
|
|
1892
|
+
)
|
|
1893
|
+
except _RELEASE.LocalizationReleaseBlocked:
|
|
1894
|
+
raise CMSBridgeBlocked("cms.release.integrity_failed") from None
|
|
1895
|
+
expected_release_blocks = {"approval.missing", "approval.expired"}
|
|
1896
|
+
if any(
|
|
1897
|
+
code not in expected_release_blocks
|
|
1898
|
+
for _, code in readiness.blocked
|
|
1899
|
+
):
|
|
1900
|
+
raise CMSBridgeBlocked("cms.release.integrity_failed")
|
|
1901
|
+
|
|
1902
|
+
row = self.connection.execute(
|
|
1903
|
+
"SELECT * FROM cms_publication_deliveries WHERE event_id = ?",
|
|
1904
|
+
(progress.event_id,),
|
|
1905
|
+
).fetchone()
|
|
1906
|
+
delivery = None
|
|
1907
|
+
if row is not None:
|
|
1908
|
+
request = self._request_from_row(
|
|
1909
|
+
row, publication_authority, now, require_current_approvals=False,
|
|
1910
|
+
)
|
|
1911
|
+
payload = request.payload
|
|
1912
|
+
expected_payload_keys = {
|
|
1913
|
+
"schema", "delivery_id", "event_id", "site_id",
|
|
1914
|
+
"website_version", "plan_id", "source_id", "source_revision",
|
|
1915
|
+
"source_sequence", "source_sha256", "localizations",
|
|
1916
|
+
}
|
|
1917
|
+
expected_localization_keys = {
|
|
1918
|
+
"locale", "target_text", "target_sha256", "approval_id",
|
|
1919
|
+
"approval_expires_at", "release_evidence",
|
|
1920
|
+
}
|
|
1921
|
+
localizations = payload.get("localizations")
|
|
1922
|
+
if (
|
|
1923
|
+
set(payload) != expected_payload_keys
|
|
1924
|
+
or payload.get("schema") != PUBLICATION_SCHEMA
|
|
1925
|
+
or payload.get("delivery_id") != row["delivery_id"]
|
|
1926
|
+
or payload.get("event_id") != event["event_id"]
|
|
1927
|
+
or payload.get("site_id") != event["site_id"]
|
|
1928
|
+
or payload.get("website_version") != event["website_version"]
|
|
1929
|
+
or payload.get("plan_id") != plan.plan_id
|
|
1930
|
+
or payload.get("source_id") != event["localization"]["source_id"]
|
|
1931
|
+
or payload.get("source_revision")
|
|
1932
|
+
!= event["localization"]["source_revision"]
|
|
1933
|
+
or payload.get("source_sequence") != progress.source_sequence
|
|
1934
|
+
or payload.get("source_sha256") != plan.source_hash
|
|
1935
|
+
or not isinstance(localizations, list)
|
|
1936
|
+
or any(
|
|
1937
|
+
not isinstance(item, dict)
|
|
1938
|
+
or set(item) != expected_localization_keys
|
|
1939
|
+
for item in localizations
|
|
1940
|
+
)
|
|
1941
|
+
or tuple(sorted(item["locale"] for item in localizations))
|
|
1942
|
+
!= readiness.required_locales
|
|
1943
|
+
or any(
|
|
1944
|
+
_token(item["locale"], "cms.delivery.tampered")
|
|
1945
|
+
!= item["locale"]
|
|
1946
|
+
or not isinstance(item["target_text"], str)
|
|
1947
|
+
or not item["target_text"]
|
|
1948
|
+
or not unicodedata.is_normalized("NFC", item["target_text"])
|
|
1949
|
+
or SHA256.fullmatch(str(item["target_sha256"])) is None
|
|
1950
|
+
or _token(item["approval_id"], "cms.delivery.tampered")
|
|
1951
|
+
!= item["approval_id"]
|
|
1952
|
+
or _timestamp(
|
|
1953
|
+
item["approval_expires_at"], "cms.delivery.tampered",
|
|
1954
|
+
) <= 0
|
|
1955
|
+
or not _valid_release_evidence(
|
|
1956
|
+
item["release_evidence"],
|
|
1957
|
+
locale=item["locale"],
|
|
1958
|
+
target_sha256=item["target_sha256"],
|
|
1959
|
+
approval_id=item["approval_id"],
|
|
1960
|
+
)
|
|
1961
|
+
for item in localizations
|
|
1962
|
+
)
|
|
1963
|
+
):
|
|
1964
|
+
raise CMSBridgeBlocked("cms.delivery.tampered")
|
|
1965
|
+
delivery_status = self.delivery_status(request.delivery_id)
|
|
1966
|
+
try:
|
|
1967
|
+
next_attempt_at = _timestamp(
|
|
1968
|
+
delivery_status.next_attempt_at, "cms.delivery.tampered",
|
|
1969
|
+
)
|
|
1970
|
+
lease_expires_at = (
|
|
1971
|
+
None
|
|
1972
|
+
if delivery_status.lease_expires_at is None
|
|
1973
|
+
else _timestamp(
|
|
1974
|
+
delivery_status.lease_expires_at,
|
|
1975
|
+
"cms.delivery.tampered",
|
|
1976
|
+
)
|
|
1977
|
+
)
|
|
1978
|
+
except CMSBridgeBlocked:
|
|
1979
|
+
raise
|
|
1980
|
+
if (
|
|
1981
|
+
delivery_status.event_id != progress.event_id
|
|
1982
|
+
or delivery_status.plan_id != progress.plan_id
|
|
1983
|
+
or delivery_status.payload_sha256 != request.payload_sha256
|
|
1984
|
+
or delivery_status.status
|
|
1985
|
+
not in {"pending", "leased", "retry_wait", "failed", "succeeded"}
|
|
1986
|
+
or isinstance(delivery_status.attempts, bool)
|
|
1987
|
+
or not isinstance(delivery_status.attempts, int)
|
|
1988
|
+
or isinstance(delivery_status.max_attempts, bool)
|
|
1989
|
+
or not isinstance(delivery_status.max_attempts, int)
|
|
1990
|
+
or not 0 <= delivery_status.attempts <= delivery_status.max_attempts
|
|
1991
|
+
or not 1 <= delivery_status.max_attempts <= MAX_ATTEMPTS
|
|
1992
|
+
or (
|
|
1993
|
+
delivery_status.last_error_code is not None
|
|
1994
|
+
and (
|
|
1995
|
+
not isinstance(delivery_status.last_error_code, str)
|
|
1996
|
+
or ERROR_CODE.fullmatch(delivery_status.last_error_code) is None
|
|
1997
|
+
)
|
|
1998
|
+
)
|
|
1999
|
+
or (
|
|
2000
|
+
delivery_status.last_error_detail_hash is not None
|
|
2001
|
+
and SHA256.fullmatch(
|
|
2002
|
+
str(delivery_status.last_error_detail_hash),
|
|
2003
|
+
) is None
|
|
2004
|
+
)
|
|
2005
|
+
or (delivery_status.status == "leased") != (lease_expires_at is not None)
|
|
2006
|
+
or (
|
|
2007
|
+
delivery_status.status in {"pending", "leased", "succeeded"}
|
|
2008
|
+
and delivery_status.last_error_code is not None
|
|
2009
|
+
)
|
|
2010
|
+
or (
|
|
2011
|
+
delivery_status.status in {"retry_wait", "failed"}
|
|
2012
|
+
and delivery_status.last_error_code is None
|
|
2013
|
+
)
|
|
2014
|
+
):
|
|
2015
|
+
raise CMSBridgeBlocked("cms.delivery.tampered")
|
|
2016
|
+
delivery = {
|
|
2017
|
+
"delivery_id": delivery_status.delivery_id,
|
|
2018
|
+
"status": delivery_status.status,
|
|
2019
|
+
"attempts": delivery_status.attempts,
|
|
2020
|
+
"max_attempts": delivery_status.max_attempts,
|
|
2021
|
+
"next_attempt_at": next_attempt_at,
|
|
2022
|
+
"lease_expires_at": lease_expires_at,
|
|
2023
|
+
"lease_expired": (
|
|
2024
|
+
delivery_status.status == "leased"
|
|
2025
|
+
and lease_expires_at is not None
|
|
2026
|
+
and lease_expires_at <= now
|
|
2027
|
+
),
|
|
2028
|
+
"last_error_code": delivery_status.last_error_code,
|
|
2029
|
+
"last_error_detail_hash": delivery_status.last_error_detail_hash,
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
tombstone_row = self.connection.execute(
|
|
2033
|
+
"SELECT * FROM cms_tombstone_deliveries WHERE event_id = ?",
|
|
2034
|
+
(progress.event_id,),
|
|
2035
|
+
).fetchone()
|
|
2036
|
+
tombstone = None
|
|
2037
|
+
if tombstone_row is not None:
|
|
2038
|
+
tombstone_request = self._tombstone_request_from_row(
|
|
2039
|
+
tombstone_row, event_verifier, publication_authority,
|
|
2040
|
+
)
|
|
2041
|
+
tombstone_status = self.tombstone_status(tombstone_request.delivery_id)
|
|
2042
|
+
tombstone = {
|
|
2043
|
+
"tombstone_id": tombstone_row["tombstone_id"],
|
|
2044
|
+
"delivery_id": tombstone_status.delivery_id,
|
|
2045
|
+
"status": tombstone_status.status,
|
|
2046
|
+
"attempts": tombstone_status.attempts,
|
|
2047
|
+
"max_attempts": tombstone_status.max_attempts,
|
|
2048
|
+
"next_attempt_at": tombstone_status.next_attempt_at,
|
|
2049
|
+
"lease_expires_at": tombstone_status.lease_expires_at,
|
|
2050
|
+
"lease_expired": (
|
|
2051
|
+
tombstone_status.status == "leased"
|
|
2052
|
+
and tombstone_status.lease_expires_at is not None
|
|
2053
|
+
and tombstone_status.lease_expires_at <= now
|
|
2054
|
+
),
|
|
2055
|
+
"last_error_code": tombstone_status.last_error_code,
|
|
2056
|
+
"last_error_detail_hash": tombstone_status.last_error_detail_hash,
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
if progress.cancelled:
|
|
2060
|
+
status = "cancelled"
|
|
2061
|
+
elif tombstone is not None and tombstone["status"] == "succeeded":
|
|
2062
|
+
status = "deleted"
|
|
2063
|
+
elif tombstone is not None and tombstone["status"] == "failed":
|
|
2064
|
+
status = "deletion_failed"
|
|
2065
|
+
elif tombstone is not None:
|
|
2066
|
+
status = "deleting"
|
|
2067
|
+
elif delivery is not None and delivery["status"] == "succeeded":
|
|
2068
|
+
status = "published"
|
|
2069
|
+
elif delivery is not None and delivery["status"] == "failed":
|
|
2070
|
+
status = "publication_failed"
|
|
2071
|
+
elif delivery is not None and any(
|
|
2072
|
+
code == "approval.expired" for _, code in readiness.blocked
|
|
2073
|
+
):
|
|
2074
|
+
status = "publication_blocked"
|
|
2075
|
+
elif delivery is not None:
|
|
2076
|
+
status = "publishing"
|
|
2077
|
+
elif readiness.ready:
|
|
2078
|
+
status = "ready"
|
|
2079
|
+
elif progress.counts["failed"]:
|
|
2080
|
+
status = "localization_failed"
|
|
2081
|
+
elif progress.counts["succeeded"] == progress.job_count:
|
|
2082
|
+
status = "awaiting_approval"
|
|
2083
|
+
else:
|
|
2084
|
+
status = "processing"
|
|
2085
|
+
|
|
2086
|
+
return ChangeLifecycle(
|
|
2087
|
+
event_id=progress.event_id,
|
|
2088
|
+
site_id=progress.site_id,
|
|
2089
|
+
plan_id=progress.plan_id,
|
|
2090
|
+
website_version=progress.website_version,
|
|
2091
|
+
source_sequence=progress.source_sequence,
|
|
2092
|
+
status=status,
|
|
2093
|
+
required_locales=readiness.required_locales,
|
|
2094
|
+
approved_locales=readiness.approved_locales,
|
|
2095
|
+
blocked_locales=readiness.blocked,
|
|
2096
|
+
queue_counts=progress.counts,
|
|
2097
|
+
delivery=delivery,
|
|
2098
|
+
tombstone=tombstone,
|
|
2099
|
+
)
|
|
2100
|
+
|
|
2101
|
+
def _load_event(
|
|
2102
|
+
self,
|
|
2103
|
+
event_id: Any,
|
|
2104
|
+
verifier: CMSMessageAuthority,
|
|
2105
|
+
*,
|
|
2106
|
+
allow_superseded: bool = False,
|
|
2107
|
+
allow_cancelled: bool = False,
|
|
2108
|
+
allow_accepted: bool = False,
|
|
2109
|
+
) -> tuple[dict[str, Any], Any]:
|
|
2110
|
+
event_id = _token(event_id, "cms.event_id.invalid")
|
|
2111
|
+
if not isinstance(allow_superseded, bool):
|
|
2112
|
+
raise CMSBridgeBlocked("cms.event.supersession_mode_invalid")
|
|
2113
|
+
if not isinstance(allow_cancelled, bool):
|
|
2114
|
+
raise CMSBridgeBlocked("cms.event.cancellation_mode_invalid")
|
|
2115
|
+
if not isinstance(allow_accepted, bool):
|
|
2116
|
+
raise CMSBridgeBlocked("cms.event.status_mode_invalid")
|
|
2117
|
+
row = self.connection.execute(
|
|
2118
|
+
"SELECT * FROM cms_change_events WHERE event_id = ?",
|
|
2119
|
+
(event_id,),
|
|
2120
|
+
).fetchone()
|
|
2121
|
+
if row is None:
|
|
2122
|
+
raise CMSBridgeBlocked("cms.event.not_enqueued")
|
|
2123
|
+
supersession = self.connection.execute(
|
|
2124
|
+
"SELECT 1 FROM cms_event_supersessions WHERE event_id = ?",
|
|
2125
|
+
(event_id,),
|
|
2126
|
+
).fetchone()
|
|
2127
|
+
if supersession is not None and not allow_superseded:
|
|
2128
|
+
raise CMSBridgeBlocked("cms.event.superseded")
|
|
2129
|
+
if _hash(row["event_json"]) != row["event_sha256"]:
|
|
2130
|
+
raise CMSBridgeBlocked("cms.event.tampered")
|
|
2131
|
+
try:
|
|
2132
|
+
event = json.loads(row["event_json"])
|
|
2133
|
+
except json.JSONDecodeError:
|
|
2134
|
+
raise CMSBridgeBlocked("cms.event.tampered") from None
|
|
2135
|
+
if _canonical_json(event) != row["event_json"]:
|
|
2136
|
+
raise CMSBridgeBlocked("cms.event.tampered")
|
|
2137
|
+
signature = _signature(CMSMessageSignature(
|
|
2138
|
+
row["signature_algorithm"], row["key_id"], row["signature"],
|
|
2139
|
+
))
|
|
2140
|
+
_verify(verifier, row["event_json"].encode("utf-8"), signature, "cms.event.signature_rejected")
|
|
2141
|
+
event, plan = self._validated_event(event, allow_legacy=True)
|
|
2142
|
+
if plan.plan_id != row["plan_id"]:
|
|
2143
|
+
raise CMSBridgeBlocked("cms.event.plan_mismatch")
|
|
2144
|
+
topic = self.connection.execute("""
|
|
2145
|
+
SELECT site_id, source_id, generation FROM cms_event_topics
|
|
2146
|
+
WHERE event_id = ?
|
|
2147
|
+
""", (event_id,)).fetchone()
|
|
2148
|
+
if topic is None or (
|
|
2149
|
+
topic["site_id"], topic["source_id"]
|
|
2150
|
+
) != (event["site_id"], event["localization"]["source_id"]):
|
|
2151
|
+
raise CMSBridgeBlocked("cms.event.topic_invalid")
|
|
2152
|
+
cancellation = self.connection.execute(
|
|
2153
|
+
"SELECT * FROM cms_event_cancellations WHERE event_id = ?",
|
|
2154
|
+
(event_id,),
|
|
2155
|
+
).fetchone()
|
|
2156
|
+
if cancellation is not None:
|
|
2157
|
+
self._verify_stored_cancellation(
|
|
2158
|
+
cancellation, event, topic, row["key_id"], verifier,
|
|
2159
|
+
)
|
|
2160
|
+
if not allow_cancelled:
|
|
2161
|
+
raise CMSBridgeBlocked("cms.event.cancelled")
|
|
2162
|
+
if (
|
|
2163
|
+
row["status"] != "enqueued"
|
|
2164
|
+
and not (allow_accepted and row["status"] == "accepted")
|
|
2165
|
+
):
|
|
2166
|
+
raise CMSBridgeBlocked("cms.event.not_enqueued")
|
|
2167
|
+
newer = self.connection.execute("""
|
|
2168
|
+
SELECT 1
|
|
2169
|
+
FROM cms_event_topics AS candidate
|
|
2170
|
+
JOIN cms_change_events AS candidate_event
|
|
2171
|
+
ON candidate_event.event_id = candidate.event_id
|
|
2172
|
+
WHERE candidate.site_id = ? AND candidate.source_id = ?
|
|
2173
|
+
AND candidate.generation > ? AND candidate_event.status = 'enqueued'
|
|
2174
|
+
LIMIT 1
|
|
2175
|
+
""", (topic["site_id"], topic["source_id"], topic["generation"])).fetchone()
|
|
2176
|
+
if newer is not None and not allow_superseded:
|
|
2177
|
+
raise CMSBridgeBlocked("cms.event.superseded")
|
|
2178
|
+
return event, plan
|
|
2179
|
+
|
|
2180
|
+
def _event_is_current(self, event_id: str) -> bool:
|
|
2181
|
+
row = self.connection.execute("""
|
|
2182
|
+
SELECT topic.site_id, topic.source_id, topic.generation
|
|
2183
|
+
FROM cms_event_topics AS topic
|
|
2184
|
+
JOIN cms_change_events AS event ON event.event_id = topic.event_id
|
|
2185
|
+
WHERE topic.event_id = ? AND event.status = 'enqueued'
|
|
2186
|
+
AND topic.event_id NOT IN (SELECT event_id FROM cms_event_supersessions)
|
|
2187
|
+
AND topic.event_id NOT IN (SELECT event_id FROM cms_event_cancellations)
|
|
2188
|
+
""", (event_id,)).fetchone()
|
|
2189
|
+
if row is None:
|
|
2190
|
+
return False
|
|
2191
|
+
return self.connection.execute("""
|
|
2192
|
+
SELECT 1
|
|
2193
|
+
FROM cms_event_topics AS newer
|
|
2194
|
+
JOIN cms_change_events AS event ON event.event_id = newer.event_id
|
|
2195
|
+
WHERE newer.site_id = ? AND newer.source_id = ?
|
|
2196
|
+
AND newer.generation > ? AND event.status = 'enqueued'
|
|
2197
|
+
LIMIT 1
|
|
2198
|
+
""", (row["site_id"], row["source_id"], row["generation"])).fetchone() is None
|
|
2199
|
+
|
|
2200
|
+
def prepare_delivery(
|
|
2201
|
+
self,
|
|
2202
|
+
event_id: Any,
|
|
2203
|
+
event_verifier: CMSMessageAuthority,
|
|
2204
|
+
approval_authority: Any,
|
|
2205
|
+
publication_authority: CMSMessageAuthority,
|
|
2206
|
+
*,
|
|
2207
|
+
now: float | int,
|
|
2208
|
+
max_attempts: int = 5,
|
|
2209
|
+
) -> CMSPublicationRequest:
|
|
2210
|
+
now = _timestamp(now, "cms.time.invalid")
|
|
2211
|
+
if isinstance(max_attempts, bool) or not isinstance(max_attempts, int):
|
|
2212
|
+
raise CMSBridgeBlocked("cms.max_attempts.invalid")
|
|
2213
|
+
if not 1 <= max_attempts <= MAX_ATTEMPTS:
|
|
2214
|
+
raise CMSBridgeBlocked("cms.max_attempts.invalid")
|
|
2215
|
+
event, plan = self._load_event(event_id, event_verifier)
|
|
2216
|
+
try:
|
|
2217
|
+
bundle = self.release_store.publication_bundle(
|
|
2218
|
+
plan, approval_authority, now=now,
|
|
2219
|
+
)
|
|
2220
|
+
except _RELEASE.LocalizationReleaseBlocked:
|
|
2221
|
+
raise CMSBridgeBlocked("cms.website.not_ready") from None
|
|
2222
|
+
required = tuple(sorted(job.target.locale for job in plan.jobs))
|
|
2223
|
+
actual = tuple(item.target_locale for item in bundle)
|
|
2224
|
+
if actual != required:
|
|
2225
|
+
raise CMSBridgeBlocked("cms.bundle.incomplete")
|
|
2226
|
+
source_sequence = event.get("source_sequence")
|
|
2227
|
+
if source_sequence is None:
|
|
2228
|
+
topic = self.connection.execute(
|
|
2229
|
+
"SELECT generation FROM cms_event_topics WHERE event_id = ?",
|
|
2230
|
+
(event["event_id"],),
|
|
2231
|
+
).fetchone()
|
|
2232
|
+
if topic is None:
|
|
2233
|
+
raise CMSBridgeBlocked("cms.event.topic_invalid")
|
|
2234
|
+
source_sequence = topic["generation"]
|
|
2235
|
+
unsigned = {
|
|
2236
|
+
"schema": PUBLICATION_SCHEMA,
|
|
2237
|
+
"event_id": event["event_id"],
|
|
2238
|
+
"site_id": event["site_id"],
|
|
2239
|
+
"website_version": event["website_version"],
|
|
2240
|
+
"plan_id": plan.plan_id,
|
|
2241
|
+
"source_id": event["localization"]["source_id"],
|
|
2242
|
+
"source_revision": event["localization"]["source_revision"],
|
|
2243
|
+
"source_sequence": source_sequence,
|
|
2244
|
+
"source_sha256": plan.source_hash,
|
|
2245
|
+
"localizations": [
|
|
2246
|
+
{
|
|
2247
|
+
"locale": item.target_locale,
|
|
2248
|
+
"target_text": item.candidate,
|
|
2249
|
+
"target_sha256": item.target_sha256,
|
|
2250
|
+
"approval_id": item.approval_id,
|
|
2251
|
+
"approval_expires_at": item.expires_at,
|
|
2252
|
+
"release_evidence": item.release_evidence,
|
|
2253
|
+
}
|
|
2254
|
+
for item in bundle
|
|
2255
|
+
],
|
|
2256
|
+
}
|
|
2257
|
+
delivery_id = "blun-cms-delivery-" + _hash(_canonical_json(unsigned))
|
|
2258
|
+
payload = {**unsigned, "delivery_id": delivery_id}
|
|
2259
|
+
payload_json = _canonical_json(payload)
|
|
2260
|
+
payload_hash = _hash(payload_json)
|
|
2261
|
+
sign = getattr(publication_authority, "sign", None)
|
|
2262
|
+
try:
|
|
2263
|
+
signature = _signature(sign(payload_json.encode("utf-8"))) if callable(sign) else None
|
|
2264
|
+
except CMSBridgeBlocked:
|
|
2265
|
+
raise
|
|
2266
|
+
except Exception:
|
|
2267
|
+
raise CMSBridgeBlocked("cms.publication.signing_failed") from None
|
|
2268
|
+
if signature is None:
|
|
2269
|
+
raise CMSBridgeBlocked("cms.publication.authority_invalid")
|
|
2270
|
+
_verify(
|
|
2271
|
+
publication_authority,
|
|
2272
|
+
payload_json.encode("utf-8"),
|
|
2273
|
+
signature,
|
|
2274
|
+
"cms.publication.signature_rejected",
|
|
2275
|
+
)
|
|
2276
|
+
with _transaction(self.connection):
|
|
2277
|
+
if self.connection.execute(
|
|
2278
|
+
"SELECT 1 FROM cms_event_cancellations WHERE event_id = ?",
|
|
2279
|
+
(event["event_id"],),
|
|
2280
|
+
).fetchone() is not None:
|
|
2281
|
+
raise CMSBridgeBlocked("cms.event.cancelled")
|
|
2282
|
+
prior = self.connection.execute(
|
|
2283
|
+
"SELECT delivery_id, payload_sha256 FROM cms_publication_deliveries WHERE event_id = ?",
|
|
2284
|
+
(event["event_id"],),
|
|
2285
|
+
).fetchone()
|
|
2286
|
+
if prior is not None:
|
|
2287
|
+
if prior["delivery_id"] != delivery_id or prior["payload_sha256"] != payload_hash:
|
|
2288
|
+
raise CMSBridgeBlocked("cms.delivery.idempotency_collision")
|
|
2289
|
+
else:
|
|
2290
|
+
self.connection.execute("""
|
|
2291
|
+
INSERT INTO cms_publication_deliveries (
|
|
2292
|
+
delivery_id, event_id, plan_id, payload_json, payload_sha256,
|
|
2293
|
+
signature_algorithm, key_id, signature, status, attempts,
|
|
2294
|
+
max_attempts, next_attempt_at, created_at, updated_at
|
|
2295
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?, ?)
|
|
2296
|
+
""", (
|
|
2297
|
+
delivery_id, event["event_id"], plan.plan_id, payload_json, payload_hash,
|
|
2298
|
+
signature.algorithm, signature.key_id, signature.signature,
|
|
2299
|
+
max_attempts, now, now, now,
|
|
2300
|
+
))
|
|
2301
|
+
return CMSPublicationRequest(delivery_id, payload, payload_hash, signature)
|
|
2302
|
+
|
|
2303
|
+
def _request_from_row(
|
|
2304
|
+
self,
|
|
2305
|
+
row: sqlite3.Row,
|
|
2306
|
+
authority: CMSMessageAuthority,
|
|
2307
|
+
now: float,
|
|
2308
|
+
*,
|
|
2309
|
+
require_current_approvals: bool = True,
|
|
2310
|
+
) -> CMSPublicationRequest:
|
|
2311
|
+
if not isinstance(require_current_approvals, bool):
|
|
2312
|
+
raise CMSBridgeBlocked("cms.delivery.validation_mode_invalid")
|
|
2313
|
+
if _hash(row["payload_json"]) != row["payload_sha256"]:
|
|
2314
|
+
raise CMSBridgeBlocked("cms.delivery.tampered")
|
|
2315
|
+
try:
|
|
2316
|
+
payload = json.loads(row["payload_json"])
|
|
2317
|
+
except json.JSONDecodeError:
|
|
2318
|
+
raise CMSBridgeBlocked("cms.delivery.tampered") from None
|
|
2319
|
+
if _canonical_json(payload) != row["payload_json"] or payload.get("delivery_id") != row["delivery_id"]:
|
|
2320
|
+
raise CMSBridgeBlocked("cms.delivery.tampered")
|
|
2321
|
+
localizations = payload.get("localizations")
|
|
2322
|
+
if not isinstance(localizations, list) or not localizations:
|
|
2323
|
+
raise CMSBridgeBlocked("cms.delivery.tampered")
|
|
2324
|
+
expiries = [item.get("approval_expires_at") for item in localizations if isinstance(item, dict)]
|
|
2325
|
+
if len(expiries) != len(localizations):
|
|
2326
|
+
raise CMSBridgeBlocked("cms.delivery.tampered")
|
|
2327
|
+
validated_expiries = tuple(
|
|
2328
|
+
_timestamp(expiry, "cms.delivery.tampered") for expiry in expiries
|
|
2329
|
+
)
|
|
2330
|
+
try:
|
|
2331
|
+
for item in localizations:
|
|
2332
|
+
if not isinstance(item, dict):
|
|
2333
|
+
raise _RELEASE.LocalizationReleaseBlocked(
|
|
2334
|
+
"publication.evidence.invalid"
|
|
2335
|
+
)
|
|
2336
|
+
if not _valid_release_evidence(
|
|
2337
|
+
item.get("release_evidence"),
|
|
2338
|
+
locale=item.get("locale"),
|
|
2339
|
+
target_sha256=item.get("target_sha256"),
|
|
2340
|
+
approval_id=item.get("approval_id"),
|
|
2341
|
+
):
|
|
2342
|
+
raise _RELEASE.LocalizationReleaseBlocked(
|
|
2343
|
+
"publication.evidence.invalid"
|
|
2344
|
+
)
|
|
2345
|
+
except _RELEASE.LocalizationReleaseBlocked:
|
|
2346
|
+
raise CMSBridgeBlocked("cms.delivery.tampered") from None
|
|
2347
|
+
if require_current_approvals and any(
|
|
2348
|
+
expiry <= now for expiry in validated_expiries
|
|
2349
|
+
):
|
|
2350
|
+
raise CMSBridgeBlocked("cms.delivery.approval_expired")
|
|
2351
|
+
signature = _signature(CMSMessageSignature(
|
|
2352
|
+
row["signature_algorithm"], row["key_id"], row["signature"],
|
|
2353
|
+
))
|
|
2354
|
+
_verify(
|
|
2355
|
+
authority,
|
|
2356
|
+
row["payload_json"].encode("utf-8"),
|
|
2357
|
+
signature,
|
|
2358
|
+
"cms.delivery.signature_invalid",
|
|
2359
|
+
)
|
|
2360
|
+
return CMSPublicationRequest(row["delivery_id"], payload, row["payload_sha256"], signature)
|
|
2361
|
+
|
|
2362
|
+
def _tombstone_request_from_row(
|
|
2363
|
+
self,
|
|
2364
|
+
row: sqlite3.Row,
|
|
2365
|
+
event_verifier: CMSMessageAuthority,
|
|
2366
|
+
publication_authority: CMSMessageAuthority,
|
|
2367
|
+
) -> CMSTombstoneRequest:
|
|
2368
|
+
if (
|
|
2369
|
+
_hash(row["tombstone_json"]) != row["tombstone_sha256"]
|
|
2370
|
+
or _hash(row["payload_json"]) != row["payload_sha256"]
|
|
2371
|
+
):
|
|
2372
|
+
raise CMSBridgeBlocked("cms.tombstone.tampered")
|
|
2373
|
+
try:
|
|
2374
|
+
tombstone = json.loads(row["tombstone_json"])
|
|
2375
|
+
payload = json.loads(row["payload_json"])
|
|
2376
|
+
except json.JSONDecodeError:
|
|
2377
|
+
raise CMSBridgeBlocked("cms.tombstone.tampered") from None
|
|
2378
|
+
if (
|
|
2379
|
+
_canonical_json(tombstone) != row["tombstone_json"]
|
|
2380
|
+
or _canonical_json(payload) != row["payload_json"]
|
|
2381
|
+
):
|
|
2382
|
+
raise CMSBridgeBlocked("cms.tombstone.tampered")
|
|
2383
|
+
tombstone = self._validated_tombstone(tombstone)
|
|
2384
|
+
event, plan = self._load_event(
|
|
2385
|
+
row["event_id"], event_verifier, allow_superseded=True,
|
|
2386
|
+
)
|
|
2387
|
+
topic = self.connection.execute(
|
|
2388
|
+
"SELECT generation FROM cms_event_topics WHERE event_id = ?",
|
|
2389
|
+
(row["event_id"],),
|
|
2390
|
+
).fetchone()
|
|
2391
|
+
event_row = self.connection.execute(
|
|
2392
|
+
"SELECT key_id FROM cms_change_events WHERE event_id = ?",
|
|
2393
|
+
(row["event_id"],),
|
|
2394
|
+
).fetchone()
|
|
2395
|
+
if topic is None or event_row is None:
|
|
2396
|
+
raise CMSBridgeBlocked("cms.tombstone.tampered")
|
|
2397
|
+
request_signature = _signature(CMSMessageSignature(
|
|
2398
|
+
row["request_signature_algorithm"], row["request_key_id"],
|
|
2399
|
+
row["request_signature"],
|
|
2400
|
+
))
|
|
2401
|
+
_verify(
|
|
2402
|
+
event_verifier, row["tombstone_json"].encode("utf-8"),
|
|
2403
|
+
request_signature, "cms.tombstone.signature_rejected",
|
|
2404
|
+
)
|
|
2405
|
+
request_binding = {
|
|
2406
|
+
"tombstone_id": row["tombstone_id"],
|
|
2407
|
+
"event_id": event["event_id"],
|
|
2408
|
+
"site_id": event["site_id"],
|
|
2409
|
+
"website_version": event["website_version"],
|
|
2410
|
+
"source_id": event["localization"]["source_id"],
|
|
2411
|
+
"source_sequence": int(topic["generation"]),
|
|
2412
|
+
}
|
|
2413
|
+
if (
|
|
2414
|
+
any(tombstone[field] != value for field, value in request_binding.items())
|
|
2415
|
+
or row["request_key_id"] != event_row["key_id"]
|
|
2416
|
+
):
|
|
2417
|
+
raise CMSBridgeBlocked("cms.tombstone.binding_invalid")
|
|
2418
|
+
publication_row = self.connection.execute(
|
|
2419
|
+
"SELECT * FROM cms_publication_deliveries WHERE event_id = ?",
|
|
2420
|
+
(event["event_id"],),
|
|
2421
|
+
).fetchone()
|
|
2422
|
+
if publication_row is None or publication_row["status"] != "succeeded":
|
|
2423
|
+
raise CMSBridgeBlocked("cms.tombstone.publication_invalid")
|
|
2424
|
+
publication = self._request_from_row(
|
|
2425
|
+
publication_row, publication_authority, 0,
|
|
2426
|
+
require_current_approvals=False,
|
|
2427
|
+
)
|
|
2428
|
+
expected_keys = {
|
|
2429
|
+
"schema", "delivery_id", "tombstone_id", "event_id", "site_id",
|
|
2430
|
+
"website_version", "plan_id", "source_id", "source_sequence",
|
|
2431
|
+
"publication_delivery_id", "publication_payload_sha256", "locales",
|
|
2432
|
+
}
|
|
2433
|
+
locales = sorted(
|
|
2434
|
+
item["locale"] for item in publication.payload["localizations"]
|
|
2435
|
+
)
|
|
2436
|
+
expected_payload = {
|
|
2437
|
+
"schema": TOMBSTONE_DELIVERY_SCHEMA,
|
|
2438
|
+
"delivery_id": row["delivery_id"],
|
|
2439
|
+
"tombstone_id": row["tombstone_id"],
|
|
2440
|
+
"event_id": event["event_id"],
|
|
2441
|
+
"site_id": event["site_id"],
|
|
2442
|
+
"website_version": event["website_version"],
|
|
2443
|
+
"plan_id": plan.plan_id,
|
|
2444
|
+
"source_id": event["localization"]["source_id"],
|
|
2445
|
+
"source_sequence": int(topic["generation"]),
|
|
2446
|
+
"publication_delivery_id": publication.delivery_id,
|
|
2447
|
+
"publication_payload_sha256": publication.payload_sha256,
|
|
2448
|
+
"locales": locales,
|
|
2449
|
+
}
|
|
2450
|
+
unsigned = {key: value for key, value in payload.items() if key != "delivery_id"}
|
|
2451
|
+
if (
|
|
2452
|
+
set(payload) != expected_keys
|
|
2453
|
+
or payload != expected_payload
|
|
2454
|
+
or locales != sorted(set(locales))
|
|
2455
|
+
or payload["delivery_id"]
|
|
2456
|
+
!= "blun-cms-tombstone-" + _hash(_canonical_json(unsigned))
|
|
2457
|
+
or row["plan_id"] != plan.plan_id
|
|
2458
|
+
):
|
|
2459
|
+
raise CMSBridgeBlocked("cms.tombstone.tampered")
|
|
2460
|
+
delivery_signature = _signature(CMSMessageSignature(
|
|
2461
|
+
row["signature_algorithm"], row["key_id"], row["signature"],
|
|
2462
|
+
))
|
|
2463
|
+
_verify(
|
|
2464
|
+
publication_authority, row["payload_json"].encode("utf-8"),
|
|
2465
|
+
delivery_signature, "cms.tombstone.delivery_signature_invalid",
|
|
2466
|
+
)
|
|
2467
|
+
return CMSTombstoneRequest(
|
|
2468
|
+
row["delivery_id"], payload, row["payload_sha256"], delivery_signature,
|
|
2469
|
+
)
|
|
2470
|
+
|
|
2471
|
+
def claim_tombstone(
|
|
2472
|
+
self,
|
|
2473
|
+
worker_id: Any,
|
|
2474
|
+
event_verifier: CMSMessageAuthority,
|
|
2475
|
+
publication_authority: CMSMessageAuthority,
|
|
2476
|
+
*,
|
|
2477
|
+
now: float | int,
|
|
2478
|
+
lease_seconds: float | int = 300,
|
|
2479
|
+
) -> ClaimedTombstone | None:
|
|
2480
|
+
worker_id = _token(worker_id, "cms.worker_id.invalid")
|
|
2481
|
+
now = _timestamp(now, "cms.time.invalid")
|
|
2482
|
+
lease_seconds = _duration(lease_seconds, "cms.lease.invalid")
|
|
2483
|
+
with _transaction(self.connection):
|
|
2484
|
+
self.connection.execute("""
|
|
2485
|
+
UPDATE cms_tombstone_deliveries
|
|
2486
|
+
SET status = 'failed', lease_owner = NULL, lease_token = NULL,
|
|
2487
|
+
lease_expires_at = NULL, last_error_code = 'lease_expired', updated_at = ?
|
|
2488
|
+
WHERE status = 'leased' AND lease_expires_at <= ? AND attempts >= max_attempts
|
|
2489
|
+
""", (now, now))
|
|
2490
|
+
self.connection.execute("""
|
|
2491
|
+
UPDATE cms_tombstone_deliveries
|
|
2492
|
+
SET status = 'retry_wait', next_attempt_at = ?, lease_owner = NULL,
|
|
2493
|
+
lease_token = NULL, lease_expires_at = NULL,
|
|
2494
|
+
last_error_code = 'lease_expired', updated_at = ?
|
|
2495
|
+
WHERE status = 'leased' AND lease_expires_at <= ? AND attempts < max_attempts
|
|
2496
|
+
""", (now, now, now))
|
|
2497
|
+
row = self.connection.execute("""
|
|
2498
|
+
SELECT * FROM cms_tombstone_deliveries
|
|
2499
|
+
WHERE status IN ('pending', 'retry_wait') AND next_attempt_at <= ?
|
|
2500
|
+
AND attempts < max_attempts
|
|
2501
|
+
ORDER BY created_at, delivery_id LIMIT 1
|
|
2502
|
+
""", (now,)).fetchone()
|
|
2503
|
+
if row is None:
|
|
2504
|
+
return None
|
|
2505
|
+
request = self._tombstone_request_from_row(
|
|
2506
|
+
row, event_verifier, publication_authority,
|
|
2507
|
+
)
|
|
2508
|
+
lease_token = secrets.token_urlsafe(32)
|
|
2509
|
+
expires = now + lease_seconds
|
|
2510
|
+
updated = self.connection.execute("""
|
|
2511
|
+
UPDATE cms_tombstone_deliveries
|
|
2512
|
+
SET status = 'leased', attempts = attempts + 1, lease_owner = ?,
|
|
2513
|
+
lease_token = ?, lease_expires_at = ?, last_error_code = NULL,
|
|
2514
|
+
last_error_detail_hash = NULL, updated_at = ?
|
|
2515
|
+
WHERE delivery_id = ? AND status IN ('pending', 'retry_wait')
|
|
2516
|
+
""", (worker_id, lease_token, expires, now, row["delivery_id"]))
|
|
2517
|
+
if updated.rowcount != 1:
|
|
2518
|
+
raise CMSBridgeBlocked("cms.tombstone.claim_lost")
|
|
2519
|
+
return ClaimedTombstone(
|
|
2520
|
+
request, int(row["attempts"]) + 1, int(row["max_attempts"]),
|
|
2521
|
+
worker_id, lease_token, expires,
|
|
2522
|
+
)
|
|
2523
|
+
|
|
2524
|
+
def _live_tombstone(
|
|
2525
|
+
self,
|
|
2526
|
+
claim: Any,
|
|
2527
|
+
event_verifier: CMSMessageAuthority,
|
|
2528
|
+
publication_authority: CMSMessageAuthority,
|
|
2529
|
+
now: float,
|
|
2530
|
+
) -> sqlite3.Row:
|
|
2531
|
+
if not isinstance(claim, ClaimedTombstone):
|
|
2532
|
+
raise CMSBridgeBlocked("cms.tombstone.claim_invalid")
|
|
2533
|
+
row = self.connection.execute(
|
|
2534
|
+
"SELECT * FROM cms_tombstone_deliveries WHERE delivery_id = ?",
|
|
2535
|
+
(claim.request.delivery_id,),
|
|
2536
|
+
).fetchone()
|
|
2537
|
+
if row is None or row["status"] != "leased":
|
|
2538
|
+
raise CMSBridgeBlocked("cms.tombstone.lease_lost")
|
|
2539
|
+
if row["lease_owner"] != claim.lease_owner or row["lease_token"] != claim.lease_token:
|
|
2540
|
+
raise CMSBridgeBlocked("cms.tombstone.lease_lost")
|
|
2541
|
+
if float(row["lease_expires_at"]) <= now:
|
|
2542
|
+
raise CMSBridgeBlocked("cms.tombstone.lease_expired")
|
|
2543
|
+
request = self._tombstone_request_from_row(
|
|
2544
|
+
row, event_verifier, publication_authority,
|
|
2545
|
+
)
|
|
2546
|
+
if request != claim.request:
|
|
2547
|
+
raise CMSBridgeBlocked("cms.tombstone.claim_mutated")
|
|
2548
|
+
return row
|
|
2549
|
+
|
|
2550
|
+
def _finish_tombstone(
|
|
2551
|
+
self,
|
|
2552
|
+
claim: ClaimedTombstone,
|
|
2553
|
+
event_verifier: CMSMessageAuthority,
|
|
2554
|
+
publication_authority: CMSMessageAuthority,
|
|
2555
|
+
*,
|
|
2556
|
+
now: float,
|
|
2557
|
+
error: CMSPublishFailed | None,
|
|
2558
|
+
) -> DeliveryStatus:
|
|
2559
|
+
with _transaction(self.connection):
|
|
2560
|
+
row = self._live_tombstone(
|
|
2561
|
+
claim, event_verifier, publication_authority, now,
|
|
2562
|
+
)
|
|
2563
|
+
if error is None:
|
|
2564
|
+
status, next_attempt, code, detail_hash = "succeeded", now, None, None
|
|
2565
|
+
else:
|
|
2566
|
+
terminal = not error.retryable or int(row["attempts"]) >= int(row["max_attempts"])
|
|
2567
|
+
status = "failed" if terminal else "retry_wait"
|
|
2568
|
+
next_attempt = now if terminal else now + min(
|
|
2569
|
+
3600.0, 5.0 * (2 ** (int(row["attempts"]) - 1)),
|
|
2570
|
+
)
|
|
2571
|
+
code = error.code
|
|
2572
|
+
detail_hash = _hash(error.detail) if error.detail is not None else None
|
|
2573
|
+
updated = self.connection.execute("""
|
|
2574
|
+
UPDATE cms_tombstone_deliveries
|
|
2575
|
+
SET status = ?, next_attempt_at = ?, lease_owner = NULL,
|
|
2576
|
+
lease_token = NULL, lease_expires_at = NULL,
|
|
2577
|
+
last_error_code = ?, last_error_detail_hash = ?, updated_at = ?
|
|
2578
|
+
WHERE delivery_id = ? AND status = 'leased'
|
|
2579
|
+
AND lease_owner = ? AND lease_token = ?
|
|
2580
|
+
""", (
|
|
2581
|
+
status, next_attempt, code, detail_hash, now,
|
|
2582
|
+
claim.request.delivery_id, claim.lease_owner, claim.lease_token,
|
|
2583
|
+
))
|
|
2584
|
+
if updated.rowcount != 1:
|
|
2585
|
+
raise CMSBridgeBlocked("cms.tombstone.finish_lost")
|
|
2586
|
+
return self.tombstone_status(claim.request.delivery_id)
|
|
2587
|
+
|
|
2588
|
+
def run_tombstone(
|
|
2589
|
+
self,
|
|
2590
|
+
publisher: CMSPublisher,
|
|
2591
|
+
event_verifier: CMSMessageAuthority,
|
|
2592
|
+
publication_authority: CMSMessageAuthority,
|
|
2593
|
+
*,
|
|
2594
|
+
worker_id: Any,
|
|
2595
|
+
clock: Callable[[], float] = time.time,
|
|
2596
|
+
lease_seconds: float | int = 300,
|
|
2597
|
+
operation_guard: Callable[[float], Any] | None = None,
|
|
2598
|
+
) -> DeliveryOutcome:
|
|
2599
|
+
if operation_guard is not None and not callable(operation_guard):
|
|
2600
|
+
raise CMSBridgeBlocked("cms.tombstone.operation_guard_invalid")
|
|
2601
|
+
claim = self.claim_tombstone(
|
|
2602
|
+
worker_id, event_verifier, publication_authority,
|
|
2603
|
+
now=clock(), lease_seconds=lease_seconds,
|
|
2604
|
+
)
|
|
2605
|
+
if claim is None:
|
|
2606
|
+
return DeliveryOutcome("idle")
|
|
2607
|
+
if operation_guard is not None:
|
|
2608
|
+
try:
|
|
2609
|
+
operation_guard(float(lease_seconds))
|
|
2610
|
+
except Exception:
|
|
2611
|
+
raise CMSBridgeBlocked("cms.tombstone.operation_guard_failed") from None
|
|
2612
|
+
publish = getattr(publisher, "publish", None)
|
|
2613
|
+
try:
|
|
2614
|
+
if not callable(publish):
|
|
2615
|
+
raise CMSPublishFailed("publisher.invalid", retryable=False)
|
|
2616
|
+
acknowledgement = publish(claim.request)
|
|
2617
|
+
expected = {
|
|
2618
|
+
"schema": TOMBSTONE_ACK_SCHEMA,
|
|
2619
|
+
"delivery_id": claim.request.delivery_id,
|
|
2620
|
+
"payload_sha256": claim.request.payload_sha256,
|
|
2621
|
+
"status": "deleted",
|
|
2622
|
+
}
|
|
2623
|
+
if not isinstance(acknowledgement, Mapping) or dict(acknowledgement) != expected:
|
|
2624
|
+
raise CMSPublishFailed("publisher.ack_invalid", retryable=True)
|
|
2625
|
+
except Exception as original_error:
|
|
2626
|
+
error = _declared_publish_failure(original_error)
|
|
2627
|
+
if error is None:
|
|
2628
|
+
error = CMSPublishFailed("publisher.unavailable", retryable=True)
|
|
2629
|
+
status = self._finish_tombstone(
|
|
2630
|
+
claim, event_verifier, publication_authority,
|
|
2631
|
+
now=_timestamp(clock(), "cms.time.invalid"), error=error,
|
|
2632
|
+
)
|
|
2633
|
+
return DeliveryOutcome(status.status, status.delivery_id, status.attempts, error.code)
|
|
2634
|
+
status = self._finish_tombstone(
|
|
2635
|
+
claim, event_verifier, publication_authority,
|
|
2636
|
+
now=_timestamp(clock(), "cms.time.invalid"), error=None,
|
|
2637
|
+
)
|
|
2638
|
+
return DeliveryOutcome(status.status, status.delivery_id, status.attempts)
|
|
2639
|
+
|
|
2640
|
+
def tombstone_status(self, delivery_id: Any) -> DeliveryStatus:
|
|
2641
|
+
delivery_id = _token(delivery_id, "cms.delivery_id.invalid")
|
|
2642
|
+
row = self.connection.execute("""
|
|
2643
|
+
SELECT delivery_id, event_id, plan_id, status, attempts, max_attempts,
|
|
2644
|
+
next_attempt_at, lease_expires_at, last_error_code,
|
|
2645
|
+
last_error_detail_hash, payload_sha256
|
|
2646
|
+
FROM cms_tombstone_deliveries WHERE delivery_id = ?
|
|
2647
|
+
""", (delivery_id,)).fetchone()
|
|
2648
|
+
if row is None:
|
|
2649
|
+
raise CMSBridgeBlocked("cms.tombstone.missing")
|
|
2650
|
+
return DeliveryStatus(**dict(row))
|
|
2651
|
+
|
|
2652
|
+
def claim_delivery(
|
|
2653
|
+
self,
|
|
2654
|
+
worker_id: Any,
|
|
2655
|
+
authority: CMSMessageAuthority,
|
|
2656
|
+
*,
|
|
2657
|
+
now: float | int,
|
|
2658
|
+
lease_seconds: float | int = 300,
|
|
2659
|
+
) -> ClaimedDelivery | None:
|
|
2660
|
+
worker_id = _token(worker_id, "cms.worker_id.invalid")
|
|
2661
|
+
now = _timestamp(now, "cms.time.invalid")
|
|
2662
|
+
lease_seconds = _duration(lease_seconds, "cms.lease.invalid")
|
|
2663
|
+
with _transaction(self.connection):
|
|
2664
|
+
self.connection.execute("""
|
|
2665
|
+
UPDATE cms_publication_deliveries
|
|
2666
|
+
SET status = 'failed', lease_owner = NULL, lease_token = NULL,
|
|
2667
|
+
lease_expires_at = NULL, last_error_code = 'lease_expired', updated_at = ?
|
|
2668
|
+
WHERE status = 'leased' AND lease_expires_at <= ? AND attempts >= max_attempts
|
|
2669
|
+
""", (now, now))
|
|
2670
|
+
self.connection.execute("""
|
|
2671
|
+
UPDATE cms_publication_deliveries
|
|
2672
|
+
SET status = 'retry_wait', next_attempt_at = ?, lease_owner = NULL,
|
|
2673
|
+
lease_token = NULL, lease_expires_at = NULL,
|
|
2674
|
+
last_error_code = 'lease_expired', updated_at = ?
|
|
2675
|
+
WHERE status = 'leased' AND lease_expires_at <= ? AND attempts < max_attempts
|
|
2676
|
+
""", (now, now, now))
|
|
2677
|
+
row = self.connection.execute("""
|
|
2678
|
+
SELECT * FROM cms_publication_deliveries
|
|
2679
|
+
WHERE status IN ('pending', 'retry_wait') AND next_attempt_at <= ?
|
|
2680
|
+
AND attempts < max_attempts
|
|
2681
|
+
AND event_id NOT IN (SELECT event_id FROM cms_event_supersessions)
|
|
2682
|
+
AND event_id NOT IN (SELECT event_id FROM cms_event_cancellations)
|
|
2683
|
+
AND NOT EXISTS (
|
|
2684
|
+
SELECT 1
|
|
2685
|
+
FROM cms_event_topics AS current
|
|
2686
|
+
JOIN cms_event_topics AS newer
|
|
2687
|
+
ON newer.site_id = current.site_id
|
|
2688
|
+
AND newer.source_id = current.source_id
|
|
2689
|
+
AND newer.generation > current.generation
|
|
2690
|
+
JOIN cms_change_events AS newer_event
|
|
2691
|
+
ON newer_event.event_id = newer.event_id
|
|
2692
|
+
WHERE current.event_id = cms_publication_deliveries.event_id
|
|
2693
|
+
AND newer_event.status = 'enqueued'
|
|
2694
|
+
)
|
|
2695
|
+
ORDER BY created_at, delivery_id LIMIT 1
|
|
2696
|
+
""", (now,)).fetchone()
|
|
2697
|
+
if row is None:
|
|
2698
|
+
return None
|
|
2699
|
+
request = self._request_from_row(row, authority, now)
|
|
2700
|
+
lease_token = secrets.token_urlsafe(32)
|
|
2701
|
+
expires = now + lease_seconds
|
|
2702
|
+
updated = self.connection.execute("""
|
|
2703
|
+
UPDATE cms_publication_deliveries
|
|
2704
|
+
SET status = 'leased', attempts = attempts + 1, lease_owner = ?,
|
|
2705
|
+
lease_token = ?, lease_expires_at = ?, last_error_code = NULL,
|
|
2706
|
+
last_error_detail_hash = NULL, updated_at = ?
|
|
2707
|
+
WHERE delivery_id = ? AND status IN ('pending', 'retry_wait')
|
|
2708
|
+
""", (worker_id, lease_token, expires, now, row["delivery_id"]))
|
|
2709
|
+
if updated.rowcount != 1:
|
|
2710
|
+
raise CMSBridgeBlocked("cms.delivery.claim_lost")
|
|
2711
|
+
return ClaimedDelivery(
|
|
2712
|
+
request, int(row["attempts"]) + 1, int(row["max_attempts"]),
|
|
2713
|
+
worker_id, lease_token, expires,
|
|
2714
|
+
)
|
|
2715
|
+
|
|
2716
|
+
def _live_delivery(self, claim: Any, now: float) -> sqlite3.Row:
|
|
2717
|
+
if not isinstance(claim, ClaimedDelivery):
|
|
2718
|
+
raise CMSBridgeBlocked("cms.delivery.claim_invalid")
|
|
2719
|
+
row = self.connection.execute(
|
|
2720
|
+
"SELECT * FROM cms_publication_deliveries WHERE delivery_id = ?",
|
|
2721
|
+
(claim.request.delivery_id,),
|
|
2722
|
+
).fetchone()
|
|
2723
|
+
if row is None:
|
|
2724
|
+
raise CMSBridgeBlocked("cms.delivery.lease_lost")
|
|
2725
|
+
if not self._event_is_current(row["event_id"]):
|
|
2726
|
+
cancelled = self.connection.execute(
|
|
2727
|
+
"SELECT 1 FROM cms_event_cancellations WHERE event_id = ?",
|
|
2728
|
+
(row["event_id"],),
|
|
2729
|
+
).fetchone() is not None
|
|
2730
|
+
if cancelled:
|
|
2731
|
+
raise CMSBridgeBlocked("cms.delivery.event_cancelled")
|
|
2732
|
+
raise CMSBridgeBlocked("cms.delivery.event_superseded")
|
|
2733
|
+
if row["status"] != "leased":
|
|
2734
|
+
raise CMSBridgeBlocked("cms.delivery.lease_lost")
|
|
2735
|
+
if row["lease_owner"] != claim.lease_owner or row["lease_token"] != claim.lease_token:
|
|
2736
|
+
raise CMSBridgeBlocked("cms.delivery.lease_lost")
|
|
2737
|
+
if float(row["lease_expires_at"]) <= now:
|
|
2738
|
+
raise CMSBridgeBlocked("cms.delivery.lease_expired")
|
|
2739
|
+
return row
|
|
2740
|
+
|
|
2741
|
+
def _finish(self, claim: ClaimedDelivery, *, now: float, error: CMSPublishFailed | None) -> DeliveryStatus:
|
|
2742
|
+
with _transaction(self.connection):
|
|
2743
|
+
row = self._live_delivery(claim, now)
|
|
2744
|
+
if error is None:
|
|
2745
|
+
status = "succeeded"
|
|
2746
|
+
next_attempt = now
|
|
2747
|
+
code = detail_hash = None
|
|
2748
|
+
else:
|
|
2749
|
+
terminal = not error.retryable or int(row["attempts"]) >= int(row["max_attempts"])
|
|
2750
|
+
status = "failed" if terminal else "retry_wait"
|
|
2751
|
+
next_attempt = now if terminal else now + min(3600.0, 5.0 * (2 ** (int(row["attempts"]) - 1)))
|
|
2752
|
+
code = error.code
|
|
2753
|
+
detail_hash = _hash(error.detail) if error.detail is not None else None
|
|
2754
|
+
updated = self.connection.execute("""
|
|
2755
|
+
UPDATE cms_publication_deliveries
|
|
2756
|
+
SET status = ?, next_attempt_at = ?, lease_owner = NULL,
|
|
2757
|
+
lease_token = NULL, lease_expires_at = NULL,
|
|
2758
|
+
last_error_code = ?, last_error_detail_hash = ?, updated_at = ?
|
|
2759
|
+
WHERE delivery_id = ? AND status = 'leased'
|
|
2760
|
+
AND lease_owner = ? AND lease_token = ?
|
|
2761
|
+
""", (
|
|
2762
|
+
status, next_attempt, code, detail_hash, now,
|
|
2763
|
+
claim.request.delivery_id, claim.lease_owner, claim.lease_token,
|
|
2764
|
+
))
|
|
2765
|
+
if updated.rowcount != 1:
|
|
2766
|
+
raise CMSBridgeBlocked("cms.delivery.finish_lost")
|
|
2767
|
+
return self.delivery_status(claim.request.delivery_id)
|
|
2768
|
+
|
|
2769
|
+
def run_delivery(
|
|
2770
|
+
self,
|
|
2771
|
+
publisher: CMSPublisher,
|
|
2772
|
+
publication_authority: CMSMessageAuthority,
|
|
2773
|
+
*,
|
|
2774
|
+
worker_id: Any,
|
|
2775
|
+
clock: Callable[[], float] = time.time,
|
|
2776
|
+
lease_seconds: float | int = 300,
|
|
2777
|
+
operation_guard: Callable[[float], Any] | None = None,
|
|
2778
|
+
) -> DeliveryOutcome:
|
|
2779
|
+
if operation_guard is not None and not callable(operation_guard):
|
|
2780
|
+
raise CMSBridgeBlocked("cms.delivery.operation_guard_invalid")
|
|
2781
|
+
claim = self.claim_delivery(
|
|
2782
|
+
worker_id, publication_authority, now=clock(), lease_seconds=lease_seconds,
|
|
2783
|
+
)
|
|
2784
|
+
if claim is None:
|
|
2785
|
+
return DeliveryOutcome("idle")
|
|
2786
|
+
try:
|
|
2787
|
+
self._live_delivery(claim, _timestamp(clock(), "cms.time.invalid"))
|
|
2788
|
+
except CMSBridgeBlocked as error:
|
|
2789
|
+
if error.code in {
|
|
2790
|
+
"cms.delivery.event_superseded", "cms.delivery.event_cancelled",
|
|
2791
|
+
}:
|
|
2792
|
+
return DeliveryOutcome(
|
|
2793
|
+
"failed", claim.request.delivery_id, claim.attempt, error.code,
|
|
2794
|
+
)
|
|
2795
|
+
raise
|
|
2796
|
+
if operation_guard is not None:
|
|
2797
|
+
try:
|
|
2798
|
+
operation_guard(float(lease_seconds))
|
|
2799
|
+
except Exception:
|
|
2800
|
+
raise CMSBridgeBlocked(
|
|
2801
|
+
"cms.delivery.operation_guard_failed",
|
|
2802
|
+
) from None
|
|
2803
|
+
publish = getattr(publisher, "publish", None)
|
|
2804
|
+
try:
|
|
2805
|
+
if not callable(publish):
|
|
2806
|
+
raise CMSPublishFailed("publisher.invalid", retryable=False)
|
|
2807
|
+
acknowledgement = publish(claim.request)
|
|
2808
|
+
expected = {
|
|
2809
|
+
"schema": ACK_SCHEMA,
|
|
2810
|
+
"delivery_id": claim.request.delivery_id,
|
|
2811
|
+
"payload_sha256": claim.request.payload_sha256,
|
|
2812
|
+
"status": "accepted",
|
|
2813
|
+
}
|
|
2814
|
+
if not isinstance(acknowledgement, Mapping) or dict(acknowledgement) != expected:
|
|
2815
|
+
raise CMSPublishFailed("publisher.ack_invalid", retryable=True)
|
|
2816
|
+
except Exception as original_error:
|
|
2817
|
+
error = _declared_publish_failure(original_error)
|
|
2818
|
+
if error is None:
|
|
2819
|
+
error = CMSPublishFailed("publisher.unavailable", retryable=True)
|
|
2820
|
+
status = self._finish(claim, now=_timestamp(clock(), "cms.time.invalid"), error=error)
|
|
2821
|
+
return DeliveryOutcome(status.status, status.delivery_id, status.attempts, error.code)
|
|
2822
|
+
status = self._finish(claim, now=_timestamp(clock(), "cms.time.invalid"), error=None)
|
|
2823
|
+
return DeliveryOutcome(status.status, status.delivery_id, status.attempts)
|
|
2824
|
+
|
|
2825
|
+
def delivery_status(self, delivery_id: Any) -> DeliveryStatus:
|
|
2826
|
+
delivery_id = _token(delivery_id, "cms.delivery_id.invalid")
|
|
2827
|
+
row = self.connection.execute("""
|
|
2828
|
+
SELECT delivery_id, event_id, plan_id, status, attempts, max_attempts,
|
|
2829
|
+
next_attempt_at, lease_expires_at, last_error_code,
|
|
2830
|
+
last_error_detail_hash, payload_sha256
|
|
2831
|
+
FROM cms_publication_deliveries WHERE delivery_id = ?
|
|
2832
|
+
""", (delivery_id,)).fetchone()
|
|
2833
|
+
if row is None:
|
|
2834
|
+
raise CMSBridgeBlocked("cms.delivery.missing")
|
|
2835
|
+
return DeliveryStatus(**dict(row))
|