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,1441 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fail-closed reference receiver for CMS localization callbacks.
|
|
3
|
+
|
|
4
|
+
The host owns authentication, message authorities, and atomic CMS operations.
|
|
5
|
+
This module parses each exact callback, verifies it before invoking the host,
|
|
6
|
+
and signs an acknowledgement only after the host confirms the same binding.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import importlib.util
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
import unicodedata
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Callable, Mapping, Protocol, Sequence
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
MAX_REQUEST_BYTES = 4_000_000
|
|
23
|
+
MAX_RESPONSE_BYTES = 65_536
|
|
24
|
+
RECEIVER_PATH = "/v1/localization/callback"
|
|
25
|
+
RECEIVER_ERROR_SCHEMA = "blun.cms-localization-receiver-error.v1"
|
|
26
|
+
HEADER_NAME = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$")
|
|
27
|
+
TOKEN = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$")
|
|
28
|
+
SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
|
29
|
+
SIGNATURE_VALUE = re.compile(r"^[A-Za-z0-9_.:/+=-]{1,4096}$")
|
|
30
|
+
ERROR_CODE = re.compile(r"^[a-z][a-z0-9_.-]{0,127}$")
|
|
31
|
+
PUBLICATION_FIELDS = {
|
|
32
|
+
"schema", "delivery_id", "event_id", "site_id", "website_version",
|
|
33
|
+
"plan_id", "source_id", "source_revision", "source_sequence",
|
|
34
|
+
"source_sha256", "localizations",
|
|
35
|
+
}
|
|
36
|
+
LOCALIZATION_FIELDS = {
|
|
37
|
+
"locale", "target_text", "target_sha256", "approval_id",
|
|
38
|
+
"approval_expires_at", "release_evidence",
|
|
39
|
+
}
|
|
40
|
+
TOMBSTONE_FIELDS = {
|
|
41
|
+
"schema", "delivery_id", "tombstone_id", "event_id", "site_id",
|
|
42
|
+
"website_version", "plan_id", "source_id", "source_sequence",
|
|
43
|
+
"publication_delivery_id", "publication_payload_sha256", "locales",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load_module(name: str, path: Path):
|
|
48
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
49
|
+
if spec is None or spec.loader is None:
|
|
50
|
+
raise RuntimeError(f"cannot load required CMS receiver dependency: {path.name}")
|
|
51
|
+
module = importlib.util.module_from_spec(spec)
|
|
52
|
+
sys.modules[spec.name] = module
|
|
53
|
+
spec.loader.exec_module(module)
|
|
54
|
+
return module
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_ROOT = Path(__file__).resolve().parents[1]
|
|
58
|
+
_CMS = _load_module(
|
|
59
|
+
"blun_website_localization_receiver_cms",
|
|
60
|
+
_ROOT / "integrations" / "website_localization_cms.py",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class CMSReceiverBlocked(RuntimeError):
|
|
65
|
+
"""Stable, content-free receiver failure suitable for an HTTP boundary."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, code: str, *, retryable: bool, http_status: int):
|
|
68
|
+
if not isinstance(code, str) or ERROR_CODE.fullmatch(code) is None:
|
|
69
|
+
raise ValueError("CMS receiver error code is invalid")
|
|
70
|
+
if not isinstance(retryable, bool):
|
|
71
|
+
raise ValueError("CMS receiver retryability must be boolean")
|
|
72
|
+
if isinstance(http_status, bool) or http_status not in {400, 401, 409, 500, 503}:
|
|
73
|
+
raise ValueError("CMS receiver HTTP status is invalid")
|
|
74
|
+
super().__init__(code)
|
|
75
|
+
self.code = code
|
|
76
|
+
self.retryable = retryable
|
|
77
|
+
self.http_status = http_status
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class VerifiedPublication:
|
|
82
|
+
delivery_id: str
|
|
83
|
+
payload_sha256: str
|
|
84
|
+
payload: dict[str, Any]
|
|
85
|
+
signature: Any
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class PublicationExpectation:
|
|
90
|
+
event_id: str
|
|
91
|
+
site_id: str
|
|
92
|
+
website_version: str
|
|
93
|
+
plan_id: str
|
|
94
|
+
source_id: str
|
|
95
|
+
source_revision: str
|
|
96
|
+
source_sequence: int
|
|
97
|
+
source_sha256: str
|
|
98
|
+
required_locales: tuple[str, ...]
|
|
99
|
+
content_type: str
|
|
100
|
+
commercial_profile: str | None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class VerifiedTombstone:
|
|
105
|
+
delivery_id: str
|
|
106
|
+
payload_sha256: str
|
|
107
|
+
payload: dict[str, Any]
|
|
108
|
+
signature: Any
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class TombstoneExpectation:
|
|
113
|
+
tombstone_id: str
|
|
114
|
+
event_id: str
|
|
115
|
+
site_id: str
|
|
116
|
+
website_version: str
|
|
117
|
+
plan_id: str
|
|
118
|
+
source_id: str
|
|
119
|
+
source_sequence: int
|
|
120
|
+
publication_delivery_id: str
|
|
121
|
+
publication_payload_sha256: str
|
|
122
|
+
locales: tuple[str, ...]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class VerifiedHealthProbe:
|
|
127
|
+
probe_id: str
|
|
128
|
+
contract_sha256: str
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class CMSReceiverHTTPResponse:
|
|
133
|
+
status: int
|
|
134
|
+
headers: tuple[tuple[str, str], ...]
|
|
135
|
+
body: bytes
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class CMSMessageAuthority(Protocol):
|
|
139
|
+
def sign(self, payload: bytes) -> Any: ...
|
|
140
|
+
def verify(self, payload: bytes, signature: Any) -> bool: ...
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class PublicationCommitter(Protocol):
|
|
144
|
+
def __call__(self, publication: VerifiedPublication) -> Mapping[str, Any]: ...
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class TombstoneCommitter(Protocol):
|
|
148
|
+
def __call__(self, tombstone: VerifiedTombstone) -> Mapping[str, Any]: ...
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class HealthAuthenticator(Protocol):
|
|
152
|
+
def __call__(self, headers: Mapping[str, str]) -> bool: ...
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class HealthChecker(Protocol):
|
|
156
|
+
def __call__(self, probe: VerifiedHealthProbe) -> Mapping[str, Any]: ...
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class PublicationExpectationResolver(Protocol):
|
|
160
|
+
def __call__(
|
|
161
|
+
self, publication: VerifiedPublication,
|
|
162
|
+
) -> PublicationExpectation | Mapping[str, Any]: ...
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class TombstoneExpectationResolver(Protocol):
|
|
166
|
+
def __call__(
|
|
167
|
+
self, tombstone: VerifiedTombstone,
|
|
168
|
+
) -> TombstoneExpectation | Mapping[str, Any]: ...
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _canonical_json(value: Any, *, maximum: int) -> bytes:
|
|
172
|
+
try:
|
|
173
|
+
encoded = json.dumps(
|
|
174
|
+
value,
|
|
175
|
+
ensure_ascii=False,
|
|
176
|
+
allow_nan=False,
|
|
177
|
+
sort_keys=True,
|
|
178
|
+
separators=(",", ":"),
|
|
179
|
+
).encode("utf-8")
|
|
180
|
+
except (TypeError, ValueError, RecursionError):
|
|
181
|
+
raise CMSReceiverBlocked(
|
|
182
|
+
"receiver.json_invalid", retryable=False, http_status=400,
|
|
183
|
+
) from None
|
|
184
|
+
if not encoded or len(encoded) > maximum:
|
|
185
|
+
raise CMSReceiverBlocked(
|
|
186
|
+
"receiver.message_size", retryable=False, http_status=400,
|
|
187
|
+
)
|
|
188
|
+
return encoded
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _pairs(items):
|
|
192
|
+
result = {}
|
|
193
|
+
for key, value in items:
|
|
194
|
+
if key in result:
|
|
195
|
+
raise ValueError("duplicate JSON key")
|
|
196
|
+
result[key] = value
|
|
197
|
+
return result
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _constant(value):
|
|
201
|
+
raise ValueError("non-finite JSON number")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _parse_body(body: Any) -> tuple[dict[str, Any], bytes]:
|
|
205
|
+
if not isinstance(body, bytes) or not body or len(body) > MAX_REQUEST_BYTES:
|
|
206
|
+
raise CMSReceiverBlocked(
|
|
207
|
+
"receiver.message_size", retryable=False, http_status=400,
|
|
208
|
+
)
|
|
209
|
+
try:
|
|
210
|
+
text = body.decode("utf-8")
|
|
211
|
+
if text.startswith("\ufeff"):
|
|
212
|
+
raise ValueError("BOM rejected")
|
|
213
|
+
value = json.loads(
|
|
214
|
+
text,
|
|
215
|
+
object_pairs_hook=_pairs,
|
|
216
|
+
parse_constant=_constant,
|
|
217
|
+
)
|
|
218
|
+
except (UnicodeDecodeError, json.JSONDecodeError, ValueError, RecursionError):
|
|
219
|
+
raise CMSReceiverBlocked(
|
|
220
|
+
"receiver.json_invalid", retryable=False, http_status=400,
|
|
221
|
+
) from None
|
|
222
|
+
if not isinstance(value, dict):
|
|
223
|
+
raise CMSReceiverBlocked(
|
|
224
|
+
"receiver.request_invalid", retryable=False, http_status=400,
|
|
225
|
+
)
|
|
226
|
+
canonical = _canonical_json(value, maximum=MAX_REQUEST_BYTES)
|
|
227
|
+
if canonical != body:
|
|
228
|
+
raise CMSReceiverBlocked(
|
|
229
|
+
"receiver.request_noncanonical", retryable=False, http_status=400,
|
|
230
|
+
)
|
|
231
|
+
return value, canonical
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _headers(value: Any) -> dict[str, str]:
|
|
235
|
+
if isinstance(value, Mapping):
|
|
236
|
+
items = tuple(value.items())
|
|
237
|
+
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
238
|
+
items = tuple(value)
|
|
239
|
+
else:
|
|
240
|
+
raise CMSReceiverBlocked(
|
|
241
|
+
"receiver.headers_invalid", retryable=False, http_status=400,
|
|
242
|
+
)
|
|
243
|
+
headers: dict[str, str] = {}
|
|
244
|
+
for item in items:
|
|
245
|
+
if (
|
|
246
|
+
not isinstance(item, Sequence)
|
|
247
|
+
or isinstance(item, (str, bytes, bytearray))
|
|
248
|
+
or len(item) != 2
|
|
249
|
+
):
|
|
250
|
+
raise CMSReceiverBlocked(
|
|
251
|
+
"receiver.headers_invalid", retryable=False, http_status=400,
|
|
252
|
+
)
|
|
253
|
+
name, content = item
|
|
254
|
+
if (
|
|
255
|
+
not isinstance(name, str)
|
|
256
|
+
or HEADER_NAME.fullmatch(name) is None
|
|
257
|
+
or not isinstance(content, str)
|
|
258
|
+
or "\r" in content
|
|
259
|
+
or "\n" in content
|
|
260
|
+
):
|
|
261
|
+
raise CMSReceiverBlocked(
|
|
262
|
+
"receiver.headers_invalid", retryable=False, http_status=400,
|
|
263
|
+
)
|
|
264
|
+
normalized = name.lower()
|
|
265
|
+
if normalized in headers:
|
|
266
|
+
raise CMSReceiverBlocked(
|
|
267
|
+
"receiver.headers_ambiguous", retryable=False, http_status=400,
|
|
268
|
+
)
|
|
269
|
+
headers[normalized] = content.strip()
|
|
270
|
+
content_type = headers.get("content-type", "").lower().replace(" ", "")
|
|
271
|
+
if content_type != "application/json;charset=utf-8":
|
|
272
|
+
raise CMSReceiverBlocked(
|
|
273
|
+
"receiver.content_type", retryable=False, http_status=400,
|
|
274
|
+
)
|
|
275
|
+
if "transfer-encoding" in headers:
|
|
276
|
+
raise CMSReceiverBlocked(
|
|
277
|
+
"receiver.framing_invalid", retryable=False, http_status=400,
|
|
278
|
+
)
|
|
279
|
+
return headers
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _signature(value: Any) -> Any:
|
|
283
|
+
if not isinstance(value, dict) or set(value) != {
|
|
284
|
+
"algorithm", "key_id", "signature",
|
|
285
|
+
}:
|
|
286
|
+
raise CMSReceiverBlocked(
|
|
287
|
+
"receiver.signature_invalid", retryable=False, http_status=401,
|
|
288
|
+
)
|
|
289
|
+
if (
|
|
290
|
+
not isinstance(value["algorithm"], str)
|
|
291
|
+
or TOKEN.fullmatch(value["algorithm"]) is None
|
|
292
|
+
or not isinstance(value["key_id"], str)
|
|
293
|
+
or TOKEN.fullmatch(value["key_id"]) is None
|
|
294
|
+
or not isinstance(value["signature"], str)
|
|
295
|
+
or SIGNATURE_VALUE.fullmatch(value["signature"]) is None
|
|
296
|
+
):
|
|
297
|
+
raise CMSReceiverBlocked(
|
|
298
|
+
"receiver.signature_invalid", retryable=False, http_status=401,
|
|
299
|
+
)
|
|
300
|
+
return _CMS.CMSMessageSignature(
|
|
301
|
+
value["algorithm"], value["key_id"], value["signature"],
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _timestamp(value: Any) -> float:
|
|
306
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
307
|
+
raise CMSReceiverBlocked(
|
|
308
|
+
"receiver.publication_invalid", retryable=False, http_status=400,
|
|
309
|
+
)
|
|
310
|
+
result = float(value)
|
|
311
|
+
if result < 0 or result != result or result in {float("inf"), float("-inf")}:
|
|
312
|
+
raise CMSReceiverBlocked(
|
|
313
|
+
"receiver.publication_invalid", retryable=False, http_status=400,
|
|
314
|
+
)
|
|
315
|
+
return result
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _verify_publication(value: Any, payload_sha256: str, *, now: float) -> bytes:
|
|
319
|
+
if not isinstance(value, dict) or set(value) != PUBLICATION_FIELDS:
|
|
320
|
+
raise CMSReceiverBlocked(
|
|
321
|
+
"receiver.publication_invalid", retryable=False, http_status=400,
|
|
322
|
+
)
|
|
323
|
+
token_fields = (
|
|
324
|
+
"delivery_id", "event_id", "site_id", "website_version", "plan_id",
|
|
325
|
+
"source_id", "source_revision",
|
|
326
|
+
)
|
|
327
|
+
if (
|
|
328
|
+
value.get("schema") != _CMS.PUBLICATION_SCHEMA
|
|
329
|
+
or any(
|
|
330
|
+
not isinstance(value.get(field), str)
|
|
331
|
+
or TOKEN.fullmatch(value[field]) is None
|
|
332
|
+
for field in token_fields
|
|
333
|
+
)
|
|
334
|
+
or SHA256.fullmatch(str(value.get("source_sha256"))) is None
|
|
335
|
+
or isinstance(value.get("source_sequence"), bool)
|
|
336
|
+
or not isinstance(value.get("source_sequence"), int)
|
|
337
|
+
or value["source_sequence"] <= 0
|
|
338
|
+
):
|
|
339
|
+
raise CMSReceiverBlocked(
|
|
340
|
+
"receiver.publication_invalid", retryable=False, http_status=400,
|
|
341
|
+
)
|
|
342
|
+
localizations = value.get("localizations")
|
|
343
|
+
if not isinstance(localizations, list) or not localizations:
|
|
344
|
+
raise CMSReceiverBlocked(
|
|
345
|
+
"receiver.bundle_incomplete", retryable=False, http_status=409,
|
|
346
|
+
)
|
|
347
|
+
locales: list[str] = []
|
|
348
|
+
for item in localizations:
|
|
349
|
+
if not isinstance(item, dict) or set(item) != LOCALIZATION_FIELDS:
|
|
350
|
+
raise CMSReceiverBlocked(
|
|
351
|
+
"receiver.localization_invalid", retryable=False, http_status=400,
|
|
352
|
+
)
|
|
353
|
+
locale = item.get("locale")
|
|
354
|
+
target = item.get("target_text")
|
|
355
|
+
target_sha256 = item.get("target_sha256")
|
|
356
|
+
approval_id = item.get("approval_id")
|
|
357
|
+
if (
|
|
358
|
+
not isinstance(locale, str)
|
|
359
|
+
or TOKEN.fullmatch(locale) is None
|
|
360
|
+
or not isinstance(target, str)
|
|
361
|
+
or not target
|
|
362
|
+
or not unicodedata.is_normalized("NFC", target)
|
|
363
|
+
or SHA256.fullmatch(str(target_sha256)) is None
|
|
364
|
+
or hashlib.sha256(target.encode("utf-8")).hexdigest() != target_sha256
|
|
365
|
+
or not isinstance(approval_id, str)
|
|
366
|
+
or TOKEN.fullmatch(approval_id) is None
|
|
367
|
+
or _timestamp(item.get("approval_expires_at")) <= now
|
|
368
|
+
or not _CMS._valid_release_evidence(
|
|
369
|
+
item.get("release_evidence"),
|
|
370
|
+
locale=locale,
|
|
371
|
+
target_sha256=target_sha256,
|
|
372
|
+
approval_id=approval_id,
|
|
373
|
+
)
|
|
374
|
+
):
|
|
375
|
+
raise CMSReceiverBlocked(
|
|
376
|
+
"receiver.localization_invalid", retryable=False, http_status=400,
|
|
377
|
+
)
|
|
378
|
+
locales.append(locale)
|
|
379
|
+
if locales != sorted(set(locales)):
|
|
380
|
+
raise CMSReceiverBlocked(
|
|
381
|
+
"receiver.bundle_invalid", retryable=False, http_status=409,
|
|
382
|
+
)
|
|
383
|
+
payload_bytes = _canonical_json(value, maximum=MAX_REQUEST_BYTES)
|
|
384
|
+
if hashlib.sha256(payload_bytes).hexdigest() != payload_sha256:
|
|
385
|
+
raise CMSReceiverBlocked(
|
|
386
|
+
"receiver.payload_binding", retryable=False, http_status=409,
|
|
387
|
+
)
|
|
388
|
+
unsigned = {key: content for key, content in value.items() if key != "delivery_id"}
|
|
389
|
+
expected_delivery_id = "blun-cms-delivery-" + hashlib.sha256(
|
|
390
|
+
_canonical_json(unsigned, maximum=MAX_REQUEST_BYTES)
|
|
391
|
+
).hexdigest()
|
|
392
|
+
if value["delivery_id"] != expected_delivery_id:
|
|
393
|
+
raise CMSReceiverBlocked(
|
|
394
|
+
"receiver.delivery_binding", retryable=False, http_status=409,
|
|
395
|
+
)
|
|
396
|
+
return payload_bytes
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _verify_expectation(
|
|
400
|
+
publication: dict[str, Any],
|
|
401
|
+
expectation: PublicationExpectation | Mapping[str, Any],
|
|
402
|
+
) -> None:
|
|
403
|
+
if isinstance(expectation, Mapping):
|
|
404
|
+
fields = tuple(PublicationExpectation.__dataclass_fields__)
|
|
405
|
+
if set(expectation) != set(fields):
|
|
406
|
+
raise ValueError("expectation is invalid")
|
|
407
|
+
try:
|
|
408
|
+
expectation = PublicationExpectation(**dict(expectation))
|
|
409
|
+
except TypeError:
|
|
410
|
+
raise ValueError("expectation is invalid") from None
|
|
411
|
+
if not isinstance(expectation, PublicationExpectation):
|
|
412
|
+
raise TypeError("expectation must be PublicationExpectation or an exact mapping")
|
|
413
|
+
token_fields = (
|
|
414
|
+
"event_id", "site_id", "website_version", "plan_id", "source_id",
|
|
415
|
+
"source_revision", "content_type",
|
|
416
|
+
)
|
|
417
|
+
if (
|
|
418
|
+
any(
|
|
419
|
+
not isinstance(getattr(expectation, field), str)
|
|
420
|
+
or TOKEN.fullmatch(getattr(expectation, field)) is None
|
|
421
|
+
for field in token_fields
|
|
422
|
+
)
|
|
423
|
+
or expectation.content_type not in _CMS._PLANNER.CONTENT_TYPES
|
|
424
|
+
or isinstance(expectation.source_sequence, bool)
|
|
425
|
+
or not isinstance(expectation.source_sequence, int)
|
|
426
|
+
or expectation.source_sequence <= 0
|
|
427
|
+
or not isinstance(expectation.source_sha256, str)
|
|
428
|
+
or SHA256.fullmatch(expectation.source_sha256) is None
|
|
429
|
+
or not isinstance(expectation.required_locales, tuple)
|
|
430
|
+
or not expectation.required_locales
|
|
431
|
+
or expectation.required_locales
|
|
432
|
+
!= tuple(sorted(set(expectation.required_locales)))
|
|
433
|
+
or any(
|
|
434
|
+
not isinstance(locale, str) or TOKEN.fullmatch(locale) is None
|
|
435
|
+
for locale in expectation.required_locales
|
|
436
|
+
)
|
|
437
|
+
or (
|
|
438
|
+
expectation.commercial_profile is not None
|
|
439
|
+
and (
|
|
440
|
+
not isinstance(expectation.commercial_profile, str)
|
|
441
|
+
or TOKEN.fullmatch(expectation.commercial_profile) is None
|
|
442
|
+
)
|
|
443
|
+
)
|
|
444
|
+
or (expectation.content_type == "commercial")
|
|
445
|
+
!= (expectation.commercial_profile is not None)
|
|
446
|
+
):
|
|
447
|
+
raise ValueError("expectation is invalid")
|
|
448
|
+
expected_binding = {
|
|
449
|
+
"event_id": expectation.event_id,
|
|
450
|
+
"site_id": expectation.site_id,
|
|
451
|
+
"website_version": expectation.website_version,
|
|
452
|
+
"plan_id": expectation.plan_id,
|
|
453
|
+
"source_id": expectation.source_id,
|
|
454
|
+
"source_revision": expectation.source_revision,
|
|
455
|
+
"source_sequence": expectation.source_sequence,
|
|
456
|
+
"source_sha256": expectation.source_sha256,
|
|
457
|
+
}
|
|
458
|
+
if any(publication.get(field) != value for field, value in expected_binding.items()):
|
|
459
|
+
raise CMSReceiverBlocked(
|
|
460
|
+
"receiver.source_binding", retryable=False, http_status=409,
|
|
461
|
+
)
|
|
462
|
+
localizations = publication["localizations"]
|
|
463
|
+
if tuple(item["locale"] for item in localizations) != expectation.required_locales:
|
|
464
|
+
raise CMSReceiverBlocked(
|
|
465
|
+
"receiver.bundle_incomplete", retryable=False, http_status=409,
|
|
466
|
+
)
|
|
467
|
+
for item in localizations:
|
|
468
|
+
evidence = item["release_evidence"]
|
|
469
|
+
if (
|
|
470
|
+
evidence["content_type"] != expectation.content_type
|
|
471
|
+
or evidence["commercial_profile"] != expectation.commercial_profile
|
|
472
|
+
):
|
|
473
|
+
raise CMSReceiverBlocked(
|
|
474
|
+
"receiver.release_scope", retryable=False, http_status=409,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _verify_tombstone(value: Any, payload_sha256: str) -> bytes:
|
|
479
|
+
if not isinstance(value, dict) or set(value) != TOMBSTONE_FIELDS:
|
|
480
|
+
raise CMSReceiverBlocked(
|
|
481
|
+
"receiver.tombstone_invalid", retryable=False, http_status=400,
|
|
482
|
+
)
|
|
483
|
+
token_fields = (
|
|
484
|
+
"delivery_id", "tombstone_id", "event_id", "site_id",
|
|
485
|
+
"website_version", "plan_id", "source_id", "publication_delivery_id",
|
|
486
|
+
)
|
|
487
|
+
locales = value.get("locales")
|
|
488
|
+
if (
|
|
489
|
+
value.get("schema") != _CMS.TOMBSTONE_DELIVERY_SCHEMA
|
|
490
|
+
or any(
|
|
491
|
+
not isinstance(value.get(field), str)
|
|
492
|
+
or TOKEN.fullmatch(value[field]) is None
|
|
493
|
+
for field in token_fields
|
|
494
|
+
)
|
|
495
|
+
or isinstance(value.get("source_sequence"), bool)
|
|
496
|
+
or not isinstance(value.get("source_sequence"), int)
|
|
497
|
+
or value["source_sequence"] <= 0
|
|
498
|
+
or not isinstance(value.get("publication_payload_sha256"), str)
|
|
499
|
+
or SHA256.fullmatch(value["publication_payload_sha256"]) is None
|
|
500
|
+
or not isinstance(locales, list)
|
|
501
|
+
or not locales
|
|
502
|
+
or locales != sorted(set(locales))
|
|
503
|
+
or any(
|
|
504
|
+
not isinstance(locale, str) or TOKEN.fullmatch(locale) is None
|
|
505
|
+
for locale in locales
|
|
506
|
+
)
|
|
507
|
+
):
|
|
508
|
+
raise CMSReceiverBlocked(
|
|
509
|
+
"receiver.tombstone_invalid", retryable=False, http_status=400,
|
|
510
|
+
)
|
|
511
|
+
payload_bytes = _canonical_json(value, maximum=MAX_REQUEST_BYTES)
|
|
512
|
+
if hashlib.sha256(payload_bytes).hexdigest() != payload_sha256:
|
|
513
|
+
raise CMSReceiverBlocked(
|
|
514
|
+
"receiver.payload_binding", retryable=False, http_status=409,
|
|
515
|
+
)
|
|
516
|
+
unsigned = {key: content for key, content in value.items() if key != "delivery_id"}
|
|
517
|
+
expected_delivery_id = "blun-cms-tombstone-" + hashlib.sha256(
|
|
518
|
+
_canonical_json(unsigned, maximum=MAX_REQUEST_BYTES)
|
|
519
|
+
).hexdigest()
|
|
520
|
+
if value["delivery_id"] != expected_delivery_id:
|
|
521
|
+
raise CMSReceiverBlocked(
|
|
522
|
+
"receiver.delivery_binding", retryable=False, http_status=409,
|
|
523
|
+
)
|
|
524
|
+
return payload_bytes
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _verify_tombstone_expectation(
|
|
528
|
+
tombstone: dict[str, Any],
|
|
529
|
+
expectation: TombstoneExpectation | Mapping[str, Any],
|
|
530
|
+
) -> None:
|
|
531
|
+
if isinstance(expectation, Mapping):
|
|
532
|
+
fields = tuple(TombstoneExpectation.__dataclass_fields__)
|
|
533
|
+
if set(expectation) != set(fields):
|
|
534
|
+
raise ValueError("expectation is invalid")
|
|
535
|
+
try:
|
|
536
|
+
expectation = TombstoneExpectation(**dict(expectation))
|
|
537
|
+
except TypeError:
|
|
538
|
+
raise ValueError("expectation is invalid") from None
|
|
539
|
+
if not isinstance(expectation, TombstoneExpectation):
|
|
540
|
+
raise TypeError(
|
|
541
|
+
"expectation must be TombstoneExpectation or an exact mapping"
|
|
542
|
+
)
|
|
543
|
+
token_fields = (
|
|
544
|
+
"tombstone_id", "event_id", "site_id", "website_version", "plan_id",
|
|
545
|
+
"source_id", "publication_delivery_id",
|
|
546
|
+
)
|
|
547
|
+
if (
|
|
548
|
+
any(
|
|
549
|
+
not isinstance(getattr(expectation, field), str)
|
|
550
|
+
or TOKEN.fullmatch(getattr(expectation, field)) is None
|
|
551
|
+
for field in token_fields
|
|
552
|
+
)
|
|
553
|
+
or isinstance(expectation.source_sequence, bool)
|
|
554
|
+
or not isinstance(expectation.source_sequence, int)
|
|
555
|
+
or expectation.source_sequence <= 0
|
|
556
|
+
or not isinstance(expectation.publication_payload_sha256, str)
|
|
557
|
+
or SHA256.fullmatch(expectation.publication_payload_sha256) is None
|
|
558
|
+
or not isinstance(expectation.locales, tuple)
|
|
559
|
+
or not expectation.locales
|
|
560
|
+
or expectation.locales != tuple(sorted(set(expectation.locales)))
|
|
561
|
+
or any(
|
|
562
|
+
not isinstance(locale, str) or TOKEN.fullmatch(locale) is None
|
|
563
|
+
for locale in expectation.locales
|
|
564
|
+
)
|
|
565
|
+
):
|
|
566
|
+
raise ValueError("expectation is invalid")
|
|
567
|
+
expected = {
|
|
568
|
+
"tombstone_id": expectation.tombstone_id,
|
|
569
|
+
"event_id": expectation.event_id,
|
|
570
|
+
"site_id": expectation.site_id,
|
|
571
|
+
"website_version": expectation.website_version,
|
|
572
|
+
"plan_id": expectation.plan_id,
|
|
573
|
+
"source_id": expectation.source_id,
|
|
574
|
+
"source_sequence": expectation.source_sequence,
|
|
575
|
+
"publication_delivery_id": expectation.publication_delivery_id,
|
|
576
|
+
"publication_payload_sha256": expectation.publication_payload_sha256,
|
|
577
|
+
"locales": list(expectation.locales),
|
|
578
|
+
}
|
|
579
|
+
if any(tombstone.get(field) != content for field, content in expected.items()):
|
|
580
|
+
raise CMSReceiverBlocked(
|
|
581
|
+
"receiver.tombstone_binding", retryable=False, http_status=409,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _verify_publication_transport(
|
|
586
|
+
body: bytes,
|
|
587
|
+
headers: Any,
|
|
588
|
+
publication_authority: CMSMessageAuthority,
|
|
589
|
+
*,
|
|
590
|
+
now: float | int,
|
|
591
|
+
) -> VerifiedPublication:
|
|
592
|
+
if not callable(getattr(publication_authority, "verify", None)):
|
|
593
|
+
raise TypeError("publication_authority must provide verify")
|
|
594
|
+
now = _timestamp(now)
|
|
595
|
+
envelope, _ = _parse_body(body)
|
|
596
|
+
parsed_headers = _headers(headers)
|
|
597
|
+
if set(envelope) != {"schema", "payload_sha256", "publication", "signature"}:
|
|
598
|
+
raise CMSReceiverBlocked(
|
|
599
|
+
"receiver.request_invalid", retryable=False, http_status=400,
|
|
600
|
+
)
|
|
601
|
+
payload_sha256 = envelope.get("payload_sha256")
|
|
602
|
+
if (
|
|
603
|
+
envelope.get("schema") != _CMS.PUBLICATION_HTTP_REQUEST_SCHEMA
|
|
604
|
+
or not isinstance(payload_sha256, str)
|
|
605
|
+
or SHA256.fullmatch(payload_sha256) is None
|
|
606
|
+
):
|
|
607
|
+
raise CMSReceiverBlocked(
|
|
608
|
+
"receiver.request_invalid", retryable=False, http_status=400,
|
|
609
|
+
)
|
|
610
|
+
payload_bytes = _verify_publication(
|
|
611
|
+
envelope.get("publication"), payload_sha256, now=now,
|
|
612
|
+
)
|
|
613
|
+
publication = envelope["publication"]
|
|
614
|
+
bindings = {
|
|
615
|
+
"delivery_id": publication["delivery_id"],
|
|
616
|
+
"payload_sha256": payload_sha256,
|
|
617
|
+
}
|
|
618
|
+
for name, binding in _CMS.PUBLICATION_HTTP_BINDING_HEADERS:
|
|
619
|
+
if parsed_headers.get(name.lower()) != bindings.get(binding):
|
|
620
|
+
raise CMSReceiverBlocked(
|
|
621
|
+
"receiver.header_binding", retryable=False, http_status=409,
|
|
622
|
+
)
|
|
623
|
+
declared_length = parsed_headers.get("content-length")
|
|
624
|
+
if declared_length is not None and (
|
|
625
|
+
not declared_length.isascii()
|
|
626
|
+
or not declared_length.isdecimal()
|
|
627
|
+
or int(declared_length) != len(body)
|
|
628
|
+
):
|
|
629
|
+
raise CMSReceiverBlocked(
|
|
630
|
+
"receiver.framing_invalid", retryable=False, http_status=400,
|
|
631
|
+
)
|
|
632
|
+
signature = _signature(envelope.get("signature"))
|
|
633
|
+
try:
|
|
634
|
+
verified = publication_authority.verify(payload_bytes, signature) is True
|
|
635
|
+
except Exception:
|
|
636
|
+
verified = False
|
|
637
|
+
if not verified:
|
|
638
|
+
raise CMSReceiverBlocked(
|
|
639
|
+
"receiver.signature_invalid", retryable=False, http_status=401,
|
|
640
|
+
)
|
|
641
|
+
return VerifiedPublication(
|
|
642
|
+
delivery_id=publication["delivery_id"],
|
|
643
|
+
payload_sha256=payload_sha256,
|
|
644
|
+
payload=json.loads(payload_bytes.decode("utf-8")),
|
|
645
|
+
signature=signature,
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def verify_publication_request(
|
|
650
|
+
body: bytes,
|
|
651
|
+
headers: Any,
|
|
652
|
+
publication_authority: CMSMessageAuthority,
|
|
653
|
+
expectation: PublicationExpectation,
|
|
654
|
+
*,
|
|
655
|
+
now: float | int,
|
|
656
|
+
) -> VerifiedPublication:
|
|
657
|
+
"""Verify one exact publication callback without performing a write."""
|
|
658
|
+
|
|
659
|
+
publication = _verify_publication_transport(
|
|
660
|
+
body, headers, publication_authority, now=now,
|
|
661
|
+
)
|
|
662
|
+
_verify_expectation(publication.payload, expectation)
|
|
663
|
+
return publication
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _verify_tombstone_transport(
|
|
667
|
+
body: bytes,
|
|
668
|
+
headers: Any,
|
|
669
|
+
publication_authority: CMSMessageAuthority,
|
|
670
|
+
) -> VerifiedTombstone:
|
|
671
|
+
if not callable(getattr(publication_authority, "verify", None)):
|
|
672
|
+
raise TypeError("publication_authority must provide verify")
|
|
673
|
+
envelope, _ = _parse_body(body)
|
|
674
|
+
parsed_headers = _headers(headers)
|
|
675
|
+
if set(envelope) != {"schema", "payload_sha256", "tombstone", "signature"}:
|
|
676
|
+
raise CMSReceiverBlocked(
|
|
677
|
+
"receiver.request_invalid", retryable=False, http_status=400,
|
|
678
|
+
)
|
|
679
|
+
payload_sha256 = envelope.get("payload_sha256")
|
|
680
|
+
if (
|
|
681
|
+
envelope.get("schema") != _CMS.TOMBSTONE_HTTP_REQUEST_SCHEMA
|
|
682
|
+
or not isinstance(payload_sha256, str)
|
|
683
|
+
or SHA256.fullmatch(payload_sha256) is None
|
|
684
|
+
):
|
|
685
|
+
raise CMSReceiverBlocked(
|
|
686
|
+
"receiver.request_invalid", retryable=False, http_status=400,
|
|
687
|
+
)
|
|
688
|
+
payload_bytes = _verify_tombstone(envelope.get("tombstone"), payload_sha256)
|
|
689
|
+
tombstone = envelope["tombstone"]
|
|
690
|
+
bindings = {
|
|
691
|
+
"delivery_id": tombstone["delivery_id"],
|
|
692
|
+
"payload_sha256": payload_sha256,
|
|
693
|
+
}
|
|
694
|
+
for name, binding in _CMS.PUBLICATION_HTTP_BINDING_HEADERS:
|
|
695
|
+
if parsed_headers.get(name.lower()) != bindings.get(binding):
|
|
696
|
+
raise CMSReceiverBlocked(
|
|
697
|
+
"receiver.header_binding", retryable=False, http_status=409,
|
|
698
|
+
)
|
|
699
|
+
declared_length = parsed_headers.get("content-length")
|
|
700
|
+
if declared_length is not None and (
|
|
701
|
+
not declared_length.isascii()
|
|
702
|
+
or not declared_length.isdecimal()
|
|
703
|
+
or int(declared_length) != len(body)
|
|
704
|
+
):
|
|
705
|
+
raise CMSReceiverBlocked(
|
|
706
|
+
"receiver.framing_invalid", retryable=False, http_status=400,
|
|
707
|
+
)
|
|
708
|
+
signature = _signature(envelope.get("signature"))
|
|
709
|
+
try:
|
|
710
|
+
verified = publication_authority.verify(payload_bytes, signature) is True
|
|
711
|
+
except Exception:
|
|
712
|
+
verified = False
|
|
713
|
+
if not verified:
|
|
714
|
+
raise CMSReceiverBlocked(
|
|
715
|
+
"receiver.signature_invalid", retryable=False, http_status=401,
|
|
716
|
+
)
|
|
717
|
+
return VerifiedTombstone(
|
|
718
|
+
delivery_id=tombstone["delivery_id"],
|
|
719
|
+
payload_sha256=payload_sha256,
|
|
720
|
+
payload=json.loads(payload_bytes.decode("utf-8")),
|
|
721
|
+
signature=signature,
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def verify_tombstone_request(
|
|
726
|
+
body: bytes,
|
|
727
|
+
headers: Any,
|
|
728
|
+
publication_authority: CMSMessageAuthority,
|
|
729
|
+
expectation: TombstoneExpectation,
|
|
730
|
+
) -> VerifiedTombstone:
|
|
731
|
+
"""Verify one exact tombstone callback without deleting CMS content."""
|
|
732
|
+
|
|
733
|
+
tombstone = _verify_tombstone_transport(
|
|
734
|
+
body, headers, publication_authority,
|
|
735
|
+
)
|
|
736
|
+
_verify_tombstone_expectation(tombstone.payload, expectation)
|
|
737
|
+
return tombstone
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def verify_health_request(
|
|
741
|
+
body: bytes,
|
|
742
|
+
headers: Any,
|
|
743
|
+
authenticate: Callable[[Mapping[str, str]], bool],
|
|
744
|
+
*,
|
|
745
|
+
contract_sha256: str,
|
|
746
|
+
) -> VerifiedHealthProbe:
|
|
747
|
+
"""Authenticate and verify one content-free publisher health challenge."""
|
|
748
|
+
|
|
749
|
+
if not callable(authenticate):
|
|
750
|
+
raise TypeError("authenticate must be callable")
|
|
751
|
+
if (
|
|
752
|
+
not isinstance(contract_sha256, str)
|
|
753
|
+
or SHA256.fullmatch(contract_sha256) is None
|
|
754
|
+
):
|
|
755
|
+
raise ValueError("contract_sha256 is invalid")
|
|
756
|
+
envelope, _ = _parse_body(body)
|
|
757
|
+
parsed_headers = _headers(headers)
|
|
758
|
+
if set(envelope) != {"schema", "probe"}:
|
|
759
|
+
raise CMSReceiverBlocked(
|
|
760
|
+
"receiver.health_request_invalid", retryable=False, http_status=400,
|
|
761
|
+
)
|
|
762
|
+
probe = envelope.get("probe")
|
|
763
|
+
if (
|
|
764
|
+
envelope.get("schema") != _CMS.PUBLICATION_HEALTH_HTTP_REQUEST_SCHEMA
|
|
765
|
+
or not isinstance(probe, dict)
|
|
766
|
+
or set(probe) != {"schema", "probe_id", "contract_sha256"}
|
|
767
|
+
or probe.get("schema") != _CMS.PUBLICATION_HEALTH_SCHEMA
|
|
768
|
+
or not isinstance(probe.get("probe_id"), str)
|
|
769
|
+
or not 16 <= len(probe["probe_id"]) <= 256
|
|
770
|
+
or TOKEN.fullmatch(probe["probe_id"]) is None
|
|
771
|
+
or not isinstance(probe.get("contract_sha256"), str)
|
|
772
|
+
or SHA256.fullmatch(probe["contract_sha256"]) is None
|
|
773
|
+
):
|
|
774
|
+
raise CMSReceiverBlocked(
|
|
775
|
+
"receiver.health_request_invalid", retryable=False, http_status=400,
|
|
776
|
+
)
|
|
777
|
+
bindings = {
|
|
778
|
+
"probe_id": probe["probe_id"],
|
|
779
|
+
"contract_sha256": probe["contract_sha256"],
|
|
780
|
+
}
|
|
781
|
+
for name, binding in _CMS.PUBLICATION_HEALTH_HTTP_BINDING_HEADERS:
|
|
782
|
+
if parsed_headers.get(name.lower()) != bindings.get(binding):
|
|
783
|
+
raise CMSReceiverBlocked(
|
|
784
|
+
"receiver.health_header_binding", retryable=False, http_status=409,
|
|
785
|
+
)
|
|
786
|
+
declared_length = parsed_headers.get("content-length")
|
|
787
|
+
if declared_length is not None and (
|
|
788
|
+
not declared_length.isascii()
|
|
789
|
+
or not declared_length.isdecimal()
|
|
790
|
+
or int(declared_length) != len(body)
|
|
791
|
+
):
|
|
792
|
+
raise CMSReceiverBlocked(
|
|
793
|
+
"receiver.framing_invalid", retryable=False, http_status=400,
|
|
794
|
+
)
|
|
795
|
+
try:
|
|
796
|
+
authenticated = authenticate(dict(parsed_headers)) is True
|
|
797
|
+
except Exception:
|
|
798
|
+
raise CMSReceiverBlocked(
|
|
799
|
+
"receiver.authentication_failed", retryable=True, http_status=503,
|
|
800
|
+
) from None
|
|
801
|
+
if not authenticated:
|
|
802
|
+
raise CMSReceiverBlocked(
|
|
803
|
+
"receiver.authentication_invalid", retryable=False, http_status=401,
|
|
804
|
+
)
|
|
805
|
+
if probe["contract_sha256"] != contract_sha256:
|
|
806
|
+
raise CMSReceiverBlocked(
|
|
807
|
+
"receiver.health_contract_binding", retryable=False, http_status=409,
|
|
808
|
+
)
|
|
809
|
+
return VerifiedHealthProbe(
|
|
810
|
+
probe_id=probe["probe_id"],
|
|
811
|
+
contract_sha256=probe["contract_sha256"],
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _signed_acknowledgement(
|
|
816
|
+
*,
|
|
817
|
+
delivery_id: str,
|
|
818
|
+
payload_sha256: str,
|
|
819
|
+
acknowledgement_schema: str,
|
|
820
|
+
status: str,
|
|
821
|
+
response_schema: str,
|
|
822
|
+
authority: CMSMessageAuthority,
|
|
823
|
+
) -> CMSReceiverHTTPResponse:
|
|
824
|
+
acknowledgement = {
|
|
825
|
+
"schema": acknowledgement_schema,
|
|
826
|
+
"delivery_id": delivery_id,
|
|
827
|
+
"payload_sha256": payload_sha256,
|
|
828
|
+
"status": status,
|
|
829
|
+
}
|
|
830
|
+
acknowledgement_bytes = _canonical_json(
|
|
831
|
+
acknowledgement, maximum=MAX_RESPONSE_BYTES,
|
|
832
|
+
)
|
|
833
|
+
try:
|
|
834
|
+
signature = authority.sign(acknowledgement_bytes)
|
|
835
|
+
signature_mapping = {
|
|
836
|
+
"algorithm": signature.algorithm,
|
|
837
|
+
"key_id": signature.key_id,
|
|
838
|
+
"signature": signature.signature,
|
|
839
|
+
}
|
|
840
|
+
normalized_signature = _signature(signature_mapping)
|
|
841
|
+
verified = authority.verify(
|
|
842
|
+
acknowledgement_bytes, normalized_signature,
|
|
843
|
+
) is True
|
|
844
|
+
except Exception:
|
|
845
|
+
raise CMSReceiverBlocked(
|
|
846
|
+
"receiver.acknowledgement_signing", retryable=True, http_status=503,
|
|
847
|
+
) from None
|
|
848
|
+
if not verified:
|
|
849
|
+
raise CMSReceiverBlocked(
|
|
850
|
+
"receiver.acknowledgement_signing", retryable=True, http_status=503,
|
|
851
|
+
)
|
|
852
|
+
response = {
|
|
853
|
+
"schema": response_schema,
|
|
854
|
+
"acknowledgement": acknowledgement,
|
|
855
|
+
"signature": signature_mapping,
|
|
856
|
+
}
|
|
857
|
+
response_body = _canonical_json(response, maximum=MAX_RESPONSE_BYTES)
|
|
858
|
+
return CMSReceiverHTTPResponse(
|
|
859
|
+
status=200,
|
|
860
|
+
headers=(
|
|
861
|
+
("Content-Type", "application/json; charset=utf-8"),
|
|
862
|
+
("Content-Length", str(len(response_body))),
|
|
863
|
+
("Cache-Control", "no-store"),
|
|
864
|
+
),
|
|
865
|
+
body=response_body,
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _signed_health_acknowledgement(
|
|
870
|
+
probe: VerifiedHealthProbe,
|
|
871
|
+
authority: CMSMessageAuthority,
|
|
872
|
+
) -> CMSReceiverHTTPResponse:
|
|
873
|
+
acknowledgement = {
|
|
874
|
+
"schema": _CMS.PUBLICATION_HEALTH_ACK_SCHEMA,
|
|
875
|
+
"probe_id": probe.probe_id,
|
|
876
|
+
"contract_sha256": probe.contract_sha256,
|
|
877
|
+
"status": "healthy",
|
|
878
|
+
}
|
|
879
|
+
acknowledgement_bytes = _canonical_json(
|
|
880
|
+
acknowledgement, maximum=MAX_RESPONSE_BYTES,
|
|
881
|
+
)
|
|
882
|
+
try:
|
|
883
|
+
signature = authority.sign(acknowledgement_bytes)
|
|
884
|
+
signature_mapping = {
|
|
885
|
+
"algorithm": signature.algorithm,
|
|
886
|
+
"key_id": signature.key_id,
|
|
887
|
+
"signature": signature.signature,
|
|
888
|
+
}
|
|
889
|
+
normalized_signature = _signature(signature_mapping)
|
|
890
|
+
verified = authority.verify(
|
|
891
|
+
acknowledgement_bytes, normalized_signature,
|
|
892
|
+
) is True
|
|
893
|
+
except Exception:
|
|
894
|
+
raise CMSReceiverBlocked(
|
|
895
|
+
"receiver.acknowledgement_signing", retryable=True, http_status=503,
|
|
896
|
+
) from None
|
|
897
|
+
if not verified:
|
|
898
|
+
raise CMSReceiverBlocked(
|
|
899
|
+
"receiver.acknowledgement_signing", retryable=True, http_status=503,
|
|
900
|
+
)
|
|
901
|
+
response = {
|
|
902
|
+
"schema": _CMS.PUBLICATION_HEALTH_HTTP_RESPONSE_SCHEMA,
|
|
903
|
+
"acknowledgement": acknowledgement,
|
|
904
|
+
"signature": signature_mapping,
|
|
905
|
+
}
|
|
906
|
+
response_body = _canonical_json(response, maximum=MAX_RESPONSE_BYTES)
|
|
907
|
+
return CMSReceiverHTTPResponse(
|
|
908
|
+
status=200,
|
|
909
|
+
headers=(
|
|
910
|
+
("Content-Type", "application/json; charset=utf-8"),
|
|
911
|
+
("Content-Length", str(len(response_body))),
|
|
912
|
+
("Cache-Control", "no-store"),
|
|
913
|
+
),
|
|
914
|
+
body=response_body,
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _receive_verified_publication(
|
|
919
|
+
publication: VerifiedPublication,
|
|
920
|
+
acknowledgement_authority: CMSMessageAuthority,
|
|
921
|
+
commit: Callable[[VerifiedPublication], Mapping[str, Any]],
|
|
922
|
+
) -> CMSReceiverHTTPResponse:
|
|
923
|
+
try:
|
|
924
|
+
receipt = commit(publication)
|
|
925
|
+
except Exception:
|
|
926
|
+
raise CMSReceiverBlocked(
|
|
927
|
+
"receiver.commit_failed", retryable=True, http_status=503,
|
|
928
|
+
) from None
|
|
929
|
+
expected_receipt = {
|
|
930
|
+
"delivery_id": publication.delivery_id,
|
|
931
|
+
"payload_sha256": publication.payload_sha256,
|
|
932
|
+
"status": "committed",
|
|
933
|
+
}
|
|
934
|
+
if receipt != expected_receipt:
|
|
935
|
+
raise CMSReceiverBlocked(
|
|
936
|
+
"receiver.commit_unconfirmed", retryable=True, http_status=503,
|
|
937
|
+
)
|
|
938
|
+
return _signed_acknowledgement(
|
|
939
|
+
delivery_id=publication.delivery_id,
|
|
940
|
+
payload_sha256=publication.payload_sha256,
|
|
941
|
+
acknowledgement_schema=_CMS.ACK_SCHEMA,
|
|
942
|
+
status="accepted",
|
|
943
|
+
response_schema=_CMS.PUBLICATION_HTTP_RESPONSE_SCHEMA,
|
|
944
|
+
authority=acknowledgement_authority,
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _receive_verified_tombstone(
|
|
949
|
+
tombstone: VerifiedTombstone,
|
|
950
|
+
acknowledgement_authority: CMSMessageAuthority,
|
|
951
|
+
delete: Callable[[VerifiedTombstone], Mapping[str, Any]],
|
|
952
|
+
) -> CMSReceiverHTTPResponse:
|
|
953
|
+
try:
|
|
954
|
+
receipt = delete(tombstone)
|
|
955
|
+
except Exception:
|
|
956
|
+
raise CMSReceiverBlocked(
|
|
957
|
+
"receiver.delete_failed", retryable=True, http_status=503,
|
|
958
|
+
) from None
|
|
959
|
+
expected_receipt = {
|
|
960
|
+
"delivery_id": tombstone.delivery_id,
|
|
961
|
+
"payload_sha256": tombstone.payload_sha256,
|
|
962
|
+
"status": "deleted",
|
|
963
|
+
}
|
|
964
|
+
if receipt != expected_receipt:
|
|
965
|
+
raise CMSReceiverBlocked(
|
|
966
|
+
"receiver.delete_unconfirmed", retryable=True, http_status=503,
|
|
967
|
+
)
|
|
968
|
+
return _signed_acknowledgement(
|
|
969
|
+
delivery_id=tombstone.delivery_id,
|
|
970
|
+
payload_sha256=tombstone.payload_sha256,
|
|
971
|
+
acknowledgement_schema=_CMS.TOMBSTONE_ACK_SCHEMA,
|
|
972
|
+
status="deleted",
|
|
973
|
+
response_schema=_CMS.TOMBSTONE_HTTP_RESPONSE_SCHEMA,
|
|
974
|
+
authority=acknowledgement_authority,
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def receive_publication(
|
|
979
|
+
body: bytes,
|
|
980
|
+
headers: Any,
|
|
981
|
+
publication_authority: CMSMessageAuthority,
|
|
982
|
+
acknowledgement_authority: CMSMessageAuthority,
|
|
983
|
+
expectation: PublicationExpectation,
|
|
984
|
+
commit: Callable[[VerifiedPublication], Mapping[str, Any]],
|
|
985
|
+
*,
|
|
986
|
+
now: float | int,
|
|
987
|
+
) -> CMSReceiverHTTPResponse:
|
|
988
|
+
"""Verify, commit through the host, then return one signed acknowledgement."""
|
|
989
|
+
|
|
990
|
+
if not callable(commit):
|
|
991
|
+
raise TypeError("commit must be callable")
|
|
992
|
+
if (
|
|
993
|
+
not callable(getattr(acknowledgement_authority, "sign", None))
|
|
994
|
+
or not callable(getattr(acknowledgement_authority, "verify", None))
|
|
995
|
+
):
|
|
996
|
+
raise TypeError("acknowledgement_authority must provide sign and verify")
|
|
997
|
+
publication = verify_publication_request(
|
|
998
|
+
body, headers, publication_authority, expectation, now=now,
|
|
999
|
+
)
|
|
1000
|
+
return _receive_verified_publication(
|
|
1001
|
+
publication, acknowledgement_authority, commit,
|
|
1002
|
+
)
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
def receive_tombstone(
|
|
1006
|
+
body: bytes,
|
|
1007
|
+
headers: Any,
|
|
1008
|
+
publication_authority: CMSMessageAuthority,
|
|
1009
|
+
acknowledgement_authority: CMSMessageAuthority,
|
|
1010
|
+
expectation: TombstoneExpectation,
|
|
1011
|
+
delete: Callable[[VerifiedTombstone], Mapping[str, Any]],
|
|
1012
|
+
) -> CMSReceiverHTTPResponse:
|
|
1013
|
+
"""Verify, delete through the host, then return one signed acknowledgement."""
|
|
1014
|
+
|
|
1015
|
+
if not callable(delete):
|
|
1016
|
+
raise TypeError("delete must be callable")
|
|
1017
|
+
if (
|
|
1018
|
+
not callable(getattr(acknowledgement_authority, "sign", None))
|
|
1019
|
+
or not callable(getattr(acknowledgement_authority, "verify", None))
|
|
1020
|
+
):
|
|
1021
|
+
raise TypeError("acknowledgement_authority must provide sign and verify")
|
|
1022
|
+
tombstone = verify_tombstone_request(
|
|
1023
|
+
body, headers, publication_authority, expectation,
|
|
1024
|
+
)
|
|
1025
|
+
return _receive_verified_tombstone(
|
|
1026
|
+
tombstone, acknowledgement_authority, delete,
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def receive_publication_resolved(
|
|
1031
|
+
body: bytes,
|
|
1032
|
+
headers: Any,
|
|
1033
|
+
publication_authority: CMSMessageAuthority,
|
|
1034
|
+
acknowledgement_authority: CMSMessageAuthority,
|
|
1035
|
+
resolve_expectation: Callable[
|
|
1036
|
+
[VerifiedPublication], PublicationExpectation | Mapping[str, Any]
|
|
1037
|
+
],
|
|
1038
|
+
commit: Callable[[VerifiedPublication], Mapping[str, Any]],
|
|
1039
|
+
*,
|
|
1040
|
+
now: float | int,
|
|
1041
|
+
) -> CMSReceiverHTTPResponse:
|
|
1042
|
+
"""Verify a publication before resolving its current host expectation."""
|
|
1043
|
+
|
|
1044
|
+
if not callable(resolve_expectation):
|
|
1045
|
+
raise TypeError("resolve_expectation must be callable")
|
|
1046
|
+
if not callable(commit):
|
|
1047
|
+
raise TypeError("commit must be callable")
|
|
1048
|
+
if (
|
|
1049
|
+
not callable(getattr(acknowledgement_authority, "sign", None))
|
|
1050
|
+
or not callable(getattr(acknowledgement_authority, "verify", None))
|
|
1051
|
+
):
|
|
1052
|
+
raise TypeError("acknowledgement_authority must provide sign and verify")
|
|
1053
|
+
publication = _verify_publication_transport(
|
|
1054
|
+
body, headers, publication_authority, now=now,
|
|
1055
|
+
)
|
|
1056
|
+
resolver_input = VerifiedPublication(
|
|
1057
|
+
delivery_id=publication.delivery_id,
|
|
1058
|
+
payload_sha256=publication.payload_sha256,
|
|
1059
|
+
payload=json.loads(_canonical_json(
|
|
1060
|
+
publication.payload, maximum=MAX_REQUEST_BYTES,
|
|
1061
|
+
).decode("utf-8")),
|
|
1062
|
+
signature=publication.signature,
|
|
1063
|
+
)
|
|
1064
|
+
try:
|
|
1065
|
+
expectation = resolve_expectation(resolver_input)
|
|
1066
|
+
except Exception:
|
|
1067
|
+
raise CMSReceiverBlocked(
|
|
1068
|
+
"receiver.expectation_failed", retryable=True, http_status=503,
|
|
1069
|
+
) from None
|
|
1070
|
+
try:
|
|
1071
|
+
_verify_expectation(publication.payload, expectation)
|
|
1072
|
+
except (TypeError, ValueError):
|
|
1073
|
+
raise CMSReceiverBlocked(
|
|
1074
|
+
"receiver.expectation_invalid", retryable=True, http_status=503,
|
|
1075
|
+
) from None
|
|
1076
|
+
return _receive_verified_publication(
|
|
1077
|
+
publication, acknowledgement_authority, commit,
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
|
|
1081
|
+
def receive_tombstone_resolved(
|
|
1082
|
+
body: bytes,
|
|
1083
|
+
headers: Any,
|
|
1084
|
+
publication_authority: CMSMessageAuthority,
|
|
1085
|
+
acknowledgement_authority: CMSMessageAuthority,
|
|
1086
|
+
resolve_expectation: Callable[
|
|
1087
|
+
[VerifiedTombstone], TombstoneExpectation | Mapping[str, Any]
|
|
1088
|
+
],
|
|
1089
|
+
delete: Callable[[VerifiedTombstone], Mapping[str, Any]],
|
|
1090
|
+
) -> CMSReceiverHTTPResponse:
|
|
1091
|
+
"""Verify a tombstone before resolving its current host expectation."""
|
|
1092
|
+
|
|
1093
|
+
if not callable(resolve_expectation):
|
|
1094
|
+
raise TypeError("resolve_expectation must be callable")
|
|
1095
|
+
if not callable(delete):
|
|
1096
|
+
raise TypeError("delete must be callable")
|
|
1097
|
+
if (
|
|
1098
|
+
not callable(getattr(acknowledgement_authority, "sign", None))
|
|
1099
|
+
or not callable(getattr(acknowledgement_authority, "verify", None))
|
|
1100
|
+
):
|
|
1101
|
+
raise TypeError("acknowledgement_authority must provide sign and verify")
|
|
1102
|
+
tombstone = _verify_tombstone_transport(
|
|
1103
|
+
body, headers, publication_authority,
|
|
1104
|
+
)
|
|
1105
|
+
resolver_input = VerifiedTombstone(
|
|
1106
|
+
delivery_id=tombstone.delivery_id,
|
|
1107
|
+
payload_sha256=tombstone.payload_sha256,
|
|
1108
|
+
payload=json.loads(_canonical_json(
|
|
1109
|
+
tombstone.payload, maximum=MAX_REQUEST_BYTES,
|
|
1110
|
+
).decode("utf-8")),
|
|
1111
|
+
signature=tombstone.signature,
|
|
1112
|
+
)
|
|
1113
|
+
try:
|
|
1114
|
+
expectation = resolve_expectation(resolver_input)
|
|
1115
|
+
except Exception:
|
|
1116
|
+
raise CMSReceiverBlocked(
|
|
1117
|
+
"receiver.expectation_failed", retryable=True, http_status=503,
|
|
1118
|
+
) from None
|
|
1119
|
+
try:
|
|
1120
|
+
_verify_tombstone_expectation(tombstone.payload, expectation)
|
|
1121
|
+
except (TypeError, ValueError):
|
|
1122
|
+
raise CMSReceiverBlocked(
|
|
1123
|
+
"receiver.expectation_invalid", retryable=True, http_status=503,
|
|
1124
|
+
) from None
|
|
1125
|
+
return _receive_verified_tombstone(
|
|
1126
|
+
tombstone, acknowledgement_authority, delete,
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
def receive_health(
|
|
1131
|
+
body: bytes,
|
|
1132
|
+
headers: Any,
|
|
1133
|
+
authenticate: Callable[[Mapping[str, str]], bool],
|
|
1134
|
+
acknowledgement_authority: CMSMessageAuthority,
|
|
1135
|
+
check: Callable[[VerifiedHealthProbe], Mapping[str, Any]],
|
|
1136
|
+
*,
|
|
1137
|
+
contract_sha256: str,
|
|
1138
|
+
) -> CMSReceiverHTTPResponse:
|
|
1139
|
+
"""Authenticate, check the host, then sign one content-free health reply."""
|
|
1140
|
+
|
|
1141
|
+
if not callable(check):
|
|
1142
|
+
raise TypeError("check must be callable")
|
|
1143
|
+
if (
|
|
1144
|
+
not callable(getattr(acknowledgement_authority, "sign", None))
|
|
1145
|
+
or not callable(getattr(acknowledgement_authority, "verify", None))
|
|
1146
|
+
):
|
|
1147
|
+
raise TypeError("acknowledgement_authority must provide sign and verify")
|
|
1148
|
+
probe = verify_health_request(
|
|
1149
|
+
body, headers, authenticate, contract_sha256=contract_sha256,
|
|
1150
|
+
)
|
|
1151
|
+
try:
|
|
1152
|
+
receipt = check(probe)
|
|
1153
|
+
except Exception:
|
|
1154
|
+
raise CMSReceiverBlocked(
|
|
1155
|
+
"receiver.health_check_failed", retryable=True, http_status=503,
|
|
1156
|
+
) from None
|
|
1157
|
+
expected_receipt = {
|
|
1158
|
+
"probe_id": probe.probe_id,
|
|
1159
|
+
"contract_sha256": probe.contract_sha256,
|
|
1160
|
+
"status": "healthy",
|
|
1161
|
+
}
|
|
1162
|
+
if receipt != expected_receipt:
|
|
1163
|
+
raise CMSReceiverBlocked(
|
|
1164
|
+
"receiver.health_unconfirmed", retryable=True, http_status=503,
|
|
1165
|
+
)
|
|
1166
|
+
return _signed_health_acknowledgement(probe, acknowledgement_authority)
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
class CMSReceiverApplication:
|
|
1170
|
+
"""HTTPS-only WSGI boundary for the complete reference receiver."""
|
|
1171
|
+
|
|
1172
|
+
def __init__(
|
|
1173
|
+
self,
|
|
1174
|
+
publication_authority: CMSMessageAuthority,
|
|
1175
|
+
acknowledgement_authority: CMSMessageAuthority,
|
|
1176
|
+
authenticate: Callable[[Mapping[str, str]], bool],
|
|
1177
|
+
resolve_publication_expectation: Callable[
|
|
1178
|
+
[VerifiedPublication], PublicationExpectation | Mapping[str, Any]
|
|
1179
|
+
],
|
|
1180
|
+
commit: Callable[[VerifiedPublication], Mapping[str, Any]],
|
|
1181
|
+
resolve_tombstone_expectation: Callable[
|
|
1182
|
+
[VerifiedTombstone], TombstoneExpectation | Mapping[str, Any]
|
|
1183
|
+
],
|
|
1184
|
+
delete: Callable[[VerifiedTombstone], Mapping[str, Any]],
|
|
1185
|
+
check: Callable[[VerifiedHealthProbe], Mapping[str, Any]],
|
|
1186
|
+
*,
|
|
1187
|
+
contract_sha256: str,
|
|
1188
|
+
clock: Callable[[], float | int],
|
|
1189
|
+
path: str = RECEIVER_PATH,
|
|
1190
|
+
require_https: bool = True,
|
|
1191
|
+
):
|
|
1192
|
+
callbacks = {
|
|
1193
|
+
"authenticate": authenticate,
|
|
1194
|
+
"resolve_publication_expectation": resolve_publication_expectation,
|
|
1195
|
+
"commit": commit,
|
|
1196
|
+
"resolve_tombstone_expectation": resolve_tombstone_expectation,
|
|
1197
|
+
"delete": delete,
|
|
1198
|
+
"check": check,
|
|
1199
|
+
"clock": clock,
|
|
1200
|
+
}
|
|
1201
|
+
for name, callback in callbacks.items():
|
|
1202
|
+
if not callable(callback):
|
|
1203
|
+
raise TypeError(f"{name} must be callable")
|
|
1204
|
+
if not callable(getattr(publication_authority, "verify", None)):
|
|
1205
|
+
raise TypeError("publication_authority must provide verify")
|
|
1206
|
+
if (
|
|
1207
|
+
not callable(getattr(acknowledgement_authority, "sign", None))
|
|
1208
|
+
or not callable(getattr(acknowledgement_authority, "verify", None))
|
|
1209
|
+
):
|
|
1210
|
+
raise TypeError("acknowledgement_authority must provide sign and verify")
|
|
1211
|
+
if (
|
|
1212
|
+
not isinstance(contract_sha256, str)
|
|
1213
|
+
or SHA256.fullmatch(contract_sha256) is None
|
|
1214
|
+
):
|
|
1215
|
+
raise ValueError("contract_sha256 is invalid")
|
|
1216
|
+
if (
|
|
1217
|
+
not isinstance(path, str)
|
|
1218
|
+
or not path.isascii()
|
|
1219
|
+
or not path.startswith("/")
|
|
1220
|
+
or not 2 <= len(path) <= 256
|
|
1221
|
+
or any(character in path for character in "?#\r\n")
|
|
1222
|
+
):
|
|
1223
|
+
raise ValueError("path is invalid")
|
|
1224
|
+
if not isinstance(require_https, bool):
|
|
1225
|
+
raise TypeError("require_https must be boolean")
|
|
1226
|
+
self.publication_authority = publication_authority
|
|
1227
|
+
self.acknowledgement_authority = acknowledgement_authority
|
|
1228
|
+
self.authenticate = authenticate
|
|
1229
|
+
self.resolve_publication_expectation = resolve_publication_expectation
|
|
1230
|
+
self.commit = commit
|
|
1231
|
+
self.resolve_tombstone_expectation = resolve_tombstone_expectation
|
|
1232
|
+
self.delete = delete
|
|
1233
|
+
self.check = check
|
|
1234
|
+
self.contract_sha256 = contract_sha256
|
|
1235
|
+
self.clock = clock
|
|
1236
|
+
self.path = path
|
|
1237
|
+
self.require_https = require_https
|
|
1238
|
+
|
|
1239
|
+
@staticmethod
|
|
1240
|
+
def _wsgi_headers(environ: Mapping[str, Any]) -> tuple[tuple[str, str], ...]:
|
|
1241
|
+
items = []
|
|
1242
|
+
for key, value in environ.items():
|
|
1243
|
+
if key == "CONTENT_TYPE":
|
|
1244
|
+
name = "Content-Type"
|
|
1245
|
+
elif key == "CONTENT_LENGTH":
|
|
1246
|
+
name = "Content-Length"
|
|
1247
|
+
elif isinstance(key, str) and key.startswith("HTTP_"):
|
|
1248
|
+
name = key[5:].replace("_", "-")
|
|
1249
|
+
else:
|
|
1250
|
+
continue
|
|
1251
|
+
items.append((name, value))
|
|
1252
|
+
if len(items) > 64 or any(
|
|
1253
|
+
not isinstance(name, str)
|
|
1254
|
+
or not isinstance(value, str)
|
|
1255
|
+
or len(name) + len(value) > 16_384
|
|
1256
|
+
for name, value in items
|
|
1257
|
+
):
|
|
1258
|
+
raise CMSReceiverBlocked(
|
|
1259
|
+
"receiver.headers_invalid", retryable=False, http_status=400,
|
|
1260
|
+
)
|
|
1261
|
+
return tuple(items)
|
|
1262
|
+
|
|
1263
|
+
@staticmethod
|
|
1264
|
+
def _send(
|
|
1265
|
+
start_response: Callable[..., Any],
|
|
1266
|
+
response: CMSReceiverHTTPResponse,
|
|
1267
|
+
) -> list[bytes]:
|
|
1268
|
+
phrases = {200: "OK"}
|
|
1269
|
+
headers = list(response.headers)
|
|
1270
|
+
headers.extend((
|
|
1271
|
+
("X-Content-Type-Options", "nosniff"),
|
|
1272
|
+
("Referrer-Policy", "no-referrer"),
|
|
1273
|
+
))
|
|
1274
|
+
start_response(
|
|
1275
|
+
f"{response.status} {phrases.get(response.status, 'Error')}", headers,
|
|
1276
|
+
)
|
|
1277
|
+
return [response.body]
|
|
1278
|
+
|
|
1279
|
+
@staticmethod
|
|
1280
|
+
def _blocked(
|
|
1281
|
+
start_response: Callable[..., Any],
|
|
1282
|
+
status: int,
|
|
1283
|
+
code: str,
|
|
1284
|
+
*,
|
|
1285
|
+
retryable: bool,
|
|
1286
|
+
) -> list[bytes]:
|
|
1287
|
+
phrases = {
|
|
1288
|
+
400: "Bad Request",
|
|
1289
|
+
401: "Unauthorized",
|
|
1290
|
+
404: "Not Found",
|
|
1291
|
+
405: "Method Not Allowed",
|
|
1292
|
+
409: "Conflict",
|
|
1293
|
+
411: "Length Required",
|
|
1294
|
+
413: "Content Too Large",
|
|
1295
|
+
415: "Unsupported Media Type",
|
|
1296
|
+
500: "Internal Server Error",
|
|
1297
|
+
503: "Service Unavailable",
|
|
1298
|
+
}
|
|
1299
|
+
body = _canonical_json({
|
|
1300
|
+
"schema": RECEIVER_ERROR_SCHEMA,
|
|
1301
|
+
"status": "BLOCK",
|
|
1302
|
+
"error": code,
|
|
1303
|
+
"retryable": retryable,
|
|
1304
|
+
}, maximum=MAX_RESPONSE_BYTES)
|
|
1305
|
+
start_response(f"{status} {phrases[status]}", (
|
|
1306
|
+
("Content-Type", "application/json; charset=utf-8"),
|
|
1307
|
+
("Content-Length", str(len(body))),
|
|
1308
|
+
("Cache-Control", "no-store"),
|
|
1309
|
+
("X-Content-Type-Options", "nosniff"),
|
|
1310
|
+
("Referrer-Policy", "no-referrer"),
|
|
1311
|
+
))
|
|
1312
|
+
return [body]
|
|
1313
|
+
|
|
1314
|
+
def __call__(
|
|
1315
|
+
self,
|
|
1316
|
+
environ: Mapping[str, Any],
|
|
1317
|
+
start_response: Callable[..., Any],
|
|
1318
|
+
) -> list[bytes]:
|
|
1319
|
+
if not isinstance(environ, dict):
|
|
1320
|
+
return self._blocked(
|
|
1321
|
+
start_response, 500, "receiver.environment_invalid", retryable=True,
|
|
1322
|
+
)
|
|
1323
|
+
if environ.get("PATH_INFO") != self.path:
|
|
1324
|
+
return self._blocked(
|
|
1325
|
+
start_response, 404, "receiver.path_not_found", retryable=False,
|
|
1326
|
+
)
|
|
1327
|
+
if environ.get("REQUEST_METHOD") != "POST":
|
|
1328
|
+
return self._blocked(
|
|
1329
|
+
start_response, 405, "receiver.method_not_allowed", retryable=False,
|
|
1330
|
+
)
|
|
1331
|
+
if self.require_https and environ.get("wsgi.url_scheme") != "https":
|
|
1332
|
+
return self._blocked(
|
|
1333
|
+
start_response, 400, "receiver.https_required", retryable=False,
|
|
1334
|
+
)
|
|
1335
|
+
if environ.get("QUERY_STRING") not in {None, ""}:
|
|
1336
|
+
return self._blocked(
|
|
1337
|
+
start_response, 400, "receiver.query_rejected", retryable=False,
|
|
1338
|
+
)
|
|
1339
|
+
if environ.get("HTTP_TRANSFER_ENCODING"):
|
|
1340
|
+
return self._blocked(
|
|
1341
|
+
start_response, 400, "receiver.framing_invalid", retryable=False,
|
|
1342
|
+
)
|
|
1343
|
+
content_type = environ.get("CONTENT_TYPE")
|
|
1344
|
+
if not isinstance(content_type, str) or (
|
|
1345
|
+
content_type.lower().replace(" ", "")
|
|
1346
|
+
!= "application/json;charset=utf-8"
|
|
1347
|
+
):
|
|
1348
|
+
return self._blocked(
|
|
1349
|
+
start_response, 415, "receiver.content_type", retryable=False,
|
|
1350
|
+
)
|
|
1351
|
+
length = environ.get("CONTENT_LENGTH")
|
|
1352
|
+
if (
|
|
1353
|
+
not isinstance(length, str)
|
|
1354
|
+
or not length.isascii()
|
|
1355
|
+
or not length.isdecimal()
|
|
1356
|
+
):
|
|
1357
|
+
return self._blocked(
|
|
1358
|
+
start_response, 411, "receiver.content_length_required",
|
|
1359
|
+
retryable=False,
|
|
1360
|
+
)
|
|
1361
|
+
size = int(length)
|
|
1362
|
+
if size <= 0:
|
|
1363
|
+
return self._blocked(
|
|
1364
|
+
start_response, 400, "receiver.message_size", retryable=False,
|
|
1365
|
+
)
|
|
1366
|
+
if size > MAX_REQUEST_BYTES:
|
|
1367
|
+
return self._blocked(
|
|
1368
|
+
start_response, 413, "receiver.message_size", retryable=False,
|
|
1369
|
+
)
|
|
1370
|
+
stream = environ.get("wsgi.input")
|
|
1371
|
+
try:
|
|
1372
|
+
body = stream.read(size)
|
|
1373
|
+
except Exception:
|
|
1374
|
+
body = None
|
|
1375
|
+
if not isinstance(body, bytes) or len(body) != size:
|
|
1376
|
+
return self._blocked(
|
|
1377
|
+
start_response, 400, "receiver.body_invalid", retryable=False,
|
|
1378
|
+
)
|
|
1379
|
+
try:
|
|
1380
|
+
raw_headers = self._wsgi_headers(environ)
|
|
1381
|
+
parsed_headers = _headers(raw_headers)
|
|
1382
|
+
try:
|
|
1383
|
+
authenticated = self.authenticate(dict(parsed_headers)) is True
|
|
1384
|
+
except Exception:
|
|
1385
|
+
raise CMSReceiverBlocked(
|
|
1386
|
+
"receiver.authentication_failed",
|
|
1387
|
+
retryable=True,
|
|
1388
|
+
http_status=503,
|
|
1389
|
+
) from None
|
|
1390
|
+
if not authenticated:
|
|
1391
|
+
raise CMSReceiverBlocked(
|
|
1392
|
+
"receiver.authentication_invalid",
|
|
1393
|
+
retryable=False,
|
|
1394
|
+
http_status=401,
|
|
1395
|
+
)
|
|
1396
|
+
envelope, _ = _parse_body(body)
|
|
1397
|
+
schema = envelope.get("schema")
|
|
1398
|
+
if schema == _CMS.PUBLICATION_HTTP_REQUEST_SCHEMA:
|
|
1399
|
+
response = receive_publication_resolved(
|
|
1400
|
+
body,
|
|
1401
|
+
raw_headers,
|
|
1402
|
+
self.publication_authority,
|
|
1403
|
+
self.acknowledgement_authority,
|
|
1404
|
+
self.resolve_publication_expectation,
|
|
1405
|
+
self.commit,
|
|
1406
|
+
now=self.clock(),
|
|
1407
|
+
)
|
|
1408
|
+
elif schema == _CMS.TOMBSTONE_HTTP_REQUEST_SCHEMA:
|
|
1409
|
+
response = receive_tombstone_resolved(
|
|
1410
|
+
body,
|
|
1411
|
+
raw_headers,
|
|
1412
|
+
self.publication_authority,
|
|
1413
|
+
self.acknowledgement_authority,
|
|
1414
|
+
self.resolve_tombstone_expectation,
|
|
1415
|
+
self.delete,
|
|
1416
|
+
)
|
|
1417
|
+
elif schema == _CMS.PUBLICATION_HEALTH_HTTP_REQUEST_SCHEMA:
|
|
1418
|
+
response = receive_health(
|
|
1419
|
+
body,
|
|
1420
|
+
raw_headers,
|
|
1421
|
+
lambda _: True,
|
|
1422
|
+
self.acknowledgement_authority,
|
|
1423
|
+
self.check,
|
|
1424
|
+
contract_sha256=self.contract_sha256,
|
|
1425
|
+
)
|
|
1426
|
+
else:
|
|
1427
|
+
raise CMSReceiverBlocked(
|
|
1428
|
+
"receiver.operation_invalid", retryable=False, http_status=400,
|
|
1429
|
+
)
|
|
1430
|
+
return self._send(start_response, response)
|
|
1431
|
+
except CMSReceiverBlocked as error:
|
|
1432
|
+
return self._blocked(
|
|
1433
|
+
start_response,
|
|
1434
|
+
error.http_status,
|
|
1435
|
+
error.code,
|
|
1436
|
+
retryable=error.retryable,
|
|
1437
|
+
)
|
|
1438
|
+
except Exception:
|
|
1439
|
+
return self._blocked(
|
|
1440
|
+
start_response, 500, "receiver.internal", retryable=True,
|
|
1441
|
+
)
|