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,2937 @@
1
+ // Renders the web UI for a Function-URL HTTP event, in the same process
2
+ // as the Lambda's internal verb/read dispatch — no second network hop.
3
+
4
+ use crate::api;
5
+ use crate::auth;
6
+ use crate::auth::Session;
7
+ use crate::dispatch;
8
+ use crate::field_hints::{EMAIL_HINT, TEL_HINT, TEXTAREA_HINT, URL_HINT};
9
+ use crate::ir::ir;
10
+ use crate::journal::LineageConfig;
11
+ use crate::lambda_client::LambdaInvoker;
12
+ use crate::payments;
13
+ use serde_json::{json, Value};
14
+ use std::collections::HashMap;
15
+ use std::path::Path;
16
+ use tokio::sync::Mutex;
17
+ use tokio_postgres::Client;
18
+
19
+ mod newsletter;
20
+ mod newsletter_send;
21
+ mod registrations;
22
+
23
+ use registrations::payments_routes;
24
+ // payments.rs, its tests and the boot check reach these through `web::`.
25
+ pub(crate) use registrations::{checkout_enabled, registration_complete_route, registrations_route, webhook_route, MOCK_STRIPE_WEBHOOK_SECRET};
26
+ #[cfg(test)]
27
+ pub(crate) use registrations::{seats_left, seats_taken};
28
+
29
+ const UNGATED_PATHS: &[&str] = &["/login", "/logout", "/auth/google", "/auth/google/callback"];
30
+
31
+ #[allow(clippy::too_many_arguments)]
32
+ pub async fn render(
33
+ body: &Value,
34
+ client: &Mutex<Client>,
35
+ wasm_path: &Path,
36
+ config: &LineageConfig,
37
+ invoker: &dyn LambdaInvoker,
38
+ ) -> Option<Value> {
39
+ let http_event = body.get("requestContext")?.get("http")?;
40
+
41
+ let method = http_event.get("method").and_then(|v| v.as_str()).unwrap_or("GET");
42
+ let path = body.get("rawPath").and_then(|v| v.as_str()).unwrap_or("/");
43
+ let raw_body = body.get("body").and_then(|v| v.as_str()).unwrap_or("");
44
+ let raw_body = if body.get("isBase64Encoded").and_then(|v| v.as_bool()) == Some(true) {
45
+ String::from_utf8(base64_decode(raw_body)).unwrap_or_default()
46
+ } else {
47
+ raw_body.to_string()
48
+ };
49
+
50
+ // Parsed before `checkout_enabled` because newsletter's email-link
51
+ // routes need it there. Query strings are RFC 3986 — `+` is literal,
52
+ // not a space (unlike form bodies, which still use parse_form).
53
+ let query = parse_query(body.get("rawQueryString").and_then(|v| v.as_str()).unwrap_or(""));
54
+
55
+ // Registration/checkout/newsletter routes are opt-in: they only exist
56
+ // when HECKS_CHECKOUT_DOMAIN names this domain, since Event/Registration
57
+ // and the Payments::Payment chapter are declared against it specifically.
58
+ // The capability shapes are pinned by spec/fixtures/rust_host/checkout_fixture,
59
+ // which this module's tests run against.
60
+ if checkout_enabled(std::env::var("HECKS_CHECKOUT_DOMAIN").ok().as_deref(), &config.domain) {
61
+ // Newsletter subscribe shares this gate (same hecksagon as the
62
+ // registration aggregates) and is checked first since it needs none
63
+ // of the Payments::Payment/Event context checkout requires.
64
+ if let Some(response) = newsletter::newsletter_route(method, path, &query, &raw_body, client, wasm_path, config, invoker).await {
65
+ return Some(response);
66
+ }
67
+ let stripe_signature = body.get("headers").and_then(|h| h.get("stripe-signature")).and_then(|v| v.as_str()).unwrap_or("");
68
+ if let Some(response) = payments_routes(ir(), method, path, &raw_body, stripe_signature, client, wasm_path, config, invoker).await {
69
+ return Some(response);
70
+ }
71
+ }
72
+
73
+ let Some(domain_ir) = ir() else {
74
+ return Some(respond(500, "text/plain", "HECKS_IR_PATH not set or unreadable — this domain has no web layer configured"));
75
+ };
76
+
77
+ let cookies = extract_cookies(body);
78
+
79
+ Some(route(domain_ir, method, path, &query, &raw_body, &cookies, client, wasm_path, config, invoker).await)
80
+ }
81
+
82
+ fn extract_cookies(body: &Value) -> HashMap<String, String> {
83
+ // Function URL payload's own `cookies` array, or a raw `headers.cookie`
84
+ // header as a fallback — both reduce to the same map.
85
+ let mut map = HashMap::new();
86
+ if let Some(list) = body.get("cookies").and_then(|v| v.as_array()) {
87
+ for c in list {
88
+ if let Some((k, v)) = c.as_str().and_then(|s| s.split_once('=')) {
89
+ map.insert(k.trim().to_string(), v.trim().to_string());
90
+ }
91
+ }
92
+ } else if let Some(header) = body.get("headers").and_then(|h| h.get("cookie")).and_then(|v| v.as_str()) {
93
+ for pair in header.split(';') {
94
+ if let Some((k, v)) = pair.split_once('=') {
95
+ map.insert(k.trim().to_string(), v.trim().to_string());
96
+ }
97
+ }
98
+ }
99
+ map
100
+ }
101
+
102
+ // Refuses on an empty/unset secret rather than defaulting to "" and
103
+ // signing every cookie with a publicly-known key. Checked here, not
104
+ // at boot, since a domain with no web layer never sets it at all.
105
+ fn session_secret() -> String {
106
+ let secret = std::env::var("SESSION_SECRET").unwrap_or_default();
107
+ if let Err(e) = validate_session_secret(&secret) {
108
+ panic!("{e}");
109
+ }
110
+ secret
111
+ }
112
+
113
+ // Kept pure and separately testable; only `session_secret` panics.
114
+ fn validate_session_secret(secret: &str) -> Result<(), String> {
115
+ if secret.is_empty() {
116
+ Err("SESSION_SECRET is required and must not be empty -- refusing to sign/verify session \
117
+ cookies and OAuth state with a publicly-known empty-string HMAC key"
118
+ .to_string())
119
+ } else {
120
+ Ok(())
121
+ }
122
+ }
123
+
124
+ fn redirect_uri() -> String {
125
+ std::env::var("GOOGLE_REDIRECT_URI").unwrap_or_default()
126
+ }
127
+
128
+ // JSON callers get a 401 to branch on; browsers get redirected instead.
129
+ fn auth_gate(path: &str, authenticated: bool) -> Option<Value> {
130
+ if authenticated || UNGATED_PATHS.contains(&path) {
131
+ return None;
132
+ }
133
+
134
+ Some(if json_shaped(path) {
135
+ // The Ruby engine's own refusal body, key for key, so a client
136
+ // can branch on it without caring which runtime answered.
137
+ respond(401, "application/json", &json!({"error": "Unauthenticated", "message": "sign in first"}).to_string())
138
+ } else {
139
+ redirect("/login")
140
+ })
141
+ }
142
+
143
+ // Reuses `split_format` so this can't drift from the renderers' own HTML/JSON branch.
144
+ fn json_shaped(path: &str) -> bool {
145
+ if path.starts_with("/api/") {
146
+ return true;
147
+ }
148
+ let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
149
+ match segments.last() {
150
+ Some(last) if segments.len() >= 2 => split_format(last).1 != "html",
151
+ _ => false,
152
+ }
153
+ }
154
+
155
+ #[allow(clippy::too_many_arguments)]
156
+ async fn route(
157
+ domain_ir: &Value,
158
+ method: &str,
159
+ path: &str,
160
+ query: &HashMap<String, String>,
161
+ raw_body: &str,
162
+ cookies: &HashMap<String, String>,
163
+ client: &Mutex<Client>,
164
+ wasm_path: &Path,
165
+ config: &LineageConfig,
166
+ invoker: &dyn LambdaInvoker,
167
+ ) -> Value {
168
+ let domain_name = domain_ir.get("name").and_then(|v| v.as_str()).unwrap_or("");
169
+ let secret = session_secret();
170
+ let session = cookies.get("session").and_then(|c| auth::parse_session_cookie(&secret, c));
171
+
172
+ if let Some(response) = auth_route(domain_ir, path, method, query, raw_body, cookies, session.as_ref(), &secret, client, wasm_path, config, invoker).await {
173
+ return response;
174
+ }
175
+
176
+ if let Some(refusal) = auth_gate(path, session.is_some()) {
177
+ return refusal;
178
+ }
179
+
180
+ // Every /api/ path belongs to api.rs's contract, including ones it
181
+ // 404s itself — falling through would 404 here as "no domain \"api\""
182
+ // instead, for a request the Ruby engine answers with data.
183
+ if path.starts_with("/api/") {
184
+ return api::route(domain_ir, method, path, query, raw_body, session.as_ref(), client, wasm_path, config, invoker).await;
185
+ }
186
+
187
+ let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
188
+
189
+ if segments.is_empty() {
190
+ return html(200, &page("Loaded domains", &home_body(domain_ir)));
191
+ }
192
+
193
+ if segments[0] != domain_name {
194
+ return respond(404, "text/plain", &format!("no domain {:?} loaded", segments[0]));
195
+ }
196
+
197
+ let Some((aggregate_seg, format)) = segments.get(1).map(|s| split_format(s)) else {
198
+ return respond(404, "text/plain", "no route");
199
+ };
200
+ let Some(aggregate) = find_aggregate(domain_ir, &aggregate_seg) else {
201
+ return respond(404, "text/plain", &format!("{domain_name} has no aggregate {aggregate_seg:?}"));
202
+ };
203
+
204
+ if segments.len() == 2 {
205
+ return aggregate_index(domain_name, aggregate, &format, client, wasm_path).await;
206
+ }
207
+
208
+ if segments.len() == 3 {
209
+ let (verb_or_id, format) = split_format(segments[2]);
210
+ if let Some(command) = find_command(aggregate, &verb_or_id) {
211
+ let action = format!("/{domain_name}/{}/{}.html", agg_name(aggregate), verb_or_id);
212
+ return command_route(
213
+ domain_ir, domain_name, aggregate, command, &action, &format, method, query, raw_body,
214
+ client, wasm_path, config, invoker,
215
+ )
216
+ .await;
217
+ }
218
+ return record_show(domain_name, aggregate, &verb_or_id, &format, client, wasm_path).await;
219
+ }
220
+
221
+ respond(404, "text/plain", &format!("no route for {path}"))
222
+ }
223
+
224
+ // Routes /login, /logout, /auth/google(/callback) and /admin/members.
225
+ // `None` here falls through to route()'s ordinary IR-driven dispatch.
226
+ #[allow(clippy::too_many_arguments)]
227
+ async fn auth_route(
228
+ domain_ir: &Value,
229
+ path: &str,
230
+ method: &str,
231
+ query: &HashMap<String, String>,
232
+ raw_body: &str,
233
+ cookies: &HashMap<String, String>,
234
+ session: Option<&Session>,
235
+ secret: &str,
236
+ client: &Mutex<Client>,
237
+ wasm_path: &Path,
238
+ config: &LineageConfig,
239
+ invoker: &dyn LambdaInvoker,
240
+ ) -> Option<Value> {
241
+ match (method, path) {
242
+ ("GET", "/login") => Some(html(200, &login_page(query.get("error").map(|s| s.as_str())))),
243
+
244
+ ("POST", "/logout") => Some(redirect_with_cookie("/login", &format!("session=; Max-Age=0{}", cookie_flags()))),
245
+
246
+ // No password login: Google OAuth is the only sign-in path, and the
247
+ // /accounts/* routes here are just the session-cookie family that
248
+ // follows one (logout, me, sso-token) — never a signup by itself.
249
+ ("POST", "/accounts/logout") => Some(respond_with_cookie(
250
+ 200,
251
+ "application/json",
252
+ r#"{"ok":true}"#,
253
+ &format!("{}=; Max-Age=0{}", auth::account_cookie_name(), cookie_flags()),
254
+ )),
255
+
256
+ ("GET", "/accounts/me") => Some(accounts_me_route(domain_ir, cookies, secret, client).await),
257
+
258
+ ("GET", "/members") => Some(members_route(domain_ir, cookies, secret, client).await),
259
+
260
+ ("POST", "/members") => Some(add_member_route(domain_ir, raw_body, cookies, secret, client, wasm_path, config).await),
261
+
262
+ // Sending a newsletter issue (web/newsletter_send.rs's own header):
263
+ // an Admin's or Owner's account cookie, unlike the
264
+ // guest newsletter routes served ahead of this gate.
265
+ (method, path) if newsletter_send::issue_action(method, path).is_some() => {
266
+ newsletter_send::issue_route(method, path, domain_ir, raw_body, cookies, secret, client, wasm_path, config, invoker).await
267
+ }
268
+
269
+ ("POST", "/signups") => Some(signup_route(raw_body, secret, client, wasm_path, config, invoker).await),
270
+
271
+ ("GET", "/registrations") => Some(registrations_list_route(domain_ir, cookies, secret, client, wasm_path, config).await),
272
+
273
+ ("POST", "/members/disable") => Some(set_member_disabled_route(domain_ir, raw_body, cookies, secret, client, config, true).await),
274
+
275
+ ("POST", "/members/enable") => Some(set_member_disabled_route(domain_ir, raw_body, cookies, secret, client, config, false).await),
276
+
277
+ ("POST", "/members/role") => Some(set_member_role_route(domain_ir, raw_body, cookies, secret, client, config).await),
278
+
279
+ ("POST", "/members/delete") => Some(set_member_deleted_route(domain_ir, raw_body, cookies, secret, client, config).await),
280
+
281
+ // The tenant's payment connection: same gate as the checkout routes,
282
+ // since only the HECKS_CHECKOUT_DOMAIN domain carries one — any
283
+ // other domain served by this binary falls through as unknown.
284
+ (method, path)
285
+ if payments::owns(method, path) && checkout_enabled(std::env::var("HECKS_CHECKOUT_DOMAIN").ok().as_deref(), &config.domain) =>
286
+ {
287
+ payments::route(method, path, raw_body, cookies, secret, domain_ir, &payments::PlatformConfig::load().await, client, wasm_path, config, invoker).await
288
+ }
289
+
290
+ // cms/src/endpoints/sso.ts verifies this with the same account_token
291
+ // wire format; deploy-aws/platform/template.yaml shares SessionSecret
292
+ // with the cms container as AUTH_SECRET so both sides agree on it.
293
+ ("GET", "/accounts/sso-token") => Some(accounts_sso_token_route(domain_ir, cookies, secret, client).await),
294
+
295
+ ("GET", "/auth/google") => match auth::authorization_url(&redirect_uri(), secret) {
296
+ Ok(url) => Some(redirect(&url)),
297
+ Err(e) => Some(respond(500, "text/plain", &format!("Google sign-in isn't configured: {e}"))),
298
+ },
299
+
300
+ ("GET", "/auth/google/callback") => Some(google_callback(domain_ir, query, client, wasm_path, config, secret, invoker).await),
301
+
302
+ ("GET", "/admin/members") => {
303
+ let Some(session) = session else { return Some(redirect("/login")) };
304
+ if !is_admin(client, wasm_path, domain_ir, &session.identity_id).await {
305
+ return Some(html(403, "<p>Admins only.</p>"));
306
+ }
307
+ Some(html(200, &admin_members_page(client, domain_ir).await))
308
+ }
309
+
310
+ ("POST", "/admin/members") => {
311
+ let Some(session) = session else { return Some(redirect("/login")) };
312
+ if !is_admin(client, wasm_path, domain_ir, &session.identity_id).await {
313
+ return Some(html(403, "<p>Admins only.</p>"));
314
+ }
315
+ let form = parse_form(raw_body);
316
+ let email = form.get("email").cloned().unwrap_or_default();
317
+ let role = form.get("role").cloned().unwrap_or_default();
318
+ if !auth::GRANTABLE_ROLES.contains(&role.as_str()) {
319
+ return Some(html(400, &format!("<p>Role must be one of {}.</p>", auth::GRANTABLE_ROLES.join(", "))));
320
+ }
321
+ match auth::grant_access(client, wasm_path, config, domain_ir, &email, &role).await {
322
+ Ok(true) => Some(redirect("/admin/members")),
323
+ Ok(false) => Some(html(404, "<p>No member with that email.</p>")),
324
+ Err(e) => Some(html(500, &format!("<p>{}</p>", esc(&e.to_string())))),
325
+ }
326
+ }
327
+
328
+ _ => None,
329
+ }
330
+ }
331
+
332
+ async fn is_admin(client: &Mutex<Client>, wasm_path: &Path, domain_ir: &Value, identity_id: &str) -> bool {
333
+ let provider = crate::ir::authorization_provider(domain_ir);
334
+ match dispatch::read(client, wasm_path).await {
335
+ Ok(read) => auth::holds_admin(read.get("instances").unwrap_or(&json!({})), identity_id, provider.as_ref()),
336
+ Err(_) => false,
337
+ }
338
+ }
339
+
340
+ // Verifies the token AND that the person still has access, so a
341
+ // disable takes effect immediately even with an unexpired cookie.
342
+ async fn active_session_email(
343
+ domain_ir: &Value,
344
+ cookies: &HashMap<String, String>,
345
+ secret: &str,
346
+ client: &Mutex<Client>,
347
+ ) -> Result<String, Value> {
348
+ let not_logged_in = || respond(401, "application/json", &json!({"error": "not logged in"}).to_string());
349
+ let Some(email) = cookies.get(&auth::account_cookie_name()).and_then(|token| auth::verify_account_token(secret, token)) else {
350
+ return Err(not_logged_in());
351
+ };
352
+ match auth::has_access(client, domain_ir, &email).await {
353
+ Ok(true) => Ok(email),
354
+ Ok(false) => Err(not_logged_in()),
355
+ Err(e) => Err(respond(500, "application/json", &json!({"error": format!("members lookup failed: {e}")}).to_string())),
356
+ }
357
+ }
358
+
359
+ async fn accounts_me_route(domain_ir: &Value, cookies: &HashMap<String, String>, secret: &str, client: &Mutex<Client>) -> Value {
360
+ match active_session_email(domain_ir, cookies, secret, client).await {
361
+ Ok(email) => respond(200, "application/json", &json!({"email": email}).to_string()),
362
+ Err(response) => response,
363
+ }
364
+ }
365
+
366
+ // GET /members: admitted people as JSON, for a caller holding the
367
+ // account cookie but not the Governance session /admin/members needs.
368
+ async fn members_route(domain_ir: &Value, cookies: &HashMap<String, String>, secret: &str, client: &Mutex<Client>) -> Value {
369
+ if let Err(response) = active_session_email(domain_ir, cookies, secret, client).await {
370
+ return response;
371
+ }
372
+ match auth::all_people(client, domain_ir).await {
373
+ Ok(people) => respond(200, "application/json", &Value::Array(sorted_by_name(people)).to_string()),
374
+ Err(e) => respond(500, "application/json", &json!({"error": format!("members lookup failed: {e}")}).to_string()),
375
+ }
376
+ }
377
+
378
+ // POST /members: admits a person and grants a role, for a server-side
379
+ // caller. Any admin may grant Owner, since the first Owner needs one.
380
+ pub(crate) async fn add_member_route(
381
+ domain_ir: &Value,
382
+ raw_body: &str,
383
+ cookies: &HashMap<String, String>,
384
+ secret: &str,
385
+ client: &Mutex<Client>,
386
+ wasm_path: &Path,
387
+ config: &LineageConfig,
388
+ ) -> Value {
389
+ let json_error = |status: u16, message: &str| respond(status, "application/json", &json!({"error": message}).to_string());
390
+
391
+ let Some(caller) = cookies.get(&auth::account_cookie_name()).and_then(|token| auth::verify_account_token(secret, token)) else {
392
+ return json_error(401, "not logged in");
393
+ };
394
+ match auth::caller_is_admin(client, domain_ir, &caller).await {
395
+ Ok(true) => {}
396
+ Ok(false) => return json_error(403, "admins only"),
397
+ Err(e) => return json_error(500, &format!("members lookup failed: {e}")),
398
+ }
399
+
400
+ let body: Value = serde_json::from_str(raw_body).unwrap_or(Value::Null);
401
+ let field = |key: &str| body.get(key).and_then(|v| v.as_str()).map(|s| s.trim().to_string()).unwrap_or_default();
402
+ let (email, name) = (field("email"), field("name"));
403
+ let looks_like_email = email.split_once('@').is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.') && !email.contains(char::is_whitespace));
404
+ if name.is_empty() || !looks_like_email {
405
+ return json_error(400, "a name and a valid email are required");
406
+ }
407
+ let role = match body.get("role") {
408
+ None | Some(Value::Null) => "Admin".to_string(),
409
+ Some(value) => value.as_str().map(|s| s.trim().to_string()).unwrap_or_default(),
410
+ };
411
+ if !auth::GRANTABLE_ROLES.contains(&role.as_str()) {
412
+ return json_error(400, &format!("role must be one of {}", auth::GRANTABLE_ROLES.join(", ")));
413
+ }
414
+
415
+ match auth::admit_person(client, config, domain_ir, &email, &name).await {
416
+ Ok(true) => {}
417
+ Ok(false) => return json_error(409, "that email is already admitted"),
418
+ Err(e) => return json_error(500, &format!("admit failed: {e}")),
419
+ }
420
+ let email = email.to_lowercase();
421
+ match auth::grant_access(client, wasm_path, config, domain_ir, &email, &role).await {
422
+ Ok(true) => respond(
423
+ 201,
424
+ "application/json",
425
+ &json!({"name": name, "email": email, "role": role, "linked": false, "granted": true, "disabled": false}).to_string(),
426
+ ),
427
+ Ok(false) => json_error(500, &format!("the person was admitted but the {role} role was not granted")),
428
+ Err(e) => json_error(500, &format!("the person was admitted but the {role} role was not granted: {e}")),
429
+ }
430
+ }
431
+
432
+ // The `purpose_token` purpose a `POST /signups` call must carry.
433
+ const SIGNUP_PURPOSE: &str = "signup";
434
+
435
+ // POST /signups: dispatches Signups::Signup.SignUp as System, gated by
436
+ // a signup-purpose token rather than a session cookie (nobody is signed in yet).
437
+ async fn signup_route(raw_body: &str, secret: &str, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, invoker: &dyn LambdaInvoker) -> Value {
438
+ let json_error = |status: u16, message: &str| respond(status, "application/json", &json!({"error": message}).to_string());
439
+
440
+ let body: Value = serde_json::from_str(raw_body).unwrap_or(Value::Null);
441
+ let field = |key: &str| body.get(key).and_then(|v| v.as_str()).map(|s| s.trim().to_string()).unwrap_or_default();
442
+
443
+ if auth::verify_purpose_token(secret, SIGNUP_PURPOSE, &field("token")).is_none() {
444
+ return json_error(401, "not authorized");
445
+ }
446
+
447
+ let (email, name) = (field("email").to_lowercase(), field("name"));
448
+ let looks_like_email = email.split_once('@').is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.') && !email.contains(char::is_whitespace));
449
+ if name.is_empty() || !looks_like_email {
450
+ return json_error(400, "a name and a valid email are required");
451
+ }
452
+
453
+ let args = json!({"email": {"value": email}, "name": {"value": name}});
454
+ let outcome = match dispatch::handle(client, wasm_path, "Signups::Signup.SignUp", args, Some("System"), config, invoker).await {
455
+ Ok(o) => o,
456
+ Err(e) => return json_error(500, &format!("{e:#}")),
457
+ };
458
+ if !outcome.accepted {
459
+ return respond(422, "application/json", &last_refusal(&outcome.result).to_string());
460
+ }
461
+
462
+ respond(201, "application/json", &json!({"email": email, "name": name}).to_string())
463
+ }
464
+
465
+ // POST /members/disable|enable: turns access off/on without deleting
466
+ // the person, so enabling restores exactly the prior role and link.
467
+ async fn set_member_disabled_route(
468
+ domain_ir: &Value,
469
+ raw_body: &str,
470
+ cookies: &HashMap<String, String>,
471
+ secret: &str,
472
+ client: &Mutex<Client>,
473
+ config: &LineageConfig,
474
+ disable: bool,
475
+ ) -> Value {
476
+ let json_error = |status: u16, message: &str| respond(status, "application/json", &json!({"error": message}).to_string());
477
+
478
+ let Some(caller) = cookies.get(&auth::account_cookie_name()).and_then(|token| auth::verify_account_token(secret, token)) else {
479
+ return json_error(401, "not logged in");
480
+ };
481
+
482
+ let body: Value = serde_json::from_str(raw_body).unwrap_or(Value::Null);
483
+ let email = body.get("email").and_then(|v| v.as_str()).map(|s| s.trim().to_string()).unwrap_or_default();
484
+ if email.is_empty() {
485
+ return json_error(400, "an email is required");
486
+ }
487
+
488
+ match auth::set_person_disabled(client, config, domain_ir, &caller, &email, disable).await {
489
+ Ok(auth::DisableOutcome::Done) => {
490
+ let key = if disable { "disabled" } else { "enabled" };
491
+ respond(200, "application/json", &json!({key: email.to_lowercase()}).to_string())
492
+ }
493
+ Ok(auth::DisableOutcome::CallerNotAdmin) => json_error(403, "admins only"),
494
+ Ok(auth::DisableOutcome::SelfDisable) => json_error(403, "you can't disable your own admin access"),
495
+ Ok(auth::DisableOutcome::UnknownPerson) => json_error(404, "no member with that email"),
496
+ Ok(auth::DisableOutcome::LastAdmin) => json_error(409, "there must always be at least one admin"),
497
+ Err(e) => json_error(500, &format!("members update failed: {e}")),
498
+ }
499
+ }
500
+
501
+ // POST /members/delete: soft-deletes an already-disabled member — the row
502
+ // stays in the journal (nothing here is ever erased), but the Users page
503
+ // stops listing it. Requires disable first, same "are you sure" step that
504
+ // already gates removing someone's access at all.
505
+ async fn set_member_deleted_route(
506
+ domain_ir: &Value,
507
+ raw_body: &str,
508
+ cookies: &HashMap<String, String>,
509
+ secret: &str,
510
+ client: &Mutex<Client>,
511
+ config: &LineageConfig,
512
+ ) -> Value {
513
+ let json_error = |status: u16, message: &str| respond(status, "application/json", &json!({"error": message}).to_string());
514
+
515
+ let Some(caller) = cookies.get(&auth::account_cookie_name()).and_then(|token| auth::verify_account_token(secret, token)) else {
516
+ return json_error(401, "not logged in");
517
+ };
518
+
519
+ let body: Value = serde_json::from_str(raw_body).unwrap_or(Value::Null);
520
+ let email = body.get("email").and_then(|v| v.as_str()).map(|s| s.trim().to_string()).unwrap_or_default();
521
+ if email.is_empty() {
522
+ return json_error(400, "an email is required");
523
+ }
524
+
525
+ match auth::set_person_deleted(client, config, domain_ir, &caller, &email).await {
526
+ Ok(auth::DeleteOutcome::Done) => respond(200, "application/json", &json!({"deleted": email.to_lowercase()}).to_string()),
527
+ Ok(auth::DeleteOutcome::CallerNotAdmin) => json_error(403, "admins only"),
528
+ Ok(auth::DeleteOutcome::UnknownPerson) => json_error(404, "no member with that email"),
529
+ Ok(auth::DeleteOutcome::NotDisabled) => json_error(409, "disable this admin first"),
530
+ Err(e) => json_error(500, &format!("members update failed: {e}")),
531
+ }
532
+ }
533
+
534
+ // POST /members/role: changes an already-admitted person's role. Any
535
+ // active admin may grant Owner too, since the first Owner needs one.
536
+ async fn set_member_role_route(
537
+ domain_ir: &Value,
538
+ raw_body: &str,
539
+ cookies: &HashMap<String, String>,
540
+ secret: &str,
541
+ client: &Mutex<Client>,
542
+ config: &LineageConfig,
543
+ ) -> Value {
544
+ let json_error = |status: u16, message: &str| respond(status, "application/json", &json!({"error": message}).to_string());
545
+
546
+ let Some(caller) = cookies.get(&auth::account_cookie_name()).and_then(|token| auth::verify_account_token(secret, token)) else {
547
+ return json_error(401, "not logged in");
548
+ };
549
+
550
+ let body: Value = serde_json::from_str(raw_body).unwrap_or(Value::Null);
551
+ let field = |key: &str| body.get(key).and_then(|v| v.as_str()).map(|s| s.trim().to_string()).unwrap_or_default();
552
+ let (email, role) = (field("email"), field("role"));
553
+ if email.is_empty() {
554
+ return json_error(400, "an email is required");
555
+ }
556
+ if !auth::GRANTABLE_ROLES.contains(&role.as_str()) {
557
+ return json_error(400, &format!("role must be one of {}", auth::GRANTABLE_ROLES.join(", ")));
558
+ }
559
+
560
+ match auth::set_person_role(client, config, domain_ir, &caller, &email, &role).await {
561
+ Ok(auth::RoleOutcome::Done) => respond(200, "application/json", &json!({"email": email.to_lowercase(), "role": role}).to_string()),
562
+ Ok(auth::RoleOutcome::CallerNotAdmin) => json_error(403, "admins only"),
563
+ Ok(auth::RoleOutcome::UnknownPerson) => json_error(404, "no member with that email"),
564
+ Ok(auth::RoleOutcome::LastAdmin) => json_error(409, "there must always be at least one admin"),
565
+ Err(e) => json_error(500, &format!("members update failed: {e}")),
566
+ }
567
+ }
568
+
569
+ // GET /registrations: every registration as JSON for the admin Events
570
+ // page. Never returns health or payment fields — allowlisted below.
571
+ async fn registrations_list_route(
572
+ domain_ir: &Value,
573
+ cookies: &HashMap<String, String>,
574
+ secret: &str,
575
+ client: &Mutex<Client>,
576
+ wasm_path: &Path,
577
+ config: &LineageConfig,
578
+ ) -> Value {
579
+ let json_error = |status: u16, message: &str| respond(status, "application/json", &json!({"error": message}).to_string());
580
+
581
+ let Some(caller) = cookies.get(&auth::account_cookie_name()).and_then(|token| auth::verify_account_token(secret, token)) else {
582
+ return json_error(401, "not logged in");
583
+ };
584
+ match auth::caller_is_admin(client, domain_ir, &caller).await {
585
+ Ok(true) => {}
586
+ Ok(false) => return json_error(403, "admins only"),
587
+ Err(e) => return json_error(500, &format!("members lookup failed: {e}")),
588
+ }
589
+
590
+ let read = match dispatch::read(client, wasm_path).await {
591
+ Ok(read) => read,
592
+ Err(e) => return json_error(500, &format!("registrations lookup failed: {e:#}")),
593
+ };
594
+ respond(200, "application/json", &Value::Array(registration_list_rows(&read, &config.domain)).to_string())
595
+ }
596
+
597
+ // Built field-by-field from an allowlist so intake answers (health,
598
+ // medications) and payment fields can never appear here.
599
+ fn registration_list_rows(read: &Value, domain: &str) -> Vec<Value> {
600
+ const TIMESTAMP_KEYS: [&str; 4] = ["created_at", "registered_at", "requested_at", "occurred_at"];
601
+ let plain = |value: Option<&Value>| -> Option<Value> {
602
+ let value = value?;
603
+ Some(value.get("value").cloned().unwrap_or_else(|| value.clone()))
604
+ };
605
+ let text = |value: Option<&Value>| plain(value).and_then(|v| v.as_str().map(|s| s.trim().to_string()));
606
+
607
+ let mut rows: Vec<(Option<String>, Value)> = instances_for(read, &crate::ir::registrations_binding(domain).registration_prefix())
608
+ .into_iter()
609
+ .map(|(id, registration)| {
610
+ let attendee = registration.get("attendee").cloned().unwrap_or_else(|| json!({}));
611
+ let joined = [text(attendee.get("first_name")), text(attendee.get("last_name"))].into_iter().flatten().collect::<Vec<_>>().join(" ");
612
+ let name = if joined.is_empty() { text(attendee.get("name")).unwrap_or_default() } else { joined };
613
+ let stamp = TIMESTAMP_KEYS.iter().find_map(|key| text(registration.get(*key)));
614
+ let mut row = json!({
615
+ "registration_id": id,
616
+ "email": text(attendee.get("email")),
617
+ "name": name.trim(),
618
+ "event_slug": text(registration.get("event_slug")),
619
+ "news_signup": plain(attendee.get("news_signup")).and_then(|v| v.as_bool()).unwrap_or(false),
620
+ });
621
+ if let Some(status) = text(registration.get("status")) {
622
+ row["status"] = json!(status);
623
+ }
624
+ (stamp, row)
625
+ })
626
+ .collect();
627
+ if rows.iter().any(|(stamp, _)| stamp.is_some()) {
628
+ rows.sort_by(|a, b| b.0.cmp(&a.0));
629
+ }
630
+ rows.into_iter().map(|(_, row)| row).collect()
631
+ }
632
+
633
+ fn sorted_by_name(mut people: Vec<Value>) -> Vec<Value> {
634
+ let name_of = |p: &Value| p.get("name").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
635
+ people.sort_by_key(name_of);
636
+ people
637
+ }
638
+
639
+ // Mints a 60s handoff token cms/src/endpoints/sso.ts exchanges for a
640
+ // real Payload session — one-shot, so it's returned as JSON, not a cookie.
641
+ async fn accounts_sso_token_route(domain_ir: &Value, cookies: &HashMap<String, String>, secret: &str, client: &Mutex<Client>) -> Value {
642
+ const SSO_TOKEN_TTL_SECS: u64 = 60;
643
+ let email = match active_session_email(domain_ir, cookies, secret, client).await {
644
+ Ok(email) => email,
645
+ Err(response) => return response,
646
+ };
647
+ let sso_token = auth::account_token(secret, &email, SSO_TOKEN_TTL_SECS);
648
+ respond(200, "application/json", &json!({"token": sso_token}).to_string())
649
+ }
650
+
651
+ #[allow(clippy::too_many_arguments)]
652
+ async fn google_callback(
653
+ domain_ir: &Value,
654
+ query: &HashMap<String, String>,
655
+ client: &Mutex<Client>,
656
+ wasm_path: &Path,
657
+ config: &LineageConfig,
658
+ secret: &str,
659
+ invoker: &dyn LambdaInvoker,
660
+ ) -> Value {
661
+ let Some(code) = query.get("code") else {
662
+ eprintln!("google_callback: missing code");
663
+ return redirect("/login?error=google_failed");
664
+ };
665
+ let Some(state) = query.get("state") else {
666
+ eprintln!("google_callback: missing state");
667
+ return redirect("/login?error=google_failed");
668
+ };
669
+ if let Err(e) = auth::verify_state(state, secret) {
670
+ eprintln!("google_callback: state: {e}");
671
+ return redirect("/login?error=google_failed");
672
+ }
673
+
674
+ let claims = match auth::verify(code, &redirect_uri()).await {
675
+ Ok(c) => c,
676
+ Err(e) => {
677
+ eprintln!("google_callback: token exchange: {e}");
678
+ return redirect("/login?error=google_failed");
679
+ }
680
+ };
681
+
682
+ let read = match dispatch::read(client, wasm_path).await {
683
+ Ok(r) => r,
684
+ Err(e) => {
685
+ eprintln!("google_callback: read: {e:#}");
686
+ return redirect("/login?error=google_failed");
687
+ }
688
+ };
689
+ let instances = read.get("instances").cloned().unwrap_or(json!({}));
690
+
691
+ // PostgresEra head first — Identity is not in the WASM journal.
692
+ // Journal `instances` is only a fallback for Lambda-era rows.
693
+ let identity_id = match auth::resolve_identity_from_head(client, domain_ir, &claims.issuer, &claims.subject).await {
694
+ Ok(found) => found,
695
+ Err(e) => {
696
+ eprintln!("google_callback: identity head: {e:#}");
697
+ None
698
+ }
699
+ };
700
+ let identity_id = identity_id.or_else(|| auth::resolve_identity(&instances, &claims.issuer, &claims.subject));
701
+
702
+ let session = match identity_id {
703
+ Some(ref identity_id) => match auth::session_for_member_by_identity(client, domain_ir, identity_id).await {
704
+ Ok(s) => s,
705
+ Err(e) => {
706
+ eprintln!("google_callback: member by identity: {e:#}");
707
+ None
708
+ }
709
+ },
710
+ None => None,
711
+ };
712
+ let session = match session {
713
+ Some(s) => Some(s),
714
+ None if claims.email_verified => {
715
+ let email = claims.email.clone().unwrap_or_default();
716
+ match auth::provision(client, wasm_path, config, domain_ir, &email, &claims.issuer, &claims.subject, invoker).await {
717
+ Ok(s) => s,
718
+ Err(e) => {
719
+ eprintln!("google_callback: provision: {e:#}");
720
+ None
721
+ }
722
+ }
723
+ }
724
+ None => None,
725
+ };
726
+
727
+ let Some(session) = session else { return redirect("/login?error=google_unlinked") };
728
+
729
+ // The site's admin authenticates on the account cookie, not this
730
+ // host's own session cookie. Cross-origin, a cookie here would never
731
+ // reach the site, so the token is handed across via /api/google-handoff.
732
+ const SESSION_TTL_SECS: u64 = 60 * 60 * 24 * 14;
733
+ let site = std::env::var("SITE_URL").unwrap_or_else(|_| "http://localhost:4321".to_string());
734
+ let site = site.trim_end_matches('/');
735
+ let token = auth::account_token(secret, &session.email, SESSION_TTL_SECS);
736
+ if same_origin(site, &redirect_uri()) {
737
+ let cookie = format!("{}={token}{}; Max-Age={SESSION_TTL_SECS}", auth::account_cookie_name(), cookie_flags());
738
+ redirect_with_cookie("/admin.html", &cookie)
739
+ } else {
740
+ // One-redirect URL onto the Astro origin (local rust/host :4567 vs site :4321).
741
+ redirect(&format!("{}/api/google-handoff?token={}", site, auth::urlencode(&token)))
742
+ }
743
+ }
744
+
745
+ fn same_origin(a: &str, b: &str) -> bool {
746
+ origin_of(a) == origin_of(b)
747
+ }
748
+
749
+ fn origin_of(url: &str) -> String {
750
+ let rest = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
751
+ rest.split('/').next().unwrap_or(rest).to_string()
752
+ }
753
+
754
+ const ERROR_MESSAGES: &[(&str, &str)] = &[
755
+ ("google_unlinked", "That Google account isn't recognized here."),
756
+ ("google_failed", "Google sign-in didn't go through. Try again."),
757
+ ];
758
+
759
+ fn login_page(error: Option<&str>) -> String {
760
+ let message = error.and_then(|e| ERROR_MESSAGES.iter().find(|(k, _)| *k == e)).map(|(_, m)| *m);
761
+ let error_html = message.map(|m| format!(r#"<p class="text-red-600 text-sm mb-4">{}</p>"#, esc(m))).unwrap_or_default();
762
+ format!(
763
+ r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Sign in</title><script src="https://cdn.tailwindcss.com"></script></head>
764
+ <body class="bg-slate-50 text-slate-900 min-h-screen flex items-center justify-center">
765
+ <div class="w-full max-w-sm p-8 bg-white border border-slate-200 rounded-lg">
766
+ <h1 class="text-xl font-semibold mb-1">Sign in</h1>
767
+ <p class="text-slate-500 text-sm mb-6">Sign in with your Google account.</p>
768
+ {error_html}
769
+ <a href="/auth/google" class="block w-full text-center py-2 border border-slate-300 rounded-md hover:border-slate-400">Sign in with Google</a>
770
+ </div></body></html>"#
771
+ )
772
+ }
773
+
774
+ async fn admin_members_page(client: &Mutex<Client>, domain_ir: &Value) -> String {
775
+ let people = auth::all_people(client, domain_ir).await.unwrap_or_default();
776
+ let rows: String = people
777
+ .iter()
778
+ .map(|p| {
779
+ let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("");
780
+ let email = p.get("email").and_then(|v| v.as_str()).unwrap_or("");
781
+ let role = p.get("role").and_then(|v| v.as_str()).unwrap_or("—");
782
+ let access = if p.get("linked").and_then(|v| v.as_bool()) == Some(true) {
783
+ "linked"
784
+ } else if p.get("granted").and_then(|v| v.as_bool()) == Some(true) {
785
+ "granted, not yet signed in"
786
+ } else {
787
+ "no access"
788
+ };
789
+ format!(
790
+ r#"<tr class="border-b border-slate-100"><td class="py-2 pr-4">{}</td><td class="py-2 pr-4">{}</td><td class="py-2 pr-4">{}</td><td class="py-2">{}</td></tr>"#,
791
+ esc(name), esc(email), esc(role), esc(access)
792
+ )
793
+ })
794
+ .collect();
795
+
796
+ format!(
797
+ r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Members</title><script src="https://cdn.tailwindcss.com"></script></head>
798
+ <body class="bg-slate-50 text-slate-900 min-h-screen"><main class="max-w-2xl mx-auto px-4 py-8">
799
+ <p class="mb-4"><a href="/" class="text-slate-500 hover:text-slate-700">&larr; back</a></p>
800
+ <h1 class="text-xl font-semibold mb-4">Members</h1>
801
+ <table class="w-full text-sm mb-6"><thead><tr class="text-left text-slate-500 text-xs uppercase"><th class="py-2 pr-4">Name</th><th class="py-2 pr-4">Email</th><th class="py-2 pr-4">Role</th><th class="py-2">Access</th></tr></thead><tbody>{rows}</tbody></table>
802
+ <form method="post" action="/admin/members" class="flex gap-2 items-center">
803
+ <input type="email" name="email" placeholder="email of an existing Member" required class="flex-1 border border-slate-300 rounded-md px-3 py-2 text-sm" />
804
+ <select name="role" class="border border-slate-300 rounded-md px-3 py-2 text-sm"><option value="Admin">Admin</option><option value="Owner">Owner</option><option value="Member">Member</option></select>
805
+ <button type="submit" class="px-4 py-2 bg-slate-900 text-white rounded-md text-sm">Grant access</button>
806
+ </form></main></body></html>"#
807
+ )
808
+ }
809
+
810
+ fn find_aggregate<'a>(domain_ir: &'a Value, name: &str) -> Option<&'a Value> {
811
+ domain_ir.get("aggregates")?.as_array()?.iter().find(|a| a.get("name").and_then(|v| v.as_str()) == Some(name))
812
+ }
813
+
814
+ fn find_command<'a>(aggregate: &'a Value, name: &str) -> Option<&'a Value> {
815
+ aggregate.get("commands")?.as_array()?.iter().find(|c| c.get("name").and_then(|v| v.as_str()) == Some(name))
816
+ }
817
+
818
+ fn find_value_object<'a>(aggregate: &'a Value, name: &str) -> Option<&'a Value> {
819
+ aggregate.get("value_objects")?.as_array()?.iter().find(|v| v.get("name").and_then(|v| v.as_str()) == Some(name))
820
+ }
821
+
822
+ fn agg_name(aggregate: &Value) -> &str {
823
+ aggregate.get("name").and_then(|v| v.as_str()).unwrap_or("")
824
+ }
825
+
826
+ fn identity_paths(aggregate: &Value) -> Vec<String> {
827
+ aggregate
828
+ .get("identified_by")
829
+ .and_then(|v| v.as_array())
830
+ .map(|a| a.iter().filter_map(|p| p.as_str().map(String::from)).collect())
831
+ .unwrap_or_default()
832
+ }
833
+
834
+ // IR::Attribute -> Field, mirroring Hecks::Presentation::FieldShape.
835
+
836
+ #[derive(Clone, Debug)]
837
+ struct Field {
838
+ path: String,
839
+ label: String,
840
+ kind: FieldKind,
841
+ optional: bool,
842
+ }
843
+
844
+ #[derive(Clone, Debug)]
845
+ enum FieldKind {
846
+ Text { html_type: &'static str },
847
+ Textarea,
848
+ Number { step: &'static str },
849
+ Boolean,
850
+ Radio(Vec<String>),
851
+ Select(Vec<String>),
852
+ Group(Vec<Field>),
853
+ // Renders/collects exactly like Group — its own variant only so a
854
+ // caller can ask "is this money" without matching Group's children.
855
+ Money(Vec<Field>),
856
+ // `target` is unused by today's render arm (a plain text input) but
857
+ // kept for a future dropdown renderer — hence `#[allow(dead_code)]`
858
+ // rather than removing the field.
859
+ Reference {
860
+ #[allow(dead_code)]
861
+ target: Option<String>,
862
+ },
863
+ List(Box<Field>),
864
+ }
865
+
866
+ fn humanize(path: &str) -> String {
867
+ let segment = path.rsplit('.').next().unwrap_or(path);
868
+ let words: Vec<&str> = segment.split('_').collect();
869
+ if words.is_empty() {
870
+ return segment.to_string();
871
+ }
872
+ let mut out = String::new();
873
+ for (i, w) in words.iter().enumerate() {
874
+ if i > 0 {
875
+ out.push(' ');
876
+ }
877
+ if i == 0 {
878
+ let mut chars = w.chars();
879
+ if let Some(first) = chars.next() {
880
+ out.push(first.to_ascii_uppercase());
881
+ out.push_str(chars.as_str());
882
+ }
883
+ } else {
884
+ out.push_str(w);
885
+ }
886
+ }
887
+ out
888
+ }
889
+
890
+ const PRIMITIVES: &[&str] = &["String", "Integer", "Float", "TrueClass", "FalseClass"];
891
+
892
+ // Dispatch order: list? -> reference? -> admits -> value object ->
893
+ // primitive. `domain_ir` is the whole chapter, needed for cross-aggregate lookups.
894
+ fn resolve_field(domain_ir: &Value, attribute: &Value, aggregate: &Value, path: &str) -> Field {
895
+ let is_list = attribute.get("list").and_then(|v| v.as_bool()).unwrap_or(false);
896
+ let optional = attribute.get("optional").and_then(|v| v.as_bool()).unwrap_or(false);
897
+ let ty = attribute.get("type").and_then(|v| v.as_str()).unwrap_or("String");
898
+
899
+ if is_list {
900
+ let scalar = json!({
901
+ "name": attribute.get("name"), "type": ty, "list": false, "optional": true,
902
+ "pattern": attribute.get("pattern"), "admits": attribute.get("admits"),
903
+ });
904
+ let item = resolve_field(domain_ir, &scalar, aggregate, path);
905
+ return Field { path: path.to_string(), label: humanize(path), kind: FieldKind::List(Box::new(item)), optional };
906
+ }
907
+
908
+ if let Some(target) = reference_target(ty) {
909
+ return Field { path: path.to_string(), label: humanize(path), kind: FieldKind::Reference { target: Some(target) }, optional };
910
+ }
911
+
912
+ if let Some(admits) = attribute.get("admits").and_then(|v| v.as_str()) {
913
+ return admitted_field(domain_ir, aggregate, attribute, admits, ty, path, optional);
914
+ }
915
+
916
+ if PRIMITIVES.contains(&ty) {
917
+ return primitive_field(attribute, ty, path, optional);
918
+ }
919
+
920
+ let Some(shape) = value_object_shape(domain_ir, aggregate, ty) else {
921
+ return primitive_field(attribute, ty, path, optional);
922
+ };
923
+ value_object_field(domain_ir, aggregate, shape, path, optional)
924
+ }
925
+
926
+ // `Reference<X>` -> `Some("X")`. `Reference<...>` is the IR's pinned
927
+ // export spelling (never a nested object) — mirrors naming.rb.
928
+ fn reference_target(ty: &str) -> Option<String> {
929
+ ty.strip_prefix("Reference<")?.strip_suffix('>').map(String::from)
930
+ }
931
+
932
+ // Checked on the owning aggregate first, then across the whole domain.
933
+ fn value_object_shape<'a>(domain_ir: &'a Value, aggregate: &'a Value, ty: &str) -> Option<&'a Value> {
934
+ find_value_object(aggregate, ty).or_else(|| cross_aggregate_value_object(domain_ir, ty))
935
+ }
936
+
937
+ // Walks the whole domain, not just siblings — a value object's shape
938
+ // may be declared on a different aggregate than the one referencing it.
939
+ fn cross_aggregate_value_object<'a>(domain_ir: &'a Value, type_name: &str) -> Option<&'a Value> {
940
+ domain_ir.get("aggregates")?.as_array()?.iter().find_map(|sibling| find_value_object(sibling, type_name))
941
+ }
942
+
943
+ // Shared by the native one_of branch and the admits: branch below, so
944
+ // neither carries its own drifting copy of this reading.
945
+ fn closed_set_members(vo: &Value) -> (String, Vec<String>) {
946
+ let attrs = vo.get("attributes").and_then(|v| v.as_array());
947
+ let discriminant = attrs.and_then(|a| a.first()).and_then(|a| a.get("name")).and_then(|v| v.as_str()).unwrap_or("value").to_string();
948
+ let members = vo
949
+ .get("members")
950
+ .and_then(|v| v.as_array())
951
+ .map(|members| {
952
+ members
953
+ .iter()
954
+ .filter_map(|m| {
955
+ m.as_array()?.iter().find_map(|pair| {
956
+ let pair = pair.as_array()?;
957
+ if pair.first()?.as_str()? == discriminant {
958
+ pair.get(1)?.as_str().map(String::from)
959
+ } else {
960
+ None
961
+ }
962
+ })
963
+ })
964
+ .collect()
965
+ })
966
+ .unwrap_or_default();
967
+ (discriminant, members)
968
+ }
969
+
970
+ // Radio under 4 members, Select otherwise. Callers append a discriminant
971
+ // to `.path` afterward without touching `label`, which is set here.
972
+ fn select_or_radio(path: &str, optional: bool, members: Vec<String>) -> Field {
973
+ let label = humanize(path);
974
+ let kind = if members.len() <= 4 { FieldKind::Radio(members) } else { FieldKind::Select(members) };
975
+ Field { path: path.to_string(), label, kind, optional }
976
+ }
977
+
978
+ // A closed set declared elsewhere (e.g. "Account::LedgerDirection"),
979
+ // rendered the same way the native one_of branch renders its own.
980
+ fn admitted_field(domain_ir: &Value, aggregate: &Value, attribute: &Value, admits: &str, ty: &str, path: &str, optional: bool) -> Field {
981
+ let mut parts = admits.splitn(2, "::");
982
+ let set_aggregate_name = parts.next().unwrap_or("");
983
+ let set_name = parts.next();
984
+
985
+ let set = set_name.and_then(|name| find_aggregate(domain_ir, set_aggregate_name).and_then(|agg| find_value_object(agg, name)));
986
+ // Undeclared set: render as a plain scalar (using the real attribute
987
+ // so its name/pattern still drive Tier B's hints) — dispatch refuses.
988
+ let Some(set) = set else { return primitive_field(attribute, ty, path, optional) };
989
+
990
+ let (_, members) = closed_set_members(set);
991
+ let mut field = select_or_radio(path, optional, members);
992
+
993
+ // The attribute's own value object still needs its ".value" hop
994
+ // (same unwrap `value_object_field` does) even though the admitted
995
+ // set lives on a different aggregate — it only changes the options.
996
+ let Some(own_shape) = value_object_shape(domain_ir, aggregate, ty) else { return field };
997
+ let attrs = own_shape.get("attributes").and_then(|v| v.as_array());
998
+ let Some(attrs) = attrs.filter(|a| a.len() == 1) else { return field };
999
+
1000
+ let inner_name = attrs[0].get("name").and_then(|v| v.as_str()).unwrap_or("value");
1001
+ field.path = format!("{path}.{inner_name}");
1002
+ field
1003
+ }
1004
+
1005
+ // Order: closed_set? -> money_shaped? -> single-attribute-unwrap -> group.
1006
+ fn value_object_field(domain_ir: &Value, aggregate: &Value, shape: &Value, path: &str, optional: bool) -> Field {
1007
+ let closed_set = shape.get("closed_set").and_then(|v| v.as_bool()).unwrap_or(false);
1008
+ let attrs = shape.get("attributes").and_then(|v| v.as_array()).cloned().unwrap_or_default();
1009
+
1010
+ if closed_set {
1011
+ let (discriminant, members) = closed_set_members(shape);
1012
+ let mut field = select_or_radio(path, optional, members);
1013
+ field.path = format!("{path}.{discriminant}");
1014
+ return field;
1015
+ }
1016
+
1017
+ if money_shaped(&attrs) {
1018
+ return money_field(path, optional);
1019
+ }
1020
+
1021
+ // A single-attribute value object names a scalar, not a group, so
1022
+ // the outer label wins. Recursing through `resolve_field` (not
1023
+ // `primitive_field`) keeps the inner name/pattern driving Tier B.
1024
+ if attrs.len() == 1 {
1025
+ let inner = &attrs[0];
1026
+ let inner_name = inner.get("name").and_then(|v| v.as_str()).unwrap_or("value");
1027
+ let inner_path = format!("{path}.{inner_name}");
1028
+ let mut field = resolve_field(domain_ir, inner, aggregate, &inner_path);
1029
+ field.label = humanize(path);
1030
+ field.optional = optional || field.optional;
1031
+ return field;
1032
+ }
1033
+
1034
+ let children = attrs
1035
+ .iter()
1036
+ .map(|inner| {
1037
+ let inner_name = inner.get("name").and_then(|v| v.as_str()).unwrap_or("");
1038
+ resolve_field(domain_ir, inner, aggregate, &format!("{path}.{inner_name}"))
1039
+ })
1040
+ .collect();
1041
+ Field { path: path.to_string(), label: humanize(path), kind: FieldKind::Group(children), optional }
1042
+ }
1043
+
1044
+ // Exactly `{cents, currency}`, sorted, and nothing else.
1045
+ fn money_shaped(attrs: &[Value]) -> bool {
1046
+ let mut names: Vec<&str> = attrs.iter().filter_map(|a| a.get("name").and_then(|v| v.as_str())).collect();
1047
+ names.sort_unstable();
1048
+ names == ["cents", "currency"]
1049
+ }
1050
+
1051
+ // Cents as a whole-integer Number; currency as free Text, always
1052
+ // optional (it defaults to "USD" regardless of the outer field).
1053
+ fn money_field(path: &str, optional: bool) -> Field {
1054
+ let cents = Field { path: format!("{path}.cents"), label: "Amount (cents)".to_string(), kind: FieldKind::Number { step: "1" }, optional };
1055
+ let currency = Field { path: format!("{path}.currency"), label: "Currency".to_string(), kind: FieldKind::Text { html_type: "text" }, optional: true };
1056
+ Field { path: path.to_string(), label: humanize(path), kind: FieldKind::Money(vec![cents, currency]), optional }
1057
+ }
1058
+
1059
+ fn primitive_field(attribute: &Value, ty: &str, path: &str, optional: bool) -> Field {
1060
+ let label = humanize(path);
1061
+ let kind = match ty {
1062
+ "Integer" => FieldKind::Number { step: "1" },
1063
+ "Float" => FieldKind::Number { step: "any" },
1064
+ "TrueClass" | "FalseClass" => FieldKind::Boolean,
1065
+ _ => return text_field(attribute, path, optional),
1066
+ };
1067
+ Field { path: path.to_string(), label, kind, optional }
1068
+ }
1069
+
1070
+ // Tier B's hints (field_hints.rs) match on `attribute`'s own name/
1071
+ // pattern, never derived from `path` — matters after the unwrap above.
1072
+ fn text_field(attribute: &Value, path: &str, optional: bool) -> Field {
1073
+ let name = attribute.get("name").and_then(|v| v.as_str()).unwrap_or("");
1074
+ let pattern = attribute.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
1075
+ let html_type = text_html_type(name, pattern);
1076
+ let kind = text_kind(html_type, name);
1077
+ Field { path: path.to_string(), label: humanize(path), kind, optional }
1078
+ }
1079
+
1080
+ // email, then url, then tel — first match wins. Case-insensitive
1081
+ // "http" substring catches both "http" and "https".
1082
+ fn text_html_type(name: &str, pattern: &str) -> &'static str {
1083
+ if pattern.contains('@') || EMAIL_HINT.is_match(name) {
1084
+ return "email";
1085
+ }
1086
+ if pattern.to_ascii_lowercase().contains("http") || URL_HINT.is_match(name) {
1087
+ return "url";
1088
+ }
1089
+ if TEL_HINT.is_match(name) {
1090
+ return "tel";
1091
+ }
1092
+ "text"
1093
+ }
1094
+
1095
+ // Only checked once `html_type` has already fallen through to "text".
1096
+ fn text_kind(html_type: &'static str, name: &str) -> FieldKind {
1097
+ if html_type == "text" && TEXTAREA_HINT.is_match(name) {
1098
+ return FieldKind::Textarea;
1099
+ }
1100
+ FieldKind::Text { html_type }
1101
+ }
1102
+
1103
+ fn command_fields(domain_ir: &Value, aggregate: &Value, command: &Value) -> Vec<Field> {
1104
+ let creates = command.get("references").map(|v| v.is_null()).unwrap_or(true);
1105
+ let mut fields = Vec::new();
1106
+ if !creates {
1107
+ let paths = identity_paths(aggregate).join(", ");
1108
+ fields.push(Field {
1109
+ path: "id".to_string(),
1110
+ label: format!("{} ({paths})", agg_name(aggregate)),
1111
+ kind: FieldKind::Text { html_type: "text" },
1112
+ optional: false,
1113
+ });
1114
+ }
1115
+ if let Some(attrs) = command.get("attributes").and_then(|v| v.as_array()) {
1116
+ for attribute in attrs {
1117
+ let name = attribute.get("name").and_then(|v| v.as_str()).unwrap_or("");
1118
+ fields.push(resolve_field(domain_ir, attribute, aggregate, name));
1119
+ }
1120
+ }
1121
+ fields
1122
+ }
1123
+
1124
+ // Flat dotted form body -> nested JSON args, mirroring
1125
+ // Hecks::Presentation::Params.
1126
+
1127
+ // `Err`, never a panic, on a path-prefix collision — see `nest`'s own doc.
1128
+ fn extract_args(fields: &[Field], raw: &HashMap<String, String>) -> Result<Value, String> {
1129
+ let mut pairs: Vec<(String, Value)> = Vec::new();
1130
+ for field in fields {
1131
+ collect_field(field, raw, &mut pairs);
1132
+ }
1133
+ nest(pairs)
1134
+ }
1135
+
1136
+ fn collect_field(field: &Field, raw: &HashMap<String, String>, pairs: &mut Vec<(String, Value)>) {
1137
+ match &field.kind {
1138
+ // Money shares Group's own children-flattening.
1139
+ FieldKind::Group(children) | FieldKind::Money(children) => {
1140
+ for child in children {
1141
+ collect_field(child, raw, pairs);
1142
+ }
1143
+ }
1144
+ FieldKind::List(item) => {
1145
+ if let Some(text) = raw.get(&field.path) {
1146
+ let values: Vec<Value> = text
1147
+ .lines()
1148
+ .map(str::trim)
1149
+ .filter(|l| !l.is_empty())
1150
+ .map(|l| cast_scalar(item, l))
1151
+ .collect();
1152
+ if !values.is_empty() {
1153
+ pairs.push((field.path.clone(), Value::Array(values)));
1154
+ }
1155
+ }
1156
+ }
1157
+ FieldKind::Boolean => {
1158
+ let checked = raw.get(&field.path).map(|v| matches!(v.as_str(), "on" | "1" | "true")).unwrap_or(false);
1159
+ pairs.push((field.path.clone(), Value::Bool(checked)));
1160
+ }
1161
+ _ => {
1162
+ if let Some(text) = raw.get(&field.path) {
1163
+ if !(text.is_empty() && field.optional) {
1164
+ pairs.push((field.path.clone(), cast_scalar(field, text)));
1165
+ }
1166
+ }
1167
+ }
1168
+ }
1169
+ }
1170
+
1171
+ fn cast_scalar(field: &Field, text: &str) -> Value {
1172
+ match &field.kind {
1173
+ FieldKind::Number { step } if *step == "1" => text.parse::<i64>().map(Value::from).unwrap_or(Value::Null),
1174
+ FieldKind::Number { .. } => text.parse::<f64>().map(Value::from).unwrap_or(Value::Null),
1175
+ _ => Value::String(text.to_string()),
1176
+ }
1177
+ }
1178
+
1179
+ // Refuses a path-prefix collision (e.g. "price" vs "price.cents")
1180
+ // cleanly, rather than panicking one way and silently dropping data the other.
1181
+ fn nest(pairs: Vec<(String, Value)>) -> Result<Value, String> {
1182
+ let mut result = json!({});
1183
+ for (path, value) in pairs {
1184
+ let segments: Vec<&str> = path.split('.').collect();
1185
+ let mut node = &mut result;
1186
+ for depth in 0..segments.len() - 1 {
1187
+ let obj = node
1188
+ .as_object_mut()
1189
+ .ok_or_else(|| collision_error(&path, &segments[..depth]))?;
1190
+ node = obj.entry(segments[depth]).or_insert_with(|| json!({}));
1191
+ }
1192
+ let leaf = segments[segments.len() - 1];
1193
+ let obj = node
1194
+ .as_object_mut()
1195
+ .ok_or_else(|| collision_error(&path, &segments[..segments.len() - 1]))?;
1196
+ // The other collision direction: inserting a scalar over an
1197
+ // already-built object here would silently erase its children.
1198
+ if matches!(obj.get(leaf), Some(existing) if existing.is_object()) && !value.is_object() {
1199
+ return Err(collision_error(&path, &segments));
1200
+ }
1201
+ obj.insert(leaf.to_string(), value);
1202
+ }
1203
+ Ok(result)
1204
+ }
1205
+
1206
+ fn collision_error(path: &str, existing_prefix: &[&str]) -> String {
1207
+ format!(
1208
+ "form field {path:?} collides with {:?} — one implies a scalar value, the other a nested object; refusing rather than guessing or silently dropping data",
1209
+ existing_prefix.join(".")
1210
+ )
1211
+ }
1212
+
1213
+ fn home_body(domain_ir: &Value) -> String {
1214
+ let name = domain_ir.get("name").and_then(|v| v.as_str()).unwrap_or("");
1215
+ let vision = domain_ir.get("vision").and_then(|v| v.as_str()).unwrap_or("");
1216
+ let aggregates = domain_ir.get("aggregates").and_then(|v| v.as_array()).cloned().unwrap_or_default();
1217
+ let items: String = aggregates
1218
+ .iter()
1219
+ .map(|a| {
1220
+ let n = agg_name(a);
1221
+ format!(r#"<li><a href="/{name}/{n}.html" class="text-indigo-600 hover:underline">{n}</a></li>"#)
1222
+ })
1223
+ .collect();
1224
+ format!(
1225
+ r#"<h1 class="text-2xl font-bold mb-4">Loaded domains</h1>
1226
+ <ul class="space-y-4"><li><h2 class="font-semibold text-slate-800">{}</h2>
1227
+ <p class="text-sm text-slate-500">{}</p>
1228
+ <ul class="ml-4 mt-1 list-disc list-inside">{}</ul></li></ul>"#,
1229
+ esc(name), esc(vision), items
1230
+ )
1231
+ }
1232
+
1233
+ async fn aggregate_index(domain_name: &str, aggregate: &Value, format: &str, client: &Mutex<Client>, wasm_path: &Path) -> Value {
1234
+ let agg = agg_name(aggregate);
1235
+ let prefix = format!("{domain_name}::{agg}#");
1236
+ let records = match dispatch::read(client, wasm_path).await {
1237
+ Ok(result) => instances_for(&result, &prefix),
1238
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
1239
+ };
1240
+
1241
+ if format != "html" {
1242
+ let list: Vec<Value> = records.iter().map(|(id, state)| with_id(id, state)).collect();
1243
+ return respond(200, "application/json", &serde_json::to_string_pretty(&list).unwrap_or_default());
1244
+ }
1245
+
1246
+ let creating: Vec<&Value> = aggregate
1247
+ .get("commands")
1248
+ .and_then(|v| v.as_array())
1249
+ .map(|cs| cs.iter().filter(|c| c.get("references").map(|r| r.is_null()).unwrap_or(true)).collect())
1250
+ .unwrap_or_default();
1251
+ let acting: Vec<&Value> = aggregate
1252
+ .get("commands")
1253
+ .and_then(|v| v.as_array())
1254
+ .map(|cs| cs.iter().filter(|c| !c.get("references").map(|r| r.is_null()).unwrap_or(true)).collect())
1255
+ .unwrap_or_default();
1256
+
1257
+ let new_links: String = creating
1258
+ .iter()
1259
+ .map(|c| {
1260
+ let cn = c.get("name").and_then(|v| v.as_str()).unwrap_or("");
1261
+ format!(r#"<a href="/{domain_name}/{agg}/{cn}.html" class="rounded-md bg-indigo-600 px-3 py-1.5 text-sm text-white hover:bg-indigo-500">{cn}</a>"#)
1262
+ })
1263
+ .collect();
1264
+
1265
+ let rows: String = records
1266
+ .iter()
1267
+ .map(|(id, _)| {
1268
+ let actions: String = acting
1269
+ .iter()
1270
+ .map(|c| {
1271
+ let cn = c.get("name").and_then(|v| v.as_str()).unwrap_or("");
1272
+ action_link(domain_name, agg, cn, id, "text-indigo-600 hover:underline mr-3")
1273
+ })
1274
+ .collect();
1275
+ index_row_html(domain_name, agg, id, &actions)
1276
+ })
1277
+ .collect();
1278
+
1279
+ let body = if records.is_empty() {
1280
+ format!(
1281
+ r#"<div class="flex items-center justify-between mb-4"><h1 class="text-2xl font-bold">{domain_name}::{agg}</h1><div class="flex gap-2">{new_links}</div></div><p class="text-slate-500">No records yet.</p>"#
1282
+ )
1283
+ } else {
1284
+ format!(
1285
+ r#"<div class="flex items-center justify-between mb-4"><h1 class="text-2xl font-bold">{domain_name}::{agg}</h1><div class="flex gap-2">{new_links}</div></div>
1286
+ <table class="w-full text-sm border border-slate-200 rounded-md overflow-hidden"><thead class="bg-slate-100 text-left"><tr><th class="px-3 py-2">id</th><th class="px-3 py-2">actions</th></tr></thead>
1287
+ <tbody class="divide-y divide-slate-100 bg-white">{rows}</tbody></table>"#
1288
+ )
1289
+ };
1290
+ html(200, &page(&format!("{domain_name}::{agg}"), &body))
1291
+ }
1292
+
1293
+ async fn record_show(domain_name: &str, aggregate: &Value, id: &str, format: &str, client: &Mutex<Client>, wasm_path: &Path) -> Value {
1294
+ let agg = agg_name(aggregate);
1295
+ let prefix = format!("{domain_name}::{agg}#");
1296
+ let result = match dispatch::read(client, wasm_path).await {
1297
+ Ok(r) => r,
1298
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
1299
+ };
1300
+ let records = instances_for(&result, &prefix);
1301
+ let Some((_, state)) = records.iter().find(|(rid, _)| rid == id) else {
1302
+ let message = format!("No {agg} {id}.");
1303
+ return if format != "html" {
1304
+ respond(404, "application/json", &json!({"error":"NotFound","message":message}).to_string())
1305
+ } else {
1306
+ html(404, &page("Not found", &format!(r#"<h1 class="text-2xl font-bold mb-4">Not found</h1><p class="text-slate-600">{}</p>"#, esc(&message))))
1307
+ };
1308
+ };
1309
+
1310
+ if format != "html" {
1311
+ return respond(200, "application/json", &serde_json::to_string_pretty(&with_id(id, state)).unwrap_or_default());
1312
+ }
1313
+
1314
+ let rows: String = state
1315
+ .as_object()
1316
+ .map(|o| {
1317
+ o.iter()
1318
+ .map(|(k, v)| format!(r#"<div class="px-4 py-2 grid grid-cols-3 gap-2"><dt class="text-sm font-medium text-slate-500">{}</dt><dd class="col-span-2 text-sm text-slate-900 font-mono">{}</dd></div>"#, esc(k), esc(&v.to_string())))
1319
+ .collect()
1320
+ })
1321
+ .unwrap_or_default();
1322
+ let actions: String = aggregate
1323
+ .get("commands")
1324
+ .and_then(|v| v.as_array())
1325
+ .map(|cs| {
1326
+ cs.iter()
1327
+ .filter(|c| !c.get("references").map(|r| r.is_null()).unwrap_or(true))
1328
+ .map(|c| {
1329
+ let cn = c.get("name").and_then(|v| v.as_str()).unwrap_or("");
1330
+ action_link(domain_name, agg, cn, id, "rounded-md bg-indigo-600 px-3 py-1.5 text-sm text-white hover:bg-indigo-500 mr-3")
1331
+ })
1332
+ .collect::<String>()
1333
+ })
1334
+ .unwrap_or_default();
1335
+ let body = format!(
1336
+ r#"<h1 class="text-2xl font-bold">{} <span class="font-mono text-lg text-slate-500">{}</span></h1>
1337
+ <dl class="mt-4 divide-y divide-slate-200 border border-slate-200 rounded-md bg-white">{rows}</dl>
1338
+ <div class="mt-6">{actions}<a href="/{domain_name}/{agg}.html" class="text-indigo-600 hover:underline">← {agg} list</a></div>"#,
1339
+ esc(agg), esc(id)
1340
+ );
1341
+ html(200, &page(&format!("{agg} {id}"), &body))
1342
+ }
1343
+
1344
+ #[allow(clippy::too_many_arguments)]
1345
+ async fn command_route(
1346
+ domain_ir: &Value, domain_name: &str, aggregate: &Value, command: &Value, action: &str, format: &str, method: &str,
1347
+ query: &HashMap<String, String>, raw_body: &str, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig,
1348
+ invoker: &dyn LambdaInvoker,
1349
+ ) -> Value {
1350
+ let fields = command_fields(domain_ir, aggregate, command);
1351
+ let cname = command.get("name").and_then(|v| v.as_str()).unwrap_or("");
1352
+
1353
+ if format != "html" {
1354
+ if method == "GET" {
1355
+ return respond(200, "application/json", &command.to_string());
1356
+ }
1357
+ let raw = parse_form(raw_body);
1358
+ return submit(domain_name, aggregate, command, &fields, &raw, client, wasm_path, config, true, invoker).await;
1359
+ }
1360
+
1361
+ if method == "GET" {
1362
+ return html(200, &page(&format!("{domain_name}::{}.{cname}", agg_name(aggregate)), &form_body(domain_name, aggregate, command, action, &fields, query, None)));
1363
+ }
1364
+
1365
+ let raw = parse_form(raw_body);
1366
+ submit(domain_name, aggregate, command, &fields, &raw, client, wasm_path, config, false, invoker).await
1367
+ }
1368
+
1369
+ #[allow(clippy::too_many_arguments)]
1370
+ async fn submit(
1371
+ domain_name: &str, aggregate: &Value, command: &Value, fields: &[Field], raw: &HashMap<String, String>,
1372
+ client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, json_mode: bool, invoker: &dyn LambdaInvoker,
1373
+ ) -> Value {
1374
+ let agg = agg_name(aggregate);
1375
+ let cname = command.get("name").and_then(|v| v.as_str()).unwrap_or("");
1376
+ let verb = format!("{domain_name}::{agg}.{cname}");
1377
+
1378
+ // A path-prefix collision refuses cleanly here (400) rather than
1379
+ // panicking inside `nest` (see `extract_args`'s own doc).
1380
+ let args = match extract_args(fields, raw) {
1381
+ Ok(a) => a,
1382
+ Err(e) => return bad_request(domain_name, aggregate, command, fields, raw, json_mode, &e),
1383
+ };
1384
+
1385
+ // `None`: this web UI has no concept of an authenticated caller's
1386
+ // role yet (see auth.rs's own session handling for that).
1387
+ let outcome = match dispatch::handle(client, wasm_path, &verb, args, None, config, invoker).await {
1388
+ Ok(o) => o,
1389
+ Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
1390
+ };
1391
+
1392
+ if outcome.accepted {
1393
+ let id = own_command_target_id(&outcome.result).unwrap_or("");
1394
+ if json_mode {
1395
+ return respond(201, "application/json", &outcome.result.to_string());
1396
+ }
1397
+ return redirect(&format!("/{domain_name}/{agg}/{id}.html"));
1398
+ }
1399
+
1400
+ let refusal = last_refusal(&outcome.result);
1401
+
1402
+ if json_mode {
1403
+ return respond(422, "application/json", &refusal.to_string());
1404
+ }
1405
+
1406
+ let action = format!("/{domain_name}/{agg}/{cname}.html");
1407
+ html(422, &page(&format!("{domain_name}::{agg}.{cname}"), &form_body(domain_name, aggregate, command, &action, fields, raw, Some(&refusal))))
1408
+ }
1409
+
1410
+ // Mirrors the 422-refusal rendering below (same json/html split) but at
1411
+ // 400 — this is a malformed submission `nest` caught, not a domain refusal.
1412
+ #[allow(clippy::too_many_arguments)]
1413
+ fn bad_request(domain_name: &str, aggregate: &Value, command: &Value, fields: &[Field], raw: &HashMap<String, String>, json_mode: bool, message: &str) -> Value {
1414
+ let refusal = json!({"error": message});
1415
+ if json_mode {
1416
+ return respond(400, "application/json", &refusal.to_string());
1417
+ }
1418
+ let agg = agg_name(aggregate);
1419
+ let cname = command.get("name").and_then(|v| v.as_str()).unwrap_or("");
1420
+ let action = format!("/{domain_name}/{agg}/{cname}.html");
1421
+ html(400, &page(&format!("{domain_name}::{agg}.{cname}"), &form_body(domain_name, aggregate, command, &action, fields, raw, Some(&refusal))))
1422
+ }
1423
+
1424
+ // The first mutation in the last step is always the command's own
1425
+ // target — a cascaded reaction's mutation, if any, comes after it.
1426
+ fn own_command_target_id(result: &Value) -> Option<&str> {
1427
+ result
1428
+ .get("mutations")
1429
+ .and_then(|m| m.as_array())
1430
+ .and_then(|steps| steps.last())
1431
+ .and_then(|last| last.as_array())
1432
+ .and_then(|muts| muts.first())
1433
+ .and_then(|m| m.get("id"))
1434
+ .and_then(|v| v.as_str())
1435
+ }
1436
+
1437
+ // `.last()`: dispatch::handle reruns the whole rehydrated history, and
1438
+ // every step before this call's own already succeeded once.
1439
+ pub(crate) fn last_refusal(result: &Value) -> Value {
1440
+ result
1441
+ .get("refusals")
1442
+ .and_then(|r| r.as_array())
1443
+ .and_then(|rs| rs.last())
1444
+ .cloned()
1445
+ .unwrap_or_else(|| json!({"error": "Refused"}))
1446
+ }
1447
+
1448
+ fn form_body(domain_name: &str, aggregate: &Value, command: &Value, action: &str, fields: &[Field], values: &HashMap<String, String>, error: Option<&Value>) -> String {
1449
+ let agg = agg_name(aggregate);
1450
+ let cname = command.get("name").and_then(|v| v.as_str()).unwrap_or("");
1451
+ let role = command.get("role").and_then(|v| v.as_str());
1452
+ let goal = command.get("goal").and_then(|v| v.as_str());
1453
+ let givens: String = command
1454
+ .get("givens")
1455
+ .and_then(|v| v.as_array())
1456
+ .map(|gs| gs.iter().filter_map(|g| g.get("description").and_then(|d| d.as_str())).map(|d| format!("<li>{}</li>", esc(d))).collect::<String>())
1457
+ .unwrap_or_default();
1458
+
1459
+ let error_banner = error
1460
+ .map(|e| {
1461
+ let msg = e.get("error").or_else(|| e.get("message")).and_then(|v| v.as_str()).unwrap_or("Refused");
1462
+ format!(r#"<div class="mt-3 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800"><strong>Refused</strong> — {}</div>"#, esc(msg))
1463
+ })
1464
+ .unwrap_or_default();
1465
+
1466
+ let inputs: String = fields.iter().map(|f| render_field(f, values)).collect();
1467
+
1468
+ format!(
1469
+ r#"<h1 class="text-2xl font-bold">{domain_name}::{agg}.{cname}</h1>
1470
+ {}{}
1471
+ {}
1472
+ <form method="post" action="{}" novalidate>{}
1473
+ <div class="mt-6 flex gap-3"><button type="submit" class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-500">{cname}</button>
1474
+ <a href="/{domain_name}/{agg}.html" class="inline-flex items-center rounded-md border border-slate-300 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">Cancel</a></div></form>"#,
1475
+ role.map(|r| format!(r#"<span class="inline-block mt-1 rounded bg-slate-200 px-2 py-0.5 text-xs text-slate-600">role: {}</span>"#, esc(r))).unwrap_or_default(),
1476
+ goal.map(|g| format!(r#"<p class="mt-2 text-slate-600">{}</p>"#, esc(g))).unwrap_or_default(),
1477
+ if givens.is_empty() { String::new() } else { format!(r#"<div class="mt-3 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"><strong>Preconditions</strong> — refused if any fail:<ul class="list-disc list-inside mt-1">{givens}</ul></div>{error_banner}"#) },
1478
+ esc(action), inputs
1479
+ )
1480
+ }
1481
+
1482
+ fn render_field(field: &Field, values: &HashMap<String, String>) -> String {
1483
+ let label = format!(
1484
+ r#"<label class="block text-sm font-medium text-slate-700 mt-4">{}{}</label>"#,
1485
+ esc(&field.label),
1486
+ if field.optional { "" } else { r#" <span class="text-red-500">*</span>"# }
1487
+ );
1488
+ let input_class = "mt-1 block w-full rounded-md border-slate-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm";
1489
+ let current = values.get(&field.path).cloned().unwrap_or_default();
1490
+
1491
+ match &field.kind {
1492
+ // Money renders as the same fieldset a Group does — its two
1493
+ // children (cents/currency) are just another pair of fields
1494
+ // inside it.
1495
+ FieldKind::Group(children) | FieldKind::Money(children) => {
1496
+ let inner: String = children.iter().map(|c| render_field(c, values)).collect();
1497
+ format!(r#"<fieldset class="mt-4 border border-slate-200 rounded-md p-4"><legend class="text-sm font-semibold text-slate-700 px-1">{}</legend>{}</fieldset>"#, esc(&field.label), inner)
1498
+ }
1499
+ FieldKind::List(_) => format!(
1500
+ r#"{label}<textarea name="{}" rows="4" placeholder="one per line" class="{input_class}">{}</textarea>"#,
1501
+ esc(&field.path), esc(&current)
1502
+ ),
1503
+ FieldKind::Textarea => format!(r#"{label}<textarea name="{}" class="{input_class}">{}</textarea>"#, esc(&field.path), esc(&current)),
1504
+ FieldKind::Boolean => format!(
1505
+ r#"<div class="mt-4 flex items-center gap-2"><input type="checkbox" name="{}" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"><label class="text-sm text-slate-700">{}</label></div>"#,
1506
+ esc(&field.path), esc(&field.label)
1507
+ ),
1508
+ FieldKind::Radio(options) => {
1509
+ let opts: String = options
1510
+ .iter()
1511
+ .map(|o| format!(r#"<label class="inline-flex items-center gap-1 text-sm text-slate-700"><input type="radio" name="{}" value="{}" class="text-indigo-600 focus:ring-indigo-500">{}</label>"#, esc(&field.path), esc(o), esc(o)))
1512
+ .collect();
1513
+ format!(r#"{label}<div class="mt-1 flex gap-4">{opts}</div>"#)
1514
+ }
1515
+ FieldKind::Select(options) => {
1516
+ let opts: String = options.iter().map(|o| format!(r#"<option value="{}">{}</option>"#, esc(o), esc(o))).collect();
1517
+ format!(r#"{label}<select name="{}" class="{input_class}">{opts}</select>"#, esc(&field.path))
1518
+ }
1519
+ FieldKind::Number { step } => format!(
1520
+ r#"{label}<input type="number" name="{}" value="{}" step="{step}" class="{input_class}">"#,
1521
+ esc(&field.path), esc(&current)
1522
+ ),
1523
+ FieldKind::Text { html_type } => format!(
1524
+ r#"{label}<input type="{html_type}" name="{}" value="{}" class="{input_class}">"#,
1525
+ esc(&field.path), esc(&current)
1526
+ ),
1527
+ // No reference-picker dropdown: no candidate-fetching scaffolding
1528
+ // exists yet, so this is a plain text id input, like Ruby's own fallback.
1529
+ FieldKind::Reference { .. } => format!(
1530
+ r#"{label}<input type="text" name="{}" value="{}" class="{input_class}">"#,
1531
+ esc(&field.path), esc(&current)
1532
+ ),
1533
+ }
1534
+ }
1535
+
1536
+ pub(crate) fn instances_for(result: &Value, prefix: &str) -> Vec<(String, Value)> {
1537
+ result
1538
+ .get("instances")
1539
+ .and_then(|v| v.as_object())
1540
+ .map(|o| {
1541
+ o.iter()
1542
+ .filter_map(|(k, v)| k.strip_prefix(prefix).map(|id| (id.to_string(), v.clone())))
1543
+ .collect()
1544
+ })
1545
+ .unwrap_or_default()
1546
+ }
1547
+
1548
+ fn with_id(id: &str, state: &Value) -> Value {
1549
+ let mut m = state.clone();
1550
+ if let Some(obj) = m.as_object_mut() {
1551
+ obj.insert("id".to_string(), Value::String(id.to_string()));
1552
+ }
1553
+ m
1554
+ }
1555
+
1556
+ fn split_format(segment: &str) -> (String, String) {
1557
+ match segment.split_once('.') {
1558
+ Some((name, fmt)) => (name.to_string(), fmt.to_string()),
1559
+ None => (segment.to_string(), "json".to_string()),
1560
+ }
1561
+ }
1562
+
1563
+ fn parse_form(text: &str) -> HashMap<String, String> {
1564
+ parse_urlencoded(text, true)
1565
+ }
1566
+
1567
+ fn parse_query(text: &str) -> HashMap<String, String> {
1568
+ parse_urlencoded(text, false)
1569
+ }
1570
+
1571
+ fn parse_urlencoded(text: &str, plus_as_space: bool) -> HashMap<String, String> {
1572
+ text.split('&')
1573
+ .filter(|s| !s.is_empty())
1574
+ .map(|pair| {
1575
+ let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
1576
+ (percent_decode_impl(k, plus_as_space), percent_decode_impl(v, plus_as_space))
1577
+ })
1578
+ .collect()
1579
+ }
1580
+
1581
+ pub(crate) fn percent_decode(s: &str) -> String {
1582
+ percent_decode_impl(s, true)
1583
+ }
1584
+
1585
+ fn percent_decode_impl(s: &str, plus_as_space: bool) -> String {
1586
+ let bytes = s.as_bytes();
1587
+ let mut out = Vec::with_capacity(bytes.len());
1588
+ let mut i = 0;
1589
+ while i < bytes.len() {
1590
+ match bytes[i] {
1591
+ b'+' if plus_as_space => {
1592
+ out.push(b' ');
1593
+ i += 1;
1594
+ }
1595
+ b'%' if i + 2 < bytes.len() => {
1596
+ if let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) {
1597
+ out.push(byte);
1598
+ i += 3;
1599
+ } else {
1600
+ out.push(bytes[i]);
1601
+ i += 1;
1602
+ }
1603
+ }
1604
+ b => {
1605
+ out.push(b);
1606
+ i += 1;
1607
+ }
1608
+ }
1609
+ }
1610
+ String::from_utf8_lossy(&out).into_owned()
1611
+ }
1612
+
1613
+ const B64: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1614
+
1615
+ // pub(crate) so server.rs's own Function URL response handling can
1616
+ // decode the same way, without a second copy of this decoder.
1617
+ pub(crate) fn base64_decode(input: &str) -> Vec<u8> {
1618
+ let mut table = [255u8; 256];
1619
+ for (i, &c) in B64.iter().enumerate() {
1620
+ table[c as usize] = i as u8;
1621
+ }
1622
+ let clean: Vec<u8> = input.bytes().filter(|&b| b != b'=' && !b.is_ascii_whitespace()).collect();
1623
+ let mut out = Vec::with_capacity(clean.len() * 3 / 4);
1624
+ for chunk in clean.chunks(4) {
1625
+ let vals: Vec<u32> = chunk.iter().map(|&b| table[b as usize] as u32).collect();
1626
+ let n = vals.len();
1627
+ let combined = vals.iter().enumerate().fold(0u32, |acc, (i, &v)| acc | (v << (18 - 6 * i)));
1628
+ out.push((combined >> 16) as u8);
1629
+ if n > 2 {
1630
+ out.push((combined >> 8) as u8);
1631
+ }
1632
+ if n > 3 {
1633
+ out.push(combined as u8);
1634
+ }
1635
+ }
1636
+ out
1637
+ }
1638
+
1639
+ fn esc(value: &str) -> String {
1640
+ value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\'', "&#39;")
1641
+ }
1642
+
1643
+ // `id` is user-supplied and sits in a query-string value here, so
1644
+ // `esc()` alone isn't enough — percent-encode via `auth::urlencode`.
1645
+ fn action_link(domain_name: &str, agg: &str, cn: &str, id: &str, class: &str) -> String {
1646
+ format!(r#"<a href="/{domain_name}/{agg}/{}.html?id={}" class="{class}">{}</a>"#, esc(cn), auth::urlencode(id), esc(cn))
1647
+ }
1648
+
1649
+ // `id` sits in a path segment here, not a query value, so `esc()`
1650
+ // (not percent-encoding) is the right guard.
1651
+ fn index_row_html(domain_name: &str, agg: &str, id: &str, actions: &str) -> String {
1652
+ let id = esc(id);
1653
+ format!(
1654
+ r#"<tr><td class="px-3 py-2 font-mono text-xs"><a href="/{domain_name}/{agg}/{id}.html" class="text-indigo-600 hover:underline">{id}</a></td><td class="px-3 py-2">{actions}</td></tr>"#
1655
+ )
1656
+ }
1657
+
1658
+ fn page(title: &str, body: &str) -> String {
1659
+ format!(
1660
+ r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>{}</title><script src="https://cdn.tailwindcss.com"></script></head><body class="bg-slate-50 text-slate-900 min-h-screen"><header class="bg-white border-b border-slate-200"><div class="max-w-3xl mx-auto px-4 py-3"><a href="/" class="font-semibold text-slate-900">rust/host — served straight off the bluebook IR</a></div></header><main class="max-w-3xl mx-auto px-4 py-8">{}</main></body></html>"#,
1661
+ esc(title), body
1662
+ )
1663
+ }
1664
+
1665
+ fn html(status: u16, body: &str) -> Value {
1666
+ respond(status, "text/html; charset=utf-8", body)
1667
+ }
1668
+
1669
+ fn redirect(location: &str) -> Value {
1670
+ json!({"statusCode": 302, "headers": {"location": location}, "body": "", "isBase64Encoded": false})
1671
+ }
1672
+
1673
+ // HttpOnly + SameSite always; Secure only when GOOGLE_REDIRECT_URI is
1674
+ // https — a localhost http:// callback can't set a Secure cookie.
1675
+ fn cookie_flags() -> &'static str {
1676
+ let uri = std::env::var("GOOGLE_REDIRECT_URI").unwrap_or_default();
1677
+ if uri.starts_with("http://") {
1678
+ "; Path=/; HttpOnly; SameSite=Lax"
1679
+ } else {
1680
+ "; Path=/; HttpOnly; Secure; SameSite=Lax"
1681
+ }
1682
+ }
1683
+
1684
+ fn redirect_with_cookie(location: &str, cookie: &str) -> Value {
1685
+ json!({"statusCode": 302, "headers": {"location": location}, "cookies": [cookie], "body": "", "isBase64Encoded": false})
1686
+ }
1687
+
1688
+ pub(crate) fn respond(status: u16, content_type: &str, body: &str) -> Value {
1689
+ json!({"statusCode": status, "headers": {"content-type": content_type}, "body": body, "isBase64Encoded": false})
1690
+ }
1691
+
1692
+ fn respond_with_cookie(status: u16, content_type: &str, body: &str, cookie: &str) -> Value {
1693
+ json!({"statusCode": status, "headers": {"content-type": content_type}, "cookies": [cookie], "body": body, "isBase64Encoded": false})
1694
+ }
1695
+
1696
+ #[cfg(test)]
1697
+ mod tests {
1698
+ use super::*;
1699
+
1700
+ // A record's id is user-supplied; left unescaped, a malicious one
1701
+ // would render as live markup — these tests pin that it can't.
1702
+ const MALICIOUS_ID: &str = r#"x"><script>alert(1)</script>"#;
1703
+
1704
+ // Pins that an unset/empty SESSION_SECRET is refused, not silently
1705
+ // treated as a valid empty-string HMAC key.
1706
+ #[test]
1707
+ fn validate_session_secret_refuses_empty_or_unset() {
1708
+ assert!(validate_session_secret("").is_err());
1709
+ assert!(validate_session_secret("s3cret").is_ok());
1710
+ }
1711
+
1712
+ // Pins the gate's two-answer split: without it, an unauthenticated
1713
+ // JSON request would get a redirect instead of a 401 it can branch on.
1714
+
1715
+ fn status(response: &Value) -> u64 {
1716
+ response.get("statusCode").and_then(|v| v.as_u64()).expect("a response always carries a statusCode")
1717
+ }
1718
+
1719
+ #[test]
1720
+ fn an_unauthenticated_api_request_is_refused_with_the_ruby_engines_own_401_json() {
1721
+ let refusal = auth_gate("/api/clients", false).expect("an unauthenticated /api/ request must be refused");
1722
+
1723
+ assert_eq!(status(&refusal), 401);
1724
+ assert_eq!(refusal["headers"]["content-type"], "application/json");
1725
+ // Byte-for-byte the console app's own web/app.rb
1726
+ // `halt 401, json({ error: "Unauthenticated", message: "sign in first" })`.
1727
+ assert_eq!(refusal["body"], r#"{"error":"Unauthenticated","message":"sign in first"}"#);
1728
+ assert!(refusal["headers"].get("location").is_none(), "a JSON caller must not be redirected: {refusal}");
1729
+ }
1730
+
1731
+ // Ruby keys off the `/api/` prefix alone and doesn't look at a
1732
+ // suffix; so does this, checked before the format-based rule below.
1733
+ #[test]
1734
+ fn an_api_path_is_json_shaped_whatever_suffix_it_carries() {
1735
+ assert!(json_shaped("/api/clients"));
1736
+ assert!(json_shaped("/api/clients.html"));
1737
+ assert!(json_shaped("/api/ui-schema"));
1738
+ }
1739
+
1740
+ // A no-suffix aggregate URL is still a JSON request here (format
1741
+ // lives in the path), so it must be 401'd, never redirected.
1742
+ #[test]
1743
+ fn an_unauthenticated_host_route_is_401_unless_it_asks_for_html() {
1744
+ assert_eq!(status(&auth_gate("/Pizzas/Order.json", false).expect("refused")), 401);
1745
+ assert_eq!(status(&auth_gate("/Pizzas/Order", false).expect("refused")), 401);
1746
+ assert_eq!(status(&auth_gate("/Pizzas/Order/p1.json", false).expect("refused")), 401);
1747
+
1748
+ let page = auth_gate("/Pizzas/Order.html", false).expect("refused");
1749
+ assert_eq!(status(&page), 302);
1750
+ assert_eq!(page["headers"]["location"], "/login");
1751
+
1752
+ let record_page = auth_gate("/Pizzas/Order/p1.html", false).expect("refused");
1753
+ assert_eq!(status(&record_page), 302);
1754
+ assert_eq!(record_page["headers"]["location"], "/login");
1755
+ }
1756
+
1757
+ // The home page and its 404s are HTML, reached by a browser — why
1758
+ // the rule isn't simply "everything that isn't .html".
1759
+ #[test]
1760
+ fn an_unauthenticated_browser_navigation_still_redirects_to_login() {
1761
+ let home = auth_gate("/", false).expect("refused");
1762
+ assert_eq!(status(&home), 302);
1763
+ assert_eq!(home["headers"]["location"], "/login");
1764
+
1765
+ assert_eq!(status(&auth_gate("/favicon.ico", false).expect("refused")), 302);
1766
+ assert_eq!(status(&auth_gate("/Pizzas", false).expect("refused")), 302);
1767
+ }
1768
+
1769
+ #[test]
1770
+ fn the_ungated_paths_are_still_ungated_without_a_session() {
1771
+ for path in UNGATED_PATHS {
1772
+ assert!(auth_gate(path, false).is_none(), "{path} must reach its own handler with no session");
1773
+ }
1774
+ }
1775
+
1776
+ // No regression for a signed-in caller: the gate refuses nothing,
1777
+ // JSON-shaped or not, and the request goes on to ordinary dispatch.
1778
+ #[test]
1779
+ fn an_authenticated_request_passes_the_gate_untouched() {
1780
+ assert!(auth_gate("/api/clients", true).is_none());
1781
+ assert!(auth_gate("/Pizzas/Order", true).is_none());
1782
+ assert!(auth_gate("/Pizzas/Order.html", true).is_none());
1783
+ assert!(auth_gate("/", true).is_none());
1784
+ }
1785
+
1786
+ #[test]
1787
+ fn action_link_escapes_the_link_text_and_percent_encodes_the_query_value() {
1788
+ let link = action_link("Pizzas", "Order", "Purchase", MALICIOUS_ID, "mr-3");
1789
+
1790
+ assert!(!link.contains("<script"), "{link}");
1791
+ assert!(!link.contains(MALICIOUS_ID), "the raw id must not survive into the rendered link: {link}");
1792
+ // Percent-encoded, not HTML-escaped — esc() alone would leave a
1793
+ // raw `&` that terminates the `id=` param early.
1794
+ assert!(link.contains("id=x%22%3E%3Cscript%3Ealert%281%29%3C%2Fscript%3E"), "{link}");
1795
+ }
1796
+
1797
+ #[test]
1798
+ fn action_link_renders_an_ordinary_id_unchanged() {
1799
+ let link = action_link("Pizzas", "Order", "Purchase", "p1", "mr-3");
1800
+ assert_eq!(link, r#"<a href="/Pizzas/Order/Purchase.html?id=p1" class="mr-3">Purchase</a>"#);
1801
+ }
1802
+
1803
+ #[test]
1804
+ fn index_row_html_escapes_the_id_in_both_the_link_text_and_the_path_segment() {
1805
+ let row = index_row_html("Pizzas", "Order", MALICIOUS_ID, "");
1806
+ assert!(!row.contains("<script"), "{row}");
1807
+ assert!(row.contains("&lt;script&gt;"), "{row}");
1808
+ }
1809
+
1810
+ #[test]
1811
+ fn index_row_html_renders_an_ordinary_id_unchanged() {
1812
+ let row = index_row_html("Pizzas", "Order", "p1", "");
1813
+ assert_eq!(
1814
+ row,
1815
+ r#"<tr><td class="px-3 py-2 font-mono text-xs"><a href="/Pizzas/Order/p1.html" class="text-indigo-600 hover:underline">p1</a></td><td class="px-3 py-2"></td></tr>"#
1816
+ );
1817
+ }
1818
+
1819
+ // Fixtures below are trimmed to only the keys `resolve_field`'s own
1820
+ // call graph reads; real corpus shapes (examples/banking, pizzas) confirmed them.
1821
+
1822
+ #[test]
1823
+ fn customer_email_reads_the_pattern_as_email_even_nested_inside_a_value_object() {
1824
+ // Customer.email is EmailAddress{address}; `address`'s own
1825
+ // `pattern:` names "@" — the same case field_shape_spec.rb
1826
+ // tests directly off the live Ruby IR.
1827
+ let email_address_vo = json!({
1828
+ "name": "EmailAddress",
1829
+ "attributes": [{"name": "address", "type": "String", "list": false, "default": null, "optional": false, "pattern": "^[^@ ]+@[^@ ]+\\.[^@ ]+$", "admits": null}],
1830
+ "invariants": [], "closed_set": false, "members": []
1831
+ });
1832
+ let customer = json!({"name": "Customer", "value_objects": [email_address_vo]});
1833
+ let domain_ir = json!({"aggregates": [customer.clone()]});
1834
+ let attribute = json!({"name": "email", "type": "EmailAddress", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
1835
+
1836
+ let field = resolve_field(&domain_ir, &attribute, &customer, "email");
1837
+ assert_eq!(field.path, "email.address");
1838
+ match field.kind {
1839
+ FieldKind::Text { html_type } => assert_eq!(html_type, "email"),
1840
+ other => panic!("expected Text{{html_type: email}}, got {other:?}"),
1841
+ }
1842
+ }
1843
+
1844
+ #[test]
1845
+ fn account_balance_renders_as_money_with_cents_and_currency_children() {
1846
+ let money_vo = json!({
1847
+ "name": "Money",
1848
+ "attributes": [
1849
+ {"name": "cents", "type": "Integer", "list": false, "default": 0, "optional": false, "pattern": null, "admits": null},
1850
+ {"name": "currency", "type": "String", "list": false, "default": "USD", "optional": false, "pattern": null, "admits": null}
1851
+ ],
1852
+ "invariants": [], "closed_set": false, "members": []
1853
+ });
1854
+ let account = json!({"name": "Account", "value_objects": [money_vo]});
1855
+ let domain_ir = json!({"aggregates": [account.clone()]});
1856
+ let attribute = json!({"name": "balance", "type": "Money", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
1857
+
1858
+ let field = resolve_field(&domain_ir, &attribute, &account, "balance");
1859
+ match field.kind {
1860
+ FieldKind::Money(children) => {
1861
+ let paths: Vec<&str> = children.iter().map(|c| c.path.as_str()).collect();
1862
+ assert_eq!(paths, vec!["balance.cents", "balance.currency"]);
1863
+ assert!(!children[0].optional, "cents follows the outer attribute's own optionality");
1864
+ assert!(children[1].optional, "currency is always optional, defaulting to USD, regardless of the outer field");
1865
+ }
1866
+ other => panic!("expected Money, got {other:?}"),
1867
+ }
1868
+ }
1869
+
1870
+ #[test]
1871
+ fn account_open_customer_id_renders_as_a_reference_carrying_its_target() {
1872
+ let account = json!({"name": "Account", "value_objects": []});
1873
+ let domain_ir = json!({"aggregates": [account.clone()]});
1874
+ let attribute = json!({"name": "customer_id", "type": "Reference<Customer>", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
1875
+
1876
+ let field = resolve_field(&domain_ir, &attribute, &account, "customer_id");
1877
+ match field.kind {
1878
+ FieldKind::Reference { target } => assert_eq!(target.as_deref(), Some("Customer")),
1879
+ other => panic!("expected Reference, got {other:?}"),
1880
+ }
1881
+ }
1882
+
1883
+ // **The key regression test** — exercises Tier A's cross-aggregate
1884
+ // lookup and Tier B's textarea hint together, the one combination
1885
+ // that silently breaks if either regresses alone.
1886
+ #[test]
1887
+ fn narrative_used_from_a_different_aggregate_renders_as_a_textarea_via_the_cross_aggregate_lookup() {
1888
+ // Narrative is declared on Account, never CardPayment — scoped
1889
+ // here against CardPayment specifically to prove the fallback
1890
+ // walks the whole domain, not just declaring-aggregate siblings.
1891
+ let narrative_vo = json!({
1892
+ "name": "Narrative",
1893
+ "attributes": [{"name": "text", "type": "String", "list": false, "default": null, "optional": false, "pattern": null, "admits": null}],
1894
+ "invariants": [{"description": "a movement explains itself", "canonical": "!text.to_s.empty?"}],
1895
+ "closed_set": false, "members": []
1896
+ });
1897
+ let account = json!({"name": "Account", "value_objects": [narrative_vo]});
1898
+ let card_payment = json!({
1899
+ "name": "CardPayment",
1900
+ "value_objects": [{"name": "AuthorisationCode", "attributes": [], "invariants": [], "closed_set": false, "members": []}]
1901
+ });
1902
+ let domain_ir = json!({"aggregates": [account, card_payment.clone()]});
1903
+ let attribute = json!({"name": "narrative", "type": "Narrative", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
1904
+
1905
+ let field = resolve_field(&domain_ir, &attribute, &card_payment, "narrative");
1906
+ assert_eq!(field.path, "narrative.text");
1907
+ match field.kind {
1908
+ FieldKind::Textarea => {}
1909
+ other => panic!("expected Textarea, got {other:?}"),
1910
+ }
1911
+ }
1912
+
1913
+ #[test]
1914
+ fn external_transfer_direction_resolves_its_admits_declared_set_across_aggregates() {
1915
+ // The real, live admits: example in the corpus
1916
+ // (examples/banking/bluebook/).
1917
+ let ledger_direction_vo = json!({
1918
+ "name": "LedgerDirection",
1919
+ "attributes": [{"name": "value", "type": "String", "list": false, "default": null, "optional": false, "pattern": null, "admits": null}],
1920
+ "invariants": [], "closed_set": true,
1921
+ "members": [[["value", "credit"]], [["value", "debit"]]]
1922
+ });
1923
+ let account = json!({"name": "Account", "value_objects": [ledger_direction_vo]});
1924
+ let movement_direction_vo = json!({
1925
+ "name": "MovementDirection",
1926
+ "attributes": [{"name": "value", "type": "String", "list": false, "default": null, "optional": false, "pattern": null, "admits": null}],
1927
+ "invariants": [], "closed_set": false, "members": []
1928
+ });
1929
+ let external_transfer = json!({"name": "ExternalTransfer", "value_objects": [movement_direction_vo]});
1930
+ let domain_ir = json!({"aggregates": [account, external_transfer.clone()]});
1931
+ let attribute = json!({"name": "direction", "type": "MovementDirection", "list": false, "default": null, "optional": false, "pattern": null, "admits": "Account::LedgerDirection"});
1932
+
1933
+ let field = resolve_field(&domain_ir, &attribute, &external_transfer, "direction");
1934
+ assert_eq!(field.path, "direction.value");
1935
+ match field.kind {
1936
+ FieldKind::Radio(members) => assert_eq!(members, vec!["credit".to_string(), "debit".to_string()]),
1937
+ other => panic!("expected Radio with exactly 2 members, got {other:?}"),
1938
+ }
1939
+ }
1940
+
1941
+ #[test]
1942
+ fn a_name_containing_email_matches_even_with_no_at_pattern_at_all() {
1943
+ // No `pattern:` at all (synthetic — every real email attribute
1944
+ // also carries one) — proves the name-based match alone is enough.
1945
+ let aggregate = json!({"name": "Whatever", "value_objects": []});
1946
+ let domain_ir = json!({"aggregates": [aggregate.clone()]});
1947
+ let attribute = json!({"name": "contact_email", "type": "String", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
1948
+
1949
+ let field = resolve_field(&domain_ir, &attribute, &aggregate, "contact_email");
1950
+ match field.kind {
1951
+ FieldKind::Text { html_type } => assert_eq!(html_type, "email"),
1952
+ other => panic!("expected Text{{html_type: email}}, got {other:?}"),
1953
+ }
1954
+ }
1955
+
1956
+ #[test]
1957
+ fn synthetic_url_and_tel_named_attributes_resolve_their_html_type_from_the_name_alone() {
1958
+ // No real corpus example of either shape exists; built minimal
1959
+ // here, same as auth.rs's own fixtures with no real counterpart.
1960
+ let aggregate = json!({"name": "Whatever", "value_objects": []});
1961
+ let domain_ir = json!({"aggregates": [aggregate.clone()]});
1962
+
1963
+ let website = json!({"name": "website", "type": "String", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
1964
+ match resolve_field(&domain_ir, &website, &aggregate, "website").kind {
1965
+ FieldKind::Text { html_type } => assert_eq!(html_type, "url"),
1966
+ other => panic!("expected url, got {other:?}"),
1967
+ }
1968
+
1969
+ let phone = json!({"name": "phone_number", "type": "String", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
1970
+ match resolve_field(&domain_ir, &phone, &aggregate, "phone_number").kind {
1971
+ FieldKind::Text { html_type } => assert_eq!(html_type, "tel"),
1972
+ other => panic!("expected tel, got {other:?}"),
1973
+ }
1974
+ }
1975
+
1976
+ #[test]
1977
+ fn a_single_attribute_cents_only_value_object_is_not_money_shaped() {
1978
+ // One field, no currency — must unwrap to a plain scalar, never money.
1979
+ let price_vo = json!({
1980
+ "name": "Price",
1981
+ "attributes": [{"name": "cents", "type": "Integer", "list": false, "default": null, "optional": false, "pattern": null, "admits": null}],
1982
+ "invariants": [{"description": "a price is never negative", "canonical": "cents >= 0"}],
1983
+ "closed_set": false, "members": []
1984
+ });
1985
+ let order = json!({"name": "Order", "value_objects": [price_vo]});
1986
+ let domain_ir = json!({"aggregates": [order.clone()]});
1987
+ let attribute = json!({"name": "price_cents", "type": "Price", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
1988
+
1989
+ match resolve_field(&domain_ir, &attribute, &order, "price_cents").kind {
1990
+ FieldKind::Number { step } => assert_eq!(step, "1"),
1991
+ other => panic!("expected a plain Number (unwrapped, not Money), got {other:?}"),
1992
+ }
1993
+ }
1994
+
1995
+ #[test]
1996
+ fn field_hint_word_boundaries_reject_a_substring_that_is_not_a_whole_word() {
1997
+ // Proves \b in the `regex` crate rejects a substring match
1998
+ // ("blinking" for "link") rather than assuming it does, and
1999
+ // that (?i) case-insensitivity works too.
2000
+ assert!(URL_HINT.is_match("link"));
2001
+ assert!(!URL_HINT.is_match("blinking"));
2002
+ assert!(TEXTAREA_HINT.is_match("text"));
2003
+ assert!(!TEXTAREA_HINT.is_match("context"));
2004
+ assert!(EMAIL_HINT.is_match("EMAIL"));
2005
+ }
2006
+
2007
+ #[test]
2008
+ fn nest_builds_an_ordinary_dotted_path_into_a_nested_object() {
2009
+ let result = nest(vec![("price.cents".to_string(), json!(500)), ("price.currency".to_string(), json!("USD"))]).unwrap();
2010
+ assert_eq!(result, json!({"price": {"cents": 500, "currency": "USD"}}));
2011
+ }
2012
+
2013
+ #[test]
2014
+ fn nest_refuses_rather_than_panics_when_a_scalar_is_inserted_before_a_deeper_path_sharing_its_prefix() {
2015
+ // `price` lands as a scalar first, then `price.cents` tries to
2016
+ // descend into it — the shape that would otherwise panic instead of refusing cleanly.
2017
+ let err = nest(vec![("price".to_string(), json!(500)), ("price.cents".to_string(), json!(500))]).unwrap_err();
2018
+ assert!(err.contains("price"), "{err}");
2019
+ }
2020
+
2021
+ #[test]
2022
+ fn nest_refuses_rather_than_silently_dropping_data_when_the_deeper_path_is_inserted_first() {
2023
+ // The reverse insertion order clobbers a nested object with a
2024
+ // bare scalar instead of panicking — must refuse here too.
2025
+ let err = nest(vec![("price.cents".to_string(), json!(500)), ("price".to_string(), json!(500))]).unwrap_err();
2026
+ assert!(err.contains("price"), "{err}");
2027
+ }
2028
+
2029
+ #[test]
2030
+ fn nest_is_unaffected_by_an_unrelated_sibling_sharing_no_prefix() {
2031
+ let result = nest(vec![("price.cents".to_string(), json!(500)), ("name".to_string(), json!("Widget"))]).unwrap();
2032
+ assert_eq!(result, json!({"price": {"cents": 500}, "name": "Widget"}));
2033
+ }
2034
+
2035
+ #[test]
2036
+ fn parse_query_keeps_a_literal_plus_so_google_oauth_codes_round_trip() {
2037
+ let q = parse_query("code=abc+def%2Fgh&state=1.sig");
2038
+ assert_eq!(q.get("code").map(String::as_str), Some("abc+def/gh"));
2039
+ assert_eq!(q.get("state").map(String::as_str), Some("1.sig"));
2040
+ let form = parse_form("code=abc+def");
2041
+ assert_eq!(form.get("code").map(String::as_str), Some("abc def"), "HTML forms still treat + as space");
2042
+ }
2043
+
2044
+ #[test]
2045
+ fn extract_args_surfaces_a_collision_as_an_error_rather_than_panicking() {
2046
+ let scalar_field = Field { path: "price".to_string(), label: "Price".to_string(), kind: FieldKind::Number { step: "1" }, optional: false };
2047
+ let nested_field = Field {
2048
+ path: "price".to_string(),
2049
+ label: "Price".to_string(),
2050
+ kind: FieldKind::Group(vec![Field { path: "price.cents".to_string(), label: "Cents".to_string(), kind: FieldKind::Number { step: "1" }, optional: false }]),
2051
+ optional: false,
2052
+ };
2053
+ let mut raw = HashMap::new();
2054
+ raw.insert("price".to_string(), "500".to_string());
2055
+ raw.insert("price.cents".to_string(), "500".to_string());
2056
+
2057
+ let result = extract_args(&[scalar_field, nested_field], &raw);
2058
+ assert!(result.is_err(), "expected a collision error, got {result:?}");
2059
+ }
2060
+
2061
+ #[test]
2062
+ fn own_command_target_id_picks_the_forms_own_target_when_no_reaction_fired() {
2063
+ let result = json!({"mutations": [[{"aggregate": "Pizzas::Order", "id": "order-1", "operation": "save"}]]});
2064
+ assert_eq!(own_command_target_id(&result), Some("order-1"));
2065
+ }
2066
+
2067
+ #[test]
2068
+ fn own_command_target_id_picks_the_first_mutation_not_a_cascaded_reactions_last_one() {
2069
+ // The command's own mutation (Order) is pushed first by
2070
+ // `orchestrate`, before any cascaded reaction's mutation lands
2071
+ // on the same step — `.last()` would grab the reaction's id instead.
2072
+ let result = json!({
2073
+ "mutations": [[
2074
+ {"aggregate": "Pizzas::Order", "id": "order-1", "operation": "save"},
2075
+ {"aggregate": "Pizzas::LoyaltyAccount", "id": "loyalty-9", "operation": "save"}
2076
+ ]]
2077
+ });
2078
+ assert_eq!(own_command_target_id(&result), Some("order-1"), "must redirect to the command's OWN aggregate, not the cascaded reaction's");
2079
+ }
2080
+
2081
+ #[test]
2082
+ fn own_command_target_id_only_looks_at_the_last_step_never_prior_replayed_history() {
2083
+ let result = json!({
2084
+ "mutations": [
2085
+ [{"aggregate": "Pizzas::Order", "id": "stale-from-history", "operation": "save"}],
2086
+ [{"aggregate": "Pizzas::Order", "id": "order-2", "operation": "save"}]
2087
+ ]
2088
+ });
2089
+ assert_eq!(own_command_target_id(&result), Some("order-2"));
2090
+ }
2091
+
2092
+ #[test]
2093
+ fn own_command_target_id_is_none_when_mutations_is_missing_or_empty() {
2094
+ assert_eq!(own_command_target_id(&json!({})), None);
2095
+ assert_eq!(own_command_target_id(&json!({"mutations": []})), None);
2096
+ assert_eq!(own_command_target_id(&json!({"mutations": [[]]})), None);
2097
+ }
2098
+
2099
+ // scratch_db provisions a real Postgres schema; only the Stripe hop
2100
+ // in payments.rs's own tests is faked, aimed at a local recording server.
2101
+ use tokio_postgres::NoTls;
2102
+
2103
+ pub(super) async fn scratch_db(name: &str) -> Mutex<Client> {
2104
+ let (admin, conn) = tokio_postgres::connect(&crate::test_pg::conninfo("postgres"), NoTls).await.expect("connect to postgres");
2105
+ tokio::spawn(async move {
2106
+ let _ = conn.await;
2107
+ });
2108
+ admin.batch_execute(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)")).await.unwrap();
2109
+ admin.batch_execute(&format!("CREATE DATABASE {name}")).await.unwrap();
2110
+
2111
+ let (client, conn) = tokio_postgres::connect(&crate::test_pg::conninfo(&name), NoTls).await.expect("connect to scratch db");
2112
+ tokio::spawn(async move {
2113
+ let _ = conn.await;
2114
+ });
2115
+ crate::journal::ensure_schema(&client).await.unwrap();
2116
+ Mutex::new(client)
2117
+ }
2118
+
2119
+ // Duplicates dispatch.rs's own provision_lineage shape rather than
2120
+ // sharing it, matching this crate's existing per-module test fixtures.
2121
+ pub(super) async fn provision_lineage(client: &Client, domain: &str, era: i32, aggregate_storage_names: &[&str]) {
2122
+ client.batch_execute("CREATE TABLE IF NOT EXISTS hecks_eras (domain text, ordinal int, held_text text)").await.unwrap();
2123
+ client
2124
+ .execute("INSERT INTO hecks_eras (domain, ordinal, held_text) VALUES ($1, $2, 'test')", &[&domain, &era])
2125
+ .await
2126
+ .unwrap();
2127
+
2128
+ let journal_table = format!("hecks_journal_{}", crate::journal::snake(domain));
2129
+ client
2130
+ .batch_execute(&format!(
2131
+ "CREATE TABLE IF NOT EXISTS \"{journal_table}\" (
2132
+ ordinal bigserial PRIMARY KEY,
2133
+ era int NOT NULL,
2134
+ aggregate text NOT NULL,
2135
+ aggregate_id text NOT NULL,
2136
+ operation text NOT NULL,
2137
+ state jsonb
2138
+ )"
2139
+ ))
2140
+ .await
2141
+ .unwrap();
2142
+
2143
+ for name in aggregate_storage_names {
2144
+ // domain-qualified (docs/decisions/0059) — matches what a real
2145
+ // `journal::append_lineage_mutation` write actually targets now.
2146
+ let snapshot_table = crate::journal::qualified_name(domain, &format!("{}_head_snapshot_{era}", crate::journal::snake(name)));
2147
+ client
2148
+ .batch_execute(&format!("CREATE TABLE IF NOT EXISTS \"{snapshot_table}\" (id text PRIMARY KEY, ordinal bigint NOT NULL, state jsonb NOT NULL)"))
2149
+ .await
2150
+ .unwrap();
2151
+ }
2152
+ }
2153
+
2154
+ #[tokio::test]
2155
+ async fn accounts_me_route_reads_a_real_cookie_and_refuses_a_missing_or_invalid_one() {
2156
+ let secret = "s3cret";
2157
+ let (client, domain_ir) = scratch_members_db("hecks_host_web_test_accounts_me").await;
2158
+
2159
+ let cookies = session_cookies(secret, "zed@example.com");
2160
+ let response = accounts_me_route(&domain_ir, &cookies, secret, &client).await;
2161
+ assert_eq!(response["statusCode"], 200, "{response:?}");
2162
+ let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
2163
+ assert_eq!(body["email"], "zed@example.com");
2164
+
2165
+ let empty = HashMap::new();
2166
+ assert_eq!(accounts_me_route(&domain_ir, &empty, secret, &client).await["statusCode"], 401);
2167
+
2168
+ let mut tampered = HashMap::new();
2169
+ tampered.insert(auth::account_cookie_name(), "garbage.notasignature".to_string());
2170
+ assert_eq!(accounts_me_route(&domain_ir, &tampered, secret, &client).await["statusCode"], 401);
2171
+
2172
+ // A validly signed token for someone with no granted role, or nobody
2173
+ // admitted at all, is no session.
2174
+ for email in ["amy@example.com", "stranger@example.com"] {
2175
+ let response = accounts_me_route(&domain_ir, &session_cookies(secret, email), secret, &client).await;
2176
+ assert_eq!(response["statusCode"], 401, "{email}: {response:?}");
2177
+ }
2178
+ }
2179
+
2180
+ #[tokio::test]
2181
+ async fn accounts_sso_token_route_mints_a_short_lived_token_verifiable_by_the_same_secret() {
2182
+ let secret = "s3cret";
2183
+ let (client, domain_ir) = scratch_members_db("hecks_host_web_test_accounts_sso_token").await;
2184
+
2185
+ let mut cookies = HashMap::new();
2186
+ cookies.insert(auth::account_cookie_name(), auth::account_token(secret, "zed@example.com", 60 * 60 * 24 * 14));
2187
+ let response = accounts_sso_token_route(&domain_ir, &cookies, secret, &client).await;
2188
+ assert_eq!(response["statusCode"], 200, "{response:?}");
2189
+ let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
2190
+ let sso_token = body["token"].as_str().expect("a sso token string");
2191
+
2192
+ // The point: cms/src/endpoints/sso.ts verifies this exact wire
2193
+ // format — a real, decodable token, not just a 200 with any string.
2194
+ assert_eq!(auth::verify_account_token(secret, sso_token).as_deref(), Some("zed@example.com"));
2195
+
2196
+ // Missing or invalid session cookie -- refused, no token minted.
2197
+ let empty = HashMap::new();
2198
+ assert_eq!(accounts_sso_token_route(&domain_ir, &empty, secret, &client).await["statusCode"], 401);
2199
+
2200
+ let mut tampered = HashMap::new();
2201
+ tampered.insert(auth::account_cookie_name(), "garbage.notasignature".to_string());
2202
+ assert_eq!(accounts_sso_token_route(&domain_ir, &tampered, secret, &client).await["statusCode"], 401);
2203
+
2204
+ let no_role = accounts_sso_token_route(&domain_ir, &session_cookies(secret, "amy@example.com"), secret, &client).await;
2205
+ assert_eq!(no_role["statusCode"], 401, "{no_role:?}");
2206
+ }
2207
+
2208
+ // Same head-view shape auth.rs's own tests build for the Membership
2209
+ // aggregate, seeded with one granted, linked admin and one admitted
2210
+ // person with no access yet.
2211
+ async fn scratch_members_db(name: &str) -> (Mutex<Client>, Value) {
2212
+ let client = scratch_db(name).await;
2213
+ {
2214
+ let guard = client.lock().await;
2215
+ guard
2216
+ .batch_execute(
2217
+ "CREATE TABLE acme_member_head_snapshot_1 (id text PRIMARY KEY, ordinal bigint NOT NULL, state jsonb NOT NULL);
2218
+ CREATE VIEW acme_member_head AS SELECT id, state FROM acme_member_head_snapshot_1;
2219
+ CREATE TABLE hecks_journal_acme (
2220
+ ordinal bigserial PRIMARY KEY, era int NOT NULL, aggregate text NOT NULL,
2221
+ aggregate_id text NOT NULL, operation text NOT NULL, state jsonb, mirrors jsonb
2222
+ );",
2223
+ )
2224
+ .await
2225
+ .unwrap();
2226
+ guard
2227
+ .execute(
2228
+ "INSERT INTO acme_member_head_snapshot_1 (id, ordinal, state) VALUES ($1, 0, $2::jsonb), ($3, 0, $4::jsonb)",
2229
+ &[
2230
+ &"zed@example.com",
2231
+ &json!({"name": {"value": "Zed"}, "email": {"value": "zed@example.com"},
2232
+ "role": {"value": "Admin"}, "identity_id": {"value": "id-1"}}),
2233
+ &"amy@example.com",
2234
+ &json!({"name": {"value": "amy"}, "email": {"value": "amy@example.com"},
2235
+ "role": null, "identity_id": null}),
2236
+ ],
2237
+ )
2238
+ .await
2239
+ .unwrap();
2240
+ }
2241
+ let domain_ir = json!({
2242
+ "name": "Acme",
2243
+ "lineage": {"capable_aggregates": [{"name": "Member", "storage_name": "member"}]},
2244
+ "membership": {"provider": "Acme", "aggregate": "Acme::Member"},
2245
+ });
2246
+ (client, domain_ir)
2247
+ }
2248
+
2249
+ #[tokio::test]
2250
+ async fn members_route_lists_admitted_people_as_json_sorted_by_name_for_a_valid_session() {
2251
+ let secret = "s3cret";
2252
+ let (client, domain_ir) = scratch_members_db("hecks_host_web_test_members_route").await;
2253
+
2254
+ let mut cookies = HashMap::new();
2255
+ cookies.insert(auth::account_cookie_name(), auth::account_token(secret, "zed@example.com", 60));
2256
+ let response = members_route(&domain_ir, &cookies, secret, &client).await;
2257
+ assert_eq!(response["statusCode"], 200, "{response:?}");
2258
+
2259
+ let people: Vec<Value> = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
2260
+ let names: Vec<&str> = people.iter().map(|p| p["name"].as_str().unwrap()).collect();
2261
+ assert_eq!(names, ["amy", "Zed"], "sorted case-insensitively by name");
2262
+ assert_eq!(
2263
+ people[1],
2264
+ json!({"name": "Zed", "email": "zed@example.com", "role": "Admin", "linked": true, "granted": true, "disabled": false})
2265
+ );
2266
+ assert_eq!(
2267
+ people[0],
2268
+ json!({"name": "amy", "email": "amy@example.com", "role": null, "linked": false, "granted": false, "disabled": false})
2269
+ );
2270
+ }
2271
+
2272
+ #[tokio::test]
2273
+ async fn members_route_refuses_a_missing_or_invalid_session_with_a_json_401_and_no_people() {
2274
+ let secret = "s3cret";
2275
+ let (client, domain_ir) = scratch_members_db("hecks_host_web_test_members_route_401").await;
2276
+
2277
+ let mut tampered = HashMap::new();
2278
+ tampered.insert(auth::account_cookie_name(), "garbage.notasignature".to_string());
2279
+ let mut wrong_secret = HashMap::new();
2280
+ wrong_secret.insert(auth::account_cookie_name(), auth::account_token("another", "zed@example.com", 60));
2281
+ let mut governance_only = HashMap::new();
2282
+ governance_only.insert("session".to_string(), "anything".to_string());
2283
+
2284
+ for cookies in [HashMap::new(), tampered, wrong_secret, governance_only] {
2285
+ let response = members_route(&domain_ir, &cookies, secret, &client).await;
2286
+ assert_eq!(response["statusCode"], 401, "{response:?}");
2287
+ assert!(response.get("headers").and_then(|h| h.get("location")).is_none(), "never a redirect: {response:?}");
2288
+ let body = response["body"].as_str().unwrap();
2289
+ for leaked in ["zed@example.com", "amy@example.com", "Zed", "Admin"] {
2290
+ assert!(!body.contains(leaked), "{leaked:?} leaked to an unauthenticated caller: {body}");
2291
+ }
2292
+ }
2293
+ }
2294
+
2295
+ fn session_cookies(secret: &str, email: &str) -> HashMap<String, String> {
2296
+ let mut cookies = HashMap::new();
2297
+ cookies.insert(auth::account_cookie_name(), auth::account_token(secret, email, 60));
2298
+ cookies
2299
+ }
2300
+
2301
+ fn members_config() -> LineageConfig {
2302
+ LineageConfig { domain: "Acme".to_string(), era: Some(1), mirrored: None }
2303
+ }
2304
+
2305
+ #[tokio::test]
2306
+ async fn signup_route_refuses_anything_but_a_valid_signup_token_with_a_json_401_and_writes_nothing() {
2307
+ let secret = "s3cret";
2308
+ let (client, domain_ir) = scratch_members_db("hecks_host_web_test_signup_401").await;
2309
+ let body = |token: &str| json!({"token": token, "email": "new@example.com", "name": "New Person"}).to_string();
2310
+
2311
+ // A session token and a token minted for another purpose both verify
2312
+ // as tokens under some key, but never as a signup token.
2313
+ let session_token = auth::account_token(secret, "zed@example.com", 60);
2314
+ let other_purpose = auth::purpose_token(secret, "another_purpose", json!({}), 60);
2315
+ let wrong_secret = auth::purpose_token("another", SIGNUP_PURPOSE, json!({}), 60);
2316
+
2317
+ for token in ["", "garbage.notasignature", session_token.as_str(), other_purpose.as_str(), wrong_secret.as_str()] {
2318
+ let response = signup_route(&body(token), secret, &client, Path::new("unused"), &members_config(), &crate::lambda_client::NeverInvoker).await;
2319
+ assert_eq!(response["statusCode"], 401, "{response:?}");
2320
+ assert!(response.get("headers").and_then(|h| h.get("location")).is_none(), "never a redirect: {response:?}");
2321
+ }
2322
+ let unparseable = signup_route("not json", secret, &client, Path::new("unused"), &members_config(), &crate::lambda_client::NeverInvoker).await;
2323
+ assert_eq!(unparseable["statusCode"], 401, "{unparseable:?}");
2324
+ assert_eq!(auth::all_people(&client, &domain_ir).await.unwrap().len(), 2, "nothing was written");
2325
+ }
2326
+
2327
+ #[tokio::test]
2328
+ async fn signup_route_rejects_a_blank_or_malformed_name_or_email_with_a_400() {
2329
+ let secret = "s3cret";
2330
+ let (client, _) = scratch_members_db("hecks_host_web_test_signup_400").await;
2331
+ let token = auth::purpose_token(secret, SIGNUP_PURPOSE, json!({}), 60);
2332
+
2333
+ for (email, name) in [("", "Ada"), ("no-at-sign", "Ada"), ("a@nodot", "Ada"), ("has space@x.io", "Ada"), ("ada@x.io", ""), ("ada@x.io", " ")] {
2334
+ let body = json!({"token": token, "email": email, "name": name}).to_string();
2335
+ let response = signup_route(&body, secret, &client, Path::new("unused"), &members_config(), &crate::lambda_client::NeverInvoker).await;
2336
+ assert_eq!(response["statusCode"], 400, "{email:?} / {name:?}: {response:?}");
2337
+ }
2338
+ }
2339
+
2340
+ #[tokio::test]
2341
+ async fn add_member_route_refuses_a_missing_or_invalid_session_with_a_json_401_and_writes_nothing() {
2342
+ let secret = "s3cret";
2343
+ let (client, domain_ir) = scratch_members_db("hecks_host_web_test_add_member_401").await;
2344
+ let body = r#"{"email": "new@example.com", "name": "New Person"}"#;
2345
+
2346
+ let mut tampered = HashMap::new();
2347
+ tampered.insert(auth::account_cookie_name(), "garbage.notasignature".to_string());
2348
+ let mut wrong_secret = HashMap::new();
2349
+ wrong_secret.insert(auth::account_cookie_name(), auth::account_token("another", "zed@example.com", 60));
2350
+
2351
+ for cookies in [HashMap::new(), tampered, wrong_secret] {
2352
+ let response = add_member_route(&domain_ir, body, &cookies, secret, &client, Path::new("unused"), &members_config()).await;
2353
+ assert_eq!(response["statusCode"], 401, "{response:?}");
2354
+ assert!(response.get("headers").and_then(|h| h.get("location")).is_none(), "never a redirect: {response:?}");
2355
+ let text = response["body"].as_str().unwrap();
2356
+ for leaked in ["zed@example.com", "amy@example.com", "Zed", "Admin"] {
2357
+ assert!(!text.contains(leaked), "{leaked:?} leaked to an unauthenticated caller: {text}");
2358
+ }
2359
+ }
2360
+ assert_eq!(auth::all_people(&client, &domain_ir).await.unwrap().len(), 2, "nothing was written");
2361
+ }
2362
+
2363
+ #[tokio::test]
2364
+ async fn add_member_route_refuses_a_caller_who_is_not_a_granted_admin_with_a_403() {
2365
+ let secret = "s3cret";
2366
+ let (client, domain_ir) = scratch_members_db("hecks_host_web_test_add_member_403").await;
2367
+ let body = r#"{"email": "new@example.com", "name": "New Person"}"#;
2368
+
2369
+ // amy is admitted but has no role; a stranger is not admitted at all.
2370
+ for caller in ["amy@example.com", "stranger@example.com"] {
2371
+ let cookies = session_cookies(secret, caller);
2372
+ let response = add_member_route(&domain_ir, body, &cookies, secret, &client, Path::new("unused"), &members_config()).await;
2373
+ assert_eq!(response["statusCode"], 403, "{caller}: {response:?}");
2374
+ }
2375
+ assert_eq!(auth::all_people(&client, &domain_ir).await.unwrap().len(), 2, "nothing was written");
2376
+ }
2377
+
2378
+ #[tokio::test]
2379
+ async fn add_member_route_rejects_a_blank_or_malformed_email_or_name_with_a_400() {
2380
+ let secret = "s3cret";
2381
+ let (client, domain_ir) = scratch_members_db("hecks_host_web_test_add_member_400").await;
2382
+ let cookies = session_cookies(secret, "zed@example.com");
2383
+
2384
+ for body in [
2385
+ "",
2386
+ "not json",
2387
+ "{}",
2388
+ r#"{"email": "new@example.com"}"#,
2389
+ r#"{"name": "New Person"}"#,
2390
+ r#"{"email": " ", "name": "New Person"}"#,
2391
+ r#"{"email": "new@example.com", "name": " "}"#,
2392
+ r#"{"email": "no-at-sign", "name": "New Person"}"#,
2393
+ r#"{"email": "@example.com", "name": "New Person"}"#,
2394
+ r#"{"email": "new@nodot", "name": "New Person"}"#,
2395
+ r#"{"email": "two words@example.com", "name": "New Person"}"#,
2396
+ r#"{"email": 42, "name": "New Person"}"#,
2397
+ ] {
2398
+ let response = add_member_route(&domain_ir, body, &cookies, secret, &client, Path::new("unused"), &members_config()).await;
2399
+ assert_eq!(response["statusCode"], 400, "{body:?}: {response:?}");
2400
+ }
2401
+ assert_eq!(auth::all_people(&client, &domain_ir).await.unwrap().len(), 2, "nothing was written");
2402
+ }
2403
+
2404
+ #[tokio::test]
2405
+ async fn add_member_route_answers_409_for_an_email_that_is_already_admitted_in_any_case() {
2406
+ let secret = "s3cret";
2407
+ let (client, domain_ir) = scratch_members_db("hecks_host_web_test_add_member_409").await;
2408
+ let cookies = session_cookies(secret, "zed@example.com");
2409
+
2410
+ for email in ["amy@example.com", "AMY@Example.com"] {
2411
+ let body = json!({"email": email, "name": "Another Amy"}).to_string();
2412
+ let response = add_member_route(&domain_ir, &body, &cookies, secret, &client, Path::new("unused"), &members_config()).await;
2413
+ assert_eq!(response["statusCode"], 409, "{email}: {response:?}");
2414
+ }
2415
+ assert_eq!(auth::all_people(&client, &domain_ir).await.unwrap().len(), 2, "nothing was written");
2416
+ }
2417
+
2418
+ #[tokio::test]
2419
+ async fn add_member_route_admits_and_grants_admin_then_lists_the_person_as_granted() {
2420
+ let secret = "s3cret";
2421
+ let (client, domain_ir) = scratch_members_db("hecks_host_web_test_add_member_201").await;
2422
+ let cookies = session_cookies(secret, "Zed@Example.com");
2423
+
2424
+ let body = r#"{"email": " New@Example.com ", "name": " New Person "}"#;
2425
+ let response = add_member_route(&domain_ir, body, &cookies, secret, &client, Path::new("unused"), &members_config()).await;
2426
+ assert_eq!(response["statusCode"], 201, "{response:?}");
2427
+ let created: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
2428
+ assert_eq!(
2429
+ created,
2430
+ json!({"name": "New Person", "email": "new@example.com", "role": "Admin", "linked": false, "granted": true, "disabled": false})
2431
+ );
2432
+
2433
+ let listing = members_route(&domain_ir, &cookies, secret, &client).await;
2434
+ let people: Vec<Value> = serde_json::from_str(listing["body"].as_str().unwrap()).unwrap();
2435
+ assert_eq!(people.len(), 3);
2436
+ let added = people.iter().find(|p| p["email"] == "new@example.com").expect("the new person is listed");
2437
+ assert_eq!(*added, created);
2438
+
2439
+ let guard = client.lock().await;
2440
+ let journalled: i64 = guard
2441
+ .query_one("SELECT count(*) FROM hecks_journal_acme WHERE aggregate_id = 'new@example.com'", &[])
2442
+ .await
2443
+ .unwrap()
2444
+ .get(0);
2445
+ assert_eq!(journalled, 2, "one journal row for Admit and one for GrantAccess");
2446
+ }
2447
+
2448
+ // The scratch members database with amy also granted Admin, so two
2449
+ // active admins exist and one can act on the other.
2450
+ async fn scratch_two_admins(name: &str) -> (Mutex<Client>, Value) {
2451
+ let (client, domain_ir) = scratch_members_db(name).await;
2452
+ let granted = auth::grant_access(&client, Path::new("unused"), &members_config(), &domain_ir, "amy@example.com", "Admin").await.unwrap();
2453
+ assert!(granted);
2454
+ (client, domain_ir)
2455
+ }
2456
+
2457
+ async fn switch_access(client: &Mutex<Client>, domain_ir: &Value, caller: &str, email: &str, disable: bool) -> Value {
2458
+ let secret = "s3cret";
2459
+ let body = json!({"email": email}).to_string();
2460
+ set_member_disabled_route(domain_ir, &body, &session_cookies(secret, caller), secret, client, &members_config(), disable).await
2461
+ }
2462
+
2463
+ fn error_of(response: &Value) -> String {
2464
+ let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
2465
+ body["error"].as_str().unwrap_or_default().to_string()
2466
+ }
2467
+
2468
+ async fn person(client: &Mutex<Client>, domain_ir: &Value, email: &str) -> Value {
2469
+ let people = auth::all_people(client, domain_ir).await.unwrap();
2470
+ people.into_iter().find(|p| p["email"] == email).unwrap_or_else(|| panic!("{email} is not listed"))
2471
+ }
2472
+
2473
+ async fn journal_rows(client: &Mutex<Client>, id: &str) -> i64 {
2474
+ let guard = client.lock().await;
2475
+ guard
2476
+ .query_one("SELECT count(*) FROM hecks_journal_acme WHERE aggregate_id = $1", &[&id])
2477
+ .await
2478
+ .unwrap()
2479
+ .get(0)
2480
+ }
2481
+
2482
+ #[tokio::test]
2483
+ async fn disable_and_enable_routes_refuse_a_missing_or_invalid_session_and_a_blank_email() {
2484
+ let secret = "s3cret";
2485
+ let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_disable_401").await;
2486
+
2487
+ let mut tampered = HashMap::new();
2488
+ tampered.insert(auth::account_cookie_name(), "garbage.notasignature".to_string());
2489
+ let body = json!({"email": "amy@example.com"}).to_string();
2490
+ for disable in [true, false] {
2491
+ for cookies in [HashMap::new(), tampered.clone()] {
2492
+ let response = set_member_disabled_route(&domain_ir, &body, &cookies, secret, &client, &members_config(), disable).await;
2493
+ assert_eq!(response["statusCode"], 401, "{response:?}");
2494
+ assert!(response.get("headers").and_then(|h| h.get("location")).is_none(), "never a redirect: {response:?}");
2495
+ }
2496
+ for blank in ["", " "] {
2497
+ let response = switch_access(&client, &domain_ir, "zed@example.com", blank, disable).await;
2498
+ assert_eq!(response["statusCode"], 400, "{blank:?}: {response:?}");
2499
+ }
2500
+ }
2501
+ assert_eq!(person(&client, &domain_ir, "amy@example.com").await["disabled"], false, "nothing was written");
2502
+ }
2503
+
2504
+ #[tokio::test]
2505
+ async fn disable_and_enable_routes_refuse_a_caller_who_is_not_an_active_admin_with_a_403() {
2506
+ let (client, domain_ir) = scratch_members_db("hecks_host_web_test_disable_403").await;
2507
+
2508
+ // amy is admitted with no role; the stranger was never admitted.
2509
+ for caller in ["amy@example.com", "stranger@example.com"] {
2510
+ for disable in [true, false] {
2511
+ let response = switch_access(&client, &domain_ir, caller, "zed@example.com", disable).await;
2512
+ assert_eq!(response["statusCode"], 403, "{caller}: {response:?}");
2513
+ assert_eq!(error_of(&response), "admins only");
2514
+ }
2515
+ }
2516
+ assert_eq!(person(&client, &domain_ir, "zed@example.com").await["granted"], true, "nothing was written");
2517
+ }
2518
+
2519
+ #[tokio::test]
2520
+ async fn disable_route_refuses_an_admin_disabling_themselves_in_any_case_and_writes_nothing() {
2521
+ let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_disable_self").await;
2522
+ let before = journal_rows(&client, "zed@example.com").await;
2523
+
2524
+ for typed in ["zed@example.com", " ZED@Example.com "] {
2525
+ let response = switch_access(&client, &domain_ir, "zed@example.com", typed, true).await;
2526
+ assert_eq!(response["statusCode"], 403, "{typed:?}: {response:?}");
2527
+ assert_eq!(error_of(&response), "you can't disable your own admin access");
2528
+ }
2529
+ assert_eq!(person(&client, &domain_ir, "zed@example.com").await["granted"], true);
2530
+ assert_eq!(journal_rows(&client, "zed@example.com").await, before);
2531
+ }
2532
+
2533
+ #[tokio::test]
2534
+ async fn disable_and_enable_routes_answer_404_for_an_unknown_email() {
2535
+ let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_disable_404").await;
2536
+ for disable in [true, false] {
2537
+ let response = switch_access(&client, &domain_ir, "zed@example.com", "nobody@example.com", disable).await;
2538
+ assert_eq!(response["statusCode"], 404, "{response:?}");
2539
+ }
2540
+ }
2541
+
2542
+ async fn set_role(client: &Mutex<Client>, domain_ir: &Value, caller: &str, email: &str, role: &str) -> Value {
2543
+ let secret = "s3cret";
2544
+ let body = json!({"email": email, "role": role}).to_string();
2545
+ set_member_role_route(domain_ir, &body, &session_cookies(secret, caller), secret, client, &members_config()).await
2546
+ }
2547
+
2548
+ #[tokio::test]
2549
+ async fn role_route_refuses_a_missing_session_a_blank_email_and_an_unknown_role_and_writes_nothing() {
2550
+ let secret = "s3cret";
2551
+ let (client, domain_ir) = scratch_members_db("hecks_host_web_test_role_400").await;
2552
+ let before = journal_rows(&client, "amy@example.com").await;
2553
+
2554
+ let mut tampered = HashMap::new();
2555
+ tampered.insert(auth::account_cookie_name(), "garbage.notasignature".to_string());
2556
+ let body = json!({"email": "amy@example.com", "role": "Owner"}).to_string();
2557
+ for cookies in [HashMap::new(), tampered] {
2558
+ let response = set_member_role_route(&domain_ir, &body, &cookies, secret, &client, &members_config()).await;
2559
+ assert_eq!(response["statusCode"], 401, "{response:?}");
2560
+ assert!(response.get("headers").and_then(|h| h.get("location")).is_none(), "never a redirect: {response:?}");
2561
+ }
2562
+ for blank in ["", " "] {
2563
+ let response = set_role(&client, &domain_ir, "zed@example.com", blank, "Owner").await;
2564
+ assert_eq!(response["statusCode"], 400, "{blank:?}: {response:?}");
2565
+ }
2566
+ for role in ["", "Root", "owner", "Owner Admin"] {
2567
+ let response = set_role(&client, &domain_ir, "zed@example.com", "amy@example.com", role).await;
2568
+ assert_eq!(response["statusCode"], 400, "{role:?}: {response:?}");
2569
+ assert_eq!(error_of(&response), "role must be one of Admin, Owner, Member");
2570
+ }
2571
+ assert_eq!(person(&client, &domain_ir, "amy@example.com").await["role"], Value::Null, "nothing was written");
2572
+ assert_eq!(journal_rows(&client, "amy@example.com").await, before);
2573
+ }
2574
+
2575
+ #[tokio::test]
2576
+ async fn role_route_refuses_a_caller_who_is_not_an_active_admin_with_a_403_and_answers_404_for_an_unknown_email() {
2577
+ let (client, domain_ir) = scratch_members_db("hecks_host_web_test_role_403").await;
2578
+
2579
+ // amy is admitted with no role; the stranger was never admitted.
2580
+ for caller in ["amy@example.com", "stranger@example.com"] {
2581
+ let response = set_role(&client, &domain_ir, caller, "amy@example.com", "Owner").await;
2582
+ assert_eq!(response["statusCode"], 403, "{caller}: {response:?}");
2583
+ assert_eq!(error_of(&response), "admins only");
2584
+ }
2585
+
2586
+ // A plain Member cannot promote themselves either.
2587
+ assert!(auth::grant_access(&client, Path::new("unused"), &members_config(), &domain_ir, "amy@example.com", "Member").await.unwrap());
2588
+ let response = set_role(&client, &domain_ir, "amy@example.com", "amy@example.com", "Owner").await;
2589
+ assert_eq!(response["statusCode"], 403, "{response:?}");
2590
+ assert_eq!(person(&client, &domain_ir, "amy@example.com").await["role"], "Member", "nothing was written");
2591
+
2592
+ let response = set_role(&client, &domain_ir, "zed@example.com", "nobody@example.com", "Owner").await;
2593
+ assert_eq!(response["statusCode"], 404, "{response:?}");
2594
+ }
2595
+
2596
+ #[tokio::test]
2597
+ async fn role_route_lets_an_admin_grant_owner_to_an_already_admitted_person_and_repeating_it_writes_nothing() {
2598
+ let (client, domain_ir) = scratch_members_db("hecks_host_web_test_role_grant").await;
2599
+ let status = |response: &Value| response["statusCode"].as_i64().unwrap();
2600
+
2601
+ // amy is admitted but holds no role, so she is not an admin yet.
2602
+ assert!(!auth::caller_is_admin(&client, &domain_ir, "amy@example.com").await.unwrap());
2603
+
2604
+ // Case and surrounding spaces in the email do not matter.
2605
+ let response = set_role(&client, &domain_ir, "Zed@Example.com", " Amy@Example.com ", "Owner").await;
2606
+ assert_eq!(response["statusCode"], 200, "{response:?}");
2607
+ let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
2608
+ assert_eq!(body, json!({"email": "amy@example.com", "role": "Owner"}));
2609
+
2610
+ // She now passes the admin gate and the Owner check payments makes.
2611
+ assert!(auth::caller_is_admin(&client, &domain_ir, "amy@example.com").await.unwrap());
2612
+ assert_eq!(auth::active_role(&client, &domain_ir, "amy@example.com").await.unwrap().as_deref(), Some("Owner"));
2613
+ let amy = person(&client, &domain_ir, "amy@example.com").await;
2614
+ assert_eq!((amy["role"].as_str(), amy["granted"].clone()), (Some("Owner"), json!(true)));
2615
+
2616
+ // The new Owner can act as an admin, including granting Owner in turn.
2617
+ let response = set_role(&client, &domain_ir, "amy@example.com", "zed@example.com", "Owner").await;
2618
+ assert_eq!(status(&response), 200, "{response:?}");
2619
+ assert_eq!(auth::active_role(&client, &domain_ir, "zed@example.com").await.unwrap().as_deref(), Some("Owner"));
2620
+
2621
+ // Asking for the role someone already holds is a 200 and writes nothing.
2622
+ let rows = journal_rows(&client, "amy@example.com").await;
2623
+ assert_eq!(status(&set_role(&client, &domain_ir, "zed@example.com", "amy@example.com", "Owner").await), 200);
2624
+ assert_eq!(journal_rows(&client, "amy@example.com").await, rows);
2625
+ }
2626
+
2627
+ #[tokio::test]
2628
+ async fn role_route_keeps_the_identity_link_and_the_disabled_flag_when_it_changes_a_role() {
2629
+ let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_role_keeps").await;
2630
+
2631
+ // zed is linked (id-1 in the seed); amy is disabled while holding Admin.
2632
+ assert_eq!(switch_access(&client, &domain_ir, "zed@example.com", "amy@example.com", true).await["statusCode"], 200);
2633
+ assert_eq!(set_role(&client, &domain_ir, "zed@example.com", "amy@example.com", "Owner").await["statusCode"], 200);
2634
+ assert_eq!(set_role(&client, &domain_ir, "zed@example.com", "zed@example.com", "Owner").await["statusCode"], 200);
2635
+
2636
+ let amy = person(&client, &domain_ir, "amy@example.com").await;
2637
+ assert_eq!((amy["role"].as_str(), amy["disabled"].clone(), amy["granted"].clone()), (Some("Owner"), json!(true), json!(false)));
2638
+ let zed = person(&client, &domain_ir, "zed@example.com").await;
2639
+ assert_eq!((zed["role"].as_str(), zed["linked"].clone(), zed["name"].as_str()), (Some("Owner"), json!(true), Some("Zed")));
2640
+ assert!(auth::session_for_member_by_identity(&client, &domain_ir, "id-1").await.unwrap().is_some(), "zed can still sign in");
2641
+
2642
+ // Enabling amy restores her, now as an Owner.
2643
+ assert_eq!(switch_access(&client, &domain_ir, "zed@example.com", "amy@example.com", false).await["statusCode"], 200);
2644
+ assert_eq!(auth::active_role(&client, &domain_ir, "amy@example.com").await.unwrap().as_deref(), Some("Owner"));
2645
+ }
2646
+
2647
+ #[tokio::test]
2648
+ async fn role_route_never_demotes_the_last_active_admin_but_may_demote_one_of_two() {
2649
+ let (client, domain_ir) = scratch_members_db("hecks_host_web_test_role_last_admin").await;
2650
+
2651
+ // zed is the only active admin: demoting themselves would lock everyone out.
2652
+ let response = set_role(&client, &domain_ir, "zed@example.com", "zed@example.com", "Member").await;
2653
+ assert_eq!(response["statusCode"], 409, "{response:?}");
2654
+ assert_eq!(error_of(&response), "there must always be at least one admin");
2655
+ assert_eq!(person(&client, &domain_ir, "zed@example.com").await["role"], "Admin", "nothing was written");
2656
+ // Moving between admin roles never strands anyone.
2657
+ assert_eq!(set_role(&client, &domain_ir, "zed@example.com", "zed@example.com", "Owner").await["statusCode"], 200);
2658
+
2659
+ // With a second admin, one may demote the other, and then the last one is protected again.
2660
+ assert_eq!(set_role(&client, &domain_ir, "zed@example.com", "amy@example.com", "Admin").await["statusCode"], 200);
2661
+ assert_eq!(set_role(&client, &domain_ir, "zed@example.com", "amy@example.com", "Member").await["statusCode"], 200);
2662
+ assert_eq!(set_role(&client, &domain_ir, "zed@example.com", "zed@example.com", "Member").await["statusCode"], 409);
2663
+ assert_eq!(person(&client, &domain_ir, "zed@example.com").await["role"], "Owner");
2664
+ }
2665
+
2666
+ #[tokio::test]
2667
+ async fn disabling_keeps_the_role_ends_the_old_session_at_once_and_enabling_restores_it() {
2668
+ let secret = "s3cret";
2669
+ let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_disable_enable").await;
2670
+ let amy_cookies = session_cookies(secret, "amy@example.com");
2671
+ let status = |response: &Value| response["statusCode"].as_i64().unwrap();
2672
+ assert_eq!(status(&accounts_me_route(&domain_ir, &amy_cookies, secret, &client).await), 200);
2673
+
2674
+ let response = switch_access(&client, &domain_ir, "Zed@Example.com", " Amy@Example.com ", true).await;
2675
+ assert_eq!(response["statusCode"], 200, "{response:?}");
2676
+ let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
2677
+ assert_eq!(body, json!({"disabled": "amy@example.com"}));
2678
+
2679
+ // The person, role and identity fields stay; only the explicit flag flips.
2680
+ let amy = person(&client, &domain_ir, "amy@example.com").await;
2681
+ assert_eq!(amy["role"], "Admin", "the retained role still shows");
2682
+ assert_eq!(amy["granted"], false);
2683
+ assert_eq!(amy["disabled"], true);
2684
+
2685
+ // amy's cookie is still validly signed and unexpired, yet stops working now.
2686
+ assert_eq!(status(&accounts_me_route(&domain_ir, &amy_cookies, secret, &client).await), 401);
2687
+ assert_eq!(status(&accounts_sso_token_route(&domain_ir, &amy_cookies, secret, &client).await), 401);
2688
+ assert_eq!(status(&members_route(&domain_ir, &amy_cookies, secret, &client).await), 401);
2689
+ let add = add_member_route(&domain_ir, r#"{"email":"x@example.com","name":"X"}"#, &amy_cookies, secret, &client, Path::new("unused"), &members_config()).await;
2690
+ assert_eq!(status(&add), 403);
2691
+ assert_eq!(status(&switch_access(&client, &domain_ir, "amy@example.com", "zed@example.com", true).await), 403);
2692
+ assert_eq!(person(&client, &domain_ir, "zed@example.com").await["granted"], true, "a disabled caller changed nothing");
2693
+
2694
+ // Disabling again is a 200 and writes nothing.
2695
+ let rows = journal_rows(&client, "amy@example.com").await;
2696
+ assert_eq!(status(&switch_access(&client, &domain_ir, "zed@example.com", "amy@example.com", true).await), 200);
2697
+ assert_eq!(journal_rows(&client, "amy@example.com").await, rows);
2698
+
2699
+ // Enabling restores exactly the earlier access, and is idempotent too.
2700
+ let response = switch_access(&client, &domain_ir, "zed@example.com", "amy@example.com", false).await;
2701
+ assert_eq!(response["statusCode"], 200, "{response:?}");
2702
+ let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
2703
+ assert_eq!(body, json!({"enabled": "amy@example.com"}));
2704
+ let amy = person(&client, &domain_ir, "amy@example.com").await;
2705
+ assert_eq!((amy["role"].as_str(), amy["granted"].clone(), amy["disabled"].clone()), (Some("Admin"), json!(true), json!(false)));
2706
+ assert_eq!(status(&accounts_me_route(&domain_ir, &amy_cookies, secret, &client).await), 200);
2707
+
2708
+ let rows = journal_rows(&client, "amy@example.com").await;
2709
+ assert_eq!(status(&switch_access(&client, &domain_ir, "zed@example.com", "amy@example.com", false).await), 200);
2710
+ assert_eq!(journal_rows(&client, "amy@example.com").await, rows);
2711
+ }
2712
+
2713
+ #[tokio::test]
2714
+ async fn a_disabled_person_cannot_sign_in_again_and_an_enabled_one_can() {
2715
+ let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_disable_signin").await;
2716
+
2717
+ // zed holds identity id-1 in the seed; a fresh Google sign-in resolves through it.
2718
+ assert!(auth::session_for_member_by_identity(&client, &domain_ir, "id-1").await.unwrap().is_some());
2719
+ let disabled = switch_access(&client, &domain_ir, "amy@example.com", "zed@example.com", true).await;
2720
+ assert_eq!(disabled["statusCode"], 200, "{disabled:?} amy={:?}", person(&client, &domain_ir, "amy@example.com").await);
2721
+ assert!(auth::session_for_member_by_identity(&client, &domain_ir, "id-1").await.unwrap().is_none());
2722
+ assert_eq!(switch_access(&client, &domain_ir, "amy@example.com", "zed@example.com", false).await["statusCode"], 200);
2723
+ assert!(auth::session_for_member_by_identity(&client, &domain_ir, "id-1").await.unwrap().is_some());
2724
+ }
2725
+
2726
+ #[tokio::test]
2727
+ async fn two_admins_disabling_each_other_at_once_leave_exactly_one_active_admin() {
2728
+ let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_disable_race").await;
2729
+ let config = members_config();
2730
+
2731
+ let (a, b) = tokio::join!(
2732
+ auth::set_person_disabled(&client, &config, &domain_ir, "zed@example.com", "amy@example.com", true),
2733
+ auth::set_person_disabled(&client, &config, &domain_ir, "amy@example.com", "zed@example.com", true),
2734
+ );
2735
+ let mut outcomes = [a.unwrap(), b.unwrap()];
2736
+ outcomes.sort_by_key(|o| format!("{o:?}"));
2737
+ assert_eq!(outcomes, [auth::DisableOutcome::CallerNotAdmin, auth::DisableOutcome::Done]);
2738
+
2739
+ let active = auth::all_people(&client, &domain_ir).await.unwrap().iter().filter(|p| p["role"] == "Admin" && p["granted"] == true).count();
2740
+ assert_eq!(active, 1, "there must always be one active admin");
2741
+ }
2742
+
2743
+ #[tokio::test]
2744
+ async fn registrations_list_route_refuses_anyone_but_an_active_admin_before_reading_any_registration() {
2745
+ let secret = "s3cret";
2746
+ let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_registrations_list_auth").await;
2747
+ let config = LineageConfig { domain: "Studio".to_string(), era: Some(1), mirrored: None };
2748
+ // The wasm path doesn't exist: every refusal below must come before any registration read.
2749
+ let wasm_path = Path::new("does-not-exist.wasm");
2750
+
2751
+ let mut tampered = HashMap::new();
2752
+ tampered.insert(auth::account_cookie_name(), "garbage.notasignature".to_string());
2753
+ for cookies in [HashMap::new(), tampered] {
2754
+ let response = registrations_list_route(&domain_ir, &cookies, secret, &client, wasm_path, &config).await;
2755
+ assert_eq!(response["statusCode"], 401, "{response:?}");
2756
+ assert!(response.get("headers").and_then(|h| h.get("location")).is_none(), "never a redirect: {response:?}");
2757
+ }
2758
+
2759
+ // amy is a disabled admin now; the stranger was never admitted at all.
2760
+ assert_eq!(switch_access(&client, &domain_ir, "zed@example.com", "amy@example.com", true).await["statusCode"], 200);
2761
+ for caller in ["amy@example.com", "stranger@example.com"] {
2762
+ let response = registrations_list_route(&domain_ir, &session_cookies(secret, caller), secret, &client, wasm_path, &config).await;
2763
+ assert_eq!(response["statusCode"], 403, "{caller}: {response:?}");
2764
+ assert_eq!(error_of(&response), "admins only");
2765
+ }
2766
+ }
2767
+
2768
+ async fn status_of(response: Value) -> u64 {
2769
+ response["statusCode"].as_u64().unwrap()
2770
+ }
2771
+
2772
+ #[tokio::test]
2773
+ async fn every_admin_gate_accepts_an_owner_as_well_as_an_admin_and_refuses_a_member_and_anonymous() {
2774
+ let secret = "s3cret";
2775
+ let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_owner_gates").await;
2776
+ let config = members_config();
2777
+ // amy becomes Owner (replacing her Admin role), bob is a plain Member.
2778
+ assert!(auth::grant_access(&client, Path::new("unused"), &config, &domain_ir, "amy@example.com", "Owner").await.unwrap());
2779
+ assert!(auth::admit_person(&client, &config, &domain_ir, "bob@example.com", "Bob").await.unwrap());
2780
+ assert!(auth::grant_access(&client, Path::new("unused"), &config, &domain_ir, "bob@example.com", "Member").await.unwrap());
2781
+ let studio = LineageConfig { domain: "Studio".to_string(), era: Some(1), mirrored: None };
2782
+ // No wasm at all: clearing the admin gate then fails with a 500, never 401/403.
2783
+ let missing_wasm = Path::new("does-not-exist.wasm");
2784
+
2785
+ // Every gate, as one closure per route: list, add, disable, enable, registrations.
2786
+ for (caller, admits) in [("amy@example.com", true), ("zed@example.com", true), ("bob@example.com", false), ("stranger@example.com", false)] {
2787
+ let cookies = session_cookies(secret, caller);
2788
+ let add = add_member_route(&domain_ir, &json!({"email": "not an email", "name": "N"}).to_string(), &cookies, secret, &client, Path::new("unused"), &config).await;
2789
+ let add = status_of(add).await;
2790
+ let registrations = status_of(registrations_list_route(&domain_ir, &cookies, secret, &client, missing_wasm, &studio).await).await;
2791
+ let disable = status_of(switch_access(&client, &domain_ir, caller, "bob@example.com", true).await).await;
2792
+ let enable = status_of(switch_access(&client, &domain_ir, caller, "bob@example.com", false).await).await;
2793
+ if admits {
2794
+ // The email is deliberately malformed: the gate passed, validation refused it.
2795
+ assert_eq!(add, 400, "{caller} passes the add gate");
2796
+ assert_eq!(registrations, 500, "{caller} passes the registrations gate");
2797
+ assert_eq!((disable, enable), (200, 200), "{caller} passes disable and enable");
2798
+ } else {
2799
+ assert_eq!((add, registrations, disable, enable), (403, 403, 403, 403), "{caller} is refused everywhere");
2800
+ }
2801
+ // Listing needs access, not admin: a Member may list, a stranger may not.
2802
+ let listing = status_of(members_route(&domain_ir, &cookies, secret, &client).await).await;
2803
+ assert_eq!(listing, if caller == "stranger@example.com" { 401 } else { 200 }, "{caller} listing");
2804
+ }
2805
+
2806
+ // Anonymous: 401 on every route, before any role is looked at.
2807
+ let none = HashMap::new();
2808
+ assert_eq!(status_of(add_member_route(&domain_ir, "{}", &none, secret, &client, Path::new("unused"), &config).await).await, 401);
2809
+ assert_eq!(status_of(members_route(&domain_ir, &none, secret, &client).await).await, 401);
2810
+ assert_eq!(status_of(registrations_list_route(&domain_ir, &none, secret, &client, missing_wasm, &studio).await).await, 401);
2811
+ assert_eq!(status_of(set_member_disabled_route(&domain_ir, r#"{"email":"bob@example.com"}"#, &none, secret, &client, &config, true).await).await, 401);
2812
+ }
2813
+
2814
+ #[tokio::test]
2815
+ async fn an_admin_can_grant_owner_and_the_new_owner_keeps_admin_and_members_access() {
2816
+ let secret = "s3cret";
2817
+ let (client, domain_ir) = scratch_members_db("hecks_host_web_test_grant_owner").await;
2818
+ let config = members_config();
2819
+
2820
+ let body = r#"{"email": "boss@example.com", "name": "Boss", "role": "Owner"}"#;
2821
+ let response = add_member_route(&domain_ir, body, &session_cookies(secret, "zed@example.com"), secret, &client, Path::new("unused"), &config).await;
2822
+ assert_eq!(response["statusCode"], 201, "{response:?}");
2823
+ let created: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
2824
+ assert_eq!(created["role"], "Owner");
2825
+ assert_eq!(person(&client, &domain_ir, "boss@example.com").await["role"], "Owner");
2826
+
2827
+ // The Owner isn't locked out: lists, admits, disables and enables.
2828
+ let boss = session_cookies(secret, "boss@example.com");
2829
+ assert_eq!(status_of(members_route(&domain_ir, &boss, secret, &client).await).await, 200);
2830
+ for (email, role) in [("second@example.com", "Owner"), ("plain@example.com", "Member")] {
2831
+ let body = json!({"email": email, "name": "X", "role": role}).to_string();
2832
+ let response = add_member_route(&domain_ir, &body, &boss, secret, &client, Path::new("unused"), &config).await;
2833
+ assert_eq!(response["statusCode"], 201, "{response:?}");
2834
+ assert_eq!(person(&client, &domain_ir, email).await["role"], role);
2835
+ }
2836
+ assert_eq!(switch_access(&client, &domain_ir, "boss@example.com", "zed@example.com", true).await["statusCode"], 200);
2837
+ assert_eq!(switch_access(&client, &domain_ir, "boss@example.com", "zed@example.com", false).await["statusCode"], 200);
2838
+
2839
+ // A person granted only Member cannot grant anything, Owner included.
2840
+ let response = add_member_route(&domain_ir, r#"{"email": "x@example.com", "name": "X", "role": "Owner"}"#, &session_cookies(secret, "plain@example.com"), secret, &client, Path::new("unused"), &config).await;
2841
+ assert_eq!(response["statusCode"], 403, "{response:?}");
2842
+ }
2843
+
2844
+ #[tokio::test]
2845
+ async fn the_add_route_refuses_an_unknown_role_and_writes_nothing() {
2846
+ let secret = "s3cret";
2847
+ let (client, domain_ir) = scratch_members_db("hecks_host_web_test_grant_unknown_role").await;
2848
+ for role in [json!("Root"), json!("admin"), json!(""), json!(7)] {
2849
+ let body = json!({"email": "new@example.com", "name": "New", "role": role}).to_string();
2850
+ let response = add_member_route(&domain_ir, &body, &session_cookies(secret, "zed@example.com"), secret, &client, Path::new("unused"), &members_config()).await;
2851
+ assert_eq!(response["statusCode"], 400, "{role}: {response:?}");
2852
+ assert_eq!(error_of(&response), "role must be one of Admin, Owner, Member");
2853
+ }
2854
+ assert_eq!(journal_rows(&client, "new@example.com").await, 0);
2855
+ }
2856
+
2857
+ #[test]
2858
+ fn the_admin_gate_role_set_is_exactly_admin_and_owner() {
2859
+ assert!(auth::is_admin_role("Admin") && auth::is_admin_role("Owner"));
2860
+ assert!(!auth::is_admin_role("Member") && !auth::is_admin_role("owner") && !auth::is_admin_role(""));
2861
+ assert_eq!(auth::GRANTABLE_ROLES, ["Admin", "Owner", "Member"]);
2862
+ }
2863
+
2864
+ #[test]
2865
+ fn registration_list_rows_have_exactly_the_admin_list_shape_and_never_carry_health_or_payment_fields() {
2866
+ let read = json!({"instances": {
2867
+ "Studio::Registration#reg-old": {
2868
+ "event_slug": "yoga-aug",
2869
+ "created_at": "2026-09-01T10:00:00Z",
2870
+ "attendee": {"first_name": " Ada ", "last_name": "Lovelace", "email": "ada@example.com", "news_signup": true,
2871
+ "phone": "555-0100", "medications": "SECRET-MEDICATION", "health_concerns": "SECRET-CONCERN"},
2872
+ },
2873
+ "Studio::Registration#reg-new": {
2874
+ "event_slug": "yoga-sep",
2875
+ "created_at": "2026-09-20T10:00:00Z",
2876
+ "attendee": {"first_name": {"value": "Grace"}, "last_name": {"value": "Hopper"}, "email": {"value": "grace@example.com"}},
2877
+ "amount": {"cents": 9900},
2878
+ },
2879
+ "Studio::Event#yoga-aug": {"name": {"value": "Yoga"}},
2880
+ "Payments::Payment#reg-old": {"amount": {"cents": 12345}, "status": "succeeded"},
2881
+ }});
2882
+
2883
+ let rows = registration_list_rows(&read, "Studio");
2884
+ assert_eq!(
2885
+ rows,
2886
+ vec![
2887
+ json!({"registration_id": "reg-new", "email": "grace@example.com", "name": "Grace Hopper", "event_slug": "yoga-sep", "news_signup": false}),
2888
+ json!({"registration_id": "reg-old", "email": "ada@example.com", "name": "Ada Lovelace", "event_slug": "yoga-aug", "news_signup": true}),
2889
+ ],
2890
+ "newest first, plain values, news_signup defaulting to false"
2891
+ );
2892
+
2893
+ let body = Value::Array(rows).to_string();
2894
+ for leaked in ["SECRET-MEDICATION", "SECRET-CONCERN", "medications", "health_concerns", "555-0100", "12345", "9900", "succeeded", "amount"] {
2895
+ assert!(!body.contains(leaked), "{leaked:?} leaked into the registration list: {body}");
2896
+ }
2897
+ }
2898
+
2899
+ #[test]
2900
+ fn registration_list_rows_carry_status_only_when_the_registration_has_one() {
2901
+ let read = json!({"instances": {
2902
+ "Studio::Registration#with": {"event_slug": "e", "status": "archived", "attendee": {"name": "Has Status", "email": "with@example.com"}},
2903
+ "Studio::Registration#without": {"event_slug": "e", "attendee": {"name": "No Status", "email": "without@example.com"}},
2904
+ }});
2905
+ let rows = registration_list_rows(&read, "Studio");
2906
+ let by_id = |id: &str| rows.iter().find(|row| row["registration_id"] == id).unwrap().clone();
2907
+ assert_eq!(by_id("with")["status"], "archived");
2908
+ assert!(by_id("without").get("status").is_none(), "no status key at all when the registration carries none: {:?}", by_id("without"));
2909
+ assert_eq!(
2910
+ by_id("without"),
2911
+ json!({"registration_id": "without", "email": "without@example.com", "name": "No Status", "event_slug": "e", "news_signup": false})
2912
+ );
2913
+ }
2914
+
2915
+ #[test]
2916
+ fn registration_list_rows_keep_read_order_without_timestamps_and_are_empty_with_no_registrations() {
2917
+ let read = json!({"instances": {
2918
+ "Studio::Registration#a": {"event_slug": "e", "attendee": {"name": "Flat Name", "email": "flat@example.com"}},
2919
+ }});
2920
+ let rows = registration_list_rows(&read, "Studio");
2921
+ assert_eq!(rows.len(), 1);
2922
+ assert_eq!(rows[0]["name"], "Flat Name", "falls back to a flat name when there is no first and last name");
2923
+
2924
+ assert_eq!(registration_list_rows(&json!({"instances": {}}), "Studio"), Vec::<Value>::new());
2925
+ assert_eq!(registration_list_rows(&json!({}), "Studio"), Vec::<Value>::new());
2926
+ let other_domain = json!({"instances": {"Elsewhere::Registration#x": {"attendee": {"email": "x@example.com"}}}});
2927
+ assert!(registration_list_rows(&other_domain, "Studio").is_empty(), "another domain's registrations are not listed");
2928
+ }
2929
+
2930
+ #[test]
2931
+ fn sorted_by_name_orders_case_insensitively_and_puts_a_missing_name_first() {
2932
+ let people = vec![json!({"name": "bob"}), json!({"name": "Ada"}), json!({"name": null}), json!({"name": "Cy"})];
2933
+ let names: Vec<Value> = sorted_by_name(people).into_iter().map(|p| p["name"].clone()).collect();
2934
+ assert_eq!(names, [json!(null), json!("Ada"), json!("bob"), json!("Cy")]);
2935
+ }
2936
+
2937
+ }