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
@@ -2,6 +2,7 @@
2
2
  //! `dispatch_body` with the Lambda custom-runtime path, so neither repeats the other's logic.
3
3
 
4
4
  use crate::dispatch;
5
+ use crate::host::Host;
5
6
  use crate::journal::LineageConfig;
6
7
  use crate::lambda_client::LambdaInvoker;
7
8
  use crate::log;
@@ -17,34 +18,63 @@ use axum::Router;
17
18
  use lambda_runtime::Error;
18
19
  use serde_json::Value;
19
20
  use std::net::SocketAddr;
20
- use std::path::{Path, PathBuf};
21
+ use std::path::PathBuf;
21
22
  use std::sync::Arc;
22
23
  use tokio::sync::Mutex;
23
24
  use tokio_postgres::Client;
24
25
 
25
26
  /// The per-request dispatch decision, shared between the Lambda custom-runtime path and the
26
27
  /// axum fallback route below. `body` is already-parsed JSON in one of its recognized shapes.
27
- pub async fn dispatch_body(
28
- body: Value,
29
- client: &Mutex<Client>,
30
- wasm_path: &Path,
31
- config: &LineageConfig,
32
- invoker: &dyn LambdaInvoker,
33
- ) -> Result<Value, Error> {
34
- if let Some(response) = web::render(&body, client, wasm_path, config, invoker).await {
28
+ ///
29
+ /// `"bare": true` beside any of them (`verb` with `to`/`with` or `args`, `read`, `query`) answers
30
+ /// the same outcome with each single-field value object as its bare field
31
+ /// (`{"name": "Margherita"}`, not `{"name": {"value": "Margherita"}}`). What is stored is the
32
+ /// same either way; without `bare` the answer is the stored shape.
33
+ pub async fn dispatch_body(body: Value, host: Host<'_>) -> Result<Value, Error> {
34
+ if let Some(response) = web::render(&body, host).await {
35
35
  return Ok(response);
36
36
  }
37
+ let answer = kernel_answer(&body, host).await?;
38
+ if !wants_bare(&body) {
39
+ return Ok(answer);
40
+ }
41
+ Ok(dispatch::present(host.wasm_path, answer)
42
+ .await
43
+ .map_err(|e| format!("{e:#}"))?)
44
+ }
45
+
46
+ fn wants_bare(body: &Value) -> bool {
47
+ body.get("bare").and_then(Value::as_bool) == Some(true)
48
+ }
37
49
 
38
- if body.get("read").and_then(|v| v.as_bool()) == Some(true) {
50
+ // The kernel's outcome for a read, query or command body, in the stored shape.
51
+ async fn kernel_answer(body: &Value, host: Host<'_>) -> Result<Value, Error> {
52
+ let Host {
53
+ client,
54
+ wasm_path,
55
+ config,
56
+ ..
57
+ } = host;
58
+
59
+ if body.get("read").and_then(serde_json::Value::as_bool) == Some(true) {
39
60
  // `{e:#}`: anyhow's alternate Display walks `source()` to the real message,
40
61
  // where a bare `tokio_postgres` error's Display is just "db error".
41
- let result = dispatch::read(client, wasm_path).await.map_err(|e| format!("{e:#}"))?;
62
+ let result = dispatch::read(client, wasm_path)
63
+ .await
64
+ .map_err(|e| format!("{e:#}"))?;
42
65
  return Ok(result);
43
66
  }
44
67
 
45
68
  // A query step reads current state and writes nothing to the journal.
46
69
  if body.get("query").is_some() {
47
- return Ok(crate::query_step::answer(&body, crate::ir::ir(), &config.domain, client, wasm_path).await?);
70
+ return Ok(crate::query_step::answer(
71
+ body,
72
+ crate::ir::ir(),
73
+ &config.domain,
74
+ client,
75
+ wasm_path,
76
+ )
77
+ .await?);
48
78
  }
49
79
 
50
80
  let verb = body
@@ -52,30 +82,45 @@ pub async fn dispatch_body(
52
82
  .and_then(|v| v.as_str())
53
83
  .ok_or("event missing \"verb\"")?
54
84
  .to_string();
55
- let role = body.get("role").and_then(|v| v.as_str()).map(|s| s.to_string());
85
+ let role = body
86
+ .get("role")
87
+ .and_then(|v| v.as_str())
88
+ .map(std::string::ToString::to_string);
56
89
  // Who the caller is, for Governance. Honored only where the internal protocol is: from this
57
90
  // host's own peers (`trusts_internal_dispatch`), never from a public caller.
58
- let actor_id = body.get("actor_id").and_then(|v| v.as_str()).map(|s| s.to_string());
59
- let caller = dispatch::Caller { role: role.as_deref(), actor_id: actor_id.as_deref() };
91
+ let actor_id = body
92
+ .get("actor_id")
93
+ .and_then(|v| v.as_str())
94
+ .map(std::string::ToString::to_string);
95
+ let caller = dispatch::Caller {
96
+ role: role.as_deref(),
97
+ actor_id: actor_id.as_deref(),
98
+ };
60
99
 
61
100
  let outcome = if let Some(to) = body.get("to").cloned() {
62
101
  if body.get("args").is_some() {
63
102
  return Err("cannot combine to/with with legacy args".into());
64
103
  }
65
- let facts = body.get("with").cloned().unwrap_or_else(|| serde_json::json!({}));
66
- dispatch::handle_routed_as(client, wasm_path, &verb, to, facts, caller, config, invoker)
104
+ let facts = body
105
+ .get("with")
106
+ .cloned()
107
+ .unwrap_or_else(|| serde_json::json!({}));
108
+ dispatch::handle_routed_as(host, &verb, to, facts, caller)
67
109
  .await
68
110
  .map_err(|e| format!("{e:#}"))?
69
111
  } else if let Some(facts) = body.get("with").cloned() {
70
112
  if body.get("args").is_some() {
71
113
  return Err("cannot combine \"with\" with legacy args".into());
72
114
  }
73
- dispatch::handle_facts_as(client, wasm_path, &verb, facts, caller, config, invoker)
115
+ dispatch::handle_facts_as(host, &verb, facts, caller)
74
116
  .await
75
117
  .map_err(|e| format!("{e:#}"))?
76
118
  } else {
77
- let args = body.get("args").cloned().unwrap_or_else(|| serde_json::json!({}));
78
- dispatch::handle_as(client, wasm_path, &verb, args, caller, config, invoker)
119
+ let args = body
120
+ .get("args")
121
+ .cloned()
122
+ .unwrap_or_else(|| serde_json::json!({}));
123
+ dispatch::handle_as(host, &verb, args, caller)
79
124
  .await
80
125
  .map_err(|e| format!("{e:#}"))?
81
126
  };
@@ -93,10 +138,24 @@ pub struct ServerState {
93
138
  pub limits: Arc<RateLimits>,
94
139
  }
95
140
 
141
+ impl ServerState {
142
+ /// The shared pieces a dispatch needs, borrowed for one call.
143
+ pub fn host(&self) -> Host<'_> {
144
+ Host::shared(
145
+ &self.client,
146
+ &self.wasm_path,
147
+ &self.lineage_config,
148
+ &self.invoker,
149
+ )
150
+ }
151
+ }
152
+
96
153
  /// The document `GET /version` serves: era, IR hash, and build — all fixed for the life of
97
154
  /// the process, so this is built once at boot. Carries no secrets.
98
155
  pub fn version_body(era: &str, ir_hash: &str, build_env: Option<&str>) -> Value {
99
- let build = build_env.filter(|v| !v.is_empty()).unwrap_or(env!("CARGO_PKG_VERSION"));
156
+ let build = build_env
157
+ .filter(|v| !v.is_empty())
158
+ .unwrap_or(env!("CARGO_PKG_VERSION"));
100
159
  serde_json::json!({ "era": era, "ir_hash": ir_hash, "build": build })
101
160
  }
102
161
 
@@ -104,13 +163,19 @@ pub fn version_body(era: &str, ir_hash: &str, build_env: Option<&str>) -> Value
104
163
  /// request answers a clone of the boot-time document, never touching auth or dispatch.
105
164
  pub fn version_router(body: Value) -> Router {
106
165
  let body = Arc::new(body);
107
- Router::new().route("/version", get(move || std::future::ready(axum::Json(body.as_ref().clone()))))
166
+ Router::new().route(
167
+ "/version",
168
+ get(move || std::future::ready(axum::Json(body.as_ref().clone()))),
169
+ )
108
170
  }
109
171
 
110
172
  /// Compiles the domain's wasm module, then binds `$PORT` (falls back to 8080) and serves
111
173
  /// forever. Nothing listens until the compile finishes, so the health check never lies.
112
174
  pub async fn serve(state: ServerState, version: Value) -> Result<(), Error> {
113
- let port: u16 = std::env::var("PORT").ok().and_then(|v| v.parse().ok()).unwrap_or(8080);
175
+ let port: u16 = std::env::var("PORT")
176
+ .ok()
177
+ .and_then(|v| v.parse().ok())
178
+ .unwrap_or(8080);
114
179
 
115
180
  warm_wasm(&state.wasm_path).await;
116
181
 
@@ -122,7 +187,11 @@ pub async fn serve(state: ServerState, version: Value) -> Result<(), Error> {
122
187
 
123
188
  /// The router and serve loop over an already-bound `listener`, split out so a test can
124
189
  /// run the real service on an ephemeral port.
125
- async fn serve_on(listener: tokio::net::TcpListener, state: ServerState, version: Value) -> Result<(), Error> {
190
+ async fn serve_on(
191
+ listener: tokio::net::TcpListener,
192
+ state: ServerState,
193
+ version: Value,
194
+ ) -> Result<(), Error> {
126
195
  let app = Router::new()
127
196
  .route("/", get(health))
128
197
  .fallback(any(dispatch_route))
@@ -130,7 +199,11 @@ async fn serve_on(listener: tokio::net::TcpListener, state: ServerState, version
130
199
  .merge(version_router(version));
131
200
 
132
201
  // The connect info is the TCP peer, which `rate_limit` needs to tell a visitor from a proxy.
133
- axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?;
202
+ axum::serve(
203
+ listener,
204
+ app.into_make_service_with_connect_info::<SocketAddr>(),
205
+ )
206
+ .await?;
134
207
  Ok(())
135
208
  }
136
209
 
@@ -143,8 +216,14 @@ async fn warm_wasm(wasm_path: &Arc<PathBuf>) {
143
216
  Ok(Ok(())) => {
144
217
  phase.end();
145
218
  }
146
- Ok(Err(e)) => log::error("wasm_warm_failed", serde_json::json!({ "error": format!("{e:#}") })),
147
- Err(e) => log::error("wasm_warm_failed", serde_json::json!({ "error": format!("{e}") })),
219
+ Ok(Err(e)) => log::error(
220
+ "wasm_warm_failed",
221
+ serde_json::json!({ "error": format!("{e:#}") }),
222
+ ),
223
+ Err(e) => log::error(
224
+ "wasm_warm_failed",
225
+ serde_json::json!({ "error": format!("{e}") }),
226
+ ),
148
227
  }
149
228
  }
150
229
 
@@ -179,30 +258,55 @@ async fn dispatch_route(
179
258
  response
180
259
  }
181
260
 
182
- async fn route_request(state: ServerState, peer: SocketAddr, method: Method, uri: Uri, headers: HeaderMap, body: Bytes) -> Response {
261
+ async fn route_request(
262
+ state: ServerState,
263
+ peer: SocketAddr,
264
+ method: Method,
265
+ uri: Uri,
266
+ headers: HeaderMap,
267
+ body: Bytes,
268
+ ) -> Response {
183
269
  let envelope = match admit(&state.limits, peer, &method, &uri, &headers, &body) {
184
270
  Ok(envelope) => envelope,
185
271
  Err(refusal) => return *refusal,
186
272
  };
187
273
 
188
- match dispatch_body(envelope, &state.client, &state.wasm_path, &state.lineage_config, state.invoker.as_ref()).await {
274
+ match dispatch_body(envelope, state.host()).await {
189
275
  Ok(value) => value_to_response(value),
190
276
  Err(e) => {
191
- log::error("dispatch_failed", serde_json::json!({ "error": format!("{e}") }));
192
- (StatusCode::INTERNAL_SERVER_ERROR, axum::Json(serde_json::json!({ "error": format!("{e}") }))).into_response()
277
+ log::error(
278
+ "dispatch_failed",
279
+ serde_json::json!({ "error": format!("{e}") }),
280
+ );
281
+ (
282
+ StatusCode::INTERNAL_SERVER_ERROR,
283
+ axum::Json(serde_json::json!({ "error": format!("{e}") })),
284
+ )
285
+ .into_response()
193
286
  }
194
287
  }
195
288
  }
196
289
 
197
290
  /// Turns a raw HTTP request into `dispatch_body`'s envelope, or into the response that ends
198
291
  /// it first: a `400` for non-JSON, a `429` for a caller over a public route's rate limit.
199
- fn admit(limits: &RateLimits, peer: SocketAddr, method: &Method, uri: &Uri, headers: &HeaderMap, body: &Bytes) -> Result<Value, Box<Response>> {
292
+ fn admit(
293
+ limits: &RateLimits,
294
+ peer: SocketAddr,
295
+ method: &Method,
296
+ uri: &Uri,
297
+ headers: &HeaderMap,
298
+ body: &Bytes,
299
+ ) -> Result<Value, Box<Response>> {
200
300
  let parsed: Value = if body.is_empty() {
201
301
  serde_json::json!({})
202
302
  } else {
203
303
  match serde_json::from_slice(body) {
204
304
  Ok(value) => value,
205
- Err(e) => return Err(Box::new((StatusCode::BAD_REQUEST, format!("invalid JSON body: {e}")).into_response())),
305
+ Err(e) => {
306
+ return Err(Box::new(
307
+ (StatusCode::BAD_REQUEST, format!("invalid JSON body: {e}")).into_response(),
308
+ ))
309
+ }
206
310
  }
207
311
  };
208
312
 
@@ -213,14 +317,22 @@ fn admit(limits: &RateLimits, peer: SocketAddr, method: &Method, uri: &Uri, head
213
317
  // they're honored only from a peer on this host (the task's own sidecar); everyone
214
318
  // else — the load balancer, i.e. the public internet — gets an ordinary request
215
319
  // envelope for the path it actually hit.
216
- let envelope = if trusts_internal_dispatch(peer) && is_internal_dispatch_shape(&parsed) {
320
+ // A proxy on this host (an AwsBox's Caddy, which a tunnel also goes through) makes every
321
+ // public request arrive from loopback too; the forwarding headers it always adds are what
322
+ // tell its traffic apart from a sidecar's.
323
+ let envelope = if trusts_internal_dispatch(peer)
324
+ && !came_through_a_proxy(headers)
325
+ && is_internal_dispatch_shape(&parsed)
326
+ {
217
327
  parsed
218
328
  } else {
219
329
  synthesize_function_url_envelope(method, uri, headers, body)
220
330
  };
221
331
 
222
332
  if let Some((route_method, route_path)) = envelope_route(&envelope) {
223
- if let Verdict::Limited { retry_after_secs } = limits.check(route_method, route_path, headers, Some(peer.ip())) {
333
+ if let Verdict::Limited { retry_after_secs } =
334
+ limits.check(route_method, route_path, headers, Some(peer.ip()))
335
+ {
224
336
  return Err(Box::new(rate_limit::too_many_requests(retry_after_secs)));
225
337
  }
226
338
  }
@@ -230,7 +342,11 @@ fn admit(limits: &RateLimits, peer: SocketAddr, method: &Method, uri: &Uri, head
230
342
  /// The method and path a Function-URL-shaped envelope names, or `None` for
231
343
  /// the verb and read shapes, which carry no route.
232
344
  fn envelope_route(envelope: &Value) -> Option<(&str, &str)> {
233
- let method = envelope.get("requestContext")?.get("http")?.get("method")?.as_str()?;
345
+ let method = envelope
346
+ .get("requestContext")?
347
+ .get("http")?
348
+ .get("method")?
349
+ .as_str()?;
234
350
  let path = envelope.get("rawPath")?.as_str()?;
235
351
  Some((method, path))
236
352
  }
@@ -240,10 +356,29 @@ fn envelope_route(envelope: &Value) -> Option<(&str, &str)> {
240
356
  fn trusts_internal_dispatch(peer: SocketAddr) -> bool {
241
357
  match peer.ip() {
242
358
  std::net::IpAddr::V4(addr) => addr.is_loopback(),
243
- std::net::IpAddr::V6(addr) => addr.is_loopback() || addr.to_ipv4_mapped().is_some_and(|mapped| mapped.is_loopback()),
359
+ std::net::IpAddr::V6(addr) => {
360
+ addr.is_loopback()
361
+ || addr
362
+ .to_ipv4_mapped()
363
+ .is_some_and(|mapped| mapped.is_loopback())
364
+ }
244
365
  }
245
366
  }
246
367
 
368
+ /// Whether a proxy forwarded this request: Caddy's `reverse_proxy` always sets the
369
+ /// `X-Forwarded-*` headers, and a client cannot make it drop them.
370
+ fn came_through_a_proxy(headers: &HeaderMap) -> bool {
371
+ [
372
+ "x-forwarded-for",
373
+ "x-forwarded-host",
374
+ "x-forwarded-proto",
375
+ "forwarded",
376
+ "via",
377
+ ]
378
+ .iter()
379
+ .any(|name| headers.contains_key(*name))
380
+ }
381
+
247
382
  /// Whether `value` already matches one of `dispatch_body`'s recognized shapes; anything
248
383
  /// else (including a bare `{}`) is real REST traffic needing `synthesize_function_url_envelope`.
249
384
  fn is_internal_dispatch_shape(value: &Value) -> bool {
@@ -255,11 +390,19 @@ fn is_internal_dispatch_shape(value: &Value) -> bool {
255
390
 
256
391
  /// Rebuilds the envelope shape a real Lambda Function URL invocation already produces
257
392
  /// automatically, from the raw axum request parts.
258
- fn synthesize_function_url_envelope(method: &Method, uri: &Uri, headers: &HeaderMap, body: &Bytes) -> Value {
393
+ fn synthesize_function_url_envelope(
394
+ method: &Method,
395
+ uri: &Uri,
396
+ headers: &HeaderMap,
397
+ body: &Bytes,
398
+ ) -> Value {
259
399
  let mut header_map = serde_json::Map::new();
260
- for (name, value) in headers.iter() {
400
+ for (name, value) in headers {
261
401
  if let Ok(value) = value.to_str() {
262
- header_map.insert(name.as_str().to_ascii_lowercase(), Value::String(value.to_string()));
402
+ header_map.insert(
403
+ name.as_str().to_ascii_lowercase(),
404
+ Value::String(value.to_string()),
405
+ );
263
406
  }
264
407
  }
265
408
 
@@ -276,17 +419,25 @@ fn synthesize_function_url_envelope(method: &Method, uri: &Uri, headers: &Header
276
419
  /// Translates `dispatch_body`'s JSON result into a real HTTP response: `web::render`'s
277
420
  /// Function-URL envelope when `statusCode` is present, else the raw outcome as `200` JSON.
278
421
  fn value_to_response(value: Value) -> Response {
279
- let Some(status_code) = value.get("statusCode").and_then(|v| v.as_u64()) else {
422
+ let Some(status_code) = value.get("statusCode").and_then(serde_json::Value::as_u64) else {
280
423
  return (StatusCode::OK, axum::Json(value)).into_response();
281
424
  };
282
425
  // `try_from`, not `as u16`: 65736 would wrap to 200 and turn a handler's garbage into a success.
283
- let status = u16::try_from(status_code).ok().and_then(|code| StatusCode::from_u16(code).ok()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
426
+ let status = u16::try_from(status_code)
427
+ .ok()
428
+ .and_then(|code| StatusCode::from_u16(code).ok())
429
+ .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
284
430
 
285
431
  let mut headers = HeaderMap::new();
286
432
  if let Some(header_obj) = value.get("headers").and_then(|h| h.as_object()) {
287
433
  for (name, header_value) in header_obj {
288
- let Some(header_value) = header_value.as_str() else { continue };
289
- if let (Ok(name), Ok(header_value)) = (HeaderName::from_bytes(name.as_bytes()), HeaderValue::from_str(header_value)) {
434
+ let Some(header_value) = header_value.as_str() else {
435
+ continue;
436
+ };
437
+ if let (Ok(name), Ok(header_value)) = (
438
+ HeaderName::from_bytes(name.as_bytes()),
439
+ HeaderValue::from_str(header_value),
440
+ ) {
290
441
  headers.insert(name, header_value);
291
442
  }
292
443
  }
@@ -304,7 +455,11 @@ fn value_to_response(value: Value) -> Response {
304
455
  }
305
456
 
306
457
  let raw_body = value.get("body").and_then(|v| v.as_str()).unwrap_or("");
307
- let body_bytes = if value.get("isBase64Encoded").and_then(|v| v.as_bool()) == Some(true) {
458
+ let body_bytes = if value
459
+ .get("isBase64Encoded")
460
+ .and_then(serde_json::Value::as_bool)
461
+ == Some(true)
462
+ {
308
463
  web::base64_decode(raw_body)
309
464
  } else {
310
465
  raw_body.as_bytes().to_vec()
@@ -319,29 +474,39 @@ mod tests {
319
474
 
320
475
  #[test]
321
476
  fn a_function_url_shaped_body_is_recognized_as_internal_dispatch() {
322
- assert!(is_internal_dispatch_shape(&serde_json::json!({"requestContext": {"http": {"method": "GET"}}})));
477
+ assert!(is_internal_dispatch_shape(
478
+ &serde_json::json!({"requestContext": {"http": {"method": "GET"}}})
479
+ ));
323
480
  }
324
481
 
325
482
  #[test]
326
483
  fn a_bare_read_body_is_recognized_as_internal_dispatch() {
327
- assert!(is_internal_dispatch_shape(&serde_json::json!({"read": true})));
484
+ assert!(is_internal_dispatch_shape(
485
+ &serde_json::json!({"read": true})
486
+ ));
328
487
  }
329
488
 
330
489
  #[test]
331
490
  fn a_declared_query_body_is_recognized_as_internal_dispatch() {
332
491
  // `dispatch_body` answers `{"query": ...}`, and the generated editor's list and picture
333
492
  // pickers ask it; without this a loopback caller was sent to the login page.
334
- assert!(is_internal_dispatch_shape(&serde_json::json!({"query": "Pictures::MediaItem.Pictures", "args": {}})));
493
+ assert!(is_internal_dispatch_shape(
494
+ &serde_json::json!({"query": "Pictures::MediaItem.Pictures", "args": {}})
495
+ ));
335
496
  }
336
497
 
337
498
  #[test]
338
499
  fn a_verb_command_body_is_recognized_as_internal_dispatch() {
339
- assert!(is_internal_dispatch_shape(&serde_json::json!({"verb": "Register", "args": {}})));
500
+ assert!(is_internal_dispatch_shape(
501
+ &serde_json::json!({"verb": "Register", "args": {}})
502
+ ));
340
503
  }
341
504
 
342
505
  #[test]
343
506
  fn a_plain_rest_payload_is_not_internal_dispatch() {
344
- assert!(!is_internal_dispatch_shape(&serde_json::json!({"email": "a@example.com"})));
507
+ assert!(!is_internal_dispatch_shape(
508
+ &serde_json::json!({"email": "a@example.com"})
509
+ ));
345
510
  }
346
511
 
347
512
  #[test]
@@ -355,21 +520,43 @@ mod tests {
355
520
 
356
521
  #[test]
357
522
  fn only_a_peer_on_this_host_is_trusted_with_the_internal_protocol() {
358
- for trusted in ["127.0.0.1:5000", "127.4.5.6:5000", "[::1]:5000", "[::ffff:127.0.0.1]:5000"] {
359
- assert!(trusts_internal_dispatch(peer(trusted)), "{trusted} is this host");
523
+ for trusted in [
524
+ "127.0.0.1:5000",
525
+ "127.4.5.6:5000",
526
+ "[::1]:5000",
527
+ "[::ffff:127.0.0.1]:5000",
528
+ ] {
529
+ assert!(
530
+ trusts_internal_dispatch(peer(trusted)),
531
+ "{trusted} is this host"
532
+ );
360
533
  }
361
- for outside in ["10.0.0.5:5000", "172.31.4.9:5000", "203.0.113.7:5000", "[2001:db8::1]:5000", "[::ffff:10.0.0.5]:5000"] {
362
- assert!(!trusts_internal_dispatch(peer(outside)), "{outside} is not this host");
534
+ for outside in [
535
+ "10.0.0.5:5000",
536
+ "172.31.4.9:5000",
537
+ "203.0.113.7:5000",
538
+ "[2001:db8::1]:5000",
539
+ "[::ffff:10.0.0.5]:5000",
540
+ ] {
541
+ assert!(
542
+ !trusts_internal_dispatch(peer(outside)),
543
+ "{outside} is not this host"
544
+ );
363
545
  }
364
546
  }
365
547
 
366
548
  fn admitted(from: &str, path: &str, body: &str) -> Value {
367
549
  let uri: Uri = path.parse().unwrap();
368
550
  let body = Bytes::from(body.to_string());
369
- match admit(&limits_from(&[]), peer(from), &Method::POST, &uri, &HeaderMap::new(), &body) {
370
- Ok(envelope) => envelope,
371
- Err(_) => panic!("{from} POST {path} was refused before dispatch"),
372
- }
551
+ admit(
552
+ &limits_from(&[]),
553
+ peer(from),
554
+ &Method::POST,
555
+ &uri,
556
+ &HeaderMap::new(),
557
+ &body,
558
+ )
559
+ .unwrap_or_else(|_| panic!("{from} POST {path} was refused before dispatch"))
373
560
  }
374
561
 
375
562
  // The gap this closes: a public POST to any path the load balancer forwards carried
@@ -377,11 +564,17 @@ mod tests {
377
564
  // caller's own word for its role and no session check.
378
565
  #[test]
379
566
  fn a_verb_or_read_body_from_outside_this_host_is_an_ordinary_request_not_a_command() {
380
- for body in [r#"{"verb":"Approve","role":"admin"}"#, r#"{"read":true}"#, r#"{"query":"Pictures::MediaItem.Pictures","args":{}}"#] {
567
+ for body in [
568
+ r#"{"verb":"Approve","role":"admin"}"#,
569
+ r#"{"read":true}"#,
570
+ r#"{"query":"Pictures::MediaItem.Pictures","args":{}}"#,
571
+ ] {
381
572
  let envelope = admitted("10.0.0.5:5000", "/webhooks/x", body);
382
573
 
383
574
  assert!(
384
- envelope.get("verb").is_none() && envelope.get("read").is_none() && envelope.get("query").is_none(),
575
+ envelope.get("verb").is_none()
576
+ && envelope.get("read").is_none()
577
+ && envelope.get("query").is_none(),
385
578
  "{body} must not reach dispatch as a command"
386
579
  );
387
580
  assert_eq!(envelope["requestContext"]["http"]["method"], "POST");
@@ -397,18 +590,58 @@ mod tests {
397
590
 
398
591
  assert_eq!(envelope["rawPath"], "/webhooks/x");
399
592
  assert_eq!(envelope["requestContext"]["http"]["method"], "POST");
400
- assert!(envelope["headers"].get("cookie").is_none(), "headers come from the real request, not the body");
593
+ assert!(
594
+ envelope["headers"].get("cookie").is_none(),
595
+ "headers come from the real request, not the body"
596
+ );
401
597
  }
402
598
 
403
599
  #[test]
404
600
  fn a_verb_or_read_body_from_this_host_still_reaches_dispatch_unchanged() {
405
- for body in [r#"{"verb":"Register","to":"r1","with":{}}"#, r#"{"read":true}"#, r#"{"query":"Banking::Customer.Suspended","args":{}}"#] {
601
+ for body in [
602
+ r#"{"verb":"Register","to":"r1","with":{}}"#,
603
+ r#"{"read":true}"#,
604
+ r#"{"query":"Banking::Customer.Suspended","args":{}}"#,
605
+ ] {
406
606
  let envelope = admitted("127.0.0.1:5000", "/anything", body);
407
607
 
408
608
  assert_eq!(envelope, serde_json::from_str::<Value>(body).unwrap());
409
609
  }
410
610
  }
411
611
 
612
+ // The gap this closes: on an AwsBox, Caddy proxies the public internet to 127.0.0.1, so
613
+ // a public `{"read": true}` or `{"verb": ..., "role": "Admin"}` arrived from loopback and
614
+ // reached the kernel as the internal protocol.
615
+ #[test]
616
+ fn a_verb_or_read_body_a_proxy_on_this_host_forwarded_is_an_ordinary_request() {
617
+ for header in [
618
+ "x-forwarded-for",
619
+ "x-forwarded-host",
620
+ "x-forwarded-proto",
621
+ "forwarded",
622
+ "via",
623
+ ] {
624
+ let mut headers = HeaderMap::new();
625
+ headers.insert(header, "203.0.113.7".parse().unwrap());
626
+ let body = Bytes::from(r#"{"verb":"Approve","role":"Admin"}"#);
627
+ let envelope = admit(
628
+ &limits_from(&[]),
629
+ peer("127.0.0.1:5000"),
630
+ &Method::POST,
631
+ &"/anything".parse().unwrap(),
632
+ &headers,
633
+ &body,
634
+ )
635
+ .unwrap_or_else(|_| panic!("a request carrying {header} was refused"));
636
+
637
+ assert!(
638
+ envelope.get("verb").is_none(),
639
+ "a body forwarded with {header} must not reach dispatch as a command"
640
+ );
641
+ assert_eq!(envelope["rawPath"], "/anything");
642
+ }
643
+ }
644
+
412
645
  // Pins a real REST client's plain POST, with no Function-URL wrapping: it must
413
646
  // synthesize the same shape `web::render`/`checkout_route` read, not error on a
414
647
  // missing "verb".
@@ -417,8 +650,14 @@ mod tests {
417
650
  let method = Method::POST;
418
651
  let uri: Uri = "/registrations?utm_source=test".parse().unwrap();
419
652
  let mut headers = HeaderMap::new();
420
- headers.insert(HeaderName::from_static("stripe-signature"), HeaderValue::from_static("t=1,v1=abc"));
421
- headers.insert(HeaderName::from_static("content-type"), HeaderValue::from_static("application/json"));
653
+ headers.insert(
654
+ HeaderName::from_static("stripe-signature"),
655
+ HeaderValue::from_static("t=1,v1=abc"),
656
+ );
657
+ headers.insert(
658
+ HeaderName::from_static("content-type"),
659
+ HeaderValue::from_static("application/json"),
660
+ );
422
661
  let body = Bytes::from_static(br#"{"event_id":"ci-verification-test"}"#);
423
662
 
424
663
  let envelope = synthesize_function_url_envelope(&method, &uri, &headers, &body);
@@ -430,7 +669,10 @@ mod tests {
430
669
  assert_eq!(envelope["headers"]["content-type"], "application/json");
431
670
  assert_eq!(envelope["body"], r#"{"event_id":"ci-verification-test"}"#);
432
671
  assert_eq!(envelope["isBase64Encoded"], false);
433
- assert!(is_internal_dispatch_shape(&envelope), "the synthesized envelope must itself be recognized on a second pass");
672
+ assert!(
673
+ is_internal_dispatch_shape(&envelope),
674
+ "the synthesized envelope must itself be recognized on a second pass"
675
+ );
434
676
  }
435
677
 
436
678
  #[test]
@@ -453,7 +695,9 @@ mod tests {
453
695
  // Serves `version_router` on an ephemeral port, the way the host serves
454
696
  // it, so a request travels the real HTTP stack.
455
697
  async fn serve_version(body: Value) -> std::net::SocketAddr {
456
- let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
698
+ let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
699
+ .await
700
+ .unwrap();
457
701
  let addr = listener.local_addr().unwrap();
458
702
  tokio::spawn(async move { axum::serve(listener, version_router(body)).await });
459
703
  addr
@@ -462,31 +706,56 @@ mod tests {
462
706
  #[tokio::test]
463
707
  async fn version_answers_200_json_with_no_session() {
464
708
  let addr = serve_version(sample_version()).await;
465
- let response = reqwest::get(format!("http://{addr}/version")).await.unwrap();
709
+ let response = reqwest::get(format!("http://{addr}/version"))
710
+ .await
711
+ .unwrap();
466
712
  assert_eq!(response.status(), 200);
467
- let content_type = response.headers()["content-type"].to_str().unwrap().to_string();
468
- assert!(content_type.starts_with("application/json"), "got {content_type}");
713
+ let content_type = response.headers()["content-type"]
714
+ .to_str()
715
+ .unwrap()
716
+ .to_string();
717
+ assert!(
718
+ content_type.starts_with("application/json"),
719
+ "got {content_type}"
720
+ );
469
721
  let body: Value = response.json().await.unwrap();
470
- assert_eq!(body, serde_json::json!({ "era": "199b08", "ir_hash": "a".repeat(64), "build": "build-42" }));
722
+ assert_eq!(
723
+ body,
724
+ serde_json::json!({ "era": "199b08", "ir_hash": "a".repeat(64), "build": "build-42" })
725
+ );
471
726
  }
472
727
 
473
728
  #[tokio::test]
474
729
  async fn version_era_is_a_non_empty_string() {
475
730
  let addr = serve_version(sample_version()).await;
476
- let body: Value = reqwest::get(format!("http://{addr}/version")).await.unwrap().json().await.unwrap();
731
+ let body: Value = reqwest::get(format!("http://{addr}/version"))
732
+ .await
733
+ .unwrap()
734
+ .json()
735
+ .await
736
+ .unwrap();
477
737
  assert!(body["era"].as_str().is_some_and(|era| !era.is_empty()));
478
738
  }
479
739
 
480
740
  #[tokio::test]
481
741
  async fn version_is_get_only() {
482
742
  let addr = serve_version(sample_version()).await;
483
- let response = reqwest::Client::new().post(format!("http://{addr}/version")).send().await.unwrap();
743
+ let response = reqwest::Client::new()
744
+ .post(format!("http://{addr}/version"))
745
+ .send()
746
+ .await
747
+ .unwrap();
484
748
  assert_eq!(response.status(), 405);
485
749
  }
486
750
 
487
751
  fn limits_from(pairs: &[(&str, &str)]) -> Arc<RateLimits> {
488
- let map: std::collections::HashMap<String, String> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
489
- Arc::new(RateLimits::new(crate::rate_limit::Config::from_lookup(|name| map.get(name).cloned()).0))
752
+ let map: std::collections::HashMap<String, String> = pairs
753
+ .iter()
754
+ .map(|(k, v)| (k.to_string(), v.to_string()))
755
+ .collect();
756
+ Arc::new(RateLimits::new(
757
+ crate::rate_limit::Config::from_lookup(|name| map.get(name).cloned()).0,
758
+ ))
490
759
  }
491
760
 
492
761
  async fn gate_only(
@@ -506,15 +775,30 @@ mod tests {
506
775
  // A disposable server on an ephemeral port: the real `admit` gate reading the real connection
507
776
  // peer, with an acknowledgement standing in for dispatch, which needs Postgres and wasm.
508
777
  async fn gated_server(limits: Arc<RateLimits>) -> SocketAddr {
509
- let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
778
+ let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
779
+ .await
780
+ .unwrap();
510
781
  let addr = listener.local_addr().unwrap();
511
782
  let app = Router::new().fallback(any(gate_only)).with_state(limits);
512
- tokio::spawn(async move { axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await });
783
+ tokio::spawn(async move {
784
+ axum::serve(
785
+ listener,
786
+ app.into_make_service_with_connect_info::<SocketAddr>(),
787
+ )
788
+ .await
789
+ });
513
790
  addr
514
791
  }
515
792
 
516
- async fn post(addr: SocketAddr, path: &str, forwarded_for: Option<&str>, body: &str) -> reqwest::Response {
517
- let mut request = reqwest::Client::new().post(format!("http://{addr}{path}")).body(body.to_string());
793
+ async fn post(
794
+ addr: SocketAddr,
795
+ path: &str,
796
+ forwarded_for: Option<&str>,
797
+ body: &str,
798
+ ) -> reqwest::Response {
799
+ let mut request = reqwest::Client::new()
800
+ .post(format!("http://{addr}{path}"))
801
+ .body(body.to_string());
518
802
  if let Some(forwarded_for) = forwarded_for {
519
803
  request = request.header("x-forwarded-for", forwarded_for);
520
804
  }
@@ -527,37 +811,84 @@ mod tests {
527
811
  async fn a_burst_on_a_public_write_route_gets_429_with_retry_after_and_a_json_error() {
528
812
  let addr = gated_server(limits_from(&[("HECKS_RATE_LIMIT_SUBSCRIBE", "3")])).await;
529
813
  for attempt in 1..=3 {
530
- assert_eq!(post(addr, SUBSCRIBE, None, r#"{"email":"a@example.com"}"#).await.status(), 200, "request {attempt}");
814
+ assert_eq!(
815
+ post(addr, SUBSCRIBE, None, r#"{"email":"a@example.com"}"#)
816
+ .await
817
+ .status(),
818
+ 200,
819
+ "request {attempt}"
820
+ );
531
821
  }
532
822
 
533
823
  let refused = post(addr, SUBSCRIBE, None, r#"{"email":"a@example.com"}"#).await;
534
824
  assert_eq!(refused.status(), 429);
535
- let retry_after: u64 = refused.headers()["retry-after"].to_str().unwrap().parse().unwrap();
825
+ let retry_after: u64 = refused.headers()["retry-after"]
826
+ .to_str()
827
+ .unwrap()
828
+ .parse()
829
+ .unwrap();
536
830
  assert!((1..=3600).contains(&retry_after), "got {retry_after}");
537
- assert!(refused.headers()["content-type"].to_str().unwrap().starts_with("application/json"));
831
+ assert!(refused.headers()["content-type"]
832
+ .to_str()
833
+ .unwrap()
834
+ .starts_with("application/json"));
538
835
  let body: Value = refused.json().await.unwrap();
539
- assert_eq!(body, serde_json::json!({ "error": "too many requests, please try again later" }));
836
+ assert_eq!(
837
+ body,
838
+ serde_json::json!({ "error": "too many requests, please try again later" })
839
+ );
540
840
  }
541
841
 
542
842
  #[tokio::test]
543
843
  async fn registrations_are_limited_apart_from_subscribes() {
544
- let addr = gated_server(limits_from(&[("HECKS_RATE_LIMIT_SUBSCRIBE", "1"), ("HECKS_RATE_LIMIT_REGISTER", "2")])).await;
844
+ let addr = gated_server(limits_from(&[
845
+ ("HECKS_RATE_LIMIT_SUBSCRIBE", "1"),
846
+ ("HECKS_RATE_LIMIT_REGISTER", "2"),
847
+ ]))
848
+ .await;
545
849
  assert_eq!(post(addr, SUBSCRIBE, None, "{}").await.status(), 200);
546
850
  assert_eq!(post(addr, SUBSCRIBE, None, "{}").await.status(), 429);
547
851
 
548
- assert_eq!(post(addr, "/registrations", None, "{}").await.status(), 200, "subscribe being spent leaves register alone");
852
+ assert_eq!(
853
+ post(addr, "/registrations", None, "{}").await.status(),
854
+ 200,
855
+ "subscribe being spent leaves register alone"
856
+ );
549
857
  assert_eq!(post(addr, "/registrations", None, "{}").await.status(), 200);
550
858
  assert_eq!(post(addr, "/registrations", None, "{}").await.status(), 429);
551
859
  }
552
860
 
553
861
  #[tokio::test]
554
862
  async fn reads_and_other_routes_are_never_limited() {
555
- let addr = gated_server(limits_from(&[("HECKS_RATE_LIMIT_SUBSCRIBE", "1"), ("HECKS_RATE_LIMIT_REGISTER", "1")])).await;
863
+ let addr = gated_server(limits_from(&[
864
+ ("HECKS_RATE_LIMIT_SUBSCRIBE", "1"),
865
+ ("HECKS_RATE_LIMIT_REGISTER", "1"),
866
+ ]))
867
+ .await;
556
868
  let client = reqwest::Client::new();
557
869
  for _ in 0..5 {
558
- assert_eq!(client.get(format!("http://{addr}{SUBSCRIBE}")).send().await.unwrap().status(), 200);
559
- assert_eq!(client.get(format!("http://{addr}/registrations/REG-1")).send().await.unwrap().status(), 200);
560
- assert_eq!(post(addr, "/webhooks/stripe", None, "{}").await.status(), 200);
870
+ assert_eq!(
871
+ client
872
+ .get(format!("http://{addr}{SUBSCRIBE}"))
873
+ .send()
874
+ .await
875
+ .unwrap()
876
+ .status(),
877
+ 200
878
+ );
879
+ assert_eq!(
880
+ client
881
+ .get(format!("http://{addr}/registrations/REG-1"))
882
+ .send()
883
+ .await
884
+ .unwrap()
885
+ .status(),
886
+ 200
887
+ );
888
+ assert_eq!(
889
+ post(addr, "/webhooks/stripe", None, "{}").await.status(),
890
+ 200
891
+ );
561
892
  assert_eq!(post(addr, "/members", None, "{}").await.status(), 200);
562
893
  }
563
894
  }
@@ -567,18 +898,46 @@ mod tests {
567
898
  // The test client is the peer at 127.0.0.1 and nothing trusts it, so every header is
568
899
  // typed by the caller and the count stays with the connection.
569
900
  let addr = gated_server(limits_from(&[("HECKS_RATE_LIMIT_SUBSCRIBE", "1")])).await;
570
- assert_eq!(post(addr, SUBSCRIBE, Some("1.1.1.1"), "{}").await.status(), 200);
901
+ assert_eq!(
902
+ post(addr, SUBSCRIBE, Some("1.1.1.1"), "{}").await.status(),
903
+ 200
904
+ );
571
905
  for spoof in ["2.2.2.2", "3.3.3.3", "4.4.4.4, 5.5.5.5"] {
572
- assert_eq!(post(addr, SUBSCRIBE, Some(spoof), "{}").await.status(), 429, "{spoof}");
906
+ assert_eq!(
907
+ post(addr, SUBSCRIBE, Some(spoof), "{}").await.status(),
908
+ 429,
909
+ "{spoof}"
910
+ );
573
911
  }
574
912
  }
575
913
 
576
914
  #[tokio::test]
577
915
  async fn behind_a_trusted_proxy_each_visitor_has_their_own_allowance() {
578
- let addr = gated_server(limits_from(&[("HECKS_RATE_LIMIT_SUBSCRIBE", "1"), ("HECKS_TRUSTED_PROXIES", "127.0.0.1")])).await;
579
- assert_eq!(post(addr, SUBSCRIBE, Some("1.1.1.1, 203.0.113.7"), "{}").await.status(), 200);
580
- assert_eq!(post(addr, SUBSCRIBE, Some("2.2.2.2, 203.0.113.7"), "{}").await.status(), 429, "same visitor, new typed entry");
581
- assert_eq!(post(addr, SUBSCRIBE, Some("2.2.2.2, 198.51.100.4"), "{}").await.status(), 200, "a different visitor");
916
+ let addr = gated_server(limits_from(&[
917
+ ("HECKS_RATE_LIMIT_SUBSCRIBE", "1"),
918
+ ("HECKS_TRUSTED_PROXIES", "127.0.0.1"),
919
+ ]))
920
+ .await;
921
+ assert_eq!(
922
+ post(addr, SUBSCRIBE, Some("1.1.1.1, 203.0.113.7"), "{}")
923
+ .await
924
+ .status(),
925
+ 200
926
+ );
927
+ assert_eq!(
928
+ post(addr, SUBSCRIBE, Some("2.2.2.2, 203.0.113.7"), "{}")
929
+ .await
930
+ .status(),
931
+ 429,
932
+ "same visitor, new typed entry"
933
+ );
934
+ assert_eq!(
935
+ post(addr, SUBSCRIBE, Some("2.2.2.2, 198.51.100.4"), "{}")
936
+ .await
937
+ .status(),
938
+ 200,
939
+ "a different visitor"
940
+ );
582
941
  }
583
942
 
584
943
  #[tokio::test]
@@ -587,7 +946,11 @@ mod tests {
587
946
  let envelope = r#"{"requestContext":{"http":{"method":"POST"}},"rawPath":"/registrations","body":"{}"}"#;
588
947
  assert_eq!(post(addr, "/anything", None, envelope).await.status(), 200);
589
948
  assert_eq!(post(addr, "/anything", None, envelope).await.status(), 429);
590
- assert_eq!(post(addr, "/registrations", None, "{}").await.status(), 429, "the same budget as the real path");
949
+ assert_eq!(
950
+ post(addr, "/registrations", None, "{}").await.status(),
951
+ 429,
952
+ "the same budget as the real path"
953
+ );
591
954
  }
592
955
 
593
956
  #[tokio::test]
@@ -601,7 +964,11 @@ mod tests {
601
964
 
602
965
  #[tokio::test]
603
966
  async fn turning_the_limits_off_lets_every_request_through() {
604
- let addr = gated_server(limits_from(&[("HECKS_RATE_LIMIT", "off"), ("HECKS_RATE_LIMIT_SUBSCRIBE", "1")])).await;
967
+ let addr = gated_server(limits_from(&[
968
+ ("HECKS_RATE_LIMIT", "off"),
969
+ ("HECKS_RATE_LIMIT_SUBSCRIBE", "1"),
970
+ ]))
971
+ .await;
605
972
  for _ in 0..10 {
606
973
  assert_eq!(post(addr, SUBSCRIBE, None, "{}").await.status(), 200);
607
974
  }
@@ -611,16 +978,28 @@ mod tests {
611
978
  // checkout fixture's wasm, so the first request is a real compile.
612
979
  async fn fixture_host(database: &str, copy_name: &str) -> (SocketAddr, Arc<PathBuf>) {
613
980
  let client = crate::dispatch::tests::scratch_db(database).await;
614
- crate::dispatch::tests::provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event"]).await;
981
+ crate::dispatch::tests::provision_lineage(
982
+ &*client.lock().await,
983
+ "CheckoutFixture",
984
+ 1,
985
+ &["Event"],
986
+ )
987
+ .await;
615
988
  let wasm_path = Arc::new(crate::wasm_runner::tests::cold_copy_of_fixture(copy_name));
616
989
  let state = ServerState {
617
990
  client: Arc::new(client),
618
991
  wasm_path: Arc::clone(&wasm_path),
619
- lineage_config: Arc::new(LineageConfig { domain: "CheckoutFixture".to_string(), era: Some(1), mirrored: None }),
992
+ lineage_config: Arc::new(LineageConfig {
993
+ domain: "CheckoutFixture".to_string(),
994
+ era: Some(1),
995
+ mirrored: None,
996
+ }),
620
997
  invoker: Arc::new(crate::lambda_client::NeverInvoker),
621
998
  limits: limits_from(&[]),
622
999
  };
623
- let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
1000
+ let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
1001
+ .await
1002
+ .unwrap();
624
1003
  let addr = listener.local_addr().unwrap();
625
1004
  tokio::spawn(async move { serve_on(listener, state, sample_version()).await });
626
1005
  (addr, wasm_path)
@@ -631,7 +1010,12 @@ mod tests {
631
1010
  let requests: Vec<_> = (0..burst)
632
1011
  .map(|_| {
633
1012
  tokio::spawn(async move {
634
- let response = reqwest::Client::new().post(format!("http://{addr}/dispatch")).body(r#"{"read": true}"#).send().await.unwrap();
1013
+ let response = reqwest::Client::new()
1014
+ .post(format!("http://{addr}/dispatch"))
1015
+ .body(r#"{"read": true}"#)
1016
+ .send()
1017
+ .await
1018
+ .unwrap();
635
1019
  let status = StatusCode::from_u16(response.status().as_u16()).unwrap();
636
1020
  (status, response.json::<Value>().await.unwrap())
637
1021
  })
@@ -639,7 +1023,12 @@ mod tests {
639
1023
  .collect();
640
1024
  let mut answers = Vec::new();
641
1025
  for request in requests {
642
- answers.push(tokio::time::timeout(std::time::Duration::from_secs(120), request).await.expect("a read was never answered").unwrap());
1026
+ answers.push(
1027
+ tokio::time::timeout(std::time::Duration::from_secs(120), request)
1028
+ .await
1029
+ .expect("a read was never answered")
1030
+ .unwrap(),
1031
+ );
643
1032
  }
644
1033
  answers
645
1034
  }
@@ -665,7 +1054,63 @@ mod tests {
665
1054
  let answers = read_burst(addr, 1).await;
666
1055
  assert_eq!(answers[0].0, StatusCode::OK, "{}", answers[0].1);
667
1056
  assert_eq!(crate::wasm_runner::compile_count(&wasm_path), 1);
668
- assert!(started.elapsed() < std::time::Duration::from_secs(1), "a warmed read took {:?}", started.elapsed());
1057
+ assert!(
1058
+ started.elapsed() < std::time::Duration::from_secs(1),
1059
+ "a warmed read took {:?}",
1060
+ started.elapsed()
1061
+ );
1062
+ }
1063
+
1064
+ // The checkout fixture's `Event`: `slug`, `name` and `capacity` hold one `value` field and
1065
+ // `price` one `cents` field, so each answers as a bare scalar when asked to.
1066
+ #[tokio::test]
1067
+ async fn a_bare_body_answers_single_field_value_objects_bare_and_the_default_stays_wrapped() {
1068
+ let client = crate::dispatch::tests::scratch_db("rust_host_serve_bare").await;
1069
+ crate::dispatch::tests::provision_lineage(
1070
+ &*client.lock().await,
1071
+ "CheckoutFixture",
1072
+ 1,
1073
+ &["Event"],
1074
+ )
1075
+ .await;
1076
+ let config = LineageConfig {
1077
+ domain: "CheckoutFixture".to_string(),
1078
+ era: Some(1),
1079
+ mirrored: None,
1080
+ };
1081
+ let wasm = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dist/checkout_fixture.wasm");
1082
+ let host = Host::new(&client, &wasm, &config, &crate::lambda_client::NeverInvoker);
1083
+ let schedule = serde_json::json!({
1084
+ "verb": "CheckoutFixture::Event.Schedule",
1085
+ "role": "Organizer",
1086
+ "with": {"slug": "intro", "name": "Intro", "price": 1500, "capacity": 10},
1087
+ "bare": true,
1088
+ });
1089
+
1090
+ let scheduled = dispatch_body(schedule, host).await.unwrap();
1091
+ let wrapped = dispatch_body(serde_json::json!({"read": true}), host)
1092
+ .await
1093
+ .unwrap();
1094
+ let bare = dispatch_body(serde_json::json!({"read": true, "bare": true}), host)
1095
+ .await
1096
+ .unwrap();
1097
+
1098
+ let event = "CheckoutFixture::Event#intro";
1099
+ assert_eq!(
1100
+ scheduled["instances"][event]["name"], "Intro",
1101
+ "{scheduled}"
1102
+ );
1103
+ assert_eq!(
1104
+ scheduled["events"][0]["payload"]["price"], 1500,
1105
+ "{scheduled}"
1106
+ );
1107
+ assert_eq!(
1108
+ wrapped["instances"][event]["name"],
1109
+ serde_json::json!({"value": "Intro"}),
1110
+ "{wrapped}"
1111
+ );
1112
+ assert_eq!(bare["instances"][event]["price"], 1500, "{bare}");
1113
+ assert_eq!(bare["instances"][event]["capacity"], 10, "{bare}");
669
1114
  }
670
1115
 
671
1116
  #[test]
@@ -675,8 +1120,14 @@ mod tests {
675
1120
 
676
1121
  #[test]
677
1122
  fn build_falls_back_to_the_crate_version_when_unset_or_empty() {
678
- assert_eq!(version_body("e", "h", None)["build"], env!("CARGO_PKG_VERSION"));
679
- assert_eq!(version_body("e", "h", Some(""))["build"], env!("CARGO_PKG_VERSION"));
1123
+ assert_eq!(
1124
+ version_body("e", "h", None)["build"],
1125
+ env!("CARGO_PKG_VERSION")
1126
+ );
1127
+ assert_eq!(
1128
+ version_body("e", "h", Some(""))["build"],
1129
+ env!("CARGO_PKG_VERSION")
1130
+ );
680
1131
  }
681
1132
  }
682
1133