hecks 3.13.0 → 4.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 (440) hide show
  1. checksums.yaml +4 -4
  2. data/examples/pizzas/adapters/fake_processor.rb +34 -0
  3. data/examples/pizzas/adapters/payment_signature.rb +66 -0
  4. data/examples/pizzas/adapters/payment_webhook.rb +87 -0
  5. data/examples/pizzas/bluebook/pizzas.behaviors +33 -0
  6. data/examples/pizzas/bluebook/pizzas.bluebook +4 -4
  7. data/examples/pizzas/bluebook/pizzas.hecksagon +5 -3
  8. data/examples/pizzas/config.ru +22 -0
  9. data/lib/hecks/adapters/driven/prism.rb +0 -2
  10. data/lib/hecks/adapters/driven.rb +0 -2
  11. data/lib/hecks/adapters/driving/json.rb +1 -7
  12. data/lib/hecks/bluebook/dsl/attribute_collector.rb +15 -5
  13. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -2
  14. data/lib/hecks/bluebook/dsl/derivation_words.rb +58 -0
  15. data/lib/hecks/bluebook/dsl/translation_builder.rb +6 -37
  16. data/lib/hecks/bluebook/expression/ast_json.rb +0 -9
  17. data/lib/hecks/bluebook/hecksagon.rb +1 -1
  18. data/lib/hecks/bluebook/meta_validator/translation_judge/rules.rb +7 -10
  19. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +4 -8
  20. data/lib/hecks/bluebook/meta_validator.rb +0 -11
  21. data/lib/hecks/bluebook/named_pattern.rb +33 -0
  22. data/lib/hecks/bluebook/translation.rb +18 -10
  23. data/lib/hecks/bluebook/translation_expression/evaluator.rb +63 -0
  24. data/lib/hecks/bluebook/translation_expression/parser.rb +127 -0
  25. data/lib/hecks/bluebook/translation_expression.rb +39 -0
  26. data/lib/hecks/bluebook/translation_plan.rb +70 -0
  27. data/lib/hecks/bluebook.rb +3 -0
  28. data/lib/hecks/chapters.rb +3 -3
  29. data/lib/hecks/cli/domain_stub.rb +1 -1
  30. data/lib/hecks/cli/interview_aggregate.rb +4 -4
  31. data/lib/hecks/composer/adapters/claude_composer.adapter +3 -0
  32. data/lib/hecks/composer/adapters/claude_composer.rb +83 -0
  33. data/lib/hecks/composer/adapters/fake_agent.adapter +3 -0
  34. data/lib/hecks/composer/adapters/fake_agent.rb +55 -0
  35. data/lib/hecks/composer/adapters/jev.adapter +3 -0
  36. data/lib/hecks/composer/adapters/jev.rb +66 -0
  37. data/lib/hecks/composer/agent.rb +113 -0
  38. data/lib/hecks/composer/assistant.rb +110 -0
  39. data/lib/hecks/composer/behavior_run.rb +53 -0
  40. data/lib/hecks/composer/bluebook/composer.bluebook +178 -0
  41. data/lib/hecks/composer/bluebook/composer.hecksagon +16 -0
  42. data/lib/hecks/composer/board.rb +65 -0
  43. data/lib/hecks/composer/board_edit/command_text.rb +39 -0
  44. data/lib/hecks/composer/board_edit/source.rb +84 -0
  45. data/lib/hecks/composer/board_edit.rb +144 -0
  46. data/lib/hecks/composer/check.rb +89 -0
  47. data/lib/hecks/composer/domains.rb +81 -0
  48. data/lib/hecks/composer/jobs.rb +134 -0
  49. data/lib/hecks/composer/layout.rb +37 -0
  50. data/lib/hecks/composer/live.rb +150 -0
  51. data/lib/hecks/composer/outline.rb +48 -0
  52. data/lib/hecks/composer/page.html +989 -0
  53. data/lib/hecks/composer/panels.rb +142 -0
  54. data/lib/hecks/composer/parse.rb +83 -0
  55. data/lib/hecks/composer/plain.rb +28 -0
  56. data/lib/hecks/composer/ports/composer_agent.port +7 -0
  57. data/lib/hecks/composer/session.rb +141 -0
  58. data/lib/hecks/composer/toolbox.rb +166 -0
  59. data/lib/hecks/composer/web.rb +156 -0
  60. data/lib/hecks/composer/workbench.rb +51 -0
  61. data/lib/hecks/composer/workspace.rb +81 -0
  62. data/lib/hecks/composer.rb +17 -0
  63. data/lib/hecks/corpus/adapters.rb +22 -0
  64. data/lib/hecks/corpus/rust.rb +1 -1
  65. data/lib/hecks/corpus/worlds.rb +19 -0
  66. data/lib/hecks/corpus.rb +5 -0
  67. data/lib/hecks/deploy/bluebook/deploy.bluebook +34 -34
  68. data/lib/hecks/forms/app/commands.rb +2 -3
  69. data/lib/hecks/forms/app/queries.rb +2 -3
  70. data/lib/hecks/forms/app/records.rb +17 -5
  71. data/lib/hecks/forms/command_form_renderer.rb +42 -16
  72. data/lib/hecks/forms/field_controls.rb +2 -2
  73. data/lib/hecks/forms/field_renderer.rb +1 -1
  74. data/lib/hecks/forms/field_shape.rb +36 -17
  75. data/lib/hecks/forms/index_renderer.rb +2 -1
  76. data/lib/hecks/forms/page.rb +1 -1
  77. data/lib/hecks/forms/params.rb +0 -13
  78. data/lib/hecks/forms/query_form_renderer.rb +7 -6
  79. data/lib/hecks/forms/record_renderer.rb +11 -7
  80. data/lib/hecks/forms/rule_words.rb +100 -0
  81. data/lib/hecks/forms/value_object_shape.rb +2 -1
  82. data/lib/hecks/framework/bluebook/change.bluebook +2 -2
  83. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -3
  84. data/lib/hecks/fuzzing/domain_generator/block_rendering.rb +1 -1
  85. data/lib/hecks/fuzzing/isolated_boot/worlds.rb +3 -6
  86. data/lib/hecks/fuzzing/replay/filters.rb +2 -6
  87. data/lib/hecks/gate/stages.yml +25 -0
  88. data/lib/hecks/grammar/evolve/seeds.rb +0 -6
  89. data/lib/hecks/grammar/evolve.rb +0 -3
  90. data/lib/hecks/grammar/translation.bluebook +1 -1
  91. data/lib/hecks/hecks/adapters/codebase/composing.rb +56 -0
  92. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
  93. data/lib/hecks/hecks/adapters/journal_store/changes.rb +0 -26
  94. data/lib/hecks/hecks/adapters/journal_store/examination.rb +2 -23
  95. data/lib/hecks/hecks/adapters/journal_store/readings.rb +3 -11
  96. data/lib/hecks/hecks/adapters/journal_store.rb +0 -11
  97. data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +4 -11
  98. data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +0 -9
  99. data/lib/hecks/hecks/adapters/rehearsal/postgres_reading.rb +0 -4
  100. data/lib/hecks/hecks/codebase.behaviors +6 -0
  101. data/lib/hecks/hecks/codebase.bluebook +58 -37
  102. data/lib/hecks/hecks/composer.behaviors +68 -0
  103. data/lib/hecks/hecks/context_map.hecksagon +1 -1
  104. data/lib/hecks/hecks/custodian.behaviors +0 -6
  105. data/lib/hecks/hecks/custodian.bluebook +15 -92
  106. data/lib/hecks/hecks/hecks.bluebook +2 -2
  107. data/lib/hecks/hecks/hecks.hecksagon +2 -2
  108. data/lib/hecks/hecks/hecks.world +1 -1
  109. data/lib/hecks/hecks/rehearsal.bluebook +10 -16
  110. data/lib/hecks/hecks/tooling.bluebook +22 -22
  111. data/lib/hecks/kernel_capabilities/templates.rb +4 -4
  112. data/lib/hecks/kernel_capabilities.rb +1 -1
  113. data/lib/hecks/language/bluebook/aggregate.bluebook +1 -0
  114. data/lib/hecks/language/bluebook/command.bluebook +1 -0
  115. data/lib/hecks/language/bluebook/entity.bluebook +1 -0
  116. data/lib/hecks/language/bluebook/query.bluebook +1 -0
  117. data/lib/hecks/language/bluebook/shape.bluebook +8 -7
  118. data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -3
  119. data/lib/hecks/language/hecksagon/port_operation.bluebook +1 -0
  120. data/lib/hecks/language/translation/translation_aggregate.bluebook +21 -24
  121. data/lib/hecks/ports/access_control.rb +2 -2
  122. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -41
  123. data/lib/hecks/ports/persistence/plugins/era/lineage/coverage.rb +7 -7
  124. data/lib/hecks/ports/persistence/plugins/era/lineage/rewrite.rb +25 -2
  125. data/lib/hecks/ports/persistence/plugins/era/lineage/rules.rb +4 -4
  126. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +17 -25
  127. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/base_sql.rb +0 -12
  128. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/domain_rename.rb +1 -1
  129. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -22
  130. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +0 -1
  131. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +9 -3
  132. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +3 -29
  133. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/refusals.rb +2 -26
  134. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +6 -33
  135. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +3 -3
  136. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +2 -4
  137. data/lib/hecks/ports/persistence/plugins/era/translation/expression_sql.rb +52 -0
  138. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +23 -29
  139. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +1 -1
  140. data/lib/hecks/ports/persistence/plugins/era/translation.rb +0 -1
  141. data/lib/hecks/ports/persistence/plugins/era.rb +3 -6
  142. data/lib/hecks/ports.rb +0 -1
  143. data/lib/hecks/projection_files.rb +3 -1
  144. data/lib/hecks/projections/deploy/templates/lambda/nat_comment_oauth.tmpl +1 -1
  145. data/lib/hecks/projections/glossary/entries.rb +10 -4
  146. data/lib/hecks/projections/glossary/groups.rb +42 -0
  147. data/lib/hecks/projections/glossary/html/page.rb +7 -2
  148. data/lib/hecks/projections/glossary/html/parser.rb +1 -1
  149. data/lib/hecks/projections/glossary/html/sections.rb +31 -11
  150. data/lib/hecks/projections/glossary/html.rb +1 -2
  151. data/lib/hecks/projections/glossary/markdown.rb +31 -28
  152. data/lib/hecks/projections/glossary/mermaid.rb +3 -0
  153. data/lib/hecks/projections/glossary/page.css +143 -153
  154. data/lib/hecks/projections/glossary/page.js +63 -3
  155. data/lib/hecks/projections/glossary/sentences/actions.rb +88 -0
  156. data/lib/hecks/projections/glossary/sentences/paths.rb +77 -0
  157. data/lib/hecks/projections/glossary/sentences/queries.rb +90 -0
  158. data/lib/hecks/projections/glossary/sentences/value_objects.rb +32 -5
  159. data/lib/hecks/projections/glossary/sentences.rb +13 -3
  160. data/lib/hecks/projections/glossary/slugs.rb +4 -1
  161. data/lib/hecks/projections/glossary.rb +2 -1
  162. data/lib/hecks/projections/plain_english.rb +97 -0
  163. data/lib/hecks/projections/rust_vocabulary/arguments.rb +2 -2
  164. data/lib/hecks/projections/rust_vocabulary/enum_body.rb +20 -25
  165. data/lib/hecks/projections/rust_vocabulary/tests.rb +2 -2
  166. data/lib/hecks/projections/rust_vocabulary.rb +14 -6
  167. data/lib/hecks/projections/site/cms_editor/color.rb +0 -6
  168. data/lib/hecks/projections/statements/actions.rb +99 -0
  169. data/lib/hecks/projections/statements/relationships.rb +86 -0
  170. data/lib/hecks/projections/statements.rb +15 -24
  171. data/lib/hecks/projector/exporter/adapters.rb +112 -0
  172. data/lib/hecks/projector/exporter/translations.rb +18 -35
  173. data/lib/hecks/projector/exporter/worlds.rb +103 -0
  174. data/lib/hecks/projector/exporter.rb +5 -1
  175. data/lib/hecks/quality_control/adapters/agent.rb +6 -7
  176. data/lib/hecks/quality_control/adapters/agent_profile.rb +16 -114
  177. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +1 -1
  178. data/lib/hecks/quality_control/quality_control.bluebook +51 -51
  179. data/lib/hecks/runtime/loader/boot_steps.rb +0 -20
  180. data/lib/hecks/runtime/outbox.rb +2 -9
  181. data/lib/hecks/runtime/port_operation_interpreter.rb +1 -9
  182. data/lib/hecks/runtime/remote_dispatcher.rb +2 -2
  183. data/lib/hecks/runtime/value.rb +13 -0
  184. data/lib/hecks/rust_build/project_rust/target_ir.rb +23 -4
  185. data/lib/hecks/rust_build/wasm.rb +0 -12
  186. data/lib/hecks/rust_build/write_if_changed.rb +12 -12
  187. data/lib/hecks/rust_format.rb +77 -0
  188. data/lib/hecks/site/bluebook/site.bluebook +5 -5
  189. data/lib/hecks/sme/bluebook/sme.bluebook +12 -12
  190. data/lib/hecks/tickets/bluebook/tickets.bluebook +3 -3
  191. data/lib/hecks/tools/fuzz_sweep/findings.rb +3 -9
  192. data/lib/hecks/tools/fuzz_sweep.rb +1 -3
  193. data/lib/hecks/tools/translation_audit/verdicts.rb +3 -28
  194. data/lib/hecks/tools/translation_audit.rb +8 -10
  195. data/lib/hecks/tools/translation_scaffold/reporting.rb +1 -1
  196. data/lib/hecks/version.rb +1 -1
  197. data/lib/hecks/vocabulary.rb +2 -1
  198. data/lib/hecks.rb +3 -0
  199. data/prebuilt/{58208399bada10b0db99928c48e38789288fc58f7b71a72b21bfa386680e32ed.marshal → 02c09c73b272c1fb5a9ae6d87bb5e5e529208e00f6b4b597dc2c0fd70a9c0964.marshal} +0 -0
  200. data/prebuilt/{2b59737fbdbd5f44edda0fddce2611f45d76087b3a7c782d1f1a17ea58c77948.marshal → bb84a835cf6a15136bad5ca03aa5bf93a5b92f5f72444618d3ef2412b987fb32.marshal} +0 -0
  201. data/prebuilt/verdicts-f1f10da14c8e8f01b4c9b7e3e2d74be8cd2bf4676364ed17fa16975d2a076d75.json +1 -0
  202. data/rust/Cargo.toml +40 -0
  203. data/rust/build/Cargo.toml +36 -0
  204. data/rust/build/clippy.toml +4 -0
  205. data/rust/build/src/adapters_pass.rs +196 -0
  206. data/rust/build/src/binding_pass.rs +54 -13
  207. data/rust/build/src/build_artifact.rs +23 -5
  208. data/rust/build/src/cargo_sync.rs +69 -26
  209. data/rust/build/src/lineage_pass.rs +101 -87
  210. data/rust/build/src/main.rs +11 -2
  211. data/rust/build/src/pipeline.rs +80 -19
  212. data/rust/build/src/reserved_names.rs +6 -50
  213. data/rust/build/src/resolve.rs +82 -35
  214. data/rust/build/src/rustfmt.rs +100 -0
  215. data/rust/build/src/seams_pass.rs +85 -16
  216. data/rust/build/src/subprocess.rs +30 -6
  217. data/rust/build/src/tmp.rs +5 -2
  218. data/rust/build/src/translation_pass.rs +72 -22
  219. data/rust/build/src/world_pass.rs +109 -0
  220. data/rust/clippy.toml +4 -0
  221. data/rust/codegen/Cargo.toml +36 -0
  222. data/rust/codegen/clippy.toml +4 -0
  223. data/rust/codegen/src/asks.rs +40 -14
  224. data/rust/codegen/src/attr.rs +2 -2
  225. data/rust/codegen/src/bridging.rs +676 -117
  226. data/rust/codegen/src/commands.rs +979 -373
  227. data/rust/codegen/src/constraints.rs +88 -25
  228. data/rust/codegen/src/dependency_planning.rs +128 -34
  229. data/rust/codegen/src/domain_generator.rs +300 -170
  230. data/rust/codegen/src/exemplar.rs +87 -32
  231. data/rust/codegen/src/expr_emitter.rs +54 -6
  232. data/rust/codegen/src/fielded.rs +193 -41
  233. data/rust/codegen/src/hecks_naming.rs +124 -16
  234. data/rust/codegen/src/hecks_naming_tests.rs +81 -0
  235. data/rust/codegen/src/json.rs +80 -44
  236. data/rust/codegen/src/json_codec.rs +585 -149
  237. data/rust/codegen/src/literal.rs +104 -138
  238. data/rust/codegen/src/main.rs +145 -52
  239. data/rust/codegen/src/manifest.rs +76 -14
  240. data/rust/codegen/src/mutations.rs +1212 -239
  241. data/rust/codegen/src/naming.rs +111 -60
  242. data/rust/codegen/src/optional_pass.rs +90 -28
  243. data/rust/codegen/src/ports.rs +5 -4
  244. data/rust/codegen/src/prelude.rs +139 -49
  245. data/rust/codegen/src/queries.rs +601 -226
  246. data/rust/codegen/src/reactions.rs +268 -74
  247. data/rust/codegen/src/read_models.rs +606 -205
  248. data/rust/codegen/src/reference_specs.rs +16 -3
  249. data/rust/codegen/src/registry.rs +128 -79
  250. data/rust/codegen/src/reserved_names.rs +6 -50
  251. data/rust/codegen/src/shared.rs +71 -22
  252. data/rust/codegen/src/sidecars.rs +15 -5
  253. data/rust/codegen/src/skip_reason.rs +8 -2
  254. data/rust/codegen/src/types.rs +306 -108
  255. data/rust/host/Cargo.lock +1 -0
  256. data/rust/host/Cargo.toml +40 -4
  257. data/rust/host/HECKS_RELEASE +1 -1
  258. data/rust/host/clippy.toml +4 -0
  259. data/rust/host/src/api.rs +698 -218
  260. data/rust/host/src/auth.rs +491 -214
  261. data/rust/host/src/b64.rs +91 -0
  262. data/rust/host/src/bin/expr_harness.rs +6 -2
  263. data/rust/host/src/bin/lineage_harness.rs +47 -11
  264. data/rust/host/src/bin/mint_harness.rs +70 -37
  265. data/rust/host/src/bin/validate_harness.rs +16 -8
  266. data/rust/host/src/boot.rs +144 -68
  267. data/rust/host/src/boundary_fuzz/api.rs +130 -32
  268. data/rust/host/src/boundary_fuzz/auth.rs +108 -23
  269. data/rust/host/src/boundary_fuzz/expr_json.rs +85 -11
  270. data/rust/host/src/boundary_fuzz/ir.rs +164 -12
  271. data/rust/host/src/boundary_fuzz/server.rs +283 -36
  272. data/rust/host/src/boundary_fuzz/web.rs +56 -8
  273. data/rust/host/src/checkout.rs +78 -32
  274. data/rust/host/src/clock.rs +8 -0
  275. data/rust/host/src/commerce_ir.rs +333 -95
  276. data/rust/host/src/crypto.rs +33 -0
  277. data/rust/host/src/dispatch.rs +990 -331
  278. data/rust/host/src/expr_json.rs +1021 -273
  279. data/rust/host/src/extension.rs +24 -4
  280. data/rust/host/src/field_hints.rs +11 -7
  281. data/rust/host/src/fuzz_support.rs +85 -13
  282. data/rust/host/src/host.rs +100 -0
  283. data/rust/host/src/http.rs +22 -0
  284. data/rust/host/src/ir.rs +326 -16
  285. data/rust/host/src/journal.rs +277 -176
  286. data/rust/host/src/lambda_client.rs +219 -53
  287. data/rust/host/src/lib.rs +16 -4
  288. data/rust/host/src/log.rs +62 -11
  289. data/rust/host/src/mint.rs +1153 -326
  290. data/rust/host/src/needs.rs +185 -44
  291. data/rust/host/src/payments/keystore.rs +197 -45
  292. data/rust/host/src/payments/roles.rs +191 -0
  293. data/rust/host/src/payments/tests.rs +1445 -275
  294. data/rust/host/src/payments.rs +409 -121
  295. data/rust/host/src/platform_signin/attack_tests.rs +5 -5
  296. data/rust/host/src/platform_signin/config.rs +11 -11
  297. data/rust/host/src/platform_signin/cookie.rs +2 -2
  298. data/rust/host/src/platform_signin/extension.rs +2 -1
  299. data/rust/host/src/platform_signin/handler.rs +9 -8
  300. data/rust/host/src/platform_signin/jti.rs +4 -2
  301. data/rust/host/src/platform_signin/jwks.rs +2 -1
  302. data/rust/host/src/platform_signin/jws.rs +2 -37
  303. data/rust/host/src/platform_signin/known_answer_tests.rs +5 -1
  304. data/rust/host/src/platform_signin/mode_tests.rs +5 -3
  305. data/rust/host/src/platform_signin/pages.rs +20 -20
  306. data/rust/host/src/platform_signin/session.rs +8 -1
  307. data/rust/host/src/platform_signin/settings.rs +6 -6
  308. data/rust/host/src/platform_signin/strict_json.rs +12 -9
  309. data/rust/host/src/platform_signin/verify.rs +41 -41
  310. data/rust/host/src/platform_signin/wiring_support.rs +13 -18
  311. data/rust/host/src/presentation.rs +207 -63
  312. data/rust/host/src/presentation_write.rs +502 -187
  313. data/rust/host/src/query_step.rs +69 -18
  314. data/rust/host/src/rate_limit.rs +541 -142
  315. data/rust/host/src/reference_transform.rs +77 -25
  316. data/rust/host/src/reference_validate.rs +443 -104
  317. data/rust/host/src/resend.rs +190 -55
  318. data/rust/host/src/secrets.rs +116 -21
  319. data/rust/host/src/server.rs +483 -104
  320. data/rust/host/src/storage_shape.rs +88 -21
  321. data/rust/host/src/test_pg.rs +17 -7
  322. data/rust/host/src/ui_schema.rs +495 -207
  323. data/rust/host/src/wasm_runner.rs +21 -6
  324. data/rust/host/src/web/commerce.rs +101 -23
  325. data/rust/host/src/web/newsletter/cooldown.rs +10 -3
  326. data/rust/host/src/web/newsletter/template.rs +26 -7
  327. data/rust/host/src/web/newsletter/tests.rs +112 -23
  328. data/rust/host/src/web/newsletter.rs +209 -59
  329. data/rust/host/src/web/newsletter_send.rs +251 -79
  330. data/rust/host/src/web/registration_receipt/tests.rs +16 -4
  331. data/rust/host/src/web/registration_receipt.rs +100 -24
  332. data/rust/host/src/web/registrations.rs +1384 -332
  333. data/rust/host/src/web/resend_webhook.rs +138 -80
  334. data/rust/host/src/web.rs +2416 -508
  335. data/rust/lsp/Cargo.toml +36 -0
  336. data/rust/lsp/clippy.toml +4 -0
  337. data/rust/lsp/src/json.rs +29 -22
  338. data/rust/lsp/src/main.rs +81 -31
  339. data/rust/lsp/src/outline.rs +23 -7
  340. data/rust/lsp/src/rpc.rs +17 -4
  341. data/rust/parser/Cargo.toml +36 -0
  342. data/rust/parser/clippy.toml +4 -0
  343. data/rust/parser/src/build/closed_sets.rs +6 -1
  344. data/rust/parser/src/build/identity.rs +4 -3
  345. data/rust/parser/src/build/mod.rs +1 -0
  346. data/rust/parser/src/build/named_pattern.rs +26 -0
  347. data/rust/parser/src/build/naming.rs +2 -80
  348. data/rust/parser/src/build/pattern_subset.rs +6 -8
  349. data/rust/parser/src/build/query_derive.rs +3 -1
  350. data/rust/parser/src/build/query_inference.rs +14 -5
  351. data/rust/parser/src/build/read_model.rs +4 -1
  352. data/rust/parser/src/build/references.rs +4 -3
  353. data/rust/parser/src/canonical.rs +34 -15
  354. data/rust/parser/src/emit.rs +187 -105
  355. data/rust/parser/src/expr/ast_json.rs +134 -42
  356. data/rust/parser/src/expr/evaluator.rs +27 -9
  357. data/rust/parser/src/expr/mod.rs +54 -13
  358. data/rust/parser/src/expr/resolver.rs +299 -92
  359. data/rust/parser/src/ir.rs +35 -3
  360. data/rust/parser/src/keywords.rs +7168 -510
  361. data/rust/parser/src/lex.rs +11 -25
  362. data/rust/parser/src/lib.rs +1 -0
  363. data/rust/parser/src/main.rs +65 -6
  364. data/rust/parser/src/parse/adapter.rs +157 -0
  365. data/rust/parser/src/parse/aggregate.rs +19 -10
  366. data/rust/parser/src/parse/chapter.rs +16 -17
  367. data/rust/parser/src/parse/command.rs +35 -16
  368. data/rust/parser/src/parse/domain_port.rs +4 -7
  369. data/rust/parser/src/parse/entity.rs +21 -11
  370. data/rust/parser/src/parse/hecksagon.rs +10 -4
  371. data/rust/parser/src/parse/lifecycle.rs +9 -5
  372. data/rust/parser/src/parse/mod.rs +43 -91
  373. data/rust/parser/src/parse/needs.rs +21 -12
  374. data/rust/parser/src/parse/policy.rs +2 -2
  375. data/rust/parser/src/parse/process_manager.rs +9 -11
  376. data/rust/parser/src/parse/query.rs +8 -4
  377. data/rust/parser/src/parse/read_model.rs +57 -16
  378. data/rust/parser/src/parse/value_object.rs +7 -2
  379. data/rust/parser/src/parse/world.rs +322 -0
  380. data/rust/parser/src/ruby_value.rs +7 -6
  381. data/rust/src/exemplar/json.rs +4 -4
  382. data/rust/src/exemplar/mutations.rs +18 -0
  383. data/rust/src/exemplar/read_models.rs +1 -10
  384. data/rust/src/kernel/attribute_shapes/composite.rs +54 -14
  385. data/rust/src/kernel/attribute_shapes/list.rs +2 -2
  386. data/rust/src/kernel/attribute_shapes/mod.rs +8 -8
  387. data/rust/src/kernel/attribute_shapes/scalar.rs +2 -2
  388. data/rust/src/kernel/cli.rs +229 -112
  389. data/rust/src/kernel/dispatch.rs +323 -115
  390. data/rust/src/kernel/dispatch_guard_tests.rs +327 -65
  391. data/rust/src/kernel/expr.rs +203 -86
  392. data/rust/src/kernel/expression_operators/arithmetic.rs +138 -25
  393. data/rust/src/kernel/expression_operators/comparison.rs +87 -2
  394. data/rust/src/kernel/expression_operators/enumeration.rs +106 -22
  395. data/rust/src/kernel/expression_operators/logical.rs +9 -3
  396. data/rust/src/kernel/expression_operators/membership.rs +3 -1
  397. data/rust/src/kernel/expression_operators/mod.rs +34 -21
  398. data/rust/src/kernel/expression_operators/pattern_match.rs +46 -11
  399. data/rust/src/kernel/expression_operators/positional.rs +84 -30
  400. data/rust/src/kernel/expression_operators/presence.rs +52 -13
  401. data/rust/src/kernel/expression_operators/sign_test.rs +3 -1
  402. data/rust/src/kernel/expression_operators/sized.rs +24 -8
  403. data/rust/src/kernel/expression_operators/text.rs +120 -27
  404. data/rust/src/kernel/expression_operators/to_string.rs +10 -4
  405. data/rust/src/kernel/json.rs +230 -148
  406. data/rust/src/kernel/mod.rs +46 -33
  407. data/rust/src/kernel/named_query.rs +63 -15
  408. data/rust/src/kernel/needs.rs +112 -25
  409. data/rust/src/kernel/orchestrate.rs +980 -581
  410. data/rust/src/kernel/orchestrate_guard_tests.rs +538 -140
  411. data/rust/src/kernel/pattern.rs +107 -34
  412. data/rust/src/kernel/query_comparators.rs +118 -41
  413. data/rust/src/kernel/query_ordering.rs +13 -5
  414. data/rust/src/kernel/read_model.rs +479 -181
  415. data/rust/src/kernel/reference_lookup.rs +122 -26
  416. data/rust/src/kernel/repository.rs +293 -66
  417. data/rust/src/kernel/routing.rs +58 -42
  418. data/rust/src/kernel/ruby_float.rs +139 -0
  419. data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +57 -57
  420. data/rust/src/kernel/vocab/entity_dispatch_order.rs +54 -54
  421. data/rust/src/kernel/vocab/field_hint.rs +19 -25
  422. data/rust/src/kernel/vocab/mod.rs +6 -6
  423. data/rust/src/kernel/vocab/query_comparator.rs +23 -24
  424. data/rust/src/kernel/vocab/refusal_template.rs +501 -297
  425. data/rust/src/lib.rs +50 -0
  426. data/rust/src/main.rs +4 -1
  427. data/rust/web/Cargo.toml +36 -0
  428. data/rust/web/clippy.toml +4 -0
  429. metadata +85 -15
  430. data/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +0 -36
  431. data/lib/hecks/adapters/driven/google_authentication.adapter +0 -3
  432. data/lib/hecks/adapters/driven/google_authentication.rb +0 -99
  433. data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +0 -3
  434. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +0 -32
  435. data/lib/hecks/ports/authentication.port +0 -6
  436. data/lib/hecks/ports/authentication.rb +0 -67
  437. data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +0 -255
  438. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +0 -22
  439. data/prebuilt/verdicts-48318ca172edd21443b1ad02801c697fff100b746e828f09db987647641a5df2.json +0 -1
  440. data/rust/host/src/approval.rs +0 -652
data/rust/host/src/web.rs CHANGED
@@ -5,11 +5,12 @@ use crate::api;
5
5
  use crate::auth;
6
6
  use crate::auth::Session;
7
7
  use crate::dispatch;
8
+ use crate::extension::{self, Ctx};
8
9
  use crate::field_hints::{EMAIL_HINT, TEL_HINT, TEXTAREA_HINT, URL_HINT};
10
+ use crate::host::Host;
9
11
  use crate::ir::ir;
10
12
  use crate::journal::LineageConfig;
11
13
  use crate::lambda_client::LambdaInvoker;
12
- use crate::extension::{self, Ctx};
13
14
  use serde_json::{json, Value};
