hecks 2.9.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (600) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +51 -6
  3. data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  5. data/lib/hecks/adapters/driven/lambda.rb +1 -1
  6. data/lib/hecks/adapters/driven/memory.rb +16 -3
  7. data/lib/hecks/adapters/driven/postgres/reconnect.rb +6 -2
  8. data/lib/hecks/adapters/driven/postgres/shared_connection.rb +180 -0
  9. data/lib/hecks/adapters/driven/postgres.rb +4 -2
  10. data/lib/hecks/adapters/driven/prism.rb +41 -8
  11. data/lib/hecks/adapters/driven/tenant_provisioner.rb +73 -16
  12. data/lib/hecks/behaviors/expectations.rb +1 -1
  13. data/lib/hecks/behaviors.rb +1 -1
  14. data/lib/hecks/bench/cli.rb +62 -0
  15. data/lib/hecks/bench/environment.rb +71 -0
  16. data/lib/hecks/bench/postgres_probe.rb +30 -0
  17. data/lib/hecks/bench/report.rb +76 -0
  18. data/lib/hecks/bench/ruby_runner.rb +93 -0
  19. data/lib/hecks/bench/run.rb +55 -0
  20. data/lib/hecks/bench/rust_runner.rb +101 -0
  21. data/lib/hecks/bench/stats.rb +46 -0
  22. data/lib/hecks/bench/suite.rb +98 -0
  23. data/lib/hecks/bench/workload.rb +89 -0
  24. data/lib/hecks/bench.rb +17 -0
  25. data/lib/hecks/bluebook/assembly/contracts.rb +2 -0
  26. data/lib/hecks/bluebook/attribute.rb +1 -1
  27. data/lib/hecks/bluebook/behaviour/aggregate.rb +30 -1
  28. data/lib/hecks/bluebook/behaviour/domain_port.rb +7 -0
  29. data/lib/hecks/bluebook/chapter.rb +6 -2
  30. data/lib/hecks/bluebook/domain_port.rb +36 -5
  31. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +10 -0
  32. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +8 -0
  33. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -1
  34. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +31 -3
  35. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +122 -3
  36. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +1 -1
  37. data/lib/hecks/bluebook/dsl/query_builder.rb +27 -1
  38. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -1
  39. data/lib/hecks/bluebook/dsl/world_builder.rb +29 -2
  40. data/lib/hecks/bluebook/expression/canonical_form.rb +1 -1
  41. data/lib/hecks/bluebook/expression/resolver.rb +3 -2
  42. data/lib/hecks/bluebook/hexagon.rb +13 -5
  43. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -0
  44. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +4 -1
  45. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +246 -0
  46. data/lib/hecks/bluebook/meta_validator.rb +9 -4
  47. data/lib/hecks/bluebook/model_check.rb +23 -0
  48. data/lib/hecks/bluebook/pattern_subset.rb +38 -0
  49. data/lib/hecks/bluebook/query.rb +20 -5
  50. data/lib/hecks/bluebook/smoke_test.rb +1 -1
  51. data/lib/hecks/canonical_json.rb +30 -0
  52. data/lib/hecks/chapters.rb +102 -0
  53. data/lib/hecks/cli/behaviors.rb +61 -0
  54. data/lib/hecks/cli/canonicalise.rb +25 -0
  55. data/lib/hecks/cli/check_engine_agreement.rb +32 -0
  56. data/lib/hecks/cli/check_era.rb +88 -0
  57. data/lib/hecks/cli/console.rb +66 -0
  58. data/lib/hecks/cli/deploy_template_diff.rb +40 -0
  59. data/lib/hecks/cli/doc_coverage.rb +32 -0
  60. data/lib/hecks/cli/document.rb +2 -2
  61. data/lib/hecks/cli/expression_projection.rb +41 -0
  62. data/lib/hecks/cli/follow.rb +88 -0
  63. data/lib/hecks/cli/history.rb +44 -0
  64. data/lib/hecks/cli/ir.rb +1 -1
  65. data/lib/hecks/cli/mcp.rb +3 -3
  66. data/lib/hecks/cli/model_check.rb +7 -4
  67. data/lib/hecks/cli/pattern_cases.rb +47 -0
  68. data/lib/hecks/cli/present.rb +39 -0
  69. data/lib/hecks/cli/project_cli.rb +224 -32
  70. data/lib/hecks/cli/project_diagrams.rb +4 -3
  71. data/lib/hecks/cli/project_glossary.rb +44 -0
  72. data/lib/hecks/cli/reference.rb +46 -0
  73. data/lib/hecks/cli/refresh_projections.rb +44 -0
  74. data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +150 -0
  75. data/lib/hecks/cli/release.rb +70 -0
  76. data/lib/hecks/cli/release_gem.rb +59 -0
  77. data/lib/hecks/cli/rspec_io_parallel_files.rb +137 -0
  78. data/lib/hecks/cli/rspec_shard_files.rb +66 -0
  79. data/lib/hecks/cli/run.rb +3 -3
  80. data/lib/hecks/cli/seed_semantics_corpus.rb +71 -0
  81. data/lib/hecks/cli/serve_query_ir_mcp.rb +30 -0
  82. data/lib/hecks/cli/shape.rb +70 -0
  83. data/lib/hecks/cli/smoke_http.rb +230 -0
  84. data/lib/hecks/cli/smoke_test.rb +5 -3
  85. data/lib/hecks/cli/spec_example.rb +27 -0
  86. data/lib/hecks/cli/statements.rb +32 -0
  87. data/lib/hecks/cli/stores.rb +4 -5
  88. data/lib/hecks/cli/stress_concurrency_specs.rb +177 -0
  89. data/lib/hecks/cli.rb +201 -52
  90. data/lib/hecks/codemod.rb +324 -0
  91. data/lib/hecks/corpus.rb +423 -0
  92. data/lib/hecks/deploy/bluebook/deploy.bluebook +591 -23
  93. data/lib/hecks/deploy/bluebook/deploy.hecksagon +41 -4
  94. data/lib/hecks/deploy/oidc.json +82 -2
  95. data/lib/hecks/doc/reference.rb +351 -0
  96. data/lib/hecks/doors/cli_door.rb +196 -0
  97. data/lib/hecks/doors/cli_runner.rb +297 -0
  98. data/lib/hecks/{facade → doors}/command_request.rb +1 -1
  99. data/lib/hecks/{facade → doors}/handle.rb +2 -2
  100. data/lib/hecks/{facade → doors}/json_door.rb +4 -4
  101. data/lib/hecks/doors/launcher_options.rb +129 -0
  102. data/lib/hecks/{cli → doors}/mcp_door.rb +7 -7
  103. data/lib/hecks/doors/mcp_door_scope.rb +97 -0
  104. data/lib/hecks/{facade/surface → doors/ruby_door}/aggregate_door.rb +8 -3
  105. data/lib/hecks/{facade/surface → doors/ruby_door}/chapter.rb +3 -3
  106. data/lib/hecks/{facade/surface.rb → doors/ruby_door.rb} +36 -5
  107. data/lib/hecks/doors.rb +61 -0
  108. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +1 -1
  109. data/lib/hecks/embryonaut_bluebook.rb +1 -1
  110. data/lib/hecks/engine_agreement.rb +156 -0
  111. data/lib/hecks/facade.rb +24 -37
  112. data/lib/hecks/forms/app.rb +6 -6
  113. data/lib/hecks/forms/banking_presentation.rb +50 -0
  114. data/lib/hecks/forms/field_shape.rb +2 -1
  115. data/lib/hecks/forms/port_argument.rb +1 -1
  116. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +393 -0
  117. data/lib/hecks/fuzzing/combination_miner.rb +86 -0
  118. data/lib/hecks/fuzzing/concurrent_dispatch.rb +214 -0
  119. data/lib/hecks/fuzzing/coverage_campaign.rb +143 -0
  120. data/lib/hecks/fuzzing/differential.rb +156 -0
  121. data/lib/hecks/fuzzing/domain_generator.rb +615 -0
  122. data/lib/hecks/fuzzing/era_boundary.rb +71 -0
  123. data/lib/hecks/fuzzing/form_census.rb +136 -0
  124. data/lib/hecks/fuzzing/generated_domain_check.rb +93 -0
  125. data/lib/hecks/fuzzing/invalid_value_generator.rb +64 -0
  126. data/lib/hecks/fuzzing/isolated_boot.rb +301 -0
  127. data/lib/hecks/fuzzing/nondeterministic.rb +51 -0
  128. data/lib/hecks/fuzzing/outside_world.rb +45 -0
  129. data/lib/hecks/fuzzing/persistence_parity.rb +102 -0
  130. data/lib/hecks/fuzzing/properties/corrections.rb +88 -0
  131. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +314 -0
  132. data/lib/hecks/fuzzing/properties/guards.rb +190 -0
  133. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +300 -0
  134. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +93 -0
  135. data/lib/hecks/fuzzing/properties/outbox.rb +89 -0
  136. data/lib/hecks/fuzzing/properties/querying.rb +175 -0
  137. data/lib/hecks/fuzzing/properties.rb +162 -0
  138. data/lib/hecks/fuzzing/qa_settings.rb +123 -0
  139. data/lib/hecks/fuzzing/replay.rb +505 -0
  140. data/lib/hecks/fuzzing/rotation_priority.rb +48 -0
  141. data/lib/hecks/fuzzing/rust_gap_manifest.rb +108 -0
  142. data/lib/hecks/fuzzing/self_consistency.rb +339 -0
  143. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +385 -0
  144. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +132 -0
  145. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +95 -0
  146. data/lib/hecks/fuzzing/sequence_generator/picker.rb +77 -0
  147. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +109 -0
  148. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +237 -0
  149. data/lib/hecks/fuzzing/sequence_generator.rb +189 -0
  150. data/lib/hecks/fuzzing/shrinker.rb +166 -0
  151. data/lib/hecks/fuzzing/structural_skips.rb +33 -0
  152. data/lib/hecks/fuzzing/sweep_depth.rb +43 -0
  153. data/lib/hecks/fuzzing/target_capabilities.rb +163 -0
  154. data/lib/hecks/fuzzing/value_generator.rb +216 -0
  155. data/lib/hecks/fuzzing.rb +23 -0
  156. data/lib/hecks/grammar/evolve.rb +336 -0
  157. data/lib/hecks/hecks/adapters/codebase/codemods.rb +45 -0
  158. data/lib/hecks/hecks/adapters/codebase/conformance.rb +71 -0
  159. data/lib/hecks/hecks/adapters/codebase/corpus_tasks.rb +202 -0
  160. data/lib/hecks/hecks/adapters/codebase/gem_registry.rb +80 -0
  161. data/lib/hecks/hecks/adapters/codebase/kernel_tables.rb +54 -0
  162. data/lib/hecks/hecks/adapters/codebase/language.rb +240 -0
  163. data/lib/hecks/hecks/adapters/codebase/npm_registry.rb +102 -0
  164. data/lib/hecks/hecks/adapters/codebase/publishing.rb +142 -0
  165. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +67 -0
  166. data/lib/hecks/hecks/adapters/codebase/release_facts.rb +90 -0
  167. data/lib/hecks/hecks/adapters/codebase/ruby_child.rb +83 -0
  168. data/lib/hecks/hecks/adapters/codebase/secret_vault.rb +36 -0
  169. data/lib/hecks/hecks/adapters/codebase/source_tree.adapter +7 -0
  170. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +189 -0
  171. data/lib/hecks/hecks/adapters/codebase/sqlite_fixture.rb +71 -0
  172. data/lib/hecks/hecks/adapters/codebase/style.rb +171 -0
  173. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +55 -0
  174. data/lib/hecks/hecks/adapters/codebase/test_suite.rb +165 -0
  175. data/lib/hecks/hecks/adapters/codebase/tree.rb +111 -0
  176. data/lib/hecks/hecks/adapters/console_capture.rb +79 -0
  177. data/lib/hecks/hecks/adapters/deploy_toolchain.adapter +7 -0
  178. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +108 -0
  179. data/lib/hecks/hecks/adapters/git.adapter +7 -0
  180. data/lib/hecks/hecks/adapters/git.rb +102 -0
  181. data/lib/hecks/hecks/adapters/host_http.adapter +7 -0
  182. data/lib/hecks/hecks/adapters/host_http.rb +65 -0
  183. data/lib/hecks/hecks/adapters/in_process_boot.adapter +7 -0
  184. data/lib/hecks/hecks/adapters/in_process_boot.rb +197 -0
  185. data/lib/hecks/hecks/adapters/in_process_operations.rb +202 -0
  186. data/lib/hecks/hecks/adapters/journal_store/changes.rb +143 -0
  187. data/lib/hecks/hecks/adapters/journal_store/compaction.rb +145 -0
  188. data/lib/hecks/hecks/adapters/journal_store/edge_audit.rb +132 -0
  189. data/lib/hecks/hecks/adapters/journal_store/examination.rb +135 -0
  190. data/lib/hecks/hecks/adapters/journal_store/held_domain.rb +163 -0
  191. data/lib/hecks/hecks/adapters/journal_store/readings.rb +187 -0
  192. data/lib/hecks/hecks/adapters/journal_store.adapter +7 -0
  193. data/lib/hecks/hecks/adapters/journal_store.rb +64 -0
  194. data/lib/hecks/hecks/adapters/local_files.adapter +7 -0
  195. data/lib/hecks/hecks/adapters/local_files.rb +46 -0
  196. data/lib/hecks/hecks/adapters/pg_admin.adapter +7 -0
  197. data/lib/hecks/hecks/adapters/pg_admin.rb +245 -0
  198. data/lib/hecks/hecks/adapters/process_pool.adapter +7 -0
  199. data/lib/hecks/hecks/adapters/process_pool.rb +119 -0
  200. data/lib/hecks/hecks/adapters/rust_toolchain.adapter +7 -0
  201. data/lib/hecks/hecks/adapters/rust_toolchain.rb +179 -0
  202. data/lib/hecks/hecks/adapters/rust_workspace.rb +150 -0
  203. data/lib/hecks/hecks/adapters/shell.adapter +7 -0
  204. data/lib/hecks/hecks/adapters/shell.rb +49 -0
  205. data/lib/hecks/hecks/adapters/terminal.adapter +7 -0
  206. data/lib/hecks/hecks/adapters/terminal.rb +74 -0
  207. data/lib/hecks/hecks/codebase.behaviors +172 -0
  208. data/lib/hecks/hecks/codebase.bluebook +2453 -0
  209. data/lib/hecks/hecks/context_map.hecksagon +13 -0
  210. data/lib/hecks/hecks/custodian.behaviors +179 -0
  211. data/lib/hecks/hecks/custodian.bluebook +1847 -0
  212. data/lib/hecks/hecks/deploy.behaviors +74 -0
  213. data/lib/hecks/hecks/deploy.world +12 -0
  214. data/lib/hecks/hecks/environments/memory.world +10 -0
  215. data/lib/hecks/hecks/governance.world +7 -0
  216. data/lib/hecks/hecks/hecks.behaviors +62 -0
  217. data/lib/hecks/hecks/hecks.bluebook +124 -0
  218. data/lib/hecks/hecks/hecks.hecksagon +390 -0
  219. data/lib/hecks/hecks/hecks.world +42 -0
  220. data/lib/hecks/kernel_capabilities.rb +194 -0
  221. data/lib/hecks/language/bluebook/bluebook.bluebook +11 -0
  222. data/lib/hecks/language/bluebook/query.bluebook +7 -0
  223. data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -4
  224. data/lib/hecks/language/hecksagon/domain_port.bluebook +10 -0
  225. data/lib/hecks/language/hecksagon/hecksagon.bluebook +20 -0
  226. data/lib/hecks/mcp_stdio_guard.rb +2 -2
  227. data/lib/hecks/persistence_legacy_fixture/regenerate.rb +161 -0
  228. data/lib/hecks/persistence_legacy_fixture.rb +113 -0
  229. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +8 -5
  230. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +4 -1
  231. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +15 -2
  232. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +1 -1
  233. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +1 -1
  234. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +1 -1
  235. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +32 -15
  236. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +6 -3
  237. data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +227 -0
  238. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +1 -1
  239. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +43 -24
  240. data/lib/hecks/ports/persistence/plugins/era/translation.rb +1 -0
  241. data/lib/hecks/ports/persistence/state_codec.rb +24 -5
  242. data/lib/hecks/projection_files.rb +285 -0
  243. data/lib/hecks/projections/bootstrap_table.rb +1 -1
  244. data/lib/hecks/projections/deploy/fargate.rb +5 -5
  245. data/lib/hecks/projections/deploy/lambda.rb +43 -32
  246. data/lib/hecks/projections/deploy/preview/preview.sh.erb +1 -1
  247. data/lib/hecks/projections/deploy/preview/template.rb +1 -1
  248. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +1 -1
  249. data/lib/hecks/projections/deploy/scripts/expected-era.header +1 -1
  250. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +3 -3
  251. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +1 -1
  252. data/lib/hecks/projections/deploy/shared.rb +1 -1
  253. data/lib/hecks/projections/diagrams.rb +3 -3
  254. data/lib/hecks/projections/model/deviations.rb +1 -1
  255. data/lib/hecks/projections/parser_table.rb +4 -4
  256. data/lib/hecks/projections/reference.rb +2 -3
  257. data/lib/hecks/projections/rust_vocabulary.rb +1 -1
  258. data/lib/hecks/projector/cli_projector.rb +106 -37
  259. data/lib/hecks/projector.rb +1 -1
  260. data/lib/hecks/quality_control/adapters/agent.adapter +7 -0
  261. data/lib/hecks/quality_control/adapters/agent.rb +58 -0
  262. data/lib/hecks/quality_control/adapters/angle_tools.adapter +7 -0
  263. data/lib/hecks/quality_control/adapters/angle_tools.rb +15 -0
  264. data/lib/hecks/quality_control/adapters/clearance_recorder.rb +36 -0
  265. data/lib/hecks/quality_control/adapters/clearance_tools.adapter +7 -0
  266. data/lib/hecks/quality_control/adapters/clearance_tools.rb +17 -0
  267. data/lib/hecks/quality_control/adapters/git_pr.adapter +7 -0
  268. data/lib/hecks/quality_control/adapters/git_pr.rb +226 -0
  269. data/lib/hecks/quality_control/adapters/github_checks.adapter +9 -0
  270. data/lib/hecks/quality_control/adapters/github_checks.rb +83 -0
  271. data/lib/hecks/quality_control/adapters/github_ci_webhook.rb +56 -0
  272. data/lib/hecks/quality_control/adapters/github_issues.adapter +7 -0
  273. data/lib/hecks/quality_control/adapters/github_issues.rb +49 -0
  274. data/lib/hecks/quality_control/adapters/qa_tool.rb +59 -0
  275. data/lib/hecks/quality_control/adapters/sweep_tools.adapter +7 -0
  276. data/lib/hecks/quality_control/adapters/sweep_tools.rb +51 -0
  277. data/lib/hecks/quality_control/adapters/target_tools.adapter +7 -0
  278. data/lib/hecks/quality_control/adapters/target_tools.rb +43 -0
  279. data/lib/hecks/quality_control/cli/child.rb +45 -0
  280. data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +45 -0
  281. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +248 -0
  282. data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +163 -0
  283. data/lib/hecks/quality_control/cli/qa_generated_domains.rb +450 -0
  284. data/lib/hecks/quality_control/cli/qa_log_bug.rb +195 -0
  285. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +220 -0
  286. data/lib/hecks/quality_control/cli/qa_open_pr.rb +305 -0
  287. data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +154 -0
  288. data/lib/hecks/quality_control/cli/qa_postgres_role.rb +159 -0
  289. data/lib/hecks/quality_control/cli/qa_pr_check.rb +232 -0
  290. data/lib/hecks/quality_control/cli/qa_seed_angles.rb +142 -0
  291. data/lib/hecks/quality_control/cli/qa_seed_targets.rb +69 -0
  292. data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +280 -0
  293. data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +329 -0
  294. data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +92 -0
  295. data/lib/hecks/quality_control/cli/qa_sweep/finding_report.rb +117 -0
  296. data/lib/hecks/quality_control/cli/qa_sweep/parsing.rb +175 -0
  297. data/lib/hecks/quality_control/cli/qa_sweep/release_mode.rb +60 -0
  298. data/lib/hecks/quality_control/cli/qa_sweep.rb +611 -0
  299. data/lib/hecks/quality_control/cli/qa_tick.rb +274 -0
  300. data/lib/hecks/quality_control/quality_control.bluebook +2224 -0
  301. data/lib/hecks/quality_control/quality_control.hecksagon +13 -0
  302. data/lib/hecks/quality_control/quality_control.ports.hecksagon +54 -0
  303. data/lib/hecks/query_ir.rb +346 -0
  304. data/lib/hecks/query_ir_mcp.rb +198 -0
  305. data/lib/hecks/release/runner/ci_publisher.rb +2 -2
  306. data/lib/hecks/release/runner/clean_tree.rb +42 -0
  307. data/lib/hecks/release/runner/console.rb +6 -0
  308. data/lib/hecks/release/runner/gem_publisher.rb +14 -7
  309. data/lib/hecks/release/runner/npm_publisher.rb +6 -36
  310. data/lib/hecks/release/runner/preflight.rb +26 -2
  311. data/lib/hecks/release/runner/published.rb +8 -26
  312. data/lib/hecks/release/runner/tagger.rb +30 -15
  313. data/lib/hecks/release/runner.rb +57 -6
  314. data/lib/hecks/router/namespace_installer.rb +3 -3
  315. data/lib/hecks/runtime/adapter_lookup.rb +124 -0
  316. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +4 -0
  317. data/lib/hecks/runtime/dependency_planning.rb +20 -2
  318. data/lib/hecks/runtime/dispatcher.rb +53 -3
  319. data/lib/hecks/runtime/interpreting.rb +3 -0
  320. data/lib/hecks/runtime/loader.rb +65 -12
  321. data/lib/hecks/runtime/outbox.rb +1 -1
  322. data/lib/hecks/runtime/policy_interpreter.rb +5 -2
  323. data/lib/hecks/runtime/port_operation_interpreter.rb +2 -10
  324. data/lib/hecks/runtime/query_interpreter.rb +77 -0
  325. data/lib/hecks/runtime/reaction_outcome.rb +89 -0
  326. data/lib/hecks/runtime/refusal_wording.rb +23 -1
  327. data/lib/hecks/runtime/registry/verification.rb +163 -4
  328. data/lib/hecks/runtime/registry.rb +57 -3
  329. data/lib/hecks/runtime/remote_dispatcher.rb +31 -2
  330. data/lib/hecks/runtime/value/coercion.rb +23 -135
  331. data/lib/hecks/runtime/value/field_checks.rb +172 -0
  332. data/lib/hecks/runtime/value.rb +4 -1
  333. data/lib/hecks/runtime.rb +20 -6
  334. data/lib/hecks/rust_build/conformance.rb +158 -0
  335. data/lib/hecks/rust_build/conformance_fuzz.rb +71 -0
  336. data/lib/hecks/rust_build/coverage.rb +218 -0
  337. data/lib/hecks/rust_build/native_build.rb +124 -0
  338. data/lib/hecks/rust_build/project_rust/root_mod.rb +120 -0
  339. data/lib/hecks/rust_build/project_rust.rb +332 -0
  340. data/lib/hecks/rust_build/wasm.rb +105 -0
  341. data/lib/hecks/rust_build/wasm_browser.rb +113 -0
  342. data/lib/hecks/rust_build.rb +144 -0
  343. data/lib/hecks/storehouse.rb +16 -16
  344. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +2 -2
  345. data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +1 -1
  346. data/lib/hecks/three_zero/forms.yml +127 -0
  347. data/lib/hecks/three_zero.rb +12 -0
  348. data/lib/hecks/tools/argument_gate_matrix.rb +316 -0
  349. data/lib/hecks/tools/comment_style.rb +1070 -0
  350. data/lib/hecks/tools/corpus_report.rb +87 -0
  351. data/lib/hecks/tools/deploy_recipe.rb +255 -0
  352. data/lib/hecks/tools/deploy_recipe_lint.rb +282 -0
  353. data/lib/hecks/tools/drop_implicit_append_fields.rb +95 -0
  354. data/lib/hecks/tools/era_projection_backfill.rb +94 -0
  355. data/lib/hecks/tools/era_reattest.rb +146 -0
  356. data/lib/hecks/tools/evolve_run.rb +189 -0
  357. data/lib/hecks/tools/fuzz_sweep.rb +373 -0
  358. data/lib/hecks/tools/heki_compaction.rb +100 -0
  359. data/lib/hecks/tools/hoist_local_givens.rb +246 -0
  360. data/lib/hecks/tools/journal_compaction.rb +89 -0
  361. data/lib/hecks/tools/oidc_manifests.rb +70 -0
  362. data/lib/hecks/tools/query_ir_run.rb +81 -0
  363. data/lib/hecks/tools/regeneration_run.rb +166 -0
  364. data/lib/hecks/tools/rust_comment_style.rb +642 -0
  365. data/lib/hecks/tools/sequence_script.rb +62 -0
  366. data/lib/hecks/tools/tail_merge.rb +117 -0
  367. data/lib/hecks/tools/tenant_provisioning.rb +142 -0
  368. data/lib/hecks/tools/translation_audit.rb +208 -0
  369. data/lib/hecks/tools/translation_scaffold.rb +131 -0
  370. data/lib/hecks/tools.rb +86 -0
  371. data/lib/hecks/version.rb +1 -1
  372. data/lib/hecks.rb +32 -15
  373. data/qa/settings.yml +66 -0
  374. data/rust/.cargo/config.toml +6 -0
  375. data/rust/.gitignore +2 -0
  376. data/rust/Cargo.lock +54 -0
  377. data/rust/Cargo.toml +61 -0
  378. data/rust/build/.cargo/config.toml +3 -0
  379. data/rust/build/.gitignore +1 -0
  380. data/rust/build/Cargo.lock +7 -0
  381. data/rust/build/Cargo.toml +19 -0
  382. data/rust/build/src/build_artifact.rs +47 -0
  383. data/rust/build/src/cargo_sync.rs +248 -0
  384. data/rust/build/src/json.rs +421 -0
  385. data/rust/build/src/lineage_pass.rs +285 -0
  386. data/rust/build/src/main.rs +51 -0
  387. data/rust/build/src/optional_pass.rs +150 -0
  388. data/rust/build/src/pipeline.rs +264 -0
  389. data/rust/build/src/reserved_names.rs +76 -0
  390. data/rust/build/src/resolve.rs +124 -0
  391. data/rust/build/src/root.rs +25 -0
  392. data/rust/build/src/sidecars.rs +73 -0
  393. data/rust/build/src/subprocess.rs +51 -0
  394. data/rust/build/src/tmp.rs +31 -0
  395. data/rust/codegen/.cargo/config.toml +3 -0
  396. data/rust/codegen/.gitignore +1 -0
  397. data/rust/codegen/Cargo.lock +7 -0
  398. data/rust/codegen/Cargo.toml +20 -0
  399. data/rust/codegen/src/attr.rs +32 -0
  400. data/rust/codegen/src/bridging.rs +481 -0
  401. data/rust/codegen/src/commands.rs +1226 -0
  402. data/rust/codegen/src/constraints.rs +109 -0
  403. data/rust/codegen/src/dependency_planning.rs +209 -0
  404. data/rust/codegen/src/domain_generator.rs +1288 -0
  405. data/rust/codegen/src/exemplar.rs +230 -0
  406. data/rust/codegen/src/expr_emitter.rs +87 -0
  407. data/rust/codegen/src/fielded.rs +192 -0
  408. data/rust/codegen/src/hecks_naming.rs +33 -0
  409. data/rust/codegen/src/json.rs +335 -0
  410. data/rust/codegen/src/json_codec.rs +697 -0
  411. data/rust/codegen/src/literal.rs +207 -0
  412. data/rust/codegen/src/main.rs +290 -0
  413. data/rust/codegen/src/manifest.rs +227 -0
  414. data/rust/codegen/src/mutations.rs +784 -0
  415. data/rust/codegen/src/naming.rs +286 -0
  416. data/rust/codegen/src/ports.rs +208 -0
  417. data/rust/codegen/src/prelude.rs +116 -0
  418. data/rust/codegen/src/queries.rs +786 -0
  419. data/rust/codegen/src/reactions.rs +532 -0
  420. data/rust/codegen/src/read_models.rs +697 -0
  421. data/rust/codegen/src/reference_specs.rs +40 -0
  422. data/rust/codegen/src/registry.rs +966 -0
  423. data/rust/codegen/src/reserved_names.rs +76 -0
  424. data/rust/codegen/src/shared.rs +93 -0
  425. data/rust/codegen/src/skip_reason.rs +25 -0
  426. data/rust/codegen/src/types.rs +362 -0
  427. data/rust/host/.cargo/config.toml +3 -0
  428. data/rust/host/.gitignore +1 -0
  429. data/rust/host/Cargo.lock +4297 -0
  430. data/rust/host/Cargo.toml +61 -0
  431. data/rust/host/HECKS_RELEASE +1 -0
  432. data/rust/host/rds-ca-bundle.pem +2736 -0
  433. data/rust/host/src/api.rs +1596 -0
  434. data/rust/host/src/approval.rs +652 -0
  435. data/rust/host/src/auth.rs +1287 -0
  436. data/rust/host/src/bin/expr_harness.rs +46 -0
  437. data/rust/host/src/bin/lineage_harness.rs +119 -0
  438. data/rust/host/src/bin/mint_harness.rs +135 -0
  439. data/rust/host/src/checkout.rs +265 -0
  440. data/rust/host/src/dispatch.rs +1443 -0
  441. data/rust/host/src/expr_json.rs +1117 -0
  442. data/rust/host/src/field_hints.rs +52 -0
  443. data/rust/host/src/ir.rs +733 -0
  444. data/rust/host/src/journal.rs +874 -0
  445. data/rust/host/src/lambda_client.rs +378 -0
  446. data/rust/host/src/log.rs +147 -0
  447. data/rust/host/src/main.rs +474 -0
  448. data/rust/host/src/mint.rs +1865 -0
  449. data/rust/host/src/payments/keystore.rs +330 -0
  450. data/rust/host/src/payments/tests.rs +1400 -0
  451. data/rust/host/src/payments.rs +767 -0
  452. data/rust/host/src/presentation.rs +698 -0
  453. data/rust/host/src/presentation_write.rs +1431 -0
  454. data/rust/host/src/query_step.rs +149 -0
  455. data/rust/host/src/rate_limit.rs +904 -0
  456. data/rust/host/src/reference_transform.rs +241 -0
  457. data/rust/host/src/reference_validate.rs +338 -0
  458. data/rust/host/src/resend.rs +296 -0
  459. data/rust/host/src/secrets.rs +106 -0
  460. data/rust/host/src/server.rs +663 -0
  461. data/rust/host/src/storage_shape.rs +175 -0
  462. data/rust/host/src/test_pg.rs +40 -0
  463. data/rust/host/src/ui_schema.rs +1351 -0
  464. data/rust/host/src/wasm_runner.rs +191 -0
  465. data/rust/host/src/web/newsletter/cooldown.rs +126 -0
  466. data/rust/host/src/web/newsletter/tests.rs +104 -0
  467. data/rust/host/src/web/newsletter.rs +310 -0
  468. data/rust/host/src/web/newsletter_send.rs +357 -0
  469. data/rust/host/src/web/registrations.rs +1505 -0
  470. data/rust/host/src/web.rs +2937 -0
  471. data/rust/lsp/.gitignore +1 -0
  472. data/rust/lsp/Cargo.lock +7 -0
  473. data/rust/lsp/Cargo.toml +16 -0
  474. data/rust/lsp/README.md +133 -0
  475. data/rust/lsp/src/diagnostics.rs +165 -0
  476. data/rust/lsp/src/json.rs +385 -0
  477. data/rust/lsp/src/main.rs +399 -0
  478. data/rust/lsp/src/outline.rs +177 -0
  479. data/rust/lsp/src/rpc.rs +67 -0
  480. data/rust/parser/.cargo/config.toml +5 -0
  481. data/rust/parser/.gitignore +1 -0
  482. data/rust/parser/Cargo.lock +7 -0
  483. data/rust/parser/Cargo.toml +21 -0
  484. data/rust/parser/src/build/closed_sets.rs +27 -0
  485. data/rust/parser/src/build/identity.rs +276 -0
  486. data/rust/parser/src/build/mod.rs +12 -0
  487. data/rust/parser/src/build/naming.rs +141 -0
  488. data/rust/parser/src/build/pattern_subset.rs +161 -0
  489. data/rust/parser/src/build/query_derive.rs +72 -0
  490. data/rust/parser/src/build/query_inference.rs +269 -0
  491. data/rust/parser/src/build/query_options.rs +100 -0
  492. data/rust/parser/src/build/read_model.rs +94 -0
  493. data/rust/parser/src/build/references.rs +32 -0
  494. data/rust/parser/src/canonical.rs +114 -0
  495. data/rust/parser/src/diag.rs +55 -0
  496. data/rust/parser/src/emit.rs +953 -0
  497. data/rust/parser/src/expr/ast_json.rs +147 -0
  498. data/rust/parser/src/expr/evaluator.rs +81 -0
  499. data/rust/parser/src/expr/mod.rs +84 -0
  500. data/rust/parser/src/expr/resolver.rs +497 -0
  501. data/rust/parser/src/ir.rs +387 -0
  502. data/rust/parser/src/keywords.rs +585 -0
  503. data/rust/parser/src/lex.rs +662 -0
  504. data/rust/parser/src/main.rs +252 -0
  505. data/rust/parser/src/parse/aggregate.rs +612 -0
  506. data/rust/parser/src/parse/chapter.rs +517 -0
  507. data/rust/parser/src/parse/command.rs +637 -0
  508. data/rust/parser/src/parse/domain_port.rs +127 -0
  509. data/rust/parser/src/parse/entity.rs +527 -0
  510. data/rust/parser/src/parse/file.rs +67 -0
  511. data/rust/parser/src/parse/hecksagon.rs +231 -0
  512. data/rust/parser/src/parse/lifecycle.rs +162 -0
  513. data/rust/parser/src/parse/mod.rs +1262 -0
  514. data/rust/parser/src/parse/policy.rs +87 -0
  515. data/rust/parser/src/parse/process_manager.rs +305 -0
  516. data/rust/parser/src/parse/query.rs +100 -0
  517. data/rust/parser/src/parse/read_model.rs +214 -0
  518. data/rust/parser/src/parse/value_object.rs +250 -0
  519. data/rust/parser/src/ruby_value.rs +422 -0
  520. data/rust/project/bridging.rb +257 -0
  521. data/rust/project/commands.rb +877 -0
  522. data/rust/project/constraints.rb +86 -0
  523. data/rust/project/dependency_planning.rb +178 -0
  524. data/rust/project/domain_generator.rb +980 -0
  525. data/rust/project/exemplar.rb +148 -0
  526. data/rust/project/expr_emitter.rb +72 -0
  527. data/rust/project/fielded.rb +141 -0
  528. data/rust/project/json_codec.rb +641 -0
  529. data/rust/project/mutations.rb +721 -0
  530. data/rust/project/naming.rb +205 -0
  531. data/rust/project/ports.rb +65 -0
  532. data/rust/project/queries.rb +493 -0
  533. data/rust/project/reactions.rb +317 -0
  534. data/rust/project/read_models.rb +690 -0
  535. data/rust/project/reference_specs.rb +27 -0
  536. data/rust/project/registry.rb +396 -0
  537. data/rust/project/skip_reason.rb +29 -0
  538. data/rust/project/types.rb +225 -0
  539. data/rust/project/write_if_changed.rb +83 -0
  540. data/rust/project.rb +30 -0
  541. data/rust/project_rust_pipeline.rb +383 -0
  542. data/rust/src/exemplar/commands.rs +279 -0
  543. data/rust/src/exemplar/constraints.rs +18 -0
  544. data/rust/src/exemplar/fielded.rs +280 -0
  545. data/rust/src/exemplar/json.rs +277 -0
  546. data/rust/src/exemplar/mod.rs +17 -0
  547. data/rust/src/exemplar/mutations.rs +80 -0
  548. data/rust/src/exemplar/queries.rs +33 -0
  549. data/rust/src/exemplar/reactions.rs +88 -0
  550. data/rust/src/exemplar/read_models.rs +48 -0
  551. data/rust/src/exemplar/registry.rs +166 -0
  552. data/rust/src/exemplar/types.rs +44 -0
  553. data/rust/src/kernel/attribute_shapes/composite.rs +88 -0
  554. data/rust/src/kernel/attribute_shapes/list.rs +18 -0
  555. data/rust/src/kernel/attribute_shapes/mod.rs +44 -0
  556. data/rust/src/kernel/attribute_shapes/optional.rs +9 -0
  557. data/rust/src/kernel/attribute_shapes/scalar.rs +39 -0
  558. data/rust/src/kernel/cli.rs +585 -0
  559. data/rust/src/kernel/dispatch.rs +1090 -0
  560. data/rust/src/kernel/expr.rs +458 -0
  561. data/rust/src/kernel/expression_operators/arithmetic.rs +128 -0
  562. data/rust/src/kernel/expression_operators/comparison.rs +48 -0
  563. data/rust/src/kernel/expression_operators/enumeration.rs +232 -0
  564. data/rust/src/kernel/expression_operators/logical.rs +13 -0
  565. data/rust/src/kernel/expression_operators/membership.rs +18 -0
  566. data/rust/src/kernel/expression_operators/mod.rs +68 -0
  567. data/rust/src/kernel/expression_operators/pattern_match.rs +73 -0
  568. data/rust/src/kernel/expression_operators/positional.rs +126 -0
  569. data/rust/src/kernel/expression_operators/presence.rs +101 -0
  570. data/rust/src/kernel/expression_operators/sign_test.rs +16 -0
  571. data/rust/src/kernel/expression_operators/sized.rs +35 -0
  572. data/rust/src/kernel/expression_operators/text.rs +79 -0
  573. data/rust/src/kernel/expression_operators/to_string.rs +18 -0
  574. data/rust/src/kernel/json.rs +779 -0
  575. data/rust/src/kernel/mod.rs +133 -0
  576. data/rust/src/kernel/named_query.rs +219 -0
  577. data/rust/src/kernel/orchestrate.rs +1810 -0
  578. data/rust/src/kernel/pattern.rs +307 -0
  579. data/rust/src/kernel/query_comparators.rs +206 -0
  580. data/rust/src/kernel/query_ordering.rs +103 -0
  581. data/rust/src/kernel/read_model.rs +914 -0
  582. data/rust/src/kernel/reference_lookup.rs +149 -0
  583. data/rust/src/kernel/repository.rs +468 -0
  584. data/rust/src/kernel/routing.rs +612 -0
  585. data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +118 -0
  586. data/rust/src/kernel/vocab/entity_dispatch_order.rs +114 -0
  587. data/rust/src/kernel/vocab/field_hint.rs +70 -0
  588. data/rust/src/kernel/vocab/mod.rs +20 -0
  589. data/rust/src/kernel/vocab/query_comparator.rs +65 -0
  590. data/rust/src/kernel/vocab/refusal_template.rs +1592 -0
  591. data/rust/src/lib.rs +8 -0
  592. data/rust/src/main.rs +16 -0
  593. data/rust/web/.gitignore +1 -0
  594. data/rust/web/Cargo.lock +119 -0
  595. data/rust/web/Cargo.toml +18 -0
  596. data/rust/web/src/lib.rs +8 -0
  597. metadata +477 -12
  598. data/lib/hecks/facade/cli_door.rb +0 -96
  599. data/lib/hecks/facade/cli_runner.rb +0 -127
  600. data/lib/hecks/mcp_door_scope.rb +0 -95
