hecks 3.14.0 → 5.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 (427) hide show
  1. checksums.yaml +4 -4
  2. data/examples/pizzas/adapters/fake_processor.rb +34 -0
  3. data/examples/pizzas/adapters/payment_signature.rb +66 -0
  4. data/examples/pizzas/adapters/payment_webhook.rb +87 -0
  5. data/examples/pizzas/bluebook/pizzas.behaviors +33 -0
  6. data/examples/pizzas/bluebook/pizzas.hecksagon +5 -3
  7. data/examples/pizzas/config.ru +22 -0
  8. data/exe/hecks +1 -1
  9. data/lib/hecks/adapters/driven/prism.rb +0 -2
  10. data/lib/hecks/adapters/driven.rb +0 -2
  11. data/lib/hecks/adapters/driving/json.rb +9 -16
  12. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -2
  13. data/lib/hecks/bluebook/dsl/derivation_words.rb +58 -0
  14. data/lib/hecks/bluebook/dsl/translation_builder.rb +6 -37
  15. data/lib/hecks/bluebook/expression/ast_json.rb +0 -9
  16. data/lib/hecks/bluebook/hecksagon.rb +1 -1
  17. data/lib/hecks/bluebook/meta_validator/translation_judge/rules.rb +7 -10
  18. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +4 -8
  19. data/lib/hecks/bluebook/meta_validator.rb +0 -11
  20. data/lib/hecks/bluebook/translation.rb +18 -10
  21. data/lib/hecks/bluebook/translation_expression/evaluator.rb +63 -0
  22. data/lib/hecks/bluebook/translation_expression/parser.rb +127 -0
  23. data/lib/hecks/bluebook/translation_expression.rb +39 -0
  24. data/lib/hecks/bluebook/translation_plan.rb +70 -0
  25. data/lib/hecks/bluebook.rb +2 -0
  26. data/lib/hecks/chapters.rb +3 -3
  27. data/lib/hecks/cli/project_cli/launcher_source.rb +1 -1
  28. data/lib/hecks/composer/adapters/claude_composer.adapter +3 -0
  29. data/lib/hecks/composer/adapters/claude_composer.rb +83 -0
  30. data/lib/hecks/composer/adapters/fake_agent.adapter +3 -0
  31. data/lib/hecks/composer/adapters/fake_agent.rb +55 -0
  32. data/lib/hecks/composer/adapters/jev.adapter +3 -0
  33. data/lib/hecks/composer/adapters/jev.rb +66 -0
  34. data/lib/hecks/composer/agent.rb +113 -0
  35. data/lib/hecks/composer/assistant.rb +110 -0
  36. data/lib/hecks/composer/behavior_run.rb +53 -0
  37. data/lib/hecks/composer/bluebook/composer.bluebook +178 -0
  38. data/lib/hecks/composer/bluebook/composer.hecksagon +16 -0
  39. data/lib/hecks/composer/board.rb +65 -0
  40. data/lib/hecks/composer/board_edit/command_text.rb +39 -0
  41. data/lib/hecks/composer/board_edit/source.rb +84 -0
  42. data/lib/hecks/composer/board_edit.rb +144 -0
  43. data/lib/hecks/composer/check.rb +89 -0
  44. data/lib/hecks/composer/domains.rb +81 -0
  45. data/lib/hecks/composer/jobs.rb +134 -0
  46. data/lib/hecks/composer/layout.rb +37 -0
  47. data/lib/hecks/composer/live.rb +150 -0
  48. data/lib/hecks/composer/outline.rb +48 -0
  49. data/lib/hecks/composer/page.html +989 -0
  50. data/lib/hecks/composer/panels.rb +142 -0
  51. data/lib/hecks/composer/parse.rb +83 -0
  52. data/lib/hecks/composer/plain.rb +28 -0
  53. data/lib/hecks/composer/ports/composer_agent.port +7 -0
  54. data/lib/hecks/composer/session.rb +141 -0
  55. data/lib/hecks/composer/toolbox.rb +166 -0
  56. data/lib/hecks/composer/web.rb +156 -0
  57. data/lib/hecks/composer/workbench.rb +51 -0
  58. data/lib/hecks/composer/workspace.rb +81 -0
  59. data/lib/hecks/composer.rb +17 -0
  60. data/lib/hecks/corpus/adapters.rb +22 -0
  61. data/lib/hecks/corpus/rust.rb +1 -1
  62. data/lib/hecks/corpus/worlds.rb +19 -0
  63. data/lib/hecks/corpus.rb +5 -0
  64. data/lib/hecks/forms/app/commands.rb +2 -3
  65. data/lib/hecks/forms/app/queries.rb +2 -3
  66. data/lib/hecks/forms/app/records.rb +17 -5
  67. data/lib/hecks/forms/app/responses.rb +3 -1
  68. data/lib/hecks/forms/command_form_renderer.rb +42 -16
  69. data/lib/hecks/forms/field_controls.rb +2 -2
  70. data/lib/hecks/forms/field_renderer.rb +1 -1
  71. data/lib/hecks/forms/field_shape.rb +36 -17
  72. data/lib/hecks/forms/index_renderer.rb +2 -1
  73. data/lib/hecks/forms/page.rb +1 -1
  74. data/lib/hecks/forms/params.rb +0 -13
  75. data/lib/hecks/forms/query_form_renderer.rb +7 -6
  76. data/lib/hecks/forms/record_renderer.rb +11 -7
  77. data/lib/hecks/forms/rule_words.rb +100 -0
  78. data/lib/hecks/forms/value_object_shape.rb +2 -1
  79. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -3
  80. data/lib/hecks/fuzzing/isolated_boot/worlds.rb +3 -6
  81. data/lib/hecks/fuzzing/replay/filters.rb +2 -6
  82. data/lib/hecks/gate/stages.yml +25 -0
  83. data/lib/hecks/grammar/evolve/seeds.rb +0 -6
  84. data/lib/hecks/grammar/evolve.rb +0 -3
  85. data/lib/hecks/grammar/translation.bluebook +1 -1
  86. data/lib/hecks/hecks/adapters/codebase/composing.rb +56 -0
  87. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
  88. data/lib/hecks/hecks/adapters/first_admin.rb +3 -3
  89. data/lib/hecks/hecks/adapters/journal_store/changes.rb +0 -26
  90. data/lib/hecks/hecks/adapters/journal_store/examination.rb +2 -23
  91. data/lib/hecks/hecks/adapters/journal_store/readings.rb +3 -11
  92. data/lib/hecks/hecks/adapters/journal_store.rb +0 -11
  93. data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +4 -11
  94. data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +0 -9
  95. data/lib/hecks/hecks/adapters/rehearsal/postgres_reading.rb +0 -4
  96. data/lib/hecks/hecks/codebase.behaviors +6 -0
  97. data/lib/hecks/hecks/codebase.bluebook +21 -0
  98. data/lib/hecks/hecks/composer.behaviors +68 -0
  99. data/lib/hecks/hecks/context_map.hecksagon +1 -1
  100. data/lib/hecks/hecks/custodian.behaviors +0 -6
  101. data/lib/hecks/hecks/custodian.bluebook +3 -80
  102. data/lib/hecks/hecks/hecks.hecksagon +2 -2
  103. data/lib/hecks/hecks/hecks.world +1 -1
  104. data/lib/hecks/hecks/rehearsal.bluebook +7 -13
  105. data/lib/hecks/kernel_capabilities/templates.rb +4 -4
  106. data/lib/hecks/kernel_capabilities.rb +1 -1
  107. data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -3
  108. data/lib/hecks/language/translation/translation_aggregate.bluebook +21 -24
  109. data/lib/hecks/ports/access_control.rb +2 -2
  110. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -41
  111. data/lib/hecks/ports/persistence/plugins/era/lineage/coverage.rb +7 -7
  112. data/lib/hecks/ports/persistence/plugins/era/lineage/rewrite.rb +25 -2
  113. data/lib/hecks/ports/persistence/plugins/era/lineage/rules.rb +4 -4
  114. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +17 -25
  115. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/base_sql.rb +0 -12
  116. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/domain_rename.rb +1 -1
  117. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -22
  118. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +0 -1
  119. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +9 -3
  120. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +3 -29
  121. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/refusals.rb +2 -26
  122. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +6 -33
  123. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +3 -3
  124. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +2 -4
  125. data/lib/hecks/ports/persistence/plugins/era/translation/expression_sql.rb +52 -0
  126. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +23 -29
  127. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +1 -1
  128. data/lib/hecks/ports/persistence/plugins/era/translation.rb +0 -1
  129. data/lib/hecks/ports/persistence/plugins/era.rb +3 -6
  130. data/lib/hecks/ports.rb +0 -1
  131. data/lib/hecks/projection_files.rb +3 -1
  132. data/lib/hecks/projections/deploy/templates/lambda/nat_comment_oauth.tmpl +1 -1
  133. data/lib/hecks/projections/glossary/entries.rb +10 -4
  134. data/lib/hecks/projections/glossary/groups.rb +42 -0
  135. data/lib/hecks/projections/glossary/html/page.rb +7 -2
  136. data/lib/hecks/projections/glossary/html/parser.rb +1 -1
  137. data/lib/hecks/projections/glossary/html/sections.rb +31 -11
  138. data/lib/hecks/projections/glossary/html.rb +1 -2
  139. data/lib/hecks/projections/glossary/markdown.rb +31 -28
  140. data/lib/hecks/projections/glossary/mermaid.rb +3 -0
  141. data/lib/hecks/projections/glossary/page.css +143 -153
  142. data/lib/hecks/projections/glossary/page.js +63 -3
  143. data/lib/hecks/projections/glossary/sentences/actions.rb +88 -0
  144. data/lib/hecks/projections/glossary/sentences/paths.rb +77 -0
  145. data/lib/hecks/projections/glossary/sentences/queries.rb +90 -0
  146. data/lib/hecks/projections/glossary/sentences/value_objects.rb +32 -5
  147. data/lib/hecks/projections/glossary/sentences.rb +13 -3
  148. data/lib/hecks/projections/glossary/slugs.rb +4 -1
  149. data/lib/hecks/projections/glossary.rb +2 -1
  150. data/lib/hecks/projections/plain_english.rb +97 -0
  151. data/lib/hecks/projections/rust_vocabulary/arguments.rb +2 -2
  152. data/lib/hecks/projections/rust_vocabulary/enum_body.rb +20 -25
  153. data/lib/hecks/projections/rust_vocabulary/tests.rb +2 -2
  154. data/lib/hecks/projections/rust_vocabulary.rb +14 -6
  155. data/lib/hecks/projections/site/cms_editor/color.rb +0 -6
  156. data/lib/hecks/projections/site/cms_editor/templates/src/host.ts.tmpl +7 -2
  157. data/lib/hecks/projections/statements/actions.rb +99 -0
  158. data/lib/hecks/projections/statements/relationships.rb +86 -0
  159. data/lib/hecks/projections/statements.rb +15 -24
  160. data/lib/hecks/projector/exporter/adapters.rb +112 -0
  161. data/lib/hecks/projector/exporter/translations.rb +18 -35
  162. data/lib/hecks/projector/exporter/worlds.rb +103 -0
  163. data/lib/hecks/projector/exporter.rb +5 -1
  164. data/lib/hecks/quality_control/adapters/agent.rb +6 -7
  165. data/lib/hecks/quality_control/adapters/agent_profile.rb +16 -114
  166. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +1 -1
  167. data/lib/hecks/runtime/loader/boot_steps.rb +0 -20
  168. data/lib/hecks/runtime/outbox.rb +2 -9
  169. data/lib/hecks/runtime/port_operation_interpreter.rb +1 -9
  170. data/lib/hecks/runtime/remote_dispatcher.rb +2 -2
  171. data/lib/hecks/runtime/value/unwrapping.rb +60 -0
  172. data/lib/hecks/runtime/value.rb +10 -21
  173. data/lib/hecks/rust_build/project_rust/target_ir.rb +23 -4
  174. data/lib/hecks/rust_build/wasm.rb +0 -12
  175. data/lib/hecks/rust_build/write_if_changed.rb +12 -12
  176. data/lib/hecks/rust_format.rb +77 -0
  177. data/lib/hecks/tools/fuzz_sweep/findings.rb +3 -9
  178. data/lib/hecks/tools/fuzz_sweep.rb +1 -3
  179. data/lib/hecks/tools/translation_audit/verdicts.rb +3 -28
  180. data/lib/hecks/tools/translation_audit.rb +8 -10
  181. data/lib/hecks/tools/translation_scaffold/reporting.rb +1 -1
  182. data/lib/hecks/version.rb +1 -1
  183. data/lib/hecks/vocabulary.rb +2 -1
  184. data/lib/hecks.rb +3 -0
  185. data/prebuilt/{1a6f596be124228be93eba1d86dbc9d2933190fa389b284331241cb9f27115a0.marshal → ad68f9e06c8068c2a4176b57abf3083d591d5d8980c2b3e897b97331f965b643.marshal} +0 -0
  186. data/prebuilt/{a883a7e568667aa5ac904c82daff356bdf9e996252a36af6def4a4a43efb967e.marshal → ee22ec8642c4006717ba2b80f46e5efb81de459eef9dfec043e838c0ad4fd720.marshal} +0 -0
  187. data/prebuilt/{verdicts-872993b1aa26d73fe48f28251374b8585fed145d02aab42be0f00de386b6ff80.json → verdicts-b69f5dcbba79c20daa0493476fa7c317004a30aa2bc3d3dc6a722ea70ae2b43b.json} +1 -1
  188. data/rust/Cargo.toml +40 -0
  189. data/rust/build/Cargo.toml +36 -0
  190. data/rust/build/clippy.toml +4 -0
  191. data/rust/build/src/adapters_pass.rs +196 -0
  192. data/rust/build/src/binding_pass.rs +54 -13
  193. data/rust/build/src/build_artifact.rs +23 -5
  194. data/rust/build/src/cargo_sync.rs +69 -26
  195. data/rust/build/src/lineage_pass.rs +101 -87
  196. data/rust/build/src/main.rs +11 -2
  197. data/rust/build/src/pipeline.rs +80 -19
  198. data/rust/build/src/reserved_names.rs +6 -50
  199. data/rust/build/src/resolve.rs +82 -35
  200. data/rust/build/src/rustfmt.rs +100 -0
  201. data/rust/build/src/seams_pass.rs +85 -16
  202. data/rust/build/src/subprocess.rs +30 -6
  203. data/rust/build/src/tmp.rs +5 -2
  204. data/rust/build/src/translation_pass.rs +72 -22
  205. data/rust/build/src/world_pass.rs +109 -0
  206. data/rust/clippy.toml +4 -0
  207. data/rust/codegen/Cargo.toml +36 -0
  208. data/rust/codegen/clippy.toml +4 -0
  209. data/rust/codegen/src/asks.rs +40 -14
  210. data/rust/codegen/src/attr.rs +2 -2
  211. data/rust/codegen/src/bridging.rs +676 -117
  212. data/rust/codegen/src/commands.rs +979 -373
  213. data/rust/codegen/src/constraints.rs +88 -25
  214. data/rust/codegen/src/dependency_planning.rs +128 -34
  215. data/rust/codegen/src/domain_generator.rs +306 -170
  216. data/rust/codegen/src/exemplar.rs +87 -32
  217. data/rust/codegen/src/expr_emitter.rs +54 -6
  218. data/rust/codegen/src/fielded.rs +193 -41
  219. data/rust/codegen/src/hecks_naming.rs +124 -16
  220. data/rust/codegen/src/hecks_naming_tests.rs +81 -0
  221. data/rust/codegen/src/json.rs +80 -44
  222. data/rust/codegen/src/json_codec.rs +585 -149
  223. data/rust/codegen/src/literal.rs +104 -138
  224. data/rust/codegen/src/main.rs +151 -52
  225. data/rust/codegen/src/manifest.rs +76 -14
  226. data/rust/codegen/src/mutations.rs +1212 -239
  227. data/rust/codegen/src/naming.rs +111 -60
  228. data/rust/codegen/src/optional_pass.rs +90 -28
  229. data/rust/codegen/src/ports.rs +5 -4
  230. data/rust/codegen/src/prelude.rs +139 -49
  231. data/rust/codegen/src/presenters.rs +335 -0
  232. data/rust/codegen/src/queries.rs +601 -226
  233. data/rust/codegen/src/reactions.rs +268 -74
  234. data/rust/codegen/src/read_models.rs +574 -229
  235. data/rust/codegen/src/reference_specs.rs +16 -3
  236. data/rust/codegen/src/registry.rs +128 -79
  237. data/rust/codegen/src/reserved_names.rs +6 -50
  238. data/rust/codegen/src/shared.rs +71 -22
  239. data/rust/codegen/src/sidecars.rs +15 -5
  240. data/rust/codegen/src/skip_reason.rs +8 -2
  241. data/rust/codegen/src/types.rs +306 -108
  242. data/rust/host/Cargo.lock +1 -0
  243. data/rust/host/Cargo.toml +40 -4
  244. data/rust/host/HECKS_RELEASE +1 -1
  245. data/rust/host/clippy.toml +4 -0
  246. data/rust/host/src/api.rs +698 -218
  247. data/rust/host/src/auth.rs +491 -214
  248. data/rust/host/src/b64.rs +91 -0
  249. data/rust/host/src/bin/expr_harness.rs +6 -2
  250. data/rust/host/src/bin/lineage_harness.rs +47 -11
  251. data/rust/host/src/bin/mint_harness.rs +70 -37
  252. data/rust/host/src/bin/validate_harness.rs +16 -8
  253. data/rust/host/src/boot.rs +144 -68
  254. data/rust/host/src/boundary_fuzz/api.rs +130 -32
  255. data/rust/host/src/boundary_fuzz/auth.rs +108 -23
  256. data/rust/host/src/boundary_fuzz/expr_json.rs +85 -11
  257. data/rust/host/src/boundary_fuzz/ir.rs +164 -12
  258. data/rust/host/src/boundary_fuzz/server.rs +283 -36
  259. data/rust/host/src/boundary_fuzz/web.rs +56 -8
  260. data/rust/host/src/checkout.rs +78 -32
  261. data/rust/host/src/clock.rs +8 -0
  262. data/rust/host/src/commerce_ir.rs +333 -95
  263. data/rust/host/src/crypto.rs +33 -0
  264. data/rust/host/src/dispatch.rs +1037 -331
  265. data/rust/host/src/expr_json.rs +1021 -273
  266. data/rust/host/src/extension.rs +24 -4
  267. data/rust/host/src/field_hints.rs +11 -7
  268. data/rust/host/src/fuzz_support.rs +85 -13
  269. data/rust/host/src/host.rs +100 -0
  270. data/rust/host/src/http.rs +22 -0
  271. data/rust/host/src/ir.rs +326 -16
  272. data/rust/host/src/journal.rs +277 -176
  273. data/rust/host/src/lambda_client.rs +219 -53
  274. data/rust/host/src/lib.rs +16 -4
  275. data/rust/host/src/log.rs +62 -11
  276. data/rust/host/src/mint.rs +1153 -326
  277. data/rust/host/src/needs.rs +185 -44
  278. data/rust/host/src/payments/keystore.rs +197 -45
  279. data/rust/host/src/payments/roles.rs +39 -7
  280. data/rust/host/src/payments/tests.rs +1427 -282
  281. data/rust/host/src/payments.rs +391 -112
  282. data/rust/host/src/platform_signin/attack_tests.rs +5 -5
  283. data/rust/host/src/platform_signin/config.rs +11 -11
  284. data/rust/host/src/platform_signin/cookie.rs +2 -2
  285. data/rust/host/src/platform_signin/extension.rs +2 -1
  286. data/rust/host/src/platform_signin/handler.rs +9 -8
  287. data/rust/host/src/platform_signin/jti.rs +4 -2
  288. data/rust/host/src/platform_signin/jwks.rs +2 -1
  289. data/rust/host/src/platform_signin/jws.rs +2 -37
  290. data/rust/host/src/platform_signin/known_answer_tests.rs +5 -1
  291. data/rust/host/src/platform_signin/mode_tests.rs +5 -3
  292. data/rust/host/src/platform_signin/pages.rs +20 -20
  293. data/rust/host/src/platform_signin/session.rs +8 -1
  294. data/rust/host/src/platform_signin/settings.rs +6 -6
  295. data/rust/host/src/platform_signin/strict_json.rs +12 -9
  296. data/rust/host/src/platform_signin/verify.rs +41 -41
  297. data/rust/host/src/platform_signin/wiring_support.rs +13 -18
  298. data/rust/host/src/presentation.rs +207 -63
  299. data/rust/host/src/presentation_write.rs +502 -187
  300. data/rust/host/src/query_step.rs +69 -18
  301. data/rust/host/src/rate_limit.rs +541 -142
  302. data/rust/host/src/reference_transform.rs +77 -25
  303. data/rust/host/src/reference_validate.rs +443 -104
  304. data/rust/host/src/resend.rs +190 -55
  305. data/rust/host/src/secrets.rs +116 -21
  306. data/rust/host/src/server.rs +555 -104
  307. data/rust/host/src/storage_shape.rs +88 -21
  308. data/rust/host/src/test_pg.rs +17 -7
  309. data/rust/host/src/ui_schema.rs +495 -207
  310. data/rust/host/src/wasm_runner.rs +21 -6
  311. data/rust/host/src/web/commerce.rs +101 -23
  312. data/rust/host/src/web/newsletter/cooldown.rs +10 -3
  313. data/rust/host/src/web/newsletter/template.rs +26 -7
  314. data/rust/host/src/web/newsletter/tests.rs +112 -23
  315. data/rust/host/src/web/newsletter.rs +209 -59
  316. data/rust/host/src/web/newsletter_send.rs +251 -79
  317. data/rust/host/src/web/registration_receipt/tests.rs +16 -4
  318. data/rust/host/src/web/registration_receipt.rs +100 -24
  319. data/rust/host/src/web/registrations.rs +1384 -332
  320. data/rust/host/src/web/resend_webhook.rs +138 -80
  321. data/rust/host/src/web.rs +2416 -508
  322. data/rust/lsp/Cargo.toml +36 -0
  323. data/rust/lsp/clippy.toml +4 -0
  324. data/rust/lsp/src/json.rs +29 -22
  325. data/rust/lsp/src/main.rs +81 -31
  326. data/rust/lsp/src/outline.rs +23 -7
  327. data/rust/lsp/src/rpc.rs +17 -4
  328. data/rust/parser/Cargo.toml +36 -0
  329. data/rust/parser/clippy.toml +4 -0
  330. data/rust/parser/src/build/closed_sets.rs +6 -1
  331. data/rust/parser/src/build/identity.rs +4 -3
  332. data/rust/parser/src/build/mod.rs +1 -1
  333. data/rust/parser/src/build/named_pattern.rs +4 -1
  334. data/rust/parser/src/build/naming.rs +2 -80
  335. data/rust/parser/src/build/pattern_subset.rs +6 -8
  336. data/rust/parser/src/build/query_derive.rs +3 -1
  337. data/rust/parser/src/build/query_inference.rs +14 -5
  338. data/rust/parser/src/build/read_model.rs +4 -1
  339. data/rust/parser/src/build/references.rs +4 -3
  340. data/rust/parser/src/canonical.rs +34 -15
  341. data/rust/parser/src/emit.rs +187 -105
  342. data/rust/parser/src/expr/ast_json.rs +134 -42
  343. data/rust/parser/src/expr/evaluator.rs +27 -9
  344. data/rust/parser/src/expr/mod.rs +54 -13
  345. data/rust/parser/src/expr/resolver.rs +299 -92
  346. data/rust/parser/src/ir.rs +35 -3
  347. data/rust/parser/src/keywords.rs +7168 -516
  348. data/rust/parser/src/lex.rs +11 -25
  349. data/rust/parser/src/lib.rs +1 -0
  350. data/rust/parser/src/main.rs +65 -6
  351. data/rust/parser/src/parse/adapter.rs +157 -0
  352. data/rust/parser/src/parse/aggregate.rs +19 -10
  353. data/rust/parser/src/parse/chapter.rs +16 -17
  354. data/rust/parser/src/parse/command.rs +35 -16
  355. data/rust/parser/src/parse/domain_port.rs +4 -7
  356. data/rust/parser/src/parse/entity.rs +21 -11
  357. data/rust/parser/src/parse/hecksagon.rs +10 -4
  358. data/rust/parser/src/parse/lifecycle.rs +9 -5
  359. data/rust/parser/src/parse/mod.rs +22 -91
  360. data/rust/parser/src/parse/needs.rs +21 -12
  361. data/rust/parser/src/parse/policy.rs +2 -2
  362. data/rust/parser/src/parse/process_manager.rs +9 -11
  363. data/rust/parser/src/parse/query.rs +8 -4
  364. data/rust/parser/src/parse/read_model.rs +57 -16
  365. data/rust/parser/src/parse/value_object.rs +7 -2
  366. data/rust/parser/src/parse/world.rs +322 -0
  367. data/rust/parser/src/ruby_value.rs +7 -6
  368. data/rust/src/exemplar/json.rs +4 -4
  369. data/rust/src/exemplar/mutations.rs +18 -0
  370. data/rust/src/exemplar/read_models.rs +1 -10
  371. data/rust/src/kernel/attribute_shapes/composite.rs +54 -14
  372. data/rust/src/kernel/attribute_shapes/list.rs +2 -2
  373. data/rust/src/kernel/attribute_shapes/mod.rs +8 -8
  374. data/rust/src/kernel/attribute_shapes/scalar.rs +2 -2
  375. data/rust/src/kernel/cli.rs +244 -114
  376. data/rust/src/kernel/dispatch.rs +323 -115
  377. data/rust/src/kernel/dispatch_guard_tests.rs +327 -65
  378. data/rust/src/kernel/expr.rs +203 -86
  379. data/rust/src/kernel/expression_operators/arithmetic.rs +138 -25
  380. data/rust/src/kernel/expression_operators/comparison.rs +87 -2
  381. data/rust/src/kernel/expression_operators/enumeration.rs +106 -22
  382. data/rust/src/kernel/expression_operators/logical.rs +9 -3
  383. data/rust/src/kernel/expression_operators/membership.rs +3 -1
  384. data/rust/src/kernel/expression_operators/mod.rs +34 -21
  385. data/rust/src/kernel/expression_operators/pattern_match.rs +46 -11
  386. data/rust/src/kernel/expression_operators/positional.rs +84 -30
  387. data/rust/src/kernel/expression_operators/presence.rs +52 -13
  388. data/rust/src/kernel/expression_operators/sign_test.rs +3 -1
  389. data/rust/src/kernel/expression_operators/sized.rs +24 -8
  390. data/rust/src/kernel/expression_operators/text.rs +120 -27
  391. data/rust/src/kernel/expression_operators/to_string.rs +10 -4
  392. data/rust/src/kernel/json.rs +244 -148
  393. data/rust/src/kernel/mod.rs +47 -33
  394. data/rust/src/kernel/named_query.rs +63 -15
  395. data/rust/src/kernel/needs.rs +112 -25
  396. data/rust/src/kernel/orchestrate.rs +980 -581
  397. data/rust/src/kernel/orchestrate_guard_tests.rs +538 -140
  398. data/rust/src/kernel/pattern.rs +107 -34
  399. data/rust/src/kernel/present.rs +261 -0
  400. data/rust/src/kernel/query_comparators.rs +118 -41
  401. data/rust/src/kernel/query_ordering.rs +13 -5
  402. data/rust/src/kernel/read_model.rs +479 -181
  403. data/rust/src/kernel/reference_lookup.rs +122 -26
  404. data/rust/src/kernel/repository.rs +293 -66
  405. data/rust/src/kernel/routing.rs +58 -42
  406. data/rust/src/kernel/ruby_float.rs +139 -0
  407. data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +57 -57
  408. data/rust/src/kernel/vocab/entity_dispatch_order.rs +54 -54
  409. data/rust/src/kernel/vocab/field_hint.rs +19 -25
  410. data/rust/src/kernel/vocab/mod.rs +6 -6
  411. data/rust/src/kernel/vocab/query_comparator.rs +23 -24
  412. data/rust/src/kernel/vocab/refusal_template.rs +501 -297
  413. data/rust/src/lib.rs +50 -0
  414. data/rust/src/main.rs +4 -1
  415. data/rust/web/Cargo.toml +36 -0
  416. data/rust/web/clippy.toml +4 -0
  417. metadata +85 -15
  418. data/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +0 -36
  419. data/lib/hecks/adapters/driven/google_authentication.adapter +0 -3
  420. data/lib/hecks/adapters/driven/google_authentication.rb +0 -99
  421. data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +0 -3
  422. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +0 -32
  423. data/lib/hecks/ports/authentication.port +0 -6
  424. data/lib/hecks/ports/authentication.rb +0 -67
  425. data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +0 -255
  426. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +0 -22
  427. data/rust/host/src/approval.rs +0 -652