14
15
  use std::collections::HashMap;
15
16
  use std::path::Path;
@@ -28,26 +29,27 @@ use registrations::payments_routes;
28
29
  // payments.rs, its tests and the boot check reach these through `web::`.
29
30
  pub use registrations::checkout_enabled;
30
31
  #[cfg(test)]
31
- pub(crate) use registrations::{registration_complete_route, registrations_route, webhook_route, MOCK_STRIPE_WEBHOOK_SECRET};
32
+ pub(crate) use registrations::{
33
+ registration_complete_route, registrations_route, webhook_route, MOCK_STRIPE_WEBHOOK_SECRET,
34
+ };
32
35
  #[cfg(test)]
33
36
  pub(crate) use registrations::{seats_left, seats_taken};
34
37
 
35
38
  const UNGATED_PATHS: &[&str] = &["/login", "/logout", "/auth/google", "/auth/google/callback"];
36
-
37
- #[allow(clippy::too_many_arguments)]
38
- pub async fn render(
39
- body: &Value,
40
- client: &Mutex<Client>,
41
- wasm_path: &Path,
42
- config: &LineageConfig,
43
- invoker: &dyn LambdaInvoker,
44
- ) -> Option<Value> {
39
+ pub async fn render(body: &Value, host: Host<'_>) -> Option<Value> {
45
40
  let http_event = body.get("requestContext")?.get("http")?;
46
41
 
47
- let method = http_event.get("method").and_then(|v| v.as_str()).unwrap_or("GET");
42
+ let method = http_event
43
+ .get("method")
44
+ .and_then(|v| v.as_str())
45
+ .unwrap_or("GET");
48
46
  let path = body.get("rawPath").and_then(|v| v.as_str()).unwrap_or("/");
49
47
  let raw_body = body.get("body").and_then(|v| v.as_str()).unwrap_or("");
50
- let raw_body = if body.get("isBase64Encoded").and_then(|v| v.as_bool()) == Some(true) {
48
+ let raw_body = if body
49
+ .get("isBase64Encoded")
50
+ .and_then(serde_json::Value::as_bool)
51
+ == Some(true)
52
+ {
51
53
  String::from_utf8(base64_decode(raw_body)).unwrap_or_default()
52
54
  } else {
53
55
  raw_body.to_string()
@@ -56,7 +58,11 @@ pub async fn render(
56
58
  // Parsed before `checkout_enabled` because newsletter's email-link
57
59
  // routes need it there. Query strings are RFC 3986 — `+` is literal,
58
60
  // not a space (unlike form bodies, which still use parse_form).
59
- let query = parse_query(body.get("rawQueryString").and_then(|v| v.as_str()).unwrap_or(""));
61
+ let query = parse_query(
62
+ body.get("rawQueryString")
63
+ .and_then(|v| v.as_str())
64
+ .unwrap_or(""),
65
+ );
60
66
 
61
67
  let cookies = extract_cookies(body);
62
68
  let ctx = Ctx {
@@ -67,10 +73,11 @@ pub async fn render(
67
73
  raw_body: &raw_body,
68
74
  cookies: &cookies,
69
75
  domain_ir: ir(),
70
- client,
71
- wasm_path,
72
- config,
73
- invoker,
76
+ client: host.client,
77
+ wasm_path: host.wasm_path,
78
+ config: host.config,
79
+ invoker: host.invoker,
80
+ host,
74
81
  };
75
82
  for extension in extension::installed() {
76
83
  if let Some(response) = extension.guest_route(&ctx).await {
@@ -79,10 +86,14 @@ pub async fn render(
79
86
  }
80
87
 
81
88
  let Some(domain_ir) = ir() else {
82
- return Some(respond(500, "text/plain", "HECKS_IR_PATH not set or unreadable — this domain has no web layer configured"));
89
+ return Some(respond(
90
+ 500,
91
+ "text/plain",
92
+ "HECKS_IR_PATH not set or unreadable — this domain has no web layer configured",
93
+ ));
83
94
  };
84
95
 
85
- Some(route(domain_ir, method, path, &query, &raw_body, &cookies, client, wasm_path, config, invoker).await)
96
+ Some(route(domain_ir, method, path, &query, &raw_body, &cookies, host).await)
86
97
  }
87
98
 
88
99
  fn extract_cookies(body: &Value) -> HashMap<String, String> {
@@ -95,7 +106,11 @@ fn extract_cookies(body: &Value) -> HashMap<String, String> {
95
106
  map.insert(k.trim().to_string(), v.trim().to_string());
96
107
  }
97
108
  }
98
- } else if let Some(header) = body.get("headers").and_then(|h| h.get("cookie")).and_then(|v| v.as_str()) {
109
+ } else if let Some(header) = body
110
+ .get("headers")
111
+ .and_then(|h| h.get("cookie"))
112
+ .and_then(|v| v.as_str())
113
+ {
99
114
  for pair in header.split(';') {
100
115
  if let Some((k, v)) = pair.split_once('=') {
101
116
  map.insert(k.trim().to_string(), v.trim().to_string());
@@ -108,8 +123,9 @@ fn extract_cookies(body: &Value) -> HashMap<String, String> {
108
123
  // Refuses on an empty/unset secret rather than defaulting to "" and
109
124
  // signing every cookie with a publicly-known key. Checked here, not
110
125
  // at boot, since a domain with no web layer never sets it at all.
126
+ #[allow(clippy::panic)] // reason: refusing to boot beats signing cookies with a publicly-known key
111
127
  fn session_secret() -> String {
112
- let secret = std::env::var("SESSION_SECRET").unwrap_or_default();
128
+ let secret = crate::secrets::var("SESSION_SECRET").unwrap_or_default();
113
129
  if let Err(e) = validate_session_secret(&secret) {
114
130
  panic!("{e}");
115
131
  }
@@ -119,9 +135,11 @@ fn session_secret() -> String {
119
135
  // Kept pure and separately testable; only `session_secret` panics.
120
136
  fn validate_session_secret(secret: &str) -> Result<(), String> {
121
137
  if secret.is_empty() {
122
- Err("SESSION_SECRET is required and must not be empty -- refusing to sign/verify session \
138
+ Err(
139
+ "SESSION_SECRET is required and must not be empty -- refusing to sign/verify session \
123
140
  cookies and OAuth state with a publicly-known empty-string HMAC key"
124
- .to_string())
141
+ .to_string(),
142
+ )
125
143
  } else {
126
144
  Ok(())
127
145
  }
@@ -140,7 +158,11 @@ fn auth_gate(path: &str, authenticated: bool) -> Option<Value> {
140
158
  Some(if json_shaped(path) {
141
159
  // The Ruby engine's own refusal body, key for key, so a client
142
160
  // can branch on it without caring which runtime answered.
143
- respond(401, "application/json", &json!({"error": "Unauthenticated", "message": "sign in first"}).to_string())
161
+ respond(
162
+ 401,
163
+ "application/json",
164
+ &json!({"error": "Unauthenticated", "message": "sign in first"}).to_string(),
165
+ )
144
166
  } else {
145
167
  redirect("/login")
146
168
  })
@@ -157,8 +179,6 @@ fn json_shaped(path: &str) -> bool {
157
179
  _ => false,
158
180
  }
159
181
  }
160
-
161
- #[allow(clippy::too_many_arguments)]
162
182
  async fn route(
163
183
  domain_ir: &Value,
164
184
  method: &str,
@@ -166,16 +186,30 @@ async fn route(
166
186
  query: &HashMap<String, String>,
167
187
  raw_body: &str,
168
188
  cookies: &HashMap<String, String>,
169
- client: &Mutex<Client>,
170
- wasm_path: &Path,
171
- config: &LineageConfig,
172
- invoker: &dyn LambdaInvoker,
189
+ host: Host<'_>,
173
190
  ) -> Value {
191
+ let Host {
192
+ client, wasm_path, ..
193
+ } = host;
174
194
  let domain_name = domain_ir.get("name").and_then(|v| v.as_str()).unwrap_or("");
175
195
  let secret = session_secret();
176
- let session = cookies.get("session").and_then(|c| auth::parse_session_cookie(&secret, c));
196
+ let session = cookies
197
+ .get("session")
198
+ .and_then(|c| auth::parse_session_cookie(&secret, c));
177
199
 
178
- if let Some(response) = auth_route(domain_ir, path, method, query, raw_body, cookies, session.as_ref(), &secret, client, wasm_path, config, invoker).await {
200
+ if let Some(response) = auth_route(
201
+ domain_ir,
202
+ path,
203
+ method,
204
+ query,
205
+ raw_body,
206
+ cookies,
207
+ session.as_ref(),
208
+ &secret,
209
+ host,
210
+ )
211
+ .await
212
+ {
179
213
  return response;
180
214
  }
181
215
 
@@ -187,7 +221,16 @@ async fn route(
187
221
  // 404s itself — falling through would 404 here as "no domain \"api\""
188
222
  // instead, for a request the Ruby engine answers with data.
189
223
  if path.starts_with("/api/") {
190
- return api::route(domain_ir, method, path, query, raw_body, session.as_ref(), client, wasm_path, config, invoker).await;
224
+ return api::route(
225
+ domain_ir,
226
+ method,
227
+ path,
228
+ query,
229
+ raw_body,
230
+ session.as_ref(),
231
+ host,
232
+ )
233
+ .await;
191
234
  }
192
235
 
193
236
  let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
@@ -197,14 +240,22 @@ async fn route(
197
240
  }
198
241
 
199
242
  if segments[0] != domain_name {
200
- return respond(404, "text/plain", &format!("no domain {:?} loaded", segments[0]));
243
+ return respond(
244
+ 404,
245
+ "text/plain",
246
+ &format!("no domain {:?} loaded", segments[0]),
247
+ );
201
248
  }
202
249
 
203
250
  let Some((aggregate_seg, format)) = segments.get(1).map(|s| split_format(s)) else {
204
251
  return respond(404, "text/plain", "no route");
205
252
  };
206
253
  let Some(aggregate) = find_aggregate(domain_ir, &aggregate_seg) else {
207
- return respond(404, "text/plain", &format!("{domain_name} has no aggregate {aggregate_seg:?}"));
254
+ return respond(
255
+ 404,
256
+ "text/plain",
257
+ &format!("{domain_name} has no aggregate {aggregate_seg:?}"),
258
+ );
208
259
  };
209
260
 
210
261
  if segments.len() == 2 {
@@ -216,12 +267,28 @@ async fn route(
216
267
  if let Some(command) = find_command(aggregate, &verb_or_id) {
217
268
  let action = format!("/{domain_name}/{}/{}.html", agg_name(aggregate), verb_or_id);
218
269
  return command_route(
219
- domain_ir, domain_name, aggregate, command, &action, &format, method, query, raw_body,
220
- client, wasm_path, config, invoker,
270
+ domain_ir,
271
+ domain_name,
272
+ aggregate,
273
+ command,
274
+ &action,
275
+ &format,
276
+ method,
277
+ query,
278
+ raw_body,
279
+ host,
221
280
  )
222
281
  .await;
223
282
  }
224
- return record_show(domain_name, aggregate, &verb_or_id, &format, client, wasm_path).await;
283
+ return record_show(
284
+ domain_name,
285
+ aggregate,
286
+ &verb_or_id,
287
+ &format,
288
+ client,
289
+ wasm_path,
290
+ )
291
+ .await;
225
292
  }
226
293
 
227
294
  respond(404, "text/plain", &format!("no route for {path}"))
@@ -239,11 +306,15 @@ async fn auth_route(
239
306
  cookies: &HashMap<String, String>,
240
307
  session: Option<&Session>,
241
308
  secret: &str,
242
- client: &Mutex<Client>,
243
- wasm_path: &Path,
244
- config: &LineageConfig,
245
- invoker: &dyn LambdaInvoker,
309
+ host: Host<'_>,
246
310
  ) -> Option<Value> {
311
+ let Host {
312
+ client,
313
+ wasm_path,
314
+ config,
315
+ invoker,
316
+ ..
317
+ } = host;
247
318
  let ctx = Ctx {
248
319
  event: None,
249
320
  method,
@@ -252,10 +323,11 @@ async fn auth_route(
252
323
  raw_body,
253
324
  cookies,
254
325
  domain_ir: Some(domain_ir),
255
- client,
256
- wasm_path,
257
- config,
258
- invoker,
326
+ client: host.client,
327
+ wasm_path: host.wasm_path,
328
+ config: host.config,
329
+ invoker: host.invoker,
330
+ host,
259
331
  };
260
332
  for extension in extension::installed() {
261
333
  if let Some(response) = extension.account_route(&ctx).await {
@@ -264,9 +336,15 @@ async fn auth_route(
264
336
  }
265
337
 
266
338
  match (method, path) {
267
- ("GET", "/login") => Some(html(200, &login_page(query.get("error").map(|s| s.as_str())))),
339
+ ("GET", "/login") => Some(html(
340
+ 200,
341
+ &login_page(query.get("error").map(std::string::String::as_str)),
342
+ )),
268
343
 
269
- ("POST", "/logout") => Some(redirect_with_cookie("/login", &format!("session=; Max-Age=0{}", cookie_flags()))),
344
+ ("POST", "/logout") => Some(redirect_with_cookie(
345
+ "/login",
346
+ &format!("session=; Max-Age=0{}", cookie_flags()),
347
+ )),
270
348
 
271
349
  // No password login: Google OAuth is the only sign-in path, and the
272
350
  // /accounts/* routes here are just the session-cookie family that
@@ -275,73 +353,186 @@ async fn auth_route(
275
353
  200,
276
354
  "application/json",
277
355
  r#"{"ok":true}"#,
278
- &format!("{}=; Max-Age=0{}", auth::account_cookie_name(), cookie_flags()),
356
+ &format!(
357
+ "{}=; Max-Age=0{}",
358
+ auth::account_cookie_name(),
359
+ cookie_flags()
360
+ ),
279
361
  )),
280
362
 
281
- ("GET", "/accounts/me") => Some(accounts_me_route(domain_ir, cookies, secret, client).await),
363
+ ("GET", "/accounts/me") => {
364
+ Some(accounts_me_route(domain_ir, cookies, secret, client).await)
365
+ }
282
366
 
283
367
  ("GET", "/members") => Some(members_route(domain_ir, cookies, secret, client).await),
284
368
 
285
- ("POST", "/members") => Some(add_member_route(domain_ir, raw_body, cookies, secret, client, wasm_path, config).await),
369
+ ("POST", "/members") => Some(
370
+ add_member_route(
371
+ domain_ir, raw_body, cookies, secret, client, wasm_path, config,
372
+ )
373
+ .await,
374
+ ),
286
375
 
287
- ("POST", "/signups") => Some(signup_route(raw_body, secret, client, wasm_path, config, invoker).await),
376
+ ("POST", "/signups") => Some(signup_route(raw_body, secret, host).await),
288
377
 
289
- ("POST", "/members/disable") => Some(set_member_disabled_route(domain_ir, raw_body, cookies, secret, client, config, true).await),
378
+ ("POST", "/members/disable") => Some(
379
+ set_member_disabled_route(domain_ir, raw_body, cookies, secret, client, config, true)
380
+ .await,
381
+ ),
290
382
 
291
- ("POST", "/members/enable") => Some(set_member_disabled_route(domain_ir, raw_body, cookies, secret, client, config, false).await),
383
+ ("POST", "/members/enable") => Some(
384
+ set_member_disabled_route(domain_ir, raw_body, cookies, secret, client, config, false)
385
+ .await,
386
+ ),
292
387
 
293
- ("POST", "/members/role") => Some(set_member_role_route(domain_ir, raw_body, cookies, secret, client, config).await),
388
+ ("POST", "/members/role") => {
389
+ Some(set_member_role_route(domain_ir, raw_body, cookies, secret, client, config).await)
390
+ }
294
391
 
295
- ("POST", "/members/delete") => Some(set_member_deleted_route(domain_ir, raw_body, cookies, secret, client, config).await),
392
+ ("POST", "/members/delete") => Some(
393
+ set_member_deleted_route(domain_ir, raw_body, cookies, secret, client, config).await,
394
+ ),
296
395
 
297
396
  // cms/src/endpoints/sso.ts verifies this with the same account_token
298
397
  // wire format; deploy-aws/platform/template.yaml shares SessionSecret
299
398
  // with the cms container as AUTH_SECRET so both sides agree on it.
300
- ("GET", "/accounts/sso-token") => Some(accounts_sso_token_route(domain_ir, cookies, secret, client).await),
399
+ ("GET", "/accounts/sso-token") => {
400
+ Some(accounts_sso_token_route(domain_ir, cookies, secret, client).await)
401
+ }
301
402
 
302
403
  ("GET", "/auth/google") => match auth::authorization_url(&redirect_uri(), secret) {
303
404
  Ok(url) => Some(redirect(&url)),
304
- Err(e) => Some(respond(500, "text/plain", &format!("Google sign-in isn't configured: {e}"))),
405
+ Err(e) => Some(respond(
406
+ 500,
407
+ "text/plain",
408
+ &format!("Google sign-in isn't configured: {e}"),
409
+ )),
305
410
  },
306
411
 
307
- ("GET", "/auth/google/callback") => Some(google_callback(domain_ir, query, client, wasm_path, config, secret, invoker).await),
412
+ ("GET", "/auth/google/callback") => Some(
413
+ google_callback(domain_ir, query, client, wasm_path, config, secret, invoker).await,
414
+ ),
308
415
 
309
- ("GET", "/admin/members") => {
310
- let Some(session) = session else { return Some(redirect("/login")) };
311
- if !is_admin(client, wasm_path, domain_ir, &session.identity_id).await {
312
- return Some(html(403, "<p>Admins only.</p>"));
313
- }
314
- Some(html(200, &admin_members_page(client, domain_ir).await))
315
- }
416
+ ("GET" | "POST", "/admin/members") => Some(
417
+ admin_members_route(
418
+ method, domain_ir, raw_body, cookies, session, secret, client, wasm_path, config,
419
+ )
420
+ .await,
421
+ ),
316
422
 
317
- ("POST", "/admin/members") => {
318
- let Some(session) = session else { return Some(redirect("/login")) };
319
- if !is_admin(client, wasm_path, domain_ir, &session.identity_id).await {
320
- return Some(html(403, "<p>Admins only.</p>"));
321
- }
322
- let form = parse_form(raw_body);
323
- let email = form.get("email").cloned().unwrap_or_default();
324
- let role = form.get("role").cloned().unwrap_or_default();
325
- if !auth::GRANTABLE_ROLES.contains(&role.as_str()) {
326
- return Some(html(400, &format!("<p>Role must be one of {}.</p>", auth::GRANTABLE_ROLES.join(", "))));
327
- }
328
- match auth::grant_access(client, wasm_path, config, domain_ir, &email, &role).await {
329
- Ok(true) => Some(redirect("/admin/members")),
330
- Ok(false) => Some(html(404, "<p>No member with that email.</p>")),
331
- Err(e) => Some(html(500, &format!("<p>{}</p>", esc(&e.to_string())))),
332
- }
423
+ _ => None,
424
+ }
425
+ }
426
+
427
+ // Whether the caller may use /admin/members: an admin by the Governance
428
+ // session (the Google callback's `session` cookie) or by the account cookie
429
+ // (what platform sign-in and every other admin route carry). A site whose
430
+ // sign-in only sets the account cookie would otherwise loop back to /login.
431
+ async fn members_page_caller(
432
+ domain_ir: &Value,
433
+ cookies: &HashMap<String, String>,
434
+ session: Option<&Session>,
435
+ secret: &str,
436
+ client: &Mutex<Client>,
437
+ wasm_path: &Path,
438
+ ) -> Result<(), Value> {
439
+ if let Some(session) = session {
440
+ if is_admin(client, wasm_path, domain_ir, &session.identity_id).await {
441
+ return Ok(());
442
+ }
443
+ }
444
+ let account = cookies
445
+ .get(&auth::account_cookie_name())
446
+ .and_then(|token| auth::verify_account_token(secret, token));
447
+ if let Some(email) = &account {
448
+ if auth::caller_is_admin(client, domain_ir, email)
449
+ .await
450
+ .unwrap_or(false)
451
+ {
452
+ return Ok(());
333
453
  }
454
+ }
455
+ if session.is_none() && account.is_none() {
456
+ return Err(redirect("/login"));
457
+ }
458
+ Err(html(403, "<p>Admins only.</p>"))
459
+ }
334
460
 
335
- _ => None,
461
+ // GET /admin/members lists everyone and offers a form; POST from that form
462
+ // admits the person when they are new (named as typed, or by the part of the
463
+ // email before the @) and grants the chosen role.
464
+ #[allow(clippy::too_many_arguments)]
465
+ async fn admin_members_route(
466
+ method: &str,
467
+ domain_ir: &Value,
468
+ raw_body: &str,
469
+ cookies: &HashMap<String, String>,
470
+ session: Option<&Session>,
471
+ secret: &str,
472
+ client: &Mutex<Client>,
473
+ wasm_path: &Path,
474
+ config: &LineageConfig,
475
+ ) -> Value {
476
+ if let Err(response) =
477
+ members_page_caller(domain_ir, cookies, session, secret, client, wasm_path).await
478
+ {
479
+ return response;
480
+ }
481
+ if method == "GET" {
482
+ return html(200, &admin_members_page(client, domain_ir).await);
483
+ }
484
+
485
+ let form = parse_form(raw_body);
486
+ let email = form
487
+ .get("email")
488
+ .map(|s| s.trim().to_lowercase())
489
+ .unwrap_or_default();
490
+ let role = form.get("role").cloned().unwrap_or_default();
491
+ if !auth::GRANTABLE_ROLES.contains(&role.as_str()) {
492
+ return html(
493
+ 400,
494
+ &format!(
495
+ "<p>Role must be one of {}.</p>",
496
+ auth::GRANTABLE_ROLES.join(", ")
497
+ ),
498
+ );
499
+ }
500
+ if !email
501
+ .split_once('@')
502
+ .is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.'))
503
+ {
504
+ return html(400, "<p>Give a valid email.</p>");
505
+ }
506
+ let name = form
507
+ .get("name")
508
+ .map(|s| s.trim().to_string())
509
+ .filter(|s| !s.is_empty())
510
+ .unwrap_or_else(|| email.split('@').next().unwrap_or_default().to_string());
511
+ // Ok(false) is "already admitted": the grant below is then all there is to do.
512
+ if let Err(e) = auth::admit_person(client, config, domain_ir, &email, &name).await {
513
+ return html(500, &format!("<p>{}</p>", esc(&e.to_string())));
514
+ }
515
+ match auth::grant_access(client, wasm_path, config, domain_ir, &email, &role).await {
516
+ Ok(true) => redirect("/admin/members"),
517
+ Ok(false) => html(404, "<p>No member with that email.</p>"),
518
+ Err(e) => html(500, &format!("<p>{}</p>", esc(&e.to_string()))),
336
519
  }
337
520
  }
338
521
 
339
- async fn is_admin(client: &Mutex<Client>, wasm_path: &Path, domain_ir: &Value, identity_id: &str) -> bool {
522
+ async fn is_admin(
523
+ client: &Mutex<Client>,
524
+ wasm_path: &Path,
525
+ domain_ir: &Value,
526
+ identity_id: &str,
527
+ ) -> bool {
340
528
  let provider = crate::ir::authorization_provider(domain_ir);
341
- match dispatch::read(client, wasm_path).await {
342
- Ok(read) => auth::holds_admin(read.get("instances").unwrap_or(&json!({})), identity_id, provider.as_ref()),
343
- Err(_) => false,
344
- }
529
+ dispatch::read(client, wasm_path).await.is_ok_and(|read| {
530
+ auth::holds_admin(
531
+ read.get("instances").unwrap_or(&json!({})),
532
+ identity_id,
533
+ provider.as_ref(),
534
+ )
535
+ })
345
536
  }
346
537
 
347
538
  // Verifies the token AND that the person still has access, so a
@@ -352,22 +543,48 @@ async fn active_session_email(
352
543
  secret: &str,
353
544
  client: &Mutex<Client>,
354
545
  ) -> Result<String, Value> {
355
- let not_logged_in = || respond(401, "application/json", &json!({"error": "not logged in"}).to_string());
356
- let Some(email) = cookies.get(&auth::account_cookie_name()).and_then(|token| auth::verify_account_token(secret, token)) else {
546
+ let not_logged_in = || {
547
+ respond(
548
+ 401,
549
+ "application/json",
550
+ &json!({"error": "not logged in"}).to_string(),
551
+ )
552
+ };
553
+ let Some(email) = cookies
554
+ .get(&auth::account_cookie_name())
555
+ .and_then(|token| auth::verify_account_token(secret, token))
556
+ else {
357
557
  return Err(not_logged_in());
358
558
  };
359
559
  match auth::has_access(client, domain_ir, &email).await {
360
560
  Ok(true) => Ok(email),
361
561
  Ok(false) => Err(not_logged_in()),
362
- Err(e) => Err(respond(500, "application/json", &json!({"error": format!("members lookup failed: {e}")}).to_string())),
562
+ Err(e) => Err(respond(
563
+ 500,
564
+ "application/json",
565
+ &json!({"error": format!("members lookup failed: {e}")}).to_string(),
566
+ )),
363
567
  }
364
568
  }
365
569
 
366
- async fn accounts_me_route(domain_ir: &Value, cookies: &HashMap<String, String>, secret: &str, client: &Mutex<Client>) -> Value {
570
+ async fn accounts_me_route(
571
+ domain_ir: &Value,
572
+ cookies: &HashMap<String, String>,
573
+ secret: &str,
574
+ client: &Mutex<Client>,
575
+ ) -> Value {
367
576
  match active_session_email(domain_ir, cookies, secret, client).await {
368
577
  Ok(email) => match auth::member_identity_id(client, domain_ir, &email).await {
369
- Ok(identity_id) => respond(200, "application/json", &json!({"email": email, "identity_id": identity_id}).to_string()),
370
- Err(e) => respond(500, "application/json", &json!({"error": format!("members lookup failed: {e}")}).to_string()),
578
+ Ok(identity_id) => respond(
579
+ 200,
580
+ "application/json",
581
+ &json!({"email": email, "identity_id": identity_id}).to_string(),
582
+ ),
583
+ Err(e) => respond(
584
+ 500,
585
+ "application/json",
586
+ &json!({"error": format!("members lookup failed: {e}")}).to_string(),
587
+ ),
371
588
  },
372
589
  Err(response) => response,
373
590
  }
@@ -376,13 +593,27 @@ async fn accounts_me_route(domain_ir: &Value, cookies: &HashMap<String, String>,
376
593
  // GET /members: admitted people as JSON, for an Admin or Owner holding the
377
594
  // account cookie but not the Governance session /admin/members needs. The list
378
595
  // is every person's name, email and role, so a plain member does not get it.
379
- async fn members_route(domain_ir: &Value, cookies: &HashMap<String, String>, secret: &str, client: &Mutex<Client>) -> Value {
380
- if let Err(response) = newsletter_send::require_admin(domain_ir, cookies, secret, client).await {
596
+ async fn members_route(
597
+ domain_ir: &Value,
598
+ cookies: &HashMap<String, String>,
599
+ secret: &str,
600
+ client: &Mutex<Client>,
601
+ ) -> Value {
602
+ if let Err(response) = newsletter_send::require_admin(domain_ir, cookies, secret, client).await
603
+ {
381
604
  return response;
382
605
  }
383
606
  match auth::all_people(client, domain_ir).await {
384
- Ok(people) => respond(200, "application/json", &Value::Array(sorted_by_name(people)).to_string()),
385
- Err(e) => respond(500, "application/json", &json!({"error": format!("members lookup failed: {e}")}).to_string()),
607
+ Ok(people) => respond(
608
+ 200,
609
+ "application/json",
610
+ &Value::Array(sorted_by_name(people)).to_string(),
611
+ ),
612
+ Err(e) => respond(
613
+ 500,
614
+ "application/json",
615
+ &json!({"error": format!("members lookup failed: {e}")}).to_string(),
616
+ ),
386
617
  }
387
618
  }
388
619
 
@@ -397,9 +628,10 @@ pub(crate) async fn add_member_route(
397
628
  wasm_path: &Path,
398
629
  config: &LineageConfig,
399
630
  ) -> Value {
400
- let json_error = |status: u16, message: &str| respond(status, "application/json", &json!({"error": message}).to_string());
401
-
402
- let Some(caller) = cookies.get(&auth::account_cookie_name()).and_then(|token| auth::verify_account_token(secret, token)) else {
631
+ let Some(caller) = cookies
632
+ .get(&auth::account_cookie_name())
633
+ .and_then(|token| auth::verify_account_token(secret, token))
634
+ else {
403
635
  return json_error(401, "not logged in");
404
636
  };
405
637
  match auth::caller_is_admin(client, domain_ir, &caller).await {
@@ -409,18 +641,31 @@ pub(crate) async fn add_member_route(
409
641
  }
410
642
 
411
643
  let body: Value = serde_json::from_str(raw_body).unwrap_or(Value::Null);
412
- let field = |key: &str| body.get(key).and_then(|v| v.as_str()).map(|s| s.trim().to_string()).unwrap_or_default();
644
+ let field = |key: &str| {
645
+ body.get(key)
646
+ .and_then(|v| v.as_str())
647
+ .map(|s| s.trim().to_string())
648
+ .unwrap_or_default()
649
+ };
413
650
  let (email, name) = (field("email"), field("name"));
414
- let looks_like_email = email.split_once('@').is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.') && !email.contains(char::is_whitespace));
651
+ let looks_like_email = email.split_once('@').is_some_and(|(local, domain)| {
652
+ !local.is_empty() && domain.contains('.') && !email.contains(char::is_whitespace)
653
+ });
415
654
  if name.is_empty() || !looks_like_email {
416
655
  return json_error(400, "a name and a valid email are required");
417
656
  }
418
657
  let role = match body.get("role") {
419
658
  None | Some(Value::Null) => "Admin".to_string(),
420
- Some(value) => value.as_str().map(|s| s.trim().to_string()).unwrap_or_default(),
659
+ Some(value) => value
660
+ .as_str()
661
+ .map(|s| s.trim().to_string())
662
+ .unwrap_or_default(),
421
663
  };
422
664
  if !auth::GRANTABLE_ROLES.contains(&role.as_str()) {
423
- return json_error(400, &format!("role must be one of {}", auth::GRANTABLE_ROLES.join(", ")));
665
+ return json_error(
666
+ 400,
667
+ &format!("role must be one of {}", auth::GRANTABLE_ROLES.join(", ")),
668
+ );
424
669
  }
425
670
 
426
671
  match auth::admit_person(client, config, domain_ir, &email, &name).await {
@@ -445,32 +690,46 @@ const SIGNUP_PURPOSE: &str = "signup";
445
690
 
446
691
  // POST /signups: dispatches Signups::Signup.SignUp as System, gated by
447
692
  // a signup-purpose token rather than a session cookie (nobody is signed in yet).
448
- async fn signup_route(raw_body: &str, secret: &str, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, invoker: &dyn LambdaInvoker) -> Value {
449
- let json_error = |status: u16, message: &str| respond(status, "application/json", &json!({"error": message}).to_string());
450
-
693
+ async fn signup_route(raw_body: &str, secret: &str, host: Host<'_>) -> Value {
451
694
  let body: Value = serde_json::from_str(raw_body).unwrap_or(Value::Null);
452
- let field = |key: &str| body.get(key).and_then(|v| v.as_str()).map(|s| s.trim().to_string()).unwrap_or_default();
695
+ let field = |key: &str| {
696
+ body.get(key)
697
+ .and_then(|v| v.as_str())
698
+ .map(|s| s.trim().to_string())
699
+ .unwrap_or_default()
700
+ };
453
701
 
454
702
  if auth::verify_purpose_token(secret, SIGNUP_PURPOSE, &field("token")).is_none() {
455
703
  return json_error(401, "not authorized");
456
704
  }
457
705
 
458
706
  let (email, name) = (field("email").to_lowercase(), field("name"));
459
- let looks_like_email = email.split_once('@').is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.') && !email.contains(char::is_whitespace));
707
+ let looks_like_email = email.split_once('@').is_some_and(|(local, domain)| {
708
+ !local.is_empty() && domain.contains('.') && !email.contains(char::is_whitespace)
709
+ });
460
710
  if name.is_empty() || !looks_like_email {
461
711
  return json_error(400, "a name and a valid email are required");
462
712
  }
463
713
 
464
714
  let args = json!({"email": {"value": email}, "name": {"value": name}});
465
- let outcome = match dispatch::handle(client, wasm_path, "Signups::Signup.SignUp", args, Some("System"), config, invoker).await {
715
+ let outcome = match dispatch::handle(host, "Signups::Signup.SignUp", args, Some("System")).await
716
+ {
466
717
  Ok(o) => o,
467
718
  Err(e) => return json_error(500, &format!("{e:#}")),
468
719
  };
469
720
  if !outcome.accepted {
470
- return respond(422, "application/json", &last_refusal(&outcome.result).to_string());
721
+ return respond(
722
+ 422,
723
+ "application/json",
724
+ &last_refusal(&outcome.result).to_string(),
725
+ );
471
726
  }
472
727
 
473
- respond(201, "application/json", &json!({"email": email, "name": name}).to_string())
728
+ respond(
729
+ 201,
730
+ "application/json",
731
+ &json!({"email": email, "name": name}).to_string(),
732
+ )
474
733
  }
475
734
 
476
735
  // POST /members/disable|enable: turns access off/on without deleting
@@ -484,14 +743,19 @@ async fn set_member_disabled_route(
484
743
  config: &LineageConfig,
485
744
  disable: bool,
486
745
  ) -> Value {
487
- let json_error = |status: u16, message: &str| respond(status, "application/json", &json!({"error": message}).to_string());
488
-
489
- let Some(caller) = cookies.get(&auth::account_cookie_name()).and_then(|token| auth::verify_account_token(secret, token)) else {
746
+ let Some(caller) = cookies
747
+ .get(&auth::account_cookie_name())
748
+ .and_then(|token| auth::verify_account_token(secret, token))
749
+ else {
490
750
  return json_error(401, "not logged in");
491
751
  };
492
752
 
493
753
  let body: Value = serde_json::from_str(raw_body).unwrap_or(Value::Null);
494
- let email = body.get("email").and_then(|v| v.as_str()).map(|s| s.trim().to_string()).unwrap_or_default();
754
+ let email = body
755
+ .get("email")
756
+ .and_then(|v| v.as_str())
757
+ .map(|s| s.trim().to_string())
758
+ .unwrap_or_default();
495
759
  if email.is_empty() {
496
760
  return json_error(400, "an email is required");
497
761
  }
@@ -499,12 +763,20 @@ async fn set_member_disabled_route(
499
763
  match auth::set_person_disabled(client, config, domain_ir, &caller, &email, disable).await {
500
764
  Ok(auth::DisableOutcome::Done) => {
501
765
  let key = if disable { "disabled" } else { "enabled" };
502
- respond(200, "application/json", &json!({key: email.to_lowercase()}).to_string())
766
+ respond(
767
+ 200,
768
+ "application/json",
769
+ &json!({key: email.to_lowercase()}).to_string(),
770
+ )
503
771
  }
504
772
  Ok(auth::DisableOutcome::CallerNotAdmin) => json_error(403, "admins only"),
505
- Ok(auth::DisableOutcome::SelfDisable) => json_error(403, "you can't disable your own admin access"),
773
+ Ok(auth::DisableOutcome::SelfDisable) => {
774
+ json_error(403, "you can't disable your own admin access")
775
+ }
506
776
  Ok(auth::DisableOutcome::UnknownPerson) => json_error(404, "no member with that email"),
507
- Ok(auth::DisableOutcome::LastAdmin) => json_error(409, "there must always be at least one admin"),
777
+ Ok(auth::DisableOutcome::LastAdmin) => {
778
+ json_error(409, "there must always be at least one admin")
779
+ }
508
780
  Err(e) => json_error(500, &format!("members update failed: {e}")),
509
781
  }
510
782
  }
@@ -521,20 +793,29 @@ async fn set_member_deleted_route(
521
793
  client: &Mutex<Client>,
522
794
  config: &LineageConfig,
523
795
  ) -> Value {
524
- let json_error = |status: u16, message: &str| respond(status, "application/json", &json!({"error": message}).to_string());
525
-
526
- let Some(caller) = cookies.get(&auth::account_cookie_name()).and_then(|token| auth::verify_account_token(secret, token)) else {
796
+ let Some(caller) = cookies
797
+ .get(&auth::account_cookie_name())
798
+ .and_then(|token| auth::verify_account_token(secret, token))
799
+ else {
527
800
  return json_error(401, "not logged in");
528
801
  };
529
802
 
530
803
  let body: Value = serde_json::from_str(raw_body).unwrap_or(Value::Null);
531
- let email = body.get("email").and_then(|v| v.as_str()).map(|s| s.trim().to_string()).unwrap_or_default();
804
+ let email = body
805
+ .get("email")
806
+ .and_then(|v| v.as_str())
807
+ .map(|s| s.trim().to_string())
808
+ .unwrap_or_default();
532
809
  if email.is_empty() {
533
810
  return json_error(400, "an email is required");
534
811
  }
535
812
 
536
813
  match auth::set_person_deleted(client, config, domain_ir, &caller, &email).await {
537
- Ok(auth::DeleteOutcome::Done) => respond(200, "application/json", &json!({"deleted": email.to_lowercase()}).to_string()),
814
+ Ok(auth::DeleteOutcome::Done) => respond(
815
+ 200,
816
+ "application/json",
817
+ &json!({"deleted": email.to_lowercase()}).to_string(),
818
+ ),
538
819
  Ok(auth::DeleteOutcome::CallerNotAdmin) => json_error(403, "admins only"),
539
820
  Ok(auth::DeleteOutcome::UnknownPerson) => json_error(404, "no member with that email"),
540
821
  Ok(auth::DeleteOutcome::NotDisabled) => json_error(409, "disable this admin first"),
@@ -552,27 +833,42 @@ async fn set_member_role_route(
552
833
  client: &Mutex<Client>,
553
834
  config: &LineageConfig,
554
835
  ) -> Value {
555
- let json_error = |status: u16, message: &str| respond(status, "application/json", &json!({"error": message}).to_string());
556
-
557
- let Some(caller) = cookies.get(&auth::account_cookie_name()).and_then(|token| auth::verify_account_token(secret, token)) else {
836
+ let Some(caller) = cookies
837
+ .get(&auth::account_cookie_name())
838
+ .and_then(|token| auth::verify_account_token(secret, token))
839
+ else {
558
840
  return json_error(401, "not logged in");
559
841
  };
560
842
 
561
843
  let body: Value = serde_json::from_str(raw_body).unwrap_or(Value::Null);
562
- let field = |key: &str| body.get(key).and_then(|v| v.as_str()).map(|s| s.trim().to_string()).unwrap_or_default();
844
+ let field = |key: &str| {
845
+ body.get(key)
846
+ .and_then(|v| v.as_str())
847
+ .map(|s| s.trim().to_string())
848
+ .unwrap_or_default()
849
+ };
563
850
  let (email, role) = (field("email"), field("role"));
564
851
  if email.is_empty() {
565
852
  return json_error(400, "an email is required");
566
853
  }
567
854
  if !auth::GRANTABLE_ROLES.contains(&role.as_str()) {
568
- return json_error(400, &format!("role must be one of {}", auth::GRANTABLE_ROLES.join(", ")));
855
+ return json_error(
856
+ 400,
857
+ &format!("role must be one of {}", auth::GRANTABLE_ROLES.join(", ")),
858
+ );
569
859
  }
570
860
 
571
861
  match auth::set_person_role(client, config, domain_ir, &caller, &email, &role).await {
572
- Ok(auth::RoleOutcome::Done) => respond(200, "application/json", &json!({"email": email.to_lowercase(), "role": role}).to_string()),
862
+ Ok(auth::RoleOutcome::Done) => respond(
863
+ 200,
864
+ "application/json",
865
+ &json!({"email": email.to_lowercase(), "role": role}).to_string(),
866
+ ),
573
867
  Ok(auth::RoleOutcome::CallerNotAdmin) => json_error(403, "admins only"),
574
868
  Ok(auth::RoleOutcome::UnknownPerson) => json_error(404, "no member with that email"),
575
- Ok(auth::RoleOutcome::LastAdmin) => json_error(409, "there must always be at least one admin"),
869
+ Ok(auth::RoleOutcome::LastAdmin) => {
870
+ json_error(409, "there must always be at least one admin")
871
+ }
576
872
  Err(e) => json_error(500, &format!("members update failed: {e}")),
577
873
  }
578
874
  }
@@ -587,9 +883,10 @@ async fn registrations_list_route(
587
883
  wasm_path: &Path,
588
884
  config: &LineageConfig,
589
885
  ) -> Value {
590
- let json_error = |status: u16, message: &str| respond(status, "application/json", &json!({"error": message}).to_string());
591
-
592
- let Some(caller) = cookies.get(&auth::account_cookie_name()).and_then(|token| auth::verify_account_token(secret, token)) else {
886
+ let Some(caller) = cookies
887
+ .get(&auth::account_cookie_name())
888
+ .and_then(|token| auth::verify_account_token(secret, token))
889
+ else {
593
890
  return json_error(401, "not logged in");
594
891
  };
595
892
  match auth::caller_is_admin(client, domain_ir, &caller).await {
@@ -602,7 +899,11 @@ async fn registrations_list_route(
602
899
  Ok(read) => read,
603
900
  Err(e) => return json_error(500, &format!("registrations lookup failed: {e:#}")),
604
901
  };
605
- respond(200, "application/json", &Value::Array(registration_list_rows(&read, &config.domain)).to_string())
902
+ respond(
903
+ 200,
904
+ "application/json",
905
+ &Value::Array(registration_list_rows(&read, &config.domain)).to_string(),
906
+ )
606
907
  }
607
908
 
608
909
  // Built field-by-field from an allowlist so intake answers (health,
@@ -613,7 +914,8 @@ fn registration_list_rows(read: &Value, domain: &str) -> Vec<Value> {
613
914
  let value = value?;
614
915
  Some(value.get("value").cloned().unwrap_or_else(|| value.clone()))
615
916
  };
616
- let text = |value: Option<&Value>| plain(value).and_then(|v| v.as_str().map(|s| s.trim().to_string()));
917
+ let text =
918
+ |value: Option<&Value>| plain(value).and_then(|v| v.as_str().map(|s| s.trim().to_string()));
617
919
 
618
920
  let mut rows: Vec<(Option<String>, Value)> = instances_for(read, &binding.registration_prefix())
619
921
  .into_iter()
@@ -642,24 +944,36 @@ fn registration_list_rows(read: &Value, domain: &str) -> Vec<Value> {
642
944
  }
643
945
 
644
946
  fn sorted_by_name(mut people: Vec<Value>) -> Vec<Value> {
645
- let name_of = |p: &Value| p.get("name").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
947
+ let name_of = |p: &Value| {
948
+ p.get("name")
949
+ .and_then(|v| v.as_str())
950
+ .unwrap_or("")
951
+ .to_lowercase()
952
+ };
646
953
  people.sort_by_key(name_of);
647
954
  people
648
955
  }
649
956
 
650
957
  // Mints a 60s handoff token cms/src/endpoints/sso.ts exchanges for a
651
958
  // real Payload session — one-shot, so it's returned as JSON, not a cookie.
652
- async fn accounts_sso_token_route(domain_ir: &Value, cookies: &HashMap<String, String>, secret: &str, client: &Mutex<Client>) -> Value {
959
+ async fn accounts_sso_token_route(
960
+ domain_ir: &Value,
961
+ cookies: &HashMap<String, String>,
962
+ secret: &str,
963
+ client: &Mutex<Client>,
964
+ ) -> Value {
653
965
  const SSO_TOKEN_TTL_SECS: u64 = 60;
654
966
  let email = match active_session_email(domain_ir, cookies, secret, client).await {
655
967
  Ok(email) => email,
656
968
  Err(response) => return response,
657
969
  };
658
970
  let sso_token = auth::account_token(secret, &email, SSO_TOKEN_TTL_SECS);
659
- respond(200, "application/json", &json!({"token": sso_token}).to_string())
971
+ respond(
972
+ 200,
973
+ "application/json",
974
+ &json!({"token": sso_token}).to_string(),
975
+ )
660
976
  }
661
-
662
- #[allow(clippy::too_many_arguments)]
663
977
  async fn google_callback(
664
978
  domain_ir: &Value,
665
979
  query: &HashMap<String, String>,
@@ -701,30 +1015,47 @@ async fn google_callback(
701
1015
 
702
1016
  // PostgresEra head first — Identity is not in the WASM journal.
703
1017
  // Journal `instances` is only a fallback for Lambda-era rows.
704
- let identity_id = match auth::resolve_identity_from_head(client, domain_ir, &claims.issuer, &claims.subject).await {
705
- Ok(found) => found,
706
- Err(e) => {
707
- eprintln!("google_callback: identity head: {e:#}");
708
- None
709
- }
710
- };
711
- let identity_id = identity_id.or_else(|| auth::resolve_identity(&instances, &claims.issuer, &claims.subject));
712
-
713
- let session = match identity_id {
714
- Some(ref identity_id) => match auth::session_for_member_by_identity(client, domain_ir, identity_id).await {
715
- Ok(s) => s,
1018
+ let identity_id =
1019
+ match auth::resolve_identity_from_head(client, domain_ir, &claims.issuer, &claims.subject)
1020
+ .await
1021
+ {
1022
+ Ok(found) => found,
716
1023
  Err(e) => {
717
- eprintln!("google_callback: member by identity: {e:#}");
1024
+ eprintln!("google_callback: identity head: {e:#}");
718
1025
  None
719
1026
  }
720
- },
1027
+ };
1028
+ let identity_id =
1029
+ identity_id.or_else(|| auth::resolve_identity(&instances, &claims.issuer, &claims.subject));
1030
+
1031
+ let session = match identity_id {
1032
+ Some(ref identity_id) => {
1033
+ match auth::session_for_member_by_identity(client, domain_ir, identity_id).await {
1034
+ Ok(s) => s,
1035
+ Err(e) => {
1036
+ eprintln!("google_callback: member by identity: {e:#}");
1037
+ None
1038
+ }
1039
+ }
1040
+ }
721
1041
  None => None,
722
1042
  };
723
1043
  let session = match session {
724
1044
  Some(s) => Some(s),
725
1045
  None if claims.email_verified => {
726
1046
  let email = claims.email.clone().unwrap_or_default();
727
- match auth::provision(client, wasm_path, config, domain_ir, &email, &claims.issuer, &claims.subject, invoker).await {
1047
+ match auth::provision(
1048
+ client,
1049
+ wasm_path,
1050
+ config,
1051
+ domain_ir,
1052
+ &email,
1053
+ &claims.issuer,
1054
+ &claims.subject,
1055
+ invoker,
1056
+ )
1057
+ .await
1058
+ {
728
1059
  Ok(s) => s,
729
1060
  Err(e) => {
730
1061
  eprintln!("google_callback: provision: {e:#}");
@@ -735,20 +1066,31 @@ async fn google_callback(
735
1066
  None => None,
736
1067
  };
737
1068
 
738
- let Some(session) = session else { return redirect("/login?error=google_unlinked") };
1069
+ let Some(session) = session else {
1070
+ return redirect("/login?error=google_unlinked");
1071
+ };
739
1072
 
740
1073
  // The site's admin authenticates on the account cookie, not this
741
1074
  // host's own session cookie. Cross-origin, a cookie here would never
742
1075
  // reach the site, so the token is handed across via /api/google-handoff.
743
- let site = std::env::var("SITE_URL").unwrap_or_else(|_| "http://localhost:4321".to_string());
1076
+ let site = site_url();
744
1077
  let site = site.trim_end_matches('/');
745
1078
  let token = auth::account_token(secret, &session.email, auth::SESSION_TTL_SECS);
746
1079
  if same_origin(site, &redirect_uri()) {
747
- let cookie = format!("{}={token}{}; Max-Age={}", auth::account_cookie_name(), cookie_flags(), auth::SESSION_TTL_SECS);
1080
+ let cookie = format!(
1081
+ "{}={token}{}; Max-Age={}",
1082
+ auth::account_cookie_name(),
1083
+ cookie_flags(),
1084
+ auth::SESSION_TTL_SECS
1085
+ );
748
1086
  redirect_with_cookie("/admin.html", &cookie)
749
1087
  } else {
750
1088
  // One-redirect URL onto the Astro origin (local rust/host :4567 vs site :4321).
751
- redirect(&format!("{}/api/google-handoff?token={}", site, auth::urlencode(&token)))
1089
+ redirect(&format!(
1090
+ "{}/api/google-handoff?token={}",
1091
+ site,
1092
+ auth::urlencode(&token)
1093
+ ))
752
1094
  }
753
1095
  }
754
1096
 
@@ -757,18 +1099,28 @@ fn same_origin(a: &str, b: &str) -> bool {
757
1099
  }
758
1100
 
759
1101
  fn origin_of(url: &str) -> String {
760
- let rest = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
1102
+ let rest = url.split_once("://").map_or(url, |(_, r)| r);
761
1103
  rest.split('/').next().unwrap_or(rest).to_string()
762
1104
  }
763
1105
 
764
1106
  const ERROR_MESSAGES: &[(&str, &str)] = &[
765
- ("google_unlinked", "That Google account isn't recognized here."),
766
- ("google_failed", "Google sign-in didn't go through. Try again."),
1107
+ (
1108
+ "google_unlinked",
1109
+ "That Google account isn't recognized here.",
1110
+ ),
1111
+ (
1112
+ "google_failed",
1113
+ "Google sign-in didn't go through. Try again.",
1114
+ ),
767
1115
  ];
768
1116
 
769
1117
  fn login_page(error: Option<&str>) -> String {
770
- let message = error.and_then(|e| ERROR_MESSAGES.iter().find(|(k, _)| *k == e)).map(|(_, m)| *m);
771
- let error_html = message.map(|m| format!(r#"<p class="text-red-600 text-sm mb-4">{}</p>"#, esc(m))).unwrap_or_default();
1118
+ let message = error
1119
+ .and_then(|e| ERROR_MESSAGES.iter().find(|(k, _)| *k == e))
1120
+ .map(|(_, m)| *m);
1121
+ let error_html = message
1122
+ .map(|m| format!(r#"<p class="text-red-600 text-sm mb-4">{}</p>"#, esc(m)))
1123
+ .unwrap_or_default();
772
1124
  format!(
773
1125
  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>
774
1126
  <body class="bg-slate-50 text-slate-900 min-h-screen flex items-center justify-center">
@@ -782,16 +1134,18 @@ fn login_page(error: Option<&str>) -> String {
782
1134
  }
783
1135
 
784
1136
  async fn admin_members_page(client: &Mutex<Client>, domain_ir: &Value) -> String {
785
- let people = auth::all_people(client, domain_ir).await.unwrap_or_default();
1137
+ let people = auth::all_people(client, domain_ir)
1138
+ .await
1139
+ .unwrap_or_default();
786
1140
  let rows: String = people
787
1141
  .iter()
788
1142
  .map(|p| {
789
1143
  let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("");
790
1144
  let email = p.get("email").and_then(|v| v.as_str()).unwrap_or("");
791
1145
  let role = p.get("role").and_then(|v| v.as_str()).unwrap_or("—");
792
- let access = if p.get("linked").and_then(|v| v.as_bool()) == Some(true) {
1146
+ let access = if p.get("linked").and_then(serde_json::Value::as_bool) == Some(true) {
793
1147
  "linked"
794
- } else if p.get("granted").and_then(|v| v.as_bool()) == Some(true) {
1148
+ } else if p.get("granted").and_then(serde_json::Value::as_bool) == Some(true) {
795
1149
  "granted, not yet signed in"
796
1150
  } else {
797
1151
  "no access"
@@ -801,7 +1155,8 @@ async fn admin_members_page(client: &Mutex<Client>, domain_ir: &Value) -> String
801
1155
  esc(name), esc(email), esc(role), esc(access)
802
1156
  )
803
1157
  })
804
- .collect();
1158
+ .collect::<Vec<_>>()
1159
+ .concat();
805
1160
 
806
1161
  format!(
807
1162
  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>
@@ -810,7 +1165,8 @@ async fn admin_members_page(client: &Mutex<Client>, domain_ir: &Value) -> String
810
1165
  <h1 class="text-xl font-semibold mb-4">Members</h1>
811
1166
  <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>
812
1167
  <form method="post" action="/admin/members" class="flex gap-2 items-center">
813
- <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" />
1168
+ <input type="text" name="name" placeholder="Name" class="w-40 border border-slate-300 rounded-md px-3 py-2 text-sm" />
1169
+ <input type="email" name="email" placeholder="Email (their Google account)" required class="flex-1 border border-slate-300 rounded-md px-3 py-2 text-sm" />
814
1170
  <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>
815
1171
  <button type="submit" class="px-4 py-2 bg-slate-900 text-white rounded-md text-sm">Grant access</button>
816
1172
  </form></main></body></html>"#
@@ -818,15 +1174,27 @@ async fn admin_members_page(client: &Mutex<Client>, domain_ir: &Value) -> String
818
1174
  }
819
1175
 
820
1176
  fn find_aggregate<'a>(domain_ir: &'a Value, name: &str) -> Option<&'a Value> {
821
- domain_ir.get("aggregates")?.as_array()?.iter().find(|a| a.get("name").and_then(|v| v.as_str()) == Some(name))
1177
+ domain_ir
1178
+ .get("aggregates")?
1179
+ .as_array()?
1180
+ .iter()
1181
+ .find(|a| a.get("name").and_then(|v| v.as_str()) == Some(name))
822
1182
  }
823
1183
 
824
1184
  fn find_command<'a>(aggregate: &'a Value, name: &str) -> Option<&'a Value> {
825
- aggregate.get("commands")?.as_array()?.iter().find(|c| c.get("name").and_then(|v| v.as_str()) == Some(name))
1185
+ aggregate
1186
+ .get("commands")?
1187
+ .as_array()?
1188
+ .iter()
1189
+ .find(|c| c.get("name").and_then(|v| v.as_str()) == Some(name))
826
1190
  }
827
1191
 
828
1192
  fn find_value_object<'a>(aggregate: &'a Value, name: &str) -> Option<&'a Value> {
829
- aggregate.get("value_objects")?.as_array()?.iter().find(|v| v.get("name").and_then(|v| v.as_str()) == Some(name))
1193
+ aggregate
1194
+ .get("value_objects")?
1195
+ .as_array()?
1196
+ .iter()
1197
+ .find(|v| v.get("name").and_then(|v| v.as_str()) == Some(name))
830
1198
  }
831
1199
 
832
1200
  fn agg_name(aggregate: &Value) -> &str {
@@ -837,7 +1205,11 @@ fn identity_paths(aggregate: &Value) -> Vec<String> {
837
1205
  aggregate
838
1206
  .get("identified_by")
839
1207
  .and_then(|v| v.as_array())
840
- .map(|a| a.iter().filter_map(|p| p.as_str().map(String::from)).collect())
1208
+ .map(|a| {
1209
+ a.iter()
1210
+ .filter_map(|p| p.as_str().map(String::from))
1211
+ .collect()
1212
+ })
841
1213
  .unwrap_or_default()
842
1214
  }
843
1215
 
@@ -853,9 +1225,13 @@ struct Field {
853
1225
 
854
1226
  #[derive(Clone, Debug)]
855
1227
  enum FieldKind {
856
- Text { html_type: &'static str },
1228
+ Text {
1229
+ html_type: &'static str,
1230
+ },
857
1231
  Textarea,
858
- Number { step: &'static str },
1232
+ Number {
1233
+ step: &'static str,
1234
+ },
859
1235
  Boolean,
860
1236
  Radio(Vec<String>),
861
1237
  Select(Vec<String>),
@@ -902,9 +1278,18 @@ const PRIMITIVES: &[&str] = &["String", "Integer", "Float", "TrueClass", "FalseC
902
1278
  // Dispatch order: list? -> reference? -> admits -> value object ->
903
1279
  // primitive. `domain_ir` is the whole chapter, needed for cross-aggregate lookups.
904
1280
  fn resolve_field(domain_ir: &Value, attribute: &Value, aggregate: &Value, path: &str) -> Field {
905
- let is_list = attribute.get("list").and_then(|v| v.as_bool()).unwrap_or(false);
906
- let optional = attribute.get("optional").and_then(|v| v.as_bool()).unwrap_or(false);
907
- let ty = attribute.get("type").and_then(|v| v.as_str()).unwrap_or("String");
1281
+ let is_list = attribute
1282
+ .get("list")
1283
+ .and_then(serde_json::Value::as_bool)
1284
+ .unwrap_or(false);
1285
+ let optional = attribute
1286
+ .get("optional")
1287
+ .and_then(serde_json::Value::as_bool)
1288
+ .unwrap_or(false);
1289
+ let ty = attribute
1290
+ .get("type")
1291
+ .and_then(|v| v.as_str())
1292
+ .unwrap_or("String");
908
1293
 
909
1294
  if is_list {
910
1295
  let scalar = json!({
@@ -912,11 +1297,23 @@ fn resolve_field(domain_ir: &Value, attribute: &Value, aggregate: &Value, path:
912
1297
  "pattern": attribute.get("pattern"), "admits": attribute.get("admits"),
913
1298
  });
914
1299
  let item = resolve_field(domain_ir, &scalar, aggregate, path);
915
- return Field { path: path.to_string(), label: humanize(path), kind: FieldKind::List(Box::new(item)), optional };
1300
+ return Field {
1301
+ path: path.to_string(),
1302
+ label: humanize(path),
1303
+ kind: FieldKind::List(Box::new(item)),
1304
+ optional,
1305
+ };
916
1306
  }
917
1307
 
918
1308
  if let Some(target) = reference_target(ty) {
919
- return Field { path: path.to_string(), label: humanize(path), kind: FieldKind::Reference { target: Some(target) }, optional };
1309
+ return Field {
1310
+ path: path.to_string(),
1311
+ label: humanize(path),
1312
+ kind: FieldKind::Reference {
1313
+ target: Some(target),
1314
+ },
1315
+ optional,
1316
+ };
920
1317
  }
921
1318
 
922
1319
  if let Some(admits) = attribute.get("admits").and_then(|v| v.as_str()) {
@@ -936,25 +1333,40 @@ fn resolve_field(domain_ir: &Value, attribute: &Value, aggregate: &Value, path:
936
1333
  // `Reference<X>` -> `Some("X")`. `Reference<...>` is the IR's pinned
937
1334
  // export spelling (never a nested object) — mirrors naming.rb.
938
1335
  fn reference_target(ty: &str) -> Option<String> {
939
- ty.strip_prefix("Reference<")?.strip_suffix('>').map(String::from)
1336
+ ty.strip_prefix("Reference<")?
1337
+ .strip_suffix('>')
1338
+ .map(String::from)
940
1339
  }
941
1340
 
942
1341
  // Checked on the owning aggregate first, then across the whole domain.
943
- fn value_object_shape<'a>(domain_ir: &'a Value, aggregate: &'a Value, ty: &str) -> Option<&'a Value> {
1342
+ fn value_object_shape<'a>(
1343
+ domain_ir: &'a Value,
1344
+ aggregate: &'a Value,
1345
+ ty: &str,
1346
+ ) -> Option<&'a Value> {
944
1347
  find_value_object(aggregate, ty).or_else(|| cross_aggregate_value_object(domain_ir, ty))
945
1348
  }
946
1349
 
947
1350
  // Walks the whole domain, not just siblings — a value object's shape
948
1351
  // may be declared on a different aggregate than the one referencing it.
949
1352
  fn cross_aggregate_value_object<'a>(domain_ir: &'a Value, type_name: &str) -> Option<&'a Value> {
950
- domain_ir.get("aggregates")?.as_array()?.iter().find_map(|sibling| find_value_object(sibling, type_name))
1353
+ domain_ir
1354
+ .get("aggregates")?
1355
+ .as_array()?
1356
+ .iter()
1357
+ .find_map(|sibling| find_value_object(sibling, type_name))
951
1358
  }
952
1359
 
953
1360
  // Shared by the native one_of branch and the admits: branch below, so
954
1361
  // neither carries its own drifting copy of this reading.
955
1362
  fn closed_set_members(vo: &Value) -> (String, Vec<String>) {
956
1363
  let attrs = vo.get("attributes").and_then(|v| v.as_array());
957
- 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();
1364
+ let discriminant = attrs
1365
+ .and_then(|a| a.first())
1366
+ .and_then(|a| a.get("name"))
1367
+ .and_then(|v| v.as_str())
1368
+ .unwrap_or("value")
1369
+ .to_string();
958
1370
  let members = vo
959
1371
  .get("members")
960
1372
  .and_then(|v| v.as_array())
@@ -981,21 +1393,42 @@ fn closed_set_members(vo: &Value) -> (String, Vec<String>) {
981
1393
  // to `.path` afterward without touching `label`, which is set here.
982
1394
  fn select_or_radio(path: &str, optional: bool, members: Vec<String>) -> Field {
983
1395
  let label = humanize(path);
984
- let kind = if members.len() <= 4 { FieldKind::Radio(members) } else { FieldKind::Select(members) };
985
- Field { path: path.to_string(), label, kind, optional }
1396
+ let kind = if members.len() <= 4 {
1397
+ FieldKind::Radio(members)
1398
+ } else {
1399
+ FieldKind::Select(members)
1400
+ };
1401
+ Field {
1402
+ path: path.to_string(),
1403
+ label,
1404
+ kind,
1405
+ optional,
1406
+ }
986
1407
  }
987
1408
 
988
1409
  // A closed set declared elsewhere (e.g. "Account::LedgerDirection"),
989
1410
  // rendered the same way the native one_of branch renders its own.
990
- fn admitted_field(domain_ir: &Value, aggregate: &Value, attribute: &Value, admits: &str, ty: &str, path: &str, optional: bool) -> Field {
1411
+ fn admitted_field(
1412
+ domain_ir: &Value,
1413
+ aggregate: &Value,
1414
+ attribute: &Value,
1415
+ admits: &str,
1416
+ ty: &str,
1417
+ path: &str,
1418
+ optional: bool,
1419
+ ) -> Field {
991
1420
  let mut parts = admits.splitn(2, "::");
992
1421
  let set_aggregate_name = parts.next().unwrap_or("");
993
1422
  let set_name = parts.next();
994
1423
 
995
- let set = set_name.and_then(|name| find_aggregate(domain_ir, set_aggregate_name).and_then(|agg| find_value_object(agg, name)));
1424
+ let set = set_name.and_then(|name| {
1425
+ find_aggregate(domain_ir, set_aggregate_name).and_then(|agg| find_value_object(agg, name))
1426
+ });
996
1427
  // Undeclared set: render as a plain scalar (using the real attribute
997
1428
  // so its name/pattern still drive Tier B's hints) — dispatch refuses.
998
- let Some(set) = set else { return primitive_field(attribute, ty, path, optional) };
1429
+ let Some(set) = set else {
1430
+ return primitive_field(attribute, ty, path, optional);
1431
+ };
999
1432
 
1000
1433
  let (_, members) = closed_set_members(set);
1001
1434
  let mut field = select_or_radio(path, optional, members);
@@ -1003,19 +1436,39 @@ fn admitted_field(domain_ir: &Value, aggregate: &Value, attribute: &Value, admit
1003
1436
  // The attribute's own value object still needs its ".value" hop
1004
1437
  // (same unwrap `value_object_field` does) even though the admitted
1005
1438
  // set lives on a different aggregate — it only changes the options.
1006
- let Some(own_shape) = value_object_shape(domain_ir, aggregate, ty) else { return field };
1439
+ let Some(own_shape) = value_object_shape(domain_ir, aggregate, ty) else {
1440
+ return field;
1441
+ };
1007
1442
  let attrs = own_shape.get("attributes").and_then(|v| v.as_array());
1008
- let Some(attrs) = attrs.filter(|a| a.len() == 1) else { return field };
1443
+ let Some(attrs) = attrs.filter(|a| a.len() == 1) else {
1444
+ return field;
1445
+ };
1009
1446
 
1010
- let inner_name = attrs[0].get("name").and_then(|v| v.as_str()).unwrap_or("value");
1447
+ let inner_name = attrs[0]
1448
+ .get("name")
1449
+ .and_then(|v| v.as_str())
1450
+ .unwrap_or("value");
1011
1451
  field.path = format!("{path}.{inner_name}");
1012
1452
  field
1013
1453
  }
1014
1454
 
1015
1455
  // Order: closed_set? -> money_shaped? -> single-attribute-unwrap -> group.
1016
- fn value_object_field(domain_ir: &Value, aggregate: &Value, shape: &Value, path: &str, optional: bool) -> Field {
1017
- let closed_set = shape.get("closed_set").and_then(|v| v.as_bool()).unwrap_or(false);
1018
- let attrs = shape.get("attributes").and_then(|v| v.as_array()).cloned().unwrap_or_default();
1456
+ fn value_object_field(
1457
+ domain_ir: &Value,
1458
+ aggregate: &Value,
1459
+ shape: &Value,
1460
+ path: &str,
1461
+ optional: bool,
1462
+ ) -> Field {
1463
+ let closed_set = shape
1464
+ .get("closed_set")
1465
+ .and_then(serde_json::Value::as_bool)
1466
+ .unwrap_or(false);
1467
+ let attrs = shape
1468
+ .get("attributes")
1469
+ .and_then(|v| v.as_array())
1470
+ .cloned()
1471
+ .unwrap_or_default();
1019
1472
 
1020
1473
  if closed_set {
1021
1474
  let (discriminant, members) = closed_set_members(shape);
@@ -1033,7 +1486,10 @@ fn value_object_field(domain_ir: &Value, aggregate: &Value, shape: &Value, path:
1033
1486
  // `primitive_field`) keeps the inner name/pattern driving Tier B.
1034
1487
  if attrs.len() == 1 {
1035
1488
  let inner = &attrs[0];
1036
- let inner_name = inner.get("name").and_then(|v| v.as_str()).unwrap_or("value");
1489
+ let inner_name = inner
1490
+ .get("name")
1491
+ .and_then(|v| v.as_str())
1492
+ .unwrap_or("value");
1037
1493
  let inner_path = format!("{path}.{inner_name}");
1038
1494
  let mut field = resolve_field(domain_ir, inner, aggregate, &inner_path);
1039
1495
  field.label = humanize(path);
@@ -1048,12 +1504,20 @@ fn value_object_field(domain_ir: &Value, aggregate: &Value, shape: &Value, path:
1048
1504
  resolve_field(domain_ir, inner, aggregate, &format!("{path}.{inner_name}"))
1049
1505
  })
1050
1506
  .collect();
1051
- Field { path: path.to_string(), label: humanize(path), kind: FieldKind::Group(children), optional }
1507
+ Field {
1508
+ path: path.to_string(),
1509
+ label: humanize(path),
1510
+ kind: FieldKind::Group(children),
1511
+ optional,
1512
+ }
1052
1513
  }
1053
1514
 
1054
1515
  // Exactly `{cents, currency}`, sorted, and nothing else.
1055
1516
  fn money_shaped(attrs: &[Value]) -> bool {
1056
- let mut names: Vec<&str> = attrs.iter().filter_map(|a| a.get("name").and_then(|v| v.as_str())).collect();
1517
+ let mut names: Vec<&str> = attrs
1518
+ .iter()
1519
+ .filter_map(|a| a.get("name").and_then(|v| v.as_str()))
1520
+ .collect();
1057
1521
  names.sort_unstable();
1058
1522
  names == ["cents", "currency"]
1059
1523
  }
@@ -1061,9 +1525,24 @@ fn money_shaped(attrs: &[Value]) -> bool {
1061
1525
  // Cents as a whole-integer Number; currency as free Text, always
1062
1526
  // optional (it defaults to "USD" regardless of the outer field).
1063
1527
  fn money_field(path: &str, optional: bool) -> Field {
1064
- let cents = Field { path: format!("{path}.cents"), label: "Amount (cents)".to_string(), kind: FieldKind::Number { step: "1" }, optional };
1065
- let currency = Field { path: format!("{path}.currency"), label: "Currency".to_string(), kind: FieldKind::Text { html_type: "text" }, optional: true };
1066
- Field { path: path.to_string(), label: humanize(path), kind: FieldKind::Money(vec![cents, currency]), optional }
1528
+ let cents = Field {
1529
+ path: format!("{path}.cents"),
1530
+ label: "Amount (cents)".to_string(),
1531
+ kind: FieldKind::Number { step: "1" },
1532
+ optional,
1533
+ };
1534
+ let currency = Field {
1535
+ path: format!("{path}.currency"),
1536
+ label: "Currency".to_string(),
1537
+ kind: FieldKind::Text { html_type: "text" },
1538
+ optional: true,
1539
+ };
1540
+ Field {
1541
+ path: path.to_string(),
1542
+ label: humanize(path),
1543
+ kind: FieldKind::Money(vec![cents, currency]),
1544
+ optional,
1545
+ }
1067
1546
  }
1068
1547
 
1069
1548
  fn primitive_field(attribute: &Value, ty: &str, path: &str, optional: bool) -> Field {
@@ -1074,17 +1553,30 @@ fn primitive_field(attribute: &Value, ty: &str, path: &str, optional: bool) -> F
1074
1553
  "TrueClass" | "FalseClass" => FieldKind::Boolean,
1075
1554
  _ => return text_field(attribute, path, optional),
1076
1555
  };
1077
- Field { path: path.to_string(), label, kind, optional }
1556
+ Field {
1557
+ path: path.to_string(),
1558
+ label,
1559
+ kind,
1560
+ optional,
1561
+ }
1078
1562
  }
1079
1563
 
1080
1564
  // Tier B's hints (field_hints.rs) match on `attribute`'s own name/
1081
1565
  // pattern, never derived from `path` — matters after the unwrap above.
1082
1566
  fn text_field(attribute: &Value, path: &str, optional: bool) -> Field {
1083
1567
  let name = attribute.get("name").and_then(|v| v.as_str()).unwrap_or("");
1084
- let pattern = attribute.get("pattern").and_then(|v| v.as_str()).unwrap_or("");
1568
+ let pattern = attribute
1569
+ .get("pattern")
1570
+ .and_then(|v| v.as_str())
1571
+ .unwrap_or("");
1085
1572
  let html_type = text_html_type(name, pattern);
1086
1573
  let kind = text_kind(html_type, name);
1087
- Field { path: path.to_string(), label: humanize(path), kind, optional }
1574
+ Field {
1575
+ path: path.to_string(),
1576
+ label: humanize(path),
1577
+ kind,
1578
+ optional,
1579
+ }
1088
1580
  }
1089
1581
 
1090
1582
  // email, then url, then tel — first match wins. Case-insensitive
@@ -1111,7 +1603,9 @@ fn text_kind(html_type: &'static str, name: &str) -> FieldKind {
1111
1603
  }
1112
1604
 
1113
1605
  fn command_fields(domain_ir: &Value, aggregate: &Value, command: &Value) -> Vec<Field> {
1114
- let creates = command.get("references").map(|v| v.is_null()).unwrap_or(true);
1606
+ let creates = command
1607
+ .get("references")
1608
+ .is_none_or(serde_json::Value::is_null);
1115
1609
  let mut fields = Vec::new();
1116
1610
  if !creates {
1117
1611
  let paths = identity_paths(aggregate).join(", ");
@@ -1165,7 +1659,9 @@ fn collect_field(field: &Field, raw: &HashMap<String, String>, pairs: &mut Vec<(
1165
1659
  }
1166
1660
  }
1167
1661
  FieldKind::Boolean => {
1168
- let checked = raw.get(&field.path).map(|v| matches!(v.as_str(), "on" | "1" | "true")).unwrap_or(false);
1662
+ let checked = raw
1663
+ .get(&field.path)
1664
+ .is_some_and(|v| matches!(v.as_str(), "on" | "1" | "true"));
1169
1665
  pairs.push((field.path.clone(), Value::Bool(checked)));
1170
1666
  }
1171
1667
  _ => {
@@ -1180,8 +1676,10 @@ fn collect_field(field: &Field, raw: &HashMap<String, String>, pairs: &mut Vec<(
1180
1676
 
1181
1677
  fn cast_scalar(field: &Field, text: &str) -> Value {
1182
1678
  match &field.kind {
1183
- FieldKind::Number { step } if *step == "1" => text.parse::<i64>().map(Value::from).unwrap_or(Value::Null),
1184
- FieldKind::Number { .. } => text.parse::<f64>().map(Value::from).unwrap_or(Value::Null),
1679
+ FieldKind::Number { step } if *step == "1" => {
1680
+ text.parse::<i64>().map_or(Value::Null, Value::from)
1681
+ }
1682
+ FieldKind::Number { .. } => text.parse::<f64>().map_or(Value::Null, Value::from),
1185
1683
  _ => Value::String(text.to_string()),
1186
1684
  }
1187
1685
  }
@@ -1222,25 +1720,41 @@ fn collision_error(path: &str, existing_prefix: &[&str]) -> String {
1222
1720
 
1223
1721
  fn home_body(domain_ir: &Value) -> String {
1224
1722
  let name = domain_ir.get("name").and_then(|v| v.as_str()).unwrap_or("");
1225
- let vision = domain_ir.get("vision").and_then(|v| v.as_str()).unwrap_or("");
1226
- let aggregates = domain_ir.get("aggregates").and_then(|v| v.as_array()).cloned().unwrap_or_default();
1723
+ let vision = domain_ir
1724
+ .get("vision")
1725
+ .and_then(|v| v.as_str())
1726
+ .unwrap_or("");
1727
+ let aggregates = domain_ir
1728
+ .get("aggregates")
1729
+ .and_then(|v| v.as_array())
1730
+ .cloned()
1731
+ .unwrap_or_default();
1227
1732
  let items: String = aggregates
1228
1733
  .iter()
1229
1734
  .map(|a| {
1230
1735
  let n = agg_name(a);
1231
1736
  format!(r#"<li><a href="/{name}/{n}.html" class="text-indigo-600 hover:underline">{n}</a></li>"#)
1232
1737
  })
1233
- .collect();
1738
+ .collect::<Vec<_>>()
1739
+ .concat();
1234
1740
  format!(
1235
1741
  r#"<h1 class="text-2xl font-bold mb-4">Loaded domains</h1>
1236
1742
  <ul class="space-y-4"><li><h2 class="font-semibold text-slate-800">{}</h2>
1237
1743
  <p class="text-sm text-slate-500">{}</p>
1238
1744
  <ul class="ml-4 mt-1 list-disc list-inside">{}</ul></li></ul>"#,
1239
- esc(name), esc(vision), items
1745
+ esc(name),
1746
+ esc(vision),
1747
+ items
1240
1748
  )
1241
1749
  }
1242
1750
 
1243
- async fn aggregate_index(domain_name: &str, aggregate: &Value, format: &str, client: &Mutex<Client>, wasm_path: &Path) -> Value {
1751
+ async fn aggregate_index(
1752
+ domain_name: &str,
1753
+ aggregate: &Value,
1754
+ format: &str,
1755
+ client: &Mutex<Client>,
1756
+ wasm_path: &Path,
1757
+ ) -> Value {
1244
1758
  let agg = agg_name(aggregate);
1245
1759
  let prefix = format!("{domain_name}::{agg}#");
1246
1760
  let records = match dispatch::read(client, wasm_path).await {
@@ -1249,19 +1763,34 @@ async fn aggregate_index(domain_name: &str, aggregate: &Value, format: &str, cli
1249
1763
  };
1250
1764
 
1251
1765
  if format != "html" {
1252
- let list: Vec<Value> = records.iter().map(|(id, state)| with_id(id, state)).collect();
1253
- return respond(200, "application/json", &serde_json::to_string_pretty(&list).unwrap_or_default());
1766
+ let list: Vec<Value> = records
1767
+ .iter()
1768
+ .map(|(id, state)| with_id(id, state))
1769
+ .collect();
1770
+ return respond(
1771
+ 200,
1772
+ "application/json",
1773
+ &serde_json::to_string_pretty(&list).unwrap_or_default(),
1774
+ );
1254
1775
  }
1255
1776
 
1256
1777
  let creating: Vec<&Value> = aggregate
1257
1778
  .get("commands")
1258
1779
  .and_then(|v| v.as_array())
1259
- .map(|cs| cs.iter().filter(|c| c.get("references").map(|r| r.is_null()).unwrap_or(true)).collect())
1780
+ .map(|cs| {
1781
+ cs.iter()
1782
+ .filter(|c| c.get("references").is_none_or(serde_json::Value::is_null))
1783
+ .collect()
1784
+ })
1260
1785
  .unwrap_or_default();
1261
1786
  let acting: Vec<&Value> = aggregate
1262
1787
  .get("commands")
1263
1788
  .and_then(|v| v.as_array())
1264
- .map(|cs| cs.iter().filter(|c| !c.get("references").map(|r| r.is_null()).unwrap_or(true)).collect())
1789
+ .map(|cs| {
1790
+ cs.iter()
1791
+ .filter(|c| !c.get("references").is_none_or(serde_json::Value::is_null))
1792
+ .collect()
1793
+ })
1265
1794
  .unwrap_or_default();
1266
1795
 
1267
1796
  let new_links: String = creating
@@ -1270,7 +1799,8 @@ async fn aggregate_index(domain_name: &str, aggregate: &Value, format: &str, cli
1270
1799
  let cn = c.get("name").and_then(|v| v.as_str()).unwrap_or("");
1271
1800
  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>"#)
1272
1801
  })
1273
- .collect();
1802
+ .collect::<Vec<_>>()
1803
+ .concat();
1274
1804
 
1275
1805
  let rows: String = records
1276
1806
  .iter()
@@ -1279,7 +1809,13 @@ async fn aggregate_index(domain_name: &str, aggregate: &Value, format: &str, cli
1279
1809
  .iter()
1280
1810
  .map(|c| {
1281
1811
  let cn = c.get("name").and_then(|v| v.as_str()).unwrap_or("");
1282
- action_link(domain_name, agg, cn, id, "text-indigo-600 hover:underline mr-3")
1812
+ action_link(
1813
+ domain_name,
1814
+ agg,
1815
+ cn,
1816
+ id,
1817
+ "text-indigo-600 hover:underline mr-3",
1818
+ )
1283
1819
  })
1284
1820
  .collect();
1285
1821
  index_row_html(domain_name, agg, id, &actions)
@@ -1300,7 +1836,14 @@ async fn aggregate_index(domain_name: &str, aggregate: &Value, format: &str, cli
1300
1836
  html(200, &page(&format!("{domain_name}::{agg}"), &body))
1301
1837
  }
1302
1838
 
1303
- async fn record_show(domain_name: &str, aggregate: &Value, id: &str, format: &str, client: &Mutex<Client>, wasm_path: &Path) -> Value {
1839
+ async fn record_show(
1840
+ domain_name: &str,
1841
+ aggregate: &Value,
1842
+ id: &str,
1843
+ format: &str,
1844
+ client: &Mutex<Client>,
1845
+ wasm_path: &Path,
1846
+ ) -> Value {
1304
1847
  let agg = agg_name(aggregate);
1305
1848
  let prefix = format!("{domain_name}::{agg}#");
1306
1849
  let result = match dispatch::read(client, wasm_path).await {
@@ -1310,15 +1853,32 @@ async fn record_show(domain_name: &str, aggregate: &Value, id: &str, format: &st
1310
1853
  let records = instances_for(&result, &prefix);
1311
1854
  let Some((_, state)) = records.iter().find(|(rid, _)| rid == id) else {
1312
1855
  let message = format!("No {agg} {id}.");
1313
- return if format != "html" {
1314
- respond(404, "application/json", &json!({"error":"NotFound","message":message}).to_string())
1856
+ return if format == "html" {
1857
+ html(
1858
+ 404,
1859
+ &page(
1860
+ "Not found",
1861
+ &format!(
1862
+ r#"<h1 class="text-2xl font-bold mb-4">Not found</h1><p class="text-slate-600">{}</p>"#,
1863
+ esc(&message)
1864
+ ),
1865
+ ),
1866
+ )
1315
1867
  } else {
1316
- 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))))
1868
+ respond(
1869
+ 404,
1870
+ "application/json",
1871
+ &json!({"error":"NotFound","message":message}).to_string(),
1872
+ )
1317
1873
  };
1318
1874
  };
1319
1875
 
1320
1876
  if format != "html" {
1321
- return respond(200, "application/json", &serde_json::to_string_pretty(&with_id(id, state)).unwrap_or_default());
1877
+ return respond(
1878
+ 200,
1879
+ "application/json",
1880
+ &serde_json::to_string_pretty(&with_id(id, state)).unwrap_or_default(),
1881
+ );
1322
1882
  }
1323
1883
 
1324
1884
  let rows: String = state
@@ -1326,7 +1886,8 @@ async fn record_show(domain_name: &str, aggregate: &Value, id: &str, format: &st
1326
1886
  .map(|o| {
1327
1887
  o.iter()
1328
1888
  .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())))
1329
- .collect()
1889
+ .collect::<Vec<_>>()
1890
+ .concat()
1330
1891
  })
1331
1892
  .unwrap_or_default();
1332
1893
  let actions: String = aggregate
@@ -1334,7 +1895,7 @@ async fn record_show(domain_name: &str, aggregate: &Value, id: &str, format: &st
1334
1895
  .and_then(|v| v.as_array())
1335
1896
  .map(|cs| {
1336
1897
  cs.iter()
1337
- .filter(|c| !c.get("references").map(|r| r.is_null()).unwrap_or(true))
1898
+ .filter(|c| !c.get("references").is_none_or(serde_json::Value::is_null))
1338
1899
  .map(|c| {
1339
1900
  let cn = c.get("name").and_then(|v| v.as_str()).unwrap_or("");
1340
1901
  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")
@@ -1346,17 +1907,31 @@ async fn record_show(domain_name: &str, aggregate: &Value, id: &str, format: &st
1346
1907
  r#"<h1 class="text-2xl font-bold">{} <span class="font-mono text-lg text-slate-500">{}</span></h1>
1347
1908
  <dl class="mt-4 divide-y divide-slate-200 border border-slate-200 rounded-md bg-white">{rows}</dl>
1348
1909
  <div class="mt-6">{actions}<a href="/{domain_name}/{agg}.html" class="text-indigo-600 hover:underline">← {agg} list</a></div>"#,
1349
- esc(agg), esc(id)
1910
+ esc(agg),
1911
+ esc(id)
1350
1912
  );
1351
1913
  html(200, &page(&format!("{agg} {id}"), &body))
1352
1914
  }
1353
-
1354
1915
  #[allow(clippy::too_many_arguments)]
1355
1916
  async fn command_route(
1356
- domain_ir: &Value, domain_name: &str, aggregate: &Value, command: &Value, action: &str, format: &str, method: &str,
1357
- query: &HashMap<String, String>, raw_body: &str, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig,
1358
- invoker: &dyn LambdaInvoker,
1917
+ domain_ir: &Value,
1918
+ domain_name: &str,
1919
+ aggregate: &Value,
1920
+ command: &Value,
1921
+ action: &str,
1922
+ format: &str,
1923
+ method: &str,
1924
+ query: &HashMap<String, String>,
1925
+ raw_body: &str,
1926
+ host: Host<'_>,
1359
1927
  ) -> Value {
1928
+ let Host {
1929
+ client,
1930
+ wasm_path,
1931
+ config,
1932
+ invoker,
1933
+ ..
1934
+ } = host;
1360
1935
  let fields = command_fields(domain_ir, aggregate, command);
1361
1936
  let cname = command.get("name").and_then(|v| v.as_str()).unwrap_or("");
1362
1937
 
@@ -1365,21 +1940,66 @@ async fn command_route(
1365
1940
  return respond(200, "application/json", &command.to_string());
1366
1941
  }
1367
1942
  let raw = parse_form(raw_body);
1368
- return submit(domain_name, aggregate, command, &fields, &raw, client, wasm_path, config, true, invoker).await;
1943
+ return submit(
1944
+ domain_name,
1945
+ aggregate,
1946
+ command,
1947
+ &fields,
1948
+ &raw,
1949
+ client,
1950
+ wasm_path,
1951
+ config,
1952
+ true,
1953
+ invoker,
1954
+ )
1955
+ .await;
1369
1956
  }
1370
1957
 
1371
1958
  if method == "GET" {
1372
- return html(200, &page(&format!("{domain_name}::{}.{cname}", agg_name(aggregate)), &form_body(domain_name, aggregate, command, action, &fields, query, None)));
1959
+ return html(
1960
+ 200,
1961
+ &page(
1962
+ &format!("{domain_name}::{}.{cname}", agg_name(aggregate)),
1963
+ &form_body(
1964
+ domain_name,
1965
+ aggregate,
1966
+ command,
1967
+ action,
1968
+ &fields,
1969
+ query,
1970
+ None,
1971
+ ),
1972
+ ),
1973
+ );
1373
1974
  }
1374
1975
 
1375
1976
  let raw = parse_form(raw_body);
1376
- submit(domain_name, aggregate, command, &fields, &raw, client, wasm_path, config, false, invoker).await
1977
+ submit(
1978
+ domain_name,
1979
+ aggregate,
1980
+ command,
1981
+ &fields,
1982
+ &raw,
1983
+ client,
1984
+ wasm_path,
1985
+ config,
1986
+ false,
1987
+ invoker,
1988
+ )
1989
+ .await
1377
1990
  }
1378
-
1379
1991
  #[allow(clippy::too_many_arguments)]
1380
1992
  async fn submit(
1381
- domain_name: &str, aggregate: &Value, command: &Value, fields: &[Field], raw: &HashMap<String, String>,
1382
- client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, json_mode: bool, invoker: &dyn LambdaInvoker,
1993
+ domain_name: &str,
1994
+ aggregate: &Value,
1995
+ command: &Value,
1996
+ fields: &[Field],
1997
+ raw: &HashMap<String, String>,
1998
+ client: &Mutex<Client>,
1999
+ wasm_path: &Path,
2000
+ config: &LineageConfig,
2001
+ json_mode: bool,
2002
+ invoker: &dyn LambdaInvoker,
1383
2003
  ) -> Value {
1384
2004
  let agg = agg_name(aggregate);
1385
2005
  let cname = command.get("name").and_then(|v| v.as_str()).unwrap_or("");
@@ -1394,7 +2014,14 @@ async fn submit(
1394
2014
 
1395
2015
  // `None`: this web UI has no concept of an authenticated caller's
1396
2016
  // role yet (see auth.rs's own session handling for that).
1397
- let outcome = match dispatch::handle(client, wasm_path, &verb, args, None, config, invoker).await {
2017
+ let outcome = match dispatch::handle(
2018
+ Host::new(client, wasm_path, config, invoker),
2019
+ &verb,
2020
+ args,
2021
+ None,
2022
+ )
2023
+ .await
2024
+ {
1398
2025
  Ok(o) => o,
1399
2026
  Err(e) => return respond(500, "text/plain", &format!("{e:#}")),
1400
2027
  };
@@ -1414,13 +2041,34 @@ async fn submit(
1414
2041
  }
1415
2042
 
1416
2043
  let action = format!("/{domain_name}/{agg}/{cname}.html");
1417
- html(422, &page(&format!("{domain_name}::{agg}.{cname}"), &form_body(domain_name, aggregate, command, &action, fields, raw, Some(&refusal))))
2044
+ html(
2045
+ 422,
2046
+ &page(
2047
+ &format!("{domain_name}::{agg}.{cname}"),
2048
+ &form_body(
2049
+ domain_name,
2050
+ aggregate,
2051
+ command,
2052
+ &action,
2053
+ fields,
2054
+ raw,
2055
+ Some(&refusal),
2056
+ ),
2057
+ ),
2058
+ )
1418
2059
  }
1419
2060
 
1420
2061
  // Mirrors the 422-refusal rendering below (same json/html split) but at
1421
2062
  // 400 — this is a malformed submission `nest` caught, not a domain refusal.
1422
- #[allow(clippy::too_many_arguments)]
1423
- fn bad_request(domain_name: &str, aggregate: &Value, command: &Value, fields: &[Field], raw: &HashMap<String, String>, json_mode: bool, message: &str) -> Value {
2063
+ fn bad_request(
2064
+ domain_name: &str,
2065
+ aggregate: &Value,
2066
+ command: &Value,
2067
+ fields: &[Field],
2068
+ raw: &HashMap<String, String>,
2069
+ json_mode: bool,
2070
+ message: &str,
2071
+ ) -> Value {
1424
2072
  let refusal = json!({"error": message});
1425
2073
  if json_mode {
1426
2074
  return respond(400, "application/json", &refusal.to_string());
@@ -1428,7 +2076,21 @@ fn bad_request(domain_name: &str, aggregate: &Value, command: &Value, fields: &[
1428
2076
  let agg = agg_name(aggregate);
1429
2077
  let cname = command.get("name").and_then(|v| v.as_str()).unwrap_or("");
1430
2078
  let action = format!("/{domain_name}/{agg}/{cname}.html");
1431
- html(400, &page(&format!("{domain_name}::{agg}.{cname}"), &form_body(domain_name, aggregate, command, &action, fields, raw, Some(&refusal))))
2079
+ html(
2080
+ 400,
2081
+ &page(
2082
+ &format!("{domain_name}::{agg}.{cname}"),
2083
+ &form_body(
2084
+ domain_name,
2085
+ aggregate,
2086
+ command,
2087
+ &action,
2088
+ fields,
2089
+ raw,
2090
+ Some(&refusal),
2091
+ ),
2092
+ ),
2093
+ )
1432
2094
  }
1433
2095
 
1434
2096
  // The first mutation in the last step is always the command's own
@@ -1455,7 +2117,15 @@ pub(crate) fn last_refusal(result: &Value) -> Value {
1455
2117
  .unwrap_or_else(|| json!({"error": "Refused"}))
1456
2118
  }
1457
2119
 
1458
- fn form_body(domain_name: &str, aggregate: &Value, command: &Value, action: &str, fields: &[Field], values: &HashMap<String, String>, error: Option<&Value>) -> String {
2120
+ fn form_body(
2121
+ domain_name: &str,
2122
+ aggregate: &Value,
2123
+ command: &Value,
2124
+ action: &str,
2125
+ fields: &[Field],
2126
+ values: &HashMap<String, String>,
2127
+ error: Option<&Value>,
2128
+ ) -> String {
1459
2129
  let agg = agg_name(aggregate);
1460
2130
  let cname = command.get("name").and_then(|v| v.as_str()).unwrap_or("");
1461
2131
  let role = command.get("role").and_then(|v| v.as_str());
@@ -1463,7 +2133,13 @@ fn form_body(domain_name: &str, aggregate: &Value, command: &Value, action: &str
1463
2133
  let givens: String = command
1464
2134
  .get("givens")
1465
2135
  .and_then(|v| v.as_array())
1466
- .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>())
2136
+ .map(|gs| {
2137
+ gs.iter()
2138
+ .filter_map(|g| g.get("description").and_then(|d| d.as_str()))
2139
+ .map(|d| format!("<li>{}</li>", esc(d)))
2140
+ .collect::<Vec<_>>()
2141
+ .concat()
2142
+ })
1467
2143
  .unwrap_or_default();
1468
2144
 
1469
2145
  let error_banner = error
@@ -1493,7 +2169,11 @@ fn render_field(field: &Field, values: &HashMap<String, String>) -> String {
1493
2169
  let label = format!(
1494
2170
  r#"<label class="block text-sm font-medium text-slate-700 mt-4">{}{}</label>"#,
1495
2171
  esc(&field.label),
1496
- if field.optional { "" } else { r#" <span class="text-red-500">*</span>"# }
2172
+ if field.optional {
2173
+ ""
2174
+ } else {
2175
+ r#" <span class="text-red-500">*</span>"#
2176
+ }
1497
2177
  );
1498
2178
  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";
1499
2179
  let current = values.get(&field.path).cloned().unwrap_or_default();
@@ -1504,41 +2184,62 @@ fn render_field(field: &Field, values: &HashMap<String, String>) -> String {
1504
2184
  // inside it.
1505
2185
  FieldKind::Group(children) | FieldKind::Money(children) => {
1506
2186
  let inner: String = children.iter().map(|c| render_field(c, values)).collect();
1507
- 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)
2187
+ format!(
2188
+ 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>"#,
2189
+ esc(&field.label),
2190
+ inner
2191
+ )
1508
2192
  }
1509
2193
  FieldKind::List(_) => format!(
1510
2194
  r#"{label}<textarea name="{}" rows="4" placeholder="one per line" class="{input_class}">{}</textarea>"#,
1511
- esc(&field.path), esc(&current)
2195
+ esc(&field.path),
2196
+ esc(&current)
2197
+ ),
2198
+ FieldKind::Textarea => format!(
2199
+ r#"{label}<textarea name="{}" class="{input_class}">{}</textarea>"#,
2200
+ esc(&field.path),
2201
+ esc(&current)
1512
2202
  ),
1513
- FieldKind::Textarea => format!(r#"{label}<textarea name="{}" class="{input_class}">{}</textarea>"#, esc(&field.path), esc(&current)),
1514
2203
  FieldKind::Boolean => format!(
1515
2204
  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>"#,
1516
- esc(&field.path), esc(&field.label)
2205
+ esc(&field.path),
2206
+ esc(&field.label)
1517
2207
  ),
1518
2208
  FieldKind::Radio(options) => {
1519
2209
  let opts: String = options
1520
2210
  .iter()
1521
2211
  .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)))
1522
- .collect();
2212
+ .collect::<Vec<_>>()
2213
+ .concat();
1523
2214
  format!(r#"{label}<div class="mt-1 flex gap-4">{opts}</div>"#)
1524
2215
  }
1525
2216
  FieldKind::Select(options) => {
1526
- let opts: String = options.iter().map(|o| format!(r#"<option value="{}">{}</option>"#, esc(o), esc(o))).collect();
1527
- format!(r#"{label}<select name="{}" class="{input_class}">{opts}</select>"#, esc(&field.path))
2217
+ let opts: String = options
2218
+ .iter()
2219
+ .map(|o| format!(r#"<option value="{}">{}</option>"#, esc(o), esc(o)))
2220
+ .collect::<Vec<_>>()
2221
+ .concat();
2222
+ format!(
2223
+ r#"{label}<select name="{}" class="{input_class}">{opts}</select>"#,
2224
+ esc(&field.path)
2225
+ )
1528
2226
  }
1529
2227
  FieldKind::Number { step } => format!(
1530
2228
  r#"{label}<input type="number" name="{}" value="{}" step="{step}" class="{input_class}">"#,
1531
- esc(&field.path), esc(&current)
2229
+ esc(&field.path),
2230
+ esc(&current)
1532
2231
  ),
1533
2232
  FieldKind::Text { html_type } => format!(
1534
2233
  r#"{label}<input type="{html_type}" name="{}" value="{}" class="{input_class}">"#,
1535
- esc(&field.path), esc(&current)
2234
+ esc(&field.path),
2235
+ esc(&current)
1536
2236
  ),
1537
2237
  // No reference-picker dropdown: no candidate-fetching scaffolding
1538
2238
  // exists yet, so this is a plain text id input, like Ruby's own fallback.
1539
2239
  FieldKind::Reference { .. } => format!(
1540
2240
  r#"{label}<input type="text" name="{}" value="{}" class="{input_class}">"#,
1541
- esc(&field.path), esc(&current)
2241
+ esc(&field.path),
2242
+ esc(&current)
1542
2243
  ),
1543
2244
  }
1544
2245
  }
@@ -1583,7 +2284,10 @@ fn parse_urlencoded(text: &str, plus_as_space: bool) -> HashMap<String, String>
1583
2284
  .filter(|s| !s.is_empty())
1584
2285
  .map(|pair| {
1585
2286
  let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
1586
- (percent_decode_impl(k, plus_as_space), percent_decode_impl(v, plus_as_space))
2287
+ (
2288
+ percent_decode_impl(k, plus_as_space),
2289
+ percent_decode_impl(v, plus_as_space),
2290
+ )
1587
2291
  })
1588
2292
  .collect()
1589
2293
  }
@@ -1605,7 +2309,8 @@ fn percent_decode_impl(s: &str, plus_as_space: bool) -> String {
1605
2309
  b'%' if i + 2 < bytes.len() => {
1606
2310
  // Byte-wise, not `s[i + 1..i + 3]`: that slice panics when it lands inside a
1607
2311
  // multi-byte character (`%a🍕`), and `from_str_radix` would read a `+` sign as a digit.
1608
- if let (Some(high), Some(low)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2])) {
2312
+ if let (Some(high), Some(low)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2]))
2313
+ {
1609
2314
  out.push(high << 4 | low);
1610
2315
  i += 3;
1611
2316
  } else {
@@ -1626,40 +2331,31 @@ fn hex_digit(byte: u8) -> Option<u8> {
1626
2331
  (byte as char).to_digit(16).map(|digit| digit as u8)
1627
2332
  }
1628
2333
 
1629
- const B64: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1630
-
1631
2334
  // pub(crate) so server.rs's own Function URL response handling can
1632
2335
  // decode the same way, without a second copy of this decoder.
1633
2336
  pub(crate) fn base64_decode(input: &str) -> Vec<u8> {
1634
- let mut table = [255u8; 256];
1635
- for (i, &c) in B64.iter().enumerate() {
1636
- table[c as usize] = i as u8;
1637
- }
1638
- let clean: Vec<u8> = input.bytes().filter(|&b| b != b'=' && !b.is_ascii_whitespace()).collect();
1639
- let mut out = Vec::with_capacity(clean.len() * 3 / 4);
1640
- for chunk in clean.chunks(4) {
1641
- let vals: Vec<u32> = chunk.iter().map(|&b| table[b as usize] as u32).collect();
1642
- let n = vals.len();
1643
- let combined = vals.iter().enumerate().fold(0u32, |acc, (i, &v)| acc | (v << (18 - 6 * i)));
1644
- out.push((combined >> 16) as u8);
1645
- if n > 2 {
1646
- out.push((combined >> 8) as u8);
1647
- }
1648
- if n > 3 {
1649
- out.push(combined as u8);
1650
- }
1651
- }
1652
- out
2337
+ let compact: String = input.split_ascii_whitespace().collect();
2338
+ crate::b64::standard_decode(&compact).unwrap_or_default()
1653
2339
  }
1654
2340
 
1655
2341
  fn esc(value: &str) -> String {
1656
- value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\'', "&#39;")
2342
+ value
2343
+ .replace('&', "&amp;")
2344
+ .replace('<', "&lt;")
2345
+ .replace('>', "&gt;")
2346
+ .replace('"', "&quot;")
2347
+ .replace('\'', "&#39;")
1657
2348
  }
1658
2349
 
1659
2350
  // `id` is user-supplied and sits in a query-string value here, so
1660
2351
  // `esc()` alone isn't enough — percent-encode via `auth::urlencode`.
1661
2352
  fn action_link(domain_name: &str, agg: &str, cn: &str, id: &str, class: &str) -> String {
1662
- format!(r#"<a href="/{domain_name}/{agg}/{}.html?id={}" class="{class}">{}</a>"#, esc(cn), auth::urlencode(id), esc(cn))
2353
+ format!(
2354
+ r#"<a href="/{domain_name}/{agg}/{}.html?id={}" class="{class}">{}</a>"#,
2355
+ esc(cn),
2356
+ auth::urlencode(id),
2357
+ esc(cn)
2358
+ )
1663
2359
  }
1664
2360
 
1665
2361
  // `id` sits in a path segment here, not a query value, so `esc()`
@@ -1674,7 +2370,8 @@ fn index_row_html(domain_name: &str, agg: &str, id: &str, actions: &str) -> Stri
1674
2370
  fn page(title: &str, body: &str) -> String {
1675
2371
  format!(
1676
2372
  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>"#,
1677
- esc(title), body
2373
+ esc(title),
2374
+ body
1678
2375
  )
1679
2376
  }
1680
2377
 
@@ -1698,7 +2395,11 @@ fn cookie_flags() -> &'static str {
1698
2395
  // A host that sets the redirect URI is judged by it, as ever; a host that has stopped setting it
1699
2396
  // is judged by SITE_URL, and with neither set the cookie is Secure.
1700
2397
  pub(crate) fn account_cookie_flags(redirect_uri: &str, site_url: &str) -> &'static str {
1701
- let judged_by = if redirect_uri.is_empty() { site_url } else { redirect_uri };
2398
+ let judged_by = if redirect_uri.is_empty() {
2399
+ site_url
2400
+ } else {
2401
+ redirect_uri
2402
+ };
1702
2403
  if judged_by.starts_with("http://") {
1703
2404
  COOKIE_FLAGS_PLAIN
1704
2405
  } else {
@@ -1713,6 +2414,22 @@ fn redirect_with_cookie(location: &str, cookie: &str) -> Value {
1713
2414
  json!({"statusCode": 302, "headers": {"location": location}, "cookies": [cookie], "body": "", "isBase64Encoded": false})
1714
2415
  }
1715
2416
 
2417
+ const DEFAULT_SITE_URL: &str = "http://localhost:4321";
2418
+
2419
+ /// The public site's origin: `SITE_URL`, else the local development address.
2420
+ pub(crate) fn site_url() -> String {
2421
+ std::env::var("SITE_URL").unwrap_or_else(|_| DEFAULT_SITE_URL.to_string())
2422
+ }
2423
+
2424
+ /// A JSON `{"error": message}` reply with `status`.
2425
+ pub(crate) fn json_error(status: u16, message: &str) -> Value {
2426
+ respond(
2427
+ status,
2428
+ "application/json",
2429
+ &json!({"error": message}).to_string(),
2430
+ )
2431
+ }
2432
+
1716
2433
  pub(crate) fn respond(status: u16, content_type: &str, body: &str) -> Value {
1717
2434
  json!({"statusCode": status, "headers": {"content-type": content_type}, "body": body, "isBase64Encoded": false})
1718
2435
  }
@@ -1741,19 +2458,29 @@ mod tests {
1741
2458
  // JSON request would get a redirect instead of a 401 it can branch on.
1742
2459
 
1743
2460
  fn status(response: &Value) -> u64 {
1744
- response.get("statusCode").and_then(|v| v.as_u64()).expect("a response always carries a statusCode")
2461
+ response
2462
+ .get("statusCode")
2463
+ .and_then(serde_json::Value::as_u64)
2464
+ .expect("a response always carries a statusCode")
1745
2465
  }
1746
2466
 
1747
2467
  #[test]
1748
2468
  fn an_unauthenticated_api_request_is_refused_with_the_ruby_engines_own_401_json() {
1749
- let refusal = auth_gate("/api/clients", false).expect("an unauthenticated /api/ request must be refused");
2469
+ let refusal = auth_gate("/api/clients", false)
2470
+ .expect("an unauthenticated /api/ request must be refused");
1750
2471
 
1751
2472
  assert_eq!(status(&refusal), 401);
1752
2473
  assert_eq!(refusal["headers"]["content-type"], "application/json");
1753
2474
  // Byte-for-byte the console app's own web/app.rb
1754
2475
  // `halt 401, json({ error: "Unauthenticated", message: "sign in first" })`.
1755
- assert_eq!(refusal["body"], r#"{"error":"Unauthenticated","message":"sign in first"}"#);
1756
- assert!(refusal["headers"].get("location").is_none(), "a JSON caller must not be redirected: {refusal}");
2476
+ assert_eq!(
2477
+ refusal["body"],
2478
+ r#"{"error":"Unauthenticated","message":"sign in first"}"#
2479
+ );
2480
+ assert!(
2481
+ refusal["headers"].get("location").is_none(),
2482
+ "a JSON caller must not be redirected: {refusal}"
2483
+ );
1757
2484
  }
1758
2485
 
1759
2486
  // Ruby keys off the `/api/` prefix alone and doesn't look at a
@@ -1769,9 +2496,18 @@ mod tests {
1769
2496
  // lives in the path), so it must be 401'd, never redirected.
1770
2497
  #[test]
1771
2498
  fn an_unauthenticated_host_route_is_401_unless_it_asks_for_html() {
1772
- assert_eq!(status(&auth_gate("/Pizzas/Order.json", false).expect("refused")), 401);
1773
- assert_eq!(status(&auth_gate("/Pizzas/Order", false).expect("refused")), 401);
1774
- assert_eq!(status(&auth_gate("/Pizzas/Order/p1.json", false).expect("refused")), 401);
2499
+ assert_eq!(
2500
+ status(&auth_gate("/Pizzas/Order.json", false).expect("refused")),
2501
+ 401
2502
+ );
2503
+ assert_eq!(
2504
+ status(&auth_gate("/Pizzas/Order", false).expect("refused")),
2505
+ 401
2506
+ );
2507
+ assert_eq!(
2508
+ status(&auth_gate("/Pizzas/Order/p1.json", false).expect("refused")),
2509
+ 401
2510
+ );
1775
2511
 
1776
2512
  let page = auth_gate("/Pizzas/Order.html", false).expect("refused");
1777
2513
  assert_eq!(status(&page), 302);
@@ -1790,14 +2526,20 @@ mod tests {
1790
2526
  assert_eq!(status(&home), 302);
1791
2527
  assert_eq!(home["headers"]["location"], "/login");
1792
2528
 
1793
- assert_eq!(status(&auth_gate("/favicon.ico", false).expect("refused")), 302);
2529
+ assert_eq!(
2530
+ status(&auth_gate("/favicon.ico", false).expect("refused")),
2531
+ 302
2532
+ );
1794
2533
  assert_eq!(status(&auth_gate("/Pizzas", false).expect("refused")), 302);
1795
2534
  }
1796
2535
 
1797
2536
  #[test]
1798
2537
  fn the_ungated_paths_are_still_ungated_without_a_session() {
1799
2538
  for path in UNGATED_PATHS {
1800
- assert!(auth_gate(path, false).is_none(), "{path} must reach its own handler with no session");
2539
+ assert!(
2540
+ auth_gate(path, false).is_none(),
2541
+ "{path} must reach its own handler with no session"
2542
+ );
1801
2543
  }
1802
2544
  }
1803
2545
 
@@ -1816,16 +2558,25 @@ mod tests {
1816
2558
  let link = action_link("Pizzas", "Order", "Purchase", MALICIOUS_ID, "mr-3");
1817
2559
 
1818
2560
  assert!(!link.contains("<script"), "{link}");
1819
- assert!(!link.contains(MALICIOUS_ID), "the raw id must not survive into the rendered link: {link}");
2561
+ assert!(
2562
+ !link.contains(MALICIOUS_ID),
2563
+ "the raw id must not survive into the rendered link: {link}"
2564
+ );
1820
2565
  // Percent-encoded, not HTML-escaped — esc() alone would leave a
1821
2566
  // raw `&` that terminates the `id=` param early.
1822
- assert!(link.contains("id=x%22%3E%3Cscript%3Ealert%281%29%3C%2Fscript%3E"), "{link}");
2567
+ assert!(
2568
+ link.contains("id=x%22%3E%3Cscript%3Ealert%281%29%3C%2Fscript%3E"),
2569
+ "{link}"
2570
+ );
1823
2571
  }
1824
2572
 
1825
2573
  #[test]
1826
2574
  fn action_link_renders_an_ordinary_id_unchanged() {
1827
2575
  let link = action_link("Pizzas", "Order", "Purchase", "p1", "mr-3");
1828
- assert_eq!(link, r#"<a href="/Pizzas/Order/Purchase.html?id=p1" class="mr-3">Purchase</a>"#);
2576
+ assert_eq!(
2577
+ link,
2578
+ r#"<a href="/Pizzas/Order/Purchase.html?id=p1" class="mr-3">Purchase</a>"#
2579
+ );
1829
2580
  }
1830
2581
 
1831
2582
  #[test]
@@ -1858,7 +2609,7 @@ mod tests {
1858
2609
  "invariants": [], "closed_set": false, "members": []
1859
2610
  });
1860
2611
  let customer = json!({"name": "Customer", "value_objects": [email_address_vo]});
1861
- let domain_ir = json!({"aggregates": [customer.clone()]});
2612
+ let domain_ir = json!({"aggregates": [customer]});
1862
2613
  let attribute = json!({"name": "email", "type": "EmailAddress", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
1863
2614
 
1864
2615
  let field = resolve_field(&domain_ir, &attribute, &customer, "email");
@@ -1880,7 +2631,7 @@ mod tests {
1880
2631
  "invariants": [], "closed_set": false, "members": []
1881
2632
  });
1882
2633
  let account = json!({"name": "Account", "value_objects": [money_vo]});
1883
- let domain_ir = json!({"aggregates": [account.clone()]});
2634
+ let domain_ir = json!({"aggregates": [account]});
1884
2635
  let attribute = json!({"name": "balance", "type": "Money", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
1885
2636
 
1886
2637
  let field = resolve_field(&domain_ir, &attribute, &account, "balance");
@@ -1888,8 +2639,14 @@ mod tests {
1888
2639
  FieldKind::Money(children) => {
1889
2640
  let paths: Vec<&str> = children.iter().map(|c| c.path.as_str()).collect();
1890
2641
  assert_eq!(paths, vec!["balance.cents", "balance.currency"]);
1891
- assert!(!children[0].optional, "cents follows the outer attribute's own optionality");
1892
- assert!(children[1].optional, "currency is always optional, defaulting to USD, regardless of the outer field");
2642
+ assert!(
2643
+ !children[0].optional,
2644
+ "cents follows the outer attribute's own optionality"
2645
+ );
2646
+ assert!(
2647
+ children[1].optional,
2648
+ "currency is always optional, defaulting to USD, regardless of the outer field"
2649
+ );
1893
2650
  }
1894
2651
  other => panic!("expected Money, got {other:?}"),
1895
2652
  }
@@ -1898,7 +2655,7 @@ mod tests {
1898
2655
  #[test]
1899
2656
  fn account_open_customer_id_renders_as_a_reference_carrying_its_target() {
1900
2657
  let account = json!({"name": "Account", "value_objects": []});
1901
- let domain_ir = json!({"aggregates": [account.clone()]});
2658
+ let domain_ir = json!({"aggregates": [account]});
1902
2659
  let attribute = json!({"name": "customer_id", "type": "Reference<Customer>", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
1903
2660
 
1904
2661
  let field = resolve_field(&domain_ir, &attribute, &account, "customer_id");
@@ -1912,7 +2669,8 @@ mod tests {
1912
2669
  // lookup and Tier B's textarea hint together, the one combination
1913
2670
  // that silently breaks if either regresses alone.
1914
2671
  #[test]
1915
- fn narrative_used_from_a_different_aggregate_renders_as_a_textarea_via_the_cross_aggregate_lookup() {
2672
+ fn narrative_used_from_a_different_aggregate_renders_as_a_textarea_via_the_cross_aggregate_lookup(
2673
+ ) {
1916
2674
  // Narrative is declared on Account, never CardPayment — scoped
1917
2675
  // here against CardPayment specifically to prove the fallback
1918
2676
  // walks the whole domain, not just declaring-aggregate siblings.
@@ -1927,7 +2685,7 @@ mod tests {
1927
2685
  "name": "CardPayment",
1928
2686
  "value_objects": [{"name": "AuthorisationCode", "attributes": [], "invariants": [], "closed_set": false, "members": []}]
1929
2687
  });
1930
- let domain_ir = json!({"aggregates": [account, card_payment.clone()]});
2688
+ let domain_ir = json!({"aggregates": [account, card_payment]});
1931
2689
  let attribute = json!({"name": "narrative", "type": "Narrative", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
1932
2690
 
1933
2691
  let field = resolve_field(&domain_ir, &attribute, &card_payment, "narrative");
@@ -1954,14 +2712,17 @@ mod tests {
1954
2712
  "attributes": [{"name": "value", "type": "String", "list": false, "default": null, "optional": false, "pattern": null, "admits": null}],
1955
2713
  "invariants": [], "closed_set": false, "members": []
1956
2714
  });
1957
- let external_transfer = json!({"name": "ExternalTransfer", "value_objects": [movement_direction_vo]});
1958
- let domain_ir = json!({"aggregates": [account, external_transfer.clone()]});
2715
+ let external_transfer =
2716
+ json!({"name": "ExternalTransfer", "value_objects": [movement_direction_vo]});
2717
+ let domain_ir = json!({"aggregates": [account, external_transfer]});
1959
2718
  let attribute = json!({"name": "direction", "type": "MovementDirection", "list": false, "default": null, "optional": false, "pattern": null, "admits": "Account::LedgerDirection"});
1960
2719
 
1961
2720
  let field = resolve_field(&domain_ir, &attribute, &external_transfer, "direction");
1962
2721
  assert_eq!(field.path, "direction.value");
1963
2722
  match field.kind {
1964
- FieldKind::Radio(members) => assert_eq!(members, vec!["credit".to_string(), "debit".to_string()]),
2723
+ FieldKind::Radio(members) => {
2724
+ assert_eq!(members, vec!["credit".to_string(), "debit".to_string()]);
2725
+ }
1965
2726
  other => panic!("expected Radio with exactly 2 members, got {other:?}"),
1966
2727
  }
1967
2728
  }
@@ -1971,7 +2732,7 @@ mod tests {
1971
2732
  // No `pattern:` at all (synthetic — every real email attribute
1972
2733
  // also carries one) — proves the name-based match alone is enough.
1973
2734
  let aggregate = json!({"name": "Whatever", "value_objects": []});
1974
- let domain_ir = json!({"aggregates": [aggregate.clone()]});
2735
+ let domain_ir = json!({"aggregates": [aggregate]});
1975
2736
  let attribute = json!({"name": "contact_email", "type": "String", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
1976
2737
 
1977
2738
  let field = resolve_field(&domain_ir, &attribute, &aggregate, "contact_email");
@@ -1986,7 +2747,7 @@ mod tests {
1986
2747
  // No real corpus example of either shape exists; built minimal
1987
2748
  // here, same as auth.rs's own fixtures with no real counterpart.
1988
2749
  let aggregate = json!({"name": "Whatever", "value_objects": []});
1989
- let domain_ir = json!({"aggregates": [aggregate.clone()]});
2750
+ let domain_ir = json!({"aggregates": [aggregate]});
1990
2751
 
1991
2752
  let website = json!({"name": "website", "type": "String", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
1992
2753
  match resolve_field(&domain_ir, &website, &aggregate, "website").kind {
@@ -2011,7 +2772,7 @@ mod tests {
2011
2772
  "closed_set": false, "members": []
2012
2773
  });
2013
2774
  let order = json!({"name": "Order", "value_objects": [price_vo]});
2014
- let domain_ir = json!({"aggregates": [order.clone()]});
2775
+ let domain_ir = json!({"aggregates": [order]});
2015
2776
  let attribute = json!({"name": "price_cents", "type": "Price", "list": false, "default": null, "optional": false, "pattern": null, "admits": null});
2016
2777
 
2017
2778
  match resolve_field(&domain_ir, &attribute, &order, "price_cents").kind {
@@ -2034,15 +2795,24 @@ mod tests {
2034
2795
 
2035
2796
  #[test]
2036
2797
  fn nest_builds_an_ordinary_dotted_path_into_a_nested_object() {
2037
- let result = nest(vec![("price.cents".to_string(), json!(500)), ("price.currency".to_string(), json!("USD"))]).unwrap();
2798
+ let result = nest(vec![
2799
+ ("price.cents".to_string(), json!(500)),
2800
+ ("price.currency".to_string(), json!("USD")),
2801
+ ])
2802
+ .unwrap();
2038
2803
  assert_eq!(result, json!({"price": {"cents": 500, "currency": "USD"}}));
2039
2804
  }
2040
2805
 
2041
2806
  #[test]
2042
- fn nest_refuses_rather_than_panics_when_a_scalar_is_inserted_before_a_deeper_path_sharing_its_prefix() {
2807
+ fn nest_refuses_rather_than_panics_when_a_scalar_is_inserted_before_a_deeper_path_sharing_its_prefix(
2808
+ ) {
2043
2809
  // `price` lands as a scalar first, then `price.cents` tries to
2044
2810
  // descend into it — the shape that would otherwise panic instead of refusing cleanly.
2045
- let err = nest(vec![("price".to_string(), json!(500)), ("price.cents".to_string(), json!(500))]).unwrap_err();
2811
+ let err = nest(vec![
2812
+ ("price".to_string(), json!(500)),
2813
+ ("price.cents".to_string(), json!(500)),
2814
+ ])
2815
+ .unwrap_err();
2046
2816
  assert!(err.contains("price"), "{err}");
2047
2817
  }
2048
2818
 
@@ -2050,13 +2820,21 @@ mod tests {
2050
2820
  fn nest_refuses_rather_than_silently_dropping_data_when_the_deeper_path_is_inserted_first() {
2051
2821
  // The reverse insertion order clobbers a nested object with a
2052
2822
  // bare scalar instead of panicking — must refuse here too.
2053
- let err = nest(vec![("price.cents".to_string(), json!(500)), ("price".to_string(), json!(500))]).unwrap_err();
2823
+ let err = nest(vec![
2824
+ ("price.cents".to_string(), json!(500)),
2825
+ ("price".to_string(), json!(500)),
2826
+ ])
2827
+ .unwrap_err();
2054
2828
  assert!(err.contains("price"), "{err}");
2055
2829
  }
2056
2830
 
2057
2831
  #[test]
2058
2832
  fn nest_is_unaffected_by_an_unrelated_sibling_sharing_no_prefix() {
2059
- let result = nest(vec![("price.cents".to_string(), json!(500)), ("name".to_string(), json!("Widget"))]).unwrap();
2833
+ let result = nest(vec![
2834
+ ("price.cents".to_string(), json!(500)),
2835
+ ("name".to_string(), json!("Widget")),
2836
+ ])
2837
+ .unwrap();
2060
2838
  assert_eq!(result, json!({"price": {"cents": 500}, "name": "Widget"}));
2061
2839
  }
2062
2840
 
@@ -2066,16 +2844,30 @@ mod tests {
2066
2844
  assert_eq!(q.get("code").map(String::as_str), Some("abc+def/gh"));
2067
2845
  assert_eq!(q.get("state").map(String::as_str), Some("1.sig"));
2068
2846
  let form = parse_form("code=abc+def");
2069
- assert_eq!(form.get("code").map(String::as_str), Some("abc def"), "HTML forms still treat + as space");
2847
+ assert_eq!(
2848
+ form.get("code").map(String::as_str),
2849
+ Some("abc def"),
2850
+ "HTML forms still treat + as space"
2851
+ );
2070
2852
  }
2071
2853
 
2072
2854
  #[test]
2073
2855
  fn extract_args_surfaces_a_collision_as_an_error_rather_than_panicking() {
2074
- let scalar_field = Field { path: "price".to_string(), label: "Price".to_string(), kind: FieldKind::Number { step: "1" }, optional: false };
2856
+ let scalar_field = Field {
2857
+ path: "price".to_string(),
2858
+ label: "Price".to_string(),
2859
+ kind: FieldKind::Number { step: "1" },
2860
+ optional: false,
2861
+ };
2075
2862
  let nested_field = Field {
2076
2863
  path: "price".to_string(),
2077
2864
  label: "Price".to_string(),
2078
- kind: FieldKind::Group(vec![Field { path: "price.cents".to_string(), label: "Cents".to_string(), kind: FieldKind::Number { step: "1" }, optional: false }]),
2865
+ kind: FieldKind::Group(vec![Field {
2866
+ path: "price.cents".to_string(),
2867
+ label: "Cents".to_string(),
2868
+ kind: FieldKind::Number { step: "1" },
2869
+ optional: false,
2870
+ }]),
2079
2871
  optional: false,
2080
2872
  };
2081
2873
  let mut raw = HashMap::new();
@@ -2083,7 +2875,10 @@ mod tests {
2083
2875
  raw.insert("price.cents".to_string(), "500".to_string());
2084
2876
 
2085
2877
  let result = extract_args(&[scalar_field, nested_field], &raw);
2086
- assert!(result.is_err(), "expected a collision error, got {result:?}");
2878
+ assert!(
2879
+ result.is_err(),
2880
+ "expected a collision error, got {result:?}"
2881
+ );
2087
2882
  }
2088
2883
 
2089
2884
  #[test]
@@ -2103,7 +2898,11 @@ mod tests {
2103
2898
  {"aggregate": "Pizzas::LoyaltyAccount", "id": "loyalty-9", "operation": "save"}
2104
2899
  ]]
2105
2900
  });
2106
- assert_eq!(own_command_target_id(&result), Some("order-1"), "must redirect to the command's OWN aggregate, not the cascaded reaction's");
2901
+ assert_eq!(
2902
+ own_command_target_id(&result),
2903
+ Some("order-1"),
2904
+ "must redirect to the command's OWN aggregate, not the cascaded reaction's"
2905
+ );
2107
2906
  }
2108
2907
 
2109
2908
  #[test]
@@ -2129,14 +2928,24 @@ mod tests {
2129
2928
  use tokio_postgres::NoTls;
2130
2929
 
2131
2930
  pub(super) async fn scratch_db(name: &str) -> Mutex<Client> {
2132
- let (admin, conn) = tokio_postgres::connect(&crate::test_pg::conninfo("postgres"), NoTls).await.expect("connect to postgres");
2931
+ let (admin, conn) = tokio_postgres::connect(&crate::test_pg::conninfo("postgres"), NoTls)
2932
+ .await
2933
+ .expect("connect to postgres");
2133
2934
  tokio::spawn(async move {
2134
2935
  let _ = conn.await;
2135
2936
  });
2136
- admin.batch_execute(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)")).await.unwrap();
2137
- admin.batch_execute(&format!("CREATE DATABASE {name}")).await.unwrap();
2937
+ admin
2938
+ .batch_execute(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)"))
2939
+ .await
2940
+ .unwrap();
2941
+ admin
2942
+ .batch_execute(&format!("CREATE DATABASE {name}"))
2943
+ .await
2944
+ .unwrap();
2138
2945
 
2139
- let (client, conn) = tokio_postgres::connect(&crate::test_pg::conninfo(&name), NoTls).await.expect("connect to scratch db");
2946
+ let (client, conn) = tokio_postgres::connect(&crate::test_pg::conninfo(name), NoTls)
2947
+ .await
2948
+ .expect("connect to scratch db");
2140
2949
  tokio::spawn(async move {
2141
2950
  let _ = conn.await;
2142
2951
  });
@@ -2146,10 +2955,23 @@ mod tests {
2146
2955
 
2147
2956
  // Duplicates dispatch.rs's own provision_lineage shape rather than
2148
2957
  // sharing it, matching this crate's existing per-module test fixtures.
2149
- pub(super) async fn provision_lineage(client: &Client, domain: &str, era: i32, aggregate_storage_names: &[&str]) {
2150
- client.batch_execute("CREATE TABLE IF NOT EXISTS hecks_eras (domain text, ordinal int, held_text text)").await.unwrap();
2958
+ pub(super) async fn provision_lineage(
2959
+ client: &Client,
2960
+ domain: &str,
2961
+ era: i32,
2962
+ aggregate_storage_names: &[&str],
2963
+ ) {
2151
2964
  client
2152
- .execute("INSERT INTO hecks_eras (domain, ordinal, held_text) VALUES ($1, $2, 'test')", &[&domain, &era])
2965
+ .batch_execute(
2966
+ "CREATE TABLE IF NOT EXISTS hecks_eras (domain text, ordinal int, held_text text)",
2967
+ )
2968
+ .await
2969
+ .unwrap();
2970
+ client
2971
+ .execute(
2972
+ "INSERT INTO hecks_eras (domain, ordinal, held_text) VALUES ($1, $2, 'test')",
2973
+ &[&domain, &era],
2974
+ )
2153
2975
  .await
2154
2976
  .unwrap();
2155
2977
 
@@ -2171,7 +2993,10 @@ mod tests {
2171
2993
  for name in aggregate_storage_names {
2172
2994
  // domain-qualified (docs/decisions/0059) — matches what a real
2173
2995
  // `journal::append_lineage_mutation` write actually targets now.
2174
- let snapshot_table = crate::journal::qualified_name(domain, &format!("{}_head_snapshot_{era}", crate::journal::snake(name)));
2996
+ let snapshot_table = crate::journal::qualified_name(
2997
+ domain,
2998
+ &format!("{}_head_snapshot_{era}", crate::journal::snake(name)),
2999
+ );
2175
3000
  client
2176
3001
  .batch_execute(&format!("CREATE TABLE IF NOT EXISTS \"{snapshot_table}\" (id text PRIMARY KEY, ordinal bigint NOT NULL, state jsonb NOT NULL)"))
2177
3002
  .await
@@ -2189,19 +3014,33 @@ mod tests {
2189
3014
  assert_eq!(response["statusCode"], 200, "{response:?}");
2190
3015
  let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
2191
3016
  assert_eq!(body["email"], "zed@example.com");
2192
- assert_eq!(body["identity_id"], "id-1", "the signed-in account's Governance identity id");
3017
+ assert_eq!(
3018
+ body["identity_id"], "id-1",
3019
+ "the signed-in account's Governance identity id"
3020
+ );
2193
3021
 
2194
3022
  let empty = HashMap::new();
2195
- assert_eq!(accounts_me_route(&domain_ir, &empty, secret, &client).await["statusCode"], 401);
3023
+ assert_eq!(
3024
+ accounts_me_route(&domain_ir, &empty, secret, &client).await["statusCode"],
3025
+ 401
3026
+ );
2196
3027
 
2197
3028
  let mut tampered = HashMap::new();
2198
- tampered.insert(auth::account_cookie_name(), "garbage.notasignature".to_string());
2199
- assert_eq!(accounts_me_route(&domain_ir, &tampered, secret, &client).await["statusCode"], 401);
3029
+ tampered.insert(
3030
+ auth::account_cookie_name(),
3031
+ "garbage.notasignature".to_string(),
3032
+ );
3033
+ assert_eq!(
3034
+ accounts_me_route(&domain_ir, &tampered, secret, &client).await["statusCode"],
3035
+ 401
3036
+ );
2200
3037
 
2201
3038
  // A validly signed token for someone with no granted role, or nobody
2202
3039
  // admitted at all, is no session.
2203
3040
  for email in ["amy@example.com", "stranger@example.com"] {
2204
- let response = accounts_me_route(&domain_ir, &session_cookies(secret, email), secret, &client).await;
3041
+ let response =
3042
+ accounts_me_route(&domain_ir, &session_cookies(secret, email), secret, &client)
3043
+ .await;
2205
3044
  assert_eq!(response["statusCode"], 401, "{email}: {response:?}");
2206
3045
  }
2207
3046
  }
@@ -2209,10 +3048,14 @@ mod tests {
2209
3048
  #[tokio::test]
2210
3049
  async fn accounts_sso_token_route_mints_a_short_lived_token_verifiable_by_the_same_secret() {
2211
3050
  let secret = "s3cret";
2212
- let (client, domain_ir) = scratch_members_db("hecks_host_web_test_accounts_sso_token").await;
3051
+ let (client, domain_ir) =
3052
+ scratch_members_db("hecks_host_web_test_accounts_sso_token").await;
2213
3053
 
2214
3054
  let mut cookies = HashMap::new();
2215
- cookies.insert(auth::account_cookie_name(), auth::account_token(secret, "zed@example.com", 60 * 60 * 24 * 14));
3055
+ cookies.insert(
3056
+ auth::account_cookie_name(),
3057
+ auth::account_token(secret, "zed@example.com", 60 * 60 * 24 * 14),
3058
+ );
2216
3059
  let response = accounts_sso_token_route(&domain_ir, &cookies, secret, &client).await;
2217
3060
  assert_eq!(response["statusCode"], 200, "{response:?}");
2218
3061
  let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
@@ -2220,17 +3063,35 @@ mod tests {
2220
3063
 
2221
3064
  // The point: cms/src/endpoints/sso.ts verifies this exact wire
2222
3065
  // format — a real, decodable token, not just a 200 with any string.
2223
- assert_eq!(auth::verify_account_token(secret, sso_token).as_deref(), Some("zed@example.com"));
3066
+ assert_eq!(
3067
+ auth::verify_account_token(secret, sso_token).as_deref(),
3068
+ Some("zed@example.com")
3069
+ );
2224
3070
 
2225
3071
  // Missing or invalid session cookie -- refused, no token minted.
2226
3072
  let empty = HashMap::new();
2227
- assert_eq!(accounts_sso_token_route(&domain_ir, &empty, secret, &client).await["statusCode"], 401);
3073
+ assert_eq!(
3074
+ accounts_sso_token_route(&domain_ir, &empty, secret, &client).await["statusCode"],
3075
+ 401
3076
+ );
2228
3077
 
2229
3078
  let mut tampered = HashMap::new();
2230
- tampered.insert(auth::account_cookie_name(), "garbage.notasignature".to_string());
2231
- assert_eq!(accounts_sso_token_route(&domain_ir, &tampered, secret, &client).await["statusCode"], 401);
3079
+ tampered.insert(
3080
+ auth::account_cookie_name(),
3081
+ "garbage.notasignature".to_string(),
3082
+ );
3083
+ assert_eq!(
3084
+ accounts_sso_token_route(&domain_ir, &tampered, secret, &client).await["statusCode"],
3085
+ 401
3086
+ );
2232
3087
 
2233
- let no_role = accounts_sso_token_route(&domain_ir, &session_cookies(secret, "amy@example.com"), secret, &client).await;
3088
+ let no_role = accounts_sso_token_route(
3089
+ &domain_ir,
3090
+ &session_cookies(secret, "amy@example.com"),
3091
+ secret,
3092
+ &client,
3093
+ )
3094
+ .await;
2234
3095
  assert_eq!(no_role["statusCode"], 401, "{no_role:?}");
2235
3096
  }
2236
3097
 
@@ -2281,7 +3142,10 @@ mod tests {
2281
3142
  let (client, domain_ir) = scratch_members_db("hecks_host_web_test_members_route").await;
2282
3143
 
2283
3144
  let mut cookies = HashMap::new();
2284
- cookies.insert(auth::account_cookie_name(), auth::account_token(secret, "zed@example.com", 60));
3145
+ cookies.insert(
3146
+ auth::account_cookie_name(),
3147
+ auth::account_token(secret, "zed@example.com", 60),
3148
+ );
2285
3149
  let response = members_route(&domain_ir, &cookies, secret, &client).await;
2286
3150
  assert_eq!(response["statusCode"], 200, "{response:?}");
2287
3151
 
@@ -2304,19 +3168,34 @@ mod tests {
2304
3168
  let (client, domain_ir) = scratch_members_db("hecks_host_web_test_members_route_401").await;
2305
3169
 
2306
3170
  let mut tampered = HashMap::new();
2307
- tampered.insert(auth::account_cookie_name(), "garbage.notasignature".to_string());
3171
+ tampered.insert(
3172
+ auth::account_cookie_name(),
3173
+ "garbage.notasignature".to_string(),
3174
+ );
2308
3175
  let mut wrong_secret = HashMap::new();
2309
- wrong_secret.insert(auth::account_cookie_name(), auth::account_token("another", "zed@example.com", 60));
3176
+ wrong_secret.insert(
3177
+ auth::account_cookie_name(),
3178
+ auth::account_token("another", "zed@example.com", 60),
3179
+ );
2310
3180
  let mut governance_only = HashMap::new();
2311
3181
  governance_only.insert("session".to_string(), "anything".to_string());
2312
3182
 
2313
3183
  for cookies in [HashMap::new(), tampered, wrong_secret, governance_only] {
2314
3184
  let response = members_route(&domain_ir, &cookies, secret, &client).await;
2315
3185
  assert_eq!(response["statusCode"], 401, "{response:?}");
2316
- assert!(response.get("headers").and_then(|h| h.get("location")).is_none(), "never a redirect: {response:?}");
3186
+ assert!(
3187
+ response
3188
+ .get("headers")
3189
+ .and_then(|h| h.get("location"))
3190
+ .is_none(),
3191
+ "never a redirect: {response:?}"
3192
+ );
2317
3193
  let body = response["body"].as_str().unwrap();
2318
3194
  for leaked in ["zed@example.com", "amy@example.com", "Zed", "Admin"] {
2319
- assert!(!body.contains(leaked), "{leaked:?} leaked to an unauthenticated caller: {body}");
3195
+ assert!(
3196
+ !body.contains(leaked),
3197
+ "{leaked:?} leaked to an unauthenticated caller: {body}"
3198
+ );
2320
3199
  }
2321
3200
  }
2322
3201
  }
@@ -2339,29 +3218,48 @@ mod tests {
2339
3218
  .await
2340
3219
  .unwrap();
2341
3220
 
2342
- let response = members_route(&domain_ir, &session_cookies(secret, "max@example.com"), secret, &client).await;
3221
+ let response = members_route(
3222
+ &domain_ir,
3223
+ &session_cookies(secret, "max@example.com"),
3224
+ secret,
3225
+ &client,
3226
+ )
3227
+ .await;
2343
3228
  assert_eq!(response["statusCode"], 403, "{response:?}");
2344
3229
  let body = response["body"].as_str().unwrap();
2345
3230
  for leaked in ["zed@example.com", "amy@example.com", "Zed"] {
2346
- assert!(!body.contains(leaked), "{leaked:?} leaked to a plain member: {body}");
3231
+ assert!(
3232
+ !body.contains(leaked),
3233
+ "{leaked:?} leaked to a plain member: {body}"
3234
+ );
2347
3235
  }
2348
3236
  }
2349
3237
 
2350
3238
  fn session_cookies(secret: &str, email: &str) -> HashMap<String, String> {
2351
3239
  let mut cookies = HashMap::new();
2352
- cookies.insert(auth::account_cookie_name(), auth::account_token(secret, email, 60));
3240
+ cookies.insert(
3241
+ auth::account_cookie_name(),
3242
+ auth::account_token(secret, email, 60),
3243
+ );
2353
3244
  cookies
2354
3245
  }
2355
3246
 
2356
3247
  fn members_config() -> LineageConfig {
2357
- LineageConfig { domain: "Acme".to_string(), era: Some(1), mirrored: None }
3248
+ LineageConfig {
3249
+ domain: "Acme".to_string(),
3250
+ era: Some(1),
3251
+ mirrored: None,
3252
+ }
2358
3253
  }
2359
3254
 
2360
3255
  #[tokio::test]
2361
- async fn signup_route_refuses_anything_but_a_valid_signup_token_with_a_json_401_and_writes_nothing() {
3256
+ async fn signup_route_refuses_anything_but_a_valid_signup_token_with_a_json_401_and_writes_nothing(
3257
+ ) {
2362
3258
  let secret = "s3cret";
2363
3259
  let (client, domain_ir) = scratch_members_db("hecks_host_web_test_signup_401").await;
2364
- let body = |token: &str| json!({"token": token, "email": "new@example.com", "name": "New Person"}).to_string();
3260
+ let body = |token: &str| {
3261
+ json!({"token": token, "email": "new@example.com", "name": "New Person"}).to_string()
3262
+ };
2365
3263
 
2366
3264
  // A session token and a token minted for another purpose both verify
2367
3265
  // as tokens under some key, but never as a signup token.
@@ -2369,14 +3267,50 @@ mod tests {
2369
3267
  let other_purpose = auth::purpose_token(secret, "another_purpose", json!({}), 60);
2370
3268
  let wrong_secret = auth::purpose_token("another", SIGNUP_PURPOSE, json!({}), 60);
2371
3269
 
2372
- for token in ["", "garbage.notasignature", session_token.as_str(), other_purpose.as_str(), wrong_secret.as_str()] {
2373
- let response = signup_route(&body(token), secret, &client, Path::new("unused"), &members_config(), &crate::lambda_client::NeverInvoker).await;
3270
+ for token in [
3271
+ "",
3272
+ "garbage.notasignature",
3273
+ session_token.as_str(),
3274
+ other_purpose.as_str(),
3275
+ wrong_secret.as_str(),
3276
+ ] {
3277
+ let response = signup_route(
3278
+ &body(token),
3279
+ secret,
3280
+ Host::new(
3281
+ &client,
3282
+ Path::new("unused"),
3283
+ &members_config(),
3284
+ &crate::lambda_client::NeverInvoker,
3285
+ ),
3286
+ )
3287
+ .await;
2374
3288
  assert_eq!(response["statusCode"], 401, "{response:?}");
2375
- assert!(response.get("headers").and_then(|h| h.get("location")).is_none(), "never a redirect: {response:?}");
3289
+ assert!(
3290
+ response
3291
+ .get("headers")
3292
+ .and_then(|h| h.get("location"))
3293
+ .is_none(),
3294
+ "never a redirect: {response:?}"
3295
+ );
2376
3296
  }
2377
- let unparseable = signup_route("not json", secret, &client, Path::new("unused"), &members_config(), &crate::lambda_client::NeverInvoker).await;
3297
+ let unparseable = signup_route(
3298
+ "not json",
3299
+ secret,
3300
+ Host::new(
3301
+ &client,
3302
+ Path::new("unused"),
3303
+ &members_config(),
3304
+ &crate::lambda_client::NeverInvoker,
3305
+ ),
3306
+ )
3307
+ .await;
2378
3308
  assert_eq!(unparseable["statusCode"], 401, "{unparseable:?}");
2379
- assert_eq!(auth::all_people(&client, &domain_ir).await.unwrap().len(), 2, "nothing was written");
3309
+ assert_eq!(
3310
+ auth::all_people(&client, &domain_ir).await.unwrap().len(),
3311
+ 2,
3312
+ "nothing was written"
3313
+ );
2380
3314
  }
2381
3315
 
2382
3316
  #[tokio::test]
@@ -2385,34 +3319,83 @@ mod tests {
2385
3319
  let (client, _) = scratch_members_db("hecks_host_web_test_signup_400").await;
2386
3320
  let token = auth::purpose_token(secret, SIGNUP_PURPOSE, json!({}), 60);
2387
3321
 
2388
- for (email, name) in [("", "Ada"), ("no-at-sign", "Ada"), ("a@nodot", "Ada"), ("has space@x.io", "Ada"), ("ada@x.io", ""), ("ada@x.io", " ")] {
3322
+ for (email, name) in [
3323
+ ("", "Ada"),
3324
+ ("no-at-sign", "Ada"),
3325
+ ("a@nodot", "Ada"),
3326
+ ("has space@x.io", "Ada"),
3327
+ ("ada@x.io", ""),
3328
+ ("ada@x.io", " "),
3329
+ ] {
2389
3330
  let body = json!({"token": token, "email": email, "name": name}).to_string();
2390
- let response = signup_route(&body, secret, &client, Path::new("unused"), &members_config(), &crate::lambda_client::NeverInvoker).await;
2391
- assert_eq!(response["statusCode"], 400, "{email:?} / {name:?}: {response:?}");
3331
+ let response = signup_route(
3332
+ &body,
3333
+ secret,
3334
+ Host::new(
3335
+ &client,
3336
+ Path::new("unused"),
3337
+ &members_config(),
3338
+ &crate::lambda_client::NeverInvoker,
3339
+ ),
3340
+ )
3341
+ .await;
3342
+ assert_eq!(
3343
+ response["statusCode"], 400,
3344
+ "{email:?} / {name:?}: {response:?}"
3345
+ );
2392
3346
  }
2393
3347
  }
2394
3348
 
2395
3349
  #[tokio::test]
2396
- async fn add_member_route_refuses_a_missing_or_invalid_session_with_a_json_401_and_writes_nothing() {
3350
+ async fn add_member_route_refuses_a_missing_or_invalid_session_with_a_json_401_and_writes_nothing(
3351
+ ) {
2397
3352
  let secret = "s3cret";
2398
3353
  let (client, domain_ir) = scratch_members_db("hecks_host_web_test_add_member_401").await;
2399
3354
  let body = r#"{"email": "new@example.com", "name": "New Person"}"#;
2400
3355
 
2401
3356
  let mut tampered = HashMap::new();
2402
- tampered.insert(auth::account_cookie_name(), "garbage.notasignature".to_string());
3357
+ tampered.insert(
3358
+ auth::account_cookie_name(),
3359
+ "garbage.notasignature".to_string(),
3360
+ );
2403
3361
  let mut wrong_secret = HashMap::new();
2404
- wrong_secret.insert(auth::account_cookie_name(), auth::account_token("another", "zed@example.com", 60));
3362
+ wrong_secret.insert(
3363
+ auth::account_cookie_name(),
3364
+ auth::account_token("another", "zed@example.com", 60),
3365
+ );
2405
3366
 
2406
3367
  for cookies in [HashMap::new(), tampered, wrong_secret] {
2407
- let response = add_member_route(&domain_ir, body, &cookies, secret, &client, Path::new("unused"), &members_config()).await;
3368
+ let response = add_member_route(
3369
+ &domain_ir,
3370
+ body,
3371
+ &cookies,
3372
+ secret,
3373
+ &client,
3374
+ Path::new("unused"),
3375
+ &members_config(),
3376
+ )
3377
+ .await;
2408
3378
  assert_eq!(response["statusCode"], 401, "{response:?}");
2409
- assert!(response.get("headers").and_then(|h| h.get("location")).is_none(), "never a redirect: {response:?}");
3379
+ assert!(
3380
+ response
3381
+ .get("headers")
3382
+ .and_then(|h| h.get("location"))
3383
+ .is_none(),
3384
+ "never a redirect: {response:?}"
3385
+ );
2410
3386
  let text = response["body"].as_str().unwrap();
2411
3387
  for leaked in ["zed@example.com", "amy@example.com", "Zed", "Admin"] {
2412
- assert!(!text.contains(leaked), "{leaked:?} leaked to an unauthenticated caller: {text}");
3388
+ assert!(
3389
+ !text.contains(leaked),
3390
+ "{leaked:?} leaked to an unauthenticated caller: {text}"
3391
+ );
2413
3392
  }
2414
3393
  }
2415
- assert_eq!(auth::all_people(&client, &domain_ir).await.unwrap().len(), 2, "nothing was written");
3394
+ assert_eq!(
3395
+ auth::all_people(&client, &domain_ir).await.unwrap().len(),
3396
+ 2,
3397
+ "nothing was written"
3398
+ );
2416
3399
  }
2417
3400
 
2418
3401
  #[tokio::test]
@@ -2424,10 +3407,23 @@ mod tests {
2424
3407
  // amy is admitted but has no role; a stranger is not admitted at all.
2425
3408
  for caller in ["amy@example.com", "stranger@example.com"] {
2426
3409
  let cookies = session_cookies(secret, caller);
2427
- let response = add_member_route(&domain_ir, body, &cookies, secret, &client, Path::new("unused"), &members_config()).await;
3410
+ let response = add_member_route(
3411
+ &domain_ir,
3412
+ body,
3413
+ &cookies,
3414
+ secret,
3415
+ &client,
3416
+ Path::new("unused"),
3417
+ &members_config(),
3418
+ )
3419
+ .await;
2428
3420
  assert_eq!(response["statusCode"], 403, "{caller}: {response:?}");
2429
3421
  }
2430
- assert_eq!(auth::all_people(&client, &domain_ir).await.unwrap().len(), 2, "nothing was written");
3422
+ assert_eq!(
3423
+ auth::all_people(&client, &domain_ir).await.unwrap().len(),
3424
+ 2,
3425
+ "nothing was written"
3426
+ );
2431
3427
  }
2432
3428
 
2433
3429
  #[tokio::test]
@@ -2450,10 +3446,23 @@ mod tests {
2450
3446
  r#"{"email": "two words@example.com", "name": "New Person"}"#,
2451
3447
  r#"{"email": 42, "name": "New Person"}"#,
2452
3448
  ] {
2453
- let response = add_member_route(&domain_ir, body, &cookies, secret, &client, Path::new("unused"), &members_config()).await;
3449
+ let response = add_member_route(
3450
+ &domain_ir,
3451
+ body,
3452
+ &cookies,
3453
+ secret,
3454
+ &client,
3455
+ Path::new("unused"),
3456
+ &members_config(),
3457
+ )
3458
+ .await;
2454
3459
  assert_eq!(response["statusCode"], 400, "{body:?}: {response:?}");
2455
3460
  }
2456
- assert_eq!(auth::all_people(&client, &domain_ir).await.unwrap().len(), 2, "nothing was written");
3461
+ assert_eq!(
3462
+ auth::all_people(&client, &domain_ir).await.unwrap().len(),
3463
+ 2,
3464
+ "nothing was written"
3465
+ );
2457
3466
  }
2458
3467
 
2459
3468
  #[tokio::test]
@@ -2464,10 +3473,152 @@ mod tests {
2464
3473
 
2465
3474
  for email in ["amy@example.com", "AMY@Example.com"] {
2466
3475
  let body = json!({"email": email, "name": "Another Amy"}).to_string();
2467
- let response = add_member_route(&domain_ir, &body, &cookies, secret, &client, Path::new("unused"), &members_config()).await;
3476
+ let response = add_member_route(
3477
+ &domain_ir,
3478
+ &body,
3479
+ &cookies,
3480
+ secret,
3481
+ &client,
3482
+ Path::new("unused"),
3483
+ &members_config(),
3484
+ )
3485
+ .await;
2468
3486
  assert_eq!(response["statusCode"], 409, "{email}: {response:?}");
2469
3487
  }
2470
- assert_eq!(auth::all_people(&client, &domain_ir).await.unwrap().len(), 2, "nothing was written");
3488
+ assert_eq!(
3489
+ auth::all_people(&client, &domain_ir).await.unwrap().len(),
3490
+ 2,
3491
+ "nothing was written"
3492
+ );
3493
+ }
3494
+
3495
+ // /admin/members by the account cookie alone, as a site whose sign-in sets only that cookie.
3496
+ async fn members_page(
3497
+ method: &str,
3498
+ body: &str,
3499
+ cookies: &HashMap<String, String>,
3500
+ secret: &str,
3501
+ client: &Mutex<Client>,
3502
+ domain_ir: &Value,
3503
+ ) -> Value {
3504
+ admin_members_route(
3505
+ method,
3506
+ domain_ir,
3507
+ body,
3508
+ cookies,
3509
+ None,
3510
+ secret,
3511
+ client,
3512
+ Path::new("unused"),
3513
+ &members_config(),
3514
+ )
3515
+ .await
3516
+ }
3517
+
3518
+ #[tokio::test]
3519
+ async fn members_page_takes_an_admin_by_the_account_cookie_and_turns_away_anyone_else() {
3520
+ let secret = "s3cret";
3521
+ let (client, domain_ir) =
3522
+ scratch_members_db("hecks_host_web_test_members_page_cookie").await;
3523
+
3524
+ let page = members_page(
3525
+ "GET",
3526
+ "",
3527
+ &session_cookies(secret, "zed@example.com"),
3528
+ secret,
3529
+ &client,
3530
+ &domain_ir,
3531
+ )
3532
+ .await;
3533
+ assert_eq!(page["statusCode"], 200, "{page:?}");
3534
+ assert!(
3535
+ page["body"].as_str().unwrap().contains("amy@example.com"),
3536
+ "the page lists the members"
3537
+ );
3538
+
3539
+ let not_admin = members_page(
3540
+ "GET",
3541
+ "",
3542
+ &session_cookies(secret, "amy@example.com"),
3543
+ secret,
3544
+ &client,
3545
+ &domain_ir,
3546
+ )
3547
+ .await;
3548
+ assert_eq!(not_admin["statusCode"], 403);
3549
+
3550
+ let nobody = members_page("GET", "", &HashMap::new(), secret, &client, &domain_ir).await;
3551
+ assert_eq!(nobody["statusCode"], 302);
3552
+ assert_eq!(nobody["headers"]["location"], "/login");
3553
+ }
3554
+
3555
+ #[tokio::test]
3556
+ async fn members_page_form_admits_a_new_person_and_grants_the_role_or_grants_an_existing_one() {
3557
+ let secret = "s3cret";
3558
+ let (client, domain_ir) = scratch_members_db("hecks_host_web_test_members_page_form").await;
3559
+ let admin = session_cookies(secret, "zed@example.com");
3560
+
3561
+ let added = members_page(
3562
+ "POST",
3563
+ "name=Lou+Ann&email=Lou%40Example.org&role=Admin",
3564
+ &admin,
3565
+ secret,
3566
+ &client,
3567
+ &domain_ir,
3568
+ )
3569
+ .await;
3570
+ assert_eq!(added["statusCode"], 302, "{added:?}");
3571
+ assert_eq!(added["headers"]["location"], "/admin/members");
3572
+ let granted = members_page(
3573
+ "POST",
3574
+ "email=amy%40example.com&role=Owner",
3575
+ &admin,
3576
+ secret,
3577
+ &client,
3578
+ &domain_ir,
3579
+ )
3580
+ .await;
3581
+ assert_eq!(granted["statusCode"], 302, "{granted:?}");
3582
+
3583
+ let people = auth::all_people(&client, &domain_ir).await.unwrap();
3584
+ let lou = people
3585
+ .iter()
3586
+ .find(|p| p["email"] == "lou@example.org")
3587
+ .expect("Lou Ann is admitted");
3588
+ assert_eq!(
3589
+ (
3590
+ lou["name"].as_str(),
3591
+ lou["role"].as_str(),
3592
+ lou["granted"].as_bool()
3593
+ ),
3594
+ (Some("Lou Ann"), Some("Admin"), Some(true))
3595
+ );
3596
+ let amy = people
3597
+ .iter()
3598
+ .find(|p| p["email"] == "amy@example.com")
3599
+ .unwrap();
3600
+ assert_eq!(amy["role"], "Owner");
3601
+
3602
+ let bad = members_page(
3603
+ "POST",
3604
+ "email=not-an-email&role=Admin",
3605
+ &admin,
3606
+ secret,
3607
+ &client,
3608
+ &domain_ir,
3609
+ )
3610
+ .await;
3611
+ assert_eq!(bad["statusCode"], 400);
3612
+ let stranger = members_page(
3613
+ "POST",
3614
+ "email=x%40example.com&role=Admin",
3615
+ &session_cookies(secret, "amy-not@example.com"),
3616
+ secret,
3617
+ &client,
3618
+ &domain_ir,
3619
+ )
3620
+ .await;
3621
+ assert_eq!(stranger["statusCode"], 403);
2471
3622
  }
2472
3623
 
2473
3624
  #[tokio::test]
@@ -2477,7 +3628,16 @@ mod tests {
2477
3628
  let cookies = session_cookies(secret, "Zed@Example.com");
2478
3629
 
2479
3630
  let body = r#"{"email": " New@Example.com ", "name": " New Person "}"#;
2480
- let response = add_member_route(&domain_ir, body, &cookies, secret, &client, Path::new("unused"), &members_config()).await;
3631
+ let response = add_member_route(
3632
+ &domain_ir,
3633
+ body,
3634
+ &cookies,
3635
+ secret,
3636
+ &client,
3637
+ Path::new("unused"),
3638
+ &members_config(),
3639
+ )
3640
+ .await;
2481
3641
  assert_eq!(response["statusCode"], 201, "{response:?}");
2482
3642
  let created: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
2483
3643
  assert_eq!(
@@ -2488,31 +3648,64 @@ mod tests {
2488
3648
  let listing = members_route(&domain_ir, &cookies, secret, &client).await;
2489
3649
  let people: Vec<Value> = serde_json::from_str(listing["body"].as_str().unwrap()).unwrap();
2490
3650
  assert_eq!(people.len(), 3);
2491
- let added = people.iter().find(|p| p["email"] == "new@example.com").expect("the new person is listed");
3651
+ let added = people
3652
+ .iter()
3653
+ .find(|p| p["email"] == "new@example.com")
3654
+ .expect("the new person is listed");
2492
3655
  assert_eq!(*added, created);
2493
3656
 
2494
3657
  let guard = client.lock().await;
2495
3658
  let journalled: i64 = guard
2496
- .query_one("SELECT count(*) FROM hecks_journal_acme WHERE aggregate_id = 'new@example.com'", &[])
3659
+ .query_one(
3660
+ "SELECT count(*) FROM hecks_journal_acme WHERE aggregate_id = 'new@example.com'",
3661
+ &[],
3662
+ )
2497
3663
  .await
2498
3664
  .unwrap()
2499
3665
  .get(0);
2500
- assert_eq!(journalled, 2, "one journal row for Admit and one for GrantAccess");
3666
+ assert_eq!(
3667
+ journalled, 2,
3668
+ "one journal row for Admit and one for GrantAccess"
3669
+ );
2501
3670
  }
2502
3671
 
2503
3672
  // The scratch members database with amy also granted Admin, so two
2504
3673
  // active admins exist and one can act on the other.
2505
3674
  async fn scratch_two_admins(name: &str) -> (Mutex<Client>, Value) {
2506
3675
  let (client, domain_ir) = scratch_members_db(name).await;
2507
- let granted = auth::grant_access(&client, Path::new("unused"), &members_config(), &domain_ir, "amy@example.com", "Admin").await.unwrap();
3676
+ let granted = auth::grant_access(
3677
+ &client,
3678
+ Path::new("unused"),
3679
+ &members_config(),
3680
+ &domain_ir,
3681
+ "amy@example.com",
3682
+ "Admin",
3683
+ )
3684
+ .await
3685
+ .unwrap();
2508
3686
  assert!(granted);
2509
3687
  (client, domain_ir)
2510
3688
  }
2511
3689
 
2512
- async fn switch_access(client: &Mutex<Client>, domain_ir: &Value, caller: &str, email: &str, disable: bool) -> Value {
3690
+ async fn switch_access(
3691
+ client: &Mutex<Client>,
3692
+ domain_ir: &Value,
3693
+ caller: &str,
3694
+ email: &str,
3695
+ disable: bool,
3696
+ ) -> Value {
2513
3697
  let secret = "s3cret";
2514
3698
  let body = json!({"email": email}).to_string();
2515
- set_member_disabled_route(domain_ir, &body, &session_cookies(secret, caller), secret, client, &members_config(), disable).await
3699
+ set_member_disabled_route(
3700
+ domain_ir,
3701
+ &body,
3702
+ &session_cookies(secret, caller),
3703
+ secret,
3704
+ client,
3705
+ &members_config(),
3706
+ disable,
3707
+ )
3708
+ .await
2516
3709
  }
2517
3710
 
2518
3711
  fn error_of(response: &Value) -> String {
@@ -2522,13 +3715,19 @@ mod tests {
2522
3715
 
2523
3716
  async fn person(client: &Mutex<Client>, domain_ir: &Value, email: &str) -> Value {
2524
3717
  let people = auth::all_people(client, domain_ir).await.unwrap();
2525
- people.into_iter().find(|p| p["email"] == email).unwrap_or_else(|| panic!("{email} is not listed"))
3718
+ people
3719
+ .into_iter()
3720
+ .find(|p| p["email"] == email)
3721
+ .unwrap_or_else(|| panic!("{email} is not listed"))
2526
3722
  }
2527
3723
 
2528
3724
  async fn journal_rows(client: &Mutex<Client>, id: &str) -> i64 {
2529
3725
  let guard = client.lock().await;
2530
3726
  guard
2531
- .query_one("SELECT count(*) FROM hecks_journal_acme WHERE aggregate_id = $1", &[&id])
3727
+ .query_one(
3728
+ "SELECT count(*) FROM hecks_journal_acme WHERE aggregate_id = $1",
3729
+ &[&id],
3730
+ )
2532
3731
  .await
2533
3732
  .unwrap()
2534
3733
  .get(0)
@@ -2540,20 +3739,43 @@ mod tests {
2540
3739
  let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_disable_401").await;
2541
3740
 
2542
3741
  let mut tampered = HashMap::new();
2543
- tampered.insert(auth::account_cookie_name(), "garbage.notasignature".to_string());
3742
+ tampered.insert(
3743
+ auth::account_cookie_name(),
3744
+ "garbage.notasignature".to_string(),
3745
+ );
2544
3746
  let body = json!({"email": "amy@example.com"}).to_string();
2545
3747
  for disable in [true, false] {
2546
3748
  for cookies in [HashMap::new(), tampered.clone()] {
2547
- let response = set_member_disabled_route(&domain_ir, &body, &cookies, secret, &client, &members_config(), disable).await;
3749
+ let response = set_member_disabled_route(
3750
+ &domain_ir,
3751
+ &body,
3752
+ &cookies,
3753
+ secret,
3754
+ &client,
3755
+ &members_config(),
3756
+ disable,
3757
+ )
3758
+ .await;
2548
3759
  assert_eq!(response["statusCode"], 401, "{response:?}");
2549
- assert!(response.get("headers").and_then(|h| h.get("location")).is_none(), "never a redirect: {response:?}");
3760
+ assert!(
3761
+ response
3762
+ .get("headers")
3763
+ .and_then(|h| h.get("location"))
3764
+ .is_none(),
3765
+ "never a redirect: {response:?}"
3766
+ );
2550
3767
  }
2551
3768
  for blank in ["", " "] {
2552
- let response = switch_access(&client, &domain_ir, "zed@example.com", blank, disable).await;
3769
+ let response =
3770
+ switch_access(&client, &domain_ir, "zed@example.com", blank, disable).await;
2553
3771
  assert_eq!(response["statusCode"], 400, "{blank:?}: {response:?}");
2554
3772
  }
2555
3773
  }
2556
- assert_eq!(person(&client, &domain_ir, "amy@example.com").await["disabled"], false, "nothing was written");
3774
+ assert_eq!(
3775
+ person(&client, &domain_ir, "amy@example.com").await["disabled"],
3776
+ false,
3777
+ "nothing was written"
3778
+ );
2557
3779
  }
2558
3780
 
2559
3781
  #[tokio::test]
@@ -2563,12 +3785,17 @@ mod tests {
2563
3785
  // amy is admitted with no role; the stranger was never admitted.
2564
3786
  for caller in ["amy@example.com", "stranger@example.com"] {
2565
3787
  for disable in [true, false] {
2566
- let response = switch_access(&client, &domain_ir, caller, "zed@example.com", disable).await;
3788
+ let response =
3789
+ switch_access(&client, &domain_ir, caller, "zed@example.com", disable).await;
2567
3790
  assert_eq!(response["statusCode"], 403, "{caller}: {response:?}");
2568
3791
  assert_eq!(error_of(&response), "admins only");
2569
3792
  }
2570
3793
  }
2571
- assert_eq!(person(&client, &domain_ir, "zed@example.com").await["granted"], true, "nothing was written");
3794
+ assert_eq!(
3795
+ person(&client, &domain_ir, "zed@example.com").await["granted"],
3796
+ true,
3797
+ "nothing was written"
3798
+ );
2572
3799
  }
2573
3800
 
2574
3801
  #[tokio::test]
@@ -2579,9 +3806,15 @@ mod tests {
2579
3806
  for typed in ["zed@example.com", " ZED@Example.com "] {
2580
3807
  let response = switch_access(&client, &domain_ir, "zed@example.com", typed, true).await;
2581
3808
  assert_eq!(response["statusCode"], 403, "{typed:?}: {response:?}");
2582
- assert_eq!(error_of(&response), "you can't disable your own admin access");
3809
+ assert_eq!(
3810
+ error_of(&response),
3811
+ "you can't disable your own admin access"
3812
+ );
2583
3813
  }
2584
- assert_eq!(person(&client, &domain_ir, "zed@example.com").await["granted"], true);
3814
+ assert_eq!(
3815
+ person(&client, &domain_ir, "zed@example.com").await["granted"],
3816
+ true
3817
+ );
2585
3818
  assert_eq!(journal_rows(&client, "zed@example.com").await, before);
2586
3819
  }
2587
3820
 
@@ -2589,46 +3822,100 @@ mod tests {
2589
3822
  async fn disable_and_enable_routes_answer_404_for_an_unknown_email() {
2590
3823
  let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_disable_404").await;
2591
3824
  for disable in [true, false] {
2592
- let response = switch_access(&client, &domain_ir, "zed@example.com", "nobody@example.com", disable).await;
3825
+ let response = switch_access(
3826
+ &client,
3827
+ &domain_ir,
3828
+ "zed@example.com",
3829
+ "nobody@example.com",
3830
+ disable,
3831
+ )
3832
+ .await;
2593
3833
  assert_eq!(response["statusCode"], 404, "{response:?}");
2594
3834
  }
2595
3835
  }
2596
3836
 
2597
- async fn set_role(client: &Mutex<Client>, domain_ir: &Value, caller: &str, email: &str, role: &str) -> Value {
3837
+ async fn set_role(
3838
+ client: &Mutex<Client>,
3839
+ domain_ir: &Value,
3840
+ caller: &str,
3841
+ email: &str,
3842
+ role: &str,
3843
+ ) -> Value {
2598
3844
  let secret = "s3cret";
2599
3845
  let body = json!({"email": email, "role": role}).to_string();
2600
- set_member_role_route(domain_ir, &body, &session_cookies(secret, caller), secret, client, &members_config()).await
3846
+ set_member_role_route(
3847
+ domain_ir,
3848
+ &body,
3849
+ &session_cookies(secret, caller),
3850
+ secret,
3851
+ client,
3852
+ &members_config(),
3853
+ )
3854
+ .await
2601
3855
  }
2602
3856
 
2603
3857
  #[tokio::test]
2604
- async fn role_route_refuses_a_missing_session_a_blank_email_and_an_unknown_role_and_writes_nothing() {
3858
+ async fn role_route_refuses_a_missing_session_a_blank_email_and_an_unknown_role_and_writes_nothing(
3859
+ ) {
2605
3860
  let secret = "s3cret";
2606
3861
  let (client, domain_ir) = scratch_members_db("hecks_host_web_test_role_400").await;
2607
3862
  let before = journal_rows(&client, "amy@example.com").await;
2608
3863
 
2609
3864
  let mut tampered = HashMap::new();
2610
- tampered.insert(auth::account_cookie_name(), "garbage.notasignature".to_string());
3865
+ tampered.insert(
3866
+ auth::account_cookie_name(),
3867
+ "garbage.notasignature".to_string(),
3868
+ );
2611
3869
  let body = json!({"email": "amy@example.com", "role": "Owner"}).to_string();
2612
3870
  for cookies in [HashMap::new(), tampered] {
2613
- let response = set_member_role_route(&domain_ir, &body, &cookies, secret, &client, &members_config()).await;
3871
+ let response = set_member_role_route(
3872
+ &domain_ir,
3873
+ &body,
3874
+ &cookies,
3875
+ secret,
3876
+ &client,
3877
+ &members_config(),
3878
+ )
3879
+ .await;
2614
3880
  assert_eq!(response["statusCode"], 401, "{response:?}");
2615
- assert!(response.get("headers").and_then(|h| h.get("location")).is_none(), "never a redirect: {response:?}");
3881
+ assert!(
3882
+ response
3883
+ .get("headers")
3884
+ .and_then(|h| h.get("location"))
3885
+ .is_none(),
3886
+ "never a redirect: {response:?}"
3887
+ );
2616
3888
  }
2617
3889
  for blank in ["", " "] {
2618
3890
  let response = set_role(&client, &domain_ir, "zed@example.com", blank, "Owner").await;
2619
3891
  assert_eq!(response["statusCode"], 400, "{blank:?}: {response:?}");
2620
3892
  }
2621
3893
  for role in ["", "Root", "owner", "Owner Admin"] {
2622
- let response = set_role(&client, &domain_ir, "zed@example.com", "amy@example.com", role).await;
3894
+ let response = set_role(
3895
+ &client,
3896
+ &domain_ir,
3897
+ "zed@example.com",
3898
+ "amy@example.com",
3899
+ role,
3900
+ )
3901
+ .await;
2623
3902
  assert_eq!(response["statusCode"], 400, "{role:?}: {response:?}");
2624
- assert_eq!(error_of(&response), "role must be one of Admin, Owner, Member");
3903
+ assert_eq!(
3904
+ error_of(&response),
3905
+ "role must be one of Admin, Owner, Member"
3906
+ );
2625
3907
  }
2626
- assert_eq!(person(&client, &domain_ir, "amy@example.com").await["role"], Value::Null, "nothing was written");
3908
+ assert_eq!(
3909
+ person(&client, &domain_ir, "amy@example.com").await["role"],
3910
+ Value::Null,
3911
+ "nothing was written"
3912
+ );
2627
3913
  assert_eq!(journal_rows(&client, "amy@example.com").await, before);
2628
3914
  }
2629
3915
 
2630
3916
  #[tokio::test]
2631
- async fn role_route_refuses_a_caller_who_is_not_an_active_admin_with_a_403_and_answers_404_for_an_unknown_email() {
3917
+ async fn role_route_refuses_a_caller_who_is_not_an_active_admin_with_a_403_and_answers_404_for_an_unknown_email(
3918
+ ) {
2632
3919
  let (client, domain_ir) = scratch_members_db("hecks_host_web_test_role_403").await;
2633
3920
 
2634
3921
  // amy is admitted with no role; the stranger was never admitted.
@@ -2639,43 +3926,120 @@ mod tests {
2639
3926
  }
2640
3927
 
2641
3928
  // A plain Member cannot promote themselves either.
2642
- assert!(auth::grant_access(&client, Path::new("unused"), &members_config(), &domain_ir, "amy@example.com", "Member").await.unwrap());
2643
- let response = set_role(&client, &domain_ir, "amy@example.com", "amy@example.com", "Owner").await;
3929
+ assert!(auth::grant_access(
3930
+ &client,
3931
+ Path::new("unused"),
3932
+ &members_config(),
3933
+ &domain_ir,
3934
+ "amy@example.com",
3935
+ "Member"
3936
+ )
3937
+ .await
3938
+ .unwrap());
3939
+ let response = set_role(
3940
+ &client,
3941
+ &domain_ir,
3942
+ "amy@example.com",
3943
+ "amy@example.com",
3944
+ "Owner",
3945
+ )
3946
+ .await;
2644
3947
  assert_eq!(response["statusCode"], 403, "{response:?}");
2645
- assert_eq!(person(&client, &domain_ir, "amy@example.com").await["role"], "Member", "nothing was written");
3948
+ assert_eq!(
3949
+ person(&client, &domain_ir, "amy@example.com").await["role"],
3950
+ "Member",
3951
+ "nothing was written"
3952
+ );
2646
3953
 
2647
- let response = set_role(&client, &domain_ir, "zed@example.com", "nobody@example.com", "Owner").await;
3954
+ let response = set_role(
3955
+ &client,
3956
+ &domain_ir,
3957
+ "zed@example.com",
3958
+ "nobody@example.com",
3959
+ "Owner",
3960
+ )
3961
+ .await;
2648
3962
  assert_eq!(response["statusCode"], 404, "{response:?}");
2649
3963
  }
2650
3964
 
2651
3965
  #[tokio::test]
2652
- async fn role_route_lets_an_admin_grant_owner_to_an_already_admitted_person_and_repeating_it_writes_nothing() {
3966
+ async fn role_route_lets_an_admin_grant_owner_to_an_already_admitted_person_and_repeating_it_writes_nothing(
3967
+ ) {
2653
3968
  let (client, domain_ir) = scratch_members_db("hecks_host_web_test_role_grant").await;
2654
3969
  let status = |response: &Value| response["statusCode"].as_i64().unwrap();
2655
3970
 
2656
3971
  // amy is admitted but holds no role, so she is not an admin yet.
2657
- assert!(!auth::caller_is_admin(&client, &domain_ir, "amy@example.com").await.unwrap());
3972
+ assert!(
3973
+ !auth::caller_is_admin(&client, &domain_ir, "amy@example.com")
3974
+ .await
3975
+ .unwrap()
3976
+ );
2658
3977
 
2659
3978
  // Case and surrounding spaces in the email do not matter.
2660
- let response = set_role(&client, &domain_ir, "Zed@Example.com", " Amy@Example.com ", "Owner").await;
3979
+ let response = set_role(
3980
+ &client,
3981
+ &domain_ir,
3982
+ "Zed@Example.com",
3983
+ " Amy@Example.com ",
3984
+ "Owner",
3985
+ )
3986
+ .await;
2661
3987
  assert_eq!(response["statusCode"], 200, "{response:?}");
2662
3988
  let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
2663
3989
  assert_eq!(body, json!({"email": "amy@example.com", "role": "Owner"}));
2664
3990
 
2665
3991
  // She now passes the admin gate and the Owner check payments makes.
2666
- assert!(auth::caller_is_admin(&client, &domain_ir, "amy@example.com").await.unwrap());
2667
- assert_eq!(auth::active_role(&client, &domain_ir, "amy@example.com").await.unwrap().as_deref(), Some("Owner"));
3992
+ assert!(
3993
+ auth::caller_is_admin(&client, &domain_ir, "amy@example.com")
3994
+ .await
3995
+ .unwrap()
3996
+ );
3997
+ assert_eq!(
3998
+ auth::active_role(&client, &domain_ir, "amy@example.com")
3999
+ .await
4000
+ .unwrap()
4001
+ .as_deref(),
4002
+ Some("Owner")
4003
+ );
2668
4004
  let amy = person(&client, &domain_ir, "amy@example.com").await;
2669
- assert_eq!((amy["role"].as_str(), amy["granted"].clone()), (Some("Owner"), json!(true)));
4005
+ assert_eq!(
4006
+ (amy["role"].as_str(), amy["granted"].clone()),
4007
+ (Some("Owner"), json!(true))
4008
+ );
2670
4009
 
2671
4010
  // The new Owner can act as an admin, including granting Owner in turn.
2672
- let response = set_role(&client, &domain_ir, "amy@example.com", "zed@example.com", "Owner").await;
4011
+ let response = set_role(
4012
+ &client,
4013
+ &domain_ir,
4014
+ "amy@example.com",
4015
+ "zed@example.com",
4016
+ "Owner",
4017
+ )
4018
+ .await;
2673
4019
  assert_eq!(status(&response), 200, "{response:?}");
2674
- assert_eq!(auth::active_role(&client, &domain_ir, "zed@example.com").await.unwrap().as_deref(), Some("Owner"));
4020
+ assert_eq!(
4021
+ auth::active_role(&client, &domain_ir, "zed@example.com")
4022
+ .await
4023
+ .unwrap()
4024
+ .as_deref(),
4025
+ Some("Owner")
4026
+ );
2675
4027
 
2676
4028
  // Asking for the role someone already holds is a 200 and writes nothing.
2677
4029
  let rows = journal_rows(&client, "amy@example.com").await;
2678
- assert_eq!(status(&set_role(&client, &domain_ir, "zed@example.com", "amy@example.com", "Owner").await), 200);
4030
+ assert_eq!(
4031
+ status(
4032
+ &set_role(
4033
+ &client,
4034
+ &domain_ir,
4035
+ "zed@example.com",
4036
+ "amy@example.com",
4037
+ "Owner"
4038
+ )
4039
+ .await
4040
+ ),
4041
+ 200
4042
+ );
2679
4043
  assert_eq!(journal_rows(&client, "amy@example.com").await, rows);
2680
4044
  }
2681
4045
 
@@ -2684,19 +4048,85 @@ mod tests {
2684
4048
  let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_role_keeps").await;
2685
4049
 
2686
4050
  // zed is linked (id-1 in the seed); amy is disabled while holding Admin.
2687
- assert_eq!(switch_access(&client, &domain_ir, "zed@example.com", "amy@example.com", true).await["statusCode"], 200);
2688
- assert_eq!(set_role(&client, &domain_ir, "zed@example.com", "amy@example.com", "Owner").await["statusCode"], 200);
2689
- assert_eq!(set_role(&client, &domain_ir, "zed@example.com", "zed@example.com", "Owner").await["statusCode"], 200);
4051
+ assert_eq!(
4052
+ switch_access(
4053
+ &client,
4054
+ &domain_ir,
4055
+ "zed@example.com",
4056
+ "amy@example.com",
4057
+ true
4058
+ )
4059
+ .await["statusCode"],
4060
+ 200
4061
+ );
4062
+ assert_eq!(
4063
+ set_role(
4064
+ &client,
4065
+ &domain_ir,
4066
+ "zed@example.com",
4067
+ "amy@example.com",
4068
+ "Owner"
4069
+ )
4070
+ .await["statusCode"],
4071
+ 200
4072
+ );
4073
+ assert_eq!(
4074
+ set_role(
4075
+ &client,
4076
+ &domain_ir,
4077
+ "zed@example.com",
4078
+ "zed@example.com",
4079
+ "Owner"
4080
+ )
4081
+ .await["statusCode"],
4082
+ 200
4083
+ );
2690
4084
 
2691
4085
  let amy = person(&client, &domain_ir, "amy@example.com").await;
2692
- assert_eq!((amy["role"].as_str(), amy["disabled"].clone(), amy["granted"].clone()), (Some("Owner"), json!(true), json!(false)));
4086
+ assert_eq!(
4087
+ (
4088
+ amy["role"].as_str(),
4089
+ amy["disabled"].clone(),
4090
+ amy["granted"].clone()
4091
+ ),
4092
+ (Some("Owner"), json!(true), json!(false))
4093
+ );
2693
4094
  let zed = person(&client, &domain_ir, "zed@example.com").await;
2694
- assert_eq!((zed["role"].as_str(), zed["linked"].clone(), zed["name"].as_str()), (Some("Owner"), json!(true), Some("Zed")));
2695
- assert!(auth::session_for_member_by_identity(&client, &domain_ir, "id-1").await.unwrap().is_some(), "zed can still sign in");
4095
+ assert_eq!(
4096
+ (
4097
+ zed["role"].as_str(),
4098
+ zed["linked"].clone(),
4099
+ zed["name"].as_str()
4100
+ ),
4101
+ (Some("Owner"), json!(true), Some("Zed"))
4102
+ );
4103
+ assert!(
4104
+ auth::session_for_member_by_identity(&client, &domain_ir, "id-1")
4105
+ .await
4106
+ .unwrap()
4107
+ .is_some(),
4108
+ "zed can still sign in"
4109
+ );
2696
4110
 
2697
4111
  // Enabling amy restores her, now as an Owner.
2698
- assert_eq!(switch_access(&client, &domain_ir, "zed@example.com", "amy@example.com", false).await["statusCode"], 200);
2699
- assert_eq!(auth::active_role(&client, &domain_ir, "amy@example.com").await.unwrap().as_deref(), Some("Owner"));
4112
+ assert_eq!(
4113
+ switch_access(
4114
+ &client,
4115
+ &domain_ir,
4116
+ "zed@example.com",
4117
+ "amy@example.com",
4118
+ false
4119
+ )
4120
+ .await["statusCode"],
4121
+ 200
4122
+ );
4123
+ assert_eq!(
4124
+ auth::active_role(&client, &domain_ir, "amy@example.com")
4125
+ .await
4126
+ .unwrap()
4127
+ .as_deref(),
4128
+ Some("Owner")
4129
+ );
2700
4130
  }
2701
4131
 
2702
4132
  #[tokio::test]
@@ -2704,18 +4134,75 @@ mod tests {
2704
4134
  let (client, domain_ir) = scratch_members_db("hecks_host_web_test_role_last_admin").await;
2705
4135
 
2706
4136
  // zed is the only active admin: demoting themselves would lock everyone out.
2707
- let response = set_role(&client, &domain_ir, "zed@example.com", "zed@example.com", "Member").await;
4137
+ let response = set_role(
4138
+ &client,
4139
+ &domain_ir,
4140
+ "zed@example.com",
4141
+ "zed@example.com",
4142
+ "Member",
4143
+ )
4144
+ .await;
2708
4145
  assert_eq!(response["statusCode"], 409, "{response:?}");
2709
- assert_eq!(error_of(&response), "there must always be at least one admin");
2710
- assert_eq!(person(&client, &domain_ir, "zed@example.com").await["role"], "Admin", "nothing was written");
4146
+ assert_eq!(
4147
+ error_of(&response),
4148
+ "there must always be at least one admin"
4149
+ );
4150
+ assert_eq!(
4151
+ person(&client, &domain_ir, "zed@example.com").await["role"],
4152
+ "Admin",
4153
+ "nothing was written"
4154
+ );
2711
4155
  // Moving between admin roles never strands anyone.
2712
- assert_eq!(set_role(&client, &domain_ir, "zed@example.com", "zed@example.com", "Owner").await["statusCode"], 200);
4156
+ assert_eq!(
4157
+ set_role(
4158
+ &client,
4159
+ &domain_ir,
4160
+ "zed@example.com",
4161
+ "zed@example.com",
4162
+ "Owner"
4163
+ )
4164
+ .await["statusCode"],
4165
+ 200
4166
+ );
2713
4167
 
2714
4168
  // With a second admin, one may demote the other, and then the last one is protected again.
2715
- assert_eq!(set_role(&client, &domain_ir, "zed@example.com", "amy@example.com", "Admin").await["statusCode"], 200);
2716
- assert_eq!(set_role(&client, &domain_ir, "zed@example.com", "amy@example.com", "Member").await["statusCode"], 200);
2717
- assert_eq!(set_role(&client, &domain_ir, "zed@example.com", "zed@example.com", "Member").await["statusCode"], 409);
2718
- assert_eq!(person(&client, &domain_ir, "zed@example.com").await["role"], "Owner");
4169
+ assert_eq!(
4170
+ set_role(
4171
+ &client,
4172
+ &domain_ir,
4173
+ "zed@example.com",
4174
+ "amy@example.com",
4175
+ "Admin"
4176
+ )
4177
+ .await["statusCode"],
4178
+ 200
4179
+ );
4180
+ assert_eq!(
4181
+ set_role(
4182
+ &client,
4183
+ &domain_ir,
4184
+ "zed@example.com",
4185
+ "amy@example.com",
4186
+ "Member"
4187
+ )
4188
+ .await["statusCode"],
4189
+ 200
4190
+ );
4191
+ assert_eq!(
4192
+ set_role(
4193
+ &client,
4194
+ &domain_ir,
4195
+ "zed@example.com",
4196
+ "zed@example.com",
4197
+ "Member"
4198
+ )
4199
+ .await["statusCode"],
4200
+ 409
4201
+ );
4202
+ assert_eq!(
4203
+ person(&client, &domain_ir, "zed@example.com").await["role"],
4204
+ "Owner"
4205
+ );
2719
4206
  }
2720
4207
 
2721
4208
  #[tokio::test]
@@ -2724,9 +4211,19 @@ mod tests {
2724
4211
  let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_disable_enable").await;
2725
4212
  let amy_cookies = session_cookies(secret, "amy@example.com");
2726
4213
  let status = |response: &Value| response["statusCode"].as_i64().unwrap();
2727
- assert_eq!(status(&accounts_me_route(&domain_ir, &amy_cookies, secret, &client).await), 200);
4214
+ assert_eq!(
4215
+ status(&accounts_me_route(&domain_ir, &amy_cookies, secret, &client).await),
4216
+ 200
4217
+ );
2728
4218
 
2729
- let response = switch_access(&client, &domain_ir, "Zed@Example.com", " Amy@Example.com ", true).await;
4219
+ let response = switch_access(
4220
+ &client,
4221
+ &domain_ir,
4222
+ "Zed@Example.com",
4223
+ " Amy@Example.com ",
4224
+ true,
4225
+ )
4226
+ .await;
2730
4227
  assert_eq!(response["statusCode"], 200, "{response:?}");
2731
4228
  let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
2732
4229
  assert_eq!(body, json!({"disabled": "amy@example.com"}));
@@ -2738,30 +4235,105 @@ mod tests {
2738
4235
  assert_eq!(amy["disabled"], true);
2739
4236
 
2740
4237
  // amy's cookie is still validly signed and unexpired, yet stops working now.
2741
- assert_eq!(status(&accounts_me_route(&domain_ir, &amy_cookies, secret, &client).await), 401);
2742
- assert_eq!(status(&accounts_sso_token_route(&domain_ir, &amy_cookies, secret, &client).await), 401);
2743
- assert_eq!(status(&members_route(&domain_ir, &amy_cookies, secret, &client).await), 401);
2744
- let add = add_member_route(&domain_ir, r#"{"email":"x@example.com","name":"X"}"#, &amy_cookies, secret, &client, Path::new("unused"), &members_config()).await;
4238
+ assert_eq!(
4239
+ status(&accounts_me_route(&domain_ir, &amy_cookies, secret, &client).await),
4240
+ 401
4241
+ );
4242
+ assert_eq!(
4243
+ status(&accounts_sso_token_route(&domain_ir, &amy_cookies, secret, &client).await),
4244
+ 401
4245
+ );
4246
+ assert_eq!(
4247
+ status(&members_route(&domain_ir, &amy_cookies, secret, &client).await),
4248
+ 401
4249
+ );
4250
+ let add = add_member_route(
4251
+ &domain_ir,
4252
+ r#"{"email":"x@example.com","name":"X"}"#,
4253
+ &amy_cookies,
4254
+ secret,
4255
+ &client,
4256
+ Path::new("unused"),
4257
+ &members_config(),
4258
+ )
4259
+ .await;
2745
4260
  assert_eq!(status(&add), 403);
2746
- assert_eq!(status(&switch_access(&client, &domain_ir, "amy@example.com", "zed@example.com", true).await), 403);
2747
- assert_eq!(person(&client, &domain_ir, "zed@example.com").await["granted"], true, "a disabled caller changed nothing");
4261
+ assert_eq!(
4262
+ status(
4263
+ &switch_access(
4264
+ &client,
4265
+ &domain_ir,
4266
+ "amy@example.com",
4267
+ "zed@example.com",
4268
+ true
4269
+ )
4270
+ .await
4271
+ ),
4272
+ 403
4273
+ );
4274
+ assert_eq!(
4275
+ person(&client, &domain_ir, "zed@example.com").await["granted"],
4276
+ true,
4277
+ "a disabled caller changed nothing"
4278
+ );
2748
4279
 
2749
4280
  // Disabling again is a 200 and writes nothing.
2750
4281
  let rows = journal_rows(&client, "amy@example.com").await;
2751
- assert_eq!(status(&switch_access(&client, &domain_ir, "zed@example.com", "amy@example.com", true).await), 200);
4282
+ assert_eq!(
4283
+ status(
4284
+ &switch_access(
4285
+ &client,
4286
+ &domain_ir,
4287
+ "zed@example.com",
4288
+ "amy@example.com",
4289
+ true
4290
+ )
4291
+ .await
4292
+ ),
4293
+ 200
4294
+ );
2752
4295
  assert_eq!(journal_rows(&client, "amy@example.com").await, rows);
2753
4296
 
2754
4297
  // Enabling restores exactly the earlier access, and is idempotent too.
2755
- let response = switch_access(&client, &domain_ir, "zed@example.com", "amy@example.com", false).await;
4298
+ let response = switch_access(
4299
+ &client,
4300
+ &domain_ir,
4301
+ "zed@example.com",
4302
+ "amy@example.com",
4303
+ false,
4304
+ )
4305
+ .await;
2756
4306
  assert_eq!(response["statusCode"], 200, "{response:?}");
2757
4307
  let body: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
2758
4308
  assert_eq!(body, json!({"enabled": "amy@example.com"}));
2759
4309
  let amy = person(&client, &domain_ir, "amy@example.com").await;
2760
- assert_eq!((amy["role"].as_str(), amy["granted"].clone(), amy["disabled"].clone()), (Some("Admin"), json!(true), json!(false)));
2761
- assert_eq!(status(&accounts_me_route(&domain_ir, &amy_cookies, secret, &client).await), 200);
4310
+ assert_eq!(
4311
+ (
4312
+ amy["role"].as_str(),
4313
+ amy["granted"].clone(),
4314
+ amy["disabled"].clone()
4315
+ ),
4316
+ (Some("Admin"), json!(true), json!(false))
4317
+ );
4318
+ assert_eq!(
4319
+ status(&accounts_me_route(&domain_ir, &amy_cookies, secret, &client).await),
4320
+ 200
4321
+ );
2762
4322
 
2763
4323
  let rows = journal_rows(&client, "amy@example.com").await;
2764
- assert_eq!(status(&switch_access(&client, &domain_ir, "zed@example.com", "amy@example.com", false).await), 200);
4324
+ assert_eq!(
4325
+ status(
4326
+ &switch_access(
4327
+ &client,
4328
+ &domain_ir,
4329
+ "zed@example.com",
4330
+ "amy@example.com",
4331
+ false
4332
+ )
4333
+ .await
4334
+ ),
4335
+ 200
4336
+ );
2765
4337
  assert_eq!(journal_rows(&client, "amy@example.com").await, rows);
2766
4338
  }
2767
4339
 
@@ -2770,12 +4342,49 @@ mod tests {
2770
4342
  let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_disable_signin").await;
2771
4343
 
2772
4344
  // zed holds identity id-1 in the seed; a fresh Google sign-in resolves through it.
2773
- assert!(auth::session_for_member_by_identity(&client, &domain_ir, "id-1").await.unwrap().is_some());
2774
- let disabled = switch_access(&client, &domain_ir, "amy@example.com", "zed@example.com", true).await;
2775
- assert_eq!(disabled["statusCode"], 200, "{disabled:?} amy={:?}", person(&client, &domain_ir, "amy@example.com").await);
2776
- assert!(auth::session_for_member_by_identity(&client, &domain_ir, "id-1").await.unwrap().is_none());
2777
- assert_eq!(switch_access(&client, &domain_ir, "amy@example.com", "zed@example.com", false).await["statusCode"], 200);
2778
- assert!(auth::session_for_member_by_identity(&client, &domain_ir, "id-1").await.unwrap().is_some());
4345
+ assert!(
4346
+ auth::session_for_member_by_identity(&client, &domain_ir, "id-1")
4347
+ .await
4348
+ .unwrap()
4349
+ .is_some()
4350
+ );
4351
+ let disabled = switch_access(
4352
+ &client,
4353
+ &domain_ir,
4354
+ "amy@example.com",
4355
+ "zed@example.com",
4356
+ true,
4357
+ )
4358
+ .await;
4359
+ assert_eq!(
4360
+ disabled["statusCode"],
4361
+ 200,
4362
+ "{disabled:?} amy={:?}",
4363
+ person(&client, &domain_ir, "amy@example.com").await
4364
+ );
4365
+ assert!(
4366
+ auth::session_for_member_by_identity(&client, &domain_ir, "id-1")
4367
+ .await
4368
+ .unwrap()
4369
+ .is_none()
4370
+ );
4371
+ assert_eq!(
4372
+ switch_access(
4373
+ &client,
4374
+ &domain_ir,
4375
+ "amy@example.com",
4376
+ "zed@example.com",
4377
+ false
4378
+ )
4379
+ .await["statusCode"],
4380
+ 200
4381
+ );
4382
+ assert!(
4383
+ auth::session_for_member_by_identity(&client, &domain_ir, "id-1")
4384
+ .await
4385
+ .unwrap()
4386
+ .is_some()
4387
+ );
2779
4388
  }
2780
4389
 
2781
4390
  #[tokio::test]
@@ -2784,78 +4393,206 @@ mod tests {
2784
4393
  let config = members_config();
2785
4394
 
2786
4395
  let (a, b) = tokio::join!(
2787
- auth::set_person_disabled(&client, &config, &domain_ir, "zed@example.com", "amy@example.com", true),
2788
- auth::set_person_disabled(&client, &config, &domain_ir, "amy@example.com", "zed@example.com", true),
4396
+ auth::set_person_disabled(
4397
+ &client,
4398
+ &config,
4399
+ &domain_ir,
4400
+ "zed@example.com",
4401
+ "amy@example.com",
4402
+ true
4403
+ ),
4404
+ auth::set_person_disabled(
4405
+ &client,
4406
+ &config,
4407
+ &domain_ir,
4408
+ "amy@example.com",
4409
+ "zed@example.com",
4410
+ true
4411
+ ),
2789
4412
  );
2790
4413
  let mut outcomes = [a.unwrap(), b.unwrap()];
2791
4414
  outcomes.sort_by_key(|o| format!("{o:?}"));
2792
- assert_eq!(outcomes, [auth::DisableOutcome::CallerNotAdmin, auth::DisableOutcome::Done]);
4415
+ assert_eq!(
4416
+ outcomes,
4417
+ [
4418
+ auth::DisableOutcome::CallerNotAdmin,
4419
+ auth::DisableOutcome::Done
4420
+ ]
4421
+ );
2793
4422
 
2794
- let active = auth::all_people(&client, &domain_ir).await.unwrap().iter().filter(|p| p["role"] == "Admin" && p["granted"] == true).count();
4423
+ let active = auth::all_people(&client, &domain_ir)
4424
+ .await
4425
+ .unwrap()
4426
+ .iter()
4427
+ .filter(|p| p["role"] == "Admin" && p["granted"] == true)
4428
+ .count();
2795
4429
  assert_eq!(active, 1, "there must always be one active admin");
2796
4430
  }
2797
4431
 
2798
4432
  #[tokio::test]
2799
- async fn registrations_list_route_refuses_anyone_but_an_active_admin_before_reading_any_registration() {
4433
+ async fn registrations_list_route_refuses_anyone_but_an_active_admin_before_reading_any_registration(
4434
+ ) {
2800
4435
  let secret = "s3cret";
2801
- let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_registrations_list_auth").await;
2802
- let config = LineageConfig { domain: "Studio".to_string(), era: Some(1), mirrored: None };
4436
+ let (client, domain_ir) =
4437
+ scratch_two_admins("hecks_host_web_test_registrations_list_auth").await;
4438
+ let config = LineageConfig {
4439
+ domain: "Studio".to_string(),
4440
+ era: Some(1),
4441
+ mirrored: None,
4442
+ };
2803
4443
  // The wasm path doesn't exist: every refusal below must come before any registration read.
2804
4444
  let wasm_path = Path::new("does-not-exist.wasm");
2805
4445
 
2806
4446
  let mut tampered = HashMap::new();
2807
- tampered.insert(auth::account_cookie_name(), "garbage.notasignature".to_string());
4447
+ tampered.insert(
4448
+ auth::account_cookie_name(),
4449
+ "garbage.notasignature".to_string(),
4450
+ );
2808
4451
  for cookies in [HashMap::new(), tampered] {
2809
- let response = registrations_list_route(&domain_ir, &cookies, secret, &client, wasm_path, &config).await;
4452
+ let response =
4453
+ registrations_list_route(&domain_ir, &cookies, secret, &client, wasm_path, &config)
4454
+ .await;
2810
4455
  assert_eq!(response["statusCode"], 401, "{response:?}");
2811
- assert!(response.get("headers").and_then(|h| h.get("location")).is_none(), "never a redirect: {response:?}");
4456
+ assert!(
4457
+ response
4458
+ .get("headers")
4459
+ .and_then(|h| h.get("location"))
4460
+ .is_none(),
4461
+ "never a redirect: {response:?}"
4462
+ );
2812
4463
  }
2813
4464
 
2814
4465
  // amy is a disabled admin now; the stranger was never admitted at all.
2815
- assert_eq!(switch_access(&client, &domain_ir, "zed@example.com", "amy@example.com", true).await["statusCode"], 200);
4466
+ assert_eq!(
4467
+ switch_access(
4468
+ &client,
4469
+ &domain_ir,
4470
+ "zed@example.com",
4471
+ "amy@example.com",
4472
+ true
4473
+ )
4474
+ .await["statusCode"],
4475
+ 200
4476
+ );
2816
4477
  for caller in ["amy@example.com", "stranger@example.com"] {
2817
- let response = registrations_list_route(&domain_ir, &session_cookies(secret, caller), secret, &client, wasm_path, &config).await;
4478
+ let response = registrations_list_route(
4479
+ &domain_ir,
4480
+ &session_cookies(secret, caller),
4481
+ secret,
4482
+ &client,
4483
+ wasm_path,
4484
+ &config,
4485
+ )
4486
+ .await;
2818
4487
  assert_eq!(response["statusCode"], 403, "{caller}: {response:?}");
2819
4488
  assert_eq!(error_of(&response), "admins only");
2820
4489
  }
2821
4490
  }
2822
4491
 
4492
+ #[allow(clippy::unused_async)] // reason: call sites chain it straight after the async route calls
2823
4493
  async fn status_of(response: Value) -> u64 {
2824
4494
  response["statusCode"].as_u64().unwrap()
2825
4495
  }
2826
4496
 
2827
4497
  #[tokio::test]
2828
- async fn every_admin_gate_accepts_an_owner_as_well_as_an_admin_and_refuses_a_member_and_anonymous() {
4498
+ async fn every_admin_gate_accepts_an_owner_as_well_as_an_admin_and_refuses_a_member_and_anonymous(
4499
+ ) {
2829
4500
  let secret = "s3cret";
2830
4501
  let (client, domain_ir) = scratch_two_admins("hecks_host_web_test_owner_gates").await;
2831
4502
  let config = members_config();
2832
4503
  // amy becomes Owner (replacing her Admin role), bob is a plain Member.
2833
- assert!(auth::grant_access(&client, Path::new("unused"), &config, &domain_ir, "amy@example.com", "Owner").await.unwrap());
2834
- assert!(auth::admit_person(&client, &config, &domain_ir, "bob@example.com", "Bob").await.unwrap());
2835
- assert!(auth::grant_access(&client, Path::new("unused"), &config, &domain_ir, "bob@example.com", "Member").await.unwrap());
2836
- let studio = LineageConfig { domain: "Studio".to_string(), era: Some(1), mirrored: None };
4504
+ assert!(auth::grant_access(
4505
+ &client,
4506
+ Path::new("unused"),
4507
+ &config,
4508
+ &domain_ir,
4509
+ "amy@example.com",
4510
+ "Owner"
4511
+ )
4512
+ .await
4513
+ .unwrap());
4514
+ assert!(
4515
+ auth::admit_person(&client, &config, &domain_ir, "bob@example.com", "Bob")
4516
+ .await
4517
+ .unwrap()
4518
+ );
4519
+ assert!(auth::grant_access(
4520
+ &client,
4521
+ Path::new("unused"),
4522
+ &config,
4523
+ &domain_ir,
4524
+ "bob@example.com",
4525
+ "Member"
4526
+ )
4527
+ .await
4528
+ .unwrap());
4529
+ let studio = LineageConfig {
4530
+ domain: "Studio".to_string(),
4531
+ era: Some(1),
4532
+ mirrored: None,
4533
+ };
2837
4534
  // No wasm at all: clearing the admin gate then fails with a 500, never 401/403.
2838
4535
  let missing_wasm = Path::new("does-not-exist.wasm");
2839
4536
 
2840
4537
  // Every gate, as one closure per route: list, add, disable, enable, registrations.
2841
- for (caller, admits) in [("amy@example.com", true), ("zed@example.com", true), ("bob@example.com", false), ("stranger@example.com", false)] {
4538
+ for (caller, admits) in [
4539
+ ("amy@example.com", true),
4540
+ ("zed@example.com", true),
4541
+ ("bob@example.com", false),
4542
+ ("stranger@example.com", false),
4543
+ ] {
2842
4544
  let cookies = session_cookies(secret, caller);
2843
- let add = add_member_route(&domain_ir, &json!({"email": "not an email", "name": "N"}).to_string(), &cookies, secret, &client, Path::new("unused"), &config).await;
4545
+ let add = add_member_route(
4546
+ &domain_ir,
4547
+ &json!({"email": "not an email", "name": "N"}).to_string(),
4548
+ &cookies,
4549
+ secret,
4550
+ &client,
4551
+ Path::new("unused"),
4552
+ &config,
4553
+ )
4554
+ .await;
2844
4555
  let add = status_of(add).await;
2845
- let registrations = status_of(registrations_list_route(&domain_ir, &cookies, secret, &client, missing_wasm, &studio).await).await;
2846
- let disable = status_of(switch_access(&client, &domain_ir, caller, "bob@example.com", true).await).await;
2847
- let enable = status_of(switch_access(&client, &domain_ir, caller, "bob@example.com", false).await).await;
4556
+ let registrations = status_of(
4557
+ registrations_list_route(
4558
+ &domain_ir,
4559
+ &cookies,
4560
+ secret,
4561
+ &client,
4562
+ missing_wasm,
4563
+ &studio,
4564
+ )
4565
+ .await,
4566
+ )
4567
+ .await;
4568
+ let disable = status_of(
4569
+ switch_access(&client, &domain_ir, caller, "bob@example.com", true).await,
4570
+ )
4571
+ .await;
4572
+ let enable = status_of(
4573
+ switch_access(&client, &domain_ir, caller, "bob@example.com", false).await,
4574
+ )
4575
+ .await;
2848
4576
  if admits {
2849
4577
  // The email is deliberately malformed: the gate passed, validation refused it.
2850
4578
  assert_eq!(add, 400, "{caller} passes the add gate");
2851
4579
  assert_eq!(registrations, 500, "{caller} passes the registrations gate");
2852
- assert_eq!((disable, enable), (200, 200), "{caller} passes disable and enable");
4580
+ assert_eq!(
4581
+ (disable, enable),
4582
+ (200, 200),
4583
+ "{caller} passes disable and enable"
4584
+ );
2853
4585
  } else {
2854
- assert_eq!((add, registrations, disable, enable), (403, 403, 403, 403), "{caller} is refused everywhere");
4586
+ assert_eq!(
4587
+ (add, registrations, disable, enable),
4588
+ (403, 403, 403, 403),
4589
+ "{caller} is refused everywhere"
4590
+ );
2855
4591
  }
2856
4592
  // Listing is an admin gate too: it carries every person's email and role, so an
2857
4593
  // Admin or Owner lists, a Member is refused, and a stranger has no session at all.
2858
- let listing = status_of(members_route(&domain_ir, &cookies, secret, &client).await).await;
4594
+ let listing =
4595
+ status_of(members_route(&domain_ir, &cookies, secret, &client).await).await;
2859
4596
  let expected = match caller {
2860
4597
  "stranger@example.com" => 401,
2861
4598
  _ if admits => 200,
@@ -2866,10 +4603,50 @@ mod tests {
2866
4603
 
2867
4604
  // Anonymous: 401 on every route, before any role is looked at.
2868
4605
  let none = HashMap::new();
2869
- assert_eq!(status_of(add_member_route(&domain_ir, "{}", &none, secret, &client, Path::new("unused"), &config).await).await, 401);
2870
- assert_eq!(status_of(members_route(&domain_ir, &none, secret, &client).await).await, 401);
2871
- assert_eq!(status_of(registrations_list_route(&domain_ir, &none, secret, &client, missing_wasm, &studio).await).await, 401);
2872
- assert_eq!(status_of(set_member_disabled_route(&domain_ir, r#"{"email":"bob@example.com"}"#, &none, secret, &client, &config, true).await).await, 401);
4606
+ assert_eq!(
4607
+ status_of(
4608
+ add_member_route(
4609
+ &domain_ir,
4610
+ "{}",
4611
+ &none,
4612
+ secret,
4613
+ &client,
4614
+ Path::new("unused"),
4615
+ &config
4616
+ )
4617
+ .await
4618
+ )
4619
+ .await,
4620
+ 401
4621
+ );
4622
+ assert_eq!(
4623
+ status_of(members_route(&domain_ir, &none, secret, &client).await).await,
4624
+ 401
4625
+ );
4626
+ assert_eq!(
4627
+ status_of(
4628
+ registrations_list_route(&domain_ir, &none, secret, &client, missing_wasm, &studio)
4629
+ .await
4630
+ )
4631
+ .await,
4632
+ 401
4633
+ );
4634
+ assert_eq!(
4635
+ status_of(
4636
+ set_member_disabled_route(
4637
+ &domain_ir,
4638
+ r#"{"email":"bob@example.com"}"#,
4639
+ &none,
4640
+ secret,
4641
+ &client,
4642
+ &config,
4643
+ true
4644
+ )
4645
+ .await
4646
+ )
4647
+ .await,
4648
+ 401
4649
+ );
2873
4650
  }
2874
4651
 
2875
4652
  #[tokio::test]
@@ -2879,38 +4656,107 @@ mod tests {
2879
4656
  let config = members_config();
2880
4657
 
2881
4658
  let body = r#"{"email": "boss@example.com", "name": "Boss", "role": "Owner"}"#;
2882
- let response = add_member_route(&domain_ir, body, &session_cookies(secret, "zed@example.com"), secret, &client, Path::new("unused"), &config).await;
4659
+ let response = add_member_route(
4660
+ &domain_ir,
4661
+ body,
4662
+ &session_cookies(secret, "zed@example.com"),
4663
+ secret,
4664
+ &client,
4665
+ Path::new("unused"),
4666
+ &config,
4667
+ )
4668
+ .await;
2883
4669
  assert_eq!(response["statusCode"], 201, "{response:?}");
2884
4670
  let created: Value = serde_json::from_str(response["body"].as_str().unwrap()).unwrap();
2885
4671
  assert_eq!(created["role"], "Owner");
2886
- assert_eq!(person(&client, &domain_ir, "boss@example.com").await["role"], "Owner");
4672
+ assert_eq!(
4673
+ person(&client, &domain_ir, "boss@example.com").await["role"],
4674
+ "Owner"
4675
+ );
2887
4676
 
2888
4677
  // The Owner isn't locked out: lists, admits, disables and enables.
2889
4678
  let boss = session_cookies(secret, "boss@example.com");
2890
- assert_eq!(status_of(members_route(&domain_ir, &boss, secret, &client).await).await, 200);
2891
- for (email, role) in [("second@example.com", "Owner"), ("plain@example.com", "Member")] {
4679
+ assert_eq!(
4680
+ status_of(members_route(&domain_ir, &boss, secret, &client).await).await,
4681
+ 200
4682
+ );
4683
+ for (email, role) in [
4684
+ ("second@example.com", "Owner"),
4685
+ ("plain@example.com", "Member"),
4686
+ ] {
2892
4687
  let body = json!({"email": email, "name": "X", "role": role}).to_string();
2893
- let response = add_member_route(&domain_ir, &body, &boss, secret, &client, Path::new("unused"), &config).await;
4688
+ let response = add_member_route(
4689
+ &domain_ir,
4690
+ &body,
4691
+ &boss,
4692
+ secret,
4693
+ &client,
4694
+ Path::new("unused"),
4695
+ &config,
4696
+ )
4697
+ .await;
2894
4698
  assert_eq!(response["statusCode"], 201, "{response:?}");
2895
4699
  assert_eq!(person(&client, &domain_ir, email).await["role"], role);
2896
4700
  }
2897
- assert_eq!(switch_access(&client, &domain_ir, "boss@example.com", "zed@example.com", true).await["statusCode"], 200);
2898
- assert_eq!(switch_access(&client, &domain_ir, "boss@example.com", "zed@example.com", false).await["statusCode"], 200);
4701
+ assert_eq!(
4702
+ switch_access(
4703
+ &client,
4704
+ &domain_ir,
4705
+ "boss@example.com",
4706
+ "zed@example.com",
4707
+ true
4708
+ )
4709
+ .await["statusCode"],
4710
+ 200
4711
+ );
4712
+ assert_eq!(
4713
+ switch_access(
4714
+ &client,
4715
+ &domain_ir,
4716
+ "boss@example.com",
4717
+ "zed@example.com",
4718
+ false
4719
+ )
4720
+ .await["statusCode"],
4721
+ 200
4722
+ );
2899
4723
 
2900
4724
  // A person granted only Member cannot grant anything, Owner included.
2901
- 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;
4725
+ let response = add_member_route(
4726
+ &domain_ir,
4727
+ r#"{"email": "x@example.com", "name": "X", "role": "Owner"}"#,
4728
+ &session_cookies(secret, "plain@example.com"),
4729
+ secret,
4730
+ &client,
4731
+ Path::new("unused"),
4732
+ &config,
4733
+ )
4734
+ .await;
2902
4735
  assert_eq!(response["statusCode"], 403, "{response:?}");
2903
4736
  }
2904
4737
 
2905
4738
  #[tokio::test]
2906
4739
  async fn the_add_route_refuses_an_unknown_role_and_writes_nothing() {
2907
4740
  let secret = "s3cret";
2908
- let (client, domain_ir) = scratch_members_db("hecks_host_web_test_grant_unknown_role").await;
4741
+ let (client, domain_ir) =
4742
+ scratch_members_db("hecks_host_web_test_grant_unknown_role").await;
2909
4743
  for role in [json!("Root"), json!("admin"), json!(""), json!(7)] {
2910
4744
  let body = json!({"email": "new@example.com", "name": "New", "role": role}).to_string();
2911
- let response = add_member_route(&domain_ir, &body, &session_cookies(secret, "zed@example.com"), secret, &client, Path::new("unused"), &members_config()).await;
4745
+ let response = add_member_route(
4746
+ &domain_ir,
4747
+ &body,
4748
+ &session_cookies(secret, "zed@example.com"),
4749
+ secret,
4750
+ &client,
4751
+ Path::new("unused"),
4752
+ &members_config(),
4753
+ )
4754
+ .await;
2912
4755
  assert_eq!(response["statusCode"], 400, "{role}: {response:?}");
2913
- assert_eq!(error_of(&response), "role must be one of Admin, Owner, Member");
4756
+ assert_eq!(
4757
+ error_of(&response),
4758
+ "role must be one of Admin, Owner, Member"
4759
+ );
2914
4760
  }
2915
4761
  assert_eq!(journal_rows(&client, "new@example.com").await, 0);
2916
4762
  }
@@ -2918,12 +4764,17 @@ mod tests {
2918
4764
  #[test]
2919
4765
  fn the_admin_gate_role_set_is_exactly_admin_and_owner() {
2920
4766
  assert!(auth::is_admin_role("Admin") && auth::is_admin_role("Owner"));
2921
- assert!(!auth::is_admin_role("Member") && !auth::is_admin_role("owner") && !auth::is_admin_role(""));
4767
+ assert!(
4768
+ !auth::is_admin_role("Member")
4769
+ && !auth::is_admin_role("owner")
4770
+ && !auth::is_admin_role("")
4771
+ );
2922
4772
  assert_eq!(auth::GRANTABLE_ROLES, ["Admin", "Owner", "Member"]);
2923
4773
  }
2924
4774
 
2925
4775
  #[test]
2926
- fn registration_list_rows_have_exactly_the_admin_list_shape_and_never_carry_health_or_payment_fields() {
4776
+ fn registration_list_rows_have_exactly_the_admin_list_shape_and_never_carry_health_or_payment_fields(
4777
+ ) {
2927
4778
  let read = json!({"instances": {
2928
4779
  "Studio::Registration#reg-old": {
2929
4780
  "event_slug": "yoga-aug",
@@ -2952,8 +4803,21 @@ mod tests {
2952
4803
  );
2953
4804
 
2954
4805
  let body = Value::Array(rows).to_string();
2955
- for leaked in ["SECRET-MEDICATION", "SECRET-CONCERN", "medications", "health_concerns", "555-0100", "12345", "9900", "succeeded", "amount"] {
2956
- assert!(!body.contains(leaked), "{leaked:?} leaked into the registration list: {body}");
4806
+ for leaked in [
4807
+ "SECRET-MEDICATION",
4808
+ "SECRET-CONCERN",
4809
+ "medications",
4810
+ "health_concerns",
4811
+ "555-0100",
4812
+ "12345",
4813
+ "9900",
4814
+ "succeeded",
4815
+ "amount",
4816
+ ] {
4817
+ assert!(
4818
+ !body.contains(leaked),
4819
+ "{leaked:?} leaked into the registration list: {body}"
4820
+ );
2957
4821
  }
2958
4822
  }
2959
4823
 
@@ -2964,9 +4828,18 @@ mod tests {
2964
4828
  "Studio::Registration#without": {"event_slug": "e", "attendee": {"name": "No Status", "email": "without@example.com"}},
2965
4829
  }});
2966
4830
  let rows = registration_list_rows(&read, "Studio");
2967
- let by_id = |id: &str| rows.iter().find(|row| row["registration_id"] == id).unwrap().clone();
4831
+ let by_id = |id: &str| {
4832
+ rows.iter()
4833
+ .find(|row| row["registration_id"] == id)
4834
+ .unwrap()
4835
+ .clone()
4836
+ };
2968
4837
  assert_eq!(by_id("with")["status"], "archived");
2969
- assert!(by_id("without").get("status").is_none(), "no status key at all when the registration carries none: {:?}", by_id("without"));
4838
+ assert!(
4839
+ by_id("without").get("status").is_none(),
4840
+ "no status key at all when the registration carries none: {:?}",
4841
+ by_id("without")
4842
+ );
2970
4843
  assert_eq!(
2971
4844
  by_id("without"),
2972
4845
  json!({"registration_id": "without", "email": "without@example.com", "name": "No Status", "event_slug": "e", "news_signup": false})
@@ -2974,27 +4847,50 @@ mod tests {
2974
4847
  }
2975
4848
 
2976
4849
  #[test]
2977
- fn registration_list_rows_keep_read_order_without_timestamps_and_are_empty_with_no_registrations() {
4850
+ fn registration_list_rows_keep_read_order_without_timestamps_and_are_empty_with_no_registrations(
4851
+ ) {
2978
4852
  let read = json!({"instances": {
2979
4853
  "Studio::Registration#a": {"event_slug": "e", "attendee": {"name": "Flat Name", "email": "flat@example.com"}},
2980
4854
  }});
2981
4855
  let rows = registration_list_rows(&read, "Studio");
2982
4856
  assert_eq!(rows.len(), 1);
2983
- assert_eq!(rows[0]["name"], "Flat Name", "falls back to a flat name when there is no first and last name");
4857
+ assert_eq!(
4858
+ rows[0]["name"], "Flat Name",
4859
+ "falls back to a flat name when there is no first and last name"
4860
+ );
2984
4861
 
2985
- assert_eq!(registration_list_rows(&json!({"instances": {}}), "Studio"), Vec::<Value>::new());
2986
- assert_eq!(registration_list_rows(&json!({}), "Studio"), Vec::<Value>::new());
4862
+ assert_eq!(
4863
+ registration_list_rows(&json!({"instances": {}}), "Studio"),
4864
+ Vec::<Value>::new()
4865
+ );
4866
+ assert_eq!(
4867
+ registration_list_rows(&json!({}), "Studio"),
4868
+ Vec::<Value>::new()
4869
+ );
2987
4870
  let other_domain = json!({"instances": {"Elsewhere::Registration#x": {"attendee": {"email": "x@example.com"}}}});
2988
- assert!(registration_list_rows(&other_domain, "Studio").is_empty(), "another domain's registrations are not listed");
4871
+ assert!(
4872
+ registration_list_rows(&other_domain, "Studio").is_empty(),
4873
+ "another domain's registrations are not listed"
4874
+ );
2989
4875
  }
2990
4876
 
2991
4877
  #[test]
2992
4878
  fn sorted_by_name_orders_case_insensitively_and_puts_a_missing_name_first() {
2993
- let people = vec![json!({"name": "bob"}), json!({"name": "Ada"}), json!({"name": null}), json!({"name": "Cy"})];
2994
- let names: Vec<Value> = sorted_by_name(people).into_iter().map(|p| p["name"].clone()).collect();
2995
- assert_eq!(names, [json!(null), json!("Ada"), json!("bob"), json!("Cy")]);
4879
+ let people = vec![
4880
+ json!({"name": "bob"}),
4881
+ json!({"name": "Ada"}),
4882
+ json!({"name": null}),
4883
+ json!({"name": "Cy"}),
4884
+ ];
4885
+ let names: Vec<Value> = sorted_by_name(people)
4886
+ .into_iter()
4887
+ .map(|p| p["name"].clone())
4888
+ .collect();
4889
+ assert_eq!(
4890
+ names,
4891
+ [json!(null), json!("Ada"), json!("bob"), json!("Cy")]
4892
+ );
2996
4893
  }
2997
-
2998
4894
  }
2999
4895
 
3000
4896
  #[cfg(test)]
@@ -3006,10 +4902,22 @@ mod cookie_flag_tests {
3006
4902
 
3007
4903
  #[test]
3008
4904
  fn a_host_that_sets_the_redirect_uri_is_judged_by_it_as_before() {
3009
- assert_eq!(account_cookie_flags("https://site.example/auth/google/callback", ""), SECURE);
3010
- assert_eq!(account_cookie_flags("http://localhost:4567/auth/google/callback", ""), PLAIN);
3011
- assert_eq!(account_cookie_flags("https://site.example/cb", "http://localhost:4321"), SECURE);
3012
- assert_eq!(account_cookie_flags("http://localhost:4567/cb", "https://site.example"), PLAIN);
4905
+ assert_eq!(
4906
+ account_cookie_flags("https://site.example/auth/google/callback", ""),
4907
+ SECURE
4908
+ );
4909
+ assert_eq!(
4910
+ account_cookie_flags("http://localhost:4567/auth/google/callback", ""),
4911
+ PLAIN
4912
+ );
4913
+ assert_eq!(
4914
+ account_cookie_flags("https://site.example/cb", "http://localhost:4321"),
4915
+ SECURE
4916
+ );
4917
+ assert_eq!(
4918
+ account_cookie_flags("http://localhost:4567/cb", "https://site.example"),
4919
+ PLAIN
4920
+ );
3013
4921
  }
3014
4922
 
3015
4923
  #[test]