hecks 2.9.0 → 3.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.
Files changed (600) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +51 -6
  3. data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  5. data/lib/hecks/adapters/driven/lambda.rb +1 -1
  6. data/lib/hecks/adapters/driven/memory.rb +16 -3
  7. data/lib/hecks/adapters/driven/postgres/reconnect.rb +6 -2
  8. data/lib/hecks/adapters/driven/postgres/shared_connection.rb +180 -0
  9. data/lib/hecks/adapters/driven/postgres.rb +4 -2
  10. data/lib/hecks/adapters/driven/prism.rb +41 -8
  11. data/lib/hecks/adapters/driven/tenant_provisioner.rb +73 -16
  12. data/lib/hecks/behaviors/expectations.rb +1 -1
  13. data/lib/hecks/behaviors.rb +1 -1
  14. data/lib/hecks/bench/cli.rb +62 -0
  15. data/lib/hecks/bench/environment.rb +71 -0
  16. data/lib/hecks/bench/postgres_probe.rb +30 -0
  17. data/lib/hecks/bench/report.rb +76 -0
  18. data/lib/hecks/bench/ruby_runner.rb +93 -0
  19. data/lib/hecks/bench/run.rb +55 -0
  20. data/lib/hecks/bench/rust_runner.rb +101 -0
  21. data/lib/hecks/bench/stats.rb +46 -0
  22. data/lib/hecks/bench/suite.rb +98 -0
  23. data/lib/hecks/bench/workload.rb +89 -0
  24. data/lib/hecks/bench.rb +17 -0
  25. data/lib/hecks/bluebook/assembly/contracts.rb +2 -0
  26. data/lib/hecks/bluebook/attribute.rb +1 -1
  27. data/lib/hecks/bluebook/behaviour/aggregate.rb +30 -1
  28. data/lib/hecks/bluebook/behaviour/domain_port.rb +7 -0
  29. data/lib/hecks/bluebook/chapter.rb +6 -2
  30. data/lib/hecks/bluebook/domain_port.rb +36 -5
  31. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +10 -0
  32. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +8 -0
  33. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -1
  34. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +31 -3
  35. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +122 -3
  36. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +1 -1
  37. data/lib/hecks/bluebook/dsl/query_builder.rb +27 -1
  38. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -1
  39. data/lib/hecks/bluebook/dsl/world_builder.rb +29 -2
  40. data/lib/hecks/bluebook/expression/canonical_form.rb +1 -1
  41. data/lib/hecks/bluebook/expression/resolver.rb +3 -2
  42. data/lib/hecks/bluebook/hexagon.rb +13 -5
  43. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -0
  44. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +4 -1
  45. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +246 -0
  46. data/lib/hecks/bluebook/meta_validator.rb +9 -4
  47. data/lib/hecks/bluebook/model_check.rb +23 -0
  48. data/lib/hecks/bluebook/pattern_subset.rb +38 -0
  49. data/lib/hecks/bluebook/query.rb +20 -5
  50. data/lib/hecks/bluebook/smoke_test.rb +1 -1
  51. data/lib/hecks/canonical_json.rb +30 -0
  52. data/lib/hecks/chapters.rb +102 -0
  53. data/lib/hecks/cli/behaviors.rb +61 -0
  54. data/lib/hecks/cli/canonicalise.rb +25 -0
  55. data/lib/hecks/cli/check_engine_agreement.rb +32 -0
  56. data/lib/hecks/cli/check_era.rb +88 -0
  57. data/lib/hecks/cli/console.rb +66 -0
  58. data/lib/hecks/cli/deploy_template_diff.rb +40 -0
  59. data/lib/hecks/cli/doc_coverage.rb +32 -0
  60. data/lib/hecks/cli/document.rb +2 -2
  61. data/lib/hecks/cli/expression_projection.rb +41 -0
  62. data/lib/hecks/cli/follow.rb +88 -0
  63. data/lib/hecks/cli/history.rb +44 -0
  64. data/lib/hecks/cli/ir.rb +1 -1
  65. data/lib/hecks/cli/mcp.rb +3 -3
  66. data/lib/hecks/cli/model_check.rb +7 -4
  67. data/lib/hecks/cli/pattern_cases.rb +47 -0
  68. data/lib/hecks/cli/present.rb +39 -0
  69. data/lib/hecks/cli/project_cli.rb +224 -32
  70. data/lib/hecks/cli/project_diagrams.rb +4 -3
  71. data/lib/hecks/cli/project_glossary.rb +44 -0
  72. data/lib/hecks/cli/reference.rb +46 -0
  73. data/lib/hecks/cli/refresh_projections.rb +44 -0
  74. data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +150 -0
  75. data/lib/hecks/cli/release.rb +70 -0
  76. data/lib/hecks/cli/release_gem.rb +59 -0
  77. data/lib/hecks/cli/rspec_io_parallel_files.rb +137 -0
  78. data/lib/hecks/cli/rspec_shard_files.rb +66 -0
  79. data/lib/hecks/cli/run.rb +3 -3
  80. data/lib/hecks/cli/seed_semantics_corpus.rb +71 -0
  81. data/lib/hecks/cli/serve_query_ir_mcp.rb +30 -0
  82. data/lib/hecks/cli/shape.rb +70 -0
  83. data/lib/hecks/cli/smoke_http.rb +230 -0
  84. data/lib/hecks/cli/smoke_test.rb +5 -3
  85. data/lib/hecks/cli/spec_example.rb +27 -0
  86. data/lib/hecks/cli/statements.rb +32 -0
  87. data/lib/hecks/cli/stores.rb +4 -5
  88. data/lib/hecks/cli/stress_concurrency_specs.rb +177 -0
  89. data/lib/hecks/cli.rb +201 -52
  90. data/lib/hecks/codemod.rb +324 -0
  91. data/lib/hecks/corpus.rb +423 -0
  92. data/lib/hecks/deploy/bluebook/deploy.bluebook +591 -23
  93. data/lib/hecks/deploy/bluebook/deploy.hecksagon +41 -4
  94. data/lib/hecks/deploy/oidc.json +82 -2
  95. data/lib/hecks/doc/reference.rb +351 -0
  96. data/lib/hecks/doors/cli_door.rb +196 -0
  97. data/lib/hecks/doors/cli_runner.rb +297 -0
  98. data/lib/hecks/{facade → doors}/command_request.rb +1 -1
  99. data/lib/hecks/{facade → doors}/handle.rb +2 -2
  100. data/lib/hecks/{facade → doors}/json_door.rb +4 -4
  101. data/lib/hecks/doors/launcher_options.rb +129 -0
  102. data/lib/hecks/{cli → doors}/mcp_door.rb +7 -7
  103. data/lib/hecks/doors/mcp_door_scope.rb +97 -0
  104. data/lib/hecks/{facade/surface → doors/ruby_door}/aggregate_door.rb +8 -3
  105. data/lib/hecks/{facade/surface → doors/ruby_door}/chapter.rb +3 -3
  106. data/lib/hecks/{facade/surface.rb → doors/ruby_door.rb} +36 -5
  107. data/lib/hecks/doors.rb +61 -0
  108. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +1 -1
  109. data/lib/hecks/embryonaut_bluebook.rb +1 -1
  110. data/lib/hecks/engine_agreement.rb +156 -0
  111. data/lib/hecks/facade.rb +24 -37
  112. data/lib/hecks/forms/app.rb +6 -6
  113. data/lib/hecks/forms/banking_presentation.rb +50 -0
  114. data/lib/hecks/forms/field_shape.rb +2 -1
  115. data/lib/hecks/forms/port_argument.rb +1 -1
  116. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +393 -0
  117. data/lib/hecks/fuzzing/combination_miner.rb +86 -0
  118. data/lib/hecks/fuzzing/concurrent_dispatch.rb +214 -0
  119. data/lib/hecks/fuzzing/coverage_campaign.rb +143 -0
  120. data/lib/hecks/fuzzing/differential.rb +156 -0
  121. data/lib/hecks/fuzzing/domain_generator.rb +615 -0
  122. data/lib/hecks/fuzzing/era_boundary.rb +71 -0
  123. data/lib/hecks/fuzzing/form_census.rb +136 -0
  124. data/lib/hecks/fuzzing/generated_domain_check.rb +93 -0
  125. data/lib/hecks/fuzzing/invalid_value_generator.rb +64 -0
  126. data/lib/hecks/fuzzing/isolated_boot.rb +301 -0
  127. data/lib/hecks/fuzzing/nondeterministic.rb +51 -0
  128. data/lib/hecks/fuzzing/outside_world.rb +45 -0
  129. data/lib/hecks/fuzzing/persistence_parity.rb +102 -0
  130. data/lib/hecks/fuzzing/properties/corrections.rb +88 -0
  131. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +314 -0
  132. data/lib/hecks/fuzzing/properties/guards.rb +190 -0
  133. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +300 -0
  134. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +93 -0
  135. data/lib/hecks/fuzzing/properties/outbox.rb +89 -0
  136. data/lib/hecks/fuzzing/properties/querying.rb +175 -0
  137. data/lib/hecks/fuzzing/properties.rb +162 -0
  138. data/lib/hecks/fuzzing/qa_settings.rb +123 -0
  139. data/lib/hecks/fuzzing/replay.rb +505 -0
  140. data/lib/hecks/fuzzing/rotation_priority.rb +48 -0
  141. data/lib/hecks/fuzzing/rust_gap_manifest.rb +108 -0
  142. data/lib/hecks/fuzzing/self_consistency.rb +339 -0
  143. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +385 -0
  144. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +132 -0
  145. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +95 -0
  146. data/lib/hecks/fuzzing/sequence_generator/picker.rb +77 -0
  147. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +109 -0
  148. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +237 -0
  149. data/lib/hecks/fuzzing/sequence_generator.rb +189 -0
  150. data/lib/hecks/fuzzing/shrinker.rb +166 -0
  151. data/lib/hecks/fuzzing/structural_skips.rb +33 -0
  152. data/lib/hecks/fuzzing/sweep_depth.rb +43 -0
  153. data/lib/hecks/fuzzing/target_capabilities.rb +163 -0
  154. data/lib/hecks/fuzzing/value_generator.rb +216 -0
  155. data/lib/hecks/fuzzing.rb +23 -0
  156. data/lib/hecks/grammar/evolve.rb +336 -0
  157. data/lib/hecks/hecks/adapters/codebase/codemods.rb +45 -0
  158. data/lib/hecks/hecks/adapters/codebase/conformance.rb +71 -0
  159. data/lib/hecks/hecks/adapters/codebase/corpus_tasks.rb +202 -0
  160. data/lib/hecks/hecks/adapters/codebase/gem_registry.rb +80 -0
  161. data/lib/hecks/hecks/adapters/codebase/kernel_tables.rb +54 -0
  162. data/lib/hecks/hecks/adapters/codebase/language.rb +240 -0
  163. data/lib/hecks/hecks/adapters/codebase/npm_registry.rb +102 -0
  164. data/lib/hecks/hecks/adapters/codebase/publishing.rb +142 -0
  165. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +67 -0
  166. data/lib/hecks/hecks/adapters/codebase/release_facts.rb +90 -0
  167. data/lib/hecks/hecks/adapters/codebase/ruby_child.rb +83 -0
  168. data/lib/hecks/hecks/adapters/codebase/secret_vault.rb +36 -0
  169. data/lib/hecks/hecks/adapters/codebase/source_tree.adapter +7 -0
  170. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +189 -0
  171. data/lib/hecks/hecks/adapters/codebase/sqlite_fixture.rb +71 -0
  172. data/lib/hecks/hecks/adapters/codebase/style.rb +171 -0
  173. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +55 -0
  174. data/lib/hecks/hecks/adapters/codebase/test_suite.rb +165 -0
  175. data/lib/hecks/hecks/adapters/codebase/tree.rb +111 -0
  176. data/lib/hecks/hecks/adapters/console_capture.rb +79 -0
  177. data/lib/hecks/hecks/adapters/deploy_toolchain.adapter +7 -0
  178. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +108 -0
  179. data/lib/hecks/hecks/adapters/git.adapter +7 -0
  180. data/lib/hecks/hecks/adapters/git.rb +102 -0
  181. data/lib/hecks/hecks/adapters/host_http.adapter +7 -0
  182. data/lib/hecks/hecks/adapters/host_http.rb +65 -0
  183. data/lib/hecks/hecks/adapters/in_process_boot.adapter +7 -0
  184. data/lib/hecks/hecks/adapters/in_process_boot.rb +197 -0
  185. data/lib/hecks/hecks/adapters/in_process_operations.rb +202 -0
  186. data/lib/hecks/hecks/adapters/journal_store/changes.rb +143 -0
  187. data/lib/hecks/hecks/adapters/journal_store/compaction.rb +145 -0
  188. data/lib/hecks/hecks/adapters/journal_store/edge_audit.rb +132 -0
  189. data/lib/hecks/hecks/adapters/journal_store/examination.rb +135 -0
  190. data/lib/hecks/hecks/adapters/journal_store/held_domain.rb +163 -0
  191. data/lib/hecks/hecks/adapters/journal_store/readings.rb +187 -0
  192. data/lib/hecks/hecks/adapters/journal_store.adapter +7 -0
  193. data/lib/hecks/hecks/adapters/journal_store.rb +64 -0
  194. data/lib/hecks/hecks/adapters/local_files.adapter +7 -0
  195. data/lib/hecks/hecks/adapters/local_files.rb +46 -0
  196. data/lib/hecks/hecks/adapters/pg_admin.adapter +7 -0
  197. data/lib/hecks/hecks/adapters/pg_admin.rb +245 -0
  198. data/lib/hecks/hecks/adapters/process_pool.adapter +7 -0
  199. data/lib/hecks/hecks/adapters/process_pool.rb +119 -0
  200. data/lib/hecks/hecks/adapters/rust_toolchain.adapter +7 -0
  201. data/lib/hecks/hecks/adapters/rust_toolchain.rb +179 -0
  202. data/lib/hecks/hecks/adapters/rust_workspace.rb +150 -0
  203. data/lib/hecks/hecks/adapters/shell.adapter +7 -0
  204. data/lib/hecks/hecks/adapters/shell.rb +49 -0
  205. data/lib/hecks/hecks/adapters/terminal.adapter +7 -0
  206. data/lib/hecks/hecks/adapters/terminal.rb +74 -0
  207. data/lib/hecks/hecks/codebase.behaviors +172 -0
  208. data/lib/hecks/hecks/codebase.bluebook +2453 -0
  209. data/lib/hecks/hecks/context_map.hecksagon +13 -0
  210. data/lib/hecks/hecks/custodian.behaviors +179 -0
  211. data/lib/hecks/hecks/custodian.bluebook +1847 -0
  212. data/lib/hecks/hecks/deploy.behaviors +74 -0
  213. data/lib/hecks/hecks/deploy.world +12 -0
  214. data/lib/hecks/hecks/environments/memory.world +10 -0
  215. data/lib/hecks/hecks/governance.world +7 -0
  216. data/lib/hecks/hecks/hecks.behaviors +62 -0
  217. data/lib/hecks/hecks/hecks.bluebook +124 -0
  218. data/lib/hecks/hecks/hecks.hecksagon +390 -0
  219. data/lib/hecks/hecks/hecks.world +42 -0
  220. data/lib/hecks/kernel_capabilities.rb +194 -0
  221. data/lib/hecks/language/bluebook/bluebook.bluebook +11 -0
  222. data/lib/hecks/language/bluebook/query.bluebook +7 -0
  223. data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -4
  224. data/lib/hecks/language/hecksagon/domain_port.bluebook +10 -0
  225. data/lib/hecks/language/hecksagon/hecksagon.bluebook +20 -0
  226. data/lib/hecks/mcp_stdio_guard.rb +2 -2
  227. data/lib/hecks/persistence_legacy_fixture/regenerate.rb +161 -0
  228. data/lib/hecks/persistence_legacy_fixture.rb +113 -0
  229. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +8 -5
  230. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +4 -1
  231. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +15 -2
  232. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +1 -1
  233. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +1 -1
  234. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +1 -1
  235. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +32 -15
  236. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +6 -3
  237. data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +227 -0
  238. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +1 -1
  239. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +43 -24
  240. data/lib/hecks/ports/persistence/plugins/era/translation.rb +1 -0
  241. data/lib/hecks/ports/persistence/state_codec.rb +24 -5
  242. data/lib/hecks/projection_files.rb +285 -0
  243. data/lib/hecks/projections/bootstrap_table.rb +1 -1
  244. data/lib/hecks/projections/deploy/fargate.rb +5 -5
  245. data/lib/hecks/projections/deploy/lambda.rb +43 -32
  246. data/lib/hecks/projections/deploy/preview/preview.sh.erb +1 -1
  247. data/lib/hecks/projections/deploy/preview/template.rb +1 -1
  248. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +1 -1
  249. data/lib/hecks/projections/deploy/scripts/expected-era.header +1 -1
  250. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +3 -3
  251. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +1 -1
  252. data/lib/hecks/projections/deploy/shared.rb +1 -1
  253. data/lib/hecks/projections/diagrams.rb +3 -3
  254. data/lib/hecks/projections/model/deviations.rb +1 -1
  255. data/lib/hecks/projections/parser_table.rb +4 -4
  256. data/lib/hecks/projections/reference.rb +2 -3
  257. data/lib/hecks/projections/rust_vocabulary.rb +1 -1
  258. data/lib/hecks/projector/cli_projector.rb +106 -37
  259. data/lib/hecks/projector.rb +1 -1
  260. data/lib/hecks/quality_control/adapters/agent.adapter +7 -0
  261. data/lib/hecks/quality_control/adapters/agent.rb +58 -0
  262. data/lib/hecks/quality_control/adapters/angle_tools.adapter +7 -0
  263. data/lib/hecks/quality_control/adapters/angle_tools.rb +15 -0
  264. data/lib/hecks/quality_control/adapters/clearance_recorder.rb +36 -0
  265. data/lib/hecks/quality_control/adapters/clearance_tools.adapter +7 -0
  266. data/lib/hecks/quality_control/adapters/clearance_tools.rb +17 -0
  267. data/lib/hecks/quality_control/adapters/git_pr.adapter +7 -0
  268. data/lib/hecks/quality_control/adapters/git_pr.rb +226 -0
  269. data/lib/hecks/quality_control/adapters/github_checks.adapter +9 -0
  270. data/lib/hecks/quality_control/adapters/github_checks.rb +83 -0
  271. data/lib/hecks/quality_control/adapters/github_ci_webhook.rb +56 -0
  272. data/lib/hecks/quality_control/adapters/github_issues.adapter +7 -0
  273. data/lib/hecks/quality_control/adapters/github_issues.rb +49 -0
  274. data/lib/hecks/quality_control/adapters/qa_tool.rb +59 -0
  275. data/lib/hecks/quality_control/adapters/sweep_tools.adapter +7 -0
  276. data/lib/hecks/quality_control/adapters/sweep_tools.rb +51 -0
  277. data/lib/hecks/quality_control/adapters/target_tools.adapter +7 -0
  278. data/lib/hecks/quality_control/adapters/target_tools.rb +43 -0
  279. data/lib/hecks/quality_control/cli/child.rb +45 -0
  280. data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +45 -0
  281. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +248 -0
  282. data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +163 -0
  283. data/lib/hecks/quality_control/cli/qa_generated_domains.rb +450 -0
  284. data/lib/hecks/quality_control/cli/qa_log_bug.rb +195 -0
  285. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +220 -0
  286. data/lib/hecks/quality_control/cli/qa_open_pr.rb +305 -0
  287. data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +154 -0
  288. data/lib/hecks/quality_control/cli/qa_postgres_role.rb +159 -0
  289. data/lib/hecks/quality_control/cli/qa_pr_check.rb +232 -0
  290. data/lib/hecks/quality_control/cli/qa_seed_angles.rb +142 -0
  291. data/lib/hecks/quality_control/cli/qa_seed_targets.rb +69 -0
  292. data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +280 -0
  293. data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +329 -0
  294. data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +92 -0
  295. data/lib/hecks/quality_control/cli/qa_sweep/finding_report.rb +117 -0
  296. data/lib/hecks/quality_control/cli/qa_sweep/parsing.rb +175 -0
  297. data/lib/hecks/quality_control/cli/qa_sweep/release_mode.rb +60 -0
  298. data/lib/hecks/quality_control/cli/qa_sweep.rb +611 -0
  299. data/lib/hecks/quality_control/cli/qa_tick.rb +274 -0
  300. data/lib/hecks/quality_control/quality_control.bluebook +2224 -0
  301. data/lib/hecks/quality_control/quality_control.hecksagon +13 -0
  302. data/lib/hecks/quality_control/quality_control.ports.hecksagon +54 -0
  303. data/lib/hecks/query_ir.rb +346 -0
  304. data/lib/hecks/query_ir_mcp.rb +198 -0
  305. data/lib/hecks/release/runner/ci_publisher.rb +2 -2
  306. data/lib/hecks/release/runner/clean_tree.rb +42 -0
  307. data/lib/hecks/release/runner/console.rb +6 -0
  308. data/lib/hecks/release/runner/gem_publisher.rb +14 -7
  309. data/lib/hecks/release/runner/npm_publisher.rb +6 -36
  310. data/lib/hecks/release/runner/preflight.rb +26 -2
  311. data/lib/hecks/release/runner/published.rb +8 -26
  312. data/lib/hecks/release/runner/tagger.rb +30 -15
  313. data/lib/hecks/release/runner.rb +57 -6
  314. data/lib/hecks/router/namespace_installer.rb +3 -3
  315. data/lib/hecks/runtime/adapter_lookup.rb +124 -0
  316. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +4 -0
  317. data/lib/hecks/runtime/dependency_planning.rb +20 -2
  318. data/lib/hecks/runtime/dispatcher.rb +53 -3
  319. data/lib/hecks/runtime/interpreting.rb +3 -0
  320. data/lib/hecks/runtime/loader.rb +65 -12
  321. data/lib/hecks/runtime/outbox.rb +1 -1
  322. data/lib/hecks/runtime/policy_interpreter.rb +5 -2
  323. data/lib/hecks/runtime/port_operation_interpreter.rb +2 -10
  324. data/lib/hecks/runtime/query_interpreter.rb +77 -0
  325. data/lib/hecks/runtime/reaction_outcome.rb +89 -0
  326. data/lib/hecks/runtime/refusal_wording.rb +23 -1
  327. data/lib/hecks/runtime/registry/verification.rb +163 -4
  328. data/lib/hecks/runtime/registry.rb +57 -3
  329. data/lib/hecks/runtime/remote_dispatcher.rb +31 -2
  330. data/lib/hecks/runtime/value/coercion.rb +23 -135
  331. data/lib/hecks/runtime/value/field_checks.rb +172 -0
  332. data/lib/hecks/runtime/value.rb +4 -1
  333. data/lib/hecks/runtime.rb +20 -6
  334. data/lib/hecks/rust_build/conformance.rb +158 -0
  335. data/lib/hecks/rust_build/conformance_fuzz.rb +71 -0
  336. data/lib/hecks/rust_build/coverage.rb +218 -0
  337. data/lib/hecks/rust_build/native_build.rb +124 -0
  338. data/lib/hecks/rust_build/project_rust/root_mod.rb +120 -0
  339. data/lib/hecks/rust_build/project_rust.rb +332 -0
  340. data/lib/hecks/rust_build/wasm.rb +105 -0
  341. data/lib/hecks/rust_build/wasm_browser.rb +113 -0
  342. data/lib/hecks/rust_build.rb +144 -0
  343. data/lib/hecks/storehouse.rb +16 -16
  344. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +2 -2
  345. data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +1 -1
  346. data/lib/hecks/three_zero/forms.yml +127 -0
  347. data/lib/hecks/three_zero.rb +12 -0
  348. data/lib/hecks/tools/argument_gate_matrix.rb +316 -0
  349. data/lib/hecks/tools/comment_style.rb +1070 -0
  350. data/lib/hecks/tools/corpus_report.rb +87 -0
  351. data/lib/hecks/tools/deploy_recipe.rb +255 -0
  352. data/lib/hecks/tools/deploy_recipe_lint.rb +282 -0
  353. data/lib/hecks/tools/drop_implicit_append_fields.rb +95 -0
  354. data/lib/hecks/tools/era_projection_backfill.rb +94 -0
  355. data/lib/hecks/tools/era_reattest.rb +146 -0
  356. data/lib/hecks/tools/evolve_run.rb +189 -0
  357. data/lib/hecks/tools/fuzz_sweep.rb +373 -0
  358. data/lib/hecks/tools/heki_compaction.rb +100 -0
  359. data/lib/hecks/tools/hoist_local_givens.rb +246 -0
  360. data/lib/hecks/tools/journal_compaction.rb +89 -0
  361. data/lib/hecks/tools/oidc_manifests.rb +70 -0
  362. data/lib/hecks/tools/query_ir_run.rb +81 -0
  363. data/lib/hecks/tools/regeneration_run.rb +166 -0
  364. data/lib/hecks/tools/rust_comment_style.rb +642 -0
  365. data/lib/hecks/tools/sequence_script.rb +62 -0
  366. data/lib/hecks/tools/tail_merge.rb +117 -0
  367. data/lib/hecks/tools/tenant_provisioning.rb +142 -0
  368. data/lib/hecks/tools/translation_audit.rb +208 -0
  369. data/lib/hecks/tools/translation_scaffold.rb +131 -0
  370. data/lib/hecks/tools.rb +86 -0
  371. data/lib/hecks/version.rb +1 -1
  372. data/lib/hecks.rb +32 -15
  373. data/qa/settings.yml +66 -0
  374. data/rust/.cargo/config.toml +6 -0
  375. data/rust/.gitignore +2 -0
  376. data/rust/Cargo.lock +54 -0
  377. data/rust/Cargo.toml +61 -0
  378. data/rust/build/.cargo/config.toml +3 -0
  379. data/rust/build/.gitignore +1 -0
  380. data/rust/build/Cargo.lock +7 -0
  381. data/rust/build/Cargo.toml +19 -0
  382. data/rust/build/src/build_artifact.rs +47 -0
  383. data/rust/build/src/cargo_sync.rs +248 -0
  384. data/rust/build/src/json.rs +421 -0
  385. data/rust/build/src/lineage_pass.rs +285 -0
  386. data/rust/build/src/main.rs +51 -0
  387. data/rust/build/src/optional_pass.rs +150 -0
  388. data/rust/build/src/pipeline.rs +264 -0
  389. data/rust/build/src/reserved_names.rs +76 -0
  390. data/rust/build/src/resolve.rs +124 -0
  391. data/rust/build/src/root.rs +25 -0
  392. data/rust/build/src/sidecars.rs +73 -0
  393. data/rust/build/src/subprocess.rs +51 -0
  394. data/rust/build/src/tmp.rs +31 -0
  395. data/rust/codegen/.cargo/config.toml +3 -0
  396. data/rust/codegen/.gitignore +1 -0
  397. data/rust/codegen/Cargo.lock +7 -0
  398. data/rust/codegen/Cargo.toml +20 -0
  399. data/rust/codegen/src/attr.rs +32 -0
  400. data/rust/codegen/src/bridging.rs +481 -0
  401. data/rust/codegen/src/commands.rs +1226 -0
  402. data/rust/codegen/src/constraints.rs +109 -0
  403. data/rust/codegen/src/dependency_planning.rs +209 -0
  404. data/rust/codegen/src/domain_generator.rs +1288 -0
  405. data/rust/codegen/src/exemplar.rs +230 -0
  406. data/rust/codegen/src/expr_emitter.rs +87 -0
  407. data/rust/codegen/src/fielded.rs +192 -0
  408. data/rust/codegen/src/hecks_naming.rs +33 -0
  409. data/rust/codegen/src/json.rs +335 -0
  410. data/rust/codegen/src/json_codec.rs +697 -0
  411. data/rust/codegen/src/literal.rs +207 -0
  412. data/rust/codegen/src/main.rs +290 -0
  413. data/rust/codegen/src/manifest.rs +227 -0
  414. data/rust/codegen/src/mutations.rs +784 -0
  415. data/rust/codegen/src/naming.rs +286 -0
  416. data/rust/codegen/src/ports.rs +208 -0
  417. data/rust/codegen/src/prelude.rs +116 -0
  418. data/rust/codegen/src/queries.rs +786 -0
  419. data/rust/codegen/src/reactions.rs +532 -0
  420. data/rust/codegen/src/read_models.rs +697 -0
  421. data/rust/codegen/src/reference_specs.rs +40 -0
  422. data/rust/codegen/src/registry.rs +966 -0
  423. data/rust/codegen/src/reserved_names.rs +76 -0
  424. data/rust/codegen/src/shared.rs +93 -0
  425. data/rust/codegen/src/skip_reason.rs +25 -0
  426. data/rust/codegen/src/types.rs +362 -0
  427. data/rust/host/.cargo/config.toml +3 -0
  428. data/rust/host/.gitignore +1 -0
  429. data/rust/host/Cargo.lock +4297 -0
  430. data/rust/host/Cargo.toml +61 -0
  431. data/rust/host/HECKS_RELEASE +1 -0
  432. data/rust/host/rds-ca-bundle.pem +2736 -0
  433. data/rust/host/src/api.rs +1596 -0
  434. data/rust/host/src/approval.rs +652 -0
  435. data/rust/host/src/auth.rs +1287 -0
  436. data/rust/host/src/bin/expr_harness.rs +46 -0
  437. data/rust/host/src/bin/lineage_harness.rs +119 -0
  438. data/rust/host/src/bin/mint_harness.rs +135 -0
  439. data/rust/host/src/checkout.rs +265 -0
  440. data/rust/host/src/dispatch.rs +1443 -0
  441. data/rust/host/src/expr_json.rs +1117 -0
  442. data/rust/host/src/field_hints.rs +52 -0
  443. data/rust/host/src/ir.rs +733 -0
  444. data/rust/host/src/journal.rs +874 -0
  445. data/rust/host/src/lambda_client.rs +378 -0
  446. data/rust/host/src/log.rs +147 -0
  447. data/rust/host/src/main.rs +474 -0
  448. data/rust/host/src/mint.rs +1865 -0
  449. data/rust/host/src/payments/keystore.rs +330 -0
  450. data/rust/host/src/payments/tests.rs +1400 -0
  451. data/rust/host/src/payments.rs +767 -0
  452. data/rust/host/src/presentation.rs +698 -0
  453. data/rust/host/src/presentation_write.rs +1431 -0
  454. data/rust/host/src/query_step.rs +149 -0
  455. data/rust/host/src/rate_limit.rs +904 -0
  456. data/rust/host/src/reference_transform.rs +241 -0
  457. data/rust/host/src/reference_validate.rs +338 -0
  458. data/rust/host/src/resend.rs +296 -0
  459. data/rust/host/src/secrets.rs +106 -0
  460. data/rust/host/src/server.rs +663 -0
  461. data/rust/host/src/storage_shape.rs +175 -0
  462. data/rust/host/src/test_pg.rs +40 -0
  463. data/rust/host/src/ui_schema.rs +1351 -0
  464. data/rust/host/src/wasm_runner.rs +191 -0
  465. data/rust/host/src/web/newsletter/cooldown.rs +126 -0
  466. data/rust/host/src/web/newsletter/tests.rs +104 -0
  467. data/rust/host/src/web/newsletter.rs +310 -0
  468. data/rust/host/src/web/newsletter_send.rs +357 -0
  469. data/rust/host/src/web/registrations.rs +1505 -0
  470. data/rust/host/src/web.rs +2937 -0
  471. data/rust/lsp/.gitignore +1 -0
  472. data/rust/lsp/Cargo.lock +7 -0
  473. data/rust/lsp/Cargo.toml +16 -0
  474. data/rust/lsp/README.md +133 -0
  475. data/rust/lsp/src/diagnostics.rs +165 -0
  476. data/rust/lsp/src/json.rs +385 -0
  477. data/rust/lsp/src/main.rs +399 -0
  478. data/rust/lsp/src/outline.rs +177 -0
  479. data/rust/lsp/src/rpc.rs +67 -0
  480. data/rust/parser/.cargo/config.toml +5 -0
  481. data/rust/parser/.gitignore +1 -0
  482. data/rust/parser/Cargo.lock +7 -0
  483. data/rust/parser/Cargo.toml +21 -0
  484. data/rust/parser/src/build/closed_sets.rs +27 -0
  485. data/rust/parser/src/build/identity.rs +276 -0
  486. data/rust/parser/src/build/mod.rs +12 -0
  487. data/rust/parser/src/build/naming.rs +141 -0
  488. data/rust/parser/src/build/pattern_subset.rs +161 -0
  489. data/rust/parser/src/build/query_derive.rs +72 -0
  490. data/rust/parser/src/build/query_inference.rs +269 -0
  491. data/rust/parser/src/build/query_options.rs +100 -0
  492. data/rust/parser/src/build/read_model.rs +94 -0
  493. data/rust/parser/src/build/references.rs +32 -0
  494. data/rust/parser/src/canonical.rs +114 -0
  495. data/rust/parser/src/diag.rs +55 -0
  496. data/rust/parser/src/emit.rs +953 -0
  497. data/rust/parser/src/expr/ast_json.rs +147 -0
  498. data/rust/parser/src/expr/evaluator.rs +81 -0
  499. data/rust/parser/src/expr/mod.rs +84 -0
  500. data/rust/parser/src/expr/resolver.rs +497 -0
  501. data/rust/parser/src/ir.rs +387 -0
  502. data/rust/parser/src/keywords.rs +585 -0
  503. data/rust/parser/src/lex.rs +662 -0
  504. data/rust/parser/src/main.rs +252 -0
  505. data/rust/parser/src/parse/aggregate.rs +612 -0
  506. data/rust/parser/src/parse/chapter.rs +517 -0
  507. data/rust/parser/src/parse/command.rs +637 -0
  508. data/rust/parser/src/parse/domain_port.rs +127 -0
  509. data/rust/parser/src/parse/entity.rs +527 -0
  510. data/rust/parser/src/parse/file.rs +67 -0
  511. data/rust/parser/src/parse/hecksagon.rs +231 -0
  512. data/rust/parser/src/parse/lifecycle.rs +162 -0
  513. data/rust/parser/src/parse/mod.rs +1262 -0
  514. data/rust/parser/src/parse/policy.rs +87 -0
  515. data/rust/parser/src/parse/process_manager.rs +305 -0
  516. data/rust/parser/src/parse/query.rs +100 -0
  517. data/rust/parser/src/parse/read_model.rs +214 -0
  518. data/rust/parser/src/parse/value_object.rs +250 -0
  519. data/rust/parser/src/ruby_value.rs +422 -0
  520. data/rust/project/bridging.rb +257 -0
  521. data/rust/project/commands.rb +877 -0
  522. data/rust/project/constraints.rb +86 -0
  523. data/rust/project/dependency_planning.rb +178 -0
  524. data/rust/project/domain_generator.rb +980 -0
  525. data/rust/project/exemplar.rb +148 -0
  526. data/rust/project/expr_emitter.rb +72 -0
  527. data/rust/project/fielded.rb +141 -0
  528. data/rust/project/json_codec.rb +641 -0
  529. data/rust/project/mutations.rb +721 -0
  530. data/rust/project/naming.rb +205 -0
  531. data/rust/project/ports.rb +65 -0
  532. data/rust/project/queries.rb +493 -0
  533. data/rust/project/reactions.rb +317 -0
  534. data/rust/project/read_models.rb +690 -0
  535. data/rust/project/reference_specs.rb +27 -0
  536. data/rust/project/registry.rb +396 -0
  537. data/rust/project/skip_reason.rb +29 -0
  538. data/rust/project/types.rb +225 -0
  539. data/rust/project/write_if_changed.rb +83 -0
  540. data/rust/project.rb +30 -0
  541. data/rust/project_rust_pipeline.rb +383 -0
  542. data/rust/src/exemplar/commands.rs +279 -0
  543. data/rust/src/exemplar/constraints.rs +18 -0
  544. data/rust/src/exemplar/fielded.rs +280 -0
  545. data/rust/src/exemplar/json.rs +277 -0
  546. data/rust/src/exemplar/mod.rs +17 -0
  547. data/rust/src/exemplar/mutations.rs +80 -0
  548. data/rust/src/exemplar/queries.rs +33 -0
  549. data/rust/src/exemplar/reactions.rs +88 -0
  550. data/rust/src/exemplar/read_models.rs +48 -0
  551. data/rust/src/exemplar/registry.rs +166 -0
  552. data/rust/src/exemplar/types.rs +44 -0
  553. data/rust/src/kernel/attribute_shapes/composite.rs +88 -0
  554. data/rust/src/kernel/attribute_shapes/list.rs +18 -0
  555. data/rust/src/kernel/attribute_shapes/mod.rs +44 -0
  556. data/rust/src/kernel/attribute_shapes/optional.rs +9 -0
  557. data/rust/src/kernel/attribute_shapes/scalar.rs +39 -0
  558. data/rust/src/kernel/cli.rs +585 -0
  559. data/rust/src/kernel/dispatch.rs +1090 -0
  560. data/rust/src/kernel/expr.rs +458 -0
  561. data/rust/src/kernel/expression_operators/arithmetic.rs +128 -0
  562. data/rust/src/kernel/expression_operators/comparison.rs +48 -0
  563. data/rust/src/kernel/expression_operators/enumeration.rs +232 -0
  564. data/rust/src/kernel/expression_operators/logical.rs +13 -0
  565. data/rust/src/kernel/expression_operators/membership.rs +18 -0
  566. data/rust/src/kernel/expression_operators/mod.rs +68 -0
  567. data/rust/src/kernel/expression_operators/pattern_match.rs +73 -0
  568. data/rust/src/kernel/expression_operators/positional.rs +126 -0
  569. data/rust/src/kernel/expression_operators/presence.rs +101 -0
  570. data/rust/src/kernel/expression_operators/sign_test.rs +16 -0
  571. data/rust/src/kernel/expression_operators/sized.rs +35 -0
  572. data/rust/src/kernel/expression_operators/text.rs +79 -0
  573. data/rust/src/kernel/expression_operators/to_string.rs +18 -0
  574. data/rust/src/kernel/json.rs +779 -0
  575. data/rust/src/kernel/mod.rs +133 -0
  576. data/rust/src/kernel/named_query.rs +219 -0
  577. data/rust/src/kernel/orchestrate.rs +1810 -0
  578. data/rust/src/kernel/pattern.rs +307 -0
  579. data/rust/src/kernel/query_comparators.rs +206 -0
  580. data/rust/src/kernel/query_ordering.rs +103 -0
  581. data/rust/src/kernel/read_model.rs +914 -0
  582. data/rust/src/kernel/reference_lookup.rs +149 -0
  583. data/rust/src/kernel/repository.rs +468 -0
  584. data/rust/src/kernel/routing.rs +612 -0
  585. data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +118 -0
  586. data/rust/src/kernel/vocab/entity_dispatch_order.rs +114 -0
  587. data/rust/src/kernel/vocab/field_hint.rs +70 -0
  588. data/rust/src/kernel/vocab/mod.rs +20 -0
  589. data/rust/src/kernel/vocab/query_comparator.rs +65 -0
  590. data/rust/src/kernel/vocab/refusal_template.rs +1592 -0
  591. data/rust/src/lib.rs +8 -0
  592. data/rust/src/main.rs +16 -0
  593. data/rust/web/.gitignore +1 -0
  594. data/rust/web/Cargo.lock +119 -0
  595. data/rust/web/Cargo.toml +18 -0
  596. data/rust/web/src/lib.rs +8 -0
  597. metadata +477 -12
  598. data/lib/hecks/facade/cli_door.rb +0 -96
  599. data/lib/hecks/facade/cli_runner.rb +0 -127
  600. data/lib/hecks/mcp_door_scope.rb +0 -95