@@ -1,9 +1,10 @@
1
1
  use super::{instances_for, last_refusal, respond, with_id};
2
2
  use crate::checkout;
3
- use crate::dispatch;
3
+ use crate::clock::unix_now;
4
4
  use crate::commerce_ir::{payments_provider, PaymentsProvider};
5
+ use crate::dispatch;
6
+ use crate::host::Host;
5
7
  use crate::journal::LineageConfig;
6
- use crate::lambda_client::LambdaInvoker;
7
8
  use crate::payments;
8
9
  use serde_json::{json, Value};
9
10
  use std::path::Path;
@@ -31,7 +32,13 @@ fn payment_processor(read: &Value, payments: &PaymentsProvider, reference: &str)
31
32
  instances_for(read, &payments.instance_prefix())
32
33
  .into_iter()
33
34
  .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
+ .and_then(|(_, payment)| {
36
+ payment
37
+ .get("processor")
38
+ .and_then(|p| p.get("value"))
39
+ .and_then(|v| v.as_str())
40
+ .map(String::from)
41
+ })
35
42
  }
36
43
 
37
44
  // Payment statuses whose registration still holds a seat come from the
@@ -55,22 +62,40 @@ fn plain_id(raw: Option<&Value>) -> Option<String> {
55
62
 
56
63
  /// Seats used per event slug: Registrations not in a seat-freeing state whose Payment (same
57
64
  /// reference) is in a seat-holding status.
58
- fn seat_counts(read: &Value, domain: &str, payments: &PaymentsProvider) -> std::collections::HashMap<String, usize> {
65
+ fn seat_counts(
66
+ read: &Value,
67
+ domain: &str,
68
+ payments: &PaymentsProvider,
69
+ ) -> std::collections::HashMap<String, usize> {
59
70
  let binding = crate::commerce_ir::registrations_binding(domain);
60
71
  count_seats(read, &binding, payments)
61
72
  }
62
73
 
63
74
  /// The seat count of `seat_counts` against an explicit registrations binding.
64
- fn count_seats(read: &Value, binding: &crate::commerce_ir::RegistrationsProvider, payments: &PaymentsProvider) -> std::collections::HashMap<String, usize> {
65
- let statuses: std::collections::HashMap<String, String> = instances_for(read, &payments.instance_prefix())
66
- .into_iter()
67
- .filter_map(|(id, payment)| payment.get("status").and_then(|s| s.as_str()).map(|s| (id, s.to_string())))
68
- .collect();
75
+ fn count_seats(
76
+ read: &Value,
77
+ binding: &crate::commerce_ir::RegistrationsProvider,
78
+ payments: &PaymentsProvider,
79
+ ) -> std::collections::HashMap<String, usize> {
80
+ let statuses: std::collections::HashMap<String, String> =
81
+ instances_for(read, &payments.instance_prefix())
82
+ .into_iter()
83
+ .filter_map(|(id, payment)| {
84
+ payment
85
+ .get("status")
86
+ .and_then(|s| s.as_str())
87
+ .map(|s| (id, s.to_string()))
88
+ })
89
+ .collect();
69
90
  let mut counts = std::collections::HashMap::new();
70
91
  for (id, registration) in instances_for(read, &binding.registration_prefix()) {
71
- let holds = statuses.get(&id).is_some_and(|status| payments.holds_seat.iter().any(|held| held == status));
92
+ let holds = statuses
93
+ .get(&id)
94
+ .is_some_and(|status| payments.holds_seat.iter().any(|held| held == status));
72
95
  let freed = registration_frees_seat(&registration, &binding.frees_seat);
73
- if let (true, false, Some(event_slug)) = (holds, freed, plain_id(registration.get("event_slug"))) {
96
+ if let (true, false, Some(event_slug)) =
97
+ (holds, freed, plain_id(registration.get("event_slug")))
98
+ {
74
99
  *counts.entry(event_slug).or_insert(0) += 1;
75
100
  }
76
101
  }
@@ -78,13 +103,24 @@ fn count_seats(read: &Value, binding: &crate::commerce_ir::RegistrationsProvider
78
103
  }
79
104
 
80
105
  /// How many seats one event has used.
81
- pub(crate) fn seats_taken(read: &Value, domain: &str, payments: &PaymentsProvider, event_slug: &str) -> usize {
82
- seat_counts(read, domain, payments).get(event_slug).copied().unwrap_or(0)
106
+ pub(crate) fn seats_taken(
107
+ read: &Value,
108
+ domain: &str,
109
+ payments: &PaymentsProvider,
110
+ event_slug: &str,
111
+ ) -> usize {
112
+ seat_counts(read, domain, payments)
113
+ .get(event_slug)
114
+ .copied()
115
+ .unwrap_or(0)
83
116
  }
84
117
 
85
118
  /// An event's capacity, when it carries a readable one.
86
119
  fn event_capacity(event: &Value) -> Option<i64> {
87
- event.get("capacity").and_then(|c| c.get("value")).and_then(|v| v.as_i64())
120
+ event
121
+ .get("capacity")
122
+ .and_then(|c| c.get("value"))
123
+ .and_then(serde_json::Value::as_i64)
88
124
  }
89
125
 
90
126
  /// A whole-number field read as a bare number or as a single-field value
@@ -92,7 +128,9 @@ fn event_capacity(event: &Value) -> Option<i64> {
92
128
  /// never blocks a registration.
93
129
  fn whole_number(field: Option<&Value>) -> Option<i64> {
94
130
  let field = field?;
95
- field.as_i64().or_else(|| field.get("value").and_then(|v| v.as_i64()))
131
+ field
132
+ .as_i64()
133
+ .or_else(|| field.get("value").and_then(serde_json::Value::as_i64))
96
134
  }
97
135
 
98
136
  /// Whether an event's registration window has shut because its start is
@@ -102,7 +140,10 @@ fn whole_number(field: Option<&Value>) -> Option<i64> {
102
140
  /// cutoff is still open. Either field absent, null or unreadable means no
103
141
  /// cutoff, the stance the seat check takes.
104
142
  fn registration_cutoff_passed(event: &Value, now_secs: i64) -> bool {
105
- let (Some(starts_at), Some(hours)) = (whole_number(event.get("starts_at")), whole_number(event.get("registration_cutoff_hours"))) else {
143
+ let (Some(starts_at), Some(hours)) = (
144
+ whole_number(event.get("starts_at")),
145
+ whole_number(event.get("registration_cutoff_hours")),
146
+ ) else {
106
147
  return false;
107
148
  };
108
149
  now_secs.saturating_add(hours.saturating_mul(3600)) > starts_at
@@ -115,10 +156,21 @@ fn open_seats(capacity: i64, taken: usize) -> i64 {
115
156
 
116
157
  /// Seats still open on one event, never below zero; `None` when there is no
117
158
  /// such event or it carries no readable capacity.
118
- pub(crate) fn seats_left(read: &Value, domain: &str, payments: &PaymentsProvider, event_slug: &str) -> Option<i64> {
119
- let events = instances_for(read, &crate::commerce_ir::registrations_binding(domain).event_prefix());
159
+ pub(crate) fn seats_left(
160
+ read: &Value,
161
+ domain: &str,
162
+ payments: &PaymentsProvider,
163
+ event_slug: &str,
164
+ ) -> Option<i64> {
165
+ let events = instances_for(
166
+ read,
167
+ &crate::commerce_ir::registrations_binding(domain).event_prefix(),
168
+ );
120
169
  let (_, event) = events.iter().find(|(id, _)| id == event_slug)?;
121
- Some(open_seats(event_capacity(event)?, seats_taken(read, domain, payments, event_slug)))
170
+ Some(open_seats(
171
+ event_capacity(event)?,
172
+ seats_taken(read, domain, payments, event_slug),
173
+ ))
122
174
  }
123
175
 
124
176
  /// The seat figures a caller reads for one event: `capacity` and `seats_left`
@@ -131,15 +183,33 @@ fn seat_figures(event: &Value, taken: usize) -> Value {
131
183
  /// The seat figures of every event, keyed by event slug.
132
184
  fn all_seat_figures(read: &Value, domain: &str, payments: &PaymentsProvider) -> Value {
133
185
  let counts = seat_counts(read, domain, payments);
134
- let events = instances_for(read, &crate::commerce_ir::registrations_binding(domain).event_prefix());
135
- let figures: serde_json::Map<String, Value> =
136
- events.iter().map(|(slug, event)| (slug.clone(), seat_figures(event, counts.get(slug).copied().unwrap_or(0)))).collect();
186
+ let events = instances_for(
187
+ read,
188
+ &crate::commerce_ir::registrations_binding(domain).event_prefix(),
189
+ );
190
+ let figures: serde_json::Map<String, Value> = events
191
+ .iter()
192
+ .map(|(slug, event)| {
193
+ (
194
+ slug.clone(),
195
+ seat_figures(event, counts.get(slug).copied().unwrap_or(0)),
196
+ )
197
+ })
198
+ .collect();
137
199
  json!({"events": figures})
138
200
  }
139
201
 
140
202
  /// The seat figures of one event, with its `slug`; `None` when there is no such event.
141
- fn one_seat_figures(read: &Value, domain: &str, payments: &PaymentsProvider, event_slug: &str) -> Option<Value> {
142
- let events = instances_for(read, &crate::commerce_ir::registrations_binding(domain).event_prefix());
203
+ fn one_seat_figures(
204
+ read: &Value,
205
+ domain: &str,
206
+ payments: &PaymentsProvider,
207
+ event_slug: &str,
208
+ ) -> Option<Value> {
209
+ let events = instances_for(
210
+ read,
211
+ &crate::commerce_ir::registrations_binding(domain).event_prefix(),
212
+ );
143
213
  let (_, event) = events.iter().find(|(id, _)| id == event_slug)?;
144
214
  let mut figures = seat_figures(event, seats_taken(read, domain, payments, event_slug));
145
215
  figures["slug"] = json!(event_slug);
@@ -154,7 +224,13 @@ fn seats_path_slug(path: &str) -> Option<&str> {
154
224
 
155
225
  /// GET /events/seats and GET /events/<slug>/seats: the seat rule as a read,
156
226
  /// so a site never has to count registrations itself.
157
- async fn seats_route(path: &str, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, payments: &PaymentsProvider) -> Value {
227
+ async fn seats_route(
228
+ path: &str,
229
+ client: &Mutex<Client>,
230
+ wasm_path: &Path,
231
+ config: &LineageConfig,
232
+ payments: &PaymentsProvider,
233
+ ) -> Value {
158
234
  let read = match dispatch::read(client, wasm_path).await {
159
235
  Ok(r) => r,
160
236
  Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
@@ -163,106 +239,162 @@ async fn seats_route(path: &str, client: &Mutex<Client>, wasm_path: &Path, confi
163
239
  None => all_seat_figures(&read, &config.domain, payments),
164
240
  Some(slug) => match one_seat_figures(&read, &config.domain, payments, slug) {
165
241
  Some(figures) => figures,
166
- None => return respond(404, "application/json", &json!({"error": "no such event"}).to_string()),
242
+ None => {
243
+ return respond(
244
+ 404,
245
+ "application/json",
246
+ &json!({"error": "no such event"}).to_string(),
247
+ )
248
+ }
167
249
  },
168
250
  };
169
251
  respond(200, "application/json", &body.to_string())
170
252
  }
171
253
 
172
- fn unix_now() -> i64 {
173
- std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64
174
- }
175
-
176
- #[allow(clippy::too_many_arguments)]
177
254
  // Serves the checkout, registration-payment and webhook routes only when the
178
255
  // domain's IR declares `provides "payments"`. Takes the IR as a parameter,
179
256
  // not `ir()`, so the gate can be tested against one that lacks the key.
180
- #[allow(clippy::too_many_arguments)]
181
257
  pub(super) async fn payments_routes(
182
258
  domain_ir: Option<&Value>,
183
259
  method: &str,
184
260
  path: &str,
185
261
  raw_body: &str,
186
262
  stripe_signature: &str,
187
- client: &Mutex<Client>,
188
- wasm_path: &Path,
189
- config: &LineageConfig,
190
- invoker: &dyn LambdaInvoker,
263
+ host: Host<'_>,
191
264
  ) -> Option<Value> {
192
265
  let payments = domain_ir.and_then(payments_provider)?;
193
- checkout_route(method, path, raw_body, stripe_signature, client, wasm_path, config, invoker, &payments).await
266
+ checkout_route(method, path, raw_body, stripe_signature, host, &payments).await
194
267
  }
195
-
196
268
  pub(super) async fn checkout_route(
197
269
  method: &str,
198
270
  path: &str,
199
271
  raw_body: &str,
200
272
  stripe_signature: &str,
201
- client: &Mutex<Client>,
202
- wasm_path: &Path,
203
- config: &LineageConfig,
204
- invoker: &dyn LambdaInvoker,
273
+ host: Host<'_>,
205
274
  payments: &PaymentsProvider,
206
275
  ) -> Option<Value> {
276
+ let Host {
277
+ client,
278
+ wasm_path,
279
+ config,
280
+ ..
281
+ } = host;
207
282
  match (method, path) {
208
- ("POST", "/registrations") => {
209
- Some(registrations_route(raw_body, &payments::PlatformConfig::load().await, client, wasm_path, config, invoker, payments).await)
210
- }
283
+ ("POST", "/registrations") => Some(
284
+ registrations_route(
285
+ raw_body,
286
+ &payments::PlatformConfig::load().await,
287
+ host,
288
+ payments,
289
+ )
290
+ .await,
291
+ ),
211
292
  // GET /registrations/:id: read-only, re-derives state server-side
212
293
  // for the confirmation and pay pages rather than trusting their own
213
294
  // query string.
214
295
  ("GET", path) if path.starts_with("/registrations/") && !path.ends_with("/complete") => {
215
296
  let registration_id = path.trim_start_matches("/registrations/");
216
- Some(registration_show_route(registration_id, client, wasm_path, config, payments).await)
297
+ Some(
298
+ registration_show_route(registration_id, client, wasm_path, config, payments).await,
299
+ )
217
300
  }
218
301
  // POST /registrations/:id/complete: mock checkout's Pay/Cancel button,
219
302
  // refused unless the Payment's own processor is "mock_stripe". Settles
220
303
  // through the same PaymentGateway port as POST /webhooks/stripe.
221
304
  ("POST", path) if path.starts_with("/registrations/") && path.ends_with("/complete") => {
222
- let registration_id = path.trim_start_matches("/registrations/").trim_end_matches("/complete").trim_end_matches('/');
223
- Some(registration_complete_route(registration_id, raw_body, client, wasm_path, config, invoker, payments).await)
224
- }
225
- ("POST", "/webhooks/stripe") => {
226
- Some(webhook_route(raw_body, stripe_signature, &payments::PlatformConfig::load().await, client, wasm_path, config, invoker, payments).await)
305
+ let registration_id = path
306
+ .trim_start_matches("/registrations/")
307
+ .trim_end_matches("/complete")
308
+ .trim_end_matches('/');
309
+ Some(registration_complete_route(registration_id, raw_body, host, payments).await)
227
310
  }
311
+ ("POST", "/webhooks/stripe") => Some(
312
+ webhook_route(
313
+ raw_body,
314
+ stripe_signature,
315
+ &payments::PlatformConfig::load().await,
316
+ host,
317
+ payments,
318
+ )
319
+ .await,
320
+ ),
228
321
  // POST /events: a mock-payments service schedules a new session
229
322
  // when an editor picks a date. Idempotent — Event.find first means
230
323
  // a repeat call for the same slug is a no-op, not an error.
231
- ("GET", "/events/seats") => Some(seats_route(path, client, wasm_path, config, payments).await),
232
- ("GET", path) if seats_path_slug(path).is_some() => Some(seats_route(path, client, wasm_path, config, payments).await),
233
- ("POST", "/events") => Some(events_route(raw_body, client, wasm_path, config, invoker).await),
324
+ ("GET", "/events/seats") => {
325
+ Some(seats_route(path, client, wasm_path, config, payments).await)
326
+ }
327
+ ("GET", path) if seats_path_slug(path).is_some() => {
328
+ Some(seats_route(path, client, wasm_path, config, payments).await)
329
+ }
330
+ ("POST", "/events") => Some(events_route(raw_body, host).await),
234
331
  _ => None,
235
332
  }
236
333
  }
237
334
 
238
335
  /// POST /events. Dispatches `<domain>::Event.Schedule` — Event is this
239
336
  /// deploy's own top-level aggregate, not a vendored chapter like Payments.
240
- async fn events_route(raw_body: &str, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, invoker: &dyn LambdaInvoker) -> Value {
337
+ async fn events_route(raw_body: &str, host: Host<'_>) -> Value {
338
+ let Host {
339
+ client,
340
+ wasm_path,
341
+ config,
342
+ ..
343
+ } = host;
241
344
  let body: Value = match serde_json::from_str(raw_body) {
242
345
  Ok(v) => v,
243
- Err(e) => return respond(400, "application/json", &json!({"error": format!("invalid JSON: {e}")}).to_string()),
346
+ Err(e) => {
347
+ return respond(
348
+ 400,
349
+ "application/json",
350
+ &json!({"error": format!("invalid JSON: {e}")}).to_string(),
351
+ )
352
+ }
244
353
  };
245
354
  let Some(slug) = body.get("slug").and_then(|v| v.as_str()) else {
246
- return respond(400, "application/json", &json!({"error": "missing slug"}).to_string());
355
+ return respond(
356
+ 400,
357
+ "application/json",
358
+ &json!({"error": "missing slug"}).to_string(),
359
+ );
247
360
  };
248
361
 
249
362
  let read = match dispatch::read(client, wasm_path).await {
250
363
  Ok(r) => r,
251
364
  Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
252
365
  };
253
- let events = instances_for(&read, &crate::commerce_ir::registrations_binding(&config.domain).event_prefix());
366
+ let events = instances_for(
367
+ &read,
368
+ &crate::commerce_ir::registrations_binding(&config.domain).event_prefix(),
369
+ );
254
370
  if let Some((_, existing)) = events.iter().find(|(id, _)| id == slug) {
255
- return respond(200, "application/json", &serde_json::to_string_pretty(&with_id(slug, existing)).unwrap_or_default());
371
+ return respond(
372
+ 200,
373
+ "application/json",
374
+ &serde_json::to_string_pretty(&with_id(slug, existing)).unwrap_or_default(),
375
+ );
256
376
  }
257
377
 
258
378
  let Some(name) = body.get("name").and_then(|v| v.as_str()) else {
259
- return respond(400, "application/json", &json!({"error": "missing name"}).to_string());
379
+ return respond(
380
+ 400,
381
+ "application/json",
382
+ &json!({"error": "missing name"}).to_string(),
383
+ );
260
384
  };
261
- let Some(price_cents) = body.get("price_cents").and_then(|v| v.as_i64()) else {
262
- return respond(400, "application/json", &json!({"error": "missing price_cents"}).to_string());
385
+ let Some(price_cents) = body.get("price_cents").and_then(serde_json::Value::as_i64) else {
386
+ return respond(
387
+ 400,
388
+ "application/json",
389
+ &json!({"error": "missing price_cents"}).to_string(),
390
+ );
263
391
  };
264
- let Some(capacity) = body.get("capacity").and_then(|v| v.as_i64()) else {
265
- return respond(400, "application/json", &json!({"error": "missing capacity"}).to_string());
392
+ let Some(capacity) = body.get("capacity").and_then(serde_json::Value::as_i64) else {
393
+ return respond(
394
+ 400,
395
+ "application/json",
396
+ &json!({"error": "missing capacity"}).to_string(),
397
+ );
266
398
  };
267
399
 
268
400
  let args = json!({
@@ -272,44 +404,83 @@ async fn events_route(raw_body: &str, client: &Mutex<Client>, wasm_path: &Path,
272
404
  "capacity": {"value": capacity},
273
405
  });
274
406
  let verb = crate::commerce_ir::registrations_binding(&config.domain).schedule;
275
- let outcome = match dispatch::handle(client, wasm_path, &verb, args, None, config, invoker).await {
407
+ let outcome = match dispatch::handle(host, &verb, args, None).await {
276
408
  Ok(o) => o,
277
409
  Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
278
410
  };
279
411
  if !outcome.accepted {
280
- return respond(422, "application/json", &last_refusal(&outcome.result).to_string());
412
+ return respond(
413
+ 422,
414
+ "application/json",
415
+ &last_refusal(&outcome.result).to_string(),
416
+ );
281
417
  }
282
418
 
283
419
  let read = match dispatch::read(client, wasm_path).await {
284
420
  Ok(r) => r,
285
421
  Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
286
422
  };
287
- let events = instances_for(&read, &crate::commerce_ir::registrations_binding(&config.domain).event_prefix());
423
+ let events = instances_for(
424
+ &read,
425
+ &crate::commerce_ir::registrations_binding(&config.domain).event_prefix(),
426
+ );
288
427
  let Some((_, event)) = events.iter().find(|(id, _)| id == slug) else {
289
- return respond(500, "text/plain", "event vanished immediately after being scheduled");
428
+ return respond(
429
+ 500,
430
+ "text/plain",
431
+ "event vanished immediately after being scheduled",
432
+ );
290
433
  };
291
- respond(201, "application/json", &serde_json::to_string_pretty(&with_id(slug, event)).unwrap_or_default())
434
+ respond(
435
+ 201,
436
+ "application/json",
437
+ &serde_json::to_string_pretty(&with_id(slug, event)).unwrap_or_default(),
438
+ )
292
439
  }
293
440
 
294
441
  /// GET /registrations/:id: event and attendee fields, plus amount_cents and
295
442
  /// payment_status from the same-reference Payment.
296
- async fn registration_show_route(registration_id: &str, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, payments: &PaymentsProvider) -> Value {
443
+ async fn registration_show_route(
444
+ registration_id: &str,
445
+ client: &Mutex<Client>,
446
+ wasm_path: &Path,
447
+ config: &LineageConfig,
448
+ payments: &PaymentsProvider,
449
+ ) -> Value {
297
450
  let read = match dispatch::read(client, wasm_path).await {
298
451
  Ok(r) => r,
299
452
  Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
300
453
  };
301
- let registrations = instances_for(&read, &crate::commerce_ir::registrations_binding(&config.domain).registration_prefix());
454
+ let registrations = instances_for(
455
+ &read,
456
+ &crate::commerce_ir::registrations_binding(&config.domain).registration_prefix(),
457
+ );
302
458
  let Some((_, registration)) = registrations.iter().find(|(id, _)| id == registration_id) else {
303
- return respond(404, "application/json", &json!({"error": "no such registration"}).to_string());
459
+ return respond(
460
+ 404,
461
+ "application/json",
462
+ &json!({"error": "no such registration"}).to_string(),
463
+ );
304
464
  };
305
465
  let event_slug = registration.get("event_slug").and_then(|v| v.as_str());
306
- let events = instances_for(&read, &crate::commerce_ir::registrations_binding(&config.domain).event_prefix());
307
- let event = event_slug.and_then(|slug| events.iter().find(|(id, _)| id == slug)).map(|(_, e)| e);
466
+ let events = instances_for(
467
+ &read,
468
+ &crate::commerce_ir::registrations_binding(&config.domain).event_prefix(),
469
+ );
470
+ let event = event_slug
471
+ .and_then(|slug| events.iter().find(|(id, _)| id == slug))
472
+ .map(|(_, e)| e);
308
473
 
309
474
  let payment_instances = instances_for(&read, &payments.instance_prefix());
310
- let payment = payment_instances.iter().find(|(id, _)| id == registration_id).map(|(_, p)| p);
311
-
312
- let attendee = registration.get("attendee").cloned().unwrap_or_else(|| json!({}));
475
+ let payment = payment_instances
476
+ .iter()
477
+ .find(|(id, _)| id == registration_id)
478
+ .map(|(_, p)| p);
479
+
480
+ let attendee = registration
481
+ .get("attendee")
482
+ .cloned()
483
+ .unwrap_or_else(|| json!({}));
313
484
  respond(200, "application/json", &json!({
314
485
  "registration_id": registration_id,
315
486
  "event": {
@@ -332,35 +503,65 @@ async fn registration_show_route(registration_id: &str, client: &Mutex<Client>,
332
503
  pub(crate) async fn registration_complete_route(
333
504
  registration_id: &str,
334
505
  raw_body: &str,
335
- client: &Mutex<Client>,
336
- wasm_path: &Path,
337
- config: &LineageConfig,
338
- invoker: &dyn LambdaInvoker,
506
+ host: Host<'_>,
339
507
  payments: &PaymentsProvider,
340
508
  ) -> Value {
509
+ let Host {
510
+ client,
511
+ wasm_path,
512
+ config,
513
+ ..
514
+ } = host;
341
515
  let body: Value = match serde_json::from_str(raw_body) {
342
516
  Ok(v) => v,
343
- Err(e) => return respond(400, "application/json", &json!({"error": format!("invalid JSON: {e}")}).to_string()),
517
+ Err(e) => {
518
+ return respond(
519
+ 400,
520
+ "application/json",
521
+ &json!({"error": format!("invalid JSON: {e}")}).to_string(),
522
+ )
523
+ }
344
524
  };
345
525
  let Some(outcome) = body.get("outcome").and_then(|v| v.as_str()) else {
346
- return respond(400, "application/json", &json!({"error": "missing outcome"}).to_string());
526
+ return respond(
527
+ 400,
528
+ "application/json",
529
+ &json!({"error": "missing outcome"}).to_string(),
530
+ );
347
531
  };
348
532
  if outcome != "succeeded" && outcome != "failed" {
349
- return respond(400, "application/json", &json!({"error": "outcome must be \"succeeded\" or \"failed\""}).to_string());
533
+ return respond(
534
+ 400,
535
+ "application/json",
536
+ &json!({"error": "outcome must be \"succeeded\" or \"failed\""}).to_string(),
537
+ );
350
538
  }
351
539
 
352
540
  let read = match dispatch::read(client, wasm_path).await {
353
541
  Ok(r) => r,
354
542
  Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
355
543
  };
356
- let registrations = instances_for(&read, &crate::commerce_ir::registrations_binding(&config.domain).registration_prefix());
544
+ let registrations = instances_for(
545
+ &read,
546
+ &crate::commerce_ir::registrations_binding(&config.domain).registration_prefix(),
547
+ );
357
548
  if !registrations.iter().any(|(id, _)| id == registration_id) {
358
- return respond(404, "application/json", &json!({"error": "no such registration"}).to_string());
549
+ return respond(
550
+ 404,
551
+ "application/json",
552
+ &json!({"error": "no such registration"}).to_string(),
553
+ );
359
554
  }
360
555
 
361
556
  let processor = match payment_processor(&read, payments, registration_id) {
362
557
  Some(processor) if processor == "mock_stripe" => processor,
363
- _ => return respond(403, "application/json", &json!({"error": "not available with a real payment processor"}).to_string()),
558
+ _ => {
559
+ return respond(
560
+ 403,
561
+ "application/json",
562
+ &json!({"error": "not available with a real payment processor"}).to_string(),
563
+ )
564
+ }
364
565
  };
365
566
  let reported_processor = json!({"value": processor});
366
567
  // `reference_to Payment, as: :reference` keeps `reference` inside `with:`
@@ -378,16 +579,20 @@ pub(crate) async fn registration_complete_route(
378
579
  json!({"reference": reference_fact, "reason": {"value": "declined_at_local_checkout"}, "reported_processor": reported_processor}),
379
580
  )
380
581
  };
381
- let outcome_result = match dispatch::handle_routed(client, wasm_path, verb, json!(registration_id), facts, None, config, invoker).await {
382
- Ok(o) => o,
383
- Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
384
- };
582
+ let outcome_result =
583
+ match dispatch::handle_routed(host, verb, json!(registration_id), facts, None).await {
584
+ Ok(o) => o,
585
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
586
+ };
385
587
  // A repeat Succeeded/Failed on an already-settled Payment is a silent
386
588
  // no-op on Payment's own `given`, so it isn't surfaced as a 422 here
387
589
  // either.
388
590
  if !outcome_result.accepted {
389
591
  let refusal = last_refusal(&outcome_result.result);
390
- let already_settled = refusal.get("error").and_then(|v| v.as_str()).map(|s| s.contains("pending")).unwrap_or(false);
592
+ let already_settled = refusal
593
+ .get("error")
594
+ .and_then(|v| v.as_str())
595
+ .is_some_and(|s| s.contains("pending"));
391
596
  if !already_settled {
392
597
  return respond(422, "application/json", &refusal.to_string());
393
598
  }
@@ -396,7 +601,7 @@ pub(crate) async fn registration_complete_route(
396
601
  // Only a payment that settled just now earns a receipt; a repeat click was
397
602
  // refused above as already settled.
398
603
  if outcome == "succeeded" && outcome_result.accepted {
399
- super::registration_receipt::send_receipt(registration_id, client, wasm_path, config, payments).await;
604
+ super::registration_receipt::deliver_receipt(registration_id, host, payments).await;
400
605
  }
401
606
 
402
607
  let read = match dispatch::read(client, wasm_path).await {
@@ -404,8 +609,17 @@ pub(crate) async fn registration_complete_route(
404
609
  Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
405
610
  };
406
611
  let payment_instances = instances_for(&read, &payments.instance_prefix());
407
- let status = payment_instances.iter().find(|(id, _)| id == registration_id).and_then(|(_, p)| p.get("status")).and_then(|v| v.as_str()).unwrap_or("");
408
- respond(200, "application/json", &json!({"registration_id": registration_id, "payment_status": status}).to_string())
612
+ let status = payment_instances
613
+ .iter()
614
+ .find(|(id, _)| id == registration_id)
615
+ .and_then(|(_, p)| p.get("status"))
616
+ .and_then(|v| v.as_str())
617
+ .unwrap_or("");
618
+ respond(
619
+ 200,
620
+ "application/json",
621
+ &json!({"registration_id": registration_id, "payment_status": status}).to_string(),
622
+ )
409
623
  }
410
624
 
411
625
  // Attendee shape varies per domain, so this forwards the caller's fields
@@ -436,83 +650,133 @@ fn display_name_from(body: &Value) -> Option<String> {
436
650
  // POST /registrations. Payment first, then Registration, sharing one
437
651
  // reference minted here; `role: None` throughout since the site calls in
438
652
  // server-to-server, not as a signed-in user.
439
- #[allow(clippy::too_many_arguments)]
440
653
  pub(crate) async fn registrations_route(
441
654
  raw_body: &str,
442
655
  platform: &payments::PlatformConfig,
443
- client: &Mutex<Client>,
444
- wasm_path: &Path,
445
- config: &LineageConfig,
446
- invoker: &dyn LambdaInvoker,
656
+ host: Host<'_>,
447
657
  payments: &PaymentsProvider,
448
658
  ) -> Value {
449
- registrations_route_at(raw_body, platform, client, wasm_path, config, invoker, payments, unix_now()).await
659
+ registrations_route_at(raw_body, platform, host, payments, unix_now()).await
450
660
  }
451
661
 
452
662
  // The route with the wall clock passed in, so a test can fix the time the
453
663
  // registration cutoff is measured against.
454
- #[allow(clippy::too_many_arguments)]
455
664
  pub(crate) async fn registrations_route_at(
456
665
  raw_body: &str,
457
666
  platform: &payments::PlatformConfig,
458
- client: &Mutex<Client>,
459
- wasm_path: &Path,
460
- config: &LineageConfig,
461
- invoker: &dyn LambdaInvoker,
667
+ host: Host<'_>,
462
668
  payments: &PaymentsProvider,
463
669
  now_secs: i64,
464
670
  ) -> Value {
671
+ let Host {
672
+ client,
673
+ wasm_path,
674
+ config,
675
+ ..
676
+ } = host;
465
677
  let body: Value = match serde_json::from_str(raw_body) {
466
678
  Ok(v) => v,
467
- Err(e) => return respond(400, "application/json", &json!({"error": format!("invalid JSON: {e}")}).to_string()),
679
+ Err(e) => {
680
+ return respond(
681
+ 400,
682
+ "application/json",
683
+ &json!({"error": format!("invalid JSON: {e}")}).to_string(),
684
+ )
685
+ }
468
686
  };
469
687
  let Some(event_slug) = body.get("event_slug").and_then(|v| v.as_str()) else {
470
- return respond(400, "application/json", &json!({"error": "missing event_slug"}).to_string());
688
+ return respond(
689
+ 400,
690
+ "application/json",
691
+ &json!({"error": "missing event_slug"}).to_string(),
692
+ );
471
693
  };
472
694
  // Cheap early exits before dispatching Payment.Initiate — full Attendee
473
695
  // validation still happens downstream in Registration.Request's own
474
696
  // given/invariant checks.
475
697
  let Some(name) = display_name_from(&body) else {
476
- return respond(400, "application/json", &json!({"error": "missing name (or first_name and last_name)"}).to_string());
698
+ return respond(
699
+ 400,
700
+ "application/json",
701
+ &json!({"error": "missing name (or first_name and last_name)"}).to_string(),
702
+ );
477
703
  };
478
704
  let Some(email) = body.get("email").and_then(|v| v.as_str()) else {
479
- return respond(400, "application/json", &json!({"error": "missing email"}).to_string());
705
+ return respond(
706
+ 400,
707
+ "application/json",
708
+ &json!({"error": "missing email"}).to_string(),
709
+ );
480
710
  };
481
711
 
482
712
  let read = match dispatch::read(client, wasm_path).await {
483
713
  Ok(r) => r,
484
714
  Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
485
715
  };
486
- let events = instances_for(&read, &crate::commerce_ir::registrations_binding(&config.domain).event_prefix());
716
+ let events = instances_for(
717
+ &read,
718
+ &crate::commerce_ir::registrations_binding(&config.domain).event_prefix(),
719
+ );
487
720
  let Some((_, event)) = events.iter().find(|(id, _)| id == event_slug) else {
488
- return respond(404, "application/json", &json!({"error": "no such event"}).to_string());
721
+ return respond(
722
+ 404,
723
+ "application/json",
724
+ &json!({"error": "no such event"}).to_string(),
725
+ );
489
726
  };
490
727
  if event.get("status").and_then(|v| v.as_str()) != Some("open") {
491
- return respond(422, "application/json", &json!({"error": "registration is closed for this event"}).to_string());
728
+ return respond(
729
+ 422,
730
+ "application/json",
731
+ &json!({"error": "registration is closed for this event"}).to_string(),
732
+ );
492
733
  }
493
734
  // An event that opted into a cutoff stops taking registrations that many
494
735
  // hours before it starts; a missing or unreadable field is no cutoff.
495
736
  if registration_cutoff_passed(event, now_secs) {
496
- return respond(422, "application/json", &json!({"error": "registration has closed for this event"}).to_string());
737
+ return respond(
738
+ 422,
739
+ "application/json",
740
+ &json!({"error": "registration has closed for this event"}).to_string(),
741
+ );
497
742
  }
498
743
  // A full event refuses before any Stripe call or write. An event whose
499
744
  // capacity cannot be read is not blocked: capacity is required when an
500
745
  // event is scheduled, so its absence means nothing to enforce.
501
746
  if seats_left(&read, &config.domain, payments, event_slug) == Some(0) {
502
- return respond(409, "application/json", &json!({"error": "this event is full"}).to_string());
747
+ return respond(
748
+ 409,
749
+ "application/json",
750
+ &json!({"error": "this event is full"}).to_string(),
751
+ );
503
752
  }
504
- let price_cents = event.get("price").and_then(|p| p.get("cents")).and_then(|v| v.as_i64()).unwrap_or(0);
505
- let event_name = event.get("name").and_then(|n| n.get("value")).and_then(|v| v.as_str()).unwrap_or("");
753
+ let price_cents = event
754
+ .get("price")
755
+ .and_then(|p| p.get("cents"))
756
+ .and_then(serde_json::Value::as_i64)
757
+ .unwrap_or(0);
758
+ let event_name = event
759
+ .get("name")
760
+ .and_then(|n| n.get("value"))
761
+ .and_then(|v| v.as_str())
762
+ .unwrap_or("");
506
763
 
507
764
  // What checkout does is decided per request from this tenant's own
508
765
  // PaymentConnection, before anything is written — a paused connection
509
766
  // must never fall back to the mock walkthrough.
510
- let plan = payments::checkout_plan(payments::connection(&read, &config.domain).as_ref(), platform);
767
+ let plan = payments::checkout_plan(
768
+ payments::connection(&read, &config.domain).as_ref(),
769
+ platform,
770
+ );
511
771
  let processor = match plan {
512
772
  payments::CheckoutPlan::Mock => "mock_stripe",
513
773
  payments::CheckoutPlan::Stripe { .. } => "stripe",
514
774
  payments::CheckoutPlan::Paused => {
515
- return respond(503, "application/json", &json!({"error": "payments are temporarily unavailable"}).to_string());
775
+ return respond(
776
+ 503,
777
+ "application/json",
778
+ &json!({"error": "payments are temporarily unavailable"}).to_string(),
779
+ );
516
780
  }
517
781
  };
518
782
  let site_url = platform.site_url.as_str();
@@ -522,9 +786,27 @@ pub(crate) async fn registrations_route_at(
522
786
  // A Stripe plan opens its embedded session before anything is written, so
523
787
  // a failure leaves no Payment or Registration behind. If a domain refusal
524
788
  // follows, the unused session simply expires.
525
- let embedded_checkout = if let payments::CheckoutPlan::Stripe { api_key, publishable_key } = &plan {
526
- let auth = checkout::StripeAuth { api_key, base_url: &platform.api_base };
527
- match checkout::create_checkout_session(&auth, price_cents, event_name, &reference, checkout::session_expires_at(now_secs, crate::commerce_ir::checkout_windows_binding().session_hold)).await {
789
+ let embedded_checkout = if let payments::CheckoutPlan::Stripe {
790
+ api_key,
791
+ publishable_key,
792
+ } = &plan
793
+ {
794
+ let auth = checkout::StripeAuth {
795
+ api_key,
796
+ base_url: &platform.api_base,
797
+ };
798
+ match checkout::create_checkout_session(
799
+ &auth,
800
+ price_cents,
801
+ event_name,
802
+ &reference,
803
+ checkout::session_expires_at(
804
+ now_secs,
805
+ crate::commerce_ir::checkout_windows_binding().session_hold,
806
+ ),
807
+ )
808
+ .await
809
+ {
528
810
  // Stripe.js is opened with the publishable key; the answer carries
529
811
  // no `checkout_url`.
530
812
  Ok(session) => Some(json!({
@@ -533,8 +815,14 @@ pub(crate) async fn registrations_route_at(
533
815
  "session_id": session.session_id,
534
816
  })),
535
817
  Err(e) => {
536
- eprintln!("registrations: opening the Stripe session failed, nothing was recorded: {e:#}");
537
- return respond(502, "application/json", &json!({"error": "payments are temporarily unavailable"}).to_string());
818
+ eprintln!(
819
+ "registrations: opening the Stripe session failed, nothing was recorded: {e:#}"
820
+ );
821
+ return respond(
822
+ 502,
823
+ "application/json",
824
+ &json!({"error": "payments are temporarily unavailable"}).to_string(),
825
+ );
538
826
  }
539
827
  }
540
828
  } else {
@@ -548,12 +836,16 @@ pub(crate) async fn registrations_route_at(
548
836
  "amount": {"cents": price_cents},
549
837
  "client": {"name": name, "email": email},
550
838
  });
551
- let outcome = match dispatch::handle(client, wasm_path, &payments.initiate, initiate_args, None, config, invoker).await {
839
+ let outcome = match dispatch::handle(host, &payments.initiate, initiate_args, None).await {
552
840
  Ok(o) => o,
553
841
  Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
554
842
  };
555
843
  if !outcome.accepted {
556
- return respond(422, "application/json", &last_refusal(&outcome.result).to_string());
844
+ return respond(
845
+ 422,
846
+ "application/json",
847
+ &last_refusal(&outcome.result).to_string(),
848
+ );
557
849
  }
558
850
 
559
851
  let mut request_args = json!({
@@ -564,10 +856,17 @@ pub(crate) async fn registrations_route_at(
564
856
  // A reaction reads only fields its event carries, never ones nested
565
857
  // inside `attendee`, so newsletter fields are declared flat on
566
858
  // Registration.Request; a command refuses args it doesn't declare.
567
- let news_signup = body.get("news_signup").and_then(|v| v.as_bool()).unwrap_or(false);
859
+ let news_signup = body
860
+ .get("news_signup")
861
+ .and_then(serde_json::Value::as_bool)
862
+ .unwrap_or(false);
568
863
  let binding = crate::commerce_ir::registrations_binding(&config.domain);
569
864
  let forwards_newsletter = crate::ir::ir().is_some_and(|ir| {
570
- binding.request_target().is_some_and(|(aggregate, command)| crate::commerce_ir::command_declares(ir, aggregate, command, "news_signup"))
865
+ binding
866
+ .request_target()
867
+ .is_some_and(|(aggregate, command)| {
868
+ crate::commerce_ir::command_declares(ir, aggregate, command, "news_signup")
869
+ })
571
870
  });
572
871
  if forwards_newsletter {
573
872
  request_args["news_signup"] = json!(news_signup);
@@ -579,12 +878,16 @@ pub(crate) async fn registrations_route_at(
579
878
  }
580
879
  }
581
880
  let request_verb = crate::commerce_ir::registrations_binding(&config.domain).request;
582
- let outcome = match dispatch::handle(client, wasm_path, &request_verb, request_args, None, config, invoker).await {
881
+ let outcome = match dispatch::handle(host, &request_verb, request_args, None).await {
583
882
  Ok(o) => o,
584
883
  Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
585
884
  };
586
885
  if !outcome.accepted {
587
- return respond(422, "application/json", &last_refusal(&outcome.result).to_string());
886
+ return respond(
887
+ 422,
888
+ "application/json",
889
+ &last_refusal(&outcome.result).to_string(),
890
+ );
588
891
  }
589
892
 
590
893
  // The domain's reaction has already subscribed the address by now; a
@@ -595,7 +898,11 @@ pub(crate) async fn registrations_route_at(
595
898
  }
596
899
 
597
900
  if let Some(embedded) = embedded_checkout {
598
- return respond(200, "application/json", &json!({"registration_id": reference, "embedded_checkout": embedded}).to_string());
901
+ return respond(
902
+ 200,
903
+ "application/json",
904
+ &json!({"registration_id": reference, "embedded_checkout": embedded}).to_string(),
905
+ );
599
906
  }
600
907
 
601
908
  // `return_to` is guest-supplied and never trusted raw — only a same-site
@@ -606,21 +913,35 @@ pub(crate) async fn registrations_route_at(
606
913
  _ => "/".to_string(),
607
914
  };
608
915
  let confirm_query = |outcome: &str| -> String {
609
- let mut url = reqwest::Url::parse("http://placeholder.invalid/").unwrap();
916
+ // The base is only a carrier for the query encoder; it always parses.
917
+ let Ok(mut url) = reqwest::Url::parse("http://placeholder.invalid/") else {
918
+ return String::new();
919
+ };
610
920
  url.query_pairs_mut()
611
921
  .append_pair("registration_id", &reference)
612
922
  .append_pair("outcome", outcome)
613
923
  .append_pair("return_to", &return_to);
614
924
  url.query().unwrap_or("").to_string()
615
925
  };
616
- let success_url = format!("{site_url}/registration-confirmed.html?{}", confirm_query("succeeded"));
617
- let cancel_url = format!("{site_url}/registration-confirmed.html?{}", confirm_query("cancelled"));
926
+ let success_url = format!(
927
+ "{site_url}/registration-confirmed.html?{}",
928
+ confirm_query("succeeded")
929
+ );
930
+ let cancel_url = format!(
931
+ "{site_url}/registration-confirmed.html?{}",
932
+ confirm_query("cancelled")
933
+ );
618
934
 
619
935
  // A tenant with no connection, or a disabled one, is on the mock
620
936
  // walkthrough, never a misconfiguration to refuse. success_url/cancel_url
621
937
  // belong to this walkthrough only — an embedded session redirects nowhere.
622
- let checkout_url = checkout::mock_checkout_session(&reference, &success_url, &cancel_url, site_url);
623
- respond(200, "application/json", &json!({"checkout_url": checkout_url, "registration_id": reference}).to_string())
938
+ let checkout_url =
939
+ checkout::mock_checkout_session(&reference, &success_url, &cancel_url, site_url);
940
+ respond(
941
+ 200,
942
+ "application/json",
943
+ &json!({"checkout_url": checkout_url, "registration_id": reference}).to_string(),
944
+ )
624
945
  }
625
946
 
626
947
  // The answer for an event that only the public mock secret could vouch for.
@@ -642,13 +963,13 @@ pub(crate) async fn webhook_route(
642
963
  raw_body: &str,
643
964
  signature_header: &str,
644
965
  platform: &payments::PlatformConfig,
645
- client: &Mutex<Client>,
646
- wasm_path: &Path,
647
- config: &LineageConfig,
648
- invoker: &dyn LambdaInvoker,
966
+ host: Host<'_>,
649
967
  payments: &PaymentsProvider,
650
968
  ) -> Value {
651
- let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
969
+ let Host {
970
+ client, wasm_path, ..
971
+ } = host;
972
+ let now = unix_now();
652
973
  // The environment's signing secret and any saved with the business's own
653
974
  // keys are all accepted; with none configured only the mock secret is.
654
975
  let configured = platform.webhook_secrets();
@@ -657,11 +978,16 @@ pub(crate) async fn webhook_route(
657
978
  // mock secret must not stand in for it.
658
979
  return mock_secret_refused();
659
980
  }
660
- let candidates = if configured.is_empty() { vec![MOCK_STRIPE_WEBHOOK_SECRET] } else { configured };
981
+ let candidates = if configured.is_empty() {
982
+ vec![MOCK_STRIPE_WEBHOOK_SECRET]
983
+ } else {
984
+ configured
985
+ };
661
986
  let tolerance = crate::commerce_ir::checkout_windows_binding().webhook_tolerance;
662
987
  let mut verification = Ok(());
663
988
  for secret in &candidates {
664
- verification = checkout::verify_signature(raw_body, signature_header, secret, now, tolerance);
989
+ verification =
990
+ checkout::verify_signature(raw_body, signature_header, secret, now, tolerance);
665
991
  if verification.is_ok() {
666
992
  break;
667
993
  }
@@ -687,12 +1013,21 @@ pub(crate) async fn webhook_route(
687
1013
  let refuse_fallback = mock_secret_refused;
688
1014
 
689
1015
  let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
690
- let object = event.get("data").and_then(|d| d.get("object")).cloned().unwrap_or_else(|| json!({}));
691
- let reference = object.get("metadata").and_then(|m| m.get("registration_id")).and_then(|v| v.as_str()).map(String::from);
1016
+ let object = event
1017
+ .get("data")
1018
+ .and_then(|d| d.get("object"))
1019
+ .cloned()
1020
+ .unwrap_or_else(|| json!({}));
1021
+ let reference = object
1022
+ .get("metadata")
1023
+ .and_then(|m| m.get("registration_id"))
1024
+ .and_then(|v| v.as_str())
1025
+ .map(String::from);
692
1026
 
693
1027
  if let Some(reference) = reference {
694
1028
  // Defaults to "stripe" when there's no matching Payment.
695
- let processor = payment_processor(&read, payments, &reference).unwrap_or_else(|| "stripe".to_string());
1029
+ let processor =
1030
+ payment_processor(&read, payments, &reference).unwrap_or_else(|| "stripe".to_string());
696
1031
  if fallback_secret && processor != "mock_stripe" {
697
1032
  return refuse_fallback();
698
1033
  }
@@ -707,21 +1042,29 @@ pub(crate) async fn webhook_route(
707
1042
  // The PaymentIntent id when one exists, else the Checkout
708
1043
  // Session's own id — `.get`, not a panic, since a synthetic
709
1044
  // test payload carries no payment_intent at all.
710
- let transaction_id = object.get("payment_intent").and_then(|v| v.as_str())
1045
+ let transaction_id = object
1046
+ .get("payment_intent")
1047
+ .and_then(|v| v.as_str())
711
1048
  .or_else(|| object.get("id").and_then(|v| v.as_str()))
712
1049
  .unwrap_or("")
713
1050
  .to_string();
714
- Some((payments.succeeded.as_str(), json!({
1051
+ Some((
1052
+ payments.succeeded.as_str(),
1053
+ json!({
1054
+ "reference": reference_fact,
1055
+ "transaction_id": {"value": transaction_id},
1056
+ "reported_processor": reported_processor,
1057
+ }),
1058
+ ))
1059
+ }
1060
+ "checkout.session.expired" => Some((
1061
+ payments.failed.as_str(),
1062
+ json!({
715
1063
  "reference": reference_fact,
716
- "transaction_id": {"value": transaction_id},
1064
+ "reason": {"value": payments.lapse_reason},
717
1065
  "reported_processor": reported_processor,
718
- })))
719
- }
720
- "checkout.session.expired" => Some((payments.failed.as_str(), json!({
721
- "reference": reference_fact,
722
- "reason": {"value": payments.lapse_reason},
723
- "reported_processor": reported_processor,
724
- }))),
1066
+ }),
1067
+ )),
725
1068
  _ => None,
726
1069
  };
727
1070
 
@@ -729,12 +1072,12 @@ pub(crate) async fn webhook_route(
729
1072
  // A refusal here (e.g. a redelivered webhook — Stripe's delivery
730
1073
  // is at-least-once) is a benign no-op: the payment already holds
731
1074
  // the right status, and 200 tells Stripe's retry logic to stop.
732
- match dispatch::handle_routed(client, wasm_path, verb, json!(reference), facts, None, config, invoker).await {
1075
+ match dispatch::handle_routed(host, verb, json!(reference), facts, None).await {
733
1076
  Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
734
1077
  // Only the delivery that settles the payment sends the receipt;
735
1078
  // a redelivery is refused and stays quiet.
736
1079
  Ok(settled) if settled.accepted && event_type == "checkout.session.completed" => {
737
- super::registration_receipt::send_receipt(&reference, client, wasm_path, config, payments).await;
1080
+ super::registration_receipt::deliver_receipt(&reference, host, payments).await;
738
1081
  }
739
1082
  Ok(_) => {}
740
1083
  }
@@ -751,34 +1094,65 @@ mod tests {
751
1094
  use crate::web::tests::{provision_lineage, scratch_db};
752
1095
 
753
1096
  fn checkout_config(era: i32) -> LineageConfig {
754
- LineageConfig { domain: "CheckoutFixture".to_string(), era: Some(era), mirrored: None }
1097
+ LineageConfig {
1098
+ domain: "CheckoutFixture".to_string(),
1099
+ era: Some(era),
1100
+ mirrored: None,
1101
+ }
755
1102
  }
756
1103
 
757
1104
  fn checkout_wasm_path() -> std::path::PathBuf {
758
1105
  std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dist/checkout_fixture.wasm")
759
1106
  }
760
1107
 
761
- async fn schedule_event(client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, slug: &str, price_cents: i64) {
1108
+ async fn schedule_event(
1109
+ client: &Mutex<Client>,
1110
+ wasm_path: &Path,
1111
+ config: &LineageConfig,
1112
+ slug: &str,
1113
+ price_cents: i64,
1114
+ ) {
762
1115
  let args = json!({
763
1116
  "slug": {"value": slug}, "name": {"value": "Sample Studio"},
764
1117
  "price": {"cents": price_cents}, "capacity": {"value": 20},
765
1118
  });
766
- let outcome = dispatch::handle(client, wasm_path, "CheckoutFixture::Event.Schedule", args, None, config, &lambda_client::NeverInvoker)
767
- .await
768
- .unwrap();
769
- assert!(outcome.accepted, "scheduling the fixture event should succeed: {:?}", outcome.result);
1119
+ let outcome = dispatch::handle(
1120
+ Host::new(client, wasm_path, config, &lambda_client::NeverInvoker),
1121
+ "CheckoutFixture::Event.Schedule",
1122
+ args,
1123
+ None,
1124
+ )
1125
+ .await
1126
+ .unwrap();
1127
+ assert!(
1128
+ outcome.accepted,
1129
+ "scheduling the fixture event should succeed: {:?}",
1130
+ outcome.result
1131
+ );
770
1132
  }
771
1133
 
772
1134
  fn sign_stripe_header(secret: &str, now: i64, payload: &str) -> String {
773
1135
  use hmac::{Hmac, Mac};
774
1136
  use sha2::Sha256;
1137
+ use std::fmt::Write;
775
1138
  let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
776
1139
  mac.update(format!("{now}.{payload}").as_bytes());
777
- format!("t={now},v1={}", mac.finalize().into_bytes().iter().map(|b| format!("{b:02x}")).collect::<String>())
1140
+ let digest = mac
1141
+ .finalize()
1142
+ .into_bytes()
1143
+ .iter()
1144
+ .fold(String::new(), |mut hex, b| {
1145
+ let _ = write!(hex, "{b:02x}"); // writing to a String cannot fail
1146
+ hex
1147
+ });
1148
+ format!("t={now},v1={digest}")
778
1149
  }
779
1150
 
780
1151
  fn now_secs() -> i64 {
781
- std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64
1152
+ std::time::SystemTime::now()
1153
+ .duration_since(std::time::UNIX_EPOCH)
1154
+ .unwrap()
1155
+ .as_secs() as i64
782
1156
  }
783
1157
 
784
1158
  // A read of the domain: one `yoga` event of `capacity` seats, one `other`
@@ -786,11 +1160,23 @@ mod tests {
786
1160
  // (reference, event slug, payment status).
787
1161
  fn read_with(capacity: i64, rows: &[(&str, &str, &str)]) -> Value {
788
1162
  let mut instances = serde_json::Map::new();
789
- instances.insert("CheckoutFixture::Event#yoga".into(), json!({"capacity": {"value": capacity}}));
790
- instances.insert("CheckoutFixture::Event#other".into(), json!({"capacity": {"value": 5}}));
1163
+ instances.insert(
1164
+ "CheckoutFixture::Event#yoga".into(),
1165
+ json!({"capacity": {"value": capacity}}),
1166
+ );
1167
+ instances.insert(
1168
+ "CheckoutFixture::Event#other".into(),
1169
+ json!({"capacity": {"value": 5}}),
1170
+ );
791
1171
  for (reference, event, status) in rows {
792
- instances.insert(format!("CheckoutFixture::Registration#{reference}"), json!({"event_slug": event}));
793
- instances.insert(format!("Payments::Payment#{reference}"), json!({"status": status}));
1172
+ instances.insert(
1173
+ format!("CheckoutFixture::Registration#{reference}"),
1174
+ json!({"event_slug": event}),
1175
+ );
1176
+ instances.insert(
1177
+ format!("Payments::Payment#{reference}"),
1178
+ json!({"status": status}),
1179
+ );
794
1180
  }
795
1181
  json!({"instances": instances})
796
1182
  }
@@ -800,10 +1186,18 @@ mod tests {
800
1186
  let payments = four_state_payments();
801
1187
  let read = read_with(
802
1188
  10,
803
- &[("a", "yoga", "pending"), ("b", "yoga", "succeeded"), ("c", "yoga", "refunding"), ("d", "yoga", "disputed")],
1189
+ &[
1190
+ ("a", "yoga", "pending"),
1191
+ ("b", "yoga", "succeeded"),
1192
+ ("c", "yoga", "refunding"),
1193
+ ("d", "yoga", "disputed"),
1194
+ ],
804
1195
  );
805
1196
  assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 4);
806
- assert_eq!(seats_left(&read, "CheckoutFixture", &payments, "yoga"), Some(6));
1197
+ assert_eq!(
1198
+ seats_left(&read, "CheckoutFixture", &payments, "yoga"),
1199
+ Some(6)
1200
+ );
807
1201
  }
808
1202
 
809
1203
  #[test]
@@ -811,34 +1205,67 @@ mod tests {
811
1205
  let payments = crate::commerce_ir::fixture_payments();
812
1206
  let read = read_with(
813
1207
  3,
814
- &[("a", "yoga", "failed"), ("b", "yoga", "refunded"), ("c", "yoga", "charged_back"), ("d", "yoga", "succeeded")],
1208
+ &[
1209
+ ("a", "yoga", "failed"),
1210
+ ("b", "yoga", "refunded"),
1211
+ ("c", "yoga", "charged_back"),
1212
+ ("d", "yoga", "succeeded"),
1213
+ ],
815
1214
  );
816
1215
  assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 1);
817
- assert_eq!(seats_left(&read, "CheckoutFixture", &payments, "yoga"), Some(2));
1216
+ assert_eq!(
1217
+ seats_left(&read, "CheckoutFixture", &payments, "yoga"),
1218
+ Some(2)
1219
+ );
818
1220
  }
819
1221
 
820
1222
  #[test]
821
1223
  fn seats_are_counted_per_event_and_a_registration_without_a_payment_holds_none() {
822
1224
  let payments = crate::commerce_ir::fixture_payments();
823
- let mut read = read_with(4, &[("a", "yoga", "succeeded"), ("b", "other", "succeeded")]);
1225
+ let mut read = read_with(
1226
+ 4,
1227
+ &[("a", "yoga", "succeeded"), ("b", "other", "succeeded")],
1228
+ );
824
1229
  read["instances"]["CheckoutFixture::Registration#orphan"] = json!({"event_slug": "yoga"});
825
- assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 1, "only this event's registrations, and only those with a payment");
1230
+ assert_eq!(
1231
+ seats_taken(&read, "CheckoutFixture", &payments, "yoga"),
1232
+ 1,
1233
+ "only this event's registrations, and only those with a payment"
1234
+ );
826
1235
  assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "other"), 1);
827
1236
  }
828
1237
 
829
1238
  #[test]
830
1239
  fn an_archived_registration_gives_its_seat_back_whatever_its_payment_says() {
831
1240
  let payments = crate::commerce_ir::fixture_payments();
832
- let mut read = read_with(3, &[("a", "yoga", "succeeded"), ("b", "yoga", "pending"), ("c", "yoga", "succeeded")]);
1241
+ let mut read = read_with(
1242
+ 3,
1243
+ &[
1244
+ ("a", "yoga", "succeeded"),
1245
+ ("b", "yoga", "pending"),
1246
+ ("c", "yoga", "succeeded"),
1247
+ ],
1248
+ );
833
1249
  assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 3);
834
1250
  read["instances"]["CheckoutFixture::Registration#a"]["status"] = json!("archived");
835
- assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 2, "a paid but archived registration frees its seat");
836
- assert_eq!(seats_left(&read, "CheckoutFixture", &payments, "yoga"), Some(1));
1251
+ assert_eq!(
1252
+ seats_taken(&read, "CheckoutFixture", &payments, "yoga"),
1253
+ 2,
1254
+ "a paid but archived registration frees its seat"
1255
+ );
1256
+ assert_eq!(
1257
+ seats_left(&read, "CheckoutFixture", &payments, "yoga"),
1258
+ Some(1)
1259
+ );
837
1260
  }
838
1261
 
839
1262
  fn binding_freeing(states: &[&str]) -> crate::commerce_ir::RegistrationsProvider {
840
- let mut binding = crate::commerce_ir::RegistrationsProvider::conventional("CheckoutFixture");
841
- binding.frees_seat = states.iter().map(|s| s.to_string()).collect();
1263
+ let mut binding =
1264
+ crate::commerce_ir::RegistrationsProvider::conventional("CheckoutFixture");
1265
+ binding.frees_seat = states
1266
+ .iter()
1267
+ .map(std::string::ToString::to_string)
1268
+ .collect();
842
1269
  binding
843
1270
  }
844
1271
 
@@ -848,17 +1275,32 @@ mod tests {
848
1275
  let mut read = read_with(3, &[("a", "yoga", "succeeded"), ("b", "yoga", "succeeded")]);
849
1276
  set_registration_status(&mut read, "a", "cancelled");
850
1277
  let counts = count_seats(&read, &binding_freeing(&["cancelled"]), &payments);
851
- assert_eq!(counts.get("yoga"), Some(&1), "cancelled is declared, so it frees the seat");
1278
+ assert_eq!(
1279
+ counts.get("yoga"),
1280
+ Some(&1),
1281
+ "cancelled is declared, so it frees the seat"
1282
+ );
852
1283
  }
853
1284
 
854
1285
  #[test]
855
1286
  fn the_fixture_declared_cancelled_state_frees_its_seat_beside_archived() {
856
1287
  let payments = crate::commerce_ir::fixture_payments();
857
- let binding = crate::commerce_ir::registrations_provider(&crate::commerce_ir::fixture_ir()).expect("registrations");
858
- let mut read = read_with(3, &[("a", "yoga", "succeeded"), ("b", "yoga", "succeeded"), ("c", "yoga", "succeeded")]);
1288
+ let binding = crate::commerce_ir::registrations_provider(&crate::commerce_ir::fixture_ir())
1289
+ .expect("registrations");
1290
+ let mut read = read_with(
1291
+ 3,
1292
+ &[
1293
+ ("a", "yoga", "succeeded"),
1294
+ ("b", "yoga", "succeeded"),
1295
+ ("c", "yoga", "succeeded"),
1296
+ ],
1297
+ );
859
1298
  set_registration_status(&mut read, "a", "cancelled");
860
1299
  set_registration_status(&mut read, "b", "archived");
861
- assert_eq!(count_seats(&read, &binding, &payments).get("yoga"), Some(&1));
1300
+ assert_eq!(
1301
+ count_seats(&read, &binding, &payments).get("yoga"),
1302
+ Some(&1)
1303
+ );
862
1304
  }
863
1305
 
864
1306
  #[test]
@@ -867,7 +1309,11 @@ mod tests {
867
1309
  let mut read = read_with(3, &[("a", "yoga", "succeeded")]);
868
1310
  set_registration_status(&mut read, "a", "archived");
869
1311
  let counts = count_seats(&read, &binding_freeing(&["cancelled"]), &payments);
870
- assert_eq!(counts.get("yoga"), Some(&1), "only the declared states free a seat");
1312
+ assert_eq!(
1313
+ counts.get("yoga"),
1314
+ Some(&1),
1315
+ "only the declared states free a seat"
1316
+ );
871
1317
  }
872
1318
 
873
1319
  #[test]
@@ -875,8 +1321,12 @@ mod tests {
875
1321
  let payments = crate::commerce_ir::fixture_payments();
876
1322
  let mut read = read_with(3, &[("a", "yoga", "succeeded")]);
877
1323
  set_registration_status(&mut read, "a", "archived");
878
- let conventional = crate::commerce_ir::RegistrationsProvider::conventional("CheckoutFixture");
879
- assert_eq!(count_seats(&read, &conventional, &payments).get("yoga"), None);
1324
+ let conventional =
1325
+ crate::commerce_ir::RegistrationsProvider::conventional("CheckoutFixture");
1326
+ assert_eq!(
1327
+ count_seats(&read, &conventional, &payments).get("yoga"),
1328
+ None
1329
+ );
880
1330
  }
881
1331
 
882
1332
  #[test]
@@ -888,7 +1338,11 @@ mod tests {
888
1338
  read["instances"]["CheckoutFixture::Registration#b"]["status"] = json!("active");
889
1339
  assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 2);
890
1340
  read["instances"]["CheckoutFixture::Registration#a"]["status"] = json!(null);
891
- assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 2, "a null status is not archived either");
1341
+ assert_eq!(
1342
+ seats_taken(&read, "CheckoutFixture", &payments, "yoga"),
1343
+ 2,
1344
+ "a null status is not archived either"
1345
+ );
892
1346
  }
893
1347
 
894
1348
  #[test]
@@ -896,9 +1350,15 @@ mod tests {
896
1350
  let payments = crate::commerce_ir::fixture_payments();
897
1351
  let mut read = read_with(2, &[("a", "yoga", "succeeded")]);
898
1352
  read["instances"]["CheckoutFixture::Registration#a"]["status"] = json!("archived");
899
- assert_eq!(seats_left(&read, "CheckoutFixture", &payments, "yoga"), Some(2));
1353
+ assert_eq!(
1354
+ seats_left(&read, "CheckoutFixture", &payments, "yoga"),
1355
+ Some(2)
1356
+ );
900
1357
  read["instances"]["CheckoutFixture::Registration#a"]["status"] = json!("active");
901
- assert_eq!(seats_left(&read, "CheckoutFixture", &payments, "yoga"), Some(1));
1358
+ assert_eq!(
1359
+ seats_left(&read, "CheckoutFixture", &payments, "yoga"),
1360
+ Some(1)
1361
+ );
902
1362
  }
903
1363
 
904
1364
  #[test]
@@ -906,7 +1366,13 @@ mod tests {
906
1366
  let payments = crate::commerce_ir::fixture_payments();
907
1367
  let mut read = read_with(
908
1368
  6,
909
- &[("a", "yoga", "succeeded"), ("b", "yoga", "succeeded"), ("c", "yoga", "pending"), ("d", "yoga", "failed"), ("e", "other", "succeeded")],
1369
+ &[
1370
+ ("a", "yoga", "succeeded"),
1371
+ ("b", "yoga", "succeeded"),
1372
+ ("c", "yoga", "pending"),
1373
+ ("d", "yoga", "failed"),
1374
+ ("e", "other", "succeeded"),
1375
+ ],
910
1376
  );
911
1377
  read["instances"]["CheckoutFixture::Registration#a"]["status"] = json!("archived");
912
1378
  read["instances"]["CheckoutFixture::Registration#b"]["status"] = json!("active");
@@ -915,18 +1381,35 @@ mod tests {
915
1381
  // b (active, paid) and c (no status, pending) hold; a is archived, d's
916
1382
  // payment failed, and e is another event's.
917
1383
  assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 2);
918
- assert_eq!(seats_left(&read, "CheckoutFixture", &payments, "yoga"), Some(4));
919
- assert_eq!(seats_left(&read, "CheckoutFixture", &payments, "other"), Some(5), "the archived registration on `other` frees its seat");
1384
+ assert_eq!(
1385
+ seats_left(&read, "CheckoutFixture", &payments, "yoga"),
1386
+ Some(4)
1387
+ );
1388
+ assert_eq!(
1389
+ seats_left(&read, "CheckoutFixture", &payments, "other"),
1390
+ Some(5),
1391
+ "the archived registration on `other` frees its seat"
1392
+ );
920
1393
  }
921
1394
 
922
1395
  #[test]
923
1396
  fn seats_left_never_goes_below_zero_and_is_none_without_an_event_or_capacity() {
924
1397
  let payments = crate::commerce_ir::fixture_payments();
925
1398
  let oversold = read_with(1, &[("a", "yoga", "succeeded"), ("b", "yoga", "succeeded")]);
926
- assert_eq!(seats_left(&oversold, "CheckoutFixture", &payments, "yoga"), Some(0));
927
- assert_eq!(seats_left(&oversold, "CheckoutFixture", &payments, "nowhere"), None);
928
- let no_capacity = json!({"instances": {"CheckoutFixture::Event#yoga": {"name": {"value": "Yoga"}}}});
929
- assert_eq!(seats_left(&no_capacity, "CheckoutFixture", &payments, "yoga"), None);
1399
+ assert_eq!(
1400
+ seats_left(&oversold, "CheckoutFixture", &payments, "yoga"),
1401
+ Some(0)
1402
+ );
1403
+ assert_eq!(
1404
+ seats_left(&oversold, "CheckoutFixture", &payments, "nowhere"),
1405
+ None
1406
+ );
1407
+ let no_capacity =
1408
+ json!({"instances": {"CheckoutFixture::Event#yoga": {"name": {"value": "Yoga"}}}});
1409
+ assert_eq!(
1410
+ seats_left(&no_capacity, "CheckoutFixture", &payments, "yoga"),
1411
+ None
1412
+ );
930
1413
  }
931
1414
 
932
1415
  // The site's own JavaScript tests of this rule (its seat-count spec) use the
@@ -935,20 +1418,27 @@ mod tests {
935
1418
  const FREEING: [&str; 3] = ["failed", "refunded", "charged_back"];
936
1419
 
937
1420
  fn set_registration_status(read: &mut Value, reference: &str, status: &str) {
938
- read["instances"][format!("CheckoutFixture::Registration#{reference}")]["status"] = json!(status);
1421
+ read["instances"][format!("CheckoutFixture::Registration#{reference}")]["status"] =
1422
+ json!(status);
939
1423
  }
940
1424
 
941
1425
  /// The fixture's payments binding with the production table: the fixture lifecycle has only
942
1426
  /// pending, succeeded and failed, so refunding and disputed need the table set here.
943
1427
  fn four_state_payments() -> crate::commerce_ir::PaymentsProvider {
944
1428
  let mut payments = crate::commerce_ir::fixture_payments();
945
- payments.holds_seat = HOLDING.iter().map(|s| s.to_string()).collect();
1429
+ payments.holds_seat = HOLDING
1430
+ .iter()
1431
+ .map(std::string::ToString::to_string)
1432
+ .collect();
946
1433
  payments
947
1434
  }
948
1435
 
949
1436
  #[test]
950
1437
  fn the_seat_holding_table_comes_from_the_fixture_ir_mark() {
951
- assert_eq!(crate::commerce_ir::fixture_payments().holds_seat, vec!["pending", "succeeded"]);
1438
+ assert_eq!(
1439
+ crate::commerce_ir::fixture_payments().holds_seat,
1440
+ vec!["pending", "succeeded"]
1441
+ );
952
1442
  }
953
1443
 
954
1444
  #[test]
@@ -956,11 +1446,19 @@ mod tests {
956
1446
  let payments = four_state_payments();
957
1447
  for status in HOLDING {
958
1448
  let read = read_with(10, &[("r", "yoga", status)]);
959
- assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 1, "{status} should hold a seat");
1449
+ assert_eq!(
1450
+ seats_taken(&read, "CheckoutFixture", &payments, "yoga"),
1451
+ 1,
1452
+ "{status} should hold a seat"
1453
+ );
960
1454
  }
961
1455
  for status in FREEING {
962
1456
  let read = read_with(10, &[("r", "yoga", status)]);
963
- assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 0, "{status} should free the seat");
1457
+ assert_eq!(
1458
+ seats_taken(&read, "CheckoutFixture", &payments, "yoga"),
1459
+ 0,
1460
+ "{status} should free the seat"
1461
+ );
964
1462
  }
965
1463
  }
966
1464
 
@@ -969,13 +1467,23 @@ mod tests {
969
1467
  let payments = crate::commerce_ir::fixture_payments();
970
1468
  let mut read = read_with(
971
1469
  10,
972
- &[("a", "yoga", "succeeded"), ("b", "yoga", "pending"), ("c", "other", "pending"), ("d", "yoga", "failed")],
1470
+ &[
1471
+ ("a", "yoga", "succeeded"),
1472
+ ("b", "yoga", "pending"),
1473
+ ("c", "other", "pending"),
1474
+ ("d", "yoga", "failed"),
1475
+ ],
973
1476
  );
974
- read["instances"]["CheckoutFixture::Registration#b"]["event_slug"] = json!({"value": "yoga"});
1477
+ read["instances"]["CheckoutFixture::Registration#b"]["event_slug"] =
1478
+ json!({"value": "yoga"});
975
1479
  assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 2);
976
1480
  assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "other"), 1);
977
1481
  read["instances"]["CheckoutFixture::Registration#a"]["event_slug"] = json!("");
978
- assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 1, "an empty event id is no reference");
1482
+ assert_eq!(
1483
+ seats_taken(&read, "CheckoutFixture", &payments, "yoga"),
1484
+ 1,
1485
+ "an empty event id is no reference"
1486
+ );
979
1487
  }
980
1488
 
981
1489
  #[test]
@@ -997,14 +1505,25 @@ mod tests {
997
1505
  for status in HOLDING {
998
1506
  let mut read = read_with(10, &[("r", "yoga", status)]);
999
1507
  set_registration_status(&mut read, "r", "archived");
1000
- assert_eq!(seats_taken(&read, "CheckoutFixture", &payments, "yoga"), 0, "archived + {status} should not hold a seat");
1508
+ assert_eq!(
1509
+ seats_taken(&read, "CheckoutFixture", &payments, "yoga"),
1510
+ 0,
1511
+ "archived + {status} should not hold a seat"
1512
+ );
1001
1513
  }
1002
1514
  }
1003
1515
 
1004
1516
  #[test]
1005
1517
  fn archiving_one_of_several_guests_frees_exactly_one_seat() {
1006
1518
  let payments = crate::commerce_ir::fixture_payments();
1007
- let mut read = read_with(10, &[("a", "yoga", "succeeded"), ("b", "yoga", "succeeded"), ("c", "yoga", "pending")]);
1519
+ let mut read = read_with(
1520
+ 10,
1521
+ &[
1522
+ ("a", "yoga", "succeeded"),
1523
+ ("b", "yoga", "succeeded"),
1524
+ ("c", "yoga", "pending"),
1525
+ ],
1526
+ );
1008
1527
  set_registration_status(&mut read, "a", "active");
1009
1528
  set_registration_status(&mut read, "b", "archived");
1010
1529
  set_registration_status(&mut read, "c", "active");
@@ -1014,9 +1533,23 @@ mod tests {
1014
1533
  #[test]
1015
1534
  fn seat_figures_carry_capacity_taken_and_left_and_null_without_a_capacity() {
1016
1535
  let payments = crate::commerce_ir::fixture_payments();
1017
- let read = read_with(3, &[("a", "yoga", "succeeded"), ("b", "yoga", "pending"), ("c", "yoga", "succeeded"), ("d", "yoga", "succeeded")]);
1018
- assert_eq!(one_seat_figures(&read, "CheckoutFixture", &payments, "yoga"), Some(json!({"slug": "yoga", "capacity": 3, "seats_taken": 4, "seats_left": 0})));
1019
- assert_eq!(one_seat_figures(&read, "CheckoutFixture", &payments, "nowhere"), None);
1536
+ let read = read_with(
1537
+ 3,
1538
+ &[
1539
+ ("a", "yoga", "succeeded"),
1540
+ ("b", "yoga", "pending"),
1541
+ ("c", "yoga", "succeeded"),
1542
+ ("d", "yoga", "succeeded"),
1543
+ ],
1544
+ );
1545
+ assert_eq!(
1546
+ one_seat_figures(&read, "CheckoutFixture", &payments, "yoga"),
1547
+ Some(json!({"slug": "yoga", "capacity": 3, "seats_taken": 4, "seats_left": 0}))
1548
+ );
1549
+ assert_eq!(
1550
+ one_seat_figures(&read, "CheckoutFixture", &payments, "nowhere"),
1551
+ None
1552
+ );
1020
1553
  assert_eq!(
1021
1554
  all_seat_figures(&read, "CheckoutFixture", &payments),
1022
1555
  json!({"events": {
@@ -1024,7 +1557,8 @@ mod tests {
1024
1557
  "other": {"capacity": 5, "seats_taken": 0, "seats_left": 5},
1025
1558
  }})
1026
1559
  );
1027
- let no_capacity = json!({"instances": {"CheckoutFixture::Event#yoga": {"name": {"value": "Yoga"}}}});
1560
+ let no_capacity =
1561
+ json!({"instances": {"CheckoutFixture::Event#yoga": {"name": {"value": "Yoga"}}}});
1028
1562
  assert_eq!(
1029
1563
  one_seat_figures(&no_capacity, "CheckoutFixture", &payments, "yoga"),
1030
1564
  Some(json!({"slug": "yoga", "capacity": null, "seats_taken": 0, "seats_left": null}))
@@ -1034,19 +1568,42 @@ mod tests {
1034
1568
  #[test]
1035
1569
  fn the_seat_figures_agree_with_seats_left_for_every_event() {
1036
1570
  let payments = crate::commerce_ir::fixture_payments();
1037
- let mut read = read_with(6, &[("a", "yoga", "succeeded"), ("b", "yoga", "refunding"), ("c", "other", "pending"), ("d", "yoga", "failed")]);
1571
+ let mut read = read_with(
1572
+ 6,
1573
+ &[
1574
+ ("a", "yoga", "succeeded"),
1575
+ ("b", "yoga", "refunding"),
1576
+ ("c", "other", "pending"),
1577
+ ("d", "yoga", "failed"),
1578
+ ],
1579
+ );
1038
1580
  set_registration_status(&mut read, "b", "archived");
1039
1581
  let all = all_seat_figures(&read, "CheckoutFixture", &payments);
1040
1582
  for slug in ["yoga", "other"] {
1041
- assert_eq!(all["events"][slug]["seats_left"].as_i64(), seats_left(&read, "CheckoutFixture", &payments, slug), "{slug}");
1042
- assert_eq!(all["events"][slug]["seats_taken"].as_u64(), Some(seats_taken(&read, "CheckoutFixture", &payments, slug) as u64), "{slug}");
1583
+ assert_eq!(
1584
+ all["events"][slug]["seats_left"].as_i64(),
1585
+ seats_left(&read, "CheckoutFixture", &payments, slug),
1586
+ "{slug}"
1587
+ );
1588
+ assert_eq!(
1589
+ all["events"][slug]["seats_taken"].as_u64(),
1590
+ Some(seats_taken(&read, "CheckoutFixture", &payments, slug) as u64),
1591
+ "{slug}"
1592
+ );
1043
1593
  }
1044
1594
  }
1045
1595
 
1046
1596
  #[test]
1047
1597
  fn only_a_single_segment_slug_between_events_and_seats_names_an_event() {
1048
1598
  assert_eq!(seats_path_slug("/events/yoga/seats"), Some("yoga"));
1049
- for path in ["/events/seats", "/events//seats", "/events/a/b/seats", "/events/yoga", "/events/yoga/seats/x", "/registrations/x/seats"] {
1599
+ for path in [
1600
+ "/events/seats",
1601
+ "/events//seats",
1602
+ "/events/a/b/seats",
1603
+ "/events/yoga",
1604
+ "/events/yoga/seats/x",
1605
+ "/registrations/x/seats",
1606
+ ] {
1050
1607
  assert_eq!(seats_path_slug(path), None, "{path}");
1051
1608
  }
1052
1609
  }
@@ -1054,7 +1611,13 @@ mod tests {
1054
1611
  #[tokio::test]
1055
1612
  async fn the_seat_routes_answer_from_a_real_read_and_404_an_unknown_event() {
1056
1613
  let client = scratch_db("hecks_host_web_test_seat_routes").await;
1057
- provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1614
+ provision_lineage(
1615
+ &*client.lock().await,
1616
+ "CheckoutFixture",
1617
+ 1,
1618
+ &["Event", "Registration", "Payment"],
1619
+ )
1620
+ .await;
1058
1621
  let config = checkout_config(1);
1059
1622
  let wasm_path = checkout_wasm_path();
1060
1623
  schedule_event(&client, &wasm_path, &config, "yoga", 4200).await;
@@ -1062,13 +1625,40 @@ mod tests {
1062
1625
  let get = |path: &'static str| {
1063
1626
  let (client, wasm_path, config, ir) = (&client, &wasm_path, &config, &ir);
1064
1627
  async move {
1065
- let response = payments_routes(Some(ir), "GET", path, "", "", client, wasm_path, config, &lambda_client::NeverInvoker).await.expect("a seats path");
1066
- (response["statusCode"].as_u64().unwrap(), serde_json::from_str::<Value>(response["body"].as_str().unwrap()).unwrap())
1628
+ let response = payments_routes(
1629
+ Some(ir),
1630
+ "GET",
1631
+ path,
1632
+ "",
1633
+ "",
1634
+ Host::new(client, wasm_path, config, &lambda_client::NeverInvoker),
1635
+ )
1636
+ .await
1637
+ .expect("a seats path");
1638
+ (
1639
+ response["statusCode"].as_u64().unwrap(),
1640
+ serde_json::from_str::<Value>(response["body"].as_str().unwrap()).unwrap(),
1641
+ )
1067
1642
  }
1068
1643
  };
1069
- assert_eq!(get("/events/yoga/seats").await, (200, json!({"slug": "yoga", "capacity": 20, "seats_taken": 0, "seats_left": 20})));
1070
- assert_eq!(get("/events/seats").await, (200, json!({"events": {"yoga": {"capacity": 20, "seats_taken": 0, "seats_left": 20}}})));
1071
- assert_eq!(get("/events/nowhere/seats").await, (404, json!({"error": "no such event"})));
1644
+ assert_eq!(
1645
+ get("/events/yoga/seats").await,
1646
+ (
1647
+ 200,
1648
+ json!({"slug": "yoga", "capacity": 20, "seats_taken": 0, "seats_left": 20})
1649
+ )
1650
+ );
1651
+ assert_eq!(
1652
+ get("/events/seats").await,
1653
+ (
1654
+ 200,
1655
+ json!({"events": {"yoga": {"capacity": 20, "seats_taken": 0, "seats_left": 20}}})
1656
+ )
1657
+ );
1658
+ assert_eq!(
1659
+ get("/events/nowhere/seats").await,
1660
+ (404, json!({"error": "no such event"}))
1661
+ );
1072
1662
  }
1073
1663
 
1074
1664
  #[test]
@@ -1096,8 +1686,16 @@ mod tests {
1096
1686
 
1097
1687
  let attendee = attendee_from(&body);
1098
1688
 
1099
- assert_eq!(attendee.get("event_slug"), None, "routing metadata must not leak into Attendee");
1100
- assert_eq!(attendee.get("return_to"), None, "routing metadata must not leak into Attendee");
1689
+ assert_eq!(
1690
+ attendee.get("event_slug"),
1691
+ None,
1692
+ "routing metadata must not leak into Attendee"
1693
+ );
1694
+ assert_eq!(
1695
+ attendee.get("return_to"),
1696
+ None,
1697
+ "routing metadata must not leak into Attendee"
1698
+ );
1101
1699
  assert_eq!(attendee["first_name"], "Ada");
1102
1700
  assert_eq!(attendee["last_name"], "Lovelace");
1103
1701
  assert_eq!(attendee["email"], "ada@example.com");
@@ -1113,7 +1711,10 @@ mod tests {
1113
1711
  // Must round-trip byte-for-byte — this exact shape is what
1114
1712
  // CheckoutFixture's tests, and any simple-Attendee domain, send.
1115
1713
  let body = json!({"event_slug": "happy-event", "name": "Ada Lovelace", "email": "ada@example.com"});
1116
- assert_eq!(attendee_from(&body), json!({"name": "Ada Lovelace", "email": "ada@example.com"}));
1714
+ assert_eq!(
1715
+ attendee_from(&body),
1716
+ json!({"name": "Ada Lovelace", "email": "ada@example.com"})
1717
+ );
1117
1718
  }
1118
1719
 
1119
1720
  #[test]
@@ -1130,17 +1731,45 @@ mod tests {
1130
1731
 
1131
1732
  #[test]
1132
1733
  fn display_name_from_is_none_when_neither_shape_is_present() {
1133
- assert_eq!(display_name_from(&json!({"email": "ada@example.com"})), None);
1134
- assert_eq!(display_name_from(&json!({"first_name": "Ada"})), None, "last_name alone is missing");
1135
- assert_eq!(display_name_from(&json!({"last_name": "Lovelace"})), None, "first_name alone is missing");
1734
+ assert_eq!(
1735
+ display_name_from(&json!({"email": "ada@example.com"})),
1736
+ None
1737
+ );
1738
+ assert_eq!(
1739
+ display_name_from(&json!({"first_name": "Ada"})),
1740
+ None,
1741
+ "last_name alone is missing"
1742
+ );
1743
+ assert_eq!(
1744
+ display_name_from(&json!({"last_name": "Lovelace"})),
1745
+ None,
1746
+ "first_name alone is missing"
1747
+ );
1136
1748
  }
1137
1749
 
1138
1750
  #[tokio::test]
1139
1751
  async fn registrations_route_refuses_a_body_missing_any_required_field() {
1140
1752
  let client = scratch_db("hecks_host_web_test_registrations_missing_fields").await;
1141
- provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1142
-
1143
- let response = registrations_route(r#"{"event_slug":"yoga-aug"}"#, &payments::test_platform(), &client, &checkout_wasm_path(), &checkout_config(1), &lambda_client::NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
1753
+ provision_lineage(
1754
+ &*client.lock().await,
1755
+ "CheckoutFixture",
1756
+ 1,
1757
+ &["Event", "Registration", "Payment"],
1758
+ )
1759
+ .await;
1760
+
1761
+ let response = registrations_route(
1762
+ r#"{"event_slug":"yoga-aug"}"#,
1763
+ &payments::test_platform(),
1764
+ Host::new(
1765
+ &client,
1766
+ &checkout_wasm_path(),
1767
+ &checkout_config(1),
1768
+ &lambda_client::NeverInvoker,
1769
+ ),
1770
+ &crate::commerce_ir::fixture_payments(),
1771
+ )
1772
+ .await;
1144
1773
  assert_eq!(response["statusCode"], 400);
1145
1774
  assert!(response["body"].as_str().unwrap().contains("missing name"));
1146
1775
  }
@@ -1149,9 +1778,16 @@ mod tests {
1149
1778
  // `name`-only Attendee — proves display_name_from composes a real Client
1150
1779
  // name and attendee_from forwards fields verbatim, without a second fixture.
1151
1780
  #[tokio::test]
1152
- async fn registrations_route_forwards_a_new_shaped_attendee_verbatim_even_against_an_old_shaped_fixture() {
1781
+ async fn registrations_route_forwards_a_new_shaped_attendee_verbatim_even_against_an_old_shaped_fixture(
1782
+ ) {
1153
1783
  let client = scratch_db("hecks_host_web_test_registrations_new_shape").await;
1154
- provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1784
+ provision_lineage(
1785
+ &*client.lock().await,
1786
+ "CheckoutFixture",
1787
+ 1,
1788
+ &["Event", "Registration", "Payment"],
1789
+ )
1790
+ .await;
1155
1791
  let config = checkout_config(1);
1156
1792
  let wasm_path = checkout_wasm_path();
1157
1793
 
@@ -1164,7 +1800,13 @@ mod tests {
1164
1800
  "email": "ada@example.com",
1165
1801
  })
1166
1802
  .to_string();
1167
- let response = registrations_route(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
1803
+ let response = registrations_route(
1804
+ &body,
1805
+ &payments::test_platform(),
1806
+ Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
1807
+ &crate::commerce_ir::fixture_payments(),
1808
+ )
1809
+ .await;
1168
1810
 
1169
1811
  // Registration.Request refuses (no first_name/last_name in this
1170
1812
  // Attendee) but only after Payment.Initiate already ran, proving
@@ -1182,7 +1824,9 @@ mod tests {
1182
1824
  "Payment.Initiate should have committed for real using the composed display name: {instances:?}"
1183
1825
  );
1184
1826
  assert!(
1185
- instances.keys().all(|k| !k.starts_with("CheckoutFixture::Registration#")),
1827
+ instances
1828
+ .keys()
1829
+ .all(|k| !k.starts_with("CheckoutFixture::Registration#")),
1186
1830
  "Registration.Request must never have committed: {instances:?}"
1187
1831
  );
1188
1832
  }
@@ -1207,40 +1851,109 @@ mod tests {
1207
1851
  ("GET", "/events/yoga/seats"),
1208
1852
  ] {
1209
1853
  for ir in [Some(&no_payments), None] {
1210
- let response = payments_routes(ir, method, path, "not json", "", &client, &wasm_path, &config, &lambda_client::NeverInvoker).await;
1211
- assert!(response.is_none(), "{method} {path} answered although the IR declares no payments: {response:?}");
1854
+ let response = payments_routes(
1855
+ ir,
1856
+ method,
1857
+ path,
1858
+ "not json",
1859
+ "",
1860
+ Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
1861
+ )
1862
+ .await;
1863
+ assert!(
1864
+ response.is_none(),
1865
+ "{method} {path} answered although the IR declares no payments: {response:?}"
1866
+ );
1212
1867
  }
1213
1868
  }
1214
1869
 
1215
1870
  let fixture_ir = crate::commerce_ir::fixture_ir();
1216
- let response = payments_routes(Some(&fixture_ir), "POST", "/registrations", "not json", "", &client, &wasm_path, &config, &lambda_client::NeverInvoker).await;
1217
- assert_eq!(response.expect("the fixture IR declares payments, so the route answers")["statusCode"], 400);
1871
+ let response = payments_routes(
1872
+ Some(&fixture_ir),
1873
+ "POST",
1874
+ "/registrations",
1875
+ "not json",
1876
+ "",
1877
+ Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
1878
+ )
1879
+ .await;
1880
+ assert_eq!(
1881
+ response.expect("the fixture IR declares payments, so the route answers")["statusCode"],
1882
+ 400
1883
+ );
1218
1884
  }
1219
1885
 
1220
1886
  #[tokio::test]
1221
1887
  async fn registrations_route_refuses_invalid_json_outright() {
1222
1888
  let client = scratch_db("hecks_host_web_test_registrations_bad_json").await;
1223
- provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1224
-
1225
- let response = registrations_route("not json", &payments::test_platform(), &client, &checkout_wasm_path(), &checkout_config(1), &lambda_client::NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
1889
+ provision_lineage(
1890
+ &*client.lock().await,
1891
+ "CheckoutFixture",
1892
+ 1,
1893
+ &["Event", "Registration", "Payment"],
1894
+ )
1895
+ .await;
1896
+
1897
+ let response = registrations_route(
1898
+ "not json",
1899
+ &payments::test_platform(),
1900
+ Host::new(
1901
+ &client,
1902
+ &checkout_wasm_path(),
1903
+ &checkout_config(1),
1904
+ &lambda_client::NeverInvoker,
1905
+ ),
1906
+ &crate::commerce_ir::fixture_payments(),
1907
+ )
1908
+ .await;
1226
1909
  assert_eq!(response["statusCode"], 400);
1227
1910
  }
1228
1911
 
1229
1912
  #[tokio::test]
1230
1913
  async fn events_route_refuses_invalid_json_outright() {
1231
1914
  let client = scratch_db("hecks_host_web_test_events_bad_json").await;
1232
- provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1233
-
1234
- let response = events_route("not json", &client, &checkout_wasm_path(), &checkout_config(1), &lambda_client::NeverInvoker).await;
1915
+ provision_lineage(
1916
+ &*client.lock().await,
1917
+ "CheckoutFixture",
1918
+ 1,
1919
+ &["Event", "Registration", "Payment"],
1920
+ )
1921
+ .await;
1922
+
1923
+ let response = events_route(
1924
+ "not json",
1925
+ Host::new(
1926
+ &client,
1927
+ &checkout_wasm_path(),
1928
+ &checkout_config(1),
1929
+ &lambda_client::NeverInvoker,
1930
+ ),
1931
+ )
1932
+ .await;
1235
1933
  assert_eq!(response["statusCode"], 400);
1236
1934
  }
1237
1935
 
1238
1936
  #[tokio::test]
1239
1937
  async fn events_route_refuses_a_body_missing_any_required_field() {
1240
1938
  let client = scratch_db("hecks_host_web_test_events_missing_fields").await;
1241
- provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1242
-
1243
- let response = events_route(r#"{"slug":"new-event"}"#, &client, &checkout_wasm_path(), &checkout_config(1), &lambda_client::NeverInvoker).await;
1939
+ provision_lineage(
1940
+ &*client.lock().await,
1941
+ "CheckoutFixture",
1942
+ 1,
1943
+ &["Event", "Registration", "Payment"],
1944
+ )
1945
+ .await;
1946
+
1947
+ let response = events_route(
1948
+ r#"{"slug":"new-event"}"#,
1949
+ Host::new(
1950
+ &client,
1951
+ &checkout_wasm_path(),
1952
+ &checkout_config(1),
1953
+ &lambda_client::NeverInvoker,
1954
+ ),
1955
+ )
1956
+ .await;
1244
1957
  assert_eq!(response["statusCode"], 400);
1245
1958
  assert!(response["body"].as_str().unwrap().contains("missing name"));
1246
1959
  }
@@ -1248,14 +1961,25 @@ mod tests {
1248
1961
  #[tokio::test]
1249
1962
  async fn events_route_schedules_a_new_event_and_returns_201() {
1250
1963
  let client = scratch_db("hecks_host_web_test_events_new").await;
1251
- provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1964
+ provision_lineage(
1965
+ &*client.lock().await,
1966
+ "CheckoutFixture",
1967
+ 1,
1968
+ &["Event", "Registration", "Payment"],
1969
+ )
1970
+ .await;
1252
1971
  let config = checkout_config(1);
1253
1972
  let wasm_path = checkout_wasm_path();
1254
1973
 
1255
1974
  let body = json!({"slug": "mock-payments-event", "name": "Mock Payments Event", "price_cents": 4200, "capacity": 20}).to_string();
1256
- let response = events_route(&body, &client, &wasm_path, &config, &lambda_client::NeverInvoker).await;
1975
+ let response = events_route(
1976
+ &body,
1977
+ Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
1978
+ )
1979
+ .await;
1257
1980
  assert_eq!(response["statusCode"], 201, "{response:?}");
1258
- let response_body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
1981
+ let response_body: Value =
1982
+ serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
1259
1983
  assert_eq!(response_body["slug"]["value"], "mock-payments-event");
1260
1984
  assert_eq!(response_body["name"]["value"], "Mock Payments Event");
1261
1985
  assert_eq!(response_body["price"]["cents"], 4200);
@@ -1265,7 +1989,9 @@ mod tests {
1265
1989
  let read = dispatch::read(&client, &wasm_path).await.unwrap();
1266
1990
  let instances = read["instances"].as_object().unwrap();
1267
1991
  assert!(
1268
- instances.keys().any(|k| k == "CheckoutFixture::Event#mock-payments-event"),
1992
+ instances
1993
+ .keys()
1994
+ .any(|k| k == "CheckoutFixture::Event#mock-payments-event"),
1269
1995
  "Event.Schedule should have committed for real: {instances:?}"
1270
1996
  );
1271
1997
  }
@@ -1275,27 +2001,47 @@ mod tests {
1275
2001
  #[tokio::test]
1276
2002
  async fn events_route_is_idempotent_on_a_repeat_slug() {
1277
2003
  let client = scratch_db("hecks_host_web_test_events_idempotent").await;
1278
- provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
2004
+ provision_lineage(
2005
+ &*client.lock().await,
2006
+ "CheckoutFixture",
2007
+ 1,
2008
+ &["Event", "Registration", "Payment"],
2009
+ )
2010
+ .await;
1279
2011
  let config = checkout_config(1);
1280
2012
  let wasm_path = checkout_wasm_path();
1281
2013
 
1282
2014
  let body = json!({"slug": "repeat-event", "name": "Repeat Event", "price_cents": 1000, "capacity": 5}).to_string();
1283
- let first = events_route(&body, &client, &wasm_path, &config, &lambda_client::NeverInvoker).await;
2015
+ let first = events_route(
2016
+ &body,
2017
+ Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
2018
+ )
2019
+ .await;
1284
2020
  assert_eq!(first["statusCode"], 201, "{first:?}");
1285
2021
 
1286
2022
  // A second call with different name/price/capacity still returns the
1287
2023
  // original event's state, 200 not 201 — idempotency keys only on slug.
1288
2024
  let second_body = json!({"slug": "repeat-event", "name": "Different Name", "price_cents": 9999, "capacity": 1}).to_string();
1289
- let second = events_route(&second_body, &client, &wasm_path, &config, &lambda_client::NeverInvoker).await;
2025
+ let second = events_route(
2026
+ &second_body,
2027
+ Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
2028
+ )
2029
+ .await;
1290
2030
  assert_eq!(second["statusCode"], 200, "{second:?}");
1291
2031
  let second_body: Value = serde_json::from_str(second["body"].as_str().unwrap()).unwrap();
1292
- assert_eq!(second_body["name"]["value"], "Repeat Event", "must return the ORIGINAL event, not re-schedule with the new fields");
2032
+ assert_eq!(
2033
+ second_body["name"]["value"], "Repeat Event",
2034
+ "must return the ORIGINAL event, not re-schedule with the new fields"
2035
+ );
1293
2036
  assert_eq!(second_body["price"]["cents"], 1000);
1294
2037
 
1295
2038
  let read = dispatch::read(&client, &wasm_path).await.unwrap();
1296
2039
  let instances = read["instances"].as_object().unwrap();
1297
2040
  assert_eq!(
1298
- instances.keys().filter(|k| k.starts_with("CheckoutFixture::Event#repeat-event")).count(),
2041
+ instances
2042
+ .keys()
2043
+ .filter(|k| k.starts_with("CheckoutFixture::Event#repeat-event"))
2044
+ .count(),
1299
2045
  1,
1300
2046
  "exactly one Event, never a second dispatch: {instances:?}"
1301
2047
  );
@@ -1304,35 +2050,82 @@ mod tests {
1304
2050
  #[tokio::test]
1305
2051
  async fn registrations_route_404s_an_unknown_event_slug() {
1306
2052
  let client = scratch_db("hecks_host_web_test_registrations_no_event").await;
1307
- provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
1308
-
1309
- let body = json!({"event_slug": "nope", "name": "Ada", "email": "ada@example.com"}).to_string();
1310
- let response = registrations_route(&body, &payments::test_platform(), &client, &checkout_wasm_path(), &checkout_config(1), &lambda_client::NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
2053
+ provision_lineage(
2054
+ &*client.lock().await,
2055
+ "CheckoutFixture",
2056
+ 1,
2057
+ &["Event", "Registration", "Payment"],
2058
+ )
2059
+ .await;
2060
+
2061
+ let body =
2062
+ json!({"event_slug": "nope", "name": "Ada", "email": "ada@example.com"}).to_string();
2063
+ let response = registrations_route(
2064
+ &body,
2065
+ &payments::test_platform(),
2066
+ Host::new(
2067
+ &client,
2068
+ &checkout_wasm_path(),
2069
+ &checkout_config(1),
2070
+ &lambda_client::NeverInvoker,
2071
+ ),
2072
+ &crate::commerce_ir::fixture_payments(),
2073
+ )
2074
+ .await;
1311
2075
  assert_eq!(response["statusCode"], 404);
1312
2076
  }
1313
2077
 
1314
2078
  #[tokio::test]
1315
2079
  async fn registrations_route_refuses_a_closed_event() {
1316
2080
  let client = scratch_db("hecks_host_web_test_registrations_closed_event").await;
1317
- provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
2081
+ provision_lineage(
2082
+ &*client.lock().await,
2083
+ "CheckoutFixture",
2084
+ 1,
2085
+ &["Event", "Registration", "Payment"],
2086
+ )
2087
+ .await;
1318
2088
  let config = checkout_config(1);
1319
2089
  let wasm_path = checkout_wasm_path();
1320
2090
 
1321
2091
  schedule_event(&client, &wasm_path, &config, "closed-event", 4200).await;
1322
- let close = dispatch::handle(&client, &wasm_path, "CheckoutFixture::Event.Close", json!({"id": "closed-event"}), None, &config, &lambda_client::NeverInvoker)
1323
- .await
1324
- .unwrap();
1325
- assert!(close.accepted, "closing the fixture event should succeed: {:?}", close.result);
2092
+ let close = dispatch::handle(
2093
+ Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
2094
+ "CheckoutFixture::Event.Close",
2095
+ json!({"id": "closed-event"}),
2096
+ None,
2097
+ )
2098
+ .await
2099
+ .unwrap();
2100
+ assert!(
2101
+ close.accepted,
2102
+ "closing the fixture event should succeed: {:?}",
2103
+ close.result
2104
+ );
1326
2105
 
1327
- let body = json!({"event_slug": "closed-event", "name": "Ada", "email": "ada@example.com"}).to_string();
1328
- let response = registrations_route(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
2106
+ let body = json!({"event_slug": "closed-event", "name": "Ada", "email": "ada@example.com"})
2107
+ .to_string();
2108
+ let response = registrations_route(
2109
+ &body,
2110
+ &payments::test_platform(),
2111
+ Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
2112
+ &crate::commerce_ir::fixture_payments(),
2113
+ )
2114
+ .await;
1329
2115
  assert_eq!(response["statusCode"], 422);
1330
2116
  assert!(response["body"].as_str().unwrap().contains("closed"));
1331
2117
  }
1332
2118
 
1333
2119
  // Schedules `slug` starting at `starts_at` with the optional cutoff set
1334
2120
  // (or absent), through the real Event.Schedule command.
1335
- async fn schedule_timed_event(client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, slug: &str, starts_at: i64, cutoff_hours: Option<i64>) {
2121
+ async fn schedule_timed_event(
2122
+ client: &Mutex<Client>,
2123
+ wasm_path: &Path,
2124
+ config: &LineageConfig,
2125
+ slug: &str,
2126
+ starts_at: i64,
2127
+ cutoff_hours: Option<i64>,
2128
+ ) {
1336
2129
  let mut args = json!({
1337
2130
  "slug": {"value": slug}, "name": {"value": "Sample Studio"},
1338
2131
  "price": {"cents": 4200}, "capacity": {"value": 20},
@@ -1341,25 +2134,57 @@ mod tests {
1341
2134
  if let Some(hours) = cutoff_hours {
1342
2135
  args["registration_cutoff_hours"] = json!({"value": hours});
1343
2136
  }
1344
- let outcome = dispatch::handle(client, wasm_path, "CheckoutFixture::Event.Schedule", args, None, config, &lambda_client::NeverInvoker)
1345
- .await
1346
- .unwrap();
1347
- assert!(outcome.accepted, "scheduling the timed fixture event should succeed: {:?}", outcome.result);
2137
+ let outcome = dispatch::handle(
2138
+ Host::new(client, wasm_path, config, &lambda_client::NeverInvoker),
2139
+ "CheckoutFixture::Event.Schedule",
2140
+ args,
2141
+ None,
2142
+ )
2143
+ .await
2144
+ .unwrap();
2145
+ assert!(
2146
+ outcome.accepted,
2147
+ "scheduling the timed fixture event should succeed: {:?}",
2148
+ outcome.result
2149
+ );
1348
2150
  }
1349
2151
 
1350
2152
  #[tokio::test]
1351
- async fn registrations_route_refuses_a_session_inside_its_registration_cutoff_and_writes_nothing() {
2153
+ async fn registrations_route_refuses_a_session_inside_its_registration_cutoff_and_writes_nothing(
2154
+ ) {
1352
2155
  let client = scratch_db("hecks_host_web_test_registrations_cutoff_passed").await;
1353
- provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
2156
+ provision_lineage(
2157
+ &*client.lock().await,
2158
+ "CheckoutFixture",
2159
+ 1,
2160
+ &["Event", "Registration", "Payment"],
2161
+ )
2162
+ .await;
1354
2163
  let config = checkout_config(1);
1355
2164
  let wasm_path = checkout_wasm_path();
1356
2165
 
1357
2166
  // Starts in 5 hours with a 6 hour cutoff: registration closed an hour ago.
1358
2167
  let now = 1_800_000_000;
1359
- schedule_timed_event(&client, &wasm_path, &config, "too-late", now + 5 * 3600, Some(6)).await;
2168
+ schedule_timed_event(
2169
+ &client,
2170
+ &wasm_path,
2171
+ &config,
2172
+ "too-late",
2173
+ now + 5 * 3600,
2174
+ Some(6),
2175
+ )
2176
+ .await;
1360
2177
 
1361
- let body = json!({"event_slug": "too-late", "name": "Ada", "email": "ada@example.com"}).to_string();
1362
- let response = registrations_route_at(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::commerce_ir::fixture_payments(), now).await;
2178
+ let body = json!({"event_slug": "too-late", "name": "Ada", "email": "ada@example.com"})
2179
+ .to_string();
2180
+ let response = registrations_route_at(
2181
+ &body,
2182
+ &payments::test_platform(),
2183
+ Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
2184
+ &crate::commerce_ir::fixture_payments(),
2185
+ now,
2186
+ )
2187
+ .await;
1363
2188
  assert_eq!(response["statusCode"], 422, "{response:?}");
1364
2189
  assert_eq!(
1365
2190
  serde_json::from_str::<Value>(response["body"].as_str().unwrap()).unwrap(),
@@ -1369,7 +2194,10 @@ mod tests {
1369
2194
  let read = dispatch::read(&client, &wasm_path).await.unwrap();
1370
2195
  let instances = read["instances"].as_object().unwrap();
1371
2196
  assert!(
1372
- instances.keys().all(|k| !k.starts_with("Payments::Payment#") && !k.starts_with("CheckoutFixture::Registration#")),
2197
+ instances
2198
+ .keys()
2199
+ .all(|k| !k.starts_with("Payments::Payment#")
2200
+ && !k.starts_with("CheckoutFixture::Registration#")),
1373
2201
  "a refused registration must write no Payment and no Registration: {instances:?}"
1374
2202
  );
1375
2203
  }
@@ -1377,7 +2205,13 @@ mod tests {
1377
2205
  #[tokio::test]
1378
2206
  async fn registrations_route_accepts_a_session_with_no_cutoff_even_when_it_has_a_start() {
1379
2207
  let client = scratch_db("hecks_host_web_test_registrations_cutoff_absent").await;
1380
- provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
2208
+ provision_lineage(
2209
+ &*client.lock().await,
2210
+ "CheckoutFixture",
2211
+ 1,
2212
+ &["Event", "Registration", "Payment"],
2213
+ )
2214
+ .await;
1381
2215
  let config = checkout_config(1);
1382
2216
  let wasm_path = checkout_wasm_path();
1383
2217
 
@@ -1385,14 +2219,32 @@ mod tests {
1385
2219
  let now = 1_800_000_000;
1386
2220
  schedule_timed_event(&client, &wasm_path, &config, "no-cutoff", now - 3600, None).await;
1387
2221
 
1388
- let body = json!({"event_slug": "no-cutoff", "name": "Ada", "email": "ada@example.com"}).to_string();
1389
- let response = registrations_route_at(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::commerce_ir::fixture_payments(), now).await;
2222
+ let body = json!({"event_slug": "no-cutoff", "name": "Ada", "email": "ada@example.com"})
2223
+ .to_string();
2224
+ let response = registrations_route_at(
2225
+ &body,
2226
+ &payments::test_platform(),
2227
+ Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
2228
+ &crate::commerce_ir::fixture_payments(),
2229
+ now,
2230
+ )
2231
+ .await;
1390
2232
  assert_eq!(response["statusCode"], 200, "{response:?}");
1391
2233
 
1392
2234
  let read = dispatch::read(&client, &wasm_path).await.unwrap();
1393
2235
  let instances = read["instances"].as_object().unwrap();
1394
- assert!(instances.keys().any(|k| k.starts_with("CheckoutFixture::Registration#")), "{instances:?}");
1395
- assert!(instances.keys().any(|k| k.starts_with("Payments::Payment#")), "{instances:?}");
2236
+ assert!(
2237
+ instances
2238
+ .keys()
2239
+ .any(|k| k.starts_with("CheckoutFixture::Registration#")),
2240
+ "{instances:?}"
2241
+ );
2242
+ assert!(
2243
+ instances
2244
+ .keys()
2245
+ .any(|k| k.starts_with("Payments::Payment#")),
2246
+ "{instances:?}"
2247
+ );
1396
2248
  }
1397
2249
 
1398
2250
  #[test]
@@ -1400,13 +2252,34 @@ mod tests {
1400
2252
  let now = 1_000_000;
1401
2253
  let past_start = json!({"starts_at": 0});
1402
2254
  assert!(!registration_cutoff_passed(&json!({}), now));
1403
- assert!(!registration_cutoff_passed(&past_start, now), "a start with no cutoff hours is no cutoff");
1404
- assert!(!registration_cutoff_passed(&json!({"registration_cutoff_hours": 48}), now), "hours with no start is no cutoff");
1405
- assert!(!registration_cutoff_passed(&json!({"starts_at": null, "registration_cutoff_hours": 48}), now));
1406
- assert!(!registration_cutoff_passed(&json!({"starts_at": 0, "registration_cutoff_hours": null}), now));
1407
- assert!(!registration_cutoff_passed(&json!({"starts_at": "soon", "registration_cutoff_hours": 48}), now));
1408
- assert!(!registration_cutoff_passed(&json!({"starts_at": 0, "registration_cutoff_hours": "two"}), now));
1409
- assert!(!registration_cutoff_passed(&json!({"starts_at": {"value": "soon"}, "registration_cutoff_hours": {"value": 1.5}}), now));
2255
+ assert!(
2256
+ !registration_cutoff_passed(&past_start, now),
2257
+ "a start with no cutoff hours is no cutoff"
2258
+ );
2259
+ assert!(
2260
+ !registration_cutoff_passed(&json!({"registration_cutoff_hours": 48}), now),
2261
+ "hours with no start is no cutoff"
2262
+ );
2263
+ assert!(!registration_cutoff_passed(
2264
+ &json!({"starts_at": null, "registration_cutoff_hours": 48}),
2265
+ now
2266
+ ));
2267
+ assert!(!registration_cutoff_passed(
2268
+ &json!({"starts_at": 0, "registration_cutoff_hours": null}),
2269
+ now
2270
+ ));
2271
+ assert!(!registration_cutoff_passed(
2272
+ &json!({"starts_at": "soon", "registration_cutoff_hours": 48}),
2273
+ now
2274
+ ));
2275
+ assert!(!registration_cutoff_passed(
2276
+ &json!({"starts_at": 0, "registration_cutoff_hours": "two"}),
2277
+ now
2278
+ ));
2279
+ assert!(!registration_cutoff_passed(
2280
+ &json!({"starts_at": {"value": "soon"}, "registration_cutoff_hours": {"value": 1.5}}),
2281
+ now
2282
+ ));
1410
2283
  }
1411
2284
 
1412
2285
  #[test]
@@ -1414,13 +2287,17 @@ mod tests {
1414
2287
  // Cutoff 2 hours before a start of 100_000: the window shuts after 92_800.
1415
2288
  let event = json!({"starts_at": 100_000, "registration_cutoff_hours": 2});
1416
2289
  assert!(!registration_cutoff_passed(&event, 92_799));
1417
- assert!(!registration_cutoff_passed(&event, 92_800), "exactly at the cutoff is still open");
2290
+ assert!(
2291
+ !registration_cutoff_passed(&event, 92_800),
2292
+ "exactly at the cutoff is still open"
2293
+ );
1418
2294
  assert!(registration_cutoff_passed(&event, 92_801));
1419
2295
  }
1420
2296
 
1421
2297
  #[test]
1422
2298
  fn a_registration_cutoff_reads_a_value_object_or_a_bare_number() {
1423
- let wrapped = json!({"starts_at": {"value": 100_000}, "registration_cutoff_hours": {"value": 2}});
2299
+ let wrapped =
2300
+ json!({"starts_at": {"value": 100_000}, "registration_cutoff_hours": {"value": 2}});
1424
2301
  let bare = json!({"starts_at": 100_000, "registration_cutoff_hours": 2});
1425
2302
  let mixed = json!({"starts_at": {"value": 100_000}, "registration_cutoff_hours": 2});
1426
2303
  for event in [wrapped, bare, mixed] {
@@ -1444,14 +2321,27 @@ mod tests {
1444
2321
  // Payment.Initiate before Registration.Request is ever reached,
1445
2322
  // proving the refusal is the real domain rule.
1446
2323
  let client = scratch_db("hecks_host_web_test_registrations_zero_price").await;
1447
- provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
2324
+ provision_lineage(
2325
+ &*client.lock().await,
2326
+ "CheckoutFixture",
2327
+ 1,
2328
+ &["Event", "Registration", "Payment"],
2329
+ )
2330
+ .await;
1448
2331
  let config = checkout_config(1);
1449
2332
  let wasm_path = checkout_wasm_path();
1450
2333
 
1451
2334
  schedule_event(&client, &wasm_path, &config, "free-event", 0).await;
1452
2335
 
1453
- let body = json!({"event_slug": "free-event", "name": "Ada", "email": "ada@example.com"}).to_string();
1454
- let response = registrations_route(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
2336
+ let body = json!({"event_slug": "free-event", "name": "Ada", "email": "ada@example.com"})
2337
+ .to_string();
2338
+ let response = registrations_route(
2339
+ &body,
2340
+ &payments::test_platform(),
2341
+ Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
2342
+ &crate::commerce_ir::fixture_payments(),
2343
+ )
2344
+ .await;
1455
2345
  assert_eq!(response["statusCode"], 422);
1456
2346
  assert!(
1457
2347
  response["body"].as_str().unwrap().contains("positive"),
@@ -1462,13 +2352,23 @@ mod tests {
1462
2352
  // Registration.Request must never have been dispatched at all.
1463
2353
  let read = dispatch::read(&client, &wasm_path).await.unwrap();
1464
2354
  let instances = read["instances"].as_object().unwrap();
1465
- assert!(instances.keys().all(|k| !k.starts_with("CheckoutFixture::Registration#") && !k.starts_with("Payments::Payment#")));
2355
+ assert!(instances
2356
+ .keys()
2357
+ .all(|k| !k.starts_with("CheckoutFixture::Registration#")
2358
+ && !k.starts_with("Payments::Payment#")));
1466
2359
  }
1467
2360
 
1468
2361
  #[tokio::test]
1469
- async fn registrations_route_runs_the_whole_dispatch_chain_and_returns_a_real_mock_checkout_url() {
2362
+ async fn registrations_route_runs_the_whole_dispatch_chain_and_returns_a_real_mock_checkout_url(
2363
+ ) {
1470
2364
  let client = scratch_db("hecks_host_web_test_registrations_happy_path").await;
1471
- provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
2365
+ provision_lineage(
2366
+ &*client.lock().await,
2367
+ "CheckoutFixture",
2368
+ 1,
2369
+ &["Event", "Registration", "Payment"],
2370
+ )
2371
+ .await;
1472
2372
  let config = checkout_config(1);
1473
2373
  let wasm_path = checkout_wasm_path();
1474
2374
 
@@ -1478,7 +2378,13 @@ mod tests {
1478
2378
  // a misconfiguration — the whole chain runs and returns a real
1479
2379
  // checkout URL, never a 500.
1480
2380
  let body = json!({"event_slug": "happy-event", "name": "Ada Lovelace", "email": "ada@example.com"}).to_string();
1481
- let response = registrations_route(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
2381
+ let response = registrations_route(
2382
+ &body,
2383
+ &payments::test_platform(),
2384
+ Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
2385
+ &crate::commerce_ir::fixture_payments(),
2386
+ )
2387
+ .await;
1482
2388
  assert_eq!(response["statusCode"], 200, "{response:?}");
1483
2389
  let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
1484
2390
  let reference = body["registration_id"].as_str().unwrap().to_string();
@@ -1494,10 +2400,22 @@ mod tests {
1494
2400
 
1495
2401
  let read = dispatch::read(&client, &wasm_path).await.unwrap();
1496
2402
  let instances = read["instances"].as_object().unwrap();
1497
- let registration = instances.iter().find(|(k, _)| k.starts_with("CheckoutFixture::Registration#")).map(|(_, v)| v);
1498
- let payment = instances.iter().find(|(k, _)| k.starts_with("Payments::Payment#")).map(|(_, v)| v);
1499
- assert!(registration.is_some(), "Registration.Request should have committed for real: {instances:?}");
1500
- assert!(payment.is_some(), "Payment.Initiate should have committed for real: {instances:?}");
2403
+ let registration = instances
2404
+ .iter()
2405
+ .find(|(k, _)| k.starts_with("CheckoutFixture::Registration#"))
2406
+ .map(|(_, v)| v);
2407
+ let payment = instances
2408
+ .iter()
2409
+ .find(|(k, _)| k.starts_with("Payments::Payment#"))
2410
+ .map(|(_, v)| v);
2411
+ assert!(
2412
+ registration.is_some(),
2413
+ "Registration.Request should have committed for real: {instances:?}"
2414
+ );
2415
+ assert!(
2416
+ payment.is_some(),
2417
+ "Payment.Initiate should have committed for real: {instances:?}"
2418
+ );
1501
2419
 
1502
2420
  let registration = registration.unwrap();
1503
2421
  let payment = payment.unwrap();
@@ -1520,7 +2438,13 @@ mod tests {
1520
2438
  #[tokio::test]
1521
2439
  async fn registrations_route_redirects_to_the_caller_s_own_return_to_not_the_event_slug() {
1522
2440
  let client = scratch_db("hecks_host_web_test_registrations_return_to").await;
1523
- provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
2441
+ provision_lineage(
2442
+ &*client.lock().await,
2443
+ "CheckoutFixture",
2444
+ 1,
2445
+ &["Event", "Registration", "Payment"],
2446
+ )
2447
+ .await;
1524
2448
  let config = checkout_config(1);
1525
2449
  let wasm_path = checkout_wasm_path();
1526
2450
 
@@ -1533,7 +2457,13 @@ mod tests {
1533
2457
  "return_to": "/sample-studio.html",
1534
2458
  })
1535
2459
  .to_string();
1536
- let response = registrations_route(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
2460
+ let response = registrations_route(
2461
+ &body,
2462
+ &payments::test_platform(),
2463
+ Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
2464
+ &crate::commerce_ir::fixture_payments(),
2465
+ )
2466
+ .await;
1537
2467
  assert_eq!(response["statusCode"], 200, "{response:?}");
1538
2468
  let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
1539
2469
  let reference = body["registration_id"].as_str().unwrap().to_string();
@@ -1550,7 +2480,13 @@ mod tests {
1550
2480
  #[tokio::test]
1551
2481
  async fn registrations_route_refuses_an_absolute_or_protocol_relative_return_to() {
1552
2482
  let client = scratch_db("hecks_host_web_test_registrations_return_to_unsafe").await;
1553
- provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
2483
+ provision_lineage(
2484
+ &*client.lock().await,
2485
+ "CheckoutFixture",
2486
+ 1,
2487
+ &["Event", "Registration", "Payment"],
2488
+ )
2489
+ .await;
1554
2490
  let config = checkout_config(1);
1555
2491
  let wasm_path = checkout_wasm_path();
1556
2492
 
@@ -1564,7 +2500,13 @@ mod tests {
1564
2500
  "return_to": unsafe_return_to,
1565
2501
  })
1566
2502
  .to_string();
1567
- let response = registrations_route(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
2503
+ let response = registrations_route(
2504
+ &body,
2505
+ &payments::test_platform(),
2506
+ Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
2507
+ &crate::commerce_ir::fixture_payments(),
2508
+ )
2509
+ .await;
1568
2510
  assert_eq!(response["statusCode"], 200, "{response:?}");
1569
2511
  let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
1570
2512
  // checkout_url is the /pay/<id>.html walkthrough page; return_to
@@ -1572,7 +2514,11 @@ mod tests {
1572
2514
  // it) so it shows up as "%252F".
1573
2515
  let checkout_url = body["checkout_url"].as_str().unwrap();
1574
2516
  let parsed = reqwest::Url::parse(checkout_url).unwrap();
1575
- let success_url = parsed.query_pairs().find(|(k, _)| k == "success_url").map(|(_, v)| v.into_owned()).unwrap_or_default();
2517
+ let success_url = parsed
2518
+ .query_pairs()
2519
+ .find(|(k, _)| k == "success_url")
2520
+ .map(|(_, v)| v.into_owned())
2521
+ .unwrap_or_default();
1576
2522
  assert!(
1577
2523
  success_url.ends_with("return_to=%2F"),
1578
2524
  "an unsafe return_to ({unsafe_return_to:?}) must fall back to \"/\": {body:?}"
@@ -1586,17 +2532,33 @@ mod tests {
1586
2532
  // checkout_url, then a webhook signed against the same mock secret
1587
2533
  // webhook_route falls back to while STRIPE_WEBHOOK_SECRET is unset.
1588
2534
  let client = scratch_db("hecks_host_web_test_mock_full_loop").await;
1589
- provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
2535
+ provision_lineage(
2536
+ &*client.lock().await,
2537
+ "CheckoutFixture",
2538
+ 1,
2539
+ &["Event", "Registration", "Payment"],
2540
+ )
2541
+ .await;
1590
2542
  let config = checkout_config(1);
1591
2543
  let wasm_path = checkout_wasm_path();
1592
2544
 
1593
2545
  schedule_event(&client, &wasm_path, &config, "mock-loop-event", 4200).await;
1594
2546
 
1595
2547
  let body = json!({"event_slug": "mock-loop-event", "name": "Ada Lovelace", "email": "ada@example.com"}).to_string();
1596
- let response = registrations_route(&body, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
2548
+ let response = registrations_route(
2549
+ &body,
2550
+ &payments::test_platform(),
2551
+ Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
2552
+ &crate::commerce_ir::fixture_payments(),
2553
+ )
2554
+ .await;
1597
2555
  assert_eq!(response["statusCode"], 200, "{response:?}");
1598
- let response_body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
1599
- let reference = response_body["registration_id"].as_str().unwrap().to_string();
2556
+ let response_body: Value =
2557
+ serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
2558
+ let reference = response_body["registration_id"]
2559
+ .as_str()
2560
+ .unwrap()
2561
+ .to_string();
1600
2562
 
1601
2563
  let secret = MOCK_STRIPE_WEBHOOK_SECRET;
1602
2564
  let payload = json!({
@@ -1606,44 +2568,82 @@ mod tests {
1606
2568
  let now = now_secs();
1607
2569
  let header = sign_stripe_header(secret, now, &payload);
1608
2570
 
1609
- let response = webhook_route(&payload, &header, &payments::test_platform(), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
2571
+ let response = webhook_route(
2572
+ &payload,
2573
+ &header,
2574
+ &payments::test_platform(),
2575
+ Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
2576
+ &crate::commerce_ir::fixture_payments(),
2577
+ )
2578
+ .await;
1610
2579
  assert_eq!(response["statusCode"], 200, "{response:?}");
1611
2580
 
1612
2581
  let read = dispatch::read(&client, &wasm_path).await.unwrap();
1613
2582
  let payment = &read["instances"][format!("Payments::Payment#{reference}")];
1614
2583
  assert_eq!(payment["status"], "succeeded");
1615
- assert_eq!(payment["transaction_id"]["value"], format!("cs_manual_{reference}"));
2584
+ assert_eq!(
2585
+ payment["transaction_id"]["value"],
2586
+ format!("cs_manual_{reference}")
2587
+ );
1616
2588
  }
1617
2589
 
1618
2590
  #[tokio::test]
1619
2591
  async fn webhook_route_rejects_a_bad_signature_before_touching_the_domain_at_all() {
1620
2592
  let client = scratch_db("hecks_host_web_test_webhook_bad_sig").await;
1621
- provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
2593
+ provision_lineage(
2594
+ &*client.lock().await,
2595
+ "CheckoutFixture",
2596
+ 1,
2597
+ &["Event", "Registration", "Payment"],
2598
+ )
2599
+ .await;
1622
2600
 
1623
- let payload = json!({"type": "checkout.session.completed", "data": {"object": {}}}).to_string();
2601
+ let payload =
2602
+ json!({"type": "checkout.session.completed", "data": {"object": {}}}).to_string();
1624
2603
  let bad_header = "t=1700000000,v1=deadbeef";
1625
2604
 
1626
- 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::commerce_ir::fixture_payments()).await;
2605
+ let response = webhook_route(
2606
+ &payload,
2607
+ bad_header,
2608
+ &payments::test_platform_with_secret("whsec_test_bad_sig"),
2609
+ Host::new(
2610
+ &client,
2611
+ &checkout_wasm_path(),
2612
+ &checkout_config(1),
2613
+ &lambda_client::NeverInvoker,
2614
+ ),
2615
+ &crate::commerce_ir::fixture_payments(),
2616
+ )
2617
+ .await;
1627
2618
  assert_eq!(response["statusCode"], 400);
1628
2619
  }
1629
2620
 
1630
2621
  #[tokio::test]
1631
2622
  async fn webhook_route_settles_a_payment_on_checkout_session_completed() {
1632
2623
  let client = scratch_db("hecks_host_web_test_webhook_completed").await;
1633
- provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
2624
+ provision_lineage(
2625
+ &*client.lock().await,
2626
+ "CheckoutFixture",
2627
+ 1,
2628
+ &["Event", "Registration", "Payment"],
2629
+ )
2630
+ .await;
1634
2631
  let config = checkout_config(1);
1635
2632
  let wasm_path = checkout_wasm_path();
1636
2633
 
1637
2634
  schedule_event(&client, &wasm_path, &config, "webhook-event", 4200).await;
1638
2635
  let initiate = dispatch::handle(
1639
- &client, &wasm_path, "Payments::Payment.Initiate",
2636
+ Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
2637
+ "Payments::Payment.Initiate",
1640
2638
  json!({
1641
2639
  "reference": {"value": "REG-WEBHOOK-1"}, "processor": {"value": "stripe"},
1642
2640
  "payment_type": {"value": "card"}, "amount": {"cents": 4200},
1643
2641
  "client": {"name": "Ada Lovelace", "email": "ada@example.com"},
1644
2642
  }),
1645
- None, &config, &lambda_client::NeverInvoker,
1646
- ).await.unwrap();
2643
+ None,
2644
+ )
2645
+ .await
2646
+ .unwrap();
1647
2647
  assert!(initiate.accepted, "{:?}", initiate.result);
1648
2648
 
1649
2649
  let secret = "whsec_test_completed";
@@ -1654,7 +2654,14 @@ mod tests {
1654
2654
  let now = now_secs();
1655
2655
  let header = sign_stripe_header(secret, now, &payload);
1656
2656
 
1657
- let response = webhook_route(&payload, &header, &payments::test_platform_with_secret(secret), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
2657
+ let response = webhook_route(
2658
+ &payload,
2659
+ &header,
2660
+ &payments::test_platform_with_secret(secret),
2661
+ Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
2662
+ &crate::commerce_ir::fixture_payments(),
2663
+ )
2664
+ .await;
1658
2665
  assert_eq!(response["statusCode"], 200, "{response:?}");
1659
2666
 
1660
2667
  let read = dispatch::read(&client, &wasm_path).await.unwrap();
@@ -1665,37 +2672,64 @@ mod tests {
1665
2672
  // A redelivered webhook (Stripe's delivery is at-least-once) for an
1666
2673
  // already-succeeded payment must still answer 200 — the refusal
1667
2674
  // underneath is a benign no-op, not surfaced as an error.
1668
- let redelivered = webhook_route(&payload, &header, &payments::test_platform_with_secret(secret), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
2675
+ let redelivered = webhook_route(
2676
+ &payload,
2677
+ &header,
2678
+ &payments::test_platform_with_secret(secret),
2679
+ Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
2680
+ &crate::commerce_ir::fixture_payments(),
2681
+ )
2682
+ .await;
1669
2683
  assert_eq!(redelivered["statusCode"], 200, "a redelivered webhook must not surface the resulting refusal as an error: {redelivered:?}");
1670
2684
  }
1671
2685
 
1672
2686
  #[tokio::test]
1673
2687
  async fn webhook_route_declines_a_payment_on_checkout_session_expired() {
1674
2688
  let client = scratch_db("hecks_host_web_test_webhook_expired").await;
1675
- provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
2689
+ provision_lineage(
2690
+ &*client.lock().await,
2691
+ "CheckoutFixture",
2692
+ 1,
2693
+ &["Event", "Registration", "Payment"],
2694
+ )
2695
+ .await;
1676
2696
  let config = checkout_config(1);
1677
2697
  let wasm_path = checkout_wasm_path();
1678
2698
 
1679
2699
  schedule_event(&client, &wasm_path, &config, "webhook-event-2", 4200).await;
1680
2700
  dispatch::handle(
1681
- &client, &wasm_path, "Payments::Payment.Initiate",
2701
+ Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
2702
+ "Payments::Payment.Initiate",
1682
2703
  json!({
1683
2704
  "reference": {"value": "REG-WEBHOOK-2"}, "processor": {"value": "stripe"},
1684
2705
  "payment_type": {"value": "card"}, "amount": {"cents": 4200},
1685
2706
  "client": {"name": "Ada Lovelace", "email": "ada@example.com"},
1686
2707
  }),
1687
- None, &config, &lambda_client::NeverInvoker,
1688
- ).await.unwrap().accepted.then_some(()).expect("initiate should succeed");
2708
+ None,
2709
+ )
2710
+ .await
2711
+ .unwrap()
2712
+ .accepted
2713
+ .then_some(())
2714
+ .expect("initiate should succeed");
1689
2715
 
1690
2716
  let secret = "whsec_test_expired";
1691
2717
  let payload = json!({
1692
2718
  "type": "checkout.session.expired",
1693
2719
  "data": {"object": {"metadata": {"registration_id": "REG-WEBHOOK-2"}}},
1694
- }).to_string();
2720
+ })
2721
+ .to_string();
1695
2722
  let now = now_secs();
1696
2723
  let header = sign_stripe_header(secret, now, &payload);
1697
2724
 
1698
- let response = webhook_route(&payload, &header, &payments::test_platform_with_secret(secret), &client, &wasm_path, &config, &lambda_client::NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
2725
+ let response = webhook_route(
2726
+ &payload,
2727
+ &header,
2728
+ &payments::test_platform_with_secret(secret),
2729
+ Host::new(&client, &wasm_path, &config, &lambda_client::NeverInvoker),
2730
+ &crate::commerce_ir::fixture_payments(),
2731
+ )
2732
+ .await;
1699
2733
  assert_eq!(response["statusCode"], 200, "{response:?}");
1700
2734
 
1701
2735
  let read = dispatch::read(&client, &wasm_path).await.unwrap();
@@ -1707,14 +2741,32 @@ mod tests {
1707
2741
  #[tokio::test]
1708
2742
  async fn webhook_route_ignores_an_event_type_it_does_not_handle_and_still_answers_200() {
1709
2743
  let client = scratch_db("hecks_host_web_test_webhook_unhandled_type").await;
1710
- provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event", "Registration", "Payment"]).await;
2744
+ provision_lineage(
2745
+ &*client.lock().await,
2746
+ "CheckoutFixture",
2747
+ 1,
2748
+ &["Event", "Registration", "Payment"],
2749
+ )
2750
+ .await;
1711
2751
 
1712
2752
  let secret = "whsec_test_unhandled";
1713
2753
  let payload = json!({"type": "charge.refunded", "data": {"object": {"metadata": {"registration_id": "whatever"}}}}).to_string();
1714
2754
  let now = now_secs();
1715
2755
  let header = sign_stripe_header(secret, now, &payload);
1716
2756
 
1717
- let response = webhook_route(&payload, &header, &payments::test_platform_with_secret(secret), &client, &checkout_wasm_path(), &checkout_config(1), &lambda_client::NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
2757
+ let response = webhook_route(
2758
+ &payload,
2759
+ &header,
2760
+ &payments::test_platform_with_secret(secret),
2761
+ Host::new(
2762
+ &client,
2763
+ &checkout_wasm_path(),
2764
+ &checkout_config(1),
2765
+ &lambda_client::NeverInvoker,
2766
+ ),
2767
+ &crate::commerce_ir::fixture_payments(),
2768
+ )
2769
+ .await;
1718
2770
  assert_eq!(response["statusCode"], 200);
1719
2771
  }
1720
2772
  }