hecks 3.14.0 → 4.0.0
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.
- checksums.yaml +4 -4
- data/examples/pizzas/adapters/fake_processor.rb +34 -0
- data/examples/pizzas/adapters/payment_signature.rb +66 -0
- data/examples/pizzas/adapters/payment_webhook.rb +87 -0
- data/examples/pizzas/bluebook/pizzas.behaviors +33 -0
- data/examples/pizzas/bluebook/pizzas.hecksagon +5 -3
- data/examples/pizzas/config.ru +22 -0
- data/lib/hecks/adapters/driven/prism.rb +0 -2
- data/lib/hecks/adapters/driven.rb +0 -2
- data/lib/hecks/adapters/driving/json.rb +1 -7
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -2
- data/lib/hecks/bluebook/dsl/derivation_words.rb +58 -0
- data/lib/hecks/bluebook/dsl/translation_builder.rb +6 -37
- data/lib/hecks/bluebook/expression/ast_json.rb +0 -9
- data/lib/hecks/bluebook/hecksagon.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/translation_judge/rules.rb +7 -10
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +4 -8
- data/lib/hecks/bluebook/meta_validator.rb +0 -11
- data/lib/hecks/bluebook/translation.rb +18 -10
- data/lib/hecks/bluebook/translation_expression/evaluator.rb +63 -0
- data/lib/hecks/bluebook/translation_expression/parser.rb +127 -0
- data/lib/hecks/bluebook/translation_expression.rb +39 -0
- data/lib/hecks/bluebook/translation_plan.rb +70 -0
- data/lib/hecks/bluebook.rb +2 -0
- data/lib/hecks/chapters.rb +3 -3
- data/lib/hecks/composer/adapters/claude_composer.adapter +3 -0
- data/lib/hecks/composer/adapters/claude_composer.rb +83 -0
- data/lib/hecks/composer/adapters/fake_agent.adapter +3 -0
- data/lib/hecks/composer/adapters/fake_agent.rb +55 -0
- data/lib/hecks/composer/adapters/jev.adapter +3 -0
- data/lib/hecks/composer/adapters/jev.rb +66 -0
- data/lib/hecks/composer/agent.rb +113 -0
- data/lib/hecks/composer/assistant.rb +110 -0
- data/lib/hecks/composer/behavior_run.rb +53 -0
- data/lib/hecks/composer/bluebook/composer.bluebook +178 -0
- data/lib/hecks/composer/bluebook/composer.hecksagon +16 -0
- data/lib/hecks/composer/board.rb +65 -0
- data/lib/hecks/composer/board_edit/command_text.rb +39 -0
- data/lib/hecks/composer/board_edit/source.rb +84 -0
- data/lib/hecks/composer/board_edit.rb +144 -0
- data/lib/hecks/composer/check.rb +89 -0
- data/lib/hecks/composer/domains.rb +81 -0
- data/lib/hecks/composer/jobs.rb +134 -0
- data/lib/hecks/composer/layout.rb +37 -0
- data/lib/hecks/composer/live.rb +150 -0
- data/lib/hecks/composer/outline.rb +48 -0
- data/lib/hecks/composer/page.html +989 -0
- data/lib/hecks/composer/panels.rb +142 -0
- data/lib/hecks/composer/parse.rb +83 -0
- data/lib/hecks/composer/plain.rb +28 -0
- data/lib/hecks/composer/ports/composer_agent.port +7 -0
- data/lib/hecks/composer/session.rb +141 -0
- data/lib/hecks/composer/toolbox.rb +166 -0
- data/lib/hecks/composer/web.rb +156 -0
- data/lib/hecks/composer/workbench.rb +51 -0
- data/lib/hecks/composer/workspace.rb +81 -0
- data/lib/hecks/composer.rb +17 -0
- data/lib/hecks/corpus/adapters.rb +22 -0
- data/lib/hecks/corpus/rust.rb +1 -1
- data/lib/hecks/corpus/worlds.rb +19 -0
- data/lib/hecks/corpus.rb +5 -0
- data/lib/hecks/forms/app/commands.rb +2 -3
- data/lib/hecks/forms/app/queries.rb +2 -3
- data/lib/hecks/forms/app/records.rb +17 -5
- data/lib/hecks/forms/command_form_renderer.rb +42 -16
- data/lib/hecks/forms/field_controls.rb +2 -2
- data/lib/hecks/forms/field_renderer.rb +1 -1
- data/lib/hecks/forms/field_shape.rb +36 -17
- data/lib/hecks/forms/index_renderer.rb +2 -1
- data/lib/hecks/forms/page.rb +1 -1
- data/lib/hecks/forms/params.rb +0 -13
- data/lib/hecks/forms/query_form_renderer.rb +7 -6
- data/lib/hecks/forms/record_renderer.rb +11 -7
- data/lib/hecks/forms/rule_words.rb +100 -0
- data/lib/hecks/forms/value_object_shape.rb +2 -1
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -3
- data/lib/hecks/fuzzing/isolated_boot/worlds.rb +3 -6
- data/lib/hecks/fuzzing/replay/filters.rb +2 -6
- data/lib/hecks/gate/stages.yml +25 -0
- data/lib/hecks/grammar/evolve/seeds.rb +0 -6
- data/lib/hecks/grammar/evolve.rb +0 -3
- data/lib/hecks/grammar/translation.bluebook +1 -1
- data/lib/hecks/hecks/adapters/codebase/composing.rb +56 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
- data/lib/hecks/hecks/adapters/journal_store/changes.rb +0 -26
- data/lib/hecks/hecks/adapters/journal_store/examination.rb +2 -23
- data/lib/hecks/hecks/adapters/journal_store/readings.rb +3 -11
- data/lib/hecks/hecks/adapters/journal_store.rb +0 -11
- data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +4 -11
- data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +0 -9
- data/lib/hecks/hecks/adapters/rehearsal/postgres_reading.rb +0 -4
- data/lib/hecks/hecks/codebase.behaviors +6 -0
- data/lib/hecks/hecks/codebase.bluebook +21 -0
- data/lib/hecks/hecks/composer.behaviors +68 -0
- data/lib/hecks/hecks/context_map.hecksagon +1 -1
- data/lib/hecks/hecks/custodian.behaviors +0 -6
- data/lib/hecks/hecks/custodian.bluebook +3 -80
- data/lib/hecks/hecks/hecks.hecksagon +2 -2
- data/lib/hecks/hecks/hecks.world +1 -1
- data/lib/hecks/hecks/rehearsal.bluebook +7 -13
- data/lib/hecks/kernel_capabilities/templates.rb +4 -4
- data/lib/hecks/kernel_capabilities.rb +1 -1
- data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -3
- data/lib/hecks/language/translation/translation_aggregate.bluebook +21 -24
- data/lib/hecks/ports/access_control.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -41
- data/lib/hecks/ports/persistence/plugins/era/lineage/coverage.rb +7 -7
- data/lib/hecks/ports/persistence/plugins/era/lineage/rewrite.rb +25 -2
- data/lib/hecks/ports/persistence/plugins/era/lineage/rules.rb +4 -4
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +17 -25
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/base_sql.rb +0 -12
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/domain_rename.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -22
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +0 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +9 -3
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +3 -29
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/refusals.rb +2 -26
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +6 -33
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +3 -3
- data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +2 -4
- data/lib/hecks/ports/persistence/plugins/era/translation/expression_sql.rb +52 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +23 -29
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation.rb +0 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +3 -6
- data/lib/hecks/ports.rb +0 -1
- data/lib/hecks/projection_files.rb +3 -1
- data/lib/hecks/projections/deploy/templates/lambda/nat_comment_oauth.tmpl +1 -1
- data/lib/hecks/projections/glossary/entries.rb +10 -4
- data/lib/hecks/projections/glossary/groups.rb +42 -0
- data/lib/hecks/projections/glossary/html/page.rb +7 -2
- data/lib/hecks/projections/glossary/html/parser.rb +1 -1
- data/lib/hecks/projections/glossary/html/sections.rb +31 -11
- data/lib/hecks/projections/glossary/html.rb +1 -2
- data/lib/hecks/projections/glossary/markdown.rb +31 -28
- data/lib/hecks/projections/glossary/mermaid.rb +3 -0
- data/lib/hecks/projections/glossary/page.css +143 -153
- data/lib/hecks/projections/glossary/page.js +63 -3
- data/lib/hecks/projections/glossary/sentences/actions.rb +88 -0
- data/lib/hecks/projections/glossary/sentences/paths.rb +77 -0
- data/lib/hecks/projections/glossary/sentences/queries.rb +90 -0
- data/lib/hecks/projections/glossary/sentences/value_objects.rb +32 -5
- data/lib/hecks/projections/glossary/sentences.rb +13 -3
- data/lib/hecks/projections/glossary/slugs.rb +4 -1
- data/lib/hecks/projections/glossary.rb +2 -1
- data/lib/hecks/projections/plain_english.rb +97 -0
- data/lib/hecks/projections/rust_vocabulary/arguments.rb +2 -2
- data/lib/hecks/projections/rust_vocabulary/enum_body.rb +20 -25
- data/lib/hecks/projections/rust_vocabulary/tests.rb +2 -2
- data/lib/hecks/projections/rust_vocabulary.rb +14 -6
- data/lib/hecks/projections/site/cms_editor/color.rb +0 -6
- data/lib/hecks/projections/statements/actions.rb +99 -0
- data/lib/hecks/projections/statements/relationships.rb +86 -0
- data/lib/hecks/projections/statements.rb +15 -24
- data/lib/hecks/projector/exporter/adapters.rb +112 -0
- data/lib/hecks/projector/exporter/translations.rb +18 -35
- data/lib/hecks/projector/exporter/worlds.rb +103 -0
- data/lib/hecks/projector/exporter.rb +5 -1
- data/lib/hecks/quality_control/adapters/agent.rb +6 -7
- data/lib/hecks/quality_control/adapters/agent_profile.rb +16 -114
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +1 -1
- data/lib/hecks/runtime/loader/boot_steps.rb +0 -20
- data/lib/hecks/runtime/outbox.rb +2 -9
- data/lib/hecks/runtime/port_operation_interpreter.rb +1 -9
- data/lib/hecks/runtime/remote_dispatcher.rb +2 -2
- data/lib/hecks/runtime/value.rb +13 -0
- data/lib/hecks/rust_build/project_rust/target_ir.rb +23 -4
- data/lib/hecks/rust_build/wasm.rb +0 -12
- data/lib/hecks/rust_build/write_if_changed.rb +12 -12
- data/lib/hecks/rust_format.rb +77 -0
- data/lib/hecks/tools/fuzz_sweep/findings.rb +3 -9
- data/lib/hecks/tools/fuzz_sweep.rb +1 -3
- data/lib/hecks/tools/translation_audit/verdicts.rb +3 -28
- data/lib/hecks/tools/translation_audit.rb +8 -10
- data/lib/hecks/tools/translation_scaffold/reporting.rb +1 -1
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +2 -1
- data/lib/hecks.rb +3 -0
- data/prebuilt/{a883a7e568667aa5ac904c82daff356bdf9e996252a36af6def4a4a43efb967e.marshal → 02c09c73b272c1fb5a9ae6d87bb5e5e529208e00f6b4b597dc2c0fd70a9c0964.marshal} +0 -0
- data/prebuilt/{1a6f596be124228be93eba1d86dbc9d2933190fa389b284331241cb9f27115a0.marshal → bb84a835cf6a15136bad5ca03aa5bf93a5b92f5f72444618d3ef2412b987fb32.marshal} +0 -0
- data/prebuilt/{verdicts-872993b1aa26d73fe48f28251374b8585fed145d02aab42be0f00de386b6ff80.json → verdicts-f1f10da14c8e8f01b4c9b7e3e2d74be8cd2bf4676364ed17fa16975d2a076d75.json} +1 -1
- data/rust/Cargo.toml +40 -0
- data/rust/build/Cargo.toml +36 -0
- data/rust/build/clippy.toml +4 -0
- data/rust/build/src/adapters_pass.rs +196 -0
- data/rust/build/src/binding_pass.rs +54 -13
- data/rust/build/src/build_artifact.rs +23 -5
- data/rust/build/src/cargo_sync.rs +69 -26
- data/rust/build/src/lineage_pass.rs +101 -87
- data/rust/build/src/main.rs +11 -2
- data/rust/build/src/pipeline.rs +80 -19
- data/rust/build/src/reserved_names.rs +6 -50
- data/rust/build/src/resolve.rs +82 -35
- data/rust/build/src/rustfmt.rs +100 -0
- data/rust/build/src/seams_pass.rs +85 -16
- data/rust/build/src/subprocess.rs +30 -6
- data/rust/build/src/tmp.rs +5 -2
- data/rust/build/src/translation_pass.rs +72 -22
- data/rust/build/src/world_pass.rs +109 -0
- data/rust/clippy.toml +4 -0
- data/rust/codegen/Cargo.toml +36 -0
- data/rust/codegen/clippy.toml +4 -0
- data/rust/codegen/src/asks.rs +40 -14
- data/rust/codegen/src/attr.rs +2 -2
- data/rust/codegen/src/bridging.rs +676 -117
- data/rust/codegen/src/commands.rs +979 -373
- data/rust/codegen/src/constraints.rs +88 -25
- data/rust/codegen/src/dependency_planning.rs +128 -34
- data/rust/codegen/src/domain_generator.rs +300 -170
- data/rust/codegen/src/exemplar.rs +87 -32
- data/rust/codegen/src/expr_emitter.rs +54 -6
- data/rust/codegen/src/fielded.rs +193 -41
- data/rust/codegen/src/hecks_naming.rs +124 -16
- data/rust/codegen/src/hecks_naming_tests.rs +81 -0
- data/rust/codegen/src/json.rs +80 -44
- data/rust/codegen/src/json_codec.rs +585 -149
- data/rust/codegen/src/literal.rs +104 -138
- data/rust/codegen/src/main.rs +145 -52
- data/rust/codegen/src/manifest.rs +76 -14
- data/rust/codegen/src/mutations.rs +1212 -239
- data/rust/codegen/src/naming.rs +111 -60
- data/rust/codegen/src/optional_pass.rs +90 -28
- data/rust/codegen/src/ports.rs +5 -4
- data/rust/codegen/src/prelude.rs +139 -49
- data/rust/codegen/src/queries.rs +601 -226
- data/rust/codegen/src/reactions.rs +268 -74
- data/rust/codegen/src/read_models.rs +606 -205
- data/rust/codegen/src/reference_specs.rs +16 -3
- data/rust/codegen/src/registry.rs +128 -79
- data/rust/codegen/src/reserved_names.rs +6 -50
- data/rust/codegen/src/shared.rs +71 -22
- data/rust/codegen/src/sidecars.rs +15 -5
- data/rust/codegen/src/skip_reason.rs +8 -2
- data/rust/codegen/src/types.rs +306 -108
- data/rust/host/Cargo.lock +1 -0
- data/rust/host/Cargo.toml +40 -4
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/clippy.toml +4 -0
- data/rust/host/src/api.rs +698 -218
- data/rust/host/src/auth.rs +491 -214
- data/rust/host/src/b64.rs +91 -0
- data/rust/host/src/bin/expr_harness.rs +6 -2
- data/rust/host/src/bin/lineage_harness.rs +47 -11
- data/rust/host/src/bin/mint_harness.rs +70 -37
- data/rust/host/src/bin/validate_harness.rs +16 -8
- data/rust/host/src/boot.rs +144 -68
- data/rust/host/src/boundary_fuzz/api.rs +130 -32
- data/rust/host/src/boundary_fuzz/auth.rs +108 -23
- data/rust/host/src/boundary_fuzz/expr_json.rs +85 -11
- data/rust/host/src/boundary_fuzz/ir.rs +164 -12
- data/rust/host/src/boundary_fuzz/server.rs +283 -36
- data/rust/host/src/boundary_fuzz/web.rs +56 -8
- data/rust/host/src/checkout.rs +78 -32
- data/rust/host/src/clock.rs +8 -0
- data/rust/host/src/commerce_ir.rs +333 -95
- data/rust/host/src/crypto.rs +33 -0
- data/rust/host/src/dispatch.rs +990 -331
- data/rust/host/src/expr_json.rs +1021 -273
- data/rust/host/src/extension.rs +24 -4
- data/rust/host/src/field_hints.rs +11 -7
- data/rust/host/src/fuzz_support.rs +85 -13
- data/rust/host/src/host.rs +100 -0
- data/rust/host/src/http.rs +22 -0
- data/rust/host/src/ir.rs +326 -16
- data/rust/host/src/journal.rs +277 -176
- data/rust/host/src/lambda_client.rs +219 -53
- data/rust/host/src/lib.rs +16 -4
- data/rust/host/src/log.rs +62 -11
- data/rust/host/src/mint.rs +1153 -326
- data/rust/host/src/needs.rs +185 -44
- data/rust/host/src/payments/keystore.rs +197 -45
- data/rust/host/src/payments/roles.rs +39 -7
- data/rust/host/src/payments/tests.rs +1427 -282
- data/rust/host/src/payments.rs +391 -112
- data/rust/host/src/platform_signin/attack_tests.rs +5 -5
- data/rust/host/src/platform_signin/config.rs +11 -11
- data/rust/host/src/platform_signin/cookie.rs +2 -2
- data/rust/host/src/platform_signin/extension.rs +2 -1
- data/rust/host/src/platform_signin/handler.rs +9 -8
- data/rust/host/src/platform_signin/jti.rs +4 -2
- data/rust/host/src/platform_signin/jwks.rs +2 -1
- data/rust/host/src/platform_signin/jws.rs +2 -37
- data/rust/host/src/platform_signin/known_answer_tests.rs +5 -1
- data/rust/host/src/platform_signin/mode_tests.rs +5 -3
- data/rust/host/src/platform_signin/pages.rs +20 -20
- data/rust/host/src/platform_signin/session.rs +8 -1
- data/rust/host/src/platform_signin/settings.rs +6 -6
- data/rust/host/src/platform_signin/strict_json.rs +12 -9
- data/rust/host/src/platform_signin/verify.rs +41 -41
- data/rust/host/src/platform_signin/wiring_support.rs +13 -18
- data/rust/host/src/presentation.rs +207 -63
- data/rust/host/src/presentation_write.rs +502 -187
- data/rust/host/src/query_step.rs +69 -18
- data/rust/host/src/rate_limit.rs +541 -142
- data/rust/host/src/reference_transform.rs +77 -25
- data/rust/host/src/reference_validate.rs +443 -104
- data/rust/host/src/resend.rs +190 -55
- data/rust/host/src/secrets.rs +116 -21
- data/rust/host/src/server.rs +483 -104
- data/rust/host/src/storage_shape.rs +88 -21
- data/rust/host/src/test_pg.rs +17 -7
- data/rust/host/src/ui_schema.rs +495 -207
- data/rust/host/src/wasm_runner.rs +21 -6
- data/rust/host/src/web/commerce.rs +101 -23
- data/rust/host/src/web/newsletter/cooldown.rs +10 -3
- data/rust/host/src/web/newsletter/template.rs +26 -7
- data/rust/host/src/web/newsletter/tests.rs +112 -23
- data/rust/host/src/web/newsletter.rs +209 -59
- data/rust/host/src/web/newsletter_send.rs +251 -79
- data/rust/host/src/web/registration_receipt/tests.rs +16 -4
- data/rust/host/src/web/registration_receipt.rs +100 -24
- data/rust/host/src/web/registrations.rs +1384 -332
- data/rust/host/src/web/resend_webhook.rs +138 -80
- data/rust/host/src/web.rs +2416 -508
- data/rust/lsp/Cargo.toml +36 -0
- data/rust/lsp/clippy.toml +4 -0
- data/rust/lsp/src/json.rs +29 -22
- data/rust/lsp/src/main.rs +81 -31
- data/rust/lsp/src/outline.rs +23 -7
- data/rust/lsp/src/rpc.rs +17 -4
- data/rust/parser/Cargo.toml +36 -0
- data/rust/parser/clippy.toml +4 -0
- data/rust/parser/src/build/closed_sets.rs +6 -1
- data/rust/parser/src/build/identity.rs +4 -3
- data/rust/parser/src/build/mod.rs +1 -1
- data/rust/parser/src/build/named_pattern.rs +4 -1
- data/rust/parser/src/build/naming.rs +2 -80
- data/rust/parser/src/build/pattern_subset.rs +6 -8
- data/rust/parser/src/build/query_derive.rs +3 -1
- data/rust/parser/src/build/query_inference.rs +14 -5
- data/rust/parser/src/build/read_model.rs +4 -1
- data/rust/parser/src/build/references.rs +4 -3
- data/rust/parser/src/canonical.rs +34 -15
- data/rust/parser/src/emit.rs +187 -105
- data/rust/parser/src/expr/ast_json.rs +134 -42
- data/rust/parser/src/expr/evaluator.rs +27 -9
- data/rust/parser/src/expr/mod.rs +54 -13
- data/rust/parser/src/expr/resolver.rs +299 -92
- data/rust/parser/src/ir.rs +35 -3
- data/rust/parser/src/keywords.rs +7168 -516
- data/rust/parser/src/lex.rs +11 -25
- data/rust/parser/src/lib.rs +1 -0
- data/rust/parser/src/main.rs +65 -6
- data/rust/parser/src/parse/adapter.rs +157 -0
- data/rust/parser/src/parse/aggregate.rs +19 -10
- data/rust/parser/src/parse/chapter.rs +16 -17
- data/rust/parser/src/parse/command.rs +35 -16
- data/rust/parser/src/parse/domain_port.rs +4 -7
- data/rust/parser/src/parse/entity.rs +21 -11
- data/rust/parser/src/parse/hecksagon.rs +10 -4
- data/rust/parser/src/parse/lifecycle.rs +9 -5
- data/rust/parser/src/parse/mod.rs +22 -91
- data/rust/parser/src/parse/needs.rs +21 -12
- data/rust/parser/src/parse/policy.rs +2 -2
- data/rust/parser/src/parse/process_manager.rs +9 -11
- data/rust/parser/src/parse/query.rs +8 -4
- data/rust/parser/src/parse/read_model.rs +57 -16
- data/rust/parser/src/parse/value_object.rs +7 -2
- data/rust/parser/src/parse/world.rs +322 -0
- data/rust/parser/src/ruby_value.rs +7 -6
- data/rust/src/exemplar/json.rs +4 -4
- data/rust/src/exemplar/mutations.rs +18 -0
- data/rust/src/exemplar/read_models.rs +1 -10
- data/rust/src/kernel/attribute_shapes/composite.rs +54 -14
- data/rust/src/kernel/attribute_shapes/list.rs +2 -2
- data/rust/src/kernel/attribute_shapes/mod.rs +8 -8
- data/rust/src/kernel/attribute_shapes/scalar.rs +2 -2
- data/rust/src/kernel/cli.rs +229 -112
- data/rust/src/kernel/dispatch.rs +323 -115
- data/rust/src/kernel/dispatch_guard_tests.rs +327 -65
- data/rust/src/kernel/expr.rs +203 -86
- data/rust/src/kernel/expression_operators/arithmetic.rs +138 -25
- data/rust/src/kernel/expression_operators/comparison.rs +87 -2
- data/rust/src/kernel/expression_operators/enumeration.rs +106 -22
- data/rust/src/kernel/expression_operators/logical.rs +9 -3
- data/rust/src/kernel/expression_operators/membership.rs +3 -1
- data/rust/src/kernel/expression_operators/mod.rs +34 -21
- data/rust/src/kernel/expression_operators/pattern_match.rs +46 -11
- data/rust/src/kernel/expression_operators/positional.rs +84 -30
- data/rust/src/kernel/expression_operators/presence.rs +52 -13
- data/rust/src/kernel/expression_operators/sign_test.rs +3 -1
- data/rust/src/kernel/expression_operators/sized.rs +24 -8
- data/rust/src/kernel/expression_operators/text.rs +120 -27
- data/rust/src/kernel/expression_operators/to_string.rs +10 -4
- data/rust/src/kernel/json.rs +230 -148
- data/rust/src/kernel/mod.rs +46 -33
- data/rust/src/kernel/named_query.rs +63 -15
- data/rust/src/kernel/needs.rs +112 -25
- data/rust/src/kernel/orchestrate.rs +980 -581
- data/rust/src/kernel/orchestrate_guard_tests.rs +538 -140
- data/rust/src/kernel/pattern.rs +107 -34
- data/rust/src/kernel/query_comparators.rs +118 -41
- data/rust/src/kernel/query_ordering.rs +13 -5
- data/rust/src/kernel/read_model.rs +479 -181
- data/rust/src/kernel/reference_lookup.rs +122 -26
- data/rust/src/kernel/repository.rs +293 -66
- data/rust/src/kernel/routing.rs +58 -42
- data/rust/src/kernel/ruby_float.rs +139 -0
- data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +57 -57
- data/rust/src/kernel/vocab/entity_dispatch_order.rs +54 -54
- data/rust/src/kernel/vocab/field_hint.rs +19 -25
- data/rust/src/kernel/vocab/mod.rs +6 -6
- data/rust/src/kernel/vocab/query_comparator.rs +23 -24
- data/rust/src/kernel/vocab/refusal_template.rs +501 -297
- data/rust/src/lib.rs +50 -0
- data/rust/src/main.rs +4 -1
- data/rust/web/Cargo.toml +36 -0
- data/rust/web/clippy.toml +4 -0
- metadata +82 -15
- data/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +0 -36
- data/lib/hecks/adapters/driven/google_authentication.adapter +0 -3
- data/lib/hecks/adapters/driven/google_authentication.rb +0 -99
- data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +0 -3
- data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +0 -32
- data/lib/hecks/ports/authentication.port +0 -6
- data/lib/hecks/ports/authentication.rb +0 -67
- data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +0 -255
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +0 -22
- data/rust/host/src/approval.rs +0 -652
data/rust/host/src/resend.rs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
//! Sends one newsletter email through Resend's REST API. The
|
|
2
|
-
//!
|
|
1
|
+
//! Sends one newsletter email through Resend's REST API. The Rust host is the only
|
|
2
|
+
//! implementation of this `deliver` answer; there is no Ruby adapter or mock beside it.
|
|
3
3
|
|
|
4
4
|
use serde_json::{json, Value};
|
|
5
5
|
use std::time::Duration;
|
|
@@ -10,7 +10,7 @@ const MAX_ATTEMPTS: u32 = 4;
|
|
|
10
10
|
const DEFAULT_RETRY_SECS: u64 = 1;
|
|
11
11
|
const MAX_RETRY_SECS: u64 = 5;
|
|
12
12
|
/// The address the mock refuses, and (in `mock_deliver`) the reason it gives. Adapter-side test
|
|
13
|
-
/// vocabulary: the domain names neither
|
|
13
|
+
/// vocabulary: the domain names neither (ADR 0099).
|
|
14
14
|
const MOCK_BOUNCE_ADDRESS: &str = "bounce@example.com";
|
|
15
15
|
|
|
16
16
|
/// One email to one recipient.
|
|
@@ -26,7 +26,7 @@ pub struct Email<'a> {
|
|
|
26
26
|
|
|
27
27
|
/// What handing an email to the provider came to. A refusal or outage for one
|
|
28
28
|
/// recipient is `ok: false` with a reason (never an `Err`), so a fan-out can carry on.
|
|
29
|
-
#[derive(Debug, PartialEq)]
|
|
29
|
+
#[derive(Debug, PartialEq, Eq)]
|
|
30
30
|
pub struct Delivery {
|
|
31
31
|
pub ok: bool,
|
|
32
32
|
pub message_id: Option<String>,
|
|
@@ -34,31 +34,49 @@ pub struct Delivery {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
impl Delivery {
|
|
37
|
-
fn sent(message_id: String) -> Self {
|
|
38
|
-
|
|
37
|
+
const fn sent(message_id: String) -> Self {
|
|
38
|
+
Self {
|
|
39
|
+
ok: true,
|
|
40
|
+
message_id: Some(message_id),
|
|
41
|
+
reason: None,
|
|
42
|
+
}
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
fn failed(reason: impl Into<String>) -> Self {
|
|
42
|
-
|
|
46
|
+
Self {
|
|
47
|
+
ok: false,
|
|
48
|
+
message_id: None,
|
|
49
|
+
reason: Some(reason.into()),
|
|
50
|
+
}
|
|
43
51
|
}
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
/// How email leaves the host: really, through Resend, or logged and dropped.
|
|
47
55
|
pub enum Mailer {
|
|
48
|
-
Live {
|
|
56
|
+
Live {
|
|
57
|
+
http: reqwest::Client,
|
|
58
|
+
api_key: String,
|
|
59
|
+
from: String,
|
|
60
|
+
reply_to: Option<String>,
|
|
61
|
+
base_url: String,
|
|
62
|
+
},
|
|
49
63
|
Mock,
|
|
50
64
|
}
|
|
51
65
|
|
|
52
66
|
impl Mailer {
|
|
53
67
|
/// `Ok(None)` when nothing is configured; `Err` when a key is set without `RESEND_FROM` —
|
|
54
68
|
/// marking an issue sent is irreversible, so a missing key must refuse before that happens.
|
|
55
|
-
pub fn from_env() -> Result<Option<
|
|
56
|
-
let var = |name: &str|
|
|
57
|
-
Self::configured(
|
|
58
|
-
|
|
69
|
+
pub fn from_env() -> Result<Option<Self>, String> {
|
|
70
|
+
let var = |name: &str| crate::secrets::var(name).unwrap_or_default();
|
|
71
|
+
Self::configured(
|
|
72
|
+
&var("RESEND_API_KEY"),
|
|
73
|
+
&var("RESEND_FROM"),
|
|
74
|
+
&var("RESEND_MOCK"),
|
|
75
|
+
)
|
|
76
|
+
.map(|mailer| mailer.map(|mailer| mailer.replying_to(&var("RESEND_REPLY_TO"))))
|
|
59
77
|
}
|
|
60
78
|
|
|
61
|
-
fn configured(api_key: &str, from: &str, mock: &str) -> Result<Option<
|
|
79
|
+
fn configured(api_key: &str, from: &str, mock: &str) -> Result<Option<Self>, String> {
|
|
62
80
|
if !api_key.is_empty() {
|
|
63
81
|
if from.is_empty() {
|
|
64
82
|
return Err("RESEND_API_KEY is set but RESEND_FROM is not — Resend needs a from address on a verified domain".to_string());
|
|
@@ -66,36 +84,64 @@ impl Mailer {
|
|
|
66
84
|
return Ok(Some(Self::live(api_key, from, API_BASE)));
|
|
67
85
|
}
|
|
68
86
|
// Mock requires an explicit opt-in, unlike checkout's mock-by-default.
|
|
69
|
-
Ok((mock == "1").then_some(
|
|
87
|
+
Ok((mock == "1").then_some(Self::Mock))
|
|
70
88
|
}
|
|
71
89
|
|
|
72
90
|
/// A mailer that posts to `base_url` (Resend's API, or a stand-in in tests).
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
91
|
+
#[allow(clippy::expect_used)] // reason: startup config; a client with only a timeout set always builds
|
|
92
|
+
pub fn live(api_key: &str, from: &str, base_url: &str) -> Self {
|
|
93
|
+
let http = reqwest::Client::builder()
|
|
94
|
+
.timeout(TIMEOUT)
|
|
95
|
+
.build()
|
|
96
|
+
.expect("a reqwest client with a timeout always builds");
|
|
97
|
+
Self::Live {
|
|
98
|
+
http,
|
|
99
|
+
api_key: api_key.to_string(),
|
|
100
|
+
from: from.to_string(),
|
|
101
|
+
reply_to: None,
|
|
102
|
+
base_url: base_url.to_string(),
|
|
103
|
+
}
|
|
76
104
|
}
|
|
77
105
|
|
|
78
106
|
/// Sets where replies go (`Reply-To`), when the address people reply to is not the one the mail
|
|
79
107
|
/// is sent from. Blank leaves replies going to the From address. A mock mailer has no replies.
|
|
80
|
-
pub fn replying_to(self, reply_to: &str) ->
|
|
108
|
+
pub fn replying_to(self, reply_to: &str) -> Self {
|
|
81
109
|
match self {
|
|
82
|
-
|
|
110
|
+
Self::Live {
|
|
111
|
+
http,
|
|
112
|
+
api_key,
|
|
113
|
+
from,
|
|
114
|
+
base_url,
|
|
115
|
+
..
|
|
116
|
+
} => {
|
|
83
117
|
let reply_to = (!reply_to.trim().is_empty()).then(|| reply_to.trim().to_string());
|
|
84
|
-
|
|
118
|
+
Self::Live {
|
|
119
|
+
http,
|
|
120
|
+
api_key,
|
|
121
|
+
from,
|
|
122
|
+
reply_to,
|
|
123
|
+
base_url,
|
|
124
|
+
}
|
|
85
125
|
}
|
|
86
|
-
mock => mock,
|
|
126
|
+
mock @ Self::Mock => mock,
|
|
87
127
|
}
|
|
88
128
|
}
|
|
89
129
|
|
|
90
130
|
#[cfg(test)]
|
|
91
|
-
pub fn is_mock(&self) -> bool {
|
|
92
|
-
matches!(self,
|
|
131
|
+
pub const fn is_mock(&self) -> bool {
|
|
132
|
+
matches!(self, Self::Mock)
|
|
93
133
|
}
|
|
94
134
|
|
|
95
135
|
pub async fn deliver(&self, email: &Email<'_>) -> Delivery {
|
|
96
136
|
match self {
|
|
97
|
-
|
|
98
|
-
|
|
137
|
+
Self::Mock => mock_deliver(email),
|
|
138
|
+
Self::Live {
|
|
139
|
+
http,
|
|
140
|
+
api_key,
|
|
141
|
+
from,
|
|
142
|
+
reply_to,
|
|
143
|
+
base_url,
|
|
144
|
+
} => live_deliver(http, api_key, from, reply_to.as_deref(), base_url, email).await,
|
|
99
145
|
}
|
|
100
146
|
}
|
|
101
147
|
}
|
|
@@ -104,7 +150,10 @@ fn mock_deliver(email: &Email<'_>) -> Delivery {
|
|
|
104
150
|
if email.to == MOCK_BOUNCE_ADDRESS {
|
|
105
151
|
return Delivery::failed("bounced");
|
|
106
152
|
}
|
|
107
|
-
println!(
|
|
153
|
+
println!(
|
|
154
|
+
"[mock resend] to={} subject={:?}\n{}",
|
|
155
|
+
email.to, email.subject, email.body
|
|
156
|
+
);
|
|
108
157
|
Delivery::sent(format!("re_{}", uuid::Uuid::new_v4().simple()))
|
|
109
158
|
}
|
|
110
159
|
|
|
@@ -113,7 +162,11 @@ fn payload(from: &str, reply_to: Option<&str>, email: &Email<'_>) -> Value {
|
|
|
113
162
|
if let Some(reply_to) = reply_to {
|
|
114
163
|
payload["reply_to"] = json!(reply_to);
|
|
115
164
|
}
|
|
116
|
-
let content_key = if email.body.trim_start().starts_with('<') {
|
|
165
|
+
let content_key = if email.body.trim_start().starts_with('<') {
|
|
166
|
+
"html"
|
|
167
|
+
} else {
|
|
168
|
+
"text"
|
|
169
|
+
};
|
|
117
170
|
payload[content_key] = json!(email.body);
|
|
118
171
|
if let Some(url) = email.unsubscribe_url {
|
|
119
172
|
payload["headers"] = json!({ "List-Unsubscribe": format!("<{url}>") });
|
|
@@ -131,10 +184,23 @@ fn retry_delay(response: &reqwest::Response) -> Duration {
|
|
|
131
184
|
Duration::from_secs(secs.min(MAX_RETRY_SECS))
|
|
132
185
|
}
|
|
133
186
|
|
|
134
|
-
async fn live_deliver(
|
|
187
|
+
async fn live_deliver(
|
|
188
|
+
http: &reqwest::Client,
|
|
189
|
+
api_key: &str,
|
|
190
|
+
from: &str,
|
|
191
|
+
reply_to: Option<&str>,
|
|
192
|
+
base_url: &str,
|
|
193
|
+
email: &Email<'_>,
|
|
194
|
+
) -> Delivery {
|
|
135
195
|
let body = payload(from, reply_to, email);
|
|
136
196
|
for attempt in 1..=MAX_ATTEMPTS {
|
|
137
|
-
let response = match http
|
|
197
|
+
let response = match http
|
|
198
|
+
.post(format!("{base_url}/emails"))
|
|
199
|
+
.bearer_auth(api_key)
|
|
200
|
+
.json(&body)
|
|
201
|
+
.send()
|
|
202
|
+
.await
|
|
203
|
+
{
|
|
138
204
|
Ok(r) => r,
|
|
139
205
|
Err(e) => return Delivery::failed(format!("resend unreachable: {}", e.without_url())),
|
|
140
206
|
};
|
|
@@ -145,12 +211,15 @@ async fn live_deliver(http: &reqwest::Client, api_key: &str, from: &str, reply_t
|
|
|
145
211
|
}
|
|
146
212
|
let answer: Value = response.json().await.unwrap_or(Value::Null);
|
|
147
213
|
if status.is_success() {
|
|
148
|
-
return
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
214
|
+
return answer.get("id").and_then(|v| v.as_str()).map_or_else(
|
|
215
|
+
|| Delivery::failed("resend accepted the email but returned no id"),
|
|
216
|
+
|id| Delivery::sent(id.to_string()),
|
|
217
|
+
);
|
|
152
218
|
}
|
|
153
|
-
let message = answer
|
|
219
|
+
let message = answer
|
|
220
|
+
.get("message")
|
|
221
|
+
.and_then(|v| v.as_str())
|
|
222
|
+
.map(String::from);
|
|
154
223
|
return Delivery::failed(message.unwrap_or_else(|| format!("resend answered {status}")));
|
|
155
224
|
}
|
|
156
225
|
Delivery::failed("resend kept rate limiting this email")
|
|
@@ -185,7 +254,7 @@ mod tests {
|
|
|
185
254
|
stream.write_all(response.as_bytes()).unwrap();
|
|
186
255
|
}
|
|
187
256
|
});
|
|
188
|
-
|
|
257
|
+
Self { base, requests }
|
|
189
258
|
}
|
|
190
259
|
|
|
191
260
|
fn requests(&self) -> Vec<String> {
|
|
@@ -203,7 +272,11 @@ mod tests {
|
|
|
203
272
|
if let Some((head, body)) = text.split_once("\r\n\r\n") {
|
|
204
273
|
let length = head
|
|
205
274
|
.lines()
|
|
206
|
-
.find_map(|l|
|
|
275
|
+
.find_map(|l| {
|
|
276
|
+
l.to_lowercase()
|
|
277
|
+
.strip_prefix("content-length:")
|
|
278
|
+
.and_then(|v| v.trim().parse::<usize>().ok())
|
|
279
|
+
})
|
|
207
280
|
.unwrap_or(0);
|
|
208
281
|
if body.len() >= length {
|
|
209
282
|
return text;
|
|
@@ -216,7 +289,14 @@ mod tests {
|
|
|
216
289
|
}
|
|
217
290
|
|
|
218
291
|
fn email<'a>(to: &'a str, body: &'a str) -> Email<'a> {
|
|
219
|
-
Email {
|
|
292
|
+
Email {
|
|
293
|
+
to,
|
|
294
|
+
subject: "Spring news",
|
|
295
|
+
body,
|
|
296
|
+
unsubscribe_url: Some(
|
|
297
|
+
"https://example.com/newsletter-unsubscribed.html?email=a%40b.com",
|
|
298
|
+
),
|
|
299
|
+
}
|
|
220
300
|
}
|
|
221
301
|
|
|
222
302
|
#[test]
|
|
@@ -231,7 +311,9 @@ mod tests {
|
|
|
231
311
|
|
|
232
312
|
#[test]
|
|
233
313
|
fn a_key_and_from_is_live_even_when_mock_is_also_set() {
|
|
234
|
-
let mailer = Mailer::configured("re_x", "News <n@mail.example.com>", "1")
|
|
314
|
+
let mailer = Mailer::configured("re_x", "News <n@mail.example.com>", "1")
|
|
315
|
+
.unwrap()
|
|
316
|
+
.unwrap();
|
|
235
317
|
assert!(!mailer.is_mock());
|
|
236
318
|
}
|
|
237
319
|
|
|
@@ -243,7 +325,9 @@ mod tests {
|
|
|
243
325
|
|
|
244
326
|
#[test]
|
|
245
327
|
fn an_html_body_goes_as_html_and_a_plain_one_as_text() {
|
|
246
|
-
assert!(payload("f", None, &email("a@b.com", " <p>hi</p>"))
|
|
328
|
+
assert!(payload("f", None, &email("a@b.com", " <p>hi</p>"))
|
|
329
|
+
.get("html")
|
|
330
|
+
.is_some());
|
|
247
331
|
let plain = payload("f", None, &email("a@b.com", "just a link"));
|
|
248
332
|
assert!(plain.get("text").is_some() && plain.get("html").is_none());
|
|
249
333
|
}
|
|
@@ -251,8 +335,20 @@ mod tests {
|
|
|
251
335
|
#[test]
|
|
252
336
|
fn the_unsubscribe_url_rides_as_a_list_unsubscribe_header() {
|
|
253
337
|
let with = payload("f", None, &email("a@b.com", "x"));
|
|
254
|
-
assert_eq!(
|
|
255
|
-
|
|
338
|
+
assert_eq!(
|
|
339
|
+
with["headers"]["List-Unsubscribe"],
|
|
340
|
+
"<https://example.com/newsletter-unsubscribed.html?email=a%40b.com>"
|
|
341
|
+
);
|
|
342
|
+
let without = payload(
|
|
343
|
+
"f",
|
|
344
|
+
None,
|
|
345
|
+
&Email {
|
|
346
|
+
to: "a@b.com",
|
|
347
|
+
subject: "s",
|
|
348
|
+
body: "x",
|
|
349
|
+
unsubscribe_url: None,
|
|
350
|
+
},
|
|
351
|
+
);
|
|
256
352
|
assert!(without.get("headers").is_none());
|
|
257
353
|
}
|
|
258
354
|
|
|
@@ -260,33 +356,47 @@ mod tests {
|
|
|
260
356
|
fn a_reply_to_address_rides_in_the_payload_and_is_absent_without_one() {
|
|
261
357
|
let with = payload("f", Some("replies@example.com"), &email("a@b.com", "x"));
|
|
262
358
|
assert_eq!(with["reply_to"], "replies@example.com");
|
|
263
|
-
assert!(payload("f", None, &email("a@b.com", "x"))
|
|
359
|
+
assert!(payload("f", None, &email("a@b.com", "x"))
|
|
360
|
+
.get("reply_to")
|
|
361
|
+
.is_none());
|
|
264
362
|
}
|
|
265
363
|
|
|
266
364
|
#[test]
|
|
267
365
|
fn replying_to_trims_the_address_and_a_blank_one_sets_none() {
|
|
268
|
-
let set =
|
|
366
|
+
let set =
|
|
367
|
+
Mailer::live("re_x", "n@mail.example.com", "http://x").replying_to(" me@example.com ");
|
|
269
368
|
assert!(matches!(&set, Mailer::Live { reply_to: Some(r), .. } if r == "me@example.com"));
|
|
270
369
|
let blank = Mailer::live("re_x", "n@mail.example.com", "http://x").replying_to(" ");
|
|
271
370
|
assert!(matches!(&blank, Mailer::Live { reply_to: None, .. }));
|
|
272
|
-
assert!(
|
|
371
|
+
assert!(
|
|
372
|
+
Mailer::Mock.replying_to("me@example.com").is_mock(),
|
|
373
|
+
"a mock mailer has no replies"
|
|
374
|
+
);
|
|
273
375
|
}
|
|
274
376
|
|
|
275
377
|
#[tokio::test]
|
|
276
378
|
async fn a_live_send_carries_the_reply_to_address_to_resend() {
|
|
277
379
|
let fake = FakeResend::start(vec![("200 OK", "", r#"{"id":"em_9"}"#)]);
|
|
278
|
-
let mailer = Mailer::live("re_x", "News <n@mail.example.com>", &fake.base)
|
|
380
|
+
let mailer = Mailer::live("re_x", "News <n@mail.example.com>", &fake.base)
|
|
381
|
+
.replying_to("replies@example.com");
|
|
279
382
|
|
|
280
383
|
mailer.deliver(&email("a@b.com", "x")).await;
|
|
281
384
|
|
|
282
|
-
assert!(
|
|
385
|
+
assert!(
|
|
386
|
+
fake.requests()[0].contains(r#""reply_to":"replies@example.com""#),
|
|
387
|
+
"{}",
|
|
388
|
+
fake.requests()[0]
|
|
389
|
+
);
|
|
283
390
|
}
|
|
284
391
|
|
|
285
392
|
#[test]
|
|
286
393
|
fn the_mock_logs_instead_of_sending_and_bounces_the_test_address() {
|
|
287
394
|
let sent = mock_deliver(&email("a@b.com", "x"));
|
|
288
395
|
assert!(sent.ok && sent.message_id.unwrap().starts_with("re_"));
|
|
289
|
-
assert_eq!(
|
|
396
|
+
assert_eq!(
|
|
397
|
+
mock_deliver(&email(MOCK_BOUNCE_ADDRESS, "x")),
|
|
398
|
+
Delivery::failed("bounced")
|
|
399
|
+
);
|
|
290
400
|
}
|
|
291
401
|
|
|
292
402
|
#[tokio::test]
|
|
@@ -301,26 +411,49 @@ mod tests {
|
|
|
301
411
|
assert_eq!(requests.len(), 1);
|
|
302
412
|
let request = requests[0].to_lowercase();
|
|
303
413
|
assert!(request.starts_with("post /emails "), "{request}");
|
|
304
|
-
assert!(
|
|
305
|
-
|
|
414
|
+
assert!(
|
|
415
|
+
request.contains("authorization: bearer re_secret"),
|
|
416
|
+
"{request}"
|
|
417
|
+
);
|
|
418
|
+
assert!(
|
|
419
|
+
requests[0].contains(r#""to":["a@b.com"]"#)
|
|
420
|
+
&& requests[0].contains(r#""from":"News <n@mail.example.com>""#),
|
|
421
|
+
"{}",
|
|
422
|
+
requests[0]
|
|
423
|
+
);
|
|
306
424
|
}
|
|
307
425
|
|
|
308
426
|
#[tokio::test]
|
|
309
427
|
async fn a_refusal_from_resend_is_a_failed_delivery_carrying_its_message() {
|
|
310
|
-
let fake = FakeResend::start(vec![(
|
|
311
|
-
|
|
428
|
+
let fake = FakeResend::start(vec![(
|
|
429
|
+
"403 Forbidden",
|
|
430
|
+
"",
|
|
431
|
+
r#"{"message":"The mail.example.com domain is not verified.","name":"validation_error"}"#,
|
|
432
|
+
)]);
|
|
433
|
+
let delivery = Mailer::live("re_x", "n@mail.example.com", &fake.base)
|
|
434
|
+
.deliver(&email("a@b.com", "x"))
|
|
435
|
+
.await;
|
|
312
436
|
|
|
313
437
|
assert!(!delivery.ok);
|
|
314
|
-
assert_eq!(
|
|
438
|
+
assert_eq!(
|
|
439
|
+
delivery.reason.as_deref(),
|
|
440
|
+
Some("The mail.example.com domain is not verified.")
|
|
441
|
+
);
|
|
315
442
|
}
|
|
316
443
|
|
|
317
444
|
#[tokio::test]
|
|
318
445
|
async fn a_rate_limited_send_waits_and_is_retried() {
|
|
319
446
|
let fake = FakeResend::start(vec![
|
|
320
|
-
(
|
|
447
|
+
(
|
|
448
|
+
"429 Too Many Requests",
|
|
449
|
+
"retry-after: 0\r\n",
|
|
450
|
+
r#"{"message":"Too many requests"}"#,
|
|
451
|
+
),
|
|
321
452
|
("200 OK", "", r#"{"id":"em_after_retry"}"#),
|
|
322
453
|
]);
|
|
323
|
-
let delivery = Mailer::live("re_x", "n@mail.example.com", &fake.base)
|
|
454
|
+
let delivery = Mailer::live("re_x", "n@mail.example.com", &fake.base)
|
|
455
|
+
.deliver(&email("a@b.com", "x"))
|
|
456
|
+
.await;
|
|
324
457
|
|
|
325
458
|
assert_eq!(delivery, Delivery::sent("em_after_retry".to_string()));
|
|
326
459
|
assert_eq!(fake.requests().len(), 2);
|
|
@@ -332,7 +465,9 @@ mod tests {
|
|
|
332
465
|
let base = format!("http://{}", unused.local_addr().unwrap());
|
|
333
466
|
drop(unused);
|
|
334
467
|
|
|
335
|
-
let delivery = Mailer::live("re_x", "n@mail.example.com", &base)
|
|
468
|
+
let delivery = Mailer::live("re_x", "n@mail.example.com", &base)
|
|
469
|
+
.deliver(&email("a@b.com", "x"))
|
|
470
|
+
.await;
|
|
336
471
|
|
|
337
472
|
assert!(!delivery.ok);
|
|
338
473
|
assert!(delivery.reason.unwrap().starts_with("resend unreachable"));
|
data/rust/host/src/secrets.rs
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
//! Fetches secrets from Secrets Manager at runtime, not via CloudFormation dynamic references.
|
|
2
2
|
//! A resolved reference lands decrypted in function configuration, readable account-wide.
|
|
3
3
|
|
|
4
|
+
use std::collections::HashMap;
|
|
5
|
+
use std::sync::{OnceLock, RwLock};
|
|
6
|
+
|
|
7
|
+
// Secrets fetched at boot live here instead of in the process environment: `std::env::set_var` is
|
|
8
|
+
// unsound once tokio's worker and blocking threads exist, since any of them may read the
|
|
9
|
+
// environment (a DNS lookup does) at the same moment.
|
|
10
|
+
static FETCHED: OnceLock<RwLock<HashMap<&'static str, String>>> = OnceLock::new();
|
|
11
|
+
|
|
12
|
+
fn fetched() -> &'static RwLock<HashMap<&'static str, String>> {
|
|
13
|
+
FETCHED.get_or_init(RwLock::default)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/// Records a secret fetched at boot under the environment variable name it stands in for.
|
|
17
|
+
pub fn provide(name: &'static str, value: String) {
|
|
18
|
+
fetched()
|
|
19
|
+
.write()
|
|
20
|
+
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
21
|
+
.insert(name, value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// The value of `name`: a secret fetched at boot when there is one, else the process environment.
|
|
25
|
+
pub fn var(name: &str) -> Result<String, std::env::VarError> {
|
|
26
|
+
let provided = fetched()
|
|
27
|
+
.read()
|
|
28
|
+
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
29
|
+
.get(name)
|
|
30
|
+
.cloned();
|
|
31
|
+
provided.map_or_else(|| std::env::var(name), Ok)
|
|
32
|
+
}
|
|
33
|
+
|
|
4
34
|
pub struct AwsSecretFetcher {
|
|
5
35
|
client: aws_sdk_secretsmanager::Client,
|
|
6
36
|
}
|
|
@@ -10,10 +40,17 @@ impl AwsSecretFetcher {
|
|
|
10
40
|
// default features would wire in aws-lc-rs instead (see Cargo.toml).
|
|
11
41
|
pub async fn from_env() -> Self {
|
|
12
42
|
let http_client = aws_smithy_http_client::Builder::new()
|
|
13
|
-
.tls_provider(aws_smithy_http_client::tls::Provider::Rustls(
|
|
43
|
+
.tls_provider(aws_smithy_http_client::tls::Provider::Rustls(
|
|
44
|
+
aws_smithy_http_client::tls::rustls_provider::CryptoMode::Ring,
|
|
45
|
+
))
|
|
14
46
|
.build_https();
|
|
15
|
-
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
|
|
16
|
-
|
|
47
|
+
let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
|
|
48
|
+
.http_client(http_client)
|
|
49
|
+
.load()
|
|
50
|
+
.await;
|
|
51
|
+
Self {
|
|
52
|
+
client: aws_sdk_secretsmanager::Client::new(&config),
|
|
53
|
+
}
|
|
17
54
|
}
|
|
18
55
|
|
|
19
56
|
/// The secret's whole `SecretString`, undecoded. `secret_id` is a full ARN or a name.
|
|
@@ -27,17 +64,31 @@ impl AwsSecretFetcher {
|
|
|
27
64
|
.map_err(|e| anyhow::anyhow!("fetching secret {secret_id}: {e:?}"))?;
|
|
28
65
|
response
|
|
29
66
|
.secret_string()
|
|
30
|
-
.map(
|
|
67
|
+
.map(std::string::ToString::to_string)
|
|
31
68
|
.ok_or_else(|| anyhow::anyhow!("secret {secret_id} has no SecretString"))
|
|
32
69
|
}
|
|
33
70
|
|
|
34
71
|
/// Like `fetch_secret_string`, but a secret that does not exist yet is
|
|
35
72
|
/// `None`, not an error: the payment keys' secret is only created by the
|
|
36
73
|
/// first save.
|
|
37
|
-
pub async fn fetch_secret_string_if_present(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
74
|
+
pub async fn fetch_secret_string_if_present(
|
|
75
|
+
&self,
|
|
76
|
+
secret_id: &str,
|
|
77
|
+
) -> anyhow::Result<Option<String>> {
|
|
78
|
+
match self
|
|
79
|
+
.client
|
|
80
|
+
.get_secret_value()
|
|
81
|
+
.secret_id(secret_id)
|
|
82
|
+
.send()
|
|
83
|
+
.await
|
|
84
|
+
{
|
|
85
|
+
Ok(response) => Ok(response.secret_string().map(std::string::ToString::to_string)),
|
|
86
|
+
Err(e)
|
|
87
|
+
if e.as_service_error()
|
|
88
|
+
.is_some_and(aws_sdk_secretsmanager::operation::get_secret_value::GetSecretValueError::is_resource_not_found_exception) =>
|
|
89
|
+
{
|
|
90
|
+
Ok(None)
|
|
91
|
+
}
|
|
41
92
|
Err(_) => Err(anyhow::anyhow!("reading secret {secret_id} failed")),
|
|
42
93
|
}
|
|
43
94
|
}
|
|
@@ -45,9 +96,19 @@ impl AwsSecretFetcher {
|
|
|
45
96
|
/// Replaces the secret's `SecretString`, creating the secret the first time.
|
|
46
97
|
/// The errors name the secret and never carry the value.
|
|
47
98
|
pub async fn put_secret_string(&self, secret_id: &str, value: &str) -> anyhow::Result<()> {
|
|
48
|
-
match self
|
|
99
|
+
match self
|
|
100
|
+
.client
|
|
101
|
+
.put_secret_value()
|
|
102
|
+
.secret_id(secret_id)
|
|
103
|
+
.secret_string(value)
|
|
104
|
+
.send()
|
|
105
|
+
.await
|
|
106
|
+
{
|
|
49
107
|
Ok(_) => Ok(()),
|
|
50
|
-
Err(e)
|
|
108
|
+
Err(e)
|
|
109
|
+
if e.as_service_error()
|
|
110
|
+
.is_some_and(aws_sdk_secretsmanager::operation::put_secret_value::PutSecretValueError::is_resource_not_found_exception) =>
|
|
111
|
+
{
|
|
51
112
|
self.client
|
|
52
113
|
.create_secret()
|
|
53
114
|
.name(secret_id)
|
|
@@ -66,19 +127,21 @@ impl AwsSecretFetcher {
|
|
|
66
127
|
///
|
|
67
128
|
/// Pure, so it is tested without a client.
|
|
68
129
|
pub fn extract_field(secret_json: &str, field: &str) -> Result<String, String> {
|
|
69
|
-
let value: serde_json::Value =
|
|
70
|
-
|
|
130
|
+
let value: serde_json::Value = serde_json::from_str(secret_json)
|
|
131
|
+
.map_err(|e| format!("secret's SecretString isn't valid JSON: {e}"))?;
|
|
71
132
|
value
|
|
72
133
|
.get(field)
|
|
73
134
|
.and_then(|p| p.as_str())
|
|
74
|
-
.map(
|
|
135
|
+
.map(std::string::ToString::to_string)
|
|
75
136
|
.ok_or_else(|| format!("secret's SecretString has no string {field:?} field"))
|
|
76
137
|
}
|
|
77
138
|
|
|
78
139
|
/// One named string field out of a secret's JSON `SecretString`, or `None` when the secret does not
|
|
79
140
|
/// carry it (or is not JSON, or the field is blank). For a field a secret may have and may not.
|
|
80
141
|
pub fn optional_field(secret_json: &str, field: &str) -> Option<String> {
|
|
81
|
-
extract_field(secret_json, field)
|
|
142
|
+
extract_field(secret_json, field)
|
|
143
|
+
.ok()
|
|
144
|
+
.filter(|value| !value.trim().is_empty())
|
|
82
145
|
}
|
|
83
146
|
|
|
84
147
|
/// The `postgres://` URL for a database secret.
|
|
@@ -93,9 +156,16 @@ pub fn database_url(secret_json: &str, host: &str, database: &str) -> Result<Str
|
|
|
93
156
|
let user = extract_field(secret_json, "username").unwrap_or_else(|_| "postgres".to_string());
|
|
94
157
|
let port = serde_json::from_str::<serde_json::Value>(secret_json)
|
|
95
158
|
.ok()
|
|
96
|
-
.and_then(|v|
|
|
159
|
+
.and_then(|v| {
|
|
160
|
+
v.get("port").and_then(|p| {
|
|
161
|
+
p.as_u64()
|
|
162
|
+
.or_else(|| p.as_str().and_then(|s| s.parse().ok()))
|
|
163
|
+
})
|
|
164
|
+
})
|
|
97
165
|
.unwrap_or(5432);
|
|
98
|
-
Ok(format!(
|
|
166
|
+
Ok(format!(
|
|
167
|
+
"postgres://{user}:{password}@{host}:{port}/{database}"
|
|
168
|
+
))
|
|
99
169
|
}
|
|
100
170
|
|
|
101
171
|
#[cfg(test)]
|
|
@@ -105,7 +175,10 @@ mod tests {
|
|
|
105
175
|
#[test]
|
|
106
176
|
fn an_optional_field_is_read_when_present_and_none_when_absent_blank_or_not_json() {
|
|
107
177
|
let json = r#"{"api_key":"re_1","webhook_secret":"whsec_abc","blank":" "}"#;
|
|
108
|
-
assert_eq!(
|
|
178
|
+
assert_eq!(
|
|
179
|
+
optional_field(json, "webhook_secret").as_deref(),
|
|
180
|
+
Some("whsec_abc")
|
|
181
|
+
);
|
|
109
182
|
assert_eq!(optional_field(json, "missing"), None);
|
|
110
183
|
assert_eq!(optional_field(json, "blank"), None);
|
|
111
184
|
assert_eq!(optional_field("not json", "webhook_secret"), None);
|
|
@@ -114,23 +187,35 @@ mod tests {
|
|
|
114
187
|
#[test]
|
|
115
188
|
fn a_master_secret_logs_in_as_postgres_on_the_default_port() {
|
|
116
189
|
let json = r#"{"username":"postgres","password":"abc123"}"#;
|
|
117
|
-
assert_eq!(
|
|
190
|
+
assert_eq!(
|
|
191
|
+
database_url(json, "db.example", "app").unwrap(),
|
|
192
|
+
"postgres://postgres:abc123@db.example:5432/app"
|
|
193
|
+
);
|
|
118
194
|
}
|
|
119
195
|
|
|
120
196
|
#[test]
|
|
121
197
|
fn a_clients_own_secret_logs_in_as_its_role() {
|
|
122
198
|
let json = r#"{"username":"acme","password":"s3cr3t","host":"db.example","port":5433,"dbname":"acme"}"#;
|
|
123
|
-
assert_eq!(
|
|
199
|
+
assert_eq!(
|
|
200
|
+
database_url(json, "db.example", "acme").unwrap(),
|
|
201
|
+
"postgres://acme:s3cr3t@db.example:5433/acme"
|
|
202
|
+
);
|
|
124
203
|
}
|
|
125
204
|
|
|
126
205
|
#[test]
|
|
127
206
|
fn a_secret_with_no_username_is_read_as_postgres() {
|
|
128
|
-
assert_eq!(
|
|
207
|
+
assert_eq!(
|
|
208
|
+
database_url(r#"{"password":"pw"}"#, "h", "d").unwrap(),
|
|
209
|
+
"postgres://postgres:pw@h:5432/d"
|
|
210
|
+
);
|
|
129
211
|
}
|
|
130
212
|
|
|
131
213
|
#[test]
|
|
132
214
|
fn a_port_written_as_text_is_read() {
|
|
133
|
-
assert_eq!(
|
|
215
|
+
assert_eq!(
|
|
216
|
+
database_url(r#"{"password":"pw","port":"6000"}"#, "h", "d").unwrap(),
|
|
217
|
+
"postgres://postgres:pw@h:6000/d"
|
|
218
|
+
);
|
|
134
219
|
}
|
|
135
220
|
|
|
136
221
|
#[test]
|
|
@@ -162,4 +247,14 @@ mod tests {
|
|
|
162
247
|
fn refuses_invalid_json() {
|
|
163
248
|
assert!(extract_field("not json", "password").is_err());
|
|
164
249
|
}
|
|
250
|
+
|
|
251
|
+
#[test]
|
|
252
|
+
fn a_provided_secret_is_read_back_and_an_unprovided_one_falls_back_to_the_environment() {
|
|
253
|
+
provide("HECKS_SECRETS_SPEC_PROVIDED", "from-boot".to_string());
|
|
254
|
+
assert_eq!(
|
|
255
|
+
var("HECKS_SECRETS_SPEC_PROVIDED").as_deref(),
|
|
256
|
+
Ok("from-boot")
|
|
257
|
+
);
|
|
258
|
+
assert!(var("HECKS_SECRETS_SPEC_NEVER_SET").is_err());
|
|
259
|
+
}
|
|
165
260
|
}
|