@@ -0,0 +1,663 @@
1
+ //! HTTP server for AWS Fargate (`HECKS_SERVE_MODE=1`): the axum fallback route below shares
2
+ //! `dispatch_body` with the Lambda custom-runtime path, so neither repeats the other's logic.
3
+
4
+ use crate::dispatch;
5
+ use crate::journal::LineageConfig;
6
+ use crate::lambda_client::LambdaInvoker;
7
+ use crate::log;
8
+ use crate::rate_limit::{self, RateLimits, Verdict};
9
+ use crate::wasm_runner;
10
+ use crate::web;
11
+ use axum::body::Bytes;
12
+ use axum::extract::{ConnectInfo, State};
13
+ use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri};
14
+ use axum::response::{IntoResponse, Response};
15
+ use axum::routing::{any, get};
16
+ use axum::Router;
17
+ use lambda_runtime::Error;
18
+ use serde_json::Value;
19
+ use std::net::SocketAddr;
20
+ use std::path::{Path, PathBuf};
21
+ use std::sync::Arc;
22
+ use tokio::sync::Mutex;
23
+ use tokio_postgres::Client;
24
+
25
+ /// The per-request dispatch decision, shared between the Lambda custom-runtime path and the
26
+ /// axum fallback route below. `body` is already-parsed JSON in one of three 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 {
35
+ return Ok(response);
36
+ }
37
+
38
+ if body.get("read").and_then(|v| v.as_bool()) == Some(true) {
39
+ // `{e:#}`: anyhow's alternate Display walks `source()` to the real message,
40
+ // 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:#}"))?;
42
+ return Ok(result);
43
+ }
44
+
45
+ // A query step reads current state and writes nothing to the journal.
46
+ if body.get("query").is_some() {
47
+ return Ok(crate::query_step::answer(&body, crate::ir::ir(), &config.domain, client, wasm_path).await?);
48
+ }
49
+
50
+ let verb = body
51
+ .get("verb")
52
+ .and_then(|v| v.as_str())
53
+ .ok_or("event missing \"verb\"")?
54
+ .to_string();
55
+ let role = body.get("role").and_then(|v| v.as_str()).map(|s| s.to_string());
56
+
57
+ let outcome = if let Some(to) = body.get("to").cloned() {
58
+ if body.get("args").is_some() {
59
+ return Err("cannot combine to/with with legacy args".into());
60
+ }
61
+ let facts = body.get("with").cloned().unwrap_or_else(|| serde_json::json!({}));
62
+ dispatch::handle_routed(client, wasm_path, &verb, to, facts, role.as_deref(), config, invoker)
63
+ .await
64
+ .map_err(|e| format!("{e:#}"))?
65
+ } else if let Some(facts) = body.get("with").cloned() {
66
+ if body.get("args").is_some() {
67
+ return Err("cannot combine \"with\" with legacy args".into());
68
+ }
69
+ dispatch::handle_facts(client, wasm_path, &verb, facts, role.as_deref(), config, invoker)
70
+ .await
71
+ .map_err(|e| format!("{e:#}"))?
72
+ } else {
73
+ let args = body.get("args").cloned().unwrap_or_else(|| serde_json::json!({}));
74
+ dispatch::handle(client, wasm_path, &verb, args, role.as_deref(), config, invoker)
75
+ .await
76
+ .map_err(|e| format!("{e:#}"))?
77
+ };
78
+ Ok(outcome.result)
79
+ }
80
+
81
+ /// Everything `dispatch_body` needs, `Arc`-shared for axum's per-request handler cloning.
82
+ /// `client`'s mutex serializes every write onto the one Postgres connection this process holds.
83
+ #[derive(Clone)]
84
+ pub struct ServerState {
85
+ pub client: Arc<Mutex<Client>>,
86
+ pub wasm_path: Arc<PathBuf>,
87
+ pub lineage_config: Arc<LineageConfig>,
88
+ pub invoker: Arc<dyn LambdaInvoker>,
89
+ pub limits: Arc<RateLimits>,
90
+ }
91
+
92
+ /// The document `GET /version` serves: era, IR hash, and build — all fixed for the life of
93
+ /// the process, so this is built once at boot. Carries no secrets.
94
+ pub fn version_body(era: &str, ir_hash: &str, build_env: Option<&str>) -> Value {
95
+ let build = build_env.filter(|v| !v.is_empty()).unwrap_or(env!("CARGO_PKG_VERSION"));
96
+ serde_json::json!({ "era": era, "ir_hash": ir_hash, "build": build })
97
+ }
98
+
99
+ /// `GET /version` as its own router, merged ahead of the dispatch fallback: a matched
100
+ /// request answers a clone of the boot-time document, never touching auth or dispatch.
101
+ pub fn version_router(body: Value) -> Router {
102
+ let body = Arc::new(body);
103
+ Router::new().route("/version", get(move || std::future::ready(axum::Json(body.as_ref().clone()))))
104
+ }
105
+
106
+ /// Compiles the domain's wasm module, then binds `$PORT` (falls back to 8080) and serves
107
+ /// forever. Nothing listens until the compile finishes, so the health check never lies.
108
+ pub async fn serve(state: ServerState, version: Value) -> Result<(), Error> {
109
+ let port: u16 = std::env::var("PORT").ok().and_then(|v| v.parse().ok()).unwrap_or(8080);
110
+
111
+ warm_wasm(&state.wasm_path).await;
112
+
113
+ let phase = log::phase_with("serve_start", serde_json::json!({ "port": port }));
114
+ let listener = tokio::net::TcpListener::bind(("0.0.0.0", port)).await?;
115
+ phase.end();
116
+ serve_on(listener, state, version).await
117
+ }
118
+
119
+ /// The router and serve loop over an already-bound `listener`, split out so a test can
120
+ /// run the real service on an ephemeral port.
121
+ async fn serve_on(listener: tokio::net::TcpListener, state: ServerState, version: Value) -> Result<(), Error> {
122
+ let app = Router::new()
123
+ .route("/", get(health))
124
+ .fallback(any(dispatch_route))
125
+ .with_state(state)
126
+ .merge(version_router(version));
127
+
128
+ // The connect info is the TCP peer, which `rate_limit` needs to tell a visitor from a proxy.
129
+ axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?;
130
+ Ok(())
131
+ }
132
+
133
+ /// Compiles the domain's wasm module before the host listens, so a request right after
134
+ /// boot finds it ready. A module that fails to compile is logged, not fatal.
135
+ async fn warm_wasm(wasm_path: &Arc<PathBuf>) {
136
+ let phase = log::phase("wasm_warm");
137
+ let path = Arc::clone(wasm_path);
138
+ match tokio::task::spawn_blocking(move || wasm_runner::warm(&path)).await {
139
+ Ok(Ok(())) => {
140
+ phase.end();
141
+ }
142
+ Ok(Err(e)) => log::error("wasm_warm_failed", serde_json::json!({ "error": format!("{e:#}") })),
143
+ Err(e) => log::error("wasm_warm_failed", serde_json::json!({ "error": format!("{e}") })),
144
+ }
145
+ }
146
+
147
+ /// The ALB health check — no dispatch logic behind it, so a request backlog never delays it.
148
+ async fn health() -> StatusCode {
149
+ StatusCode::OK
150
+ }
151
+
152
+ /// Runs one request and logs it: method, path (never the query string, which can carry
153
+ /// OAuth codes/tokens), status, and duration.
154
+ async fn dispatch_route(
155
+ State(state): State<ServerState>,
156
+ ConnectInfo(peer): ConnectInfo<SocketAddr>,
157
+ method: Method,
158
+ uri: Uri,
159
+ headers: HeaderMap,
160
+ body: Bytes,
161
+ ) -> Response {
162
+ let started = std::time::Instant::now();
163
+ let path = uri.path().to_string();
164
+ let verb = method.as_str().to_string();
165
+ let response = route_request(state, peer, method, uri, headers, body).await;
166
+ let status = response.status().as_u16();
167
+ let fields = serde_json::json!({
168
+ "method": verb, "path": path, "status": status, "ms": started.elapsed().as_millis() as u64,
169
+ });
170
+ if status >= 500 {
171
+ log::error("request", fields);
172
+ } else {
173
+ log::info("request", fields);
174
+ }
175
+ response
176
+ }
177
+
178
+ async fn route_request(state: ServerState, peer: SocketAddr, method: Method, uri: Uri, headers: HeaderMap, body: Bytes) -> Response {
179
+ let envelope = match admit(&state.limits, peer, &method, &uri, &headers, &body) {
180
+ Ok(envelope) => envelope,
181
+ Err(refusal) => return *refusal,
182
+ };
183
+
184
+ match dispatch_body(envelope, &state.client, &state.wasm_path, &state.lineage_config, state.invoker.as_ref()).await {
185
+ Ok(value) => value_to_response(value),
186
+ Err(e) => {
187
+ log::error("dispatch_failed", serde_json::json!({ "error": format!("{e}") }));
188
+ (StatusCode::INTERNAL_SERVER_ERROR, axum::Json(serde_json::json!({ "error": format!("{e}") }))).into_response()
189
+ }
190
+ }
191
+ }
192
+
193
+ /// Turns a raw HTTP request into `dispatch_body`'s envelope, or into the response that ends
194
+ /// it first: a `400` for non-JSON, a `429` for a caller over a public route's rate limit.
195
+ fn admit(limits: &RateLimits, peer: SocketAddr, method: &Method, uri: &Uri, headers: &HeaderMap, body: &Bytes) -> Result<Value, Box<Response>> {
196
+ let parsed: Value = if body.is_empty() {
197
+ serde_json::json!({})
198
+ } else {
199
+ match serde_json::from_slice(body) {
200
+ Ok(value) => value,
201
+ Err(e) => return Err(Box::new((StatusCode::BAD_REQUEST, format!("invalid JSON body: {e}")).into_response())),
202
+ }
203
+ };
204
+
205
+ // A real HTTP client (browser via the ALB, or a server-to-server call) carries none of a
206
+ // Function URL's automatic wrapping, so anything not already one of `dispatch_body`'s
207
+ // three recognized shapes gets that envelope synthesized from the real request instead.
208
+ // The internal shapes carry a caller's own claim of `role` with no session check, so
209
+ // they're honored only from a peer on this host (the task's own sidecar); everyone
210
+ // else — the load balancer, i.e. the public internet — gets an ordinary request
211
+ // envelope for the path it actually hit.
212
+ let envelope = if trusts_internal_dispatch(peer) && is_internal_dispatch_shape(&parsed) {
213
+ parsed
214
+ } else {
215
+ synthesize_function_url_envelope(method, uri, headers, body)
216
+ };
217
+
218
+ if let Some((route_method, route_path)) = envelope_route(&envelope) {
219
+ if let Verdict::Limited { retry_after_secs } = limits.check(route_method, route_path, headers, Some(peer.ip())) {
220
+ return Err(Box::new(rate_limit::too_many_requests(retry_after_secs)));
221
+ }
222
+ }
223
+ Ok(envelope)
224
+ }
225
+
226
+ /// The method and path a Function-URL-shaped envelope names, or `None` for
227
+ /// the verb and read shapes, which carry no route.
228
+ fn envelope_route(envelope: &Value) -> Option<(&str, &str)> {
229
+ let method = envelope.get("requestContext")?.get("http")?.get("method")?.as_str()?;
230
+ let path = envelope.get("rawPath")?.as_str()?;
231
+ Some((method, path))
232
+ }
233
+
234
+ /// Whether `peer` is on this host (loopback) — the only caller trusted with the internal
235
+ /// dispatch protocol. An IPv4 address mapped into IPv6 counts as the IPv4 it wraps.
236
+ fn trusts_internal_dispatch(peer: SocketAddr) -> bool {
237
+ match peer.ip() {
238
+ std::net::IpAddr::V4(addr) => addr.is_loopback(),
239
+ std::net::IpAddr::V6(addr) => addr.is_loopback() || addr.to_ipv4_mapped().is_some_and(|mapped| mapped.is_loopback()),
240
+ }
241
+ }
242
+
243
+ /// Whether `value` already matches one of `dispatch_body`'s recognized shapes; anything
244
+ /// else (including a bare `{}`) is real REST traffic needing `synthesize_function_url_envelope`.
245
+ fn is_internal_dispatch_shape(value: &Value) -> bool {
246
+ value.get("requestContext").is_some() || value.get("read").is_some() || value.get("verb").is_some()
247
+ }
248
+
249
+ /// Rebuilds the envelope shape a real Lambda Function URL invocation already produces
250
+ /// automatically, from the raw axum request parts.
251
+ fn synthesize_function_url_envelope(method: &Method, uri: &Uri, headers: &HeaderMap, body: &Bytes) -> Value {
252
+ let mut header_map = serde_json::Map::new();
253
+ for (name, value) in headers.iter() {
254
+ if let Ok(value) = value.to_str() {
255
+ header_map.insert(name.as_str().to_ascii_lowercase(), Value::String(value.to_string()));
256
+ }
257
+ }
258
+
259
+ serde_json::json!({
260
+ "requestContext": { "http": { "method": method.as_str() } },
261
+ "rawPath": uri.path(),
262
+ "rawQueryString": uri.query().unwrap_or(""),
263
+ "headers": header_map,
264
+ "body": String::from_utf8_lossy(body).into_owned(),
265
+ "isBase64Encoded": false,
266
+ })
267
+ }
268
+
269
+ /// Translates `dispatch_body`'s JSON result into a real HTTP response: `web::render`'s
270
+ /// Function-URL envelope when `statusCode` is present, else the raw outcome as `200` JSON.
271
+ fn value_to_response(value: Value) -> Response {
272
+ let Some(status_code) = value.get("statusCode").and_then(|v| v.as_u64()) else {
273
+ return (StatusCode::OK, axum::Json(value)).into_response();
274
+ };
275
+ let status = StatusCode::from_u16(status_code as u16).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
276
+
277
+ let mut headers = HeaderMap::new();
278
+ if let Some(header_obj) = value.get("headers").and_then(|h| h.as_object()) {
279
+ for (name, header_value) in header_obj {
280
+ let Some(header_value) = header_value.as_str() else { continue };
281
+ if let (Ok(name), Ok(header_value)) = (HeaderName::from_bytes(name.as_bytes()), HeaderValue::from_str(header_value)) {
282
+ headers.insert(name, header_value);
283
+ }
284
+ }
285
+ }
286
+ // The Function-URL response's `cookies` array (raw "name=value; ..." strings) becomes
287
+ // its own `Set-Cookie` header per entry, the way a real Function URL invocation does.
288
+ if let Some(cookies) = value.get("cookies").and_then(|c| c.as_array()) {
289
+ for cookie in cookies {
290
+ if let Some(cookie) = cookie.as_str() {
291
+ if let Ok(header_value) = HeaderValue::from_str(cookie) {
292
+ headers.append(axum::http::header::SET_COOKIE, header_value);
293
+ }
294
+ }
295
+ }
296
+ }
297
+
298
+ let raw_body = value.get("body").and_then(|v| v.as_str()).unwrap_or("");
299
+ let body_bytes = if value.get("isBase64Encoded").and_then(|v| v.as_bool()) == Some(true) {
300
+ web::base64_decode(raw_body)
301
+ } else {
302
+ raw_body.as_bytes().to_vec()
303
+ };
304
+
305
+ (status, headers, body_bytes).into_response()
306
+ }
307
+
308
+ #[cfg(test)]
309
+ mod tests {
310
+ use super::*;
311
+
312
+ #[test]
313
+ fn a_function_url_shaped_body_is_recognized_as_internal_dispatch() {
314
+ assert!(is_internal_dispatch_shape(&serde_json::json!({"requestContext": {"http": {"method": "GET"}}})));
315
+ }
316
+
317
+ #[test]
318
+ fn a_bare_read_body_is_recognized_as_internal_dispatch() {
319
+ assert!(is_internal_dispatch_shape(&serde_json::json!({"read": true})));
320
+ }
321
+
322
+ #[test]
323
+ fn a_verb_command_body_is_recognized_as_internal_dispatch() {
324
+ assert!(is_internal_dispatch_shape(&serde_json::json!({"verb": "Register", "args": {}})));
325
+ }
326
+
327
+ #[test]
328
+ fn a_plain_rest_payload_is_not_internal_dispatch() {
329
+ assert!(!is_internal_dispatch_shape(&serde_json::json!({"email": "a@example.com"})));
330
+ }
331
+
332
+ #[test]
333
+ fn an_empty_body_is_not_internal_dispatch() {
334
+ assert!(!is_internal_dispatch_shape(&serde_json::json!({})));
335
+ }
336
+
337
+ fn peer(text: &str) -> SocketAddr {
338
+ text.parse().unwrap()
339
+ }
340
+
341
+ #[test]
342
+ fn only_a_peer_on_this_host_is_trusted_with_the_internal_protocol() {
343
+ for trusted in ["127.0.0.1:5000", "127.4.5.6:5000", "[::1]:5000", "[::ffff:127.0.0.1]:5000"] {
344
+ assert!(trusts_internal_dispatch(peer(trusted)), "{trusted} is this host");
345
+ }
346
+ 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"] {
347
+ assert!(!trusts_internal_dispatch(peer(outside)), "{outside} is not this host");
348
+ }
349
+ }
350
+
351
+ fn admitted(from: &str, path: &str, body: &str) -> Value {
352
+ let uri: Uri = path.parse().unwrap();
353
+ let body = Bytes::from(body.to_string());
354
+ match admit(&limits_from(&[]), peer(from), &Method::POST, &uri, &HeaderMap::new(), &body) {
355
+ Ok(envelope) => envelope,
356
+ Err(_) => panic!("{from} POST {path} was refused before dispatch"),
357
+ }
358
+ }
359
+
360
+ // The gap this closes: a public POST to any path the load balancer forwards carried
361
+ // `{"verb": ..., "role": ...}` or `{"read": true}` straight to the kernel, with the
362
+ // caller's own word for its role and no session check.
363
+ #[test]
364
+ fn a_verb_or_read_body_from_outside_this_host_is_an_ordinary_request_not_a_command() {
365
+ for body in [r#"{"verb":"Approve","role":"admin"}"#, r#"{"read":true}"#] {
366
+ let envelope = admitted("10.0.0.5:5000", "/webhooks/x", body);
367
+
368
+ assert!(envelope.get("verb").is_none() && envelope.get("read").is_none(), "{body} must not reach dispatch as a command");
369
+ assert_eq!(envelope["requestContext"]["http"]["method"], "POST");
370
+ assert_eq!(envelope["rawPath"], "/webhooks/x");
371
+ assert_eq!(envelope["body"], body);
372
+ }
373
+ }
374
+
375
+ #[test]
376
+ fn a_body_from_outside_this_host_cannot_name_a_different_path_than_the_one_it_hit() {
377
+ let body = r#"{"requestContext":{"http":{"method":"GET"}},"rawPath":"/api/clients","headers":{"cookie":"session=forged"}}"#;
378
+ let envelope = admitted("203.0.113.7:5000", "/webhooks/x", body);
379
+
380
+ assert_eq!(envelope["rawPath"], "/webhooks/x");
381
+ assert_eq!(envelope["requestContext"]["http"]["method"], "POST");
382
+ assert!(envelope["headers"].get("cookie").is_none(), "headers come from the real request, not the body");
383
+ }
384
+
385
+ #[test]
386
+ fn a_verb_or_read_body_from_this_host_still_reaches_dispatch_unchanged() {
387
+ for body in [r#"{"verb":"Register","to":"r1","with":{}}"#, r#"{"read":true}"#] {
388
+ let envelope = admitted("127.0.0.1:5000", "/anything", body);
389
+
390
+ assert_eq!(envelope, serde_json::from_str::<Value>(body).unwrap());
391
+ }
392
+ }
393
+
394
+ // Pins a real REST client's plain POST, with no Function-URL wrapping: it must
395
+ // synthesize the same shape `web::render`/`checkout_route` read, not error on a
396
+ // missing "verb".
397
+ #[test]
398
+ fn synthesizes_a_function_url_envelope_from_a_real_rest_request() {
399
+ let method = Method::POST;
400
+ let uri: Uri = "/registrations?utm_source=test".parse().unwrap();
401
+ let mut headers = HeaderMap::new();
402
+ headers.insert(HeaderName::from_static("stripe-signature"), HeaderValue::from_static("t=1,v1=abc"));
403
+ headers.insert(HeaderName::from_static("content-type"), HeaderValue::from_static("application/json"));
404
+ let body = Bytes::from_static(br#"{"event_id":"ci-verification-test"}"#);
405
+
406
+ let envelope = synthesize_function_url_envelope(&method, &uri, &headers, &body);
407
+
408
+ assert_eq!(envelope["requestContext"]["http"]["method"], "POST");
409
+ assert_eq!(envelope["rawPath"], "/registrations");
410
+ assert_eq!(envelope["rawQueryString"], "utm_source=test");
411
+ assert_eq!(envelope["headers"]["stripe-signature"], "t=1,v1=abc");
412
+ assert_eq!(envelope["headers"]["content-type"], "application/json");
413
+ assert_eq!(envelope["body"], r#"{"event_id":"ci-verification-test"}"#);
414
+ assert_eq!(envelope["isBase64Encoded"], false);
415
+ assert!(is_internal_dispatch_shape(&envelope), "the synthesized envelope must itself be recognized on a second pass");
416
+ }
417
+
418
+ #[test]
419
+ fn synthesizes_an_empty_query_string_when_the_request_has_none() {
420
+ let method = Method::GET;
421
+ let uri: Uri = "/login".parse().unwrap();
422
+ let headers = HeaderMap::new();
423
+ let body = Bytes::new();
424
+
425
+ let envelope = synthesize_function_url_envelope(&method, &uri, &headers, &body);
426
+
427
+ assert_eq!(envelope["rawQueryString"], "");
428
+ assert_eq!(envelope["body"], "");
429
+ }
430
+
431
+ fn sample_version() -> Value {
432
+ version_body("199b08", &"a".repeat(64), Some("build-42"))
433
+ }
434
+
435
+ // Serves `version_router` on an ephemeral port, the way the host serves
436
+ // it, so a request travels the real HTTP stack.
437
+ async fn serve_version(body: Value) -> std::net::SocketAddr {
438
+ let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
439
+ let addr = listener.local_addr().unwrap();
440
+ tokio::spawn(async move { axum::serve(listener, version_router(body)).await });
441
+ addr
442
+ }
443
+
444
+ #[tokio::test]
445
+ async fn version_answers_200_json_with_no_session() {
446
+ let addr = serve_version(sample_version()).await;
447
+ let response = reqwest::get(format!("http://{addr}/version")).await.unwrap();
448
+ assert_eq!(response.status(), 200);
449
+ let content_type = response.headers()["content-type"].to_str().unwrap().to_string();
450
+ assert!(content_type.starts_with("application/json"), "got {content_type}");
451
+ let body: Value = response.json().await.unwrap();
452
+ assert_eq!(body, serde_json::json!({ "era": "199b08", "ir_hash": "a".repeat(64), "build": "build-42" }));
453
+ }
454
+
455
+ #[tokio::test]
456
+ async fn version_era_is_a_non_empty_string() {
457
+ let addr = serve_version(sample_version()).await;
458
+ let body: Value = reqwest::get(format!("http://{addr}/version")).await.unwrap().json().await.unwrap();
459
+ assert!(body["era"].as_str().is_some_and(|era| !era.is_empty()));
460
+ }
461
+
462
+ #[tokio::test]
463
+ async fn version_is_get_only() {
464
+ let addr = serve_version(sample_version()).await;
465
+ let response = reqwest::Client::new().post(format!("http://{addr}/version")).send().await.unwrap();
466
+ assert_eq!(response.status(), 405);
467
+ }
468
+
469
+ fn limits_from(pairs: &[(&str, &str)]) -> Arc<RateLimits> {
470
+ let map: std::collections::HashMap<String, String> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
471
+ Arc::new(RateLimits::new(crate::rate_limit::Config::from_lookup(|name| map.get(name).cloned()).0))
472
+ }
473
+
474
+ async fn gate_only(
475
+ State(limits): State<Arc<RateLimits>>,
476
+ ConnectInfo(peer): ConnectInfo<SocketAddr>,
477
+ method: Method,
478
+ uri: Uri,
479
+ headers: HeaderMap,
480
+ body: Bytes,
481
+ ) -> Response {
482
+ match admit(&limits, peer, &method, &uri, &headers, &body) {
483
+ Ok(_) => (StatusCode::OK, "admitted").into_response(),
484
+ Err(refusal) => *refusal,
485
+ }
486
+ }
487
+
488
+ // A disposable server on an ephemeral port: the real `admit` gate reading the real connection
489
+ // peer, with an acknowledgement standing in for dispatch, which needs Postgres and wasm.
490
+ async fn gated_server(limits: Arc<RateLimits>) -> SocketAddr {
491
+ let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
492
+ let addr = listener.local_addr().unwrap();
493
+ let app = Router::new().fallback(any(gate_only)).with_state(limits);
494
+ tokio::spawn(async move { axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await });
495
+ addr
496
+ }
497
+
498
+ async fn post(addr: SocketAddr, path: &str, forwarded_for: Option<&str>, body: &str) -> reqwest::Response {
499
+ let mut request = reqwest::Client::new().post(format!("http://{addr}{path}")).body(body.to_string());
500
+ if let Some(forwarded_for) = forwarded_for {
501
+ request = request.header("x-forwarded-for", forwarded_for);
502
+ }
503
+ request.send().await.unwrap()
504
+ }
505
+
506
+ const SUBSCRIBE: &str = "/newsletter/subscribers";
507
+
508
+ #[tokio::test]
509
+ async fn a_burst_on_a_public_write_route_gets_429_with_retry_after_and_a_json_error() {
510
+ let addr = gated_server(limits_from(&[("HECKS_RATE_LIMIT_SUBSCRIBE", "3")])).await;
511
+ for attempt in 1..=3 {
512
+ assert_eq!(post(addr, SUBSCRIBE, None, r#"{"email":"a@example.com"}"#).await.status(), 200, "request {attempt}");
513
+ }
514
+
515
+ let refused = post(addr, SUBSCRIBE, None, r#"{"email":"a@example.com"}"#).await;
516
+ assert_eq!(refused.status(), 429);
517
+ let retry_after: u64 = refused.headers()["retry-after"].to_str().unwrap().parse().unwrap();
518
+ assert!((1..=3600).contains(&retry_after), "got {retry_after}");
519
+ assert!(refused.headers()["content-type"].to_str().unwrap().starts_with("application/json"));
520
+ let body: Value = refused.json().await.unwrap();
521
+ assert_eq!(body, serde_json::json!({ "error": "too many requests, please try again later" }));
522
+ }
523
+
524
+ #[tokio::test]
525
+ async fn registrations_are_limited_apart_from_subscribes() {
526
+ let addr = gated_server(limits_from(&[("HECKS_RATE_LIMIT_SUBSCRIBE", "1"), ("HECKS_RATE_LIMIT_REGISTER", "2")])).await;
527
+ assert_eq!(post(addr, SUBSCRIBE, None, "{}").await.status(), 200);
528
+ assert_eq!(post(addr, SUBSCRIBE, None, "{}").await.status(), 429);
529
+
530
+ assert_eq!(post(addr, "/registrations", None, "{}").await.status(), 200, "subscribe being spent leaves register alone");
531
+ assert_eq!(post(addr, "/registrations", None, "{}").await.status(), 200);
532
+ assert_eq!(post(addr, "/registrations", None, "{}").await.status(), 429);
533
+ }
534
+
535
+ #[tokio::test]
536
+ async fn reads_and_other_routes_are_never_limited() {
537
+ let addr = gated_server(limits_from(&[("HECKS_RATE_LIMIT_SUBSCRIBE", "1"), ("HECKS_RATE_LIMIT_REGISTER", "1")])).await;
538
+ let client = reqwest::Client::new();
539
+ for _ in 0..5 {
540
+ assert_eq!(client.get(format!("http://{addr}{SUBSCRIBE}")).send().await.unwrap().status(), 200);
541
+ assert_eq!(client.get(format!("http://{addr}/registrations/REG-1")).send().await.unwrap().status(), 200);
542
+ assert_eq!(post(addr, "/webhooks/stripe", None, "{}").await.status(), 200);
543
+ assert_eq!(post(addr, "/members", None, "{}").await.status(), 200);
544
+ }
545
+ }
546
+
547
+ #[tokio::test]
548
+ async fn a_spoofed_forwarded_header_does_not_buy_a_fresh_allowance() {
549
+ // The test client is the peer at 127.0.0.1 and nothing trusts it, so every header is
550
+ // typed by the caller and the count stays with the connection.
551
+ let addr = gated_server(limits_from(&[("HECKS_RATE_LIMIT_SUBSCRIBE", "1")])).await;
552
+ assert_eq!(post(addr, SUBSCRIBE, Some("1.1.1.1"), "{}").await.status(), 200);
553
+ for spoof in ["2.2.2.2", "3.3.3.3", "4.4.4.4, 5.5.5.5"] {
554
+ assert_eq!(post(addr, SUBSCRIBE, Some(spoof), "{}").await.status(), 429, "{spoof}");
555
+ }
556
+ }
557
+
558
+ #[tokio::test]
559
+ async fn behind_a_trusted_proxy_each_visitor_has_their_own_allowance() {
560
+ let addr = gated_server(limits_from(&[("HECKS_RATE_LIMIT_SUBSCRIBE", "1"), ("HECKS_TRUSTED_PROXIES", "127.0.0.1")])).await;
561
+ assert_eq!(post(addr, SUBSCRIBE, Some("1.1.1.1, 203.0.113.7"), "{}").await.status(), 200);
562
+ assert_eq!(post(addr, SUBSCRIBE, Some("2.2.2.2, 203.0.113.7"), "{}").await.status(), 429, "same visitor, new typed entry");
563
+ assert_eq!(post(addr, SUBSCRIBE, Some("2.2.2.2, 198.51.100.4"), "{}").await.status(), 200, "a different visitor");
564
+ }
565
+
566
+ #[tokio::test]
567
+ async fn a_body_that_carries_the_function_url_shape_counts_against_the_route_it_names() {
568
+ let addr = gated_server(limits_from(&[("HECKS_RATE_LIMIT_REGISTER", "1")])).await;
569
+ let envelope = r#"{"requestContext":{"http":{"method":"POST"}},"rawPath":"/registrations","body":"{}"}"#;
570
+ assert_eq!(post(addr, "/anything", None, envelope).await.status(), 200);
571
+ assert_eq!(post(addr, "/anything", None, envelope).await.status(), 429);
572
+ assert_eq!(post(addr, "/registrations", None, "{}").await.status(), 429, "the same budget as the real path");
573
+ }
574
+
575
+ #[tokio::test]
576
+ async fn a_body_that_is_not_json_is_a_400_and_costs_no_allowance() {
577
+ let addr = gated_server(limits_from(&[("HECKS_RATE_LIMIT_SUBSCRIBE", "1")])).await;
578
+ for _ in 0..3 {
579
+ assert_eq!(post(addr, SUBSCRIBE, None, "not json").await.status(), 400);
580
+ }
581
+ assert_eq!(post(addr, SUBSCRIBE, None, "{}").await.status(), 200);
582
+ }
583
+
584
+ #[tokio::test]
585
+ async fn turning_the_limits_off_lets_every_request_through() {
586
+ let addr = gated_server(limits_from(&[("HECKS_RATE_LIMIT", "off"), ("HECKS_RATE_LIMIT_SUBSCRIBE", "1")])).await;
587
+ for _ in 0..10 {
588
+ assert_eq!(post(addr, SUBSCRIBE, None, "{}").await.status(), 200);
589
+ }
590
+ }
591
+
592
+ // The real service on an ephemeral port, over a scratch Postgres and a cold copy of the
593
+ // checkout fixture's wasm, so the first request is a real compile.
594
+ async fn fixture_host(database: &str, copy_name: &str) -> (SocketAddr, Arc<PathBuf>) {
595
+ let client = crate::dispatch::tests::scratch_db(database).await;
596
+ crate::dispatch::tests::provision_lineage(&*client.lock().await, "CheckoutFixture", 1, &["Event"]).await;
597
+ let wasm_path = Arc::new(crate::wasm_runner::tests::cold_copy_of_fixture(copy_name));
598
+ let state = ServerState {
599
+ client: Arc::new(client),
600
+ wasm_path: Arc::clone(&wasm_path),
601
+ lineage_config: Arc::new(LineageConfig { domain: "CheckoutFixture".to_string(), era: Some(1), mirrored: None }),
602
+ invoker: Arc::new(crate::lambda_client::NeverInvoker),
603
+ limits: limits_from(&[]),
604
+ };
605
+ let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
606
+ let addr = listener.local_addr().unwrap();
607
+ tokio::spawn(async move { serve_on(listener, state, sample_version()).await });
608
+ (addr, wasm_path)
609
+ }
610
+
611
+ // `burst` simultaneous reads, each with the protocol's own `{"read": true}` body.
612
+ async fn read_burst(addr: SocketAddr, burst: usize) -> Vec<(StatusCode, Value)> {
613
+ let requests: Vec<_> = (0..burst)
614
+ .map(|_| {
615
+ tokio::spawn(async move {
616
+ let response = reqwest::Client::new().post(format!("http://{addr}/dispatch")).body(r#"{"read": true}"#).send().await.unwrap();
617
+ let status = StatusCode::from_u16(response.status().as_u16()).unwrap();
618
+ (status, response.json::<Value>().await.unwrap())
619
+ })
620
+ })
621
+ .collect();
622
+ let mut answers = Vec::new();
623
+ for request in requests {
624
+ answers.push(tokio::time::timeout(std::time::Duration::from_secs(120), request).await.expect("a read was never answered").unwrap());
625
+ }
626
+ answers
627
+ }
628
+
629
+ #[tokio::test]
630
+ async fn a_burst_of_reads_on_a_cold_host_is_answered_from_one_compile() {
631
+ let (addr, wasm_path) = fixture_host("rust_host_serve_cold_burst", "serve_cold").await;
632
+
633
+ for (status, answer) in read_burst(addr, 8).await {
634
+ assert_eq!(status, StatusCode::OK, "{answer}");
635
+ assert!(answer["instances"].is_object(), "{answer}");
636
+ }
637
+ assert_eq!(crate::wasm_runner::compile_count(&wasm_path), 1);
638
+ }
639
+
640
+ #[tokio::test]
641
+ async fn warming_before_serving_leaves_no_compile_for_the_first_read() {
642
+ let (addr, wasm_path) = fixture_host("rust_host_serve_warmed", "serve_warmed").await;
643
+ warm_wasm(&wasm_path).await;
644
+ assert_eq!(crate::wasm_runner::compile_count(&wasm_path), 1);
645
+
646
+ let started = std::time::Instant::now();
647
+ let answers = read_burst(addr, 1).await;
648
+ assert_eq!(answers[0].0, StatusCode::OK, "{}", answers[0].1);
649
+ assert_eq!(crate::wasm_runner::compile_count(&wasm_path), 1);
650
+ assert!(started.elapsed() < std::time::Duration::from_secs(1), "a warmed read took {:?}", started.elapsed());
651
+ }
652
+
653
+ #[test]
654
+ fn build_is_the_env_value_when_set() {
655
+ assert_eq!(version_body("e", "h", Some("cms-1"))["build"], "cms-1");
656
+ }
657
+
658
+ #[test]
659
+ fn build_falls_back_to_the_crate_version_when_unset_or_empty() {
660
+ assert_eq!(version_body("e", "h", None)["build"], env!("CARGO_PKG_VERSION"));
661
+ assert_eq!(version_body("e", "h", Some(""))["build"], env!("CARGO_PKG_VERSION"));
662
+ }
663
+ }