hecks 2.9.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +13 -4
  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 +15 -8
  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 +15 -5
  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,310 @@
1
+ use super::newsletter_send::{site_url, unsubscribe_token, unsubscribe_token_matches, unsubscribe_url};
2
+ use super::{instances_for, last_refusal, respond};
3
+ use crate::auth;
4
+ use crate::dispatch;
5
+ use crate::ir::{ir, newsletter_provider, NewsletterProvider};
6
+ use crate::journal::LineageConfig;
7
+ use crate::lambda_client::LambdaInvoker;
8
+ use crate::resend::{Email, Mailer};
9
+ use serde_json::{json, Value};
10
+ use std::collections::HashMap;
11
+ use std::path::Path;
12
+ use std::sync::OnceLock;
13
+ use tokio::sync::Mutex;
14
+ use tokio_postgres::Client;
15
+
16
+ mod cooldown;
17
+ #[cfg(test)]
18
+ mod tests;
19
+
20
+ // Guest-facing subscribe/confirm/unsubscribe routes, plus the admin listing.
21
+ // Served here (not passed to Ruby) so a signed link still resolves without one.
22
+ pub(super) async fn newsletter_route(
23
+ method: &str,
24
+ path: &str,
25
+ query: &HashMap<String, String>,
26
+ raw_body: &str,
27
+ client: &Mutex<Client>,
28
+ wasm_path: &Path,
29
+ config: &LineageConfig,
30
+ invoker: &dyn LambdaInvoker,
31
+ ) -> Option<Value> {
32
+ // The chapter that declares `provides "newsletter"` names the verbs
33
+ // and the subscribing aggregate; a domain that attaches none serves
34
+ // none of these routes.
35
+ let provider = ir().and_then(newsletter_provider)?;
36
+ match (method, path) {
37
+ ("POST", "/newsletter/subscribers") => Some(newsletter_subscribe_route(&provider, raw_body, client, wasm_path, config, invoker).await),
38
+ ("GET", "/newsletter/subscribers") => Some(newsletter_subscribers_list_route(&provider, client, wasm_path).await),
39
+ ("GET", "/newsletter/subscribers/confirm") => Some(newsletter_confirm_route(&provider, query, client, wasm_path, config, invoker).await),
40
+ ("GET", "/newsletter/subscribers/unsubscribe") => Some(newsletter_unsubscribe_route(&provider, query, client, wasm_path, config, invoker).await),
41
+ _ => None,
42
+ }
43
+ }
44
+
45
+ // Called twice by the two-step signup form: first without a name (Subscribe),
46
+ // then with one (AddName).
47
+ async fn newsletter_subscribe_route(
48
+ provider: &NewsletterProvider,
49
+ raw_body: &str,
50
+ client: &Mutex<Client>,
51
+ wasm_path: &Path,
52
+ config: &LineageConfig,
53
+ invoker: &dyn LambdaInvoker,
54
+ ) -> Value {
55
+ let body: Value = match serde_json::from_str(raw_body) {
56
+ Ok(v) => v,
57
+ Err(e) => return respond(400, "application/json", &json!({"error": format!("invalid JSON: {e}")}).to_string()),
58
+ };
59
+ let Some(email) = body.get("email").and_then(|v| v.as_str()) else {
60
+ return respond(400, "application/json", &json!({"error": "email"}).to_string());
61
+ };
62
+ let first_name = body.get("first_name").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
63
+ let last_name = body.get("last_name").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
64
+
65
+ let read = match dispatch::read(client, wasm_path).await {
66
+ Ok(r) => r,
67
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
68
+ };
69
+ let existing = instances_for(&read, &provider.instance_prefix()).iter().any(|(id, _)| id == email);
70
+
71
+ if existing {
72
+ if let (Some(first), Some(last)) = (first_name, last_name) {
73
+ let facts = json!({"first_name": {"value": first}, "last_name": {"value": last}});
74
+ if let Err(e) = dispatch::handle_routed(client, wasm_path, &provider.add_name, json!(email), facts, None, config, invoker).await {
75
+ return respond(500, "text/plain", &format!("{e:#}"));
76
+ }
77
+ }
78
+ } else {
79
+ let mut facts = json!({"email": {"value": email}});
80
+ if let Some(first) = first_name {
81
+ facts["first_name"] = json!({"value": first});
82
+ }
83
+ if let Some(last) = last_name {
84
+ facts["last_name"] = json!({"value": last});
85
+ }
86
+ let outcome = match dispatch::handle_facts(client, wasm_path, &provider.subscribe, facts, None, config, invoker).await {
87
+ Ok(o) => o,
88
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
89
+ };
90
+ if !outcome.accepted {
91
+ return respond(422, "application/json", &last_refusal(&outcome.result).to_string());
92
+ }
93
+ send_confirmation(email).await;
94
+ }
95
+
96
+ // Confirm is not dispatched here: a new subscriber stays `pending`
97
+ // until they follow the confirm link (the route below). This route only
98
+ // reports where the subscriber ended up.
99
+ let read = match dispatch::read(client, wasm_path).await {
100
+ Ok(r) => r,
101
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
102
+ };
103
+ let subscribers = instances_for(&read, &provider.instance_prefix());
104
+ let Some((_, subscriber)) = subscribers.iter().find(|(id, _)| id == email) else {
105
+ return respond(500, "text/plain", "subscriber vanished immediately after being written");
106
+ };
107
+ let status = subscriber.get("status").and_then(|v| v.as_str()).unwrap_or("pending");
108
+ respond(200, "application/json", &json!({"email": email, "status": status}).to_string())
109
+ }
110
+
111
+ // Reads directly off `instances`, not `dispatch::query`: Subscriber lives
112
+ // under the Newsletter chapter, not `config.domain`, so the qualified
113
+ // question name would not resolve there.
114
+ async fn newsletter_subscribers_list_route(provider: &NewsletterProvider, client: &Mutex<Client>, wasm_path: &Path) -> Value {
115
+ let read = match dispatch::read(client, wasm_path).await {
116
+ Ok(r) => r,
117
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
118
+ };
119
+ let mut subscribers: Vec<Value> = instances_for(&read, &provider.instance_prefix())
120
+ .into_iter()
121
+ .map(|(email, s)| {
122
+ json!({
123
+ "email": email,
124
+ "status": s.get("status"),
125
+ "first_name": s.get("first_name").and_then(|v| v.get("value")),
126
+ "last_name": s.get("last_name").and_then(|v| v.get("value")),
127
+ })
128
+ })
129
+ .collect();
130
+ subscribers.sort_by(|a, b| a["email"].as_str().unwrap_or("").cmp(b["email"].as_str().unwrap_or("")));
131
+ respond(200, "application/json", &json!(subscribers).to_string())
132
+ }
133
+
134
+ const CONFIRM_PURPOSE: &str = "newsletter-confirm";
135
+ const CONFIRM_TTL_SECS: u64 = 14 * 24 * 60 * 60;
136
+
137
+ // SESSION_SECRET; unset means no confirm link can be signed and none is sent.
138
+ fn confirm_secret() -> Option<String> {
139
+ std::env::var("SESSION_SECRET").ok().filter(|s| !s.is_empty())
140
+ }
141
+
142
+ fn confirm_token(secret: &str, email: &str) -> String {
143
+ auth::purpose_token(secret, CONFIRM_PURPOSE, json!({ "email": email }), CONFIRM_TTL_SECS)
144
+ }
145
+
146
+ fn confirm_token_matches(secret: &str, token: &str, email: &str) -> bool {
147
+ auth::verify_purpose_token(secret, CONFIRM_PURPOSE, token)
148
+ .and_then(|claims| claims.get("email").and_then(|v| v.as_str()).map(|signed| signed == email))
149
+ .unwrap_or(false)
150
+ }
151
+
152
+ // Percent-encodes the address so a '+' in the local part survives the URL.
153
+ fn confirm_url(site_url: &str, email: &str, token: &str) -> String {
154
+ let mut url = reqwest::Url::parse(&format!("{site_url}/newsletter-confirmed.html")).unwrap_or_else(|_| reqwest::Url::parse("http://invalid.invalid/").unwrap());
155
+ url.query_pairs_mut().append_pair("email", email).append_pair("token", token);
156
+ url.to_string()
157
+ }
158
+
159
+ // Per-process only (not shared across instances); see `ConfirmationCooldown`.
160
+ fn confirmation_cooldown() -> &'static cooldown::ConfirmationCooldown {
161
+ static COOLDOWN: OnceLock<cooldown::ConfirmationCooldown> = OnceLock::new();
162
+ COOLDOWN.get_or_init(|| cooldown::ConfirmationCooldown::new(cooldown::WINDOW, cooldown::CAPACITY))
163
+ }
164
+
165
+ fn confirmation_body(confirm_url: &str) -> String {
166
+ format!(
167
+ "Please confirm your newsletter subscription by opening this link:\n\n{confirm_url}\n\nIf you didn't ask for this, you can ignore this email and nothing will be sent to you.\n"
168
+ )
169
+ }
170
+
171
+ // Best-effort: failures are swallowed (subscriber stays `pending`) so a
172
+ // flaky mail provider never surfaces as a signup error. Cooldown-gated so
173
+ // the form can't be used to flood an address with repeat confirmations.
174
+ pub(super) async fn send_confirmation(email: &str) {
175
+ let Some(secret) = confirm_secret() else {
176
+ eprintln!("newsletter: SESSION_SECRET is not set, so no confirmation link can be signed; the subscriber stays pending");
177
+ return;
178
+ };
179
+ let mailer = match Mailer::from_env() {
180
+ Ok(Some(mailer)) => mailer,
181
+ Ok(None) => {
182
+ eprintln!("newsletter: email delivery is not configured (RESEND_API_KEY and RESEND_FROM), so the confirmation was not sent");
183
+ return;
184
+ }
185
+ Err(e) => {
186
+ eprintln!("newsletter: {e}");
187
+ return;
188
+ }
189
+ };
190
+ if !confirmation_cooldown().try_acquire(email) {
191
+ eprintln!("newsletter: a confirmation was already emailed to this address in the last 10 minutes, so none was sent; the subscriber stays pending");
192
+ return;
193
+ }
194
+ let site = site_url();
195
+ let link = confirm_url(&site, email, &confirm_token(&secret, email));
196
+ let unsubscribe = unsubscribe_url(&site, email, &unsubscribe_token(&secret, email));
197
+ let delivery = mailer
198
+ .deliver(&Email { to: email, subject: "Confirm your newsletter subscription", body: &confirmation_body(&link), unsubscribe_url: Some(&unsubscribe) })
199
+ .await;
200
+ if !delivery.ok {
201
+ eprintln!("newsletter: the confirmation email was not delivered: {}", delivery.reason.unwrap_or_default());
202
+ }
203
+ }
204
+
205
+ // Only mails when the address is now `pending`; an already-confirmed or
206
+ // unsubscribed registrant is left alone.
207
+ pub(super) async fn send_confirmation_if_pending(email: &str, client: &Mutex<Client>, wasm_path: &Path) {
208
+ let Some(provider) = ir().and_then(newsletter_provider) else {
209
+ return;
210
+ };
211
+ let Ok(read) = dispatch::read(client, wasm_path).await else {
212
+ return;
213
+ };
214
+ let pending = instances_for(&read, &provider.instance_prefix())
215
+ .iter()
216
+ .any(|(id, subscriber)| id == email && subscriber.get("status").and_then(|v| v.as_str()) == Some("pending"));
217
+ if pending {
218
+ send_confirmation(email).await;
219
+ }
220
+ }
221
+
222
+ // The token must have been minted for this exact address (`confirm_token`);
223
+ // a missing, wrong or expired one refuses before any subscriber is touched.
224
+ // Idempotent: dispatches Confirm only while still `pending`, so a double
225
+ // click or link-prefetch lands on the same success response, not a 422.
226
+ async fn newsletter_confirm_route(provider: &NewsletterProvider, query: &HashMap<String, String>, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, invoker: &dyn LambdaInvoker) -> Value {
227
+ let Some(email) = query.get("email") else {
228
+ return respond(400, "application/json", &json!({"error": "email"}).to_string());
229
+ };
230
+ let signed = match (confirm_secret(), query.get("token")) {
231
+ (Some(secret), Some(token)) => confirm_token_matches(&secret, token, email),
232
+ _ => false,
233
+ };
234
+ if !signed {
235
+ return respond(403, "application/json", &json!({"error": "this confirmation link is invalid or has expired"}).to_string());
236
+ }
237
+
238
+ let read = match dispatch::read(client, wasm_path).await {
239
+ Ok(r) => r,
240
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
241
+ };
242
+ let subscribers = instances_for(&read, &provider.instance_prefix());
243
+ let Some((_, subscriber)) = subscribers.iter().find(|(id, _)| id == email) else {
244
+ return respond(404, "application/json", &json!({"error": "no such subscriber"}).to_string());
245
+ };
246
+
247
+ if subscriber.get("status").and_then(|v| v.as_str()) == Some("pending") {
248
+ if let Err(e) = dispatch::handle_routed(client, wasm_path, &provider.confirm, json!(email), json!({}), None, config, invoker).await {
249
+ return respond(500, "text/plain", &format!("{e:#}"));
250
+ }
251
+ }
252
+
253
+ let read = match dispatch::read(client, wasm_path).await {
254
+ Ok(r) => r,
255
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
256
+ };
257
+ let subscribers = instances_for(&read, &provider.instance_prefix());
258
+ let status = subscribers.iter().find(|(id, _)| id == email).and_then(|(_, s)| s.get("status")).and_then(|v| v.as_str()).unwrap_or("pending");
259
+ respond(200, "application/json", &json!({"email": email, "status": status}).to_string())
260
+ }
261
+
262
+ // 400 with no address; 403 for a missing, wrong, other-address or expired
263
+ // token, or no secret to check against. Otherwise Ok(email).
264
+ fn unsubscribe_authorized<'a>(secret: Option<&str>, query: &'a HashMap<String, String>) -> Result<&'a str, Value> {
265
+ let Some(email) = query.get("email") else {
266
+ return Err(respond(400, "application/json", &json!({"error": "email"}).to_string()));
267
+ };
268
+ let signed = match (secret, query.get("token")) {
269
+ (Some(secret), Some(token)) => unsubscribe_token_matches(secret, token, email),
270
+ _ => false,
271
+ };
272
+ if signed {
273
+ Ok(email)
274
+ } else {
275
+ Err(respond(403, "application/json", &json!({"error": "this unsubscribe link is invalid or has expired"}).to_string()))
276
+ }
277
+ }
278
+
279
+ // Same token/idempotency shape as newsletter_confirm_route: Unsubscribe's
280
+ // own `given` accepts a pending or confirmed subscriber, so a repeat click
281
+ // on an already-unsubscribed row lands on the same success response.
282
+ async fn newsletter_unsubscribe_route(provider: &NewsletterProvider, query: &HashMap<String, String>, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, invoker: &dyn LambdaInvoker) -> Value {
283
+ let email = match unsubscribe_authorized(confirm_secret().as_deref(), query) {
284
+ Ok(email) => email,
285
+ Err(refusal) => return refusal,
286
+ };
287
+
288
+ let read = match dispatch::read(client, wasm_path).await {
289
+ Ok(r) => r,
290
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
291
+ };
292
+ let subscribers = instances_for(&read, &provider.instance_prefix());
293
+ let Some((_, subscriber)) = subscribers.iter().find(|(id, _)| id == email) else {
294
+ return respond(404, "application/json", &json!({"error": "no such subscriber"}).to_string());
295
+ };
296
+
297
+ if subscriber.get("status").and_then(|v| v.as_str()) != Some("unsubscribed") {
298
+ if let Err(e) = dispatch::handle_routed(client, wasm_path, &provider.unsubscribe, json!(email), json!({}), None, config, invoker).await {
299
+ return respond(500, "text/plain", &format!("{e:#}"));
300
+ }
301
+ }
302
+
303
+ let read = match dispatch::read(client, wasm_path).await {
304
+ Ok(r) => r,
305
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
306
+ };
307
+ let subscribers = instances_for(&read, &provider.instance_prefix());
308
+ let status = subscribers.iter().find(|(id, _)| id == email).and_then(|(_, s)| s.get("status")).and_then(|v| v.as_str()).unwrap_or("pending");
309
+ respond(200, "application/json", &json!({"email": email, "status": status}).to_string())
310
+ }
@@ -0,0 +1,357 @@
1
+ use super::{active_session_email, instances_for, last_refusal, percent_decode, respond};
2
+ use crate::auth;
3
+ use crate::dispatch;
4
+ use crate::ir::{newsletter_issues_provider, newsletter_provider, NewsletterIssuesProvider, NewsletterProvider};
5
+ use crate::journal::LineageConfig;
6
+ use crate::lambda_client::LambdaInvoker;
7
+ use crate::resend::{Email, Mailer};
8
+ use serde_json::{json, Value};
9
+ use std::collections::HashMap;
10
+ use std::path::Path;
11
+ use tokio::sync::Mutex;
12
+ use tokio_postgres::Client;
13
+
14
+ // Sending a newsletter issue: POST .../send and .../send-test, gated to
15
+ // Admin/Owner. `newsletter_issues` marks issues sent; `newsletter` holds subscribers.
16
+
17
+ const UNSUBSCRIBE_TOKEN: &str = "{{UNSUBSCRIBE_URL}}";
18
+ const DEFAULT_SITE_URL: &str = "http://localhost:4321";
19
+
20
+ // The purpose keys an unsubscribe token apart from a confirm token, so neither
21
+ // verifies as the other. The lifetime is long (two years) because the link sits
22
+ // in emails that are opened months after they were sent.
23
+ pub(super) const UNSUBSCRIBE_PURPOSE: &str = "newsletter-unsubscribe";
24
+ const UNSUBSCRIBE_TTL_SECS: u64 = 730 * 24 * 60 * 60;
25
+
26
+ #[derive(Debug, PartialEq)]
27
+ pub(super) enum IssueAction {
28
+ Send,
29
+ SendTest,
30
+ }
31
+
32
+ // Matches `POST /newsletter/issues/{slug}/send(-test)`; the slug comes back
33
+ // percent-decoded.
34
+ pub(super) fn issue_action(method: &str, path: &str) -> Option<(String, IssueAction)> {
35
+ if method != "POST" {
36
+ return None;
37
+ }
38
+ let (slug, action) = path.strip_prefix("/newsletter/issues/")?.split_once('/')?;
39
+ if slug.is_empty() {
40
+ return None;
41
+ }
42
+ let action = match action {
43
+ "send" => IssueAction::Send,
44
+ "send-test" => IssueAction::SendTest,
45
+ _ => return None,
46
+ };
47
+ Some((percent_decode(slug), action))
48
+ }
49
+
50
+ // `None` when the path doesn't match, or this domain declares neither
51
+ // `newsletter_issues` nor `newsletter`.
52
+ #[allow(clippy::too_many_arguments)]
53
+ pub(super) async fn issue_route(
54
+ method: &str,
55
+ path: &str,
56
+ domain_ir: &Value,
57
+ raw_body: &str,
58
+ cookies: &HashMap<String, String>,
59
+ secret: &str,
60
+ client: &Mutex<Client>,
61
+ wasm_path: &Path,
62
+ config: &LineageConfig,
63
+ invoker: &dyn LambdaInvoker,
64
+ ) -> Option<Value> {
65
+ let (slug, action) = issue_action(method, path)?;
66
+ let issues = newsletter_issues_provider(domain_ir)?;
67
+ let subscribers = newsletter_provider(domain_ir)?;
68
+
69
+ let mailer = match authorize(domain_ir, cookies, secret, client).await {
70
+ Ok(mailer) => mailer,
71
+ Err(response) => return Some(response),
72
+ };
73
+ Some(match action {
74
+ IssueAction::Send => send_issue(&slug, &issues, &subscribers, &mailer, secret, client, wasm_path, config, invoker).await,
75
+ IssueAction::SendTest => send_test(raw_body, &mailer, secret).await,
76
+ })
77
+ }
78
+
79
+ fn json_error(status: u16, message: &str) -> Value {
80
+ respond(status, "application/json", &json!({ "error": message }).to_string())
81
+ }
82
+
83
+ // Every email carries a signed unsubscribe link, so nothing may go out (or
84
+ // be marked sent) without a secret to sign it with.
85
+ fn require_signing_secret(secret: &str) -> Result<(), Value> {
86
+ if secret.is_empty() {
87
+ return Err(json_error(503, "unsubscribe links cannot be signed: set SESSION_SECRET"));
88
+ }
89
+ Ok(())
90
+ }
91
+
92
+ // Refuses (before anything is marked sent) when the caller isn't an
93
+ // Admin/Owner, mail isn't configured, or there's no signing secret.
94
+ async fn authorize(domain_ir: &Value, cookies: &HashMap<String, String>, secret: &str, client: &Mutex<Client>) -> Result<Mailer, Value> {
95
+ let email = active_session_email(domain_ir, cookies, secret, client).await?;
96
+ match auth::caller_is_admin(client, domain_ir, &email).await {
97
+ Ok(true) => {}
98
+ Ok(false) => return Err(json_error(403, "only an Admin or Owner can send the newsletter")),
99
+ Err(e) => return Err(json_error(500, &format!("members lookup failed: {e}"))),
100
+ }
101
+ require_signing_secret(secret)?;
102
+ match Mailer::from_env() {
103
+ Ok(Some(mailer)) => Ok(mailer),
104
+ Ok(None) => Err(json_error(503, "email delivery is not configured: set RESEND_API_KEY and RESEND_FROM")),
105
+ Err(e) => Err(json_error(500, &e)),
106
+ }
107
+ }
108
+
109
+ pub(super) fn site_url() -> String {
110
+ std::env::var("SITE_URL").unwrap_or_else(|_| DEFAULT_SITE_URL.to_string())
111
+ }
112
+
113
+ pub(super) fn unsubscribe_token(secret: &str, email: &str) -> String {
114
+ auth::purpose_token(secret, UNSUBSCRIBE_PURPOSE, json!({ "email": email }), UNSUBSCRIBE_TTL_SECS)
115
+ }
116
+
117
+ pub(super) fn unsubscribe_token_matches(secret: &str, token: &str, email: &str) -> bool {
118
+ auth::verify_purpose_token(secret, UNSUBSCRIBE_PURPOSE, token)
119
+ .and_then(|claims| claims.get("email").and_then(|v| v.as_str()).map(|signed| signed == email))
120
+ .unwrap_or(false)
121
+ }
122
+
123
+ // Percent-encodes the address so a '+' in the local part survives the URL.
124
+ pub(super) fn unsubscribe_url(site_url: &str, email: &str, token: &str) -> String {
125
+ let mut url = reqwest::Url::parse(&format!("{site_url}/newsletter-unsubscribed.html")).unwrap_or_else(|_| reqwest::Url::parse("http://invalid.invalid/").unwrap());
126
+ url.query_pairs_mut().append_pair("email", email).append_pair("token", token);
127
+ url.to_string()
128
+ }
129
+
130
+ fn personalize(body: &str, unsubscribe_url: &str) -> String {
131
+ body.replace(UNSUBSCRIBE_TOKEN, unsubscribe_url)
132
+ }
133
+
134
+ // An aggregate field that is a one-attribute value object: `{"value": ...}`.
135
+ fn value_of<'a>(state: &'a Value, field: &str) -> &'a str {
136
+ state.get(field).and_then(|v| v.get("value")).and_then(|v| v.as_str()).unwrap_or("")
137
+ }
138
+
139
+ // Every confirmed subscriber's address, alphabetical so a send is repeatable.
140
+ fn confirmed_emails(subscribers: &[(String, Value)]) -> Vec<String> {
141
+ let mut emails: Vec<String> = subscribers
142
+ .iter()
143
+ .filter(|(_, s)| s.get("status").and_then(|v| v.as_str()) == Some("confirmed"))
144
+ .map(|(email, _)| email.clone())
145
+ .collect();
146
+ emails.sort();
147
+ emails
148
+ }
149
+
150
+ fn unix_now() -> i64 {
151
+ std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0)
152
+ }
153
+
154
+ #[allow(clippy::too_many_arguments)]
155
+ async fn send_issue(
156
+ slug: &str,
157
+ issues: &NewsletterIssuesProvider,
158
+ subscribers: &NewsletterProvider,
159
+ mailer: &Mailer,
160
+ secret: &str,
161
+ client: &Mutex<Client>,
162
+ wasm_path: &Path,
163
+ config: &LineageConfig,
164
+ invoker: &dyn LambdaInvoker,
165
+ ) -> Value {
166
+ let read = match dispatch::read(client, wasm_path).await {
167
+ Ok(r) => r,
168
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
169
+ };
170
+ let Some((_, issue)) = instances_for(&read, &issues.issue_prefix()).into_iter().find(|(id, _)| id == slug) else {
171
+ return json_error(404, "no such issue");
172
+ };
173
+ let subject = value_of(&issue, "subject").to_string();
174
+ let body = value_of(&issue, "body").to_string();
175
+ let recipients = confirmed_emails(&instances_for(&read, &subscribers.instance_prefix()));
176
+
177
+ // Marked sent before mailing: a second click then refuses ("only a draft
178
+ // issue can be sent") instead of mailing everyone twice.
179
+ match dispatch::handle_routed(client, wasm_path, &issues.send_issue, json!(slug), json!({ "sent_at": { "value": unix_now() } }), None, config, invoker).await {
180
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
181
+ Ok(outcome) if !outcome.accepted => return respond(422, "application/json", &last_refusal(&outcome.result).to_string()),
182
+ Ok(_) => {}
183
+ }
184
+
185
+ let site = site_url();
186
+ let mut sent = 0;
187
+ let mut unrecorded = 0;
188
+ let mut failures: Vec<Value> = Vec::new();
189
+ for email in &recipients {
190
+ let unsubscribe = unsubscribe_url(&site, email, &unsubscribe_token(secret, email));
191
+ let personalized = personalize(&body, &unsubscribe);
192
+ let delivery = mailer.deliver(&Email { to: email, subject: &subject, body: &personalized, unsubscribe_url: Some(&unsubscribe) }).await;
193
+ if !delivery.ok {
194
+ failures.push(json!({ "email": email, "reason": delivery.reason }));
195
+ continue;
196
+ }
197
+ sent += 1;
198
+
199
+ let facts = json!({
200
+ "delivery_id": { "value": format!("{slug}:{email}") },
201
+ "issue_slug": { "value": slug },
202
+ "subscriber_email": { "value": email },
203
+ "message_id": { "value": delivery.message_id.unwrap_or_default() },
204
+ });
205
+ match dispatch::handle_facts(client, wasm_path, &issues.record_delivery, facts, None, config, invoker).await {
206
+ Ok(outcome) if outcome.accepted => {}
207
+ other => {
208
+ // The email went out; only its record is missing. Surface it
209
+ // in the response and the log rather than fail the whole send.
210
+ unrecorded += 1;
211
+ eprintln!("newsletter send {slug}: delivered to {email} but could not record it: {}", other.map(|o| last_refusal(&o.result).to_string()).unwrap_or_else(|e| format!("{e:#}")));
212
+ }
213
+ }
214
+ }
215
+
216
+ respond(200, "application/json", &json!({ "slug": slug, "sent": sent, "failed": failures.len(), "failures": failures, "unrecorded": unrecorded }).to_string())
217
+ }
218
+
219
+ // Never touches the issue's own state: `Issue.Send` is irreversible, so a
220
+ // test send can't reuse it; it just renders the given subject/body.
221
+ async fn send_test(raw_body: &str, mailer: &Mailer, secret: &str) -> Value {
222
+ let body: Value = match serde_json::from_str(raw_body) {
223
+ Ok(v) => v,
224
+ Err(e) => return json_error(400, &format!("invalid JSON: {e}")),
225
+ };
226
+ let field = |name: &str| body.get(name).and_then(|v| v.as_str());
227
+ let (Some(to), Some(subject), Some(text)) = (field("to"), field("subject"), field("body")) else {
228
+ return json_error(400, "to, subject and body are required");
229
+ };
230
+
231
+ let unsubscribe = unsubscribe_url(&site_url(), to, &unsubscribe_token(secret, to));
232
+ let subject = format!("[TEST] {subject}");
233
+ let delivery = mailer.deliver(&Email { to, subject: &subject, body: &personalize(text, &unsubscribe), unsubscribe_url: Some(&unsubscribe) }).await;
234
+ if delivery.ok {
235
+ respond(200, "application/json", &json!({ "to": to }).to_string())
236
+ } else {
237
+ respond(502, "application/json", &json!({ "error": "delivery failed", "reason": delivery.reason }).to_string())
238
+ }
239
+ }
240
+
241
+ #[cfg(test)]
242
+ mod tests {
243
+ use super::*;
244
+
245
+ #[test]
246
+ fn send_and_send_test_are_the_only_issue_routes() {
247
+ assert_eq!(issue_action("POST", "/newsletter/issues/spring/send"), Some(("spring".to_string(), IssueAction::Send)));
248
+ assert_eq!(issue_action("POST", "/newsletter/issues/spring/send-test"), Some(("spring".to_string(), IssueAction::SendTest)));
249
+ assert_eq!(issue_action("GET", "/newsletter/issues/spring/send"), None);
250
+ assert_eq!(issue_action("POST", "/newsletter/issues/spring/edit"), None);
251
+ assert_eq!(issue_action("POST", "/newsletter/issues//send"), None);
252
+ assert_eq!(issue_action("POST", "/newsletter/issues/spring"), None);
253
+ assert_eq!(issue_action("POST", "/newsletter/subscribers"), None);
254
+ }
255
+
256
+ #[test]
257
+ fn the_slug_is_percent_decoded() {
258
+ assert_eq!(issue_action("POST", "/newsletter/issues/spring%20news/send").unwrap().0, "spring news");
259
+ }
260
+
261
+ #[test]
262
+ fn the_unsubscribe_token_is_replaced_wherever_it_appears() {
263
+ let out = personalize("<a href=\"{{UNSUBSCRIBE_URL}}\">leave</a> {{UNSUBSCRIBE_URL}}", "https://x/u");
264
+ assert_eq!(out, "<a href=\"https://x/u\">leave</a> https://x/u");
265
+ }
266
+
267
+ #[test]
268
+ fn the_unsubscribe_url_carries_the_encoded_address_and_the_token() {
269
+ assert_eq!(
270
+ unsubscribe_url("https://example.com", "a+b@example.com", "tok.en"),
271
+ "https://example.com/newsletter-unsubscribed.html?email=a%2Bb%40example.com&token=tok.en"
272
+ );
273
+ }
274
+
275
+ #[test]
276
+ fn a_signed_unsubscribe_url_carries_a_token_that_verifies_for_its_address() {
277
+ let url = reqwest::Url::parse(&unsubscribe_url("https://example.com", "a+b@example.com", &unsubscribe_token("s3cret-value", "a+b@example.com"))).unwrap();
278
+ let query: HashMap<String, String> = url.query_pairs().map(|(k, v)| (k.to_string(), v.to_string())).collect();
279
+ assert_eq!(query["email"], "a+b@example.com");
280
+ assert!(unsubscribe_token_matches("s3cret-value", &query["token"], &query["email"]));
281
+ }
282
+
283
+ #[test]
284
+ fn an_unsubscribe_token_verifies_only_for_the_address_it_was_minted_for() {
285
+ let token = unsubscribe_token("s3cret-value", "a@example.com");
286
+ assert!(unsubscribe_token_matches("s3cret-value", &token, "a@example.com"));
287
+ assert!(!unsubscribe_token_matches("s3cret-value", &token, "b@example.com"));
288
+ assert!(!unsubscribe_token_matches("another-secret", &token, "a@example.com"));
289
+ }
290
+
291
+ #[test]
292
+ fn an_unsubscribe_token_and_a_confirm_token_are_not_interchangeable() {
293
+ let unsubscribe = unsubscribe_token("s3cret-value", "a@example.com");
294
+ let confirm = auth::purpose_token("s3cret-value", "newsletter-confirm", json!({ "email": "a@example.com" }), 60);
295
+ assert!(auth::verify_purpose_token("s3cret-value", "newsletter-confirm", &unsubscribe).is_none());
296
+ assert!(!unsubscribe_token_matches("s3cret-value", &confirm, "a@example.com"));
297
+ }
298
+
299
+ #[test]
300
+ fn an_unsubscribe_token_outlives_the_confirm_window_by_two_years() {
301
+ let claims = auth::verify_purpose_token("s3cret-value", UNSUBSCRIBE_PURPOSE, &unsubscribe_token("s3cret-value", "a@example.com")).unwrap();
302
+ let remaining = claims["exp"].as_u64().unwrap() - unix_now() as u64;
303
+ assert!(remaining > 729 * 24 * 60 * 60 && remaining <= 730 * 24 * 60 * 60);
304
+ }
305
+
306
+ #[test]
307
+ fn an_expired_or_garbled_unsubscribe_token_is_refused() {
308
+ let expired = auth::purpose_token("s3cret-value", UNSUBSCRIBE_PURPOSE, json!({ "email": "a@example.com" }), 0);
309
+ std::thread::sleep(std::time::Duration::from_millis(1100));
310
+ assert!(!unsubscribe_token_matches("s3cret-value", &expired, "a@example.com"));
311
+ assert!(!unsubscribe_token_matches("s3cret-value", "not-a-token", "a@example.com"));
312
+ assert!(!unsubscribe_token_matches("s3cret-value", "", "a@example.com"));
313
+ }
314
+
315
+ #[test]
316
+ fn a_send_without_a_signing_secret_is_a_503() {
317
+ assert_eq!(require_signing_secret("").unwrap_err()["statusCode"], 503);
318
+ assert!(require_signing_secret("s3cret-value").is_ok());
319
+ }
320
+
321
+ #[test]
322
+ fn only_confirmed_subscribers_are_recipients_in_alphabetical_order() {
323
+ let subscribers = vec![
324
+ ("zed@example.com".to_string(), json!({ "status": "confirmed" })),
325
+ ("pending@example.com".to_string(), json!({ "status": "pending" })),
326
+ ("amy@example.com".to_string(), json!({ "status": "confirmed" })),
327
+ ("gone@example.com".to_string(), json!({ "status": "unsubscribed" })),
328
+ ];
329
+ assert_eq!(confirmed_emails(&subscribers), vec!["amy@example.com".to_string(), "zed@example.com".to_string()]);
330
+ }
331
+
332
+ #[test]
333
+ fn value_object_fields_read_through_their_value() {
334
+ let issue = json!({ "subject": { "value": "Spring" }, "body": { "value": "<p>hi</p>" } });
335
+ assert_eq!(value_of(&issue, "subject"), "Spring");
336
+ assert_eq!(value_of(&issue, "missing"), "");
337
+ }
338
+
339
+ #[tokio::test]
340
+ async fn a_test_send_without_its_fields_is_a_400() {
341
+ let response = send_test(r#"{"to":"a@b.com"}"#, &Mailer::Mock, "s3cret-value").await;
342
+ assert_eq!(response["statusCode"], 400);
343
+ }
344
+
345
+ #[tokio::test]
346
+ async fn a_test_send_goes_to_the_one_address_with_a_test_subject() {
347
+ let response = send_test(r#"{"to":"a@b.com","subject":"Spring","body":"<p>hi</p>"}"#, &Mailer::Mock, "s3cret-value").await;
348
+ assert_eq!(response["statusCode"], 200);
349
+ assert_eq!(serde_json::from_str::<Value>(response["body"].as_str().unwrap()).unwrap(), json!({ "to": "a@b.com" }));
350
+ }
351
+
352
+ #[tokio::test]
353
+ async fn a_test_send_the_provider_refuses_is_a_502() {
354
+ let response = send_test(r#"{"to":"bounce@example.com","subject":"Spring","body":"x"}"#, &Mailer::Mock, "s3cret-value").await;
355
+ assert_eq!(response["statusCode"], 502);
356
+ }
357
+ }