@@ -0,0 +1,1505 @@
1
+ use super::{instances_for, last_refusal, respond, with_id};
2
+ use crate::checkout;
3
+ use crate::dispatch;
4
+ use crate::ir::{payments_provider, PaymentsProvider};
5
+ use crate::journal::LineageConfig;
6
+ use crate::lambda_client::LambdaInvoker;
7
+ use crate::payments;
8
+ use serde_json::{json, Value};
9
+ use std::path::Path;
10
+ use tokio::sync::Mutex;
11
+ use tokio_postgres::Client;
12
+
13
+ // Routes for /events, /registrations and /webhooks/stripe. Event and
14
+ // Registration names are fixed here rather than read from the IR.
15
+
16
+ /// Whether checkout is on: `configured` must be set and equal `domain`
17
+ /// exactly — a mismatch would dispatch the wrong domain's Registration.Request.
18
+ pub(crate) fn checkout_enabled(configured: Option<&str>, domain: &str) -> bool {
19
+ configured.is_some_and(|c| !c.is_empty() && c == domain)
20
+ }
21
+
22
+ // Fixed, publicly-known, non-secret mock webhook secret; used only while
23
+ // STRIPE_WEBHOOK_SECRET is unset, so a mock deploy needs no secret to be
24
+ // exercisable end to end.
25
+ pub(crate) const MOCK_STRIPE_WEBHOOK_SECRET: &str = "whsec_mock_checkout_fixed";
26
+
27
+ // The processor a Payment reports, as Payment.Initiate recorded it. Read off
28
+ // the Payment itself, not the connection now in effect — the connection can
29
+ // change between a guest registering and the webhook arriving.
30
+ fn payment_processor(read: &Value, payments: &PaymentsProvider, reference: &str) -> Option<String> {
31
+ instances_for(read, &payments.instance_prefix())
32
+ .into_iter()
33
+ .find(|(id, _)| id == reference)
34
+ .and_then(|(_, payment)| payment.get("processor").and_then(|p| p.get("value")).and_then(|v| v.as_str()).map(String::from))
35
+ }
36
+
37
+ /// Payment statuses whose registration still holds a seat. A registration
38
+ /// with no Payment, or one that's archived, holds none regardless of status.
39
+ const SEAT_HOLDING_PAYMENT_STATUSES: [&str; 4] = ["pending", "succeeded", "refunding", "disputed"];
40
+
41
+ /// The Registration lifecycle state that gives its seat back.
42
+ const ARCHIVED_REGISTRATION_STATUS: &str = "archived";
43
+
44
+ /// Whether a registration's own lifecycle has archived it. A missing or
45
+ /// non-text `status` is active.
46
+ fn registration_archived(registration: &Value) -> bool {
47
+ registration.get("status").and_then(|s| s.as_str()) == Some(ARCHIVED_REGISTRATION_STATUS)
48
+ }
49
+
50
+ /// A reference to an aggregate reads back as its plain id, or wrapped as
51
+ /// `{"value": id}`; an empty id is no reference.
52
+ fn plain_id(raw: Option<&Value>) -> Option<String> {
53
+ let raw = raw?;
54
+ let value = raw.get("value").unwrap_or(raw);
55
+ value.as_str().filter(|id| !id.is_empty()).map(String::from)
56
+ }
57
+
58
+ /// Seats used per event slug: non-archived Registrations whose Payment (same
59
+ /// reference) is in a seat-holding status.
60
+ fn seat_counts(read: &Value, domain: &str, payments: &PaymentsProvider) -> std::collections::HashMap<String, usize> {
61
+ let statuses: std::collections::HashMap<String, String> = instances_for(read, &payments.instance_prefix())
62
+ .into_iter()
63
+ .filter_map(|(id, payment)| payment.get("status").and_then(|s| s.as_str()).map(|s| (id, s.to_string())))
64
+ .collect();
65
+ let mut counts = std::collections::HashMap::new();
66
+ for (id, registration) in instances_for(read, &crate::ir::registrations_binding(domain).registration_prefix()) {
67
+ let holds = statuses.get(&id).is_some_and(|status| SEAT_HOLDING_PAYMENT_STATUSES.contains(&status.as_str()));
68
+ if let (true, false, Some(event_slug)) = (holds, registration_archived(&registration), plain_id(registration.get("event_slug"))) {
69
+ *counts.entry(event_slug).or_insert(0) += 1;
70
+ }
71
+ }
72
+ counts
73
+ }
74
+
75
+ /// How many seats one event has used.
76
+ pub(crate) fn seats_taken(read: &Value, domain: &str, payments: &PaymentsProvider, event_slug: &str) -> usize {
77
+ seat_counts(read, domain, payments).get(event_slug).copied().unwrap_or(0)
78
+ }
79
+
80
+ /// An event's capacity, when it carries a readable one.
81
+ fn event_capacity(event: &Value) -> Option<i64> {
82
+ event.get("capacity").and_then(|c| c.get("value")).and_then(|v| v.as_i64())
83
+ }
84
+
85
+ /// Seats still open given `taken`, never below zero.
86
+ fn open_seats(capacity: i64, taken: usize) -> i64 {
87
+ (capacity - taken as i64).max(0)
88
+ }
89
+
90
+ /// Seats still open on one event, never below zero; `None` when there is no
91
+ /// such event or it carries no readable capacity.
92
+ pub(crate) fn seats_left(read: &Value, domain: &str, payments: &PaymentsProvider, event_slug: &str) -> Option<i64> {
93
+ let events = instances_for(read, &crate::ir::registrations_binding(domain).event_prefix());
94
+ let (_, event) = events.iter().find(|(id, _)| id == event_slug)?;
95
+ Some(open_seats(event_capacity(event)?, seats_taken(read, domain, payments, event_slug)))
96
+ }
97
+
98
+ /// The seat figures a caller reads for one event: `capacity` and `seats_left`
99
+ /// are `null` when the event carries no readable capacity.
100
+ fn seat_figures(event: &Value, taken: usize) -> Value {
101
+ let capacity = event_capacity(event);
102
+ json!({"capacity": capacity, "seats_taken": taken, "seats_left": capacity.map(|c| open_seats(c, taken))})
103
+ }
104
+
105
+ /// The seat figures of every event, keyed by event slug.
106
+ fn all_seat_figures(read: &Value, domain: &str, payments: &PaymentsProvider) -> Value {
107
+ let counts = seat_counts(read, domain, payments);
108
+ let events = instances_for(read, &crate::ir::registrations_binding(domain).event_prefix());
109
+ let figures: serde_json::Map<String, Value> =
110
+ events.iter().map(|(slug, event)| (slug.clone(), seat_figures(event, counts.get(slug).copied().unwrap_or(0)))).collect();
111
+ json!({"events": figures})
112
+ }
113
+
114
+ /// The seat figures of one event, with its `slug`; `None` when there is no such event.
115
+ fn one_seat_figures(read: &Value, domain: &str, payments: &PaymentsProvider, event_slug: &str) -> Option<Value> {
116
+ let events = instances_for(read, &crate::ir::registrations_binding(domain).event_prefix());
117
+ let (_, event) = events.iter().find(|(id, _)| id == event_slug)?;
118
+ let mut figures = seat_figures(event, seats_taken(read, domain, payments, event_slug));
119
+ figures["slug"] = json!(event_slug);
120
+ Some(figures)
121
+ }
122
+
123
+ /// The slug in `/events/<slug>/seats`; a slug is one non-empty path segment.
124
+ fn seats_path_slug(path: &str) -> Option<&str> {
125
+ let slug = path.strip_prefix("/events/")?.strip_suffix("/seats")?;
126
+ (!slug.is_empty() && !slug.contains('/')).then_some(slug)
127
+ }
128
+
129
+ /// GET /events/seats and GET /events/<slug>/seats: the seat rule as a read,
130
+ /// so a site never has to count registrations itself.
131
+ async fn seats_route(path: &str, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, payments: &PaymentsProvider) -> Value {
132
+ let read = match dispatch::read(client, wasm_path).await {
133
+ Ok(r) => r,
134
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
135
+ };
136
+ let body = match seats_path_slug(path) {
137
+ None => all_seat_figures(&read, &config.domain, payments),
138
+ Some(slug) => match one_seat_figures(&read, &config.domain, payments, slug) {
139
+ Some(figures) => figures,
140
+ None => return respond(404, "application/json", &json!({"error": "no such event"}).to_string()),
141
+ },
142
+ };
143
+ respond(200, "application/json", &body.to_string())
144
+ }
145
+
146
+ fn unix_now() -> i64 {
147
+ std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64
148
+ }
149
+
150
+ #[allow(clippy::too_many_arguments)]
151
+ // Serves the checkout, registration-payment and webhook routes only when the
152
+ // domain's IR declares `provides "payments"`. Takes the IR as a parameter,
153
+ // not `ir()`, so the gate can be tested against one that lacks the key.
154
+ #[allow(clippy::too_many_arguments)]
155
+ pub(super) async fn payments_routes(
156
+ domain_ir: Option<&Value>,
157
+ method: &str,
158
+ path: &str,
159
+ raw_body: &str,
160
+ stripe_signature: &str,
161
+ client: &Mutex<Client>,
162
+ wasm_path: &Path,
163
+ config: &LineageConfig,
164
+ invoker: &dyn LambdaInvoker,
165
+ ) -> Option<Value> {
166
+ let payments = domain_ir.and_then(payments_provider)?;
167
+ checkout_route(method, path, raw_body, stripe_signature, client, wasm_path, config, invoker, &payments).await
168
+ }
169
+
170
+ pub(super) async fn checkout_route(
171
+ method: &str,
172
+ path: &str,
173
+ raw_body: &str,
174
+ stripe_signature: &str,
175
+ client: &Mutex<Client>,
176
+ wasm_path: &Path,
177
+ config: &LineageConfig,
178
+ invoker: &dyn LambdaInvoker,
179
+ payments: &PaymentsProvider,
180
+ ) -> Option<Value> {
181
+ match (method, path) {
182
+ ("POST", "/registrations") => {
183
+ Some(registrations_route(raw_body, &payments::PlatformConfig::load().await, client, wasm_path, config, invoker, payments).await)
184
+ }
185
+ // GET /registrations/:id: read-only, re-derives state server-side
186
+ // for the confirmation and pay pages rather than trusting their own
187
+ // query string.
188
+ ("GET", path) if path.starts_with("/registrations/") && !path.ends_with("/complete") => {
189
+ let registration_id = path.trim_start_matches("/registrations/");
190
+ Some(registration_show_route(registration_id, client, wasm_path, config, payments).await)
191
+ }
192
+ // POST /registrations/:id/complete: mock checkout's Pay/Cancel button,
193
+ // refused unless the Payment's own processor is "mock_stripe". Settles
194
+ // through the same PaymentGateway port as POST /webhooks/stripe.
195
+ ("POST", path) if path.starts_with("/registrations/") && path.ends_with("/complete") => {
196
+ let registration_id = path.trim_start_matches("/registrations/").trim_end_matches("/complete").trim_end_matches('/');
197
+ Some(registration_complete_route(registration_id, raw_body, client, wasm_path, config, invoker, payments).await)
198
+ }
199
+ ("POST", "/webhooks/stripe") => {
200
+ Some(webhook_route(raw_body, stripe_signature, &payments::PlatformConfig::load().await, client, wasm_path, config, invoker, payments).await)
201
+ }
202
+ // POST /events: a mock-payments service schedules a new session
203
+ // when an editor picks a date. Idempotent — Event.find first means
204
+ // a repeat call for the same slug is a no-op, not an error.
205
+ ("GET", "/events/seats") => Some(seats_route(path, client, wasm_path, config, payments).await),
206
+ ("GET", path) if seats_path_slug(path).is_some() => Some(seats_route(path, client, wasm_path, config, payments).await),
207
+ ("POST", "/events") => Some(events_route(raw_body, client, wasm_path, config, invoker).await),
208
+ _ => None,
209
+ }
210
+ }
211
+
212
+ /// POST /events. Dispatches `<domain>::Event.Schedule` — Event is this
213
+ /// deploy's own top-level aggregate, not a vendored chapter like Payments.
214
+ async fn events_route(raw_body: &str, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, invoker: &dyn LambdaInvoker) -> Value {
215
+ let body: Value = match serde_json::from_str(raw_body) {
216
+ Ok(v) => v,
217
+ Err(e) => return respond(400, "application/json", &json!({"error": format!("invalid JSON: {e}")}).to_string()),
218
+ };
219
+ let Some(slug) = body.get("slug").and_then(|v| v.as_str()) else {
220
+ return respond(400, "application/json", &json!({"error": "missing slug"}).to_string());
221
+ };
222
+
223
+ let read = match dispatch::read(client, wasm_path).await {
224
+ Ok(r) => r,
225
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
226
+ };
227
+ let events = instances_for(&read, &crate::ir::registrations_binding(&config.domain).event_prefix());
228
+ if let Some((_, existing)) = events.iter().find(|(id, _)| id == slug) {
229
+ return respond(200, "application/json", &serde_json::to_string_pretty(&with_id(slug, existing)).unwrap_or_default());
230
+ }
231
+
232
+ let Some(name) = body.get("name").and_then(|v| v.as_str()) else {
233
+ return respond(400, "application/json", &json!({"error": "missing name"}).to_string());
234
+ };
235
+ let Some(price_cents) = body.get("price_cents").and_then(|v| v.as_i64()) else {
236
+ return respond(400, "application/json", &json!({"error": "missing price_cents"}).to_string());
237
+ };
238
+ let Some(capacity) = body.get("capacity").and_then(|v| v.as_i64()) else {
239
+ return respond(400, "application/json", &json!({"error": "missing capacity"}).to_string());
240
+ };
241
+
242
+ let args = json!({
243
+ "slug": {"value": slug},
244
+ "name": {"value": name},
245
+ "price": {"cents": price_cents},
246
+ "capacity": {"value": capacity},
247
+ });
248
+ let verb = crate::ir::registrations_binding(&config.domain).schedule;
249
+ let outcome = match dispatch::handle(client, wasm_path, &verb, args, None, config, invoker).await {
250
+ Ok(o) => o,
251
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
252
+ };
253
+ if !outcome.accepted {
254
+ return respond(422, "application/json", &last_refusal(&outcome.result).to_string());
255
+ }
256
+
257
+ let read = match dispatch::read(client, wasm_path).await {
258
+ Ok(r) => r,
259
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
260
+ };
261
+ let events = instances_for(&read, &crate::ir::registrations_binding(&config.domain).event_prefix());
262
+ let Some((_, event)) = events.iter().find(|(id, _)| id == slug) else {
263
+ return respond(500, "text/plain", "event vanished immediately after being scheduled");
264
+ };
265
+ respond(201, "application/json", &serde_json::to_string_pretty(&with_id(slug, event)).unwrap_or_default())
266
+ }
267
+
268
+ /// GET /registrations/:id: event and attendee fields, plus amount_cents and
269
+ /// payment_status from the same-reference Payment.
270
+ async fn registration_show_route(registration_id: &str, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, payments: &PaymentsProvider) -> Value {
271
+ let read = match dispatch::read(client, wasm_path).await {
272
+ Ok(r) => r,
273
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
274
+ };
275
+ let registrations = instances_for(&read, &crate::ir::registrations_binding(&config.domain).registration_prefix());
276
+ let Some((_, registration)) = registrations.iter().find(|(id, _)| id == registration_id) else {
277
+ return respond(404, "application/json", &json!({"error": "no such registration"}).to_string());
278
+ };
279
+ let event_slug = registration.get("event_slug").and_then(|v| v.as_str());
280
+ let events = instances_for(&read, &crate::ir::registrations_binding(&config.domain).event_prefix());
281
+ let event = event_slug.and_then(|slug| events.iter().find(|(id, _)| id == slug)).map(|(_, e)| e);
282
+
283
+ let payment_instances = instances_for(&read, &payments.instance_prefix());
284
+ let payment = payment_instances.iter().find(|(id, _)| id == registration_id).map(|(_, p)| p);
285
+
286
+ let attendee = registration.get("attendee").cloned().unwrap_or_else(|| json!({}));
287
+ respond(200, "application/json", &json!({
288
+ "registration_id": registration_id,
289
+ "event": {
290
+ "slug": event_slug,
291
+ "name": event.and_then(|e| e.get("name")).and_then(|n| n.get("value")).and_then(|v| v.as_str()),
292
+ },
293
+ "attendee": {
294
+ "first_name": attendee.get("first_name"),
295
+ "last_name": attendee.get("last_name"),
296
+ "email": attendee.get("email"),
297
+ "phone": attendee.get("phone"),
298
+ },
299
+ "amount_cents": payment.and_then(|p| p.get("amount")).and_then(|a| a.get("cents")),
300
+ "payment_status": payment.and_then(|p| p.get("status")),
301
+ }).to_string())
302
+ }
303
+
304
+ /// POST /registrations/:id/complete. Refused unless the Payment itself was
305
+ /// initiated on "mock_stripe"; settles through the same PaymentGateway port webhook_route uses.
306
+ pub(crate) async fn registration_complete_route(
307
+ registration_id: &str,
308
+ raw_body: &str,
309
+ client: &Mutex<Client>,
310
+ wasm_path: &Path,
311
+ config: &LineageConfig,
312
+ invoker: &dyn LambdaInvoker,
313
+ payments: &PaymentsProvider,
314
+ ) -> Value {
315
+ let body: Value = match serde_json::from_str(raw_body) {
316
+ Ok(v) => v,
317
+ Err(e) => return respond(400, "application/json", &json!({"error": format!("invalid JSON: {e}")}).to_string()),
318
+ };
319
+ let Some(outcome) = body.get("outcome").and_then(|v| v.as_str()) else {
320
+ return respond(400, "application/json", &json!({"error": "missing outcome"}).to_string());
321
+ };
322
+ if outcome != "succeeded" && outcome != "failed" {
323
+ return respond(400, "application/json", &json!({"error": "outcome must be \"succeeded\" or \"failed\""}).to_string());
324
+ }
325
+
326
+ let read = match dispatch::read(client, wasm_path).await {
327
+ Ok(r) => r,
328
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
329
+ };
330
+ let registrations = instances_for(&read, &crate::ir::registrations_binding(&config.domain).registration_prefix());
331
+ if !registrations.iter().any(|(id, _)| id == registration_id) {
332
+ return respond(404, "application/json", &json!({"error": "no such registration"}).to_string());
333
+ }
334
+
335
+ let processor = match payment_processor(&read, payments, registration_id) {
336
+ Some(processor) if processor == "mock_stripe" => processor,
337
+ _ => return respond(403, "application/json", &json!({"error": "not available with a real payment processor"}).to_string()),
338
+ };
339
+ let reported_processor = json!({"value": processor});
340
+ // `reference_to Payment, as: :reference` keeps `reference` inside `with:`
341
+ // too — `to:` alone isn't enough, the argument parser still expects it
342
+ // present in facts or it TypeMismatches.
343
+ let reference_fact = json!({"value": registration_id});
344
+ let (verb, facts) = if outcome == "succeeded" {
345
+ (
346
+ payments.succeeded.as_str(),
347
+ json!({"reference": reference_fact, "transaction_id": {"value": format!("local_{}", uuid::Uuid::new_v4().simple())}, "reported_processor": reported_processor}),
348
+ )
349
+ } else {
350
+ (
351
+ payments.failed.as_str(),
352
+ json!({"reference": reference_fact, "reason": {"value": "declined_at_local_checkout"}, "reported_processor": reported_processor}),
353
+ )
354
+ };
355
+ let outcome_result = match dispatch::handle_routed(client, wasm_path, verb, json!(registration_id), facts, None, config, invoker).await {
356
+ Ok(o) => o,
357
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
358
+ };
359
+ // A repeat Succeeded/Failed on an already-settled Payment is a silent
360
+ // no-op on Payment's own `given`, so it isn't surfaced as a 422 here
361
+ // either.
362
+ if !outcome_result.accepted {
363
+ let refusal = last_refusal(&outcome_result.result);
364
+ let already_settled = refusal.get("error").and_then(|v| v.as_str()).map(|s| s.contains("pending")).unwrap_or(false);
365
+ if !already_settled {
366
+ return respond(422, "application/json", &refusal.to_string());
367
+ }
368
+ }
369
+
370
+ let read = match dispatch::read(client, wasm_path).await {
371
+ Ok(r) => r,
372
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
373
+ };
374
+ let payment_instances = instances_for(&read, &payments.instance_prefix());
375
+ let status = payment_instances.iter().find(|(id, _)| id == registration_id).and_then(|(_, p)| p.get("status")).and_then(|v| v.as_str()).unwrap_or("");
376
+ respond(200, "application/json", &json!({"registration_id": registration_id, "payment_status": status}).to_string())
377
+ }
378
+
379
+ // Attendee shape varies per domain, so this forwards the caller's fields
380
+ // through verbatim rather than hardcoding one shape; each domain's own
381
+ // given/invariant checks remain the real validation. Only `event_slug` and
382
+ // `return_to` (routing metadata, never part of Attendee) are stripped.
383
+ fn attendee_from(body: &Value) -> Value {
384
+ let mut attendee = body.clone();
385
+ if let Some(object) = attendee.as_object_mut() {
386
+ object.remove("event_slug");
387
+ object.remove("return_to");
388
+ }
389
+ attendee
390
+ }
391
+
392
+ // Payment's own Client value object wants a single `name`, so a caller
393
+ // sending `first_name`/`last_name` instead gets a composed display name
394
+ // rather than this route hardcoding one shape.
395
+ fn display_name_from(body: &Value) -> Option<String> {
396
+ if let Some(name) = body.get("name").and_then(|v| v.as_str()) {
397
+ return Some(name.to_string());
398
+ }
399
+ let first = body.get("first_name").and_then(|v| v.as_str())?;
400
+ let last = body.get("last_name").and_then(|v| v.as_str())?;
401
+ Some(format!("{first} {last}"))
402
+ }
403
+
404
+ // POST /registrations. Payment first, then Registration, sharing one
405
+ // reference minted here; `role: None` throughout since the site calls in
406
+ // server-to-server, not as a signed-in user.
407
+ #[allow(clippy::too_many_arguments)]
408
+ pub(crate) async fn registrations_route(
409
+ raw_body: &str,
410
+ platform: &payments::PlatformConfig,
411
+ client: &Mutex<Client>,
412
+ wasm_path: &Path,
413
+ config: &LineageConfig,
414
+ invoker: &dyn LambdaInvoker,
415
+ payments: &PaymentsProvider,
416
+ ) -> Value {
417
+ let body: Value = match serde_json::from_str(raw_body) {
418
+ Ok(v) => v,
419
+ Err(e) => return respond(400, "application/json", &json!({"error": format!("invalid JSON: {e}")}).to_string()),
420
+ };
421
+ let Some(event_slug) = body.get("event_slug").and_then(|v| v.as_str()) else {
422
+ return respond(400, "application/json", &json!({"error": "missing event_slug"}).to_string());
423
+ };
424
+ // Cheap early exits before dispatching Payment.Initiate — full Attendee
425
+ // validation still happens downstream in Registration.Request's own
426
+ // given/invariant checks.
427
+ let Some(name) = display_name_from(&body) else {
428
+ return respond(400, "application/json", &json!({"error": "missing name (or first_name and last_name)"}).to_string());
429
+ };
430
+ let Some(email) = body.get("email").and_then(|v| v.as_str()) else {
431
+ return respond(400, "application/json", &json!({"error": "missing email"}).to_string());
432
+ };
433
+
434
+ let read = match dispatch::read(client, wasm_path).await {
435
+ Ok(r) => r,
436
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
437
+ };
438
+ let events = instances_for(&read, &crate::ir::registrations_binding(&config.domain).event_prefix());
439
+ let Some((_, event)) = events.iter().find(|(id, _)| id == event_slug) else {
440
+ return respond(404, "application/json", &json!({"error": "no such event"}).to_string());
441
+ };
442
+ if event.get("status").and_then(|v| v.as_str()) != Some("open") {
443
+ return respond(422, "application/json", &json!({"error": "registration is closed for this event"}).to_string());
444
+ }
445
+ // A full event refuses before any Stripe call or write. An event whose
446
+ // capacity cannot be read is not blocked: capacity is required when an
447
+ // event is scheduled, so its absence means nothing to enforce.
448
+ if seats_left(&read, &config.domain, payments, event_slug) == Some(0) {
449
+ return respond(409, "application/json", &json!({"error": "this event is full"}).to_string());
450
+ }
451
+ let price_cents = event.get("price").and_then(|p| p.get("cents")).and_then(|v| v.as_i64()).unwrap_or(0);
452
+ let event_name = event.get("name").and_then(|n| n.get("value")).and_then(|v| v.as_str()).unwrap_or("");
453
+
454
+ // What checkout does is decided per request from this tenant's own
455
+ // PaymentConnection, before anything is written — a paused connection
456
+ // must never fall back to the mock walkthrough.
457
+ let plan = payments::checkout_plan(payments::connection(&read, &config.domain).as_ref(), platform);
458
+ let processor = match plan {
459
+ payments::CheckoutPlan::Mock => "mock_stripe",
460
+ payments::CheckoutPlan::Stripe { .. } => "stripe",
461
+ payments::CheckoutPlan::Paused => {
462
+ return respond(503, "application/json", &json!({"error": "payments are temporarily unavailable"}).to_string());
463
+ }
464
+ };
465
+ let site_url = platform.site_url.as_str();
466
+
467
+ let reference = uuid::Uuid::new_v4().to_string();
468
+
469
+ // A Stripe plan opens its embedded session before anything is written, so
470
+ // a failure leaves no Payment or Registration behind. If a domain refusal
471
+ // follows, the unused session simply expires.
472
+ let embedded_checkout = if let payments::CheckoutPlan::Stripe { api_key, publishable_key } = &plan {
473
+ let auth = checkout::StripeAuth { api_key, base_url: &platform.api_base };
474
+ match checkout::create_checkout_session(&auth, price_cents, event_name, &reference, checkout::session_expires_at(unix_now())).await {
475
+ // Stripe.js is opened with the publishable key; the answer carries
476
+ // no `checkout_url`.
477
+ Ok(session) => Some(json!({
478
+ "client_secret": session.client_secret,
479
+ "publishable_key": publishable_key,
480
+ "session_id": session.session_id,
481
+ })),
482
+ Err(e) => {
483
+ eprintln!("registrations: opening the Stripe session failed, nothing was recorded: {e:#}");
484
+ return respond(502, "application/json", &json!({"error": "payments are temporarily unavailable"}).to_string());
485
+ }
486
+ }
487
+ } else {
488
+ None
489
+ };
490
+
491
+ let initiate_args = json!({
492
+ "reference": {"value": reference},
493
+ "processor": {"value": processor},
494
+ "payment_type": {"value": "card"},
495
+ "amount": {"cents": price_cents},
496
+ "client": {"name": name, "email": email},
497
+ });
498
+ let outcome = match dispatch::handle(client, wasm_path, &payments.initiate, initiate_args, None, config, invoker).await {
499
+ Ok(o) => o,
500
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
501
+ };
502
+ if !outcome.accepted {
503
+ return respond(422, "application/json", &last_refusal(&outcome.result).to_string());
504
+ }
505
+
506
+ let mut request_args = json!({
507
+ "event_slug": event_slug,
508
+ "registration_id": {"value": reference},
509
+ "attendee": attendee_from(&body),
510
+ });
511
+ // A reaction reads only fields its event carries, never ones nested
512
+ // inside `attendee`, so newsletter fields are declared flat on
513
+ // Registration.Request; a command refuses args it doesn't declare.
514
+ let news_signup = body.get("news_signup").and_then(|v| v.as_bool()).unwrap_or(false);
515
+ let binding = crate::ir::registrations_binding(&config.domain);
516
+ let forwards_newsletter = crate::ir::ir().is_some_and(|ir| {
517
+ binding.request_target().is_some_and(|(aggregate, command)| crate::ir::command_declares(ir, aggregate, command, "news_signup"))
518
+ });
519
+ if forwards_newsletter {
520
+ request_args["news_signup"] = json!(news_signup);
521
+ request_args["email"] = json!(email);
522
+ for field in ["first_name", "last_name"] {
523
+ if let Some(value) = body.get(field) {
524
+ request_args[field] = value.clone();
525
+ }
526
+ }
527
+ }
528
+ let request_verb = crate::ir::registrations_binding(&config.domain).request;
529
+ let outcome = match dispatch::handle(client, wasm_path, &request_verb, request_args, None, config, invoker).await {
530
+ Ok(o) => o,
531
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
532
+ };
533
+ if !outcome.accepted {
534
+ return respond(422, "application/json", &last_refusal(&outcome.result).to_string());
535
+ }
536
+
537
+ // The domain's reaction has already subscribed the address by now; a
538
+ // registrant who ticked the box gets the same signed confirm link as the
539
+ // footer form. Best effort: the registration stands whatever mail does.
540
+ if forwards_newsletter && news_signup {
541
+ super::newsletter::send_confirmation_if_pending(email, client, wasm_path).await;
542
+ }
543
+
544
+ if let Some(embedded) = embedded_checkout {
545
+ return respond(200, "application/json", &json!({"registration_id": reference, "embedded_checkout": embedded}).to_string());
546
+ }
547
+
548
+ // `return_to` is guest-supplied and never trusted raw — only a same-site
549
+ // path (one leading slash, not two) is kept, since e.g. "//evil.example"
550
+ // would otherwise be an open redirect; anything else falls back to "/".
551
+ let return_to = match body.get("return_to").and_then(|v| v.as_str()) {
552
+ Some(path) if path.starts_with('/') && !path.starts_with("//") => path.to_string(),
553
+ _ => "/".to_string(),
554
+ };
555
+ let confirm_query = |outcome: &str| -> String {
556
+ let mut url = reqwest::Url::parse("http://placeholder.invalid/").unwrap();
557
+ url.query_pairs_mut()
558
+ .append_pair("registration_id", &reference)
559
+ .append_pair("outcome", outcome)
560
+ .append_pair("return_to", &return_to);
561
+ url.query().unwrap_or("").to_string()
562
+ };
563
+ let success_url = format!("{site_url}/registration-confirmed.html?{}", confirm_query("succeeded"));
564
+ let cancel_url = format!("{site_url}/registration-confirmed.html?{}", confirm_query("cancelled"));
565
+
566
+ // A tenant with no connection, or a disabled one, is on the mock
567
+ // walkthrough, never a misconfiguration to refuse. success_url/cancel_url
568
+ // belong to this walkthrough only — an embedded session redirects nowhere.
569
+ let checkout_url = checkout::mock_checkout_session(&reference, &success_url, &cancel_url, site_url);
570
+ respond(200, "application/json", &json!({"checkout_url": checkout_url, "registration_id": reference}).to_string())
571
+ }
572
+
573
+ // The answer for an event that only the public mock secret could vouch for.
574
+ fn mock_secret_refused() -> Value {
575
+ respond(
576
+ 500,
577
+ "application/json",
578
+ &json!({"error": "STRIPE_WEBHOOK_SECRET is required to accept events from a real payment processor -- \
579
+ refusing to trust the publicly-known mock webhook secret"})
580
+ .to_string(),
581
+ )
582
+ }
583
+
584
+ /// POST /webhooks/stripe. Verifies the signature against
585
+ /// `STRIPE_WEBHOOK_SECRET`, a saved signing secret, or — only while neither
586
+ /// is set — the public mock secret, refusing anything only a real processor
587
+ /// could have sent.
588
+ pub(crate) async fn webhook_route(
589
+ raw_body: &str,
590
+ signature_header: &str,
591
+ platform: &payments::PlatformConfig,
592
+ client: &Mutex<Client>,
593
+ wasm_path: &Path,
594
+ config: &LineageConfig,
595
+ invoker: &dyn LambdaInvoker,
596
+ payments: &PaymentsProvider,
597
+ ) -> Value {
598
+ let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
599
+ // The environment's signing secret and any saved with the business's own
600
+ // keys are all accepted; with none configured only the mock secret is.
601
+ let configured = platform.webhook_secrets();
602
+ if configured.is_empty() && platform.has_real_credentials() {
603
+ // A real key with no signing secret to check events against: the public
604
+ // mock secret must not stand in for it.
605
+ return mock_secret_refused();
606
+ }
607
+ let candidates = if configured.is_empty() { vec![MOCK_STRIPE_WEBHOOK_SECRET] } else { configured };
608
+ let mut verification = Ok(());
609
+ for secret in &candidates {
610
+ verification = checkout::verify_signature(raw_body, signature_header, secret, now);
611
+ if verification.is_ok() {
612
+ break;
613
+ }
614
+ }
615
+ if let Err(e) = verification {
616
+ return respond(400, "text/plain", &e.to_string());
617
+ }
618
+ let event: Value = match serde_json::from_str(raw_body) {
619
+ Ok(v) => v,
620
+ Err(_) => return respond(400, "text/plain", "invalid JSON"),
621
+ };
622
+
623
+ let read = match dispatch::read(client, wasm_path).await {
624
+ Ok(r) => r,
625
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
626
+ };
627
+ // The business's own endpoint delivers events with no `account`; one that
628
+ // names an account is some other account's, acknowledged and ignored.
629
+ if event.get("account").and_then(|v| v.as_str()).is_some() {
630
+ return respond(200, "text/plain", "");
631
+ }
632
+ let fallback_secret = platform.webhook_secrets().is_empty();
633
+ let refuse_fallback = mock_secret_refused;
634
+
635
+ let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
636
+ let object = event.get("data").and_then(|d| d.get("object")).cloned().unwrap_or_else(|| json!({}));
637
+ let reference = object.get("metadata").and_then(|m| m.get("registration_id")).and_then(|v| v.as_str()).map(String::from);
638
+
639
+ if let Some(reference) = reference {
640
+ // Defaults to "stripe" when there's no matching Payment.
641
+ let processor = payment_processor(&read, payments, &reference).unwrap_or_else(|| "stripe".to_string());
642
+ if fallback_secret && processor != "mock_stripe" {
643
+ return refuse_fallback();
644
+ }
645
+ let reported_processor = json!({"value": processor});
646
+ // The kernel refuses a flat `{"reference": ...}` for an
647
+ // aggregate-scoped port operation — `to` is the bare reference
648
+ // string, and `reference` stays inside `with:` too, since the
649
+ // argument parser still expects to find it in facts.
650
+ let reference_fact = json!({"value": reference.clone()});
651
+ let verb_and_facts = match event_type {
652
+ "checkout.session.completed" => {
653
+ // The PaymentIntent id when one exists, else the Checkout
654
+ // Session's own id — `.get`, not a panic, since a synthetic
655
+ // test payload carries no payment_intent at all.
656
+ let transaction_id = object.get("payment_intent").and_then(|v| v.as_str())
657
+ .or_else(|| object.get("id").and_then(|v| v.as_str()))
658
+ .unwrap_or("")
659
+ .to_string();
660
+ Some((payments.succeeded.as_str(), json!({
661
+ "reference": reference_fact,
662
+ "transaction_id": {"value": transaction_id},
663
+ "reported_processor": reported_processor,
664
+ })))
665
+ }
666
+ "checkout.session.expired" => Some((payments.failed.as_str(), json!({
667
+ "reference": reference_fact,
668
+ "reason": {"value": "checkout_expired"},
669
+ "reported_processor": reported_processor,
670
+ }))),
671
+ _ => None,
672
+ };
673
+
674
+ if let Some((verb, facts)) = verb_and_facts {
675
+ // A refusal here (e.g. a redelivered webhook — Stripe's delivery
676
+ // is at-least-once) is a benign no-op: the payment already holds
677
+ // the right status, and 200 tells Stripe's retry logic to stop.
678
+ if let Err(e) = dispatch::handle_routed(client, wasm_path, verb, json!(reference), facts, None, config, invoker).await {
679
+ return respond(500, "text/plain", &format!("{e:#}"));
680
+ }
681
+ }
682
+ }
683
+
684
+ respond(200, "text/plain", "")
685
+ }
686
+
687
+ #[cfg(test)]
688
+ mod tests {
689
+ use super::*;
690
+ use crate::lambda_client;
691
+ use crate::web::tests::{provision_lineage, scratch_db};
692
+
693
+ fn checkout_config(era: i32) -> LineageConfig {
694
+ LineageConfig { domain: "CheckoutFixture".to_string(), era: Some(era), mirrored: None }
695
+ }
696
+
697
+ fn checkout_wasm_path() -> std::path::PathBuf {
698
+ std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dist/checkout_fixture.wasm")
699
+ }
700
+
701
+ async fn schedule_event(client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, slug: &str, price_cents: i64) {
702
+ let args = json!({
703
+ "slug": {"value": slug}, "name": {"value": "Sample Studio"},
704
+ "price": {"cents": price_cents}, "capacity": {"value": 20},
705
+ });
706
+ let outcome = dispatch::handle(client, wasm_path, "CheckoutFixture::Event.Schedule", args, None, config, &lambda_client::NeverInvoker)
707
+ .await
708
+ .unwrap();
709
+ assert!(outcome.accepted, "scheduling the fixture event should succeed: {:?}", outcome.result);
710
+ }
711
+
712
+ fn sign_stripe_header(secret: &str, now: i64, payload: &str) -> String {
713
+ use hmac::{Hmac, Mac};
714
+ use sha2::Sha256;
715
+ let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
716
+ mac.update(format!("{now}.{payload}").as_bytes());
717
+ format!("t={now},v1={}", mac.finalize().into_bytes().iter().map(|b| format!("{b:02x}")).collect::<String>())
718
+ }
719
+
720
+ fn now_secs() -> i64 {
721
+ std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64
722
+ }
723
+
724
+ // A read of the domain: one `yoga` event of `capacity` seats, one `other`
725
+ // event, and a Registration plus same-reference Payment per row, each as
726
+ // (reference, event slug, payment status).
727
+ fn read_with(capacity: i64, rows: &[(&str, &str, &str)]) -> Value {
728
+ let mut instances = serde_json::Map::new();
729
+ instances.insert("CheckoutFixture::Event#yoga".into(), json!({"capacity": {"value": capacity}}));
730
+ instances.insert("CheckoutFixture::Event#other".into(), json!({"capacity": {"value": 5}}));
731
+ for (reference, event, status) in rows {
732
+ instances.insert(format!("CheckoutFixture::Registration#{reference}"), json!({"event_slug": event}));
733
+ instances.insert(format!("Payments::Payment#{reference}"), json!({"status": status}));
734
+ }
735
+ json!({"instances": instances})
736
+ }
737
+
738
+ #[test]
739
+ fn a_registration_holds_a_seat_while_its_payment_is_open_paid_or_being_settled() {
740
+ let payments = crate::ir::fixture_payments();
741
+ let read = read_with(
742
+ 10,
743
+ &[("a", "yoga", "pending"), ("b", "yoga", "succeeded"), ("c", "yoga", "refunding"), ("d", "yoga", "disputed")],
744
+ );
745
+ assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 4);
746
+ assert_eq!(seats_left(&read, "CheckoutFixture", &payments, "yoga"), Some(6));
747
+ }
748
+
749
+ #[test]
750
+ fn a_failed_refunded_or_charged_back_payment_gives_its_seat_back() {
751
+ let payments = crate::ir::fixture_payments();
752
+ let read = read_with(
753
+ 3,
754
+ &[("a", "yoga", "failed"), ("b", "yoga", "refunded"), ("c", "yoga", "charged_back"), ("d", "yoga", "succeeded")],
755
+ );
756
+ assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 1);
757
+ assert_eq!(seats_left(&read, "CheckoutFixture", &payments, "yoga"), Some(2));
758
+ }
759
+
760
+ #[test]
761
+ fn seats_are_counted_per_event_and_a_registration_without_a_payment_holds_none() {
762
+ let payments = crate::ir::fixture_payments();
763
+ let mut read = read_with(4, &[("a", "yoga", "succeeded"), ("b", "other", "succeeded")]);
764
+ read["instances"]["CheckoutFixture::Registration#orphan"] = json!({"event_slug": "yoga"});
765
+ assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 1, "only this event's registrations, and only those with a payment");
766
+ assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "other"), 1);
767
+ }
768
+
769
+ #[test]
770
+ fn an_archived_registration_gives_its_seat_back_whatever_its_payment_says() {
771
+ let payments = crate::ir::fixture_payments();
772
+ let mut read = read_with(3, &[("a", "yoga", "succeeded"), ("b", "yoga", "pending"), ("c", "yoga", "succeeded")]);
773
+ assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 3);
774
+ read["instances"]["CheckoutFixture::Registration#a"]["status"] = json!("archived");
775
+ assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 2, "a paid but archived registration frees its seat");
776
+ assert_eq!(seats_left(&read, "CheckoutFixture", &payments, "yoga"), Some(1));
777
+ }
778
+
779
+ #[test]
780
+ fn a_registration_with_no_status_counts_as_active() {
781
+ let payments = crate::ir::fixture_payments();
782
+ let mut read = read_with(5, &[("a", "yoga", "succeeded"), ("b", "yoga", "succeeded")]);
783
+ // `a` carries no status at all (a domain without the lifecycle, or data
784
+ // from before it existed); `b` says it is active.
785
+ read["instances"]["CheckoutFixture::Registration#b"]["status"] = json!("active");
786
+ assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 2);
787
+ read["instances"]["CheckoutFixture::Registration#a"]["status"] = json!(null);
788
+ assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 2, "a null status is not archived either");
789
+ }
790
+
791
+ #[test]
792
+ fn a_restored_registration_takes_its_seat_back() {
793
+ let payments = crate::ir::fixture_payments();
794
+ let mut read = read_with(2, &[("a", "yoga", "succeeded")]);
795
+ read["instances"]["CheckoutFixture::Registration#a"]["status"] = json!("archived");
796
+ assert_eq!(seats_left(&read, "CheckoutFixture", &payments, "yoga"), Some(2));
797
+ read["instances"]["CheckoutFixture::Registration#a"]["status"] = json!("active");
798
+ assert_eq!(seats_left(&read, "CheckoutFixture", &payments, "yoga"), Some(1));
799
+ }
800
+
801
+ #[test]
802
+ fn seats_left_with_mixed_statuses_counts_only_the_active_holders() {
803
+ let payments = crate::ir::fixture_payments();
804
+ let mut read = read_with(
805
+ 6,
806
+ &[("a", "yoga", "succeeded"), ("b", "yoga", "succeeded"), ("c", "yoga", "pending"), ("d", "yoga", "failed"), ("e", "other", "succeeded")],
807
+ );
808
+ read["instances"]["CheckoutFixture::Registration#a"]["status"] = json!("archived");
809
+ read["instances"]["CheckoutFixture::Registration#b"]["status"] = json!("active");
810
+ read["instances"]["CheckoutFixture::Registration#d"]["status"] = json!("active");
811
+ read["instances"]["CheckoutFixture::Registration#e"]["status"] = json!("archived");
812
+ // b (active, paid) and c (no status, pending) hold; a is archived, d's
813
+ // payment failed, and e is another event's.
814
+ assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 2);
815
+ assert_eq!(seats_left(&read, "CheckoutFixture", &payments, "yoga"), Some(4));
816
+ assert_eq!(seats_left(&read, "CheckoutFixture", &payments, "other"), Some(5), "the archived registration on `other` frees its seat");
817
+ }
818
+
819
+ #[test]
820
+ fn seats_left_never_goes_below_zero_and_is_none_without_an_event_or_capacity() {
821
+ let payments = crate::ir::fixture_payments();
822
+ let oversold = read_with(1, &[("a", "yoga", "succeeded"), ("b", "yoga", "succeeded")]);
823
+ assert_eq!(seats_left(&oversold, "CheckoutFixture", &payments, "yoga"), Some(0));
824
+ assert_eq!(seats_left(&oversold, "CheckoutFixture", &payments, "nowhere"), None);
825
+ let no_capacity = json!({"instances": {"CheckoutFixture::Event#yoga": {"name": {"value": "Yoga"}}}});
826
+ assert_eq!(seats_left(&no_capacity, "CheckoutFixture", &payments, "yoga"), None);
827
+ }
828
+
829
+ // The site's own JavaScript tests of this rule (its seat-count spec) use the
830
+ // fixtures below; the same cases run here so the two counts cannot drift.
831
+ const HOLDING: [&str; 4] = ["pending", "succeeded", "refunding", "disputed"];
832
+ const FREEING: [&str; 3] = ["failed", "refunded", "charged_back"];
833
+
834
+ fn set_registration_status(read: &mut Value, reference: &str, status: &str) {
835
+ read["instances"][format!("CheckoutFixture::Registration#{reference}")]["status"] = json!(status);
836
+ }
837
+
838
+ #[test]
839
+ fn the_seat_holding_table_is_exactly_pending_succeeded_refunding_and_disputed() {
840
+ let mut table = SEAT_HOLDING_PAYMENT_STATUSES;
841
+ table.sort_unstable();
842
+ let mut expected = HOLDING;
843
+ expected.sort_unstable();
844
+ assert_eq!(table, expected);
845
+ }
846
+
847
+ #[test]
848
+ fn every_holding_status_holds_one_seat_and_every_freeing_status_holds_none() {
849
+ let payments = crate::ir::fixture_payments();
850
+ for status in HOLDING {
851
+ let read = read_with(10, &[("r", "yoga", status)]);
852
+ assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 1, "{status} should hold a seat");
853
+ }
854
+ for status in FREEING {
855
+ let read = read_with(10, &[("r", "yoga", status)]);
856
+ assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 0, "{status} should free the seat");
857
+ }
858
+ }
859
+
860
+ #[test]
861
+ fn a_reference_reads_as_a_plain_id_or_a_wrapped_value_and_counts_per_event() {
862
+ let payments = crate::ir::fixture_payments();
863
+ let mut read = read_with(
864
+ 10,
865
+ &[("a", "yoga", "succeeded"), ("b", "yoga", "pending"), ("c", "other", "pending"), ("d", "yoga", "failed")],
866
+ );
867
+ read["instances"]["CheckoutFixture::Registration#b"]["event_slug"] = json!({"value": "yoga"});
868
+ assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 2);
869
+ assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "other"), 1);
870
+ read["instances"]["CheckoutFixture::Registration#a"]["event_slug"] = json!("");
871
+ assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 1, "an empty event id is no reference");
872
+ }
873
+
874
+ #[test]
875
+ fn a_payment_with_no_registration_is_ignored_and_a_missing_or_empty_read_counts_nothing() {
876
+ let payments = crate::ir::fixture_payments();
877
+ let mut read = read_with(5, &[]);
878
+ read["instances"]["CheckoutFixture::Registration#lonely"] = json!({"event_slug": "yoga"});
879
+ read["instances"]["Payments::Payment#orphan"] = json!({"status": "succeeded"});
880
+ assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 0);
881
+ for empty in [json!({}), json!({"instances": {}}), Value::Null] {
882
+ assert_eq!(seats_taken(&empty, "CheckoutFixture", &payments, "yoga"), 0);
883
+ assert!(seat_counts(&empty, "CheckoutFixture", &payments).is_empty());
884
+ }
885
+ }
886
+
887
+ #[test]
888
+ fn an_archived_registration_frees_its_seat_whichever_holding_status_its_payment_has() {
889
+ let payments = crate::ir::fixture_payments();
890
+ for status in HOLDING {
891
+ let mut read = read_with(10, &[("r", "yoga", status)]);
892
+ set_registration_status(&mut read, "r", "archived");
893
+ assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 0, "archived + {status} should not hold a seat");
894
+ }
895
+ }
896
+
897
+ #[test]
898
+ fn archiving_one_of_several_guests_frees_exactly_one_seat() {
899
+ let payments = crate::ir::fixture_payments();
900
+ let mut read = read_with(10, &[("a", "yoga", "succeeded"), ("b", "yoga", "succeeded"), ("c", "yoga", "pending")]);
901
+ set_registration_status(&mut read, "a", "active");
902
+ set_registration_status(&mut read, "b", "archived");
903
+ set_registration_status(&mut read, "c", "active");
904
+ assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 2);
905
+ }
906
+
907
+ #[test]
908
+ fn seat_figures_carry_capacity_taken_and_left_and_null_without_a_capacity() {
909
+ let payments = crate::ir::fixture_payments();
910
+ let read = read_with(3, &[("a", "yoga", "succeeded"), ("b", "yoga", "pending"), ("c", "yoga", "succeeded"), ("d", "yoga", "succeeded")]);
911
+ assert_eq!(one_seat_figures(&read, "CheckoutFixture", &payments, "yoga"), Some(json!({"slug": "yoga", "capacity": 3, "seats_taken": 4, "seats_left": 0})));
912
+ assert_eq!(one_seat_figures(&read, "CheckoutFixture", &payments, "nowhere"), None);
913
+ assert_eq!(
914
+ all_seat_figures(&read, "CheckoutFixture", &payments),
915
+ json!({"events": {
916
+ "yoga": {"capacity": 3, "seats_taken": 4, "seats_left": 0},
917
+ "other": {"capacity": 5, "seats_taken": 0, "seats_left": 5},
918
+ }})
919
+ );
920
+ let no_capacity = json!({"instances": {"CheckoutFixture::Event#yoga": {"name": {"value": "Yoga"}}}});
921
+ assert_eq!(
922
+ one_seat_figures(&no_capacity, "CheckoutFixture", &payments, "yoga"),
923
+ Some(json!({"slug": "yoga", "capacity": null, "seats_taken": 0, "seats_left": null}))
924
+ );
925
+ }
926
+
927
+ #[test]
928
+ fn the_seat_figures_agree_with_seats_left_for_every_event() {
929
+ let payments = crate::ir::fixture_payments();
930
+ let mut read = read_with(6, &[("a", "yoga", "succeeded"), ("b", "yoga", "refunding"), ("c", "other", "pending"), ("d", "yoga", "failed")]);
931
+ set_registration_status(&mut read, "b", "archived");
932
+ let all = all_seat_figures(&read, "CheckoutFixture", &payments);
933
+ for slug in ["yoga", "other"] {
934
+ assert_eq!(all["events"][slug]["seats_left"].as_i64(), seats_left(&read, "CheckoutFixture", &payments, slug), "{slug}");
935
+ assert_eq!(all["events"][slug]["seats_taken"].as_u64(), Some(seats_taken(&read, "CheckoutFixture", &payments, slug) as u64), "{slug}");
936
+ }
937
+ }
938
+
939
+ #[test]
940
+ fn only_a_single_segment_slug_between_events_and_seats_names_an_event() {
941
+ assert_eq!(seats_path_slug("/events/yoga/seats"), Some("yoga"));
942
+ for path in ["/events/seats", "/events//seats", "/events/a/b/seats", "/events/yoga", "/events/yoga/seats/x", "/registrations/x/seats"] {
943
+ assert_eq!(seats_path_slug(path), None, "{path}");
944
+ }
945
+ }
946
+
947
+ #[tokio::test]
948
+ async fn the_seat_routes_answer_from_a_real_read_and_404_an_unknown_event() {
949
+ let client = scratch_db("hecks_host_web_test_seat_routes").await;
950
+ provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
951
+ let config = checkout_config(1);
952
+ let wasm_path = checkout_wasm_path();
953
+ schedule_event(&client, &wasm_path, &config, "yoga", 4200).await;
954
+ let ir = crate::ir::fixture_ir();
955
+ let get = |path: &'static str| {
956
+ let (client, wasm_path, config, ir) = (&client, &wasm_path, &config, &ir);
957
+ async move {
958
+ let response = payments_routes(Some(ir), "GET", path, "", "", client, wasm_path, config, &lambda_client::NeverInvoker).await.expect("a seats path");
959
+ (response["statusCode"].as_u64().unwrap(), serde_json::from_str::<Value>(response["body"].as_str().unwrap()).unwrap())
960
+ }
961
+ };
962
+ assert_eq!(get("/events/yoga/seats").await, (200, json!({"slug": "yoga", "capacity": 20, "seats_taken": 0, "seats_left": 20})));
963
+ assert_eq!(get("/events/seats").await, (200, json!({"events": {"yoga": {"capacity": 20, "seats_taken": 0, "seats_left": 20}}})));
964
+ assert_eq!(get("/events/nowhere/seats").await, (404, json!({"error": "no such event"})));
965
+ }
966
+
967
+ #[test]
968
+ fn checkout_is_enabled_only_for_the_exactly_configured_domain() {
969
+ assert!(checkout_enabled(Some("CheckoutFixture"), "CheckoutFixture"));
970
+ assert!(!checkout_enabled(None, "CheckoutFixture"));
971
+ assert!(!checkout_enabled(Some(""), "CheckoutFixture"));
972
+ assert!(!checkout_enabled(Some("Banking"), "CheckoutFixture"));
973
+ }
974
+
975
+ #[test]
976
+ fn attendee_from_strips_only_routing_metadata_not_attendee_fields() {
977
+ let body = json!({
978
+ "event_slug": "yoga-aug",
979
+ "return_to": "/yoga-aug",
980
+ "first_name": "Ada",
981
+ "last_name": "Lovelace",
982
+ "email": "ada@example.com",
983
+ "phone": "555-0100",
984
+ "previous_sessions": false,
985
+ "first_time": true,
986
+ "how_heard": "a friend",
987
+ "aim": "flexibility",
988
+ });
989
+
990
+ let attendee = attendee_from(&body);
991
+
992
+ assert_eq!(attendee.get("event_slug"), None, "routing metadata must not leak into Attendee");
993
+ assert_eq!(attendee.get("return_to"), None, "routing metadata must not leak into Attendee");
994
+ assert_eq!(attendee["first_name"], "Ada");
995
+ assert_eq!(attendee["last_name"], "Lovelace");
996
+ assert_eq!(attendee["email"], "ada@example.com");
997
+ assert_eq!(attendee["phone"], "555-0100");
998
+ assert_eq!(attendee["previous_sessions"], false);
999
+ assert_eq!(attendee["first_time"], true);
1000
+ assert_eq!(attendee["how_heard"], "a friend");
1001
+ assert_eq!(attendee["aim"], "flexibility");
1002
+ }
1003
+
1004
+ #[test]
1005
+ fn attendee_from_forwards_the_old_flat_shape_unchanged_for_backward_compatibility() {
1006
+ // Must round-trip byte-for-byte — this exact shape is what
1007
+ // CheckoutFixture's tests, and any simple-Attendee domain, send.
1008
+ let body = json!({"event_slug": "happy-event", "name": "Ada Lovelace", "email": "ada@example.com"});
1009
+ assert_eq!(attendee_from(&body), json!({"name": "Ada Lovelace", "email": "ada@example.com"}));
1010
+ }
1011
+
1012
+ #[test]
1013
+ fn display_name_from_prefers_a_flat_name_field_when_present() {
1014
+ let body = json!({"name": "Ada Lovelace", "first_name": "should be ignored", "last_name": "should be ignored"});
1015
+ assert_eq!(display_name_from(&body), Some("Ada Lovelace".to_string()));
1016
+ }
1017
+
1018
+ #[test]
1019
+ fn display_name_from_composes_first_and_last_name_when_no_flat_name_exists() {
1020
+ let body = json!({"first_name": "Ada", "last_name": "Lovelace"});
1021
+ assert_eq!(display_name_from(&body), Some("Ada Lovelace".to_string()));
1022
+ }
1023
+
1024
+ #[test]
1025
+ fn display_name_from_is_none_when_neither_shape_is_present() {
1026
+ assert_eq!(display_name_from(&json!({"email": "ada@example.com"})), None);
1027
+ assert_eq!(display_name_from(&json!({"first_name": "Ada"})), None, "last_name alone is missing");
1028
+ assert_eq!(display_name_from(&json!({"last_name": "Lovelace"})), None, "first_name alone is missing");
1029
+ }
1030
+
1031
+ #[tokio::test]
1032
+ async fn registrations_route_refuses_a_body_missing_any_required_field() {
1033
+ let client = scratch_db("hecks_host_web_test_registrations_missing_fields").await;
1034
+ provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1035
+
1036
+ let response = registrations_route(r#"{"event_slug":"yoga-aug"}"#, &payments::test_platform(), &client, &checkout_wasm_path(), &checkout_config(1), &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
1037
+ assert_eq!(response["statusCode"], 400);
1038
+ assert!(response["body"].as_str().unwrap().contains("missing name"));
1039
+ }
1040
+
1041
+ // A caller submits `first_name`/`last_name` against CheckoutFixture's
1042
+ // `name`-only Attendee — proves display_name_from composes a real Client
1043
+ // name and attendee_from forwards fields verbatim, without a second fixture.
1044
+ #[tokio::test]
1045
+ async fn registrations_route_forwards_a_new_shaped_attendee_verbatim_even_against_an_old_shaped_fixture() {
1046
+ let client = scratch_db("hecks_host_web_test_registrations_new_shape").await;
1047
+ provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1048
+ let config = checkout_config(1);
1049
+ let wasm_path = checkout_wasm_path();
1050
+
1051
+ schedule_event(&client, &wasm_path, &config, "happy-event", 4200).await;
1052
+
1053
+ let body = json!({
1054
+ "event_slug": "happy-event",
1055
+ "first_name": "Ada",
1056
+ "last_name": "Lovelace",
1057
+ "email": "ada@example.com",
1058
+ })
1059
+ .to_string();
1060
+ let response = registrations_route(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
1061
+
1062
+ // Registration.Request refuses (no first_name/last_name in this
1063
+ // Attendee) but only after Payment.Initiate already ran, proving
1064
+ // display_name_from composed a real name Payment accepted.
1065
+ assert_eq!(response["statusCode"], 422, "{response:?}");
1066
+ assert!(
1067
+ response["body"].as_str().unwrap().contains("name"),
1068
+ "should surface CheckoutFixture's own real Attendee refusal, not a generic error: {response:?}"
1069
+ );
1070
+
1071
+ let read = dispatch::read(&client, &wasm_path).await.unwrap();
1072
+ let instances = read["instances"].as_object().unwrap();
1073
+ assert!(
1074
+ instances.keys().any(|k| k.starts_with("Payments::Payment#")),
1075
+ "Payment.Initiate should have committed for real using the composed display name: {instances:?}"
1076
+ );
1077
+ assert!(
1078
+ instances.keys().all(|k| !k.starts_with("CheckoutFixture::Registration#")),
1079
+ "Registration.Request must never have committed: {instances:?}"
1080
+ );
1081
+ }
1082
+
1083
+ // A domain whose IR declares no payments capability serves none of the
1084
+ // checkout, registration-payment or webhook routes; the positive control
1085
+ // (the fixture's own IR) shows the same call is answered when it does.
1086
+ #[tokio::test]
1087
+ async fn payments_routes_serve_nothing_without_a_payments_capability() {
1088
+ let client = scratch_db("hecks_host_web_test_payments_gate").await;
1089
+ let wasm_path = checkout_wasm_path();
1090
+ let config = checkout_config(1);
1091
+ let no_payments = json!({"name": "Studio"});
1092
+
1093
+ for (method, path) in [
1094
+ ("POST", "/registrations"),
1095
+ ("GET", "/registrations/REG-1"),
1096
+ ("POST", "/registrations/REG-1/complete"),
1097
+ ("POST", "/webhooks/stripe"),
1098
+ ("POST", "/events"),
1099
+ ("GET", "/events/seats"),
1100
+ ("GET", "/events/yoga/seats"),
1101
+ ] {
1102
+ for ir in [Some(&no_payments), None] {
1103
+ let response = payments_routes(ir, method, path, "not json", "", &client, &wasm_path, &config, &lambda_client::NeverInvoker).await;
1104
+ assert!(response.is_none(), "{method} {path} answered although the IR declares no payments: {response:?}");
1105
+ }
1106
+ }
1107
+
1108
+ let fixture_ir = crate::ir::fixture_ir();
1109
+ let response = payments_routes(Some(&fixture_ir), "POST", "/registrations", "not json", "", &client, &wasm_path, &config, &lambda_client::NeverInvoker).await;
1110
+ assert_eq!(response.expect("the fixture IR declares payments, so the route answers")["statusCode"], 400);
1111
+ }
1112
+
1113
+ #[tokio::test]
1114
+ async fn registrations_route_refuses_invalid_json_outright() {
1115
+ let client = scratch_db("hecks_host_web_test_registrations_bad_json").await;
1116
+ provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1117
+
1118
+ let response = registrations_route("not json", &payments::test_platform(), &client, &checkout_wasm_path(), &checkout_config(1), &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
1119
+ assert_eq!(response["statusCode"], 400);
1120
+ }
1121
+
1122
+ #[tokio::test]
1123
+ async fn events_route_refuses_invalid_json_outright() {
1124
+ let client = scratch_db("hecks_host_web_test_events_bad_json").await;
1125
+ provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1126
+
1127
+ let response = events_route("not json", &client, &checkout_wasm_path(), &checkout_config(1), &lambda_client::NeverInvoker).await;
1128
+ assert_eq!(response["statusCode"], 400);
1129
+ }
1130
+
1131
+ #[tokio::test]
1132
+ async fn events_route_refuses_a_body_missing_any_required_field() {
1133
+ let client = scratch_db("hecks_host_web_test_events_missing_fields").await;
1134
+ provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1135
+
1136
+ let response = events_route(r#"{"slug":"new-event"}"#, &client, &checkout_wasm_path(), &checkout_config(1), &lambda_client::NeverInvoker).await;
1137
+ assert_eq!(response["statusCode"], 400);
1138
+ assert!(response["body"].as_str().unwrap().contains("missing name"));
1139
+ }
1140
+
1141
+ #[tokio::test]
1142
+ async fn events_route_schedules_a_new_event_and_returns_201() {
1143
+ let client = scratch_db("hecks_host_web_test_events_new").await;
1144
+ provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1145
+ let config = checkout_config(1);
1146
+ let wasm_path = checkout_wasm_path();
1147
+
1148
+ let body = json!({"slug": "mock-payments-event", "name": "Mock Payments Event", "price_cents": 4200, "capacity": 20}).to_string();
1149
+ let response = events_route(&body, &client, &wasm_path, &config, &lambda_client::NeverInvoker).await;
1150
+ assert_eq!(response["statusCode"], 201, "{response:?}");
1151
+ let response_body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
1152
+ assert_eq!(response_body["slug"]["value"], "mock-payments-event");
1153
+ assert_eq!(response_body["name"]["value"], "Mock Payments Event");
1154
+ assert_eq!(response_body["price"]["cents"], 4200);
1155
+ assert_eq!(response_body["capacity"]["value"], 20);
1156
+ assert_eq!(response_body["status"], "open");
1157
+
1158
+ let read = dispatch::read(&client, &wasm_path).await.unwrap();
1159
+ let instances = read["instances"].as_object().unwrap();
1160
+ assert!(
1161
+ instances.keys().any(|k| k == "CheckoutFixture::Event#mock-payments-event"),
1162
+ "Event.Schedule should have committed for real: {instances:?}"
1163
+ );
1164
+ }
1165
+
1166
+ // A repeat call for the same slug is a 200 no-op returning the existing
1167
+ // event's state, never a second Event.Schedule dispatch.
1168
+ #[tokio::test]
1169
+ async fn events_route_is_idempotent_on_a_repeat_slug() {
1170
+ let client = scratch_db("hecks_host_web_test_events_idempotent").await;
1171
+ provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1172
+ let config = checkout_config(1);
1173
+ let wasm_path = checkout_wasm_path();
1174
+
1175
+ let body = json!({"slug": "repeat-event", "name": "Repeat Event", "price_cents": 1000, "capacity": 5}).to_string();
1176
+ let first = events_route(&body, &client, &wasm_path, &config, &lambda_client::NeverInvoker).await;
1177
+ assert_eq!(first["statusCode"], 201, "{first:?}");
1178
+
1179
+ // A second call with different name/price/capacity still returns the
1180
+ // original event's state, 200 not 201 — idempotency keys only on slug.
1181
+ let second_body = json!({"slug": "repeat-event", "name": "Different Name", "price_cents": 9999, "capacity": 1}).to_string();
1182
+ let second = events_route(&second_body, &client, &wasm_path, &config, &lambda_client::NeverInvoker).await;
1183
+ assert_eq!(second["statusCode"], 200, "{second:?}");
1184
+ let second_body: Value = serde_json::from_str(second["body"].as_str().unwrap()).unwrap();
1185
+ assert_eq!(second_body["name"]["value"], "Repeat Event", "must return the ORIGINAL event, not re-schedule with the new fields");
1186
+ assert_eq!(second_body["price"]["cents"], 1000);
1187
+
1188
+ let read = dispatch::read(&client, &wasm_path).await.unwrap();
1189
+ let instances = read["instances"].as_object().unwrap();
1190
+ assert_eq!(
1191
+ instances.keys().filter(|k| k.starts_with("CheckoutFixture::Event#repeat-event")).count(),
1192
+ 1,
1193
+ "exactly one Event, never a second dispatch: {instances:?}"
1194
+ );
1195
+ }
1196
+
1197
+ #[tokio::test]
1198
+ async fn registrations_route_404s_an_unknown_event_slug() {
1199
+ let client = scratch_db("hecks_host_web_test_registrations_no_event").await;
1200
+ provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1201
+
1202
+ let body = json!({"event_slug": "nope", "name": "Ada", "email": "ada@example.com"}).to_string();
1203
+ let response = registrations_route(&body, &payments::test_platform(), &client, &checkout_wasm_path(), &checkout_config(1), &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
1204
+ assert_eq!(response["statusCode"], 404);
1205
+ }
1206
+
1207
+ #[tokio::test]
1208
+ async fn registrations_route_refuses_a_closed_event() {
1209
+ let client = scratch_db("hecks_host_web_test_registrations_closed_event").await;
1210
+ provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1211
+ let config = checkout_config(1);
1212
+ let wasm_path = checkout_wasm_path();
1213
+
1214
+ schedule_event(&client, &wasm_path, &config, "closed-event", 4200).await;
1215
+ let close = dispatch::handle(&client, &wasm_path, "CheckoutFixture::Event.Close", json!({"id": "closed-event"}), None, &config, &lambda_client::NeverInvoker)
1216
+ .await
1217
+ .unwrap();
1218
+ assert!(close.accepted, "closing the fixture event should succeed: {:?}", close.result);
1219
+
1220
+ let body = json!({"event_slug": "closed-event", "name": "Ada", "email": "ada@example.com"}).to_string();
1221
+ let response = registrations_route(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
1222
+ assert_eq!(response["statusCode"], 422);
1223
+ assert!(response["body"].as_str().unwrap().contains("closed"));
1224
+ }
1225
+
1226
+ #[tokio::test]
1227
+ async fn registrations_route_propagates_a_real_domain_refusal_from_payment_initiate() {
1228
+ // A zero-price event: PositiveMoney's invariant refuses
1229
+ // Payment.Initiate before Registration.Request is ever reached,
1230
+ // proving the refusal is the real domain rule.
1231
+ let client = scratch_db("hecks_host_web_test_registrations_zero_price").await;
1232
+ provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1233
+ let config = checkout_config(1);
1234
+ let wasm_path = checkout_wasm_path();
1235
+
1236
+ schedule_event(&client, &wasm_path, &config, "free-event", 0).await;
1237
+
1238
+ let body = json!({"event_slug": "free-event", "name": "Ada", "email": "ada@example.com"}).to_string();
1239
+ let response = registrations_route(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
1240
+ assert_eq!(response["statusCode"], 422);
1241
+ assert!(
1242
+ response["body"].as_str().unwrap().contains("positive"),
1243
+ "should surface PositiveMoney's own invariant text: {response:?}"
1244
+ );
1245
+
1246
+ // And neither the payment nor the registration was persisted —
1247
+ // Registration.Request must never have been dispatched at all.
1248
+ let read = dispatch::read(&client, &wasm_path).await.unwrap();
1249
+ let instances = read["instances"].as_object().unwrap();
1250
+ assert!(instances.keys().all(|k| !k.starts_with("CheckoutFixture::Registration#") && !k.starts_with("Payments::Payment#")));
1251
+ }
1252
+
1253
+ #[tokio::test]
1254
+ async fn registrations_route_runs_the_whole_dispatch_chain_and_returns_a_real_mock_checkout_url() {
1255
+ let client = scratch_db("hecks_host_web_test_registrations_happy_path").await;
1256
+ provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1257
+ let config = checkout_config(1);
1258
+ let wasm_path = checkout_wasm_path();
1259
+
1260
+ schedule_event(&client, &wasm_path, &config, "happy-event", 4200).await;
1261
+
1262
+ // No connection: checkout is genuinely on the mock walkthrough, not
1263
+ // a misconfiguration — the whole chain runs and returns a real
1264
+ // checkout URL, never a 500.
1265
+ let body = json!({"event_slug": "happy-event", "name": "Ada Lovelace", "email": "ada@example.com"}).to_string();
1266
+ let response = registrations_route(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
1267
+ assert_eq!(response["statusCode"], 200, "{response:?}");
1268
+ let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
1269
+ let reference = body["registration_id"].as_str().unwrap().to_string();
1270
+ // LocalCheckout's shape: /pay/<reference>.html, with success_url and
1271
+ // cancel_url as encoded query params pointing at
1272
+ // registration-confirmed.html; no return_to sent, so it falls back to "/".
1273
+ assert_eq!(
1274
+ body["checkout_url"],
1275
+ format!(
1276
+ "http://localhost:4321/pay/{reference}.html?success_url=http%3A%2F%2Flocalhost%3A4321%2Fregistration-confirmed.html%3Fregistration_id%3D{reference}%26outcome%3Dsucceeded%26return_to%3D%252F&cancel_url=http%3A%2F%2Flocalhost%3A4321%2Fregistration-confirmed.html%3Fregistration_id%3D{reference}%26outcome%3Dcancelled%26return_to%3D%252F"
1277
+ )
1278
+ );
1279
+
1280
+ let read = dispatch::read(&client, &wasm_path).await.unwrap();
1281
+ let instances = read["instances"].as_object().unwrap();
1282
+ let registration = instances.iter().find(|(k, _)| k.starts_with("CheckoutFixture::Registration#")).map(|(_, v)| v);
1283
+ let payment = instances.iter().find(|(k, _)| k.starts_with("Payments::Payment#")).map(|(_, v)| v);
1284
+ assert!(registration.is_some(), "Registration.Request should have committed for real: {instances:?}");
1285
+ assert!(payment.is_some(), "Payment.Initiate should have committed for real: {instances:?}");
1286
+
1287
+ let registration = registration.unwrap();
1288
+ let payment = payment.unwrap();
1289
+ assert_eq!(registration["event_slug"], "happy-event");
1290
+ assert_eq!(registration["attendee"]["name"], "Ada Lovelace");
1291
+ // The Registration's id equals the Payment's reference, minted once
1292
+ // here, never independently.
1293
+ assert_eq!(registration["registration_id"]["value"], reference);
1294
+ assert_eq!(payment["reference"]["value"], reference);
1295
+ assert_eq!(payment["amount"]["cents"], 4200);
1296
+ // Mock, not "stripe": the processor checkout_plan picks with no
1297
+ // connection, which the webhook reads back off the Payment itself
1298
+ // for Payment::Succeed's "processor matches" given.
1299
+ assert_eq!(payment["processor"]["value"], "mock_stripe");
1300
+ }
1301
+
1302
+ // Pins a real bug: a guest was redirected to the Event's internal slug
1303
+ // page (never a real route) instead of back to their own `return_to`,
1304
+ // 404ing every guest right after a successful, already-charged registration.
1305
+ #[tokio::test]
1306
+ async fn registrations_route_redirects_to_the_caller_s_own_return_to_not_the_event_slug() {
1307
+ let client = scratch_db("hecks_host_web_test_registrations_return_to").await;
1308
+ provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1309
+ let config = checkout_config(1);
1310
+ let wasm_path = checkout_wasm_path();
1311
+
1312
+ schedule_event(&client, &wasm_path, &config, "happy-event", 4200).await;
1313
+
1314
+ let body = json!({
1315
+ "event_slug": "happy-event",
1316
+ "name": "Ada Lovelace",
1317
+ "email": "ada@example.com",
1318
+ "return_to": "/sample-studio.html",
1319
+ })
1320
+ .to_string();
1321
+ let response = registrations_route(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
1322
+ assert_eq!(response["statusCode"], 200, "{response:?}");
1323
+ let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
1324
+ let reference = body["registration_id"].as_str().unwrap().to_string();
1325
+ assert_eq!(
1326
+ body["checkout_url"],
1327
+ format!(
1328
+ "http://localhost:4321/pay/{reference}.html?success_url=http%3A%2F%2Flocalhost%3A4321%2Fregistration-confirmed.html%3Fregistration_id%3D{reference}%26outcome%3Dsucceeded%26return_to%3D%252Fsample-studio.html&cancel_url=http%3A%2F%2Flocalhost%3A4321%2Fregistration-confirmed.html%3Fregistration_id%3D{reference}%26outcome%3Dcancelled%26return_to%3D%252Fsample-studio.html"
1329
+ )
1330
+ );
1331
+ }
1332
+
1333
+ // An absolute or protocol-relative return_to must never become an open
1334
+ // redirect; falls back to "/" exactly as if none had been sent.
1335
+ #[tokio::test]
1336
+ async fn registrations_route_refuses_an_absolute_or_protocol_relative_return_to() {
1337
+ let client = scratch_db("hecks_host_web_test_registrations_return_to_unsafe").await;
1338
+ provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1339
+ let config = checkout_config(1);
1340
+ let wasm_path = checkout_wasm_path();
1341
+
1342
+ schedule_event(&client, &wasm_path, &config, "happy-event", 4200).await;
1343
+
1344
+ for unsafe_return_to in ["https://evil.example", "//evil.example"] {
1345
+ let body = json!({
1346
+ "event_slug": "happy-event",
1347
+ "name": "Ada Lovelace",
1348
+ "email": "ada@example.com",
1349
+ "return_to": unsafe_return_to,
1350
+ })
1351
+ .to_string();
1352
+ let response = registrations_route(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
1353
+ assert_eq!(response["statusCode"], 200, "{response:?}");
1354
+ let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
1355
+ // checkout_url is the /pay/<id>.html walkthrough page; return_to
1356
+ // is double-encoded (once for /pay's success_url, once more inside
1357
+ // it) so it shows up as "%252F".
1358
+ let checkout_url = body["checkout_url"].as_str().unwrap();
1359
+ let parsed = reqwest::Url::parse(checkout_url).unwrap();
1360
+ let success_url = parsed.query_pairs().find(|(k, _)| k == "success_url").map(|(_, v)| v.into_owned()).unwrap_or_default();
1361
+ assert!(
1362
+ success_url.ends_with("return_to=%2F"),
1363
+ "an unsafe return_to ({unsafe_return_to:?}) must fall back to \"/\": {body:?}"
1364
+ );
1365
+ }
1366
+ }
1367
+
1368
+ #[tokio::test]
1369
+ async fn a_mock_registration_confirms_end_to_end_through_a_synthetic_signed_webhook() {
1370
+ // The full loop, mock both ends: registrations_route's own
1371
+ // checkout_url, then a webhook signed against the same mock secret
1372
+ // webhook_route falls back to while STRIPE_WEBHOOK_SECRET is unset.
1373
+ let client = scratch_db("hecks_host_web_test_mock_full_loop").await;
1374
+ provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1375
+ let config = checkout_config(1);
1376
+ let wasm_path = checkout_wasm_path();
1377
+
1378
+ schedule_event(&client, &wasm_path, &config, "mock-loop-event", 4200).await;
1379
+
1380
+ let body = json!({"event_slug": "mock-loop-event", "name": "Ada Lovelace", "email": "ada@example.com"}).to_string();
1381
+ let response = registrations_route(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
1382
+ assert_eq!(response["statusCode"], 200, "{response:?}");
1383
+ let response_body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
1384
+ let reference = response_body["registration_id"].as_str().unwrap().to_string();
1385
+
1386
+ let secret = MOCK_STRIPE_WEBHOOK_SECRET;
1387
+ let payload = json!({
1388
+ "type": "checkout.session.completed",
1389
+ "data": {"object": {"id": format!("cs_manual_{reference}"), "metadata": {"registration_id": reference}}},
1390
+ }).to_string();
1391
+ let now = now_secs();
1392
+ let header = sign_stripe_header(secret, now, &payload);
1393
+
1394
+ let response = webhook_route(&payload, &header, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
1395
+ assert_eq!(response["statusCode"], 200, "{response:?}");
1396
+
1397
+ let read = dispatch::read(&client, &wasm_path).await.unwrap();
1398
+ let payment = &read["instances"][format!("Payments::Payment#{reference}")];
1399
+ assert_eq!(payment["status"], "succeeded");
1400
+ assert_eq!(payment["transaction_id"]["value"], format!("cs_manual_{reference}"));
1401
+ }
1402
+
1403
+ #[tokio::test]
1404
+ async fn webhook_route_rejects_a_bad_signature_before_touching_the_domain_at_all() {
1405
+ let client = scratch_db("hecks_host_web_test_webhook_bad_sig").await;
1406
+ provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1407
+
1408
+ let payload = json!({"type": "checkout.session.completed", "data": {"object": {}}}).to_string();
1409
+ let bad_header = "t=1700000000,v1=deadbeef";
1410
+
1411
+ let response = webhook_route(&payload, bad_header, &payments::test_platform_with_secret("whsec_test_bad_sig"), &client, &checkout_wasm_path(), &checkout_config(1), &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
1412
+ assert_eq!(response["statusCode"], 400);
1413
+ }
1414
+
1415
+ #[tokio::test]
1416
+ async fn webhook_route_settles_a_payment_on_checkout_session_completed() {
1417
+ let client = scratch_db("hecks_host_web_test_webhook_completed").await;
1418
+ provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1419
+ let config = checkout_config(1);
1420
+ let wasm_path = checkout_wasm_path();
1421
+
1422
+ schedule_event(&client, &wasm_path, &config, "webhook-event", 4200).await;
1423
+ let initiate = dispatch::handle(
1424
+ &client, &wasm_path, "Payments::Payment.Initiate",
1425
+ json!({
1426
+ "reference": {"value": "REG-WEBHOOK-1"}, "processor": {"value": "stripe"},
1427
+ "payment_type": {"value": "card"}, "amount": {"cents": 4200},
1428
+ "client": {"name": "Ada Lovelace", "email": "ada@example.com"},
1429
+ }),
1430
+ None, &config, &lambda_client::NeverInvoker,
1431
+ ).await.unwrap();
1432
+ assert!(initiate.accepted, "{:?}", initiate.result);
1433
+
1434
+ let secret = "whsec_test_completed";
1435
+ let payload = json!({
1436
+ "type": "checkout.session.completed",
1437
+ "data": {"object": {"metadata": {"registration_id": "REG-WEBHOOK-1"}, "payment_intent": "pi_test_abc"}},
1438
+ }).to_string();
1439
+ let now = now_secs();
1440
+ let header = sign_stripe_header(secret, now, &payload);
1441
+
1442
+ let response = webhook_route(&payload, &header, &payments::test_platform_with_secret(secret), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
1443
+ assert_eq!(response["statusCode"], 200, "{response:?}");
1444
+
1445
+ let read = dispatch::read(&client, &wasm_path).await.unwrap();
1446
+ let payment = &read["instances"]["Payments::Payment#REG-WEBHOOK-1"];
1447
+ assert_eq!(payment["status"], "succeeded");
1448
+ assert_eq!(payment["transaction_id"]["value"], "pi_test_abc");
1449
+
1450
+ // A redelivered webhook (Stripe's delivery is at-least-once) for an
1451
+ // already-succeeded payment must still answer 200 — the refusal
1452
+ // underneath is a benign no-op, not surfaced as an error.
1453
+ let redelivered = webhook_route(&payload, &header, &payments::test_platform_with_secret(secret), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
1454
+ assert_eq!(redelivered["statusCode"], 200, "a redelivered webhook must not surface the resulting refusal as an error: {redelivered:?}");
1455
+ }
1456
+
1457
+ #[tokio::test]
1458
+ async fn webhook_route_declines_a_payment_on_checkout_session_expired() {
1459
+ let client = scratch_db("hecks_host_web_test_webhook_expired").await;
1460
+ provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1461
+ let config = checkout_config(1);
1462
+ let wasm_path = checkout_wasm_path();
1463
+
1464
+ schedule_event(&client, &wasm_path, &config, "webhook-event-2", 4200).await;
1465
+ dispatch::handle(
1466
+ &client, &wasm_path, "Payments::Payment.Initiate",
1467
+ json!({
1468
+ "reference": {"value": "REG-WEBHOOK-2"}, "processor": {"value": "stripe"},
1469
+ "payment_type": {"value": "card"}, "amount": {"cents": 4200},
1470
+ "client": {"name": "Ada Lovelace", "email": "ada@example.com"},
1471
+ }),
1472
+ None, &config, &lambda_client::NeverInvoker,
1473
+ ).await.unwrap().accepted.then_some(()).expect("initiate should succeed");
1474
+
1475
+ let secret = "whsec_test_expired";
1476
+ let payload = json!({
1477
+ "type": "checkout.session.expired",
1478
+ "data": {"object": {"metadata": {"registration_id": "REG-WEBHOOK-2"}}},
1479
+ }).to_string();
1480
+ let now = now_secs();
1481
+ let header = sign_stripe_header(secret, now, &payload);
1482
+
1483
+ let response = webhook_route(&payload, &header, &payments::test_platform_with_secret(secret), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
1484
+ assert_eq!(response["statusCode"], 200, "{response:?}");
1485
+
1486
+ let read = dispatch::read(&client, &wasm_path).await.unwrap();
1487
+ let payment = &read["instances"]["Payments::Payment#REG-WEBHOOK-2"];
1488
+ assert_eq!(payment["status"], "failed");
1489
+ assert_eq!(payment["failure_reason"]["value"], "checkout_expired");
1490
+ }
1491
+
1492
+ #[tokio::test]
1493
+ async fn webhook_route_ignores_an_event_type_it_does_not_handle_and_still_answers_200() {
1494
+ let client = scratch_db("hecks_host_web_test_webhook_unhandled_type").await;
1495
+ provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1496
+
1497
+ let secret = "whsec_test_unhandled";
1498
+ let payload = json!({"type": "charge.refunded", "data": {"object": {"metadata": {"registration_id": "whatever"}}}}).to_string();
1499
+ let now = now_secs();
1500
+ let header = sign_stripe_header(secret, now, &payload);
1501
+
1502
+ let response = webhook_route(&payload, &header, &payments::test_platform_with_secret(secret), &client, &checkout_wasm_path(), &checkout_config(1), &lambda_client::NeverInvoker, &crate::ir::fixture_payments()).await;
1503
+ assert_eq!(response["statusCode"], 200);
1504
+ }
1505
+ }