hecks 2.9.0 → 3.0.1
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/exe/hecks +51 -6
- data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
- data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
- data/lib/hecks/adapters/driven/lambda.rb +1 -1
- data/lib/hecks/adapters/driven/memory.rb +16 -3
- data/lib/hecks/adapters/driven/postgres/reconnect.rb +6 -2
- data/lib/hecks/adapters/driven/postgres/shared_connection.rb +180 -0
- data/lib/hecks/adapters/driven/postgres.rb +13 -4
- data/lib/hecks/adapters/driven/prism.rb +41 -8
- data/lib/hecks/adapters/driven/tenant_provisioner.rb +73 -16
- data/lib/hecks/behaviors/expectations.rb +1 -1
- data/lib/hecks/behaviors.rb +1 -1
- data/lib/hecks/bench/cli.rb +62 -0
- data/lib/hecks/bench/environment.rb +71 -0
- data/lib/hecks/bench/postgres_probe.rb +30 -0
- data/lib/hecks/bench/report.rb +76 -0
- data/lib/hecks/bench/ruby_runner.rb +93 -0
- data/lib/hecks/bench/run.rb +55 -0
- data/lib/hecks/bench/rust_runner.rb +101 -0
- data/lib/hecks/bench/stats.rb +46 -0
- data/lib/hecks/bench/suite.rb +98 -0
- data/lib/hecks/bench/workload.rb +89 -0
- data/lib/hecks/bench.rb +17 -0
- data/lib/hecks/bluebook/assembly/contracts.rb +2 -0
- data/lib/hecks/bluebook/attribute.rb +1 -1
- data/lib/hecks/bluebook/behaviour/aggregate.rb +30 -1
- data/lib/hecks/bluebook/behaviour/domain_port.rb +7 -0
- data/lib/hecks/bluebook/chapter.rb +6 -2
- data/lib/hecks/bluebook/domain_port.rb +36 -5
- data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +10 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +8 -0
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -1
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +31 -3
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +122 -3
- data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/query_builder.rb +27 -1
- data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -1
- data/lib/hecks/bluebook/dsl/world_builder.rb +29 -2
- data/lib/hecks/bluebook/expression/canonical_form.rb +1 -1
- data/lib/hecks/bluebook/expression/resolver.rb +3 -2
- data/lib/hecks/bluebook/hexagon.rb +13 -5
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -0
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +4 -1
- data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +246 -0
- data/lib/hecks/bluebook/meta_validator.rb +9 -4
- data/lib/hecks/bluebook/model_check.rb +23 -0
- data/lib/hecks/bluebook/pattern_subset.rb +38 -0
- data/lib/hecks/bluebook/query.rb +20 -5
- data/lib/hecks/bluebook/smoke_test.rb +1 -1
- data/lib/hecks/canonical_json.rb +30 -0
- data/lib/hecks/chapters.rb +102 -0
- data/lib/hecks/cli/behaviors.rb +61 -0
- data/lib/hecks/cli/canonicalise.rb +25 -0
- data/lib/hecks/cli/check_engine_agreement.rb +32 -0
- data/lib/hecks/cli/check_era.rb +88 -0
- data/lib/hecks/cli/console.rb +66 -0
- data/lib/hecks/cli/deploy_template_diff.rb +40 -0
- data/lib/hecks/cli/doc_coverage.rb +32 -0
- data/lib/hecks/cli/document.rb +2 -2
- data/lib/hecks/cli/expression_projection.rb +41 -0
- data/lib/hecks/cli/follow.rb +88 -0
- data/lib/hecks/cli/history.rb +44 -0
- data/lib/hecks/cli/ir.rb +1 -1
- data/lib/hecks/cli/mcp.rb +3 -3
- data/lib/hecks/cli/model_check.rb +15 -8
- data/lib/hecks/cli/pattern_cases.rb +47 -0
- data/lib/hecks/cli/present.rb +39 -0
- data/lib/hecks/cli/project_cli.rb +224 -32
- data/lib/hecks/cli/project_diagrams.rb +4 -3
- data/lib/hecks/cli/project_glossary.rb +44 -0
- data/lib/hecks/cli/reference.rb +46 -0
- data/lib/hecks/cli/refresh_projections.rb +44 -0
- data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +150 -0
- data/lib/hecks/cli/release.rb +70 -0
- data/lib/hecks/cli/release_gem.rb +59 -0
- data/lib/hecks/cli/rspec_io_parallel_files.rb +137 -0
- data/lib/hecks/cli/rspec_shard_files.rb +66 -0
- data/lib/hecks/cli/run.rb +3 -3
- data/lib/hecks/cli/seed_semantics_corpus.rb +71 -0
- data/lib/hecks/cli/serve_query_ir_mcp.rb +30 -0
- data/lib/hecks/cli/shape.rb +70 -0
- data/lib/hecks/cli/smoke_http.rb +230 -0
- data/lib/hecks/cli/smoke_test.rb +5 -3
- data/lib/hecks/cli/spec_example.rb +27 -0
- data/lib/hecks/cli/statements.rb +32 -0
- data/lib/hecks/cli/stores.rb +4 -5
- data/lib/hecks/cli/stress_concurrency_specs.rb +177 -0
- data/lib/hecks/cli.rb +201 -52
- data/lib/hecks/codemod.rb +324 -0
- data/lib/hecks/corpus.rb +423 -0
- data/lib/hecks/deploy/bluebook/deploy.bluebook +591 -23
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +41 -4
- data/lib/hecks/deploy/oidc.json +82 -2
- data/lib/hecks/doc/reference.rb +351 -0
- data/lib/hecks/doors/cli_door.rb +196 -0
- data/lib/hecks/doors/cli_runner.rb +297 -0
- data/lib/hecks/{facade → doors}/command_request.rb +1 -1
- data/lib/hecks/{facade → doors}/handle.rb +2 -2
- data/lib/hecks/{facade → doors}/json_door.rb +4 -4
- data/lib/hecks/doors/launcher_options.rb +129 -0
- data/lib/hecks/{cli → doors}/mcp_door.rb +7 -7
- data/lib/hecks/doors/mcp_door_scope.rb +97 -0
- data/lib/hecks/{facade/surface → doors/ruby_door}/aggregate_door.rb +8 -3
- data/lib/hecks/{facade/surface → doors/ruby_door}/chapter.rb +3 -3
- data/lib/hecks/{facade/surface.rb → doors/ruby_door.rb} +36 -5
- data/lib/hecks/doors.rb +61 -0
- data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +1 -1
- data/lib/hecks/embryonaut_bluebook.rb +1 -1
- data/lib/hecks/engine_agreement.rb +156 -0
- data/lib/hecks/facade.rb +24 -37
- data/lib/hecks/forms/app.rb +6 -6
- data/lib/hecks/forms/banking_presentation.rb +50 -0
- data/lib/hecks/forms/field_shape.rb +2 -1
- data/lib/hecks/forms/port_argument.rb +1 -1
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +393 -0
- data/lib/hecks/fuzzing/combination_miner.rb +86 -0
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +214 -0
- data/lib/hecks/fuzzing/coverage_campaign.rb +143 -0
- data/lib/hecks/fuzzing/differential.rb +156 -0
- data/lib/hecks/fuzzing/domain_generator.rb +615 -0
- data/lib/hecks/fuzzing/era_boundary.rb +71 -0
- data/lib/hecks/fuzzing/form_census.rb +136 -0
- data/lib/hecks/fuzzing/generated_domain_check.rb +93 -0
- data/lib/hecks/fuzzing/invalid_value_generator.rb +64 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +301 -0
- data/lib/hecks/fuzzing/nondeterministic.rb +51 -0
- data/lib/hecks/fuzzing/outside_world.rb +45 -0
- data/lib/hecks/fuzzing/persistence_parity.rb +102 -0
- data/lib/hecks/fuzzing/properties/corrections.rb +88 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +314 -0
- data/lib/hecks/fuzzing/properties/guards.rb +190 -0
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +300 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +93 -0
- data/lib/hecks/fuzzing/properties/outbox.rb +89 -0
- data/lib/hecks/fuzzing/properties/querying.rb +175 -0
- data/lib/hecks/fuzzing/properties.rb +162 -0
- data/lib/hecks/fuzzing/qa_settings.rb +123 -0
- data/lib/hecks/fuzzing/replay.rb +505 -0
- data/lib/hecks/fuzzing/rotation_priority.rb +48 -0
- data/lib/hecks/fuzzing/rust_gap_manifest.rb +108 -0
- data/lib/hecks/fuzzing/self_consistency.rb +339 -0
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +385 -0
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +132 -0
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +95 -0
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +77 -0
- data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +109 -0
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +237 -0
- data/lib/hecks/fuzzing/sequence_generator.rb +189 -0
- data/lib/hecks/fuzzing/shrinker.rb +166 -0
- data/lib/hecks/fuzzing/structural_skips.rb +33 -0
- data/lib/hecks/fuzzing/sweep_depth.rb +43 -0
- data/lib/hecks/fuzzing/target_capabilities.rb +163 -0
- data/lib/hecks/fuzzing/value_generator.rb +216 -0
- data/lib/hecks/fuzzing.rb +23 -0
- data/lib/hecks/grammar/evolve.rb +336 -0
- data/lib/hecks/hecks/adapters/codebase/codemods.rb +45 -0
- data/lib/hecks/hecks/adapters/codebase/conformance.rb +71 -0
- data/lib/hecks/hecks/adapters/codebase/corpus_tasks.rb +202 -0
- data/lib/hecks/hecks/adapters/codebase/gem_registry.rb +80 -0
- data/lib/hecks/hecks/adapters/codebase/kernel_tables.rb +54 -0
- data/lib/hecks/hecks/adapters/codebase/language.rb +240 -0
- data/lib/hecks/hecks/adapters/codebase/npm_registry.rb +102 -0
- data/lib/hecks/hecks/adapters/codebase/publishing.rb +142 -0
- data/lib/hecks/hecks/adapters/codebase/regeneration.rb +67 -0
- data/lib/hecks/hecks/adapters/codebase/release_facts.rb +90 -0
- data/lib/hecks/hecks/adapters/codebase/ruby_child.rb +83 -0
- data/lib/hecks/hecks/adapters/codebase/secret_vault.rb +36 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.adapter +7 -0
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +189 -0
- data/lib/hecks/hecks/adapters/codebase/sqlite_fixture.rb +71 -0
- data/lib/hecks/hecks/adapters/codebase/style.rb +171 -0
- data/lib/hecks/hecks/adapters/codebase/test_runner.rb +55 -0
- data/lib/hecks/hecks/adapters/codebase/test_suite.rb +165 -0
- data/lib/hecks/hecks/adapters/codebase/tree.rb +111 -0
- data/lib/hecks/hecks/adapters/console_capture.rb +79 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.adapter +7 -0
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +108 -0
- data/lib/hecks/hecks/adapters/git.adapter +7 -0
- data/lib/hecks/hecks/adapters/git.rb +102 -0
- data/lib/hecks/hecks/adapters/host_http.adapter +7 -0
- data/lib/hecks/hecks/adapters/host_http.rb +65 -0
- data/lib/hecks/hecks/adapters/in_process_boot.adapter +7 -0
- data/lib/hecks/hecks/adapters/in_process_boot.rb +197 -0
- data/lib/hecks/hecks/adapters/in_process_operations.rb +202 -0
- data/lib/hecks/hecks/adapters/journal_store/changes.rb +143 -0
- data/lib/hecks/hecks/adapters/journal_store/compaction.rb +145 -0
- data/lib/hecks/hecks/adapters/journal_store/edge_audit.rb +132 -0
- data/lib/hecks/hecks/adapters/journal_store/examination.rb +135 -0
- data/lib/hecks/hecks/adapters/journal_store/held_domain.rb +163 -0
- data/lib/hecks/hecks/adapters/journal_store/readings.rb +187 -0
- data/lib/hecks/hecks/adapters/journal_store.adapter +7 -0
- data/lib/hecks/hecks/adapters/journal_store.rb +64 -0
- data/lib/hecks/hecks/adapters/local_files.adapter +7 -0
- data/lib/hecks/hecks/adapters/local_files.rb +46 -0
- data/lib/hecks/hecks/adapters/pg_admin.adapter +7 -0
- data/lib/hecks/hecks/adapters/pg_admin.rb +245 -0
- data/lib/hecks/hecks/adapters/process_pool.adapter +7 -0
- data/lib/hecks/hecks/adapters/process_pool.rb +119 -0
- data/lib/hecks/hecks/adapters/rust_toolchain.adapter +7 -0
- data/lib/hecks/hecks/adapters/rust_toolchain.rb +179 -0
- data/lib/hecks/hecks/adapters/rust_workspace.rb +150 -0
- data/lib/hecks/hecks/adapters/shell.adapter +7 -0
- data/lib/hecks/hecks/adapters/shell.rb +49 -0
- data/lib/hecks/hecks/adapters/terminal.adapter +7 -0
- data/lib/hecks/hecks/adapters/terminal.rb +74 -0
- data/lib/hecks/hecks/codebase.behaviors +172 -0
- data/lib/hecks/hecks/codebase.bluebook +2453 -0
- data/lib/hecks/hecks/context_map.hecksagon +13 -0
- data/lib/hecks/hecks/custodian.behaviors +179 -0
- data/lib/hecks/hecks/custodian.bluebook +1847 -0
- data/lib/hecks/hecks/deploy.behaviors +74 -0
- data/lib/hecks/hecks/deploy.world +12 -0
- data/lib/hecks/hecks/environments/memory.world +10 -0
- data/lib/hecks/hecks/governance.world +7 -0
- data/lib/hecks/hecks/hecks.behaviors +62 -0
- data/lib/hecks/hecks/hecks.bluebook +124 -0
- data/lib/hecks/hecks/hecks.hecksagon +390 -0
- data/lib/hecks/hecks/hecks.world +42 -0
- data/lib/hecks/kernel_capabilities.rb +194 -0
- data/lib/hecks/language/bluebook/bluebook.bluebook +11 -0
- data/lib/hecks/language/bluebook/query.bluebook +7 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -4
- data/lib/hecks/language/hecksagon/domain_port.bluebook +10 -0
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +20 -0
- data/lib/hecks/mcp_stdio_guard.rb +2 -2
- data/lib/hecks/persistence_legacy_fixture/regenerate.rb +161 -0
- data/lib/hecks/persistence_legacy_fixture.rb +113 -0
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +8 -5
- data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +4 -1
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +15 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +32 -15
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +15 -5
- data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +227 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +43 -24
- data/lib/hecks/ports/persistence/plugins/era/translation.rb +1 -0
- data/lib/hecks/ports/persistence/state_codec.rb +24 -5
- data/lib/hecks/projection_files.rb +285 -0
- data/lib/hecks/projections/bootstrap_table.rb +1 -1
- data/lib/hecks/projections/deploy/fargate.rb +5 -5
- data/lib/hecks/projections/deploy/lambda.rb +43 -32
- data/lib/hecks/projections/deploy/preview/preview.sh.erb +1 -1
- data/lib/hecks/projections/deploy/preview/template.rb +1 -1
- data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/scripts/expected-era.header +1 -1
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +3 -3
- data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/shared.rb +1 -1
- data/lib/hecks/projections/diagrams.rb +3 -3
- data/lib/hecks/projections/model/deviations.rb +1 -1
- data/lib/hecks/projections/parser_table.rb +4 -4
- data/lib/hecks/projections/reference.rb +2 -3
- data/lib/hecks/projections/rust_vocabulary.rb +1 -1
- data/lib/hecks/projector/cli_projector.rb +106 -37
- data/lib/hecks/projector.rb +1 -1
- data/lib/hecks/quality_control/adapters/agent.adapter +7 -0
- data/lib/hecks/quality_control/adapters/agent.rb +58 -0
- data/lib/hecks/quality_control/adapters/angle_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/angle_tools.rb +15 -0
- data/lib/hecks/quality_control/adapters/clearance_recorder.rb +36 -0
- data/lib/hecks/quality_control/adapters/clearance_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/clearance_tools.rb +17 -0
- data/lib/hecks/quality_control/adapters/git_pr.adapter +7 -0
- data/lib/hecks/quality_control/adapters/git_pr.rb +226 -0
- data/lib/hecks/quality_control/adapters/github_checks.adapter +9 -0
- data/lib/hecks/quality_control/adapters/github_checks.rb +83 -0
- data/lib/hecks/quality_control/adapters/github_ci_webhook.rb +56 -0
- data/lib/hecks/quality_control/adapters/github_issues.adapter +7 -0
- data/lib/hecks/quality_control/adapters/github_issues.rb +49 -0
- data/lib/hecks/quality_control/adapters/qa_tool.rb +59 -0
- data/lib/hecks/quality_control/adapters/sweep_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/sweep_tools.rb +51 -0
- data/lib/hecks/quality_control/adapters/target_tools.adapter +7 -0
- data/lib/hecks/quality_control/adapters/target_tools.rb +43 -0
- data/lib/hecks/quality_control/cli/child.rb +45 -0
- data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +45 -0
- data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +248 -0
- data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +163 -0
- data/lib/hecks/quality_control/cli/qa_generated_domains.rb +450 -0
- data/lib/hecks/quality_control/cli/qa_log_bug.rb +195 -0
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +220 -0
- data/lib/hecks/quality_control/cli/qa_open_pr.rb +305 -0
- data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +154 -0
- data/lib/hecks/quality_control/cli/qa_postgres_role.rb +159 -0
- data/lib/hecks/quality_control/cli/qa_pr_check.rb +232 -0
- data/lib/hecks/quality_control/cli/qa_seed_angles.rb +142 -0
- data/lib/hecks/quality_control/cli/qa_seed_targets.rb +69 -0
- data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +280 -0
- data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +329 -0
- data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +92 -0
- data/lib/hecks/quality_control/cli/qa_sweep/finding_report.rb +117 -0
- data/lib/hecks/quality_control/cli/qa_sweep/parsing.rb +175 -0
- data/lib/hecks/quality_control/cli/qa_sweep/release_mode.rb +60 -0
- data/lib/hecks/quality_control/cli/qa_sweep.rb +611 -0
- data/lib/hecks/quality_control/cli/qa_tick.rb +274 -0
- data/lib/hecks/quality_control/quality_control.bluebook +2224 -0
- data/lib/hecks/quality_control/quality_control.hecksagon +13 -0
- data/lib/hecks/quality_control/quality_control.ports.hecksagon +54 -0
- data/lib/hecks/query_ir.rb +346 -0
- data/lib/hecks/query_ir_mcp.rb +198 -0
- data/lib/hecks/release/runner/ci_publisher.rb +2 -2
- data/lib/hecks/release/runner/clean_tree.rb +42 -0
- data/lib/hecks/release/runner/console.rb +6 -0
- data/lib/hecks/release/runner/gem_publisher.rb +14 -7
- data/lib/hecks/release/runner/npm_publisher.rb +6 -36
- data/lib/hecks/release/runner/preflight.rb +26 -2
- data/lib/hecks/release/runner/published.rb +8 -26
- data/lib/hecks/release/runner/tagger.rb +30 -15
- data/lib/hecks/release/runner.rb +57 -6
- data/lib/hecks/router/namespace_installer.rb +3 -3
- data/lib/hecks/runtime/adapter_lookup.rb +124 -0
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +4 -0
- data/lib/hecks/runtime/dependency_planning.rb +20 -2
- data/lib/hecks/runtime/dispatcher.rb +53 -3
- data/lib/hecks/runtime/interpreting.rb +3 -0
- data/lib/hecks/runtime/loader.rb +65 -12
- data/lib/hecks/runtime/outbox.rb +1 -1
- data/lib/hecks/runtime/policy_interpreter.rb +5 -2
- data/lib/hecks/runtime/port_operation_interpreter.rb +2 -10
- data/lib/hecks/runtime/query_interpreter.rb +77 -0
- data/lib/hecks/runtime/reaction_outcome.rb +89 -0
- data/lib/hecks/runtime/refusal_wording.rb +23 -1
- data/lib/hecks/runtime/registry/verification.rb +163 -4
- data/lib/hecks/runtime/registry.rb +57 -3
- data/lib/hecks/runtime/remote_dispatcher.rb +31 -2
- data/lib/hecks/runtime/value/coercion.rb +23 -135
- data/lib/hecks/runtime/value/field_checks.rb +172 -0
- data/lib/hecks/runtime/value.rb +4 -1
- data/lib/hecks/runtime.rb +20 -6
- data/lib/hecks/rust_build/conformance.rb +158 -0
- data/lib/hecks/rust_build/conformance_fuzz.rb +71 -0
- data/lib/hecks/rust_build/coverage.rb +218 -0
- data/lib/hecks/rust_build/native_build.rb +124 -0
- data/lib/hecks/rust_build/project_rust/root_mod.rb +120 -0
- data/lib/hecks/rust_build/project_rust.rb +332 -0
- data/lib/hecks/rust_build/wasm.rb +105 -0
- data/lib/hecks/rust_build/wasm_browser.rb +113 -0
- data/lib/hecks/rust_build.rb +144 -0
- data/lib/hecks/storehouse.rb +16 -16
- data/lib/hecks/tenancy/bluebook/tenancy.bluebook +2 -2
- data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +1 -1
- data/lib/hecks/three_zero/forms.yml +127 -0
- data/lib/hecks/three_zero.rb +12 -0
- data/lib/hecks/tools/argument_gate_matrix.rb +316 -0
- data/lib/hecks/tools/comment_style.rb +1070 -0
- data/lib/hecks/tools/corpus_report.rb +87 -0
- data/lib/hecks/tools/deploy_recipe.rb +255 -0
- data/lib/hecks/tools/deploy_recipe_lint.rb +282 -0
- data/lib/hecks/tools/drop_implicit_append_fields.rb +95 -0
- data/lib/hecks/tools/era_projection_backfill.rb +94 -0
- data/lib/hecks/tools/era_reattest.rb +146 -0
- data/lib/hecks/tools/evolve_run.rb +189 -0
- data/lib/hecks/tools/fuzz_sweep.rb +373 -0
- data/lib/hecks/tools/heki_compaction.rb +100 -0
- data/lib/hecks/tools/hoist_local_givens.rb +246 -0
- data/lib/hecks/tools/journal_compaction.rb +89 -0
- data/lib/hecks/tools/oidc_manifests.rb +70 -0
- data/lib/hecks/tools/query_ir_run.rb +81 -0
- data/lib/hecks/tools/regeneration_run.rb +166 -0
- data/lib/hecks/tools/rust_comment_style.rb +642 -0
- data/lib/hecks/tools/sequence_script.rb +62 -0
- data/lib/hecks/tools/tail_merge.rb +117 -0
- data/lib/hecks/tools/tenant_provisioning.rb +142 -0
- data/lib/hecks/tools/translation_audit.rb +208 -0
- data/lib/hecks/tools/translation_scaffold.rb +131 -0
- data/lib/hecks/tools.rb +86 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks.rb +32 -15
- data/qa/settings.yml +66 -0
- data/rust/.cargo/config.toml +6 -0
- data/rust/.gitignore +2 -0
- data/rust/Cargo.lock +54 -0
- data/rust/Cargo.toml +61 -0
- data/rust/build/.cargo/config.toml +3 -0
- data/rust/build/.gitignore +1 -0
- data/rust/build/Cargo.lock +7 -0
- data/rust/build/Cargo.toml +19 -0
- data/rust/build/src/build_artifact.rs +47 -0
- data/rust/build/src/cargo_sync.rs +248 -0
- data/rust/build/src/json.rs +421 -0
- data/rust/build/src/lineage_pass.rs +285 -0
- data/rust/build/src/main.rs +51 -0
- data/rust/build/src/optional_pass.rs +150 -0
- data/rust/build/src/pipeline.rs +264 -0
- data/rust/build/src/reserved_names.rs +76 -0
- data/rust/build/src/resolve.rs +124 -0
- data/rust/build/src/root.rs +25 -0
- data/rust/build/src/sidecars.rs +73 -0
- data/rust/build/src/subprocess.rs +51 -0
- data/rust/build/src/tmp.rs +31 -0
- data/rust/codegen/.cargo/config.toml +3 -0
- data/rust/codegen/.gitignore +1 -0
- data/rust/codegen/Cargo.lock +7 -0
- data/rust/codegen/Cargo.toml +20 -0
- data/rust/codegen/src/attr.rs +32 -0
- data/rust/codegen/src/bridging.rs +481 -0
- data/rust/codegen/src/commands.rs +1226 -0
- data/rust/codegen/src/constraints.rs +109 -0
- data/rust/codegen/src/dependency_planning.rs +209 -0
- data/rust/codegen/src/domain_generator.rs +1288 -0
- data/rust/codegen/src/exemplar.rs +230 -0
- data/rust/codegen/src/expr_emitter.rs +87 -0
- data/rust/codegen/src/fielded.rs +192 -0
- data/rust/codegen/src/hecks_naming.rs +33 -0
- data/rust/codegen/src/json.rs +335 -0
- data/rust/codegen/src/json_codec.rs +697 -0
- data/rust/codegen/src/literal.rs +207 -0
- data/rust/codegen/src/main.rs +290 -0
- data/rust/codegen/src/manifest.rs +227 -0
- data/rust/codegen/src/mutations.rs +784 -0
- data/rust/codegen/src/naming.rs +286 -0
- data/rust/codegen/src/ports.rs +208 -0
- data/rust/codegen/src/prelude.rs +116 -0
- data/rust/codegen/src/queries.rs +786 -0
- data/rust/codegen/src/reactions.rs +532 -0
- data/rust/codegen/src/read_models.rs +697 -0
- data/rust/codegen/src/reference_specs.rs +40 -0
- data/rust/codegen/src/registry.rs +966 -0
- data/rust/codegen/src/reserved_names.rs +76 -0
- data/rust/codegen/src/shared.rs +93 -0
- data/rust/codegen/src/skip_reason.rs +25 -0
- data/rust/codegen/src/types.rs +362 -0
- data/rust/host/.cargo/config.toml +3 -0
- data/rust/host/.gitignore +1 -0
- data/rust/host/Cargo.lock +4297 -0
- data/rust/host/Cargo.toml +61 -0
- data/rust/host/HECKS_RELEASE +1 -0
- data/rust/host/rds-ca-bundle.pem +2736 -0
- data/rust/host/src/api.rs +1596 -0
- data/rust/host/src/approval.rs +652 -0
- data/rust/host/src/auth.rs +1287 -0
- data/rust/host/src/bin/expr_harness.rs +46 -0
- data/rust/host/src/bin/lineage_harness.rs +119 -0
- data/rust/host/src/bin/mint_harness.rs +135 -0
- data/rust/host/src/checkout.rs +265 -0
- data/rust/host/src/dispatch.rs +1443 -0
- data/rust/host/src/expr_json.rs +1117 -0
- data/rust/host/src/field_hints.rs +52 -0
- data/rust/host/src/ir.rs +733 -0
- data/rust/host/src/journal.rs +874 -0
- data/rust/host/src/lambda_client.rs +378 -0
- data/rust/host/src/log.rs +147 -0
- data/rust/host/src/main.rs +474 -0
- data/rust/host/src/mint.rs +1865 -0
- data/rust/host/src/payments/keystore.rs +330 -0
- data/rust/host/src/payments/tests.rs +1400 -0
- data/rust/host/src/payments.rs +767 -0
- data/rust/host/src/presentation.rs +698 -0
- data/rust/host/src/presentation_write.rs +1431 -0
- data/rust/host/src/query_step.rs +149 -0
- data/rust/host/src/rate_limit.rs +904 -0
- data/rust/host/src/reference_transform.rs +241 -0
- data/rust/host/src/reference_validate.rs +338 -0
- data/rust/host/src/resend.rs +296 -0
- data/rust/host/src/secrets.rs +106 -0
- data/rust/host/src/server.rs +663 -0
- data/rust/host/src/storage_shape.rs +175 -0
- data/rust/host/src/test_pg.rs +40 -0
- data/rust/host/src/ui_schema.rs +1351 -0
- data/rust/host/src/wasm_runner.rs +191 -0
- data/rust/host/src/web/newsletter/cooldown.rs +126 -0
- data/rust/host/src/web/newsletter/tests.rs +104 -0
- data/rust/host/src/web/newsletter.rs +310 -0
- data/rust/host/src/web/newsletter_send.rs +357 -0
- data/rust/host/src/web/registrations.rs +1505 -0
- data/rust/host/src/web.rs +2937 -0
- data/rust/lsp/.gitignore +1 -0
- data/rust/lsp/Cargo.lock +7 -0
- data/rust/lsp/Cargo.toml +16 -0
- data/rust/lsp/README.md +133 -0
- data/rust/lsp/src/diagnostics.rs +165 -0
- data/rust/lsp/src/json.rs +385 -0
- data/rust/lsp/src/main.rs +399 -0
- data/rust/lsp/src/outline.rs +177 -0
- data/rust/lsp/src/rpc.rs +67 -0
- data/rust/parser/.cargo/config.toml +5 -0
- data/rust/parser/.gitignore +1 -0
- data/rust/parser/Cargo.lock +7 -0
- data/rust/parser/Cargo.toml +21 -0
- data/rust/parser/src/build/closed_sets.rs +27 -0
- data/rust/parser/src/build/identity.rs +276 -0
- data/rust/parser/src/build/mod.rs +12 -0
- data/rust/parser/src/build/naming.rs +141 -0
- data/rust/parser/src/build/pattern_subset.rs +161 -0
- data/rust/parser/src/build/query_derive.rs +72 -0
- data/rust/parser/src/build/query_inference.rs +269 -0
- data/rust/parser/src/build/query_options.rs +100 -0
- data/rust/parser/src/build/read_model.rs +94 -0
- data/rust/parser/src/build/references.rs +32 -0
- data/rust/parser/src/canonical.rs +114 -0
- data/rust/parser/src/diag.rs +55 -0
- data/rust/parser/src/emit.rs +953 -0
- data/rust/parser/src/expr/ast_json.rs +147 -0
- data/rust/parser/src/expr/evaluator.rs +81 -0
- data/rust/parser/src/expr/mod.rs +84 -0
- data/rust/parser/src/expr/resolver.rs +497 -0
- data/rust/parser/src/ir.rs +387 -0
- data/rust/parser/src/keywords.rs +585 -0
- data/rust/parser/src/lex.rs +662 -0
- data/rust/parser/src/main.rs +252 -0
- data/rust/parser/src/parse/aggregate.rs +612 -0
- data/rust/parser/src/parse/chapter.rs +517 -0
- data/rust/parser/src/parse/command.rs +637 -0
- data/rust/parser/src/parse/domain_port.rs +127 -0
- data/rust/parser/src/parse/entity.rs +527 -0
- data/rust/parser/src/parse/file.rs +67 -0
- data/rust/parser/src/parse/hecksagon.rs +231 -0
- data/rust/parser/src/parse/lifecycle.rs +162 -0
- data/rust/parser/src/parse/mod.rs +1262 -0
- data/rust/parser/src/parse/policy.rs +87 -0
- data/rust/parser/src/parse/process_manager.rs +305 -0
- data/rust/parser/src/parse/query.rs +100 -0
- data/rust/parser/src/parse/read_model.rs +214 -0
- data/rust/parser/src/parse/value_object.rs +250 -0
- data/rust/parser/src/ruby_value.rs +422 -0
- data/rust/project/bridging.rb +257 -0
- data/rust/project/commands.rb +877 -0
- data/rust/project/constraints.rb +86 -0
- data/rust/project/dependency_planning.rb +178 -0
- data/rust/project/domain_generator.rb +980 -0
- data/rust/project/exemplar.rb +148 -0
- data/rust/project/expr_emitter.rb +72 -0
- data/rust/project/fielded.rb +141 -0
- data/rust/project/json_codec.rb +641 -0
- data/rust/project/mutations.rb +721 -0
- data/rust/project/naming.rb +205 -0
- data/rust/project/ports.rb +65 -0
- data/rust/project/queries.rb +493 -0
- data/rust/project/reactions.rb +317 -0
- data/rust/project/read_models.rb +690 -0
- data/rust/project/reference_specs.rb +27 -0
- data/rust/project/registry.rb +396 -0
- data/rust/project/skip_reason.rb +29 -0
- data/rust/project/types.rb +225 -0
- data/rust/project/write_if_changed.rb +83 -0
- data/rust/project.rb +30 -0
- data/rust/project_rust_pipeline.rb +383 -0
- data/rust/src/exemplar/commands.rs +279 -0
- data/rust/src/exemplar/constraints.rs +18 -0
- data/rust/src/exemplar/fielded.rs +280 -0
- data/rust/src/exemplar/json.rs +277 -0
- data/rust/src/exemplar/mod.rs +17 -0
- data/rust/src/exemplar/mutations.rs +80 -0
- data/rust/src/exemplar/queries.rs +33 -0
- data/rust/src/exemplar/reactions.rs +88 -0
- data/rust/src/exemplar/read_models.rs +48 -0
- data/rust/src/exemplar/registry.rs +166 -0
- data/rust/src/exemplar/types.rs +44 -0
- data/rust/src/kernel/attribute_shapes/composite.rs +88 -0
- data/rust/src/kernel/attribute_shapes/list.rs +18 -0
- data/rust/src/kernel/attribute_shapes/mod.rs +44 -0
- data/rust/src/kernel/attribute_shapes/optional.rs +9 -0
- data/rust/src/kernel/attribute_shapes/scalar.rs +39 -0
- data/rust/src/kernel/cli.rs +585 -0
- data/rust/src/kernel/dispatch.rs +1090 -0
- data/rust/src/kernel/expr.rs +458 -0
- data/rust/src/kernel/expression_operators/arithmetic.rs +128 -0
- data/rust/src/kernel/expression_operators/comparison.rs +48 -0
- data/rust/src/kernel/expression_operators/enumeration.rs +232 -0
- data/rust/src/kernel/expression_operators/logical.rs +13 -0
- data/rust/src/kernel/expression_operators/membership.rs +18 -0
- data/rust/src/kernel/expression_operators/mod.rs +68 -0
- data/rust/src/kernel/expression_operators/pattern_match.rs +73 -0
- data/rust/src/kernel/expression_operators/positional.rs +126 -0
- data/rust/src/kernel/expression_operators/presence.rs +101 -0
- data/rust/src/kernel/expression_operators/sign_test.rs +16 -0
- data/rust/src/kernel/expression_operators/sized.rs +35 -0
- data/rust/src/kernel/expression_operators/text.rs +79 -0
- data/rust/src/kernel/expression_operators/to_string.rs +18 -0
- data/rust/src/kernel/json.rs +779 -0
- data/rust/src/kernel/mod.rs +133 -0
- data/rust/src/kernel/named_query.rs +219 -0
- data/rust/src/kernel/orchestrate.rs +1810 -0
- data/rust/src/kernel/pattern.rs +307 -0
- data/rust/src/kernel/query_comparators.rs +206 -0
- data/rust/src/kernel/query_ordering.rs +103 -0
- data/rust/src/kernel/read_model.rs +914 -0
- data/rust/src/kernel/reference_lookup.rs +149 -0
- data/rust/src/kernel/repository.rs +468 -0
- data/rust/src/kernel/routing.rs +612 -0
- data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +118 -0
- data/rust/src/kernel/vocab/entity_dispatch_order.rs +114 -0
- data/rust/src/kernel/vocab/field_hint.rs +70 -0
- data/rust/src/kernel/vocab/mod.rs +20 -0
- data/rust/src/kernel/vocab/query_comparator.rs +65 -0
- data/rust/src/kernel/vocab/refusal_template.rs +1592 -0
- data/rust/src/lib.rs +8 -0
- data/rust/src/main.rs +16 -0
- data/rust/web/.gitignore +1 -0
- data/rust/web/Cargo.lock +119 -0
- data/rust/web/Cargo.toml +18 -0
- data/rust/web/src/lib.rs +8 -0
- metadata +477 -12
- data/lib/hecks/facade/cli_door.rb +0 -96
- data/lib/hecks/facade/cli_runner.rb +0 -127
- data/lib/hecks/mcp_door_scope.rb +0 -95
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
// The tenant's own link to a payment processor and what checkout does with it:
|
|
2
|
+
// a business's own Stripe account, keyed by `<domain>::PaymentConnection` (see `checkout_plan`).
|
|
3
|
+
|
|
4
|
+
use crate::auth;
|
|
5
|
+
use crate::checkout::STRIPE_API_VERSION;
|
|
6
|
+
use crate::dispatch;
|
|
7
|
+
use crate::journal::LineageConfig;
|
|
8
|
+
use crate::lambda_client::LambdaInvoker;
|
|
9
|
+
use crate::web::{instances_for, last_refusal, respond};
|
|
10
|
+
use serde_json::{json, Value};
|
|
11
|
+
use std::collections::HashMap;
|
|
12
|
+
use std::path::Path;
|
|
13
|
+
use std::sync::Arc;
|
|
14
|
+
use tokio::sync::Mutex;
|
|
15
|
+
use tokio_postgres::Client;
|
|
16
|
+
|
|
17
|
+
mod keystore;
|
|
18
|
+
use keystore::{KeyStore, StoredDocument, StoredKeys};
|
|
19
|
+
|
|
20
|
+
const CONNECTION_SLUG: &str = "payments";
|
|
21
|
+
const STRIPE_API_BASE: &str = "https://api.stripe.com";
|
|
22
|
+
const MODES: [&str; 2] = ["test", "live"];
|
|
23
|
+
/// The reserved `account_ref` of a connection that uses the business's own
|
|
24
|
+
/// Stripe account directly. It is the only kind of connection this host charges.
|
|
25
|
+
pub const SELF_ACCOUNT: &str = "self";
|
|
26
|
+
const DIRECT_DISPLAY_FALLBACK: &str = "Your Stripe account";
|
|
27
|
+
const STRIPE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
|
28
|
+
|
|
29
|
+
/// This deploy's payment settings, read once per request as a plain value (so a
|
|
30
|
+
/// test can build one directly). Deliberately not `Debug`: it holds secret keys.
|
|
31
|
+
#[derive(Clone)]
|
|
32
|
+
pub struct PlatformConfig {
|
|
33
|
+
pub site_url: String,
|
|
34
|
+
/// The public origin Stripe delivers webhooks to (`PAYMENTS_WEBHOOK_BASE_URL`,
|
|
35
|
+
/// else `site_url`); the webhook the site creates points at `<this>/webhooks/stripe`.
|
|
36
|
+
pub webhook_base_url: String,
|
|
37
|
+
/// Where keys saved from the Payments page live, when this deploy has such a
|
|
38
|
+
/// store (keystore.rs); `None` means keys can only come from the environment.
|
|
39
|
+
pub store: Option<Arc<KeyStore>>,
|
|
40
|
+
/// A snapshot of what the store held when `apply_store` last ran. Empty
|
|
41
|
+
/// until then.
|
|
42
|
+
pub stored: Arc<StoredDocument>,
|
|
43
|
+
pub api_base: String,
|
|
44
|
+
/// The description Stripe shows beside the webhook endpoint this site
|
|
45
|
+
/// creates: `PAYMENTS_WEBHOOK_DESCRIPTION`, else "<HECKS_DOMAIN> website".
|
|
46
|
+
pub webhook_description: String,
|
|
47
|
+
/// The business's own Stripe keys, one pair per mode (available once both are
|
|
48
|
+
/// set). Unlike the secret, the publishable key may safely appear in a response.
|
|
49
|
+
pub direct_test_key: String,
|
|
50
|
+
pub direct_live_key: String,
|
|
51
|
+
pub direct_test_publishable_key: String,
|
|
52
|
+
pub direct_live_publishable_key: String,
|
|
53
|
+
/// The signing secret of the business's own webhook endpoint; `None` when
|
|
54
|
+
/// unset.
|
|
55
|
+
pub webhook_secret: Option<String>,
|
|
56
|
+
/// Lower-cased emails allowed to enable or disable real payments.
|
|
57
|
+
pub operators: Vec<String>,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// Refuses the boot on AWS when checkout is enabled but `PAYMENTS_ACCOUNT_SECRET_ID`
|
|
61
|
+
/// names no secret; off AWS, or with checkout off, the host boots as usual.
|
|
62
|
+
pub fn check_boot(checkout_enabled: bool) -> Result<(), String> {
|
|
63
|
+
keystore::check_boot(checkout_enabled)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// The webhook endpoint's description: the configured text when it is not
|
|
67
|
+
/// blank, else "<domain> website".
|
|
68
|
+
fn webhook_description(configured: Option<&str>, domain: &str) -> String {
|
|
69
|
+
match configured.map(str::trim).filter(|text| !text.is_empty()) {
|
|
70
|
+
Some(text) => text.to_string(),
|
|
71
|
+
None => format!("{domain} website"),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
impl PlatformConfig {
|
|
76
|
+
pub fn from_env() -> Self {
|
|
77
|
+
let var = |name: &str| std::env::var(name).unwrap_or_default();
|
|
78
|
+
let site_url = std::env::var("SITE_URL").unwrap_or_else(|_| "http://localhost:4321".to_string());
|
|
79
|
+
let webhook_base_url = Some(var("PAYMENTS_WEBHOOK_BASE_URL")).filter(|url| !url.trim().is_empty()).unwrap_or_else(|| site_url.clone()).trim_end_matches('/').to_string();
|
|
80
|
+
Self {
|
|
81
|
+
site_url,
|
|
82
|
+
webhook_base_url,
|
|
83
|
+
store: keystore::default_store(),
|
|
84
|
+
stored: Arc::new(StoredDocument::default()),
|
|
85
|
+
api_base: STRIPE_API_BASE.to_string(),
|
|
86
|
+
webhook_description: webhook_description(std::env::var("PAYMENTS_WEBHOOK_DESCRIPTION").ok().as_deref(), &var("HECKS_DOMAIN")),
|
|
87
|
+
direct_test_key: var("STRIPE_ACCOUNT_TEST_KEY"),
|
|
88
|
+
direct_live_key: var("STRIPE_ACCOUNT_LIVE_KEY"),
|
|
89
|
+
direct_test_publishable_key: var("STRIPE_ACCOUNT_TEST_PUBLISHABLE_KEY"),
|
|
90
|
+
direct_live_publishable_key: var("STRIPE_ACCOUNT_LIVE_PUBLISHABLE_KEY"),
|
|
91
|
+
webhook_secret: Some(var("STRIPE_WEBHOOK_SECRET")).filter(|s| !s.is_empty()),
|
|
92
|
+
operators: var("PAYMENTS_OPERATOR_EMAILS")
|
|
93
|
+
.split(',')
|
|
94
|
+
.map(|email| email.trim().to_lowercase())
|
|
95
|
+
.filter(|email| !email.is_empty())
|
|
96
|
+
.collect(),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// `Self::from_env()` with what the key store holds applied.
|
|
101
|
+
pub async fn load() -> Self {
|
|
102
|
+
let mut platform = Self::from_env();
|
|
103
|
+
platform.apply_store().await;
|
|
104
|
+
platform
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/// Reads the key store into `stored` (from its cache when fresh); a store that
|
|
108
|
+
/// cannot be read leaves only environment keys, logged without naming any key.
|
|
109
|
+
pub async fn apply_store(&mut self) {
|
|
110
|
+
let Some(store) = self.store.clone() else { return };
|
|
111
|
+
match store.document().await {
|
|
112
|
+
Ok(document) => self.stored = document,
|
|
113
|
+
Err(e) => eprintln!("the saved payment keys could not be read, so only environment keys are used: {e}"),
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// Whether this deploy can save keys from the Payments page.
|
|
118
|
+
pub fn can_save_keys(&self) -> bool {
|
|
119
|
+
self.store.is_some()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// Whether any real Stripe credential exists (environment or saved keys). While
|
|
123
|
+
/// one does, the public mock webhook secret must never verify anything.
|
|
124
|
+
pub fn has_real_credentials(&self) -> bool {
|
|
125
|
+
let configured = [&self.direct_test_key, &self.direct_live_key].iter().any(|key| !key.is_empty());
|
|
126
|
+
let saved = [&self.stored.test, &self.stored.live].iter().any(|keys| keys.as_ref().is_some_and(|keys| !keys.secret_key.is_empty()));
|
|
127
|
+
configured || saved
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// Every webhook signing secret this deploy accepts: the environment's, then
|
|
131
|
+
/// any saved with the business's keys.
|
|
132
|
+
pub fn webhook_secrets(&self) -> Vec<&str> {
|
|
133
|
+
self.webhook_secret.iter().map(String::as_str).chain(self.stored.webhook_secrets()).collect()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The business's own keys for `mode`: the environment's pair when both are
|
|
137
|
+
// set, else the pair saved from the Payments page, else whatever the
|
|
138
|
+
// environment has (which then leaves the mode unavailable).
|
|
139
|
+
fn direct_pair(&self, mode: &str) -> (&str, &str) {
|
|
140
|
+
let (key, publishable) =
|
|
141
|
+
if mode == "live" { (&self.direct_live_key, &self.direct_live_publishable_key) } else { (&self.direct_test_key, &self.direct_test_publishable_key) };
|
|
142
|
+
if !key.is_empty() && !publishable.is_empty() {
|
|
143
|
+
return (key, publishable);
|
|
144
|
+
}
|
|
145
|
+
match self.stored.mode(mode) {
|
|
146
|
+
Some(saved) if !saved.secret_key.is_empty() && !saved.publishable_key.is_empty() => (&saved.secret_key, &saved.publishable_key),
|
|
147
|
+
_ => (key, publishable),
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
fn direct_key(&self, mode: &str) -> &str {
|
|
152
|
+
self.direct_pair(mode).0
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
fn direct_publishable_key(&self, mode: &str) -> &str {
|
|
156
|
+
self.direct_pair(mode).1
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/// The modes the business's own account can be used in: those with both
|
|
160
|
+
/// its secret key and its publishable key, from the environment or saved.
|
|
161
|
+
pub fn direct_modes(&self) -> Vec<&'static str> {
|
|
162
|
+
MODES.into_iter().filter(|mode| !self.direct_key(mode).is_empty() && !self.direct_publishable_key(mode).is_empty()).collect()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
fn is_operator(&self, email: &str) -> bool {
|
|
166
|
+
self.operators.contains(&email.to_lowercase())
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// The public facts a tenant's `PaymentConnection` holds.
|
|
171
|
+
pub struct Connection {
|
|
172
|
+
pub status: String,
|
|
173
|
+
pub processor: String,
|
|
174
|
+
pub account_ref: String,
|
|
175
|
+
pub mode: String,
|
|
176
|
+
pub display_name: String,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
impl Connection {
|
|
180
|
+
/// Whether this connection uses the business's own Stripe account
|
|
181
|
+
/// (`account_ref` is `SELF_ACCOUNT`), the only kind this host can charge.
|
|
182
|
+
pub fn is_direct(&self) -> bool {
|
|
183
|
+
self.account_ref == SELF_ACCOUNT
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/// This tenant's connection, read out of the replayed instances, or `None`
|
|
188
|
+
/// until an Owner has connected an account.
|
|
189
|
+
pub fn connection(read: &Value, domain: &str) -> Option<Connection> {
|
|
190
|
+
let (_, state) = instances_for(read, &crate::ir::payment_connection_binding(domain).instance_prefix()).into_iter().find(|(id, _)| id == CONNECTION_SLUG)?;
|
|
191
|
+
let text = |field: &str| state.get(field).and_then(|v| v.get("value")).and_then(|v| v.as_str()).unwrap_or_default().to_string();
|
|
192
|
+
Some(Connection {
|
|
193
|
+
status: state.get("status").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
|
|
194
|
+
processor: text("processor"),
|
|
195
|
+
account_ref: text("account_ref"),
|
|
196
|
+
mode: text("mode"),
|
|
197
|
+
display_name: text("display_name"),
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/// What checkout does for one request.
|
|
202
|
+
pub enum CheckoutPlan {
|
|
203
|
+
/// No connection, or one that is not enabled: the mock walkthrough.
|
|
204
|
+
Mock,
|
|
205
|
+
/// Payments are enabled: charged on the business's own account with its own
|
|
206
|
+
/// key; the guest pays in a form embedded in the site via the publishable key.
|
|
207
|
+
Stripe { api_key: String, publishable_key: String },
|
|
208
|
+
/// Payments were enabled and cannot be taken now. Registrations answer
|
|
209
|
+
/// 503; never a fallback to the mock.
|
|
210
|
+
Paused,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/// Decides checkout per request from the tenant's own connection, never a
|
|
214
|
+
/// process-wide setting; an enabled connection this host cannot actually charge is paused too.
|
|
215
|
+
pub fn checkout_plan(connection: Option<&Connection>, platform: &PlatformConfig) -> CheckoutPlan {
|
|
216
|
+
let Some(connection) = connection else { return CheckoutPlan::Mock };
|
|
217
|
+
match connection.status.as_str() {
|
|
218
|
+
"enabled" => {
|
|
219
|
+
let (key, publishable_key) = platform.direct_pair(&connection.mode);
|
|
220
|
+
if connection.processor != "stripe" || !connection.is_direct() || key.is_empty() {
|
|
221
|
+
CheckoutPlan::Paused
|
|
222
|
+
} else if publishable_key.is_empty() {
|
|
223
|
+
eprintln!(
|
|
224
|
+
"payments are paused: the {} mode has a Stripe key but no publishable key (set STRIPE_ACCOUNT_{}_PUBLISHABLE_KEY)",
|
|
225
|
+
connection.mode,
|
|
226
|
+
connection.mode.to_uppercase()
|
|
227
|
+
);
|
|
228
|
+
CheckoutPlan::Paused
|
|
229
|
+
} else {
|
|
230
|
+
CheckoutPlan::Stripe { api_key: key.to_string(), publishable_key: publishable_key.to_string() }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
"paused" => CheckoutPlan::Paused,
|
|
234
|
+
_ => CheckoutPlan::Mock,
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async fn dispatch_on_connection(
|
|
239
|
+
which: crate::ir::ConnectionVerb,
|
|
240
|
+
facts: Value,
|
|
241
|
+
client: &Mutex<Client>,
|
|
242
|
+
wasm_path: &Path,
|
|
243
|
+
config: &LineageConfig,
|
|
244
|
+
invoker: &dyn LambdaInvoker,
|
|
245
|
+
) -> anyhow::Result<dispatch::Outcome> {
|
|
246
|
+
let verb = crate::ir::payment_connection_binding(&config.domain).verb(which).to_string();
|
|
247
|
+
dispatch::handle_routed(client, wasm_path, &verb, json!(CONNECTION_SLUG), facts, None, config, invoker).await
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/// Whether `path` belongs to the payments surface.
|
|
251
|
+
pub fn owns(method: &str, path: &str) -> bool {
|
|
252
|
+
matches!(
|
|
253
|
+
(method, path),
|
|
254
|
+
("GET", "/payments/connection")
|
|
255
|
+
| ("POST", "/payments/connection/direct")
|
|
256
|
+
| ("POST", "/payments/connection/disconnect")
|
|
257
|
+
| ("POST", "/payments/connection/enable")
|
|
258
|
+
| ("POST", "/payments/connection/disable")
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
fn json_error(status: u16, message: &str) -> Value {
|
|
263
|
+
respond(status, "application/json", &json!({"error": message}).to_string())
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
fn refusal_message(refusal: &Value) -> String {
|
|
267
|
+
["message", "error"].iter().find_map(|key| refusal.get(*key).and_then(|v| v.as_str())).unwrap_or("Refused").to_string()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
struct Caller {
|
|
271
|
+
owner: bool,
|
|
272
|
+
operator: bool,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// The person behind an account cookie, only while they still
|
|
276
|
+
// have access, with what they may do here. `Err` is the JSON response.
|
|
277
|
+
async fn caller(domain_ir: &Value, cookies: &HashMap<String, String>, secret: &str, client: &Mutex<Client>, platform: &PlatformConfig) -> Result<Caller, Value> {
|
|
278
|
+
let not_logged_in = || json_error(401, "not logged in");
|
|
279
|
+
let Some(email) = cookies.get(&auth::account_cookie_name()).and_then(|token| auth::verify_account_token(secret, token)) else {
|
|
280
|
+
return Err(not_logged_in());
|
|
281
|
+
};
|
|
282
|
+
match auth::active_role(client, domain_ir, &email).await {
|
|
283
|
+
Ok(Some(role)) => Ok(Caller { owner: role == "Owner", operator: platform.is_operator(&email) }),
|
|
284
|
+
Ok(None) => Err(not_logged_in()),
|
|
285
|
+
Err(e) => Err(json_error(500, &format!("members lookup failed: {e}"))),
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
fn require_owner(caller: &Caller) -> Result<(), Value> {
|
|
290
|
+
if caller.owner { Ok(()) } else { Err(json_error(403, "only an Owner can manage payments")) }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
fn require_operator(caller: &Caller) -> Result<(), Value> {
|
|
294
|
+
if caller.operator { Ok(()) } else { Err(json_error(403, "only the platform operator can change whether payments are enabled")) }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// What the payments page may show: public facts and which of the business's own
|
|
298
|
+
// modes have keys. Built field by field, so a credential can never appear here:
|
|
299
|
+
// none is ever read into this value.
|
|
300
|
+
fn connection_json(connection: Option<&Connection>, platform: &PlatformConfig, caller: &Caller) -> Value {
|
|
301
|
+
let label = connection.filter(|c| c.processor == "stripe").map(|_| "Stripe");
|
|
302
|
+
let present = |value: Option<&str>| value.filter(|s| !s.is_empty()).map(Value::from).unwrap_or(Value::Null);
|
|
303
|
+
json!({
|
|
304
|
+
"status": connection.map(|c| c.status.as_str()).unwrap_or("not_connected"),
|
|
305
|
+
"processor": present(connection.map(|c| c.processor.as_str())),
|
|
306
|
+
"label": label,
|
|
307
|
+
"account_ref": present(connection.map(|c| c.account_ref.as_str())),
|
|
308
|
+
"mode": present(connection.map(|c| c.mode.as_str())),
|
|
309
|
+
"display_name": present(connection.map(|c| c.display_name.as_str())),
|
|
310
|
+
// Always empty: no processor is connected through this host, only the
|
|
311
|
+
// business's own account is used. Pages that still read the list keep
|
|
312
|
+
// rendering.
|
|
313
|
+
"adapters": [],
|
|
314
|
+
"direct_modes": platform.direct_modes(),
|
|
315
|
+
"direct": connection.is_some_and(Connection::is_direct),
|
|
316
|
+
"can_save_keys": platform.can_save_keys(),
|
|
317
|
+
"can_manage": caller.owner,
|
|
318
|
+
"can_enable": caller.operator,
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async fn current_connection(client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig) -> Result<Option<Connection>, Value> {
|
|
323
|
+
match dispatch::read(client, wasm_path).await {
|
|
324
|
+
Ok(read) => Ok(connection(&read, &config.domain)),
|
|
325
|
+
Err(e) => Err(json_error(500, &format!("payment connection lookup failed: {e:#}"))),
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async fn connection_response(caller: &Caller, platform: &PlatformConfig, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig) -> Value {
|
|
330
|
+
match current_connection(client, wasm_path, config).await {
|
|
331
|
+
Ok(connection) => {
|
|
332
|
+
respond(200, "application/json", &connection_json(connection.as_ref(), platform, caller).to_string())
|
|
333
|
+
}
|
|
334
|
+
Err(response) => response,
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/// The `/payments/connection` routes for a caller holding the account cookie; every
|
|
339
|
+
/// answer is JSON, a bad session is 401 not a redirect, and `None` means an unowned path.
|
|
340
|
+
#[allow(clippy::too_many_arguments)]
|
|
341
|
+
pub async fn route(
|
|
342
|
+
method: &str,
|
|
343
|
+
path: &str,
|
|
344
|
+
raw_body: &str,
|
|
345
|
+
cookies: &HashMap<String, String>,
|
|
346
|
+
secret: &str,
|
|
347
|
+
domain_ir: &Value,
|
|
348
|
+
platform: &PlatformConfig,
|
|
349
|
+
client: &Mutex<Client>,
|
|
350
|
+
wasm_path: &Path,
|
|
351
|
+
config: &LineageConfig,
|
|
352
|
+
invoker: &dyn LambdaInvoker,
|
|
353
|
+
) -> Option<Value> {
|
|
354
|
+
if !owns(method, path) {
|
|
355
|
+
return None;
|
|
356
|
+
}
|
|
357
|
+
let caller = match caller(domain_ir, cookies, secret, client, platform).await {
|
|
358
|
+
Ok(caller) => caller,
|
|
359
|
+
Err(response) => return Some(response),
|
|
360
|
+
};
|
|
361
|
+
Some(match path {
|
|
362
|
+
"/payments/connection" => show(&caller, platform, client, wasm_path, config).await,
|
|
363
|
+
"/payments/connection/direct" => direct_route(&caller, raw_body, platform, client, wasm_path, config, invoker).await,
|
|
364
|
+
"/payments/connection/disconnect" => disconnect_route(&caller, platform, client, wasm_path, config, invoker).await,
|
|
365
|
+
"/payments/connection/enable" => switch_route(&caller, crate::ir::ConnectionVerb::Enable, platform, client, wasm_path, config, invoker).await,
|
|
366
|
+
_ => switch_route(&caller, crate::ir::ConnectionVerb::Disable, platform, client, wasm_path, config, invoker).await,
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// GET: readable by an Owner or the operator, nobody else.
|
|
371
|
+
async fn show(caller: &Caller, platform: &PlatformConfig, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig) -> Value {
|
|
372
|
+
if !caller.owner && !caller.operator {
|
|
373
|
+
return json_error(403, "not permitted");
|
|
374
|
+
}
|
|
375
|
+
connection_response(caller, platform, client, wasm_path, config).await
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
fn body_field(body: &Value, key: &str) -> Result<String, Value> {
|
|
379
|
+
body.get(key).and_then(|v| v.as_str()).map(String::from).ok_or_else(|| json_error(400, &format!("missing {key}")))
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
fn parse_body(raw_body: &str) -> Result<Value, Value> {
|
|
383
|
+
serde_json::from_str(raw_body).map_err(|e| json_error(400, &format!("invalid JSON: {e}")))
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Records the connection an Owner just made: a first connection creates the
|
|
387
|
+
// singleton, a disconnected one reconnects, a paused one resumes, and anything
|
|
388
|
+
// else already has an account linked.
|
|
389
|
+
async fn record_connection(
|
|
390
|
+
caller: &Caller,
|
|
391
|
+
platform: &PlatformConfig,
|
|
392
|
+
facts: Value,
|
|
393
|
+
client: &Mutex<Client>,
|
|
394
|
+
wasm_path: &Path,
|
|
395
|
+
config: &LineageConfig,
|
|
396
|
+
invoker: &dyn LambdaInvoker,
|
|
397
|
+
) -> Value {
|
|
398
|
+
let existing = match current_connection(client, wasm_path, config).await {
|
|
399
|
+
Ok(existing) => existing,
|
|
400
|
+
Err(response) => return response,
|
|
401
|
+
};
|
|
402
|
+
let outcome = match existing.as_ref().map(|c| c.status.as_str()) {
|
|
403
|
+
None => {
|
|
404
|
+
let mut creation = facts;
|
|
405
|
+
creation["slug"] = json!({"value": CONNECTION_SLUG});
|
|
406
|
+
dispatch::handle_facts(client, wasm_path, &crate::ir::payment_connection_binding(&config.domain).connect, creation, None, config, invoker).await
|
|
407
|
+
}
|
|
408
|
+
Some("disconnected") => dispatch_on_connection(crate::ir::ConnectionVerb::Reconnect, facts, client, wasm_path, config, invoker).await,
|
|
409
|
+
Some("paused") => dispatch_on_connection(crate::ir::ConnectionVerb::Resume, facts, client, wasm_path, config, invoker).await,
|
|
410
|
+
Some(_) => return json_error(409, "an account is already connected — disconnect it first"),
|
|
411
|
+
};
|
|
412
|
+
match outcome {
|
|
413
|
+
Ok(outcome) if outcome.accepted => connection_response(caller, platform, client, wasm_path, config).await,
|
|
414
|
+
Ok(outcome) => json_error(422, &refusal_message(&last_refusal(&outcome.result))),
|
|
415
|
+
Err(e) => json_error(500, &format!("{e:#}")),
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// An Owner either saves keys (`save_keys_route`) or picks a mode with keys already
|
|
420
|
+
// in the environment; only public facts are stored (account ref, mode, display
|
|
421
|
+
// name), and the key itself is never stored or logged.
|
|
422
|
+
#[allow(clippy::too_many_arguments)]
|
|
423
|
+
async fn direct_route(caller: &Caller, raw_body: &str, platform: &PlatformConfig, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, invoker: &dyn LambdaInvoker) -> Value {
|
|
424
|
+
if let Err(response) = require_owner(caller) {
|
|
425
|
+
return response;
|
|
426
|
+
}
|
|
427
|
+
let body = match parse_body(raw_body) {
|
|
428
|
+
Ok(body) => body,
|
|
429
|
+
Err(response) => return response,
|
|
430
|
+
};
|
|
431
|
+
// The Payments page's Save form sends the keys themselves; the other form
|
|
432
|
+
// names a mode whose keys are already in the environment.
|
|
433
|
+
if body.get("secret_key").is_some() || body.get("publishable_key").is_some() {
|
|
434
|
+
return save_keys_route(caller, &body, platform, client, wasm_path, config, invoker).await;
|
|
435
|
+
}
|
|
436
|
+
let mode = match body_field(&body, "mode") {
|
|
437
|
+
Ok(mode) => mode,
|
|
438
|
+
Err(response) => return response,
|
|
439
|
+
};
|
|
440
|
+
if !platform.direct_modes().contains(&mode.as_str()) {
|
|
441
|
+
return json_error(422, &format!("this site's own Stripe account is not set up for {mode} mode"));
|
|
442
|
+
}
|
|
443
|
+
let display_name = own_account_display_name(platform, platform.direct_key(&mode)).await;
|
|
444
|
+
let facts = json!({
|
|
445
|
+
"processor": {"value": "stripe"},
|
|
446
|
+
"account_ref": {"value": SELF_ACCOUNT},
|
|
447
|
+
"mode": {"value": mode},
|
|
448
|
+
"display_name": {"value": display_name},
|
|
449
|
+
});
|
|
450
|
+
record_connection(caller, platform, facts, client, wasm_path, config, invoker).await
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Not enabled: a plain disconnect. Enabled: registrations pause instead, since real
|
|
454
|
+
// guests have paid through this account. Disconnecting the business's own account
|
|
455
|
+
// also removes its webhook and deletes the saved keys.
|
|
456
|
+
async fn disconnect_route(caller: &Caller, platform: &PlatformConfig, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, invoker: &dyn LambdaInvoker) -> Value {
|
|
457
|
+
if let Err(response) = require_owner(caller) {
|
|
458
|
+
return response;
|
|
459
|
+
}
|
|
460
|
+
let existing = match current_connection(client, wasm_path, config).await {
|
|
461
|
+
Ok(existing) => existing,
|
|
462
|
+
Err(response) => return response,
|
|
463
|
+
};
|
|
464
|
+
let Some(existing) = existing.filter(|c| matches!(c.status.as_str(), "connected" | "enabled")) else {
|
|
465
|
+
return json_error(409, "nothing to disconnect");
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
let which = if existing.status == "enabled" { crate::ir::ConnectionVerb::Suspend } else { crate::ir::ConnectionVerb::Disconnect };
|
|
469
|
+
match dispatch_on_connection(which, json!({}), client, wasm_path, config, invoker).await {
|
|
470
|
+
Ok(outcome) if outcome.accepted => {
|
|
471
|
+
if existing.is_direct() {
|
|
472
|
+
forget_saved_keys(platform, &existing.mode).await;
|
|
473
|
+
}
|
|
474
|
+
let refreshed = refreshed(platform).await;
|
|
475
|
+
connection_response(caller, &refreshed, client, wasm_path, config).await
|
|
476
|
+
}
|
|
477
|
+
Ok(outcome) => json_error(422, &refusal_message(&last_refusal(&outcome.result))),
|
|
478
|
+
Err(e) => json_error(500, &format!("{e:#}")),
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Enable or disable real payments: operator only.
|
|
483
|
+
async fn switch_route(caller: &Caller, which: crate::ir::ConnectionVerb, platform: &PlatformConfig, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, invoker: &dyn LambdaInvoker) -> Value {
|
|
484
|
+
if let Err(response) = require_operator(caller) {
|
|
485
|
+
return response;
|
|
486
|
+
}
|
|
487
|
+
match current_connection(client, wasm_path, config).await {
|
|
488
|
+
Ok(Some(_)) => {}
|
|
489
|
+
Ok(None) => return json_error(409, "connect an account first"),
|
|
490
|
+
Err(response) => return response,
|
|
491
|
+
}
|
|
492
|
+
match dispatch_on_connection(which, json!({}), client, wasm_path, config, invoker).await {
|
|
493
|
+
Ok(outcome) if outcome.accepted => connection_response(caller, platform, client, wasm_path, config).await,
|
|
494
|
+
Ok(outcome) => json_error(422, &refusal_message(&last_refusal(&outcome.result))),
|
|
495
|
+
Err(e) => json_error(500, &format!("{e:#}")),
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
fn stripe_http() -> Result<reqwest::Client, String> {
|
|
500
|
+
reqwest::Client::builder().timeout(STRIPE_TIMEOUT).build().map_err(|e| e.to_string())
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// The name of the business's own account, read with that account's own key
|
|
504
|
+
// (`GET /v1/account`). Falls back to a generic label when Stripe cannot be
|
|
505
|
+
// asked or has nothing better; the key is only ever sent as the bearer.
|
|
506
|
+
async fn own_account_display_name(platform: &PlatformConfig, key: &str) -> String {
|
|
507
|
+
let fetch = async {
|
|
508
|
+
let response = stripe_http()?.get(format!("{}/v1/account", platform.api_base)).bearer_auth(key).send().await.map_err(|e| e.to_string())?;
|
|
509
|
+
if !response.status().is_success() {
|
|
510
|
+
return Err("account lookup refused".to_string());
|
|
511
|
+
}
|
|
512
|
+
response.json::<Value>().await.map_err(|e| e.to_string())
|
|
513
|
+
};
|
|
514
|
+
let Ok(account) = fetch.await else { return DIRECT_DISPLAY_FALLBACK.to_string() };
|
|
515
|
+
let candidates = [
|
|
516
|
+
account.get("business_profile").and_then(|p| p.get("name")),
|
|
517
|
+
account.get("settings").and_then(|s| s.get("dashboard")).and_then(|d| d.get("display_name")),
|
|
518
|
+
account.get("email"),
|
|
519
|
+
];
|
|
520
|
+
let name = candidates.into_iter().flatten().find_map(|v| v.as_str().filter(|s| !s.is_empty())).unwrap_or(DIRECT_DISPLAY_FALLBACK).to_string();
|
|
521
|
+
name
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const KEYS_NOT_ENABLED: &str = "saving keys is not set up on this site";
|
|
525
|
+
const KEY_REFUSED: &str = "Stripe did not accept that key.";
|
|
526
|
+
const STRIPE_UNREACHABLE: &str = "Could not reach Stripe. Please try again in a moment.";
|
|
527
|
+
const WEBHOOK_PERMISSION: &str = "That key can't create the webhook. In Stripe, give it Webhook Endpoints → Write as well and try again.";
|
|
528
|
+
const WEBHOOK_FAILED: &str = "Stripe could not set up the payment webhook. Please try again.";
|
|
529
|
+
const STORE_FAILED: &str = "The keys could not be stored, so nothing was saved. Please try again.";
|
|
530
|
+
const WEBHOOK_EVENTS: [&str; 3] = ["checkout.session.completed", "checkout.session.expired", "charge.refunded"];
|
|
531
|
+
|
|
532
|
+
// A copy of `platform` with the key store read again, for a response that must
|
|
533
|
+
// show the keys just saved or removed.
|
|
534
|
+
async fn refreshed(platform: &PlatformConfig) -> PlatformConfig {
|
|
535
|
+
let mut fresh = platform.clone();
|
|
536
|
+
fresh.apply_store().await;
|
|
537
|
+
fresh
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// The mode both keys belong to. Each message says what to fix and never quotes
|
|
541
|
+
// any part of a key.
|
|
542
|
+
fn keys_mode(secret_key: &str, publishable_key: &str) -> Result<&'static str, &'static str> {
|
|
543
|
+
let secret_mode = ["rk_test_", "sk_test_"]
|
|
544
|
+
.iter()
|
|
545
|
+
.any(|prefix| secret_key.starts_with(prefix))
|
|
546
|
+
.then_some("test")
|
|
547
|
+
.or_else(|| ["rk_live_", "sk_live_"].iter().any(|prefix| secret_key.starts_with(prefix)).then_some("live"))
|
|
548
|
+
.ok_or("That does not look like a Stripe restricted key. It should start with rk_test_ or rk_live_.")?;
|
|
549
|
+
let publishable_mode = if publishable_key.starts_with("pk_test_") {
|
|
550
|
+
"test"
|
|
551
|
+
} else if publishable_key.starts_with("pk_live_") {
|
|
552
|
+
"live"
|
|
553
|
+
} else {
|
|
554
|
+
return Err("That does not look like a Stripe publishable key. It should start with pk_test_ or pk_live_.");
|
|
555
|
+
};
|
|
556
|
+
if secret_mode == publishable_mode {
|
|
557
|
+
Ok(secret_mode)
|
|
558
|
+
} else {
|
|
559
|
+
Err("One key is for test mode and the other is for live mode. Use two keys from the same mode.")
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// The business name Stripe has for an account, when it has one.
|
|
564
|
+
fn business_name(account: &Value) -> Option<String> {
|
|
565
|
+
let candidates = [
|
|
566
|
+
account.get("business_profile").and_then(|p| p.get("name")),
|
|
567
|
+
account.get("settings").and_then(|s| s.get("dashboard")).and_then(|d| d.get("display_name")),
|
|
568
|
+
account.get("email"),
|
|
569
|
+
];
|
|
570
|
+
candidates.into_iter().flatten().find_map(|v| v.as_str().filter(|s| !s.is_empty())).map(String::from)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Asks Stripe whether the key works and what the business is called. A restricted
|
|
574
|
+
// key that cannot read the account gets the generic name, not a refusal here:
|
|
575
|
+
// creating the webhook is the real test of its permissions.
|
|
576
|
+
async fn verify_key(platform: &PlatformConfig, key: &str) -> Result<String, &'static str> {
|
|
577
|
+
let response = stripe_http()
|
|
578
|
+
.map_err(|_| STRIPE_UNREACHABLE)?
|
|
579
|
+
.get(format!("{}/v1/account", platform.api_base))
|
|
580
|
+
.bearer_auth(key)
|
|
581
|
+
.header("Stripe-Version", STRIPE_API_VERSION)
|
|
582
|
+
.send()
|
|
583
|
+
.await
|
|
584
|
+
.map_err(|_| STRIPE_UNREACHABLE)?;
|
|
585
|
+
let status = response.status();
|
|
586
|
+
if status.as_u16() == 401 {
|
|
587
|
+
return Err(KEY_REFUSED);
|
|
588
|
+
}
|
|
589
|
+
if !status.is_success() {
|
|
590
|
+
return Ok(DIRECT_DISPLAY_FALLBACK.to_string());
|
|
591
|
+
}
|
|
592
|
+
let account = response.json::<Value>().await.unwrap_or(Value::Null);
|
|
593
|
+
Ok(business_name(&account).unwrap_or_else(|| DIRECT_DISPLAY_FALLBACK.to_string()))
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
enum WebhookError {
|
|
597
|
+
/// The key is not allowed to create webhook endpoints.
|
|
598
|
+
Permission,
|
|
599
|
+
/// Anything else: Stripe down, refusing the request, or answering oddly.
|
|
600
|
+
Other,
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Creates the webhook endpoint in the business's own Stripe account and returns
|
|
604
|
+
// its id and signing secret. Stripe shows the signing secret only in this
|
|
605
|
+
// answer, so it is captured here and nowhere else. Only the status is logged.
|
|
606
|
+
async fn create_webhook(platform: &PlatformConfig, key: &str) -> Result<(String, String), WebhookError> {
|
|
607
|
+
let url = format!("{}/webhooks/stripe", platform.webhook_base_url);
|
|
608
|
+
let mut params: Vec<(String, String)> = vec![("url".into(), url), ("description".into(), platform.webhook_description.clone())];
|
|
609
|
+
params.extend(WEBHOOK_EVENTS.iter().enumerate().map(|(index, event)| (format!("enabled_events[{index}]"), event.to_string())));
|
|
610
|
+
let response = stripe_http()
|
|
611
|
+
.map_err(|_| WebhookError::Other)?
|
|
612
|
+
.post(format!("{}/v1/webhook_endpoints", platform.api_base))
|
|
613
|
+
.bearer_auth(key)
|
|
614
|
+
.header("Stripe-Version", STRIPE_API_VERSION)
|
|
615
|
+
.form(¶ms)
|
|
616
|
+
.send()
|
|
617
|
+
.await
|
|
618
|
+
.map_err(|_| WebhookError::Other)?;
|
|
619
|
+
let status = response.status();
|
|
620
|
+
if matches!(status.as_u16(), 401 | 403) {
|
|
621
|
+
return Err(WebhookError::Permission);
|
|
622
|
+
}
|
|
623
|
+
if !status.is_success() {
|
|
624
|
+
eprintln!("Stripe refused to create the payment webhook ({status})");
|
|
625
|
+
return Err(WebhookError::Other);
|
|
626
|
+
}
|
|
627
|
+
let body: Value = response.json().await.map_err(|_| WebhookError::Other)?;
|
|
628
|
+
let text = |field: &str| body.get(field).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from);
|
|
629
|
+
match (text("id"), text("secret")) {
|
|
630
|
+
(Some(id), Some(secret)) => Ok((id, secret)),
|
|
631
|
+
_ => Err(WebhookError::Other),
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Removes a webhook endpoint from the business's Stripe account, best effort:
|
|
636
|
+
// the answer says whether Stripe confirmed it, and nothing else is reported.
|
|
637
|
+
async fn delete_webhook(platform: &PlatformConfig, key: &str, endpoint_id: &str) -> bool {
|
|
638
|
+
let Ok(http) = stripe_http() else { return false };
|
|
639
|
+
let sent = http.delete(format!("{}/v1/webhook_endpoints/{endpoint_id}", platform.api_base)).bearer_auth(key).header("Stripe-Version", STRIPE_API_VERSION).send().await;
|
|
640
|
+
matches!(sent, Ok(response) if response.status().is_success())
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Checks the keys, has Stripe deliver events here, and stores the keys and webhook
|
|
644
|
+
// secret before recording the connection; saving again in place replaces both.
|
|
645
|
+
// Nothing here answers with, logs, or errors with a key.
|
|
646
|
+
#[allow(clippy::too_many_arguments)]
|
|
647
|
+
async fn save_keys_route(caller: &Caller, body: &Value, platform: &PlatformConfig, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, invoker: &dyn LambdaInvoker) -> Value {
|
|
648
|
+
let Some(store) = platform.store.clone() else { return json_error(422, KEYS_NOT_ENABLED) };
|
|
649
|
+
let (secret_key, publishable_key) = match (body_field(body, "secret_key"), body_field(body, "publishable_key")) {
|
|
650
|
+
(Ok(secret), Ok(publishable)) => (secret.trim().to_string(), publishable.trim().to_string()),
|
|
651
|
+
(Err(response), _) | (_, Err(response)) => return response,
|
|
652
|
+
};
|
|
653
|
+
let mode = match keys_mode(&secret_key, &publishable_key) {
|
|
654
|
+
Ok(mode) => mode,
|
|
655
|
+
Err(message) => return json_error(422, message),
|
|
656
|
+
};
|
|
657
|
+
let existing = match current_connection(client, wasm_path, config).await {
|
|
658
|
+
Ok(existing) => existing,
|
|
659
|
+
Err(response) => return response,
|
|
660
|
+
};
|
|
661
|
+
let in_place = match existing.as_ref() {
|
|
662
|
+
Some(c) if matches!(c.status.as_str(), "connected" | "enabled") => {
|
|
663
|
+
if !(c.is_direct() && c.mode == mode) {
|
|
664
|
+
return json_error(409, "an account is already connected — disconnect it first");
|
|
665
|
+
}
|
|
666
|
+
true
|
|
667
|
+
}
|
|
668
|
+
_ => false,
|
|
669
|
+
};
|
|
670
|
+
let display_name = match verify_key(platform, &secret_key).await {
|
|
671
|
+
Ok(name) => name,
|
|
672
|
+
Err(message) => return json_error(422, message),
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
// What the last save made, to be replaced once the new webhook is safely
|
|
676
|
+
// stored: a save that fails part way must never leave the business without
|
|
677
|
+
// a working webhook.
|
|
678
|
+
let previous = store.refresh().await.ok().and_then(|document| document.mode(mode).cloned()).filter(|old| !old.webhook_endpoint_id.is_empty());
|
|
679
|
+
let (endpoint_id, webhook_secret) = match create_webhook(platform, &secret_key).await {
|
|
680
|
+
Ok(created) => created,
|
|
681
|
+
Err(WebhookError::Permission) => return json_error(422, WEBHOOK_PERMISSION),
|
|
682
|
+
Err(WebhookError::Other) => return json_error(422, WEBHOOK_FAILED),
|
|
683
|
+
};
|
|
684
|
+
let saved = StoredKeys { secret_key: secret_key.clone(), publishable_key, webhook_secret, webhook_endpoint_id: endpoint_id.clone(), saved_at: keystore::now_timestamp() };
|
|
685
|
+
if let Err(e) = store.update(|document| document.set(mode, Some(saved))).await {
|
|
686
|
+
eprintln!("saving the payment keys failed: {e}");
|
|
687
|
+
delete_webhook(platform, &secret_key, &endpoint_id).await;
|
|
688
|
+
return json_error(500, STORE_FAILED);
|
|
689
|
+
}
|
|
690
|
+
// The new webhook and keys are stored, so the old webhook can go. Best
|
|
691
|
+
// effort: one that cannot be removed only stays in Stripe, unused.
|
|
692
|
+
if let Some(old) = previous {
|
|
693
|
+
if !delete_webhook(platform, &old.secret_key, &old.webhook_endpoint_id).await {
|
|
694
|
+
eprintln!("the previous Stripe webhook could not be removed");
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
let fresh = refreshed(platform).await;
|
|
699
|
+
if in_place {
|
|
700
|
+
return connection_response(caller, &fresh, client, wasm_path, config).await;
|
|
701
|
+
}
|
|
702
|
+
let facts = json!({
|
|
703
|
+
"processor": {"value": "stripe"},
|
|
704
|
+
"account_ref": {"value": SELF_ACCOUNT},
|
|
705
|
+
"mode": {"value": mode},
|
|
706
|
+
"display_name": {"value": display_name},
|
|
707
|
+
});
|
|
708
|
+
let response = record_connection(caller, &fresh, facts, client, wasm_path, config, invoker).await;
|
|
709
|
+
if response["statusCode"].as_u64() != Some(200) {
|
|
710
|
+
// The domain would not record the connection, so leave nothing behind.
|
|
711
|
+
delete_webhook(platform, &secret_key, &endpoint_id).await;
|
|
712
|
+
if store.update(|document| document.set(mode, None)).await.is_err() {
|
|
713
|
+
eprintln!("the saved payment keys could not be removed after a refused connection");
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
response
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// After a disconnect: remove the webhook from Stripe and delete the saved keys for
|
|
720
|
+
// that mode, best effort and logged without any secret. Environment keys and Stripe
|
|
721
|
+
// are untouched when nothing was ever saved.
|
|
722
|
+
async fn forget_saved_keys(platform: &PlatformConfig, mode: &str) {
|
|
723
|
+
let Some(store) = platform.store.clone() else { return };
|
|
724
|
+
let saved = match store.refresh().await {
|
|
725
|
+
Ok(document) => document.mode(mode).cloned(),
|
|
726
|
+
Err(e) => {
|
|
727
|
+
eprintln!("the saved payment keys could not be read for removal: {e}");
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
let Some(saved) = saved else { return };
|
|
732
|
+
if !saved.webhook_endpoint_id.is_empty() && !delete_webhook(platform, &saved.secret_key, &saved.webhook_endpoint_id).await {
|
|
733
|
+
eprintln!("the Stripe webhook could not be removed from the business's account");
|
|
734
|
+
}
|
|
735
|
+
if let Err(e) = store.update(|document| document.set(mode, None)).await {
|
|
736
|
+
eprintln!("the saved payment keys could not be deleted: {e}");
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/// Settings with nothing configured: no keys, no webhook secret, and a Stripe
|
|
741
|
+
/// base URL that nothing listens on. What a mock deploy looks like.
|
|
742
|
+
#[cfg(test)]
|
|
743
|
+
pub(crate) fn test_platform() -> PlatformConfig {
|
|
744
|
+
PlatformConfig {
|
|
745
|
+
site_url: "http://localhost:4321".to_string(),
|
|
746
|
+
webhook_base_url: "http://localhost:4321".to_string(),
|
|
747
|
+
store: None,
|
|
748
|
+
stored: Arc::new(StoredDocument::default()),
|
|
749
|
+
api_base: "http://127.0.0.1:9".to_string(),
|
|
750
|
+
webhook_description: "Test Domain website".to_string(),
|
|
751
|
+
direct_test_key: String::new(),
|
|
752
|
+
direct_live_key: String::new(),
|
|
753
|
+
direct_test_publishable_key: String::new(),
|
|
754
|
+
direct_live_publishable_key: String::new(),
|
|
755
|
+
webhook_secret: None,
|
|
756
|
+
operators: Vec::new(),
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/// `test_platform` with a webhook signing secret set.
|
|
761
|
+
#[cfg(test)]
|
|
762
|
+
pub(crate) fn test_platform_with_secret(secret: &str) -> PlatformConfig {
|
|
763
|
+
PlatformConfig { webhook_secret: Some(secret.to_string()), ..test_platform() }
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
#[cfg(test)]
|
|
767
|
+
mod tests;
|